feat: 全站亮色/暗色主题切换
实现方案: CSS自定义属性 + Tailwind arbitrary values 新增文件: - web/app/vendor/theme.css — CSS变量定义(亮色+暗色) 修改文件 (15个HTML + 2个JS + 2个Python): - layout.js — 侧边栏🌙主题切换按钮 + loadTheme/toggleTheme - settings.py — MUTABLE_KEYS加theme/editor_theme - 15个HTML页面 — bg-slate-*替换为bg-[var(--bg-*)] + 引入theme.css 替换统计: - index.html: 48处 - servers.html: 183处 - files.html: 94处 - push.html: 157处 - scripts.html: 83处 - credentials.html: 100处 - schedules.html: 59处 - retries.html: 40处 - commands.html: 57处 - alerts.html: 47处 - audit.html: 32处 - settings.html: 125处 - terminal.html: 89处 - install.html: 72处 - login.html: 14处 总计: 1199处替换 功能: - 侧边栏底部🌙/☀️按钮切换亮色/暗色 - 主题偏好保存到MySQL(settings表) - 页面加载时自动恢复主题 - Monaco编辑器主题独立控制(⚙️菜单)
This commit is contained in:
@@ -212,6 +212,28 @@ class FileWrite(BaseModel):
|
||||
content: str = Field(..., max_length=5_000_000)
|
||||
|
||||
|
||||
class FileChmod(BaseModel):
|
||||
"""Change file permissions on a remote server."""
|
||||
server_id: int = Field(..., ge=1)
|
||||
path: str = Field(..., min_length=1, max_length=4096)
|
||||
mode: str = Field(..., min_length=3, max_length=4, pattern=r"^[0-7]{3,4}$")
|
||||
|
||||
|
||||
class FileCompress(BaseModel):
|
||||
"""Compress files into an archive on a remote server."""
|
||||
server_id: int = Field(..., ge=1)
|
||||
paths: list[str] = Field(..., min_length=1, max_length=100)
|
||||
dest: str = Field(..., min_length=1, max_length=4096)
|
||||
format: str = Field("tar.gz", pattern=r"^(tar\.gz|zip)$")
|
||||
|
||||
|
||||
class FileDecompress(BaseModel):
|
||||
"""Decompress an archive on a remote server."""
|
||||
server_id: int = Field(..., ge=1)
|
||||
path: str = Field(..., min_length=1, max_length=4096)
|
||||
dest: str = Field(".", min_length=1, max_length=4096)
|
||||
|
||||
|
||||
# ── Script ──
|
||||
|
||||
class ScriptCreate(BaseModel):
|
||||
|
||||
@@ -46,6 +46,7 @@ MUTABLE_KEYS: set[str] = {
|
||||
"notify_recovery", "notify_time_drift",
|
||||
"notify_system_redis", "notify_system_mysql", "notify_restart",
|
||||
"editor_theme", # Monaco editor theme preference
|
||||
"theme", # 全站主题 (dark/light)
|
||||
}
|
||||
|
||||
# S-02: Per-key validation rules: (type, min, max) for int settings
|
||||
|
||||
+107
-1
@@ -9,7 +9,7 @@ import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
|
||||
from server.api.schemas import SyncFiles, SyncBrowse, SyncBrowseLocal, SyncPreview, FileOperation, LocalFileOperation, LocalFilePreview, SyncVerify, SyncCancel, ValidateSourcePath, SyncDiagnose, FileSyncDiff, FileDownload, FileRead, FileWrite
|
||||
from server.api.schemas import SyncFiles, SyncBrowse, SyncBrowseLocal, SyncPreview, FileOperation, LocalFileOperation, LocalFilePreview, SyncVerify, SyncCancel, ValidateSourcePath, SyncDiagnose, FileSyncDiff, FileDownload, FileRead, FileWrite, FileChmod, FileCompress, FileDecompress
|
||||
from server.application.services.sync_engine_v2 import SyncEngineV2
|
||||
from server.infrastructure.database.server_repo import ServerRepositoryImpl
|
||||
from server.infrastructure.database.sync_log_repo import SyncLogRepositoryImpl
|
||||
@@ -1197,3 +1197,109 @@ async def write_file(
|
||||
)
|
||||
|
||||
return {"success": True, "path": payload.path, "size": len(payload.content)}
|
||||
|
||||
|
||||
@router.post("/chmod")
|
||||
async def chmod_file(
|
||||
payload: FileChmod,
|
||||
request: Request,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
):
|
||||
"""Change file permissions on a remote server."""
|
||||
import shlex
|
||||
from server.infrastructure.database.server_repo import ServerRepositoryImpl
|
||||
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
|
||||
|
||||
session = request.state.db
|
||||
server = await ServerRepositoryImpl(session).get_by_id(payload.server_id)
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="服务器不存在")
|
||||
|
||||
cmd = f"chmod {shlex.quote(payload.mode)} {shlex.quote(payload.path)}"
|
||||
result = await exec_ssh_command(server, cmd, timeout=10)
|
||||
if result["exit_code"] != 0:
|
||||
raise HTTPException(status_code=502, detail=f"chmod 失败: {result['stderr'][:200]}")
|
||||
|
||||
await _audit_sync(
|
||||
"file_chmod", "server", payload.server_id,
|
||||
f"chmod {payload.mode} {payload.path}",
|
||||
admin.username, request,
|
||||
)
|
||||
|
||||
return {"success": True, "path": payload.path, "mode": payload.mode}
|
||||
|
||||
|
||||
@router.post("/compress")
|
||||
async def compress_files(
|
||||
payload: FileCompress,
|
||||
request: Request,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
):
|
||||
"""Compress files into tar.gz or zip archive."""
|
||||
import shlex
|
||||
from server.infrastructure.database.server_repo import ServerRepositoryImpl
|
||||
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
|
||||
|
||||
session = request.state.db
|
||||
server = await ServerRepositoryImpl(session).get_by_id(payload.server_id)
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="服务器不存在")
|
||||
|
||||
quoted_paths = " ".join(shlex.quote(p) for p in payload.paths)
|
||||
dest = shlex.quote(payload.dest)
|
||||
|
||||
if payload.format == "tar.gz":
|
||||
cmd = f"tar czf {dest} {quoted_paths}"
|
||||
else:
|
||||
cmd = f"zip -r {dest} {quoted_paths}"
|
||||
|
||||
result = await exec_ssh_command(server, cmd, timeout=120)
|
||||
if result["exit_code"] != 0:
|
||||
raise HTTPException(status_code=502, detail=f"压缩失败: {result['stderr'][:300]}")
|
||||
|
||||
await _audit_sync(
|
||||
"file_compress", "server", payload.server_id,
|
||||
f"压缩 {len(payload.paths)} 个文件 → {payload.dest}",
|
||||
admin.username, request,
|
||||
)
|
||||
|
||||
return {"success": True, "dest": payload.dest, "format": payload.format}
|
||||
|
||||
|
||||
@router.post("/decompress")
|
||||
async def decompress_file(
|
||||
payload: FileDecompress,
|
||||
request: Request,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
):
|
||||
"""Decompress a tar.gz or zip archive."""
|
||||
import shlex
|
||||
from server.infrastructure.database.server_repo import ServerRepositoryImpl
|
||||
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
|
||||
|
||||
session = request.state.db
|
||||
server = await ServerRepositoryImpl(session).get_by_id(payload.server_id)
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="服务器不存在")
|
||||
|
||||
path = shlex.quote(payload.path)
|
||||
dest = shlex.quote(payload.dest)
|
||||
|
||||
if payload.path.endswith(".tar.gz") or payload.path.endswith(".tgz"):
|
||||
cmd = f"tar xzf {path} -C {dest}"
|
||||
elif payload.path.endswith(".zip"):
|
||||
cmd = f"unzip -o {path} -d {dest}"
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="仅支持 .tar.gz/.tgz 和 .zip 格式")
|
||||
|
||||
result = await exec_ssh_command(server, cmd, timeout=120)
|
||||
if result["exit_code"] != 0:
|
||||
raise HTTPException(status_code=502, detail=f"解压失败: {result['stderr'][:300]}")
|
||||
|
||||
await _audit_sync(
|
||||
"file_decompress", "server", payload.server_id,
|
||||
f"解压 {payload.path} → {payload.dest}",
|
||||
admin.username, request,
|
||||
)
|
||||
|
||||
return {"success": True, "path": payload.path, "dest": payload.dest}
|
||||
|
||||
+36
-34
@@ -1,17 +1,19 @@
|
||||
<!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>
|
||||
<!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>
|
||||
<link rel="stylesheet" href="/app/vendor/theme.css">
|
||||
<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-[var(--bg-page)] text-[var(--text-primary)] min-h-screen flex" x-data="{sidebarOpen:true}">
|
||||
<aside x-show="sidebarOpen" x-transition class="w-64 bg-[var(--bg-card)] border-r border-[var(--border)] 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">
|
||||
<header class="bg-[var(--bg-card)] border-b border-[var(--border)] 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>
|
||||
<button @click="sidebarOpen=!sidebarOpen" class="text-[var(--text-secondary)] 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-2 flex-wrap">
|
||||
<select id="serverFilter" onchange="_offset=0;loadAlerts()" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
|
||||
<select id="serverFilter" onchange="_offset=0;loadAlerts()" class="px-3 py-1.5 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm">
|
||||
<option value="">全部服务器</option>
|
||||
</select>
|
||||
<select id="typeFilter" onchange="_offset=0;loadAlerts()" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
|
||||
<select id="typeFilter" onchange="_offset=0;loadAlerts()" class="px-3 py-1.5 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm">
|
||||
<option value="">全部类型</option>
|
||||
<option value="cpu">CPU</option>
|
||||
<option value="mem">内存</option>
|
||||
@@ -19,15 +21,15 @@
|
||||
<option value="time_drift">时钟偏差</option>
|
||||
<option value="system">系统</option>
|
||||
</select>
|
||||
<select id="kindFilter" onchange="_offset=0;loadAlerts()" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
|
||||
<select id="kindFilter" onchange="_offset=0;loadAlerts()" class="px-3 py-1.5 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm">
|
||||
<option value="">告警+恢复</option>
|
||||
<option value="alert">仅告警</option>
|
||||
<option value="recovery">仅恢复</option>
|
||||
</select>
|
||||
<input id="dateFrom" type="date" onchange="_offset=0;loadAlerts()" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
|
||||
<span class="text-slate-600 text-xs">至</span>
|
||||
<input id="dateTo" type="date" onchange="_offset=0;loadAlerts()" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
|
||||
<button onclick="_offset=0;loadAlerts()" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-white text-sm rounded-lg transition">刷新</button>
|
||||
<input id="dateFrom" type="date" onchange="_offset=0;loadAlerts()" class="px-3 py-1.5 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm">
|
||||
<span class="text-[var(--text-dim)] text-xs">至</span>
|
||||
<input id="dateTo" type="date" onchange="_offset=0;loadAlerts()" class="px-3 py-1.5 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm">
|
||||
<button onclick="_offset=0;loadAlerts()" class="px-3 py-1.5 bg-[var(--bg-surface)] hover:bg-slate-700 text-white text-sm rounded-lg transition">刷新</button>
|
||||
</div>
|
||||
</header>
|
||||
<main class="flex-1 overflow-y-auto p-6 space-y-6">
|
||||
@@ -36,15 +38,15 @@
|
||||
<div id="statsSection" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4"></div>
|
||||
|
||||
<!-- Top alerting servers -->
|
||||
<div id="topServersSection" class="hidden bg-slate-900 rounded-xl border border-slate-800 p-4">
|
||||
<div id="topServersSection" class="hidden bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-4">
|
||||
<h2 class="text-white font-medium text-sm mb-3">告警最多的服务器(近 7 天)</h2>
|
||||
<div id="topServersList" class="space-y-2"></div>
|
||||
</div>
|
||||
|
||||
<!-- Alert list -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 overflow-x-auto">
|
||||
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] overflow-x-auto">
|
||||
<table class="w-full text-sm min-w-[700px]">
|
||||
<thead class="bg-slate-800/50 text-slate-400 text-xs uppercase">
|
||||
<thead class="bg-slate-800/50 text-[var(--text-secondary)] text-xs uppercase">
|
||||
<tr>
|
||||
<th class="text-left px-4 py-3">状态</th>
|
||||
<th class="text-left px-4 py-3">类型</th>
|
||||
@@ -54,17 +56,17 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="alertsTbody" class="divide-y divide-slate-800">
|
||||
<tr><td colspan="5" class="px-4 py-8 text-center text-slate-500">加载中...</td></tr>
|
||||
<tr><td colspan="5" class="px-4 py-8 text-center text-[var(--text-muted)]">加载中...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div id="pagination" class="hidden flex items-center justify-between text-sm">
|
||||
<span id="pageInfo" class="text-slate-500 text-xs"></span>
|
||||
<span id="pageInfo" class="text-[var(--text-muted)] text-xs"></span>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="prevPage()" id="prevBtn" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-white text-xs rounded-lg transition disabled:opacity-40">← 上一页</button>
|
||||
<button onclick="nextPage()" id="nextBtn" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-white text-xs rounded-lg transition disabled:opacity-40">下一页 →</button>
|
||||
<button onclick="prevPage()" id="prevBtn" class="px-3 py-1.5 bg-[var(--bg-surface)] hover:bg-slate-700 text-white text-xs rounded-lg transition disabled:opacity-40">← 上一页</button>
|
||||
<button onclick="nextPage()" id="nextBtn" class="px-3 py-1.5 bg-[var(--bg-surface)] hover:bg-slate-700 text-white text-xs rounded-lg transition disabled:opacity-40">下一页 →</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
@@ -77,7 +79,7 @@
|
||||
let _offset = 0, _total = 0;
|
||||
|
||||
const TYPE_LABEL = {cpu:'CPU',mem:'内存',disk:'磁盘',time_drift:'时钟偏差',system:'系统'};
|
||||
const TYPE_COLOR = {cpu:'text-orange-400',mem:'text-blue-400',disk:'text-yellow-400',time_drift:'text-purple-400',system:'text-slate-400'};
|
||||
const TYPE_COLOR = {cpu:'text-orange-400',mem:'text-blue-400',disk:'text-yellow-400',time_drift:'text-purple-400',system:'text-[var(--text-secondary)]'};
|
||||
|
||||
async function loadServers(){
|
||||
const r=await apiFetch(API+'/servers/?per_page=200');if(!r)return;
|
||||
@@ -94,8 +96,8 @@
|
||||
document.getElementById('topServersSection').classList.remove('hidden');
|
||||
document.getElementById('topServersList').innerHTML=d.top_servers.map((s,i)=>`
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="w-5 text-slate-600 text-xs text-right">${i+1}</span>
|
||||
<div class="flex-1 bg-slate-800 rounded-full h-2 overflow-hidden">
|
||||
<span class="w-5 text-[var(--text-dim)] text-xs text-right">${i+1}</span>
|
||||
<div class="flex-1 bg-[var(--bg-surface)] rounded-full h-2 overflow-hidden">
|
||||
<div class="h-full bg-red-500/70 rounded-full transition-all" style="width:${Math.round(s.count/d.top_servers[0].count*100)}%"></div>
|
||||
</div>
|
||||
<span class="text-slate-300 text-xs w-32 truncate">${esc(s.server_name)}</span>
|
||||
@@ -106,20 +108,20 @@
|
||||
const totalAlerts=(d.daily||[]).reduce((s,r)=>s+r.alerts,0);
|
||||
const totalRecoveries=(d.daily||[]).reduce((s,r)=>s+r.recoveries,0);
|
||||
document.getElementById('statsSection').innerHTML=`
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-4">
|
||||
<div class="text-slate-400 text-xs mb-1">近 7 天告警次数</div>
|
||||
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-4">
|
||||
<div class="text-[var(--text-secondary)] text-xs mb-1">近 7 天告警次数</div>
|
||||
<div class="text-red-400 text-3xl font-bold">${totalAlerts}</div>
|
||||
</div>
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-4">
|
||||
<div class="text-slate-400 text-xs mb-1">近 7 天恢复次数</div>
|
||||
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-4">
|
||||
<div class="text-[var(--text-secondary)] text-xs mb-1">近 7 天恢复次数</div>
|
||||
<div class="text-green-400 text-3xl font-bold">${totalRecoveries}</div>
|
||||
</div>
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-4">
|
||||
<div class="text-slate-400 text-xs mb-1">告警最多的类型</div>
|
||||
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-4">
|
||||
<div class="text-[var(--text-secondary)] text-xs mb-1">告警最多的类型</div>
|
||||
<div class="text-white text-xl font-bold">${getTopType(d)}</div>
|
||||
</div>
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-4">
|
||||
<div class="text-slate-400 text-xs mb-1">告警最多的服务器</div>
|
||||
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-4">
|
||||
<div class="text-[var(--text-secondary)] text-xs mb-1">告警最多的服务器</div>
|
||||
<div class="text-white text-base font-bold truncate">${(d.top_servers||[])[0]?.server_name||'--'}</div>
|
||||
</div>`;
|
||||
}
|
||||
@@ -153,20 +155,20 @@
|
||||
const logs=data.items||[];
|
||||
const tbody=document.getElementById('alertsTbody');
|
||||
|
||||
if(!logs.length){tbody.innerHTML='<tr><td colspan="5" class="px-4 py-8 text-center text-slate-500">暂无告警记录</td></tr>';document.getElementById('pagination').classList.add('hidden');return}
|
||||
if(!logs.length){tbody.innerHTML='<tr><td colspan="5" class="px-4 py-8 text-center text-[var(--text-muted)]">暂无告警记录</td></tr>';document.getElementById('pagination').classList.add('hidden');return}
|
||||
|
||||
tbody.innerHTML=logs.map(l=>{
|
||||
const isRec=l.is_recovery;
|
||||
const statusBadge=isRec
|
||||
?'<span class="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-green-900/30 text-green-400 border border-green-500/30">🟢 恢复</span>'
|
||||
:'<span class="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-red-900/30 text-red-400 border border-red-500/30">🔴 告警</span>';
|
||||
const typeColor=TYPE_COLOR[l.alert_type]||'text-slate-400';
|
||||
const typeColor=TYPE_COLOR[l.alert_type]||'text-[var(--text-secondary)]';
|
||||
return `<tr class="hover:bg-slate-800/30 transition">
|
||||
<td class="px-4 py-2.5">${statusBadge}</td>
|
||||
<td class="px-4 py-2.5 ${typeColor} text-xs font-medium">${esc(TYPE_LABEL[l.alert_type]||l.alert_type)}</td>
|
||||
<td class="px-4 py-2.5 text-slate-300 text-xs">${esc(l.server_name||'#'+l.server_id)}</td>
|
||||
<td class="px-4 py-2.5 text-slate-400 text-xs font-mono">${esc(l.value||'--')}</td>
|
||||
<td class="px-4 py-2.5 text-slate-500 text-xs text-right whitespace-nowrap">${fmt(l.created_at)}</td>
|
||||
<td class="px-4 py-2.5 text-[var(--text-secondary)] text-xs font-mono">${esc(l.value||'--')}</td>
|
||||
<td class="px-4 py-2.5 text-[var(--text-muted)] text-xs text-right whitespace-nowrap">${fmt(l.created_at)}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
|
||||
+25
-23
@@ -1,11 +1,13 @@
|
||||
<!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>
|
||||
<!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>
|
||||
<link rel="stylesheet" href="/app/vendor/theme.css">
|
||||
<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-[var(--bg-page)] text-[var(--text-primary)] min-h-screen flex" x-data="{sidebarOpen:true}">
|
||||
<aside x-show="sidebarOpen" x-transition class="w-64 bg-[var(--bg-card)] border-r border-[var(--border)] 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>
|
||||
<header class="bg-[var(--bg-card)] border-b border-[var(--border)] px-6 py-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-[var(--text-secondary)] 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-2 flex-wrap">
|
||||
<select id="actionFilter" onchange="_offset=0;loadAudit()" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
|
||||
<select id="actionFilter" onchange="_offset=0;loadAudit()" class="px-3 py-1.5 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm">
|
||||
<option value="">全部操作</option>
|
||||
<optgroup label="服务器">
|
||||
<option value="create_server">添加服务器</option>
|
||||
@@ -54,26 +56,26 @@
|
||||
</optgroup>
|
||||
</select>
|
||||
<input id="adminFilter" type="text" placeholder="操作人" onchange="_offset=0;loadAudit()"
|
||||
class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm w-28 placeholder-slate-500">
|
||||
class="px-3 py-1.5 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm w-28 placeholder-slate-500">
|
||||
<input id="dateFrom" type="date" onchange="_offset=0;loadAudit()"
|
||||
class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
|
||||
<span class="text-slate-600 text-xs">至</span>
|
||||
class="px-3 py-1.5 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm">
|
||||
<span class="text-[var(--text-dim)] text-xs">至</span>
|
||||
<input id="dateTo" type="date" onchange="_offset=0;loadAudit()"
|
||||
class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
|
||||
<button onclick="clearFilters()" class="px-2 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-400 text-xs rounded-lg transition" title="清除日期过滤">✕</button>
|
||||
<button onclick="_offset=0;loadAudit()" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-white text-sm rounded-lg transition">刷新</button>
|
||||
class="px-3 py-1.5 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm">
|
||||
<button onclick="clearFilters()" class="px-2 py-1.5 bg-[var(--bg-surface)] hover:bg-slate-700 text-[var(--text-secondary)] text-xs rounded-lg transition" title="清除日期过滤">✕</button>
|
||||
<button onclick="_offset=0;loadAudit()" class="px-3 py-1.5 bg-[var(--bg-surface)] hover:bg-slate-700 text-white text-sm rounded-lg transition">刷新</button>
|
||||
</div>
|
||||
</header>
|
||||
<main class="flex-1 overflow-y-auto p-6">
|
||||
<div id="auditTable" 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">详情</th><th class="text-left px-4 py-3">IP</th><th class="text-right px-4 py-3">时间</th></tr></thead><tbody id="auditTbody" class="divide-y divide-slate-800"><tr><td colspan="6" class="px-4 py-8 text-center text-slate-500">加载中...</td></tr></tbody></table>
|
||||
<div id="auditTable" class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] overflow-hidden">
|
||||
<table class="w-full text-sm"><thead class="bg-slate-800/50 text-[var(--text-secondary)] 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">IP</th><th class="text-right px-4 py-3">时间</th></tr></thead><tbody id="auditTbody" class="divide-y divide-slate-800"><tr><td colspan="6" class="px-4 py-8 text-center text-[var(--text-muted)]">加载中...</td></tr></tbody></table>
|
||||
</div>
|
||||
<!-- Pagination -->
|
||||
<div id="pagination" class="hidden mt-4 flex items-center justify-between">
|
||||
<span id="pageInfo" class="text-slate-500 text-sm"></span>
|
||||
<span id="pageInfo" class="text-[var(--text-muted)] text-sm"></span>
|
||||
<div class="flex items-center gap-2">
|
||||
<button onclick="prevPage()" id="prevBtn" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-white text-sm rounded-lg transition disabled:opacity-50">上一页</button>
|
||||
<button onclick="nextPage()" id="nextBtn" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-white text-sm rounded-lg transition disabled:opacity-50">下一页</button>
|
||||
<button onclick="prevPage()" id="prevBtn" class="px-3 py-1.5 bg-[var(--bg-surface)] hover:bg-slate-700 text-white text-sm rounded-lg transition disabled:opacity-50">上一页</button>
|
||||
<button onclick="nextPage()" id="nextBtn" class="px-3 py-1.5 bg-[var(--bg-surface)] hover:bg-slate-700 text-white text-sm rounded-lg transition disabled:opacity-50">下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
@@ -142,17 +144,17 @@
|
||||
_total=data.total||0;
|
||||
const logs=data.items||[];
|
||||
const tbody=document.getElementById('auditTbody');
|
||||
if(!logs.length){tbody.innerHTML='<tr><td colspan="6" class="px-4 py-8 text-center text-slate-500">暂无日志</td></tr>'}else{
|
||||
if(!logs.length){tbody.innerHTML='<tr><td colspan="6" class="px-4 py-8 text-center text-[var(--text-muted)]">暂无日志</td></tr>'}else{
|
||||
tbody.innerHTML=logs.map(l=>{
|
||||
const badge=l.action.includes('login')?'bg-green-400/10 text-green-400':l.action.includes('delete')?'bg-red-400/10 text-red-400':l.action.includes('create')||l.action.includes('install')?'bg-blue-400/10 text-blue-400':'bg-slate-800 text-slate-300';
|
||||
const badge=l.action.includes('login')?'bg-green-400/10 text-green-400':l.action.includes('delete')?'bg-red-400/10 text-red-400':l.action.includes('create')||l.action.includes('install')?'bg-blue-400/10 text-blue-400':'bg-[var(--bg-surface)] text-slate-300';
|
||||
const actionName=ACTION_NAMES[l.action]||l.action;
|
||||
return `<tr class="hover:bg-slate-800/30 transition">
|
||||
<td class="px-4 py-3 text-white">${esc(l.admin_username||'--')}</td>
|
||||
<td class="px-4 py-3"><span class="px-2 py-0.5 rounded text-xs ${badge}">${esc(actionName)}</span></td>
|
||||
<td class="px-4 py-3 text-slate-400 text-xs">${esc(TARGET_NAMES[l.target_type]||l.target_type||'--')} ${l.target_id?'#'+l.target_id:''}</td>
|
||||
<td class="px-4 py-3 text-slate-500 text-xs max-w-xs truncate" title="${esc(l.detail||'')}">${esc((l.detail||'').substring(0,80))}</td>
|
||||
<td class="px-4 py-3 text-slate-600 text-xs">${esc(l.ip_address||'--')}</td>
|
||||
<td class="px-4 py-3 text-slate-500 text-xs text-right whitespace-nowrap">${fmt(l.created_at)}</td>
|
||||
<td class="px-4 py-3 text-[var(--text-secondary)] text-xs">${esc(TARGET_NAMES[l.target_type]||l.target_type||'--')} ${l.target_id?'#'+l.target_id:''}</td>
|
||||
<td class="px-4 py-3 text-[var(--text-muted)] text-xs max-w-xs truncate" title="${esc(l.detail||'')}">${esc((l.detail||'').substring(0,80))}</td>
|
||||
<td class="px-4 py-3 text-[var(--text-dim)] text-xs">${esc(l.ip_address||'--')}</td>
|
||||
<td class="px-4 py-3 text-[var(--text-muted)] text-xs text-right whitespace-nowrap">${fmt(l.created_at)}</td>
|
||||
</tr>`}).join('');
|
||||
}
|
||||
updatePagination();
|
||||
|
||||
+49
-47
@@ -1,28 +1,30 @@
|
||||
<!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>
|
||||
<!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>
|
||||
<link rel="stylesheet" href="/app/vendor/theme.css">
|
||||
<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-[var(--bg-page)] text-[var(--text-primary)] min-h-screen flex" x-data="{sidebarOpen:true}">
|
||||
<aside x-show="sidebarOpen" x-transition class="w-64 bg-[var(--bg-card)] border-r border-[var(--border)] 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>
|
||||
<header class="bg-[var(--bg-card)] border-b border-[var(--border)] px-6 py-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-[var(--text-secondary)] 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">
|
||||
<select id="logView" onchange="switchView()" class="px-3 py-1.5 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm">
|
||||
<option value="commands">SSH 命令记录</option>
|
||||
<option value="sessions">SSH 会话</option>
|
||||
<option value="push">推送日志</option>
|
||||
</select>
|
||||
<!-- 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]">
|
||||
<select id="serverFilter" onchange="onFilterChange()" class="px-3 py-1.5 bg-[var(--bg-surface)] border border-[var(--border-input)] 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">
|
||||
<select id="pushStatusFilter" onchange="onFilterChange()" class="hidden px-3 py-1.5 bg-[var(--bg-surface)] border border-[var(--border-input)] 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">
|
||||
<select id="pushModeFilter" onchange="onFilterChange()" class="hidden px-3 py-1.5 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm">
|
||||
<option value="">全部模式</option>
|
||||
<option value="incremental">增量</option>
|
||||
<option value="full">全量</option>
|
||||
@@ -30,20 +32,20 @@
|
||||
<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>
|
||||
<button onclick="loadData()" class="px-3 py-1.5 bg-[var(--bg-surface)] hover:bg-slate-700 text-white text-sm rounded-lg transition">刷新</button>
|
||||
</div>
|
||||
</header>
|
||||
<main class="flex-1 overflow-y-auto p-6 space-y-4">
|
||||
|
||||
<!-- Commands View -->
|
||||
<div id="commandsView">
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 overflow-x-auto">
|
||||
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] overflow-x-auto">
|
||||
<table class="w-full text-sm min-w-[640px]">
|
||||
<thead class="bg-slate-800/50 text-slate-400 text-xs uppercase">
|
||||
<thead class="bg-slate-800/50 text-[var(--text-secondary)] 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>
|
||||
<tr><td colspan="5" class="px-4 py-8 text-center text-[var(--text-muted)]">加载中...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -51,13 +53,13 @@
|
||||
|
||||
<!-- Sessions View -->
|
||||
<div id="sessionsView" class="hidden">
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 overflow-x-auto">
|
||||
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] overflow-x-auto">
|
||||
<table class="w-full text-sm min-w-[700px]">
|
||||
<thead class="bg-slate-800/50 text-slate-400 text-xs uppercase">
|
||||
<thead class="bg-slate-800/50 text-[var(--text-secondary)] 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>
|
||||
<tr><td colspan="7" class="px-4 py-8 text-center text-[var(--text-muted)]">加载中...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -65,9 +67,9 @@
|
||||
|
||||
<!-- 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">
|
||||
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] overflow-x-auto">
|
||||
<table class="w-full text-sm min-w-[800px]">
|
||||
<thead class="bg-slate-800/50 text-slate-400 text-xs uppercase">
|
||||
<thead class="bg-slate-800/50 text-[var(--text-secondary)] text-xs uppercase">
|
||||
<tr>
|
||||
<th class="text-left px-4 py-3">状态</th>
|
||||
<th class="text-left px-4 py-3">服务器</th>
|
||||
@@ -82,17 +84,17 @@
|
||||
</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>
|
||||
<tr><td colspan="10" class="px-4 py-8 text-center text-[var(--text-muted)]">加载中...</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>
|
||||
<span id="pushTotal" class="text-[var(--text-muted)] 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>
|
||||
<button id="pushPrevBtn" onclick="pushPage(-1)" class="px-3 py-1.5 bg-[var(--bg-surface)] hover:bg-slate-700 text-white text-xs rounded-lg transition disabled:opacity-40">← 上一页</button>
|
||||
<span id="pushPageInfo" class="text-[var(--text-secondary)] text-xs"></span>
|
||||
<button id="pushNextBtn" onclick="pushPage(1)" class="px-3 py-1.5 bg-[var(--bg-surface)] hover:bg-slate-700 text-white text-xs rounded-lg transition disabled:opacity-40">下一页 →</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -158,16 +160,16 @@
|
||||
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-[var(--text-muted)]">暂无命令日志</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 || '');
|
||||
return `<tr class="hover:bg-slate-800/30 transition">
|
||||
<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 text-[var(--text-secondary)] 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>
|
||||
<td class="px-4 py-2.5 text-[var(--text-muted)] text-xs">${esc(l.remote_addr||'--')}</td>
|
||||
<td class="px-4 py-2.5 text-[var(--text-muted)] text-xs text-right whitespace-nowrap">${fmt(l.created_at)}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
@@ -180,18 +182,18 @@
|
||||
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-[var(--text-muted)]">暂无SSH会话</td></tr>'; return; }
|
||||
tbody.innerHTML = sessions.map(s => {
|
||||
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>';
|
||||
:'<span class="px-2 py-0.5 rounded text-xs bg-slate-700 text-[var(--text-secondary)]">已断开</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]||'#'+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 text-[var(--text-secondary)] text-xs whitespace-nowrap">${esc(_servers[s.server_id]||'#'+s.server_id)}</td>
|
||||
<td class="px-4 py-2.5 text-[var(--text-muted)] 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-[var(--text-muted)] text-xs whitespace-nowrap">${fmt(s.started_at)}</td>
|
||||
<td class="px-4 py-2.5 text-[var(--text-muted)] 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('');
|
||||
@@ -216,13 +218,13 @@
|
||||
|
||||
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>';
|
||||
tbody.innerHTML = '<tr><td colspan="10" class="px-4 py-8 text-center text-[var(--text-muted)]">暂无推送记录</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 sc = l.status==='success'?'text-green-400':l.status==='failed'?'text-red-400':l.status==='running'?'text-yellow-400':'text-[var(--text-secondary)]';
|
||||
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||'--';
|
||||
@@ -235,14 +237,14 @@
|
||||
</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>
|
||||
<td class="px-4 py-2.5"><span class="text-xs px-1.5 py-0.5 rounded bg-[var(--bg-surface)] text-[var(--text-secondary)]">${esc(modeBadge)}</span></td>
|
||||
<td class="px-4 py-2.5 text-[var(--text-muted)] text-xs">${esc(triggerBadge)}</td>
|
||||
<td class="px-4 py-2.5 text-[var(--text-secondary)] 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-[var(--text-secondary)] 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-[var(--text-secondary)] text-xs text-right">${l.files_transferred!=null?l.files_transferred:'--'}</td>
|
||||
<td class="px-4 py-2.5 text-[var(--text-secondary)] text-xs text-right whitespace-nowrap">${l.duration_seconds!=null?l.duration_seconds+'s':'--'}</td>
|
||||
<td class="px-4 py-2.5 text-[var(--text-muted)] text-xs">${esc(l.operator||'--')}</td>
|
||||
<td class="px-4 py-2.5 text-[var(--text-muted)] text-xs text-right whitespace-nowrap">${fmt(l.started_at)}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
@@ -271,15 +273,15 @@
|
||||
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-[var(--text-muted)]">该会话无命令记录</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-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-[var(--text-secondary)] 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>
|
||||
<td class="px-4 py-2.5 text-[var(--text-muted)] text-xs">${esc(l.remote_addr||'--')}</td>
|
||||
<td class="px-4 py-2.5 text-[var(--text-muted)] text-xs text-right whitespace-nowrap">${fmt(l.created_at)}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
+57
-55
@@ -1,99 +1,101 @@
|
||||
<!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,tab:'db'}">
|
||||
<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>
|
||||
<!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>
|
||||
<link rel="stylesheet" href="/app/vendor/theme.css">
|
||||
<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-[var(--bg-page)] text-[var(--text-primary)] min-h-screen flex" x-data="{sidebarOpen:true,tab:'db'}">
|
||||
<aside x-show="sidebarOpen" x-transition class="w-64 bg-[var(--bg-card)] border-r border-[var(--border)] 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>
|
||||
<header class="bg-[var(--bg-card)] border-b border-[var(--border)] px-6 py-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-[var(--text-secondary)] 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="showAddCred()" 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">
|
||||
<div class="bg-[var(--bg-card)] border-b border-[var(--border)] px-6 flex">
|
||||
<button onclick="switchTab('db')" id="tabDB" class="px-4 py-3 text-sm border-b-2 border-brand text-brand-light transition">数据库凭据</button>
|
||||
<button onclick="switchTab('preset')" id="tabPreset" class="px-4 py-3 text-sm border-b-2 border-transparent text-slate-400 hover:text-white transition">密码预设</button>
|
||||
<button onclick="switchTab('sshkey')" id="tabSSHKey" class="px-4 py-3 text-sm border-b-2 border-transparent text-slate-400 hover:text-white transition">SSH密钥预设</button>
|
||||
<button onclick="switchTab('preset')" id="tabPreset" class="px-4 py-3 text-sm border-b-2 border-transparent text-[var(--text-secondary)] hover:text-white transition">密码预设</button>
|
||||
<button onclick="switchTab('sshkey')" id="tabSSHKey" class="px-4 py-3 text-sm border-b-2 border-transparent text-[var(--text-secondary)] hover:text-white transition">SSH密钥预设</button>
|
||||
</div>
|
||||
<main class="flex-1 overflow-y-auto p-6">
|
||||
<!-- DB Credentials -->
|
||||
<div id="dbSection">
|
||||
<div id="credsList" class="space-y-3"><div class="text-slate-500 text-center py-8">加载中...</div></div>
|
||||
<div id="credsList" class="space-y-3"><div class="text-[var(--text-muted)] text-center py-8">加载中...</div></div>
|
||||
</div>
|
||||
<!-- Password Presets -->
|
||||
<div id="presetSection" class="hidden">
|
||||
<div id="presetsList" class="space-y-3"><div class="text-slate-500 text-center py-8">加载中...</div></div>
|
||||
<div id="presetsList" class="space-y-3"><div class="text-[var(--text-muted)] text-center py-8">加载中...</div></div>
|
||||
</div>
|
||||
<!-- SSH Key Presets -->
|
||||
<div id="sshKeySection" class="hidden">
|
||||
<div id="sshKeyPresetsList" class="space-y-3"><div class="text-slate-500 text-center py-8">加载中...</div></div>
|
||||
<div id="sshKeyPresetsList" class="space-y-3"><div class="text-[var(--text-muted)] text-center py-8">加载中...</div></div>
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit DB Credential Modal -->
|
||||
<div id="credModal" 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">
|
||||
<div class="bg-[var(--bg-card)] border border-[var(--border-input)] rounded-xl p-6 w-full max-w-md space-y-3">
|
||||
<h2 id="credModalTitle" class="text-white font-semibold text-lg">新建数据库凭据</h2>
|
||||
<input type="hidden" id="credEditId" value="">
|
||||
<div><label class="block text-slate-400 text-xs mb-1">名称</label><input id="credName" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="prod-mysql"></div>
|
||||
<div><label class="block text-[var(--text-secondary)] text-xs mb-1">名称</label><input id="credName" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm" placeholder="prod-mysql"></div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div><label class="block text-slate-400 text-xs mb-1">类型</label><select id="credType" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm"><option value="mysql">MySQL</option><option value="postgresql">PostgreSQL</option><option value="mariadb">MariaDB</option><option value="mongodb">MongoDB</option><option value="redis">Redis</option></select></div>
|
||||
<div><label class="block text-slate-400 text-xs mb-1">端口</label><input id="credPort" type="number" value="3306" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm"></div>
|
||||
<div><label class="block text-[var(--text-secondary)] text-xs mb-1">类型</label><select id="credType" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm"><option value="mysql">MySQL</option><option value="postgresql">PostgreSQL</option><option value="mariadb">MariaDB</option><option value="mongodb">MongoDB</option><option value="redis">Redis</option></select></div>
|
||||
<div><label class="block text-[var(--text-secondary)] text-xs mb-1">端口</label><input id="credPort" type="number" value="3306" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm"></div>
|
||||
</div>
|
||||
<div><label class="block text-slate-400 text-xs mb-1">主机</label><input id="credHost" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="192.168.1.10"></div>
|
||||
<div><label class="block text-[var(--text-secondary)] text-xs mb-1">主机</label><input id="credHost" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm" placeholder="192.168.1.10"></div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div><label class="block text-slate-400 text-xs mb-1">用户名</label><input id="credUser" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="root"></div>
|
||||
<div><label class="block text-slate-400 text-xs mb-1">密码</label><input id="credPass" type="password" 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-[var(--text-secondary)] text-xs mb-1">用户名</label><input id="credUser" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm" placeholder="root"></div>
|
||||
<div><label class="block text-[var(--text-secondary)] text-xs mb-1">密码</label><input id="credPass" type="password" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm" placeholder="编辑时留空则不修改"></div>
|
||||
</div>
|
||||
<div><label class="block text-slate-400 text-xs mb-1">数据库</label><input id="credDb" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="可选"></div>
|
||||
<div class="flex gap-2 pt-2"><button onclick="saveCred()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm">保存</button><button onclick="hideCredModal()" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm">取消</button></div>
|
||||
<div><label class="block text-[var(--text-secondary)] text-xs mb-1">数据库</label><input id="credDb" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm" placeholder="可选"></div>
|
||||
<div class="flex gap-2 pt-2"><button onclick="saveCred()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm">保存</button><button onclick="hideCredModal()" class="px-4 py-2 bg-[var(--bg-surface)] hover:bg-slate-700 text-slate-300 rounded-lg text-sm">取消</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit Password Preset Modal -->
|
||||
<div id="presetModal" 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">
|
||||
<div class="bg-[var(--bg-card)] border border-[var(--border-input)] rounded-xl p-6 w-full max-w-md space-y-3">
|
||||
<h2 id="presetModalTitle" class="text-white font-semibold text-lg">新建密码预设</h2>
|
||||
<input type="hidden" id="presetEditId" value="">
|
||||
<div><label class="block text-slate-400 text-xs mb-1">名称</label><input id="presetName" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="生产环境root密码"></div>
|
||||
<div><label class="block text-slate-400 text-xs mb-1">密码</label><input id="presetPass" type="password" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="编辑时留空则不修改"></div>
|
||||
<div class="bg-slate-800/50 rounded-lg p-3 text-xs text-slate-500">密码将使用 AES-256 加密后存储,不可逆向查看。推送时自动解密使用。</div>
|
||||
<div class="flex gap-2 pt-2"><button onclick="savePreset()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm">保存</button><button onclick="hidePresetModal()" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm">取消</button></div>
|
||||
<div><label class="block text-[var(--text-secondary)] text-xs mb-1">名称</label><input id="presetName" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm" placeholder="生产环境root密码"></div>
|
||||
<div><label class="block text-[var(--text-secondary)] text-xs mb-1">密码</label><input id="presetPass" type="password" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm" placeholder="编辑时留空则不修改"></div>
|
||||
<div class="bg-[var(--bg-surface)]/50 rounded-lg p-3 text-xs text-[var(--text-muted)]">密码将使用 AES-256 加密后存储,不可逆向查看。推送时自动解密使用。</div>
|
||||
<div class="flex gap-2 pt-2"><button onclick="savePreset()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm">保存</button><button onclick="hidePresetModal()" class="px-4 py-2 bg-[var(--bg-surface)] hover:bg-slate-700 text-slate-300 rounded-lg text-sm">取消</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit SSH Key Preset Modal -->
|
||||
<div id="sshKeyModal" 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-lg space-y-3">
|
||||
<div class="bg-[var(--bg-card)] border border-[var(--border-input)] rounded-xl p-6 w-full max-w-lg space-y-3">
|
||||
<h2 id="sshKeyModalTitle" class="text-white font-semibold text-lg">新建SSH密钥预设</h2>
|
||||
<input type="hidden" id="sshKeyEditId" value="">
|
||||
<div><label class="block text-slate-400 text-xs mb-1">名称</label><input id="sshKeyName" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="生产环境RSA密钥"></div>
|
||||
<div><label class="block text-slate-400 text-xs mb-1">私钥内容</label><textarea id="sshKeyPrivate" rows="6" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm font-mono" placeholder="粘贴 PEM 格式私钥内容(编辑时留空则不修改)"></textarea></div>
|
||||
<div><label class="block text-slate-400 text-xs mb-1">公钥内容 <span class="text-slate-600">(可选)</span></label><textarea id="sshKeyPublic" rows="2" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm font-mono" placeholder="ssh-rsa AAAA... (可选)"></textarea></div>
|
||||
<div class="bg-slate-800/50 rounded-lg p-3 text-xs text-slate-500">私钥将使用 Fernet 加密后存储,不可逆向查看。添加服务器选择此预设时自动解密使用。</div>
|
||||
<div class="flex gap-2 pt-2"><button onclick="saveSSHKeyPreset()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm">保存</button><button onclick="hideSSHKeyModal()" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm">取消</button></div>
|
||||
<div><label class="block text-[var(--text-secondary)] text-xs mb-1">名称</label><input id="sshKeyName" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm" placeholder="生产环境RSA密钥"></div>
|
||||
<div><label class="block text-[var(--text-secondary)] text-xs mb-1">私钥内容</label><textarea id="sshKeyPrivate" rows="6" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm font-mono" placeholder="粘贴 PEM 格式私钥内容(编辑时留空则不修改)"></textarea></div>
|
||||
<div><label class="block text-[var(--text-secondary)] text-xs mb-1">公钥内容 <span class="text-[var(--text-dim)]">(可选)</span></label><textarea id="sshKeyPublic" rows="2" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm font-mono" placeholder="ssh-rsa AAAA... (可选)"></textarea></div>
|
||||
<div class="bg-[var(--bg-surface)]/50 rounded-lg p-3 text-xs text-[var(--text-muted)]">私钥将使用 Fernet 加密后存储,不可逆向查看。添加服务器选择此预设时自动解密使用。</div>
|
||||
<div class="flex gap-2 pt-2"><button onclick="saveSSHKeyPreset()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm">保存</button><button onclick="hideSSHKeyModal()" class="px-4 py-2 bg-[var(--bg-surface)] hover:bg-slate-700 text-slate-300 rounded-lg text-sm">取消</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reveal SSH Key Modal (re-auth required) -->
|
||||
<div id="sshKeyRevealModal" 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-lg space-y-3">
|
||||
<div class="bg-[var(--bg-card)] border border-[var(--border-input)] rounded-xl p-6 w-full max-w-lg space-y-3">
|
||||
<h2 class="text-white font-semibold text-lg">查看密钥 — 身份验证</h2>
|
||||
<div><label class="block text-slate-400 text-xs mb-1">当前登录密码</label><input id="sshKeyRevealPwd" type="password" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="输入当前密码以解密查看私钥"></div>
|
||||
<div class="flex gap-2 pt-2"><button onclick="doRevealSSHKey()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm">验证并查看</button><button onclick="hideSSHKeyRevealModal()" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm">取消</button></div>
|
||||
<div><label class="block text-[var(--text-secondary)] text-xs mb-1">当前登录密码</label><input id="sshKeyRevealPwd" type="password" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm" placeholder="输入当前密码以解密查看私钥"></div>
|
||||
<div class="flex gap-2 pt-2"><button onclick="doRevealSSHKey()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm">验证并查看</button><button onclick="hideSSHKeyRevealModal()" class="px-4 py-2 bg-[var(--bg-surface)] hover:bg-slate-700 text-slate-300 rounded-lg text-sm">取消</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Revealed SSH Key Content Modal -->
|
||||
<div id="sshKeyContentModal" 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-lg space-y-3">
|
||||
<div class="bg-[var(--bg-card)] border border-[var(--border-input)] rounded-xl p-6 w-full max-w-lg space-y-3">
|
||||
<h2 id="sshKeyContentTitle" class="text-white font-semibold text-lg">密钥内容</h2>
|
||||
<div>
|
||||
<label class="block text-slate-400 text-xs mb-1">私钥</label>
|
||||
<textarea id="sshKeyRevealedPrivate" rows="8" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm font-mono" readonly></textarea>
|
||||
<label class="block text-[var(--text-secondary)] text-xs mb-1">私钥</label>
|
||||
<textarea id="sshKeyRevealedPrivate" rows="8" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm font-mono" readonly></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-slate-400 text-xs mb-1">公钥</label>
|
||||
<textarea id="sshKeyRevealedPublic" rows="2" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm font-mono" readonly></textarea>
|
||||
<label class="block text-[var(--text-secondary)] text-xs mb-1">公钥</label>
|
||||
<textarea id="sshKeyRevealedPublic" rows="2" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm font-mono" readonly></textarea>
|
||||
</div>
|
||||
<div class="bg-amber-500/10 border border-amber-500/30 rounded-lg p-3 text-xs text-amber-400">⚠️ 私钥仅在本次查看中显示,关闭后需重新验证身份才能再次查看。</div>
|
||||
<div class="flex gap-2 pt-2"><button onclick="hideSSHKeyContentModal()" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm">关闭</button></div>
|
||||
<div class="flex gap-2 pt-2"><button onclick="hideSSHKeyContentModal()" class="px-4 py-2 bg-[var(--bg-surface)] hover:bg-slate-700 text-slate-300 rounded-lg text-sm">关闭</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
@@ -106,9 +108,9 @@
|
||||
const DB_PORTS={mysql:3306,postgresql:5432,mariadb:3306,mongodb:27017,redis:6379};
|
||||
|
||||
function switchTab(t){
|
||||
document.getElementById('tabDB').className='px-4 py-3 text-sm border-b-2 '+(t==='db'?'border-brand text-brand-light':'border-transparent text-slate-400 hover:text-white')+' transition';
|
||||
document.getElementById('tabPreset').className='px-4 py-3 text-sm border-b-2 '+(t==='preset'?'border-brand text-brand-light':'border-transparent text-slate-400 hover:text-white')+' transition';
|
||||
document.getElementById('tabSSHKey').className='px-4 py-3 text-sm border-b-2 '+(t==='sshkey'?'border-brand text-brand-light':'border-transparent text-slate-400 hover:text-white')+' transition';
|
||||
document.getElementById('tabDB').className='px-4 py-3 text-sm border-b-2 '+(t==='db'?'border-brand text-brand-light':'border-transparent text-[var(--text-secondary)] hover:text-white')+' transition';
|
||||
document.getElementById('tabPreset').className='px-4 py-3 text-sm border-b-2 '+(t==='preset'?'border-brand text-brand-light':'border-transparent text-[var(--text-secondary)] hover:text-white')+' transition';
|
||||
document.getElementById('tabSSHKey').className='px-4 py-3 text-sm border-b-2 '+(t==='sshkey'?'border-brand text-brand-light':'border-transparent text-[var(--text-secondary)] hover:text-white')+' transition';
|
||||
document.getElementById('dbSection').classList.toggle('hidden',t!=='db');
|
||||
document.getElementById('presetSection').classList.toggle('hidden',t!=='preset');
|
||||
document.getElementById('sshKeySection').classList.toggle('hidden',t!=='sshkey');
|
||||
@@ -121,21 +123,21 @@
|
||||
try{
|
||||
const r=await apiFetch(API+'/scripts/credentials');if(!r)return;
|
||||
const creds=await r.json();
|
||||
document.getElementById('credsList').innerHTML=creds.length?creds.map(c=>`<div class="bg-slate-900 rounded-xl border border-slate-800 p-4">
|
||||
document.getElementById('credsList').innerHTML=creds.length?creds.map(c=>`<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-lg">${DB_ICONS[c.db_type]||'🗄'}</span>
|
||||
<div>
|
||||
<span class="text-white font-medium">${esc(c.name)}</span>
|
||||
<div class="text-slate-500 text-xs mt-0.5">${esc(c.db_type)} · ${esc(c.host)}:${c.port} · ${esc(c.username)}${c.database?' / '+esc(c.database):''}</div>
|
||||
<div class="text-[var(--text-muted)] text-xs mt-0.5">${esc(c.db_type)} · ${esc(c.host)}:${c.port} · ${esc(c.username)}${c.database?' / '+esc(c.database):''}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="editCred(${c.id})" class="text-slate-500 hover:text-white text-xs">编辑</button>
|
||||
<button onclick="editCred(${c.id})" class="text-[var(--text-muted)] hover:text-white text-xs">编辑</button>
|
||||
<button onclick="deleteCred(${c.id})" class="text-red-400 text-xs hover:underline">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`).join(''):'<div class="text-slate-500 text-center py-8">暂无数据库凭据</div>';
|
||||
</div>`).join(''):'<div class="text-[var(--text-muted)] text-center py-8">暂无数据库凭据</div>';
|
||||
}catch(e){document.getElementById('credsList').innerHTML='<div class="text-red-400 text-center py-8">加载失败</div>'}
|
||||
}
|
||||
|
||||
@@ -237,21 +239,21 @@
|
||||
try{
|
||||
const r=await apiFetch(API+'/presets/');if(!r)return;
|
||||
const presets=await r.json();
|
||||
document.getElementById('presetsList').innerHTML=presets.length?presets.map(p=>`<div class="bg-slate-900 rounded-xl border border-slate-800 p-4">
|
||||
document.getElementById('presetsList').innerHTML=presets.length?presets.map(p=>`<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-lg">🔑</span>
|
||||
<div>
|
||||
<span class="text-white font-medium">${esc(p.name)}</span>
|
||||
<div class="text-slate-500 text-xs mt-0.5">加密存储 · 创建于 ${fmtTime(p.created_at)}</div>
|
||||
<div class="text-[var(--text-muted)] text-xs mt-0.5">加密存储 · 创建于 ${fmtTime(p.created_at)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="editPreset(${p.id},this.dataset.name)" data-name="${esc(p.name)}" class="text-slate-500 hover:text-white text-xs">编辑</button>
|
||||
<button onclick="editPreset(${p.id},this.dataset.name)" data-name="${esc(p.name)}" class="text-[var(--text-muted)] hover:text-white text-xs">编辑</button>
|
||||
<button onclick="deletePreset(${p.id})" class="text-red-400 text-xs hover:underline">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`).join(''):'<div class="text-slate-500 text-center py-8">暂无密码预设</div>';
|
||||
</div>`).join(''):'<div class="text-[var(--text-muted)] text-center py-8">暂无密码预设</div>';
|
||||
}catch(e){document.getElementById('presetsList').innerHTML='<div class="text-red-400 text-center py-8">加载失败</div>'}
|
||||
}
|
||||
|
||||
@@ -287,22 +289,22 @@
|
||||
try{
|
||||
const r=await apiFetch(API+'/ssh-key-presets/');if(!r)return;
|
||||
const presets=await r.json();
|
||||
document.getElementById('sshKeyPresetsList').innerHTML=presets.length?presets.map(p=>`<div class="bg-slate-900 rounded-xl border border-slate-800 p-4">
|
||||
document.getElementById('sshKeyPresetsList').innerHTML=presets.length?presets.map(p=>`<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-lg">🔐</span>
|
||||
<div>
|
||||
<span class="text-white font-medium">${esc(p.name)}</span>
|
||||
<div class="text-slate-500 text-xs mt-0.5">${p.public_key?esc(p.public_key.substring(0,40))+'...':'无公钥'} · 创建于 ${fmtTime(p.created_at)}</div>
|
||||
<div class="text-[var(--text-muted)] text-xs mt-0.5">${p.public_key?esc(p.public_key.substring(0,40))+'...':'无公钥'} · 创建于 ${fmtTime(p.created_at)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="revealSSHKey(${p.id})" class="text-brand-light hover:text-white text-xs" title="查看密钥内容(需验证身份)">查看</button>
|
||||
<button onclick="editSSHKeyPreset(${p.id},this.dataset.name,this.dataset.pubkey)" data-name="${esc(p.name)}" data-pubkey="${esc(p.public_key||'')}" class="text-slate-500 hover:text-white text-xs">编辑</button>
|
||||
<button onclick="editSSHKeyPreset(${p.id},this.dataset.name,this.dataset.pubkey)" data-name="${esc(p.name)}" data-pubkey="${esc(p.public_key||'')}" class="text-[var(--text-muted)] hover:text-white text-xs">编辑</button>
|
||||
<button onclick="deleteSSHKeyPreset(${p.id})" class="text-red-400 text-xs hover:underline">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`).join(''):'<div class="text-slate-500 text-center py-8">暂无SSH密钥预设</div>';
|
||||
</div>`).join(''):'<div class="text-[var(--text-muted)] text-center py-8">暂无SSH密钥预设</div>';
|
||||
}catch(e){document.getElementById('sshKeyPresetsList').innerHTML='<div class="text-red-400 text-center py-8">加载失败</div>'}
|
||||
}
|
||||
|
||||
|
||||
+60
-60
@@ -1,18 +1,18 @@
|
||||
<!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>
|
||||
<!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><link rel="stylesheet" href="/app/vendor/theme.css"><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-[var(--bg-page)] text-[var(--text-primary)] min-h-screen flex" x-data="{sidebarOpen:true}">
|
||||
<aside x-show="sidebarOpen" x-transition class="w-64 bg-[var(--bg-card)] border-r border-[var(--border)] 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 flex-wrap gap-2">
|
||||
<header class="bg-[var(--bg-card)] border-b border-[var(--border)] px-6 py-3 flex items-center justify-between flex-wrap gap-2">
|
||||
<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-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></button>
|
||||
<button @click="sidebarOpen=!sidebarOpen" class="text-[var(--text-secondary)] hover:text-white transition"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="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-2 flex-wrap">
|
||||
<select id="serverSelect" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-brand" onchange="onServerChange()">
|
||||
<select id="serverSelect" class="px-3 py-1.5 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-brand" onchange="onServerChange()">
|
||||
<option value="">-- 选择服务器 --</option>
|
||||
</select>
|
||||
<button id="refreshServersBtn" class="px-2 py-1.5 bg-slate-700 hover:bg-slate-600 text-white text-sm rounded-lg transition" title="刷新服务器列表" onclick="loadServers()">🔄</button>
|
||||
<input id="dirPath" type="text" value="/www/wwwroot" placeholder="/www/wwwroot" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-brand w-48">
|
||||
<input id="dirPath" type="text" value="/www/wwwroot" placeholder="/www/wwwroot" class="px-3 py-1.5 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-brand w-48">
|
||||
<button id="browseBtn" onclick="browseDir()" class="px-3 py-1.5 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">浏览</button>
|
||||
<button onclick="doMkdir()" class="px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-white text-sm rounded-lg transition" title="新建目录">📁+</button>
|
||||
<button onclick="doNewFile()" class="px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-white text-sm rounded-lg transition" title="新建文件">📄+</button>
|
||||
@@ -22,36 +22,36 @@
|
||||
</div>
|
||||
</header>
|
||||
<!-- Upload Progress Bar -->
|
||||
<div id="uploadProgress" class="hidden bg-slate-900/80 border-b border-slate-800 px-6 py-2">
|
||||
<div id="uploadProgress" class="hidden bg-[var(--bg-card)]/80 border-b border-[var(--border)] px-6 py-2">
|
||||
<div class="flex items-center gap-3 text-sm">
|
||||
<span id="uploadFileName" class="text-slate-300 truncate flex-1"></span>
|
||||
<span id="uploadPercent" class="text-brand-light tabular-nums w-12 text-right"></span>
|
||||
</div>
|
||||
<div class="mt-1 h-1.5 bg-slate-800 rounded-full overflow-hidden">
|
||||
<div class="mt-1 h-1.5 bg-[var(--bg-surface)] rounded-full overflow-hidden">
|
||||
<div id="uploadBar" class="h-full bg-brand rounded-full transition-all" style="width:0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Breadcrumb + Search -->
|
||||
<div id="breadcrumb" class="bg-slate-900/50 border-b border-slate-800 px-6 py-2 flex items-center gap-2 hidden">
|
||||
<span class="text-slate-500 text-sm shrink-0">路径:</span>
|
||||
<div id="breadcrumb" class="bg-[var(--bg-card)]/50 border-b border-[var(--border)] px-6 py-2 flex items-center gap-2 hidden">
|
||||
<span class="text-[var(--text-muted)] text-sm shrink-0">路径:</span>
|
||||
<div id="breadcrumbPath" class="flex items-center gap-1 flex-1 overflow-x-auto"></div>
|
||||
<input id="fileSearch" placeholder="🔍 搜索..." oninput="filterFiles(this.value)" class="px-2 py-1 bg-slate-800 border border-slate-700 rounded text-white text-xs w-40 focus:outline-none focus:ring-1 focus:ring-brand">
|
||||
<select onchange="setSort(this.value)" class="px-2 py-1 bg-slate-800 border border-slate-700 rounded text-white text-xs focus:outline-none">
|
||||
<input id="fileSearch" placeholder="🔍 搜索..." oninput="filterFiles(this.value)" class="px-2 py-1 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded text-white text-xs w-40 focus:outline-none focus:ring-1 focus:ring-brand">
|
||||
<select onchange="setSort(this.value)" class="px-2 py-1 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded text-white text-xs focus:outline-none">
|
||||
<option value="name">名称↑</option><option value="size">大小</option><option value="time">时间</option>
|
||||
</select>
|
||||
<select onchange="filterByExt(this.value)" class="px-2 py-1 bg-slate-800 border border-slate-700 rounded text-white text-xs focus:outline-none">
|
||||
<select onchange="filterByExt(this.value)" class="px-2 py-1 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded text-white text-xs focus:outline-none">
|
||||
<option value="">全部</option><option value="conf">.conf</option><option value="log">.log</option><option value="sh">.sh</option><option value="py">.py</option><option value="json">.json</option><option value="yml">.yml</option><option value="txt">.txt</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- Batch Action Bar -->
|
||||
<div id="batchBar" class="hidden bg-slate-800 border-b border-slate-700 px-6 py-2 flex items-center gap-3">
|
||||
<div id="batchBar" class="hidden bg-[var(--bg-surface)] border-b border-[var(--border-input)] px-6 py-2 flex items-center gap-3">
|
||||
<span id="batchCount" class="text-slate-300 text-sm"></span>
|
||||
<button onclick="batchDelete()" class="px-3 py-1 bg-red-600 hover:bg-red-700 text-white text-xs rounded-lg transition">🗑 批量删除</button>
|
||||
<button onclick="clearSelection()" class="px-3 py-1 bg-slate-600 hover:bg-slate-500 text-white text-xs rounded-lg transition">取消选择</button>
|
||||
</div>
|
||||
<main class="flex-1 overflow-y-auto p-6">
|
||||
<div id="fileList" class="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
|
||||
<div class="px-4 py-12 text-center text-slate-500">
|
||||
<div id="fileList" class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] overflow-hidden">
|
||||
<div class="px-4 py-12 text-center text-[var(--text-muted)]">
|
||||
<div class="text-4xl mb-3">📂</div>
|
||||
<p>选择服务器并输入目录路径开始浏览</p>
|
||||
</div>
|
||||
@@ -62,24 +62,24 @@
|
||||
<!-- Floating Editor Panel (draggable + resizable + file tree) -->
|
||||
<div id="editorPanel" class="hidden fixed z-40 flex flex-col" style="right:24px;bottom:60px;width:75vw;height:65vh;min-width:500px;min-height:300px;border-radius:12px;overflow:hidden;box-shadow:0 25px 50px -12px rgba(0,0,0,0.5);border:1px solid rgb(51 65 85);background:rgb(15 23 42)">
|
||||
<!-- Drag handle + tabs + buttons -->
|
||||
<div id="editorHeader" class="flex items-center bg-slate-900 border-b border-slate-800 shrink-0 select-none">
|
||||
<div id="editorHeader" class="flex items-center bg-[var(--bg-card)] border-b border-[var(--border)] shrink-0 select-none">
|
||||
<!-- Drag grip icon -->
|
||||
<div class="px-1.5 py-2 text-slate-600 cursor-grab active:cursor-grabbing shrink-0" title="拖拽移动">⠿</div>
|
||||
<div class="px-1.5 py-2 text-[var(--text-dim)] cursor-grab active:cursor-grabbing shrink-0" title="拖拽移动">⠿</div>
|
||||
<!-- File tree toggle -->
|
||||
<button onclick="toggleEditorTree()" id="treeToggleBtn" class="text-slate-500 hover:text-white px-2.5 py-2 text-sm hover:bg-slate-700 shrink-0" title="文件树">🌳</button>
|
||||
<button onclick="toggleEditorTree()" id="treeToggleBtn" class="text-[var(--text-muted)] hover:text-white px-2.5 py-2 text-sm hover:bg-slate-700 shrink-0" title="文件树">🌳</button>
|
||||
<!-- Tabs (clickable, not draggable) -->
|
||||
<div id="editorTabBar" class="flex flex-1 overflow-x-auto min-w-0"></div>
|
||||
<!-- Right buttons -->
|
||||
<div class="flex items-center shrink-0 relative">
|
||||
<!-- Settings menu -->
|
||||
<button onclick="toggleEditorMenu(event)" class="text-slate-500 hover:text-white px-2 py-2 text-sm hover:bg-slate-700" title="设置">⚙️</button>
|
||||
<button onclick="toggleEditorSize()" id="sizeBtn" class="text-slate-500 hover:text-white px-2 py-2 text-sm hover:bg-slate-700" title="最大化/还原">⬜</button>
|
||||
<button onclick="toggleEditorFullscreen()" id="fsBtn" class="text-slate-500 hover:text-white px-2 py-2 text-sm hover:bg-slate-700" title="全屏">⤢</button>
|
||||
<button onclick="minimizeEditor()" class="text-slate-500 hover:text-white px-2 py-2 text-sm hover:bg-slate-700" title="最小化">—</button>
|
||||
<button onclick="closeAllTabs()" class="text-slate-500 hover:text-red-400 px-2 py-2 text-sm hover:bg-red-900/30" title="关闭全部">✕</button>
|
||||
<button onclick="toggleEditorMenu(event)" class="text-[var(--text-muted)] hover:text-white px-2 py-2 text-sm hover:bg-slate-700" title="设置">⚙️</button>
|
||||
<button onclick="toggleEditorSize()" id="sizeBtn" class="text-[var(--text-muted)] hover:text-white px-2 py-2 text-sm hover:bg-slate-700" title="最大化/还原">⬜</button>
|
||||
<button onclick="toggleEditorFullscreen()" id="fsBtn" class="text-[var(--text-muted)] hover:text-white px-2 py-2 text-sm hover:bg-slate-700" title="全屏">⤢</button>
|
||||
<button onclick="minimizeEditor()" class="text-[var(--text-muted)] hover:text-white px-2 py-2 text-sm hover:bg-slate-700" title="最小化">—</button>
|
||||
<button onclick="closeAllTabs()" class="text-[var(--text-muted)] hover:text-red-400 px-2 py-2 text-sm hover:bg-red-900/30" title="关闭全部">✕</button>
|
||||
<!-- Settings dropdown menu -->
|
||||
<div id="editorMenu" class="hidden absolute right-0 top-full mt-1 bg-slate-800 border border-slate-700 rounded-lg shadow-xl py-2 min-w-[200px] z-50">
|
||||
<div class="px-4 py-2 text-xs text-slate-500 font-medium border-b border-slate-700 mb-1">编辑器设置</div>
|
||||
<div id="editorMenu" class="hidden absolute right-0 top-full mt-1 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg shadow-xl py-2 min-w-[200px] z-50">
|
||||
<div class="px-4 py-2 text-xs text-[var(--text-muted)] font-medium border-b border-[var(--border-input)] mb-1">编辑器设置</div>
|
||||
<button onclick="toggleEditorTheme()" class="w-full text-left px-4 py-2 text-sm text-slate-200 hover:bg-slate-700 flex items-center gap-2">
|
||||
<span id="themeIcon">☀️</span> <span id="themeLabel">亮色主题</span>
|
||||
</button>
|
||||
@@ -89,36 +89,36 @@
|
||||
<!-- Body: file tree + editor -->
|
||||
<div class="flex flex-1 min-h-0">
|
||||
<!-- File tree sidebar -->
|
||||
<div id="editorTree" class="w-52 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0 overflow-hidden">
|
||||
<div class="px-3 py-2 border-b border-slate-800 text-xs text-slate-500 font-medium">📁 文件列表</div>
|
||||
<div id="editorTree" class="w-52 bg-[var(--bg-card)] border-r border-[var(--border)] flex flex-col shrink-0 overflow-hidden">
|
||||
<div class="px-3 py-2 border-b border-[var(--border)] text-xs text-[var(--text-muted)] font-medium">📁 文件列表</div>
|
||||
<div id="editorTreeList" class="flex-1 overflow-y-auto text-sm"></div>
|
||||
</div>
|
||||
<!-- Monaco container -->
|
||||
<div id="editorMonacoWrap" class="flex-1 relative min-w-0"></div>
|
||||
</div>
|
||||
<!-- Status bar -->
|
||||
<div class="flex items-center gap-4 px-4 py-1.5 bg-slate-800 border-t border-slate-700 text-xs text-slate-500 shrink-0">
|
||||
<div class="flex items-center gap-4 px-4 py-1.5 bg-[var(--bg-surface)] border-t border-[var(--border-input)] text-xs text-[var(--text-muted)] shrink-0">
|
||||
<span id="editorPos">Ln 1, Col 1</span>
|
||||
<span id="editorLang">plaintext</span>
|
||||
<span>UTF-8</span>
|
||||
<span id="editorMod" class="text-amber-400 hidden">● 未保存</span>
|
||||
<div class="flex-1"></div>
|
||||
<span id="editorFileName" class="text-slate-400 truncate max-w-xs"></span>
|
||||
<span id="editorFileName" class="text-[var(--text-secondary)] truncate max-w-xs"></span>
|
||||
</div>
|
||||
<!-- Resize handle (bottom-right corner) -->
|
||||
<div id="editorResizeHandle" class="absolute bottom-0 right-0 w-4 h-4 cursor-nwse-resize z-10" style="background:linear-gradient(135deg,transparent 50%,rgb(100 116 139) 50%)"></div>
|
||||
</div>
|
||||
|
||||
<!-- Minimized editor bar -->
|
||||
<div id="editorMinBar" class="hidden fixed bottom-0 left-0 right-0 z-30 bg-slate-900 border-t border-slate-700 px-4 py-1.5 flex items-center gap-2">
|
||||
<div id="editorMinBar" class="hidden fixed bottom-0 left-0 right-0 z-30 bg-[var(--bg-card)] border-t border-[var(--border-input)] px-4 py-1.5 flex items-center gap-2">
|
||||
<div id="editorMinTabs" class="flex-1 flex items-center gap-1 overflow-x-auto"></div>
|
||||
<button onclick="restoreEditor()" class="px-2 py-0.5 text-xs text-slate-400 hover:text-white hover:bg-slate-700 rounded">▲ 展开</button>
|
||||
<button onclick="closeAllTabs()" class="px-2 py-0.5 text-xs text-slate-400 hover:text-red-400 hover:bg-red-900/30 rounded">✕</button>
|
||||
<button onclick="restoreEditor()" class="px-2 py-0.5 text-xs text-[var(--text-secondary)] hover:text-white hover:bg-slate-700 rounded">▲ 展开</button>
|
||||
<button onclick="closeAllTabs()" class="px-2 py-0.5 text-xs text-[var(--text-secondary)] hover:text-red-400 hover:bg-red-900/30 rounded">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- Modal (for non-editor operations: rename/delete/mkdir/chmod/compress) -->
|
||||
<div id="modal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div id="modalDialog" class="bg-slate-800 rounded-xl p-6 max-w-lg w-full mx-4 shadow-2xl border border-slate-700">
|
||||
<div id="modalDialog" class="bg-[var(--bg-surface)] rounded-xl p-6 max-w-lg w-full mx-4 shadow-2xl border border-[var(--border-input)]">
|
||||
<h3 id="modalTitle" class="text-white font-medium mb-4 text-lg"></h3>
|
||||
<p id="modalText" class="text-slate-300 text-sm mb-4 hidden whitespace-pre-line"></p>
|
||||
<input id="modalInput" class="hidden w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white mb-4 focus:outline-none focus:ring-2 focus:ring-brand text-sm" autocomplete="off">
|
||||
@@ -130,17 +130,17 @@
|
||||
</div>
|
||||
|
||||
<!-- F-1: Right-click context menu -->
|
||||
<div id="contextMenu" class="hidden fixed z-50 bg-slate-800 border border-slate-700 rounded-lg shadow-xl py-1 min-w-[180px]">
|
||||
<div id="contextMenu" class="hidden fixed z-50 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg shadow-xl py-1 min-w-[180px]">
|
||||
<button data-ctx="preview" class="ctx-item w-full text-left px-4 py-2 text-sm text-slate-200 hover:bg-slate-700 transition">👁 预览/编辑</button>
|
||||
<button data-ctx="download" class="ctx-item w-full text-left px-4 py-2 text-sm text-slate-200 hover:bg-slate-700 transition">⬇ 下载</button>
|
||||
<button data-ctx="rename" class="ctx-item w-full text-left px-4 py-2 text-sm text-slate-200 hover:bg-slate-700 transition">✏ 重命名</button>
|
||||
<button data-ctx="chmod" class="ctx-item w-full text-left px-4 py-2 text-sm text-slate-200 hover:bg-slate-700 transition">🔐 修改权限</button>
|
||||
<button data-ctx="copyPath" class="ctx-item w-full text-left px-4 py-2 text-sm text-slate-200 hover:bg-slate-700 transition">📋 复制路径</button>
|
||||
<button data-ctx="terminal" class="ctx-item w-full text-left px-4 py-2 text-sm text-slate-200 hover:bg-slate-700 transition">🖥 在此打开终端</button>
|
||||
<div class="border-t border-slate-700 my-1"></div>
|
||||
<div class="border-t border-[var(--border-input)] my-1"></div>
|
||||
<button data-ctx="compress" class="ctx-item w-full text-left px-4 py-2 text-sm text-slate-200 hover:bg-slate-700 transition">📦 压缩为 tar.gz</button>
|
||||
<button data-ctx="decompress" class="ctx-item w-full text-left px-4 py-2 text-sm text-slate-200 hover:bg-slate-700 transition">📂 解压到当前目录</button>
|
||||
<div class="border-t border-slate-700 my-1"></div>
|
||||
<div class="border-t border-[var(--border-input)] my-1"></div>
|
||||
<button data-ctx="delete" class="ctx-item w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-red-900/30 transition">🗑 删除</button>
|
||||
</div>
|
||||
|
||||
@@ -187,7 +187,7 @@
|
||||
pdf:'📕',doc:'📘',docx:'📘',xls:'📗',xlsx:'📗',
|
||||
mp3:'🎵',mp4:'🎬',avi:'🎬',mkv:'🎬',
|
||||
env:'🔒',key:'🔒',pem:'🔒',crt:'🔒',cert:'🔒'};
|
||||
return{icon:m[ext]||'📄',color:'text-slate-400'};
|
||||
return{icon:m[ext]||'📄',color:'text-[var(--text-secondary)]'};
|
||||
}
|
||||
|
||||
// ── P2-2/P2-3: Event delegation ──
|
||||
@@ -349,7 +349,7 @@
|
||||
function onServerChange(){
|
||||
document.getElementById('dirPath').value='/www/wwwroot';
|
||||
document.getElementById('breadcrumb').classList.add('hidden');
|
||||
fileListEl.innerHTML='<div class="px-4 py-12 text-center text-slate-500"><div class="text-4xl mb-3">📂</div><p>点击「浏览」查看目录</p></div>';
|
||||
fileListEl.innerHTML='<div class="px-4 py-12 text-center text-[var(--text-muted)]"><div class="text-4xl mb-3">📂</div><p>点击「浏览」查看目录</p></div>';
|
||||
_fileCache={};
|
||||
}
|
||||
|
||||
@@ -403,7 +403,7 @@
|
||||
if(!sid){toast('warning','请先选择服务器');return}
|
||||
_currentServerId=parseInt(sid);_selectedPaths.clear();updateBatchBar();
|
||||
|
||||
fileListEl.innerHTML='<div class="px-4 py-12 text-center text-slate-500"><div class="text-2xl mb-2 animate-spin">⏳</div>加载中...</div>';
|
||||
fileListEl.innerHTML='<div class="px-4 py-12 text-center text-[var(--text-muted)]"><div class="text-2xl mb-2 animate-spin">⏳</div>加载中...</div>';
|
||||
try{
|
||||
const r=await apiFetch(API+'/sync/browse',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({server_id:_currentServerId,path:_currentPath})});
|
||||
if(!r){fileListEl.innerHTML='<div class="px-4 py-12 text-center text-red-400">认证失败</div>';return}
|
||||
@@ -426,13 +426,13 @@
|
||||
// Parent directory
|
||||
if(_currentPath!=='/'){
|
||||
const parentPath=_currentPath.replace(/\/[^/]+\/?$/,'')||'/';
|
||||
html+=`<div class="flex items-center gap-3 px-4 py-2 border-b border-slate-800 hover:bg-slate-800/50 cursor-pointer" data-action="browse" data-path="${esc(parentPath)}">
|
||||
<span class="text-slate-500">📂</span><span class="text-slate-400 text-sm">..</span><span class="text-slate-600 text-xs">返回上级目录</span>
|
||||
html+=`<div class="flex items-center gap-3 px-4 py-2 border-b border-[var(--border)] hover:bg-[var(--bg-surface)]/50 cursor-pointer" data-action="browse" data-path="${esc(parentPath)}">
|
||||
<span class="text-[var(--text-muted)]">📂</span><span class="text-[var(--text-secondary)] text-sm">..</span><span class="text-[var(--text-dim)] text-xs">返回上级目录</span>
|
||||
</div>`;
|
||||
}
|
||||
// P2-10: Select all checkbox
|
||||
if(entries.length){
|
||||
html+=`<div class="flex items-center gap-3 px-4 py-1.5 border-b border-slate-800 bg-slate-800/30 text-xs text-slate-500">
|
||||
html+=`<div class="flex items-center gap-3 px-4 py-1.5 border-b border-[var(--border)] bg-[var(--bg-surface)]/30 text-xs text-[var(--text-muted)]">
|
||||
<input type="checkbox" data-action="select-all" class="rounded border-slate-600 bg-slate-700 text-brand focus:ring-brand cursor-pointer">
|
||||
<span class="flex-1">全选</span><span class="w-16 text-right">大小</span><span class="w-20 hidden sm:block">权限</span><span class="w-16 hidden md:block">所有者</span><span class="w-20"></span>
|
||||
</div>`;
|
||||
@@ -444,24 +444,24 @@
|
||||
const fi=fileIcon(e.name,e.is_dir,e.is_link);
|
||||
const fullPath=_currentPath.replace(/\/$/,'')+'/'+e.name;
|
||||
const linkHint=e.is_link?`title="→ ${esc(e.link_target||'')}"`:'';
|
||||
return `<div class="file-row group flex items-center gap-3 px-4 py-2.5 border-b border-slate-800 hover:bg-slate-800/50" data-name="${esc(e.name.toLowerCase())}">
|
||||
return `<div class="file-row group flex items-center gap-3 px-4 py-2.5 border-b border-[var(--border)] hover:bg-[var(--bg-surface)]/50" data-name="${esc(e.name.toLowerCase())}">
|
||||
<input type="checkbox" data-action="select" data-path="${esc(fullPath)}" class="file-cb rounded border-slate-600 bg-slate-700 text-brand focus:ring-brand shrink-0">
|
||||
<span class="${fi.color} text-sm shrink-0" ${linkHint}>${fi.icon}</span>
|
||||
<span class="text-white text-sm flex-1 truncate ${e.is_dir?'cursor-pointer hover:text-brand-light':''}" ${e.is_dir?`data-action="browse" data-path="${esc(fullPath)}"`:''} ${linkHint}>${esc(e.name)}</span>
|
||||
<span class="text-slate-500 text-xs tabular-nums w-16 text-right shrink-0">${e.is_dir?'--':formatSize(e.size)}</span>
|
||||
<span class="text-slate-600 text-xs shrink-0 w-20 hidden sm:block">${esc(e.perms)}</span>
|
||||
<span class="text-slate-600 text-xs shrink-0 w-16 hidden md:block truncate">${esc(e.owner||'')}</span>
|
||||
<span class="text-[var(--text-muted)] text-xs tabular-nums w-16 text-right shrink-0">${e.is_dir?'--':formatSize(e.size)}</span>
|
||||
<span class="text-[var(--text-dim)] text-xs shrink-0 w-20 hidden sm:block">${esc(e.perms)}</span>
|
||||
<span class="text-[var(--text-dim)] text-xs shrink-0 w-16 hidden md:block truncate">${esc(e.owner||'')}</span>
|
||||
<div class="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0 w-20 justify-end">
|
||||
${!e.is_dir?`<button data-action="preview" data-path="${esc(fullPath)}" class="px-1.5 py-1 text-xs text-slate-400 hover:text-white hover:bg-slate-700 rounded" title="预览">👁</button>`:''}
|
||||
${!e.is_dir?`<button data-action="download" data-path="${esc(fullPath)}" data-name="${esc(e.name)}" class="px-1.5 py-1 text-xs text-slate-400 hover:text-white hover:bg-slate-700 rounded" title="下载">⬇</button>`:''}
|
||||
<button data-action="rename" data-path="${esc(fullPath)}" data-name="${esc(e.name)}" class="px-1.5 py-1 text-xs text-slate-400 hover:text-white hover:bg-slate-700 rounded" title="重命名">✏</button>
|
||||
${!e.is_dir?`<button data-action="preview" data-path="${esc(fullPath)}" class="px-1.5 py-1 text-xs text-[var(--text-secondary)] hover:text-white hover:bg-slate-700 rounded" title="预览">👁</button>`:''}
|
||||
${!e.is_dir?`<button data-action="download" data-path="${esc(fullPath)}" data-name="${esc(e.name)}" class="px-1.5 py-1 text-xs text-[var(--text-secondary)] hover:text-white hover:bg-slate-700 rounded" title="下载">⬇</button>`:''}
|
||||
<button data-action="rename" data-path="${esc(fullPath)}" data-name="${esc(e.name)}" class="px-1.5 py-1 text-xs text-[var(--text-secondary)] hover:text-white hover:bg-slate-700 rounded" title="重命名">✏</button>
|
||||
<button data-action="delete" data-path="${esc(fullPath)}" data-is-dir="${e.is_dir}" class="px-1.5 py-1 text-xs text-red-400 hover:text-red-300 hover:bg-red-900/30 rounded" title="${e.is_dir?'删除目录':'删除文件'}">🗑</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
// P3-3: Empty state
|
||||
if(!entries.length){
|
||||
html+=`<div class="px-4 py-12 text-center"><div class="text-4xl mb-3">📂</div><p class="text-slate-400 mb-3">空目录</p><button onclick="pickUploadFile()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">上传文件</button></div>`;
|
||||
html+=`<div class="px-4 py-12 text-center"><div class="text-4xl mb-3">📂</div><p class="text-[var(--text-secondary)] mb-3">空目录</p><button onclick="pickUploadFile()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">上传文件</button></div>`;
|
||||
}
|
||||
fileListEl.innerHTML=html;
|
||||
document.getElementById('fileSearch').value='';
|
||||
@@ -525,7 +525,7 @@
|
||||
let accumulated='';
|
||||
segments.forEach((seg,i)=>{
|
||||
accumulated+='/'+seg;const p=accumulated;
|
||||
const sep=i===0?'':'<span class="text-slate-600">/</span>';
|
||||
const sep=i===0?'':'<span class="text-[var(--text-dim)]">/</span>';
|
||||
if(i<segments.length-1){
|
||||
html+=`${sep}<span class="cursor-pointer text-brand-light hover:underline" data-action="browse" data-path="${esc(p)}">${esc(seg)}</span>`;
|
||||
}else{
|
||||
@@ -833,17 +833,17 @@
|
||||
function renderFileTree(){
|
||||
const list=document.getElementById('editorTreeList');
|
||||
if(!list)return;
|
||||
let html=`<div class="px-3 py-1.5 text-xs text-slate-600 cursor-pointer hover:bg-slate-800" onclick="browseDir('${esc(_currentPath.replace(/\/[^/]+\/?$/,'')||'/')}')">📂 ..</div>`;
|
||||
let html=`<div class="px-3 py-1.5 text-xs text-[var(--text-dim)] cursor-pointer hover:bg-[var(--bg-surface)]" onclick="browseDir('${esc(_currentPath.replace(/\/[^/]+\/?$/,'')||'/')}')">📂 ..</div>`;
|
||||
const sorted=sortEntries(_allEntries);
|
||||
sorted.forEach(e=>{
|
||||
const fp=_currentPath.replace(/\/$/,'')+'/'+e.name;
|
||||
if(e.is_dir){
|
||||
html+=`<div class="px-3 py-1 text-xs text-slate-400 cursor-pointer hover:bg-slate-800 truncate" onclick="browseDir('${esc(fp)}')">📁 ${esc(e.name)}</div>`;
|
||||
html+=`<div class="px-3 py-1 text-xs text-[var(--text-secondary)] cursor-pointer hover:bg-[var(--bg-surface)] truncate" onclick="browseDir('${esc(fp)}')">📁 ${esc(e.name)}</div>`;
|
||||
}else{
|
||||
const fi=fileIcon(e.name,false,e.is_link);
|
||||
const inTab=_openTabs.findIndex(t=>t.path===fp);
|
||||
const active=inTab===_activeTab;
|
||||
html+=`<div class="px-3 py-1 text-xs cursor-pointer hover:bg-slate-800 truncate ${active?'bg-slate-800 text-white':'text-slate-400'}" onclick="openFileFromTree('${esc(fp)}','${esc(e.name)}')">
|
||||
html+=`<div class="px-3 py-1 text-xs cursor-pointer hover:bg-[var(--bg-surface)] truncate ${active?'bg-[var(--bg-surface)] text-white':'text-[var(--text-secondary)]'}" onclick="openFileFromTree('${esc(fp)}','${esc(e.name)}')">
|
||||
${fi.icon} ${esc(e.name)}${inTab>=0?'<span class="text-brand-light ml-1">●</span>':''}
|
||||
</div>`;
|
||||
}
|
||||
@@ -929,9 +929,9 @@
|
||||
bar.innerHTML=_openTabs.map((t,i)=>{
|
||||
const active=i===_activeTab;
|
||||
const mod=t.modified?'● ':'';
|
||||
return `<div class="flex items-center gap-1 px-3 py-2 text-xs cursor-pointer border-r border-slate-800 shrink-0 ${active?'bg-slate-800 text-white':'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'}" onclick="switchTab(${i})">
|
||||
return `<div class="flex items-center gap-1 px-3 py-2 text-xs cursor-pointer border-r border-[var(--border)] shrink-0 ${active?'bg-[var(--bg-surface)] text-white':'text-[var(--text-secondary)] hover:text-slate-200 hover:bg-[var(--bg-surface)]/50'}" onclick="switchTab(${i})">
|
||||
<span class="${t.modified?'text-amber-400':''}">${mod}${esc(t.name)}</span>
|
||||
<button onclick="event.stopPropagation();closeTab(${i})" class="ml-1 text-slate-600 hover:text-red-400">×</button>
|
||||
<button onclick="event.stopPropagation();closeTab(${i})" class="ml-1 text-[var(--text-dim)] hover:text-red-400">×</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
@@ -940,9 +940,9 @@
|
||||
const bar=document.getElementById('editorMinTabs');
|
||||
bar.innerHTML=_openTabs.map((t,i)=>{
|
||||
const active=i===_activeTab;
|
||||
return `<div class="flex items-center gap-1 px-2 py-1 text-xs cursor-pointer rounded ${active?'bg-slate-700 text-white':'text-slate-400 hover:text-slate-200'}" onclick="switchTab(${i});restoreEditor()">
|
||||
return `<div class="flex items-center gap-1 px-2 py-1 text-xs cursor-pointer rounded ${active?'bg-slate-700 text-white':'text-[var(--text-secondary)] hover:text-slate-200'}" onclick="switchTab(${i});restoreEditor()">
|
||||
<span class="${t.modified?'text-amber-400':''}">${t.modified?'● ':''}${esc(t.name)}</span>
|
||||
<button onclick="event.stopPropagation();closeTab(${i})" class="ml-1 text-slate-600 hover:text-red-400">×</button>
|
||||
<button onclick="event.stopPropagation();closeTab(${i})" class="ml-1 text-[var(--text-dim)] hover:text-red-400">×</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
+37
-36
@@ -5,6 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Nexus — 仪表盘</title>
|
||||
<script src="/app/vendor/tailwindcss-browser.js"></script>
|
||||
<link rel="stylesheet" href="/app/vendor/theme.css">
|
||||
<script defer src="/app/vendor/alpinejs.min.js"></script>
|
||||
<style type="text/tailwindcss">
|
||||
@theme {
|
||||
@@ -14,21 +15,21 @@
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-slate-950 text-slate-100 min-h-screen flex" x-data="{sidebarOpen:true}">
|
||||
<body class="bg-[var(--bg-page)] text-[var(--text-primary)] 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>
|
||||
<aside x-show="sidebarOpen" x-transition class="w-64 bg-[var(--bg-card)] border-r border-[var(--border)] 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">
|
||||
<header class="bg-[var(--bg-card)] border-b border-[var(--border)] 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">
|
||||
<button @click="sidebarOpen = !sidebarOpen" class="text-[var(--text-secondary)] 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">
|
||||
<button id="refreshBtn" onclick="refreshAll()" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-300 text-sm rounded-lg transition flex items-center gap-1.5">刷新</button>
|
||||
<span id="headerUser" class="text-slate-400 text-sm">...</span>
|
||||
<button id="refreshBtn" onclick="refreshAll()" class="px-3 py-1.5 bg-[var(--bg-surface)] hover:bg-slate-700 text-slate-300 text-sm rounded-lg transition flex items-center gap-1.5">刷新</button>
|
||||
<span id="headerUser" class="text-[var(--text-secondary)] text-sm">...</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -36,20 +37,20 @@
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
|
||||
<div class="text-slate-400 text-sm mb-1">服务器总数</div>
|
||||
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-5">
|
||||
<div class="text-[var(--text-secondary)] text-sm mb-1">服务器总数</div>
|
||||
<div class="text-3xl font-bold text-white" id="statTotal">--</div>
|
||||
</div>
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
|
||||
<div class="text-slate-400 text-sm mb-1">在线</div>
|
||||
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-5">
|
||||
<div class="text-[var(--text-secondary)] text-sm mb-1">在线</div>
|
||||
<div class="text-3xl font-bold text-green-400" id="statOnline">--</div>
|
||||
</div>
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
|
||||
<div class="text-slate-400 text-sm mb-1">离线</div>
|
||||
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-5">
|
||||
<div class="text-[var(--text-secondary)] text-sm mb-1">离线</div>
|
||||
<div class="text-3xl font-bold text-red-400" id="statOffline">--</div>
|
||||
</div>
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
|
||||
<div class="text-slate-400 text-sm mb-1">告警中</div>
|
||||
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-5">
|
||||
<div class="text-[var(--text-secondary)] text-sm mb-1">告警中</div>
|
||||
<div class="text-3xl font-bold text-yellow-400" id="statAlerts">--</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -57,20 +58,20 @@
|
||||
<!-- Run status + Category Pie -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<div class="lg:col-span-2">
|
||||
<div id="statusPanel" class="bg-slate-900 rounded-xl border border-slate-800 p-4">
|
||||
<div id="statusPanel" class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-4">
|
||||
<h2 class="text-white font-semibold text-sm mb-2">运行状态</h2>
|
||||
<div id="statusSummary" class="flex items-center gap-2 text-slate-500 text-sm">
|
||||
<div id="statusSummary" class="flex items-center gap-2 text-[var(--text-muted)] text-sm">
|
||||
<span id="statusDot" class="inline-block w-2 h-2 rounded-full bg-slate-600 shrink-0"></span>
|
||||
<span id="statusText">加载中...</span>
|
||||
</div>
|
||||
<p class="text-slate-600 text-xs mt-2">告警详情通过 Telegram 推送;此处仅展示汇总状态。</p>
|
||||
<p class="text-[var(--text-dim)] text-xs mt-2">告警详情通过 Telegram 推送;此处仅展示汇总状态。</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Category Breakdown -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
|
||||
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-5">
|
||||
<h2 class="text-white font-semibold mb-4 text-sm">分类分布</h2>
|
||||
<div id="categoryBars" class="space-y-3">
|
||||
<div class="text-slate-500 text-sm">加载中...</div>
|
||||
<div class="text-[var(--text-muted)] text-sm">加载中...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -78,20 +79,20 @@
|
||||
<!-- Recent Sync + Recent Audit -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<!-- Recent Sync Logs -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
|
||||
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-white font-semibold text-sm">最近同步</h2>
|
||||
<a href="/app/push.html" class="text-brand-light text-xs hover:underline">查看全部</a>
|
||||
</div>
|
||||
<div id="recentSyncs" class="text-slate-400 text-sm">加载中...</div>
|
||||
<div id="recentSyncs" class="text-[var(--text-secondary)] text-sm">加载中...</div>
|
||||
</div>
|
||||
<!-- Recent Audit -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
|
||||
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-white font-semibold text-sm">最近活动</h2>
|
||||
<a href="/app/audit.html" class="text-brand-light text-xs hover:underline">查看全部</a>
|
||||
</div>
|
||||
<div id="recentActivity" class="text-slate-400 text-sm">加载中...</div>
|
||||
<div id="recentActivity" class="text-[var(--text-secondary)] text-sm">加载中...</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
@@ -127,14 +128,14 @@
|
||||
const text = document.getElementById('statusText');
|
||||
if (!panel || !dot || !text) return;
|
||||
if (n > 0) {
|
||||
panel.className = 'bg-slate-900 rounded-xl border border-yellow-700/40 p-4';
|
||||
panel.className = 'bg-[var(--bg-card)] rounded-xl border border-yellow-700/40 p-4';
|
||||
dot.className = 'inline-block w-2 h-2 rounded-full bg-yellow-400 shrink-0';
|
||||
text.className = 'text-yellow-200/90 text-sm';
|
||||
text.textContent = `当前有 ${n} 台服务器处于告警状态(详见 Telegram)`;
|
||||
} else {
|
||||
panel.className = 'bg-slate-900 rounded-xl border border-slate-800 p-4';
|
||||
panel.className = 'bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-4';
|
||||
dot.className = 'inline-block w-2 h-2 rounded-full bg-green-400 shrink-0';
|
||||
text.className = 'text-slate-400 text-sm';
|
||||
text.className = 'text-[var(--text-secondary)] text-sm';
|
||||
text.textContent = '所有服务器运行正常';
|
||||
}
|
||||
}
|
||||
@@ -142,7 +143,7 @@
|
||||
function renderCategories(categories) {
|
||||
const entries = Object.entries(categories);
|
||||
if (!entries.length) {
|
||||
document.getElementById('categoryBars').innerHTML = '<div class="text-slate-500 text-sm">暂无分类数据</div>';
|
||||
document.getElementById('categoryBars').innerHTML = '<div class="text-[var(--text-muted)] text-sm">暂无分类数据</div>';
|
||||
return;
|
||||
}
|
||||
const total = entries.reduce((s,[,v]) => s+v, 0);
|
||||
@@ -150,8 +151,8 @@
|
||||
document.getElementById('categoryBars').innerHTML = entries.map(([cat,count],i) => {
|
||||
const pct = Math.round(count/total*100);
|
||||
return `<div>
|
||||
<div class="flex justify-between text-xs mb-1"><span class="text-slate-300">${esc(cat||'未分类')}</span><span class="text-slate-500">${count}台 (${pct}%)</span></div>
|
||||
<div class="w-full bg-slate-800 rounded-full h-2"><div class="${colors[i%colors.length]} h-2 rounded-full transition-all" style="width:${pct}%"></div></div>
|
||||
<div class="flex justify-between text-xs mb-1"><span class="text-slate-300">${esc(cat||'未分类')}</span><span class="text-[var(--text-muted)]">${count}台 (${pct}%)</span></div>
|
||||
<div class="w-full bg-[var(--bg-surface)] rounded-full h-2"><div class="${colors[i%colors.length]} h-2 rounded-full transition-all" style="width:${pct}%"></div></div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
@@ -163,15 +164,15 @@
|
||||
if (!res) return;
|
||||
const logs = await res.json();
|
||||
if (!logs.length) {
|
||||
document.getElementById('recentSyncs').innerHTML = '<div class="text-slate-500 text-sm">暂无同步记录</div>';
|
||||
document.getElementById('recentSyncs').innerHTML = '<div class="text-[var(--text-muted)] text-sm">暂无同步记录</div>';
|
||||
return;
|
||||
}
|
||||
document.getElementById('recentSyncs').innerHTML = logs.map(l => {
|
||||
const sc = l.status==='success'?'text-green-400':l.status==='failed'?'text-red-400':'text-yellow-400';
|
||||
const icon = l.status==='success'?'✅':l.status==='failed'?'❌':'⏳';
|
||||
return `<div class="flex items-center justify-between py-2 border-b border-slate-800 last:border-0">
|
||||
return `<div class="flex items-center justify-between py-2 border-b border-[var(--border)] last:border-0">
|
||||
<div class="flex items-center gap-2"><span class="text-xs">${icon}</span><span class="text-slate-300 text-xs">${esc(l.server_name||'#'+l.server_id)}</span><span class="${sc} text-xs">${esc(l.status)}</span></div>
|
||||
<span class="text-slate-600 text-xs">${fmtTime(l.started_at)}</span>
|
||||
<span class="text-[var(--text-dim)] text-xs">${fmtTime(l.started_at)}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
} catch(e) {
|
||||
@@ -187,14 +188,14 @@
|
||||
const data = await res.json();
|
||||
const audits = data.items || data;
|
||||
if (!audits.length) {
|
||||
document.getElementById('recentActivity').innerHTML = '<div class="text-slate-500 text-sm">暂无活动</div>';
|
||||
document.getElementById('recentActivity').innerHTML = '<div class="text-[var(--text-muted)] text-sm">暂无活动</div>';
|
||||
return;
|
||||
}
|
||||
document.getElementById('recentActivity').innerHTML = audits.map(a => {
|
||||
const icon = a.action.includes('delete')?'🔴':a.action.includes('create')?'🔵':a.action.includes('login')?'🟢':'⚪';
|
||||
return `<div class="flex items-center justify-between py-2 border-b border-slate-800 last:border-0">
|
||||
<div class="flex items-center gap-2"><span class="text-xs">${icon}</span><span class="text-brand-light text-xs">${esc(a.admin_username||'system')}</span><span class="text-slate-500 text-xs">${esc(a.action)}</span></div>
|
||||
<span class="text-slate-600 text-xs">${fmtTime(a.created_at)}</span>
|
||||
return `<div class="flex items-center justify-between py-2 border-b border-[var(--border)] last:border-0">
|
||||
<div class="flex items-center gap-2"><span class="text-xs">${icon}</span><span class="text-brand-light text-xs">${esc(a.admin_username||'system')}</span><span class="text-[var(--text-muted)] text-xs">${esc(a.action)}</span></div>
|
||||
<span class="text-[var(--text-dim)] text-xs">${fmtTime(a.created_at)}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
} catch(e) {
|
||||
|
||||
+38
-37
@@ -5,6 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Nexus 6.0 安装向导</title>
|
||||
<script src="/app/vendor/tailwindcss-browser.js"></script>
|
||||
<link rel="stylesheet" href="/app/vendor/theme.css">
|
||||
<script defer src="/app/vendor/alpinejs.min.js"></script>
|
||||
<style type="text/tailwindcss">
|
||||
@theme {
|
||||
@@ -29,7 +30,7 @@
|
||||
.copy-btn.copied { background: #22c55e; color: #fff; border-color: #22c55e; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-slate-950 min-h-screen flex items-center justify-center p-4">
|
||||
<body class="bg-[var(--bg-page)] min-h-screen flex items-center justify-center p-4">
|
||||
|
||||
<div class="installer-card w-full bg-white rounded-2xl shadow-2xl overflow-hidden">
|
||||
|
||||
@@ -44,7 +45,7 @@
|
||||
<template x-for="i in 5" :key="i">
|
||||
<div class="flex items-center">
|
||||
<div class="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold transition-all"
|
||||
:class="step > i ? 'bg-green-500 text-white' : step === i ? 'bg-blue-500 text-white' : 'border-2 border-slate-200 text-slate-400'"
|
||||
:class="step > i ? 'bg-green-500 text-white' : step === i ? 'bg-blue-500 text-white' : 'border-2 border-slate-200 text-[var(--text-secondary)]'"
|
||||
x-text="step > i ? '✓' : i"></div>
|
||||
<div x-show="i < 5" class="w-8 h-0.5 mx-0.5 transition-all"
|
||||
:class="step > i ? 'bg-green-500' : 'bg-slate-200'"></div>
|
||||
@@ -64,7 +65,7 @@
|
||||
<div class="text-center py-8">
|
||||
<div class="text-5xl mb-4">⚠️</div>
|
||||
<h2 class="text-xl font-bold text-red-600 mb-2">系统已安装</h2>
|
||||
<p class="text-slate-500 mb-4">检测到 .env 配置文件,系统已完成安装。</p>
|
||||
<p class="text-[var(--text-muted)] mb-4">检测到 .env 配置文件,系统已完成安装。</p>
|
||||
<a href="/app/login.html" class="inline-flex items-center gap-2 bg-blue-500 hover:bg-blue-600 text-white px-5 py-2.5 rounded-lg font-medium transition">前往登录 →</a>
|
||||
</div>
|
||||
</template>
|
||||
@@ -73,7 +74,7 @@
|
||||
<template x-if="step === 1 && !installed">
|
||||
<div class="text-center py-4">
|
||||
<h2 class="text-xl font-bold text-slate-800 mb-4">欢迎使用 Nexus 6.0</h2>
|
||||
<p class="text-slate-500 mb-6 leading-relaxed">
|
||||
<p class="text-[var(--text-muted)] mb-6 leading-relaxed">
|
||||
本向导将引导您完成系统安装配置。<br>
|
||||
安装完成后将自动生成:<code class="bg-slate-100 px-1.5 py-0.5 rounded text-sm">.env</code> (Python后端) +
|
||||
<code class="bg-slate-100 px-1.5 py-0.5 rounded text-sm">config.json</code> (共享配置) +
|
||||
@@ -82,7 +83,7 @@
|
||||
</p>
|
||||
<div class="bg-slate-50 rounded-lg p-5 text-left mb-6">
|
||||
<h3 class="font-semibold text-slate-700 mb-3">📋 安装前准备</h3>
|
||||
<ul class="text-slate-600 text-sm ml-5 list-disc space-y-1">
|
||||
<ul class="text-[var(--text-dim)] text-sm ml-5 list-disc space-y-1">
|
||||
<li>Python 3.12+ (FastAPI + uvicorn)</li>
|
||||
<li>MySQL 8.0+ (需提前创建数据库和用户)</li>
|
||||
<li>Redis 6+ (心跳缓冲和实时数据)</li>
|
||||
@@ -100,7 +101,7 @@
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-slate-800 mb-4">🔍 环境检测</h2>
|
||||
|
||||
<div x-show="envLoading" class="text-center py-8 text-slate-400">
|
||||
<div x-show="envLoading" class="text-center py-8 text-[var(--text-secondary)]">
|
||||
<div class="animate-spin inline-block w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full mb-2"></div>
|
||||
<p>正在检测环境...</p>
|
||||
</div>
|
||||
@@ -110,7 +111,7 @@
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b border-slate-100 last:border-0">
|
||||
<div>
|
||||
<span class="font-medium text-slate-700" x-text="c.name"></span>
|
||||
<span class="text-slate-400 text-xs ml-2" x-text="'需要: ' + c.required"></span>
|
||||
<span class="text-[var(--text-secondary)] text-xs ml-2" x-text="'需要: ' + c.required"></span>
|
||||
</div>
|
||||
<span class="font-semibold text-sm" :class="c.pass ? 'text-green-500' : 'text-red-500'"
|
||||
x-text="c.pass ? '✓ 通过' : '✗ ' + c.current"></span>
|
||||
@@ -208,13 +209,13 @@
|
||||
<h3 class="font-bold text-slate-700 mb-3">🌐 网站地址 + API 服务 + 时区</h3>
|
||||
<div class="mt-3">
|
||||
<label class="block font-semibold text-sm text-slate-700 mb-1">网站地址</label>
|
||||
<p class="text-xs text-slate-400 mb-1">自动检测 — 用于 Agent 上报和前端 API 调用,可手动修改</p>
|
||||
<p class="text-xs text-[var(--text-secondary)] mb-1">自动检测 — 用于 Agent 上报和前端 API 调用,可手动修改</p>
|
||||
<input x-model="form.site_url" class="w-full px-3 py-2 border-2 border-slate-200 rounded-lg text-sm focus:border-blue-500 focus:outline-none transition">
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4 mt-3">
|
||||
<div>
|
||||
<label class="block font-semibold text-sm text-slate-700 mb-1">Python API 端口</label>
|
||||
<p class="text-xs text-slate-400 mb-1">uvicorn 监听端口</p>
|
||||
<p class="text-xs text-[var(--text-secondary)] mb-1">uvicorn 监听端口</p>
|
||||
<input x-model="form.api_port" class="w-full px-3 py-2 border-2 border-slate-200 rounded-lg text-sm focus:border-blue-500 focus:outline-none transition">
|
||||
</div>
|
||||
<div>
|
||||
@@ -268,7 +269,7 @@
|
||||
<h3 class="font-bold text-slate-700 mb-3">系统品牌</h3>
|
||||
<div class="mt-3">
|
||||
<label class="block font-semibold text-sm text-slate-700 mb-1">系统名称</label>
|
||||
<p class="text-xs text-slate-400 mb-1">显示在浏览器标题和后台标题栏</p>
|
||||
<p class="text-xs text-[var(--text-secondary)] mb-1">显示在浏览器标题和后台标题栏</p>
|
||||
<input x-model="adminForm.system_name" class="w-full px-3 py-2 border-2 border-slate-200 rounded-lg text-sm focus:border-blue-500 focus:outline-none transition">
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
@@ -286,7 +287,7 @@
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<label class="block font-semibold text-sm text-slate-700 mb-1">管理员密码</label>
|
||||
<p class="text-xs text-slate-400 mb-1">至少6位字符</p>
|
||||
<p class="text-xs text-[var(--text-secondary)] mb-1">至少6位字符</p>
|
||||
<input x-model="adminForm.admin_password" type="password" required minlength="6" placeholder="请输入安全密码"
|
||||
class="w-full px-3 py-2 border-2 border-slate-200 rounded-lg text-sm focus:border-blue-500 focus:outline-none transition">
|
||||
</div>
|
||||
@@ -313,8 +314,8 @@
|
||||
<div class="text-center">
|
||||
<div class="w-16 h-16 rounded-full bg-green-100 text-green-500 text-3xl flex items-center justify-center mx-auto mb-4">✓</div>
|
||||
<h2 class="text-xl font-bold text-slate-800 mb-2">安装完成!</h2>
|
||||
<p class="text-slate-500 mb-1">Nexus 6.0 已成功安装。</p>
|
||||
<p class="text-slate-400 text-xs">安装向导已锁定 — 防止重复安装(如需重装请删除 .env 文件后重启服务)</p>
|
||||
<p class="text-[var(--text-muted)] mb-1">Nexus 6.0 已成功安装。</p>
|
||||
<p class="text-[var(--text-secondary)] text-xs">安装向导已锁定 — 防止重复安装(如需重装请删除 .env 文件后重启服务)</p>
|
||||
|
||||
<!-- Guardian results -->
|
||||
<div x-show="initResult?.guardian_results?.length" class="text-left mt-6 mb-4 rounded-lg p-4"
|
||||
@@ -336,12 +337,12 @@
|
||||
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-blue-500 text-white text-xs font-bold mr-2">1</span>
|
||||
Supervisor 进程守护
|
||||
</div>
|
||||
<div x-show="!guardianAllOk" class="text-xs text-slate-500 space-y-1">
|
||||
<div x-show="!guardianAllOk" class="text-xs text-[var(--text-muted)] space-y-1">
|
||||
<!-- BT Panel mode -->
|
||||
<template x-if="btPanel">
|
||||
<div>
|
||||
<p>宝塔面板 → 软件商店 → Supervisor管理器 → 添加守护进程</p>
|
||||
<p>或手动写入配置: <code class="bg-slate-800 text-slate-200 px-1.5 py-0.5 rounded">/www/server/panel/plugin/supervisor/profile/nexus.ini</code></p>
|
||||
<p>或手动写入配置: <code class="bg-[var(--bg-surface)] text-slate-200 px-1.5 py-0.5 rounded">/www/server/panel/plugin/supervisor/profile/nexus.ini</code></p>
|
||||
<div class="code-block"><button class="copy-btn" @click="copyCode($event, 'supervisorConf')">复制配置</button><pre id="supervisorConf">[program:nexus]
|
||||
command=<span x-text="installDir"></span>/venv/bin/uvicorn server.main:app --host 0.0.0.0 --port <span x-text="form.api_port"></span>
|
||||
directory=<span x-text="installDir"></span>
|
||||
@@ -357,14 +358,14 @@
|
||||
stdout_logfile=/www/wwwlogs/nexus_access.log
|
||||
stderr_logfile_maxbytes=50MB
|
||||
stdout_logfile_maxbytes=50MB</pre></div>
|
||||
<p>重载: <code class="bg-slate-800 text-slate-200 px-1.5 py-0.5 rounded">supervisorctl reread && supervisorctl update && supervisorctl start nexus</code></p>
|
||||
<p>重载: <code class="bg-[var(--bg-surface)] text-slate-200 px-1.5 py-0.5 rounded">supervisorctl reread && supervisorctl update && supervisorctl start nexus</code></p>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Standard mode -->
|
||||
<template x-if="!btPanel">
|
||||
<div>
|
||||
<p>安装: <code class="bg-slate-800 text-slate-200 px-1.5 py-0.5 rounded">sudo apt install -y supervisor</code></p>
|
||||
<p>配置文件复制到: <code class="bg-slate-800 text-slate-200 px-1.5 py-0.5 rounded">/etc/supervisor/conf.d/nexus.conf</code></p>
|
||||
<p>安装: <code class="bg-[var(--bg-surface)] text-slate-200 px-1.5 py-0.5 rounded">sudo apt install -y supervisor</code></p>
|
||||
<p>配置文件复制到: <code class="bg-[var(--bg-surface)] text-slate-200 px-1.5 py-0.5 rounded">/etc/supervisor/conf.d/nexus.conf</code></p>
|
||||
<div class="code-block"><button class="copy-btn" @click="copyCode($event, 'supervisorConf2')">复制配置</button><pre id="supervisorConf2">[program:nexus]
|
||||
command=<span x-text="installDir"></span>/venv/bin/uvicorn server.main:app --host 0.0.0.0 --port <span x-text="form.api_port"></span>
|
||||
directory=<span x-text="installDir"></span>
|
||||
@@ -380,7 +381,7 @@
|
||||
stdout_logfile=/var/log/nexus/access.log
|
||||
stderr_logfile_maxbytes=50MB
|
||||
stdout_logfile_maxbytes=50MB</pre></div>
|
||||
<p>启动: <code class="bg-slate-800 text-slate-200 px-1.5 py-0.5 rounded">sudo supervisorctl reread && sudo supervisorctl update && sudo supervisorctl start nexus</code></p>
|
||||
<p>启动: <code class="bg-[var(--bg-surface)] text-slate-200 px-1.5 py-0.5 rounded">sudo supervisorctl reread && sudo supervisorctl update && sudo supervisorctl start nexus</code></p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -393,11 +394,11 @@
|
||||
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-blue-500 text-white text-xs font-bold mr-2">2</span>
|
||||
Python 虚拟环境 + 依赖
|
||||
</div>
|
||||
<div class="text-xs text-slate-500 space-y-1">
|
||||
<p><code class="bg-slate-800 text-slate-200 px-1.5 py-0.5 rounded">cd <span x-text="installDir"></span></code></p>
|
||||
<p><code class="bg-slate-800 text-slate-200 px-1.5 py-0.5 rounded">python3.12 -m venv venv</code></p>
|
||||
<p><code class="bg-slate-800 text-slate-200 px-1.5 py-0.5 rounded">source venv/bin/activate</code></p>
|
||||
<p><code class="bg-slate-800 text-slate-200 px-1.5 py-0.5 rounded">pip install -r requirements.txt</code></p>
|
||||
<div class="text-xs text-[var(--text-muted)] space-y-1">
|
||||
<p><code class="bg-[var(--bg-surface)] text-slate-200 px-1.5 py-0.5 rounded">cd <span x-text="installDir"></span></code></p>
|
||||
<p><code class="bg-[var(--bg-surface)] text-slate-200 px-1.5 py-0.5 rounded">python3.12 -m venv venv</code></p>
|
||||
<p><code class="bg-[var(--bg-surface)] text-slate-200 px-1.5 py-0.5 rounded">source venv/bin/activate</code></p>
|
||||
<p><code class="bg-[var(--bg-surface)] text-slate-200 px-1.5 py-0.5 rounded">pip install -r requirements.txt</code></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -407,11 +408,11 @@
|
||||
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-blue-500 text-white text-xs font-bold mr-2">3</span>
|
||||
Nginx 反向代理 (API + WebSocket)
|
||||
</div>
|
||||
<div class="text-xs text-slate-500">
|
||||
<div class="text-xs text-[var(--text-muted)]">
|
||||
<!-- BT Panel mode -->
|
||||
<template x-if="btPanel">
|
||||
<div>
|
||||
<p class="mb-1">宝塔 → 网站 → 配置文件 → <code class="bg-slate-800 text-slate-200 px-1.5 py-0.5 rounded">#PHP-INFO-END</code> 后面粘贴:</p>
|
||||
<p class="mb-1">宝塔 → 网站 → 配置文件 → <code class="bg-[var(--bg-surface)] text-slate-200 px-1.5 py-0.5 rounded">#PHP-INFO-END</code> 后面粘贴:</p>
|
||||
<div class="code-block"><button class="copy-btn" @click="copyCode($event, 'nginxConf')">复制配置</button><pre id="nginxConf"> # Nexus Python API + WebSocket
|
||||
location ^~ /api/ {
|
||||
proxy_pass http://127.0.0.1:<span x-text="form.api_port"></span>;
|
||||
@@ -436,7 +437,7 @@
|
||||
<!-- Standard mode -->
|
||||
<template x-if="!btPanel">
|
||||
<div>
|
||||
<p class="mb-1">将以下配置写入 <code class="bg-slate-800 text-slate-200 px-1.5 py-0.5 rounded">/etc/nginx/sites-available/nexus</code> 并创建符号链接到 sites-enabled:</p>
|
||||
<p class="mb-1">将以下配置写入 <code class="bg-[var(--bg-surface)] text-slate-200 px-1.5 py-0.5 rounded">/etc/nginx/sites-available/nexus</code> 并创建符号链接到 sites-enabled:</p>
|
||||
<div class="code-block"><button class="copy-btn" @click="copyCode($event, 'nginxConf2')">复制配置</button><pre id="nginxConf2">upstream nexus_api {
|
||||
server 127.0.0.1:<span x-text="form.api_port"></span>;
|
||||
keepalive 32;
|
||||
@@ -465,7 +466,7 @@ server {
|
||||
return 404;
|
||||
}
|
||||
}</pre></div>
|
||||
<p>启用: <code class="bg-slate-800 text-slate-200 px-1.5 py-0.5 rounded">sudo ln -sf /etc/nginx/sites-available/nexus /etc/nginx/sites-enabled/ && sudo nginx -t && sudo systemctl reload nginx</code></p>
|
||||
<p>启用: <code class="bg-[var(--bg-surface)] text-slate-200 px-1.5 py-0.5 rounded">sudo ln -sf /etc/nginx/sites-available/nexus /etc/nginx/sites-enabled/ && sudo nginx -t && sudo systemctl reload nginx</code></p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -477,7 +478,7 @@ server {
|
||||
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-blue-500 text-white text-xs font-bold mr-2">4</span>
|
||||
Nginx 伪静态
|
||||
</div>
|
||||
<div class="text-xs text-slate-500">
|
||||
<div class="text-xs text-[var(--text-muted)]">
|
||||
<p class="mb-1">宝塔 → 网站 → 伪静态 → 粘贴:</p>
|
||||
<div class="code-block"><button class="copy-btn" @click="copyCode($event, 'nginxRewrite')">复制</button><pre id="nginxRewrite">location /app/ {
|
||||
try_files $uri $uri/ /app/index.html;
|
||||
@@ -494,13 +495,13 @@ location / {
|
||||
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-blue-500 text-white text-xs font-bold mr-2">5</span>
|
||||
SSL证书 (强制HTTPS)
|
||||
</div>
|
||||
<div class="text-xs text-slate-500">
|
||||
<div class="text-xs text-[var(--text-muted)]">
|
||||
<template x-if="btPanel">
|
||||
<div>宝塔 → 网站 → SSL → Let's Encrypt → 一键申请 → 开启强制HTTPS</div>
|
||||
</template>
|
||||
<template x-if="!btPanel">
|
||||
<div>安装certbot: <code class="bg-slate-800 text-slate-200 px-1.5 py-0.5 rounded">sudo apt install -y certbot python3-certbot-nginx</code><br>
|
||||
申请证书: <code class="bg-slate-800 text-slate-200 px-1.5 py-0.5 rounded">sudo certbot --nginx -d YOUR_DOMAIN</code></div>
|
||||
<div>安装certbot: <code class="bg-[var(--bg-surface)] text-slate-200 px-1.5 py-0.5 rounded">sudo apt install -y certbot python3-certbot-nginx</code><br>
|
||||
申请证书: <code class="bg-[var(--bg-surface)] text-slate-200 px-1.5 py-0.5 rounded">sudo certbot --nginx -d YOUR_DOMAIN</code></div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@@ -511,12 +512,12 @@ location / {
|
||||
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-blue-500 text-white text-xs font-bold mr-2">6</span>
|
||||
Shell 健康检查 (外部守护 + Telegram告警)
|
||||
</div>
|
||||
<div x-show="!guardianAllOk" class="text-xs text-slate-500">
|
||||
<div x-show="!guardianAllOk" class="text-xs text-[var(--text-muted)]">
|
||||
<p>配置 crontab:</p>
|
||||
<div class="code-block"><button class="copy-btn" @click="copyCode($event, 'crontab')">复制</button><pre id="crontab">* * * * * <span x-text="installDir"></span>/deploy/health_monitor.sh</pre></div>
|
||||
</div>
|
||||
<div x-show="guardianAllOk" class="text-xs text-green-600">✓ 已自动配置 — health_monitor.sh INSTALL_DIR 已更新,Crontab 已添加</div>
|
||||
<div class="text-xs text-slate-500 mt-2">
|
||||
<div class="text-xs text-[var(--text-muted)] mt-2">
|
||||
<b>3层守护机制:</b><br>
|
||||
Layer 1: Supervisor — Python崩溃自动重启<br>
|
||||
Layer 2: Python self_monitor — 每30s检查Redis/MySQL/WebSocket<br>
|
||||
@@ -530,9 +531,9 @@ location / {
|
||||
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-blue-500 text-white text-xs font-bold mr-2">7</span>
|
||||
安全收尾
|
||||
</div>
|
||||
<div class="text-xs text-slate-500 space-y-1">
|
||||
<p>✓ 安装向导已锁定 — 删除 <code class="bg-slate-800 text-slate-200 px-1.5 py-0.5 rounded">.env</code> 文件可重新安装</p>
|
||||
<p>✓ <code class="bg-slate-800 text-slate-200 px-1.5 py-0.5 rounded">.env</code> 包含 SECRET_KEY/API_KEY — <b>安装后不可修改</b>(加密一致性)</p>
|
||||
<div class="text-xs text-[var(--text-muted)] space-y-1">
|
||||
<p>✓ 安装向导已锁定 — 删除 <code class="bg-[var(--bg-surface)] text-slate-200 px-1.5 py-0.5 rounded">.env</code> 文件可重新安装</p>
|
||||
<p>✓ <code class="bg-[var(--bg-surface)] text-slate-200 px-1.5 py-0.5 rounded">.env</code> 包含 SECRET_KEY/API_KEY — <b>安装后不可修改</b>(加密一致性)</p>
|
||||
<p>✓ 防火墙限制端口 <span x-text="form.api_port"></span> (仅允许本机和子服务器IP)</p>
|
||||
<p>✓ 修改管理员默认密码</p>
|
||||
</div>
|
||||
|
||||
+36
-1
@@ -63,13 +63,17 @@ function initLayout(activeNav) {
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span id="sidebarUser" class="text-slate-400 text-sm">...</span>
|
||||
<button onclick="doLogout()" class="text-slate-500 hover:text-red-400 text-xs transition">退出</button>
|
||||
<div class="flex items-center gap-2">
|
||||
<button onclick="toggleTheme()" id="themeToggle" class="text-slate-500 hover:text-white text-sm transition" title="切换主题">🌙</button>
|
||||
<button onclick="doLogout()" class="text-slate-500 hover:text-red-400 text-xs transition">退出</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Load user info
|
||||
loadLayoutUser();
|
||||
loadTheme();
|
||||
|
||||
// ── Mobile Responsive ──
|
||||
// On mobile: collapse sidebar by default, add overlay + backdrop
|
||||
@@ -138,6 +142,37 @@ async function loadLayoutUser() {
|
||||
} catch(e) { console.error('Failed to load user info:', e); }
|
||||
}
|
||||
|
||||
// ── Theme System ──
|
||||
async function loadTheme() {
|
||||
try {
|
||||
const r = await apiFetch(API + '/settings/theme');
|
||||
if (r && r.ok) {
|
||||
const d = await r.json();
|
||||
if (d.value === 'light' || d.value === 'dark') {
|
||||
document.documentElement.dataset.theme = d.value;
|
||||
}
|
||||
}
|
||||
} catch(e) {}
|
||||
updateThemeIcon();
|
||||
}
|
||||
|
||||
function updateThemeIcon() {
|
||||
const el = document.getElementById('themeToggle');
|
||||
if (el) el.textContent = document.documentElement.dataset.theme === 'light' ? '☀️' : '🌙';
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
const current = document.documentElement.dataset.theme || 'dark';
|
||||
const next = current === 'dark' ? 'light' : 'dark';
|
||||
document.documentElement.dataset.theme = next;
|
||||
updateThemeIcon();
|
||||
// Save to MySQL
|
||||
apiFetch(API + '/settings/theme', {
|
||||
method: 'PUT', headers: apiHeadersJSON(),
|
||||
body: JSON.stringify({ value: next })
|
||||
});
|
||||
}
|
||||
|
||||
// ── Global Search ──
|
||||
let _searchTimer = null;
|
||||
|
||||
|
||||
+11
-10
@@ -7,6 +7,7 @@
|
||||
|
||||
<!-- Tailwind CSS v4 CDN -->
|
||||
<script src="/app/vendor/tailwindcss-browser.js"></script>
|
||||
<link rel="stylesheet" href="/app/vendor/theme.css">
|
||||
|
||||
<style type="text/tailwindcss">
|
||||
@theme {
|
||||
@@ -17,7 +18,7 @@
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-slate-950 min-h-screen flex items-center justify-center p-4">
|
||||
<body class="bg-[var(--bg-page)] min-h-screen flex items-center justify-center p-4">
|
||||
|
||||
<div class="w-full max-w-md">
|
||||
|
||||
@@ -29,11 +30,11 @@
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-white">Nexus</h1>
|
||||
<p class="text-slate-400 text-sm mt-1">服务器运维管理平台</p>
|
||||
<p class="text-[var(--text-secondary)] text-sm mt-1">服务器运维管理平台</p>
|
||||
</div>
|
||||
|
||||
<!-- Login Card -->
|
||||
<div id="loginCard" class="bg-slate-900 rounded-2xl border border-slate-800 p-8 shadow-xl">
|
||||
<div id="loginCard" class="bg-[var(--bg-card)] rounded-2xl border border-[var(--border)] p-8 shadow-xl">
|
||||
|
||||
<!-- Step 1: Password -->
|
||||
<div id="stepPassword">
|
||||
@@ -43,7 +44,7 @@
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-2">用户名</label>
|
||||
<input type="text" id="username" name="username" required autocomplete="username"
|
||||
class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-brand focus:border-transparent transition"
|
||||
class="w-full px-4 py-3 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-brand focus:border-transparent transition"
|
||||
placeholder="请输入用户名">
|
||||
</div>
|
||||
|
||||
@@ -51,10 +52,10 @@
|
||||
<label class="block text-sm font-medium text-slate-300 mb-2">密码</label>
|
||||
<div class="relative">
|
||||
<input type="password" id="password" name="password" required autocomplete="current-password"
|
||||
class="w-full px-4 py-3 pr-12 bg-slate-800 border border-slate-700 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-brand focus:border-transparent transition"
|
||||
class="w-full px-4 py-3 pr-12 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-brand focus:border-transparent transition"
|
||||
placeholder="••••••••">
|
||||
<button type="button" id="togglePwd" tabindex="-1"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-slate-500 hover:text-slate-300 transition p-1">
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-[var(--text-muted)] hover:text-slate-300 transition p-1">
|
||||
<!-- Eye icon (show) -->
|
||||
<svg id="eyeShow" 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="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
@@ -78,12 +79,12 @@
|
||||
<!-- Step 2: TOTP (hidden by default) -->
|
||||
<div id="stepTotp" class="hidden">
|
||||
<h2 class="text-lg font-semibold text-white mb-2">两步验证</h2>
|
||||
<p class="text-slate-400 text-sm mb-6">请输入验证器应用中的 6 位数字</p>
|
||||
<p class="text-[var(--text-secondary)] text-sm mb-6">请输入验证器应用中的 6 位数字</p>
|
||||
|
||||
<form id="totpForm" class="space-y-5">
|
||||
<div>
|
||||
<input type="text" id="totpCode" name="totp_code" required maxlength="6" inputmode="numeric" pattern="[0-9]{6}" autocomplete="one-time-code"
|
||||
class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white text-center text-2xl tracking-[0.5em] placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-brand focus:border-transparent transition"
|
||||
class="w-full px-4 py-3 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-xl text-white text-center text-2xl tracking-[0.5em] placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-brand focus:border-transparent transition"
|
||||
placeholder="000000">
|
||||
</div>
|
||||
|
||||
@@ -93,7 +94,7 @@
|
||||
</button>
|
||||
|
||||
<button type="button" id="backBtn"
|
||||
class="w-full py-2 text-slate-400 hover:text-white text-sm transition">
|
||||
class="w-full py-2 text-[var(--text-secondary)] hover:text-white text-sm transition">
|
||||
← 返回
|
||||
</button>
|
||||
</form>
|
||||
@@ -107,7 +108,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<p class="text-center text-slate-600 text-xs mt-6">Nexus v6.0 · 安全连接</p>
|
||||
<p class="text-center text-[var(--text-dim)] text-xs mt-6">Nexus v6.0 · 安全连接</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
|
||||
+109
-109
@@ -1,53 +1,53 @@
|
||||
<!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>
|
||||
<!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><link rel="stylesheet" href="/app/vendor/theme.css"><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-[var(--bg-page)] text-[var(--text-primary)] min-h-screen flex" x-data="{sidebarOpen:true}">
|
||||
<aside x-show="sidebarOpen" x-transition class="w-64 bg-[var(--bg-card)] border-r border-[var(--border)] 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>
|
||||
<header class="bg-[var(--bg-card)] border-b border-[var(--border)] px-6 py-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-[var(--text-secondary)] 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>
|
||||
</header>
|
||||
<main class="flex-1 overflow-y-auto p-6 max-w-3xl mx-auto w-full">
|
||||
<!-- Push Form -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6 space-y-4">
|
||||
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-6 space-y-4">
|
||||
<!-- Template selector -->
|
||||
<div class="flex items-center gap-2">
|
||||
<select id="templateSelect" onchange="applyTemplate()" class="flex-1 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-sm text-slate-300">
|
||||
<select id="templateSelect" onchange="applyTemplate()" class="flex-1 px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-sm text-slate-300">
|
||||
<option value="">📋 推送模板...</option>
|
||||
</select>
|
||||
<button onclick="saveTemplate()" class="px-3 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 text-sm rounded-lg transition whitespace-nowrap" title="保存当前配置为模板">💾 保存</button>
|
||||
<button onclick="deleteTemplate()" class="px-3 py-2 bg-slate-800 hover:bg-red-900/50 text-slate-400 text-sm rounded-lg transition" title="删除选中模板">🗑</button>
|
||||
<button onclick="saveTemplate()" class="px-3 py-2 bg-[var(--bg-surface)] hover:bg-slate-700 text-slate-300 text-sm rounded-lg transition whitespace-nowrap" title="保存当前配置为模板">💾 保存</button>
|
||||
<button onclick="deleteTemplate()" class="px-3 py-2 bg-[var(--bg-surface)] hover:bg-red-900/50 text-[var(--text-secondary)] text-sm rounded-lg transition" title="删除选中模板">🗑</button>
|
||||
</div>
|
||||
<!-- Source: ZIP upload (drag & drop + click) -->
|
||||
<div>
|
||||
<label class="text-sm text-slate-300 mb-2 block">上传 ZIP</label>
|
||||
<div id="sourceUpload" class="space-y-2">
|
||||
<div id="dropZone" class="relative flex items-center justify-center gap-3 px-6 py-8 border-2 border-dashed border-slate-700 rounded-xl cursor-pointer transition hover:border-brand hover:bg-brand/5"
|
||||
<div id="dropZone" class="relative flex items-center justify-center gap-3 px-6 py-8 border-2 border-dashed border-[var(--border-input)] rounded-xl cursor-pointer transition hover:border-brand hover:bg-brand/5"
|
||||
onclick="document.getElementById('zipFile').click()"
|
||||
ondragover="event.preventDefault();this.classList.add('border-brand','bg-brand/5')"
|
||||
ondragleave="this.classList.remove('border-brand','bg-brand/5')"
|
||||
ondrop="event.preventDefault();this.classList.remove('border-brand','bg-brand/5');handleDrop(event)">
|
||||
<input id="zipFile" type="file" accept=".zip" class="hidden" onchange="uploadZip()">
|
||||
<svg class="w-8 h-8 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/></svg>
|
||||
<svg class="w-8 h-8 text-[var(--text-muted)]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/></svg>
|
||||
<div>
|
||||
<p class="text-sm text-slate-300">拖拽 ZIP 文件到此处,或 <span class="text-brand-light hover:underline">点击选择</span></p>
|
||||
<p class="text-xs text-slate-500 mt-1">仅支持 .zip 格式</p>
|
||||
<p class="text-xs text-[var(--text-muted)] mt-1">仅支持 .zip 格式</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="uploadProgress" class="hidden flex items-center gap-2 text-sm text-slate-400">
|
||||
<div id="uploadProgress" class="hidden flex items-center gap-2 text-sm text-[var(--text-secondary)]">
|
||||
<div class="animate-spin w-4 h-4 border-2 border-brand border-t-transparent rounded-full"></div>
|
||||
上传并解压中...
|
||||
</div>
|
||||
<div id="uploadResult" class="hidden space-y-2">
|
||||
<div class="flex items-center gap-3 px-3 py-2 bg-green-900/20 border border-green-700/30 rounded-lg">
|
||||
<span class="text-green-400 text-sm">✓</span>
|
||||
<span id="uploadedInfo" class="text-xs text-slate-400"></span>
|
||||
<button onclick="clearUpload()" class="text-xs text-slate-500 hover:text-slate-300 ml-auto">✕ 清除</button>
|
||||
<span id="uploadedInfo" class="text-xs text-[var(--text-secondary)]"></span>
|
||||
<button onclick="clearUpload()" class="text-xs text-[var(--text-muted)] hover:text-slate-300 ml-auto">✕ 清除</button>
|
||||
</div>
|
||||
<!-- File Manager -->
|
||||
<div id="fileManager" class="bg-slate-800/50 border border-slate-700/50 rounded-lg overflow-hidden">
|
||||
<div class="flex items-center gap-2 px-3 py-2 bg-slate-800 border-b border-slate-700/50">
|
||||
<span class="text-xs text-slate-400">📁</span>
|
||||
<div id="fileManager" class="bg-[var(--bg-surface)]/50 border border-[var(--border-input)]/50 rounded-lg overflow-hidden">
|
||||
<div class="flex items-center gap-2 px-3 py-2 bg-[var(--bg-surface)] border-b border-[var(--border-input)]/50">
|
||||
<span class="text-xs text-[var(--text-secondary)]">📁</span>
|
||||
<span id="fmBreadcrumb" class="text-xs text-slate-300 flex-1 truncate"></span>
|
||||
<span id="fmCount" class="text-xs text-slate-500"></span>
|
||||
<span id="fmCount" class="text-xs text-[var(--text-muted)]"></span>
|
||||
</div>
|
||||
<div id="fmEntries" class="max-h-64 overflow-y-auto"></div>
|
||||
</div>
|
||||
@@ -56,41 +56,41 @@
|
||||
</div>
|
||||
<!-- H5: Source path direct input -->
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-slate-500 shrink-0">或输入源路径:</span>
|
||||
<input id="sourcePathInput" placeholder="/path/to/files" class="flex-1 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-xs placeholder-slate-600">
|
||||
<button onclick="validateSourcePath()" id="validatePathBtn" class="px-3 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 text-xs rounded-lg transition whitespace-nowrap">验证路径</button>
|
||||
<span class="text-xs text-[var(--text-muted)] shrink-0">或输入源路径:</span>
|
||||
<input id="sourcePathInput" placeholder="/path/to/files" class="flex-1 px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-xs placeholder-slate-600">
|
||||
<button onclick="validateSourcePath()" id="validatePathBtn" class="px-3 py-2 bg-[var(--bg-surface)] hover:bg-slate-700 text-slate-300 text-xs rounded-lg transition whitespace-nowrap">验证路径</button>
|
||||
<span id="sourcePathResult" class="hidden text-xs"></span>
|
||||
</div>
|
||||
<div><label class="block text-sm text-slate-300 mb-2">目标路径</label><input id="destPath" placeholder="默认使用服务器配置的目标路径" class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white text-sm"></div>
|
||||
<div><label class="block text-sm text-slate-300 mb-2">目标路径</label><input id="destPath" placeholder="默认使用服务器配置的目标路径" class="w-full px-4 py-3 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-xl text-white text-sm"></div>
|
||||
<div><label class="block text-sm text-slate-300 mb-2">目标服务器</label>
|
||||
<div class="flex flex-wrap items-center gap-2 mb-2">
|
||||
<select id="filterCategory" onchange="filterServers()" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-xs text-slate-300">
|
||||
<select id="filterCategory" onchange="filterServers()" class="px-3 py-1.5 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-xs text-slate-300">
|
||||
<option value="">全部分类</option>
|
||||
</select>
|
||||
<select id="filterPlatform" onchange="filterServers()" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-xs text-slate-300">
|
||||
<select id="filterPlatform" onchange="filterServers()" class="px-3 py-1.5 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-xs text-slate-300">
|
||||
<option value="">全部平台</option>
|
||||
</select>
|
||||
<button onclick="selectAllServers()" class="text-xs text-brand-light hover:underline">全选</button>
|
||||
<button onclick="deselectAllServers()" class="text-xs text-slate-400 hover:underline">全不选</button>
|
||||
<button onclick="deselectAllServers()" class="text-xs text-[var(--text-secondary)] hover:underline">全不选</button>
|
||||
<button onclick="selectFilteredServers()" class="text-xs text-brand-light hover:underline">选中当前筛选</button>
|
||||
</div>
|
||||
<input id="serverSearch" placeholder="搜索服务器名称/域名..." oninput="filterServerOptions()" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-xs text-slate-300 placeholder-slate-600 mb-1">
|
||||
<select id="targetServers" multiple class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white text-sm h-40"></select>
|
||||
<div id="serverCount" class="text-slate-500 text-xs mt-1">已选择 0 台服务器</div>
|
||||
<input id="serverSearch" placeholder="搜索服务器名称/域名..." oninput="filterServerOptions()" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-xs text-slate-300 placeholder-slate-600 mb-1">
|
||||
<select id="targetServers" multiple class="w-full px-4 py-3 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-xl text-white text-sm h-40"></select>
|
||||
<div id="serverCount" class="text-[var(--text-muted)] text-xs mt-1">已选择 0 台服务器</div>
|
||||
</div>
|
||||
<div><label class="block text-sm text-slate-300 mb-2">同步模式</label>
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<label class="flex items-center gap-2 px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl cursor-pointer has-[:checked]:border-brand has-[:checked]:bg-brand/10 transition">
|
||||
<label class="flex items-center gap-2 px-4 py-3 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-xl cursor-pointer has-[:checked]:border-brand has-[:checked]:bg-brand/10 transition">
|
||||
<input type="radio" name="syncMode" value="incremental" checked class="accent-brand" onchange="onSyncModeChange()"> <span class="text-sm">增量同步</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl cursor-pointer has-[:checked]:border-brand has-[:checked]:bg-brand/10 transition">
|
||||
<label class="flex items-center gap-2 px-4 py-3 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-xl cursor-pointer has-[:checked]:border-brand has-[:checked]:bg-brand/10 transition">
|
||||
<input type="radio" name="syncMode" value="full" class="accent-brand" onchange="onSyncModeChange()"> <span class="text-sm">全量同步</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl cursor-pointer has-[:checked]:border-brand has-[:checked]:bg-brand/10 transition">
|
||||
<label class="flex items-center gap-2 px-4 py-3 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-xl cursor-pointer has-[:checked]:border-brand has-[:checked]:bg-brand/10 transition">
|
||||
<input type="radio" name="syncMode" value="checksum" class="accent-brand" onchange="onSyncModeChange()"> <span class="text-sm">校验和</span>
|
||||
</label>
|
||||
</div>
|
||||
<div id="syncModeDesc" class="text-xs text-slate-500 mt-2 px-1">仅传输源目录中新增或修改的文件,目标已有且未变的文件跳过。安全快速,适合日常更新。</div>
|
||||
<div id="syncModeDesc" class="text-xs text-[var(--text-muted)] mt-2 px-1">仅传输源目录中新增或修改的文件,目标已有且未变的文件跳过。安全快速,适合日常更新。</div>
|
||||
</div>
|
||||
|
||||
<!-- Full sync warning + preview -->
|
||||
@@ -108,56 +108,56 @@
|
||||
</button>
|
||||
</div>
|
||||
<div id="lightPreview" class="flex items-center justify-end">
|
||||
<button onclick="doPreview()" class="text-xs text-slate-500 hover:text-slate-300 transition">预览首台变动 →</button>
|
||||
<button onclick="doPreview()" class="text-xs text-[var(--text-muted)] hover:text-slate-300 transition">预览首台变动 →</button>
|
||||
</div>
|
||||
|
||||
<!-- Preview result -->
|
||||
<div id="previewResult" class="hidden rounded-xl border border-slate-700 bg-slate-800/50 p-4 space-y-3">
|
||||
<div id="previewResult" class="hidden rounded-xl border border-[var(--border-input)] bg-[var(--bg-surface)]/50 p-4 space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-slate-200" id="previewServerName">预览中...</span>
|
||||
<button onclick="clearPreview()" class="text-xs text-slate-500 hover:text-slate-300">✕ 关闭</button>
|
||||
<button onclick="clearPreview()" class="text-xs text-[var(--text-muted)] hover:text-slate-300">✕ 关闭</button>
|
||||
</div>
|
||||
<div id="previewStats" class="grid grid-cols-2 gap-2 text-sm"></div>
|
||||
<div id="previewVerboseToggle" class="hidden">
|
||||
<button onclick="doPreview(true)" class="text-xs text-brand-light hover:underline">显示详细文件列表 ▼</button>
|
||||
</div>
|
||||
<div id="previewFileList" class="hidden">
|
||||
<pre id="previewFiles" class="text-xs text-slate-400 bg-slate-900 rounded-lg p-3 max-h-56 overflow-y-auto whitespace-pre-wrap"></pre>
|
||||
<p id="previewTruncated" class="hidden text-xs text-slate-500 mt-1"></p>
|
||||
<pre id="previewFiles" class="text-xs text-[var(--text-secondary)] bg-[var(--bg-card)] rounded-lg p-3 max-h-56 overflow-y-auto whitespace-pre-wrap"></pre>
|
||||
<p id="previewTruncated" class="hidden text-xs text-[var(--text-muted)] mt-1"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div><label class="block text-sm text-slate-300 mb-2">并发数</label><input id="concurrency" type="number" value="10" min="1" max="50" class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white text-sm"></div>
|
||||
<div><label class="block text-sm text-slate-300 mb-2">批次大小</label><input id="batchSize" type="number" value="50" min="1" max="200" class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white text-sm"></div>
|
||||
<div><label class="block text-sm text-slate-300 mb-2">并发数</label><input id="concurrency" type="number" value="10" min="1" max="50" class="w-full px-4 py-3 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-xl text-white text-sm"></div>
|
||||
<div><label class="block text-sm text-slate-300 mb-2">批次大小</label><input id="batchSize" type="number" value="50" min="1" max="200" class="w-full px-4 py-3 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-xl text-white text-sm"></div>
|
||||
</div>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="flex gap-3">
|
||||
<button onclick="doPush()" id="pushBtn" class="flex-1 py-3 bg-brand hover:bg-brand-dark text-white font-semibold rounded-xl transition disabled:opacity-50 disabled:cursor-not-allowed">开始推送</button>
|
||||
<button onclick="showScheduleModal()" class="px-4 py-3 bg-slate-800 hover:bg-slate-700 text-slate-300 font-medium rounded-xl transition text-sm whitespace-nowrap">⏰ 定时推送</button>
|
||||
<button onclick="showScheduleModal()" class="px-4 py-3 bg-[var(--bg-surface)] hover:bg-slate-700 text-slate-300 font-medium rounded-xl transition text-sm whitespace-nowrap">⏰ 定时推送</button>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm text-slate-400 -mt-2">
|
||||
<label class="flex items-center gap-2 text-sm text-[var(--text-secondary)] -mt-2">
|
||||
<input type="checkbox" id="verifyAfterPush" class="accent-brand"> 推送后校验(md5sum 对比)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Progress Section -->
|
||||
<div id="progressSection" class="hidden mt-6 bg-slate-900 rounded-xl border border-slate-800 p-6 space-y-4">
|
||||
<div id="progressSection" class="hidden mt-6 bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-6 space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-white font-semibold">推送进度</h2>
|
||||
<div class="flex items-center gap-3">
|
||||
<span id="progressStats" class="text-sm text-slate-400">0/0</span>
|
||||
<span id="progressStats" class="text-sm text-[var(--text-secondary)]">0/0</span>
|
||||
<button id="cancelPushBtn" onclick="cancelPush()" class="hidden px-3 py-1 bg-red-500/20 hover:bg-red-500/30 border border-red-500/50 text-red-400 text-xs rounded-lg transition">取消推送</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full bg-slate-800 rounded-full h-3 overflow-hidden">
|
||||
<div class="w-full bg-[var(--bg-surface)] rounded-full h-3 overflow-hidden">
|
||||
<div id="progressBar" class="h-full bg-brand rounded-full transition-all duration-500" style="width:0%"></div>
|
||||
</div>
|
||||
<div class="flex gap-4 text-sm">
|
||||
<span class="text-green-400">✓ 成功: <span id="countSuccess">0</span></span>
|
||||
<span class="text-red-400">✗ 失败: <span id="countFailed">0</span></span>
|
||||
<span class="text-slate-400">◌ 等待: <span id="countPending">0</span></span>
|
||||
<span class="text-[var(--text-secondary)]">◌ 等待: <span id="countPending">0</span></span>
|
||||
<button id="retryAllBtn" class="hidden text-xs text-brand-light hover:underline ml-2" onclick="retryAllFailed()">🔄 重试全部失败</button>
|
||||
</div>
|
||||
<div id="serverResults" class="space-y-2 max-h-96 overflow-y-auto"></div>
|
||||
@@ -168,42 +168,42 @@
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="text-white font-semibold">推送记录</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<select id="historyStatus" onchange="loadHistory(1)" class="px-2 py-1 bg-slate-800 border border-slate-700 rounded text-xs text-slate-300">
|
||||
<select id="historyStatus" onchange="loadHistory(1)" class="px-2 py-1 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded text-xs text-slate-300">
|
||||
<option value="">全部状态</option>
|
||||
<option value="success">成功</option>
|
||||
<option value="failed">失败</option>
|
||||
<option value="cancelled">已取消</option>
|
||||
</select>
|
||||
<input id="historyServer" placeholder="服务器ID" class="w-20 px-2 py-1 bg-slate-800 border border-slate-700 rounded text-xs text-slate-300" onchange="loadHistory(1)">
|
||||
<input id="historyServer" placeholder="服务器ID" class="w-20 px-2 py-1 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded text-xs text-slate-300" onchange="loadHistory(1)">
|
||||
</div>
|
||||
</div>
|
||||
<div id="pushHistory" class="space-y-2"><div class="text-slate-500 text-center py-6 text-sm">加载中...</div></div>
|
||||
<div id="historyPager" class="hidden flex items-center justify-center gap-2 mt-3 text-xs text-slate-400">
|
||||
<button onclick="loadHistory(_histPage-1)" id="histPrev" class="px-2 py-1 bg-slate-800 rounded hover:bg-slate-700 disabled:opacity-30">上一页</button>
|
||||
<div id="pushHistory" class="space-y-2"><div class="text-[var(--text-muted)] text-center py-6 text-sm">加载中...</div></div>
|
||||
<div id="historyPager" class="hidden flex items-center justify-center gap-2 mt-3 text-xs text-[var(--text-secondary)]">
|
||||
<button onclick="loadHistory(_histPage-1)" id="histPrev" class="px-2 py-1 bg-[var(--bg-surface)] rounded hover:bg-slate-700 disabled:opacity-30">上一页</button>
|
||||
<span id="histPageInfo">1 / 1</span>
|
||||
<button onclick="loadHistory(_histPage+1)" id="histNext" class="px-2 py-1 bg-slate-800 rounded hover:bg-slate-700 disabled:opacity-30">下一页</button>
|
||||
<button onclick="loadHistory(_histPage+1)" id="histNext" class="px-2 py-1 bg-[var(--bg-surface)] rounded hover:bg-slate-700 disabled:opacity-30">下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Verify Result Section -->
|
||||
<div id="verifySection" class="hidden mt-6 bg-slate-900 rounded-xl border border-slate-800 p-6 space-y-4">
|
||||
<div id="verifySection" class="hidden mt-6 bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-6 space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-white font-semibold">推送校验结果</h2>
|
||||
<button onclick="document.getElementById('verifySection').classList.add('hidden')" class="text-xs text-slate-500 hover:text-slate-300">✕ 关闭</button>
|
||||
<button onclick="document.getElementById('verifySection').classList.add('hidden')" class="text-xs text-[var(--text-muted)] hover:text-slate-300">✕ 关闭</button>
|
||||
</div>
|
||||
<div id="verifyResults" class="space-y-2 max-h-96 overflow-y-auto"></div>
|
||||
</div>
|
||||
|
||||
<!-- File Preview Modal -->
|
||||
<div id="filePreviewModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/60" onclick="if(event.target===this)this.classList.add('hidden')">
|
||||
<div class="bg-slate-900 border border-slate-700 rounded-xl w-[640px] max-h-[80vh] flex flex-col">
|
||||
<div class="flex items-center justify-between px-5 py-3 border-b border-slate-800">
|
||||
<div class="bg-[var(--bg-card)] border border-[var(--border-input)] rounded-xl w-[640px] max-h-[80vh] flex flex-col">
|
||||
<div class="flex items-center justify-between px-5 py-3 border-b border-[var(--border)]">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-slate-400">📄</span>
|
||||
<span class="text-[var(--text-secondary)]">📄</span>
|
||||
<span id="previewFileName" class="text-sm text-slate-200 font-medium"></span>
|
||||
<span id="previewFileSize" class="text-xs text-slate-500"></span>
|
||||
<span id="previewFileSize" class="text-xs text-[var(--text-muted)]"></span>
|
||||
</div>
|
||||
<button onclick="document.getElementById('filePreviewModal').classList.add('hidden')" class="text-slate-500 hover:text-white text-lg">×</button>
|
||||
<button onclick="document.getElementById('filePreviewModal').classList.add('hidden')" class="text-[var(--text-muted)] hover:text-white text-lg">×</button>
|
||||
</div>
|
||||
<pre id="previewFileContent" class="flex-1 overflow-auto px-5 py-4 text-xs text-slate-300 font-mono whitespace-pre-wrap break-all"></pre>
|
||||
</div>
|
||||
@@ -211,43 +211,43 @@
|
||||
|
||||
<!-- Schedule Modal -->
|
||||
<div id="scheduleModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/60" onclick="if(event.target===this)this.classList.add('hidden')">
|
||||
<div class="bg-slate-900 border border-slate-700 rounded-xl w-full max-w-md p-6 space-y-4">
|
||||
<div class="bg-[var(--bg-card)] border border-[var(--border-input)] rounded-xl w-full max-w-md p-6 space-y-4">
|
||||
<h2 class="text-white font-semibold text-lg">⏰ 定时推送</h2>
|
||||
<p class="text-sm text-slate-400">将当前推送配置保存为一次性调度任务,到指定时间自动执行。</p>
|
||||
<p class="text-sm text-[var(--text-secondary)]">将当前推送配置保存为一次性调度任务,到指定时间自动执行。</p>
|
||||
<div>
|
||||
<label class="block text-slate-400 text-xs mb-1">执行时间 *</label>
|
||||
<input id="schedFireAt" type="datetime-local" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
|
||||
<label class="block text-[var(--text-secondary)] text-xs mb-1">执行时间 *</label>
|
||||
<input id="schedFireAt" type="datetime-local" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm">
|
||||
</div>
|
||||
<div id="schedSummary" class="text-xs text-slate-500 bg-slate-800/50 rounded-lg px-3 py-2"></div>
|
||||
<div id="schedSummary" class="text-xs text-[var(--text-muted)] bg-[var(--bg-surface)]/50 rounded-lg px-3 py-2"></div>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="doSchedulePush()" class="flex-1 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm font-medium">确认调度</button>
|
||||
<button onclick="document.getElementById('scheduleModal').classList.add('hidden')" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm">取消</button>
|
||||
<button onclick="document.getElementById('scheduleModal').classList.add('hidden')" class="px-4 py-2 bg-[var(--bg-surface)] hover:bg-slate-700 text-slate-300 rounded-lg text-sm">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- H4: Diagnose Modal -->
|
||||
<div id="diagModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/60" onclick="if(event.target===this)this.classList.add('hidden')">
|
||||
<div class="bg-slate-900 border border-slate-700 rounded-xl w-full max-w-md p-6 space-y-4">
|
||||
<div class="bg-[var(--bg-card)] border border-[var(--border-input)] rounded-xl w-full max-w-md p-6 space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-white font-semibold">🔍 推送诊断</h2>
|
||||
<button onclick="document.getElementById('diagModal').classList.add('hidden')" class="text-slate-500 hover:text-white text-lg">×</button>
|
||||
<button onclick="document.getElementById('diagModal').classList.add('hidden')" class="text-[var(--text-muted)] hover:text-white text-lg">×</button>
|
||||
</div>
|
||||
<div id="diagServerName" class="text-sm text-slate-400"></div>
|
||||
<div id="diagServerName" class="text-sm text-[var(--text-secondary)]"></div>
|
||||
<div id="diagResults" class="space-y-3"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- H3: File Diff Modal -->
|
||||
<div id="diffModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/60" onclick="if(event.target===this)this.classList.add('hidden')">
|
||||
<div class="bg-slate-900 border border-slate-700 rounded-xl w-[720px] max-h-[80vh] flex flex-col">
|
||||
<div class="flex items-center justify-between px-5 py-3 border-b border-slate-800">
|
||||
<div class="bg-[var(--bg-card)] border border-[var(--border-input)] rounded-xl w-[720px] max-h-[80vh] flex flex-col">
|
||||
<div class="flex items-center justify-between px-5 py-3 border-b border-[var(--border)]">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-slate-400">📄</span>
|
||||
<span class="text-[var(--text-secondary)]">📄</span>
|
||||
<span id="diffFileName" class="text-sm text-slate-200 font-medium"></span>
|
||||
<span id="diffFileInfo" class="text-xs text-slate-500"></span>
|
||||
<span id="diffFileInfo" class="text-xs text-[var(--text-muted)]"></span>
|
||||
</div>
|
||||
<button onclick="document.getElementById('diffModal').classList.add('hidden')" class="text-slate-500 hover:text-white text-lg">×</button>
|
||||
<button onclick="document.getElementById('diffModal').classList.add('hidden')" class="text-[var(--text-muted)] hover:text-white text-lg">×</button>
|
||||
</div>
|
||||
<div id="diffContent" class="flex-1 overflow-auto px-3 py-3 font-mono text-xs leading-5"></div>
|
||||
</div>
|
||||
@@ -351,9 +351,9 @@
|
||||
if(retryEl)retryEl.classList.add('hidden');
|
||||
if(diagEl)diagEl.classList.add('hidden');
|
||||
}else if(msg.status==='cancelled'){
|
||||
iconEl.textContent='⊘';iconEl.className='shrink-0 w-5 h-5 flex items-center justify-center text-slate-400';
|
||||
statusEl.textContent='已取消';statusEl.className='text-xs text-slate-400';
|
||||
detailEl.textContent='推送已取消';detailEl.classList.remove('hidden');detailEl.className='text-xs text-slate-500';
|
||||
iconEl.textContent='⊘';iconEl.className='shrink-0 w-5 h-5 flex items-center justify-center text-[var(--text-secondary)]';
|
||||
statusEl.textContent='已取消';statusEl.className='text-xs text-[var(--text-secondary)]';
|
||||
detailEl.textContent='推送已取消';detailEl.classList.remove('hidden');detailEl.className='text-xs text-[var(--text-muted)]';
|
||||
if(retryEl)retryEl.classList.add('hidden');
|
||||
if(diagEl)diagEl.classList.add('hidden');
|
||||
}else{
|
||||
@@ -420,7 +420,7 @@
|
||||
_fmCurrentPath=path;
|
||||
const rel=path.replace(_uploadSourcePath,'').replace(/^\//,'');
|
||||
document.getElementById('fmBreadcrumb').textContent=rel?'📁 '+rel:'📁 /';
|
||||
document.getElementById('fmEntries').innerHTML='<div class="px-3 py-2 text-xs text-slate-500">加载中...</div>';
|
||||
document.getElementById('fmEntries').innerHTML='<div class="px-3 py-2 text-xs text-[var(--text-muted)]">加载中...</div>';
|
||||
try{
|
||||
const r=await apiFetch(API+'/sync/browse-local',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({path})});
|
||||
if(!r){toast('error','浏览失败');return}
|
||||
@@ -437,17 +437,17 @@
|
||||
let html='';
|
||||
if(d.path!==_uploadSourcePath){
|
||||
const parent=d.path.substring(0,d.path.lastIndexOf('/'))||_uploadSourcePath;
|
||||
html+=`<div class="flex items-center gap-2 px-3 py-1.5 hover:bg-slate-700/30 cursor-pointer text-xs" onclick="browseLocal('${esc(parent)}')"><span class="text-slate-400">📁</span><span class="text-slate-300">..</span></div>`;
|
||||
html+=`<div class="flex items-center gap-2 px-3 py-1.5 hover:bg-slate-700/30 cursor-pointer text-xs" onclick="browseLocal('${esc(parent)}')"><span class="text-[var(--text-secondary)]">📁</span><span class="text-slate-300">..</span></div>`;
|
||||
}
|
||||
for(const e of dirs){
|
||||
const fullPath=d.path+'/'+e.name;
|
||||
html+=`<div class="group flex items-center gap-2 px-3 py-1.5 hover:bg-slate-700/30 cursor-pointer text-xs" onclick="browseLocal('${esc(fullPath)}')"><span class="text-brand-light">📁</span><span class="text-slate-200 flex-1 truncate">${esc(e.name)}</span><span class="hidden group-hover:flex items-center gap-1 shrink-0"><button onclick="event.stopPropagation();renameLocalFile('${esc(fullPath)}','${esc(e.name)}')" class="text-slate-500 hover:text-slate-300 px-1" title="重命名">✏</button><button onclick="event.stopPropagation();deleteLocalFile('${esc(fullPath)}','${esc(e.name)}')" class="text-slate-500 hover:text-red-400 px-1" title="删除">🗑</button></span></div>`;
|
||||
html+=`<div class="group flex items-center gap-2 px-3 py-1.5 hover:bg-slate-700/30 cursor-pointer text-xs" onclick="browseLocal('${esc(fullPath)}')"><span class="text-brand-light">📁</span><span class="text-slate-200 flex-1 truncate">${esc(e.name)}</span><span class="hidden group-hover:flex items-center gap-1 shrink-0"><button onclick="event.stopPropagation();renameLocalFile('${esc(fullPath)}','${esc(e.name)}')" class="text-[var(--text-muted)] hover:text-slate-300 px-1" title="重命名">✏</button><button onclick="event.stopPropagation();deleteLocalFile('${esc(fullPath)}','${esc(e.name)}')" class="text-[var(--text-muted)] hover:text-red-400 px-1" title="删除">🗑</button></span></div>`;
|
||||
}
|
||||
for(const e of files){
|
||||
const fullPath=d.path+'/'+e.name;
|
||||
html+=`<div class="group flex items-center gap-2 px-3 py-1.5 text-xs"><span class="text-slate-500">📄</span><span class="text-slate-300 flex-1 truncate cursor-pointer hover:text-brand-light transition" onclick="previewLocalFile('${esc(fullPath)}','${esc(e.name)}')" title="点击预览">${esc(e.name)}</span><span class="text-slate-600 shrink-0">${fmtBytes(e.size)}</span><span class="hidden group-hover:flex items-center gap-1 shrink-0"><button onclick="renameLocalFile('${esc(fullPath)}','${esc(e.name)}')" class="text-slate-500 hover:text-slate-300 px-1" title="重命名">✏</button><button onclick="deleteLocalFile('${esc(fullPath)}','${esc(e.name)}')" class="text-slate-500 hover:text-red-400 px-1" title="删除">🗑</button></span></div>`;
|
||||
html+=`<div class="group flex items-center gap-2 px-3 py-1.5 text-xs"><span class="text-[var(--text-muted)]">📄</span><span class="text-slate-300 flex-1 truncate cursor-pointer hover:text-brand-light transition" onclick="previewLocalFile('${esc(fullPath)}','${esc(e.name)}')" title="点击预览">${esc(e.name)}</span><span class="text-[var(--text-dim)] shrink-0">${fmtBytes(e.size)}</span><span class="hidden group-hover:flex items-center gap-1 shrink-0"><button onclick="renameLocalFile('${esc(fullPath)}','${esc(e.name)}')" class="text-[var(--text-muted)] hover:text-slate-300 px-1" title="重命名">✏</button><button onclick="deleteLocalFile('${esc(fullPath)}','${esc(e.name)}')" class="text-[var(--text-muted)] hover:text-red-400 px-1" title="删除">🗑</button></span></div>`;
|
||||
}
|
||||
if(!dirs.length&&!files.length)html='<div class="px-3 py-2 text-xs text-slate-500">空目录</div>';
|
||||
if(!dirs.length&&!files.length)html='<div class="px-3 py-2 text-xs text-[var(--text-muted)]">空目录</div>';
|
||||
document.getElementById('fmEntries').innerHTML=html;
|
||||
}
|
||||
|
||||
@@ -588,13 +588,13 @@
|
||||
const serversMap={};_serversCache.forEach(s=>serversMap[s.id]=s);
|
||||
document.getElementById('serverResults').innerHTML=ids.map(id=>{
|
||||
const s=serversMap[id];
|
||||
return `<div id="srv_${id}" class="flex items-center gap-3 px-3 py-2 bg-slate-800/50 rounded-lg">
|
||||
<span class="shrink-0 w-5 h-5 flex items-center justify-center text-slate-400" id="srv_icon_${id}">◌</span>
|
||||
return `<div id="srv_${id}" class="flex items-center gap-3 px-3 py-2 bg-[var(--bg-surface)]/50 rounded-lg">
|
||||
<span class="shrink-0 w-5 h-5 flex items-center justify-center text-[var(--text-secondary)]" id="srv_icon_${id}">◌</span>
|
||||
<span class="text-sm text-slate-300 flex-1">${esc(s?.name||'服务器#'+id)}</span>
|
||||
<span class="text-xs text-slate-500" id="srv_status_${id}">等待中</span>
|
||||
<span class="text-xs text-slate-600 hidden" id="srv_detail_${id}"></span>
|
||||
<span class="text-xs text-[var(--text-muted)]" id="srv_status_${id}">等待中</span>
|
||||
<span class="text-xs text-[var(--text-dim)] hidden" id="srv_detail_${id}"></span>
|
||||
<button id="srv_retry_${id}" class="hidden text-xs text-brand-light hover:underline shrink-0" onclick="retryServer(this)">🔄 重试</button>
|
||||
<button id="srv_diag_${id}" class="hidden text-xs text-slate-400 hover:text-amber-400 shrink-0" onclick="diagnoseServer(${id})">🔍 诊断</button>
|
||||
<button id="srv_diag_${id}" class="hidden text-xs text-[var(--text-secondary)] hover:text-amber-400 shrink-0" onclick="diagnoseServer(${id})">🔍 诊断</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
@@ -617,8 +617,8 @@
|
||||
if(log.duration_seconds){detailEl.textContent=log.duration_seconds+'s';detailEl.classList.remove('hidden')}
|
||||
if(diagEl)diagEl.classList.add('hidden');
|
||||
}else if(log.status==='cancelled'){
|
||||
iconEl.textContent='⊘';iconEl.className='shrink-0 w-5 h-5 flex items-center justify-center text-slate-400';
|
||||
statusEl.textContent='已取消';statusEl.className='text-xs text-slate-400';
|
||||
iconEl.textContent='⊘';iconEl.className='shrink-0 w-5 h-5 flex items-center justify-center text-[var(--text-secondary)]';
|
||||
statusEl.textContent='已取消';statusEl.className='text-xs text-[var(--text-secondary)]';
|
||||
if(diagEl)diagEl.classList.add('hidden');
|
||||
}else{
|
||||
iconEl.textContent='✗';iconEl.className='shrink-0 w-5 h-5 flex items-center justify-center text-red-400';
|
||||
@@ -689,7 +689,7 @@
|
||||
const targetPath=document.getElementById('destPath').value||null;
|
||||
const modal=document.getElementById('diagModal');
|
||||
document.getElementById('diagServerName').textContent='服务器 #'+serverId;
|
||||
document.getElementById('diagResults').innerHTML='<div class="text-slate-500 text-sm text-center py-4">诊断中...</div>';
|
||||
document.getElementById('diagResults').innerHTML='<div class="text-[var(--text-muted)] text-sm text-center py-4">诊断中...</div>';
|
||||
modal.classList.remove('hidden');
|
||||
try{
|
||||
const body={server_id:serverId,target_path:targetPath};
|
||||
@@ -706,7 +706,7 @@
|
||||
document.getElementById('diagResults').innerHTML=checks.map(c=>`
|
||||
<div class="flex items-start gap-3 px-3 py-2 ${c.ok?'bg-green-900/10 border border-green-700/20':'bg-red-900/10 border border-red-700/20'} rounded-lg">
|
||||
<span class="${c.ok?'text-green-400':'text-red-400'} text-sm mt-0.5">${c.ok?'✓':'✗'}</span>
|
||||
<div><div class="text-sm text-slate-200">${esc(c.label)}</div><div class="text-xs ${c.ok?'text-slate-400':'text-red-400/80'}">${esc(c.detail)}</div></div>
|
||||
<div><div class="text-sm text-slate-200">${esc(c.label)}</div><div class="text-xs ${c.ok?'text-[var(--text-secondary)]':'text-red-400/80'}">${esc(c.detail)}</div></div>
|
||||
</div>`).join('');
|
||||
}catch(e){document.getElementById('diagResults').innerHTML=`<div class="text-red-400 text-sm">诊断异常: ${esc(e.message)}</div>`}
|
||||
}
|
||||
@@ -732,12 +732,12 @@
|
||||
if(d.truncated)info+=' (截断至100KB)';
|
||||
document.getElementById('diffFileInfo').textContent=info;
|
||||
const lines=d.diff_lines||[];
|
||||
if(!lines.length){document.getElementById('diffContent').innerHTML='<div class="text-slate-500 text-center py-4">文件内容相同,无差异</div>';return}
|
||||
if(!lines.length){document.getElementById('diffContent').innerHTML='<div class="text-[var(--text-muted)] text-center py-4">文件内容相同,无差异</div>';return}
|
||||
document.getElementById('diffContent').innerHTML=lines.map(l=>{
|
||||
if(l.type==='header')return `<div class="text-cyan-400 font-bold">${esc(l.content)}</div>`;
|
||||
if(l.type==='add')return `<div class="bg-green-900/30 text-green-300 pl-2">${esc(l.content)}</div>`;
|
||||
if(l.type==='del')return `<div class="bg-red-900/30 text-red-300 pl-2">${esc(l.content)}</div>`;
|
||||
return `<div class="text-slate-500 pl-2">${esc(l.content)}</div>`;
|
||||
return `<div class="text-[var(--text-muted)] pl-2">${esc(l.content)}</div>`;
|
||||
}).join('');
|
||||
}catch(e){toast('error','对比异常: '+e.message)}
|
||||
}
|
||||
@@ -775,7 +775,7 @@
|
||||
// ── Post-Push Verify (md5sum) ──
|
||||
async function doVerify(ids,sourcePath,targetPath){
|
||||
const section=document.getElementById('verifySection');section.classList.remove('hidden');
|
||||
document.getElementById('verifyResults').innerHTML='<div class="text-slate-500 text-sm text-center py-4">校验中...</div>';
|
||||
document.getElementById('verifyResults').innerHTML='<div class="text-[var(--text-muted)] text-sm text-center py-4">校验中...</div>';
|
||||
try{
|
||||
const body={server_ids:ids,source_path:sourcePath,target_path:targetPath||null};
|
||||
const r=await apiFetch(API+'/sync/verify',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify(body)});
|
||||
@@ -783,12 +783,12 @@
|
||||
const d=await r.json();if(!r.ok){document.getElementById('verifyResults').innerHTML=`<div class="text-red-400 text-sm">${esc(d.detail||'校验失败')}</div>`;return}
|
||||
const results=d.results||{};
|
||||
document.getElementById('verifyResults').innerHTML=Object.values(results).map(v=>{
|
||||
if(v.error)return `<div class="bg-slate-800/50 rounded-lg px-4 py-3 border-l-2 border-l-red-500"><span class="text-red-400 text-sm">✗</span> <span class="text-sm text-slate-300">${esc(v.server_name||'#'+v.server_id)}</span><span class="text-xs text-red-400 ml-2">${esc(v.error)}</span></div>`;
|
||||
if(v.error)return `<div class="bg-[var(--bg-surface)]/50 rounded-lg px-4 py-3 border-l-2 border-l-red-500"><span class="text-red-400 text-sm">✗</span> <span class="text-sm text-slate-300">${esc(v.server_name||'#'+v.server_id)}</span><span class="text-xs text-red-400 ml-2">${esc(v.error)}</span></div>`;
|
||||
const allOk=v.missing===0&&v.mismatched===0;const border=allOk?'border-l-green-500':'border-l-amber-500';
|
||||
let detail='';
|
||||
if(v.missing>0)detail+=`<div class="text-xs text-amber-400 mt-1">缺失 ${v.missing} 文件: ${v.missing_files.slice(0,5).map(esc).join(', ')}${v.missing>5?' ...':''}</div>`;
|
||||
if(v.mismatched>0)detail+=`<div class="text-xs text-red-400 mt-1">不一致 ${v.mismatched} 文件: ${v.mismatched_files.slice(0,5).map(esc).join(', ')}${v.mismatched>5?' ...':''}</div>`;
|
||||
return `<div class="bg-slate-800/50 rounded-lg px-4 py-3 border-l-2 ${border}"><span class="${allOk?'text-green-400':'text-amber-400'} text-sm">${allOk?'✓':'⚠'}</span><span class="text-sm text-slate-300">${esc(v.server_name||'#'+v.server_id)}</span><span class="text-xs text-slate-500 ml-2">${v.matched} 匹配${v.missing>0?`, ${v.missing} 缺失`:''}${v.mismatched>0?`, ${v.mismatched} 不一致`:''}</span>${detail}</div>`;
|
||||
return `<div class="bg-[var(--bg-surface)]/50 rounded-lg px-4 py-3 border-l-2 ${border}"><span class="${allOk?'text-green-400':'text-amber-400'} text-sm">${allOk?'✓':'⚠'}</span><span class="text-sm text-slate-300">${esc(v.server_name||'#'+v.server_id)}</span><span class="text-xs text-[var(--text-muted)] ml-2">${v.matched} 匹配${v.missing>0?`, ${v.missing} 缺失`:''}${v.mismatched>0?`, ${v.mismatched} 不一致`:''}</span>${detail}</div>`;
|
||||
}).join('');
|
||||
}catch(e){document.getElementById('verifyResults').innerHTML=`<div class="text-red-400 text-sm">校验异常: ${esc(e.message)}</div>`}
|
||||
}
|
||||
@@ -810,18 +810,18 @@
|
||||
const border=cancelled?'border-l-2 border-l-slate-500':ok?'border-l-2 border-l-green-500':'border-l-2 border-l-red-500';
|
||||
let summary=`${esc(l.source_path||'')} → ${esc(l.target_path||'')}`;if(l.duration_seconds!=null)summary+=` · ${l.duration_seconds}s`;
|
||||
let detail='';
|
||||
if(l.files_transferred!=null)detail+=`<div class="text-xs text-slate-400">传输: ${l.files_transferred} 文件</div>`;
|
||||
if(l.bytes_transferred!=null&&l.bytes_transferred>0)detail+=`<div class="text-xs text-slate-400">大小: ${fmtBytes(l.bytes_transferred)}</div>`;
|
||||
if(l.files_transferred!=null)detail+=`<div class="text-xs text-[var(--text-secondary)]">传输: ${l.files_transferred} 文件</div>`;
|
||||
if(l.bytes_transferred!=null&&l.bytes_transferred>0)detail+=`<div class="text-xs text-[var(--text-secondary)]">大小: ${fmtBytes(l.bytes_transferred)}</div>`;
|
||||
if(l.error_message)detail+=`<div class="text-xs text-red-400/80 break-all">${esc(l.error_message.substring(0,300))}</div>`;
|
||||
if(l.diff_summary)detail+=`<details class="mt-1"><summary class="text-xs text-slate-500 cursor-pointer hover:text-slate-300">查看 diff</summary><pre class="text-xs text-slate-500 bg-slate-900 rounded p-2 mt-1 max-h-40 overflow-y-auto whitespace-pre-wrap">${esc(l.diff_summary.substring(0,2000))}</pre></details>`;
|
||||
if(l.diff_summary)detail+=`<details class="mt-1"><summary class="text-xs text-[var(--text-muted)] cursor-pointer hover:text-slate-300">查看 diff</summary><pre class="text-xs text-[var(--text-muted)] bg-[var(--bg-card)] rounded p-2 mt-1 max-h-40 overflow-y-auto whitespace-pre-wrap">${esc(l.diff_summary.substring(0,2000))}</pre></details>`;
|
||||
const statusLabel=cancelled?'⊘ 已取消':ok?'✓':'✗';
|
||||
const statusCls=cancelled?'text-slate-400':ok?'text-green-400':'text-red-400';
|
||||
return `<div class="bg-slate-900 rounded-lg border border-slate-800 ${border} px-4 py-3 cursor-pointer hover:bg-slate-800/50 transition" onclick="this.querySelector('.hist-detail')?.classList.toggle('hidden')">
|
||||
<div class="flex items-center justify-between"><div class="flex items-center gap-3"><span class="${statusCls} text-sm">${statusLabel}</span><div><span class="text-sm text-slate-300">${esc(l.server_name||'#'+l.server_id)}</span><span class="text-slate-600 mx-1">·</span><span class="text-xs text-slate-500">${esc(l.operator||'system')}</span><span class="text-slate-600 mx-1">·</span><span class="text-xs text-slate-500">${esc(l.sync_mode||'file')}</span></div></div><div class="text-xs text-slate-500">${fmtTime(l.started_at)}</div></div>
|
||||
<div class="text-xs text-slate-500 mt-1 truncate">${summary}</div>
|
||||
<div class="hist-detail hidden mt-2 space-y-1 border-t border-slate-700/50 pt-2">${detail}</div>
|
||||
const statusCls=cancelled?'text-[var(--text-secondary)]':ok?'text-green-400':'text-red-400';
|
||||
return `<div class="bg-[var(--bg-card)] rounded-lg border border-[var(--border)] ${border} px-4 py-3 cursor-pointer hover:bg-[var(--bg-surface)]/50 transition" onclick="this.querySelector('.hist-detail')?.classList.toggle('hidden')">
|
||||
<div class="flex items-center justify-between"><div class="flex items-center gap-3"><span class="${statusCls} text-sm">${statusLabel}</span><div><span class="text-sm text-slate-300">${esc(l.server_name||'#'+l.server_id)}</span><span class="text-[var(--text-dim)] mx-1">·</span><span class="text-xs text-[var(--text-muted)]">${esc(l.operator||'system')}</span><span class="text-[var(--text-dim)] mx-1">·</span><span class="text-xs text-[var(--text-muted)]">${esc(l.sync_mode||'file')}</span></div></div><div class="text-xs text-[var(--text-muted)]">${fmtTime(l.started_at)}</div></div>
|
||||
<div class="text-xs text-[var(--text-muted)] mt-1 truncate">${summary}</div>
|
||||
<div class="hist-detail hidden mt-2 space-y-1 border-t border-[var(--border-input)]/50 pt-2">${detail}</div>
|
||||
</div>`;
|
||||
}).join(''):'<div class="text-slate-500 text-center py-6 text-sm">暂无推送记录</div>';
|
||||
}).join(''):'<div class="text-[var(--text-muted)] text-center py-6 text-sm">暂无推送记录</div>';
|
||||
// Pagination
|
||||
const pager=document.getElementById('historyPager');
|
||||
if(pages>1){
|
||||
@@ -830,7 +830,7 @@
|
||||
document.getElementById('histPrev').disabled=page<=1;
|
||||
document.getElementById('histNext').disabled=page>=pages;
|
||||
}else{pager.classList.add('hidden')}
|
||||
}catch(e){document.getElementById('pushHistory').innerHTML='<div class="text-slate-500 text-center py-6 text-sm">加载失败</div>'}
|
||||
}catch(e){document.getElementById('pushHistory').innerHTML='<div class="text-[var(--text-muted)] text-center py-6 text-sm">加载失败</div>'}
|
||||
}
|
||||
|
||||
function esc(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML.replace(/'/g,''').replace(/"/g,'"')}
|
||||
@@ -857,7 +857,7 @@
|
||||
const btn=document.getElementById('previewBtn');const serverName=_serversCache.find(s=>s.id===firstServerId)?.name||('#'+firstServerId);
|
||||
const resultEl=document.getElementById('previewResult');resultEl.classList.remove('hidden');
|
||||
document.getElementById('previewServerName').textContent=`预览中:${esc(serverName)}...`;
|
||||
document.getElementById('previewStats').innerHTML='<span class="text-slate-500 text-xs col-span-2">正在运行 rsync --dry-run...</span>';
|
||||
document.getElementById('previewStats').innerHTML='<span class="text-[var(--text-muted)] text-xs col-span-2">正在运行 rsync --dry-run...</span>';
|
||||
document.getElementById('previewVerboseToggle').classList.add('hidden');document.getElementById('previewFileList').classList.add('hidden');
|
||||
if(btn)btn.disabled=true;
|
||||
try{
|
||||
@@ -873,9 +873,9 @@
|
||||
document.getElementById('previewServerName').textContent=`预览结果:${esc(serverName)}`;
|
||||
const s=d.stats||{};const isDestructive=(d.sync_mode==='full'||d.sync_mode==='overwrite');
|
||||
document.getElementById('previewStats').innerHTML=`
|
||||
<div class="bg-slate-900 rounded-lg px-3 py-2"><div class="text-slate-400 text-xs mb-1">待传输文件</div><div class="text-white font-semibold text-lg">${s.files_transferred??'—'}</div>${s.transfer_size_bytes!=null?`<div class="text-slate-500 text-xs">${fmtBytes(s.transfer_size_bytes)}</div>`:''}</div>
|
||||
<div class="bg-slate-900 rounded-lg px-3 py-2"><div class="text-slate-400 text-xs mb-1">新增文件</div><div class="text-green-400 font-semibold text-lg">${s.files_created??'—'}</div>${s.files_total!=null?`<div class="text-slate-500 text-xs">共 ${s.files_total} 个文件</div>`:''}</div>
|
||||
${isDestructive&&s.files_deleted!=null?`<div class="bg-red-900/30 border border-red-500/30 rounded-lg px-3 py-2 col-span-2"><div class="text-red-400 text-xs mb-1">⚠ 将删除文件(--delete)</div><div class="text-red-400 font-semibold text-lg">${s.files_deleted} 个文件</div></div>`:(s.files_deleted?`<div class="bg-slate-900 rounded-lg px-3 py-2"><div class="text-slate-400 text-xs mb-1">删除文件</div><div class="text-red-400 font-semibold text-lg">${s.files_deleted}</div></div>`:'')}`;
|
||||
<div class="bg-[var(--bg-card)] rounded-lg px-3 py-2"><div class="text-[var(--text-secondary)] text-xs mb-1">待传输文件</div><div class="text-white font-semibold text-lg">${s.files_transferred??'—'}</div>${s.transfer_size_bytes!=null?`<div class="text-[var(--text-muted)] text-xs">${fmtBytes(s.transfer_size_bytes)}</div>`:''}</div>
|
||||
<div class="bg-[var(--bg-card)] rounded-lg px-3 py-2"><div class="text-[var(--text-secondary)] text-xs mb-1">新增文件</div><div class="text-green-400 font-semibold text-lg">${s.files_created??'—'}</div>${s.files_total!=null?`<div class="text-[var(--text-muted)] text-xs">共 ${s.files_total} 个文件</div>`:''}</div>
|
||||
${isDestructive&&s.files_deleted!=null?`<div class="bg-red-900/30 border border-red-500/30 rounded-lg px-3 py-2 col-span-2"><div class="text-red-400 text-xs mb-1">⚠ 将删除文件(--delete)</div><div class="text-red-400 font-semibold text-lg">${s.files_deleted} 个文件</div></div>`:(s.files_deleted?`<div class="bg-[var(--bg-card)] rounded-lg px-3 py-2"><div class="text-[var(--text-secondary)] text-xs mb-1">删除文件</div><div class="text-red-400 font-semibold text-lg">${s.files_deleted}</div></div>`:'')}`;
|
||||
if(!verbose)document.getElementById('previewVerboseToggle').classList.remove('hidden');
|
||||
if(verbose&&d.files&&d.files.length>0){document.getElementById('previewFileList').classList.remove('hidden');
|
||||
document.getElementById('previewFiles').innerHTML=d.files.map(f=>`<div class="flex items-center gap-2"><span class="flex-1 truncate">${esc(f)}</span><button onclick="showFileDiff(${_previewServerId},'${esc(f)}')" class="text-xs text-brand-light hover:underline shrink-0">📄 diff</button></div>`).join('\n');
|
||||
|
||||
+17
-15
@@ -1,22 +1,24 @@
|
||||
<!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>
|
||||
<!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>
|
||||
<link rel="stylesheet" href="/app/vendor/theme.css">
|
||||
<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-[var(--bg-page)] text-[var(--text-primary)] min-h-screen flex" x-data="{sidebarOpen:true}">
|
||||
<aside x-show="sidebarOpen" x-transition class="w-64 bg-[var(--bg-card)] border-r border-[var(--border)] 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>
|
||||
<header class="bg-[var(--bg-card)] border-b border-[var(--border)] px-6 py-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-[var(--text-secondary)] 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="statusFilter" onchange="loadRetries()" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
|
||||
<select id="statusFilter" onchange="loadRetries()" class="px-3 py-1.5 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm">
|
||||
<option value="">全部状态</option>
|
||||
<option value="pending">等待中</option>
|
||||
<option value="running">执行中</option>
|
||||
<option value="failed">失败</option>
|
||||
<option value="success">成功</option>
|
||||
</select>
|
||||
<button onclick="loadRetries()" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-white text-sm rounded-lg transition">刷新</button>
|
||||
<button onclick="loadRetries()" class="px-3 py-1.5 bg-[var(--bg-surface)] hover:bg-slate-700 text-white text-sm rounded-lg transition">刷新</button>
|
||||
</div>
|
||||
</header>
|
||||
<main class="flex-1 overflow-y-auto p-6">
|
||||
<div id="retryList" class="space-y-3"><div class="text-slate-500 text-center py-8">加载中...</div></div>
|
||||
<div id="retryList" class="space-y-3"><div class="text-[var(--text-muted)] text-center py-8">加载中...</div></div>
|
||||
</main>
|
||||
</div>
|
||||
<script src="/app/api.js"></script>
|
||||
@@ -40,7 +42,7 @@
|
||||
const statusIcon=j.status==='pending'?'⏳':j.status==='running'?'🔄':j.status==='failed'?'❌':'✅';
|
||||
const retryPct=Math.min((j.retry_count/j.max_retries)*100,100);
|
||||
const canRetry=j.status==='failed'||j.status==='pending';
|
||||
return `<div class="bg-slate-900 rounded-xl border border-slate-800 p-4">
|
||||
return `<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm">${statusIcon}</span>
|
||||
@@ -48,17 +50,17 @@
|
||||
<span class="${statusColor} text-xs px-2 py-0.5 rounded ${j.status==='failed'?'bg-red-400/10':j.status==='pending'?'bg-yellow-400/10':j.status==='success'?'bg-green-400/10':'bg-blue-400/10'}">${esc(j.status)}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-slate-400">重试 ${j.retry_count}/${j.max_retries}</span>
|
||||
<span class="text-xs text-[var(--text-secondary)]">重试 ${j.retry_count}/${j.max_retries}</span>
|
||||
${canRetry?`<button onclick="retryJob(${j.id})" class="text-brand-light text-xs hover:underline px-2 py-1 bg-brand/10 rounded">重试</button>`:''}
|
||||
<button onclick="deleteJob(${j.id})" class="text-red-400 text-xs hover:underline px-2 py-1 bg-red-400/10 rounded">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 text-xs text-slate-500">${esc(j.source_path)} → ${esc(j.target_path||'')}</div>
|
||||
${j.operator?`<div class="mt-1 text-xs text-slate-600">操作人: ${esc(j.operator)}</div>`:''}
|
||||
<div class="mt-2 text-xs text-[var(--text-muted)]">${esc(j.source_path)} → ${esc(j.target_path||'')}</div>
|
||||
${j.operator?`<div class="mt-1 text-xs text-[var(--text-dim)]">操作人: ${esc(j.operator)}</div>`:''}
|
||||
${j.last_error?`<div class="mt-1 text-xs text-red-400/70 truncate" title="${esc(j.last_error)}">${esc(j.last_error.substring(0,100))}</div>`:''}
|
||||
<div class="mt-2 w-full bg-slate-800 rounded-full h-1"><div class="h-1 rounded-full ${retryPct>=80?'bg-red-400':retryPct>=50?'bg-yellow-400':'bg-brand'}" style="width:${retryPct}%"></div></div>
|
||||
${j.next_retry_at?`<div class="mt-1 text-xs text-slate-600">下次重试: ${fmtTime(j.next_retry_at)}</div>`:''}
|
||||
</div>`}).join(''):'<div class="text-slate-500 text-center py-8">暂无重试任务</div>';
|
||||
<div class="mt-2 w-full bg-[var(--bg-surface)] rounded-full h-1"><div class="h-1 rounded-full ${retryPct>=80?'bg-red-400':retryPct>=50?'bg-yellow-400':'bg-brand'}" style="width:${retryPct}%"></div></div>
|
||||
${j.next_retry_at?`<div class="mt-1 text-xs text-[var(--text-dim)]">下次重试: ${fmtTime(j.next_retry_at)}</div>`:''}
|
||||
</div>`}).join(''):'<div class="text-[var(--text-muted)] text-center py-8">暂无重试任务</div>';
|
||||
}catch(e){document.getElementById('retryList').innerHTML='<div class="text-red-400 text-center py-8">加载失败</div>'}
|
||||
}
|
||||
|
||||
|
||||
+38
-36
@@ -1,32 +1,34 @@
|
||||
<!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>
|
||||
<!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>
|
||||
<link rel="stylesheet" href="/app/vendor/theme.css">
|
||||
<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-[var(--bg-page)] text-[var(--text-primary)] min-h-screen flex" x-data="{sidebarOpen:true}">
|
||||
<aside x-show="sidebarOpen" x-transition class="w-64 bg-[var(--bg-card)] border-r border-[var(--border)] 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>
|
||||
<header class="bg-[var(--bg-card)] border-b border-[var(--border)] px-6 py-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-[var(--text-secondary)] 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="showAddSched()" class="px-3 py-1.5 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">+ 新建</button>
|
||||
</header>
|
||||
<main class="flex-1 overflow-y-auto p-6">
|
||||
<div id="schedList" class="space-y-3"><div class="text-slate-500 text-center py-8">加载中...</div></div>
|
||||
<div id="schedList" class="space-y-3"><div class="text-[var(--text-muted)] text-center py-8">加载中...</div></div>
|
||||
|
||||
<!-- Modal -->
|
||||
<div id="schedModal" class="hidden fixed inset-0 bg-black/60 flex items-center justify-center z-50 py-6 overflow-y-auto">
|
||||
<div class="bg-slate-900 border border-slate-700 rounded-xl p-6 w-full max-w-lg space-y-4 my-auto">
|
||||
<div class="bg-[var(--bg-card)] border border-[var(--border-input)] rounded-xl p-6 w-full max-w-lg space-y-4 my-auto">
|
||||
<h2 id="schedModalTitle" class="text-white font-semibold text-lg">新建调度</h2>
|
||||
<input type="hidden" id="editSchedId">
|
||||
|
||||
<!-- Basic -->
|
||||
<div><label class="block text-slate-400 text-xs mb-1">名称 *</label><input id="sName" 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-[var(--text-secondary)] text-xs mb-1">名称 *</label><input id="sName" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm" placeholder="每日更新"></div>
|
||||
|
||||
<!-- Run mode selector -->
|
||||
<div>
|
||||
<label class="block text-slate-400 text-xs mb-1">执行方式 *</label>
|
||||
<label class="block text-[var(--text-secondary)] text-xs mb-1">执行方式 *</label>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<label class="flex items-center gap-2 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg cursor-pointer has-[:checked]:border-brand has-[:checked]:bg-brand/10 transition">
|
||||
<label class="flex items-center gap-2 px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg cursor-pointer has-[:checked]:border-brand has-[:checked]:bg-brand/10 transition">
|
||||
<input type="radio" name="sRunMode" value="once" checked class="accent-brand" onchange="onRunModeChange()">
|
||||
<span class="text-sm">🕐 一次性</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg cursor-pointer has-[:checked]:border-brand has-[:checked]:bg-brand/10 transition">
|
||||
<label class="flex items-center gap-2 px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg cursor-pointer has-[:checked]:border-brand has-[:checked]:bg-brand/10 transition">
|
||||
<input type="radio" name="sRunMode" value="cron" class="accent-brand" onchange="onRunModeChange()">
|
||||
<span class="text-sm">🔁 循环 (Cron)</span>
|
||||
</label>
|
||||
@@ -35,26 +37,26 @@
|
||||
|
||||
<!-- Once: fire_at -->
|
||||
<div id="onceFields">
|
||||
<label class="block text-slate-400 text-xs mb-1">执行时间 *</label>
|
||||
<input id="sFireAt" type="datetime-local" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
|
||||
<label class="block text-[var(--text-secondary)] text-xs mb-1">执行时间 *</label>
|
||||
<input id="sFireAt" type="datetime-local" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm">
|
||||
</div>
|
||||
|
||||
<!-- Cron: expression -->
|
||||
<div id="cronFields" class="hidden">
|
||||
<label class="block text-slate-400 text-xs mb-1">Cron 表达式 *</label>
|
||||
<input id="sCron" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm font-mono" placeholder="0 2 * * *">
|
||||
<div class="text-slate-600 text-xs mt-1">分 时 日 月 周 · 0 2 * * * = 每天凌晨 2 点 · 最小精度 1 分钟</div>
|
||||
<label class="block text-[var(--text-secondary)] text-xs mb-1">Cron 表达式 *</label>
|
||||
<input id="sCron" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm font-mono" placeholder="0 2 * * *">
|
||||
<div class="text-[var(--text-dim)] text-xs mt-1">分 时 日 月 周 · 0 2 * * * = 每天凌晨 2 点 · 最小精度 1 分钟</div>
|
||||
</div>
|
||||
|
||||
<!-- Type selector -->
|
||||
<div>
|
||||
<label class="block text-slate-400 text-xs mb-1">调度类型 *</label>
|
||||
<label class="block text-[var(--text-secondary)] text-xs mb-1">调度类型 *</label>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<label class="flex items-center gap-2 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg cursor-pointer has-[:checked]:border-brand has-[:checked]:bg-brand/10 transition">
|
||||
<label class="flex items-center gap-2 px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg cursor-pointer has-[:checked]:border-brand has-[:checked]:bg-brand/10 transition">
|
||||
<input type="radio" name="sType" value="push" checked class="accent-brand" onchange="onTypeChange()">
|
||||
<span class="text-sm">📁 文件推送 (rsync)</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg cursor-pointer has-[:checked]:border-brand has-[:checked]:bg-brand/10 transition">
|
||||
<label class="flex items-center gap-2 px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg cursor-pointer has-[:checked]:border-brand has-[:checked]:bg-brand/10 transition">
|
||||
<input type="radio" name="sType" value="script" class="accent-brand" onchange="onTypeChange()">
|
||||
<span class="text-sm">⚡ 脚本执行</span>
|
||||
</label>
|
||||
@@ -63,24 +65,24 @@
|
||||
|
||||
<!-- Push fields -->
|
||||
<div id="pushFields" class="space-y-3">
|
||||
<div><label class="block text-slate-400 text-xs mb-1">源路径 *</label><input id="sPath" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="/home/deploy/project/"></div>
|
||||
<div><label class="block text-slate-400 text-xs mb-1">同步模式</label><select id="sSyncMode" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm"><option value="incremental">增量同步</option><option value="full">全量同步(--delete)</option><option value="checksum">校验和</option></select></div>
|
||||
<div><label class="block text-[var(--text-secondary)] text-xs mb-1">源路径 *</label><input id="sPath" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm" placeholder="/home/deploy/project/"></div>
|
||||
<div><label class="block text-[var(--text-secondary)] text-xs mb-1">同步模式</label><select id="sSyncMode" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm"><option value="incremental">增量同步</option><option value="full">全量同步(--delete)</option><option value="checksum">校验和</option></select></div>
|
||||
</div>
|
||||
|
||||
<!-- Script fields -->
|
||||
<div id="scriptFields" class="hidden space-y-3">
|
||||
<div>
|
||||
<label class="block text-slate-400 text-xs mb-1">脚本库选择(可选)</label>
|
||||
<select id="sScriptId" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
|
||||
<label class="block text-[var(--text-secondary)] text-xs mb-1">脚本库选择(可选)</label>
|
||||
<select id="sScriptId" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm">
|
||||
<option value="">不使用脚本库(使用下方命令)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-slate-400 text-xs mb-1">命令内容(脚本库为空时生效)</label>
|
||||
<textarea id="sScriptContent" rows="4" placeholder="echo 'scheduled task' uptime" class="w-full px-3 py-2 bg-slate-950 border border-slate-700 rounded-lg text-green-400 text-sm font-mono resize-y"></textarea>
|
||||
<label class="block text-[var(--text-secondary)] text-xs mb-1">命令内容(脚本库为空时生效)</label>
|
||||
<textarea id="sScriptContent" rows="4" placeholder="echo 'scheduled task' uptime" class="w-full px-3 py-2 bg-[var(--bg-page)] border border-[var(--border-input)] rounded-lg text-green-400 text-sm font-mono resize-y"></textarea>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div><label class="block text-slate-400 text-xs mb-1">超时(秒)</label><input id="sTimeout" type="number" value="60" min="10" max="600" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm"></div>
|
||||
<div><label class="block text-[var(--text-secondary)] text-xs mb-1">超时(秒)</label><input id="sTimeout" type="number" value="60" min="10" max="600" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm"></div>
|
||||
<div class="flex items-center pt-5"><label class="flex items-center gap-2 cursor-pointer"><input type="checkbox" id="sLongTask" class="rounded border-slate-600 bg-slate-700 text-brand focus:ring-brand"><span class="text-sm text-slate-300">长任务(nohup)</span></label></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -88,15 +90,15 @@
|
||||
<!-- Common: server selector -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<label class="block text-slate-400 text-xs">目标服务器 *</label>
|
||||
<label class="block text-[var(--text-secondary)] text-xs">目标服务器 *</label>
|
||||
<button onclick="Array.from(document.getElementById('sServers').options).forEach(o=>o.selected=true)" class="text-brand-light text-xs hover:underline">全选</button>
|
||||
</div>
|
||||
<select id="sServers" multiple class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm h-32"></select>
|
||||
<select id="sServers" multiple class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm h-32"></select>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 pt-1">
|
||||
<button onclick="saveSched()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm font-medium">保存</button>
|
||||
<button onclick="hideSchedModal()" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm">取消</button>
|
||||
<button onclick="hideSchedModal()" class="px-4 py-2 bg-[var(--bg-surface)] hover:bg-slate-700 text-slate-300 rounded-lg text-sm">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -134,8 +136,8 @@
|
||||
const scheduleDesc=(s.run_mode||'once')==='cron'
|
||||
?`<span class="font-mono">${esc(s.cron_expr)}</span>`
|
||||
:`一次性 ${s.fire_at?fmtTime(s.fire_at):'(未设置)'}`;
|
||||
const doneBadge=(s.run_mode==='once'&&s.last_run_at)?' <span class="text-slate-600 text-[10px]">✓ 已执行</span>':'';
|
||||
return `<div class="bg-slate-900 rounded-xl border border-slate-800 p-4">
|
||||
const doneBadge=(s.run_mode==='once'&&s.last_run_at)?' <span class="text-[var(--text-dim)] text-[10px]">✓ 已执行</span>':'';
|
||||
return `<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<button onclick="toggleSched(${s.id},${!s.enabled})" class="w-10 h-6 rounded-full transition-colors ${s.enabled?'bg-brand':'bg-slate-700'} relative shrink-0" title="${s.enabled?'点击禁用':'点击启用'}">
|
||||
@@ -144,21 +146,21 @@
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-white font-medium">${esc(s.name)}</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-slate-800 text-slate-400">${TYPE_LABEL[s.schedule_type||'push']||'推送'}</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-slate-800 text-slate-500">${runModeLabel}</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--bg-surface)] text-[var(--text-secondary)]">${TYPE_LABEL[s.schedule_type||'push']||'推送'}</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--bg-surface)] text-[var(--text-muted)]">${runModeLabel}</span>
|
||||
</div>
|
||||
<div class="text-slate-500 text-xs mt-0.5 truncate">
|
||||
<div class="text-[var(--text-muted)] text-xs mt-0.5 truncate">
|
||||
${scheduleDesc} · ${detail}${s.last_run_at&&s.run_mode==='cron'?' · 上次: '+fmtTime(s.last_run_at):''}${doneBadge}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0 ml-3">
|
||||
<button onclick="showEditSched(${s.id})" class="text-slate-400 hover:text-white text-xs transition">编辑</button>
|
||||
<button onclick="showEditSched(${s.id})" class="text-[var(--text-secondary)] hover:text-white text-xs transition">编辑</button>
|
||||
<button onclick="deleteSched(${s.id})" class="text-red-400 text-xs hover:underline">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`}).join(''):'<div class="text-slate-500 text-center py-8">暂无调度</div>';
|
||||
</div>`}).join(''):'<div class="text-[var(--text-muted)] text-center py-8">暂无调度</div>';
|
||||
}catch(e){document.getElementById('schedList').innerHTML='<div class="text-red-400 text-center py-8">加载失败</div>'}
|
||||
}
|
||||
|
||||
|
||||
+45
-45
@@ -1,51 +1,51 @@
|
||||
<!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"><div class="flex items-center justify-between"><div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-slate-400 hover:text-white"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="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-2"><a href="/app/script-executions.html" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-300 text-sm rounded-lg">执行历史</a><button onclick="showNewScript()" class="px-3 py-1.5 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg">+ 新建脚本</button></div></div></header>
|
||||
<!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><link rel="stylesheet" href="/app/vendor/theme.css"><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-[var(--bg-page)] text-[var(--text-primary)] min-h-screen flex" x-data="{sidebarOpen:true}">
|
||||
<aside x-show="sidebarOpen" x-transition class="w-64 bg-[var(--bg-card)] border-r border-[var(--border)] flex flex-col shrink-0" data-sidebar></aside>
|
||||
<div class="flex-1 flex flex-col min-w-0"><header class="bg-[var(--bg-card)] border-b border-[var(--border)] px-6 py-3"><div class="flex items-center justify-between"><div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-[var(--text-secondary)] hover:text-white"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="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-2"><a href="/app/script-executions.html" class="px-3 py-1.5 bg-[var(--bg-surface)] hover:bg-slate-700 text-slate-300 text-sm rounded-lg">执行历史</a><button onclick="showNewScript()" class="px-3 py-1.5 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg">+ 新建脚本</button></div></div></header>
|
||||
<main class="flex-1 overflow-y-auto p-6 space-y-4">
|
||||
|
||||
<!-- ── 直接执行(无需保存到脚本库)── -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
|
||||
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] overflow-hidden">
|
||||
<button onclick="toggleQuickExec()" id="quickExecToggleBtn"
|
||||
class="w-full flex items-center justify-between px-5 py-3 text-left hover:bg-slate-800/50 transition group">
|
||||
class="w-full flex items-center justify-between px-5 py-3 text-left hover:bg-[var(--bg-surface)]/50 transition group">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-brand-light font-mono text-sm">⚡</span>
|
||||
<span class="text-white font-medium text-sm">直接执行命令</span>
|
||||
<span class="text-slate-500 text-xs">(临时命令,不保存到脚本库)</span>
|
||||
<span class="text-[var(--text-muted)] text-xs">(临时命令,不保存到脚本库)</span>
|
||||
</div>
|
||||
<svg id="quickExecChevron" class="w-4 h-4 text-slate-500 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg id="quickExecChevron" class="w-4 h-4 text-[var(--text-muted)] transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div id="quickExecPanel" class="hidden border-t border-slate-800 p-5 space-y-4">
|
||||
<div id="quickExecPanel" class="hidden border-t border-[var(--border)] p-5 space-y-4">
|
||||
<!-- Command input -->
|
||||
<div>
|
||||
<label class="block text-slate-400 text-xs mb-1.5">命令内容 <span class="text-slate-600">(支持多行 Shell 脚本)</span></label>
|
||||
<label class="block text-[var(--text-secondary)] text-xs mb-1.5">命令内容 <span class="text-[var(--text-dim)]">(支持多行 Shell 脚本)</span></label>
|
||||
<textarea id="quickCmd" rows="4" placeholder="echo 'hello world' df -h uptime"
|
||||
class="w-full px-4 py-3 bg-slate-950 border border-slate-700 rounded-xl text-green-400 text-sm font-mono
|
||||
class="w-full px-4 py-3 bg-[var(--bg-page)] border border-[var(--border-input)] rounded-xl text-green-400 text-sm font-mono
|
||||
focus:outline-none focus:ring-2 focus:ring-brand resize-y placeholder-slate-700"
|
||||
onkeydown="if(event.ctrlKey&&event.key==='Enter'){event.preventDefault();doQuickExec()}"></textarea>
|
||||
<p class="text-slate-600 text-xs mt-1">Ctrl + Enter 快捷执行</p>
|
||||
<p class="text-[var(--text-dim)] text-xs mt-1">Ctrl + Enter 快捷执行</p>
|
||||
</div>
|
||||
<!-- Servers + options row -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-1.5">
|
||||
<label class="text-slate-400 text-xs">目标服务器</label>
|
||||
<label class="text-[var(--text-secondary)] text-xs">目标服务器</label>
|
||||
<button onclick="selectAllQuickServers()" class="text-brand-light text-xs hover:underline">全选</button>
|
||||
</div>
|
||||
<select id="quickServers" multiple class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm h-28"></select>
|
||||
<p id="quickBatchHint" class="text-slate-600 text-xs mt-1"></p>
|
||||
<select id="quickServers" multiple class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm h-28"></select>
|
||||
<p id="quickBatchHint" class="text-[var(--text-dim)] text-xs mt-1"></p>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-slate-400 text-xs mb-1">超时 (秒)</label>
|
||||
<label class="block text-[var(--text-secondary)] text-xs mb-1">超时 (秒)</label>
|
||||
<input id="quickTimeout" type="number" value="60" min="10" max="600"
|
||||
class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
|
||||
class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-slate-400 text-xs mb-1">DB 凭据(可选)</label>
|
||||
<select id="quickCredential" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
|
||||
<label class="block text-[var(--text-secondary)] text-xs mb-1">DB 凭据(可选)</label>
|
||||
<select id="quickCredential" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm">
|
||||
<option value="">不使用</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -61,55 +61,55 @@
|
||||
class="px-5 py-2.5 bg-brand hover:bg-brand-dark text-white text-sm font-medium rounded-lg transition disabled:opacity-50">
|
||||
⚡ 执行
|
||||
</button>
|
||||
<button onclick="document.getElementById('quickCmd').value=''" class="px-3 py-2.5 bg-slate-800 hover:bg-slate-700 text-slate-400 text-xs rounded-lg transition">
|
||||
<button onclick="document.getElementById('quickCmd').value=''" class="px-3 py-2.5 bg-[var(--bg-surface)] hover:bg-slate-700 text-[var(--text-secondary)] text-xs rounded-lg transition">
|
||||
清空
|
||||
</button>
|
||||
<span class="text-slate-600 text-xs">命令不会被保存;执行结果在下方「批量执行状态」查看</span>
|
||||
<span class="text-[var(--text-dim)] text-xs">命令不会被保存;执行结果在下方「批量执行状态」查看</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── 执行状态 ── -->
|
||||
<div id="execStatusPanel" class="hidden bg-slate-900 rounded-xl border border-slate-800 p-4">
|
||||
<div id="execStatusPanel" class="hidden bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-4">
|
||||
<h2 class="text-white font-semibold text-sm mb-3">批量执行状态</h2>
|
||||
<div id="execStatusList" class="space-y-2"></div>
|
||||
</div>
|
||||
|
||||
<!-- ── 脚本库 ── -->
|
||||
<div id="scriptList" class="space-y-3"><div class="text-slate-500 text-center py-8">加载中...</div></div>
|
||||
<div id="scriptList" class="space-y-3"><div class="text-[var(--text-muted)] text-center py-8">加载中...</div></div>
|
||||
|
||||
<!-- Add/Edit Script Form -->
|
||||
<div id="scriptForm" class="hidden mt-6 bg-slate-900 rounded-xl border border-slate-800 p-6 space-y-3">
|
||||
<div id="scriptForm" class="hidden mt-6 bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-6 space-y-3">
|
||||
<input type="hidden" id="editScriptId">
|
||||
<h2 id="scriptFormTitle" class="text-white font-semibold">新建脚本</h2>
|
||||
<input id="scriptName" placeholder="脚本名称" class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white text-sm">
|
||||
<input id="scriptCategory" placeholder="分类 (ops/deploy/check)" class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white text-sm">
|
||||
<textarea id="scriptContent" rows="10" placeholder="Shell 命令内容" class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white text-sm font-mono"></textarea>
|
||||
<div class="flex gap-2"><button onclick="saveScript()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm">保存</button><button onclick="hideScriptForm()" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm">取消</button></div>
|
||||
<input id="scriptName" placeholder="脚本名称" class="w-full px-4 py-3 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-xl text-white text-sm">
|
||||
<input id="scriptCategory" placeholder="分类 (ops/deploy/check)" class="w-full px-4 py-3 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-xl text-white text-sm">
|
||||
<textarea id="scriptContent" rows="10" placeholder="Shell 命令内容" class="w-full px-4 py-3 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-xl text-white text-sm font-mono"></textarea>
|
||||
<div class="flex gap-2"><button onclick="saveScript()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm">保存</button><button onclick="hideScriptForm()" class="px-4 py-2 bg-[var(--bg-surface)] hover:bg-slate-700 text-slate-300 rounded-lg text-sm">取消</button></div>
|
||||
</div>
|
||||
|
||||
<!-- Execute Modal -->
|
||||
<div id="execModal" 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-lg space-y-3">
|
||||
<div class="bg-[var(--bg-card)] border border-[var(--border-input)] rounded-xl p-6 w-full max-w-lg space-y-3">
|
||||
<h2 class="text-white font-semibold text-lg">执行脚本</h2>
|
||||
<p id="execScriptName" class="text-slate-400 text-sm"></p>
|
||||
<p id="execScriptName" class="text-[var(--text-secondary)] text-sm"></p>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<label class="block text-slate-400 text-xs">选择服务器</label>
|
||||
<label class="block text-[var(--text-secondary)] text-xs">选择服务器</label>
|
||||
<button type="button" onclick="selectAllExecServers()" class="text-brand-light text-xs hover:underline">全选</button>
|
||||
</div>
|
||||
<select id="execServers" multiple class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm h-32"></select>
|
||||
<p id="execBatchHint" class="text-slate-500 text-xs mt-1"></p>
|
||||
<select id="execServers" multiple class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm h-32"></select>
|
||||
<p id="execBatchHint" class="text-[var(--text-muted)] text-xs mt-1"></p>
|
||||
</div>
|
||||
<div><label class="block text-slate-400 text-xs mb-1">超时(秒)</label><input id="execTimeout" type="number" value="60" min="10" max="600" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm"><p class="text-slate-500 text-xs mt-1">仅限制「点火」命令;长任务勾选后 60~120 秒即可</p></div>
|
||||
<div><label class="block text-slate-400 text-xs mb-1">DB 凭据(可选,替换命令中的 $DB_*)</label><select id="execCredential" 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-[var(--text-secondary)] text-xs mb-1">超时(秒)</label><input id="execTimeout" type="number" value="60" min="10" max="600" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm"><p class="text-[var(--text-muted)] text-xs mt-1">仅限制「点火」命令;长任务勾选后 60~120 秒即可</p></div>
|
||||
<div><label class="block text-[var(--text-secondary)] text-xs mb-1">DB 凭据(可选,替换命令中的 $DB_*)</label><select id="execCredential" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm"><option value="">不使用</option></select></div>
|
||||
<label class="flex items-start gap-2 cursor-pointer select-none">
|
||||
<input type="checkbox" id="execLongTask" class="mt-1 rounded border-slate-600 bg-slate-700 text-brand focus:ring-brand" onchange="updateLongTaskHint()">
|
||||
<span class="text-sm text-slate-300">长任务(后台 <code class="text-slate-400">nohup</code>,日志在子机 <code class="text-slate-400">/var/log/nexus-job/</code>)</span>
|
||||
<span class="text-sm text-slate-300">长任务(后台 <code class="text-[var(--text-secondary)]">nohup</code>,日志在子机 <code class="text-[var(--text-secondary)]">/var/log/nexus-job/</code>)</span>
|
||||
</label>
|
||||
<p id="execLongHint" class="hidden text-amber-400/90 text-xs pl-6">主站 nohup 包装并注册任务;子机脚本结束后自动 curl 主站回传结果(需配置 NEXUS_API_BASE_URL)</p>
|
||||
<p class="text-slate-500 text-xs">提交后在本页「批量执行状态」查看进度,可关闭此窗口</p>
|
||||
<div class="flex gap-2"><button onclick="doExec()" id="execBtn" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm">执行</button><button onclick="hideExecModal()" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm">关闭</button></div>
|
||||
<p class="text-[var(--text-muted)] text-xs">提交后在本页「批量执行状态」查看进度,可关闭此窗口</p>
|
||||
<div class="flex gap-2"><button onclick="doExec()" id="execBtn" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm">执行</button><button onclick="hideExecModal()" class="px-4 py-2 bg-[var(--bg-surface)] hover:bg-slate-700 text-slate-300 rounded-lg text-sm">关闭</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
@@ -413,24 +413,24 @@
|
||||
const label=EXEC_STATUS_LABEL[st]||'执行中';
|
||||
const cls=EXEC_STATUS_CLASS[st]||EXEC_STATUS_CLASS.running;
|
||||
const ids=t.executionIds?.length?`#${t.executionIds.join(',#')}`:'';
|
||||
const logBlock=t.logExpanded?`<pre class="mt-2 p-3 bg-slate-900 rounded-lg text-xs text-slate-300 font-mono max-h-72 overflow-auto whitespace-pre-wrap border border-slate-800">${esc(t.logText||'(空)')}</pre>`:'';
|
||||
const logBlock=t.logExpanded?`<pre class="mt-2 p-3 bg-[var(--bg-card)] rounded-lg text-xs text-slate-300 font-mono max-h-72 overflow-auto whitespace-pre-wrap border border-[var(--border)]">${esc(t.logText||'(空)')}</pre>`:'';
|
||||
const btnDis=t.logLoading||!t.executionIds?.length?'disabled':'';
|
||||
const showStop=jobHasPending(t)&&!t.submitting;
|
||||
const showRetry=jobCanRetry(t)&&!t.submitting&&!t.logLoading;
|
||||
const showStuck=(t.data?.status==='running'||jobHasPending(t))&&!t.submitting;
|
||||
return `<div class="rounded-lg bg-slate-950 border border-slate-800 p-3">
|
||||
return `<div class="rounded-lg bg-[var(--bg-page)] border border-[var(--border)] p-3">
|
||||
<div class="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-white text-sm truncate">${esc(t.scriptName||'脚本')}</div>
|
||||
<div class="text-slate-500 text-xs mt-0.5">${esc(execProgressDetail(t))}${ids?` <span class="text-slate-600">${esc(ids)}</span>`:''}</div>
|
||||
<div class="text-[var(--text-muted)] text-xs mt-0.5">${esc(execProgressDetail(t))}${ids?` <span class="text-[var(--text-dim)]">${esc(ids)}</span>`:''}</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0 flex-wrap justify-end">
|
||||
${showStuck?`<button type="button" onclick="markStuckJob('${t.id}')" class="text-xs px-2 py-1 rounded border border-slate-600 text-slate-400 hover:bg-slate-800">标记卡住</button>`:''}
|
||||
${showStuck?`<button type="button" onclick="markStuckJob('${t.id}')" class="text-xs px-2 py-1 rounded border border-slate-600 text-[var(--text-secondary)] hover:bg-[var(--bg-surface)]">标记卡住</button>`:''}
|
||||
${showStop?`<button type="button" onclick="stopJob('${t.id}')" class="text-xs px-2 py-1 rounded border border-red-500/50 text-red-300 hover:bg-red-500/10">停止</button>`:''}
|
||||
${showRetry?`<button type="button" onclick="retryJob('${t.id}')" class="text-xs px-2 py-1 rounded border border-amber-500/50 text-amber-200 hover:bg-amber-500/10">重试</button>`:''}
|
||||
<button type="button" onclick="refreshJobStatusLogs('${t.id}',false)" ${btnDis} class="text-xs px-2 py-1 rounded border border-slate-700 text-slate-300 hover:bg-slate-800 disabled:opacity-40">刷新状态</button>
|
||||
<button type="button" onclick="refreshJobStatusLogs('${t.id}',false)" ${btnDis} class="text-xs px-2 py-1 rounded border border-[var(--border-input)] text-slate-300 hover:bg-[var(--bg-surface)] disabled:opacity-40">刷新状态</button>
|
||||
<button type="button" onclick="refreshJobStatusLogs('${t.id}',true)" ${btnDis} class="text-xs px-2 py-1 rounded border border-brand/50 text-brand-light hover:bg-brand/10 disabled:opacity-40">${t.logLoading?'拉取中…':'状态+日志'}</button>
|
||||
${t.logText?`<button type="button" onclick="toggleJobLog('${t.id}')" class="text-xs px-2 py-1 rounded border border-slate-700 text-slate-400 hover:bg-slate-800">${t.logExpanded?'收起':'展开'}</button>`:''}
|
||||
${t.logText?`<button type="button" onclick="toggleJobLog('${t.id}')" class="text-xs px-2 py-1 rounded border border-[var(--border-input)] text-[var(--text-secondary)] hover:bg-[var(--bg-surface)]">${t.logExpanded?'收起':'展开'}</button>`:''}
|
||||
<span class="text-xs px-2.5 py-1 rounded-full border ${cls}">${label}</span>
|
||||
</div>
|
||||
</div>${logBlock}
|
||||
@@ -509,7 +509,7 @@
|
||||
const t=document.getElementById('execTimeout');
|
||||
if(on&&parseInt(t.value,10)<90)t.value='120';
|
||||
}
|
||||
async function loadScripts(){try{const r=await apiFetch(API+'/scripts/');if(!r)return;const scripts=await r.json();_scriptsCache=scripts;document.getElementById('scriptList').innerHTML=scripts.length?scripts.map(s=>`<div class="bg-slate-900 rounded-xl border border-slate-800 p-4"><div class="flex items-center justify-between"><span class="text-white font-medium cursor-pointer hover:text-brand-light transition" onclick="showEditScript(${s.id})">${esc(s.name)}</span><div class="flex items-center gap-2"><span class="text-xs px-2 py-0.5 rounded bg-slate-800 text-slate-400">${esc(s.category||'ops')}</span><button onclick="showExec(${s.id})" class="text-brand-light text-xs hover:underline">执行</button><button onclick="showEditScript(${s.id})" class="text-slate-400 text-xs hover:underline">编辑</button><button onclick="deleteScript(${s.id})" class="text-red-400 text-xs hover:underline">删除</button></div></div><pre class="mt-2 text-sm text-slate-400 font-mono bg-slate-950 rounded-lg p-3 overflow-x-auto max-h-32">${esc(s.content?.substring(0,300)||'')}</pre></div>`).join(''):'<div class="text-slate-500 text-center py-8">暂无脚本</div>'}catch(e){}}
|
||||
async function loadScripts(){try{const r=await apiFetch(API+'/scripts/');if(!r)return;const scripts=await r.json();_scriptsCache=scripts;document.getElementById('scriptList').innerHTML=scripts.length?scripts.map(s=>`<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-4"><div class="flex items-center justify-between"><span class="text-white font-medium cursor-pointer hover:text-brand-light transition" onclick="showEditScript(${s.id})">${esc(s.name)}</span><div class="flex items-center gap-2"><span class="text-xs px-2 py-0.5 rounded bg-[var(--bg-surface)] text-[var(--text-secondary)]">${esc(s.category||'ops')}</span><button onclick="showExec(${s.id})" class="text-brand-light text-xs hover:underline">执行</button><button onclick="showEditScript(${s.id})" class="text-[var(--text-secondary)] text-xs hover:underline">编辑</button><button onclick="deleteScript(${s.id})" class="text-red-400 text-xs hover:underline">删除</button></div></div><pre class="mt-2 text-sm text-[var(--text-secondary)] font-mono bg-[var(--bg-page)] rounded-lg p-3 overflow-x-auto max-h-32">${esc(s.content?.substring(0,300)||'')}</pre></div>`).join(''):'<div class="text-[var(--text-muted)] text-center py-8">暂无脚本</div>'}catch(e){}}
|
||||
|
||||
function showNewScript(){
|
||||
document.getElementById('editScriptId').value='';
|
||||
|
||||
+102
-102
@@ -1,45 +1,45 @@
|
||||
<!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><link rel="stylesheet" href="/app/vendor/xterm.css"/><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>
|
||||
<!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><link rel="stylesheet" href="/app/vendor/theme.css"><script defer src="/app/vendor/alpinejs.min.js"></script><link rel="stylesheet" href="/app/vendor/xterm.css"/><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>
|
||||
<style>
|
||||
.detail-panel{max-height:0;overflow:hidden;transition:max-height .3s ease}
|
||||
.detail-panel.open{max-height:800px}
|
||||
</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>
|
||||
<body class="bg-[var(--bg-page)] text-[var(--text-primary)] min-h-screen flex" x-data="{sidebarOpen:true}">
|
||||
<aside x-show="sidebarOpen" x-transition class="w-64 bg-[var(--bg-card)] border-r border-[var(--border)] 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>
|
||||
<header class="bg-[var(--bg-card)] border-b border-[var(--border)] px-6 py-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-[var(--text-secondary)] 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-2 flex-wrap">
|
||||
<select id="categoryFilter" onchange="loadServers(true)" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
|
||||
<select id="categoryFilter" onchange="loadServers(true)" class="px-3 py-1.5 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm">
|
||||
<option value="">全部分类</option>
|
||||
</select>
|
||||
<select id="statusFilter" onchange="_statusFilter=this.value;loadServers(true)" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
|
||||
<select id="statusFilter" onchange="_statusFilter=this.value;loadServers(true)" class="px-3 py-1.5 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm">
|
||||
<option value="">全部状态</option>
|
||||
<option value="online">在线</option>
|
||||
<option value="offline">离线</option>
|
||||
</select>
|
||||
<input id="searchInput" type="text" placeholder="搜索名称/地址..." autocomplete="off" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-brand w-48">
|
||||
<button id="refreshToggle" onclick="toggleAutoRefresh()" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-400 text-sm rounded-lg transition" title="自动刷新">自动刷新</button>
|
||||
<button onclick="loadServers()" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-white text-sm rounded-lg transition">刷新</button>
|
||||
<button onclick="exportServersCSV()" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-white text-sm rounded-lg transition">↑ 导出</button>
|
||||
<button onclick="showImportModal()" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-white text-sm rounded-lg transition">↓ 导入</button>
|
||||
<input id="searchInput" type="text" placeholder="搜索名称/地址..." autocomplete="off" class="px-3 py-1.5 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-brand w-48">
|
||||
<button id="refreshToggle" onclick="toggleAutoRefresh()" class="px-3 py-1.5 bg-[var(--bg-surface)] hover:bg-slate-700 text-[var(--text-secondary)] text-sm rounded-lg transition" title="自动刷新">自动刷新</button>
|
||||
<button onclick="loadServers()" class="px-3 py-1.5 bg-[var(--bg-surface)] hover:bg-slate-700 text-white text-sm rounded-lg transition">刷新</button>
|
||||
<button onclick="exportServersCSV()" class="px-3 py-1.5 bg-[var(--bg-surface)] hover:bg-slate-700 text-white text-sm rounded-lg transition">↑ 导出</button>
|
||||
<button onclick="showImportModal()" class="px-3 py-1.5 bg-[var(--bg-surface)] hover:bg-slate-700 text-white text-sm rounded-lg transition">↓ 导入</button>
|
||||
<button onclick="showAddServer()" class="px-3 py-1.5 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">+ 添加</button>
|
||||
<button onclick="document.getElementById('shortcutHelp').classList.toggle('hidden')" class="px-2 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-400 text-sm rounded-lg transition" title="快捷键">?</button>
|
||||
<button onclick="document.getElementById('shortcutHelp').classList.toggle('hidden')" class="px-2 py-1.5 bg-[var(--bg-surface)] hover:bg-slate-700 text-[var(--text-secondary)] text-sm rounded-lg transition" title="快捷键">?</button>
|
||||
</div>
|
||||
</header>
|
||||
<main class="flex-1 overflow-y-auto p-6">
|
||||
<div id="serversTable" 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="px-4 py-3 w-10"><input type="checkbox" id="selectAll" onchange="toggleSelectAll(this.checked)" class="rounded border-slate-600 bg-slate-700 text-brand focus:ring-brand"></th><th class="text-left px-4 py-3 cursor-pointer select-none hover:text-brand-light" onclick="toggleSort('status')">Agent 状态 <span id="sort-status" class="text-[10px]"></span></th><th class="text-left px-4 py-3 cursor-pointer select-none hover:text-brand-light" onclick="toggleSort('name')">名称 <span id="sort-name" class="text-[10px]"></span></th><th class="text-left px-4 py-3 cursor-pointer select-none hover:text-brand-light" onclick="toggleSort('domain')">地址 <span id="sort-domain" class="text-[10px]"></span></th><th class="text-left px-4 py-3 cursor-pointer select-none hover:text-brand-light" onclick="toggleSort('category')">分类 <span id="sort-category" class="text-[10px]"></span></th><th class="text-left px-4 py-3 cursor-pointer select-none hover:text-brand-light" onclick="toggleSort('agent_version')">Agent <span id="sort-agent_version" class="text-[10px]"></span></th><th class="text-left px-4 py-3 cursor-pointer select-none hover:text-brand-light" onclick="toggleSort('heartbeat')">最后心跳 <span id="sort-heartbeat" class="text-[10px]"></span></th><th class="text-right px-4 py-3">操作</th></tr></thead><tbody id="serversTbody" class="divide-y divide-slate-800"><tr><td colspan="8" class="px-4 py-8 text-center text-slate-500">加载中...</td></tr></tbody></table>
|
||||
<div id="serversTable" class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] overflow-hidden">
|
||||
<table class="w-full text-sm"><thead class="bg-[var(--bg-surface)]/50 text-[var(--text-secondary)] text-xs uppercase"><tr><th class="px-4 py-3 w-10"><input type="checkbox" id="selectAll" onchange="toggleSelectAll(this.checked)" class="rounded border-slate-600 bg-slate-700 text-brand focus:ring-brand"></th><th class="text-left px-4 py-3 cursor-pointer select-none hover:text-brand-light" onclick="toggleSort('status')">Agent 状态 <span id="sort-status" class="text-[10px]"></span></th><th class="text-left px-4 py-3 cursor-pointer select-none hover:text-brand-light" onclick="toggleSort('name')">名称 <span id="sort-name" class="text-[10px]"></span></th><th class="text-left px-4 py-3 cursor-pointer select-none hover:text-brand-light" onclick="toggleSort('domain')">地址 <span id="sort-domain" class="text-[10px]"></span></th><th class="text-left px-4 py-3 cursor-pointer select-none hover:text-brand-light" onclick="toggleSort('category')">分类 <span id="sort-category" class="text-[10px]"></span></th><th class="text-left px-4 py-3 cursor-pointer select-none hover:text-brand-light" onclick="toggleSort('agent_version')">Agent <span id="sort-agent_version" class="text-[10px]"></span></th><th class="text-left px-4 py-3 cursor-pointer select-none hover:text-brand-light" onclick="toggleSort('heartbeat')">最后心跳 <span id="sort-heartbeat" class="text-[10px]"></span></th><th class="text-right px-4 py-3">操作</th></tr></thead><tbody id="serversTbody" class="divide-y divide-slate-800"><tr><td colspan="8" class="px-4 py-8 text-center text-[var(--text-muted)]">加载中...</td></tr></tbody></table>
|
||||
</div>
|
||||
<div id="pagination" class="mt-4 flex items-center justify-between text-xs text-slate-500">
|
||||
<div id="pagination" class="mt-4 flex items-center justify-between text-xs text-[var(--text-muted)]">
|
||||
<span id="serverCount">共 -- 台</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<button onclick="goToPage(1)" id="firstBtn" class="px-2 py-1 bg-slate-800 hover:bg-slate-700 text-white text-xs rounded-lg transition disabled:opacity-40">首页</button>
|
||||
<button onclick="goToPage(_currentPage-1)" id="prevBtn" class="px-3 py-1 bg-slate-800 hover:bg-slate-700 text-white text-xs rounded-lg transition disabled:opacity-40">←</button>
|
||||
<button onclick="goToPage(1)" id="firstBtn" class="px-2 py-1 bg-[var(--bg-surface)] hover:bg-slate-700 text-white text-xs rounded-lg transition disabled:opacity-40">首页</button>
|
||||
<button onclick="goToPage(_currentPage-1)" id="prevBtn" class="px-3 py-1 bg-[var(--bg-surface)] hover:bg-slate-700 text-white text-xs rounded-lg transition disabled:opacity-40">←</button>
|
||||
<span id="pageNumbers" class="flex gap-1"></span>
|
||||
<button onclick="goToPage(_currentPage+1)" id="nextBtn" class="px-3 py-1 bg-slate-800 hover:bg-slate-700 text-white text-xs rounded-lg transition disabled:opacity-40">→</button>
|
||||
<button onclick="goToPage(_totalPages)" id="lastBtn" class="px-2 py-1 bg-slate-800 hover:bg-slate-700 text-white text-xs rounded-lg transition disabled:opacity-40">末页</button>
|
||||
<select id="perPageSelect" onchange="changePerPage(this.value)" class="px-2 py-1 bg-slate-800 border border-slate-700 rounded-lg text-white text-xs">
|
||||
<button onclick="goToPage(_currentPage+1)" id="nextBtn" class="px-3 py-1 bg-[var(--bg-surface)] hover:bg-slate-700 text-white text-xs rounded-lg transition disabled:opacity-40">→</button>
|
||||
<button onclick="goToPage(_totalPages)" id="lastBtn" class="px-2 py-1 bg-[var(--bg-surface)] hover:bg-slate-700 text-white text-xs rounded-lg transition disabled:opacity-40">末页</button>
|
||||
<select id="perPageSelect" onchange="changePerPage(this.value)" class="px-2 py-1 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-xs">
|
||||
<option value="50">50条/页</option>
|
||||
<option value="100">100条/页</option>
|
||||
<option value="200">200条/页</option>
|
||||
@@ -47,7 +47,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<!-- Keyboard shortcut help -->
|
||||
<div id="shortcutHelp" class="hidden fixed bottom-4 right-4 bg-slate-800 border border-slate-700 rounded-xl p-4 text-xs text-slate-300 space-y-1 z-50">
|
||||
<div id="shortcutHelp" class="hidden fixed bottom-4 right-4 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-xl p-4 text-xs text-slate-300 space-y-1 z-50">
|
||||
<div class="text-white font-medium mb-2">快捷键</div>
|
||||
<div><kbd class="px-1.5 py-0.5 bg-slate-700 rounded text-[10px]">↑↓</kbd> / <kbd class="px-1.5 py-0.5 bg-slate-700 rounded text-[10px]">j k</kbd> 上下移动</div>
|
||||
<div><kbd class="px-1.5 py-0.5 bg-slate-700 rounded text-[10px]">Esc</kbd> 关闭面板/弹窗</div>
|
||||
@@ -58,7 +58,7 @@
|
||||
<div id="batchBar" class="hidden mt-3 bg-brand/10 border border-brand/30 rounded-xl px-5 py-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-brand-light text-sm font-medium">已选择 <span id="selectedCount">0</span> 台服务器</span>
|
||||
<button onclick="clearSelection()" class="text-slate-400 hover:text-white text-xs underline">取消选择</button>
|
||||
<button onclick="clearSelection()" class="text-[var(--text-secondary)] hover:text-white text-xs underline">取消选择</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button onclick="batchCheck()" class="px-4 py-1.5 bg-emerald-600/80 hover:bg-emerald-600 text-white text-sm rounded-lg transition">健康检查</button>
|
||||
@@ -72,156 +72,156 @@
|
||||
</div>
|
||||
|
||||
<!-- Server Detail Panel -->
|
||||
<div id="detailPanel" class="hidden mt-4 bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-slate-800 bg-slate-800/30">
|
||||
<div id="detailPanel" class="hidden mt-4 bg-[var(--bg-card)] rounded-xl border border-[var(--border)] overflow-hidden">
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-[var(--border)] bg-[var(--bg-surface)]/30">
|
||||
<div class="flex items-center gap-3">
|
||||
<span id="detailStatus" class="inline-block w-3 h-3 rounded-full"></span>
|
||||
<h2 id="detailName" class="text-white font-semibold text-lg"></h2>
|
||||
<span id="detailAddr" class="text-slate-400 text-sm"></span>
|
||||
<span id="detailAddr" class="text-[var(--text-secondary)] text-sm"></span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<a id="detailSSHLink" href="#" class="px-3 py-1.5 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">SSH终端</a>
|
||||
<button onclick="showEditServer(selectedServerId)" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-white text-sm rounded-lg transition">编辑</button>
|
||||
<button onclick="hideDetail()" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-300 text-sm rounded-lg transition">关闭</button>
|
||||
<button onclick="showEditServer(selectedServerId)" class="px-3 py-1.5 bg-[var(--bg-surface)] hover:bg-slate-700 text-white text-sm rounded-lg transition">编辑</button>
|
||||
<button onclick="hideDetail()" class="px-3 py-1.5 bg-[var(--bg-surface)] hover:bg-slate-700 text-slate-300 text-sm rounded-lg transition">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Tabs -->
|
||||
<div class="flex border-b border-slate-800 px-6">
|
||||
<div class="flex border-b border-[var(--border)] px-6">
|
||||
<button onclick="showDetailTab('info')" id="tabInfo" class="px-4 py-3 text-sm border-b-2 border-brand text-brand-light transition">系统信息</button>
|
||||
<button onclick="showDetailTab('sync')" id="tabSync" class="px-4 py-3 text-sm border-b-2 border-transparent text-slate-400 hover:text-white transition">同步日志</button>
|
||||
<button onclick="showDetailTab('ssh')" id="tabSSH" class="px-4 py-3 text-sm border-b-2 border-transparent text-slate-400 hover:text-white transition">SSH会话</button>
|
||||
<button onclick="showDetailTab('agent')" id="tabAgent" class="px-4 py-3 text-sm border-b-2 border-transparent text-slate-400 hover:text-white transition">Agent 安装</button>
|
||||
<button onclick="showDetailTab('sync')" id="tabSync" class="px-4 py-3 text-sm border-b-2 border-transparent text-[var(--text-secondary)] hover:text-white transition">同步日志</button>
|
||||
<button onclick="showDetailTab('ssh')" id="tabSSH" class="px-4 py-3 text-sm border-b-2 border-transparent text-[var(--text-secondary)] hover:text-white transition">SSH会话</button>
|
||||
<button onclick="showDetailTab('agent')" id="tabAgent" class="px-4 py-3 text-sm border-b-2 border-transparent text-[var(--text-secondary)] hover:text-white transition">Agent 安装</button>
|
||||
</div>
|
||||
<!-- Tab Content: System Info -->
|
||||
<div id="tabContentInfo" class="p-6">
|
||||
<div id="sysInfoGrid" class="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div class="bg-slate-800/50 rounded-lg p-4"><div class="text-slate-500 text-xs mb-1">CPU使用率</div><div id="siCPU" class="text-2xl font-bold text-white">--</div></div>
|
||||
<div class="bg-slate-800/50 rounded-lg p-4"><div class="text-slate-500 text-xs mb-1">内存使用率</div><div id="siMem" class="text-2xl font-bold text-white">--</div></div>
|
||||
<div class="bg-slate-800/50 rounded-lg p-4"><div class="text-slate-500 text-xs mb-1">磁盘使用率</div><div id="siDisk" class="text-2xl font-bold text-white">--</div></div>
|
||||
<div class="bg-slate-800/50 rounded-lg p-4"><div class="text-slate-500 text-xs mb-1">发行版</div><div id="siOSRelease" class="text-lg font-medium text-white truncate">--</div></div>
|
||||
<div class="bg-slate-800/50 rounded-lg p-4"><div class="text-slate-500 text-xs mb-1">内核平台</div><div id="siPlatform" class="text-sm font-medium text-slate-300 truncate">--</div></div>
|
||||
<div class="bg-[var(--bg-surface)]/50 rounded-lg p-4"><div class="text-[var(--text-muted)] text-xs mb-1">CPU使用率</div><div id="siCPU" class="text-2xl font-bold text-white">--</div></div>
|
||||
<div class="bg-[var(--bg-surface)]/50 rounded-lg p-4"><div class="text-[var(--text-muted)] text-xs mb-1">内存使用率</div><div id="siMem" class="text-2xl font-bold text-white">--</div></div>
|
||||
<div class="bg-[var(--bg-surface)]/50 rounded-lg p-4"><div class="text-[var(--text-muted)] text-xs mb-1">磁盘使用率</div><div id="siDisk" class="text-2xl font-bold text-white">--</div></div>
|
||||
<div class="bg-[var(--bg-surface)]/50 rounded-lg p-4"><div class="text-[var(--text-muted)] text-xs mb-1">发行版</div><div id="siOSRelease" class="text-lg font-medium text-white truncate">--</div></div>
|
||||
<div class="bg-[var(--bg-surface)]/50 rounded-lg p-4"><div class="text-[var(--text-muted)] text-xs mb-1">内核平台</div><div id="siPlatform" class="text-sm font-medium text-slate-300 truncate">--</div></div>
|
||||
</div>
|
||||
<div id="sysInfoMeta" class="mt-4 flex flex-wrap gap-x-6 gap-y-2 text-sm">
|
||||
<div id="siDescRow"><span class="text-slate-500">描述:</span><span id="siDesc" class="text-slate-300"></span></div>
|
||||
<div id="siTargetRow"><span class="text-slate-500">目标路径:</span><code id="siTarget" class="text-slate-300 text-xs"></code></div>
|
||||
<div id="siAuthRow"><span class="text-slate-500">认证方式:</span><span id="siAuth" class="text-slate-300"></span></div>
|
||||
<div id="siDescRow"><span class="text-[var(--text-muted)]">描述:</span><span id="siDesc" class="text-slate-300"></span></div>
|
||||
<div id="siTargetRow"><span class="text-[var(--text-muted)]">目标路径:</span><code id="siTarget" class="text-slate-300 text-xs"></code></div>
|
||||
<div id="siAuthRow"><span class="text-[var(--text-muted)]">认证方式:</span><span id="siAuth" class="text-slate-300"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Tab Content: Sync Logs -->
|
||||
<div id="tabContentSync" class="hidden p-6">
|
||||
<table class="w-full text-sm"><thead class="text-slate-500 text-xs uppercase"><tr><th class="text-left py-2">模式</th><th class="text-left py-2">状态</th><th class="text-left py-2">源路径</th><th class="text-left py-2">耗时</th><th class="text-left py-2">时间</th></tr></thead><tbody id="syncLogTbody"><tr><td colspan="5" class="py-4 text-center text-slate-500">加载中...</td></tr></tbody></table>
|
||||
<table class="w-full text-sm"><thead class="text-[var(--text-muted)] text-xs uppercase"><tr><th class="text-left py-2">模式</th><th class="text-left py-2">状态</th><th class="text-left py-2">源路径</th><th class="text-left py-2">耗时</th><th class="text-left py-2">时间</th></tr></thead><tbody id="syncLogTbody"><tr><td colspan="5" class="py-4 text-center text-[var(--text-muted)]">加载中...</td></tr></tbody></table>
|
||||
</div>
|
||||
<!-- Tab Content: SSH Sessions -->
|
||||
<div id="tabContentSSH" class="hidden p-6">
|
||||
<table class="w-full text-sm"><thead class="text-slate-500 text-xs uppercase"><tr><th class="text-left py-2">来源IP</th><th class="text-left py-2">状态</th><th class="text-left py-2">开始</th><th class="text-left py-2">结束</th></tr></thead><tbody id="sshSessTbody"><tr><td colspan="4" class="py-4 text-center text-slate-500">加载中...</td></tr></tbody></table>
|
||||
<table class="w-full text-sm"><thead class="text-[var(--text-muted)] text-xs uppercase"><tr><th class="text-left py-2">来源IP</th><th class="text-left py-2">状态</th><th class="text-left py-2">开始</th><th class="text-left py-2">结束</th></tr></thead><tbody id="sshSessTbody"><tr><td colspan="4" class="py-4 text-center text-[var(--text-muted)]">加载中...</td></tr></tbody></table>
|
||||
</div>
|
||||
<!-- Tab Content: Agent Install -->
|
||||
<div id="tabContentAgent" class="hidden p-6 space-y-4">
|
||||
<div id="agentInstallContent"><div class="text-slate-500 text-center py-4 text-sm">加载中...</div></div>
|
||||
<div id="agentInstallContent"><div class="text-[var(--text-muted)] text-center py-4 text-sm">加载中...</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit Server Modal -->
|
||||
<div id="serverModal" class="hidden fixed inset-0 bg-black/60 flex items-center justify-center z-50 overflow-y-auto py-8">
|
||||
<div class="bg-slate-900 border border-slate-700 rounded-xl p-6 w-full max-w-lg space-y-3 my-auto">
|
||||
<div class="bg-[var(--bg-card)] border border-[var(--border-input)] rounded-xl p-6 w-full max-w-lg space-y-3 my-auto">
|
||||
<h2 id="modalTitle" class="text-white font-semibold text-lg">添加服务器</h2>
|
||||
<input type="hidden" id="editServerId">
|
||||
<!-- Basic -->
|
||||
<div class="text-slate-500 text-xs uppercase tracking-wider mb-1">基本</div>
|
||||
<div class="text-[var(--text-muted)] text-xs uppercase tracking-wider mb-1">基本</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div><label class="block text-slate-400 text-xs mb-1">名称 *</label><input id="srvName" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="web-server-01"></div>
|
||||
<div><label class="block text-slate-400 text-xs mb-1">地址 *</label><input id="srvDomain" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="192.168.1.10"></div>
|
||||
<div><label class="block text-[var(--text-secondary)] text-xs mb-1">名称 *</label><input id="srvName" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm" placeholder="web-server-01"></div>
|
||||
<div><label class="block text-[var(--text-secondary)] text-xs mb-1">地址 *</label><input id="srvDomain" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm" placeholder="192.168.1.10"></div>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div><label class="block text-slate-400 text-xs mb-1">SSH端口</label><input id="srvPort" type="number" value="22" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm"></div>
|
||||
<div><label class="block text-slate-400 text-xs mb-1">用户名</label><input id="srvUser" value="root" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm"></div>
|
||||
<div><label class="block text-slate-400 text-xs mb-1">认证方式</label><select id="srvAuth" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm"><option value="password">密码</option><option value="key">密钥</option></select></div>
|
||||
<div><label class="block text-[var(--text-secondary)] text-xs mb-1">SSH端口</label><input id="srvPort" type="number" value="22" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm"></div>
|
||||
<div><label class="block text-[var(--text-secondary)] text-xs mb-1">用户名</label><input id="srvUser" value="root" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm"></div>
|
||||
<div><label class="block text-[var(--text-secondary)] text-xs mb-1">认证方式</label><select id="srvAuth" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm"><option value="password">密码</option><option value="key">密钥</option></select></div>
|
||||
</div>
|
||||
<!-- Password auth section -->
|
||||
<div id="srvPasswordSection">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div><label class="block text-slate-400 text-xs mb-1">密码预设</label><select id="srvPreset" 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 id="srvPasswordRow"><label class="block text-slate-400 text-xs mb-1">密码</label><input id="srvPassword" type="password" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="SSH密码"></div>
|
||||
<div><label class="block text-[var(--text-secondary)] text-xs mb-1">密码预设</label><select id="srvPreset" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm"><option value="">— 手动输入 —</option></select></div>
|
||||
<div id="srvPasswordRow"><label class="block text-[var(--text-secondary)] text-xs mb-1">密码</label><input id="srvPassword" type="password" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm" placeholder="SSH密码"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Key auth section -->
|
||||
<div id="srvKeySection" class="hidden space-y-3">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div><label class="block text-slate-400 text-xs mb-1">密钥预设</label><select id="srvKeyPreset" 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 id="srvKeyPathRow"><label class="block text-slate-400 text-xs mb-1">密钥路径</label><input id="srvKeyPath" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="/root/.ssh/id_rsa"></div>
|
||||
<div><label class="block text-[var(--text-secondary)] text-xs mb-1">密钥预设</label><select id="srvKeyPreset" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm"><option value="">— 手动输入 —</option></select></div>
|
||||
<div id="srvKeyPathRow"><label class="block text-[var(--text-secondary)] text-xs mb-1">密钥路径</label><input id="srvKeyPath" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm" placeholder="/root/.ssh/id_rsa"></div>
|
||||
</div>
|
||||
<div id="srvKeyPrivateRow"><label class="block text-slate-400 text-xs mb-1">私钥内容</label><textarea id="srvKeyPrivate" rows="4" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm font-mono" placeholder="粘贴 PEM 格式私钥内容(可选,优先于路径)"></textarea><p class="text-slate-600 text-[10px] mt-1">填入私钥内容后无需指定路径,系统自动加密存储</p></div>
|
||||
<div id="srvKeyPrivateRow"><label class="block text-[var(--text-secondary)] text-xs mb-1">私钥内容</label><textarea id="srvKeyPrivate" rows="4" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm font-mono" placeholder="粘贴 PEM 格式私钥内容(可选,优先于路径)"></textarea><p class="text-[var(--text-dim)] text-[10px] mt-1">填入私钥内容后无需指定路径,系统自动加密存储</p></div>
|
||||
</div>
|
||||
<!-- Agent -->
|
||||
<div class="text-slate-500 text-xs uppercase tracking-wider mt-3 mb-1">Agent</div>
|
||||
<div class="text-[var(--text-muted)] text-xs uppercase tracking-wider mt-3 mb-1">Agent</div>
|
||||
<div class="grid grid-cols-1 gap-3">
|
||||
<input id="srvAgentPort" type="hidden" value="8601">
|
||||
<div>
|
||||
<label class="block text-slate-400 text-xs mb-1">Agent API Key</label>
|
||||
<label class="block text-[var(--text-secondary)] text-xs mb-1">Agent API Key</label>
|
||||
<!-- Add mode: auto-generate notice -->
|
||||
<div id="agentKeyAddMode" class="px-3 py-2 bg-slate-800/50 border border-slate-700 rounded-lg text-slate-500 text-xs">创建时自动生成</div>
|
||||
<div id="agentKeyAddMode" class="px-3 py-2 bg-[var(--bg-surface)]/50 border border-[var(--border-input)] rounded-lg text-[var(--text-muted)] text-xs">创建时自动生成</div>
|
||||
<!-- Edit mode: existing key info + regenerate button -->
|
||||
<div id="agentKeyEditMode" class="hidden">
|
||||
<div class="flex items-center gap-2">
|
||||
<input id="srvAgentKey" class="flex-1 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm text-xs" placeholder="留空则使用全局 API Key" readonly>
|
||||
<input id="srvAgentKey" class="flex-1 px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm text-xs" placeholder="留空则使用全局 API Key" readonly>
|
||||
<button type="button" onclick="generateAgentKey()" class="px-2 py-2 bg-slate-700 hover:bg-slate-600 text-white text-xs rounded-lg shrink-0 transition" title="重新生成密钥">🔄</button>
|
||||
</div>
|
||||
<p class="text-slate-600 text-[10px] mt-1">点击🔄重新生成(旧密钥将失效)</p>
|
||||
<p class="text-[var(--text-dim)] text-[10px] mt-1">点击🔄重新生成(旧密钥将失效)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Organization -->
|
||||
<div class="text-slate-500 text-xs uppercase tracking-wider mt-3 mb-1">组织</div>
|
||||
<div class="text-[var(--text-muted)] text-xs uppercase tracking-wider mt-3 mb-1">组织</div>
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label class="block text-slate-400 text-xs mb-1">分类</label>
|
||||
<select id="srvCategorySelect" onchange="_onCategoryChange()" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
|
||||
<label class="block text-[var(--text-secondary)] text-xs mb-1">分类</label>
|
||||
<select id="srvCategorySelect" onchange="_onCategoryChange()" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm">
|
||||
<option value="">— 无分类 —</option>
|
||||
</select>
|
||||
<input id="srvCategory" class="hidden w-full mt-1 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="输入自定义分类">
|
||||
<input id="srvCategory" class="hidden w-full mt-1 px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm" placeholder="输入自定义分类">
|
||||
</div>
|
||||
<div><label class="block text-slate-400 text-xs mb-1">目标路径</label><input id="srvTarget" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="/www/wwwroot"></div>
|
||||
<div><label class="block text-slate-400 text-xs mb-1">备注</label><input id="srvDesc" 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-[var(--text-secondary)] text-xs mb-1">目标路径</label><input id="srvTarget" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm" placeholder="/www/wwwroot"></div>
|
||||
<div><label class="block text-[var(--text-secondary)] text-xs mb-1">备注</label><input id="srvDesc" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm" placeholder="可选"></div>
|
||||
</div>
|
||||
<div class="flex gap-2 pt-2"><button onclick="saveServer()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm">保存</button><button onclick="hideServerModal()" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm">取消</button></div>
|
||||
<div class="flex gap-2 pt-2"><button onclick="saveServer()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm">保存</button><button onclick="hideServerModal()" class="px-4 py-2 bg-[var(--bg-surface)] hover:bg-slate-700 text-slate-300 rounded-lg text-sm">取消</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CSV Import Modal -->
|
||||
<div id="importModal" 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-lg space-y-3">
|
||||
<div class="bg-[var(--bg-card)] border border-[var(--border-input)] rounded-xl p-6 w-full max-w-lg space-y-3">
|
||||
<h2 class="text-white font-semibold text-lg">批量导入服务器</h2>
|
||||
<div class="bg-slate-800/50 rounded-lg p-3 text-xs text-slate-400 space-y-1">
|
||||
<div class="bg-[var(--bg-surface)]/50 rounded-lg p-3 text-xs text-[var(--text-secondary)] space-y-1">
|
||||
<p>1. <a href="/app/servers_import_template.csv" download="servers_import_template.csv" class="text-brand-light underline">下载 CSV 模板</a></p>
|
||||
<p>2. 按模板格式填写(<span class="text-slate-200">名称</span>和<span class="text-slate-200">地址</span>必填,其余选填)</p>
|
||||
<p>3. 认证方式填 <span class="text-slate-200">password</span> 或 <span class="text-slate-200">key</span>,默认 password</p>
|
||||
<p>4. 上传 CSV 文件,最多 500 行</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-slate-400 text-xs mb-1">选择 CSV 文件</label>
|
||||
<label class="block text-[var(--text-secondary)] text-xs mb-1">选择 CSV 文件</label>
|
||||
<input id="importFile" type="file" accept=".csv" class="w-full text-sm text-slate-300 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-brand file:text-white hover:file:bg-brand-dark">
|
||||
</div>
|
||||
<div id="importProgress" class="hidden"><div class="bg-slate-800 rounded-full h-2 overflow-hidden"><div id="importProgressBar" class="bg-brand h-full transition-all" style="width:0%"></div></div><p id="importStatus" class="text-slate-400 text-xs mt-1 text-center">上传中...</p></div>
|
||||
<div id="importProgress" class="hidden"><div class="bg-[var(--bg-surface)] rounded-full h-2 overflow-hidden"><div id="importProgressBar" class="bg-brand h-full transition-all" style="width:0%"></div></div><p id="importStatus" class="text-[var(--text-secondary)] text-xs mt-1 text-center">上传中...</p></div>
|
||||
<div id="importResult" class="hidden space-y-2">
|
||||
<div class="grid grid-cols-3 gap-2 text-center">
|
||||
<div class="bg-emerald-500/10 border border-emerald-500/30 rounded-lg p-3"><div id="importCreated" class="text-2xl font-bold text-emerald-400">0</div><div class="text-xs text-emerald-400/70">成功</div></div>
|
||||
<div class="bg-amber-500/10 border border-amber-500/30 rounded-lg p-3"><div id="importSkipped" class="text-2xl font-bold text-amber-400">0</div><div class="text-xs text-amber-400/70">跳过</div></div>
|
||||
<div class="bg-red-500/10 border border-red-500/30 rounded-lg p-3"><div id="importFailed" class="text-2xl font-bold text-red-400">0</div><div class="text-xs text-red-400/70">失败</div></div>
|
||||
</div>
|
||||
<div id="importErrors" class="hidden max-h-40 overflow-y-auto bg-slate-800 rounded-lg p-3 text-xs text-red-400 space-y-1"></div>
|
||||
<div id="importErrors" class="hidden max-h-40 overflow-y-auto bg-[var(--bg-surface)] rounded-lg p-3 text-xs text-red-400 space-y-1"></div>
|
||||
</div>
|
||||
<div class="flex gap-2 pt-2">
|
||||
<button id="importBtn" onclick="doImport()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm">开始导入</button>
|
||||
<button onclick="hideImportModal()" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm">关闭</button>
|
||||
<button onclick="hideImportModal()" class="px-4 py-2 bg-[var(--bg-surface)] hover:bg-slate-700 text-slate-300 rounded-lg text-sm">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Batch Agent Result Modal -->
|
||||
<div id="batchAgentModal" 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-lg space-y-3">
|
||||
<div class="bg-[var(--bg-card)] border border-[var(--border-input)] rounded-xl p-6 w-full max-w-lg space-y-3">
|
||||
<h2 id="batchAgentTitle" class="text-white font-semibold text-lg">批量操作结果</h2>
|
||||
<div id="batchAgentProgress" class="hidden text-center py-4"><div class="animate-spin inline-block w-8 h-8 border-4 border-brand border-t-transparent rounded-full"></div><p class="text-slate-400 text-sm mt-2">执行中,请等待...</p></div>
|
||||
<div id="batchAgentProgress" class="hidden text-center py-4"><div class="animate-spin inline-block w-8 h-8 border-4 border-brand border-t-transparent rounded-full"></div><p class="text-[var(--text-secondary)] text-sm mt-2">执行中,请等待...</p></div>
|
||||
<div id="batchAgentResult" class="hidden space-y-2">
|
||||
<div class="grid grid-cols-2 gap-2 text-center">
|
||||
<div class="bg-emerald-500/10 border border-emerald-500/30 rounded-lg p-3"><div id="batchAgentOk" class="text-2xl font-bold text-emerald-400">0</div><div class="text-xs text-emerald-400/70">成功</div></div>
|
||||
@@ -229,7 +229,7 @@
|
||||
</div>
|
||||
<div id="batchAgentDetails" class="max-h-60 overflow-y-auto space-y-1"></div>
|
||||
</div>
|
||||
<div class="flex gap-2 pt-2"><button onclick="hideBatchAgentModal()" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm">关闭</button></div>
|
||||
<div class="flex gap-2 pt-2"><button onclick="hideBatchAgentModal()" class="px-4 py-2 bg-[var(--bg-surface)] hover:bg-slate-700 text-slate-300 rounded-lg text-sm">关闭</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -332,14 +332,14 @@
|
||||
const statusHtml=renderStatusBadge(s);
|
||||
const driftHtml=driftBadge(s);
|
||||
const actionsHtml=renderActions(s);
|
||||
return `<tr class="hover:bg-slate-800/30 transition cursor-pointer ${selectedServerId===s.id?'bg-brand/5':''}" onclick="selectServer(${s.id})" id="row-${s.id}">
|
||||
return `<tr class="hover:bg-[var(--bg-surface)]/30 transition cursor-pointer ${selectedServerId===s.id?'bg-brand/5':''}" onclick="selectServer(${s.id})" id="row-${s.id}">
|
||||
<td class="px-4 py-3" onclick="event.stopPropagation()"><input type="checkbox" class="rounded border-slate-600 bg-slate-700 text-brand focus:ring-brand srv-chk" data-id="${s.id}" onchange="toggleSelect(${s.id},this.checked)" ${_selectedIds.has(s.id)?'checked':''}></td>
|
||||
<td class="px-4 py-3">${statusHtml}</td>
|
||||
<td class="px-4 py-3 text-white font-medium">${esc(s.name)}${driftHtml}</td>
|
||||
<td class="px-4 py-3 text-slate-400">${esc(s.domain)}:${s.port||22}</td>
|
||||
<td class="px-4 py-3 text-slate-400">${esc(s.category||'--')}</td>
|
||||
<td class="px-4 py-3 text-slate-400">${esc(s.agent_version)||'--'}</td>
|
||||
<td class="px-4 py-3 text-slate-500 text-xs">${fmtTime(s.last_heartbeat)}</td>
|
||||
<td class="px-4 py-3 text-[var(--text-secondary)]">${esc(s.domain)}:${s.port||22}</td>
|
||||
<td class="px-4 py-3 text-[var(--text-secondary)]">${esc(s.category||'--')}</td>
|
||||
<td class="px-4 py-3 text-[var(--text-secondary)]">${esc(s.agent_version)||'--'}</td>
|
||||
<td class="px-4 py-3 text-[var(--text-muted)] text-xs">${fmtTime(s.last_heartbeat)}</td>
|
||||
<td class="px-4 py-3 text-right">${actionsHtml}</td>
|
||||
</tr>`;
|
||||
}
|
||||
@@ -354,14 +354,14 @@
|
||||
if(s.agent_version&&!s.is_online){
|
||||
return `<span class="inline-flex items-center gap-1.5 text-xs"><span class="inline-block w-2 h-2 rounded-full bg-red-400 animate-pulse"></span><span class="text-red-400">离线</span></span>`;
|
||||
}
|
||||
return `<span class="inline-flex items-center gap-1.5 text-xs"><span class="inline-block w-2 h-2 rounded-full bg-slate-600"></span><span class="text-slate-500">未安装</span></span>`;
|
||||
return `<span class="inline-flex items-center gap-1.5 text-xs"><span class="inline-block w-2 h-2 rounded-full bg-slate-600"></span><span class="text-[var(--text-muted)]">未安装</span></span>`;
|
||||
}
|
||||
|
||||
function renderActions(s){
|
||||
let html=`<a href="/app/terminal.html?server_id=${s.id}" onclick="event.stopPropagation()" class="text-brand-light hover:underline text-xs mr-2">终端</a>`;
|
||||
html+=`<a href="/app/files.html?server_id=${s.id}" onclick="event.stopPropagation()" class="text-amber-400 hover:underline text-xs mr-2">文件</a>`;
|
||||
if(s.agent_version) html+=`<button onclick="event.stopPropagation();_quickCheck(${s.id})" class="text-emerald-400 hover:underline text-xs mr-2">检查</button>`;
|
||||
html+=`<button onclick="event.stopPropagation();showEditServer(${s.id})" class="text-slate-400 hover:underline text-xs mr-2">编辑</button>`;
|
||||
html+=`<button onclick="event.stopPropagation();showEditServer(${s.id})" class="text-[var(--text-secondary)] hover:underline text-xs mr-2">编辑</button>`;
|
||||
html+=`<button onclick="event.stopPropagation();deleteServer(${s.id})" class="text-red-400 hover:underline text-xs">删除</button>`;
|
||||
return html;
|
||||
}
|
||||
@@ -374,9 +374,9 @@
|
||||
function renderEmptyState(){
|
||||
const hasFilters=document.getElementById('searchInput').value||document.getElementById('categoryFilter').value||_statusFilter;
|
||||
if(hasFilters){
|
||||
return `<tr><td colspan="8" class="px-4 py-16 text-center"><div class="text-slate-500 text-4xl mb-3">🔍</div><div class="text-slate-400 mb-1">没有匹配的服务器</div><div class="text-slate-600 text-xs">尝试调整搜索条件或筛选项</div></td></tr>`;
|
||||
return `<tr><td colspan="8" class="px-4 py-16 text-center"><div class="text-[var(--text-muted)] text-4xl mb-3">🔍</div><div class="text-[var(--text-secondary)] mb-1">没有匹配的服务器</div><div class="text-[var(--text-dim)] text-xs">尝试调整搜索条件或筛选项</div></td></tr>`;
|
||||
}
|
||||
return `<tr><td colspan="8" class="px-4 py-16 text-center"><div class="text-slate-500 text-4xl mb-3">🖥</div><div class="text-slate-400 mb-1">暂无服务器</div><div class="text-slate-600 text-xs">点击右上角「+ 添加」或「↓ 导入」开始</div></td></tr>`;
|
||||
return `<tr><td colspan="8" class="px-4 py-16 text-center"><div class="text-[var(--text-muted)] text-4xl mb-3">🖥</div><div class="text-[var(--text-secondary)] mb-1">暂无服务器</div><div class="text-[var(--text-dim)] text-xs">点击右上角「+ 添加」或「↓ 导入」开始</div></td></tr>`;
|
||||
}
|
||||
|
||||
// ── Render: Pagination ──
|
||||
@@ -404,7 +404,7 @@
|
||||
nums.innerHTML='';
|
||||
for(let i=start;i<=end;i++){
|
||||
const active=i===_currentPage;
|
||||
nums.innerHTML+=`<button onclick="goToPage(${i})" class="px-2.5 py-1 text-xs rounded-lg transition ${active?'bg-brand text-white':'bg-slate-800 hover:bg-slate-700 text-slate-300'}">${i}</button>`;
|
||||
nums.innerHTML+=`<button onclick="goToPage(${i})" class="px-2.5 py-1 text-xs rounded-lg transition ${active?'bg-brand text-white':'bg-[var(--bg-surface)] hover:bg-slate-700 text-slate-300'}">${i}</button>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -472,7 +472,7 @@
|
||||
btn.className='px-3 py-1.5 bg-brand/80 hover:bg-brand text-white text-sm rounded-lg transition';
|
||||
btn.textContent='自动刷新中';
|
||||
}else{
|
||||
btn.className='px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-400 text-sm rounded-lg transition';
|
||||
btn.className='px-3 py-1.5 bg-[var(--bg-surface)] hover:bg-slate-700 text-[var(--text-secondary)] text-sm rounded-lg transition';
|
||||
btn.textContent='自动刷新';
|
||||
}
|
||||
}
|
||||
@@ -604,10 +604,10 @@
|
||||
const r=await apiFetch(API+'/servers/'+selectedServerId+'/logs?limit=20');
|
||||
if(!r)return;const logs=await r.json();
|
||||
const tbody=document.getElementById('syncLogTbody');
|
||||
if(!logs.length){tbody.innerHTML='<tr><td colspan="5" class="py-4 text-center text-slate-500">暂无同步记录</td></tr>';return}
|
||||
if(!logs.length){tbody.innerHTML='<tr><td colspan="5" class="py-4 text-center text-[var(--text-muted)]">暂无同步记录</td></tr>';return}
|
||||
tbody.innerHTML=logs.map(l=>{
|
||||
const sc=l.status==='success'?'text-green-400':l.status==='failed'?'text-red-400':'text-yellow-400';
|
||||
return `<tr class="border-b border-slate-800/50"><td class="py-2 text-slate-300">${esc(l.sync_mode||'--')}</td><td class="py-2 ${sc}">${esc(l.status)}</td><td class="py-2 text-slate-500 text-xs max-w-xs truncate">${esc(l.source_path||'--')}</td><td class="py-2 text-slate-500">${l.duration_seconds!=null?l.duration_seconds+'s':'--'}</td><td class="py-2 text-slate-600 text-xs">${fmtTime(l.started_at)}</td></tr>`}).join('');
|
||||
return `<tr class="border-b border-[var(--border)]/50"><td class="py-2 text-slate-300">${esc(l.sync_mode||'--')}</td><td class="py-2 ${sc}">${esc(l.status)}</td><td class="py-2 text-[var(--text-muted)] text-xs max-w-xs truncate">${esc(l.source_path||'--')}</td><td class="py-2 text-[var(--text-muted)]">${l.duration_seconds!=null?l.duration_seconds+'s':'--'}</td><td class="py-2 text-[var(--text-dim)] text-xs">${fmtTime(l.started_at)}</td></tr>`}).join('');
|
||||
}catch(e){document.getElementById('syncLogTbody').innerHTML='<tr><td colspan="5" class="py-4 text-center text-red-400">加载失败</td></tr>'}
|
||||
}
|
||||
|
||||
@@ -617,10 +617,10 @@
|
||||
const r=await apiFetch(API+'/assets/ssh-sessions?server_id='+selectedServerId+'&limit=20');
|
||||
if(!r)return;const sessions=await r.json();
|
||||
const tbody=document.getElementById('sshSessTbody');
|
||||
if(!sessions.length){tbody.innerHTML='<tr><td colspan="4" class="py-4 text-center text-slate-500">暂无SSH会话</td></tr>';return}
|
||||
if(!sessions.length){tbody.innerHTML='<tr><td colspan="4" class="py-4 text-center text-[var(--text-muted)]">暂无SSH会话</td></tr>';return}
|
||||
tbody.innerHTML=sessions.map(s=>{
|
||||
const sc=s.status==='active'?'text-green-400':s.status==='closed'?'text-slate-500':'text-yellow-400';
|
||||
return `<tr class="border-b border-slate-800/50"><td class="py-2 text-slate-300">${esc(s.remote_addr||'--')}</td><td class="py-2 ${sc}">${esc(s.status)}</td><td class="py-2 text-slate-500 text-xs">${fmtTime(s.started_at)}</td><td class="py-2 text-slate-600 text-xs">${fmtTime(s.closed_at)}</td></tr>`}).join('');
|
||||
const sc=s.status==='active'?'text-green-400':s.status==='closed'?'text-[var(--text-muted)]':'text-yellow-400';
|
||||
return `<tr class="border-b border-[var(--border)]/50"><td class="py-2 text-slate-300">${esc(s.remote_addr||'--')}</td><td class="py-2 ${sc}">${esc(s.status)}</td><td class="py-2 text-[var(--text-muted)] text-xs">${fmtTime(s.started_at)}</td><td class="py-2 text-[var(--text-dim)] text-xs">${fmtTime(s.closed_at)}</td></tr>`}).join('');
|
||||
}catch(e){document.getElementById('sshSessTbody').innerHTML='<tr><td colspan="4" class="py-4 text-center text-red-400">加载失败</td></tr>'}
|
||||
}
|
||||
|
||||
@@ -631,7 +631,7 @@
|
||||
const btn=document.getElementById('tab'+t);
|
||||
if(!btn)return;
|
||||
if(t.toLowerCase()===tab){btn.className='px-4 py-3 text-sm border-b-2 border-brand text-brand-light transition'}
|
||||
else{btn.className='px-4 py-3 text-sm border-b-2 border-transparent text-slate-400 hover:text-white transition'}
|
||||
else{btn.className='px-4 py-3 text-sm border-b-2 border-transparent text-[var(--text-secondary)] hover:text-white transition'}
|
||||
});
|
||||
if(tab==='sync')loadSyncLogs();
|
||||
if(tab==='ssh')loadSSHSessions();
|
||||
@@ -645,7 +645,7 @@
|
||||
const s=_serversCache[selectedServerId]||{};
|
||||
const statusBadge=s.is_online
|
||||
?'<span class="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-green-900/40 text-green-400 border border-green-500/30">● Agent 在线</span>'
|
||||
:'<span class="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-slate-800 text-slate-500 border border-slate-700">○ Agent 离线 / 未安装</span>';
|
||||
:'<span class="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-[var(--bg-surface)] text-[var(--text-muted)] border border-[var(--border-input)]">○ Agent 离线 / 未安装</span>';
|
||||
const base_url_conf=!!(_apiBaseUrl);
|
||||
const noKey=!s.agent_api_key_set;
|
||||
let warnings='';
|
||||
@@ -653,16 +653,16 @@
|
||||
if(noKey)warnings+=`<div class="text-amber-400 text-xs">⚠ 尚未生成 Agent API Key,请点击「编辑」→「🔄 重新生成」后再安装</div>`;
|
||||
const canShowCmd=base_url_conf&&!noKey;
|
||||
const cmdBlock=_cachedInstallCmd
|
||||
?`<div class="space-y-2"><label class="block text-slate-400 text-xs">在子机上执行(需 root 权限)</label><div class="flex items-start gap-2"><code id="agentCmdText" class="flex-1 block px-3 py-3 bg-slate-950 border border-slate-700 rounded-lg text-green-400 text-xs font-mono break-all leading-relaxed">${esc(_cachedInstallCmd)}</code><button onclick="copyAgentCmd()" class="shrink-0 px-3 py-3 bg-slate-800 hover:bg-slate-700 text-white text-xs rounded-lg transition" title="复制命令">📋</button></div><p class="text-slate-600 text-xs">脚本将自动安装 Agent;心跳走 HTTPS 出站,无需公网放行 ${esc(String(s.agent_port||8601))}</p></div>`
|
||||
?`<div class="space-y-2"><label class="block text-[var(--text-secondary)] text-xs">在子机上执行(需 root 权限)</label><div class="flex items-start gap-2"><code id="agentCmdText" class="flex-1 block px-3 py-3 bg-[var(--bg-page)] border border-[var(--border-input)] rounded-lg text-green-400 text-xs font-mono break-all leading-relaxed">${esc(_cachedInstallCmd)}</code><button onclick="copyAgentCmd()" class="shrink-0 px-3 py-3 bg-[var(--bg-surface)] hover:bg-slate-700 text-white text-xs rounded-lg transition" title="复制命令">📋</button></div><p class="text-[var(--text-dim)] text-xs">脚本将自动安装 Agent;心跳走 HTTPS 出站,无需公网放行 ${esc(String(s.agent_port||8601))}</p></div>`
|
||||
:canShowCmd
|
||||
?`<button onclick="revealInstallCmd()" class="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white text-sm rounded-lg transition">🔐 查看安装命令(需验证密码)</button>`
|
||||
:`<div class="text-slate-500 text-sm">(请先配置 URL 和 API Key)</div>`;
|
||||
:`<div class="text-[var(--text-muted)] text-sm">(请先配置 URL 和 API Key)</div>`;
|
||||
const remoteBtn=canShowCmd&&!s.is_online
|
||||
?`<button onclick="remoteInstallAgent()" id="remoteInstallBtn" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">⚡ 一键远程安装(通过 SSH)</button>`
|
||||
:(s.is_online
|
||||
?`<button disabled class="px-4 py-2 bg-slate-800 text-slate-500 text-sm rounded-lg cursor-not-allowed">⚡ 远程安装(已在线)</button>`
|
||||
:`<button disabled class="px-4 py-2 bg-slate-800 text-slate-500 text-sm rounded-lg cursor-not-allowed">⚡ 远程安装</button>`);
|
||||
el.innerHTML=`<div class="flex items-center justify-between"><h3 class="text-white font-medium">Nexus Agent 安装</h3>${statusBadge}</div>${warnings}<div class="rounded-xl border border-slate-700 bg-slate-800/30 p-4 space-y-4">${cmdBlock}</div><div class="flex items-center gap-3">${remoteBtn}${s.is_online?`<button onclick="upgradeAgent()" id="upgradeAgentBtn" class="px-3 py-2 bg-slate-700 hover:bg-slate-600 text-white text-sm rounded-lg transition">⬆ 升级 Agent</button><button onclick="uninstallAgent()" id="uninstallAgentBtn" class="px-3 py-2 bg-red-900/60 hover:bg-red-800 text-white text-sm rounded-lg transition">🗑 卸载 Agent</button>`:''}<span class="text-slate-500 text-xs">安装约 30-60 秒;首次心跳约 60 秒后自动更新状态</span></div><div id="remoteInstallLog" class="hidden rounded-lg border border-slate-700 bg-slate-950 p-3 max-h-56 overflow-y-auto"><pre id="remoteInstallLogText" class="text-xs text-slate-400 whitespace-pre-wrap"></pre></div>${!s.is_online?`<div class="flex items-center gap-2 text-xs text-slate-500"><span>手动安装后,</span><button onclick="_startAgentOnlinePoller(${selectedServerId});this.textContent='等待中...';this.disabled=true" class="text-brand-light hover:underline">点击等待 Agent 上线</button><span>(最多 2 分钟)</span></div>`:''}`;
|
||||
?`<button disabled class="px-4 py-2 bg-[var(--bg-surface)] text-[var(--text-muted)] text-sm rounded-lg cursor-not-allowed">⚡ 远程安装(已在线)</button>`
|
||||
:`<button disabled class="px-4 py-2 bg-[var(--bg-surface)] text-[var(--text-muted)] text-sm rounded-lg cursor-not-allowed">⚡ 远程安装</button>`);
|
||||
el.innerHTML=`<div class="flex items-center justify-between"><h3 class="text-white font-medium">Nexus Agent 安装</h3>${statusBadge}</div>${warnings}<div class="rounded-xl border border-[var(--border-input)] bg-[var(--bg-surface)]/30 p-4 space-y-4">${cmdBlock}</div><div class="flex items-center gap-3">${remoteBtn}${s.is_online?`<button onclick="upgradeAgent()" id="upgradeAgentBtn" class="px-3 py-2 bg-slate-700 hover:bg-slate-600 text-white text-sm rounded-lg transition">⬆ 升级 Agent</button><button onclick="uninstallAgent()" id="uninstallAgentBtn" class="px-3 py-2 bg-red-900/60 hover:bg-red-800 text-white text-sm rounded-lg transition">🗑 卸载 Agent</button>`:''}<span class="text-[var(--text-muted)] text-xs">安装约 30-60 秒;首次心跳约 60 秒后自动更新状态</span></div><div id="remoteInstallLog" class="hidden rounded-lg border border-[var(--border-input)] bg-[var(--bg-page)] p-3 max-h-56 overflow-y-auto"><pre id="remoteInstallLogText" class="text-xs text-[var(--text-secondary)] whitespace-pre-wrap"></pre></div>${!s.is_online?`<div class="flex items-center gap-2 text-xs text-[var(--text-muted)]"><span>手动安装后,</span><button onclick="_startAgentOnlinePoller(${selectedServerId});this.textContent='等待中...';this.disabled=true" class="text-brand-light hover:underline">点击等待 Agent 上线</button><span>(最多 2 分钟)</span></div>`:''}`;
|
||||
}
|
||||
|
||||
async function revealInstallCmd(){
|
||||
@@ -751,7 +751,7 @@
|
||||
badge.className='inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-green-900/40 text-green-400 border border-green-500/30';
|
||||
badge.textContent='● Agent 在线';
|
||||
const btn=document.getElementById('remoteInstallBtn');
|
||||
if(btn){btn.disabled=true;btn.textContent='⚡ 远程安装(已在线)';btn.className='px-4 py-2 bg-slate-800 text-slate-500 text-sm rounded-lg cursor-not-allowed'}
|
||||
if(btn){btn.disabled=true;btn.textContent='⚡ 远程安装(已在线)';btn.className='px-4 py-2 bg-[var(--bg-surface)] text-[var(--text-muted)] text-sm rounded-lg cursor-not-allowed'}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1156,8 +1156,8 @@
|
||||
document.getElementById('batchAgentFail').textContent=fail;
|
||||
const details=document.getElementById('batchAgentDetails');
|
||||
details.innerHTML=results.map(r=>{
|
||||
if(r.success)return `<div class="flex items-center gap-2 text-xs text-emerald-400"><span>✓</span><span>${esc(r.server_name)}</span>${r.stdout?`<span class="text-slate-400">${esc(r.stdout)}</span>`:''}</div>`;
|
||||
return `<div class="flex items-center gap-2 text-xs text-red-400"><span>✗</span><span>${esc(r.server_name)}</span><span class="text-slate-500 truncate" title="${esc(r.error)}">${esc(r.error)}</span></div>`;
|
||||
if(r.success)return `<div class="flex items-center gap-2 text-xs text-emerald-400"><span>✓</span><span>${esc(r.server_name)}</span>${r.stdout?`<span class="text-[var(--text-secondary)]">${esc(r.stdout)}</span>`:''}</div>`;
|
||||
return `<div class="flex items-center gap-2 text-xs text-red-400"><span>✗</span><span>${esc(r.server_name)}</span><span class="text-[var(--text-muted)] truncate" title="${esc(r.error)}">${esc(r.error)}</span></div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
|
||||
+100
-100
@@ -1,28 +1,28 @@
|
||||
<!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,totpStep:'idle',totpSecret:'',totpUri:''}">
|
||||
<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>
|
||||
<!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><link rel="stylesheet" href="/app/vendor/theme.css"><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-[var(--bg-page)] text-[var(--text-primary)] min-h-screen flex" x-data="{sidebarOpen:true,totpStep:'idle',totpSecret:'',totpUri:''}">
|
||||
<aside x-show="sidebarOpen" x-transition class="w-64 bg-[var(--bg-card)] border-r border-[var(--border)] 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>
|
||||
<header class="bg-[var(--bg-card)] border-b border-[var(--border)] px-6 py-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-[var(--text-secondary)] 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>
|
||||
</header>
|
||||
<main class="flex-1 overflow-y-auto p-6 max-w-2xl mx-auto w-full space-y-4">
|
||||
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-4">
|
||||
<div class="text-slate-400 text-xs">以下为可修改的运行时配置。SECRET_KEY / API_KEY / DATABASE_URL 为安全锁定项,不可通过此页面修改。</div>
|
||||
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-4">
|
||||
<div class="text-[var(--text-secondary)] text-xs">以下为可修改的运行时配置。SECRET_KEY / API_KEY / DATABASE_URL 为安全锁定项,不可通过此页面修改。</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Account Security ── -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
|
||||
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-6">
|
||||
<h2 class="text-white font-medium mb-4">账户安全</h2>
|
||||
|
||||
<!-- TOTP 2FA — 完整管理区块 -->
|
||||
<div class="mb-5 pb-5 border-b border-slate-800 space-y-4">
|
||||
<div class="mb-5 pb-5 border-b border-[var(--border)] space-y-4">
|
||||
|
||||
<!-- 状态标题 -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-white text-sm font-medium">Google Authenticator 双因素认证</span>
|
||||
<span id="totpStatusBadge" class="text-xs px-2 py-0.5 rounded-full bg-slate-800 text-slate-500">检测中...</span>
|
||||
<span id="totpStatusBadge" class="text-xs px-2 py-0.5 rounded-full bg-[var(--bg-surface)] text-[var(--text-muted)]">检测中...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 mt-3">
|
||||
<button onclick="startTotpSetup(true)" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-300 text-xs rounded-lg transition">🔄 重新绑定(换设备/换 App)</button>
|
||||
<button onclick="startTotpSetup(true)" class="px-3 py-1.5 bg-[var(--bg-surface)] hover:bg-slate-700 text-slate-300 text-xs rounded-lg transition">🔄 重新绑定(换设备/换 App)</button>
|
||||
<button onclick="disableTotp()" class="px-3 py-1.5 bg-red-900/40 hover:bg-red-900/70 text-red-400 text-xs rounded-lg transition">禁用 TOTP</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -57,11 +57,11 @@
|
||||
<div id="totpBindPanel" class="hidden rounded-xl border border-brand/30 bg-brand/5 p-4 space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span id="totpBindTitle" class="text-white text-sm font-semibold">绑定 Google Authenticator</span>
|
||||
<button onclick="cancelTotpSetup()" class="text-slate-500 hover:text-white text-xs transition">✕ 取消</button>
|
||||
<button onclick="cancelTotpSetup()" class="text-[var(--text-muted)] hover:text-white text-xs transition">✕ 取消</button>
|
||||
</div>
|
||||
|
||||
<!-- 步骤说明 -->
|
||||
<div class="text-slate-400 text-xs space-y-1">
|
||||
<div class="text-[var(--text-secondary)] text-xs space-y-1">
|
||||
<p>① 打开手机上的 <strong class="text-slate-300">Google Authenticator</strong>(或 Aegis、Microsoft Authenticator 等兼容 App)</p>
|
||||
<p>② 扫描下方二维码,或手动输入密钥</p>
|
||||
<p>③ 输入 App 显示的 6 位数字确认绑定</p>
|
||||
@@ -69,30 +69,30 @@
|
||||
|
||||
<!-- QR + 密钥 -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 items-start">
|
||||
<div class="bg-slate-900 rounded-lg p-4 text-center border border-slate-700">
|
||||
<p class="text-slate-500 text-xs mb-3">扫描二维码</p>
|
||||
<div class="bg-[var(--bg-card)] rounded-lg p-4 text-center border border-[var(--border-input)]">
|
||||
<p class="text-[var(--text-muted)] text-xs mb-3">扫描二维码</p>
|
||||
<canvas id="totpQrCanvas" class="mx-auto rounded" width="180" height="180"></canvas>
|
||||
<p id="totpQrFallback" class="hidden text-slate-500 text-xs mt-2">二维码加载失败,请手动输入密钥</p>
|
||||
<p id="totpQrFallback" class="hidden text-[var(--text-muted)] text-xs mt-2">二维码加载失败,请手动输入密钥</p>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-slate-400 text-xs mb-1">手动输入密钥</label>
|
||||
<label class="block text-[var(--text-secondary)] text-xs mb-1">手动输入密钥</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<code id="totpSecretDisplay" class="flex-1 px-3 py-2 bg-slate-900 border border-slate-700 rounded-lg text-green-400 text-sm font-mono select-all break-all leading-relaxed"></code>
|
||||
<button onclick="copyTotpSecret()" class="px-2 py-2 bg-slate-800 hover:bg-slate-700 text-white text-xs rounded-lg shrink-0 transition" title="复制密钥">📋</button>
|
||||
<code id="totpSecretDisplay" class="flex-1 px-3 py-2 bg-[var(--bg-card)] border border-[var(--border-input)] rounded-lg text-green-400 text-sm font-mono select-all break-all leading-relaxed"></code>
|
||||
<button onclick="copyTotpSecret()" class="px-2 py-2 bg-[var(--bg-surface)] hover:bg-slate-700 text-white text-xs rounded-lg shrink-0 transition" title="复制密钥">📋</button>
|
||||
</div>
|
||||
<p class="text-slate-600 text-xs mt-1">密钥即使截图也无法替代 App 扫码,请妥善保管</p>
|
||||
<p class="text-[var(--text-dim)] text-xs mt-1">密钥即使截图也无法替代 App 扫码,请妥善保管</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-slate-400 text-xs mb-1">输入 App 中的验证码</label>
|
||||
<label class="block text-[var(--text-secondary)] text-xs mb-1">输入 App 中的验证码</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<input id="totpVerifyCode" maxlength="6" inputmode="numeric" pattern="\d{6}" placeholder="000000"
|
||||
class="w-28 px-3 py-2 bg-slate-900 border border-slate-700 rounded-lg text-white text-lg font-mono text-center tracking-[0.3em] focus:outline-none focus:ring-2 focus:ring-brand"
|
||||
class="w-28 px-3 py-2 bg-[var(--bg-card)] border border-[var(--border-input)] rounded-lg text-white text-lg font-mono text-center tracking-[0.3em] focus:outline-none focus:ring-2 focus:ring-brand"
|
||||
onkeydown="if(event.key==='Enter')verifyAndEnableTotp()">
|
||||
<button onclick="verifyAndEnableTotp()" class="flex-1 px-4 py-2 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition font-medium">确认绑定</button>
|
||||
</div>
|
||||
</div>
|
||||
<button onclick="startTotpSetup(_isRebind)" class="text-xs text-slate-500 hover:text-slate-300 transition">🔄 重新生成二维码</button>
|
||||
<button onclick="startTotpSetup(_isRebind)" class="text-xs text-[var(--text-muted)] hover:text-slate-300 transition">🔄 重新生成二维码</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -102,47 +102,47 @@
|
||||
<!-- Change Password -->
|
||||
<div>
|
||||
<span class="text-white text-sm font-medium">修改密码</span>
|
||||
<p class="text-slate-400 text-xs mb-3">修改当前账户的登录密码。已启用 TOTP 时需要验证码。</p>
|
||||
<p class="text-[var(--text-secondary)] text-xs mb-3">修改当前账户的登录密码。已启用 TOTP 时需要验证码。</p>
|
||||
<div class="space-y-2 max-w-sm">
|
||||
<input id="currentPassword" type="password" placeholder="当前密码" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-brand">
|
||||
<input id="newPassword" type="password" placeholder="新密码 (至少6位)" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-brand">
|
||||
<input id="confirmPassword" type="password" placeholder="确认新密码" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-brand">
|
||||
<input id="totpCodeForPassword" type="text" inputmode="numeric" maxlength="6" placeholder="TOTP 验证码 (已启用2FA时必填)" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm font-mono tracking-widest focus:outline-none focus:ring-2 focus:ring-brand">
|
||||
<input id="currentPassword" type="password" placeholder="当前密码" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-brand">
|
||||
<input id="newPassword" type="password" placeholder="新密码 (至少6位)" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-brand">
|
||||
<input id="confirmPassword" type="password" placeholder="确认新密码" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-brand">
|
||||
<input id="totpCodeForPassword" type="text" inputmode="numeric" maxlength="6" placeholder="TOTP 验证码 (已启用2FA时必填)" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm font-mono tracking-widest focus:outline-none focus:ring-2 focus:ring-brand">
|
||||
<button onclick="changePassword()" id="changePwBtn" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">修改密码</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Brand Settings -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
|
||||
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-6">
|
||||
<h2 class="text-white font-medium mb-4">品牌</h2>
|
||||
<div id="brandSection" class="space-y-3"><div class="text-slate-500 text-center py-4 text-sm">加载中...</div></div>
|
||||
<div id="brandSection" class="space-y-3"><div class="text-[var(--text-muted)] text-center py-4 text-sm">加载中...</div></div>
|
||||
</div>
|
||||
|
||||
<!-- API Key (read-only with copy) -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
|
||||
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-6">
|
||||
<h2 class="text-white font-medium mb-4">API 密钥</h2>
|
||||
<div id="apiKeySection" class="space-y-3"><div class="text-slate-500 text-center py-4 text-sm">加载中...</div></div>
|
||||
<div id="apiKeySection" class="space-y-3"><div class="text-[var(--text-muted)] text-center py-4 text-sm">加载中...</div></div>
|
||||
</div>
|
||||
|
||||
<!-- Alert Thresholds -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
|
||||
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-6">
|
||||
<h2 class="text-white font-medium mb-4">告警阈值 (%)</h2>
|
||||
<div id="alertSection" class="space-y-3"><div class="text-slate-500 text-center py-4 text-sm">加载中...</div></div>
|
||||
<div id="alertSection" class="space-y-3"><div class="text-[var(--text-muted)] text-center py-4 text-sm">加载中...</div></div>
|
||||
</div>
|
||||
|
||||
<!-- Infrastructure -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
|
||||
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-6">
|
||||
<h2 class="text-white font-medium mb-4">基础设施</h2>
|
||||
<div id="infraSection" class="space-y-3"><div class="text-slate-500 text-center py-4 text-sm">加载中...</div></div>
|
||||
<div id="infraSection" class="space-y-3"><div class="text-[var(--text-muted)] text-center py-4 text-sm">加载中...</div></div>
|
||||
</div>
|
||||
|
||||
<!-- Telegram -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6 space-y-4">
|
||||
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-6 space-y-4">
|
||||
<h2 class="text-white font-medium">Telegram 通知</h2>
|
||||
<div id="telegramSection" class="space-y-3"><div class="text-slate-500 text-center py-4 text-sm">加载中...</div></div>
|
||||
<div id="telegramSection" class="space-y-3"><div class="text-[var(--text-muted)] text-center py-4 text-sm">加载中...</div></div>
|
||||
<!-- Test + Chat ID detection -->
|
||||
<div class="flex flex-wrap items-center gap-2 pt-1 border-t border-slate-800">
|
||||
<div class="flex flex-wrap items-center gap-2 pt-1 border-t border-[var(--border)]">
|
||||
<button onclick="telegramTest()" id="telegramTestBtn"
|
||||
class="px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-white text-xs rounded-lg transition">
|
||||
📨 发送测试消息
|
||||
@@ -151,22 +151,22 @@
|
||||
class="px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-white text-xs rounded-lg transition">
|
||||
🔍 检测 Chat ID
|
||||
</button>
|
||||
<span class="text-slate-600 text-xs">检测前请先向 Bot 发一条消息</span>
|
||||
<span class="text-[var(--text-dim)] text-xs">检测前请先向 Bot 发一条消息</span>
|
||||
</div>
|
||||
<!-- Chat list -->
|
||||
<div id="telegramChatList" class="hidden space-y-2"></div>
|
||||
</div>
|
||||
|
||||
<!-- Login IP Allowlist -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6 space-y-4">
|
||||
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-6 space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-white font-medium">登录 IP 白名单</h2>
|
||||
<p class="text-slate-500 text-xs mt-0.5">开启后仅白名单 IP 可登录后台;关闭则不限制来源 IP</p>
|
||||
<p class="text-[var(--text-muted)] text-xs mt-0.5">开启后仅白名单 IP 可登录后台;关闭则不限制来源 IP</p>
|
||||
</div>
|
||||
<!-- Master toggle -->
|
||||
<div class="flex items-center gap-3">
|
||||
<span id="ipAllowlistStatus" class="text-xs px-2 py-0.5 rounded-full bg-slate-800 text-slate-500">加载中</span>
|
||||
<span id="ipAllowlistStatus" class="text-xs px-2 py-0.5 rounded-full bg-[var(--bg-surface)] text-[var(--text-muted)]">加载中</span>
|
||||
<button id="allowlistToggleBtn" onclick="toggleAllowlist()"
|
||||
class="w-12 h-6 rounded-full transition-colors bg-slate-700 relative"
|
||||
data-enabled="false" title="点击启用/关闭白名单">
|
||||
@@ -176,7 +176,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Disabled notice -->
|
||||
<div id="allowlistDisabledNotice" class="rounded-lg border border-slate-700 bg-slate-800/40 px-4 py-3 text-slate-400 text-xs">
|
||||
<div id="allowlistDisabledNotice" class="rounded-lg border border-[var(--border-input)] bg-slate-800/40 px-4 py-3 text-[var(--text-secondary)] text-xs">
|
||||
⚪ 白名单未启用 — 任何 IP 均可登录。开启后将仅允许下方白名单 IP。
|
||||
</div>
|
||||
<div id="allowlistEnabledNotice" class="hidden rounded-lg border border-green-500/30 bg-green-900/10 px-4 py-3 text-green-300 text-xs">
|
||||
@@ -184,14 +184,14 @@
|
||||
</div>
|
||||
|
||||
<!-- 订阅 URL(持久存储,每 2 小时自动刷新) -->
|
||||
<div class="rounded-xl border border-slate-700 bg-slate-800/30 p-4 space-y-3">
|
||||
<div class="rounded-xl border border-[var(--border-input)] bg-slate-800/30 p-4 space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-slate-300 text-sm font-medium">代理订阅地址(自动刷新)</label>
|
||||
<span id="lastRefreshTime" class="text-slate-600 text-xs"></span>
|
||||
<span id="lastRefreshTime" class="text-[var(--text-dim)] text-xs"></span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<input id="subUrl" type="url" placeholder="https://your-subscription-url..."
|
||||
class="flex-1 px-3 py-2 bg-slate-900 border border-slate-700 rounded-lg text-white text-sm placeholder-slate-600 focus:outline-none focus:ring-2 focus:ring-brand">
|
||||
class="flex-1 px-3 py-2 bg-[var(--bg-card)] border border-[var(--border-input)] rounded-lg text-white text-sm placeholder-slate-600 focus:outline-none focus:ring-2 focus:ring-brand">
|
||||
<button onclick="saveSubscriptionUrl()" id="saveSubBtn"
|
||||
class="px-4 py-2 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition shrink-0">
|
||||
保存
|
||||
@@ -201,7 +201,7 @@
|
||||
🔄
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-slate-600 text-xs">支持 SS / VMess / VLESS / Trojan / Hysteria2 订阅 · 系统启动后 + 每 2 小时自动拉取更新节点 IP</p>
|
||||
<p class="text-[var(--text-dim)] text-xs">支持 SS / VMess / VLESS / Trojan / Hysteria2 订阅 · 系统启动后 + 每 2 小时自动拉取更新节点 IP</p>
|
||||
</div>
|
||||
|
||||
<!-- 解析结果预览 -->
|
||||
@@ -211,20 +211,20 @@
|
||||
class="px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-white text-xs rounded-lg transition">
|
||||
🔍 预览当前订阅节点 IP
|
||||
</button>
|
||||
<span class="text-slate-600 text-xs">(不更改白名单,仅查看)</span>
|
||||
<span class="text-[var(--text-dim)] text-xs">(不更改白名单,仅查看)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 解析结果 (待确认) -->
|
||||
<div id="parsedIpsSection" class="hidden space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-slate-400 text-xs">解析到的节点 IP(勾选要加入白名单的)</label>
|
||||
<label class="text-[var(--text-secondary)] text-xs">解析到的节点 IP(勾选要加入白名单的)</label>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="selectAllParsed(true)" class="text-brand-light text-xs hover:underline">全选</button>
|
||||
<button onclick="selectAllParsed(false)" class="text-slate-500 text-xs hover:underline">全不选</button>
|
||||
<button onclick="selectAllParsed(false)" class="text-[var(--text-muted)] text-xs hover:underline">全不选</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="parsedIpsList" class="max-h-40 overflow-y-auto rounded-lg border border-slate-700 bg-slate-950 p-2 space-y-1"></div>
|
||||
<div id="parsedIpsList" class="max-h-40 overflow-y-auto rounded-lg border border-[var(--border-input)] bg-[var(--bg-page)] p-2 space-y-1"></div>
|
||||
<button onclick="addSelectedToWhitelist()"
|
||||
class="px-4 py-2 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">
|
||||
➕ 添加到白名单
|
||||
@@ -233,9 +233,9 @@
|
||||
|
||||
<!-- 手动输入 -->
|
||||
<div class="space-y-2">
|
||||
<label class="block text-slate-400 text-xs">手动添加 IP / CIDR / 域名(每行一条)</label>
|
||||
<label class="block text-[var(--text-secondary)] text-xs">手动添加 IP / CIDR / 域名(每行一条)</label>
|
||||
<textarea id="manualIps" rows="3" placeholder="1.2.3.4 10.0.0.0/8 proxy.example.com"
|
||||
class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm font-mono resize-y placeholder-slate-700"></textarea>
|
||||
class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm font-mono resize-y placeholder-slate-700"></textarea>
|
||||
<button onclick="addManualIps()" class="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white text-sm rounded-lg transition">
|
||||
➕ 添加
|
||||
</button>
|
||||
@@ -244,32 +244,32 @@
|
||||
<!-- 订阅节点 IP(只读,自动刷新) -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-slate-400 text-xs">订阅节点 IP <span class="text-slate-600">(只读,每 2 小时自动替换)</span></label>
|
||||
<span id="subIpCount" class="text-slate-600 text-xs"></span>
|
||||
<label class="text-[var(--text-secondary)] text-xs">订阅节点 IP <span class="text-[var(--text-dim)]">(只读,每 2 小时自动替换)</span></label>
|
||||
<span id="subIpCount" class="text-[var(--text-dim)] text-xs"></span>
|
||||
</div>
|
||||
<div id="subscriptionIpsList" class="min-h-8 text-slate-500 text-xs">加载中...</div>
|
||||
<div id="subscriptionIpsList" class="min-h-8 text-[var(--text-muted)] text-xs">加载中...</div>
|
||||
</div>
|
||||
|
||||
<!-- 手动 IP -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-slate-400 text-xs">手动添加的 IP <span class="text-slate-600">(永久保留)</span></label>
|
||||
<label class="text-[var(--text-secondary)] text-xs">手动添加的 IP <span class="text-[var(--text-dim)]">(永久保留)</span></label>
|
||||
<button onclick="clearManualIps()" class="text-red-400 text-xs hover:underline">清空手动 IP</button>
|
||||
</div>
|
||||
<div id="manualIpsList" class="min-h-8 text-slate-500 text-xs">加载中...</div>
|
||||
<div id="manualIpsList" class="min-h-8 text-[var(--text-muted)] text-xs">加载中...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notification toggles -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
|
||||
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-6">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<h2 class="text-white font-medium">告警通知项目</h2>
|
||||
<span class="text-slate-500 text-xs">通过 Telegram 推送,可逐项关闭</span>
|
||||
<span class="text-[var(--text-muted)] text-xs">通过 Telegram 推送,可逐项关闭</span>
|
||||
</div>
|
||||
<p class="text-slate-500 text-xs mb-4">关闭后该类告警仍记录日志,但不发送 Telegram 消息。Telegram Bot Token 未配置时全部静默。</p>
|
||||
<p class="text-[var(--text-muted)] text-xs mb-4">关闭后该类告警仍记录日志,但不发送 Telegram 消息。Telegram Bot Token 未配置时全部静默。</p>
|
||||
|
||||
<div class="space-y-1" id="notifyToggles">
|
||||
<div class="text-slate-500 text-center py-4 text-sm">加载中...</div>
|
||||
<div class="text-[var(--text-muted)] text-center py-4 text-sm">加载中...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -307,14 +307,14 @@
|
||||
const isMasked=val.includes('***')||val.includes('...');
|
||||
if(isMasked){
|
||||
return `<div class="flex items-center gap-3">
|
||||
<label class="text-slate-400 text-sm w-36 shrink-0">${cfg.labels[key]||key}</label>
|
||||
<span class="flex-1 px-3 py-2 bg-slate-800/50 border border-slate-700/50 rounded-lg text-slate-500 text-sm select-none">${esc(val)}</span>
|
||||
<span class="text-slate-600 text-xs">🔒 安全锁定</span>
|
||||
<label class="text-[var(--text-secondary)] text-sm w-36 shrink-0">${cfg.labels[key]||key}</label>
|
||||
<span class="flex-1 px-3 py-2 bg-slate-800/50 border border-slate-700/50 rounded-lg text-[var(--text-muted)] text-sm select-none">${esc(val)}</span>
|
||||
<span class="text-[var(--text-dim)] text-xs">🔒 安全锁定</span>
|
||||
</div>`;
|
||||
}
|
||||
return `<div class="flex items-center gap-3">
|
||||
<label class="text-slate-400 text-sm w-36 shrink-0">${cfg.labels[key]||key}</label>
|
||||
<input id="set_${key}" value="${esc(val)}" type="${INPUT_TYPES[key]||'text'}" ${INPUT_TYPES[key]==='number'?'min="1"':''} class="flex-1 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-brand">
|
||||
<label class="text-[var(--text-secondary)] text-sm w-36 shrink-0">${cfg.labels[key]||key}</label>
|
||||
<input id="set_${key}" value="${esc(val)}" type="${INPUT_TYPES[key]||'text'}" ${INPUT_TYPES[key]==='number'?'min="1"':''} class="flex-1 px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-brand">
|
||||
<button type="button" data-setting-key="${escAttr(key)}" class="save-setting-btn px-3 py-2 bg-brand hover:bg-brand-dark text-white text-xs rounded-lg shrink-0 transition">保存</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
@@ -339,21 +339,21 @@
|
||||
if(isMasked){
|
||||
el.innerHTML=`
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="text-slate-400 text-sm w-36 shrink-0">API_KEY</label>
|
||||
<span id="apiKeyDisplay" class="flex-1 px-3 py-2 bg-slate-800/50 border border-slate-700/50 rounded-lg text-slate-500 text-sm font-mono select-none">${esc(val)}</span>
|
||||
<button onclick="toggleApiKeyVisibility()" id="apiKeyToggleBtn" class="px-3 py-2 bg-slate-800 hover:bg-slate-700 text-white text-xs rounded-lg shrink-0 transition">显示</button>
|
||||
<button onclick="copyApiKey()" class="px-3 py-2 bg-slate-800 hover:bg-slate-700 text-white text-xs rounded-lg shrink-0 transition">复制</button>
|
||||
<span class="text-slate-600 text-xs">🔒 不可修改</span>
|
||||
<label class="text-[var(--text-secondary)] text-sm w-36 shrink-0">API_KEY</label>
|
||||
<span id="apiKeyDisplay" class="flex-1 px-3 py-2 bg-slate-800/50 border border-slate-700/50 rounded-lg text-[var(--text-muted)] text-sm font-mono select-none">${esc(val)}</span>
|
||||
<button onclick="toggleApiKeyVisibility()" id="apiKeyToggleBtn" class="px-3 py-2 bg-[var(--bg-surface)] hover:bg-slate-700 text-white text-xs rounded-lg shrink-0 transition">显示</button>
|
||||
<button onclick="copyApiKey()" class="px-3 py-2 bg-[var(--bg-surface)] hover:bg-slate-700 text-white text-xs rounded-lg shrink-0 transition">复制</button>
|
||||
<span class="text-[var(--text-dim)] text-xs">🔒 不可修改</span>
|
||||
</div>
|
||||
<p class="text-slate-500 text-xs mt-2">Agent 子服务器使用此密钥与主服务器通信。请勿泄露。</p>`;
|
||||
<p class="text-[var(--text-muted)] text-xs mt-2">Agent 子服务器使用此密钥与主服务器通信。请勿泄露。</p>`;
|
||||
}else{
|
||||
el.innerHTML=`
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="text-slate-400 text-sm w-36 shrink-0">API_KEY</label>
|
||||
<code id="apiKeyDisplay" class="flex-1 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-green-400 text-sm font-mono select-all break-all">${esc(val)}</code>
|
||||
<button onclick="copyApiKey()" class="px-3 py-2 bg-slate-800 hover:bg-slate-700 text-white text-xs rounded-lg shrink-0 transition">复制</button>
|
||||
<label class="text-[var(--text-secondary)] text-sm w-36 shrink-0">API_KEY</label>
|
||||
<code id="apiKeyDisplay" class="flex-1 px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-green-400 text-sm font-mono select-all break-all">${esc(val)}</code>
|
||||
<button onclick="copyApiKey()" class="px-3 py-2 bg-[var(--bg-surface)] hover:bg-slate-700 text-white text-xs rounded-lg shrink-0 transition">复制</button>
|
||||
</div>
|
||||
<p class="text-slate-500 text-xs mt-2">Agent 子服务器使用此密钥与主服务器通信。请勿泄露。</p>`;
|
||||
<p class="text-[var(--text-muted)] text-xs mt-2">Agent 子服务器使用此密钥与主服务器通信。请勿泄露。</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,18 +364,18 @@
|
||||
const el=document.getElementById('telegramSection');
|
||||
el.innerHTML=`
|
||||
<div class="flex items-center gap-3" id="telegramTokenRow">
|
||||
<label class="text-slate-400 text-sm w-36 shrink-0">Bot Token</label>
|
||||
<label class="text-[var(--text-secondary)] text-sm w-36 shrink-0">Bot Token</label>
|
||||
${tokenMasked
|
||||
?`<span id="tgTokenDisplay" class="flex-1 px-3 py-2 bg-slate-800/50 border border-slate-700/50 rounded-lg text-slate-500 text-sm font-mono select-none">${esc(tokenVal)}</span>
|
||||
<button onclick="revealTelegramToken()" id="tgTokenRevealBtn" class="px-3 py-2 bg-slate-800 hover:bg-slate-700 text-white text-xs rounded-lg shrink-0 transition">显示</button>`
|
||||
:`<input id="set_telegram_bot_token" value="${esc(tokenVal)}" type="password" class="flex-1 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm font-mono">
|
||||
<button type="button" onclick="toggleTgTokenVis()" id="tgTokenToggleBtn" class="px-3 py-2 bg-slate-800 hover:bg-slate-700 text-white text-xs rounded-lg shrink-0 transition">显示</button>
|
||||
?`<span id="tgTokenDisplay" class="flex-1 px-3 py-2 bg-slate-800/50 border border-slate-700/50 rounded-lg text-[var(--text-muted)] text-sm font-mono select-none">${esc(tokenVal)}</span>
|
||||
<button onclick="revealTelegramToken()" id="tgTokenRevealBtn" class="px-3 py-2 bg-[var(--bg-surface)] hover:bg-slate-700 text-white text-xs rounded-lg shrink-0 transition">显示</button>`
|
||||
:`<input id="set_telegram_bot_token" value="${esc(tokenVal)}" type="password" class="flex-1 px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm font-mono">
|
||||
<button type="button" onclick="toggleTgTokenVis()" id="tgTokenToggleBtn" class="px-3 py-2 bg-[var(--bg-surface)] hover:bg-slate-700 text-white text-xs rounded-lg shrink-0 transition">显示</button>
|
||||
<button type="button" data-setting-key="telegram_bot_token" class="save-setting-btn px-3 py-2 bg-brand hover:bg-brand-dark text-white text-xs rounded-lg shrink-0 transition">保存</button>`
|
||||
}
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="text-slate-400 text-sm w-36 shrink-0">Chat ID</label>
|
||||
<input id="set_telegram_chat_id" value="${esc(chatVal)}" class="flex-1 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
|
||||
<label class="text-[var(--text-secondary)] text-sm w-36 shrink-0">Chat ID</label>
|
||||
<input id="set_telegram_chat_id" value="${esc(chatVal)}" class="flex-1 px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm">
|
||||
<button type="button" data-setting-key="telegram_chat_id" class="save-setting-btn px-3 py-2 bg-brand hover:bg-brand-dark text-white text-xs rounded-lg shrink-0 transition">保存</button>
|
||||
</div>`;
|
||||
}
|
||||
@@ -395,9 +395,9 @@
|
||||
// Replace masked display with editable input
|
||||
const row=document.getElementById('telegramTokenRow');
|
||||
row.innerHTML=`
|
||||
<label class="text-slate-400 text-sm w-36 shrink-0">Bot Token</label>
|
||||
<input id="set_telegram_bot_token" value="${esc(realToken)}" type="password" class="flex-1 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm font-mono">
|
||||
<button type="button" onclick="toggleTgTokenVis()" id="tgTokenToggleBtn" class="px-3 py-2 bg-slate-800 hover:bg-slate-700 text-white text-xs rounded-lg shrink-0 transition">显示</button>
|
||||
<label class="text-[var(--text-secondary)] text-sm w-36 shrink-0">Bot Token</label>
|
||||
<input id="set_telegram_bot_token" value="${esc(realToken)}" type="password" class="flex-1 px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm font-mono">
|
||||
<button type="button" onclick="toggleTgTokenVis()" id="tgTokenToggleBtn" class="px-3 py-2 bg-[var(--bg-surface)] hover:bg-slate-700 text-white text-xs rounded-lg shrink-0 transition">显示</button>
|
||||
<button type="button" data-setting-key="telegram_bot_token" class="save-setting-btn px-3 py-2 bg-brand hover:bg-brand-dark text-white text-xs rounded-lg shrink-0 transition">保存</button>`;
|
||||
_allSettings['telegram_bot_token']=realToken;
|
||||
toast('success','Bot Token 已显示');
|
||||
@@ -433,12 +433,12 @@
|
||||
const data=await r.json();
|
||||
_apiKeyRaw=data.value||'';
|
||||
display.textContent=_apiKeyRaw||'(空)';
|
||||
display.className='flex-1 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-green-400 text-sm font-mono select-all break-all';
|
||||
display.className='flex-1 px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-green-400 text-sm font-mono select-all break-all';
|
||||
btn.textContent='隐藏';
|
||||
}catch(e){console.error(e);toast('error','密码验证失败')}
|
||||
}else{
|
||||
display.textContent=_allSettings['api_key']||'';
|
||||
display.className='flex-1 px-3 py-2 bg-slate-800/50 border border-slate-700/50 rounded-lg text-slate-500 text-sm font-mono select-none';
|
||||
display.className='flex-1 px-3 py-2 bg-slate-800/50 border border-slate-700/50 rounded-lg text-[var(--text-muted)] text-sm font-mono select-none';
|
||||
btn.textContent='显示';
|
||||
_apiKeyRaw='';
|
||||
}
|
||||
@@ -691,11 +691,11 @@
|
||||
listEl.classList.add('hidden');return;
|
||||
}
|
||||
listEl.classList.remove('hidden');
|
||||
listEl.innerHTML='<p class="text-slate-400 text-xs">检测到以下对话,点击填入 Chat ID:</p>'+
|
||||
d.chats.map(c=>`<div class="flex items-center gap-3 px-3 py-2 bg-slate-800 rounded-lg">
|
||||
listEl.innerHTML='<p class="text-[var(--text-secondary)] text-xs">检测到以下对话,点击填入 Chat ID:</p>'+
|
||||
d.chats.map(c=>`<div class="flex items-center gap-3 px-3 py-2 bg-[var(--bg-surface)] rounded-lg">
|
||||
<div class="flex-1">
|
||||
<span class="text-white text-sm font-medium">${esc(c.title||c.username||'私聊')}</span>
|
||||
<span class="ml-2 text-slate-500 text-xs">${esc(c.type)}</span>
|
||||
<span class="ml-2 text-[var(--text-muted)] text-xs">${esc(c.type)}</span>
|
||||
<code class="ml-2 text-brand-light text-xs font-mono">${esc(String(c.id))}</code>
|
||||
</div>
|
||||
<button onclick="fillChatId('${escAttr(String(c.id))}')"
|
||||
@@ -741,13 +741,13 @@
|
||||
let groupHeader='';
|
||||
if(item.group!==lastGroup){
|
||||
lastGroup=item.group;
|
||||
groupHeader=`<div class="text-slate-500 text-xs font-semibold uppercase tracking-wide pt-3 pb-1 border-t border-slate-800 first:border-0">${esc(item.group)}</div>`;
|
||||
groupHeader=`<div class="text-[var(--text-muted)] text-xs font-semibold uppercase tracking-wide pt-3 pb-1 border-t border-[var(--border)] first:border-0">${esc(item.group)}</div>`;
|
||||
}
|
||||
return groupHeader+`
|
||||
<div class="flex items-center justify-between py-2.5 px-3 rounded-lg hover:bg-slate-800/50 transition group">
|
||||
<div class="flex-1 min-w-0">
|
||||
<span class="text-sm text-slate-200">${esc(item.label)}</span>
|
||||
<span class="block text-xs text-slate-500 mt-0.5">${esc(item.desc)}</span>
|
||||
<span class="block text-xs text-[var(--text-muted)] mt-0.5">${esc(item.desc)}</span>
|
||||
</div>
|
||||
<button onclick="toggleNotify('${escAttr(item.key)}',this)"
|
||||
class="ml-4 shrink-0 w-11 h-6 rounded-full transition-colors ${enabled?'bg-brand':'bg-slate-700'} relative"
|
||||
@@ -817,17 +817,17 @@
|
||||
status.className='text-xs px-2 py-0.5 rounded-full bg-green-900/50 text-green-400 border border-green-500/30';
|
||||
}else{
|
||||
status.textContent='已关闭';
|
||||
status.className='text-xs px-2 py-0.5 rounded-full bg-slate-800 text-slate-500';
|
||||
status.className='text-xs px-2 py-0.5 rounded-full bg-[var(--bg-surface)] text-[var(--text-muted)]';
|
||||
}
|
||||
// Subscription IPs (read-only)
|
||||
document.getElementById('subIpCount').textContent=`${_subIps.length} 个`;
|
||||
document.getElementById('subscriptionIpsList').innerHTML=_subIps.length
|
||||
?'<div class="flex flex-wrap gap-1.5">'+_subIps.map(ip=>`<span class="px-2 py-0.5 rounded border border-slate-700 bg-slate-800 text-xs font-mono text-slate-400">${esc(ip)}</span>`).join('')+'</div>'
|
||||
:'<span class="text-slate-600">(空 — 尚未刷新或未配置订阅)</span>';
|
||||
?'<div class="flex flex-wrap gap-1.5">'+_subIps.map(ip=>`<span class="px-2 py-0.5 rounded border border-[var(--border-input)] bg-[var(--bg-surface)] text-xs font-mono text-[var(--text-secondary)]">${esc(ip)}</span>`).join('')+'</div>'
|
||||
:'<span class="text-[var(--text-dim)]">(空 — 尚未刷新或未配置订阅)</span>';
|
||||
// Manual IPs (deletable)
|
||||
document.getElementById('manualIpsList').innerHTML=_manualIps.length
|
||||
?'<div class="flex flex-wrap gap-1.5">'+_manualIps.map(ip=>`<span class="inline-flex items-center px-2 py-0.5 rounded border border-brand/40 bg-brand/10 text-brand-light text-xs font-mono">${esc(ip)}<button onclick="removeManualIp('${escAttr(ip)}')" class="ml-1 text-slate-500 hover:text-red-400">×</button></span>`).join('')+'</div>'
|
||||
:'<span class="text-slate-600">(空)</span>';
|
||||
?'<div class="flex flex-wrap gap-1.5">'+_manualIps.map(ip=>`<span class="inline-flex items-center px-2 py-0.5 rounded border border-brand/40 bg-brand/10 text-brand-light text-xs font-mono">${esc(ip)}<button onclick="removeManualIp('${escAttr(ip)}')" class="ml-1 text-[var(--text-muted)] hover:text-red-400">×</button></span>`).join('')+'</div>'
|
||||
:'<span class="text-[var(--text-dim)]">(空)</span>';
|
||||
}catch(e){console.error(e);toast('error','加载白名单失败')}
|
||||
}
|
||||
|
||||
|
||||
+46
-46
@@ -1,36 +1,36 @@
|
||||
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — SSH终端</title><script src="/app/vendor/tailwindcss-browser.js"></script><script defer src="/app/vendor/alpinejs.min.js"></script><link rel="stylesheet" href="/app/vendor/xterm.css"/><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><style>.xterm{padding:4px}#terminalArea{flex:1;min-height:0;position:relative}.term-instance{position:absolute;inset:0}.term-instance.active{z-index:1}.term-instance:not(.active){z-index:0;pointer-events:none;opacity:0}#ctxMenu{position:fixed;z-index:200;min-width:140px;box-shadow:0 4px 12px rgba(0,0,0,.5)}body.fs-mode aside,body.fs-mode header,body.fs-mode #tabBar,body.fs-mode #cmdBar{display:none!important}body.fs-mode #terminalArea{position:fixed;inset:0;z-index:100}</style></head>
|
||||
<body class="bg-slate-950 text-slate-100 min-h-screen flex">
|
||||
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — SSH终端</title><script src="/app/vendor/tailwindcss-browser.js"></script><link rel="stylesheet" href="/app/vendor/theme.css"><script defer src="/app/vendor/alpinejs.min.js"></script><link rel="stylesheet" href="/app/vendor/xterm.css"/><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><style>.xterm{padding:4px}#terminalArea{flex:1;min-height:0;position:relative}.term-instance{position:absolute;inset:0}.term-instance.active{z-index:1}.term-instance:not(.active){z-index:0;pointer-events:none;opacity:0}#ctxMenu{position:fixed;z-index:200;min-width:140px;box-shadow:0 4px 12px rgba(0,0,0,.5)}body.fs-mode aside,body.fs-mode header,body.fs-mode #tabBar,body.fs-mode #cmdBar{display:none!important}body.fs-mode #terminalArea{position:fixed;inset:0;z-index:100}</style></head>
|
||||
<body class="bg-[var(--bg-page)] text-[var(--text-primary)] min-h-screen flex">
|
||||
|
||||
<aside x-data="{open:true}" x-show="open" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0" data-sidebar></aside>
|
||||
<aside x-data="{open:true}" x-show="open" x-transition class="w-64 bg-[var(--bg-card)] border-r border-[var(--border)] flex flex-col shrink-0" data-sidebar></aside>
|
||||
|
||||
<div class="flex-1 flex flex-col min-w-0">
|
||||
<!-- Tab Bar -->
|
||||
<div id="tabBar" class="bg-slate-900/80 border-b border-slate-800 px-2 py-1 flex items-center gap-0.5 overflow-x-auto shrink-0">
|
||||
<div id="tabBar" class="bg-[var(--bg-card)]/80 border-b border-[var(--border)] px-2 py-1 flex items-center gap-0.5 overflow-x-auto shrink-0">
|
||||
<div id="tabs" class="flex items-center gap-0.5"></div>
|
||||
<button onclick="newSession()" class="shrink-0 px-1.5 py-0.5 text-slate-500 hover:text-slate-300 text-xs rounded transition ml-1" title="新会话">+</button>
|
||||
<button onclick="newSession()" class="shrink-0 px-1.5 py-0.5 text-[var(--text-muted)] hover:text-slate-300 text-xs rounded transition ml-1" title="新会话">+</button>
|
||||
</div>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<header class="bg-slate-900 border-b border-slate-800 px-4 py-1.5 flex items-center justify-between h-8 shrink-0">
|
||||
<header class="bg-[var(--bg-card)] border-b border-[var(--border)] px-4 py-1.5 flex items-center justify-between h-8 shrink-0">
|
||||
<div class="flex items-center gap-3">
|
||||
<span id="connServer" class="text-white text-xs font-semibold">...</span>
|
||||
<span id="connPath" class="text-slate-500 text-xs font-mono truncate max-w-64"></span>
|
||||
<span id="connStatus" class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-slate-800 text-slate-500">
|
||||
<span id="connPath" class="text-[var(--text-muted)] text-xs font-mono truncate max-w-64"></span>
|
||||
<span id="connStatus" class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-[var(--bg-surface)] text-[var(--text-muted)]">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-slate-600"></span>未连接
|
||||
</span>
|
||||
<span id="connUptime" class="text-slate-600 text-xs hidden"></span>
|
||||
<span id="connPing" class="text-slate-600 text-xs hidden"></span>
|
||||
<span id="connUptime" class="text-[var(--text-dim)] text-xs hidden"></span>
|
||||
<span id="connPing" class="text-[var(--text-dim)] text-xs hidden"></span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<!-- Font size -->
|
||||
<button onclick="changeFontSize(-1)" class="px-1.5 py-0.5 text-slate-500 hover:text-white text-xs rounded transition" title="缩小字体">A−</button>
|
||||
<span id="fontSizeLabel" class="text-slate-600 text-xs">14</span>
|
||||
<button onclick="changeFontSize(1)" class="px-1.5 py-0.5 text-slate-500 hover:text-white text-xs rounded transition" title="放大字体">A+</button>
|
||||
<button onclick="changeFontSize(-1)" class="px-1.5 py-0.5 text-[var(--text-muted)] hover:text-white text-xs rounded transition" title="缩小字体">A−</button>
|
||||
<span id="fontSizeLabel" class="text-[var(--text-dim)] text-xs">14</span>
|
||||
<button onclick="changeFontSize(1)" class="px-1.5 py-0.5 text-[var(--text-muted)] hover:text-white text-xs rounded transition" title="放大字体">A+</button>
|
||||
<!-- Scrollback -->
|
||||
<select id="scrollbackSel" onchange="changeScrollback(this.value)" class="bg-slate-800 border border-slate-700 rounded text-slate-400 text-xs px-1 py-0.5">
|
||||
<select id="scrollbackSel" onchange="changeScrollback(this.value)" class="bg-[var(--bg-surface)] border border-[var(--border-input)] rounded text-[var(--text-secondary)] text-xs px-1 py-0.5">
|
||||
<option value="1000">1K 行</option><option value="5000">5K 行</option><option value="10000">10K 行</option><option value="50000">50K 行</option>
|
||||
</select>
|
||||
<button onclick="toggleFullscreen()" class="px-2 py-0.5 bg-slate-800 hover:bg-slate-700 text-slate-400 text-xs rounded transition">全屏</button>
|
||||
<button onclick="toggleFullscreen()" class="px-2 py-0.5 bg-[var(--bg-surface)] hover:bg-slate-700 text-[var(--text-secondary)] text-xs rounded transition">全屏</button>
|
||||
<button onclick="disconnect()" class="px-2 py-0.5 bg-red-900/30 hover:bg-red-900/50 text-red-400 text-xs rounded transition" id="disconnectBtn">断开</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -40,45 +40,45 @@
|
||||
<div id="termContainer" class="flex-1 min-h-0 relative">
|
||||
<!-- term instances injected here -->
|
||||
</div>
|
||||
<div id="disconnectOverlay" class="hidden absolute inset-0 bg-slate-950/80 backdrop-blur-sm flex flex-col items-center justify-center z-50">
|
||||
<div id="disconnectOverlay" class="hidden absolute inset-0 bg-[var(--bg-page)]/80 backdrop-blur-sm flex flex-col items-center justify-center z-50">
|
||||
<div id="disconnectMsg" class="text-slate-300 text-lg mb-1">连接已断开</div>
|
||||
<div id="reconnectCountdown" class="text-slate-500 text-sm mb-4 hidden"></div>
|
||||
<div id="reconnectCountdown" class="text-[var(--text-muted)] text-sm mb-4 hidden"></div>
|
||||
<div class="flex gap-3">
|
||||
<button onclick="reconnect()" class="px-5 py-2 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">重新连接</button>
|
||||
<a href="/app/servers.html" class="px-5 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 text-sm rounded-lg transition">返回列表</a>
|
||||
<a href="/app/servers.html" class="px-5 py-2 bg-[var(--bg-surface)] hover:bg-slate-700 text-slate-300 text-sm rounded-lg transition">返回列表</a>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Quick Commands -->
|
||||
<div id="quickCmdBar" class="bg-slate-900/95 border-t border-slate-800 px-3 py-1.5 flex items-center gap-1.5 overflow-x-auto shrink-0">
|
||||
<span class="text-slate-600 text-xs shrink-0">⚡</span>
|
||||
<div id="quickCmdBar" class="bg-[var(--bg-card)]/95 border-t border-[var(--border)] px-3 py-1.5 flex items-center gap-1.5 overflow-x-auto shrink-0">
|
||||
<span class="text-[var(--text-dim)] text-xs shrink-0">⚡</span>
|
||||
<div id="quickCmdChips" class="flex items-center gap-1 overflow-x-auto"></div>
|
||||
<button onclick="addQuickCmd()" class="shrink-0 text-slate-600 hover:text-slate-300 text-xs px-1">+</button>
|
||||
<button onclick="addQuickCmd()" class="shrink-0 text-[var(--text-dim)] hover:text-slate-300 text-xs px-1">+</button>
|
||||
</div>
|
||||
<!-- Command Bar -->
|
||||
<div id="cmdBar" class="bg-slate-900/95 border-t border-slate-800 px-3 py-2 flex items-center gap-2 shrink-0 opacity-50 pointer-events-none">
|
||||
<div id="cmdBar" class="bg-[var(--bg-card)]/95 border-t border-[var(--border)] px-3 py-2 flex items-center gap-2 shrink-0 opacity-50 pointer-events-none">
|
||||
<span class="text-brand-light text-sm font-mono select-none shrink-0">❯</span>
|
||||
<input id="cmdInput" type="text" placeholder="输入命令,Enter 发送…" class="flex-1 bg-slate-800 border border-slate-700 rounded px-3 py-1.5 text-white text-sm font-mono placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-brand/50" autocomplete="off" spellcheck="false">
|
||||
<input id="cmdInput" type="text" placeholder="输入命令,Enter 发送…" class="flex-1 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded px-3 py-1.5 text-white text-sm font-mono placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-brand/50" autocomplete="off" spellcheck="false">
|
||||
<button onclick="sendCmd()" class="px-3 py-1.5 bg-brand/80 hover:bg-brand text-white text-sm rounded transition">发送</button>
|
||||
<div class="flex gap-1 border-l border-slate-700 pl-2">
|
||||
<button onclick="sendCtrl('c')" class="px-2 py-1 bg-slate-800 hover:bg-slate-700 text-slate-400 text-xs rounded transition" title="Ctrl+C">^C</button>
|
||||
<button onclick="sendCtrl('d')" class="px-2 py-1 bg-slate-800 hover:bg-slate-700 text-slate-400 text-xs rounded transition" title="Ctrl+D">^D</button>
|
||||
<button onclick="sendKey('\t')" class="px-2 py-1 bg-slate-800 hover:bg-slate-700 text-slate-400 text-xs rounded transition" title="Tab补全">Tab</button>
|
||||
<div class="flex gap-1 border-l border-[var(--border-input)] pl-2">
|
||||
<button onclick="sendCtrl('c')" class="px-2 py-1 bg-[var(--bg-surface)] hover:bg-slate-700 text-[var(--text-secondary)] text-xs rounded transition" title="Ctrl+C">^C</button>
|
||||
<button onclick="sendCtrl('d')" class="px-2 py-1 bg-[var(--bg-surface)] hover:bg-slate-700 text-[var(--text-secondary)] text-xs rounded transition" title="Ctrl+D">^D</button>
|
||||
<button onclick="sendKey('\t')" class="px-2 py-1 bg-[var(--bg-surface)] hover:bg-slate-700 text-[var(--text-secondary)] text-xs rounded transition" title="Tab补全">Tab</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Panel -->
|
||||
<aside id="rightPanel" class="w-64 bg-slate-900 border-l border-slate-800 flex flex-col shrink-0">
|
||||
<div class="p-3 border-b border-slate-800">
|
||||
<input id="serverSearch" placeholder="搜索服务器…" oninput="filterServerList()" class="w-full px-2 py-1.5 bg-slate-800 border border-slate-700 rounded text-slate-300 text-xs placeholder-slate-600 focus:outline-none focus:ring-1 focus:ring-brand/50">
|
||||
<aside id="rightPanel" class="w-64 bg-[var(--bg-card)] border-l border-[var(--border)] flex flex-col shrink-0">
|
||||
<div class="p-3 border-b border-[var(--border)]">
|
||||
<input id="serverSearch" placeholder="搜索服务器…" oninput="filterServerList()" class="w-full px-2 py-1.5 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded text-slate-300 text-xs placeholder-slate-600 focus:outline-none focus:ring-1 focus:ring-brand/50">
|
||||
</div>
|
||||
<div id="serverListPanel" class="flex-1 overflow-y-auto px-2 py-1 space-y-0.5" onscroll=""></div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Right-Click Context Menu -->
|
||||
<div id="ctxMenu" class="hidden bg-slate-900 border border-slate-700 rounded-lg py-1" onclick="event.stopPropagation()">
|
||||
<div id="ctxMenu" class="hidden bg-[var(--bg-card)] border border-[var(--border-input)] rounded-lg py-1" onclick="event.stopPropagation()">
|
||||
<button onclick="ctxCopy();hideCtxMenu()" class="w-full text-left px-3 py-1.5 text-xs text-slate-300 hover:bg-slate-800 transition">📋 复制</button>
|
||||
<button onclick="ctxPaste();hideCtxMenu()" class="w-full text-left px-3 py-1.5 text-xs text-slate-300 hover:bg-slate-800 transition">📄 粘贴</button>
|
||||
<button onclick="ctxClear();hideCtxMenu()" class="w-full text-left px-3 py-1.5 text-xs text-slate-300 hover:bg-slate-800 transition">🧹 清屏</button>
|
||||
@@ -87,23 +87,23 @@
|
||||
|
||||
<!-- QuickCmd Modal -->
|
||||
<div id="qcModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/60" onclick="if(event.target===this)this.classList.add('hidden')">
|
||||
<div class="bg-slate-900 border border-slate-700 rounded-xl w-full max-w-sm p-5 space-y-3">
|
||||
<div class="bg-[var(--bg-card)] border border-[var(--border-input)] rounded-xl w-full max-w-sm p-5 space-y-3">
|
||||
<h2 id="qcTitle" class="text-white font-semibold">添加命令</h2>
|
||||
<input id="qcName" placeholder="命令名称" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm placeholder-slate-500">
|
||||
<textarea id="qcCmd" placeholder="命令内容(含 \\r 回车)" rows="3" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm font-mono placeholder-slate-500"></textarea>
|
||||
<input id="qcName" placeholder="命令名称" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm placeholder-slate-500">
|
||||
<textarea id="qcCmd" placeholder="命令内容(含 \\r 回车)" rows="3" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm font-mono placeholder-slate-500"></textarea>
|
||||
<input type="hidden" id="qcEditId">
|
||||
<div class="flex gap-2">
|
||||
<button onclick="saveQuickCmd()" class="flex-1 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm">保存</button>
|
||||
<button onclick="document.getElementById('qcModal').classList.add('hidden')" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm">取消</button>
|
||||
<button onclick="document.getElementById('qcModal').classList.add('hidden')" class="px-4 py-2 bg-[var(--bg-surface)] hover:bg-slate-700 text-slate-300 rounded-lg text-sm">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Server Selector Modal (for new tab) -->
|
||||
<div id="serverModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/60" onclick="if(event.target===this)this.classList.add('hidden')">
|
||||
<div class="bg-slate-900 border border-slate-700 rounded-xl w-full max-w-sm p-4 space-y-3 max-h-[60vh] flex flex-col">
|
||||
<div class="bg-[var(--bg-card)] border border-[var(--border-input)] rounded-xl w-full max-w-sm p-4 space-y-3 max-h-[60vh] flex flex-col">
|
||||
<h2 class="text-white font-semibold text-sm">选择服务器</h2>
|
||||
<input id="serverModalSearch" placeholder="搜索…" oninput="filterServerModal()" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm placeholder-slate-500">
|
||||
<input id="serverModalSearch" placeholder="搜索…" oninput="filterServerModal()" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm placeholder-slate-500">
|
||||
<div id="serverModalList" class="flex-1 overflow-y-auto space-y-1"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -392,7 +392,7 @@ async function connectSession(idx) {
|
||||
if (idx === activeIdx) {
|
||||
document.getElementById('cmdBar').classList.add('opacity-50','pointer-events-none');
|
||||
document.getElementById('connStatus').innerHTML = '<span class="w-1.5 h-1.5 rounded-full bg-slate-600"></span>未连接';
|
||||
document.getElementById('connStatus').className = 'inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-slate-800 text-slate-500';
|
||||
document.getElementById('connStatus').className = 'inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-[var(--bg-surface)] text-[var(--text-muted)]';
|
||||
document.getElementById('connUptime').classList.add('hidden');
|
||||
document.getElementById('connPing').classList.add('hidden');
|
||||
if (uptimeInterval) { clearInterval(uptimeInterval); uptimeInterval = null; }
|
||||
@@ -491,11 +491,11 @@ function disconnect() {
|
||||
function updateTabUI() {
|
||||
document.getElementById('tabs').innerHTML = sessions.map((s, i) => {
|
||||
const active = i === activeIdx;
|
||||
return `<div class="flex items-center gap-1 px-2 py-0.5 rounded text-xs cursor-pointer transition ${active ? 'bg-brand/20 text-brand-light' : 'text-slate-400 hover:bg-slate-800'}"
|
||||
return `<div class="flex items-center gap-1 px-2 py-0.5 rounded text-xs cursor-pointer transition ${active ? 'bg-brand/20 text-brand-light' : 'text-[var(--text-secondary)] hover:bg-slate-800'}"
|
||||
onclick="switchTab(${i})" title="${esc(s.serverName)}">
|
||||
<span class="w-1.5 h-1.5 rounded-full ${s.ws && s.ws.readyState===1 ? 'bg-green-400' : 'bg-slate-600'}"></span>
|
||||
<span class="max-w-28 truncate">${esc(s.serverName)}</span>
|
||||
<button onclick="event.stopPropagation();closeTab(${i})" class="text-slate-600 hover:text-red-400 ml-0.5">×</button>
|
||||
<button onclick="event.stopPropagation();closeTab(${i})" class="text-[var(--text-dim)] hover:text-red-400 ml-0.5">×</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
@@ -511,7 +511,7 @@ function updateConnInfo() {
|
||||
} else {
|
||||
document.getElementById('connServer').textContent = '...';
|
||||
document.getElementById('connStatus').innerHTML = '<span class="w-1.5 h-1.5 rounded-full bg-slate-600"></span>未连接';
|
||||
document.getElementById('connStatus').className = 'inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-slate-800 text-slate-500';
|
||||
document.getElementById('connStatus').className = 'inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-[var(--bg-surface)] text-[var(--text-muted)]';
|
||||
}
|
||||
updateTabUI();
|
||||
}
|
||||
@@ -686,10 +686,10 @@ function renderServerList() {
|
||||
const el = document.getElementById('serverListPanel');
|
||||
el.innerHTML = _serversCache.map(s => {
|
||||
const online = s.is_online;
|
||||
return `<a href="javascript:void(0)" onclick="newSession(${s.id})" class="flex items-center gap-2 px-2 py-1.5 rounded-lg text-sm transition text-slate-400 hover:text-white hover:bg-slate-800">
|
||||
return `<a href="javascript:void(0)" onclick="newSession(${s.id})" class="flex items-center gap-2 px-2 py-1.5 rounded-lg text-sm transition text-[var(--text-secondary)] hover:text-white hover:bg-slate-800">
|
||||
<span class="w-2 h-2 rounded-full shrink-0 ${online ? 'bg-green-400' : 'bg-slate-600'}"></span>
|
||||
<span class="truncate flex-1">${esc(s.name)}</span>
|
||||
<span class="text-slate-600 text-xs shrink-0">${esc(s.domain)}</span>
|
||||
<span class="text-[var(--text-dim)] text-xs shrink-0">${esc(s.domain)}</span>
|
||||
</a>`;
|
||||
}).join('');
|
||||
}
|
||||
@@ -710,10 +710,10 @@ function loadQuickCmds() {
|
||||
el.innerHTML = all.map((c, i) => {
|
||||
const isBuiltin = c.id.startsWith('__');
|
||||
return `<span class="group flex items-center gap-0.5 px-2 py-0.5 rounded text-xs whitespace-nowrap cursor-pointer transition qc-chip
|
||||
${isBuiltin ? 'bg-slate-800/50 text-slate-400 hover:bg-slate-800 hover:text-slate-200' : 'bg-brand/10 text-brand-light hover:bg-brand/20'}"
|
||||
${isBuiltin ? 'bg-slate-800/50 text-[var(--text-secondary)] hover:bg-slate-800 hover:text-slate-200' : 'bg-brand/10 text-brand-light hover:bg-brand/20'}"
|
||||
data-cmd="${esc(c.cmd).replace(/"/g,'"')}">
|
||||
${esc(c.name)}
|
||||
${!isBuiltin ? `<button class="hidden group-hover:inline text-slate-500 hover:text-red-400 qc-del-chip" data-id="${c.id}">×</button>` : ''}
|
||||
${!isBuiltin ? `<button class="hidden group-hover:inline text-[var(--text-muted)] hover:text-red-400 qc-del-chip" data-id="${c.id}">×</button>` : ''}
|
||||
</span>`;
|
||||
}).join('');
|
||||
// Delegate clicks
|
||||
@@ -787,7 +787,7 @@ function renderServerModal(filter) {
|
||||
`<div class="flex items-center gap-2 px-2 py-1.5 rounded text-sm cursor-pointer text-slate-300 hover:bg-slate-800 transition"
|
||||
onclick="document.getElementById('serverModal').classList.add('hidden');newSession(${s.id})">
|
||||
<span class="w-2 h-2 rounded-full shrink-0 ${s.is_online?'bg-green-400':'bg-slate-600'}"></span>
|
||||
<span class="truncate">${esc(s.name)}</span><span class="text-slate-600 text-xs">${esc(s.domain)}</span>
|
||||
<span class="truncate">${esc(s.name)}</span><span class="text-[var(--text-dim)] text-xs">${esc(s.domain)}</span>
|
||||
</div>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
Vendored
+30
@@ -0,0 +1,30 @@
|
||||
/* Nexus Theme System — CSS Custom Properties */
|
||||
/* Dark is default (matches current design). Toggle via data-theme on <html> */
|
||||
|
||||
:root, [data-theme="dark"] {
|
||||
--bg-page: #020617;
|
||||
--bg-card: #0f172a;
|
||||
--bg-surface: #1e293b;
|
||||
--bg-input: #1e293b;
|
||||
--bg-hover: #1e293b;
|
||||
--border: #1e293b;
|
||||
--border-input: #334155;
|
||||
--text-primary: #f1f5f9;
|
||||
--text-secondary: #94a3b8;
|
||||
--text-muted: #64748b;
|
||||
--text-dim: #475569;
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
--bg-page: #f8fafc;
|
||||
--bg-card: #ffffff;
|
||||
--bg-surface: #f1f5f9;
|
||||
--bg-input: #ffffff;
|
||||
--bg-hover: #e2e8f0;
|
||||
--border: #e2e8f0;
|
||||
--border-input: #cbd5e1;
|
||||
--text-primary: #0f172a;
|
||||
--text-secondary: #475569;
|
||||
--text-muted: #94a3b8;
|
||||
--text-dim: #cbd5e1;
|
||||
}
|
||||
Reference in New Issue
Block a user