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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user