1e6ac95cdb
- 新增SSH密钥库管理(集中托管私钥、自动指纹识别) - 新增模板中心(24个预置模板,7大分类,一键创建Playbook) - 新增文件分发功能(内容/文件双模式,权限设置) - 新增系统信息接口(版本/主机数/运行时间) - 主机支持密码/SSH密钥双认证方式 - 全新暗色玻璃拟态UI(侧边栏导航、SSE实时日志流) - 修复UpdateHost ID丢失bug - 添加.gitignore排除敏感数据
1453 lines
109 KiB
HTML
1453 lines
109 KiB
HTML
<!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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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> 请稍候…';
|
||
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>`,
|
||
'',`<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)};
|
||
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)} ${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 Vars(key=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----- …… -----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>
|