1691 baris
73 KiB
HTML
1691 baris
73 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批量部署工具</title>
|
||
<style>
|
||
* {
|
||
margin: 0;
|
||
padding: 0;
|
||
box-sizing: border-box;
|
||
}
|
||
body {
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||
background: #0f1419;
|
||
color: #e7e9ea;
|
||
min-height: 100vh;
|
||
}
|
||
.container {
|
||
max-width: 1400px;
|
||
margin: 0 auto;
|
||
padding: 20px;
|
||
}
|
||
header {
|
||
background: linear-gradient(135deg, #1a1f2e 0%, #2d3748 100%);
|
||
padding: 20px 0;
|
||
border-bottom: 1px solid #38444d;
|
||
}
|
||
.header-content {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
h1 {
|
||
font-size: 24px;
|
||
color: #00d4aa;
|
||
}
|
||
.nav {
|
||
display: flex;
|
||
gap: 10px;
|
||
}
|
||
.nav button {
|
||
background: #2d3748;
|
||
color: #e7e9ea;
|
||
border: 1px solid #38444d;
|
||
padding: 8px 16px;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
transition: all 0.3s;
|
||
}
|
||
.nav button:hover, .nav button.active {
|
||
background: #00d4aa;
|
||
color: #0f1419;
|
||
border-color: #00d4aa;
|
||
}
|
||
.card {
|
||
background: #1a1f2e;
|
||
border-radius: 12px;
|
||
padding: 20px;
|
||
margin-bottom: 20px;
|
||
border: 1px solid #38444d;
|
||
}
|
||
.card h2 {
|
||
font-size: 18px;
|
||
margin-bottom: 15px;
|
||
color: #00d4aa;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
.grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||
gap: 20px;
|
||
}
|
||
.stat-card {
|
||
background: linear-gradient(135deg, #1a1f2e 0%, #252d3d 100%);
|
||
border-radius: 12px;
|
||
padding: 20px;
|
||
border: 1px solid #38444d;
|
||
}
|
||
.stat-card h3 {
|
||
font-size: 14px;
|
||
color: #8899a6;
|
||
margin-bottom: 8px;
|
||
}
|
||
.stat-card .value {
|
||
font-size: 32px;
|
||
font-weight: bold;
|
||
color: #00d4aa;
|
||
}
|
||
.table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
}
|
||
.table th, .table td {
|
||
padding: 12px;
|
||
text-align: left;
|
||
border-bottom: 1px solid #38444d;
|
||
}
|
||
.table th {
|
||
color: #8899a6;
|
||
font-weight: 500;
|
||
}
|
||
.table tr:hover {
|
||
background: rgba(0, 212, 170, 0.1);
|
||
}
|
||
.status {
|
||
display: inline-block;
|
||
padding: 4px 10px;
|
||
border-radius: 20px;
|
||
font-size: 12px;
|
||
}
|
||
.status.online { background: #00d4aa; color: #0f1419; }
|
||
.status.offline { background: #f4212e; color: #fff; }
|
||
.status.pending { background: #ffd400; color: #0f1419; }
|
||
.status.running { background: #1d9bf0; color: #fff; }
|
||
.status.completed { background: #00d4aa; color: #0f1419; }
|
||
.status.failed { background: #f4212e; color: #fff; }
|
||
.btn {
|
||
padding: 6px 14px;
|
||
border-radius: 6px;
|
||
border: none;
|
||
cursor: pointer;
|
||
font-size: 13px;
|
||
transition: all 0.3s;
|
||
margin-right: 4px;
|
||
}
|
||
.btn-primary {
|
||
background: #00d4aa;
|
||
color: #0f1419;
|
||
}
|
||
.btn-primary:hover {
|
||
background: #00e6b8;
|
||
}
|
||
.btn-warning {
|
||
background: #f59e0b;
|
||
color: #0f1419;
|
||
}
|
||
.btn-warning:hover {
|
||
background: #fbbf24;
|
||
}
|
||
.btn-danger {
|
||
background: #f4212e;
|
||
color: #fff;
|
||
}
|
||
.btn-danger:hover {
|
||
background: #ff5c5c;
|
||
}
|
||
.btn-info {
|
||
background: #1d9bf0;
|
||
color: #fff;
|
||
}
|
||
.btn-info:hover {
|
||
background: #4db8ff;
|
||
}
|
||
.btn:disabled {
|
||
opacity: 0.5;
|
||
cursor: not-allowed;
|
||
}
|
||
.form-group {
|
||
margin-bottom: 15px;
|
||
}
|
||
.form-group label {
|
||
display: block;
|
||
margin-bottom: 5px;
|
||
color: #8899a6;
|
||
}
|
||
.form-group input, .form-group textarea, .form-group select {
|
||
width: 100%;
|
||
padding: 10px;
|
||
border-radius: 6px;
|
||
border: 1px solid #38444d;
|
||
background: #2d3748;
|
||
color: #e7e9ea;
|
||
font-size: 14px;
|
||
}
|
||
.form-group input:focus, .form-group textarea:focus {
|
||
outline: none;
|
||
border-color: #00d4aa;
|
||
}
|
||
.terminal {
|
||
background: #0a0f14;
|
||
border-radius: 8px;
|
||
padding: 15px;
|
||
font-family: 'Monaco', 'Menlo', monospace;
|
||
font-size: 13px;
|
||
max-height: 400px;
|
||
overflow-y: auto;
|
||
}
|
||
.terminal .line {
|
||
padding: 2px 0;
|
||
white-space: pre-wrap;
|
||
word-break: break-all;
|
||
}
|
||
.terminal .success { color: #00d4aa; }
|
||
.terminal .error { color: #f4212e; }
|
||
.terminal .info { color: #1d9bf0; }
|
||
.tabs {
|
||
display: flex;
|
||
gap: 5px;
|
||
margin-bottom: 15px;
|
||
}
|
||
.tab {
|
||
padding: 10px 20px;
|
||
background: #2d3748;
|
||
border: 1px solid #38444d;
|
||
border-radius: 6px 6px 0 0;
|
||
cursor: pointer;
|
||
color: #8899a6;
|
||
}
|
||
.tab.active {
|
||
background: #1a1f2e;
|
||
color: #00d4aa;
|
||
border-bottom-color: #1a1f2e;
|
||
}
|
||
.modal {
|
||
display: none;
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
background: rgba(0, 0, 0, 0.8);
|
||
z-index: 1000;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
.modal.active {
|
||
display: flex;
|
||
}
|
||
.modal-content {
|
||
background: #1a1f2e;
|
||
border-radius: 12px;
|
||
padding: 30px;
|
||
max-width: 600px;
|
||
width: 90%;
|
||
max-height: 90vh;
|
||
overflow-y: auto;
|
||
border: 1px solid #38444d;
|
||
}
|
||
.modal-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
margin-bottom: 20px;
|
||
}
|
||
.modal-header h2 {
|
||
color: #00d4aa;
|
||
}
|
||
.close {
|
||
background: none;
|
||
border: none;
|
||
color: #8899a6;
|
||
font-size: 24px;
|
||
cursor: pointer;
|
||
}
|
||
.close:hover {
|
||
color: #e7e9ea;
|
||
}
|
||
.checkbox-group {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 10px;
|
||
}
|
||
.checkbox-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
background: #2d3748;
|
||
padding: 8px 12px;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
}
|
||
.checkbox-item input {
|
||
width: auto;
|
||
}
|
||
.progress-bar {
|
||
height: 8px;
|
||
background: #2d3748;
|
||
border-radius: 4px;
|
||
overflow: hidden;
|
||
}
|
||
.progress-bar .fill {
|
||
height: 100%;
|
||
background: #00d4aa;
|
||
transition: width 0.3s;
|
||
}
|
||
@keyframes spin {
|
||
to { transform: rotate(360deg); }
|
||
}
|
||
.spinner {
|
||
display: inline-block;
|
||
width: 16px;
|
||
height: 16px;
|
||
border: 2px solid #38444d;
|
||
border-top-color: #00d4aa;
|
||
border-radius: 50%;
|
||
animation: spin 1s linear infinite;
|
||
vertical-align: middle;
|
||
}
|
||
.toast {
|
||
position: fixed;
|
||
top: 20px;
|
||
right: 20px;
|
||
padding: 15px 25px;
|
||
border-radius: 8px;
|
||
color: #fff;
|
||
font-size: 14px;
|
||
z-index: 9999;
|
||
transition: all 0.3s;
|
||
opacity: 0;
|
||
transform: translateX(100px);
|
||
}
|
||
.toast.show {
|
||
opacity: 1;
|
||
transform: translateX(0);
|
||
}
|
||
.toast.success { background: #00d4aa; color: #0f1419; }
|
||
.toast.error { background: #f4212e; }
|
||
.toast.info { background: #1d9bf0; }
|
||
.action-group {
|
||
display: flex;
|
||
gap: 4px;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header>
|
||
<div class="container header-content">
|
||
<h1>⚡ Ansible批量部署工具</h1>
|
||
<div class="nav">
|
||
<button class="active" onclick="showTab('dashboard')">📊 仪表盘</button>
|
||
<button onclick="showTab('hosts')">🖥️ 主机管理</button>
|
||
<button onclick="showTab('command')">💻 命令执行</button>
|
||
<button onclick="showTab('playbook')">📜 Playbook</button>
|
||
<button onclick="showTab('tasks')">📋 任务列表</button>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<main class="container">
|
||
<!-- 仪表盘 -->
|
||
<div id="dashboard" class="tab-content">
|
||
<div class="grid">
|
||
<div class="stat-card">
|
||
<h3>主机总数</h3>
|
||
<div class="value" id="totalHosts">0</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<h3>在线主机</h3>
|
||
<div class="value" id="onlineHosts">0</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<h3>离线主机</h3>
|
||
<div class="value" id="offlineHosts">0</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<h3>运行中任务</h3>
|
||
<div class="value" id="runningTasks">0</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card" style="margin-top: 20px;">
|
||
<h2>📝 最近任务</h2>
|
||
<table class="table" id="recentTasks">
|
||
<thead>
|
||
<tr>
|
||
<th>任务名称</th>
|
||
<th>状态</th>
|
||
<th>进度</th>
|
||
<th>开始时间</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="taskTableBody">
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 主机管理 -->
|
||
<div id="hosts" class="tab-content" style="display: none;">
|
||
<div class="card">
|
||
<h2>🖥️ 主机列表 <button class="btn btn-primary" onclick="showAddHostModal()" style="float: right;">+ 添加主机</button></h2>
|
||
<table class="table">
|
||
<thead>
|
||
<tr>
|
||
<th>名称</th>
|
||
<th>IP地址</th>
|
||
<th>端口</th>
|
||
<th>用户名</th>
|
||
<th>状态</th>
|
||
<th>主机组</th>
|
||
<th>操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="hostTableBody">
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h2>📁 主机组</h2>
|
||
<div class="grid">
|
||
<div>
|
||
<button class="btn btn-primary" onclick="showAddGroupModal()">+ 创建组</button>
|
||
</div>
|
||
</div>
|
||
<div id="groupsList" style="margin-top: 15px;"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 命令执行 -->
|
||
<div id="command" class="tab-content" style="display: none;">
|
||
<div class="card">
|
||
<h2>💻 命令执行</h2>
|
||
<div style="display: grid; grid-template-columns: 220px 1fr 1fr; gap: 20px;">
|
||
<div>
|
||
<div class="form-group">
|
||
<label>选择主机组</label>
|
||
<div class="checkbox-group" id="cmdGroupCheckboxes"></div>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div class="form-group">
|
||
<label>选择主机</label>
|
||
<div class="checkbox-group" id="hostCheckboxes"></div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>要执行的命令</label>
|
||
<textarea id="commandInput" rows="4" placeholder="输入Shell命令,如: df -h、free -m、uptime"></textarea>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>快捷命令</label>
|
||
<div style="display: flex; flex-wrap: wrap; gap: 6px;">
|
||
<button class="btn" style="background:#2d3748;color:#e7e9ea;font-size:12px;padding:5px 10px;" onclick="document.getElementById('commandInput').value='uptime'">⏱ uptime</button>
|
||
<button class="btn" style="background:#2d3748;color:#e7e9ea;font-size:12px;padding:5px 10px;" onclick="document.getElementById('commandInput').value='df -h'">💾 df -h</button>
|
||
<button class="btn" style="background:#2d3748;color:#e7e9ea;font-size:12px;padding:5px 10px;" onclick="document.getElementById('commandInput').value='free -m'">🧠 free -m</button>
|
||
<button class="btn" style="background:#2d3748;color:#e7e9ea;font-size:12px;padding:5px 10px;" onclick="document.getElementById('commandInput').value='hostname'">🖥 hostname</button>
|
||
<button class="btn" style="background:#2d3748;color:#e7e9ea;font-size:12px;padding:5px 10px;" onclick="document.getElementById('commandInput').value='ip addr show'">🌐 ip addr</button>
|
||
<button class="btn" style="background:#2d3748;color:#e7e9ea;font-size:12px;padding:5px 10px;" onclick="document.getElementById('commandInput').value='cat /etc/os-release'">📋 系统版本</button>
|
||
<button class="btn" style="background:#2d3748;color:#e7e9ea;font-size:12px;padding:5px 10px;" onclick="document.getElementById('commandInput').value='systemctl status --failed'">⚠️ 失败服务</button>
|
||
<button class="btn" style="background:#2d3748;color:#e7e9ea;font-size:12px;padding:5px 10px;" onclick="document.getElementById('commandInput').value='top -bn1 | head -20'">📊 Top20</button>
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label><input type="checkbox" id="parallelExecute" checked> 并行执行</label>
|
||
</div>
|
||
<button class="btn btn-primary" onclick="executeCommand()" style="width:100%;padding:12px;">▶ 执行命令</button>
|
||
</div>
|
||
<div>
|
||
<div class="form-group">
|
||
<label>执行结果</label>
|
||
<div class="terminal" id="commandOutput" style="min-height: 350px;">
|
||
<div class="line info">选择主机,输入命令后点击执行...</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Playbook -->
|
||
<div id="playbook" class="tab-content" style="display: none;">
|
||
<div style="display: grid; grid-template-columns: 340px 1fr; gap: 20px;">
|
||
<!-- 左列:Playbook列表 -->
|
||
<div class="card" style="padding:15px;">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
|
||
<h2 style="margin:0;">📜 Playbook列表</h2>
|
||
<button class="btn btn-primary" onclick="showCreatePlaybookModal()" style="font-size:13px;padding:5px 12px;">➕ 新建</button>
|
||
</div>
|
||
<div id="playbooksList"></div>
|
||
</div>
|
||
|
||
<!-- 右列:执行配置 + 输出 -->
|
||
<div>
|
||
<!-- 执行配置 -->
|
||
<div class="card" style="margin-bottom:20px;">
|
||
<h2>🚀 执行配置 <span id="selectedPlaybookName" style="color:#8899a6;font-size:14px;font-weight:normal;"></span></h2>
|
||
|
||
<!-- 基础设置 -->
|
||
<div style="display:grid; grid-template-columns:1fr 1fr 1fr; gap:20px;">
|
||
<div>
|
||
<div class="form-group">
|
||
<label>选择主机组</label>
|
||
<div class="checkbox-group" id="playbookGroupCheckboxes"></div>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div class="form-group">
|
||
<label>选择主机</label>
|
||
<div class="checkbox-group" id="playbookHostCheckboxes"></div>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div class="form-group">
|
||
<label>变量 (JSON格式)</label>
|
||
<textarea id="extraVarsInput" rows="5" placeholder='{"key": "value"}' style="font-family:Monaco,Menlo,monospace;font-size:13px;"></textarea>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Playbook变量(自动从YAML解析) -->
|
||
<div id="playbookVarsSection" style="display:none;margin-bottom:15px;">
|
||
<div class="form-group">
|
||
<label>📋 Playbook变量 <span style="color:#8899a6;font-size:12px;">(自动解析,点击填充到变量区)</span></label>
|
||
<div id="playbookVarsList" style="display:flex;flex-wrap:wrap;gap:8px;"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 选项配置 -->
|
||
<div style="background:#0f1419;border-radius:8px;padding:15px;margin-bottom:15px;">
|
||
<h3 style="font-size:14px;color:#00d4aa;margin-bottom:12px;cursor:pointer;" onclick="toggleOptions()">
|
||
⚙️ 执行选项 <span id="optionsToggleIcon" style="float:right;">▼</span>
|
||
</h3>
|
||
<div id="optionsPanel" style="display:none;">
|
||
<div style="display:grid; grid-template-columns:1fr 1fr 1fr; gap:15px;">
|
||
<div class="form-group" style="margin-bottom:10px;">
|
||
<label>Verbose级别</label>
|
||
<select id="verboseSelect">
|
||
<option value="">默认</option>
|
||
<option value="v">v (-v)</option>
|
||
<option value="vv">vv (-vv)</option>
|
||
<option value="vvv">vvv (-vvv)</option>
|
||
<option value="vvvv">vvvv (-vvvv)</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group" style="margin-bottom:10px;">
|
||
<label>并发数 (Forks)</label>
|
||
<input type="number" id="forksInput" min="1" max="100" value="5" placeholder="默认5">
|
||
</div>
|
||
<div class="form-group" style="margin-bottom:10px;">
|
||
<label>超时时间(秒)</label>
|
||
<input type="number" id="timeoutInput" min="0" placeholder="0=不限">
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;flex-wrap:wrap;gap:20px;margin-top:8px;">
|
||
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;">
|
||
<input type="checkbox" id="checkMode"> 🔍 Dry-Run (检查模式)
|
||
</label>
|
||
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;">
|
||
<input type="checkbox" id="diffMode"> 📝 Diff (显示差异)
|
||
</label>
|
||
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;">
|
||
<input type="checkbox" id="becomeMode" checked> 🔑 Become (提权)
|
||
</label>
|
||
</div>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:15px;margin-top:12px;">
|
||
<div class="form-group" style="margin-bottom:10px;">
|
||
<label>Tags <span style="color:#8899a6;font-size:11px;">(逗号分隔,只执行指定标签)</span></label>
|
||
<input type="text" id="tagsInput" placeholder="如: install,config">
|
||
</div>
|
||
<div class="form-group" style="margin-bottom:10px;">
|
||
<label>Skip Tags <span style="color:#8899a6;font-size:11px;">(逗号分隔,跳过指定标签)</span></label>
|
||
<input type="text" id="skipTagsInput" placeholder="如: restart">
|
||
</div>
|
||
</div>
|
||
<div class="form-group" style="margin-bottom:10px;margin-top:5px;">
|
||
<label>自定义参数 <span style="color:#8899a6;font-size:11px;">(直接追加到命令行)</span></label>
|
||
<input type="text" id="extraArgsInput" placeholder="如: --private-key=/path/to/key">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<button class="btn btn-primary" onclick="executePlaybook()" style="width:100%;padding:12px;font-size:15px;">▶ 执行Playbook</button>
|
||
</div>
|
||
|
||
<!-- 执行输出 -->
|
||
<div class="card">
|
||
<h2>📝 执行输出</h2>
|
||
<div class="terminal" id="playbookOutput" style="min-height:250px;max-height:500px;">
|
||
<div class="line info">等待执行...</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 任务列表 -->
|
||
<div id="tasks" class="tab-content" style="display: none;">
|
||
<div class="card">
|
||
<h2>📋 所有任务</h2>
|
||
<table class="table">
|
||
<thead>
|
||
<tr>
|
||
<th>ID</th>
|
||
<th>名称</th>
|
||
<th>主机</th>
|
||
<th>状态</th>
|
||
<th>进度</th>
|
||
<th>开始时间</th>
|
||
<th>操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="allTasksBody">
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
|
||
<!-- 添加主机模态框 -->
|
||
<div id="addHostModal" class="modal">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h2>添加主机</h2>
|
||
<button class="close" onclick="closeModal('addHostModal')">×</button>
|
||
</div>
|
||
<form id="addHostForm">
|
||
<div class="form-group">
|
||
<label>名称</label>
|
||
<input type="text" name="name" required placeholder="如: web-server-01">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>IP地址</label>
|
||
<input type="text" name="ip" required placeholder="如: 192.168.1.100">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>端口</label>
|
||
<input type="number" name="port" value="22" placeholder="SSH端口">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>用户名</label>
|
||
<input type="text" name="username" required placeholder="如: root">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>密码</label>
|
||
<input type="password" name="password" placeholder="留空则使用SSH密钥">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>加入组</label>
|
||
<div class="checkbox-group" id="hostGroupCheckboxes"></div>
|
||
</div>
|
||
<button type="submit" class="btn btn-primary">添加</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 编辑主机模态框 -->
|
||
<div id="editHostModal" class="modal">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h2>编辑主机</h2>
|
||
<button class="close" onclick="closeModal('editHostModal')">×</button>
|
||
</div>
|
||
<form id="editHostForm">
|
||
<input type="hidden" name="id" id="editHostId">
|
||
<div class="form-group">
|
||
<label>名称</label>
|
||
<input type="text" name="name" id="editHostName" required placeholder="如: web-server-01">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>IP地址</label>
|
||
<input type="text" name="ip" id="editHostIp" required placeholder="如: 192.168.1.100">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>端口</label>
|
||
<input type="number" name="port" id="editHostPort" placeholder="SSH端口">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>用户名</label>
|
||
<input type="text" name="username" id="editHostUsername" required placeholder="如: root">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>密码</label>
|
||
<input type="password" name="password" id="editHostPassword" placeholder="留空则不修改密码">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>加入组</label>
|
||
<div class="checkbox-group" id="editHostGroupCheckboxes"></div>
|
||
</div>
|
||
<button type="submit" class="btn btn-primary">保存修改</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 任务详情模态框 -->
|
||
<div id="taskDetailModal" class="modal">
|
||
<div class="modal-content" style="max-width: 800px;">
|
||
<div class="modal-header">
|
||
<h2>📋 任务详情</h2>
|
||
<button class="close" onclick="closeModal('taskDetailModal')">×</button>
|
||
</div>
|
||
<div id="taskDetailContent"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 新建/编辑Playbook模态框 -->
|
||
<div id="playbookEditorModal" class="modal">
|
||
<div class="modal-content" style="max-width: 900px;">
|
||
<div class="modal-header">
|
||
<h2 id="playbookEditorTitle">📝 新建Playbook</h2>
|
||
<button class="close" onclick="closeModal('playbookEditorModal')">×</button>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Playbook名称 <span style="color:#8899a6;font-size:12px;">(仅英文、数字、中横线,自动加.yml后缀)</span></label>
|
||
<input type="text" id="playbookEditorName" placeholder="如: deploy-app" style="width:100%;">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>YAML内容</label>
|
||
<textarea id="playbookEditorContent" rows="20" placeholder="--- - name: Example Playbook hosts: all become: yes vars: key: value tasks: - name: Task name module: param: value" style="width:100%;font-family:Monaco,Menlo,Consolas,monospace;font-size:13px;line-height:1.5;resize:vertical;min-height:400px;"></textarea>
|
||
</div>
|
||
<div style="display:flex;gap:10px;justify-content:flex-end;">
|
||
<button class="btn" onclick="closeModal('playbookEditorModal')" style="background:#2d3748;color:#e7e9ea;">取消</button>
|
||
<button class="btn btn-primary" onclick="savePlaybook()">💾 保存</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
const API_BASE = '/api';
|
||
let hosts = [];
|
||
let groups = [];
|
||
let playbooks = [];
|
||
let tasks = [];
|
||
let currentTab = 'dashboard';
|
||
|
||
// Toast提示
|
||
function showToast(msg, type = 'info') {
|
||
const toast = document.createElement('div');
|
||
toast.className = `toast ${type}`;
|
||
toast.textContent = msg;
|
||
document.body.appendChild(toast);
|
||
setTimeout(() => toast.classList.add('show'), 10);
|
||
setTimeout(() => {
|
||
toast.classList.remove('show');
|
||
setTimeout(() => toast.remove(), 300);
|
||
}, 3000);
|
||
}
|
||
|
||
// 初始化
|
||
async function init() {
|
||
await loadHosts();
|
||
await loadGroups();
|
||
await loadPlaybooks();
|
||
await loadTasks();
|
||
updateDashboard();
|
||
renderHostCheckboxes();
|
||
}
|
||
|
||
// API请求
|
||
async function api(endpoint, options = {}) {
|
||
try {
|
||
const opts = { ...options };
|
||
// DELETE/GET without body should not send Content-Type: application/json
|
||
if (!opts.body) {
|
||
opts.headers = { ...(opts.headers || {}) };
|
||
delete opts.headers['Content-Type'];
|
||
} else {
|
||
opts.headers = { 'Content-Type': 'application/json', ...(opts.headers || {}) };
|
||
}
|
||
const res = await fetch(API_BASE + endpoint, opts);
|
||
const text = await res.text();
|
||
try {
|
||
return JSON.parse(text);
|
||
} catch {
|
||
return { code: res.status, msg: text };
|
||
}
|
||
} catch (err) {
|
||
console.error('API Error:', err);
|
||
return { code: 500, msg: err.message };
|
||
}
|
||
}
|
||
|
||
// 加载数据
|
||
async function loadHosts() {
|
||
const res = await api('/hosts');
|
||
if (res.code === 0) hosts = res.data || [];
|
||
renderHostsTable();
|
||
}
|
||
|
||
async function loadGroups() {
|
||
const res = await api('/groups');
|
||
if (res.code === 0) groups = res.data || [];
|
||
renderGroups();
|
||
}
|
||
|
||
async function loadPlaybooks() {
|
||
const res = await api('/playbooks');
|
||
if (res.code === 0) playbooks = res.data || [];
|
||
renderPlaybooks();
|
||
}
|
||
|
||
async function loadTasks() {
|
||
const res = await api('/tasks');
|
||
if (res.code === 0) tasks = res.data || [];
|
||
renderTasks();
|
||
}
|
||
|
||
// 渲染主机表格
|
||
function renderHostsTable() {
|
||
const tbody = document.getElementById('hostTableBody');
|
||
if (hosts.length === 0) {
|
||
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;color:#8899a6;padding:30px;">暂无主机,点击右上角添加</td></tr>';
|
||
return;
|
||
}
|
||
tbody.innerHTML = hosts.map(h => `
|
||
<tr>
|
||
<td>${h.name}</td>
|
||
<td>${h.ip}</td>
|
||
<td>${h.port || 22}</td>
|
||
<td>${h.username}</td>
|
||
<td><span class="status ${h.status || 'pending'}">${statusText(h.status)}</span></td>
|
||
<td>${(h.groups || []).join(', ') || '-'}</td>
|
||
<td>
|
||
<div class="action-group">
|
||
<button class="btn btn-info" id="testBtn_${h.id}" onclick="testConnection('${h.id}')">🔌 测试</button>
|
||
<button class="btn btn-warning" onclick="showEditHostModal('${h.id}')">✏️ 编辑</button>
|
||
<button class="btn btn-danger" onclick="deleteHost('${h.id}')">🗑 删除</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
`).join('');
|
||
}
|
||
|
||
function statusText(s) {
|
||
const map = { online: '在线', offline: '离线', pending: '未知', running: '运行中', completed: '已完成', failed: '失败' };
|
||
return map[s] || s || '未知';
|
||
}
|
||
|
||
function renderGroups() {
|
||
const groupsList = document.getElementById('groupsList');
|
||
groupsList.innerHTML = groups.map(g => `
|
||
<div style="display: inline-block; background: #2d3748; padding: 10px 15px; border-radius: 8px; margin: 5px;">
|
||
<strong>${g.name}</strong> (${g.host_list?.length || 0}台)
|
||
<button class="btn btn-danger" onclick="deleteGroup('${g.name}')" style="margin-left: 10px;">删除</button>
|
||
</div>
|
||
`).join('');
|
||
|
||
// 添加主机时的组复选框
|
||
const checkboxes = document.getElementById('hostGroupCheckboxes');
|
||
if (checkboxes) {
|
||
checkboxes.innerHTML = groups.map(g => `
|
||
<label class="checkbox-item">
|
||
<input type="checkbox" name="groups" value="${g.name}">
|
||
${g.name}
|
||
</label>
|
||
`).join('');
|
||
}
|
||
|
||
// 编辑主机时的组复选框
|
||
const editCheckboxes = document.getElementById('editHostGroupCheckboxes');
|
||
if (editCheckboxes) {
|
||
editCheckboxes.innerHTML = groups.map(g => `
|
||
<label class="checkbox-item">
|
||
<input type="checkbox" name="groups" value="${g.name}">
|
||
${g.name}
|
||
</label>
|
||
`).join('');
|
||
}
|
||
}
|
||
|
||
let selectedPlaybook = null;
|
||
|
||
function renderPlaybooks() {
|
||
const list = document.getElementById('playbooksList');
|
||
list.innerHTML = playbooks.map(p => {
|
||
const isSelected = selectedPlaybook === p.name;
|
||
const varsCount = p.variables ? Object.keys(p.variables).length : 0;
|
||
return `
|
||
<div style="
|
||
background:${isSelected ? '#2d3748' : '#1a1f2e'};
|
||
border:1px solid ${isSelected ? '#00d4aa' : '#38444d'};
|
||
border-radius:8px;
|
||
padding:12px;
|
||
margin-bottom:10px;
|
||
cursor:pointer;
|
||
transition:all 0.2s;
|
||
${isSelected ? 'box-shadow:0 0 8px rgba(0,212,170,0.2);' : ''}
|
||
" onmouseover="if('${p.name}'!=='${selectedPlaybook}')this.style.borderColor='#555'" onmouseout="if('${p.name}'!=='${selectedPlaybook}')this.style.borderColor='#38444d'">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||
<strong onclick="selectPlaybook('${p.name}')" style="color:${isSelected ? '#00d4aa' : '#e7e9ea'};font-size:14px;flex:1;">📜 ${p.name}</strong>
|
||
<div style="display:flex;gap:6px;align-items:center;">
|
||
${varsCount > 0 ? `<span style="background:#2d3748;color:#00d4aa;padding:2px 8px;border-radius:10px;font-size:11px;">${varsCount}变量</span>` : ''}
|
||
<button onclick="event.stopPropagation();showEditPlaybookModal('${p.name}')" style="background:none;border:none;color:#1d9bf0;cursor:pointer;font-size:14px;padding:2px 4px;" title="编辑">✏️</button>
|
||
<button onclick="event.stopPropagation();deletePlaybook('${p.name}')" style="background:none;border:none;color:#f44336;cursor:pointer;font-size:14px;padding:2px 4px;" title="删除">🗑️</button>
|
||
</div>
|
||
</div>
|
||
<p onclick="selectPlaybook('${p.name}')" style="color:#8899a6;margin-top:5px;font-size:12px;">${p.description || '暂无描述'}</p>
|
||
${varsCount > 0 ? `<div onclick="selectPlaybook('${p.name}')" style="margin-top:8px;display:flex;flex-wrap:wrap;gap:4px;">
|
||
${Object.keys(p.variables).map(k => `<span style="background:#0f1419;color:#1d9bf0;padding:2px 6px;border-radius:4px;font-size:11px;font-family:monospace;">${k}</span>`).join('')}
|
||
</div>` : ''}
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
function selectPlaybook(name) {
|
||
selectedPlaybook = name;
|
||
const pb = playbooks.find(p => p.name === name);
|
||
document.getElementById('selectedPlaybookName').textContent = pb ? `— ${pb.description || name}` : '';
|
||
|
||
// 显示playbook变量
|
||
const varsSection = document.getElementById('playbookVarsSection');
|
||
const varsList = document.getElementById('playbookVarsList');
|
||
if (pb && pb.variables && Object.keys(pb.variables).length > 0) {
|
||
varsSection.style.display = 'block';
|
||
varsList.innerHTML = Object.entries(pb.variables).map(([k, v]) => `
|
||
<div onclick="fillVariable('${k}', '${JSON.stringify(v).replace(/'/g, "\\'")}')" style="
|
||
background:#2d3748;
|
||
border:1px solid #38444d;
|
||
border-radius:6px;
|
||
padding:8px 12px;
|
||
cursor:pointer;
|
||
transition:all 0.2s;
|
||
" onmouseover="this.style.borderColor='#00d4aa'" onmouseout="this.style.borderColor='#38444d'">
|
||
<div style="color:#1d9bf0;font-size:12px;font-family:monospace;">${k}</div>
|
||
<div style="color:#8899a6;font-size:11px;margin-top:2px;">默认: ${JSON.stringify(v)}</div>
|
||
</div>
|
||
`).join('');
|
||
} else {
|
||
varsSection.style.display = 'none';
|
||
varsList.innerHTML = '';
|
||
}
|
||
|
||
// 自动填充当前playbook变量到变量区
|
||
if (pb && pb.variables) {
|
||
document.getElementById('extraVarsInput').value = JSON.stringify(pb.variables, null, 2);
|
||
}
|
||
|
||
renderPlaybooks(); // 刷新高亮
|
||
}
|
||
|
||
function fillVariable(key, defaultValue) {
|
||
const input = document.getElementById('extraVarsInput');
|
||
let vars = {};
|
||
if (input.value.trim()) {
|
||
try { vars = JSON.parse(input.value); } catch(e) {
|
||
showToast('变量区JSON格式有误,请先修正', 'error');
|
||
return;
|
||
}
|
||
}
|
||
try {
|
||
vars[key] = JSON.parse(defaultValue);
|
||
} catch(e) {
|
||
vars[key] = defaultValue;
|
||
}
|
||
input.value = JSON.stringify(vars, null, 2);
|
||
showToast(`变量 ${key} 已填充`, 'success');
|
||
}
|
||
|
||
// ===== Playbook 编辑器 =====
|
||
let editingPlaybookName = null; // null=新建模式, 非null=编辑模式
|
||
|
||
function showCreatePlaybookModal() {
|
||
editingPlaybookName = null;
|
||
document.getElementById('playbookEditorTitle').textContent = '📝 新建Playbook';
|
||
document.getElementById('playbookEditorName').value = '';
|
||
document.getElementById('playbookEditorName').disabled = false;
|
||
document.getElementById('playbookEditorContent').value = `---
|
||
- name: Example Playbook
|
||
hosts: all
|
||
become: yes
|
||
vars:
|
||
key: value
|
||
tasks:
|
||
- name: Example task
|
||
debug:
|
||
msg: "Hello {{ key }}"
|
||
`;
|
||
document.getElementById('playbookEditorModal').classList.add('active');
|
||
}
|
||
|
||
async function showEditPlaybookModal(name) {
|
||
editingPlaybookName = name;
|
||
document.getElementById('playbookEditorTitle').textContent = '✏️ 编辑Playbook';
|
||
document.getElementById('playbookEditorName').value = name;
|
||
document.getElementById('playbookEditorName').disabled = true;
|
||
document.getElementById('playbookEditorContent').value = '加载中...';
|
||
document.getElementById('playbookEditorModal').classList.add('active');
|
||
|
||
try {
|
||
const res = await fetch(`${API_BASE}/playbooks/${encodeURIComponent(name)}/content`);
|
||
const data = await res.json();
|
||
if (data.code === 0) {
|
||
document.getElementById('playbookEditorContent').value = data.data;
|
||
} else {
|
||
showToast('加载Playbook内容失败: ' + data.msg, 'error');
|
||
}
|
||
} catch(e) {
|
||
showToast('加载Playbook内容失败: ' + e.message, 'error');
|
||
}
|
||
}
|
||
|
||
async function savePlaybook() {
|
||
const name = document.getElementById('playbookEditorName').value.trim();
|
||
const content = document.getElementById('playbookEditorContent').value;
|
||
|
||
if (!name) {
|
||
showToast('请输入Playbook名称', 'error');
|
||
return;
|
||
}
|
||
if (!name.match(/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/)) {
|
||
showToast('名称只能包含英文、数字、中横线、下划线,且以英文或数字开头', 'error');
|
||
return;
|
||
}
|
||
if (!content.trim()) {
|
||
showToast('YAML内容不能为空', 'error');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
let res;
|
||
if (editingPlaybookName) {
|
||
// 编辑模式 - PUT
|
||
res = await fetch(`${API_BASE}/playbooks/${encodeURIComponent(editingPlaybookName)}`, {
|
||
method: 'PUT',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({ content })
|
||
});
|
||
} else {
|
||
// 新建模式 - POST
|
||
res = await fetch(`${API_BASE}/playbooks`, {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({ name, content })
|
||
});
|
||
}
|
||
const data = await res.json();
|
||
if (data.code === 0) {
|
||
showToast(editingPlaybookName ? 'Playbook更新成功' : 'Playbook创建成功', 'success');
|
||
closeModal('playbookEditorModal');
|
||
loadPlaybooks(); // 刷新列表
|
||
} else {
|
||
showToast(data.msg || '操作失败', 'error');
|
||
}
|
||
} catch(e) {
|
||
showToast('保存失败: ' + e.message, 'error');
|
||
}
|
||
}
|
||
|
||
async function deletePlaybook(name) {
|
||
if (!confirm(`确定删除Playbook "${name}" 吗?此操作不可恢复!`)) return;
|
||
|
||
try {
|
||
const res = await fetch(`${API_BASE}/playbooks/${encodeURIComponent(name)}`, {
|
||
method: 'DELETE'
|
||
});
|
||
const data = await res.json();
|
||
if (data.code === 0) {
|
||
showToast('Playbook已删除', 'success');
|
||
if (selectedPlaybook === name) {
|
||
selectedPlaybook = null;
|
||
document.getElementById('selectedPlaybookName').textContent = '';
|
||
document.getElementById('extraVarsInput').value = '';
|
||
document.getElementById('playbookVarsSection').style.display = 'none';
|
||
}
|
||
loadPlaybooks();
|
||
} else {
|
||
showToast(data.msg || '删除失败', 'error');
|
||
}
|
||
} catch(e) {
|
||
showToast('删除失败: ' + e.message, 'error');
|
||
}
|
||
}
|
||
|
||
async function loadPlaybooks() {
|
||
try {
|
||
const res = await fetch(`${API_BASE}/playbooks`);
|
||
const data = await res.json();
|
||
if (data.code === 0) {
|
||
playbooks = data.data || [];
|
||
renderPlaybooks();
|
||
// 如果当前选中的playbook还在列表中,重新触发选中
|
||
if (selectedPlaybook && playbooks.find(p => p.name === selectedPlaybook)) {
|
||
selectPlaybook(selectedPlaybook);
|
||
}
|
||
}
|
||
} catch(e) {
|
||
console.error('加载playbooks失败', e);
|
||
}
|
||
}
|
||
|
||
function toggleOptions() {
|
||
const panel = document.getElementById('optionsPanel');
|
||
const icon = document.getElementById('optionsToggleIcon');
|
||
if (panel.style.display === 'none') {
|
||
panel.style.display = 'block';
|
||
icon.textContent = '▲';
|
||
} else {
|
||
panel.style.display = 'none';
|
||
icon.textContent = '▼';
|
||
}
|
||
}
|
||
|
||
function renderTasks() {
|
||
const tbody = document.getElementById('allTasksBody');
|
||
if (tasks.length === 0) {
|
||
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;color:#8899a6;padding:30px;">暂无任务</td></tr>';
|
||
return;
|
||
}
|
||
tbody.innerHTML = tasks.map(t => `
|
||
<tr>
|
||
<td>${t.id ? t.id.substring(0,8) : '-'}</td>
|
||
<td>${t.name}</td>
|
||
<td>${(t.hosts || []).join(', ')}</td>
|
||
<td><span class="status ${t.status}">${statusText(t.status)}</span></td>
|
||
<td>
|
||
<div class="progress-bar">
|
||
<div class="fill" style="width: ${(t.progress / t.total_hosts * 100) || 0}%"></div>
|
||
</div>
|
||
${t.progress || 0}/${t.total_hosts || 0}
|
||
</td>
|
||
<td>${t.start_time ? new Date(t.start_time).toLocaleString() : '-'}</td>
|
||
<td>
|
||
<div class="action-group">
|
||
${t.status === 'running' ? `<button class="btn btn-danger" onclick="cancelTask('${t.id}')">取消</button>` : ''}
|
||
<button class="btn btn-info" onclick="viewTaskOutput('${t.id}')">详情</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
`).join('');
|
||
}
|
||
|
||
function renderHostCheckboxes() {
|
||
const container = document.getElementById('hostCheckboxes');
|
||
if (container) {
|
||
container.innerHTML = hosts.map(h => `
|
||
<label class="checkbox-item">
|
||
<input type="checkbox" name="hosts" value="${h.name}">
|
||
${h.name} (${h.ip})
|
||
</label>
|
||
`).join('');
|
||
}
|
||
|
||
const playbookHosts = document.getElementById('playbookHostCheckboxes');
|
||
if (playbookHosts) {
|
||
playbookHosts.innerHTML = hosts.map(h => `
|
||
<label class="checkbox-item">
|
||
<input type="checkbox" name="playbookHosts" value="${h.name}">
|
||
${h.name} (${h.ip})
|
||
</label>
|
||
`).join('');
|
||
}
|
||
|
||
// 渲染Playbook页面主机组复选框(可展开)
|
||
const playbookGroups = document.getElementById('playbookGroupCheckboxes');
|
||
if (playbookGroups) {
|
||
playbookGroups.innerHTML = groups.map(g => `
|
||
<div class="group-checkbox-item" data-group="${g.name}" data-type="playbook">
|
||
<label class="checkbox-item">
|
||
<input type="checkbox" name="playbookGroups" value="${g.name}" onchange="toggleGroupHosts('${g.name}', 'playbook', this.checked)">
|
||
<span class="group-toggle" onclick="toggleGroupExpand('${g.name}', 'playbook')">▶ ${g.name} (${g.host_list?.length || 0}台)</span>
|
||
</label>
|
||
<div class="group-hosts" id="playbook_group_${g.name}" style="display:none;margin-left:20px;border-left:2px solid #38444d;padding-left:10px;">
|
||
${(g.host_list || []).map(h => `
|
||
<label class="checkbox-item" style="font-size:12px;">
|
||
<input type="checkbox" name="playbookHosts" value="${h.name}" data-group="${g.name}">
|
||
${h.name} (${h.ip}) <span class="status ${h.status}" style="font-size:10px;padding:2px 6px;">${statusText(h.status)}</span>
|
||
</label>
|
||
`).join('')}
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
// 渲染命令执行页面主机组复选框(可展开)
|
||
const cmdGroups = document.getElementById('cmdGroupCheckboxes');
|
||
if (cmdGroups) {
|
||
cmdGroups.innerHTML = groups.map(g => `
|
||
<div class="group-checkbox-item" data-group="${g.name}" data-type="cmd">
|
||
<label class="checkbox-item">
|
||
<input type="checkbox" name="cmdGroups" value="${g.name}" onchange="toggleGroupHosts('${g.name}', 'cmd', this.checked)">
|
||
<span class="group-toggle" onclick="toggleGroupExpand('${g.name}', 'cmd')">▶ ${g.name} (${g.host_list?.length || 0}台)</span>
|
||
</label>
|
||
<div class="group-hosts" id="cmd_group_${g.name}" style="display:none;margin-left:20px;border-left:2px solid #38444d;padding-left:10px;">
|
||
${(g.host_list || []).map(h => `
|
||
<label class="checkbox-item" style="font-size:12px;">
|
||
<input type="checkbox" name="hosts" value="${h.name}" data-group="${g.name}">
|
||
${h.name} (${h.ip}) <span class="status ${h.status}" style="font-size:10px;padding:2px 6px;">${statusText(h.status)}</span>
|
||
</label>
|
||
`).join('')}
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
}
|
||
|
||
// 展开/收起主机组内的主机列表
|
||
function toggleGroupExpand(groupName, type) {
|
||
const div = document.getElementById(type + '_group_' + groupName);
|
||
const toggle = div.previousElementSibling.querySelector('.group-toggle');
|
||
if (div.style.display === 'none') {
|
||
div.style.display = 'block';
|
||
toggle.textContent = '▼ ' + groupName + ' (' + (groups.find(g => g.name === groupName)?.host_list?.length || 0) + '台)';
|
||
} else {
|
||
div.style.display = 'none';
|
||
toggle.textContent = '▶ ' + groupName + ' (' + (groups.find(g => g.name === groupName)?.host_list?.length || 0) + '台)';
|
||
}
|
||
}
|
||
|
||
// 选中/取消主机组时,选中/取消组内所有主机
|
||
function toggleGroupHosts(groupName, type, checked) {
|
||
const div = document.getElementById(type + '_group_' + groupName);
|
||
const checkboxes = div.querySelectorAll('input[type="checkbox"]');
|
||
checkboxes.forEach(cb => cb.checked = checked);
|
||
}
|
||
|
||
function updateDashboard() {
|
||
document.getElementById('totalHosts').textContent = hosts.length;
|
||
document.getElementById('onlineHosts').textContent = hosts.filter(h => h.status === 'online').length;
|
||
document.getElementById('offlineHosts').textContent = hosts.filter(h => h.status === 'offline').length;
|
||
document.getElementById('runningTasks').textContent = tasks.filter(t => t.status === 'running').length;
|
||
|
||
const tbody = document.getElementById('taskTableBody');
|
||
const recent = tasks.slice(-5).reverse();
|
||
tbody.innerHTML = recent.map(t => `
|
||
<tr>
|
||
<td>${t.name}</td>
|
||
<td><span class="status ${t.status}">${statusText(t.status)}</span></td>
|
||
<td>${t.progress || 0}/${t.total_hosts || 0}</td>
|
||
<td>${t.start_time ? new Date(t.start_time).toLocaleString() : '-'}</td>
|
||
</tr>
|
||
`).join('');
|
||
}
|
||
|
||
// Tab切换
|
||
function showTab(tab) {
|
||
document.querySelectorAll('.tab-content').forEach(el => el.style.display = 'none');
|
||
document.querySelectorAll('.nav button').forEach(el => el.classList.remove('active'));
|
||
document.getElementById(tab).style.display = 'block';
|
||
document.querySelector(`.nav button[onclick="showTab('${tab}')"]`).classList.add('active');
|
||
currentTab = tab;
|
||
}
|
||
|
||
// 模态框
|
||
function showAddHostModal() {
|
||
document.getElementById('addHostForm').reset();
|
||
document.getElementById('addHostModal').classList.add('active');
|
||
}
|
||
|
||
function showAddGroupModal() {
|
||
const name = prompt('输入组名称:');
|
||
if (name) {
|
||
api('/groups', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ name, description: '' })
|
||
}).then(res => {
|
||
if (res.code === 0) {
|
||
showToast('组创建成功', 'success');
|
||
loadGroups();
|
||
} else {
|
||
showToast('创建失败: ' + res.msg, 'error');
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
function closeModal(id) {
|
||
document.getElementById(id).classList.remove('active');
|
||
// 关闭任务日志 SSE 连接
|
||
if (id === 'taskDetailModal' && _taskLogSSE) {
|
||
_taskLogSSE.close();
|
||
_taskLogSSE = null;
|
||
}
|
||
}
|
||
|
||
// 编辑主机
|
||
function showEditHostModal(hostId) {
|
||
const host = hosts.find(h => h.id === hostId);
|
||
if (!host) {
|
||
showToast('主机不存在', 'error');
|
||
return;
|
||
}
|
||
|
||
document.getElementById('editHostId').value = host.id;
|
||
document.getElementById('editHostName').value = host.name || '';
|
||
document.getElementById('editHostIp').value = host.ip || '';
|
||
document.getElementById('editHostPort').value = host.port || 22;
|
||
document.getElementById('editHostUsername').value = host.username || '';
|
||
document.getElementById('editHostPassword').value = '';
|
||
|
||
// 渲染组复选框并勾选已有组
|
||
const editCheckboxes = document.getElementById('editHostGroupCheckboxes');
|
||
editCheckboxes.innerHTML = groups.map(g => `
|
||
<label class="checkbox-item">
|
||
<input type="checkbox" name="groups" value="${g.name}" ${(host.groups || []).includes(g.name) ? 'checked' : ''}>
|
||
${g.name}
|
||
</label>
|
||
`).join('');
|
||
|
||
document.getElementById('editHostModal').classList.add('active');
|
||
}
|
||
|
||
// 操作
|
||
async function addHost(form) {
|
||
const data = new FormData(form);
|
||
const host = {
|
||
name: data.get('name'),
|
||
ip: data.get('ip'),
|
||
port: parseInt(data.get('port')) || 22,
|
||
username: data.get('username'),
|
||
password: data.get('password'),
|
||
groups: data.getAll('groups')
|
||
};
|
||
|
||
const res = await api('/hosts', {
|
||
method: 'POST',
|
||
body: JSON.stringify(host)
|
||
});
|
||
|
||
if (res.code === 0) {
|
||
closeModal('addHostModal');
|
||
showToast('主机添加成功', 'success');
|
||
await loadHosts();
|
||
await loadGroups();
|
||
renderHostCheckboxes();
|
||
} else {
|
||
showToast('添加失败: ' + res.msg, 'error');
|
||
}
|
||
}
|
||
|
||
async function updateHost(form) {
|
||
const data = new FormData(form);
|
||
const hostId = data.get('id');
|
||
const host = {
|
||
name: data.get('name'),
|
||
ip: data.get('ip'),
|
||
port: parseInt(data.get('port')) || 22,
|
||
username: data.get('username'),
|
||
groups: data.getAll('groups')
|
||
};
|
||
|
||
// 密码不为空时才提交
|
||
if (data.get('password')) {
|
||
host.password = data.get('password');
|
||
}
|
||
|
||
const res = await api(`/hosts/${hostId}`, {
|
||
method: 'PUT',
|
||
body: JSON.stringify(host)
|
||
});
|
||
|
||
if (res.code === 0) {
|
||
closeModal('editHostModal');
|
||
showToast('主机更新成功', 'success');
|
||
await loadHosts();
|
||
await loadGroups();
|
||
renderHostCheckboxes();
|
||
} else {
|
||
showToast('更新失败: ' + res.msg, 'error');
|
||
}
|
||
}
|
||
|
||
async function deleteHost(id) {
|
||
if (confirm('确定删除此主机?')) {
|
||
const res = await api(`/hosts/${id}`, { method: 'DELETE' });
|
||
if (res.code === 0) {
|
||
showToast('主机已删除', 'success');
|
||
await loadHosts();
|
||
renderHostCheckboxes();
|
||
} else {
|
||
showToast('删除失败: ' + res.msg, 'error');
|
||
}
|
||
}
|
||
}
|
||
|
||
async function deleteGroup(name) {
|
||
if (confirm('确定删除此组?')) {
|
||
const res = await api(`/groups/${name}`, { method: 'DELETE' });
|
||
if (res.code === 0) {
|
||
showToast('组已删除', 'success');
|
||
await loadGroups();
|
||
} else {
|
||
showToast('删除失败: ' + res.msg, 'error');
|
||
}
|
||
}
|
||
}
|
||
|
||
async function testConnection(id) {
|
||
const btn = document.getElementById(`testBtn_${id}`);
|
||
if (btn) {
|
||
btn.disabled = true;
|
||
btn.innerHTML = '<span class="spinner"></span> 测试中';
|
||
}
|
||
|
||
try {
|
||
const res = await api(`/hosts/test/${id}`, { method: 'POST' });
|
||
if (res.code === 0 && res.data) {
|
||
if (res.data.success) {
|
||
showToast(`✓ 连接成功!延迟: ${res.data.duration || 0}ms`, 'success');
|
||
} else {
|
||
showToast(`✗ 连接失败: ${res.data.error || '未知错误'}`, 'error');
|
||
}
|
||
await loadHosts();
|
||
} else {
|
||
showToast('测试失败: ' + (res.msg || '未知错误'), 'error');
|
||
}
|
||
} catch (err) {
|
||
showToast('请求异常: ' + err.message, 'error');
|
||
} finally {
|
||
if (btn) {
|
||
btn.disabled = false;
|
||
btn.innerHTML = '🔌 测试';
|
||
}
|
||
}
|
||
}
|
||
|
||
async function executeCommand() {
|
||
const checkedHosts = document.querySelectorAll('input[name="hosts"]:checked');
|
||
const checkedGroups = document.querySelectorAll('input[name="cmdGroups"]:checked');
|
||
|
||
// 主机列表(直接选的主机)
|
||
let hostList = Array.from(checkedHosts).map(h => h.value);
|
||
|
||
// 如果选了主机组,展开为组内成员主机
|
||
const groupList = Array.from(checkedGroups).map(g => g.value);
|
||
if (groupList.length > 0) {
|
||
groupList.forEach(gName => {
|
||
const g = groups.find(gr => gr.name === gName);
|
||
if (g && g.host_list) {
|
||
g.host_list.forEach(h => {
|
||
if (!hostList.includes(h.name)) hostList.push(h.name);
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
const command = document.getElementById('commandInput').value;
|
||
const parallel = document.getElementById('parallelExecute').checked;
|
||
|
||
if (hostList.length === 0) {
|
||
showToast('请选择主机或主机组', 'error');
|
||
return;
|
||
}
|
||
if (!command) {
|
||
showToast('请输入命令', 'error');
|
||
return;
|
||
}
|
||
|
||
const output = document.getElementById('commandOutput');
|
||
output.innerHTML = '<div class="line info"><span class="spinner"></span> 执行中...</div>';
|
||
|
||
const res = await api('/command/batch', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ hosts: hostList, command, parallel })
|
||
});
|
||
|
||
if (res.code === 0) {
|
||
output.innerHTML = '';
|
||
const results = res.data?.results || [];
|
||
if (results.length === 0) {
|
||
output.innerHTML = '<div class="line info">任务已提交,请在任务列表中查看</div>';
|
||
} else {
|
||
results.forEach(r => {
|
||
const cls = r.success ? 'success' : 'error';
|
||
output.innerHTML += `<div class="line ${cls}">[${r.host}] ${r.success ? '✓ 成功' : '✗ 失败'} (${r.duration || 0}ms)</div>`;
|
||
if (r.output) output.innerHTML += `<div class="line">${escapeHtml(r.output)}</div>`;
|
||
if (r.error) output.innerHTML += `<div class="line error">${escapeHtml(r.error)}</div>`;
|
||
});
|
||
}
|
||
} else {
|
||
output.innerHTML = `<div class="line error">执行失败: ${res.msg}</div>`;
|
||
}
|
||
}
|
||
|
||
async function executePlaybook() {
|
||
if (!selectedPlaybook) {
|
||
showToast('请先选择一个Playbook', 'error');
|
||
return;
|
||
}
|
||
|
||
const checkedHosts = document.querySelectorAll('input[name="playbookHosts"]:checked');
|
||
const checkedGroups = document.querySelectorAll('input[name="playbookGroups"]:checked');
|
||
|
||
// 主机列表(直接选的主机)
|
||
let hostList = Array.from(checkedHosts).map(h => h.value);
|
||
|
||
// 如果选了主机组,展开为组内成员主机
|
||
const groupList = Array.from(checkedGroups).map(g => g.value);
|
||
if (groupList.length > 0) {
|
||
groupList.forEach(gName => {
|
||
const g = groups.find(gr => gr.name === gName);
|
||
if (g && g.host_list) {
|
||
g.host_list.forEach(h => {
|
||
if (!hostList.includes(h.name)) hostList.push(h.name);
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
if (hostList.length === 0) {
|
||
showToast('请选择主机或主机组', 'error');
|
||
return;
|
||
}
|
||
|
||
const extraVarsText = document.getElementById('extraVarsInput').value;
|
||
|
||
let extraVars = {};
|
||
if (extraVarsText) {
|
||
try {
|
||
extraVars = JSON.parse(extraVarsText);
|
||
} catch (e) {
|
||
showToast('变量格式错误,请使用JSON格式', 'error');
|
||
return;
|
||
}
|
||
}
|
||
|
||
// 收集选项
|
||
const req = {
|
||
name: selectedPlaybook,
|
||
hosts: hostList,
|
||
extra_vars: extraVars,
|
||
verbose: document.getElementById('verboseSelect').value || '',
|
||
diff: document.getElementById('diffMode').checked,
|
||
check: document.getElementById('checkMode').checked,
|
||
become: document.getElementById('becomeMode').checked,
|
||
forks: parseInt(document.getElementById('forksInput').value) || 0,
|
||
timeout: parseInt(document.getElementById('timeoutInput').value) || 0,
|
||
extra_args: document.getElementById('extraArgsInput').value.trim(),
|
||
};
|
||
|
||
// tags
|
||
const tagsVal = document.getElementById('tagsInput').value.trim();
|
||
if (tagsVal) {
|
||
req.tags = tagsVal.split(',').map(t => t.trim()).filter(t => t);
|
||
}
|
||
const skipTagsVal = document.getElementById('skipTagsInput').value.trim();
|
||
if (skipTagsVal) {
|
||
req.skip_tags = skipTagsVal.split(',').map(t => t.trim()).filter(t => t);
|
||
}
|
||
|
||
const output = document.getElementById('playbookOutput');
|
||
output.innerHTML = '<div class="line info"><span class="spinner"></span> 执行中...</div>';
|
||
|
||
const res = await api('/playbooks/execute', {
|
||
method: 'POST',
|
||
body: JSON.stringify(req)
|
||
});
|
||
|
||
if (res.code === 0) {
|
||
output.innerHTML = `<div class="line info">任务已启动,ID: ${res.taskId || '未知'}</div>`;
|
||
showToast('Playbook执行已启动', 'success');
|
||
await loadTasks();
|
||
} else {
|
||
output.innerHTML = `<div class="line error">执行失败: ${res.msg}</div>`;
|
||
showToast('执行失败: ' + res.msg, 'error');
|
||
}
|
||
}
|
||
|
||
async function cancelTask(id) {
|
||
if (confirm('确定取消此任务?')) {
|
||
const res = await api(`/tasks/${id}`, { method: 'DELETE' });
|
||
if (res.code === 0) {
|
||
showToast('任务已取消', 'success');
|
||
await loadTasks();
|
||
} else {
|
||
showToast('取消失败: ' + res.msg, 'error');
|
||
}
|
||
}
|
||
}
|
||
|
||
// SSE 流式日志连接管理
|
||
let _taskLogSSE = null;
|
||
let _taskLogOutput = '';
|
||
|
||
async function viewTaskOutput(id) {
|
||
const res = await api(`/tasks/${id}`);
|
||
if (res.code === 0 && res.data) {
|
||
const task = res.data;
|
||
const content = document.getElementById('taskDetailContent');
|
||
|
||
// 关闭之前的 SSE 连接
|
||
if (_taskLogSSE) {
|
||
_taskLogSSE.close();
|
||
_taskLogSSE = null;
|
||
}
|
||
|
||
_taskLogOutput = task.output || '';
|
||
|
||
const isRunning = task.status === 'running';
|
||
|
||
content.innerHTML = `
|
||
<div style="margin-bottom: 15px;">
|
||
<strong>任务名称:</strong> ${task.name}<br>
|
||
<strong>状态:</strong> <span class="status ${task.status}">${statusText(task.status)}</span>
|
||
${isRunning ? '<span class="spinner" style="margin-left:8px;"></span>' : ''}<br>
|
||
<strong>开始时间:</strong> ${task.start_time ? new Date(task.start_time).toLocaleString() : '-'}<br>
|
||
<strong>结束时间:</strong> ${task.end_time ? new Date(task.end_time).toLocaleString() : '-'}<br>
|
||
${task.error ? `<strong style="color:#ff6b6b;">错误:</strong> <span style="color:#ff6b6b;">${escapeHtml(task.error)}</span><br>` : ''}
|
||
</div>
|
||
<h3 style="color:#00d4aa;margin-bottom:10px;">执行日志 ${isRunning ? '<span style="color:#ffd93d;font-size:12px;">(实时更新中...)</span>' : ''}</h3>
|
||
<div class="terminal" id="taskLogTerminal" style="max-height:500px;overflow-y:auto;font-size:12px;line-height:1.6;white-space:pre-wrap;word-break:break-all;background:#0d1117;padding:15px;border-radius:8px;">${escapeHtml(_taskLogOutput) || '<span style="color:#8899a6;">等待输出...</span>'}</div>
|
||
`;
|
||
document.getElementById('taskDetailModal').classList.add('active');
|
||
|
||
// 运行中的任务用 SSE 实时推送
|
||
if (isRunning) {
|
||
const sseUrl = `${API_BASE}/tasks/${id}/stream`;
|
||
_taskLogSSE = new EventSource(sseUrl);
|
||
_taskLogSSE.addEventListener('log', function(e) {
|
||
_taskLogOutput += e.data;
|
||
const terminal = document.getElementById('taskLogTerminal');
|
||
if (terminal) {
|
||
terminal.textContent = _taskLogOutput;
|
||
terminal.scrollTop = terminal.scrollHeight;
|
||
}
|
||
});
|
||
_taskLogSSE.addEventListener('status', function(e) {
|
||
const statusEl = content.querySelector('.status');
|
||
if (statusEl) {
|
||
statusEl.className = `status ${e.data}`;
|
||
statusEl.textContent = statusText(e.data);
|
||
}
|
||
// 移除 spinner 和更新提示
|
||
const spinner = content.querySelector('.spinner');
|
||
if (spinner) spinner.remove();
|
||
const hint = content.querySelector('h3 span');
|
||
if (hint) hint.remove();
|
||
});
|
||
_taskLogSSE.addEventListener('error', function(e) {
|
||
// SSE 连接关闭(正常完成或断开)
|
||
if (_taskLogSSE) {
|
||
_taskLogSSE.close();
|
||
_taskLogSSE = null;
|
||
}
|
||
});
|
||
}
|
||
} else {
|
||
showToast('获取任务详情失败', 'error');
|
||
}
|
||
}
|
||
|
||
function escapeHtml(text) {
|
||
const div = document.createElement('div');
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
// 表单提交
|
||
document.getElementById('addHostForm').onsubmit = function(e) {
|
||
e.preventDefault();
|
||
addHost(this);
|
||
};
|
||
|
||
document.getElementById('editHostForm').onsubmit = function(e) {
|
||
e.preventDefault();
|
||
updateHost(this);
|
||
};
|
||
|
||
// 点击模态框外部关闭
|
||
document.querySelectorAll('.modal').forEach(modal => {
|
||
modal.addEventListener('click', function(e) {
|
||
if (e.target === this) {
|
||
this.classList.remove('active');
|
||
}
|
||
});
|
||
});
|
||
|
||
// 定时刷新(保留用户已选择的状态)
|
||
setInterval(async () => {
|
||
await loadTasks();
|
||
await loadHosts();
|
||
saveSelectedHosts();
|
||
renderHostCheckboxes();
|
||
restoreSelectedHosts();
|
||
if (currentTab === 'dashboard') updateDashboard();
|
||
}, 300000); // 5分钟
|
||
|
||
// 保存当前选中的主机和主机组
|
||
function saveSelectedHosts() {
|
||
window._savedHostCheckboxes = Array.from(document.querySelectorAll('input[name="hosts"]:checked')).map(i => i.value);
|
||
window._savedPlaybookHostCheckboxes = Array.from(document.querySelectorAll('input[name="playbookHosts"]:checked')).map(i => i.value);
|
||
window._savedPlaybookGroupCheckboxes = Array.from(document.querySelectorAll('input[name="playbookGroups"]:checked')).map(i => i.value);
|
||
window._savedCmdGroupCheckboxes = Array.from(document.querySelectorAll('input[name="cmdGroups"]:checked')).map(i => i.value);
|
||
}
|
||
|
||
// 恢复选中的主机和主机组
|
||
function restoreSelectedHosts() {
|
||
const savedHosts = window._savedHostCheckboxes || [];
|
||
const savedPbHosts = window._savedPlaybookHostCheckboxes || [];
|
||
const savedPbGroups = window._savedPlaybookGroupCheckboxes || [];
|
||
const savedCmdGroups = window._savedCmdGroupCheckboxes || [];
|
||
document.querySelectorAll('input[name="hosts"]').forEach(i => {
|
||
if (savedHosts.includes(i.value)) i.checked = true;
|
||
});
|
||
document.querySelectorAll('input[name="playbookHosts"]').forEach(i => {
|
||
if (savedPbHosts.includes(i.value)) i.checked = true;
|
||
});
|
||
document.querySelectorAll('input[name="playbookGroups"]').forEach(i => {
|
||
if (savedPbGroups.includes(i.value)) i.checked = true;
|
||
});
|
||
document.querySelectorAll('input[name="cmdGroups"]').forEach(i => {
|
||
if (savedCmdGroups.includes(i.value)) i.checked = true;
|
||
});
|
||
}
|
||
|
||
// 启动
|
||
init();
|
||
</script>
|
||
</body>
|
||
</html>
|