feat: push logs view in commands.html + paginated sync log API
sync_log_repo.py:
- get_all_paginated(): server_id/status/sync_mode/trigger_type filters +
server name JOIN + total count for pagination
server/api/servers.py GET /servers/logs:
- Add server_id/status/sync_mode/trigger_type/offset/limit query params
- Return {items, total, offset, limit} instead of plain list
web/app/commands.html (renamed title to '操作日志'):
- Third view: '推送日志' with paginated table
- Columns: 状态(dot badge) / 服务器 / 模式 / 触发方式 / 源路径 /
目标路径 / 文件数 / 耗时 / 操作人 / 开始时间
- Filters: 服务器 + 状态(success/failed/running) + 模式 (incremental/full/...)
- Pagination controls with prev/next page (50 per page)
- Error message in row title attribute (hover for details)
web/app/push.html:
- Update loadHistory() to handle new {items, total} response format
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+15
-8
@@ -488,19 +488,26 @@ async def push_to_servers(
|
||||
|
||||
# ── Sync Logs ──
|
||||
|
||||
@router.get("/logs", response_model=list)
|
||||
@router.get("/logs", response_model=dict)
|
||||
async def get_all_sync_logs(
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
server_id: Optional[int] = Query(None, description="Filter by server ID"),
|
||||
status: Optional[str] = Query(None, description="success|failed|running"),
|
||||
sync_mode: Optional[str] = Query(None, description="incremental|full|overwrite|checksum|command"),
|
||||
trigger_type: Optional[str] = Query(None, description="manual|schedule|retry|batch"),
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=500),
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get recent sync logs across all servers (for dashboard charts)"""
|
||||
result = await db.execute(
|
||||
select(SyncLog, Server.name).join(Server, SyncLog.server_id == Server.id, isouter=True)
|
||||
.order_by(SyncLog.started_at.desc()).limit(limit)
|
||||
"""Get paginated sync logs across all servers with optional filters."""
|
||||
from server.infrastructure.database.sync_log_repo import SyncLogRepositoryImpl
|
||||
repo = SyncLogRepositoryImpl(db)
|
||||
rows, total = await repo.get_all_paginated(
|
||||
server_id=server_id, status=status, sync_mode=sync_mode,
|
||||
trigger_type=trigger_type, offset=offset, limit=limit,
|
||||
)
|
||||
rows = result.all()
|
||||
return [_sync_log_to_dict(log, server_name=name) for log, name in rows]
|
||||
items = [_sync_log_to_dict(log, server_name=name) for log, name in rows]
|
||||
return {"items": items, "total": total, "offset": offset, "limit": limit}
|
||||
|
||||
|
||||
@router.get("/{id}/logs", response_model=list)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"""Nexus — SyncLog Repository (Async SQLAlchemy implementation)"""
|
||||
|
||||
from typing import Optional, List
|
||||
from sqlalchemy import select
|
||||
from typing import Optional, List, Tuple
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from server.domain.models import SyncLog
|
||||
from server.domain.models import SyncLog, Server
|
||||
|
||||
|
||||
class SyncLogRepositoryImpl:
|
||||
@@ -22,6 +22,45 @@ class SyncLogRepositoryImpl:
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_all_paginated(
|
||||
self,
|
||||
server_id: Optional[int] = None,
|
||||
status: Optional[str] = None,
|
||||
sync_mode: Optional[str] = None,
|
||||
trigger_type: Optional[str] = None,
|
||||
offset: int = 0,
|
||||
limit: int = 50,
|
||||
) -> Tuple[list, int]:
|
||||
"""Global sync log list with filters + server name join + total count."""
|
||||
filters = []
|
||||
if server_id:
|
||||
filters.append(SyncLog.server_id == server_id)
|
||||
if status:
|
||||
filters.append(SyncLog.status == status)
|
||||
if sync_mode:
|
||||
filters.append(SyncLog.sync_mode == sync_mode)
|
||||
if trigger_type:
|
||||
filters.append(SyncLog.trigger_type == trigger_type)
|
||||
|
||||
# Count
|
||||
count_stmt = select(func.count(SyncLog.id))
|
||||
if filters:
|
||||
count_stmt = count_stmt.where(*filters)
|
||||
total = int((await self.session.execute(count_stmt)).scalar() or 0)
|
||||
|
||||
# Data (with server name join)
|
||||
data_stmt = (
|
||||
select(SyncLog, Server.name.label("server_name"))
|
||||
.join(Server, SyncLog.server_id == Server.id, isouter=True)
|
||||
.order_by(SyncLog.started_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
if filters:
|
||||
data_stmt = data_stmt.where(*filters)
|
||||
rows = (await self.session.execute(data_stmt)).all()
|
||||
return rows, total
|
||||
|
||||
async def create(self, log: SyncLog) -> SyncLog:
|
||||
self.session.add(log)
|
||||
await self.session.commit()
|
||||
|
||||
+183
-61
@@ -1,33 +1,102 @@
|
||||
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 命令日志</title><script src="/app/vendor/tailwindcss-browser.js"></script><script defer src="/app/vendor/alpinejs.min.js"></script><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style></head>
|
||||
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 操作日志</title><script src="/app/vendor/tailwindcss-browser.js"></script><script defer src="/app/vendor/alpinejs.min.js"></script><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style></head>
|
||||
<body class="bg-slate-950 text-slate-100 min-h-screen flex" x-data="{sidebarOpen:true}">
|
||||
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0" data-sidebar></aside>
|
||||
<div class="flex-1 flex flex-col min-w-0">
|
||||
<header class="bg-slate-900 border-b border-slate-800 px-6 py-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-slate-400 hover:text-white transition"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></button><h1 class="text-white font-semibold">命令日志</h1></div>
|
||||
<div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-slate-400 hover:text-white transition"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></button><h1 class="text-white font-semibold">操作日志</h1></div>
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- View selector -->
|
||||
<select id="logView" onchange="switchView()" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
|
||||
<option value="commands">命令记录</option>
|
||||
<option value="sessions">SSH会话</option>
|
||||
<option value="commands">SSH 命令记录</option>
|
||||
<option value="sessions">SSH 会话</option>
|
||||
<option value="push">推送日志</option>
|
||||
</select>
|
||||
<select id="serverFilter" onchange="loadData()" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm max-w-[180px]">
|
||||
<!-- Server filter (all views) -->
|
||||
<select id="serverFilter" onchange="onFilterChange()" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm max-w-[160px]">
|
||||
<option value="">全部服务器</option>
|
||||
</select>
|
||||
<!-- Push-specific filters (only shown for push view) -->
|
||||
<select id="pushStatusFilter" onchange="onFilterChange()" class="hidden px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
|
||||
<option value="">全部状态</option>
|
||||
<option value="success">成功</option>
|
||||
<option value="failed">失败</option>
|
||||
<option value="running">运行中</option>
|
||||
</select>
|
||||
<select id="pushModeFilter" onchange="onFilterChange()" class="hidden px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
|
||||
<option value="">全部模式</option>
|
||||
<option value="incremental">增量</option>
|
||||
<option value="full">全量</option>
|
||||
<option value="overwrite">覆盖</option>
|
||||
<option value="checksum">校验和</option>
|
||||
<option value="command">命令</option>
|
||||
</select>
|
||||
<button onclick="loadData()" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-white text-sm rounded-lg transition">刷新</button>
|
||||
</div>
|
||||
</header>
|
||||
<main class="flex-1 overflow-y-auto p-6">
|
||||
<main class="flex-1 overflow-y-auto p-6 space-y-4">
|
||||
|
||||
<!-- Commands View -->
|
||||
<div id="commandsView">
|
||||
<div id="commandsTable" class="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
|
||||
<table class="w-full text-sm"><thead class="bg-slate-800/50 text-slate-400 text-xs uppercase"><tr><th class="text-left px-4 py-3">命令</th><th class="text-left px-4 py-3">服务器</th><th class="text-left px-4 py-3">会话</th><th class="text-left px-4 py-3">来源IP</th><th class="text-right px-4 py-3">时间</th></tr></thead><tbody id="commandsTbody" class="divide-y divide-slate-800"><tr><td colspan="5" class="px-4 py-8 text-center text-slate-500">加载中...</td></tr></tbody></table>
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 overflow-x-auto">
|
||||
<table class="w-full text-sm min-w-[640px]">
|
||||
<thead class="bg-slate-800/50 text-slate-400 text-xs uppercase">
|
||||
<tr><th class="text-left px-4 py-3">命令</th><th class="text-left px-4 py-3 whitespace-nowrap">服务器</th><th class="text-left px-4 py-3">会话</th><th class="text-left px-4 py-3">来源IP</th><th class="text-right px-4 py-3 whitespace-nowrap">时间</th></tr>
|
||||
</thead>
|
||||
<tbody id="commandsTbody" class="divide-y divide-slate-800">
|
||||
<tr><td colspan="5" class="px-4 py-8 text-center text-slate-500">加载中...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sessions View -->
|
||||
<div id="sessionsView" class="hidden">
|
||||
<div id="sessionsTable" class="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
|
||||
<table class="w-full text-sm"><thead class="bg-slate-800/50 text-slate-400 text-xs uppercase"><tr><th class="text-left px-4 py-3">会话ID</th><th class="text-left px-4 py-3">服务器</th><th class="text-left px-4 py-3">来源IP</th><th class="text-left px-4 py-3">状态</th><th class="text-left px-4 py-3">开始时间</th><th class="text-left px-4 py-3">结束时间</th><th class="text-right px-4 py-3">操作</th></tr></thead><tbody id="sessionsTbody" class="divide-y divide-slate-800"><tr><td colspan="7" class="px-4 py-8 text-center text-slate-500">加载中...</td></tr></tbody></table>
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 overflow-x-auto">
|
||||
<table class="w-full text-sm min-w-[700px]">
|
||||
<thead class="bg-slate-800/50 text-slate-400 text-xs uppercase">
|
||||
<tr><th class="text-left px-4 py-3">会话ID</th><th class="text-left px-4 py-3">服务器</th><th class="text-left px-4 py-3">来源IP</th><th class="text-left px-4 py-3">状态</th><th class="text-left px-4 py-3 whitespace-nowrap">开始时间</th><th class="text-left px-4 py-3 whitespace-nowrap">结束时间</th><th class="text-right px-4 py-3">操作</th></tr>
|
||||
</thead>
|
||||
<tbody id="sessionsTbody" class="divide-y divide-slate-800">
|
||||
<tr><td colspan="7" class="px-4 py-8 text-center text-slate-500">加载中...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Push Logs View -->
|
||||
<div id="pushView" class="hidden space-y-3">
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 overflow-x-auto">
|
||||
<table class="w-full text-sm min-w-[800px]">
|
||||
<thead class="bg-slate-800/50 text-slate-400 text-xs uppercase">
|
||||
<tr>
|
||||
<th class="text-left px-4 py-3">状态</th>
|
||||
<th class="text-left px-4 py-3">服务器</th>
|
||||
<th class="text-left px-4 py-3">模式</th>
|
||||
<th class="text-left px-4 py-3">触发</th>
|
||||
<th class="text-left px-4 py-3">源路径</th>
|
||||
<th class="text-left px-4 py-3">目标路径</th>
|
||||
<th class="text-right px-4 py-3">文件数</th>
|
||||
<th class="text-right px-4 py-3">耗时</th>
|
||||
<th class="text-left px-4 py-3">操作人</th>
|
||||
<th class="text-right px-4 py-3 whitespace-nowrap">开始时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="pushTbody" class="divide-y divide-slate-800">
|
||||
<tr><td colspan="10" class="px-4 py-8 text-center text-slate-500">加载中...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- Pagination -->
|
||||
<div id="pushPagination" class="hidden flex items-center justify-between text-sm">
|
||||
<span id="pushTotal" class="text-slate-500 text-xs"></span>
|
||||
<div class="flex items-center gap-2">
|
||||
<button id="pushPrevBtn" onclick="pushPage(-1)" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-white text-xs rounded-lg transition disabled:opacity-40">← 上一页</button>
|
||||
<span id="pushPageInfo" class="text-slate-400 text-xs"></span>
|
||||
<button id="pushNextBtn" onclick="pushPage(1)" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-white text-xs rounded-lg transition disabled:opacity-40">下一页 →</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
<script src="/app/api.js"></script>
|
||||
@@ -36,9 +105,11 @@
|
||||
initLayout('commands');
|
||||
|
||||
let _currentView = 'commands';
|
||||
let _servers = {}; // id -> name cache
|
||||
let _servers = {};
|
||||
let _pushOffset = 0;
|
||||
const PUSH_PAGE_SIZE = 50;
|
||||
let _pushTotal = 0;
|
||||
|
||||
// Load server list for filter dropdown
|
||||
async function loadServers() {
|
||||
try {
|
||||
const r = await apiFetch(API + '/servers/?per_page=200');
|
||||
@@ -59,41 +130,43 @@
|
||||
_currentView = document.getElementById('logView').value;
|
||||
document.getElementById('commandsView').classList.toggle('hidden', _currentView !== 'commands');
|
||||
document.getElementById('sessionsView').classList.toggle('hidden', _currentView !== 'sessions');
|
||||
document.getElementById('pushView').classList.toggle('hidden', _currentView !== 'push');
|
||||
// Show/hide push-specific filters
|
||||
const isPush = _currentView === 'push';
|
||||
document.getElementById('pushStatusFilter').classList.toggle('hidden', !isPush);
|
||||
document.getElementById('pushModeFilter').classList.toggle('hidden', !isPush);
|
||||
_pushOffset = 0;
|
||||
loadData();
|
||||
}
|
||||
|
||||
function onFilterChange() {
|
||||
_pushOffset = 0;
|
||||
loadData();
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
if (_currentView === 'commands') {
|
||||
await loadCommands();
|
||||
} else {
|
||||
await loadSessions();
|
||||
}
|
||||
if (_currentView === 'commands') await loadCommands();
|
||||
else if (_currentView === 'sessions') await loadSessions();
|
||||
else await loadPushLogs();
|
||||
}
|
||||
|
||||
async function loadCommands() {
|
||||
const serverId = document.getElementById('serverFilter').value;
|
||||
let url = API + '/assets/command-logs?limit=200';
|
||||
if (serverId) url += '&server_id=' + serverId;
|
||||
|
||||
const r = await apiFetch(url);
|
||||
if (!r) return;
|
||||
const logs = await r.json();
|
||||
const tbody = document.getElementById('commandsTbody');
|
||||
|
||||
if (!logs.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="px-4 py-8 text-center text-slate-500">暂无命令日志</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!logs.length) { tbody.innerHTML = '<tr><td colspan="5" class="px-4 py-8 text-center text-slate-500">暂无命令日志</td></tr>'; return; }
|
||||
tbody.innerHTML = logs.map(l => {
|
||||
const cmdShort = (l.command || '').length > 120 ? l.command.substring(0, 120) + '...' : l.command;
|
||||
const isDanger = /rm\s|chmod\s|chown\s|dd\s|mkfs|fdisk|:()\s*\{.*&\s*\}|wget\s.*\|\s*(ba)?sh|curl\s.*\|\s*(ba)?sh/i.test(l.command || '');
|
||||
const cmdClass = isDanger ? 'text-red-400' : 'text-slate-200';
|
||||
return `<tr class="hover:bg-slate-800/30 transition">
|
||||
<td class="px-4 py-2.5 font-mono text-xs ${cmdClass} max-w-md" title="${esc(l.command || '')}">${esc(cmdShort)}</td>
|
||||
<td class="px-4 py-2.5 text-slate-400 text-xs whitespace-nowrap">${esc(_servers[l.server_id] || 'Server#' + l.server_id)}</td>
|
||||
<td class="px-4 py-2.5"><a href="#" onclick="filterBySession('${l.session_id || ''}')" class="text-brand-light hover:underline text-xs font-mono">${esc((l.session_id || '').substring(0, 8) || '--')}</a></td>
|
||||
<td class="px-4 py-2.5 text-slate-500 text-xs">${esc(l.remote_addr || '--')}</td>
|
||||
<td class="px-4 py-2.5 font-mono text-xs ${isDanger?'text-red-400':'text-slate-200'} max-w-xs truncate" title="${esc(l.command||'')}">${esc(cmdShort)}</td>
|
||||
<td class="px-4 py-2.5 text-slate-400 text-xs whitespace-nowrap">${esc(_servers[l.server_id]||'#'+l.server_id)}</td>
|
||||
<td class="px-4 py-2.5"><a href="#" onclick="filterBySession('${l.session_id||''}');return false" class="text-brand-light hover:underline text-xs font-mono">${esc((l.session_id||'').substring(0,8)||'--')}</a></td>
|
||||
<td class="px-4 py-2.5 text-slate-500 text-xs">${esc(l.remote_addr||'--')}</td>
|
||||
<td class="px-4 py-2.5 text-slate-500 text-xs text-right whitespace-nowrap">${fmt(l.created_at)}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
@@ -103,26 +176,20 @@
|
||||
const serverId = document.getElementById('serverFilter').value;
|
||||
let url = API + '/assets/ssh-sessions?limit=50';
|
||||
if (serverId) url += '&server_id=' + serverId;
|
||||
|
||||
const r = await apiFetch(url);
|
||||
if (!r) return;
|
||||
const sessions = await r.json();
|
||||
const tbody = document.getElementById('sessionsTbody');
|
||||
|
||||
if (!sessions.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="px-4 py-8 text-center text-slate-500">暂无SSH会话</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sessions.length) { tbody.innerHTML = '<tr><td colspan="7" class="px-4 py-8 text-center text-slate-500">暂无SSH会话</td></tr>'; return; }
|
||||
tbody.innerHTML = sessions.map(s => {
|
||||
const statusBadge = s.status === 'active'
|
||||
? '<span class="px-2 py-0.5 rounded text-xs bg-green-400/10 text-green-400">在线</span>'
|
||||
: '<span class="px-2 py-0.5 rounded text-xs bg-slate-700 text-slate-400">已断开</span>';
|
||||
const badge = s.status==='active'
|
||||
?'<span class="px-2 py-0.5 rounded text-xs bg-green-400/10 text-green-400">在线</span>'
|
||||
:'<span class="px-2 py-0.5 rounded text-xs bg-slate-700 text-slate-400">已断开</span>';
|
||||
return `<tr class="hover:bg-slate-800/30 transition">
|
||||
<td class="px-4 py-2.5 font-mono text-xs text-brand-light">${esc(s.id.substring(0, 8))}...</td>
|
||||
<td class="px-4 py-2.5 text-slate-400 text-xs whitespace-nowrap">${esc(_servers[s.server_id] || 'Server#' + s.server_id)}</td>
|
||||
<td class="px-4 py-2.5 text-slate-500 text-xs">${esc(s.remote_addr || '--')}</td>
|
||||
<td class="px-4 py-2.5">${statusBadge}</td>
|
||||
<td class="px-4 py-2.5 font-mono text-xs text-brand-light">${esc(s.id.substring(0,8))}...</td>
|
||||
<td class="px-4 py-2.5 text-slate-400 text-xs whitespace-nowrap">${esc(_servers[s.server_id]||'#'+s.server_id)}</td>
|
||||
<td class="px-4 py-2.5 text-slate-500 text-xs">${esc(s.remote_addr||'--')}</td>
|
||||
<td class="px-4 py-2.5">${badge}</td>
|
||||
<td class="px-4 py-2.5 text-slate-500 text-xs whitespace-nowrap">${fmt(s.started_at)}</td>
|
||||
<td class="px-4 py-2.5 text-slate-500 text-xs whitespace-nowrap">${fmt(s.closed_at)}</td>
|
||||
<td class="px-4 py-2.5 text-right"><button onclick="viewSessionCommands('${s.id}')" class="text-brand-light hover:text-white text-xs transition">查看命令</button></td>
|
||||
@@ -130,43 +197,98 @@
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ── Push Logs ──
|
||||
async function loadPushLogs() {
|
||||
const serverId = document.getElementById('serverFilter').value;
|
||||
const status = document.getElementById('pushStatusFilter').value;
|
||||
const mode = document.getElementById('pushModeFilter').value;
|
||||
|
||||
let url = `${API}/servers/logs?limit=${PUSH_PAGE_SIZE}&offset=${_pushOffset}`;
|
||||
if (serverId) url += '&server_id=' + serverId;
|
||||
if (status) url += '&status=' + status;
|
||||
if (mode) url += '&sync_mode=' + mode;
|
||||
|
||||
const r = await apiFetch(url);
|
||||
if (!r) return;
|
||||
const res = await r.json();
|
||||
const items = res.items || [];
|
||||
_pushTotal = res.total || 0;
|
||||
|
||||
const tbody = document.getElementById('pushTbody');
|
||||
if (!items.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="10" class="px-4 py-8 text-center text-slate-500">暂无推送记录</td></tr>';
|
||||
document.getElementById('pushPagination').classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = items.map(l => {
|
||||
const sc = l.status==='success'?'text-green-400':l.status==='failed'?'text-red-400':l.status==='running'?'text-yellow-400':'text-slate-400';
|
||||
const scDot = l.status==='success'?'bg-green-400':l.status==='failed'?'bg-red-400':l.status==='running'?'bg-yellow-400 animate-pulse':'bg-slate-500';
|
||||
const modeBadge = {incremental:'增量',full:'全量',overwrite:'覆盖',checksum:'校验和',command:'命令'}[l.sync_mode]||l.sync_mode||'--';
|
||||
const triggerBadge = {manual:'手动',schedule:'定时',retry:'重试',batch:'批量'}[l.trigger_type]||l.trigger_type||'--';
|
||||
const errTitle = l.error_message ? ` title="${esc(l.error_message)}"` : '';
|
||||
return `<tr class="hover:bg-slate-800/30 transition"${errTitle}>
|
||||
<td class="px-4 py-2.5">
|
||||
<span class="inline-flex items-center gap-1.5 text-xs ${sc}">
|
||||
<span class="w-1.5 h-1.5 rounded-full ${scDot} inline-block shrink-0"></span>
|
||||
${esc(l.status||'--')}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-2.5 text-slate-300 text-xs whitespace-nowrap">${esc(l.server_name||_servers[l.server_id]||'#'+l.server_id)}</td>
|
||||
<td class="px-4 py-2.5"><span class="text-xs px-1.5 py-0.5 rounded bg-slate-800 text-slate-400">${esc(modeBadge)}</span></td>
|
||||
<td class="px-4 py-2.5 text-slate-500 text-xs">${esc(triggerBadge)}</td>
|
||||
<td class="px-4 py-2.5 text-slate-400 text-xs font-mono max-w-[140px] truncate" title="${esc(l.source_path||'')}">${esc(l.source_path||'--')}</td>
|
||||
<td class="px-4 py-2.5 text-slate-400 text-xs font-mono max-w-[140px] truncate" title="${esc(l.target_path||'')}">${esc(l.target_path||'--')}</td>
|
||||
<td class="px-4 py-2.5 text-slate-400 text-xs text-right">${l.files_transferred!=null?l.files_transferred:'--'}</td>
|
||||
<td class="px-4 py-2.5 text-slate-400 text-xs text-right whitespace-nowrap">${l.duration_seconds!=null?l.duration_seconds+'s':'--'}</td>
|
||||
<td class="px-4 py-2.5 text-slate-500 text-xs">${esc(l.operator||'--')}</td>
|
||||
<td class="px-4 py-2.5 text-slate-500 text-xs text-right whitespace-nowrap">${fmt(l.started_at)}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
// Pagination controls
|
||||
const pg = document.getElementById('pushPagination');
|
||||
const curPage = Math.floor(_pushOffset / PUSH_PAGE_SIZE) + 1;
|
||||
const totalPages = Math.max(1, Math.ceil(_pushTotal / PUSH_PAGE_SIZE));
|
||||
pg.classList.remove('hidden');
|
||||
document.getElementById('pushTotal').textContent = `共 ${_pushTotal} 条记录`;
|
||||
document.getElementById('pushPageInfo').textContent = `第 ${curPage} / ${totalPages} 页`;
|
||||
document.getElementById('pushPrevBtn').disabled = _pushOffset === 0;
|
||||
document.getElementById('pushNextBtn').disabled = _pushOffset + PUSH_PAGE_SIZE >= _pushTotal;
|
||||
}
|
||||
|
||||
function pushPage(dir) {
|
||||
_pushOffset = Math.max(0, _pushOffset + dir * PUSH_PAGE_SIZE);
|
||||
loadPushLogs();
|
||||
}
|
||||
|
||||
// ── Session helpers ──
|
||||
async function viewSessionCommands(sessionId) {
|
||||
document.getElementById('logView').value = 'commands';
|
||||
_currentView = 'commands';
|
||||
document.getElementById('commandsView').classList.remove('hidden');
|
||||
document.getElementById('sessionsView').classList.add('hidden');
|
||||
|
||||
switchView();
|
||||
const r = await apiFetch(API + '/assets/command-logs?session_id=' + encodeURIComponent(sessionId) + '&limit=500');
|
||||
if (!r) return;
|
||||
const logs = await r.json();
|
||||
const tbody = document.getElementById('commandsTbody');
|
||||
|
||||
if (!logs.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="px-4 py-8 text-center text-slate-500">该会话无命令记录</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!logs.length) { tbody.innerHTML = '<tr><td colspan="5" class="px-4 py-8 text-center text-slate-500">该会话无命令记录</td></tr>'; return; }
|
||||
tbody.innerHTML = logs.map(l => {
|
||||
const cmdShort = (l.command || '').length > 120 ? l.command.substring(0, 120) + '...' : l.command;
|
||||
return `<tr class="hover:bg-slate-800/30 transition">
|
||||
<td class="px-4 py-2.5 font-mono text-xs text-slate-200 max-w-md" title="${esc(l.command || '')}">${esc(cmdShort)}</td>
|
||||
<td class="px-4 py-2.5 text-slate-400 text-xs whitespace-nowrap">${esc(_servers[l.server_id] || 'Server#' + l.server_id)}</td>
|
||||
<td class="px-4 py-2.5 text-brand-light text-xs font-mono">${esc((l.session_id || '').substring(0, 8) || '--')}</td>
|
||||
<td class="px-4 py-2.5 text-slate-500 text-xs">${esc(l.remote_addr || '--')}</td>
|
||||
<td class="px-4 py-2.5 font-mono text-xs text-slate-200 max-w-xs truncate" title="${esc(l.command||'')}">${esc(cmdShort)}</td>
|
||||
<td class="px-4 py-2.5 text-slate-400 text-xs whitespace-nowrap">${esc(_servers[l.server_id]||'#'+l.server_id)}</td>
|
||||
<td class="px-4 py-2.5 text-brand-light text-xs font-mono">${esc((l.session_id||'').substring(0,8)||'--')}</td>
|
||||
<td class="px-4 py-2.5 text-slate-500 text-xs">${esc(l.remote_addr||'--')}</td>
|
||||
<td class="px-4 py-2.5 text-slate-500 text-xs text-right whitespace-nowrap">${fmt(l.created_at)}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function filterBySession(sessionId) {
|
||||
if (!sessionId) return;
|
||||
viewSessionCommands(sessionId);
|
||||
}
|
||||
function filterBySession(sid) { if (sid) viewSessionCommands(sid); }
|
||||
|
||||
function esc(s) { if (!s) return ''; const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
||||
function fmt(t) { if (!t) return '--'; return new Date(t + 'Z').toLocaleString('zh-CN', { month:'short', day:'numeric', hour:'2-digit', minute:'2-digit', second:'2-digit' }); }
|
||||
|
||||
// Init
|
||||
loadServers().then(() => loadData());
|
||||
</script>
|
||||
</body></html>
|
||||
|
||||
+1
-1
@@ -247,7 +247,7 @@
|
||||
async function loadHistory(){
|
||||
try{
|
||||
const r=await apiFetch(API+'/servers/logs?limit=10');if(!r)return;
|
||||
const logs=await r.json();
|
||||
const res=await r.json();const logs=res.items||res;
|
||||
document.getElementById('pushHistory').innerHTML=logs.length?logs.map(l=>`<div class="bg-slate-900 rounded-lg border border-slate-800 px-4 py-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="${l.status==='success'?'text-green-400':'text-red-400'} text-sm">${l.status==='success'?'✓':'✗'}</span>
|
||||
|
||||
Reference in New Issue
Block a user