Files
cnbugs c161c4487a feat: 主机变量支持 - 每台主机独立BMC/IPMI配置
- 后端: inventory生成时写入主机自定义变量(host_vars)
- 前端: 主机编辑表单新增'主机变量'输入区(key=value格式)
- 模板: ipmitool-bmc改为读取主机变量,未配置时用默认值
- 示例: 每台主机设置 bmc_ip=10.10.10.x,Playbook自动区分
- 变量写入inventory: web ansible_host=... bmc_ip=10.10.10.1
2026-07-30 23:07:10 +08:00

1458 lines
110 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ansible Deploy Pro · 自动化部署平台</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%233b82f6'/%3E%3Cpath d='M16 7l8 18h-4.4l-1.8-4.4h-5.4L16 12.8 18.7 18h-3.5z' fill='white'/%3E%3C/svg%3E">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
/* ============ 基础 ============ */
*{margin:0;padding:0;box-sizing:border-box}
:root{
--bg:#0a0e17; --panel:#0d1117; --term:#0a0f16;
--card:rgba(255,255,255,.03); --border:rgba(255,255,255,.06); --border2:rgba(255,255,255,.11);
--text:#e6edf3; --dim:#8b98a9; --faint:#66748c;
--blue:#3b82f6; --green:#10b981; --red:#ef4444; --amber:#f59e0b; --violet:#8b5cf6; --cyan:#22d3ee;
--disp:'Outfit','PingFang SC','Microsoft YaHei',sans-serif;
--body:-apple-system,'PingFang SC','Hiragino Sans GB','Microsoft YaHei','Noto Sans SC',sans-serif;
--mono:'JetBrains Mono',ui-monospace,'SFMono-Regular',Consolas,monospace;
}
html{color-scheme:dark}
body{background:var(--bg);color:var(--text);font-family:var(--body);font-size:14px;line-height:1.5;overflow-x:hidden}
::selection{background:rgba(59,130,246,.35)}
::-webkit-scrollbar{width:10px;height:10px}
::-webkit-scrollbar-thumb{background:rgba(255,255,255,.12);border-radius:8px;border:2px solid transparent;background-clip:content-box}
::-webkit-scrollbar-thumb:hover{background:rgba(255,255,255,.22);background-clip:content-box}
::-webkit-scrollbar-track{background:transparent}
button{font-family:inherit}
:focus-visible{outline:2px solid rgba(96,165,250,.6);outline-offset:2px}
.mono{font-family:var(--mono)}
.dim{color:var(--dim)} .dim2{color:var(--faint);font-size:12px}
.sp{flex:1}
.np{padding:0!important}
.grow{flex:1}
.ok-t{color:#34d399} .err-t{color:#f87171} .run-t{color:#60a5fa}
@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
/* ============ 背景层 ============ */
.bg{position:fixed;inset:0;z-index:-1;background:var(--bg);overflow:hidden}
.bg::before{content:'';position:absolute;inset:0;
background-image:linear-gradient(rgba(148,163,184,.04) 1px,transparent 1px),linear-gradient(90deg,rgba(148,163,184,.04) 1px,transparent 1px);
background-size:44px 44px;
-webkit-mask-image:radial-gradient(ellipse 95% 70% at 50% 0%,#000 25%,transparent 78%);
mask-image:radial-gradient(ellipse 95% 70% at 50% 0%,#000 25%,transparent 78%)}
.glow{position:absolute;border-radius:50%;filter:blur(90px);opacity:.55;animation:drift 26s ease-in-out infinite alternate}
.glow-a{width:560px;height:560px;background:radial-gradient(circle,rgba(37,99,235,.30),transparent 65%);top:-180px;left:10%}
.glow-b{width:480px;height:480px;background:radial-gradient(circle,rgba(16,185,129,.17),transparent 65%);bottom:-160px;right:4%;animation-delay:-13s}
@keyframes drift{from{transform:translate(0,0) scale(1)}to{transform:translate(80px,60px) scale(1.18)}}
/* ============ 侧边栏 ============ */
#sidebar{position:fixed;left:0;top:0;bottom:0;width:240px;background:rgba(13,17,23,.88);border-right:1px solid var(--border);backdrop-filter:blur(20px);z-index:60;display:flex;flex-direction:column;transition:transform .3s ease}
.logo{display:flex;align-items:center;gap:12px;padding:20px 18px;border-bottom:1px solid var(--border)}
.logo-mark{width:42px;height:42px;border-radius:13px;background:linear-gradient(135deg,#3b82f6,#0ea5e9 55%,#10b981);display:flex;align-items:center;justify-content:center;flex:none;box-shadow:0 8px 24px -8px rgba(59,130,246,.65);position:relative;overflow:hidden}
.logo-mark::after{content:'';position:absolute;inset:0;background:linear-gradient(120deg,transparent 30%,rgba(255,255,255,.35) 50%,transparent 70%);transform:translateX(-100%);animation:markshine 4.5s ease-in-out infinite}
@keyframes markshine{0%,60%{transform:translateX(-100%)}100%{transform:translateX(100%)}}
.logo-mark svg{width:24px;height:24px;color:#fff;position:relative;z-index:1}
.logo-name{font-family:var(--disp);font-weight:800;font-size:16px;letter-spacing:.01em;line-height:1.2}
.logo-sub{font-size:10px;color:var(--dim);letter-spacing:.18em;text-transform:uppercase;margin-top:2px}
.nav{flex:1;overflow-y:auto;padding:12px 10px}
.nav-cap{font-size:10px;letter-spacing:.2em;color:var(--faint);padding:14px 12px 6px;font-weight:700}
.nav-item{display:flex;align-items:center;gap:11px;padding:10px 12px;border-radius:10px;color:var(--dim);cursor:pointer;font-size:13.5px;font-weight:500;border-left:2px solid transparent;margin:2px 0;transition:all .2s;user-select:none}
.nav-item svg{width:18px;height:18px;flex:none;transition:transform .2s,color .2s}
.nav-item:hover{color:var(--text);background:rgba(255,255,255,.045);transform:translateX(2px)}
.nav-item:hover svg{transform:scale(1.15);color:#60a5fa}
.nav-item.active{color:#fff;background:linear-gradient(90deg,rgba(59,130,246,.17),rgba(59,130,246,.02));border-left-color:var(--blue);box-shadow:inset 0 0 20px -12px rgba(59,130,246,.5)}
.nav-item.active svg{color:#60a5fa}
.nav-item .cnt{margin-left:auto;background:rgba(59,130,246,.16);color:#93c5fd;font-size:10.5px;padding:1px 7px;border-radius:99px;font-weight:700;font-family:var(--mono)}
.side-foot{padding:14px 16px;border-top:1px solid var(--border);font-size:11px;color:var(--faint);display:flex;align-items:center;gap:8px}
.side-foot .mono{font-size:10px}
/* ============ 主区 / 顶栏 ============ */
#main{margin-left:240px;min-height:100vh;display:flex;flex-direction:column}
#topbar{position:sticky;top:0;z-index:50;display:flex;align-items:center;gap:12px;padding:0 28px;height:64px;background:rgba(10,14,23,.72);backdrop-filter:blur(18px);border-bottom:1px solid var(--border)}
#menuBtn{display:none}
.crumb{font-family:var(--disp);font-size:17px;font-weight:700;letter-spacing:.01em}
.crumb-sub{font-size:11.5px;color:var(--dim);margin-top:1px}
.top-chip{display:inline-flex;align-items:center;gap:7px;background:rgba(255,255,255,.04);border:1px solid var(--border);border-radius:99px;padding:6px 13px;font-size:12px;color:var(--dim);white-space:nowrap;transition:border-color .2s}
.top-chip:hover{border-color:var(--border2)}
.top-chip svg{width:13px;height:13px;color:#60a5fa;flex:none}
.top-chip b{color:var(--text);font-weight:600;font-family:var(--mono);font-size:11.5px}
#tb-refresh svg{transition:transform .3s}
#tb-refresh.spinning svg{animation:rot 1s linear infinite}
#view{padding:26px 28px 70px;flex:1;width:100%;max-width:1560px;margin:0 auto}
.page{display:none}
.page.on{display:block;animation:pagein .45s cubic-bezier(.2,.7,.3,1)}
@keyframes pagein{from{opacity:0;transform:translateY(14px)}}
/* ============ 卡片 ============ */
.card{background:var(--card);border:1px solid var(--border);border-radius:16px;backdrop-filter:blur(14px);position:relative;transition:border-color .25s}
.card-h{padding:15px 20px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:12px;flex-wrap:wrap}
.card-b{padding:20px}
.ch-ico{width:36px;height:36px;border-radius:11px;display:flex;align-items:center;justify-content:center;flex:none}
.ch-ico svg{width:18px;height:18px;color:#fff}
.ch-ico.blue{background:linear-gradient(135deg,#3b82f6,#1d4ed8);box-shadow:0 6px 18px -6px rgba(59,130,246,.6)}
.ch-ico.green{background:linear-gradient(135deg,#10b981,#047857);box-shadow:0 6px 18px -6px rgba(16,185,129,.55)}
.ch-ico.amber{background:linear-gradient(135deg,#f59e0b,#b45309);box-shadow:0 6px 18px -6px rgba(245,158,11,.5)}
.ch-ico.violet{background:linear-gradient(135deg,#8b5cf6,#6d28d9);box-shadow:0 6px 18px -6px rgba(139,92,246,.55)}
.ch-ico.red{background:linear-gradient(135deg,#ef4444,#b91c1c);box-shadow:0 6px 18px -6px rgba(239,68,68,.5)}
.ch-ico.cyan{background:linear-gradient(135deg,#06b6d4,#0e7490);box-shadow:0 6px 18px -6px rgba(6,182,212,.5)}
.ch-t{font-size:14.5px;font-weight:700}
.ch-s{font-size:11.5px;color:var(--dim);margin-top:1px}
/* ============ 按钮 ============ */
.btn{display:inline-flex;align-items:center;gap:7px;border:0;border-radius:10px;padding:9px 18px;font-size:13.5px;font-weight:600;cursor:pointer;transition:transform .15s,box-shadow .2s,filter .2s,background .2s;color:#fff;white-space:nowrap}
.btn svg{width:15px;height:15px}
.btn:hover{transform:scale(1.02)}
.btn:active{transform:scale(.98)}
.btn:disabled{opacity:.5;pointer-events:none}
.btn-primary{background:linear-gradient(135deg,#3b82f6,#2563eb);box-shadow:0 6px 20px -6px rgba(59,130,246,.6)}
.btn-primary:hover{filter:brightness(1.12)}
.btn-emerald{background:linear-gradient(135deg,#10b981,#059669);box-shadow:0 6px 20px -6px rgba(16,185,129,.55)}
.btn-emerald:hover{filter:brightness(1.12)}
.btn-danger{background:linear-gradient(135deg,#ef4444,#dc2626);box-shadow:0 6px 20px -6px rgba(239,68,68,.5)}
.btn-danger:hover{filter:brightness(1.12)}
.btn-ghost{background:rgba(255,255,255,.045);border:1px solid var(--border2);color:var(--text)}
.btn-ghost:hover{background:rgba(255,255,255,.09)}
.btn-sm{padding:6px 13px;font-size:12.5px;border-radius:8px}
.btn-big{width:100%;justify-content:center;padding:13px;font-size:15px;margin-top:6px}
.btn-icon{width:32px;height:32px;padding:0;display:inline-flex;align-items:center;justify-content:center;border-radius:9px;border:1px solid transparent;background:transparent;color:var(--dim);cursor:pointer;transition:all .18s;flex:none}
.btn-icon svg{width:15px;height:15px}
.btn-icon:hover{background:rgba(59,130,246,.12);color:#60a5fa;border-color:rgba(59,130,246,.3)}
.btn-icon.danger:hover{background:rgba(239,68,68,.12);color:#f87171;border-color:rgba(239,68,68,.3)}
/* ============ 表单 ============ */
.inp{width:100%;background:rgba(10,14,23,.65);border:1px solid var(--border2);border-radius:10px;padding:9px 12px;color:var(--text);font-size:13.5px;transition:border-color .2s,box-shadow .2s;font-family:inherit}
.inp:focus{outline:none;border-color:var(--blue);box-shadow:0 0 0 3px rgba(59,130,246,.16)}
.inp::placeholder{color:var(--faint)}
select.inp{cursor:pointer}
textarea.inp{resize:vertical;line-height:1.65}
textarea.inp.mono,textarea.ed{font-family:var(--mono);font-size:12.5px}
.f-label{display:block;font-size:12.5px;color:var(--dim);margin-bottom:6px;font-weight:600}
.f-group{margin-bottom:16px}
.f-hint{font-size:11.5px;color:var(--faint);margin-top:6px;line-height:1.6}
.f-row2{display:grid;grid-template-columns:1fr 1fr;gap:14px}
.f-row3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:14px}
.f-actions{display:flex;gap:10px;margin-top:4px}
.seg{display:inline-flex;background:rgba(255,255,255,.04);border:1px solid var(--border);border-radius:10px;padding:3px;gap:3px}
.seg.wfull{display:flex;width:100%}
.seg.wfull button{flex:1}
.seg button{border:0;background:transparent;color:var(--dim);padding:7px 15px;border-radius:8px;font-size:12.5px;cursor:pointer;transition:all .2s;font-weight:600;display:inline-flex;align-items:center;justify-content:center;gap:6px}
.seg button svg{width:13px;height:13px}
.seg button.on{background:linear-gradient(135deg,rgba(59,130,246,.28),rgba(59,130,246,.12));color:#93c5fd;box-shadow:inset 0 0 0 1px rgba(59,130,246,.4)}
.sw{position:relative;width:38px;height:21px;flex:none;display:inline-block}
.sw input{display:none}
.sw i{position:absolute;inset:0;background:rgba(255,255,255,.12);border-radius:99px;transition:.25s;cursor:pointer}
.sw i::after{content:'';position:absolute;width:15px;height:15px;border-radius:50%;background:#fff;top:3px;left:3px;transition:.25s;box-shadow:0 1px 4px rgba(0,0,0,.4)}
.sw input:checked+i{background:linear-gradient(135deg,#3b82f6,#2563eb)}
.sw input:checked+i::after{left:20px}
.sw-line{display:inline-flex;align-items:center;gap:9px;font-size:13px;color:var(--dim);cursor:pointer;user-select:none}
.sw-row{display:flex;gap:22px;flex-wrap:wrap;padding:6px 0 2px}
.chips{display:flex;flex-wrap:wrap;gap:8px}
.chip{padding:6px 14px;border-radius:99px;border:1px solid var(--border2);background:rgba(255,255,255,.03);color:var(--dim);font-size:12.5px;cursor:pointer;transition:all .18s;user-select:none}
.chip:hover{border-color:rgba(59,130,246,.4);color:var(--text)}
.chip.on{background:rgba(59,130,246,.16);border-color:rgba(59,130,246,.5);color:#93c5fd;font-weight:600}
/* ============ 表格 ============ */
.tbl{width:100%;border-collapse:separate;border-spacing:0;font-size:13.5px}
.tbl th{text-align:left;padding:10px 16px;font-size:11px;letter-spacing:.1em;color:var(--dim);font-weight:700;border-bottom:1px solid var(--border);background:rgba(255,255,255,.015);white-space:nowrap}
.tbl td{padding:12px 16px;border-bottom:1px solid rgba(255,255,255,.04);vertical-align:middle}
.tbl tbody tr{transition:background .15s}
.tbl tbody tr:hover{background:rgba(59,130,246,.05)}
.tbl tbody tr:last-child td{border-bottom:none}
.tbl .ops{white-space:nowrap;text-align:right}
.tbl .ops .btn-icon{margin-left:2px}
.h-name{font-weight:600;font-size:13.5px}
.h-os{font-size:11.5px;color:var(--faint);margin-top:1px}
.gchip{display:inline-flex;align-items:center;background:rgba(139,92,246,.12);border:1px solid rgba(139,92,246,.28);color:#c4b5fd;font-size:11px;padding:2px 9px;border-radius:6px;margin:1px 3px 1px 0;font-weight:500}
.auth-chip{display:inline-flex;align-items:center;gap:5px;font-size:11.5px;padding:3px 9px;border-radius:7px;font-weight:600}
.auth-chip svg{width:11px;height:11px}
.auth-chip.key{background:rgba(245,158,11,.12);color:#fbbf24;border:1px solid rgba(245,158,11,.28)}
.auth-chip.pwd{background:rgba(59,130,246,.12);color:#93c5fd;border:1px solid rgba(59,130,246,.28)}
/* ============ 状态徽章 / 圆点 ============ */
.badge{display:inline-flex;align-items:center;gap:6px;padding:3px 10px;border-radius:99px;font-size:11.5px;font-weight:600;white-space:nowrap}
.badge .dot{width:6px;height:6px;border-radius:50%;flex:none}
.b-online{color:#34d399;background:rgba(16,185,129,.12)} .b-online .dot{background:#10b981;animation:pulse 2s infinite}
.b-offline{color:#f87171;background:rgba(239,68,68,.12)} .b-offline .dot{background:#ef4444}
.b-pending{color:#fbbf24;background:rgba(245,158,11,.12)} .b-pending .dot{background:#f59e0b;animation:pulse 2.4s infinite}
.b-running{color:#60a5fa;background:rgba(59,130,246,.13)} .b-running .dot{background:#3b82f6;animation:pulse 1.4s infinite}
.b-success{color:#34d399;background:rgba(16,185,129,.12)} .b-success .dot{background:#10b981}
.b-failed{color:#f87171;background:rgba(239,68,68,.12)} .b-failed .dot{background:#ef4444}
.b-mute{color:#94a3b8;background:rgba(148,163,184,.1)} .b-mute .dot{background:#64748b}
@keyframes pulse{0%{box-shadow:0 0 0 0 rgba(16,185,129,.55)}70%{box-shadow:0 0 0 6px transparent}100%{box-shadow:0 0 0 0 transparent}}
.sdot{width:7px;height:7px;border-radius:50%;display:inline-block;flex:none}
.s-online{background:#10b981;box-shadow:0 0 7px rgba(16,185,129,.9)}
.s-offline{background:#ef4444}
.s-pending{background:#f59e0b}
.s-unknown{background:#64748b}
/* ============ 进度条 ============ */
.prog{height:6px;background:rgba(255,255,255,.07);border-radius:99px;overflow:hidden;min-width:90px}
.prog i{display:block;height:100%;border-radius:99px;background:linear-gradient(90deg,var(--blue),var(--cyan));transition:width .6s;position:relative;overflow:hidden}
.prog i::after{content:'';position:absolute;inset:0;background:linear-gradient(90deg,transparent,rgba(255,255,255,.4),transparent);animation:shine 1.5s infinite}
@keyframes shine{from{transform:translateX(-100%)}to{transform:translateX(100%)}}
.prog.done i{background:linear-gradient(90deg,var(--green),#34d399)} .prog.done i::after{display:none}
.prog.fail i{background:linear-gradient(90deg,var(--red),#f87171)} .prog.fail i::after{display:none}
.prog.mute i{background:#475569} .prog.mute i::after{display:none}
.prog-t{margin-top:4px;font-family:var(--mono);font-size:10.5px}
/* ============ 弹窗 ============ */
.mback,.cfback{position:fixed;inset:0;background:rgba(4,7,12,.72);backdrop-filter:blur(6px);z-index:100;display:flex;align-items:center;justify-content:center;opacity:0;pointer-events:none;transition:opacity .25s;padding:20px}
.cfback{z-index:150}
.mback.show,.cfback.show{opacity:1;pointer-events:auto}
.modal{width:min(560px,94vw);max-height:88vh;display:flex;flex-direction:column;background:rgba(17,22,33,.94);border:1px solid var(--border2);border-radius:18px;box-shadow:0 30px 90px -20px rgba(0,0,0,.75);transform:translateY(26px) scale(.97);transition:transform .32s cubic-bezier(.2,.9,.3,1.15);backdrop-filter:blur(22px)}
.mback.show .modal,.cfback.show .modal{transform:none}
.modal-sm{width:min(430px,94vw)}
.modal-lg{width:min(880px,96vw)}
.modal-xl{width:min(1080px,97vw)}
.m-h{padding:17px 22px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;gap:12px;flex:none}
.m-t{font-size:15.5px;font-weight:700;font-family:var(--disp);display:flex;align-items:center;gap:9px}
.m-t svg{width:17px;height:17px;color:#60a5fa}
.m-b{padding:22px;overflow-y:auto}
.m-f{padding:15px 22px;border-top:1px solid var(--border);display:flex;align-items:center;gap:10px;flex:none}
.cf-ico{font-size:38px;text-align:center;margin-bottom:12px}
.cf-msg{font-size:13.5px;color:var(--dim);line-height:1.8;text-align:center}
.cf-msg b{color:var(--text)}
/* ============ Toast ============ */
#toasts{position:fixed;top:18px;right:18px;z-index:300;display:flex;flex-direction:column;gap:10px;max-width:min(400px,90vw)}
.toast{background:rgba(17,22,33,.96);border:1px solid var(--border2);border-left:3px solid var(--blue);border-radius:12px;padding:12px 15px;font-size:13px;box-shadow:0 14px 44px -10px rgba(0,0,0,.65);animation:tin .38s cubic-bezier(.2,.9,.3,1.15);backdrop-filter:blur(14px);display:flex;gap:10px;align-items:flex-start;line-height:1.6}
.toast .t-ico{flex:none;margin-top:1px}
.toast .t-ico svg{width:15px;height:15px}
.toast.ok{border-left-color:var(--green)} .toast.ok .t-ico{color:#34d399}
.toast.err{border-left-color:var(--red)} .toast.err .t-ico{color:#f87171}
.toast.warn{border-left-color:var(--amber)} .toast.warn .t-ico{color:#fbbf24}
.toast.info .t-ico{color:#60a5fa}
.toast.out{animation:tout .3s forwards}
@keyframes tin{from{transform:translateX(120%);opacity:0}}
@keyframes tout{to{transform:translateX(120%);opacity:0}}
/* ============ 终端 ============ */
.term{background:var(--term);border:1px solid var(--border);border-radius:12px;font-family:var(--mono);font-size:12.5px;line-height:1.7;color:#c9d6e3;overflow:hidden;display:flex;flex-direction:column}
.term-h{display:flex;align-items:center;gap:7px;padding:9px 14px;border-bottom:1px solid var(--border);background:rgba(255,255,255,.02);flex:none}
.tdot{width:10px;height:10px;border-radius:50%;flex:none}
.term-title{font-size:11px;color:var(--faint);margin-left:6px}
.term-b{padding:14px 16px;white-space:pre-wrap;word-break:break-all;overflow-y:auto;flex:1;min-height:120px}
.live{display:inline-flex;align-items:center;gap:6px;font-size:10px;font-weight:700;letter-spacing:.14em;color:#34d399}
.live::before{content:'';width:7px;height:7px;border-radius:50%;background:#10b981;animation:pulse 1.4s infinite}
.live.off{color:var(--faint)} .live.off::before{background:#64748b;animation:none}
.a-green{color:#4ade80} .a-cyan{color:#22d3ee} .a-yellow{color:#facc15} .a-red{color:#f87171} .a-dim{color:#64748b} .a-bold{font-weight:700;color:#e2e8f0}
.task-err{display:none;margin-top:12px;background:rgba(239,68,68,.1);border:1px solid rgba(239,68,68,.3);border-radius:10px;padding:11px 14px;font-size:12.5px;color:#fca5a5;font-family:var(--mono)}
.task-meta{margin-bottom:14px}
.tm-row{display:flex;align-items:center;gap:12px;flex-wrap:wrap;font-size:12.5px}
.tm-name{font-weight:700;font-size:14.5px;font-family:var(--disp)}
/* ============ 加载 / 空状态 ============ */
.spin{width:15px;height:15px;border:2px solid rgba(255,255,255,.25);border-top-color:#fff;border-radius:50%;animation:rot .7s linear infinite;display:inline-block;flex:none}
@keyframes rot{to{transform:rotate(360deg)}}
.skl{height:14px;border-radius:6px;background:linear-gradient(90deg,rgba(255,255,255,.045) 25%,rgba(255,255,255,.1) 50%,rgba(255,255,255,.045) 75%);background-size:200% 100%;animation:skl 1.4s infinite}
@keyframes skl{to{background-position:-200% 0}}
.empty{padding:52px 20px;text-align:center;color:var(--dim)}
.empty-ico{font-size:46px;margin-bottom:14px;animation:float 4s ease-in-out infinite;display:inline-block}
@keyframes float{50%{transform:translateY(-8px)}}
.empty h3{font-size:15.5px;color:var(--text);margin-bottom:6px;font-weight:700}
.empty p{font-size:12.5px;max-width:360px;margin:0 auto 18px;line-height:1.8;color:var(--dim)}
/* ============ 仪表盘 ============ */
.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin-bottom:18px}
.stat{padding:18px 20px;display:flex;align-items:center;gap:15px;transition:transform .25s,border-color .25s,box-shadow .25s;cursor:pointer}
.stat:hover{transform:translateY(-3px);border-color:rgba(255,255,255,.14);box-shadow:0 14px 34px -14px rgba(0,0,0,.6)}
.stat-ico{width:50px;height:50px;border-radius:14px;display:flex;align-items:center;justify-content:center;flex:none}
.stat-ico svg{width:24px;height:24px;color:#fff}
.si-blue{background:linear-gradient(135deg,#3b82f6,#1d4ed8);box-shadow:0 10px 26px -8px rgba(59,130,246,.6)}
.si-green{background:linear-gradient(135deg,#10b981,#047857);box-shadow:0 10px 26px -8px rgba(16,185,129,.55)}
.si-amber{background:linear-gradient(135deg,#f59e0b,#b45309);box-shadow:0 10px 26px -8px rgba(245,158,11,.5)}
.si-violet{background:linear-gradient(135deg,#8b5cf6,#6d28d9);box-shadow:0 10px 26px -8px rgba(139,92,246,.55)}
.stat-num{font-family:var(--disp);font-size:29px;font-weight:800;line-height:1.05;letter-spacing:-.01em}
.stat-lbl{font-size:12px;color:var(--dim);margin-top:3px}
.stat-sub{font-size:11px;color:var(--faint);margin-top:2px;font-family:var(--mono)}
.dash-grid{display:grid;grid-template-columns:1.9fr 1fr;gap:16px;margin-bottom:18px}
.dash-grid2{display:grid;grid-template-columns:1.9fr 1fr;gap:16px}
.dash-grid2>.card:first-child{align-self:start}
.chart-bars{display:flex;gap:6px;align-items:flex-end;height:130px;padding-top:8px}
.chart-empty{position:absolute;inset:0 0 24px 0;display:flex;align-items:center;justify-content:center;font-size:12px;color:var(--faint);pointer-events:none}
.cbar{flex:1;display:flex;align-items:flex-end;height:100%;cursor:pointer}
.cbar i{width:100%;height:var(--h);min-height:4px;background:linear-gradient(180deg,rgba(96,165,250,.95),rgba(59,130,246,.22));border-radius:5px 5px 2px 2px;transition:filter .2s;animation:grow .9s cubic-bezier(.2,.7,.3,1) backwards}
.cbar:nth-child(n) i{animation-delay:calc(var(--i)*40ms)}
.cbar i.today{background:linear-gradient(180deg,#34d399,rgba(16,185,129,.22))}
.cbar:hover i{filter:brightness(1.45)}
@keyframes grow{from{height:0}}
.chart-x{display:flex;justify-content:space-between;font-size:10.5px;color:var(--faint);margin-top:9px;font-family:var(--mono)}
.ring-wrap{display:flex;align-items:center;justify-content:center;gap:26px;padding:14px 0;flex-wrap:wrap}
.ring{transform:rotate(-90deg)}
.ring circle{fill:none;stroke-width:9;stroke-linecap:round}
.ring-fg{transition:stroke-dashoffset 1.2s cubic-bezier(.2,.7,.3,1)}
.ring-txt b{font-family:var(--disp);font-size:32px;font-weight:800;display:block;line-height:1}
.ring-txt span{font-size:12px;color:var(--dim);display:block;margin-top:5px}
.ring-txt em{font-size:11px;color:var(--faint);font-style:normal;font-family:var(--mono);display:block;margin-top:3px}
.act{display:flex;align-items:center;gap:12px;padding:11px 6px;border-radius:10px;cursor:pointer;transition:background .15s;border-bottom:1px solid rgba(255,255,255,.035)}
.act:last-child{border-bottom:none}
.act:hover{background:rgba(59,130,246,.06)}
.act-ico{width:32px;height:32px;border-radius:9px;display:flex;align-items:center;justify-content:center;flex:none}
.act-ico svg{width:15px;height:15px}
.act-ico.success,.act-ico.completed{background:rgba(16,185,129,.13);color:#34d399}
.act-ico.failed{background:rgba(239,68,68,.13);color:#f87171}
.act-ico.running{background:rgba(59,130,246,.13);color:#60a5fa}
.act-ico.cancelled,.act-ico.unknown{background:rgba(148,163,184,.1);color:#94a3b8}
.act-name{font-size:13px;font-weight:600}
.act-sub{font-size:11.5px;color:var(--faint);margin-top:1px}
.qa{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:18px}
.qa button{display:flex;align-items:center;gap:9px;padding:12px 14px;border-radius:11px;border:1px solid var(--border2);background:rgba(255,255,255,.03);color:var(--text);font-size:13px;font-weight:600;cursor:pointer;transition:all .2s}
.qa button svg{width:15px;height:15px;color:#60a5fa}
.qa button:hover{border-color:rgba(59,130,246,.5);background:rgba(59,130,246,.08);transform:translateY(-2px)}
.sysrow{display:flex;justify-content:space-between;align-items:center;padding:8px 2px;border-bottom:1px dashed rgba(255,255,255,.05);font-size:12.5px}
.sysrow:last-child{border-bottom:none}
.sysrow span{color:var(--dim)}
.sysrow b{font-family:var(--mono);font-weight:500;font-size:12px;color:var(--text);max-width:60%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
/* ============ 主机页 ============ */
.host-split{display:grid;grid-template-columns:1fr 320px;gap:16px;align-items:start}
.search{max-width:200px}
.sel-sm{max-width:150px;padding:6px 10px;font-size:12.5px}
.grp-card{border:1px solid var(--border);border-radius:12px;padding:13px 14px;margin-bottom:11px;transition:border-color .2s;background:rgba(255,255,255,.015)}
.grp-card:hover{border-color:var(--border2)}
.grp-top{display:flex;align-items:flex-start;gap:10px}
.grp-ico{width:32px;height:32px;border-radius:9px;background:rgba(16,185,129,.13);border:1px solid rgba(16,185,129,.25);display:flex;align-items:center;justify-content:center;color:#34d399;flex:none}
.grp-ico svg{width:15px;height:15px}
.grp-name{font-weight:700;font-size:13.5px;font-family:var(--mono)}
.grp-desc{font-size:11.5px;color:var(--faint);margin-top:1px}
.grp-hosts{display:flex;flex-wrap:wrap;gap:5px;margin-top:10px}
.grp-hosts .gchip{background:rgba(255,255,255,.04);border-color:var(--border2);color:var(--dim)}
/* ============ 命令执行 ============ */
.cmd-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;align-items:start}
.cmd-inp-wrap{position:relative}
.cmd-inp-wrap .prompt{position:absolute;left:12px;top:10px;color:#34d399;font-family:var(--mono);font-weight:700;font-size:13px}
.cmd-inp-wrap textarea{padding-left:30px;background:var(--term)}
.cmd-hist{display:flex;flex-wrap:wrap;gap:6px;margin-top:9px}
.hist-chip{font-family:var(--mono);font-size:11px;color:var(--dim);background:rgba(255,255,255,.04);border:1px solid var(--border);padding:3px 10px;border-radius:7px;cursor:pointer;transition:all .15s;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.hist-chip:hover{color:#93c5fd;border-color:rgba(59,130,246,.4)}
.cmd-res{border:1px solid var(--border);border-radius:12px;overflow:hidden;margin-bottom:12px;animation:pagein .35s}
.cmd-res.ok{border-color:rgba(16,185,129,.25)}
.cmd-res.fail{border-color:rgba(239,68,68,.3)}
.cmd-res-h{display:flex;align-items:center;gap:9px;padding:9px 14px;background:rgba(255,255,255,.025);font-size:12.5px}
.cmd-res pre{margin:0;padding:12px 15px;font-family:var(--mono);font-size:12px;line-height:1.7;white-space:pre-wrap;word-break:break-all;color:#b9c7d6;max-height:230px;overflow:auto;background:var(--term)}
/* ============ Playbook ============ */
.pb-wrap{display:grid;grid-template-columns:300px 1fr;gap:16px;align-items:start}
.list-scroll{max-height:calc(100vh - 220px);overflow-y:auto;padding:10px}
.pb-item{padding:11px 13px;border-radius:10px;cursor:pointer;border:1px solid transparent;transition:all .18s;display:flex;align-items:center;gap:11px;margin-bottom:4px}
.pb-item:hover{background:rgba(255,255,255,.045)}
.pb-item.on{background:rgba(59,130,246,.1);border-color:rgba(59,130,246,.4)}
.pb-ico{width:32px;height:32px;border-radius:9px;background:rgba(245,158,11,.12);border:1px solid rgba(245,158,11,.22);display:flex;align-items:center;justify-content:center;color:#fbbf24;flex:none}
.pb-ico svg{width:15px;height:15px}
.pb-item .nm{font-family:var(--mono);font-size:12.5px;font-weight:600;word-break:break-all}
.pb-item .ds{font-size:11px;color:var(--faint);margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:180px}
.ed-wrap{display:flex;border:1px solid var(--border2);border-radius:12px;overflow:hidden;background:var(--term);margin-bottom:14px;transition:box-shadow .2s,border-color .2s}
.ed-wrap:focus-within{border-color:var(--blue);box-shadow:0 0 0 3px rgba(59,130,246,.14)}
.ed-gutter{padding:13px 0;background:rgba(255,255,255,.02);color:var(--faint);font-family:var(--mono);font-size:12.5px;line-height:1.7;text-align:right;user-select:none;min-width:46px;overflow:hidden;border-right:1px solid var(--border)}
.ed-gutter div{padding:0 12px}
textarea.ed{flex:1;background:transparent;border:0;outline:none;color:#dbe6f0;padding:13px 15px;font-size:12.5px;line-height:1.7;resize:vertical;min-height:340px;tab-size:2;white-space:pre;overflow:auto}
.coll{border-top:1px solid var(--border);margin-top:4px}
.coll-h{display:flex;align-items:center;gap:8px;cursor:pointer;color:var(--dim);font-size:13px;font-weight:700;padding:12px 2px;user-select:none;transition:color .2s}
.coll-h:hover{color:var(--text)}
.coll-h svg{width:14px;height:14px;transition:transform .25s}
.coll.open .coll-h svg{transform:rotate(90deg)}
.coll-b{max-height:0;overflow:hidden;transition:max-height .4s ease}
.coll.open .coll-b{max-height:640px}
.coll-in{padding:6px 2px 10px}
.exec-bar{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:16px;align-items:start}
/* ============ 模板中心 ============ */
.cats{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:20px}
.cat{display:inline-flex;align-items:center;gap:8px;padding:8px 16px;border-radius:12px;background:rgba(255,255,255,.03);border:1px solid var(--border);cursor:pointer;font-size:13px;color:var(--dim);transition:all .2s;user-select:none}
.cat:hover{border-color:rgba(255,255,255,.18);color:var(--text);transform:translateY(-1px)}
.cat.on{background:linear-gradient(135deg,rgba(59,130,246,.22),rgba(16,185,129,.1));border-color:rgba(59,130,246,.5);color:#fff;box-shadow:0 6px 20px -8px rgba(59,130,246,.5)}
.cat .n{font-size:10.5px;background:rgba(255,255,255,.09);padding:1px 7px;border-radius:99px;font-family:var(--mono)}
.tpl-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(285px,1fr));gap:16px}
.tpl{padding:20px;display:flex;flex-direction:column;gap:11px;transition:transform .25s,border-color .25s,box-shadow .25s}
.tpl:hover{transform:translateY(-4px);border-color:rgba(59,130,246,.4);box-shadow:0 18px 44px -18px rgba(59,130,246,.3)}
.tpl-top{display:flex;align-items:center;justify-content:space-between}
.tpl-ico{width:46px;height:46px;border-radius:13px;background:linear-gradient(135deg,rgba(59,130,246,.16),rgba(16,185,129,.08));display:flex;align-items:center;justify-content:center;font-size:23px;border:1px solid rgba(59,130,246,.22)}
.tpl-cat{font-size:10.5px;color:#60a5fa;background:rgba(59,130,246,.1);padding:3px 9px;border-radius:99px;font-weight:600}
.tpl-name{font-weight:700;font-size:15px;font-family:var(--disp)}
.tpl-desc{font-size:12.5px;color:var(--dim);line-height:1.7;flex:1;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.tpl-tags{display:flex;flex-wrap:wrap;gap:6px}
.tag{font-size:10.5px;color:var(--dim);background:rgba(255,255,255,.05);border:1px solid var(--border);padding:2px 8px;border-radius:6px;font-family:var(--mono)}
.tpl-acts{display:flex;gap:8px;margin-top:2px}
/* ============ 文件分发 ============ */
.file-split{display:grid;grid-template-columns:1fr 1fr;gap:16px;align-items:start}
.dist-meta{display:flex;flex-wrap:wrap;gap:7px;margin-bottom:12px}
.dist-chip{font-size:11px;font-family:var(--mono);color:var(--dim);background:rgba(255,255,255,.04);border:1px solid var(--border);padding:3px 10px;border-radius:7px}
/* ============ SSH 密钥 ============ */
.key-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(360px,1fr));gap:13px}
.key-card{display:flex;align-items:center;gap:15px;padding:16px 18px;transition:transform .2s,border-color .2s}
.key-card:hover{border-color:rgba(245,158,11,.35);transform:translateY(-2px)}
.key-ico{width:46px;height:46px;border-radius:13px;background:linear-gradient(135deg,rgba(245,158,11,.22),rgba(245,158,11,.05));border:1px solid rgba(245,158,11,.3);display:flex;align-items:center;justify-content:center;color:#fbbf24;flex:none}
.key-ico svg{width:20px;height:20px}
.key-name{font-weight:700;font-size:14px}
.fp{font-family:var(--mono);font-size:11.5px;color:#7dd3fc;background:rgba(56,189,248,.08);padding:3px 10px;border-radius:7px;letter-spacing:.02em;display:inline-block;margin-top:5px;word-break:break-all}
.key-path{margin-top:5px}
/* ============ 主机选择器 ============ */
.picker{position:relative}
.picker-box{min-height:44px;display:flex;flex-wrap:wrap;gap:6px;align-items:center;padding:7px 36px 7px 10px;background:rgba(10,14,23,.65);border:1px solid var(--border2);border-radius:10px;cursor:pointer;transition:all .2s;position:relative}
.picker.open .picker-box{border-color:var(--blue);box-shadow:0 0 0 3px rgba(59,130,246,.16)}
.pk-ph{color:var(--faint);font-size:13px}
.pk-arrow{position:absolute;right:11px;top:50%;transform:translateY(-50%);width:14px;height:14px;color:var(--faint);transition:transform .2s;pointer-events:none}
.picker.open .pk-arrow{transform:translateY(-50%) rotate(180deg)}
.pchip{display:inline-flex;align-items:center;gap:5px;background:rgba(59,130,246,.16);border:1px solid rgba(59,130,246,.35);color:#93c5fd;font-size:12px;padding:3px 5px 3px 10px;border-radius:7px;font-weight:600;animation:pop .2s;font-family:var(--mono)}
.pchip.grp{background:rgba(16,185,129,.14);border-color:rgba(16,185,129,.35);color:#6ee7b7}
.pchip b{cursor:pointer;opacity:.65;padding:0 4px;border-radius:4px;font-weight:700}
.pchip b:hover{opacity:1;background:rgba(255,255,255,.15)}
@keyframes pop{from{transform:scale(.7);opacity:0}}
.picker-pop{position:absolute;top:calc(100% + 6px);left:0;right:0;z-index:40;background:rgba(15,20,30,.98);border:1px solid var(--border2);border-radius:12px;box-shadow:0 22px 55px -12px rgba(0,0,0,.75);max-height:300px;overflow-y:auto;display:none;backdrop-filter:blur(18px)}
.picker.open .picker-pop{display:block;animation:popdown .18s ease}
@keyframes popdown{from{opacity:0;transform:translateY(-7px)}}
.pk-cap{font-size:10px;letter-spacing:.16em;color:var(--faint);padding:11px 14px 4px;font-weight:700}
.pk-item{display:flex;align-items:center;gap:10px;padding:9px 14px;font-size:13px;cursor:pointer;transition:background .12s}
.pk-item:hover{background:rgba(59,130,246,.09)}
.pk-item .ck{width:16px;height:16px;border-radius:5px;border:1.5px solid var(--border2);display:flex;align-items:center;justify-content:center;flex:none;transition:all .15s;font-size:10px;color:transparent;font-weight:700}
.pk-item.sel{background:rgba(59,130,246,.16);box-shadow:inset 3px 0 0 var(--blue)}
.pk-item.sel .ck{background:var(--blue);border-color:var(--blue);color:#fff}
.pk-item.sel .nm{color:#93c5fd;font-weight:600}
.pk-item.sel .pk-sub{color:#7ba7e8}
.pk-item .nm{font-weight:500}
.pk-sub{margin-left:auto;font-size:11px;color:var(--faint);font-family:var(--mono);display:flex;align-items:center;gap:6px}
.pk-empty{padding:16px 14px;font-size:12px;color:var(--faint);text-align:center}
/* ============ 响应式 ============ */
@media (max-width:1250px){
.stats{grid-template-columns:repeat(2,1fr)}
.dash-grid,.dash-grid2,.host-split,.cmd-grid,.file-split{grid-template-columns:1fr}
.pb-wrap{grid-template-columns:240px 1fr}
}
@media (max-width:900px){
#sidebar{transform:translateX(-100%)}
#sidebar.open{transform:none;box-shadow:30px 0 80px rgba(0,0,0,.6)}
#main{margin-left:0}
#menuBtn{display:inline-flex}
.hide-s{display:none!important}
#view{padding:18px 14px 60px}
#topbar{padding:0 14px}
.pb-wrap,.exec-bar,.f-row2,.f-row3{grid-template-columns:1fr}
.stats{grid-template-columns:1fr 1fr}
}
@media (max-width:560px){.stats{grid-template-columns:1fr}.qa{grid-template-columns:1fr}}
</style>
</head>
<body>
<div class="bg"><div class="glow glow-a"></div><div class="glow glow-b"></div></div>
<!-- 侧边栏 -->
<aside id="sidebar">
<div class="logo">
<span class="logo-mark"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l7.5 17"/><path d="M12 3 4.5 20"/><path d="M8.2 15.2h7.6"/></svg></span>
<div><div class="logo-name">Ansible Deploy</div><div class="logo-sub">Pro · 自动化部署</div></div>
</div>
<nav class="nav">
<div class="nav-cap">概览</div>
<div class="nav-item active" data-p="dashboard"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="9" rx="1"/><rect x="14" y="3" width="7" height="5" rx="1"/><rect x="14" y="12" width="7" height="9" rx="1"/><rect x="3" y="16" width="7" height="5" rx="1"/></svg>仪表盘</div>
<div class="nav-cap">资源管理</div>
<div class="nav-item" data-p="hosts"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>主机管理<span class="cnt" id="cnt-hosts"></span></div>
<div class="nav-item" data-p="keys"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0 3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg>SSH 密钥</div>
<div class="nav-cap">自动化</div>
<div class="nav-item" data-p="command"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>命令执行</div>
<div class="nav-item" data-p="playbooks"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><polyline points="9 13 11 15 15 11"/></svg>Playbook 管理<span class="cnt" id="cnt-pb"></span></div>
<div class="nav-item" data-p="templates"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/></svg>模板中心</div>
<div class="nav-item" data-p="files"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>文件分发</div>
<div class="nav-cap">运维</div>
<div class="nav-item" data-p="tasks"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>任务中心<span class="cnt" id="cnt-tasks"></span></div>
</nav>
<div class="side-foot"><span class="sdot s-online" id="side-dot"></span><span id="side-status">连接中…</span><div class="sp"></div><span class="mono" id="side-ver"></span></div>
</aside>
<!-- 主区 -->
<div id="main">
<header id="topbar">
<button class="btn-icon" id="menuBtn" title="菜单"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg></button>
<div><div class="crumb" id="crumb">仪表盘</div><div class="crumb-sub" id="crumb-sub">运行概览与快捷入口</div></div>
<div class="sp"></div>
<span class="top-chip hide-s" id="tb-host"></span>
<span class="top-chip hide-s" id="tb-ver"></span>
<span class="top-chip" id="tb-up"></span>
<button class="btn-icon" id="tb-refresh" title="刷新全部"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg></button>
</header>
<main id="view">
<section class="page on" id="view-dashboard"></section>
<section class="page" id="view-hosts"></section>
<section class="page" id="view-command"></section>
<section class="page" id="view-playbooks"></section>
<section class="page" id="view-templates"></section>
<section class="page" id="view-files"></section>
<section class="page" id="view-keys"></section>
<section class="page" id="view-tasks"></section>
</main>
</div>
<!-- 弹窗 -->
<div class="mback" id="modal">
<div class="modal" id="modal-box">
<div class="m-h"><div class="m-t" id="modal-title"></div><button class="btn-icon" id="modal-x" title="关闭"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div>
<div class="m-b" id="modal-body"></div>
<div class="m-f" id="modal-foot" style="display:none"></div>
</div>
</div>
<!-- 确认框 -->
<div class="cfback" id="confirm">
<div class="modal modal-sm">
<div class="m-b" style="padding-top:30px"><div class="cf-ico">⚠️</div><div class="m-t" id="cf-title" style="justify-content:center;margin-bottom:10px"></div><div class="cf-msg" id="cf-msg"></div></div>
<div class="m-f" style="display:flex;justify-content:center;border-top:none;padding-top:4px"><button class="btn btn-ghost" id="cf-no">取消</button><button class="btn btn-danger" id="cf-ok">确认</button></div>
</div>
</div>
<div id="toasts"></div>
<script>
'use strict';
/* ================= 工具 ================= */
const $=(s,r=document)=>r.querySelector(s);
const $$=(s,r=document)=>[...r.querySelectorAll(s)];
const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const csv=v=>String(v||'').split(',').map(x=>x.trim()).filter(Boolean);
function validTime(iso){if(!iso)return null;const d=new Date(iso);if(isNaN(d)||d.getFullYear()<2000)return null;return d;}
function timeAgo(iso){const d=validTime(iso);if(!d)return '—';const s=(Date.now()-d.getTime())/1000;if(s<60)return '刚刚';if(s<3600)return Math.floor(s/60)+' 分钟前';if(s<86400)return Math.floor(s/3600)+' 小时前';return Math.floor(s/86400)+' 天前';}
function fmtDT(iso){const d=validTime(iso);return d?d.toLocaleString('zh-CN',{hour12:false}):'—';}
function fmtD(iso){const d=validTime(iso);return d?d.toLocaleDateString('zh-CN'):'—';}
function dur(a,b){const s=validTime(a);if(!s)return '—';const e=validTime(b)||new Date();const sec=Math.max(0,Math.round((e-s)/1000));if(sec<60)return sec+'s';if(sec<3600)return Math.floor(sec/60)+'m '+(sec%60)+'s';return Math.floor(sec/3600)+'h '+Math.floor((sec%3600)/60)+'m';}
function shortPath(p){if(!p)return '';const parts=String(p).split('/');return parts[parts.length-1];}
/* ================= 图标 ================= */
const I=(p,vb='0 0 24 24')=>`<svg viewBox="${vb}" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${p}</svg>`;
const ic={
server:I('<rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/>'),
layers:I('<polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/>'),
terminal:I('<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>'),
file:I('<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>'),
code:I('<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>'),
layout:I('<rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/>'),
send:I('<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>'),
key:I('<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0 3 3L22 7l-3-3m-3.5 3.5L19 4"/>'),
zap:I('<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>'),
activity:I('<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>'),
clock:I('<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>'),
refresh:I('<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>'),
plus:I('<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>'),
trash:I('<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>'),
edit:I('<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>'),
play:I('<polygon points="5 3 19 12 5 21 5 3"/>'),
x:I('<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>'),
check:I('<polyline points="20 6 9 17 4 12"/>'),
eye:I('<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>'),
chev:I('<polyline points="9 18 15 12 9 6"/>'),
save:I('<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/>'),
lock:I('<rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>'),
box:I('<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/>'),
list:I('<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>'),
alert:I('<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>'),
info:I('<circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/>'),
chevdown:I('<polyline points="6 9 12 15 18 9"/>'),
};
/* ================= 全局状态 ================= */
const S={hosts:[],groups:[],playbooks:[],tasks:[],keys:[],sys:null,tplCats:[],tpls:[],curPb:null,tplCat:'all'};
let cur='dashboard',cmdPicker=null,pbPicker=null,filePicker=null;
const PAGE_META={
dashboard:['仪表盘','运行概览与快捷入口'],hosts:['主机管理','Inventory 主机与分组'],
command:['命令执行','Ad-hoc 批量 Shell 命令'],playbooks:['Playbook 管理','编排 · 编辑 · 执行'],
templates:['模板中心','开箱即用的最佳实践剧本'],files:['文件分发','Copy 模块批量推送文件'],
keys:['SSH 密钥','认证密钥统一托管'],tasks:['任务中心','执行记录与实时日志']};
/* ================= API ================= */
async function api(path,opt={}){
const o={headers:{'Content-Type':'application/json'},...opt};
if(o.body!==undefined&&typeof o.body!=='string')o.body=JSON.stringify(o.body);
let r;
try{r=await fetch(path,o);}catch(e){throw new Error('网络请求失败,请确认后端服务已启动');}
let j=null;try{j=await r.json();}catch(e){}
if(!r.ok||(j&&j.code!==undefined&&j.code!==0))throw new Error((j&&j.msg)||('请求失败 (HTTP '+r.status+')'));
return j||{};
}
/* ================= Toast / 弹窗 ================= */
function toast(msg,type='ok',ms=3400){
const icons={ok:ic.check,err:ic.alert,warn:ic.alert,info:ic.info};
const el=document.createElement('div');
el.className='toast '+type;
el.innerHTML=`<span class="t-ico">${icons[type]||icons.info}</span><div>${msg}</div>`;
$('#toasts').appendChild(el);
setTimeout(()=>{el.classList.add('out');setTimeout(()=>el.remove(),320);},ms);
}
function openModal(title,body,cls='',footer=''){
$('#modal-box').className='modal '+(cls||'');
$('#modal-title').innerHTML=title;
$('#modal-body').innerHTML=body;
$('#modal-foot').innerHTML=footer;
$('#modal-foot').style.display=footer?'flex':'none';
$('#modal').classList.add('show');
}
function closeModal(){$('#modal').classList.remove('show');}
function confirmBox(title,msg,okText='确认删除'){
return new Promise(res=>{
$('#cf-title').textContent=title;$('#cf-msg').innerHTML=msg;$('#cf-ok').textContent=okText;
const el=$('#confirm');el.classList.add('show');
const done=v=>{el.classList.remove('show');$('#cf-ok').onclick=null;$('#cf-no').onclick=null;res(v);};
$('#cf-ok').onclick=()=>done(true);$('#cf-no').onclick=()=>done(false);
});
}
async function withBtn(btn,fn){
const h=btn.innerHTML;btn.disabled=true;btn.innerHTML='<span class="spin"></span>&nbsp;请稍候…';
try{return await fn();}catch(e){toast(esc(e.message||String(e)),'err');}
finally{btn.innerHTML=h;btn.disabled=false;}
}
/* ================= 通用渲染片段 ================= */
const SB={online:['在线','b-online'],offline:['离线','b-offline'],pending:['待检测','b-pending'],unknown:['未知','b-mute'],
running:['执行中','b-running'],success:['成功','b-success'],completed:['完成','b-success'],failed:['失败','b-failed'],cancelled:['已取消','b-mute']};
const statusBadge=s=>{const[x,c]=SB[s]||[s||'未知','b-mute'];return `<span class="badge ${c}"><i class="dot"></i>${x}</span>`;};
const statusIcon=s=>({success:ic.check,completed:ic.check,failed:ic.x,running:ic.zap,cancelled:ic.x}[s]||ic.activity);
const sdot=s=>`<i class="sdot s-${['online','offline','pending'].includes(s)?s:'unknown'}"></i>`;
const empty=(ico,t,d,extra='')=>`<div class="empty"><span class="empty-ico">${ico}</span><h3>${t}</h3><p>${d}</p>${extra}</div>`;
const emptyMini=(ico,t,d)=>`<div class="empty" style="padding:34px 16px"><span class="empty-ico" style="font-size:34px">${ico}</span><h3 style="font-size:14px">${t}</h3><p style="font-size:12px;margin-bottom:0">${d}</p></div>`;
const sklRows=n=>`<div style="padding:20px;display:grid;gap:14px">${[...Array(n)].map(()=>`<div class="skl" style="height:38px"></div>`).join('')}</div>`;
const sklCards=(n,h='')=>[...Array(n)].map(()=>`<div class="card" style="padding:20px;${h?`min-height:${h}`:''}"><div class="skl" style="width:46%;margin-bottom:14px"></div><div class="skl" style="width:82%"></div></div>`).join('');
function prog(t){
const done=(t.success_hosts||0)+(t.failed_hosts||0),total=t.total_hosts||0;
if(t.status==='running')return{pct:total?Math.round(done/total*100):35,cls:''};
if(t.status==='success'||t.status==='completed')return{pct:100,cls:'done'};
if(t.status==='failed')return{pct:100,cls:'fail'};
return{pct:total?Math.round(done/total*100):100,cls:'mute'};
}
/* ANSI → HTML(日志着色) */
function ansi(t){
let s=esc(t);
s=s.replace(/\x1b\[[0-9;]*m/g,m=>{
const c=m.slice(2,-1).split(';').pop();
return {0:'</span>',30:'<span class="a-dim">',31:'<span class="a-red">',32:'<span class="a-green">',
33:'<span class="a-yellow">',34:'<span class="a-cyan">',35:'<span class="a-yellow">',36:'<span class="a-cyan">',
37:'<span>','1':'<span class="a-bold">','90':'<span class="a-dim">','91':'<span class="a-red">',
'92':'<span class="a-green">','93':'<span class="a-yellow">','94':'<span class="a-cyan">'}[c]||'';
});
s=s.replace(/\x1b\[[0-9;]*[A-Za-z]/g,'');
s=s.replace(/^(PLAY|TASK|PLAY RECAP|RUNNING HANDLER)(.*)$/gm,'<span class="a-cyan a-bold">$1$2</span>');
s=s.replace(/^(ok|changed|skipping|included):/gm,'<span class="a-green">$&</span>');
s=s.replace(/^(failed|fatal|unreachable|ERROR!|WARNING!)(.*)$/gm,'<span class="a-red a-bold">$1$2</span>');
s=s.replace(/(\bok=|changed=)(\d+)/g,'<span class="a-green">$1$2</span>');
s=s.replace(/(\bfailed=|unreachable=)([1-9]\d*)/g,'<span class="a-red">$1$2</span>');
return s;
}
/* ================= 主机选择器 ================= */
class HostPicker{
constructor(mount,{groups=true,placeholder='点击选择目标主机 / 主机组'}={}){
this.groups=groups;this.sel=new Set();this.hosts=[];this.glist=[];
this.el=document.createElement('div');this.el.className='picker';
this.el.innerHTML=`<div class="picker-box" tabindex="0"><span class="pk-ph">${esc(placeholder)}</span><span class="pk-chips" style="display:contents"></span>${ic.chevdown.replace('<svg','<svg class="pk-arrow"')}</div><div class="picker-pop"></div>`;
mount.appendChild(this.el);
this.box=this.el.querySelector('.picker-box');
this.pop=this.el.querySelector('.picker-pop');
this.box.addEventListener('click',e=>{if(e.target.closest('.pchip b'))return;this.el.classList.toggle('open');if(this.el.classList.contains('open'))this.drawPop();});
document.addEventListener('click',e=>{if(!this.el.contains(e.target))this.el.classList.remove('open');});
}
async load(){
try{
const[h,g]=await Promise.all([api('/api/hosts'),this.groups?api('/api/groups'):Promise.resolve({data:[]})]);
this.hosts=h.data||[];this.glist=(g&&g.data)||[];
}catch(e){}
this.draw();
}
value(){return[...this.sel];}
draw(){
const chips=this.el.querySelector('.pk-chips');
chips.innerHTML=[...this.sel].map(v=>{
const isG=this.glist.some(g=>g.name===v);
return `<span class="pchip${isG?' grp':''}">${isG?'⬡ ':''}${esc(v)}<b data-x="${esc(v)}" title="移除">×</b></span>`;
}).join('');
chips.querySelectorAll('b').forEach(b=>b.addEventListener('click',e=>{e.stopPropagation();this.sel.delete(b.dataset.x);this.draw();}));
this.el.querySelector('.pk-ph').style.display=this.sel.size?'none':'';
}
item(v,sub,dotHtml=''){
return `<div class="pk-item${this.sel.has(v)?' sel':''}" data-v="${esc(v)}"><span class="ck">✓</span><span class="nm">${esc(v)}</span><span class="pk-sub">${dotHtml}${esc(sub)}</span></div>`;
}
drawPop(){
let h='';
if(this.groups&&this.glist.length){
h+=`<div class="pk-cap">主机组</div>`+this.glist.map(g=>this.item(g.name,(g.host_list||g.hosts||[]).length+' 台主机','')).join('');
}
h+=`<div class="pk-cap">主机</div>`;
h+=this.hosts.length
?this.hosts.map(x=>this.item(x.name,x.ip+':'+(x.port||22),sdot(x.status))).join('')
:`<div class="pk-empty">暂无主机,请先到「主机管理」添加</div>`;
this.pop.innerHTML=h;
this.pop.querySelectorAll('.pk-item').forEach(it=>it.addEventListener('click',()=>{
const v=it.dataset.v;this.sel.has(v)?this.sel.delete(v):this.sel.add(v);this.draw();this.drawPop();
}));
}
}
/* ================= 仪表盘 ================= */
async function renderDashboard(){
const v=$('#view-dashboard');
v.innerHTML=`<div class="stats" id="d-stats">${sklCards(4)}</div>
<div class="dash-grid">
<div class="card"><div class="card-h"><span class="ch-ico blue">${ic.activity}</span><div><div class="ch-t">任务趋势</div><div class="ch-s">最近 14 天任务执行数量</div></div></div><div class="card-b" id="d-chart"><div class="skl" style="height:120px"></div></div></div>
<div class="card"><div class="card-h"><span class="ch-ico green">${ic.check}</span><div><div class="ch-t">执行成功率</div><div class="ch-s">已完成任务统计</div></div></div><div class="card-b" id="d-ring"><div class="skl" style="height:120px"></div></div></div>
</div>
<div class="dash-grid2">
<div class="card"><div class="card-h"><span class="ch-ico violet">${ic.zap}</span><div><div class="ch-t">最近任务</div><div class="ch-s">点击查看实时日志</div></div><div class="sp"></div><button class="btn btn-ghost btn-sm" onclick="navigate('tasks')">${ic.list} 全部任务</button></div><div class="card-b" id="d-act" style="padding-top:8px;padding-bottom:8px"></div></div>
<div>
<div class="card" style="margin-bottom:16px"><div class="card-h"><span class="ch-ico cyan">${ic.zap}</span><div><div class="ch-t">快捷操作</div></div></div><div class="card-b"><div class="qa">
<button onclick="openHostModal()">${ic.plus} 新建主机</button>
<button onclick="navigate('command')">${ic.terminal} 执行命令</button>
<button onclick="navigate('files')">${ic.send} 文件分发</button>
<button onclick="navigate('templates')">${ic.layout} 浏览模板</button>
</div></div></div>
<div class="card"><div class="card-h"><span class="ch-ico amber">${ic.box}</span><div><div class="ch-t">系统信息</div></div></div><div class="card-b" id="d-sys" style="padding-top:10px;padding-bottom:10px"></div></div>
</div>
</div>`;
const[h,g,p,t]=await Promise.all([api('/api/hosts'),api('/api/groups'),api('/api/playbooks'),api('/api/tasks')]);
S.hosts=h.data||[];S.groups=g.data||[];S.playbooks=p.data||[];
S.tasks=(t.data||[]).slice().sort((a,b)=>new Date(b.start_time)-new Date(a.start_time));
updateNavCounts();
const online=S.hosts.filter(x=>x.status==='online').length;const onlineDot=online?'<span class="ok-t">●</span>':'<span class="dim">●</span>';
const running=S.tasks.filter(x=>x.status==='running').length;
const stat=(ico,cls,num,lbl,sub,go)=>`<div class="card stat" onclick="navigate('${go}')"><span class="stat-ico ${cls}">${ico}</span><div><div class="stat-num">${num}</div><div class="stat-lbl">${lbl}</div><div class="stat-sub">${sub}</div></div></div>`;
$('#d-stats').innerHTML=
stat(ic.server,'si-blue',S.hosts.length,'主机总数',`${onlineDot} ${online} 在线`,'hosts')+
stat(ic.layers,'si-green',S.groups.length,'主机组',`${S.hosts.length} 台主机纳管`,'hosts')+
stat(ic.file,'si-amber',S.playbooks.length,'Playbooks','YAML 编排剧本','playbooks')+
stat(ic.zap,'si-violet',S.tasks.length,'任务总数',running?`<span class="run-t">●</span> ${running} 运行中`:'暂无运行中任务','tasks');
/* 14 天趋势 */
const days=[...Array(14)].map((_,i)=>{const d=new Date();d.setHours(0,0,0,0);d.setDate(d.getDate()-(13-i));return d;});
const cnt=days.map(d=>S.tasks.filter(x=>{const s=validTime(x.start_time);return s&&s.getFullYear()===d.getFullYear()&&s.getMonth()===d.getMonth()&&s.getDate()===d.getDate();}).length);
const max=Math.max(...cnt,1);
$('#d-chart').style.position='relative';
$('#d-chart').innerHTML=`<div class="chart-bars">`+cnt.map((c,i)=>`<div class="cbar" title="${(days[i].getMonth()+1)+'/'+days[i].getDate()} · ${c} 个任务"><i class="${i===13?'today':''}" style="--h:${Math.max(c/max*100,4)}%;--i:${i}"></i></div>`).join('')+`</div><div class="chart-x"><span>14 天前</span><span>今天</span></div>${cnt.every(c=>!c)?'<div class="chart-empty">暂无任务数据 · 执行 Playbook 后自动生成趋势</div>':''}`;
/* 成功率 */
const done=S.tasks.filter(x=>['success','completed','failed','cancelled'].includes(x.status));
const ok=done.filter(x=>x.status==='success'||x.status==='completed').length;
const has=done.length>0;const pct=has?Math.round(ok/done.length*100):0;
const C=2*Math.PI*52;
$('#d-ring').innerHTML=`<div class="ring-wrap"><svg class="ring" width="140" height="140" viewBox="0 0 120 120">
<circle cx="60" cy="60" r="52" stroke="rgba(255,255,255,.06)"/>
<circle class="ring-fg" cx="60" cy="60" r="52" stroke="url(#rg)" stroke-dasharray="${C.toFixed(1)}" stroke-dashoffset="${(has?C*(1-pct/100):C).toFixed(1)}"/>
<defs><linearGradient id="rg" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#3b82f6"/><stop offset="1" stop-color="#10b981"/></linearGradient></defs>
</svg><div class="ring-txt"><b>${has?pct+'%':'—'}</b><span>${has?'任务成功率':'暂无已完成任务'}</span><em>${ok} / ${done.length} 已完成</em></div></div>`;
/* 最近任务 */
$('#d-act').innerHTML=S.tasks.length?S.tasks.slice(0,6).map(t=>`<div class="act" onclick="openTaskModal('${t.id}')">
<span class="act-ico ${t.status}">${statusIcon(t.status)}</span>
<div class="grow" style="min-width:0"><div class="act-name">${esc(t.name)}</div><div class="act-sub">${esc((t.hosts||[]).slice(0,3).join(', ')||'—')} · ${timeAgo(t.start_time)}</div></div>
${statusBadge(t.status)}</div>`).join('')
:emptyMini('🚀','还没有执行过任务','运行 Playbook 后会显示在这里');
/* 系统信息 */
const s=S.sys;
$('#d-sys').innerHTML=s?[
['平台版本','v'+(s.version||'—')],['Ansible',s.ansible_version||'—'],['主机名',s.hostname||'—'],
['操作系统',(s.os||'')+' '+(s.arch||'')],['Ansible 路径',s.ansible_path||'—'],['运行时长',s.uptime||'—']
].map(([k,val])=>`<div class="sysrow"><span>${k}</span><b title="${esc(val)}">${esc(val)}</b></div>`).join('')
:`<div style="text-align:center;padding:16px 0"><div style="font-size:26px;margin-bottom:8px">📡</div><div class="dim2" style="margin-bottom:12px">系统信息加载失败</div><button class="btn btn-ghost btn-sm" onclick="refreshAll()">${ic.refresh} 重试</button></div>`;
}
/* ================= 主机管理 ================= */
async function renderHosts(){
$('#view-hosts').innerHTML=`<div class="host-split">
<div class="card">
<div class="card-h"><span class="ch-ico blue">${ic.server}</span><div><div class="ch-t">主机列表</div><div class="ch-s" id="host-count">加载中…</div></div>
<div class="sp"></div>
<input class="inp search" id="host-search" placeholder="搜索名称 / IP…">
<select class="inp sel-sm" id="host-gfilter"><option value="">全部主机组</option></select>
<button class="btn btn-ghost btn-sm" onclick="refreshHosts()">${ic.refresh} 刷新</button>
<button class="btn btn-primary btn-sm" onclick="openHostModal()">${ic.plus} 添加主机</button>
</div>
<div class="np" id="host-table">${sklRows(4)}</div>
</div>
<div class="card">
<div class="card-h"><span class="ch-ico green">${ic.layers}</span><div><div class="ch-t">主机组</div><div class="ch-s">Inventory 分组管理</div></div><div class="sp"></div>
<button class="btn btn-ghost btn-sm" onclick="openGroupModal()">${ic.plus} 新建组</button></div>
<div class="card-b" id="group-list" style="padding-top:14px"></div>
</div>
</div>`;
$('#host-search').addEventListener('input',drawHostTable);
$('#host-gfilter').addEventListener('change',drawHostTable);
await refreshHosts();
}
async function refreshHosts(){
const[h,g]=await Promise.all([api('/api/hosts'),api('/api/groups')]);
S.hosts=h.data||[];S.groups=g.data||[];
const gf=$('#host-gfilter');
if(gf){const cur=gf.value;gf.innerHTML=`<option value="">全部主机组</option>`+S.groups.map(x=>`<option value="${esc(x.name)}">${esc(x.name)}</option>`).join('');gf.value=cur;}
updateNavCounts();drawHostTable();drawGroups();
}
function drawHostTable(){
const el=$('#host-table');if(!el)return;
const q=($('#host-search')?.value||'').toLowerCase();
const gf=$('#host-gfilter')?.value||'';
let list=S.hosts.filter(h=>(!q||h.name.toLowerCase().includes(q)||(h.ip||'').toLowerCase().includes(q))&&(!gf||(h.groups||[]).includes(gf)));
$('#host-count').textContent=`${S.hosts.length} 台主机 · ${S.hosts.filter(x=>x.status==='online').length} 台在线`;
if(!list.length){el.innerHTML=empty('🖥️',S.hosts.length?'没有匹配的主机':'还没有主机',S.hosts.length?'换个搜索条件试试':'点击「添加主机」录入第一台服务器,支持密码与 SSH 密钥两种认证方式');return;}
el.innerHTML=`<div style="overflow-x:auto"><table class="tbl"><thead><tr><th>状态</th><th>主机</th><th>地址</th><th>认证</th><th>主机组</th><th>最近检查</th><th style="text-align:right">操作</th></tr></thead><tbody>`+
list.map(h=>`<tr>
<td>${statusBadge(h.status)}</td>
<td><div class="h-name">${esc(h.name)}</div><div class="h-os">${esc(h.os||'未知系统')}</div></td>
<td><span class="mono" style="font-size:12.5px">${esc(h.ip)}</span><span class="dim2 mono">:${h.port||22}</span><div class="dim2 mono">@${esc(h.username||'—')}</div></td>
<td>${h.auth_type==='sshkey'?`<span class="auth-chip key">${ic.key}密钥</span>`:`<span class="auth-chip pwd">${ic.lock}密码</span>`}${h.ssh_key?`<div class="dim2 mono" style="margin-top:3px">${esc(h.ssh_key)}</div>`:''}</td>
<td>${(h.groups||[]).map(g=>`<span class="gchip">${esc(g)}</span>`).join('')||'<span class="dim2">—</span>'}</td>
<td class="dim2">${validTime(h.last_check)?timeAgo(h.last_check):'从未检测'}</td>
<td class="ops">
<button class="btn-icon" title="测试连接" onclick="testHost('${esc(h.id)}',this)">${ic.zap}</button>
<button class="btn-icon" title="编辑" onclick="openHostModal('${esc(h.id)}')">${ic.edit}</button>
<button class="btn-icon danger" title="删除" onclick="delHost('${esc(h.id)}','${esc(h.name)}')">${ic.trash}</button>
</td></tr>`).join('')+`</tbody></table></div>`;
}
async function testHost(id,btn){
const old=btn.innerHTML;btn.innerHTML='<span class="spin" style="width:13px;height:13px"></span>';btn.disabled=true;
try{
const r=await api(`/api/hosts/test/${id}`);const d=r.data||{};
if(d.success)toast(`连接成功 · <b class="mono">${esc(d.host||'')}</b> · 耗时 ${d.duration_ms??0}ms`,'ok');
else toast(`连接失败:${esc(String(d.output||d.error||'未知错误').slice(0,140))}`,'err',5200);
await refreshHosts();
}catch(e){toast(esc(e.message),'err');}
finally{btn.innerHTML=old;btn.disabled=false;}
}
async function delHost(id,name){
if(!await confirmBox('删除主机',`确定要删除主机 <b class="mono">${esc(name)}</b> 吗?<br>该操作将同时移除其 Inventory 配置,且不可恢复。`))return;
try{await api(`/api/hosts/${id}`,{method:'DELETE'});toast('主机已删除');await refreshHosts();}catch(e){toast(esc(e.message),'err');}
}
async function openHostModal(id){
const h=id?S.hosts.find(x=>x.id===id):null;
let keys=[];try{keys=(await api('/api/sshkeys')).data||[];}catch(e){}
openModal(h?`${ic.edit} 编辑主机`:`${ic.plus} 添加主机`,`
<div class="f-row2">
<div class="f-group"><label class="f-label">主机名称 *</label><input class="inp" id="hf-name" value="${esc(h?.name||'')}" placeholder="如 web-01"></div>
<div class="f-group"><label class="f-label">IP 地址 *</label><input class="inp mono" id="hf-ip" value="${esc(h?.ip||'')}" placeholder="192.168.1.10"></div>
</div>
<div class="f-row3">
<div class="f-group"><label class="f-label">SSH 端口</label><input class="inp mono" id="hf-port" type="number" value="${h?.port||22}"></div>
<div class="f-group"><label class="f-label">登录用户名</label><input class="inp mono" id="hf-user" value="${esc(h?.username||'root')}" placeholder="root"></div>
<div class="f-group"><label class="f-label">认证方式</label>
<div class="seg wfull" id="hf-auth">
<button type="button" data-v="password" class="${(!h||h.auth_type!=='sshkey')?'on':''}">${ic.lock}密码</button>
<button type="button" data-v="sshkey" class="${h?.auth_type==='sshkey'?'on':''}">${ic.key}密钥</button>
</div>
</div>
</div>
<div class="f-group" id="hf-pwd-w"><label class="f-label">SSH 密码</label><input class="inp" type="password" id="hf-pwd" value="${esc(h?.password||'')}" placeholder="登录密码" autocomplete="new-password"></div>
<div class="f-group" id="hf-key-w" style="display:none"><label class="f-label">SSH 密钥</label>
<select class="inp" id="hf-key"><option value="">— 选择密钥 —</option>${keys.map(k=>`<option value="${esc(k.name)}" ${h?.ssh_key===k.name?'selected':''}>${esc(k.name)}${k.fingerprint?' · '+esc(String(k.fingerprint).slice(0,30)):''}</option>`).join('')}</select>
${keys.length?'':'<div class="f-hint">⚠️ 尚未添加密钥,请先到「SSH 密钥」页面创建</div>'}
</div>
<div class="f-group" style="margin-bottom:4px"><label class="f-label">所属主机组(可多选)</label>
<div class="chips" id="hf-groups">${S.groups.map(g=>`<span class="chip ${(h?.groups||[]).includes(g.name)?'on':''}" data-g="${esc(g.name)}">${esc(g.name)}</span>`).join('')||'<span class="f-hint" style="margin:0">暂无主机组,可在主机页右侧面板创建</span>'}</div>
</div>
<div class="f-group" style="margin-bottom:4px"><label class="f-label">主机变量(每行一个 key=value,如 bmc_ip=10.10.10.1</label>
<textarea class="inp mono" id="hf-vars" rows="3" placeholder="bmc_ip=10.10.10.1&#10;bmc_user=admin&#10;bmc_password=Admin@123">${Object.entries(h?.vars||{}).map(([k,v])=>k+'='+v).join('\n')}</textarea>
<div class="f-hint">变量会写入 inventoryPlaybook 中可用 {{ '{{' }} bmc_ip {{ '}}' }} 引用</div>
</div>`,
'',`<button class="btn btn-ghost" onclick="closeModal()">取消</button><button class="btn btn-primary" id="hf-save">${h?'保存修改':'添加主机'}</button>`);
const seg=$('#hf-auth');
const sync=()=>{const v=seg.querySelector('.on').dataset.v;$('#hf-pwd-w').style.display=v==='password'?'':'none';$('#hf-key-w').style.display=v==='sshkey'?'':'none';};
seg.querySelectorAll('button').forEach(b=>b.addEventListener('click',()=>{seg.querySelectorAll('button').forEach(x=>x.classList.remove('on'));b.classList.add('on');sync();}));
sync();
$('#hf-groups').querySelectorAll('.chip').forEach(c=>c.addEventListener('click',()=>c.classList.toggle('on')));
$('#hf-save').addEventListener('click',async()=>{
const name=$('#hf-name').value.trim(),ip=$('#hf-ip').value.trim();
if(!name||!ip)return toast('请填写主机名称与 IP 地址','warn');
const auth=seg.querySelector('.on').dataset.v;
const body={name,ip,port:+$('#hf-port').value||22,username:$('#hf-user').value.trim()||'root',auth_type:auth,
password:auth==='password'?$('#hf-pwd').value:'',ssh_key:auth==='sshkey'?$('#hf-key').value:'',
groups:$$('#hf-groups .chip.on').map(c=>c.dataset.g),
vars:Object.fromEntries($('#hf-vars').value.split('\n').map(l=>l.trim()).filter(l=>l&&!l.startsWith('#')).map(l=>{const i=l.indexOf('=');return i>0?[l.slice(0,i).trim(),l.slice(i+1).trim()]:null;}).filter(Boolean))};
await withBtn($('#hf-save'),async()=>{
if(h)await api(`/api/hosts/${h.id}`,{method:'PUT',body});
else await api('/api/hosts',{method:'POST',body});
toast(h?'主机已更新':'主机添加成功,可点击 ⚡ 测试连接');closeModal();
if($('#view-hosts').classList.contains('on'))await refreshHosts();
});
});
}
/* ---- 主机组 ---- */
function drawGroups(){
const el=$('#group-list');if(!el)return;
if(!S.groups.length){el.innerHTML=emptyMini('📁','还没有主机组','创建主机组后可按组批量执行任务');return;}
el.innerHTML=S.groups.map(g=>`<div class="grp-card">
<div class="grp-top"><span class="grp-ico">${ic.layers}</span>
<div class="grow" style="min-width:0"><div class="grp-name">${esc(g.name)}</div><div class="grp-desc">${esc(g.description||'无描述')}</div></div>
<button class="btn-icon danger" title="删除组" onclick="delGroup('${esc(g.name)}')">${ic.trash}</button></div>
<div class="grp-hosts">${(g.host_list||[]).map(x=>`<span class="gchip">${sdot(x.status)}&nbsp;${esc(x.name)}</span>`).join('')||'<span class="dim2">组内暂无主机</span>'}</div>
</div>`).join('');
}
function openGroupModal(){
openModal(`${ic.plus} 新建主机组`,`
<div class="f-group"><label class="f-label">组名称 *</label><input class="inp mono" id="gf-name" placeholder="如 web / db / prod"></div>
<div class="f-group" style="margin-bottom:4px"><label class="f-label">描述</label><input class="inp" id="gf-desc" placeholder="该分组的用途说明"></div>`,
'',`<button class="btn btn-ghost" onclick="closeModal()">取消</button><button class="btn btn-primary" id="gf-save">创建</button>`);
$('#gf-save').addEventListener('click',async()=>{
const name=$('#gf-name').value.trim();
if(!name)return toast('请填写组名称','warn');
await withBtn($('#gf-save'),async()=>{
await api('/api/groups',{method:'POST',body:{name,description:$('#gf-desc').value.trim(),hosts:[]}});
toast('主机组创建成功');closeModal();await refreshHosts();
});
});
}
async function delGroup(name){
if(!await confirmBox('删除主机组',`确定要删除主机组 <b class="mono">${esc(name)}</b> 吗?组内主机不会被删除。`))return;
try{await api(`/api/groups/${encodeURIComponent(name)}`,{method:'DELETE'});toast('主机组已删除');await refreshHosts();}catch(e){toast(esc(e.message),'err');}
}
/* ================= 命令执行 ================= */
function getHist(){try{return JSON.parse(localStorage.getItem('adp-cmd-hist')||'[]');}catch(e){return[];}}
function pushHist(c){const h=getHist().filter(x=>x!==c);h.unshift(c);try{localStorage.setItem('adp-cmd-hist',JSON.stringify(h.slice(0,8)));}catch(e){}}
function drawCmdHist(){
const el=$('#cmd-hist');if(!el)return;
const h=getHist();
el.innerHTML=h.length?'<span class="dim2" style="font-size:11px;align-self:center">历史:</span>'+h.map(x=>`<span class="hist-chip" data-c="${esc(x)}">${esc(x)}</span>`).join(''):'';
el.querySelectorAll('.hist-chip').forEach(c=>c.addEventListener('click',()=>{$('#cmd-text').value=c.dataset.c;$('#cmd-text').focus();}));
}
async function renderCommand(){
$('#view-command').innerHTML=`<div class="cmd-grid">
<div class="card">
<div class="card-h"><span class="ch-ico blue">${ic.terminal}</span><div><div class="ch-t">命令执行</div><div class="ch-s">通过 Ansible ad-hoc 在目标主机上执行 Shell 命令</div></div></div>
<div class="card-b">
<div class="f-group"><label class="f-label">目标主机 *</label><div id="cmd-picker"></div></div>
<div class="f-group"><label class="f-label">Shell 命令 *</label>
<div class="cmd-inp-wrap"><span class="prompt">$</span><textarea class="inp mono" id="cmd-text" rows="3" placeholder="uptime && free -h" spellcheck="false"></textarea></div>
<div class="cmd-hist" id="cmd-hist"></div>
</div>
<div class="f-row2">
<div class="f-group"><label class="f-label">超时时间(秒)</label><input class="inp mono" id="cmd-timeout" type="number" value="30" min="1"></div>
<div class="f-group"><label class="f-label">执行模式</label><label class="sw-line" style="margin-top:7px"><span class="sw"><input type="checkbox" id="cmd-parallel" checked><i></i></span> 并行执行</label></div>
</div>
<div class="f-actions">
<button class="btn btn-primary" id="cmd-run">${ic.play} 执行命令</button>
<button class="btn btn-ghost" id="cmd-batch">${ic.list} 批量执行(后台任务)</button>
</div>
</div>
</div>
<div class="card">
<div class="card-h"><span class="ch-ico green">${ic.activity}</span><div><div class="ch-t">执行结果</div><div class="ch-s" id="cmd-summary">等待执行…</div></div><div class="sp"></div>
<button class="btn btn-ghost btn-sm" id="cmd-clear">清空</button></div>
<div class="card-b" id="cmd-out">${emptyMini('📡','暂无执行记录','左侧执行命令后,结果将实时展示在这里')}</div>
</div>
</div>`;
cmdPicker=new HostPicker($('#cmd-picker'),{groups:false,placeholder:'点击选择目标主机(可多选)'});
cmdPicker.load();
drawCmdHist();
$('#cmd-run').addEventListener('click',runCommand);
$('#cmd-batch').addEventListener('click',runBatch);
$('#cmd-clear').addEventListener('click',()=>{$('#cmd-out').innerHTML=emptyMini('🧹','已清空','');$('#cmd-summary').textContent='等待执行…';});
}
const renderCmdRes=r=>`<div class="cmd-res ${r.success?'ok':'fail'}">
<div class="cmd-res-h">${sdot(r.success?'online':'offline')}<b class="mono">${esc(r.host)}</b>
<span class="dim2 mono">exit ${r.exit_code??'-'} · ${r.duration_ms??0}ms</span><div class="sp"></div>
${r.success?'<span class="ok-t" style="font-size:12px">✓ 成功</span>':'<span class="err-t" style="font-size:12px">✗ 失败</span>'}</div>
<pre>${esc(r.output||r.error||'(无输出)')}</pre></div>`;
async function runCommand(){
const hosts=cmdPicker.value(),command=$('#cmd-text').value.trim();
if(!hosts.length)return toast('请先选择目标主机','warn');
if(!command)return toast('请输入要执行的命令','warn');
pushHist(command);drawCmdHist();
await withBtn($('#cmd-run'),async()=>{
$('#cmd-summary').innerHTML='<span class="spin" style="width:12px;height:12px"></span> 正在目标主机上执行…';
$('#cmd-out').innerHTML=`<div style="padding:8px 0"><div class="skl" style="height:64px;margin-bottom:12px"></div><div class="skl" style="height:64px"></div></div>`;
const t0=performance.now();
const r=await api('/api/command/execute',{method:'POST',body:{hosts,command,parallel:$('#cmd-parallel').checked,timeout:+$('#cmd-timeout').value||30}});
const res=r.data||[];
const ok=res.filter(x=>x.success).length;
$('#cmd-summary').innerHTML=`执行完成 · 成功 <b class="ok-t">${ok}</b> / 失败 <b class="err-t">${res.length-ok}</b> · 总耗时 ${Math.round(performance.now()-t0)}ms`;
$('#cmd-out').innerHTML=res.length?res.map(renderCmdRes).join(''):emptyMini('🤔','没有返回结果','请检查目标主机配置');
toast(ok===res.length?`全部 ${res.length} 台主机执行成功`:`执行完成:${ok} 成功 / ${res.length-ok} 失败`,ok===res.length?'ok':'warn');
});
}
async function runBatch(){
const hosts=cmdPicker.value(),command=$('#cmd-text').value.trim();
if(!hosts.length)return toast('请先选择目标主机','warn');
if(!command)return toast('请输入要执行的命令','warn');
pushHist(command);drawCmdHist();
await withBtn($('#cmd-batch'),async()=>{
const r=await api('/api/command/batch',{method:'POST',body:{hosts,command,parallel:true,timeout:+$('#cmd-timeout').value||30}});
const d=r.data||{};
toast(`批量任务已启动 · 任务 ID <b class="mono">${esc(String(d.task_id||'').slice(0,8))}</b>`,'ok',4200);
if(d.results&&d.results.length){
$('#cmd-summary').innerHTML=`批量执行完成 · 成功 <b class="ok-t">${d.success??0}</b> / 失败 <b class="err-t">${d.failed??0}</b>`;
$('#cmd-out').innerHTML=d.results.map(renderCmdRes).join('');
}
});
}
/* ================= Playbook 管理 ================= */
async function renderPlaybooks(){
$('#view-playbooks').innerHTML=`<div class="pb-wrap">
<div class="card">
<div class="card-h"><span class="ch-ico amber">${ic.file}</span><div><div class="ch-t">Playbooks</div><div class="ch-s" id="pb-count">加载中…</div></div><div class="sp"></div>
<button class="btn btn-primary btn-sm" onclick="openPbModal()">${ic.plus} 新建</button></div>
<div class="np list-scroll" id="pb-list">${sklRows(4)}</div>
</div>
<div class="card" id="pb-detail"><div class="card-b">${emptyMini('📜','选择一个 Playbook','从左侧列表选择剧本进行编辑与执行')}</div></div>
</div>`;
await loadPlaybooks();
}
async function loadPlaybooks(){
const r=await api('/api/playbooks');S.playbooks=r.data||[];updateNavCounts();
const cnt=$('#pb-count');if(cnt)cnt.textContent=`${S.playbooks.length} 个剧本`;
const el=$('#pb-list');if(!el)return;
if(!S.playbooks.length){
el.innerHTML=emptyMini('📜','暂无 Playbook','点击「新建」或到模板中心快速创建');
$('#pb-detail').innerHTML=`<div class="card-b">${empty('📜','还没有 Playbook','点击左侧「新建」手写剧本,或前往「模板中心」一键生成最佳实践剧本',`<button class="btn btn-primary btn-sm" onclick="navigate('templates')">${ic.layout} 去模板中心看看</button>`)}</div>`;
S.curPb=null;return;
}
if(!S.curPb||!S.playbooks.find(p=>p.name===S.curPb))S.curPb=S.playbooks[0].name;
el.innerHTML=S.playbooks.map(p=>`<div class="pb-item ${p.name===S.curPb?'on':''}" onclick="selectPb('${esc(p.name)}')">
<span class="pb-ico">${ic.file}</span>
<div style="min-width:0"><div class="nm">${esc(p.name)}</div><div class="ds">${esc(p.description||'无描述')}</div></div></div>`).join('');
await selectPb(S.curPb);
}
async function selectPb(name){
S.curPb=name;
$$('#pb-list .pb-item').forEach(x=>x.classList.toggle('on',x.querySelector('.nm').textContent===name));
const meta=S.playbooks.find(p=>p.name===name)||{};
const detail=$('#pb-detail');
detail.innerHTML=`<div class="card-b" style="padding:26px"><div class="skl" style="height:300px"></div></div>`;
let content='';
try{const r=await api(`/api/playbooks/${encodeURIComponent(name)}/content`);content=(r.data&&typeof r.data==='string')?r.data:(r.data?.content||'');}
catch(e){detail.innerHTML=`<div class="card-b">${empty('⚠️','内容加载失败',esc(e.message))}</div>`;return;}
detail.innerHTML=`
<div class="card-h"><span class="ch-ico blue">${ic.code}</span>
<div style="min-width:0"><div class="ch-t mono" style="font-size:13.5px">${esc(name)}.yml</div><div class="ch-s">${esc(meta.description||'YAML Playbook')}</div></div>
<div class="sp"></div>
<button class="btn btn-ghost btn-sm" id="pb-save">${ic.save} 保存</button>
<button class="btn btn-danger btn-sm" id="pb-del">${ic.trash} 删除</button>
</div>
<div class="card-b">
<div class="ed-wrap"><div class="ed-gutter" id="pb-gutter"></div><textarea class="ed" id="pb-editor" spellcheck="false">${esc(content)}</textarea></div>
<div class="coll" id="pb-adv">
<div class="coll-h">${ic.chev} 高级执行选项(Tags / 并发 / 演练模式…)</div>
<div class="coll-b"><div class="coll-in">
<div class="f-row3">
<div class="f-group"><label class="f-label">Tags</label><input class="inp mono" id="ex-tags" placeholder="deploy,nginx"></div>
<div class="f-group"><label class="f-label">Skip Tags</label><input class="inp mono" id="ex-skip" placeholder="debug"></div>
<div class="f-group"><label class="f-label">详细级别</label><select class="inp" id="ex-v"><option value="">默认</option><option value="v">-v</option><option value="vv">-vv</option><option value="vvv">-vvv</option><option value="vvvv">-vvvv</option></select></div>
</div>
<div class="f-row3">
<div class="f-group"><label class="f-label">Forks 并发数</label><input class="inp mono" id="ex-forks" type="number" placeholder="5" min="0"></div>
<div class="f-group"><label class="f-label">超时(秒)</label><input class="inp mono" id="ex-timeout" type="number" placeholder="300" min="0"></div>
<div class="f-group"><label class="f-label">附加参数</label><input class="inp mono" id="ex-args" placeholder="--limit web-01"></div>
</div>
<div class="sw-row">
<label class="sw-line"><span class="sw"><input type="checkbox" id="ex-diff"><i></i></span> Diff 显示变更</label>
<label class="sw-line"><span class="sw"><input type="checkbox" id="ex-check"><i></i></span> Check 演练模式</label>
<label class="sw-line"><span class="sw"><input type="checkbox" id="ex-become" checked><i></i></span> Become (sudo)</label>
</div>
</div></div>
</div>
<div class="exec-bar">
<div class="f-group" style="margin-bottom:0"><label class="f-label">目标主机 / 组 *</label><div id="pb-picker"></div></div>
<div class="f-group" style="margin-bottom:0"><label class="f-label">Extra Varskey=value,空格分隔)</label><input class="inp mono" id="ex-vars" placeholder="env=prod version=1.2.0"></div>
</div>
<button class="btn btn-emerald btn-big" id="pb-exec">${ic.play} 执行 Playbook</button>
</div>`;
pbPicker=new HostPicker($('#pb-picker'),{groups:true,placeholder:'点击选择执行目标(支持主机组)'});
pbPicker.load();
$('#pb-adv .coll-h').addEventListener('click',()=>$('#pb-adv').classList.toggle('open'));
const ed=$('#pb-editor'),g=$('#pb-gutter');
const syncGutter=()=>{const n=ed.value.split('\n').length;g.innerHTML=Array.from({length:n},(_,i)=>`<div>${i+1}</div>`).join('');g.scrollTop=ed.scrollTop;};
ed.addEventListener('input',syncGutter);ed.addEventListener('scroll',()=>g.scrollTop=ed.scrollTop);
ed.addEventListener('keydown',e=>{if(e.key==='Tab'){e.preventDefault();const s=ed.selectionStart;ed.value=ed.value.slice(0,s)+' '+ed.value.slice(ed.selectionEnd);ed.selectionStart=ed.selectionEnd=s+2;syncGutter();}});
syncGutter();
$('#pb-save').addEventListener('click',()=>withBtn($('#pb-save'),async()=>{
await api(`/api/playbooks/${encodeURIComponent(name)}`,{method:'PUT',body:{content:ed.value}});
toast('Playbook 已保存');loadPlaybooks();
}));
$('#pb-del').addEventListener('click',async()=>{
if(!await confirmBox('删除 Playbook',`确定要删除剧本 <b class="mono">${esc(name)}</b> 吗?文件将被移除,不可恢复。`))return;
try{await api(`/api/playbooks/${encodeURIComponent(name)}`,{method:'DELETE'});toast('Playbook 已删除');S.curPb=null;await loadPlaybooks();}catch(e){toast(esc(e.message),'err');}
});
$('#pb-exec').addEventListener('click',()=>withBtn($('#pb-exec'),async()=>{
const hosts=pbPicker.value();
if(!hosts.length)return toast('请选择目标主机或主机组','warn');
const vars={};$('#ex-vars').value.trim().split(/\s+/).filter(Boolean).forEach(kv=>{const i=kv.indexOf('=');if(i>0)vars[kv.slice(0,i)]=kv.slice(i+1);});
const r=await api('/api/playbooks/execute',{method:'POST',body:{
name,hosts,extra_vars:vars,tags:csv($('#ex-tags').value),skip_tags:csv($('#ex-skip').value),
verbose:$('#ex-v').value,diff:$('#ex-diff').checked,check:$('#ex-check').checked,become:$('#ex-become').checked,
forks:+$('#ex-forks').value||0,timeout:+$('#ex-timeout').value||0,extra_args:$('#ex-args').value.trim()}});
toast('任务已启动,正在执行…');
openTaskModal(r.taskId);
}));
}
function openPbModal(){
const sample=`---\n- name: 示例部署任务\n hosts: all\n become: true\n tasks:\n - name: 确保软件包已安装\n ansible.builtin.package:\n name: vim\n state: present\n`;
openModal(`${ic.plus} 新建 Playbook`,`
<div class="f-group"><label class="f-label">剧本名称 *(将保存为 名称.yml</label><input class="inp mono" id="npf-name" placeholder="如 deploy-nginx"></div>
<div class="f-group" style="margin-bottom:4px"><label class="f-label">YAML 内容 *</label><textarea class="inp mono" id="npf-content" rows="12" spellcheck="false">${esc(sample)}</textarea></div>`,
'',`<button class="btn btn-ghost" onclick="closeModal()">取消</button><button class="btn btn-primary" id="npf-save">创建</button>`);
$('#npf-save').addEventListener('click',async()=>{
const name=$('#npf-name').value.trim(),content=$('#npf-content').value;
if(!name)return toast('请填写剧本名称','warn');
if(!/^[a-zA-Z0-9_\-.]+$/.test(name))return toast('名称仅支持字母、数字、下划线、中划线和点','warn');
if(!content.trim())return toast('请填写 YAML 内容','warn');
await withBtn($('#npf-save'),async()=>{
await api('/api/playbooks',{method:'POST',body:{name,content}});
toast('Playbook 创建成功');closeModal();S.curPb=name;
if($('#view-playbooks').classList.contains('on'))await loadPlaybooks();else navigate('playbooks');
});
});
}
/* ================= 模板中心 ================= */
async function renderTemplates(){
$('#view-templates').innerHTML=`<div class="cats" id="tpl-cats"><div class="skl" style="width:340px;height:38px;border-radius:12px"></div></div><div class="tpl-grid" id="tpl-grid">${sklCards(6,'180px')}</div>`;
const[c,t]=await Promise.all([api('/api/templates/categories'),api('/api/templates')]);
S.tplCats=c.data||[];S.tpls=t.data||[];S.tplCat='all';
drawCats();drawTpls();
}
function drawCats(){
const el=$('#tpl-cats');if(!el)return;
el.innerHTML=`<div class="cat ${S.tplCat==='all'?'on':''}" onclick="setCat('all')">✨ 全部 <span class="n">${S.tpls.length}</span></div>`+
S.tplCats.map(c=>`<div class="cat ${S.tplCat===c.id?'on':''}" onclick="setCat('${esc(c.id)}')">${esc(c.icon||'📦')} ${esc(c.name)} <span class="n">${c.count}</span></div>`).join('');
}
function setCat(c){S.tplCat=c;drawCats();drawTpls();}
function catName(id){const c=S.tplCats.find(x=>x.id===id);return c?c.name:id;}
function drawTpls(){
const el=$('#tpl-grid');if(!el)return;
const list=S.tplCat==='all'?S.tpls:S.tpls.filter(t=>t.category===S.tplCat);
if(!list.length){el.innerHTML=`<div class="card" style="grid-column:1/-1">${empty('🗂️','该分类下暂无模板','试试其他分类,或稍后再来')}</div>`;return;}
el.innerHTML=list.map(t=>`<div class="card tpl">
<div class="tpl-top"><span class="tpl-ico">${esc(t.icon||'📄')}</span><span class="tpl-cat">${esc(catName(t.category))}</span></div>
<div class="tpl-name">${esc(t.name)}</div>
<div class="tpl-desc">${esc(t.description||'')}</div>
<div class="tpl-tags">${(t.tags||[]).map(x=>`<span class="tag">${esc(x)}</span>`).join('')}</div>
<div class="tpl-acts">
<button class="btn btn-primary btn-sm grow" onclick="useTpl('${esc(t.id)}')">${ic.zap} 使用模板</button>
<button class="btn btn-ghost btn-sm" onclick="viewTpl('${esc(t.id)}')">${ic.eye} 预览</button>
</div>
</div>`).join('');
}
async function useTpl(id){
const t=S.tpls.find(x=>x.id===id);if(!t)return;
openModal(`${ic.zap} 使用模板创建 Playbook`,`
<div style="display:flex;align-items:center;gap:12px;background:rgba(59,130,246,.07);border:1px solid rgba(59,130,246,.2);border-radius:12px;padding:13px 15px;margin-bottom:16px">
<span style="font-size:26px">${esc(t.icon||'📄')}</span>
<div><div style="font-weight:700">${esc(t.name)}</div><div class="dim2" style="margin-top:2px">${esc(t.description||'')}</div></div>
</div>
<div class="f-group" style="margin-bottom:4px"><label class="f-label">新 Playbook 名称 *</label><input class="inp mono" id="tpl-name" value="${esc(t.id)}">
<div class="f-hint">将基于模板内容创建同名 YAML 剧本,创建后可在「Playbook 管理」中编辑执行</div></div>`,
'',`<button class="btn btn-ghost" onclick="closeModal()">取消</button><button class="btn btn-primary" id="tpl-go">创建 Playbook</button>`);
$('#tpl-go').addEventListener('click',async()=>{
const name=$('#tpl-name').value.trim();
if(!name)return toast('请填写 Playbook 名称','warn');
await withBtn($('#tpl-go'),async()=>{
await api('/api/templates/deploy',{method:'POST',body:{template_id:id,playbook_name:name}});
toast(`已基于模板创建 <b class="mono">${esc(name)}</b> 🎉`);closeModal();
S.curPb=name;navigate('playbooks');
});
});
}
async function viewTpl(id){
openModal(`${ic.eye} 模板预览`,`<div style="text-align:center;padding:30px 0"><span class="spin"></span></div>`,'modal-lg');
try{
const r=await api(`/api/templates/${encodeURIComponent(id)}`);const t=r.data||{};
openModal(`${ic.eye} ${esc(t.name||'模板预览')}`,`
<div class="tpl-tags" style="margin-bottom:14px">${(t.tags||[]).map(x=>`<span class="tag">${esc(x)}</span>`).join('')}<span class="tpl-cat">${esc(catName(t.category))}</span></div>
<div class="term"><div class="term-h"><span class="tdot" style="background:#ff5f56"></span><span class="tdot" style="background:#ffbd2e"></span><span class="tdot" style="background:#27c93f"></span><span class="term-title">${esc(t.name||'')}.yml</span></div>
<div class="term-b" style="max-height:52vh">${esc(t.content||'(无内容)')}</div></div>`,'modal-lg',
`<button class="btn btn-ghost" onclick="closeModal()">关闭</button><button class="btn btn-primary" onclick="closeModal();useTpl('${esc(t.id)}')">${ic.zap} 使用此模板</button>`);
}catch(e){toast(esc(e.message),'err');closeModal();}
}
/* ================= 文件分发 ================= */
let fileMode='content';
async function renderFiles(){
$('#view-files').innerHTML=`<div class="file-split">
<div class="card">
<div class="card-h"><span class="ch-ico cyan">${ic.send}</span><div><div class="ch-t">文件分发</div><div class="ch-s">通过 Ansible copy 模块批量推送文件到目标主机</div></div></div>
<div class="card-b">
<div class="f-group"><label class="f-label">目标主机 / 组 *</label><div id="file-picker"></div></div>
<div class="f-group"><label class="f-label">文件来源</label>
<div class="seg" id="file-mode">
<button type="button" data-v="content" class="on">${ic.edit} 直接输入内容</button>
<button type="button" data-v="source">${ic.file} 服务器源文件路径</button>
</div>
</div>
<div class="f-group" id="f-content-w"><textarea class="inp mono" id="f-content" rows="7" spellcheck="false" placeholder="粘贴文件内容,例如 nginx.conf / 部署脚本…"></textarea></div>
<div class="f-group" id="f-src-w" style="display:none"><input class="inp mono" id="f-src" placeholder="控制机上的源文件绝对路径,如 /opt/conf/app.conf"></div>
<div class="f-group"><label class="f-label">目标路径 *</label><input class="inp mono" id="f-dest" placeholder="如 /etc/nginx/nginx.conf"></div>
<div class="f-row3">
<div class="f-group"><label class="f-label">Owner</label><input class="inp mono" id="f-owner" placeholder="root"></div>
<div class="f-group"><label class="f-label">Group</label><input class="inp mono" id="f-group" placeholder="root"></div>
<div class="f-group"><label class="f-label">Mode</label><input class="inp mono" id="f-mode" placeholder="0644"></div>
</div>
<button class="btn btn-primary" id="file-go" style="width:100%;justify-content:center">${ic.send} 开始分发</button>
</div>
</div>
<div class="card">
<div class="card-h"><span class="ch-ico green">${ic.activity}</span><div><div class="ch-t">分发输出</div><div class="ch-s" id="file-summary">等待任务…</div></div></div>
<div class="card-b" id="file-out">${emptyMini('📦','暂无分发记录','左侧提交分发任务后,Ansible 执行输出将实时显示在这里')}</div>
</div>
</div>`;
filePicker=new HostPicker($('#file-picker'),{groups:true,placeholder:'点击选择目标主机 / 主机组'});
filePicker.load();
fileMode='content';
$('#file-mode').querySelectorAll('button').forEach(b=>b.addEventListener('click',()=>{
$('#file-mode').querySelectorAll('button').forEach(x=>x.classList.remove('on'));b.classList.add('on');
fileMode=b.dataset.v;
$('#f-content-w').style.display=fileMode==='content'?'':'none';
$('#f-src-w').style.display=fileMode==='source'?'':'none';
}));
$('#file-go').addEventListener('click',distribute);
}
async function distribute(){
const hosts=filePicker.value(),dest=$('#f-dest').value.trim();
if(!hosts.length)return toast('请选择目标主机','warn');
if(!dest)return toast('请填写目标路径','warn');
const content=fileMode==='content'?$('#f-content').value:'';
const src=fileMode==='source'?$('#f-src').value.trim():'';
if(fileMode==='source'&&!src)return toast('请填写服务器源文件路径','warn');
await withBtn($('#file-go'),async()=>{
$('#file-summary').innerHTML='<span class="spin" style="width:12px;height:12px"></span> 正在创建分发任务…';
const r=await api('/api/files/distribute',{method:'POST',body:{hosts,content,source_file:src,dest_path:dest,
owner:$('#f-owner').value.trim(),group:$('#f-group').value.trim(),mode:$('#f-mode').value.trim()}});
toast('分发任务已启动');
showTaskInPanel(r.taskId,$('#file-out'),$('#file-summary'),`${dest} · ${hosts.length} 个目标`);
});
}
function showTaskInPanel(taskId,el,summaryEl,label){
let buf='';
el.innerHTML=`<div class="dist-meta"><span class="dist-chip">任务 #${esc(String(taskId).slice(0,8))}</span><span class="dist-chip">${esc(label)}</span><span class="sp"></span><span class="live">LIVE</span></div>
<div class="term"><div class="term-h"><span class="tdot" style="background:#ff5f56"></span><span class="tdot" style="background:#ffbd2e"></span><span class="tdot" style="background:#27c93f"></span><span class="term-title">ansible · 实时输出</span></div>
<div class="term-b" id="panel-log" style="max-height:420px"><span class="a-dim">正在连接日志流…</span></div></div>`;
const paint=()=>{const box=$('#panel-log');if(!box)return;const atEnd=box.scrollHeight-box.scrollTop-box.clientHeight<60;
box.innerHTML=ansi(buf)||'<span class="a-dim">(暂无输出)</span>';if(atEnd)box.scrollTop=box.scrollHeight;};
api(`/api/tasks/${taskId}`).then(r=>{buf=r.data?.output||'';paint();
if(r.data&&r.data.status!=='running'){finishPanel(r.data.status);}else{openStream();}
}).catch(e=>{$('#panel-log').innerHTML=`<span class="a-red">${esc(e.message)}</span>`;});
function finishPanel(status){
el.querySelector('.live')?.classList.add('off');
if(summaryEl)summaryEl.innerHTML=status==='success'||status==='completed'
?`分发完成 · <b class="ok-t">成功</b>`:`分发结束 · ${statusBadge(status)}`;
if(status==='success')toast('文件分发完成 🎉');else if(status==='failed')toast('文件分发失败','err');
}
function openStream(){
const es=new EventSource(`/api/tasks/${taskId}/stream`);
es.addEventListener('log',e=>{buf+=e.data;requestAnimationFrame(paint);});
es.addEventListener('status',e=>{es.close();finishPanel(e.data);
api(`/api/tasks/${taskId}`).then(r=>{if(r.data?.output){buf=r.data.output;paint();}}).catch(()=>{});});
es.addEventListener('error',e=>{if(e.data){const box=$('#panel-log');if(box)box.innerHTML+=`<span class="a-red">\n${esc(e.data)}</span>`;}});
es.onerror=()=>{};
}
}
/* ================= SSH 密钥 ================= */
async function renderKeys(){
$('#view-keys').innerHTML=`<div class="card">
<div class="card-h"><span class="ch-ico amber">${ic.key}</span><div><div class="ch-t">SSH 密钥</div><div class="ch-s">私钥统一托管,主机可免密认证</div></div><div class="sp"></div>
<button class="btn btn-primary btn-sm" onclick="openKeyModal()">${ic.plus} 添加密钥</button></div>
<div class="card-b" id="key-list">${sklRows(3)}</div>
</div>`;
await loadKeys();
}
async function loadKeys(){
const r=await api('/api/sshkeys');S.keys=r.data||[];
const el=$('#key-list');if(!el)return;
if(!S.keys.length){el.innerHTML=empty('🔑','还没有 SSH 密钥','添加私钥后,新增主机时即可选择「密钥认证」方式,实现免密登录与部署');return;}
el.innerHTML=`<div class="key-grid">`+S.keys.map(k=>`<div class="card key-card">
<span class="key-ico">${ic.key}</span>
<div class="grow" style="min-width:0"><div class="key-name">${esc(k.name)}</div>
<div class="fp">${esc(k.fingerprint||'— 无指纹信息 —')}</div>
<div class="dim2 key-path mono">${esc(k.path||'')} · 创建于 ${fmtD(k.created_at)}</div></div>
<button class="btn-icon danger" title="删除密钥" onclick="delKey('${esc(k.name)}')">${ic.trash}</button>
</div>`).join('')+`</div>`;
}
function openKeyModal(){
openModal(`${ic.plus} 添加 SSH 密钥`,`
<div class="f-group"><label class="f-label">密钥名称 *</label><input class="inp mono" id="kf-name" placeholder="如 prod-deploy-key"></div>
<div class="f-group" style="margin-bottom:4px"><label class="f-label">私钥内容 *</label>
<textarea class="inp mono" id="kf-key" rows="9" spellcheck="false" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----&#10;……&#10;-----END OPENSSH PRIVATE KEY-----"></textarea>
<div class="f-hint">私钥将保存在服务器受控目录中,仅用于 Ansible 连接目标主机</div></div>`,
'',`<button class="btn btn-ghost" onclick="closeModal()">取消</button><button class="btn btn-primary" id="kf-save">添加密钥</button>`);
$('#kf-save').addEventListener('click',async()=>{
const name=$('#kf-name').value.trim(),key=$('#kf-key').value;
if(!name)return toast('请填写密钥名称','warn');
if(!key.trim())return toast('请粘贴私钥内容','warn');
await withBtn($('#kf-save'),async()=>{
await api('/api/sshkeys',{method:'POST',body:{name,private_key:key}});
toast('SSH 密钥添加成功');closeModal();
if($('#view-keys').classList.contains('on'))await loadKeys();
});
});
}
async function delKey(name){
if(!await confirmBox('删除 SSH 密钥',`确定要删除密钥 <b class="mono">${esc(name)}</b> 吗?<br>依赖该密钥的主机将无法继续连接。`))return;
try{await api(`/api/sshkeys/${encodeURIComponent(name)}`,{method:'DELETE'});toast('SSH 密钥已删除');await loadKeys();}catch(e){toast(esc(e.message),'err');}
}
/* ================= 任务中心 ================= */
async function renderTasks(){
$('#view-tasks').innerHTML=`<div class="card">
<div class="card-h"><span class="ch-ico violet">${ic.zap}</span><div><div class="ch-t">任务中心</div><div class="ch-s" id="task-summary">加载中…</div></div><div class="sp"></div>
<label class="sw-line"><span class="sw"><input type="checkbox" id="task-auto" checked><i></i></span> 自动刷新</label>
<button class="btn btn-ghost btn-sm" onclick="loadTasks()">${ic.refresh} 刷新</button></div>
<div class="np" id="task-table">${sklRows(4)}</div>
</div>`;
await loadTasks();
}
async function loadTasks(){
const el=$('#task-table');if(!el)return;
const r=await api('/api/tasks');
S.tasks=(r.data||[]).slice().sort((a,b)=>new Date(b.start_time)-new Date(a.start_time));
updateNavCounts();
const running=S.tasks.filter(t=>t.status==='running').length;
const sum=$('#task-summary');
if(sum)sum.innerHTML=`${S.tasks.length} 个任务${running?` · <b class="run-t">${running} 个运行中</b>`:' · 无运行中任务'}`;
if(!S.tasks.length){el.innerHTML=empty('🚀','还没有执行过任务','运行 Playbook、批量命令或文件分发后,任务会出现在这里,支持实时日志流');return;}
el.innerHTML=`<div style="overflow-x:auto"><table class="tbl"><thead><tr><th>状态</th><th>任务</th><th>主机</th><th style="min-width:130px">进度</th><th>开始时间</th><th>耗时</th><th style="text-align:right">操作</th></tr></thead><tbody>`+
S.tasks.map(t=>{const p=prog(t);return `<tr style="cursor:pointer" onclick="openTaskModal('${esc(t.id)}')">
<td>${statusBadge(t.status)}</td>
<td><div class="h-name">${esc(t.name)}</div><div class="dim2 mono">${esc(shortPath(t.playbook)||('ID '+String(t.id).slice(0,8)))}</div></td>
<td><span class="dim">${(t.hosts||[]).length} 台</span><div class="dim2">${esc((t.hosts||[]).slice(0,2).join(', '))}${(t.hosts||[]).length>2?' …':''}</div></td>
<td><div class="prog ${p.cls}"><i style="width:${p.pct}%"></i></div><div class="dim2 prog-t">${t.success_hosts||0}${t.failed_hosts||0}${t.total_hosts?` / ${t.total_hosts}`:''}</div></td>
<td class="dim2" style="white-space:nowrap">${fmtDT(t.start_time)}</td>
<td class="dim2 mono" style="white-space:nowrap">${dur(t.start_time,t.end_time)}</td>
<td class="ops">${t.status==='running'
?`<button class="btn btn-danger btn-sm" onclick="event.stopPropagation();cancelTask('${esc(t.id)}')">取消</button>`
:`<button class="btn-icon" title="查看日志" onclick="event.stopPropagation();openTaskModal('${esc(t.id)}')">${ic.eye}</button>`}</td>
</tr>`;}).join('')+`</tbody></table></div>`;
}
async function cancelTask(id){
if(!await confirmBox('取消任务','确定要取消该任务吗?<br>正在运行的 Ansible 进程将被终止。','确认取消'))return;
try{await api(`/api/tasks/${id}`,{method:'DELETE'});toast('已发送取消请求','warn');
if($('#modal').classList.contains('show'))openTaskModal(id);
else if($('#view-tasks').classList.contains('on'))setTimeout(()=>loadTasks(),600);
}catch(e){toast(esc(e.message),'err');}
}
/* ---- 任务日志弹窗(SSE 实时流) ---- */
let taskES=null,taskBuf='',taskPaintQueued=false,curTaskId=null;
function openTaskModal(id){
curTaskId=id;taskBuf='';
openModal(`${ic.terminal} 任务日志 <span class="mono dim2" style="font-size:12px;font-weight:400">#${esc(String(id).slice(0,8))}</span>`,`
<div class="task-meta" id="task-meta"><div class="skl" style="height:20px;width:60%"></div></div>
<div class="term" style="height:min(52vh,480px)">
<div class="term-h"><span class="tdot" style="background:#ff5f56"></span><span class="tdot" style="background:#ffbd2e"></span><span class="tdot" style="background:#27c93f"></span>
<span class="term-title">ansible-playbook · 实时输出</span><div class="sp"></div><span class="live" id="task-live">LIVE</span></div>
<div class="term-b" id="task-log"><span class="a-dim">正在连接日志流…</span></div>
</div>
<div class="task-err" id="task-err"></div>`,
'modal-xl',
`<button class="btn btn-danger" id="task-cancel">${ic.x} 取消任务</button><div class="sp"></div><button class="btn btn-ghost" onclick="closeModal()">关闭</button>`);
$('#task-cancel').addEventListener('click',()=>cancelTask(id));
api(`/api/tasks/${id}`).then(r=>{
const t=r.data;if(!t)return;
paintTaskMeta(t);
taskBuf=t.output||'';paintTaskLog();
if(t.status!=='running')finishTaskUI(t.status);
else startTaskStream(id);
}).catch(e=>{const el=$('#task-log');if(el)el.innerHTML=`<span class="a-red">${esc(e.message)}</span>`;});
}
function paintTaskMeta(t){
const el=$('#task-meta');if(!el)return;
const p=prog(t);
el.innerHTML=`<div class="tm-row"><span class="tm-name">${esc(t.name)}</span>${statusBadge(t.status)}
<span class="dim2 mono">${esc((t.hosts||[]).join(', '))}</span><div class="sp"></div>
<span class="dim2">开始 ${fmtDT(t.start_time)} · 耗时 <b class="mono" id="task-dur">${dur(t.start_time,t.end_time)}</b></span></div>
<div class="prog ${p.cls}" style="margin-top:11px"><i id="task-prog" style="width:${p.pct}%"></i></div>`;
}
function paintTaskLog(){
const el=$('#task-log');if(!el)return;
const atEnd=el.scrollHeight-el.scrollTop-el.clientHeight<70;
el.innerHTML=ansi(taskBuf)||'<span class="a-dim">(暂无输出)</span>';
if(atEnd)el.scrollTop=el.scrollHeight;
}
function queueTaskPaint(){if(taskPaintQueued)return;taskPaintQueued=true;requestAnimationFrame(()=>{taskPaintQueued=false;paintTaskLog();});}
function startTaskStream(id){
stopTaskStream();
const es=new EventSource(`/api/tasks/${id}/stream`);taskES=es;
es.addEventListener('log',e=>{taskBuf+=e.data;queueTaskPaint();});
es.addEventListener('status',e=>{finishTaskUI(e.data);});
es.addEventListener('error',e=>{if(e.data){const er=$('#task-err');if(er){er.textContent='错误:'+e.data;er.style.display='block';}}});
es.onerror=()=>{};/* 服务端完成后主动关闭流,忽略 */
}
function stopTaskStream(){if(taskES){try{taskES.close();}catch(e){}taskES=null;}}
function finishTaskUI(status){
stopTaskStream();
$('#task-live')?.classList.add('off');
const cb=$('#task-cancel');if(cb)cb.style.display='none';
api(`/api/tasks/${curTaskId}`).then(r=>{
const t=r.data;if(!t)return;
paintTaskMeta(t);
if(t.output){taskBuf=t.output;paintTaskLog();}
if(t.error){const er=$('#task-err');if(er){er.textContent='错误:'+t.error;er.style.display='block';}}
}).catch(()=>{});
if(status==='success'||status==='completed')toast('任务执行成功 🎉');
else if(status==='failed')toast('任务执行失败','err');
else if(status==='cancelled')toast('任务已取消','warn');
if($('#view-tasks').classList.contains('on'))loadTasks().catch(()=>{});
}
/* ================= 顶栏 / 系统信息 ================= */
async function loadSystemInfo(){
try{const r=await api('/api/system/info');S.sys=r.data||null;}catch(e){S.sys=null;}
drawTopbar();
}
function drawTopbar(){
const s=S.sys;
$('#tb-host').innerHTML=s?`${ic.server}<b>${esc(s.hostname||'—')}</b>`:`${ic.server}<b>离线</b>`;
$('#tb-ver').innerHTML=s?`${ic.box}<b>Ansible ${esc(String(s.ansible_version||'—').split('\n')[0].replace(/^ansible\s+/i,''))}</b>`:'';
$('#tb-up').innerHTML=`${ic.clock}<b>${esc(s?.uptime||'—')}</b>`;
const foot=$('#side-status'),dot=$('#side-dot'),ver=$('#side-ver');
if(s){foot.textContent='系统运行正常';dot.className='sdot s-online';ver.textContent='v'+(s.version||'');}
else{foot.textContent='后端连接失败';dot.className='sdot s-offline';ver.textContent='';}
}
async function refreshAll(){
const b=$('#tb-refresh');b.classList.add('spinning');
await loadSystemInfo();
try{await PAGES[cur]();}catch(e){}
setTimeout(()=>b.classList.remove('spinning'),650);
toast('已刷新','info',1600);
}
function updateNavCounts(){
const set=(id,n)=>{const el=$(id);if(el)el.textContent=n>0?n:'';};
set('#cnt-hosts',S.hosts.length);
set('#cnt-pb',S.playbooks.length);
set('#cnt-tasks',S.tasks.filter(t=>t.status==='running').length);
}
/* ================= 路由 ================= */
const PAGES={dashboard:renderDashboard,hosts:renderHosts,command:renderCommand,playbooks:renderPlaybooks,
templates:renderTemplates,files:renderFiles,keys:renderKeys,tasks:renderTasks};
async function navigate(p){
cur=p;
$$('.nav-item').forEach(n=>n.classList.toggle('active',n.dataset.p===p));
$$('.page').forEach(x=>x.classList.remove('on'));
$('#view-'+p).classList.add('on');
$('#crumb').textContent=PAGE_META[p][0];
$('#crumb-sub').textContent=PAGE_META[p][1];
$('#sidebar').classList.remove('open');
try{await PAGES[p]();}
catch(e){$('#view-'+p).innerHTML=`<div class="card"><div class="card-b">${empty('⚠️','页面加载失败',esc(e.message||String(e)),`<button class="btn btn-primary btn-sm" onclick="navigate('${p}')">${ic.refresh} 重试</button>`)}</div></div>`;}
}
/* ================= 初始化 ================= */
window.addEventListener('DOMContentLoaded',async()=>{
$('#menuBtn').addEventListener('click',()=>$('#sidebar').classList.toggle('open'));
$('#modal').addEventListener('click',e=>{if(e.target.id==='modal')closeModal();});
$('#modal-x').addEventListener('click',closeModal);
$('#tb-refresh').addEventListener('click',refreshAll);
$$('.nav-item').forEach(n=>n.addEventListener('click',()=>navigate(n.dataset.p)));
document.addEventListener('keydown',e=>{
if(e.key!=='Escape')return;
if($('#confirm').classList.contains('show'))$('#cf-no').click();
else if($('#modal').classList.contains('show'))closeModal();
});
await loadSystemInfo();
setInterval(loadSystemInfo,60000);
setInterval(()=>{/* 任务页自动刷新 */
if(cur==='tasks'&&$('#task-auto')?.checked&&!$('#modal').classList.contains('show'))loadTasks().catch(()=>{});
},4000);
navigate('dashboard');
});
</script>
</body>
</html>