2026-05-21 15:50:51 +08:00
<!DOCTYPE html>
< html lang = "zh-CN" >
< head >
< meta charset = "UTF-8" >
< meta name = "viewport" content = "width=device-width,initial-scale=1.0" >
< title > 光湖服务器主控台< / title >
< style >
*{box-sizing:border-box;margin:0;padding:0}
2026-05-21 15:57:58 +08:00
:root{--bg:#080c18;--s1:#0f1623;--s2:#141e2e;--s3:#1a2640;--border:#1e3251;--accent:#4f8ef7;--text:#e2e8f0;--dim:#64748b;--ok:#34d399;--warn:#fbbf24;--err:#f87171;--r:14px;--rs:8px;--log-bg:#0a0e1a;--log-text:#a8d8a8;--log-green:#5faf5f}
2026-05-21 15:50:51 +08:00
body{background:var(--bg);color:var(--text);font-family:-apple-system,BlinkMacSystemFont,'PingFang SC','Microsoft YaHei',sans-serif;font-size:14px;padding:24px;max-width:1200px;margin:0 auto}
h1{font-size:22px;margin-bottom:4px;display:flex;align-items:center;gap:10px}
.sub{color:var(--dim);font-size:13px;margin-bottom:24px}
.dashboard{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:16px;margin-bottom:20px}
.card{background:var(--s1);border:1px solid var(--border);border-radius:var(--r);padding:16px;transition:border-color .3s}
.card.alive{border-color:rgba(52,211,153,.3)}
.card.dead{border-color:rgba(248,113,113,.3)}
.card-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:8px}
.card-name{font-size:14px;font-weight:500}
.dot{width:10px;height:10px;border-radius:50%;display:inline-block}
.dot.alive{background:var(--ok);box-shadow:0 0 8px rgba(52,211,153,.5)}
.dot.dead{background:var(--err);box-shadow:0 0 8px rgba(248,113,113,.5)}
.card-meta{display:flex;gap:12px;font-size:12px;color:var(--dim);margin-bottom:8px;flex-wrap:wrap}
.card-meta span{background:var(--s2);padding:2px 8px;border-radius:4px}
.card-status{font-size:12px}
.card-status .ok{color:var(--ok)}
.card-status .err{color:var(--err)}
.last-seen{font-size:11px;color:var(--dim);margin-top:8px}
.panel{background:var(--s1);border:1px solid var(--border);border-radius:var(--r);padding:20px;margin-bottom:20px}
.panel h2{font-size:16px;margin-bottom:12px;color:var(--accent)}
.request-box{background:var(--s2);border:1px solid var(--border);border-radius:var(--rs);padding:12px 16px;margin-bottom:8px;display:flex;justify-content:space-between;align-items:center}
.request-box .info{flex:1}
.request-box .target{font-size:14px;font-weight:500}
.request-box .reason{font-size:12px;color:var(--dim)}
.btn{padding:8px 16px;border:none;border-radius:var(--rs);font-size:13px;cursor:pointer;font-family:inherit}
.btn-ok{background:var(--ok);color:#000}
.btn-ok:hover{opacity:.85}
.btn-no{background:var(--s3);color:var(--text);border:1px solid var(--border);margin-left:8px}
.btn-no:hover{border-color:var(--err)}
.empty{color:var(--dim);font-size:13px;text-align:center;padding:20px}
.refresh-btn{background:var(--s3);border:1px solid var(--border);color:var(--text);padding:6px 14px;border-radius:var(--rs);cursor:pointer;font-size:12px;font-family:inherit}
.refresh-btn:hover{border-color:var(--accent)}
.header-bar{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px}
.header-right{display:flex;gap:10px;align-items:center}
.toast{position:fixed;bottom:24px;right:24px;background:var(--s3);border:1px solid var(--ok);border-radius:var(--rs);padding:12px 20px;font-size:13px;color:var(--ok);display:none;z-index:100;max-width:400px}
.breathing{animation:breathe 2s ease-in-out infinite}
@keyframes breathe{0%,100%{opacity:1}50%{opacity:.4}}
2026-05-21 15:57:58 +08:00
/* Operation Log */
.log-panel{background:var(--log-bg);border:1px solid var(--border);border-radius:var(--rs);min-height:180px;max-height:420px;overflow-y:auto;font-family:'SF Mono','Cascadia Code','Fira Code','Menlo',monospace;font-size:13px;line-height:1.7;color:var(--log-text);padding:12px 16px;margin-bottom:8px;white-space:pre-wrap;word-break:break-all}
.log-panel .entry{display:flex;gap:8px;align-items:flex-start;padding:2px 0}
.log-panel .time{color:var(--dim);font-size:11px;flex-shrink:0;min-width:60px}
.log-panel .tag{font-size:11px;padding:1px 6px;border-radius:3px;flex-shrink:0;min-width:36px;text-align:center}
.log-panel .tag.knock{color:var(--warn);border:1px solid var(--warn)}
.log-panel .tag.auth{color:var(--ok);border:1px solid var(--ok)}
.log-panel .tag.exec{color:var(--accent);border:1px solid var(--accent)}
.log-panel .tag.done{color:var(--ok)}
.log-panel .tag.fail{color:var(--err);border:1px solid var(--err)}
.log-panel .tag.info{color:var(--dim);border:1px solid var(--dim)}
.log-panel .msg{flex:1;font-size:13px}
.log-panel .msg .cmd{color:var(--log-green)}
.log-panel .msg .output{color:var(--log-text);display:block;padding-left:12px;white-space:pre-wrap;border-left:1px solid rgba(52,211,153,.2);margin-top:2px}
.log-panel .msg .err{color:var(--err)}
.log-empty{color:var(--dim);font-size:13px;text-align:center;padding:40px 20px}
/* Active Session Cards */
.session-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:10px;margin-bottom:12px}
.session-card{background:var(--s2);border:1px solid var(--border);border-radius:var(--rs);padding:10px 14px}
.session-card .srv{font-size:13px;font-weight:500}
.session-card .meta{font-size:11px;color:var(--dim);margin-top:4px;display:flex;gap:12px}
.session-card .dot{width:8px;height:8px;display:inline-block;margin-right:4px}
2026-05-21 15:50:51 +08:00
< / style >
< / head >
< body >
< div class = "header-bar" >
< div >
< h1 > < span > 光湖服务器主控台< / span > < / h1 >
< div class = "sub" > 铸渊看门人 · 六台服务器实时状态< / div >
< / div >
< div class = "header-right" >
< span id = "clock" style = "font-size:12px;color:var(--dim)" > < / span >
< button class = "refresh-btn" onclick = "refreshAll()" > 刷新全部< / button >
< / div >
< / div >
<!-- Dashboard -->
< div class = "dashboard" id = "dashboard" > < / div >
2026-05-21 15:57:58 +08:00
<!-- Auth Requests -->
2026-05-21 15:50:51 +08:00
< div class = "panel" id = "knock-panel" >
< h2 id = "pending-title" > 铸渊操作请求< / h2 >
< div id = "pending-requests" > < div class = "empty" > 暂无待处理的请求< / div > < / div >
< / div >
2026-05-21 15:57:58 +08:00
<!-- Operation Log (read - only) -->
< div class = "panel" >
2026-05-21 15:50:51 +08:00
< h2 style = "display:flex;align-items:center;gap:8px" >
2026-05-21 15:57:58 +08:00
< span > 铸渊操作日志< / span >
< span style = "font-size:11px;color:var(--dim);font-weight:400" > 只读 · 铸渊通过对话驱动< / span >
2026-05-21 15:50:51 +08:00
< / h2 >
2026-05-21 15:57:58 +08:00
< div id = "active-sessions" > < / div >
< div class = "log-panel" id = "log-output" >
< div class = "log-empty" > 等待铸渊发起操作...< / div >
2026-05-21 15:50:51 +08:00
< / div >
< / div >
< div id = "toast" class = "toast" > < / div >
< script >
const API='/console/api';
2026-05-21 15:57:58 +08:00
let iv, logIv;
const MAX_LOG = 200;
2026-05-21 15:50:51 +08:00
function toast(m,c){const t=document.getElementById('toast');t.textContent=m;t.style.borderColor=c||'var(--ok)';t.style.color=c||'var(--ok)';t.style.display='block';setTimeout(()=>t.style.display='none',4000)}
async function fetchS(){try{const r=await fetch(API+'/servers');return await r.json()}catch(e){return{servers:[]}}}
async function fetchP(){try{const r=await fetch(API+'/pending');return await r.json()}catch(e){return{requests:[]}}}
2026-05-21 15:57:58 +08:00
async function fetchSessions(){try{const r=await fetch(API+'/terminal/sessions');return await r.json()}catch(e){return{sessions:[]}}}
2026-05-21 15:50:51 +08:00
2026-05-21 15:57:58 +08:00
// ===== Dashboard =====
2026-05-21 15:50:51 +08:00
function renderS(data){
const d=document.getElementById('dashboard'),sv=data.servers||[];
if(!sv.length){d.innerHTML='< div class = "empty" > 正在采集...< / div > ';return}
const alive=sv.filter(s=>s.alive).length;
document.querySelector('.sub').textContent='铸渊看门人 · '+alive+'/'+sv.length+' 台在线';
d.innerHTML=sv.map(s=>{
const c=s.alive?'alive':'dead',st=s.alive?'在线':'离线',lt=s.alive?' · '+s.latency+'ms':'';
const ls=s.last_seen?new Date(s.last_seen).toLocaleTimeString('zh-CN',{hour12:false}):'-';
return '< div class = "card '+c+'" > < div class = "card-head" > < span class = "card-name" > '+s.name+'< / span > < span class = "dot '+c+(s.alive?' breathing':'')+'" > < / span > < / div > < div class = "card-meta" > < span > '+s.code+'< / span > < span > '+s.ip+'< / span > '+(s.latency?'< span > '+s.latency+'ms< / span > ':'')+'< / div > < div class = "card-status" > < span class = "'+(s.alive?'ok':'err')+'" > '+st+lt+'< / span > < / div > < div class = "last-seen" > 最后: '+ls+'< / div > < / div > '
}).join('')
}
2026-05-21 15:57:58 +08:00
// ===== Auth Requests =====
2026-05-21 15:50:51 +08:00
function renderP(data){
const d=document.getElementById('pending-requests'),req=data.requests||[],t=document.getElementById('pending-title');
if(!req.length){t.textContent='暂无操作请求';d.innerHTML='< div class = "empty" > 铸渊没有待处理的请求< / div > ';return}
t.textContent=req.length+' 条待处理';
2026-05-21 15:57:58 +08:00
d.innerHTML=req.map(r=>{
const ago=Math.floor((Date.now()-new Date(r.created_at).getTime())/1000);
return '< div class = "request-box" > < div class = "info" > < div class = "target" > '+r.target_name+'< / div > < div class = "reason" > '+r.reason+'< / div > < div style = "font-size:11px;color:var(--dim);margin-top:4px" > '+ago+'秒前< / div > < / div > < div > < button class = "btn btn-ok" onclick = "auth(\''+r.id+'\')" > 授权< / button > < button class = "btn btn-no" onclick = "rej(\''+r.id+'\')" > 拒绝< / button > < / div > < / div > '
}).join('')
2026-05-21 15:50:51 +08:00
}
async function auth(id){
2026-05-21 15:57:58 +08:00
try{const r=await fetch(API+'/authorize',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({request_id:id})});const d=await r.json();if(d.ok){toast('已授权');addLog('auth','','授权成功');refreshAll()}}catch(e){toast('失败: '+e.message,'var(--err)')}
2026-05-21 15:50:51 +08:00
}
async function rej(id){
2026-05-21 15:57:58 +08:00
try{await fetch(API+'/reject',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({request_id:id})});toast('已拒绝');addLog('fail','','已拒绝授权');refreshAll()}catch(e){}
2026-05-21 15:50:51 +08:00
}
2026-05-21 15:57:58 +08:00
// ===== Active Sessions =====
function renderSessions(data){
const container=document.getElementById('active-sessions');
const sessions=data.sessions||[];
if(!sessions.length){container.innerHTML='';return}
2026-05-21 15:50:51 +08:00
2026-05-21 15:57:58 +08:00
const statusClass={ready:'ok',awaiting_auth:'warn',rejected:'err',closed:'dim'};
const statusLabel={ready:'就绪',awaiting_auth:'等待授权',rejected:'已拒绝',closed:'已关闭'};
2026-05-21 15:50:51 +08:00
2026-05-21 15:57:58 +08:00
container.innerHTML='< div style = "font-size:12px;color:var(--dim);margin-bottom:8px" > 活跃会话:< / div > < div class = "session-grid" > '+
sessions.map(s=>{
const sc=statusClass[s.status]||'dim';
const sl=statusLabel[s.status]||s.status;
return '< div class = "session-card" > < div class = "srv" > < span class = "dot" style = "background:var(--'+sc+')" > < / span > '+s.server_name+' ('+s.server+')< / div > < div class = "meta" > < span > '+sl+'< / span > < span > '+s.age_seconds+'s< / span > < span > '+s.session_id.slice(0,16)+'...< / span > < / div > < / div > '
}).join('')+'< / div > '
2026-05-21 15:50:51 +08:00
}
2026-05-21 15:57:58 +08:00
// ===== Operation Log =====
function addLog(tag,msg,details){
const log=document.getElementById('log-output');
const empty=log.querySelector('.log-empty');
if(empty) empty.remove();
2026-05-21 15:50:51 +08:00
2026-05-21 15:57:58 +08:00
const now=new Date();
const time=now.toLocaleTimeString('zh-CN',{hour12:false,hour:'2-digit',minute:'2-digit',second:'2-digit'});
2026-05-21 15:50:51 +08:00
2026-05-21 15:57:58 +08:00
const entry=document.createElement('div');
entry.className='entry';
entry.innerHTML='< span class = "time" > '+time+'< / span > < span class = "tag '+tag+'" > '+tag+'< / span > < span class = "msg" > '+(msg?msg:'')+(details?'< span class = "output" > '+details+'< / span > ':'')+'< / span > ';
log.appendChild(entry);
2026-05-21 15:50:51 +08:00
2026-05-21 15:57:58 +08:00
while(log.children.length>MAX_LOG) log.removeChild(log.firstChild);
log.scrollTop=log.scrollHeight;
2026-05-21 15:50:51 +08:00
}
2026-05-21 15:57:58 +08:00
// ===== Poll =====
async function pollAll(){
const[s,p,sess]=await Promise.all([fetchS(),fetchP(),fetchSessions()]);
renderS(s);
renderP(p);
renderSessions(sess);
2026-05-21 15:50:51 +08:00
}
2026-05-21 15:57:58 +08:00
async function refreshAll(){await pollAll()}
2026-05-21 15:50:51 +08:00
function clock(){document.getElementById('clock').textContent=new Date().toLocaleString('zh-CN',{hour12:false})}
(async function init(){
const s=await fetchS();
renderS(s);
const p=await fetchP();
renderP(p);
2026-05-21 15:57:58 +08:00
const sess=await fetchSessions();
renderSessions(sess);
2026-05-21 15:50:51 +08:00
clock();
2026-05-21 15:57:58 +08:00
iv=setInterval(pollAll,5000);
2026-05-21 15:50:51 +08:00
setInterval(clock,1000);
})();
< / script >
< / body >
< / html >