feat: push logs view in commands.html + paginated sync log API
Nexus CI/CD / test (push) Waiting to run
Nexus CI/CD / deploy (push) Blocked by required conditions
Nexus Pre-commit Checks / quick-check (push) Waiting to run

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:
Your Name
2026-05-23 17:20:53 +08:00
parent 14dc29f241
commit d2832567c7
4 changed files with 241 additions and 73 deletions
+15 -8
View File
@@ -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
View File
@@ -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
View File
@@ -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>