refactor: vá 4 hồi quy, tách 4 file chạm trần LOC, docstring lên 100%

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>
This commit is contained in:
2026-08-30 10:41:45 +09:00
co-authored by Claude Opus 5
parent d20306be08
commit e29a0ccdbd
264 changed files with 4593 additions and 359 deletions
@@ -0,0 +1,3 @@
"""Mảnh dùng chung của các tab Giám sát: bảng sự kiện, panel chi tiết, huy hiệu,
bộ lọc và các hàm định dạng.
"""
@@ -0,0 +1,67 @@
"""Màu, huy hiệu và avatar của agent quản trị theo LOẠI VIỆC nó làm.
Bảng Agents Admin xếp agent theo ``task_kind`` (search / monitor / cowork /
graphrag / schedule / security / help). Ở đây gom toàn bộ phần "loại việc ấy
trông thế nào": màu định danh cho avatar, tông huy hiệu cho ô Vai trò, tông
huy hiệu cho ô Trạng thái.
Khác với ``formatters.py::agent_avatar_colour`` — chỗ đó đoán màu bằng cách
dò từ khoá trong TÊN agent, dùng cho các bảng chỉ có tên. Bảng quản trị biết
chính xác loại việc nên tra thẳng, không phải đoán.
Bảy loại, bảy tông riêng: không hai loại nào trùng màu huy hiệu, để phân biệt
được bằng màu chứ không phải chỉ bằng chữ. Màu cố định, không đổi theo giao
diện sáng/tối — cùng quy ước với avatar từng agent ở ``monitoring_tab.py``.
"""
from __future__ import annotations
from PySide6.QtCore import Qt
from PySide6.QtGui import QColor, QFont, QIcon, QPainter, QPixmap
from .formatters import agent_initials
#: Màu vòng tròn avatar theo loại việc.
KIND_COLOUR = {
"search": "#8A8886", "monitor": "#FFB900", "cowork": "#0078D4",
"graphrag": "#8764B8", "schedule": "#107C10", "security": "#D13438",
"help": "#E3008C",
}
#: Tên đối tượng huy hiệu (định nghĩa trong ``theme.py``) cho ô Vai trò.
KIND_BADGE = {
"search": "badgeNeutral", "monitor": "badgeWarn", "cowork": "badge",
"graphrag": "badgePurple", "schedule": "badgeSuccess", "security": "badgeDanger",
"help": "badgePink",
}
#: Tông huy hiệu cho ô Trạng thái, theo kết quả lần kiểm tra gần nhất.
STATUS_BADGE = {
"unchecked": "badgeNeutral", "checking": "badgeWarn",
"ok": "badgeSuccess", "bad": "badgeDanger",
}
def kind_avatar_icon(kind: str, name: str, size: int = 20) -> QIcon:
"""Avatar tròn cho một agent: nền là màu của loại việc, chữ là tên viết tắt.
Loại lạ (file cấu hình sửa tay, loại mới thêm mà quên khai màu) rơi về màu
xanh mặc định thay vì không vẽ gì — thiếu màu đúng vẫn hơn thiếu avatar.
"""
pm = QPixmap(size, size)
pm.fill(Qt.transparent)
p = QPainter(pm)
p.setRenderHint(QPainter.Antialiasing)
p.setPen(Qt.NoPen)
p.setBrush(QColor(KIND_COLOUR.get(kind, "#0078D4")))
p.drawEllipse(0, 0, size, size)
font = QFont()
font.setPixelSize(max(7, size // 2))
font.setBold(True)
p.setFont(font)
p.setPen(QColor("#FFFFFF"))
p.drawText(pm.rect(), Qt.AlignCenter, agent_initials(name))
p.end()
return QIcon(pm)
__all__ = ["KIND_COLOUR", "KIND_BADGE", "STATUS_BADGE", "kind_avatar_icon"]
@@ -13,12 +13,17 @@ from PySide6.QtWidgets import QLineEdit, QPushButton
def start_ai_filter(ctx, search: QLineEdit, ai_btn: QPushButton, state: dict) -> None:
"""Nhờ AI dịch câu tìm kiếm tự nhiên thành từ khoá lọc bảng sự kiện.
Ô tìm rỗng hoặc đang chạy dở thì bỏ qua.
"""
query = search.text().strip()
if not query or state.get("worker") is not None:
return
ai_btn.setEnabled(False)
def job(worker):
"""Chạy nền: hỏi model từ khoá lọc tương ứng câu người dùng gõ."""
provider = ctx.build_active_provider()
reply = provider.chat([
{"role": "system", "content":
@@ -29,11 +34,15 @@ def start_ai_filter(ctx, search: QLineEdit, ai_btn: QPushButton, state: dict) ->
return {"keyword": (reply.get("content") or "").strip().splitlines()[0][:60]}
def done(result: dict) -> None:
"""Đổ từ khoá model trả về vào ô tìm và áp bộ lọc."""
state["worker"] = None
ai_btn.setEnabled(True)
search.setText(result.get("keyword") or query)
def failed(_err: str) -> None:
"""Lọc bằng AI lỗi: chỉ mở khoá lại nút, giữ nguyên bộ lọc đang có — đây là
tiện ích phụ, hỏng thì không được làm phiền người dùng.
"""
state["worker"] = None
ai_btn.setEnabled(True)
+11
View File
@@ -29,6 +29,7 @@ _ACTION_LABEL_KEYS = {
def action_label(name: str) -> str:
"""Nhãn đã dịch của một loại hành động; không có trong bảng thì trả nguyên tên."""
key = _ACTION_LABEL_KEYS.get(name)
return tr(key) if key else name
@@ -44,6 +45,7 @@ _STATUS_DEFAULT = ("badgePurple", "monitoring.status_blocked")
def status_info(name: str, kind: str = "security_block", ok: bool = False) -> Tuple[str, str]:
"""Cặp (tên style, khoá dịch) cho huy hiệu trạng thái của một sự kiện."""
if name in _STATUS_INFO:
return _STATUS_INFO[name]
if kind == "security_block":
@@ -58,6 +60,11 @@ def status_info(name: str, kind: str = "security_block", ok: bool = False) -> Tu
def severity_info(name: str, kind: str = "security_block", ok: bool = False) -> Tuple[str, str]:
# An unapproved shell command is the one CRITICAL case; everything else
# blocked is MEDIUM.
"""Cặp (tên style, khoá dịch) cho huy hiệu mức nghiêm trọng.
Lệnh shell chạy mà chưa được duyệt là trường hợp NGHIÊM TRỌNG duy nhất; mọi
thứ bị chặn khác đều ở mức trung bình.
"""
if name in ("dangerous_command", "run_command"):
return "badgeDanger", "monitoring.severity_critical"
if kind == "security_block":
@@ -85,6 +92,10 @@ def agent_badge_name(name: str) -> str:
def apply_badge(label: QLabel, object_name: str) -> None:
"""Gán style cho một nhãn huy hiệu và ép Qt vẽ lại.
Phải ``unpolish``/``polish`` vì Qt không tự áp lại QSS khi ``objectName`` đổi.
"""
label.setObjectName(object_name)
label.style().unpolish(label)
label.style().polish(label)
@@ -33,6 +33,7 @@ class EventDetailPanel(QWidget):
closed = Signal()
def __init__(self):
"""Panel chi tiết hiện đầy đủ một sự kiện đang chọn trong bảng."""
super().__init__()
self.setObjectName("monSection")
self._detail_text = ""
@@ -64,12 +65,14 @@ class EventDetailPanel(QWidget):
self._rows: Dict[str, Tuple[QLabel, QLabel]] = {}
def _section(key: str) -> None:
"""Thêm một dòng tiêu đề mục vào panel."""
lbl = QLabel()
lbl.setObjectName("detailSectionHdr")
self._body_lay.addWidget(lbl)
self._section_hdrs.append((key, lbl))
def _field(key: str) -> QLabel:
"""Thêm một hàng nhãn–giá trị và ghi nhớ để cập nhật sau."""
lbl, val = kv_row(self._body_lay)
self._rows[key] = (lbl, val)
return val
@@ -121,6 +124,7 @@ class EventDetailPanel(QWidget):
outer.addLayout(footer)
def retranslate(self) -> None:
"""Áp lại chữ theo ngôn ngữ đang chọn."""
self._title_lbl.setText(tr("monitoring.security_detail_title"))
self._close_btn.setToolTip(tr("monitoring.security_detail_close"))
self._footer_close_btn.setText(tr("monitoring.security_detail_close"))
@@ -146,6 +150,7 @@ class EventDetailPanel(QWidget):
self._reset_copy_btn()
def show_event(self, ev: dict, row: int) -> None:
"""Hiện chi tiết một sự kiện; trường trống hiển thị dấu "—" thay vì để rỗng."""
na = "—"
self._rows["time"][1].setText(fmt_event_time_full(ev.get("ts", "")) or na)
@@ -179,11 +184,13 @@ class EventDetailPanel(QWidget):
apply_badge(self._severity_val, severity_badge)
def _copy_detail(self) -> None:
"""Chép toàn bộ chi tiết vào clipboard và đổi nhãn nút để báo đã chép."""
QGuiApplication.clipboard().setText(self._detail_text)
self._copy_btn.setText(tr("monitoring.detail_copied"))
self._copy_btn.setIcon(icon("check"))
QTimer.singleShot(1500, self._reset_copy_btn)
def _reset_copy_btn(self) -> None:
"""Trả nút Sao chép về nhãn ban đầu sau vài giây."""
self._copy_btn.setText(tr("monitoring.detail_copy"))
self._copy_btn.setIcon(icon("document"))
@@ -26,10 +26,18 @@ class _TimeItem(QTableWidgetItem):
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)
@@ -56,6 +64,11 @@ class EventTable(QTableWidget):
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)
@@ -74,6 +87,7 @@ class EventTable(QTableWidget):
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")]
@@ -85,6 +99,11 @@ class EventTable(QTableWidget):
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))
@@ -132,6 +151,7 @@ class EventTable(QTableWidget):
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:
@@ -144,6 +164,7 @@ class EventTable(QTableWidget):
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
@@ -156,12 +177,14 @@ class ClickOutsideCloser(QObject):
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())
@@ -30,6 +30,12 @@ def _sync_event_detail(table: EventTable, panel: EventDetailPanel) -> None:
# currentRow() alone is not enough: clearSelection() (used by the panel's
# close button) drops the selection but leaves the current cell in
# place, so a stale currentRow() would keep the panel open.
"""Đồng bộ panel chi tiết với dòng đang chọn trong bảng.
Chỉ nhìn ``currentRow()`` là chưa đủ: ``clearSelection()`` (nút đóng của panel
dùng) bỏ vùng chọn nhưng GIỮ nguyên ô hiện tại, nên chỉ dựa vào
``currentRow()`` thì panel sẽ không bao giờ đóng.
"""
row = table.currentRow()
has_selection = bool(table.selectedItems())
ev = table.event_at_row(row) if (has_selection and row >= 0) else None
@@ -44,6 +50,12 @@ def build_filter_scaffold(
with_detail: bool = False,
on_ai_filter: Optional[Callable[[QLineEdit, QPushButton], None]] = None,
) -> Dict[str, object]:
"""Dựng khung chung cho một tab sự kiện: tiêu đề, nút làm mới, ô tìm kiếm,
nút lọc bằng AI và panel chi tiết.
Bốn tab sự kiện của màn Giám sát chỉ khác nhau ở nguồn dữ liệu, nên phần vỏ
này được dựng một lần và dùng chung.
"""
lay = QVBoxLayout(page)
lay.setContentsMargins(0, 0, 0, 0)
parts: Dict[str, object] = {}
@@ -18,6 +18,7 @@ from ....i18n import tr
def _parse_iso(ts: str) -> Optional[datetime]:
"""Đọc chuỗi thời gian ISO; sai định dạng thì trả ``None``."""
try:
return datetime.fromisoformat(ts)
except (TypeError, ValueError):
@@ -25,6 +26,7 @@ def _parse_iso(ts: str) -> Optional[datetime]:
def fmt_bytes(n: float) -> str:
"""Đổi số byte sang chuỗi dễ đọc (B / KB / MB / GB)."""
for unit in ("B", "KB", "MB", "GB"):
if n < 1024:
return f"{n:.0f} {unit}"
@@ -10,6 +10,11 @@ from PySide6.QtWidgets import QWidget
def open_settings_and_notify(ctx, parent: QWidget, on_changed: Callable[[], None]) -> None:
"""Mở hộp thoại Cài đặt rồi gọi ``on_changed`` sau khi đóng.
Các thẻ trong màn Giám sát dùng chung hàm này để làm mới sau khi người dùng
sửa cấu hình an toàn.
"""
from ....ui.settings_dialog import SettingsDialog
dlg = SettingsDialog(ctx, parent)