Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6c196ca87 | ||
|
|
a20c7a2d69 | ||
|
|
4f88171c64 |
Binary file not shown.
@@ -234,6 +234,12 @@ STRINGS: Dict[str, Dict[str, str]] = {
|
|||||||
"monitoring.ai_filter_btn": {"en": "AI", "ja": "AI", "vi": "AI"},
|
"monitoring.ai_filter_btn": {"en": "AI", "ja": "AI", "vi": "AI"},
|
||||||
"monitoring.page_size_label": {
|
"monitoring.page_size_label": {
|
||||||
"en": "Rows/page:", "ja": "1ページの行数:", "vi": "Số dòng/trang:"},
|
"en": "Rows/page:", "ja": "1ページの行数:", "vi": "Số dòng/trang:"},
|
||||||
|
"monitoring.page_indicator": {
|
||||||
|
"en": "Page {page}/{total}", "ja": "{page}/{total} ページ", "vi": "Trang {page}/{total}"},
|
||||||
|
"monitoring.page_prev": {
|
||||||
|
"en": "Previous page", "ja": "前のページ", "vi": "Trang trước"},
|
||||||
|
"monitoring.page_next": {
|
||||||
|
"en": "Next page", "ja": "次のページ", "vi": "Trang sau"},
|
||||||
"monitoring.pricing_title": {
|
"monitoring.pricing_title": {
|
||||||
"en": "Model pricing (USD / 1M tokens)", "ja": "モデル価格表 (USD / 100万トークン)",
|
"en": "Model pricing (USD / 1M tokens)", "ja": "モデル価格表 (USD / 100万トークン)",
|
||||||
"vi": "Bảng giá model (USD / 1 triệu token)"},
|
"vi": "Bảng giá model (USD / 1 triệu token)"},
|
||||||
|
|||||||
@@ -10,13 +10,13 @@ from typing import List, Optional
|
|||||||
from PySide6.QtCore import QSize, Qt
|
from PySide6.QtCore import QSize, Qt
|
||||||
from PySide6.QtWidgets import QMenu
|
from PySide6.QtWidgets import QMenu
|
||||||
from ...core import co4e
|
from ...core import co4e
|
||||||
from ...core.co4e import STEP_DONE, STEP_RUNNING
|
from ...core.co4e import STEP_RUNNING
|
||||||
from ...i18n import tr
|
from ...i18n import tr
|
||||||
from ...ui.dialog_buttons import ask_text
|
from ...ui.dialog_buttons import ask_text
|
||||||
from ...ui.icons import icon
|
from ...ui.icons import icon
|
||||||
from ...presentation.co4e.co4e_chat_view import _skill_names
|
from ...presentation.co4e.co4e_chat_view import _skill_names
|
||||||
|
|
||||||
_LOCKED_NODE_STATUSES = (STEP_RUNNING, STEP_DONE)
|
_LOCKED_NODE_STATUSES = (STEP_RUNNING,)
|
||||||
|
|
||||||
|
|
||||||
class Co4EWorkflowCrudMixin:
|
class Co4EWorkflowCrudMixin:
|
||||||
@@ -169,8 +169,8 @@ class Co4EWorkflowCrudMixin:
|
|||||||
def _on_node_selected(self, node_id: str) -> None:
|
def _on_node_selected(self, node_id: str) -> None:
|
||||||
"""Chọn một node thì nạp bước đó vào bảng thuộc tính, tự mở bảng nếu đang gập.
|
"""Chọn một node thì nạp bước đó vào bảng thuộc tính, tự mở bảng nếu đang gập.
|
||||||
|
|
||||||
Bước đang chạy hoặc đã chạy xong thì khoá ô nhập liệu ngay khi nạp —
|
Chỉ khoá ô nhập liệu khi bước ĐANG chạy (DF-002) — chạy xong rồi thì
|
||||||
tránh sửa nhầm cấu hình của lần chạy đang xem kết quả.
|
vẫn sửa lại được bình thường.
|
||||||
"""
|
"""
|
||||||
for n in self.canvas.nodes():
|
for n in self.canvas.nodes():
|
||||||
if n.id == node_id:
|
if n.id == node_id:
|
||||||
|
|||||||
@@ -294,11 +294,11 @@ class StepConfigPanel(_StepConfigActionsMixin, QScrollArea):
|
|||||||
def set_locked(self, locked: bool) -> None:
|
def set_locked(self, locked: bool) -> None:
|
||||||
"""Khoá/mở khoá các trường chỉnh sửa theo trạng thái chạy của bước.
|
"""Khoá/mở khoá các trường chỉnh sửa theo trạng thái chạy của bước.
|
||||||
|
|
||||||
Bước đang chạy hoặc đã chạy xong thì khoá lại — tránh sửa nhầm cấu
|
Chỉ khoá khi bước ĐANG chạy — tránh sửa nhầm cấu hình trong lúc chưa
|
||||||
hình trong lúc đang xem kết quả của chính lần chạy đó (sửa xong
|
biết kết quả (DF-002: trước đây còn khoá cả bước đã chạy xong, khiến
|
||||||
không rõ là áp dụng cho lần chạy đã xong hay lần chạy tiếp theo).
|
không sửa lại được sau khi run xong). Nút Chạy/Chạy từ đây/Xoá bước
|
||||||
Nút Chạy/Chạy từ đây/Xoá bước vẫn hoạt động bình thường khi khoá —
|
vẫn hoạt động bình thường khi khoá — chỉ ô nhập liệu bị khoá, không
|
||||||
chỉ ô nhập liệu bị khoá, không phải cả panel.
|
phải cả panel.
|
||||||
"""
|
"""
|
||||||
self._locked = locked
|
self._locked = locked
|
||||||
editable = not locked
|
editable = not locked
|
||||||
|
|||||||
@@ -219,6 +219,12 @@ class StructureGraphView(QWidget):
|
|||||||
"""Dựng sẵn khung đồ thị trước khi người dùng bấm vào, để lần mở đầu không giật."""
|
"""Dựng sẵn khung đồ thị trước khi người dùng bấm vào, để lần mở đầu không giật."""
|
||||||
self.renderer.prewarm()
|
self.renderer.prewarm()
|
||||||
|
|
||||||
|
def refresh_project_list(self) -> None:
|
||||||
|
"""Project khác vừa được tạo/sửa/xoá/đổi tên — làm mới danh sách trong
|
||||||
|
bộ chọn project của renderer (bộ chọn KHÔNG tự nạp lại khi project
|
||||||
|
thay đổi ở màn khác, chỉ khi ``set_project`` được gọi)."""
|
||||||
|
self.renderer._refresh_project_combo()
|
||||||
|
|
||||||
def hideEvent(self, e): # noqa: N802
|
def hideEvent(self, e): # noqa: N802
|
||||||
# Leaving the GraphRAG tab → drop the temporary extracted info.
|
# Leaving the GraphRAG tab → drop the temporary extracted info.
|
||||||
"""Rời màn GraphRAG thì xoá phần trích xuất tạm của khung hỏi-đáp."""
|
"""Rời màn GraphRAG thì xoá phần trích xuất tạm của khung hỏi-đáp."""
|
||||||
|
|||||||
@@ -39,8 +39,9 @@ _UNBOUNDED_PAGE_SIZE = 100_000
|
|||||||
# day-sharded JSONL — unbounded start/end means EVERY day file ever written
|
# day-sharded JSONL — unbounded start/end means EVERY day file ever written
|
||||||
# gets re-read and re-parsed on EVERY tick, which is what actually made
|
# gets re-read and re-parsed on EVERY tick, which is what actually made
|
||||||
# Monitoring "gây nặng khi log lớn" (see DF-006): the slowness was never in
|
# Monitoring "gây nặng khi log lớn" (see DF-006): the slowness was never in
|
||||||
# rendering (EventTable already caps display at 300 rows — see
|
# rendering (EventTable paginates client-side, 5-100 rows/page — see
|
||||||
# shared/event_table.py::_MAX_ROWS), it was this repeated full-history read.
|
# shared/event_table.py::_DEFAULT_PAGE_SIZE), it was this repeated full-history
|
||||||
|
# read.
|
||||||
# 30 days is a live-monitoring window, not a hard retention limit — nothing
|
# 30 days is a live-monitoring window, not a hard retention limit — nothing
|
||||||
# is deleted, older days are simply not re-read on every 3s tick.
|
# is deleted, older days are simply not re-read on every 3s tick.
|
||||||
_LOG_WINDOW_DAYS = 30
|
_LOG_WINDOW_DAYS = 30
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
from PySide6.QtCore import QEvent, QObject, QRect, QSize, Qt
|
from PySide6.QtCore import QEvent, QObject, QRect, QSize, Qt, Signal
|
||||||
from PySide6.QtGui import QBrush, QColor
|
from PySide6.QtGui import QBrush, QColor
|
||||||
from PySide6.QtWidgets import QHeaderView, QTableWidget, QTableWidgetItem, QWidget
|
from PySide6.QtWidgets import QHeaderView, QTableWidget, QTableWidgetItem, QWidget
|
||||||
|
|
||||||
@@ -17,8 +17,8 @@ from ....ui.icons import DOT_GREEN, DOT_RED, icon
|
|||||||
from .badges import action_label
|
from .badges import action_label
|
||||||
from .formatters import agent_avatar_icon, fmt_event_time
|
from .formatters import agent_avatar_icon, fmt_event_time
|
||||||
|
|
||||||
_MAX_ROWS = 300
|
_DEFAULT_PAGE_SIZE = 20
|
||||||
PAGE_SIZE_OPTIONS = (50, 100, 300, 500, 1000)
|
PAGE_SIZE_OPTIONS = (5, 10, 20, 50, 100)
|
||||||
|
|
||||||
|
|
||||||
class _TimeItem(QTableWidgetItem):
|
class _TimeItem(QTableWidgetItem):
|
||||||
@@ -63,6 +63,12 @@ class EventTable(QTableWidget):
|
|||||||
"secret_in_output": "warning",
|
"secret_in_output": "warning",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Emitted whenever the rendered page changes (new data, page-size change,
|
||||||
|
# or prev/next navigation) — args are (current_page, page_count), both
|
||||||
|
# 1-based-friendly in that current_page is 0-indexed but page_count is a
|
||||||
|
# plain count. filter_scaffold.py's pager label/buttons listen to this.
|
||||||
|
page_changed = Signal(int, int)
|
||||||
|
|
||||||
def __init__(self, show_result: bool = True):
|
def __init__(self, show_result: bool = True):
|
||||||
# Security Events drops the result column entirely (see _ACTION_TINTS).
|
# 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.
|
"""Bảng sự kiện dùng chung của các tab Giám sát.
|
||||||
@@ -71,8 +77,10 @@ class EventTable(QTableWidget):
|
|||||||
là thất bại nên cột ấy chỉ tốn chỗ.
|
là thất bại nên cột ấy chỉ tốn chỗ.
|
||||||
"""
|
"""
|
||||||
self._show_result = show_result
|
self._show_result = show_result
|
||||||
self._page_size = _MAX_ROWS
|
self._page_size = _DEFAULT_PAGE_SIZE
|
||||||
|
self._current_page = 0
|
||||||
self._last_events: List[dict] = []
|
self._last_events: List[dict] = []
|
||||||
|
self._sorted_events: List[dict] = []
|
||||||
super().__init__(0, 7 if show_result else 6)
|
super().__init__(0, 7 if show_result else 6)
|
||||||
self.setEditTriggers(QTableWidget.NoEditTriggers)
|
self.setEditTriggers(QTableWidget.NoEditTriggers)
|
||||||
self.setSelectionBehavior(QTableWidget.SelectRows)
|
self.setSelectionBehavior(QTableWidget.SelectRows)
|
||||||
@@ -105,21 +113,56 @@ class EventTable(QTableWidget):
|
|||||||
"""Số dòng đang hiển thị mỗi trang."""
|
"""Số dòng đang hiển thị mỗi trang."""
|
||||||
return self._page_size
|
return self._page_size
|
||||||
|
|
||||||
|
def page_count(self) -> int:
|
||||||
|
"""Tổng số trang với dữ liệu và số dòng/trang hiện tại (tối thiểu 1)."""
|
||||||
|
if not self._sorted_events:
|
||||||
|
return 1
|
||||||
|
return -(-len(self._sorted_events) // self._page_size) # ceil div
|
||||||
|
|
||||||
|
def current_page(self) -> int:
|
||||||
|
"""Trang đang hiển thị, đánh số từ 0."""
|
||||||
|
return self._current_page
|
||||||
|
|
||||||
|
def go_to_page(self, page: int) -> None:
|
||||||
|
"""Nhảy tới một trang cụ thể (đánh số từ 0), tự kẹp trong khoảng hợp lệ."""
|
||||||
|
self._current_page = page
|
||||||
|
self._render_current_page()
|
||||||
|
|
||||||
|
def next_page(self) -> None:
|
||||||
|
"""Sang trang kế — không làm gì nếu đã ở trang cuối."""
|
||||||
|
self.go_to_page(self._current_page + 1)
|
||||||
|
|
||||||
|
def prev_page(self) -> None:
|
||||||
|
"""Về trang trước — không làm gì nếu đã ở trang đầu."""
|
||||||
|
self.go_to_page(self._current_page - 1)
|
||||||
|
|
||||||
def set_page_size(self, n: int) -> None:
|
def set_page_size(self, n: int) -> None:
|
||||||
"""Đổi số dòng hiển thị mỗi trang rồi vẽ lại với dữ liệu đã có sẵn
|
"""Đổi số dòng hiển thị mỗi trang, quay về trang đầu, rồi vẽ lại với dữ
|
||||||
(không cần refresh lại từ nguồn — set_events() đã lưu lại lần đổ gần nhất)."""
|
liệu đã có sẵn (không cần refresh lại từ nguồn)."""
|
||||||
self._page_size = n
|
self._page_size = n
|
||||||
self.set_events(self._last_events)
|
self._current_page = 0
|
||||||
|
self._render_current_page()
|
||||||
|
|
||||||
def set_events(self, events: List[dict]) -> None:
|
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 ở ``self._page_size``
|
"""Đổ danh sách sự kiện vào bảng: mới nhất lên đầu, chia trang theo
|
||||||
(đổi được qua ``set_page_size`` — control "Số dòng/trang" ở filter_scaffold.py).
|
``self._page_size`` — xem qua trang khác bằng ``next_page``/``prev_page``
|
||||||
|
(nút tiến/lùi ở filter_scaffold.py), không còn bị cắt bỏ vĩnh viễn như
|
||||||
|
trước (DF-006)."""
|
||||||
|
self._last_events = events
|
||||||
|
self._sorted_events = sorted(events, key=lambda e: e.get("ts", ""), reverse=True)
|
||||||
|
self._current_page = 0
|
||||||
|
self._render_current_page()
|
||||||
|
|
||||||
|
def _render_current_page(self) -> None:
|
||||||
|
"""Vẽ đúng một trang (theo ``self._current_page``/``self._page_size``)
|
||||||
|
từ ``self._sorted_events`` đã sắp sẵn.
|
||||||
|
|
||||||
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
|
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.
|
nạp chậm đi theo bậc hai.
|
||||||
"""
|
"""
|
||||||
self._last_events = events
|
self._current_page = max(0, min(self._current_page, self.page_count() - 1))
|
||||||
events = sorted(events, key=lambda e: e.get("ts", ""), reverse=True)[:self._page_size]
|
start = self._current_page * self._page_size
|
||||||
|
events = self._sorted_events[start:start + self._page_size]
|
||||||
self.setSortingEnabled(False)
|
self.setSortingEnabled(False)
|
||||||
self.setRowCount(len(events))
|
self.setRowCount(len(events))
|
||||||
for row, ev in enumerate(events):
|
for row, ev in enumerate(events):
|
||||||
@@ -164,6 +207,7 @@ class EventTable(QTableWidget):
|
|||||||
self.setItem(row, col, item)
|
self.setItem(row, col, item)
|
||||||
self.setSortingEnabled(True)
|
self.setSortingEnabled(True)
|
||||||
self.apply_filter(getattr(self, "_filter_needle", ""))
|
self.apply_filter(getattr(self, "_filter_needle", ""))
|
||||||
|
self.page_changed.emit(self._current_page, self.page_count())
|
||||||
|
|
||||||
def apply_filter(self, needle: str) -> None:
|
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)."""
|
"""Ẩn/hiện dòng theo từ khoá tìm kiếm (không phân biệt hoa thường)."""
|
||||||
|
|||||||
@@ -95,20 +95,52 @@ def build_filter_scaffold(
|
|||||||
if with_page_size and isinstance(table, EventTable):
|
if with_page_size and isinstance(table, EventTable):
|
||||||
# DF-006: the item-per-page count was never surfaced anywhere in
|
# DF-006: the item-per-page count was never surfaced anywhere in
|
||||||
# the UI (design called for it) — EventTable already trims to a
|
# the UI (design called for it) — EventTable already trims to a
|
||||||
# page size internally (default 300), this just makes that
|
# page size internally, this just makes that number visible AND
|
||||||
# number visible AND user-choosable instead of a fixed constant.
|
# user-choosable instead of a fixed constant.
|
||||||
page_size_lbl = QLabel(tr("monitoring.page_size_label"))
|
page_size_lbl = QLabel(tr("monitoring.page_size_label"))
|
||||||
page_size_combo = QComboBox()
|
page_size_combo = QComboBox()
|
||||||
for n in PAGE_SIZE_OPTIONS:
|
for n in PAGE_SIZE_OPTIONS:
|
||||||
page_size_combo.addItem(str(n), n)
|
page_size_combo.addItem(str(n), n)
|
||||||
current = table.page_size()
|
current = table.page_size()
|
||||||
page_size_combo.setCurrentIndex(
|
page_size_combo.setCurrentIndex(
|
||||||
PAGE_SIZE_OPTIONS.index(current) if current in PAGE_SIZE_OPTIONS else 2)
|
PAGE_SIZE_OPTIONS.index(current) if current in PAGE_SIZE_OPTIONS else 0)
|
||||||
page_size_combo.currentIndexChanged.connect(
|
page_size_combo.currentIndexChanged.connect(
|
||||||
lambda i: table.set_page_size(page_size_combo.itemData(i)))
|
lambda i: table.set_page_size(page_size_combo.itemData(i)))
|
||||||
row.addWidget(page_size_lbl)
|
row.addWidget(page_size_lbl)
|
||||||
row.addWidget(page_size_combo)
|
row.addWidget(page_size_combo)
|
||||||
parts.update(page_size_label=page_size_lbl, page_size_combo=page_size_combo)
|
|
||||||
|
# DF-006 follow-up: trimming to a page size alone silently dropped
|
||||||
|
# every row past it with no way back to see them — prev/next
|
||||||
|
# buttons plus a "trang X/Y" indicator make the rest reachable.
|
||||||
|
page_prev_btn = QPushButton()
|
||||||
|
page_prev_btn.setIcon(icon("chevron-left"))
|
||||||
|
page_prev_btn.setCursor(Qt.PointingHandCursor)
|
||||||
|
bind_tip(page_prev_btn, "monitoring.page_prev")
|
||||||
|
page_next_btn = QPushButton()
|
||||||
|
page_next_btn.setIcon(icon("chevron-right"))
|
||||||
|
page_next_btn.setCursor(Qt.PointingHandCursor)
|
||||||
|
bind_tip(page_next_btn, "monitoring.page_next")
|
||||||
|
page_indicator_lbl = QLabel()
|
||||||
|
|
||||||
|
def _refresh_pager(cur: int = None, total: int = None) -> None:
|
||||||
|
if cur is None or total is None:
|
||||||
|
cur, total = table.current_page(), table.page_count()
|
||||||
|
page_indicator_lbl.setText(tr("monitoring.page_indicator", page=cur + 1, total=total))
|
||||||
|
page_prev_btn.setEnabled(cur > 0)
|
||||||
|
page_next_btn.setEnabled(cur < total - 1)
|
||||||
|
|
||||||
|
page_prev_btn.clicked.connect(table.prev_page)
|
||||||
|
page_next_btn.clicked.connect(table.next_page)
|
||||||
|
table.page_changed.connect(_refresh_pager)
|
||||||
|
_refresh_pager()
|
||||||
|
|
||||||
|
row.addWidget(page_prev_btn)
|
||||||
|
row.addWidget(page_indicator_lbl)
|
||||||
|
row.addWidget(page_next_btn)
|
||||||
|
parts.update(
|
||||||
|
page_size_label=page_size_lbl, page_size_combo=page_size_combo,
|
||||||
|
page_prev_btn=page_prev_btn, page_next_btn=page_next_btn,
|
||||||
|
page_indicator_label=page_indicator_lbl, page_pager_refresh=_refresh_pager)
|
||||||
lay.addLayout(row)
|
lay.addLayout(row)
|
||||||
parts.update(filter_edit=search, ai_filter_btn=ai_btn)
|
parts.update(filter_edit=search, ai_filter_btn=ai_btn)
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ class ActionLogsTab(QWidget):
|
|||||||
self.ai_filter_btn = parts["ai_filter_btn"]
|
self.ai_filter_btn = parts["ai_filter_btn"]
|
||||||
self.detail_panel = parts["detail_panel"]
|
self.detail_panel = parts["detail_panel"]
|
||||||
self.page_size_label = parts["page_size_label"]
|
self.page_size_label = parts["page_size_label"]
|
||||||
|
self.page_pager_refresh = parts["page_pager_refresh"]
|
||||||
|
|
||||||
def set_events(self, events: List[dict]) -> None:
|
def set_events(self, events: List[dict]) -> None:
|
||||||
"""Đổ danh sách sự kiện vào bảng."""
|
"""Đổ danh sách sự kiện vào bảng."""
|
||||||
@@ -47,6 +48,7 @@ class ActionLogsTab(QWidget):
|
|||||||
self.title_lbl.setText(tr(self.title_key))
|
self.title_lbl.setText(tr(self.title_key))
|
||||||
self.title_refresh_btn.setText(tr("monitoring.refresh"))
|
self.title_refresh_btn.setText(tr("monitoring.refresh"))
|
||||||
self.page_size_label.setText(tr("monitoring.page_size_label"))
|
self.page_size_label.setText(tr("monitoring.page_size_label"))
|
||||||
|
self.page_pager_refresh()
|
||||||
|
|
||||||
def _start_ai_filter(self, search: QLineEdit, ai_btn: QPushButton) -> None:
|
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."""
|
"""Nhờ AI dịch câu tìm kiếm tự nhiên thành từ khoá lọc."""
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ class McpTab(QWidget):
|
|||||||
self.ai_filter_btn = parts["ai_filter_btn"]
|
self.ai_filter_btn = parts["ai_filter_btn"]
|
||||||
self.detail_panel = parts["detail_panel"]
|
self.detail_panel = parts["detail_panel"]
|
||||||
self.page_size_label = parts["page_size_label"]
|
self.page_size_label = parts["page_size_label"]
|
||||||
|
self.page_pager_refresh = parts["page_pager_refresh"]
|
||||||
|
|
||||||
def set_events(self, events: List[dict]) -> None:
|
def set_events(self, events: List[dict]) -> None:
|
||||||
"""Đổ danh sách sự kiện vào bảng."""
|
"""Đổ danh sách sự kiện vào bảng."""
|
||||||
@@ -47,6 +48,7 @@ class McpTab(QWidget):
|
|||||||
self.title_lbl.setText(tr(self.title_key))
|
self.title_lbl.setText(tr(self.title_key))
|
||||||
self.title_refresh_btn.setText(tr("monitoring.refresh"))
|
self.title_refresh_btn.setText(tr("monitoring.refresh"))
|
||||||
self.page_size_label.setText(tr("monitoring.page_size_label"))
|
self.page_size_label.setText(tr("monitoring.page_size_label"))
|
||||||
|
self.page_pager_refresh()
|
||||||
|
|
||||||
def _start_ai_filter(self, search: QLineEdit, ai_btn: QPushButton) -> None:
|
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."""
|
"""Nhờ AI dịch câu tìm kiếm tự nhiên thành từ khoá lọc."""
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ class SecurityEventsTab(QWidget):
|
|||||||
self.ai_filter_btn = parts["ai_filter_btn"]
|
self.ai_filter_btn = parts["ai_filter_btn"]
|
||||||
self.detail_panel = parts["detail_panel"]
|
self.detail_panel = parts["detail_panel"]
|
||||||
self.page_size_label = parts["page_size_label"]
|
self.page_size_label = parts["page_size_label"]
|
||||||
|
self.page_pager_refresh = parts["page_pager_refresh"]
|
||||||
|
|
||||||
def set_events(self, events: List[dict]) -> None:
|
def set_events(self, events: List[dict]) -> None:
|
||||||
"""Đổ danh sách sự kiện vào bảng."""
|
"""Đổ danh sách sự kiện vào bảng."""
|
||||||
@@ -53,6 +54,7 @@ class SecurityEventsTab(QWidget):
|
|||||||
self.title_lbl.setText(tr(self.title_key))
|
self.title_lbl.setText(tr(self.title_key))
|
||||||
self.title_refresh_btn.setText(tr("monitoring.refresh"))
|
self.title_refresh_btn.setText(tr("monitoring.refresh"))
|
||||||
self.page_size_label.setText(tr("monitoring.page_size_label"))
|
self.page_size_label.setText(tr("monitoring.page_size_label"))
|
||||||
|
self.page_pager_refresh()
|
||||||
|
|
||||||
def _start_ai_filter(self, search: QLineEdit, ai_btn: QPushButton) -> None:
|
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."""
|
"""Nhờ AI dịch câu tìm kiếm tự nhiên thành từ khoá lọc."""
|
||||||
|
|||||||
@@ -106,4 +106,4 @@ class SessionEventsMixin:
|
|||||||
"""Project được tạo/sửa/xoá: gom nhóm lại cột lịch sử và cập nhật nhãn thư mục."""
|
"""Project được tạo/sửa/xoá: gom nhóm lại cột lịch sử và cập nhật nhãn thư mục."""
|
||||||
self.sidebar.refresh() # History regroups by project
|
self.sidebar.refresh() # History regroups by project
|
||||||
self.cowork._apply_output_folder_label() # project may have been renamed
|
self.cowork._apply_output_folder_label() # project may have been renamed
|
||||||
self.structure._refresh_project_combo() # GraphRAG's project lock list follows too
|
self.structure.refresh_project_list() # GraphRAG's project lock list follows too
|
||||||
|
|||||||
@@ -19,6 +19,14 @@ set "APPHOME=%LOCALAPPDATA%\CoworkLocal"
|
|||||||
set "VENV=%APPHOME%\venv"
|
set "VENV=%APPHOME%\venv"
|
||||||
set "LAUNCHER=%APPHOME%\launcher"
|
set "LAUNCHER=%APPHOME%\launcher"
|
||||||
|
|
||||||
|
rem An cua so console NGAY TU DAU, ke ca trong luc kiem tra ben duoi — khong
|
||||||
|
rem chi truoc luc chay app. Moi cho bao loi (echo + pause) ben duoi tu hien
|
||||||
|
rem lai cua so truoc khi in, de thong bao van doc duoc.
|
||||||
|
set "CONSOLE_VIS=%REPO%\scripts\console_visibility.ps1"
|
||||||
|
if exist "%CONSOLE_VIS%" (
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -File "%CONSOLE_VIS%" -Mode 0 >nul 2>&1
|
||||||
|
)
|
||||||
|
|
||||||
rem --------------------------------------------------------------------------
|
rem --------------------------------------------------------------------------
|
||||||
rem 1. Chon trinh thong dich
|
rem 1. Chon trinh thong dich
|
||||||
rem
|
rem
|
||||||
@@ -38,6 +46,7 @@ if exist "%VENV%\Scripts\python.exe" (
|
|||||||
)
|
)
|
||||||
|
|
||||||
if not defined RUNPY (
|
if not defined RUNPY (
|
||||||
|
if exist "%CONSOLE_VIS%" powershell -NoProfile -ExecutionPolicy Bypass -File "%CONSOLE_VIS%" -Mode 5 >nul 2>&1
|
||||||
echo.
|
echo.
|
||||||
echo [LỖI] Không tìm thấy Python. Chạy install.bat trước đã.
|
echo [LỖI] Không tìm thấy Python. Chạy install.bat trước đã.
|
||||||
echo.
|
echo.
|
||||||
@@ -51,6 +60,7 @@ rem biet la phai chay install.bat.
|
|||||||
if not exist "%VENV%\Scripts\python.exe" (
|
if not exist "%VENV%\Scripts\python.exe" (
|
||||||
!RUNPY! -c "import PySide6" >nul 2>&1
|
!RUNPY! -c "import PySide6" >nul 2>&1
|
||||||
if errorlevel 1 (
|
if errorlevel 1 (
|
||||||
|
if exist "%CONSOLE_VIS%" powershell -NoProfile -ExecutionPolicy Bypass -File "%CONSOLE_VIS%" -Mode 5 >nul 2>&1
|
||||||
echo.
|
echo.
|
||||||
echo [LỖI] Thư viện chưa được cài. Chạy install.bat trước đã.
|
echo [LỖI] Thư viện chưa được cài. Chạy install.bat trước đã.
|
||||||
echo.
|
echo.
|
||||||
@@ -94,6 +104,7 @@ if /I "%REPO_NAME%"=="cowork_local" (
|
|||||||
if exist "!PKGPATH!\cowork_local" rmdir "!PKGPATH!\cowork_local" >nul 2>&1
|
if exist "!PKGPATH!\cowork_local" rmdir "!PKGPATH!\cowork_local" >nul 2>&1
|
||||||
mklink /J "!PKGPATH!\cowork_local" "%REPO%" >nul 2>&1
|
mklink /J "!PKGPATH!\cowork_local" "%REPO%" >nul 2>&1
|
||||||
if errorlevel 1 (
|
if errorlevel 1 (
|
||||||
|
if exist "%CONSOLE_VIS%" powershell -NoProfile -ExecutionPolicy Bypass -File "%CONSOLE_VIS%" -Mode 5 >nul 2>&1
|
||||||
echo.
|
echo.
|
||||||
echo [LOI] Khong tao duoc lien ket thu muc:
|
echo [LOI] Khong tao duoc lien ket thu muc:
|
||||||
echo "!PKGPATH!\cowork_local" -> "%REPO%"
|
echo "!PKGPATH!\cowork_local" -> "%REPO%"
|
||||||
@@ -112,6 +123,7 @@ rem Chot lai: goi phai THAT SU nhin thay duoc qua duong dan vua dung. Khong co
|
|||||||
rem buoc nay thi mot junction hong chi hien ra duoi dang loi Python kho hieu
|
rem buoc nay thi mot junction hong chi hien ra duoi dang loi Python kho hieu
|
||||||
rem ("'cowork_local' is a package and cannot be directly executed").
|
rem ("'cowork_local' is a package and cannot be directly executed").
|
||||||
if not exist "!PKGPATH!\cowork_local\__main__.py" (
|
if not exist "!PKGPATH!\cowork_local\__main__.py" (
|
||||||
|
if exist "%CONSOLE_VIS%" powershell -NoProfile -ExecutionPolicy Bypass -File "%CONSOLE_VIS%" -Mode 5 >nul 2>&1
|
||||||
echo.
|
echo.
|
||||||
echo [LOI] Khong tim thay cowork_local\__main__.py qua duong dan:
|
echo [LOI] Khong tim thay cowork_local\__main__.py qua duong dan:
|
||||||
echo "!PKGPATH!"
|
echo "!PKGPATH!"
|
||||||
@@ -139,18 +151,12 @@ set "PYTHONIOENCODING=utf-8"
|
|||||||
cd /d "%REPO%"
|
cd /d "%REPO%"
|
||||||
|
|
||||||
rem --------------------------------------------------------------------------
|
rem --------------------------------------------------------------------------
|
||||||
rem 4. An cua so console trong luc chay
|
rem 4. Chay app
|
||||||
rem
|
rem
|
||||||
rem App la GUI (Qt), khong can console — nhung no chia se console cua chinh
|
rem App la GUI (Qt), khong can console — cua so console da bi an tu dau file
|
||||||
rem cmd nay (khong tu mo cua so rieng), nen cua so den cua run.bat cu the
|
rem roi (xem khoi CONSOLE_VIS phia tren), chi hien lai NEU app thoat loi, de
|
||||||
rem hien suot phien lam viec neu khong lam gi. An no ngay truoc khi chay, roi
|
rem thong bao loi ben duoi van doc duoc.
|
||||||
rem chi hien lai NEU app thoat loi, de thong bao loi ben duoi van doc duoc.
|
|
||||||
rem --------------------------------------------------------------------------
|
rem --------------------------------------------------------------------------
|
||||||
set "CONSOLE_VIS=%REPO%\scripts\console_visibility.ps1"
|
|
||||||
if exist "%CONSOLE_VIS%" (
|
|
||||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%CONSOLE_VIS%" -Mode 0 >nul 2>&1
|
|
||||||
)
|
|
||||||
|
|
||||||
!RUNPY! -m cowork_local %*
|
!RUNPY! -m cowork_local %*
|
||||||
set "RC=%ERRORLEVEL%"
|
set "RC=%ERRORLEVEL%"
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,25 @@ def test_structure_graph_view_builds(ctx):
|
|||||||
assert view.qa is not None
|
assert view.qa is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_project_list_forwards_to_renderer(ctx):
|
||||||
|
"""Crash bug: ``presentation/shell/session_events.py::_on_projects_changed``
|
||||||
|
called ``self.structure._refresh_project_combo()`` — a method that only
|
||||||
|
ever existed on ``GraphRenderer``, not on ``StructureGraphView`` itself —
|
||||||
|
so creating/renaming/deleting a project (anywhere in the app) raised
|
||||||
|
``AttributeError`` and crashed. ``refresh_project_list()`` is the public
|
||||||
|
forwarding method callers must use instead (matching ``schedule_rescan``/
|
||||||
|
``set_project``/``prewarm``'s existing forwarding pattern)."""
|
||||||
|
from cowork_local.presentation.graph.structure_graph_view import StructureGraphView
|
||||||
|
|
||||||
|
view = StructureGraphView(ctx)
|
||||||
|
assert not hasattr(view, "_refresh_project_combo")
|
||||||
|
|
||||||
|
combo = view.renderer.project_combo
|
||||||
|
before = combo.count()
|
||||||
|
view.refresh_project_list() # must not raise
|
||||||
|
assert combo.count() == before # re-populated from the same project list, same size
|
||||||
|
|
||||||
|
|
||||||
def test_render_populates_the_scene_and_emits_graph_rendered(ctx, tmp_path):
|
def test_render_populates_the_scene_and_emits_graph_rendered(ctx, tmp_path):
|
||||||
from cowork_local.presentation.graph.graph_renderer import GraphRenderer
|
from cowork_local.presentation.graph.graph_renderer import GraphRenderer
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""DF-002 (phần b) — node đang chạy HOẶC đã chạy xong không cho edit thông
|
||||||
|
tin trong Node. Trước khi sửa, ``_LOCKED_NODE_STATUSES`` khoá cả STEP_RUNNING
|
||||||
|
lẫn STEP_DONE, nên một bước đã chạy xong không bao giờ sửa lại được nữa.
|
||||||
|
|
||||||
|
Fix: chỉ khoá khi bước ĐANG chạy (STEP_RUNNING) — chạy xong rồi thì mở khoá
|
||||||
|
trở lại. Test này chốt cả nguồn sự thật (tuple
|
||||||
|
``co4e_workflow_crud._LOCKED_NODE_STATUSES``) lẫn hành vi ở widget
|
||||||
|
(``StepConfigPanel.set_locked``), để không bị hồi quy về hành vi cũ.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
QApplication = pytest.importorskip("PySide6.QtWidgets").QApplication
|
||||||
|
|
||||||
|
from cowork_local.core.co4e import (
|
||||||
|
STEP_DONE, STEP_ERROR, STEP_IDLE, STEP_PLANNED, STEP_RUNNING,
|
||||||
|
)
|
||||||
|
from cowork_local.presentation.co4e.co4e_workflow_crud import _LOCKED_NODE_STATUSES
|
||||||
|
from cowork_local.presentation.co4e.node_property_panel import StepConfigPanel
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def qapp():
|
||||||
|
app = QApplication.instance() or QApplication([])
|
||||||
|
yield app
|
||||||
|
|
||||||
|
|
||||||
|
def test_only_running_status_is_locked() -> None:
|
||||||
|
"""Một bước đã chạy xong (STEP_DONE) phải sửa lại được — chỉ bước đang
|
||||||
|
thực sự chạy (STEP_RUNNING) mới bị khoá."""
|
||||||
|
assert _LOCKED_NODE_STATUSES == (STEP_RUNNING,)
|
||||||
|
assert STEP_DONE not in _LOCKED_NODE_STATUSES
|
||||||
|
assert STEP_IDLE not in _LOCKED_NODE_STATUSES
|
||||||
|
assert STEP_ERROR not in _LOCKED_NODE_STATUSES
|
||||||
|
assert STEP_PLANNED not in _LOCKED_NODE_STATUSES
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_locked_disables_then_reenables_edit_fields(qapp) -> None:
|
||||||
|
panel = StepConfigPanel()
|
||||||
|
panel.setEnabled(True) # panel starts disabled until a step is loaded
|
||||||
|
|
||||||
|
panel.set_locked(True)
|
||||||
|
assert not panel.label_edit.isEnabled()
|
||||||
|
assert not panel.instructions_edit.isEnabled()
|
||||||
|
assert not panel.model_combo.isEnabled()
|
||||||
|
|
||||||
|
panel.set_locked(False)
|
||||||
|
assert panel.label_edit.isEnabled()
|
||||||
|
assert panel.instructions_edit.isEnabled()
|
||||||
|
assert panel.model_combo.isEnabled()
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
"""DF-006 — the "Số dòng/trang" (rows per page) control: EventTable's
|
"""DF-006 — the "Số dòng/trang" (rows per page) control plus real prev/next
|
||||||
page-size state (presentation/monitoring/shared/event_table.py) and its
|
pagination: EventTable's page-size/page-index state
|
||||||
QComboBox wiring in build_filter_scaffold (.../shared/filter_scaffold.py).
|
(presentation/monitoring/shared/event_table.py) and its QComboBox + pager
|
||||||
No dedicated test existed for this before — the design called for a
|
button wiring in build_filter_scaffold (.../shared/filter_scaffold.py).
|
||||||
user-visible/choosable item-per-page control, and this exercises it end to
|
|
||||||
end (combo selection -> EventTable actually re-trimming its rows)."""
|
Options are 5/10/20/50/100 with a next/prev pager, per the QA follow-up on
|
||||||
|
DF-006 — the earlier fix only trimmed to a page size (dropping every row past
|
||||||
|
it with no way back); this exercises the real paging end to end."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@@ -32,19 +34,62 @@ def _events(n: int):
|
|||||||
"name": f"e{i}", "ok": True, "detail": ""} for i in range(n)]
|
"name": f"e{i}", "ok": True, "detail": ""} for i in range(n)]
|
||||||
|
|
||||||
|
|
||||||
def test_default_page_size_matches_old_max_rows(qapp) -> None:
|
def test_page_size_options_are_5_10_20_50_100() -> None:
|
||||||
table = EventTable()
|
assert PAGE_SIZE_OPTIONS == (5, 10, 20, 50, 100)
|
||||||
assert table.page_size() == 300
|
|
||||||
table.set_events(_events(500))
|
|
||||||
assert table.rowCount() == 300
|
|
||||||
|
|
||||||
|
|
||||||
def test_set_page_size_retrims_without_reloading(qapp) -> None:
|
def test_default_page_size(qapp) -> None:
|
||||||
table = EventTable()
|
table = EventTable()
|
||||||
table.set_events(_events(500))
|
assert table.page_size() == 20
|
||||||
|
table.set_events(_events(45))
|
||||||
|
assert table.rowCount() == 20
|
||||||
|
assert table.page_count() == 3
|
||||||
|
assert table.current_page() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_page_size_resets_to_first_page(qapp) -> None:
|
||||||
|
table = EventTable()
|
||||||
|
table.set_events(_events(45))
|
||||||
|
table.next_page()
|
||||||
|
assert table.current_page() == 1
|
||||||
table.set_page_size(50)
|
table.set_page_size(50)
|
||||||
assert table.page_size() == 50
|
assert table.page_size() == 50
|
||||||
assert table.rowCount() == 50
|
assert table.current_page() == 0
|
||||||
|
assert table.rowCount() == 45 # only 45 events total, fits in one page of 50
|
||||||
|
|
||||||
|
|
||||||
|
def test_next_prev_page_navigate_without_dropping_rows(qapp) -> None:
|
||||||
|
table = EventTable()
|
||||||
|
table.set_events(_events(45))
|
||||||
|
table.set_page_size(20)
|
||||||
|
assert table.rowCount() == 20
|
||||||
|
|
||||||
|
table.next_page()
|
||||||
|
assert table.current_page() == 1
|
||||||
|
assert table.rowCount() == 20
|
||||||
|
|
||||||
|
table.next_page()
|
||||||
|
assert table.current_page() == 2
|
||||||
|
assert table.rowCount() == 5 # last page: remainder
|
||||||
|
|
||||||
|
table.next_page() # already on last page — stays put
|
||||||
|
assert table.current_page() == 2
|
||||||
|
|
||||||
|
table.prev_page()
|
||||||
|
assert table.current_page() == 1
|
||||||
|
table.prev_page()
|
||||||
|
table.prev_page() # already on first page — stays put
|
||||||
|
assert table.current_page() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_page_changed_signal_reports_current_and_total(qapp) -> None:
|
||||||
|
table = EventTable()
|
||||||
|
seen = []
|
||||||
|
table.page_changed.connect(lambda cur, total: seen.append((cur, total)))
|
||||||
|
table.set_events(_events(45))
|
||||||
|
table.set_page_size(20)
|
||||||
|
table.next_page()
|
||||||
|
assert seen[-1] == (1, 3)
|
||||||
|
|
||||||
|
|
||||||
def test_page_size_combo_is_only_added_when_requested(qapp) -> None:
|
def test_page_size_combo_is_only_added_when_requested(qapp) -> None:
|
||||||
@@ -52,19 +97,44 @@ def test_page_size_combo_is_only_added_when_requested(qapp) -> None:
|
|||||||
table = EventTable()
|
table = EventTable()
|
||||||
parts = build_filter_scaffold(page, table, on_refresh=lambda: None, with_page_size=False)
|
parts = build_filter_scaffold(page, table, on_refresh=lambda: None, with_page_size=False)
|
||||||
assert "page_size_combo" not in parts
|
assert "page_size_combo" not in parts
|
||||||
|
assert "page_prev_btn" not in parts
|
||||||
|
|
||||||
|
|
||||||
def test_page_size_combo_changes_the_table(qapp) -> None:
|
def test_page_size_combo_changes_the_table(qapp) -> None:
|
||||||
page = QWidget()
|
page = QWidget()
|
||||||
table = EventTable()
|
table = EventTable()
|
||||||
table.set_events(_events(500))
|
table.set_events(_events(45))
|
||||||
parts = build_filter_scaffold(page, table, on_refresh=lambda: None, with_page_size=True)
|
parts = build_filter_scaffold(page, table, on_refresh=lambda: None, with_page_size=True)
|
||||||
combo = parts["page_size_combo"]
|
combo = parts["page_size_combo"]
|
||||||
assert combo.count() == len(PAGE_SIZE_OPTIONS)
|
assert combo.count() == len(PAGE_SIZE_OPTIONS)
|
||||||
assert combo.currentData() == 300 # matches EventTable's current page_size
|
assert combo.currentData() == 20 # matches EventTable's current page_size
|
||||||
|
|
||||||
idx = PAGE_SIZE_OPTIONS.index(50)
|
idx = PAGE_SIZE_OPTIONS.index(10)
|
||||||
combo.setCurrentIndex(idx)
|
combo.setCurrentIndex(idx)
|
||||||
|
|
||||||
assert table.page_size() == 50
|
assert table.page_size() == 10
|
||||||
assert table.rowCount() == 50
|
assert table.rowCount() == 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_pager_buttons_disable_at_bounds_and_indicator_updates(qapp) -> None:
|
||||||
|
page = QWidget()
|
||||||
|
table = EventTable()
|
||||||
|
table.set_events(_events(45))
|
||||||
|
parts = build_filter_scaffold(page, table, on_refresh=lambda: None, with_page_size=True)
|
||||||
|
table.set_page_size(20)
|
||||||
|
prev_btn, next_btn = parts["page_prev_btn"], parts["page_next_btn"]
|
||||||
|
indicator = parts["page_indicator_label"]
|
||||||
|
|
||||||
|
assert not prev_btn.isEnabled()
|
||||||
|
assert next_btn.isEnabled()
|
||||||
|
assert indicator.text() == "Trang 1/3"
|
||||||
|
|
||||||
|
next_btn.click()
|
||||||
|
assert prev_btn.isEnabled()
|
||||||
|
assert next_btn.isEnabled()
|
||||||
|
assert indicator.text() == "Trang 2/3"
|
||||||
|
|
||||||
|
next_btn.click()
|
||||||
|
assert prev_btn.isEnabled()
|
||||||
|
assert not next_btn.isEnabled()
|
||||||
|
assert indicator.text() == "Trang 3/3"
|
||||||
|
|||||||
Reference in New Issue
Block a user