smlflg's picture
Initial public upload from Projekte/CodexDashBoard
981aa5d verified
Raw History Blame Contribute Delete
24.5 kB
from __future__ import annotations
import fcntl
import logging
import os
import shutil
import subprocess
import threading
import time
from pathlib import Path
import gi
gi.require_version("Gtk", "3.0")
gi.require_version("Gdk", "3.0")
from gi.repository import Gdk, GLib, Gtk, Pango
try:
gi.require_version("AyatanaAppIndicator3", "0.1")
from gi.repository import AyatanaAppIndicator3
except (ImportError, ValueError):
AyatanaAppIndicator3 = None
from .codex_data import (
DashboardStats,
ThreadSummary,
format_time,
format_tokens,
list_logs_result,
list_threads,
list_threads_result,
get_stats,
)
from .paths import CACHE_LOG, CONFIG_TOML, LOCK_PATH
from .processes import ProcessInfo, format_uptime, scan_processes, terminate_process
log = logging.getLogger("codex_dashboard")
_lock_file = None
CSS = b"""
window {
background: #0f172a;
color: #e2e8f0;
}
.topbar {
background: #111827;
border-bottom: 1px solid #1f2937;
padding: 10px 12px;
}
.app-title {
font-size: 18px;
font-weight: 700;
color: #f8fafc;
}
.muted {
color: #94a3b8;
}
.panel {
background: #0f172a;
}
.stat {
background: #111827;
border: 1px solid #1f2937;
border-radius: 10px;
padding: 12px;
}
.stat-value {
font-size: 22px;
font-weight: 700;
color: #f8fafc;
}
.badge,
.badge-ok,
.badge-warn,
.badge-idle {
border-radius: 999px;
padding: 4px 10px;
font-weight: 600;
}
.badge {
background: #1e293b;
color: #cbd5e1;
}
.badge-ok {
background: #123a2b;
color: #86efac;
}
.badge-warn {
background: #3f2a12;
color: #fcd34d;
}
.badge-idle {
background: #1f2937;
color: #cbd5e1;
}
.row {
border: 1px solid transparent;
border-bottom: 1px solid #1e293b;
border-radius: 8px;
padding: 10px 12px;
}
.row:hover {
background: #111827;
border-color: #334155;
}
button {
border-radius: 8px;
}
entry {
border-radius: 8px;
background: #111827;
color: #e2e8f0;
}
"""
def _setup_logging() -> None:
CACHE_LOG.parent.mkdir(parents=True, exist_ok=True)
logging.basicConfig(
filename=str(CACHE_LOG),
level=logging.INFO,
format="%(asctime)s %(name)s %(levelname)s %(message)s",
)
def _acquire_singleton_lock() -> None:
global _lock_file
fd = os.open(LOCK_PATH, os.O_CREAT | os.O_WRONLY, 0o644)
_lock_file = os.fdopen(fd, "w")
try:
fcntl.flock(_lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
_lock_file.truncate(0)
_lock_file.write(str(os.getpid()))
_lock_file.flush()
except BlockingIOError:
log.warning("CodexDashBoard already running")
raise SystemExit(0)
def _label(text: str = "", *, xalign: float = 0.0, css: str | None = None) -> Gtk.Label:
label = Gtk.Label(label=text, xalign=xalign)
label.set_ellipsize(Pango.EllipsizeMode.END)
if css:
label.get_style_context().add_class(css)
return label
def _button(icon_name: str, tooltip: str) -> Gtk.Button:
button = Gtk.Button.new_from_icon_name(icon_name, Gtk.IconSize.BUTTON)
button.set_tooltip_text(tooltip)
return button
def _clear_list(list_box: Gtk.ListBox) -> None:
for child in list_box.get_children():
list_box.remove(child)
def _run_bg(work, done) -> None:
def worker():
try:
result = work()
except Exception as exc: # pragma: no cover - UI guard
log.exception("background task failed")
result = exc
GLib.idle_add(done, result)
threading.Thread(target=worker, daemon=True).start()
def _open_path(path: str | Path) -> None:
subprocess.Popen(["xdg-open", str(path)], start_new_session=True)
class CodexDashboard(Gtk.Window):
def __init__(self) -> None:
super().__init__(title="CodexDashBoard")
self.set_default_size(940, 640)
self.set_resizable(True)
self.set_wmclass("codex-dashboard", "CodexDashBoard")
self._tray_indicator = None
self._session_query = ""
self._log_query = ""
self._include_archived = False
self._stats_labels: dict[str, Gtk.Label] = {}
self._refresh_in_flight: set[str] = set()
self._overview_sessions = Gtk.ListBox()
self._overview_processes = Gtk.ListBox()
self._sessions_list = Gtk.ListBox()
self._processes_list = Gtk.ListBox()
self._logs_list = Gtk.ListBox()
self._status = _label("Ready", css="muted")
self._setup_css()
self._build_ui()
self._build_tray()
self.connect("delete-event", self._on_delete)
GLib.timeout_add_seconds(30, self._refresh_visible)
self._refresh_visible()
def _setup_css(self) -> None:
provider = Gtk.CssProvider()
provider.load_from_data(CSS)
screen = Gdk.Screen.get_default()
Gtk.StyleContext.add_provider_for_screen(
screen,
provider,
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION,
)
def _build_ui(self) -> None:
root = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
self.add(root)
topbar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
topbar.get_style_context().add_class("topbar")
root.pack_start(topbar, False, False, 0)
title = _label("CodexDashBoard", css="app-title")
topbar.pack_start(title, False, False, 0)
topbar.pack_start(self._status, True, True, 0)
config_button = _button("emblem-system-symbolic", "Open Codex config")
config_button.connect("clicked", lambda *_: _open_path(CONFIG_TOML))
topbar.pack_end(config_button, False, False, 0)
refresh_button = _button("view-refresh-symbolic", "Refresh")
refresh_button.connect("clicked", lambda *_: self.refresh_all())
topbar.pack_end(refresh_button, False, False, 0)
body = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)
root.pack_start(body, True, True, 0)
self.stack = Gtk.Stack()
self.stack.set_transition_type(Gtk.StackTransitionType.CROSSFADE)
self.stack.set_transition_duration(120)
self.stack.connect("notify::visible-child-name", lambda *_: self._refresh_visible())
sidebar = Gtk.StackSidebar()
sidebar.set_stack(self.stack)
sidebar.set_size_request(150, -1)
body.pack_start(sidebar, False, False, 0)
body.pack_start(self.stack, True, True, 0)
self.stack.add_titled(self._build_overview(), "overview", "Overview")
self.stack.add_titled(self._build_sessions(), "sessions", "Sessions")
self.stack.add_titled(self._build_processes(), "processes", "Processes")
self.stack.add_titled(self._build_logs(), "logs", "Logs")
def _build_overview(self) -> Gtk.Widget:
scrolled = Gtk.ScrolledWindow()
scrolled.get_style_context().add_class("panel")
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=14)
box.set_margin_top(14)
box.set_margin_bottom(14)
box.set_margin_start(14)
box.set_margin_end(14)
scrolled.add(box)
stats = Gtk.Grid(column_spacing=10, row_spacing=10)
box.pack_start(stats, False, False, 0)
for idx, (key, label_text) in enumerate(
[
("threads", "Threads"),
("recent", "Updated Last Hour"),
("tokens", "Tokens"),
("processes", "Agent Processes"),
]
):
card = self._stat_card(label_text)
stats.attach(card, idx % 3, idx // 3, 1, 1)
self._stats_labels[key] = card.value_label
box.pack_start(_label("Letzte 10 Codex Sessions", css="app-title"), False, False, 0)
box.pack_start(self._overview_sessions, False, False, 0)
box.pack_start(_label("Active Processes", css="app-title"), False, False, 0)
box.pack_start(self._overview_processes, False, False, 0)
return scrolled
def _stat_card(self, label_text: str) -> Gtk.Box:
card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
card.get_style_context().add_class("stat")
card.set_hexpand(True)
value = _label("-", css="stat-value")
label = _label(label_text, css="muted")
card.pack_start(value, False, False, 0)
card.pack_start(label, False, False, 0)
card.value_label = value # type: ignore[attr-defined]
return card
def _build_sessions(self) -> Gtk.Widget:
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
box.set_margin_top(10)
box.set_margin_bottom(10)
box.set_margin_start(10)
box.set_margin_end(10)
controls = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
search = Gtk.SearchEntry()
search.set_placeholder_text("Search title, cwd, branch")
search.connect("search-changed", self._on_session_search)
controls.pack_start(search, True, True, 0)
archived = Gtk.CheckButton(label="Archived")
archived.connect("toggled", self._on_archived_toggled)
controls.pack_end(archived, False, False, 0)
box.pack_start(controls, False, False, 0)
scrolled = Gtk.ScrolledWindow()
scrolled.add(self._sessions_list)
box.pack_start(scrolled, True, True, 0)
return box
def _build_processes(self) -> Gtk.Widget:
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
box.set_margin_top(10)
box.set_margin_bottom(10)
box.set_margin_start(10)
box.set_margin_end(10)
hint = _label(
"Codex-related processes. Stop actions ask first.",
css="muted",
)
box.pack_start(hint, False, False, 0)
scrolled = Gtk.ScrolledWindow()
scrolled.add(self._processes_list)
box.pack_start(scrolled, True, True, 0)
return box
def _build_logs(self) -> Gtk.Widget:
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
box.set_margin_top(10)
box.set_margin_bottom(10)
box.set_margin_start(10)
box.set_margin_end(10)
search = Gtk.SearchEntry()
search.set_placeholder_text("Search logs")
search.connect("search-changed", self._on_log_search)
box.pack_start(search, False, False, 0)
scrolled = Gtk.ScrolledWindow()
scrolled.add(self._logs_list)
box.pack_start(scrolled, True, True, 0)
return box
def _build_tray(self) -> None:
if AyatanaAppIndicator3 is None:
return
indicator = AyatanaAppIndicator3.Indicator.new(
"codex-dashboard",
"utilities-system-monitor",
AyatanaAppIndicator3.IndicatorCategory.APPLICATION_STATUS,
)
indicator.set_status(AyatanaAppIndicator3.IndicatorStatus.ACTIVE)
indicator.set_title("CodexDashBoard")
menu = Gtk.Menu()
show_item = Gtk.MenuItem(label="Show")
show_item.connect("activate", lambda *_: self._show_window())
menu.append(show_item)
refresh_item = Gtk.MenuItem(label="Refresh")
refresh_item.connect("activate", lambda *_: self.refresh_all())
menu.append(refresh_item)
menu.append(Gtk.SeparatorMenuItem())
quit_item = Gtk.MenuItem(label="Quit")
quit_item.connect("activate", lambda *_: Gtk.main_quit())
menu.append(quit_item)
menu.show_all()
indicator.set_menu(menu)
self._tray_indicator = indicator
def _show_window(self) -> None:
self.show_all()
self.present()
def _on_delete(self, *_args) -> bool:
if self._tray_indicator is not None:
self.hide()
return True
Gtk.main_quit()
return False
def _on_session_search(self, entry: Gtk.SearchEntry) -> None:
self._session_query = entry.get_text()
self.refresh_sessions()
def _on_archived_toggled(self, button: Gtk.CheckButton) -> None:
self._include_archived = button.get_active()
self.refresh_sessions()
def _on_log_search(self, entry: Gtk.SearchEntry) -> None:
self._log_query = entry.get_text()
self.refresh_logs()
def _refresh_visible(self) -> bool:
name = self.stack.get_visible_child_name()
if name == "sessions":
self.refresh_sessions()
elif name == "processes":
self.refresh_processes()
elif name == "logs":
self.refresh_logs()
else:
self.refresh_overview()
return True
def refresh_all(self) -> None:
self._refresh_visible()
def _run_refresh_once(self, key: str, work, done) -> None:
if key in self._refresh_in_flight:
self._set_status(f"{key.title()} refresh already running")
return
self._refresh_in_flight.add(key)
def wrapped_done(result):
try:
return done(result)
finally:
self._refresh_in_flight.discard(key)
_run_bg(work, wrapped_done)
def refresh_overview(self) -> None:
def work():
return get_stats(), list_threads(limit=10), scan_processes()
def done(result):
if isinstance(result, Exception):
self._set_status("Refresh failed")
return False
stats, threads, processes = result
self._render_overview(stats, threads, processes)
self._set_status("Overview refreshed")
return False
self._run_refresh_once("overview", work, done)
def refresh_sessions(self) -> None:
def work():
return list_threads_result(
query=self._session_query,
include_archived=self._include_archived,
limit=120,
)
def done(result):
if isinstance(result, Exception):
self._set_status("Session refresh failed")
return False
if result.error is not None:
self._render_threads(self._sessions_list, result.data)
self._set_status(f"Session database error: {result.error.message}")
return False
self._render_threads(self._sessions_list, result.data)
self._set_status(f"{len(result.data)} sessions")
return False
self._run_refresh_once("sessions", work, done)
def refresh_processes(self) -> None:
def done(result):
if isinstance(result, Exception):
self._set_status("Process refresh failed")
return False
self._render_processes(self._processes_list, result, full=True)
self._set_status(f"{len(result)} processes")
return False
self._run_refresh_once("processes", scan_processes, done)
def refresh_logs(self) -> None:
def work():
return list_logs_result(query=self._log_query, limit=150)
def done(result):
if isinstance(result, Exception):
self._set_status("Log refresh failed")
return False
if result.error is not None:
self._render_logs(result.data)
self._set_status(f"Log database error: {result.error.message}")
return False
self._render_logs(result.data)
self._set_status(f"{len(result.data)} log rows")
return False
self._run_refresh_once("logs", work, done)
def _render_overview(
self,
stats: DashboardStats,
threads: list[ThreadSummary],
processes: list[ProcessInfo],
) -> None:
self._stats_labels["threads"].set_text(str(stats.thread_count))
self._stats_labels["recent"].set_text(str(stats.recent_hour_count))
self._stats_labels["tokens"].set_text(format_tokens(stats.total_tokens))
self._stats_labels["processes"].set_text(str(len(processes)))
active_cwds = {process.cwd for process in processes if process.cwd}
self._render_threads(
self._overview_sessions,
threads,
compact=True,
active_cwds=active_cwds,
)
self._render_processes(self._overview_processes, processes[:6], full=False)
def _render_threads(
self,
list_box: Gtk.ListBox,
threads: list[ThreadSummary],
*,
compact: bool = False,
active_cwds: set[str] | None = None,
) -> None:
_clear_list(list_box)
if not threads:
list_box.add(self._empty_row("No Codex sessions found"))
list_box.show_all()
return
for thread in threads:
list_box.add(
self._thread_row(thread, compact=compact, active_cwds=active_cwds or set())
)
list_box.show_all()
def _thread_row(
self,
thread: ThreadSummary,
*,
compact: bool = False,
active_cwds: set[str] | None = None,
) -> Gtk.ListBoxRow:
row = Gtk.ListBoxRow()
outer = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
outer.get_style_context().add_class("row")
row.add(outer)
text_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=3)
outer.pack_start(text_box, True, True, 0)
title_prefix = "Thema: " if compact else ""
title = _label(title_prefix + thread.title)
title.set_tooltip_text(thread.id)
text_box.pack_start(title, False, False, 0)
state_text, state_class = self._thread_state(thread, active_cwds or set())
meta_parts = [
"Stand: " + state_text,
"Update: " + format_time(thread.updated_ms),
"Projekt: " + (Path(thread.cwd).name if thread.cwd else "no cwd"),
thread.model or "model?",
]
if thread.reasoning_effort:
meta_parts.append(thread.reasoning_effort)
if thread.git_branch:
meta_parts.append(thread.git_branch)
if not compact:
meta_parts.append(format_tokens(thread.tokens_used) + " tokens")
meta = _label(" | ".join(meta_parts), css="muted")
text_box.pack_start(meta, False, False, 0)
badge = _label(state_text, xalign=0.5, css=state_class)
outer.pack_end(badge, False, False, 0)
if not compact:
open_button = _button("folder-open-symbolic", "Open folder")
open_button.connect("clicked", lambda *_: self._open_thread_folder(thread))
outer.pack_end(open_button, False, False, 0)
resume_button = _button("media-playback-start-symbolic", "Resume in terminal")
resume_button.connect("clicked", lambda *_: self._resume_thread(thread))
outer.pack_end(resume_button, False, False, 0)
return row
def _thread_state(self, thread: ThreadSummary, active_cwds: set[str]) -> tuple[str, str]:
if thread.archived:
return "Archived", "badge"
if thread.cwd and thread.cwd in active_cwds:
return "Running", "badge-ok"
age_ms = int(time.time() * 1000) - thread.updated_ms
if age_ms <= 10 * 60 * 1000:
return "Active", "badge-ok"
if age_ms <= 60 * 60 * 1000:
return "Recent", "badge-warn"
return "Idle", "badge-idle"
def _render_processes(
self,
list_box: Gtk.ListBox,
processes: list[ProcessInfo],
*,
full: bool,
) -> None:
_clear_list(list_box)
if not processes:
list_box.add(self._empty_row("No Codex-related processes found"))
list_box.show_all()
return
for process in processes:
list_box.add(self._process_row(process, full=full))
list_box.show_all()
def _process_row(self, process: ProcessInfo, *, full: bool) -> Gtk.ListBoxRow:
row = Gtk.ListBoxRow()
outer = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
outer.get_style_context().add_class("row")
row.add(outer)
text_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=3)
outer.pack_start(text_box, True, True, 0)
title = _label(f"{process.name} pid {process.pid}")
title.set_tooltip_text(process.command)
text_box.pack_start(title, False, False, 0)
cwd = Path(process.cwd).name if process.cwd else "-"
meta = (
f"{process.ram_mb:.1f} MB | {process.cpu:.1f}% CPU | "
f"{format_uptime(process.uptime_seconds)} | {cwd}"
)
text_box.pack_start(_label(meta, css="muted"), False, False, 0)
if process.stale:
badge = _label("Stale", xalign=0.5, css="badge-warn")
outer.pack_end(badge, False, False, 0)
if full:
stop_button = _button("process-stop-symbolic", "Stop process")
stop_button.connect("clicked", lambda *_: self._confirm_stop(process))
outer.pack_end(stop_button, False, False, 0)
return row
def _render_logs(self, logs) -> None:
_clear_list(self._logs_list)
if not logs:
self._logs_list.add(self._empty_row("No Codex logs found"))
self._logs_list.show_all()
return
for entry in logs:
row = Gtk.ListBoxRow()
outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=3)
outer.get_style_context().add_class("row")
row.add(outer)
header = _label(
f"{format_time(entry.ts_ms)} | {entry.level} | {entry.target}"
)
outer.pack_start(header, False, False, 0)
if entry.message:
outer.pack_start(_label(entry.message, css="muted"), False, False, 0)
elif entry.thread_id:
outer.pack_start(_label(entry.thread_id, css="muted"), False, False, 0)
self._logs_list.add(row)
self._logs_list.show_all()
def _empty_row(self, text: str) -> Gtk.ListBoxRow:
row = Gtk.ListBoxRow()
label = _label(text, css="muted")
label.set_margin_top(12)
label.set_margin_bottom(12)
label.set_margin_start(12)
label.set_margin_end(12)
row.add(label)
return row
def _resume_thread(self, thread: ThreadSummary) -> None:
codex = shutil.which("codex")
kitty = shutil.which("kitty")
cwd = thread.cwd if thread.cwd and Path(thread.cwd).is_dir() else str(Path.home())
if codex is None:
self._set_status("codex command not found")
return
if kitty:
cmd = [kitty, "-e", codex, "resume", thread.id]
else:
cmd = [codex, "resume", thread.id]
try:
subprocess.Popen(cmd, cwd=cwd, start_new_session=True)
self._set_status("Resume launched")
except OSError:
log.exception("resume failed")
self._set_status("Resume failed")
def _open_thread_folder(self, thread: ThreadSummary) -> None:
path = Path(thread.cwd)
if path.is_dir():
_open_path(path)
self._set_status("Folder opened")
else:
self._set_status("Folder missing")
def _confirm_stop(self, process: ProcessInfo) -> None:
dialog = Gtk.MessageDialog(
transient_for=self,
flags=0,
message_type=Gtk.MessageType.WARNING,
buttons=Gtk.ButtonsType.CANCEL,
text=f"Stop process {process.pid}?",
)
dialog.format_secondary_text(process.command)
dialog.add_button("Stop", Gtk.ResponseType.OK)
response = dialog.run()
dialog.destroy()
if response != Gtk.ResponseType.OK:
return
try:
terminate_process(process.pid)
self._set_status(f"Sent SIGTERM to {process.pid}")
self.refresh_processes()
except OSError:
log.exception("terminate failed")
self._set_status("Stop failed")
def _set_status(self, text: str) -> None:
self._status.set_text(text)
def main() -> int:
_setup_logging()
_acquire_singleton_lock()
win = CodexDashboard()
win.connect("destroy", Gtk.main_quit)
win.show_all()
Gtk.main()
return 0