Hồi quy đã vá
-------------
F-12 Kéo–thả hoặc dán tệp vào ô chat ném NameError. R08 tách `_Input` sang
`chat_input_box.py` nhưng để `_paths_from_mime()` ở lại
`composer_widget.py`, nên hai hàm sự kiện Qt gọi một cái tên không tồn
tại. Bốn hàm dùng chung chuyển sang `composer_mime.py` — module thứ ba
là chỗ duy nhất không lặp lại được lỗi này. Đo lại: cả thả lẫn dán đều
gắn 1 tệp, khớp bản trước refactor.
F-01 Đổi provider thì bộ chọn model AI-Edit không làm gì. Hook cũ kiểm
`folder.ai_model_combo`, thuộc tính R08-T12 đã dời sang
`ai_panel.resolver`. Làm mới vô điều kiện, đúng như tab cũ: lần lấy đầu
tiên hỏng thì đổi provider chính là lúc phải thử lại.
F-07 Hàng chọn kỳ của Dashboard bị đẩy xuống dưới các thẻ số liệu. Hàng này
lọc CẢ BA thẻ con chứ không riêng biểu đồ, nên để nó nằm dưới là bắt
người dùng đọc con số trước khi thấy con số đó tính cho kỳ nào. Kèm
theo: `TokenUsageCardWidget` bị bỏ sót `setContentsMargins(0,0,0,0)`
mà hai thẻ con còn lại đã có, đẩy cả hàng thẻ lệch 9px.
`check_layout_geometry` nay khớp TỪNG BYTE với bản trước refactor.
F-11 Hai lớp khai trùng tên phương thức; Python giữ bản sau nên bản đầu là
mã chết. `co4e_tab.py::showEvent` bản đầu gọi `_narrow_guard.attach()`
và không bao giờ chạy.
Tách file (F-09)
----------------
Bốn file chạm trần 400 dòng, mỗi lần cắt ra một trách nhiệm thật:
graph_renderer.py -> graph_scene_builder.py + graph_export.py
co4e_workflow_service.py -> co4e_run_history.py
json_config_repository.py -> config_sections.py
agents_admin_tab.py -> shared/agent_kind_visuals.py
File cuối còn xoá 3 bản sao của hàm đã có trong `shared/formatters.py`,
giống hệt đến từng dòng — nay định dạng thời gian và avatar không lệch nhau
giữa các bảng Giám sát nữa.
Docstring
---------
41,6% -> 100% (3.478/3.478 định nghĩa production), kể cả module dormant và
phương thức dunder. Toàn bộ phần bổ sung viết bằng tiếng Việt; comment tiếng
Anh có sẵn giữ nguyên — dịch ngược là một đợt riêng.
Seam chưa nối dây (F-05)
------------------------
9 seam mang nhãn `SEAM · dựng <ngày>` kèm hai câu: được nối khi nào, và để
dormant thì hỏng gì. Ngày lấy từ lịch sử git, không phải hạn tự đặt. Gate O
đọc nhãn đó và nhắc khi quá 30 ngày.
859 test xanh · 4/4 cổng CASAN · 19/24 checker khớp từng byte bản cũ.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
195 lines
9.1 KiB
Python
195 lines
9.1 KiB
Python
"""Read-only audit-event table shared by the Security Events / MCP Call
|
|
History / Action Logs tabs, plus the click-outside-closes-detail-panel event
|
|
filter. Extracted verbatim from ``ui/monitoring_tab.py``.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from typing import List, Optional
|
|
|
|
from PySide6.QtCore import QEvent, QObject, QRect, QSize, Qt
|
|
from PySide6.QtGui import QBrush, QColor
|
|
from PySide6.QtWidgets import QHeaderView, QTableWidget, QTableWidgetItem, QWidget
|
|
|
|
from ....core import agent_roles
|
|
from ....i18n import tr
|
|
from ....theme import current_palette
|
|
from ....ui.icons import DOT_GREEN, DOT_RED, icon
|
|
from .badges import action_label
|
|
from .formatters import agent_avatar_icon, fmt_event_time
|
|
|
|
_MAX_ROWS = 300
|
|
|
|
|
|
class _TimeItem(QTableWidgetItem):
|
|
"""The Time column shows "dd/MM hh:mm", which does not sort correctly as
|
|
text (day-of-month leads, not year/month) — so sorting compares the raw
|
|
ISO ``ts`` each item is built from instead of its displayed text."""
|
|
|
|
def __init__(self, raw_ts: str, display: str):
|
|
"""Ô thời gian giữ luôn chuỗi gốc: hiện ra là chữ đã rút gọn, nhưng sắp xếp
|
|
phải theo mốc thật chứ không theo thứ tự chữ cái.
|
|
"""
|
|
super().__init__(display)
|
|
self._raw_ts = raw_ts
|
|
|
|
def __lt__(self, other):
|
|
"""So sánh theo mốc thời gian gốc, không theo chuỗi hiển thị.
|
|
|
|
Sắp theo chuỗi đã định dạng sẽ ra thứ tự sai ngay khi định dạng có chữ
|
|
("5 phút trước" đứng trước "hôm qua").
|
|
"""
|
|
if isinstance(other, _TimeItem):
|
|
return self._raw_ts < other._raw_ts
|
|
return super().__lt__(other)
|
|
|
|
|
|
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 sorts by the underlying ISO timestamp, not its "dd/MM hh:mm"
|
|
display text — see :class:`_TimeItem`)."""
|
|
|
|
# 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).
|
|
"""Bảng sự kiện dùng chung của các tab Giám sát.
|
|
|
|
``show_result`` tắt cột Kết quả cho màn Sự kiện bảo mật — ở đó mọi dòng đều
|
|
là thất bại nên cột ấy chỉ tốn chỗ.
|
|
"""
|
|
self._show_result = show_result
|
|
super().__init__(0, 7 if show_result else 6)
|
|
self.setEditTriggers(QTableWidget.NoEditTriggers)
|
|
self.setSelectionBehavior(QTableWidget.SelectRows)
|
|
self.setIconSize(QSize(20, 20))
|
|
self.verticalHeader().setVisible(False)
|
|
# Fixed row height — letting Qt auto-size rows from content fought with
|
|
# the action column's cell widget geometry settling stale/oversized on
|
|
# an intermediate sizing pass, clipping the pill's text.
|
|
self.verticalHeader().setSectionResizeMode(QHeaderView.Fixed)
|
|
self.verticalHeader().setDefaultSectionSize(32)
|
|
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:
|
|
"""Áp lại tên cột theo ngôn ngữ đang chọn."""
|
|
cols = [tr("monitoring.col_time"),
|
|
tr("monitoring.col_agent") if not self._show_result else 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_block") if not self._show_result else tr("monitoring.col_detail")]
|
|
self.setHorizontalHeaderLabels(cols)
|
|
|
|
def set_events(self, events: List[dict]) -> None:
|
|
"""Đổ danh sách sự kiện vào bảng: mới nhất lên đầu, cắt ở ``_MAX_ROWS``.
|
|
|
|
Tắt sắp xếp trong lúc đổ dữ liệu — để bật, Qt sắp lại sau mỗi dòng và việc
|
|
nạp chậm đi theo bậc hai.
|
|
"""
|
|
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 = (_TimeItem(str(text), fmt_event_time(str(text))) if col == 0
|
|
else QTableWidgetItem(str(text)))
|
|
if col == 0:
|
|
# Stash the full event (untruncated detail included) on the
|
|
# Time cell, so a click-to-open detail panel survives the
|
|
# user re-sorting the table by any column.
|
|
item.setData(Qt.UserRole, ev)
|
|
if col == 1:
|
|
item.setIcon(agent_avatar_icon(str(text)))
|
|
if self._show_result and col == 5:
|
|
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:
|
|
# Human-readable label, tinted by which rule fired, via the
|
|
# ITEM's own colours — NOT a setCellWidget() pill, which is
|
|
# pinned to a screen position rather than travelling with
|
|
# the item across a re-sort.
|
|
tint = getattr(pal, self._ACTION_TINTS.get(
|
|
ev.get("name", ""), "text_muted"), pal.text_muted)
|
|
item.setText(action_label(str(text)))
|
|
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:
|
|
"""Ẩn/hiện dòng theo từ khoá tìm kiếm (không phân biệt hoa thường)."""
|
|
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)
|
|
|
|
def event_at_row(self, row: int) -> Optional[dict]:
|
|
"""Bản ghi sự kiện gắn với một dòng; ``None`` nếu dòng trống."""
|
|
item = self.item(row, 0)
|
|
return item.data(Qt.UserRole) if item else None
|
|
|
|
|
|
class ClickOutsideCloser(QObject):
|
|
"""Closes the event-detail panel on a click anywhere outside the
|
|
table/panel splitter — judged by screen-space geometry (is the click's
|
|
global position inside the splitter's on-screen rectangle), not by which
|
|
exact widget object received the event (unreliable mid-drag on the
|
|
splitter's handle)."""
|
|
|
|
def __init__(self, table: "EventTable", panel: QWidget, container: QWidget):
|
|
"""Bấm ra ngoài panel chi tiết thì đóng nó lại."""
|
|
super().__init__(container)
|
|
self._table = table
|
|
self._panel = panel
|
|
self._container = container
|
|
|
|
def eventFilter(self, obj, event) -> bool:
|
|
"""Bắt cú bấm chuột trong toàn khung: rơi ngoài cả bảng lẫn panel thì đóng panel."""
|
|
if event.type() == QEvent.MouseButtonPress and self._panel.isVisible():
|
|
global_pos = event.globalPosition().toPoint()
|
|
top_left = self._container.mapToGlobal(self._container.rect().topLeft())
|
|
rect = QRect(top_left, self._container.size())
|
|
if not rect.contains(global_pos):
|
|
self._table.clearSelection()
|
|
return False
|