新页面: 资产管理 + 命令日志 + 数据库备份脚本

- assets.html: Platform/Node资产管理页面
- commands.html: 命令/会话双视图 + 危险命令高亮
- deploy/db_backup.sh: mysqldump + 30天保留 + cron集成

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-05-22 22:29:13 +08:00
parent b676e320de
commit 4969876ce8
3 changed files with 578 additions and 0 deletions
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env bash
# Nexus — MySQL Auto-Backup Script
# Usage: Add to crontab for daily 3AM backup
# 0 3 * * * /path/to/nexus/deploy/db_backup.sh >> /var/log/nexus_backup.log 2>&1
#
# Reads DATABASE_URL from .env for connection parameters.
# Retains backups for 30 days, auto-deletes older ones.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
ENV_FILE="$PROJECT_DIR/.env"
BACKUP_DIR="$PROJECT_DIR/backups"
RETENTION_DAYS=30
# ── Load .env ──
if [ ! -f "$ENV_FILE" ]; then
echo "[$(date)] ERROR: .env not found at $ENV_FILE"
exit 1
fi
# Parse DATABASE_URL from .env
# Format: mysql+asyncmy://user:password@host:port/dbname
DB_URL=$(grep -E '^NEXUS_DATABASE_URL=' "$ENV_FILE" | head -1 | cut -d= -f2- | tr -d '"' | tr -d "'")
if [ -z "$DB_URL" ]; then
DB_URL=$(grep -E '^DATABASE_URL=' "$ENV_FILE" | head -1 | cut -d= -f2- | tr -d '"' | tr -d "'")
fi
if [ -z "$DB_URL" ]; then
echo "[$(date)] ERROR: DATABASE_URL not found in .env"
exit 1
fi
# Parse mysql URL: mysql+asyncmy://user:pass@host:port/dbname → user pass host port dbname
# Strip scheme
CONN="${DB_URL#*://}"
# Extract user:pass
USERPASS="${CONN%%@*}"
DB_USER="${USERPASS%%:*}"
DB_PASS="${USERPASS#*:}"
# Extract host:port/dbname
HOSTDB="${CONN#*@}"
DB_HOST="${HOSTDB%%:*}"
PORTDB="${HOSTDB#*:}"
DB_PORT="${PORTDB%%/*}"
DB_NAME="${PORTDB#*/}"
# Remove query params
DB_NAME="${DB_NAME%%\?*}"
if [ -z "$DB_NAME" ] || [ -z "$DB_HOST" ] || [ -z "$DB_USER" ]; then
echo "[$(date)] ERROR: Failed to parse DATABASE_URL"
exit 1
fi
# ── Create backup directory ──
mkdir -p "$BACKUP_DIR"
# ── Backup filename ──
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="$BACKUP_DIR/nexus_${DB_NAME}_${TIMESTAMP}.sql.gz"
# ── Run mysqldump ──
echo "[$(date)] Starting backup: $DB_NAME @ $DB_HOST"
if [ -n "$DB_PASS" ]; then
MYSQL_PWD="$DB_PASS" mysqldump \
-h "$DB_HOST" \
-P "${DB_PORT:-3306}" \
-u "$DB_USER" \
--single-transaction \
--routines \
--triggers \
--set-gtid-purged=OFF \
"$DB_NAME" | gzip > "$BACKUP_FILE"
else
mysqldump \
-h "$DB_HOST" \
-P "${DB_PORT:-3306}" \
-u "$DB_USER" \
--single-transaction \
--routines \
--triggers \
--set-gtid-purged=OFF \
"$DB_NAME" | gzip > "$BACKUP_FILE"
fi
# ── Verify backup ──
if [ -f "$BACKUP_FILE" ]; then
SIZE=$(du -h "$BACKUP_FILE" | cut -f1)
echo "[$(date)] Backup completed: $BACKUP_FILE ($SIZE)"
else
echo "[$(date)] ERROR: Backup file not created!"
exit 1
fi
# ── Cleanup old backups (retain 30 days) ──
DELETED=$(find "$BACKUP_DIR" -name "nexus_*.sql.gz" -mtime +${RETENTION_DAYS} -delete -print | wc -l)
if [ "$DELETED" -gt 0 ]; then
echo "[$(date)] Cleaned up $DELETED old backup(s) (>${RETENTION_DAYS} days)"
fi
echo "[$(date)] Backup process complete"
+303
View File
@@ -0,0 +1,303 @@
<!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="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script><script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.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,tab:'platforms'}">
<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>
<button onclick="showAddModal()" class="px-3 py-1.5 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">+ 新建</button>
</header>
<!-- Tabs -->
<div class="bg-slate-900 border-b border-slate-800 px-6 flex">
<button onclick="switchTab('platforms')" id="tabPlatforms" class="px-4 py-3 text-sm border-b-2 border-brand text-brand-light transition">平台模板</button>
<button onclick="switchTab('nodes')" id="tabNodes" class="px-4 py-3 text-sm border-b-2 border-transparent text-slate-400 hover:text-white transition">节点分组</button>
<button onclick="switchTab('sessions')" id="tabSessions" class="px-4 py-3 text-sm border-b-2 border-transparent text-slate-400 hover:text-white transition">SSH 会话</button>
</div>
<main class="flex-1 overflow-y-auto p-6">
<!-- Platforms -->
<div id="platformsSection">
<div id="platformsList" class="space-y-3"><div class="text-slate-500 text-center py-8">加载中...</div></div>
</div>
<!-- Nodes (Tree) -->
<div id="nodesSection" class="hidden">
<div id="nodesTree" class="space-y-1"><div class="text-slate-500 text-center py-8">加载中...</div></div>
</div>
<!-- SSH Sessions -->
<div id="sessionsSection" class="hidden">
<div id="sessionsList" class="space-y-2"><div class="text-slate-500 text-center py-8">加载中...</div></div>
</div>
<!-- Platform Modal -->
<div id="platformModal" class="hidden fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div class="bg-slate-900 border border-slate-700 rounded-xl p-6 w-full max-w-md space-y-3">
<h2 id="platformModalTitle" class="text-white font-semibold text-lg">新建平台模板</h2>
<input type="hidden" id="platEditId" value="">
<div><label class="block text-slate-400 text-xs mb-1">名称</label><input id="platName" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="CentOS 7"></div>
<div class="grid grid-cols-2 gap-3">
<div><label class="block text-slate-400 text-xs mb-1">分类</label><input id="platCategory" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="linux"></div>
<div><label class="block text-slate-400 text-xs mb-1">类型</label><input id="platType" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="centos"></div>
</div>
<div><label class="block text-slate-400 text-xs mb-1">默认协议</label><input id="platProtocols" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="ssh,sftp (逗号分隔)"></div>
<div><label class="block text-slate-400 text-xs mb-1">字符集</label><input id="platCharset" value="utf-8" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm"></div>
<div class="flex gap-2 pt-2"><button onclick="savePlatform()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm">保存</button><button onclick="hidePlatformModal()" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm">取消</button></div>
</div>
</div>
<!-- Node Modal -->
<div id="nodeModal" class="hidden fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div class="bg-slate-900 border border-slate-700 rounded-xl p-6 w-full max-w-md space-y-3">
<h2 id="nodeModalTitle" class="text-white font-semibold text-lg">新建节点</h2>
<input type="hidden" id="nodeEditId" value="">
<div><label class="block text-slate-400 text-xs mb-1">名称</label><input id="nodeName" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="生产环境"></div>
<div><label class="block text-slate-400 text-xs mb-1">父节点</label><select id="nodeParent" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm"><option value="">— 无 (根节点) —</option></select></div>
<div><label class="block text-slate-400 text-xs mb-1">排序</label><input id="nodeSort" type="number" value="0" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm"></div>
<div class="flex gap-2 pt-2"><button onclick="saveNode()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm">保存</button><button onclick="hideNodeModal()" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm">取消</button></div>
</div>
</div>
</main>
</div>
<script src="/app/api.js"></script>
<script src="/app/layout.js"></script>
<script>
initLayout('assets');
let _allNodes=[];
function switchTab(t){
const tabs=['platforms','nodes','sessions'];
tabs.forEach(x=>{
document.getElementById('tab'+x.charAt(0).toUpperCase()+x.slice(1)).className='px-4 py-3 text-sm border-b-2 '+(x===t?'border-brand text-brand-light':'border-transparent text-slate-400 hover:text-white')+' transition';
document.getElementById(x+'Section').classList.toggle('hidden',x!==t);
});
if(t==='nodes')loadNodes();
if(t==='sessions')loadSessions();
}
function showAddModal(){
const tab=document.getElementById('platformsSection').classList.contains('hidden')?
(document.getElementById('nodesSection').classList.contains('hidden')?'sessions':'nodes'):'platforms';
if(tab==='platforms')showAddPlatform();
else if(tab==='nodes')showAddNode();
}
// ── Platforms ──
async function loadPlatforms(){
try{
const r=await apiFetch(API+'/assets/platforms');if(!r)return;
const platforms=await r.json();
const cats={};
platforms.forEach(p=>{const c=p.category||'其他';if(!cats[c])cats[c]=[];cats[c].push(p)});
let html='';
for(const[cat,items]of Object.entries(cats)){
html+=`<div class="mb-4"><div class="text-slate-400 text-xs uppercase mb-2 px-1">${esc(cat)}</div><div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">`;
items.forEach(p=>{
html+=`<div class="bg-slate-900 rounded-xl border border-slate-800 p-4 hover:border-slate-700 transition">
<div class="flex items-center justify-between mb-2">
<span class="text-white font-medium">${esc(p.name)}</span>
<div class="flex gap-2">
<button onclick="editPlatform(${p.id})" class="text-slate-500 hover:text-white text-xs">编辑</button>
<button onclick="deletePlatform(${p.id})" class="text-red-400 text-xs hover:underline">删除</button>
</div>
</div>
<div class="text-slate-500 text-xs space-y-1">
<div>分类: ${esc(p.category)} · 类型: ${esc(p.type)}</div>
<div>协议: ${esc((p.default_protocols||[]).join(', ')||'ssh')} · 字符集: ${esc(p.charset)}</div>
</div>
</div>`;
});
html+=`</div></div>`;
}
document.getElementById('platformsList').innerHTML=html||'<div class="text-slate-500 text-center py-8">暂无平台模板</div>';
}catch(e){document.getElementById('platformsList').innerHTML='<div class="text-red-400 text-center py-8">加载失败</div>'}
}
function showAddPlatform(){
document.getElementById('platformModalTitle').textContent='新建平台模板';
document.getElementById('platEditId').value='';
document.getElementById('platName').value='';
document.getElementById('platCategory').value='';
document.getElementById('platType').value='';
document.getElementById('platProtocols').value='';
document.getElementById('platCharset').value='utf-8';
document.getElementById('platformModal').classList.remove('hidden');
}
async function editPlatform(id){
try{
const r=await apiFetch(API+'/assets/platforms/'+id);if(!r)return;
const p=await r.json();
document.getElementById('platformModalTitle').textContent='编辑平台模板';
document.getElementById('platEditId').value=id;
document.getElementById('platName').value=p.name||'';
document.getElementById('platCategory').value=p.category||'';
document.getElementById('platType').value=p.type||'';
document.getElementById('platProtocols').value=(p.default_protocols||[]).join(',');
document.getElementById('platCharset').value=p.charset||'utf-8';
document.getElementById('platformModal').classList.remove('hidden');
}catch(e){toast('error','加载失败')}
}
function hidePlatformModal(){document.getElementById('platformModal').classList.add('hidden')}
async function savePlatform(){
const editId=document.getElementById('platEditId').value;
const body={
name:document.getElementById('platName').value,
category:document.getElementById('platCategory').value,
type:document.getElementById('platType').value,
default_protocols:document.getElementById('platProtocols').value?document.getElementById('platProtocols').value.split(',').map(s=>s.trim()).filter(Boolean):null,
charset:document.getElementById('platCharset').value||'utf-8',
};
if(!body.name||!body.category||!body.type){toast('warning','名称、分类、类型必填');return}
const url=editId?API+'/assets/platforms/'+editId:API+'/assets/platforms';
const method=editId?'PUT':'POST';
const r=await apiFetch(url,{method,headers:apiHeadersJSON(),body:JSON.stringify(body)});
if(r&&r.ok)toast('success',editId?'平台已更新':'平台已创建');else toast('error','保存失败');
hidePlatformModal();loadPlatforms();
}
async function deletePlatform(id){
if(!confirm('确定删除此平台模板?'))return;
const r=await apiFetch(API+'/assets/platforms/'+id,{method:'DELETE'});
if(r&&r.ok)toast('success','平台已删除');else toast('error','删除失败');
loadPlatforms();
}
// ── Nodes (Tree) ──
async function loadNodes(){
try{
const r=await apiFetch(API+'/assets/nodes/tree');if(!r)return;
_allNodes=await r.json();
renderNodeTree();
}catch(e){document.getElementById('nodesTree').innerHTML='<div class="text-red-400 text-center py-8">加载失败</div>'}
}
function renderNodeTree(){
const flat=flattenNodes(_allNodes);
// Also load flat list for parent selector
loadNodeOptions(flat);
if(!_allNodes.length){
document.getElementById('nodesTree').innerHTML='<div class="text-slate-500 text-center py-8">暂无节点,点击"+ 新建"创建根节点</div>';
return;
}
document.getElementById('nodesTree').innerHTML=renderTreeLevel(_allNodes,0);
}
function renderTreeLevel(nodes,depth){
return nodes.map(n=>{
const pad=depth*24;
const hasChildren=n.children&&n.children.length;
return `<div>
<div class="flex items-center gap-2 py-2 px-3 rounded-lg hover:bg-slate-800/50 transition" style="padding-left:${12+pad}px">
${hasChildren?'<span class="text-slate-500 text-xs">📂</span>':'<span class="text-slate-500 text-xs">📁</span>'}
<span class="text-white text-sm flex-1">${esc(n.name)}</span>
<span class="text-slate-600 text-xs">ID:${n.id}</span>
<button onclick="addChildNode(${n.id},'${escAttr(n.name)}')" class="text-brand-light text-xs hover:underline">+子节点</button>
<button onclick="editNode(${n.id})" class="text-slate-500 hover:text-white text-xs">编辑</button>
<button onclick="deleteNode(${n.id})" class="text-red-400 text-xs hover:underline">删除</button>
</div>
${hasChildren?renderTreeLevel(n.children,depth+1):''}
</div>`;
}).join('');
}
function flattenNodes(nodes,result=[]){
nodes.forEach(n=>{result.push({id:n.id,name:n.name,parent_id:n.parent_id});if(n.children)flattenNodes(n.children,result)});
return result;
}
function loadNodeOptions(flat){
const sel=document.getElementById('nodeParent');
sel.innerHTML='<option value="">— 无 (根节点) —</option>'+flat.map(n=>`<option value="${n.id}">${esc(n.name)}</option>`).join('');
}
function showAddNode(){
document.getElementById('nodeModalTitle').textContent='新建节点';
document.getElementById('nodeEditId').value='';
document.getElementById('nodeName').value='';
document.getElementById('nodeParent').value='';
document.getElementById('nodeSort').value='0';
document.getElementById('nodeModal').classList.remove('hidden');
}
function addChildNode(parentId,parentName){
document.getElementById('nodeModalTitle').textContent='新建子节点 — '+parentName;
document.getElementById('nodeEditId').value='';
document.getElementById('nodeName').value='';
document.getElementById('nodeParent').value=parentId;
document.getElementById('nodeSort').value='0';
document.getElementById('nodeModal').classList.remove('hidden');
}
async function editNode(id){
try{
const r=await apiFetch(API+'/assets/nodes?parent_id='+id);if(!r)return;
// Get the node from flat list
const flat=flattenNodes(_allNodes);
const node=flat.find(n=>n.id===id);
if(!node){toast('error','节点未找到');return}
document.getElementById('nodeModalTitle').textContent='编辑节点';
document.getElementById('nodeEditId').value=id;
document.getElementById('nodeName').value=node.name||'';
document.getElementById('nodeParent').value=node.parent_id||'';
document.getElementById('nodeSort').value='0';
document.getElementById('nodeModal').classList.remove('hidden');
}catch(e){toast('error','加载失败')}
}
function hideNodeModal(){document.getElementById('nodeModal').classList.add('hidden')}
async function saveNode(){
const editId=document.getElementById('nodeEditId').value;
const body={
name:document.getElementById('nodeName').value,
parent_id:document.getElementById('nodeParent').value?parseInt(document.getElementById('nodeParent').value):null,
sort_order:parseInt(document.getElementById('nodeSort').value)||0,
};
if(!body.name){toast('warning','名称必填');return}
const url=editId?API+'/assets/nodes/'+editId:API+'/assets/nodes';
const method=editId?'PUT':'POST';
const r=await apiFetch(url,{method,headers:apiHeadersJSON(),body:JSON.stringify(body)});
if(r&&r.ok)toast('success',editId?'节点已更新':'节点已创建');else toast('error','保存失败');
hideNodeModal();loadNodes();
}
async function deleteNode(id){
if(!confirm('确定删除此节点? 子节点将变为根节点。'))return;
const r=await apiFetch(API+'/assets/nodes/'+id,{method:'DELETE'});
if(r&&r.ok)toast('success','节点已删除');else toast('error','删除失败');
loadNodes();
}
// ── SSH Sessions ──
async function loadSessions(){
try{
const r=await apiFetch(API+'/assets/ssh-sessions?limit=50');if(!r)return;
const sessions=await r.json();
if(!sessions.length){document.getElementById('sessionsList').innerHTML='<div class="text-slate-500 text-center py-8">暂无SSH会话记录</div>';return}
document.getElementById('sessionsList').innerHTML=sessions.map(s=>{
const statusColor=s.status==='active'?'text-green-400':s.status==='closed'?'text-slate-500':'text-yellow-400';
const statusLabel=s.status==='active'?'活跃':s.status==='closed'?'已关闭':'超时';
return `<div class="bg-slate-900 rounded-xl border border-slate-800 p-3 flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="${statusColor}">●</span>
<div>
<span class="text-white text-sm">Server #${s.server_id}</span>
<span class="text-slate-500 text-xs ml-2">${esc(s.remote_addr||'')}</span>
</div>
</div>
<div class="text-right text-xs text-slate-500">
<div>${statusLabel}</div>
<div>${fmtTime(s.started_at)}</div>
</div>
</div>`;
}).join('');
}catch(e){document.getElementById('sessionsList').innerHTML='<div class="text-red-400 text-center py-8">加载失败</div>'}
}
function esc(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML}
function escAttr(s){if(!s)return'';return s.replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/'/g,'&#39;').replace(/</g,'&lt;').replace(/>/g,'&gt;')}
function fmtTime(t){if(!t)return'--';return new Date(t+'Z').toLocaleString('zh-CN',{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'})}
loadPlatforms();
</script>
</body></html>
+172
View File
@@ -0,0 +1,172 @@
<!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="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script><script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.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-3">
<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>
</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]">
<option value="">全部服务器</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">
<!-- 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>
</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>
</div>
</main>
</div>
<script src="/app/api.js"></script>
<script src="/app/layout.js"></script>
<script>
initLayout('commands');
let _currentView = 'commands';
let _servers = {}; // id -> name cache
// Load server list for filter dropdown
async function loadServers() {
try {
const r = await apiFetch(API + '/servers/?per_page=200');
if (!r) return;
const data = await r.json();
const sel = document.getElementById('serverFilter');
(data.items || []).forEach(s => {
_servers[s.id] = s.name;
const opt = document.createElement('option');
opt.value = s.id;
opt.textContent = s.name + ' (' + s.domain + ')';
sel.appendChild(opt);
});
} catch(e) {}
}
function switchView() {
_currentView = document.getElementById('logView').value;
document.getElementById('commandsView').classList.toggle('hidden', _currentView !== 'commands');
document.getElementById('sessionsView').classList.toggle('hidden', _currentView !== 'sessions');
loadData();
}
async function loadData() {
if (_currentView === 'commands') {
await loadCommands();
} else {
await loadSessions();
}
}
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;
}
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 text-slate-500 text-xs text-right whitespace-nowrap">${fmt(l.created_at)}</td>
</tr>`;
}).join('');
}
async function loadSessions() {
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;
}
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>';
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 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>
</tr>`;
}).join('');
}
async function viewSessionCommands(sessionId) {
document.getElementById('logView').value = 'commands';
_currentView = 'commands';
document.getElementById('commandsView').classList.remove('hidden');
document.getElementById('sessionsView').classList.add('hidden');
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;
}
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 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 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>