文件
ansible-deploy/web/dist/index.html
T

1642 行
71 KiB
HTML
原始文件 Blame 文件历史

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
<!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')">&times;</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')">&times;</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')">&times;</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')">&times;</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="---&#10;- name: Example Playbook&#10; hosts: all&#10; become: yes&#10; vars:&#10; key: value&#10; tasks:&#10; - name: Task name&#10; module:&#10; 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');
}
// 编辑主机
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');
}
}
}
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');
content.innerHTML = `
<div style="margin-bottom: 15px;">
<strong>任务名称:</strong> ${task.name}<br>
<strong>状态:</strong> <span class="status ${task.status}">${statusText(task.status)}</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>
</div>
<h3 style="color:#00d4aa;margin-bottom:10px;">执行结果</h3>
<div class="terminal">
${(task.results || []).map(r => `
<div class="line ${r.success ? 'success' : 'error'}">[${r.host}] ${r.success ? '✓' : '✗'} (${r.duration || 0}ms)</div>
${r.output ? `<div class="line">${escapeHtml(r.output)}</div>` : ''}
${r.error ? `<div class="line error">${escapeHtml(r.error)}</div>` : ''}
`).join('') || '<div class="line info">暂无结果</div>'}
</div>
`;
document.getElementById('taskDetailModal').classList.add('active');
} 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>