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:
@@ -33,6 +33,12 @@ _UNBOUNDED_PAGE_SIZE = 100_000
|
||||
|
||||
|
||||
class MonitoringTab(QWidget):
|
||||
"""Vỏ màn Giám sát: dải 8 tab và phần dữ liệu dùng chung cho chúng.
|
||||
|
||||
Nhật ký kiểm toán được đọc MỘT lần mỗi nhịp làm mới rồi chia cho các tab,
|
||||
nên quyết định "đọc từ nguồn cục bộ hay nguồn chia sẻ" chỉ xảy ra đúng một
|
||||
lần chứ không phải ở từng tab.
|
||||
"""
|
||||
status_message = Signal(str)
|
||||
|
||||
# ---- cầu tương thích sau khi tách 8 tab (R08-T08) --------------------
|
||||
@@ -66,6 +72,14 @@ class MonitoringTab(QWidget):
|
||||
}
|
||||
|
||||
def __getattr__(self, name):
|
||||
"""Cầu tương thích cho 55 control cũ mang tiền tố ``ov_``.
|
||||
|
||||
R08-T08 chuyển chúng về đúng tab/thẻ của mình và bỏ tiền tố, nhưng các
|
||||
checker trong ``tools/`` vẫn hỏi theo tên cũ. Định tuyến theo TIỀN TỐ chứ
|
||||
không dò mờ: ``overview_tab`` và ``permissions_card`` có tên trùng nhau
|
||||
(``network_lbl`` ở cả hai — một cái là mức dùng mạng, một cái là quyền truy
|
||||
cập mạng), dò mờ sẽ vớ nhầm cái tìm thấy trước.
|
||||
"""
|
||||
lay_khac = self._KHAC.get(name)
|
||||
if lay_khac is not None:
|
||||
return lay_khac(self)
|
||||
@@ -84,6 +98,11 @@ class MonitoringTab(QWidget):
|
||||
raise AttributeError(name)
|
||||
|
||||
def __init__(self, ctx: AppContext, cowork=None, structure=None, task_scheduler=None):
|
||||
"""Ghép các tab con của màn Giám sát.
|
||||
|
||||
Các widget được truyền vào đều có thể là None (test dựng tab lẻ) — mọi chỗ
|
||||
dùng phải kiểm trước.
|
||||
"""
|
||||
super().__init__()
|
||||
self.ctx = ctx
|
||||
self._cowork = cowork
|
||||
@@ -174,6 +193,7 @@ class MonitoringTab(QWidget):
|
||||
return out
|
||||
|
||||
def select_subtab(self, index: int) -> None:
|
||||
"""Chuyển sang tab con thứ ``index`` (thanh menu bên trái gọi vào đây)."""
|
||||
if 0 <= index < self.tabs.count():
|
||||
self.tabs.setCurrentIndex(index)
|
||||
|
||||
@@ -182,18 +202,24 @@ class MonitoringTab(QWidget):
|
||||
self.tabs.tabBar().hide()
|
||||
|
||||
def _show_action_logs_tab(self) -> None:
|
||||
"""Nhảy tới tab Nhật ký hành động (dùng khi bấm vào phần trích ở Tổng quan)."""
|
||||
self.tabs.setCurrentIndex(self.tabs.indexOf(self.action_tab))
|
||||
|
||||
def _tab_visible(self, key: str) -> bool:
|
||||
"""Tab này có được hiện không: admin thấy hết, còn lại theo cấu hình
|
||||
``monitoring_visibility``.
|
||||
"""
|
||||
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:
|
||||
"""Đổi nhãn một tab, bỏ qua nếu tab đó đang bị ẩn theo phân quyền."""
|
||||
idx = self.tabs.indexOf(widget)
|
||||
if idx >= 0:
|
||||
self.tabs.setTabText(idx, text)
|
||||
|
||||
# ---- i18n ---------------------------------------------------------------
|
||||
def _retranslate(self) -> None:
|
||||
"""Áp lại chữ theo ngôn ngữ đang chọn cho tiêu đề và nhãn từng tab."""
|
||||
self._title.setText(tr("monitoring.title"))
|
||||
if self.tabs.count():
|
||||
self.tabs.setTabText(0, tr("monitoring.tab_overview"))
|
||||
@@ -229,6 +255,11 @@ class MonitoringTab(QWidget):
|
||||
self.overview_tab.refresh(self._load_events())
|
||||
|
||||
def _load_events(self) -> List[dict]:
|
||||
"""Đọc nhật ký kiểm toán, ưu tiên nguồn chia sẻ rồi mới tới nguồn cục bộ.
|
||||
|
||||
Có cấu hình thư mục chia sẻ VÀ đọc ra được dữ liệu thì dùng nó, để cả đội
|
||||
nhìn chung một bức tranh; rỗng thì rơi về nhật ký của máy này.
|
||||
"""
|
||||
shared_dir = self.ctx.config.shared_dir
|
||||
if shared_dir:
|
||||
from ...core import telemetry_shared
|
||||
@@ -252,6 +283,7 @@ class MonitoringTab(QWidget):
|
||||
service = MonitoringQueryService(repository)
|
||||
|
||||
def _events_for(kind) -> List[dict]:
|
||||
"""Lấy toàn bộ sự kiện của một loại, không phân trang."""
|
||||
page = service.query(kind=kind, page_size=_UNBOUNDED_PAGE_SIZE)
|
||||
return [e.to_dict() for e in page.items]
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Tám tab của màn Giám sát, mỗi tab một file: Tổng quan, Sandbox, Sự kiện bảo
|
||||
mật, Lịch sử MCP, Nhật ký hành động, Trạng thái Agent, Quản trị Agent, Công cụ.
|
||||
"""
|
||||
|
||||
@@ -15,7 +15,9 @@ from ..shared.filter_scaffold import build_filter_scaffold
|
||||
|
||||
|
||||
class ActionLogsTab(QWidget):
|
||||
"""Tab "Nhật ký hành động": bảng mọi thao tác đọc/ghi tệp và chạy lệnh của agent."""
|
||||
def __init__(self, ctx, on_refresh_all: Callable[[], None]):
|
||||
"""Tab Nhật ký hành động: bảng sự kiện kèm bộ lọc dùng chung."""
|
||||
super().__init__()
|
||||
self._ctx = ctx
|
||||
self._ai_state: dict = {}
|
||||
@@ -32,9 +34,11 @@ class ActionLogsTab(QWidget):
|
||||
self.detail_panel = parts["detail_panel"]
|
||||
|
||||
def set_events(self, events: List[dict]) -> None:
|
||||
"""Đổ danh sách sự kiện vào bảng."""
|
||||
self.table.set_events(events)
|
||||
|
||||
def retranslate(self) -> None:
|
||||
"""Áp lại chữ theo ngôn ngữ đang chọn."""
|
||||
self.table.retranslate()
|
||||
self.filter_edit.setPlaceholderText(tr("monitoring.filter_placeholder"))
|
||||
self.detail_panel.retranslate()
|
||||
@@ -42,4 +46,5 @@ class ActionLogsTab(QWidget):
|
||||
self.title_refresh_btn.setText(tr("monitoring.refresh"))
|
||||
|
||||
def _start_ai_filter(self, search: QLineEdit, ai_btn: QPushButton) -> None:
|
||||
"""Nhờ AI dịch câu tìm kiếm tự nhiên thành từ khoá lọc."""
|
||||
start_ai_filter(self._ctx, search, ai_btn, self._ai_state)
|
||||
|
||||
@@ -35,6 +35,11 @@ class AgentEditDialog(QDialog):
|
||||
def __init__(self, parent=None, ctx: Optional[AppContext] = None,
|
||||
agent: Optional[admin_agents.AdminAgent] = None,
|
||||
default_model_hint: str = ""):
|
||||
"""Form thêm/sửa một agent quản trị.
|
||||
|
||||
Danh sách model của từng provider được nạp nền và nhớ lại, để đổi qua đổi
|
||||
lại giữa các provider không phải gọi mạng lần nữa.
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self.ctx = ctx
|
||||
self._existing = agent
|
||||
@@ -96,15 +101,18 @@ class AgentEditDialog(QDialog):
|
||||
form.addRow(buttons)
|
||||
|
||||
def _load_live_models(self) -> None:
|
||||
"""Nạp danh sách model đang sống của mọi provider, ở luồng nền."""
|
||||
if self.ctx is None:
|
||||
return
|
||||
self.load_models_btn.setEnabled(False)
|
||||
ctx = self.ctx
|
||||
|
||||
def job(_worker: AgentWorker):
|
||||
"""Chạy nền: hỏi từng provider danh sách model."""
|
||||
return preview_ai.fetch_live_models(ctx)
|
||||
|
||||
def done(result: dict) -> None:
|
||||
"""Ghi nhớ danh sách model rồi nạp vào ô chọn theo provider đang chọn."""
|
||||
self.load_models_btn.setEnabled(True)
|
||||
self._live_models = result or {}
|
||||
self._refresh_model_combo()
|
||||
@@ -113,6 +121,7 @@ class AgentEditDialog(QDialog):
|
||||
tr("agents_admin.load_models_empty"))
|
||||
|
||||
def failed(err: str) -> None:
|
||||
"""Nạp model lỗi: hiện cảnh báo và mở khoá lại nút."""
|
||||
self.load_models_btn.setEnabled(True)
|
||||
QMessageBox.warning(self, tr("agents_admin.add_title"), err)
|
||||
|
||||
@@ -123,6 +132,7 @@ class AgentEditDialog(QDialog):
|
||||
w.start()
|
||||
|
||||
def _refresh_model_combo(self) -> None:
|
||||
"""Nạp lại ô chọn model theo provider đang chọn, giữ nguyên thứ đang gõ."""
|
||||
provider_key = self.provider_combo.currentData()
|
||||
current_text = self.model_combo.currentText().strip()
|
||||
models = self._live_models.get(provider_key, []) if provider_key else []
|
||||
@@ -133,6 +143,7 @@ class AgentEditDialog(QDialog):
|
||||
self.model_combo.blockSignals(False)
|
||||
|
||||
def result_fields(self) -> Dict[str, str]:
|
||||
"""Các trường agent lấy từ form, để chỗ gọi dựng bản ghi."""
|
||||
return {
|
||||
"name": self.name_edit.text().strip(),
|
||||
"task_kind": self.kind_combo.currentData(),
|
||||
|
||||
@@ -18,8 +18,10 @@ from ..shared.formatters import agent_avatar_icon
|
||||
|
||||
|
||||
class AgentStatusTab(QWidget):
|
||||
"""Tab "Trạng thái Agent": đang có bao nhiêu lượt chat, task và luồng chạy."""
|
||||
def __init__(self, ctx, on_refresh_all: Callable[[], None],
|
||||
cowork=None, structure=None, task_scheduler=None):
|
||||
"""Tab Tình trạng agent. Các widget truyền vào có thể là None khi dựng tab lẻ."""
|
||||
super().__init__()
|
||||
self._ctx = ctx
|
||||
self._cowork = cowork
|
||||
@@ -45,6 +47,7 @@ class AgentStatusTab(QWidget):
|
||||
self.title_refresh_btn = parts["title_refresh_btn"]
|
||||
|
||||
def retranslate(self) -> None:
|
||||
"""Áp lại chữ theo ngôn ngữ đang chọn."""
|
||||
self.title_lbl.setText(tr(self.title_key))
|
||||
self.title_refresh_btn.setText(tr("monitoring.refresh"))
|
||||
self.table.setHorizontalHeaderLabels([
|
||||
@@ -52,6 +55,7 @@ class AgentStatusTab(QWidget):
|
||||
])
|
||||
|
||||
def refresh(self) -> None:
|
||||
"""Đếm lại số việc đang chạy ở từng phân hệ và cập nhật bảng."""
|
||||
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)
|
||||
|
||||
@@ -12,88 +12,45 @@ The header's "Kiểm tra tất cả" icon probes each agent's effective provider
|
||||
(``check_agent``) and shows the result as the Trạng thái pill (OK / error /
|
||||
checking…) — separate from the per-row Kích hoạt switch, which only toggles
|
||||
the config flag.
|
||||
|
||||
Màu, huy hiệu và avatar theo loại việc nằm ở
|
||||
``../shared/agent_kind_visuals.py``. File này từng giữ bản sao riêng của ba
|
||||
hàm dùng chung — ``_initials``, ``_fmt_updated``, ``_kind_avatar_icon`` —
|
||||
giống hệt ``shared/formatters.py`` đến từng dòng; nay dùng thẳng bản chung
|
||||
để định dạng thời gian và avatar không lệch nhau giữa các bảng Giám sát.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .agent_edit_dialog import AgentEditDialog
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional
|
||||
from typing import Dict, List
|
||||
|
||||
from PySide6.QtCore import QSize, Qt
|
||||
from PySide6.QtGui import QColor, QFont, QIcon, QPainter, QPixmap
|
||||
from PySide6.QtWidgets import (
|
||||
QCheckBox, QComboBox, QDialog, QDialogButtonBox, QFormLayout, QHBoxLayout,
|
||||
QHeaderView, QLabel, QLineEdit, QMessageBox, QPlainTextEdit, QPushButton,
|
||||
QHBoxLayout, QHeaderView, QLabel, QMessageBox, QPushButton,
|
||||
QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from ....config import PROVIDER_LABELS
|
||||
from ....core import admin_agents, preview_ai
|
||||
from ....core import admin_agents
|
||||
from ....core.worker import AgentWorker
|
||||
from ....i18n import on_language_changed, tr
|
||||
from ....state import AppContext
|
||||
from ....ui.icons import icon
|
||||
from ....ui.widgets import ToggleSwitch, badge_pill_widget
|
||||
from .agent_edit_dialog import AgentEditDialog
|
||||
from ..shared.agent_kind_visuals import KIND_BADGE, STATUS_BADGE, kind_avatar_icon
|
||||
from ..shared.formatters import fmt_event_time
|
||||
|
||||
_PROVIDER_DEFAULT = "" # "" = each machine's own active provider (unchanged default)
|
||||
|
||||
# Identity colour (avatar circle) + badge tone per task_kind — same "fixed
|
||||
# colour regardless of theme" convention as monitoring_tab.py's per-agent
|
||||
# avatars, plus a badge object name (theme.py) for the Vai trò pill. Seven
|
||||
# kinds, seven distinct tones — no two kinds share a badge colour.
|
||||
_KIND_COLOUR = {
|
||||
"search": "#8A8886", "monitor": "#FFB900", "cowork": "#0078D4",
|
||||
"graphrag": "#8764B8", "schedule": "#107C10", "security": "#D13438",
|
||||
"help": "#E3008C",
|
||||
}
|
||||
_KIND_BADGE = {
|
||||
"search": "badgeNeutral", "monitor": "badgeWarn", "cowork": "badge",
|
||||
"graphrag": "badgePurple", "schedule": "badgeSuccess", "security": "badgeDanger",
|
||||
"help": "badgePink",
|
||||
}
|
||||
_STATUS_BADGE = {
|
||||
"unchecked": "badgeNeutral", "checking": "badgeWarn",
|
||||
"ok": "badgeSuccess", "bad": "badgeDanger",
|
||||
}
|
||||
|
||||
|
||||
def _initials(name: str) -> str:
|
||||
return "".join(w[0] for w in name.split() if w)[:2].upper()
|
||||
|
||||
|
||||
def _fmt_updated(ts: str) -> str:
|
||||
""""dd/MM hh:mm" — same Cập nhật/Thời gian format as Monitoring's
|
||||
Bảo mật/MCP/Hành động tables (``_fmt_event_time`` in monitoring_tab.py)."""
|
||||
try:
|
||||
dt = datetime.fromisoformat(ts)
|
||||
except (TypeError, ValueError):
|
||||
return ts
|
||||
return dt.strftime("%d/%m %H:%M")
|
||||
|
||||
|
||||
def _kind_avatar_icon(kind: str, name: str, size: int = 20) -> QIcon:
|
||||
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, _initials(name))
|
||||
p.end()
|
||||
return QIcon(pm)
|
||||
|
||||
|
||||
|
||||
|
||||
class AgentsAdminTab(QWidget):
|
||||
"""Tab "Quản trị Agent": cấu hình prompt, provider và model cho từng agent chuyên trách, kèm nút kiểm tra tình trạng hoạt động."""
|
||||
def __init__(self, ctx: AppContext):
|
||||
"""Dựng bảng agent quản trị.
|
||||
|
||||
Kết quả kiểm tra lần gần nhất được giữ trong ``_status`` và sống qua các lần
|
||||
vẽ lại — người dùng bấm Kiểm tra một lần rồi lọc/sắp xếp vẫn thấy kết quả.
|
||||
"""
|
||||
super().__init__()
|
||||
self.ctx = ctx
|
||||
# Last operational-health result per agent_id → (ok, message). Populated
|
||||
@@ -178,14 +135,17 @@ class AgentsAdminTab(QWidget):
|
||||
|
||||
# ---- storage ---------------------------------------------------------
|
||||
def _dir(self):
|
||||
"""Thư mục chứa agent quản trị (dùng thư mục chia sẻ nếu có cấu hình)."""
|
||||
return admin_agents.agents_admin_dir(self.ctx.config.shared_dir)
|
||||
|
||||
def _default_model_hint(self) -> str:
|
||||
"""Model mặc định gợi ý cho agent mới — lấy từ provider đang chọn."""
|
||||
conf = self.ctx.config.provider_conf(self.ctx.config.active_provider)
|
||||
return conf.get("model", "")
|
||||
|
||||
# ---- CRUD -------------------------------------------------------------
|
||||
def _add(self) -> None:
|
||||
"""Thêm một agent quản trị mới qua hộp thoại."""
|
||||
dlg = AgentEditDialog(self, ctx=self.ctx, default_model_hint=self._default_model_hint())
|
||||
if not dlg.exec():
|
||||
return
|
||||
@@ -201,6 +161,7 @@ class AgentsAdminTab(QWidget):
|
||||
self.refresh()
|
||||
|
||||
def _edit_agent(self, agent_id: str) -> None:
|
||||
"""Sửa một agent; id không còn tồn tại thì bỏ qua."""
|
||||
agent = admin_agents.load_agent(agent_id, self._dir())
|
||||
if agent is None:
|
||||
return
|
||||
@@ -224,6 +185,7 @@ class AgentsAdminTab(QWidget):
|
||||
self.refresh()
|
||||
|
||||
def _delete_agent(self, agent_id: str) -> None:
|
||||
"""Xoá một agent sau khi hỏi xác nhận."""
|
||||
agent = admin_agents.load_agent(agent_id, self._dir())
|
||||
if agent is None:
|
||||
return
|
||||
@@ -235,6 +197,7 @@ class AgentsAdminTab(QWidget):
|
||||
self.refresh()
|
||||
|
||||
def _set_enabled(self, agent_id: str, enabled: bool) -> None:
|
||||
"""Bật/tắt một agent; trạng thái không đổi thì không ghi đĩa."""
|
||||
agent = admin_agents.load_agent(agent_id, self._dir())
|
||||
if agent is None or agent.enabled == enabled:
|
||||
return
|
||||
@@ -248,7 +211,7 @@ class AgentsAdminTab(QWidget):
|
||||
# ---- view --------------------------------------------------------------
|
||||
def _status_cell(self, agent_id: str) -> tuple:
|
||||
"""(state_key, display_text, tooltip) for the Trạng thái pill —
|
||||
state_key indexes _STATUS_BADGE for the badge's colour tone."""
|
||||
state_key indexes STATUS_BADGE for the badge's colour tone."""
|
||||
res = self._status.get(agent_id)
|
||||
if res is None:
|
||||
return ("unchecked", tr("agents_admin.status_unchecked").lstrip("— ").strip(),
|
||||
@@ -259,6 +222,7 @@ class AgentsAdminTab(QWidget):
|
||||
return ("ok" if ok else "bad"), (tr("agents_admin.status_ok") if ok else tr("agents_admin.status_bad")), msg
|
||||
|
||||
def _toggle_widget(self, agent_id: str, enabled: bool) -> QWidget:
|
||||
"""Dựng ô công tắc bật/tắt cho một dòng trong bảng."""
|
||||
container = QWidget()
|
||||
container.setStyleSheet("background: transparent;")
|
||||
lay = QHBoxLayout(container)
|
||||
@@ -271,6 +235,7 @@ class AgentsAdminTab(QWidget):
|
||||
return container
|
||||
|
||||
def _row_actions_widget(self, agent_id: str) -> QWidget:
|
||||
"""Dựng cụm nút sửa/xoá cho một dòng trong bảng."""
|
||||
container = QWidget()
|
||||
container.setStyleSheet("background: transparent;")
|
||||
lay = QHBoxLayout(container)
|
||||
@@ -295,6 +260,7 @@ class AgentsAdminTab(QWidget):
|
||||
def refresh(self) -> None:
|
||||
# Make sure the built-in in-app Help assistant exists, so the Admin can
|
||||
# manage its provider/model here (the floating Help widget uses it).
|
||||
"""Vẽ lại bảng agent."""
|
||||
admin_agents.ensure_help_agent(self._dir())
|
||||
agents = admin_agents.list_agents(self._dir())
|
||||
self.table.setRowCount(len(agents))
|
||||
@@ -307,10 +273,10 @@ class AgentsAdminTab(QWidget):
|
||||
model = tr("agents_admin.default_model", model=default_model or "—")
|
||||
|
||||
name_item = QTableWidgetItem(agent.name)
|
||||
name_item.setIcon(_kind_avatar_icon(agent.task_kind, agent.name))
|
||||
name_item.setIcon(kind_avatar_icon(agent.task_kind, agent.name))
|
||||
self.table.setItem(row, 0, name_item)
|
||||
|
||||
kind_tone = _KIND_BADGE.get(agent.task_kind, "badge")
|
||||
kind_tone = KIND_BADGE.get(agent.task_kind, "badge")
|
||||
self.table.setCellWidget(
|
||||
row, 1, badge_pill_widget(tr(f"agents_admin.kind.{agent.task_kind}"), kind_tone))
|
||||
|
||||
@@ -318,12 +284,12 @@ class AgentsAdminTab(QWidget):
|
||||
self.table.setCellWidget(row, 3, self._toggle_widget(agent.agent_id, agent.enabled))
|
||||
|
||||
state_key, status_text, status_tip = self._status_cell(agent.agent_id)
|
||||
status_widget = badge_pill_widget(status_text, _STATUS_BADGE[state_key])
|
||||
status_widget = badge_pill_widget(status_text, STATUS_BADGE[state_key])
|
||||
if status_tip:
|
||||
status_widget.setToolTip(status_tip)
|
||||
self.table.setCellWidget(row, 4, status_widget)
|
||||
|
||||
self.table.setItem(row, 5, QTableWidgetItem(_fmt_updated(agent.updated)))
|
||||
self.table.setItem(row, 5, QTableWidgetItem(fmt_event_time(agent.updated)))
|
||||
self.table.setCellWidget(row, 6, self._row_actions_widget(agent.agent_id))
|
||||
|
||||
# ResizeToContents doesn't measure a cell WIDGET's real width (only
|
||||
@@ -349,14 +315,17 @@ class AgentsAdminTab(QWidget):
|
||||
ctx = self.ctx
|
||||
|
||||
def job(_worker: AgentWorker) -> dict:
|
||||
"""Chạy nền: gọi thử từng agent để kiểm tra tình trạng hoạt động."""
|
||||
return {a.agent_id: admin_agents.check_agent(ctx, a) for a in agents}
|
||||
|
||||
def done(result: dict) -> None:
|
||||
"""Ghi kết quả kiểm tra vào bảng trạng thái rồi vẽ lại."""
|
||||
self.check_btn.setEnabled(True)
|
||||
self._status.update(result or {})
|
||||
self.refresh()
|
||||
|
||||
def failed(err: str) -> None:
|
||||
"""Kiểm tra lỗi: đánh dấu mọi agent là hỏng kèm lý do (cắt ở 200 ký tự)."""
|
||||
self.check_btn.setEnabled(True)
|
||||
for a in agents:
|
||||
self._status[a.agent_id] = (False, err[:200])
|
||||
@@ -369,6 +338,7 @@ class AgentsAdminTab(QWidget):
|
||||
w.start()
|
||||
|
||||
def _retranslate(self) -> None:
|
||||
"""Áp lại chữ theo ngôn ngữ đang chọn cho tiêu đề, gợi ý và tên cột."""
|
||||
self._title_lbl.setText(tr("agents_admin.page_title"))
|
||||
self._hint.setText(tr("agents_admin.hint"))
|
||||
self.table.setHorizontalHeaderLabels([
|
||||
|
||||
@@ -15,7 +15,9 @@ from ..shared.filter_scaffold import build_filter_scaffold
|
||||
|
||||
|
||||
class McpTab(QWidget):
|
||||
"""Tab "Lịch sử MCP": bảng các lượt gọi tool MCP kèm độ trễ và kết quả."""
|
||||
def __init__(self, ctx, on_refresh_all: Callable[[], None]):
|
||||
"""Tab Lịch sử MCP: bảng sự kiện kèm bộ lọc dùng chung."""
|
||||
super().__init__()
|
||||
self._ctx = ctx
|
||||
self._ai_state: dict = {}
|
||||
@@ -32,9 +34,11 @@ class McpTab(QWidget):
|
||||
self.detail_panel = parts["detail_panel"]
|
||||
|
||||
def set_events(self, events: List[dict]) -> None:
|
||||
"""Đổ danh sách sự kiện vào bảng."""
|
||||
self.table.set_events(events)
|
||||
|
||||
def retranslate(self) -> None:
|
||||
"""Áp lại chữ theo ngôn ngữ đang chọn."""
|
||||
self.table.retranslate()
|
||||
self.filter_edit.setPlaceholderText(tr("monitoring.filter_placeholder"))
|
||||
self.detail_panel.retranslate()
|
||||
@@ -42,4 +46,5 @@ class McpTab(QWidget):
|
||||
self.title_refresh_btn.setText(tr("monitoring.refresh"))
|
||||
|
||||
def _start_ai_filter(self, search: QLineEdit, ai_btn: QPushButton) -> None:
|
||||
"""Nhờ AI dịch câu tìm kiếm tự nhiên thành từ khoá lọc."""
|
||||
start_ai_filter(self._ctx, search, ai_btn, self._ai_state)
|
||||
|
||||
@@ -25,10 +25,21 @@ from .sandbox_tab import SandboxDetailsCard
|
||||
|
||||
|
||||
class OverviewTab(QWidget):
|
||||
"""Tab "Tổng quan" của màn Giám sát: tài nguyên máy, thẻ token/chi phí, ngân
|
||||
sách, trạng thái sandbox, hoạt động gần đây và trích nhật ký kiểm toán.
|
||||
|
||||
Một cột dọc duy nhất, mỗi phần là một mục có tiêu đề — bố cục này là chủ ý
|
||||
để mắt quét từ trên xuống thay vì phải nhảy giữa hai cột.
|
||||
"""
|
||||
def __init__(self, ctx, *, on_status_message: Callable[[str], None],
|
||||
on_settings_changed: Callable[[], None],
|
||||
on_view_all_action_logs: Callable[[], None],
|
||||
action_logs_tab_visible: bool):
|
||||
"""Tab Tổng quan.
|
||||
|
||||
Mọi việc ra ngoài tab (báo trạng thái, mở màn khác) đều đi qua callback
|
||||
truyền vào, nên tab này không cần biết cửa sổ chính.
|
||||
"""
|
||||
super().__init__()
|
||||
self.ctx = ctx
|
||||
self._on_status_message = on_status_message
|
||||
@@ -64,6 +75,7 @@ class OverviewTab(QWidget):
|
||||
|
||||
# ---- Token Usage & Cost ------------------------------------------------
|
||||
def _build_usage_section(self, root: QVBoxLayout) -> None:
|
||||
"""Dựng mục thẻ token/chi phí kèm thẻ ngân sách."""
|
||||
self.usage_group = QGroupBox()
|
||||
self.usage_group.setObjectName("monSection")
|
||||
usage_lay = QGridLayout(self.usage_group)
|
||||
@@ -88,6 +100,7 @@ class OverviewTab(QWidget):
|
||||
|
||||
# ---- Recent Activity ----------------------------------------------------
|
||||
def _build_activity_section(self) -> None:
|
||||
"""Dựng mục "Hoạt động gần đây" (thêm vào cột ở dưới, cạnh nhật ký kiểm toán)."""
|
||||
self.activity_group = QGroupBox()
|
||||
self.activity_group.setObjectName("monSection")
|
||||
act_lay = QVBoxLayout(self.activity_group)
|
||||
@@ -98,12 +111,16 @@ class OverviewTab(QWidget):
|
||||
|
||||
# ---- Resource Usage -------------------------------------------------------
|
||||
def _build_resource_section(self, root: QVBoxLayout) -> None:
|
||||
"""Dựng mục tài nguyên: CPU, bộ nhớ, đĩa, mạng và trạng thái sandbox."""
|
||||
self.resource_group = QGroupBox()
|
||||
self.resource_group.setObjectName("monSection")
|
||||
res_lay = QHBoxLayout(self.resource_group)
|
||||
res_lay.setSpacing(6)
|
||||
|
||||
def _pair():
|
||||
"""Thêm một cặp nhãn–giá trị vào hàng tài nguyên, tự chèn dấu phân cách giữa
|
||||
các cặp.
|
||||
"""
|
||||
if not self._res_first:
|
||||
sep = QLabel(chr(0xB7))
|
||||
sep.setObjectName("hint")
|
||||
@@ -117,6 +134,7 @@ class OverviewTab(QWidget):
|
||||
return lbl, val
|
||||
|
||||
def _bar_row():
|
||||
"""Thêm một cặp nhãn–giá trị kèm thanh tiến độ (CPU, bộ nhớ)."""
|
||||
lbl, val = _pair()
|
||||
bar = QProgressBar()
|
||||
bar.setVisible(False)
|
||||
@@ -132,6 +150,7 @@ class OverviewTab(QWidget):
|
||||
|
||||
# ---- Audit Log preview --------------------------------------------------
|
||||
def _build_audit_section(self, root: QVBoxLayout, action_logs_tab_visible: bool) -> None:
|
||||
"""Dựng mục trích nhật ký kiểm toán; ẩn hẳn khi tab Nhật ký hành động bị tắt."""
|
||||
self.audit_group = QGroupBox()
|
||||
self.audit_group.setObjectName("monSection")
|
||||
audit_lay = QVBoxLayout(self.audit_group)
|
||||
@@ -156,6 +175,10 @@ class OverviewTab(QWidget):
|
||||
self._refresh_budget()
|
||||
|
||||
def _refresh_budget(self) -> None:
|
||||
"""Cập nhật thẻ ngân sách: còn lại / tổng, phần trăm đã dùng, cảnh báo khi vượt 85%.
|
||||
|
||||
Không ghi đè ô nhập khi người dùng đang gõ trong đó.
|
||||
"""
|
||||
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)
|
||||
@@ -175,6 +198,12 @@ class OverviewTab(QWidget):
|
||||
|
||||
# ---- resource usage --------------------------------------------------------
|
||||
def _refresh_resource_usage(self) -> None:
|
||||
"""Đọc CPU/RAM/đĩa/mạng của CHÍNH tiến trình app.
|
||||
|
||||
Đĩa và mạng là tốc độ tức thời, tính bằng hiệu hai lần lấy mẫu chia cho
|
||||
khoảng thời gian giữa chúng — nên lần đo đầu tiên luôn hiện "—". Không có
|
||||
``psutil`` thì cả mục chuyển sang "—" chứ không làm vỡ màn hình.
|
||||
"""
|
||||
try:
|
||||
import psutil
|
||||
except ImportError:
|
||||
@@ -233,6 +262,7 @@ class OverviewTab(QWidget):
|
||||
self.network_val.setText(na)
|
||||
|
||||
def _set_resource_na(self) -> None:
|
||||
"""Đặt mọi chỉ số tài nguyên về "—" (khi thiếu ``psutil``)."""
|
||||
na = tr("monitoring.na")
|
||||
self.cpu_bar.setValue(0)
|
||||
self.cpu_val.setText(na)
|
||||
@@ -243,6 +273,7 @@ class OverviewTab(QWidget):
|
||||
|
||||
# ---- usage cards -----------------------------------------------------------
|
||||
def _activity_line(self, event: dict) -> str:
|
||||
"""Một dòng hoạt động: dấu ✓/!/✗ theo kết quả, tên việc, và thời gian tương đối."""
|
||||
ok = event.get("ok", True)
|
||||
if ok:
|
||||
mark = f"<span style='color:{DOT_GREEN};'>✓</span>"
|
||||
@@ -257,6 +288,11 @@ class OverviewTab(QWidget):
|
||||
return f"{mark} {name}{suffix}"
|
||||
|
||||
def _refresh_usage_cards(self) -> None:
|
||||
"""Cập nhật các thẻ token và chi phí.
|
||||
|
||||
Đồng bộ bảng đơn giá trước rồi mới tính tiền, để con số chi phí luôn khớp
|
||||
với bảng giá người dùng vừa sửa.
|
||||
"""
|
||||
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 {})}
|
||||
@@ -297,6 +333,7 @@ class OverviewTab(QWidget):
|
||||
self.audit_lbl.setText(tr("monitoring.overview_no_activity"))
|
||||
|
||||
def retranslate(self) -> None:
|
||||
"""Áp lại chữ theo ngôn ngữ đang chọn cho mọi tiêu đề mục và nhãn."""
|
||||
self.usage_group.setTitle(tr("monitoring.overview_usage_title").upper().replace("&", "&&"))
|
||||
self.budget_card.apply_btn.setToolTip(tr("usage.budget_apply_tooltip"))
|
||||
self.budget_card.budget_spin.setToolTip(tr("usage.budget_spin_tooltip"))
|
||||
|
||||
@@ -19,7 +19,13 @@ from ....ui.icons import icon
|
||||
|
||||
|
||||
class PricingPanel(QGroupBox):
|
||||
"""Bảng đơn giá model trong màn Giám sát: nhập, xuất, tự dò và sửa tay.
|
||||
|
||||
Đơn giá ở đây là thứ Dashboard dùng để quy token ra tiền, nên sửa ở đây là
|
||||
mọi con số chi phí trong app đổi theo.
|
||||
"""
|
||||
def __init__(self, ctx, on_status_message: Callable[[str], None]):
|
||||
"""Bảng đơn giá theo model, sửa được ngay tại chỗ."""
|
||||
super().__init__()
|
||||
self.ctx = ctx
|
||||
self._on_status_message = on_status_message
|
||||
@@ -68,6 +74,7 @@ class PricingPanel(QGroupBox):
|
||||
self._reload_table()
|
||||
|
||||
def retranslate(self) -> None:
|
||||
"""Áp lại chữ theo ngôn ngữ đang chọn cho tiêu đề, nút và tên cột."""
|
||||
self.setTitle(tr("monitoring.pricing_title").upper())
|
||||
self.ccy_lbl.setText(tr("monitoring.pricing_currency"))
|
||||
self.import_btn.setText(tr("monitoring.pricing_import"))
|
||||
@@ -81,6 +88,7 @@ class PricingPanel(QGroupBox):
|
||||
tr("monitoring.pricing_col_output")])
|
||||
|
||||
def _reload_table(self, *_a) -> None:
|
||||
"""Nạp lại toàn bộ bảng đơn giá từ cấu hình."""
|
||||
from ....core import model_pricing as mp
|
||||
to_ccy = self.ccy.currentData() or "USD"
|
||||
entries = mp.list_entries(self.ctx.config)
|
||||
@@ -95,6 +103,7 @@ class PricingPanel(QGroupBox):
|
||||
self.table.setItem(r, c, QTableWidgetItem(str(v)))
|
||||
|
||||
def _import_pricing(self) -> None:
|
||||
"""Nhập đơn giá từ file Excel/CSV, gộp vào bảng hiện có."""
|
||||
from PySide6.QtWidgets import QFileDialog, QMessageBox
|
||||
|
||||
from ....core import model_pricing as mp
|
||||
@@ -117,6 +126,7 @@ class PricingPanel(QGroupBox):
|
||||
self._on_status_message(tr("monitoring.pricing_imported", n=len(imported)))
|
||||
|
||||
def _export_pricing(self) -> None:
|
||||
"""Xuất file mẫu để điền đơn giá rồi nhập ngược lại."""
|
||||
from PySide6.QtWidgets import QFileDialog
|
||||
|
||||
from ....core import model_pricing as mp
|
||||
@@ -128,6 +138,7 @@ class PricingPanel(QGroupBox):
|
||||
self._on_status_message(tr("monitoring.pricing_exported"))
|
||||
|
||||
def _add_pricing_row(self) -> None:
|
||||
"""Thêm một dòng đơn giá trống để người dùng điền tay."""
|
||||
from PySide6.QtWidgets import QInputDialog
|
||||
|
||||
from ....core import model_pricing as mp
|
||||
@@ -143,6 +154,7 @@ class PricingPanel(QGroupBox):
|
||||
self._reload_table()
|
||||
|
||||
def _autolink_pricing(self) -> None:
|
||||
"""Nhờ AI dò đơn giá cho các model đang dùng mà chưa có giá, chạy ở luồng nền."""
|
||||
from ....core import model_pricing as mp
|
||||
from ....core.worker import AgentWorker
|
||||
if self._worker is not None:
|
||||
@@ -152,9 +164,11 @@ class PricingPanel(QGroupBox):
|
||||
ccy = self.ccy.currentData() or "USD"
|
||||
|
||||
def job(_w):
|
||||
"""Chạy nền: nhờ AI dò đơn giá cho các model đang dùng mà chưa có giá."""
|
||||
return {"entries": mp.auto_link(ctx, default_ccy=ccy)}
|
||||
|
||||
def done(r):
|
||||
"""Đổ đơn giá vừa dò được vào bảng và lưu lại."""
|
||||
self._worker = None
|
||||
self.link_btn.setEnabled(True)
|
||||
self.ctx.save()
|
||||
@@ -162,6 +176,7 @@ class PricingPanel(QGroupBox):
|
||||
self._on_status_message(tr("monitoring.pricing_linked", n=len(r.get("entries", []))))
|
||||
|
||||
def failed(_e):
|
||||
"""Dò giá lỗi: chỉ mở khoá lại nút, giữ nguyên bảng đang có."""
|
||||
self._worker = None
|
||||
self.link_btn.setEnabled(True)
|
||||
|
||||
@@ -172,6 +187,7 @@ class PricingPanel(QGroupBox):
|
||||
w.start()
|
||||
|
||||
def _delete_pricing_row(self) -> None:
|
||||
"""Xoá dòng đơn giá đang chọn."""
|
||||
from ....core import model_pricing as mp
|
||||
row = self.table.currentRow()
|
||||
entries = mp.list_entries(self.ctx.config)
|
||||
|
||||
@@ -26,7 +26,11 @@ from .security_settings_tab import PermissionsCard
|
||||
|
||||
|
||||
class SandboxDetailsCard(QGroupBox):
|
||||
"""Thẻ "Trạng thái sandbox": backend cách ly đang dùng, giới hạn tài nguyên và
|
||||
trạng thái chặn mạng.
|
||||
"""
|
||||
def __init__(self, ctx, on_settings_changed: Callable[[], None]):
|
||||
"""Thẻ thông tin sandbox: cơ chế cách ly đang dùng và các giới hạn của nó."""
|
||||
super().__init__()
|
||||
self._ctx = ctx
|
||||
self._on_settings_changed = on_settings_changed
|
||||
@@ -77,6 +81,7 @@ class SandboxDetailsCard(QGroupBox):
|
||||
detail_lay.addWidget(self.permissions_card)
|
||||
|
||||
def _open_settings(self) -> None:
|
||||
"""Mở hộp thoại Cài đặt tới nhóm an toàn, đóng xong thì làm mới thẻ."""
|
||||
open_settings_and_notify(self._ctx, self, self._on_settings_changed)
|
||||
|
||||
def _sync_more_label(self, *_a) -> None:
|
||||
@@ -85,6 +90,7 @@ class SandboxDetailsCard(QGroupBox):
|
||||
self.more_btn.setText(("▾ " if open_ else "▸ ") + tr("monitoring.overview_sbx_detail"))
|
||||
|
||||
def retranslate(self) -> None:
|
||||
"""Áp lại chữ theo ngôn ngữ đang chọn."""
|
||||
self.setTitle(tr("monitoring.overview_sandbox_details_title").upper().replace("&", "&&"))
|
||||
self.id_lbl.setText(tr("monitoring.overview_sandbox_id"))
|
||||
self.status_lbl.setText(tr("monitoring.overview_status"))
|
||||
@@ -96,6 +102,7 @@ class SandboxDetailsCard(QGroupBox):
|
||||
self._sync_more_label()
|
||||
|
||||
def refresh(self) -> None:
|
||||
"""Đọc lại cấu hình an toàn và cập nhật các dòng trạng thái."""
|
||||
sec = self._ctx.config.agent_security
|
||||
net_blocked = bool(sec.get("block_network"))
|
||||
|
||||
|
||||
@@ -15,7 +15,13 @@ from ..shared.filter_scaffold import build_filter_scaffold
|
||||
|
||||
|
||||
class SecurityEventsTab(QWidget):
|
||||
"""Tab "Sự kiện bảo mật": các lượt bị chặn hoặc phải xin phê duyệt."""
|
||||
def __init__(self, ctx, on_refresh_all: Callable[[], None]):
|
||||
"""Tab Sự kiện bảo mật.
|
||||
|
||||
Bỏ cột Kết quả (mọi dòng đều là bị chặn) và thay bằng cột Hành động có tô
|
||||
màu theo mức nghiêm trọng.
|
||||
"""
|
||||
super().__init__()
|
||||
self._ctx = ctx
|
||||
self._ai_state: dict = {}
|
||||
@@ -34,9 +40,11 @@ class SecurityEventsTab(QWidget):
|
||||
self.detail_panel = parts["detail_panel"]
|
||||
|
||||
def set_events(self, events: List[dict]) -> None:
|
||||
"""Đổ danh sách sự kiện vào bảng."""
|
||||
self.table.set_events(events)
|
||||
|
||||
def retranslate(self) -> None:
|
||||
"""Áp lại chữ theo ngôn ngữ đang chọn."""
|
||||
self.table.retranslate()
|
||||
self.filter_edit.setPlaceholderText(tr("monitoring.filter_placeholder"))
|
||||
self.detail_panel.retranslate()
|
||||
@@ -44,4 +52,5 @@ class SecurityEventsTab(QWidget):
|
||||
self.title_refresh_btn.setText(tr("monitoring.refresh"))
|
||||
|
||||
def _start_ai_filter(self, search: QLineEdit, ai_btn: QPushButton) -> None:
|
||||
"""Nhờ AI dịch câu tìm kiếm tự nhiên thành từ khoá lọc."""
|
||||
start_ai_filter(self._ctx, search, ai_btn, self._ai_state)
|
||||
|
||||
@@ -20,7 +20,9 @@ from ..shared.open_settings import open_settings_and_notify
|
||||
|
||||
|
||||
class PermissionsCard(QGroupBox):
|
||||
"""Thẻ "Quyền": agent đang được phép làm gì với tệp, mạng và lệnh shell."""
|
||||
def __init__(self, ctx, on_settings_changed: Callable[[], None]):
|
||||
"""Thẻ thiết lập quyền: agent được đụng tới gì mà không phải hỏi."""
|
||||
super().__init__()
|
||||
self._ctx = ctx
|
||||
self._on_settings_changed = on_settings_changed
|
||||
@@ -38,9 +40,11 @@ class PermissionsCard(QGroupBox):
|
||||
perm_lay.addWidget(self.edit_btn, 0, Qt.AlignLeft)
|
||||
|
||||
def _open_settings(self) -> None:
|
||||
"""Mở hộp thoại Cài đặt tới nhóm an toàn, đóng xong thì làm mới thẻ."""
|
||||
open_settings_and_notify(self._ctx, self, self._on_settings_changed)
|
||||
|
||||
def retranslate(self) -> None:
|
||||
"""Áp lại chữ theo ngôn ngữ đang chọn."""
|
||||
self.setTitle(tr("monitoring.overview_permissions_title").upper())
|
||||
self.fs_lbl.setText(tr("monitoring.overview_perm_fs"))
|
||||
self.fs_val.setText(tr("monitoring.overview_perm_fs_value"))
|
||||
@@ -52,6 +56,7 @@ class PermissionsCard(QGroupBox):
|
||||
self.edit_btn.setText(tr("monitoring.overview_edit"))
|
||||
|
||||
def refresh(self) -> None:
|
||||
"""Đọc lại cấu hình an toàn và cập nhật các dòng quyền."""
|
||||
net_blocked = bool(self._ctx.config.agent_security.get("block_network"))
|
||||
self.network_val.setText(
|
||||
tr("monitoring.overview_perm_network_blocked") if net_blocked
|
||||
|
||||
@@ -43,6 +43,7 @@ _TOOL_ICON_NAME = {
|
||||
|
||||
|
||||
def _tool_icon_pixmap(name: str, size: int = 28) -> QPixmap:
|
||||
"""Vẽ icon tròn cho một thẻ công cụ."""
|
||||
pm = QPixmap(size, size)
|
||||
pm.fill(Qt.transparent)
|
||||
p = QPainter(pm)
|
||||
@@ -59,6 +60,7 @@ def _tool_icon_pixmap(name: str, size: int = 28) -> QPixmap:
|
||||
|
||||
|
||||
def _clear_flow(flow: FlowLayout) -> None:
|
||||
"""Xoá sạch mọi widget trong một ``FlowLayout`` trước khi dựng lại."""
|
||||
while flow.count():
|
||||
item = flow.takeAt(0)
|
||||
w = item.widget()
|
||||
@@ -67,7 +69,13 @@ def _clear_flow(flow: FlowLayout) -> None:
|
||||
|
||||
|
||||
class ToolsAdminTab(QWidget):
|
||||
"""Tab "Công cụ" trong màn Giám sát: bật/tắt từng tool dựng sẵn và tool MCP.
|
||||
|
||||
Tắt một tool ở đây là agent không còn được gọi nó nữa — danh sách tắt lưu
|
||||
trong cấu hình dưới dạng ``tools_disabled``.
|
||||
"""
|
||||
def __init__(self, ctx: AppContext):
|
||||
"""Tab quản trị tool, chia thành các tab con theo nhóm tool."""
|
||||
super().__init__()
|
||||
self.ctx = ctx
|
||||
root = QVBoxLayout(self)
|
||||
@@ -133,12 +141,14 @@ class ToolsAdminTab(QWidget):
|
||||
|
||||
# ---- built-in tools card grid ---------------------------------------------
|
||||
def refresh(self) -> None:
|
||||
"""Vẽ lại toàn bộ lưới thẻ công cụ theo cấu hình mới nhất."""
|
||||
disabled = set(self.ctx.config.tools_disabled)
|
||||
_clear_flow(self._tool_flow)
|
||||
for spec in TOOL_SPECS:
|
||||
self._tool_flow.addWidget(self._tool_card(spec, spec.name not in disabled))
|
||||
|
||||
def _tool_card(self, spec, enabled: bool) -> QWidget:
|
||||
"""Dựng một thẻ công cụ: icon, tên, mô tả và công tắc bật/tắt."""
|
||||
card = QFrame()
|
||||
card.setFrameShape(QFrame.NoFrame)
|
||||
style_card(card)
|
||||
@@ -190,6 +200,7 @@ class ToolsAdminTab(QWidget):
|
||||
return card
|
||||
|
||||
def _toggle_builtin(self, name: str, enabled: bool) -> None:
|
||||
"""Bật/tắt một tool dựng sẵn và ghi ngay vào cấu hình."""
|
||||
self.ctx.config.set_tool_enabled(name, enabled)
|
||||
# For fetch_url, the Enabled toggle also governs the runtime web-access
|
||||
# gate (agent_security.allow_url_fetch) — one control for the capability.
|
||||
@@ -209,17 +220,20 @@ class ToolsAdminTab(QWidget):
|
||||
return
|
||||
|
||||
def job(worker):
|
||||
"""Chạy nền: chẩn đoán kết nối Internet và tình trạng chứng chỉ TLS."""
|
||||
from ....core import tls_trust
|
||||
ok, message = tls_trust.diagnose_internet()
|
||||
return {"ok": ok, "message": message}
|
||||
|
||||
def done(result):
|
||||
"""Hiện kết quả chẩn đoán, tô xanh/đỏ theo kết quả."""
|
||||
ok = result.get("ok")
|
||||
self.test_internet_status.setText(result.get("message", ""))
|
||||
self.test_internet_status.setStyleSheet("color: #090;" if ok else "color: #c00;")
|
||||
self.test_internet_btn.setEnabled(True)
|
||||
|
||||
def failed(e):
|
||||
"""Chẩn đoán ném lỗi: hiện lỗi bằng màu đỏ."""
|
||||
self.test_internet_status.setText(str(e))
|
||||
self.test_internet_status.setStyleSheet("color: #c00;")
|
||||
self.test_internet_btn.setEnabled(True)
|
||||
@@ -235,6 +249,7 @@ class ToolsAdminTab(QWidget):
|
||||
|
||||
# ---- i18n -----------------------------------------------------------------
|
||||
def _retranslate(self) -> None:
|
||||
"""Áp lại chữ theo ngôn ngữ đang chọn rồi vẽ lại lưới thẻ."""
|
||||
self.subtabs.setTabText(0, tr("tools_admin.subtab_tool"))
|
||||
self.subtabs.setTabText(1, tr("tools_admin.subtab_connector"))
|
||||
self._hint.setText(tr("tools_admin.hint"))
|
||||
|
||||
Reference in New Issue
Block a user