A1 — hiding the assistant from the dot. The old build kept a chevron button beside the dot wired to _hide_to_edge; the redesign moved that action into the panel's ⋯ menu, so hiding went from one click to open-panel → ⋯ → Ẩn. The drawing has no button there (26px, no text, no chevron), so the reach comes back as a right-click on the dot, with the tooltip saying so. A2 — per-part cost. Folding Input/Output/Cache into the total tile's sub-line carried their token counts but not their prices, which live only on those three cards, so the cost breakdown left the screen entirely. The wireframe asks for the tiles anyway — "$0.31 Tổng chi phí · 57 lượt / 395.4K Tổng token / 292.8K Input / 102.7K Output / 108.9K Cache" — so they are back as tiles, each with its price, and the turn count rides on the cost tile's label as drawn. The dot on Cowork. _update_dock_guard lifted it whenever a composer existed, testing only the vertical axis. On a wide window the composer ends at the chat column's right edge — x=1688 against a dot at x=1892 — so the dot rose 156px to clear something that was never under it, and Cowork became the one screen where it was not in the corner. It now lifts only when the two actually overlap: 18px from the bottom at 1936px wide, still lifted at 1200 and 900 where the composer does reach under it. Also: open_tooltip and show_tooltip still said "App Assistant" after the rename. check_dock_corner covers the corner rule at three widths and fails if the lift comes back unconditionally. 18/18 checkers pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1019 lines
48 KiB
Python
1019 lines
48 KiB
Python
"""📊 Monitoring Dashboard — live view over the Sandbox / MCP / Agent Core
|
|
layers, built entirely from data those layers already produce:
|
|
|
|
- **Overview** — a card-based dashboard (Token Usage & Cost, Resource
|
|
Usage, Recent Activity, Sandbox Details, Permissions, Audit Log), using
|
|
only real state from the panels below (no fabricated numbers).
|
|
- **Security Events** — the audit log (``core/audit_log.py``) filtered to
|
|
``kind="security_block"``.
|
|
- **MCP Call History** — the audit log filtered to ``kind="mcp_call"``.
|
|
- **Action Logs** — the full audit log, newest first.
|
|
- **Agent Status** — which ``agent_roles`` (see ``core/agent_roles.py``)
|
|
are currently running, read from the existing ``ChatPanel._active``/
|
|
``TaskScheduler._workers``/GraphRAG-ask-worker state — no new runtime
|
|
tracking of its own.
|
|
|
|
Each panel is a thin, read-only VIEW — this module owns no state that
|
|
outlives a refresh tick (besides a one-sample I/O cache used to compute
|
|
instantaneous disk/network rates between ticks).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import time
|
|
from datetime import datetime
|
|
from typing import List
|
|
|
|
from PySide6.QtCore import Qt, QTimer, Signal
|
|
from PySide6.QtGui import QBrush, QColor
|
|
from PySide6.QtWidgets import (
|
|
QComboBox, QGridLayout, QGroupBox, QHBoxLayout,
|
|
QHeaderView, QLabel, QLineEdit, QProgressBar, QPushButton, QScrollArea,
|
|
QTableWidget, QTableWidgetItem, QTabWidget, QVBoxLayout, QWidget,
|
|
)
|
|
|
|
from ..core import agent_roles, audit_log
|
|
from ..core import usage_tracker as ut
|
|
from ..i18n import on_language_changed, tr
|
|
from ..state import AppContext
|
|
from ..theme import current_palette
|
|
from .icons import icon, DOT_GREEN, DOT_RED, DOT_AMBER
|
|
from .widgets import BudgetCard, StatCard, fmt_tokens
|
|
|
|
_REFRESH_MS = 3000
|
|
_MAX_ROWS = 300
|
|
|
|
|
|
def _fmt_bytes(n: float) -> str:
|
|
for unit in ("B", "KB", "MB", "GB"):
|
|
if n < 1024:
|
|
return f"{n:.0f} {unit}"
|
|
n /= 1024
|
|
return f"{n:.1f} TB"
|
|
|
|
|
|
def _relative_time(ts: str) -> str:
|
|
"""A short "Xm ago"-style string for an audit-log ``ts`` (naive local
|
|
ISO timestamp, see ``audit_log.record``); "" if unparsable."""
|
|
try:
|
|
then = datetime.fromisoformat(ts)
|
|
except (TypeError, ValueError):
|
|
return ""
|
|
delta = (datetime.now() - then).total_seconds()
|
|
if delta < 60:
|
|
return tr("monitoring.time_just_now")
|
|
if delta < 3600:
|
|
return tr("monitoring.time_minutes_ago", n=int(delta // 60))
|
|
if delta < 86400:
|
|
return tr("monitoring.time_hours_ago", n=int(delta // 3600))
|
|
return tr("monitoring.time_days_ago", n=int(delta // 86400))
|
|
|
|
|
|
class _EventTable(QTableWidget):
|
|
"""A read-only table of audit-log events — newest-first by default, and
|
|
every column header is click-to-sort (ascending/descending toggle; the
|
|
Time column's ISO timestamps sort correctly as text)."""
|
|
|
|
# What each blocked action is, as a colour. Security events all record
|
|
# ok=False, so the tick/cross column said the same thing on every row; the
|
|
# useful distinction is WHICH rule fired.
|
|
_ACTION_TINTS = {
|
|
"prompt": "accent",
|
|
"dangerous_command": "danger",
|
|
"run_command": "danger",
|
|
"install_package": "warning",
|
|
"path_outside_sandbox": "success",
|
|
"network_blocked": "accent",
|
|
"secret_in_output": "warning",
|
|
}
|
|
|
|
def __init__(self, show_result: bool = True):
|
|
# Security Events drops the result column entirely (see _ACTION_TINTS).
|
|
self._show_result = show_result
|
|
super().__init__(0, 7 if show_result else 6)
|
|
self.setEditTriggers(QTableWidget.NoEditTriggers)
|
|
self.setSelectionBehavior(QTableWidget.SelectRows)
|
|
self.verticalHeader().setVisible(False)
|
|
self.setSortingEnabled(True)
|
|
header = self.horizontalHeader()
|
|
header.setStretchLastSection(True)
|
|
for col in range(self.columnCount() - 1):
|
|
header.setSectionResizeMode(col, QHeaderView.ResizeToContents)
|
|
|
|
def retranslate(self) -> None:
|
|
cols = [tr("monitoring.col_time"), tr("monitoring.col_role"),
|
|
tr("monitoring.col_account"), tr("monitoring.col_machine")]
|
|
if self._show_result:
|
|
cols += [tr("monitoring.col_name"), tr("monitoring.col_result")]
|
|
else:
|
|
cols += [tr("monitoring.col_action")]
|
|
cols += [tr("monitoring.col_detail")]
|
|
self.setHorizontalHeaderLabels(cols)
|
|
|
|
def set_events(self, events: List[dict]) -> None:
|
|
events = sorted(events, key=lambda e: e.get("ts", ""), reverse=True)[:_MAX_ROWS]
|
|
self.setSortingEnabled(False)
|
|
self.setRowCount(len(events))
|
|
for row, ev in enumerate(events):
|
|
is_admin_violation = ev.get("role") == "admin" and not ev.get("ok", True)
|
|
cells = [
|
|
ev.get("ts", ""), agent_roles.label_for(ev.get("agent_role", "")),
|
|
ev.get("account", "") or "—", ev.get("machine", "") or "—",
|
|
ev.get("name", ""),
|
|
]
|
|
if self._show_result:
|
|
cells.append("")
|
|
cells.append((ev.get("detail") or "")[:300])
|
|
pal = current_palette()
|
|
for col, text in enumerate(cells):
|
|
item = QTableWidgetItem(str(text))
|
|
if self._show_result and col == 5:
|
|
# Result — green check / red close icon (no emoji)
|
|
item.setIcon(icon("check", color=DOT_GREEN) if ev.get("ok")
|
|
else icon("close", color=DOT_RED))
|
|
if not self._show_result and col == 4:
|
|
# The action itself, tinted by which rule fired — this is
|
|
# the column the always-identical result column made way for.
|
|
tint = getattr(pal, self._ACTION_TINTS.get(
|
|
ev.get("name", ""), "text_muted"), pal.text_muted)
|
|
colour = QColor(tint)
|
|
item.setForeground(QBrush(colour))
|
|
soft = QColor(colour)
|
|
soft.setAlpha(38)
|
|
item.setBackground(QBrush(soft))
|
|
if is_admin_violation:
|
|
item.setBackground(QBrush(QColor(229, 72, 77, 60)))
|
|
self.setItem(row, col, item)
|
|
self.setSortingEnabled(True)
|
|
self.apply_filter(getattr(self, "_filter_needle", ""))
|
|
|
|
def apply_filter(self, needle: str) -> None:
|
|
self._filter_needle = (needle or "").strip().lower()
|
|
for row in range(self.rowCount()):
|
|
if not self._filter_needle:
|
|
self.setRowHidden(row, False)
|
|
continue
|
|
match = any(
|
|
self._filter_needle in (self.item(row, col).text().lower()
|
|
if self.item(row, col) else "")
|
|
for col in range(self.columnCount()))
|
|
self.setRowHidden(row, not match)
|
|
|
|
|
|
class MonitoringTab(QWidget):
|
|
status_message = Signal(str)
|
|
|
|
def __init__(self, ctx: AppContext, cowork=None, structure=None, task_scheduler=None):
|
|
super().__init__()
|
|
self.ctx = ctx
|
|
self._cowork = cowork
|
|
self._structure = structure
|
|
self._task_scheduler = task_scheduler
|
|
self._last_io_sample = None
|
|
|
|
root = QVBoxLayout(self)
|
|
head = QHBoxLayout()
|
|
self._title = QLabel()
|
|
self._title.setStyleSheet("font-weight:700; font-size:15px;")
|
|
head.addWidget(self._title)
|
|
head.addStretch(1)
|
|
self.refresh_btn = QPushButton()
|
|
self.refresh_btn.setIcon(icon("refresh"))
|
|
self.refresh_btn.clicked.connect(self.refresh)
|
|
head.addWidget(self.refresh_btn)
|
|
root.addLayout(head)
|
|
|
|
self.tabs = QTabWidget()
|
|
root.addWidget(self.tabs, 1)
|
|
|
|
# ---- Overview (card dashboard) ----------------------------------
|
|
self.tabs.addTab(self._build_overview_page(), "")
|
|
|
|
visible = self._tab_visible
|
|
# Security events are always ok=False, so this table trades the
|
|
# tick/cross column for a tinted Action column (see _EventTable).
|
|
self.security_table = _EventTable(show_result=False)
|
|
self.security_page = self._wrap_with_filter(self.security_table)
|
|
if visible("security_events"):
|
|
self.tabs.addTab(self.security_page, "")
|
|
self.mcp_table = _EventTable()
|
|
if visible("mcp_history"):
|
|
self.tabs.addTab(self.mcp_table, "")
|
|
self.action_table = _EventTable()
|
|
self.action_page = self._wrap_with_filter(self.action_table)
|
|
if visible("action_logs"):
|
|
self.tabs.addTab(self.action_page, "")
|
|
|
|
# ---- Agent Status -------------------------------------------------
|
|
self.status_table = QTableWidget(0, 3)
|
|
self.status_table.setEditTriggers(QTableWidget.NoEditTriggers)
|
|
self.status_table.verticalHeader().setVisible(False)
|
|
self.status_table.horizontalHeader().setStretchLastSection(True)
|
|
if visible("agent_status"):
|
|
self.tabs.addTab(self.status_table, "")
|
|
|
|
# ---- Agents Admin (catalog: assign a role + pinned model per agent) --
|
|
# The system-management agents (Security, GraphRAG/Knowledge, Monitor…)
|
|
# are defined here — each gets a task_kind (role) and an optional pinned
|
|
# provider/model. Admin-only; since the app runs with full admin access
|
|
# this is always shown.
|
|
from .agents_admin_tab import AgentsAdminTab
|
|
self.agents_admin_tab = AgentsAdminTab(ctx)
|
|
if visible("agents_admin"):
|
|
self.tabs.addTab(self.agents_admin_tab, "")
|
|
|
|
# ---- Tools (govern built-in tools + Connectors/MCP in one place) -----
|
|
from .tools_admin_tab import ToolsAdminTab
|
|
self.tools_admin_tab = ToolsAdminTab(ctx)
|
|
if visible("tools_admin"):
|
|
self.tabs.addTab(self.tools_admin_tab, "")
|
|
|
|
# ---- Icons (browse built-in icons + add custom icons for agents/flows) --
|
|
from .icons_admin_tab import IconsAdminTab
|
|
self.icons_admin_tab = IconsAdminTab(ctx)
|
|
self.tabs.addTab(self.icons_admin_tab, "")
|
|
|
|
self.tabs.setCurrentIndex(0)
|
|
|
|
self._timer = QTimer(self)
|
|
# (nav integration methods defined below)
|
|
self._timer.setInterval(_REFRESH_MS)
|
|
self._timer.timeout.connect(self.refresh)
|
|
self._timer.start()
|
|
|
|
on_language_changed(self._retranslate)
|
|
self.refresh()
|
|
|
|
# ---- nav integration: sub-tabs driven from the left nav rail ------------
|
|
def nav_subtabs(self):
|
|
"""(label, index, icon_name) for each sub-tab — the left nav lists these
|
|
as children under 'Monitoring'. Icons are keyed by widget identity so
|
|
they're correct in every language."""
|
|
by_widget = {self.agents_admin_tab: "robot", self.tools_admin_tab: "wrench",
|
|
self.icons_admin_tab: "star"}
|
|
for attr, name in (("security_page", "shield"), ("mcp_table", "plug"),
|
|
("action_page", "bolt"), ("status_table", "monitor")):
|
|
w = getattr(self, attr, None)
|
|
if w is not None:
|
|
by_widget[w] = name
|
|
out = []
|
|
for i in range(self.tabs.count()):
|
|
out.append((self.tabs.tabText(i), i, by_widget.get(self.tabs.widget(i), "dashboard")))
|
|
return out
|
|
|
|
def select_subtab(self, index: int) -> None:
|
|
if 0 <= index < self.tabs.count():
|
|
self.tabs.setCurrentIndex(index)
|
|
|
|
def hide_tab_bar(self) -> None:
|
|
"""Hide the in-content tab strip; the nav rail drives the sub-tabs."""
|
|
self.tabs.tabBar().hide()
|
|
|
|
# ---- model pricing list (Overview) --------------------------------------
|
|
def _reload_pricing_table(self, *_a) -> None:
|
|
from ..core import model_pricing as mp
|
|
to_ccy = self.ov_pricing_ccy.currentData() or "USD"
|
|
entries = mp.list_entries(self.ctx.config)
|
|
t = self.ov_pricing_table
|
|
t.setRowCount(len(entries))
|
|
for r, e in enumerate(entries):
|
|
in_v = mp.convert(e.get("input_price", 0), e.get("input_ccy", "USD"), to_ccy, self.ctx.config)
|
|
out_v = mp.convert(e.get("output_price", 0), e.get("output_ccy", "USD"), to_ccy, self.ctx.config)
|
|
vals = [e.get("model", ""), e.get("context_length", ""), e.get("max_output", ""),
|
|
f"{mp.format_price(in_v, to_ccy)} / {e.get('input_unit', '')}",
|
|
f"{mp.format_price(out_v, to_ccy)} / {e.get('output_unit', '')}"]
|
|
for c, v in enumerate(vals):
|
|
t.setItem(r, c, QTableWidgetItem(str(v)))
|
|
|
|
def _import_pricing(self) -> None:
|
|
from PySide6.QtWidgets import QFileDialog, QMessageBox
|
|
|
|
from ..core import model_pricing as mp
|
|
path, _ = QFileDialog.getOpenFileName(
|
|
self, tr("monitoring.pricing_import"), "", "Pricing (*.xlsx *.csv)")
|
|
if not path:
|
|
return
|
|
default_ccy = self.ov_pricing_ccy.currentData() or "USD"
|
|
try:
|
|
imported = mp.import_table(path, default_ccy=default_ccy)
|
|
except ValueError as exc:
|
|
QMessageBox.warning(self, tr("monitoring.pricing_title"), str(exc))
|
|
return
|
|
merged = {e["model"]: e for e in mp.list_entries(self.ctx.config)}
|
|
for e in imported:
|
|
merged[e["model"]] = e
|
|
mp.save_entries(self.ctx.config, list(merged.values()))
|
|
self.ctx.save()
|
|
self._reload_pricing_table()
|
|
self.status_message.emit(tr("monitoring.pricing_imported", n=len(imported)))
|
|
|
|
def _export_pricing(self) -> None:
|
|
from PySide6.QtWidgets import QFileDialog
|
|
|
|
from ..core import model_pricing as mp
|
|
path, _ = QFileDialog.getSaveFileName(
|
|
self, tr("monitoring.pricing_export"), "model_pricing_template.xlsx", "Excel (*.xlsx)")
|
|
if not path:
|
|
return
|
|
mp.export_template(path)
|
|
self.status_message.emit(tr("monitoring.pricing_exported"))
|
|
|
|
def _add_pricing_row(self) -> None:
|
|
from PySide6.QtWidgets import QInputDialog
|
|
|
|
from ..core import model_pricing as mp
|
|
name, ok = QInputDialog.getText(self, tr("monitoring.pricing_add"),
|
|
tr("monitoring.pricing_add_prompt"))
|
|
name = (name or "").strip()
|
|
if not ok or not name:
|
|
return
|
|
ccy = self.ov_pricing_ccy.currentData() or "USD"
|
|
mp.add_entry(self.ctx.config, mp.entry_from_row(
|
|
[name, "", "", "0", "Million tokens", "0", "Million tokens"], default_ccy=ccy))
|
|
self.ctx.save()
|
|
self._reload_pricing_table()
|
|
|
|
def _autolink_pricing(self) -> None:
|
|
from ..core import model_pricing as mp
|
|
from ..core.worker import AgentWorker
|
|
if getattr(self, "_pricing_worker", None) is not None:
|
|
return
|
|
self.ov_price_link_btn.setEnabled(False)
|
|
ctx = self.ctx
|
|
ccy = self.ov_pricing_ccy.currentData() or "USD"
|
|
|
|
def job(_w):
|
|
return {"entries": mp.auto_link(ctx, default_ccy=ccy)}
|
|
|
|
def done(r):
|
|
self._pricing_worker = None
|
|
self.ov_price_link_btn.setEnabled(True)
|
|
self.ctx.save()
|
|
self._reload_pricing_table()
|
|
self.status_message.emit(tr("monitoring.pricing_linked", n=len(r.get("entries", []))))
|
|
|
|
def failed(_e):
|
|
self._pricing_worker = None
|
|
self.ov_price_link_btn.setEnabled(True)
|
|
|
|
w = AgentWorker(job)
|
|
w.finished_ok.connect(done)
|
|
w.failed.connect(failed)
|
|
self._pricing_worker = w
|
|
w.start()
|
|
|
|
def _delete_pricing_row(self) -> None:
|
|
from ..core import model_pricing as mp
|
|
row = self.ov_pricing_table.currentRow()
|
|
entries = mp.list_entries(self.ctx.config)
|
|
if 0 <= row < len(entries):
|
|
del entries[row]
|
|
mp.save_entries(self.ctx.config, entries)
|
|
self.ctx.save()
|
|
self._reload_pricing_table()
|
|
|
|
def _wrap_with_filter(self, table: "_EventTable") -> QWidget:
|
|
page = QWidget()
|
|
lay = QVBoxLayout(page)
|
|
lay.setContentsMargins(0, 0, 0, 0)
|
|
row = QHBoxLayout()
|
|
search = QLineEdit()
|
|
search.setPlaceholderText(tr("monitoring.filter_placeholder"))
|
|
search.textChanged.connect(table.apply_filter)
|
|
ai_btn = QPushButton(tr("monitoring.ai_filter_btn"))
|
|
ai_btn.setIcon(icon("sparkle"))
|
|
ai_btn.setToolTip(tr("monitoring.ai_filter_tooltip"))
|
|
ai_btn.clicked.connect(lambda: self._ai_filter(search, ai_btn))
|
|
row.addWidget(search, 1)
|
|
row.addWidget(ai_btn)
|
|
lay.addLayout(row)
|
|
lay.addWidget(table, 1)
|
|
page.filter_edit = search
|
|
page.ai_filter_btn = ai_btn
|
|
return page
|
|
|
|
def _ai_filter(self, search: QLineEdit, ai_btn: QPushButton) -> None:
|
|
query = search.text().strip()
|
|
if not query or getattr(self, "_ai_filter_worker", None) is not None:
|
|
return
|
|
ai_btn.setEnabled(False)
|
|
ctx = self.ctx
|
|
|
|
def job(worker):
|
|
provider = ctx.build_active_provider()
|
|
reply = provider.chat([
|
|
{"role": "system", "content":
|
|
"Turn the user's natural-language question about an audit/security event "
|
|
"log into ONE short search keyword. Reply with ONLY the keyword."},
|
|
{"role": "user", "content": query},
|
|
], cancel=worker.is_cancelled)
|
|
return {"keyword": (reply.get("content") or "").strip().splitlines()[0][:60]}
|
|
|
|
def done(result: dict) -> None:
|
|
self._ai_filter_worker = None
|
|
ai_btn.setEnabled(True)
|
|
search.setText(result.get("keyword") or query)
|
|
|
|
def failed(_err: str) -> None:
|
|
self._ai_filter_worker = None
|
|
ai_btn.setEnabled(True)
|
|
|
|
from ..core.worker import AgentWorker
|
|
w = AgentWorker(job)
|
|
w.finished_ok.connect(done)
|
|
w.failed.connect(failed)
|
|
self._ai_filter_worker = w
|
|
w.start()
|
|
|
|
# ---- Overview page construction ------------------------------------
|
|
def _build_overview_page(self) -> QWidget:
|
|
page = QWidget()
|
|
outer = QVBoxLayout(page)
|
|
outer.setContentsMargins(0, 0, 0, 0)
|
|
scroll = QScrollArea()
|
|
scroll.setWidgetResizable(True)
|
|
scroll.setFrameShape(QScrollArea.NoFrame)
|
|
content = QWidget()
|
|
scroll.setWidget(content)
|
|
outer.addWidget(scroll)
|
|
|
|
# ONE main column, scrolled vertically, sections in a fixed order — the
|
|
# two-column grid put six group boxes side by side and mixed three
|
|
# unrelated concerns (cost, machine resources, security) at the same
|
|
# level, which made this the densest screen in the app. Each section now
|
|
# spans the full width and lays its own contents out horizontally, so a
|
|
# wide window is still used well.
|
|
root = QVBoxLayout(content)
|
|
root.setSpacing(12)
|
|
left = root # sections are appended in reading order
|
|
right = root
|
|
|
|
# ---- Token Usage & Cost --------------------------------------------
|
|
self.ov_usage_group = QGroupBox()
|
|
self.ov_usage_group.setObjectName("monSection")
|
|
usage_lay = QGridLayout(self.ov_usage_group)
|
|
usage_lay.setSpacing(8)
|
|
self.ov_usage_total = StatCard()
|
|
self.ov_usage_in = StatCard()
|
|
self.ov_usage_out = StatCard()
|
|
self.ov_usage_cache = StatCard()
|
|
self.ov_usage_cost = StatCard()
|
|
self.ov_usage_calls = StatCard()
|
|
# The wireframe's usage block is five figures: cost (with the turn
|
|
# count on its label), then Tổng token / Input / Output / Cache. Folding
|
|
# the last three into a sub-line took their PER-PART COST off screen —
|
|
# the total tile has room for the token counts but not for three more
|
|
# prices — and the drawing asks for the tiles anyway.
|
|
for i, card in enumerate((self.ov_usage_cost, self.ov_usage_total,
|
|
self.ov_usage_in, self.ov_usage_out,
|
|
self.ov_usage_cache)):
|
|
usage_lay.addWidget(card, 0, i)
|
|
self.ov_usage_calls.setVisible(False) # rides on the cost tile's label
|
|
# Budget: remaining/budget, direct entry, auto-warns red past 85% used —
|
|
# same box (and same usage.budget_* config) as the Dashboard's.
|
|
self.ov_budget_card = BudgetCard()
|
|
self.ov_budget_card.apply_btn.setIcon(icon("check"))
|
|
self.ov_budget_card.apply_btn.clicked.connect(self._apply_budget)
|
|
usage_lay.addWidget(self.ov_budget_card, 0, 3)
|
|
# Equal stretch on every column — otherwise the grid sizes each column
|
|
# to its widest cell's natural content (Budget's longer "$X / $Y" value
|
|
# + entry row makes its column wider than the plain stat cards).
|
|
for col in range(4):
|
|
usage_lay.setColumnStretch(col, 1)
|
|
|
|
# Unit prices are NOT entered here anymore — the cost total is computed
|
|
# straight from the model pricing table (below). The display-currency
|
|
# picker moved to the Dashboard (beside its refresh button) — both
|
|
# screens still read/write the SAME usage.currency config key.
|
|
left.addWidget(self.ov_usage_group)
|
|
|
|
# ---- Recent Activity ----------------------------------------------
|
|
self.ov_activity_group = QGroupBox()
|
|
self.ov_activity_group.setObjectName("monSection")
|
|
act_lay = QVBoxLayout(self.ov_activity_group)
|
|
self.ov_activity_lbl = QLabel()
|
|
self.ov_activity_lbl.setWordWrap(True)
|
|
self.ov_activity_lbl.setTextFormat(Qt.RichText)
|
|
act_lay.addWidget(self.ov_activity_lbl)
|
|
# added near the bottom, beside the audit log — see below
|
|
|
|
# ---- Resource Usage -------------------------------------------------
|
|
self.ov_resource_group = QGroupBox()
|
|
self.ov_resource_group.setObjectName("monSection")
|
|
# ONE compact line, as the wireframe writes it: name and value sit
|
|
# together and the pairs are separated by a middle dot, packed left —
|
|
# spread across the full width they read as four unrelated columns with
|
|
# the value stranded at the far edge of a 1900px screen.
|
|
res_lay = QHBoxLayout(self.ov_resource_group)
|
|
res_lay.setSpacing(6)
|
|
self._res_first = True
|
|
|
|
def _pair():
|
|
if not self._res_first:
|
|
sep = QLabel("·")
|
|
sep.setObjectName("hint")
|
|
res_lay.addWidget(sep)
|
|
self._res_first = False
|
|
lbl = QLabel(); lbl.setObjectName("hint")
|
|
val = QLabel()
|
|
res_lay.addWidget(lbl)
|
|
res_lay.addWidget(val)
|
|
return lbl, val
|
|
|
|
def _bar_row():
|
|
# The gauge is kept and updated, but off the line: at a glance the
|
|
# number is what is read, and the bar was drawing a 700px rule.
|
|
lbl, val = _pair()
|
|
bar = QProgressBar()
|
|
bar.setVisible(False)
|
|
return lbl, bar, val
|
|
|
|
def _text_row():
|
|
return _pair()
|
|
|
|
self.ov_cpu_lbl, self.ov_cpu_bar, self.ov_cpu_val = _bar_row()
|
|
self.ov_mem_lbl, self.ov_mem_bar, self.ov_mem_val = _bar_row()
|
|
self.ov_diskfree_lbl, self.ov_diskfree_val = _text_row()
|
|
# I/O and network rates keep working; they are read from the detail
|
|
# fold rather than the summary line the wireframe draws.
|
|
self.ov_disk_lbl, self.ov_disk_val = QLabel(), QLabel()
|
|
self.ov_network_lbl, self.ov_network_val = QLabel(), QLabel()
|
|
res_lay.addStretch(1)
|
|
|
|
# ---- Model pricing (beside the CPU/resource group) ------------------
|
|
self.ov_pricing_group = QGroupBox()
|
|
self.ov_pricing_group.setObjectName("monSection")
|
|
pg = QVBoxLayout(self.ov_pricing_group)
|
|
phdr = QHBoxLayout()
|
|
self.ov_pricing_ccy_lbl = QLabel(); self.ov_pricing_ccy_lbl.setObjectName("hint")
|
|
self.ov_pricing_ccy = QComboBox()
|
|
for cur in ut.SUPPORTED_CURRENCIES:
|
|
self.ov_pricing_ccy.addItem(cur, cur)
|
|
pidx = self.ov_pricing_ccy.findData(
|
|
(self.ctx.config.data.get("usage") or {}).get("currency", "USD"))
|
|
self.ov_pricing_ccy.setCurrentIndex(max(0, pidx))
|
|
self.ov_pricing_ccy.currentIndexChanged.connect(self._reload_pricing_table)
|
|
phdr.addWidget(self.ov_pricing_ccy_lbl)
|
|
phdr.addWidget(self.ov_pricing_ccy)
|
|
phdr.addStretch(1)
|
|
self.ov_price_import_btn = QPushButton(); self.ov_price_import_btn.setIcon(icon("download"))
|
|
self.ov_price_import_btn.clicked.connect(self._import_pricing)
|
|
self.ov_price_export_btn = QPushButton(); self.ov_price_export_btn.setIcon(icon("upload"))
|
|
self.ov_price_export_btn.clicked.connect(self._export_pricing)
|
|
self.ov_price_add_btn = QPushButton(); self.ov_price_add_btn.setIcon(icon("plus"))
|
|
self.ov_price_add_btn.clicked.connect(self._add_pricing_row)
|
|
self.ov_price_link_btn = QPushButton(); self.ov_price_link_btn.setIcon(icon("refresh"))
|
|
self.ov_price_link_btn.clicked.connect(self._autolink_pricing)
|
|
self.ov_price_del_btn = QPushButton(); self.ov_price_del_btn.setIcon(icon("trash"))
|
|
self.ov_price_del_btn.clicked.connect(self._delete_pricing_row)
|
|
for b in (self.ov_price_import_btn, self.ov_price_export_btn, self.ov_price_add_btn,
|
|
self.ov_price_link_btn, self.ov_price_del_btn):
|
|
phdr.addWidget(b)
|
|
pg.addLayout(phdr)
|
|
self.ov_pricing_table = QTableWidget(0, 5)
|
|
self.ov_pricing_table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
|
|
self.ov_pricing_table.verticalHeader().setVisible(False)
|
|
self.ov_pricing_table.setEditTriggers(QTableWidget.NoEditTriggers)
|
|
self.ov_pricing_table.setSelectionBehavior(QTableWidget.SelectRows)
|
|
pg.addWidget(self.ov_pricing_table, 1)
|
|
|
|
# Resources keep the row to themselves; the model price table gets its
|
|
# own full-width section further down (it is a reference table, not a
|
|
# live meter, and squeezing it next to the CPU bars made both unreadable).
|
|
left.addWidget(self.ov_resource_group)
|
|
self._reload_pricing_table()
|
|
|
|
# ---- Sandbox Details --------------------------------------------
|
|
self.ov_sandbox_details_group = QGroupBox()
|
|
self.ov_sandbox_details_group.setObjectName("monSection")
|
|
sbx_lay = QVBoxLayout(self.ov_sandbox_details_group)
|
|
self.ov_sbx_summary = QLabel()
|
|
self.ov_sbx_summary.setWordWrap(True)
|
|
sbx_lay.addWidget(self.ov_sbx_summary)
|
|
self.ov_sbx_more_btn = QPushButton()
|
|
self.ov_sbx_more_btn.setObjectName("co4eSectionAction")
|
|
self.ov_sbx_more_btn.setFlat(True)
|
|
self.ov_sbx_more_btn.setCheckable(True)
|
|
self.ov_sbx_more_btn.setCursor(Qt.PointingHandCursor)
|
|
sbx_lay.addWidget(self.ov_sbx_more_btn, 0, Qt.AlignLeft)
|
|
self._sbx_detail = QWidget()
|
|
self._sbx_detail.setVisible(False)
|
|
self.ov_sbx_more_btn.toggled.connect(self._sbx_detail.setVisible)
|
|
self.ov_sbx_more_btn.toggled.connect(self._sync_sbx_more_label)
|
|
sbx_lay.addWidget(self._sbx_detail)
|
|
sbx_lay = QVBoxLayout(self._sbx_detail)
|
|
sbx_lay.setContentsMargins(0, 4, 0, 0)
|
|
|
|
def _kv():
|
|
row = QHBoxLayout()
|
|
lbl = QLabel(); lbl.setObjectName("hint")
|
|
val = QLabel()
|
|
row.addWidget(lbl); row.addStretch(1); row.addWidget(val)
|
|
sbx_lay.addLayout(row)
|
|
return lbl, val
|
|
|
|
self.ov_sbx_id_lbl, self.ov_sbx_id_val = _kv()
|
|
self.ov_sbx_status_lbl, self.ov_sbx_status_val = _kv()
|
|
self.ov_sbx_status_val.setObjectName("badgeSuccess")
|
|
self.ov_sbx_created_lbl, self.ov_sbx_created_val = _kv()
|
|
self.ov_sbx_uptime_lbl, self.ov_sbx_uptime_val = _kv()
|
|
|
|
limits_row = QHBoxLayout()
|
|
self.ov_sbx_limits_lbl = QLabel()
|
|
self.ov_sbx_limits_lbl.setObjectName("hint")
|
|
self.ov_sbx_limits_lbl.setWordWrap(True)
|
|
self.ov_sbx_edit_btn = QPushButton()
|
|
self.ov_sbx_edit_btn.setFlat(True)
|
|
self.ov_sbx_edit_btn.clicked.connect(self._open_settings_and_refresh)
|
|
limits_row.addWidget(self.ov_sbx_limits_lbl, 1)
|
|
limits_row.addWidget(self.ov_sbx_edit_btn)
|
|
sbx_lay.addLayout(limits_row)
|
|
|
|
self.ov_sbx_net_lbl, self.ov_sbx_net_val = _kv()
|
|
# Sandbox and Permissions answer the same question ("what is the agent
|
|
# allowed to touch?"), so they share one full-width row.
|
|
root.addWidget(self.ov_sandbox_details_group)
|
|
|
|
# ---- Permissions -----------------------------------------------
|
|
self.ov_permissions_group = QGroupBox()
|
|
self.ov_permissions_group.setObjectName("monSection")
|
|
perm_lay = QVBoxLayout(self.ov_permissions_group)
|
|
|
|
def _pkv():
|
|
row = QHBoxLayout()
|
|
lbl = QLabel(); lbl.setObjectName("hint")
|
|
val = QLabel()
|
|
row.addWidget(lbl); row.addStretch(1); row.addWidget(val)
|
|
perm_lay.addLayout(row)
|
|
return lbl, val
|
|
|
|
self.ov_perm_fs_lbl, self.ov_perm_fs_val = _pkv()
|
|
self.ov_perm_network_lbl, self.ov_perm_network_val = _pkv()
|
|
self.ov_perm_process_lbl, self.ov_perm_process_val = _pkv()
|
|
self.ov_perm_env_lbl, self.ov_perm_env_val = _pkv()
|
|
self.ov_perm_edit_btn = QPushButton()
|
|
self.ov_perm_edit_btn.setFlat(True)
|
|
self.ov_perm_edit_btn.clicked.connect(self._open_settings_and_refresh)
|
|
perm_lay.addWidget(self.ov_perm_edit_btn, 0, Qt.AlignLeft)
|
|
# Inside the same fold as the sandbox rows — one section, one line.
|
|
self._sbx_detail.layout().addWidget(self.ov_permissions_group)
|
|
|
|
# ---- Model pricing — its own section, full width ------------------
|
|
root.addWidget(self.ov_pricing_group)
|
|
|
|
# ---- What actually happened, last ---------------------------------
|
|
root.addWidget(self.ov_activity_group)
|
|
|
|
# ---- Audit Log ----------------------------------------------------
|
|
self.ov_audit_group = QGroupBox()
|
|
self.ov_audit_group.setObjectName("monSection")
|
|
audit_lay = QVBoxLayout(self.ov_audit_group)
|
|
self.ov_audit_lbl = QLabel()
|
|
self.ov_audit_lbl.setWordWrap(True)
|
|
self.ov_audit_lbl.setTextFormat(Qt.RichText)
|
|
audit_lay.addWidget(self.ov_audit_lbl)
|
|
self.ov_view_all_btn = QPushButton()
|
|
self.ov_view_all_btn.setFlat(True)
|
|
self.ov_view_all_btn.clicked.connect(
|
|
lambda: self.tabs.setCurrentIndex(self.tabs.indexOf(self.action_page)))
|
|
audit_lay.addWidget(self.ov_view_all_btn, 0, Qt.AlignRight)
|
|
self.ov_audit_group.setVisible(self._tab_visible("action_logs"))
|
|
right.addWidget(self.ov_audit_group)
|
|
right.addStretch(1)
|
|
|
|
return page
|
|
|
|
def _open_settings_and_refresh(self) -> None:
|
|
from .settings_dialog import SettingsDialog
|
|
dlg = SettingsDialog(self.ctx, self)
|
|
dlg.exec()
|
|
self.refresh()
|
|
|
|
@staticmethod
|
|
def _set_badge(label: QLabel, object_name: str) -> None:
|
|
label.setObjectName(object_name)
|
|
label.style().unpolish(label)
|
|
label.style().polish(label)
|
|
|
|
def _tab_visible(self, key: str) -> bool:
|
|
return self.ctx.role == "admin" or bool(self.ctx.config.monitoring_visibility.get(key, True))
|
|
|
|
def _set_tab_text_if_present(self, widget, text: str) -> None:
|
|
idx = self.tabs.indexOf(widget)
|
|
if idx >= 0:
|
|
self.tabs.setTabText(idx, text)
|
|
|
|
# ---- i18n ------------------------------------------------------------
|
|
def _retranslate(self) -> None:
|
|
self._title.setText(tr("monitoring.title"))
|
|
self.refresh_btn.setText(tr("monitoring.refresh"))
|
|
if self.tabs.count():
|
|
self.tabs.setTabText(0, tr("monitoring.tab_overview"))
|
|
self._set_tab_text_if_present(self.security_page, tr("monitoring.tab_security"))
|
|
self._set_tab_text_if_present(self.mcp_table, tr("monitoring.tab_mcp"))
|
|
self._set_tab_text_if_present(self.action_page, tr("monitoring.tab_actions"))
|
|
self._set_tab_text_if_present(self.status_table, tr("monitoring.tab_agents"))
|
|
self._set_tab_text_if_present(self.agents_admin_tab, tr("monitoring.tab_agents_admin"))
|
|
self._set_tab_text_if_present(self.tools_admin_tab, tr("monitoring.tab_tools"))
|
|
self._set_tab_text_if_present(self.icons_admin_tab, tr("monitoring.tab_icons"))
|
|
self.security_table.retranslate()
|
|
self.mcp_table.retranslate()
|
|
self.action_table.retranslate()
|
|
self.status_table.setHorizontalHeaderLabels([
|
|
tr("monitoring.col_agent"), tr("monitoring.col_active"), tr("monitoring.col_source"),
|
|
])
|
|
|
|
# A QGroupBox title treats "&" as a mnemonic marker, so "token & Chi
|
|
# phí" rendered as "token _Chi phí". Double it to show a literal "&".
|
|
self.ov_usage_group.setTitle(
|
|
tr("monitoring.overview_usage_title").upper().replace("&", "&&"))
|
|
self.ov_budget_card.apply_btn.setToolTip(tr("usage.budget_apply_tooltip"))
|
|
self.ov_budget_card.budget_spin.setToolTip(tr("usage.budget_spin_tooltip"))
|
|
|
|
self.ov_activity_group.setTitle(tr("monitoring.overview_activity_title").upper())
|
|
self.ov_resource_group.setTitle(tr("monitoring.overview_resource_title").upper())
|
|
self.security_page.filter_edit.setPlaceholderText(tr("monitoring.filter_placeholder"))
|
|
self.action_page.filter_edit.setPlaceholderText(tr("monitoring.filter_placeholder"))
|
|
self.ov_cpu_lbl.setText(tr("monitoring.overview_res_cpu"))
|
|
self.ov_mem_lbl.setText(tr("monitoring.overview_res_mem"))
|
|
self.ov_diskfree_lbl.setText(tr("monitoring.overview_disk_label"))
|
|
self.ov_disk_lbl.setText(tr("monitoring.overview_res_disk"))
|
|
self.ov_network_lbl.setText(tr("monitoring.overview_res_network"))
|
|
# model pricing panel
|
|
self.ov_pricing_group.setTitle(tr("monitoring.pricing_title").upper())
|
|
self.ov_pricing_ccy_lbl.setText(tr("monitoring.pricing_currency"))
|
|
self.ov_price_import_btn.setText(tr("monitoring.pricing_import"))
|
|
self.ov_price_export_btn.setText(tr("monitoring.pricing_export"))
|
|
self.ov_price_add_btn.setText(tr("monitoring.pricing_add"))
|
|
self.ov_price_link_btn.setText(tr("monitoring.pricing_autolink"))
|
|
self.ov_price_del_btn.setText(tr("monitoring.pricing_delete"))
|
|
self.ov_pricing_table.setHorizontalHeaderLabels([
|
|
tr("monitoring.pricing_col_model"), tr("monitoring.pricing_col_context"),
|
|
tr("monitoring.pricing_col_maxout"), tr("monitoring.pricing_col_input"),
|
|
tr("monitoring.pricing_col_output")])
|
|
|
|
self.ov_sandbox_details_group.setTitle(
|
|
# "&" is a mnemonic marker in a group-box title — double it.
|
|
tr("monitoring.overview_sandbox_details_title").upper().replace("&", "&&"))
|
|
self.ov_sbx_id_lbl.setText(tr("monitoring.overview_sandbox_id"))
|
|
self.ov_sbx_status_lbl.setText(tr("monitoring.overview_status"))
|
|
self.ov_sbx_created_lbl.setText(tr("monitoring.overview_created"))
|
|
self.ov_sbx_uptime_lbl.setText(tr("monitoring.overview_uptime"))
|
|
self.ov_sbx_edit_btn.setText(tr("monitoring.overview_edit"))
|
|
self.ov_sbx_net_lbl.setText(tr("monitoring.overview_network_label"))
|
|
|
|
self.ov_permissions_group.setTitle(tr("monitoring.overview_permissions_title").upper())
|
|
self.ov_perm_fs_lbl.setText(tr("monitoring.overview_perm_fs"))
|
|
self.ov_perm_fs_val.setText(tr("monitoring.overview_perm_fs_value"))
|
|
self.ov_perm_network_lbl.setText(tr("monitoring.overview_perm_network"))
|
|
self.ov_perm_process_lbl.setText(tr("monitoring.overview_perm_process"))
|
|
self.ov_perm_process_val.setText(tr("monitoring.overview_perm_process_value"))
|
|
self.ov_perm_env_lbl.setText(tr("monitoring.overview_perm_env"))
|
|
self.ov_perm_env_val.setText(tr("monitoring.overview_perm_env_value"))
|
|
self.ov_perm_edit_btn.setText(tr("monitoring.overview_edit"))
|
|
|
|
self.ov_audit_group.setTitle(tr("monitoring.overview_audit_title").upper())
|
|
self.ov_view_all_btn.setText(tr("monitoring.overview_view_all"))
|
|
|
|
self.refresh()
|
|
|
|
# ---- refresh -----------------------------------------------------------
|
|
def refresh(self) -> None:
|
|
self._refresh_resource_usage()
|
|
events = self._load_events()
|
|
self.security_table.set_events([e for e in events if e.get("kind") == "security_block"])
|
|
self.mcp_table.set_events([e for e in events if e.get("kind") == "mcp_call"])
|
|
self.action_table.set_events(events)
|
|
self._refresh_agent_status()
|
|
self._refresh_overview(events)
|
|
|
|
def _load_events(self) -> List[dict]:
|
|
shared_dir = self.ctx.config.shared_dir
|
|
if shared_dir:
|
|
from ..core import telemetry_shared
|
|
shared_events = telemetry_shared.load_shared_audit_events(shared_dir)
|
|
if shared_events:
|
|
return shared_events
|
|
return audit_log.load_events()
|
|
|
|
def _refresh_resource_usage(self) -> None:
|
|
try:
|
|
import psutil
|
|
except ImportError:
|
|
self._set_overview_resource_na()
|
|
return
|
|
|
|
try:
|
|
own = psutil.Process()
|
|
own_cpu = own.cpu_percent(interval=None)
|
|
own_mem = own.memory_info().rss
|
|
except Exception:
|
|
own, own_cpu, own_mem = None, 0.0, 0
|
|
|
|
self.ov_cpu_bar.setValue(int(min(own_cpu, 100)))
|
|
self.ov_cpu_val.setText(f"{own_cpu:.0f}%")
|
|
try:
|
|
total_mem = psutil.virtual_memory().total
|
|
mem_pct = int(own_mem * 100 / total_mem) if total_mem else 0
|
|
except Exception:
|
|
mem_pct = 0
|
|
self.ov_mem_bar.setValue(min(mem_pct, 100))
|
|
try:
|
|
self.ov_mem_val.setText(f"{_fmt_bytes(own_mem)}/{_fmt_bytes(total_mem)}")
|
|
except Exception: # noqa: BLE001
|
|
self.ov_mem_val.setText(_fmt_bytes(own_mem))
|
|
# Free disk on the workspace drive — a capacity fact, unlike the I/O
|
|
# rate that used to sit here, and the one the drawing shows.
|
|
try:
|
|
free = psutil.disk_usage(str(self.ctx.config.cowork_output_dir())).free
|
|
self.ov_diskfree_val.setText(
|
|
tr("monitoring.overview_disk_free", size=_fmt_bytes(free)))
|
|
except Exception: # noqa: BLE001
|
|
self.ov_diskfree_val.setText(tr("monitoring.na"))
|
|
|
|
now = time.monotonic()
|
|
try:
|
|
io = own.io_counters() if own is not None else None
|
|
disk_bytes = (io.read_bytes + io.write_bytes) if io is not None else None
|
|
except Exception:
|
|
disk_bytes = None
|
|
try:
|
|
net = psutil.net_io_counters()
|
|
net_bytes = net.bytes_sent + net.bytes_recv
|
|
except Exception:
|
|
net_bytes = None
|
|
|
|
prev = self._last_io_sample
|
|
self._last_io_sample = (now, disk_bytes, net_bytes)
|
|
na = tr("monitoring.na")
|
|
if prev and disk_bytes is not None and prev[1] is not None and now > prev[0]:
|
|
rate = max(0.0, (disk_bytes - prev[1]) / (now - prev[0]))
|
|
self.ov_disk_val.setText(f"{_fmt_bytes(rate)}/s")
|
|
else:
|
|
self.ov_disk_val.setText(na)
|
|
if prev and net_bytes is not None and prev[2] is not None and now > prev[0]:
|
|
rate = max(0.0, (net_bytes - prev[2]) / (now - prev[0]))
|
|
self.ov_network_val.setText(f"{_fmt_bytes(rate)}/s")
|
|
else:
|
|
self.ov_network_val.setText(na)
|
|
|
|
def _set_overview_resource_na(self) -> None:
|
|
na = tr("monitoring.na")
|
|
self.ov_cpu_bar.setValue(0)
|
|
self.ov_cpu_val.setText(na)
|
|
self.ov_mem_bar.setValue(0)
|
|
self.ov_mem_val.setText(na)
|
|
self.ov_disk_val.setText(na)
|
|
self.ov_network_val.setText(na)
|
|
|
|
def _refresh_agent_status(self) -> None:
|
|
cowork_n = len(self._cowork.active_workers()) if self._cowork is not None else 0
|
|
task_n = self._task_scheduler.running_count() if self._task_scheduler is not None else 0
|
|
ask_worker = getattr(self._structure, "_ask_worker", None)
|
|
knowledge_n = 1 if (ask_worker is not None and ask_worker.isRunning()) else 0
|
|
|
|
# Security is a system-management agent that runs INLINE on the active
|
|
# turn (agent_security prompt/command validation) — there is no separate
|
|
# worker to count, so its "active" cell shows On/Off from Settings
|
|
# instead of a live count.
|
|
sec_on = bool(self.ctx.config.agent_security.get("enabled"))
|
|
|
|
rows = [
|
|
(agent_roles.COWORK, cowork_n, tr("monitoring.source_cowork")),
|
|
(agent_roles.TASK, task_n, tr("monitoring.source_task")),
|
|
(agent_roles.KNOWLEDGE, knowledge_n, tr("monitoring.source_knowledge")),
|
|
(agent_roles.PLANNER, None, tr("monitoring.source_planner")),
|
|
(agent_roles.REASONING, None, tr("monitoring.source_reasoning")),
|
|
(agent_roles.SECURITY, None, tr("monitoring.source_security")),
|
|
]
|
|
self.status_table.setRowCount(len(rows))
|
|
for row, (role_key, count, source) in enumerate(rows):
|
|
self.status_table.setItem(row, 0, QTableWidgetItem(agent_roles.label_for(role_key)))
|
|
if role_key == agent_roles.SECURITY:
|
|
active_text = tr("monitoring.on") if sec_on else tr("monitoring.off")
|
|
else:
|
|
active_text = tr("monitoring.active_n", n=count) if count is not None else "—"
|
|
self.status_table.setItem(row, 1, QTableWidgetItem(active_text))
|
|
self.status_table.setItem(row, 2, QTableWidgetItem(source))
|
|
|
|
def _activity_line(self, event: dict) -> str:
|
|
# Monochrome colored mark (no emoji) — an HTML label can't host a QIcon,
|
|
# so a thin ✓ / ✗ / ! tinted by state is the line-style equivalent.
|
|
ok = event.get("ok", True)
|
|
if ok:
|
|
mark = f"<span style='color:{DOT_GREEN};'>✓</span>"
|
|
elif event.get("kind") == "security_block":
|
|
mark = f"<span style='color:{DOT_AMBER};'>!</span>"
|
|
else:
|
|
mark = f"<span style='color:{DOT_RED};'>✗</span>"
|
|
name = event.get("name", "") or event.get("kind", "")
|
|
rel = _relative_time(event.get("ts", ""))
|
|
muted = current_palette().text_muted
|
|
suffix = f" <span style='color:{muted};'>— {rel}</span>" if rel else ""
|
|
return f"{mark} {name}{suffix}"
|
|
|
|
def _refresh_usage_cards(self) -> None:
|
|
from ..core import model_pricing as mp
|
|
mp.sync_to_usage(self.ctx.config) # cost total comes straight from the price table
|
|
pricing = {**ut.DEFAULT_PRICING, **(self.ctx.config.data.get("usage") or {})}
|
|
events = ut.load_events()
|
|
s = ut.summarize(events)
|
|
costs = ut.cost_usd_events(events, pricing)
|
|
self.ov_usage_total.set(tr("dashboard.card_total"), fmt_tokens(s["total"]))
|
|
self.ov_usage_calls.set(tr("monitoring.overview_calls"), str(s["turns"]), "")
|
|
self.ov_usage_in.set(tr("dashboard.card_in"), fmt_tokens(s["in"]),
|
|
ut.format_cost(costs["in"], pricing))
|
|
self.ov_usage_out.set(tr("dashboard.card_out"), fmt_tokens(s["out"]),
|
|
ut.format_cost(costs["out"], pricing))
|
|
self.ov_usage_cache.set(tr("dashboard.card_cache"), fmt_tokens(s["cache"]),
|
|
ut.format_cost(costs["cache"], pricing))
|
|
# "Tổng chi phí · 57 lượt", exactly as the wireframe labels it.
|
|
self.ov_usage_cost.set(
|
|
f'{tr("dashboard.card_cost")} · {s["turns"]} {tr("monitoring.overview_calls")}',
|
|
ut.format_cost(sum(costs.values()), pricing, digits=2))
|
|
self._refresh_budget()
|
|
|
|
def _sync_sbx_more_label(self, *_a) -> None:
|
|
"""Label the fold with what it will do next."""
|
|
open_ = self.ov_sbx_more_btn.isChecked()
|
|
self.ov_sbx_more_btn.setText(
|
|
("▾ " if open_ else "▸ ") + tr("monitoring.overview_sbx_detail"))
|
|
|
|
def _apply_budget(self) -> None:
|
|
"""Persist the spin box's value as the new budget — starts a fresh
|
|
remaining-balance window (spend before now is no longer counted)."""
|
|
ccy = (self.ctx.config.data.get("usage") or {}).get("currency", "USD")
|
|
ut.set_budget(self.ctx.config, self.ov_budget_card.budget_spin.value(), ccy)
|
|
self.ctx.save()
|
|
self._refresh_budget()
|
|
|
|
def _refresh_budget(self) -> None:
|
|
from ..core import model_pricing as mp
|
|
pricing = {**ut.DEFAULT_PRICING, **(self.ctx.config.data.get("usage") or {})}
|
|
status = ut.budget_status(self.ctx.config)
|
|
if status is None:
|
|
self.ov_budget_card.set(tr("usage.budget_title"), "—", tr("usage.budget_no_budget"))
|
|
self.ov_budget_card.budget_spin.setValue(0.0)
|
|
return
|
|
amount_disp = mp.convert(status["amount_usd"], "USD",
|
|
pricing.get("currency", "USD"), self.ctx.config)
|
|
value = (f"{ut.format_cost(status['remaining_usd'], pricing, digits=2)}"
|
|
f" / {ut.format_cost(status['amount_usd'], pricing, digits=2)}")
|
|
pct = int(round(status["pct_used"] * 100))
|
|
sub = tr("usage.budget_over_warning") if status["over_85"] else tr("usage.budget_used_pct", pct=pct)
|
|
self.ov_budget_card.set(tr("usage.budget_title"), value, sub, warn=status["over_85"])
|
|
if not self.ov_budget_card.budget_spin.hasFocus():
|
|
self.ov_budget_card.budget_spin.setValue(round(amount_disp, 2))
|
|
|
|
def _refresh_overview(self, events: List[dict]) -> None:
|
|
sec = self.ctx.config.agent_security
|
|
self._refresh_usage_cards()
|
|
net_blocked = bool(sec.get("block_network"))
|
|
|
|
recent = sorted(events, key=lambda e: e.get("ts", ""), reverse=True)
|
|
if recent:
|
|
self.ov_activity_lbl.setText("<br>".join(self._activity_line(e) for e in recent[:6]))
|
|
self.ov_audit_lbl.setText("<br>".join(
|
|
f"{e.get('ts', '')} — {e.get('name', '')}" for e in recent[:4]))
|
|
else:
|
|
self.ov_activity_lbl.setText(tr("monitoring.overview_no_activity"))
|
|
self.ov_audit_lbl.setText(tr("monitoring.overview_no_activity"))
|
|
|
|
self.ov_sbx_id_val.setText(f"sbx_{os.getpid():x}")
|
|
self.ov_sbx_status_val.setText(tr("monitoring.overview_status_running"))
|
|
self.ov_sbx_created_val.setText(datetime.fromtimestamp(self.ctx.started_at).strftime("%H:%M:%S"))
|
|
uptime_s = max(0, int(time.time() - self.ctx.started_at))
|
|
h, rem = divmod(uptime_s, 3600)
|
|
m, s = divmod(rem, 60)
|
|
self.ov_sbx_uptime_val.setText(f"{h}h {m}m {s}s" if h else f"{m}m {s}s")
|
|
limit_parts = []
|
|
if sec.get("resource_limit_cpu_percent"):
|
|
limit_parts.append(f"CPU {sec['resource_limit_cpu_percent']}%")
|
|
if sec.get("resource_limit_memory_mb"):
|
|
limit_parts.append(f"MEM {sec['resource_limit_memory_mb']}MB")
|
|
if sec.get("resource_limit_disk_mb"):
|
|
limit_parts.append(f"DISK {sec['resource_limit_disk_mb']}MB")
|
|
self.ov_sbx_limits_lbl.setText(
|
|
tr("monitoring.overview_resource_limits") + ": "
|
|
+ (", ".join(limit_parts) if limit_parts else tr("monitoring.na")))
|
|
self.ov_sbx_net_val.setText(
|
|
tr("monitoring.overview_network_disabled") if net_blocked
|
|
else tr("monitoring.overview_network_enabled"))
|
|
self._set_badge(self.ov_sbx_net_val, "badgeWarn" if net_blocked else "badgeSuccess")
|
|
# The one line the wireframe shows; the detail above stays a fold away.
|
|
self.ov_sbx_summary.setText(" · ".join([
|
|
f'{tr("monitoring.overview_perm_fs")}: '
|
|
f'{tr("monitoring.overview_perm_fs_value")}',
|
|
f'{tr("monitoring.overview_perm_network")}: '
|
|
f'{tr("monitoring.overview_network_disabled") if net_blocked else tr("monitoring.overview_network_enabled")}',
|
|
f'{tr("monitoring.overview_perm_process")}: '
|
|
f'{tr("monitoring.overview_perm_process_value")}',
|
|
f'{tr("monitoring.overview_resource_limits")}: '
|
|
f'{", ".join(limit_parts) if limit_parts else tr("monitoring.na")}',
|
|
]))
|
|
self._sync_sbx_more_label()
|
|
|
|
self.ov_perm_network_val.setText(
|
|
tr("monitoring.overview_perm_network_blocked") if net_blocked
|
|
else tr("monitoring.overview_perm_network_allowed"))
|
|
self._set_badge(self.ov_perm_network_val, "badgeWarn" if net_blocked else "badgeSuccess") |