|
|
@@ -0,0 +1,1639 @@
|
|
|
+<!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>
|
|
|
+ </tr>
|
|
|
+ </thead>
|
|
|
+ <tbody id="hostTableBody">
|
|
|
+ </tbody>
|
|
|
+ </table>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="card">
|
|
|
+ <h2>📁 主机组</h2>
|
|
|
+ <div class="grid">
|
|
|
+ <div>
|
|
|
+ <button class="btn btn-primary" onclick="showAddGroupModal()">+ 创建组</button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div id="groupsList" style="margin-top: 15px;"></div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 命令执行 -->
|
|
|
+ <div id="command" class="tab-content" style="display: none;">
|
|
|
+ <div class="card">
|
|
|
+ <h2>💻 命令执行</h2>
|
|
|
+ <div style="display: grid; grid-template-columns: 220px 1fr 1fr; gap: 20px;">
|
|
|
+ <div>
|
|
|
+ <div class="form-group">
|
|
|
+ <label>选择主机组</label>
|
|
|
+ <div class="checkbox-group" id="cmdGroupCheckboxes"></div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <div class="form-group">
|
|
|
+ <label>选择主机</label>
|
|
|
+ <div class="checkbox-group" id="hostCheckboxes"></div>
|
|
|
+ </div>
|
|
|
+ <div class="form-group">
|
|
|
+ <label>要执行的命令</label>
|
|
|
+ <textarea id="commandInput" rows="4" placeholder="输入Shell命令,如: df -h、free -m、uptime"></textarea>
|
|
|
+ </div>
|
|
|
+ <div class="form-group">
|
|
|
+ <label>快捷命令</label>
|
|
|
+ <div style="display: flex; flex-wrap: wrap; gap: 6px;">
|
|
|
+ <button class="btn" style="background:#2d3748;color:#e7e9ea;font-size:12px;padding:5px 10px;" onclick="document.getElementById('commandInput').value='uptime'">⏱ uptime</button>
|
|
|
+ <button class="btn" style="background:#2d3748;color:#e7e9ea;font-size:12px;padding:5px 10px;" onclick="document.getElementById('commandInput').value='df -h'">💾 df -h</button>
|
|
|
+ <button class="btn" style="background:#2d3748;color:#e7e9ea;font-size:12px;padding:5px 10px;" onclick="document.getElementById('commandInput').value='free -m'">🧠 free -m</button>
|
|
|
+ <button class="btn" style="background:#2d3748;color:#e7e9ea;font-size:12px;padding:5px 10px;" onclick="document.getElementById('commandInput').value='hostname'">🖥 hostname</button>
|
|
|
+ <button class="btn" style="background:#2d3748;color:#e7e9ea;font-size:12px;padding:5px 10px;" onclick="document.getElementById('commandInput').value='ip addr show'">🌐 ip addr</button>
|
|
|
+ <button class="btn" style="background:#2d3748;color:#e7e9ea;font-size:12px;padding:5px 10px;" onclick="document.getElementById('commandInput').value='cat /etc/os-release'">📋 系统版本</button>
|
|
|
+ <button class="btn" style="background:#2d3748;color:#e7e9ea;font-size:12px;padding:5px 10px;" onclick="document.getElementById('commandInput').value='systemctl status --failed'">⚠️ 失败服务</button>
|
|
|
+ <button class="btn" style="background:#2d3748;color:#e7e9ea;font-size:12px;padding:5px 10px;" onclick="document.getElementById('commandInput').value='top -bn1 | head -20'">📊 Top20</button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="form-group">
|
|
|
+ <label><input type="checkbox" id="parallelExecute" checked> 并行执行</label>
|
|
|
+ </div>
|
|
|
+ <button class="btn btn-primary" onclick="executeCommand()" style="width:100%;padding:12px;">▶ 执行命令</button>
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <div class="form-group">
|
|
|
+ <label>执行结果</label>
|
|
|
+ <div class="terminal" id="commandOutput" style="min-height: 350px;">
|
|
|
+ <div class="line info">选择主机,输入命令后点击执行...</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- Playbook -->
|
|
|
+ <div id="playbook" class="tab-content" style="display: none;">
|
|
|
+ <div style="display: grid; grid-template-columns: 340px 1fr; gap: 20px;">
|
|
|
+ <!-- 左列:Playbook列表 -->
|
|
|
+ <div class="card" style="padding:15px;">
|
|
|
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
|
|
|
+ <h2 style="margin:0;">📜 Playbook列表</h2>
|
|
|
+ <button class="btn btn-primary" onclick="showCreatePlaybookModal()" style="font-size:13px;padding:5px 12px;">➕ 新建</button>
|
|
|
+ </div>
|
|
|
+ <div id="playbooksList"></div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 右列:执行配置 + 输出 -->
|
|
|
+ <div>
|
|
|
+ <!-- 执行配置 -->
|
|
|
+ <div class="card" style="margin-bottom:20px;">
|
|
|
+ <h2>🚀 执行配置 <span id="selectedPlaybookName" style="color:#8899a6;font-size:14px;font-weight:normal;"></span></h2>
|
|
|
+
|
|
|
+ <!-- 基础设置 -->
|
|
|
+ <div style="display:grid; grid-template-columns:1fr 1fr 1fr; gap:20px;">
|
|
|
+ <div>
|
|
|
+ <div class="form-group">
|
|
|
+ <label>选择主机组</label>
|
|
|
+ <div class="checkbox-group" id="playbookGroupCheckboxes"></div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <div class="form-group">
|
|
|
+ <label>选择主机</label>
|
|
|
+ <div class="checkbox-group" id="playbookHostCheckboxes"></div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <div class="form-group">
|
|
|
+ <label>变量 (JSON格式)</label>
|
|
|
+ <textarea id="extraVarsInput" rows="5" placeholder='{"key": "value"}' style="font-family:Monaco,Menlo,monospace;font-size:13px;"></textarea>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- Playbook变量(自动从YAML解析) -->
|
|
|
+ <div id="playbookVarsSection" style="display:none;margin-bottom:15px;">
|
|
|
+ <div class="form-group">
|
|
|
+ <label>📋 Playbook变量 <span style="color:#8899a6;font-size:12px;">(自动解析,点击填充到变量区)</span></label>
|
|
|
+ <div id="playbookVarsList" style="display:flex;flex-wrap:wrap;gap:8px;"></div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 选项配置 -->
|
|
|
+ <div style="background:#0f1419;border-radius:8px;padding:15px;margin-bottom:15px;">
|
|
|
+ <h3 style="font-size:14px;color:#00d4aa;margin-bottom:12px;cursor:pointer;" onclick="toggleOptions()">
|
|
|
+ ⚙️ 执行选项 <span id="optionsToggleIcon" style="float:right;">▼</span>
|
|
|
+ </h3>
|
|
|
+ <div id="optionsPanel" style="display:none;">
|
|
|
+ <div style="display:grid; grid-template-columns:1fr 1fr 1fr; gap:15px;">
|
|
|
+ <div class="form-group" style="margin-bottom:10px;">
|
|
|
+ <label>Verbose级别</label>
|
|
|
+ <select id="verboseSelect">
|
|
|
+ <option value="">默认</option>
|
|
|
+ <option value="v">v (-v)</option>
|
|
|
+ <option value="vv">vv (-vv)</option>
|
|
|
+ <option value="vvv">vvv (-vvv)</option>
|
|
|
+ <option value="vvvv">vvvv (-vvvv)</option>
|
|
|
+ </select>
|
|
|
+ </div>
|
|
|
+ <div class="form-group" style="margin-bottom:10px;">
|
|
|
+ <label>并发数 (Forks)</label>
|
|
|
+ <input type="number" id="forksInput" min="1" max="100" value="5" placeholder="默认5">
|
|
|
+ </div>
|
|
|
+ <div class="form-group" style="margin-bottom:10px;">
|
|
|
+ <label>超时时间(秒)</label>
|
|
|
+ <input type="number" id="timeoutInput" min="0" placeholder="0=不限">
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div style="display:flex;flex-wrap:wrap;gap:20px;margin-top:8px;">
|
|
|
+ <label style="display:flex;align-items:center;gap:6px;cursor:pointer;">
|
|
|
+ <input type="checkbox" id="checkMode"> 🔍 Dry-Run (检查模式)
|
|
|
+ </label>
|
|
|
+ <label style="display:flex;align-items:center;gap:6px;cursor:pointer;">
|
|
|
+ <input type="checkbox" id="diffMode"> 📝 Diff (显示差异)
|
|
|
+ </label>
|
|
|
+ <label style="display:flex;align-items:center;gap:6px;cursor:pointer;">
|
|
|
+ <input type="checkbox" id="becomeMode" checked> 🔑 Become (提权)
|
|
|
+ </label>
|
|
|
+ </div>
|
|
|
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:15px;margin-top:12px;">
|
|
|
+ <div class="form-group" style="margin-bottom:10px;">
|
|
|
+ <label>Tags <span style="color:#8899a6;font-size:11px;">(逗号分隔,只执行指定标签)</span></label>
|
|
|
+ <input type="text" id="tagsInput" placeholder="如: install,config">
|
|
|
+ </div>
|
|
|
+ <div class="form-group" style="margin-bottom:10px;">
|
|
|
+ <label>Skip Tags <span style="color:#8899a6;font-size:11px;">(逗号分隔,跳过指定标签)</span></label>
|
|
|
+ <input type="text" id="skipTagsInput" placeholder="如: restart">
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="form-group" style="margin-bottom:10px;margin-top:5px;">
|
|
|
+ <label>自定义参数 <span style="color:#8899a6;font-size:11px;">(直接追加到命令行)</span></label>
|
|
|
+ <input type="text" id="extraArgsInput" placeholder="如: --private-key=/path/to/key">
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <button class="btn btn-primary" onclick="executePlaybook()" style="width:100%;padding:12px;font-size:15px;">▶ 执行Playbook</button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 执行输出 -->
|
|
|
+ <div class="card">
|
|
|
+ <h2>📝 执行输出</h2>
|
|
|
+ <div class="terminal" id="playbookOutput" style="min-height:250px;max-height:500px;">
|
|
|
+ <div class="line info">等待执行...</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 任务列表 -->
|
|
|
+ <div id="tasks" class="tab-content" style="display: none;">
|
|
|
+ <div class="card">
|
|
|
+ <h2>📋 所有任务</h2>
|
|
|
+ <table class="table">
|
|
|
+ <thead>
|
|
|
+ <tr>
|
|
|
+ <th>ID</th>
|
|
|
+ <th>名称</th>
|
|
|
+ <th>主机</th>
|
|
|
+ <th>状态</th>
|
|
|
+ <th>进度</th>
|
|
|
+ <th>开始时间</th>
|
|
|
+ <th>操作</th>
|
|
|
+ </tr>
|
|
|
+ </thead>
|
|
|
+ <tbody id="allTasksBody">
|
|
|
+ </tbody>
|
|
|
+ </table>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </main>
|
|
|
+
|
|
|
+ <!-- 添加主机模态框 -->
|
|
|
+ <div id="addHostModal" class="modal">
|
|
|
+ <div class="modal-content">
|
|
|
+ <div class="modal-header">
|
|
|
+ <h2>添加主机</h2>
|
|
|
+ <button class="close" onclick="closeModal('addHostModal')">×</button>
|
|
|
+ </div>
|
|
|
+ <form id="addHostForm">
|
|
|
+ <div class="form-group">
|
|
|
+ <label>名称</label>
|
|
|
+ <input type="text" name="name" required placeholder="如: web-server-01">
|
|
|
+ </div>
|
|
|
+ <div class="form-group">
|
|
|
+ <label>IP地址</label>
|
|
|
+ <input type="text" name="ip" required placeholder="如: 192.168.1.100">
|
|
|
+ </div>
|
|
|
+ <div class="form-group">
|
|
|
+ <label>端口</label>
|
|
|
+ <input type="number" name="port" value="22" placeholder="SSH端口">
|
|
|
+ </div>
|
|
|
+ <div class="form-group">
|
|
|
+ <label>用户名</label>
|
|
|
+ <input type="text" name="username" required placeholder="如: root">
|
|
|
+ </div>
|
|
|
+ <div class="form-group">
|
|
|
+ <label>密码</label>
|
|
|
+ <input type="password" name="password" placeholder="留空则使用SSH密钥">
|
|
|
+ </div>
|
|
|
+ <div class="form-group">
|
|
|
+ <label>加入组</label>
|
|
|
+ <div class="checkbox-group" id="hostGroupCheckboxes"></div>
|
|
|
+ </div>
|
|
|
+ <button type="submit" class="btn btn-primary">添加</button>
|
|
|
+ </form>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 编辑主机模态框 -->
|
|
|
+ <div id="editHostModal" class="modal">
|
|
|
+ <div class="modal-content">
|
|
|
+ <div class="modal-header">
|
|
|
+ <h2>编辑主机</h2>
|
|
|
+ <button class="close" onclick="closeModal('editHostModal')">×</button>
|
|
|
+ </div>
|
|
|
+ <form id="editHostForm">
|
|
|
+ <input type="hidden" name="id" id="editHostId">
|
|
|
+ <div class="form-group">
|
|
|
+ <label>名称</label>
|
|
|
+ <input type="text" name="name" id="editHostName" required placeholder="如: web-server-01">
|
|
|
+ </div>
|
|
|
+ <div class="form-group">
|
|
|
+ <label>IP地址</label>
|
|
|
+ <input type="text" name="ip" id="editHostIp" required placeholder="如: 192.168.1.100">
|
|
|
+ </div>
|
|
|
+ <div class="form-group">
|
|
|
+ <label>端口</label>
|
|
|
+ <input type="number" name="port" id="editHostPort" placeholder="SSH端口">
|
|
|
+ </div>
|
|
|
+ <div class="form-group">
|
|
|
+ <label>用户名</label>
|
|
|
+ <input type="text" name="username" id="editHostUsername" required placeholder="如: root">
|
|
|
+ </div>
|
|
|
+ <div class="form-group">
|
|
|
+ <label>密码</label>
|
|
|
+ <input type="password" name="password" id="editHostPassword" placeholder="留空则不修改密码">
|
|
|
+ </div>
|
|
|
+ <div class="form-group">
|
|
|
+ <label>加入组</label>
|
|
|
+ <div class="checkbox-group" id="editHostGroupCheckboxes"></div>
|
|
|
+ </div>
|
|
|
+ <button type="submit" class="btn btn-primary">保存修改</button>
|
|
|
+ </form>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 任务详情模态框 -->
|
|
|
+ <div id="taskDetailModal" class="modal">
|
|
|
+ <div class="modal-content" style="max-width: 800px;">
|
|
|
+ <div class="modal-header">
|
|
|
+ <h2>📋 任务详情</h2>
|
|
|
+ <button class="close" onclick="closeModal('taskDetailModal')">×</button>
|
|
|
+ </div>
|
|
|
+ <div id="taskDetailContent"></div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 新建/编辑Playbook模态框 -->
|
|
|
+ <div id="playbookEditorModal" class="modal">
|
|
|
+ <div class="modal-content" style="max-width: 900px;">
|
|
|
+ <div class="modal-header">
|
|
|
+ <h2 id="playbookEditorTitle">📝 新建Playbook</h2>
|
|
|
+ <button class="close" onclick="closeModal('playbookEditorModal')">×</button>
|
|
|
+ </div>
|
|
|
+ <div class="form-group">
|
|
|
+ <label>Playbook名称 <span style="color:#8899a6;font-size:12px;">(仅英文、数字、中横线,自动加.yml后缀)</span></label>
|
|
|
+ <input type="text" id="playbookEditorName" placeholder="如: deploy-app" style="width:100%;">
|
|
|
+ </div>
|
|
|
+ <div class="form-group">
|
|
|
+ <label>YAML内容</label>
|
|
|
+ <textarea id="playbookEditorContent" rows="20" placeholder="--- - name: Example Playbook hosts: all become: yes vars: key: value tasks: - name: Task name module: param: value" style="width:100%;font-family:Monaco,Menlo,Consolas,monospace;font-size:13px;line-height:1.5;resize:vertical;min-height:400px;"></textarea>
|
|
|
+ </div>
|
|
|
+ <div style="display:flex;gap:10px;justify-content:flex-end;">
|
|
|
+ <button class="btn" onclick="closeModal('playbookEditorModal')" style="background:#2d3748;color:#e7e9ea;">取消</button>
|
|
|
+ <button class="btn btn-primary" onclick="savePlaybook()">💾 保存</button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <script>
|
|
|
+ const API_BASE = '/api';
|
|
|
+ let hosts = [];
|
|
|
+ let groups = [];
|
|
|
+ let playbooks = [];
|
|
|
+ let tasks = [];
|
|
|
+ let currentTab = 'dashboard';
|
|
|
+
|
|
|
+ // Toast提示
|
|
|
+ function showToast(msg, type = 'info') {
|
|
|
+ const toast = document.createElement('div');
|
|
|
+ toast.className = `toast ${type}`;
|
|
|
+ toast.textContent = msg;
|
|
|
+ document.body.appendChild(toast);
|
|
|
+ setTimeout(() => toast.classList.add('show'), 10);
|
|
|
+ setTimeout(() => {
|
|
|
+ toast.classList.remove('show');
|
|
|
+ setTimeout(() => toast.remove(), 300);
|
|
|
+ }, 3000);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 初始化
|
|
|
+ async function init() {
|
|
|
+ await loadHosts();
|
|
|
+ await loadGroups();
|
|
|
+ await loadPlaybooks();
|
|
|
+ await loadTasks();
|
|
|
+ updateDashboard();
|
|
|
+ renderHostCheckboxes();
|
|
|
+ }
|
|
|
+
|
|
|
+ // API请求
|
|
|
+ async function api(endpoint, options = {}) {
|
|
|
+ try {
|
|
|
+ const opts = { ...options };
|
|
|
+ // DELETE/GET without body should not send Content-Type: application/json
|
|
|
+ if (!opts.body) {
|
|
|
+ opts.headers = { ...(opts.headers || {}) };
|
|
|
+ delete opts.headers['Content-Type'];
|
|
|
+ } else {
|
|
|
+ opts.headers = { 'Content-Type': 'application/json', ...(opts.headers || {}) };
|
|
|
+ }
|
|
|
+ const res = await fetch(API_BASE + endpoint, opts);
|
|
|
+ const text = await res.text();
|
|
|
+ try {
|
|
|
+ return JSON.parse(text);
|
|
|
+ } catch {
|
|
|
+ return { code: res.status, msg: text };
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ console.error('API Error:', err);
|
|
|
+ return { code: 500, msg: err.message };
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 加载数据
|
|
|
+ async function loadHosts() {
|
|
|
+ const res = await api('/hosts');
|
|
|
+ if (res.code === 0) hosts = res.data || [];
|
|
|
+ renderHostsTable();
|
|
|
+ }
|
|
|
+
|
|
|
+ async function loadGroups() {
|
|
|
+ const res = await api('/groups');
|
|
|
+ if (res.code === 0) groups = res.data || [];
|
|
|
+ renderGroups();
|
|
|
+ }
|
|
|
+
|
|
|
+ async function loadPlaybooks() {
|
|
|
+ const res = await api('/playbooks');
|
|
|
+ if (res.code === 0) playbooks = res.data || [];
|
|
|
+ renderPlaybooks();
|
|
|
+ }
|
|
|
+
|
|
|
+ async function loadTasks() {
|
|
|
+ const res = await api('/tasks');
|
|
|
+ if (res.code === 0) tasks = res.data || [];
|
|
|
+ renderTasks();
|
|
|
+ }
|
|
|
+
|
|
|
+ // 渲染主机表格
|
|
|
+ function renderHostsTable() {
|
|
|
+ const tbody = document.getElementById('hostTableBody');
|
|
|
+ if (hosts.length === 0) {
|
|
|
+ tbody.innerHTML = '<tr><td colspan="6" 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>
|
|
|
+ <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.hosts?.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.hosts?.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.hosts?.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)?.hosts?.length || 0) + '台)';
|
|
|
+ } else {
|
|
|
+ div.style.display = 'none';
|
|
|
+ toggle.textContent = '▶ ' + groupName + ' (' + (groups.find(g => g.name === groupName)?.hosts?.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.hosts) {
|
|
|
+ g.hosts.forEach(hName => {
|
|
|
+ if (!hostList.includes(hName)) hostList.push(hName);
|
|
|
+ });
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ 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.hosts) {
|
|
|
+ g.hosts.forEach(hName => {
|
|
|
+ if (!hostList.includes(hName)) hostList.push(hName);
|
|
|
+ });
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ 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>
|