feat: 效能監控頁面新增日誌分頁與 UI 優化

- 系統日誌改為每頁顯示 50 筆,新增分頁控制項
- 新增 count_logs() 方法支援總數查詢
- query_logs() 支援 offset 參數進行分頁
- API 新增 total 欄位回傳過濾後總數
- 「返回首頁」連結移至 Header 區域
- 新增 2 個分頁功能測試案例

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
beabigegg
2026-02-04 08:32:31 +08:00
parent 13acbfc71b
commit 0669a92c39
4 changed files with 203 additions and 12 deletions

View File

@@ -210,6 +210,7 @@ class LogStore:
level: Optional[str] = None,
q: Optional[str] = None,
limit: int = 200,
offset: int = 0,
since: Optional[str] = None,
logger_name: Optional[str] = None
) -> List[Dict[str, Any]]:
@@ -219,6 +220,7 @@ class LogStore:
level: Filter by log level (e.g., "ERROR", "WARNING").
q: Search query for message content (case-insensitive).
limit: Maximum number of logs to return (default: 200).
offset: Number of logs to skip (for pagination).
since: ISO timestamp to filter logs after this time.
logger_name: Filter by logger name prefix.
@@ -250,8 +252,9 @@ class LogStore:
query += " AND logger_name LIKE ?"
params.append(f"{logger_name}%")
query += " ORDER BY timestamp DESC LIMIT ?"
query += " ORDER BY timestamp DESC LIMIT ? OFFSET ?"
params.append(limit)
params.append(offset)
try:
with self._get_connection() as conn:
@@ -264,6 +267,59 @@ class LogStore:
logger.error(f"Failed to query logs: {e}")
return []
def count_logs(
self,
level: Optional[str] = None,
q: Optional[str] = None,
since: Optional[str] = None,
logger_name: Optional[str] = None
) -> int:
"""Count logs matching the given filters.
Args:
level: Filter by log level (e.g., "ERROR", "WARNING").
q: Search query for message content (case-insensitive).
since: ISO timestamp to filter logs after this time.
logger_name: Filter by logger name prefix.
Returns:
Number of matching logs.
"""
if not LOG_STORE_ENABLED:
return 0
if not self._initialized:
self.initialize()
query = "SELECT COUNT(*) FROM logs WHERE 1=1"
params: List[Any] = []
if level:
query += " AND level = ?"
params.append(level.upper())
if q:
query += " AND message LIKE ?"
params.append(f"%{q}%")
if since:
query += " AND timestamp >= ?"
params.append(since)
if logger_name:
query += " AND logger_name LIKE ?"
params.append(f"{logger_name}%")
try:
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute(query, params)
row = cursor.fetchone()
return row[0] if row else 0
except Exception as e:
logger.error(f"Failed to count logs: {e}")
return 0
def cleanup_old_logs(self) -> int:
"""Remove logs older than retention period or exceeding max rows.

View File

@@ -133,21 +133,29 @@ def api_logs():
"success": True,
"data": {
"logs": [],
"enabled": False
"enabled": False,
"total": 0
}
})
# Query parameters
level = request.args.get("level")
q = request.args.get("q")
limit = request.args.get("limit", 200, type=int)
limit = request.args.get("limit", 50, type=int)
offset = request.args.get("offset", 0, type=int)
since = request.args.get("since")
log_store = get_log_store()
# Get total count for pagination
total = log_store.count_logs(level=level, q=q, since=since)
# Get paginated logs
logs = log_store.query_logs(
level=level,
q=q,
limit=min(limit, 500), # Cap at 500
limit=min(limit, 100), # Cap at 100 per page
offset=offset,
since=since
)
@@ -156,6 +164,7 @@ def api_logs():
"data": {
"logs": logs,
"count": len(logs),
"total": total,
"enabled": True,
"stats": log_store.get_stats()
}

View File

@@ -51,6 +51,22 @@
gap: 16px;
}
.back-link-header {
background: rgba(255, 255, 255, 0.2);
color: white;
border: 1px solid rgba(255, 255, 255, 0.3);
padding: 8px 16px;
border-radius: 6px;
text-decoration: none;
font-size: 14px;
transition: background 0.2s ease;
}
.back-link-header:hover {
background: rgba(255, 255, 255, 0.3);
text-decoration: none;
}
.refresh-controls {
display: flex;
align-items: center;
@@ -259,8 +275,39 @@
font-size: 14px;
}
.back-link:hover {
text-decoration: underline;
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid #eee;
}
.pagination button {
padding: 6px 12px;
border: 1px solid #ddd;
background: white;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
transition: background 0.2s ease;
}
.pagination button:hover:not(:disabled) {
background: #f0f0f0;
}
.pagination button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.pagination .page-info {
font-size: 13px;
color: #666;
padding: 0 12px;
}
.last-update {
@@ -289,6 +336,7 @@
立即重新整理
</button>
</div>
<a href="{{ url_for('portal_index') }}" class="back-link-header">← 返回首頁</a>
</div>
</div>
@@ -603,7 +651,7 @@
</div>
<div class="log-filters">
<select id="logLevel" onchange="loadLogs()">
<select id="logLevel" onchange="loadLogs(true)">
<option value="">所有等級</option>
<option value="ERROR">ERROR</option>
<option value="WARNING">WARNING</option>
@@ -626,10 +674,17 @@
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="pagination">
<button id="prevPageBtn" onclick="prevPage()" disabled>← 上一頁</button>
<span class="page-info">
<span id="currentPage">1</span> 頁,共 <span id="totalPages">1</span>
(<span id="displayedCount">0</span> / <span id="totalLogCount">0</span> 筆)
</span>
<button id="nextPageBtn" onclick="nextPage()" disabled>下一頁 →</button>
</div>
</div>
</div>
<a href="{{ url_for('portal_index') }}" class="back-link">返回首頁</a>
</div>
{% endblock %}
@@ -643,6 +698,9 @@
let latencyChart = null;
let logSearchTimeout = null;
const REFRESH_INTERVAL = 30000; // 30 seconds
const PAGE_SIZE = 50;
let currentPage = 1;
let totalLogs = 0;
// ============================================================
// Chart Setup
@@ -769,12 +827,17 @@
}
}
async function loadLogs() {
async function loadLogs(resetPage = false) {
try {
if (resetPage) {
currentPage = 1;
}
const level = document.getElementById('logLevel').value;
const q = document.getElementById('logSearch').value;
const offset = (currentPage - 1) * PAGE_SIZE;
let url = '/admin/api/logs?limit=200';
let url = `/admin/api/logs?limit=${PAGE_SIZE}&offset=${offset}`;
if (level) url += `&level=${encodeURIComponent(level)}`;
if (q) url += `&q=${encodeURIComponent(q)}`;
@@ -788,6 +851,8 @@
// Update stats
if (data.enabled && data.stats) {
const stats = data.stats;
// Use data.total for filtered count (respects level/search filters)
totalLogs = data.total !== undefined ? data.total : (stats.count || 0);
document.getElementById('logStats').textContent =
`${stats.count} 筆 (${formatBytes(stats.size_bytes)})`;
document.getElementById('logTotalCount').textContent = stats.count.toLocaleString();
@@ -805,8 +870,22 @@
document.getElementById('logOldest').textContent = '--';
document.getElementById('logRetention').textContent = '--';
document.getElementById('logMaxRows').textContent = '--';
totalLogs = 0;
}
// Update pagination info
const totalPages = Math.max(1, Math.ceil(totalLogs / PAGE_SIZE));
const startIdx = totalLogs > 0 ? offset + 1 : 0;
const endIdx = data.logs ? Math.min(offset + data.logs.length, totalLogs) : 0;
document.getElementById('currentPage').textContent = currentPage;
document.getElementById('totalPages').textContent = totalPages;
document.getElementById('totalLogCount').textContent = totalLogs;
document.getElementById('displayedCount').textContent = `${startIdx}-${endIdx}`;
// Update pagination buttons
document.getElementById('prevPageBtn').disabled = currentPage <= 1;
document.getElementById('nextPageBtn').disabled = currentPage >= totalPages;
// Render logs
if (!data.logs || data.logs.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" style="text-align: center; color: #888;">無日誌記錄</td></tr>';
@@ -829,9 +908,24 @@
}
}
function prevPage() {
if (currentPage > 1) {
currentPage--;
loadLogs();
}
}
function nextPage() {
const totalPages = Math.ceil(totalLogs / PAGE_SIZE);
if (currentPage < totalPages) {
currentPage++;
loadLogs();
}
}
function debounceLoadLogs() {
if (logSearchTimeout) clearTimeout(logSearchTimeout);
logSearchTimeout = setTimeout(loadLogs, 300);
logSearchTimeout = setTimeout(() => loadLogs(true), 300);
}
// ============================================================

View File

@@ -157,6 +157,38 @@ class TestLogsAPI:
data = json.loads(response.data)
assert data["success"] is True
def test_logs_api_pagination(self, admin_client):
"""Logs API supports pagination with limit and offset."""
# Test with limit=10
response = admin_client.get('/admin/api/logs?limit=10&offset=0')
assert response.status_code == 200
data = json.loads(response.data)
assert data["success"] is True
assert "total" in data["data"]
assert "logs" in data["data"]
assert len(data["data"]["logs"]) <= 10
def test_logs_api_pagination_offset(self, admin_client):
"""Logs API offset skips entries correctly."""
# Get first page
response1 = admin_client.get('/admin/api/logs?limit=5&offset=0')
data1 = json.loads(response1.data)
# Get second page
response2 = admin_client.get('/admin/api/logs?limit=5&offset=5')
data2 = json.loads(response2.data)
# Total should be the same
assert data1["data"]["total"] == data2["data"]["total"]
# If there are enough logs, pages should be different
if data1["data"]["total"] > 5:
logs1_ids = [log.get("id") for log in data1["data"]["logs"]]
logs2_ids = [log.get("id") for log in data2["data"]["logs"]]
# No overlap between pages
assert not set(logs1_ids) & set(logs2_ids)
class TestLogsCleanupAPI:
"""Test log cleanup API endpoint."""