Files
ansible-deploy/web/dist/index.html
T

1691 lines
73 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ansible批量部署工具</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');
// 关闭任务日志 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>