浏览代码

feat: Add image update, deletion, and log viewing features.

dqzboy 1 年之前
父节点
当前提交
9fbeaeaa13
共有 5 个文件被更改,包括 523 次插入56 次删除
  1. 1 1
      README.md
  2. 6 0
      hubcmdui/README.md
  3. 2 1
      hubcmdui/package.json
  4. 162 12
      hubcmdui/server.js
  5. 352 42
      hubcmdui/web/admin.html

+ 1 - 1
README.md

@@ -149,7 +149,7 @@ docker logs -f [容器ID或名称]
 - [x] 支持国内服务器一键部署,解决国内环境无法安装Docker\Compose服务难题
 - [x] 支持主流Linux发行版操作系统,例如Centos、Ubuntu、Rocky、Debian、Rhel等
 - [x] 支持主流ARCH架构下部署,包括linux/amd64、linux/arm64
-- [x] 针对本项目单独开发Docker Registry管理面板,实现镜像搜索、广告展示、文档教程、容器管理等
+- [x] 针对本项目单独开发Docker Registry管理面板,实现镜像搜索、广告展示、文档教程、容器管理等功能
 
 ## ✨ 教程
 #### 配置Nginx反向代理

+ 6 - 0
hubcmdui/README.md

@@ -133,6 +133,12 @@ docker logs -f [容器ID或名称]
     </tr>
 </table>
 
+<table>
+    <tr>
+        <td width="50%" align="center"><img src="https://github.com/user-attachments/assets/b36d9b53-0696-4e44-b847-1019e7739201"?raw=true"></td>
+    </tr>
+</table>
+
 ---
 
 ## 🫶 赞助

+ 2 - 1
hubcmdui/package.json

@@ -6,6 +6,7 @@
     "dockerode": "^4.0.2",
     "express": "^4.19.2",
     "express-session": "^1.18.0",
-    "morgan": "^1.10.0"
+    "morgan": "^1.10.0",
+    "ws": "^8.18.0"
   }
 }

+ 162 - 12
hubcmdui/server.js

@@ -10,6 +10,8 @@ const axios = require('axios'); // 用于发送 HTTP 请求
 const Docker = require('dockerode');
 const app = express();
 const cors = require('cors');
+const WebSocket = require('ws');
+const http = require('http');
 
 let docker = null;
 
@@ -172,11 +174,10 @@ async function writeDocumentation(content) {
 
 // 登录验证
 app.post('/api/login', async (req, res) => {
-  const { username, password, captcha } = req.body;
-  console.log(`Received login request for user: ${username}`); // 打印登录请求的用户名
+  const { username, captcha } = req.body;
 
   if (req.session.captcha !== parseInt(captcha)) {
-    console.log(`Captcha verification failed for user: ${username}`); // 打印验证码验证失败
+    console.log(`Captcha verification failed for user: ${username}`);
     return res.status(401).json({ error: '验证码错误' });
   }
 
@@ -184,17 +185,16 @@ app.post('/api/login', async (req, res) => {
   const user = users.users.find(u => u.username === username);
 
   if (!user) {
-    console.log(`User ${username} not found`); // 打印用户未找到
+    console.log(`User ${username} not found`);
     return res.status(401).json({ error: '用户名或密码错误' });
   }
 
-  console.log(`User ${username} found, comparing passwords`); // 打印用户找到,开始比较密码
-  if (bcrypt.compareSync(password, user.password)) {
-    console.log(`User ${username} logged in successfully`); // 打印登录成功
-    req.session.user = user;
+  if (bcrypt.compareSync(req.body.password, user.password)) {
+    req.session.user = { username: user.username };
+    console.log(`User ${username} logged in successfully`);
     res.json({ success: true });
   } else {
-    console.log(`Login failed for user: ${username}, password mismatch`); // 打印密码不匹配
+    console.log(`Login failed for user: ${username}`);
     res.status(401).json({ error: '用户名或密码错误' });
   }
 });
@@ -222,11 +222,19 @@ app.post('/api/change-password', async (req, res) => {
 
 // 需要登录验证的中间件
 function requireLogin(req, res, next) {
-  console.log('Session:', req.session); // 添加这行
+  // 创建一个新的对象,只包含非敏感信息
+  const sanitizedSession = {
+    cookie: req.session.cookie,
+    captcha: req.session.captcha,
+    user: req.session.user ? { username: req.session.user.username } : undefined
+  };
+
+  console.log('Session:', JSON.stringify(sanitizedSession, null, 2));
+
   if (req.session.user) {
     next();
   } else {
-    console.log('用户未登录'); // 添加这行
+    console.log('用户未登录');
     res.status(401).json({ error: 'Not logged in' });
   }
 }
@@ -496,8 +504,150 @@ app.get('/api/docker/status/:id', requireLogin, async (req, res) => {
   }
 });
 
+
+// API端点:更新容器
+app.post('/api/docker/update/:id', requireLogin, async (req, res) => {
+  try {
+    const docker = await initDocker();
+    if (!docker) {
+      return res.status(503).json({ error: '无法连接到 Docker 守护进程' });
+    }
+    const container = docker.getContainer(req.params.id);
+    const containerInfo = await container.inspect();
+    const currentImage = containerInfo.Config.Image;
+    const [imageName] = currentImage.split(':');
+    const newImage = `${imageName}:${req.body.tag}`;
+    const containerName = containerInfo.Name.slice(1);  // 去掉开头的 '/'
+
+    console.log(`Updating container ${req.params.id} from ${currentImage} to ${newImage}`);
+
+    // 拉取新镜像
+    console.log(`Pulling new image: ${newImage}`);
+    await new Promise((resolve, reject) => {
+      docker.pull(newImage, (err, stream) => {
+        if (err) return reject(err);
+        docker.modem.followProgress(stream, (err, output) => err ? reject(err) : resolve(output));
+      });
+    });
+
+    // 停止旧容器
+    console.log('Stopping old container');
+    await container.stop();
+
+    // 删除旧容器
+    console.log('Removing old container');
+    await container.remove();
+
+    // 创建新容器
+    console.log('Creating new container');
+    const newContainerConfig = {
+      ...containerInfo.Config,
+      Image: newImage,
+      HostConfig: containerInfo.HostConfig,
+      NetworkingConfig: {
+        EndpointsConfig: containerInfo.NetworkSettings.Networks
+      }
+    };
+    const newContainer = await docker.createContainer({
+      ...newContainerConfig,
+      name: containerName
+    });
+
+    // 启动新容器
+    console.log('Starting new container');
+    await newContainer.start();
+
+    console.log('Container update completed successfully');
+    res.json({ success: true, message: '容器更新成功' });
+  } catch (error) {
+    console.error('更新容器失败:', error);
+    res.status(500).json({ error: '更新容器失败', details: error.message, stack: error.stack });
+  }
+});
+
+// API端点:获取容器日志
+app.get('/api/docker/logs/:id', requireLogin, async (req, res) => {
+  try {
+    const docker = await initDocker();
+    if (!docker) {
+      return res.status(503).json({ error: '无法连接到 Docker 守护进程' });
+    }
+    const container = docker.getContainer(req.params.id);
+    const logs = await container.logs({
+      stdout: true,
+      stderr: true,
+      tail: 100,  // 获取最后100行日志
+      follow: false
+    });
+    res.send(logs);
+  } catch (error) {
+    console.error('获取容器日志失败:', error);
+    res.status(500).json({ error: '获取容器日志失败', details: error.message });
+  }
+});
+
+const server = http.createServer(app);
+const wss = new WebSocket.Server({ server });
+
+wss.on('connection', (ws, req) => {
+  const containerId = req.url.split('/').pop();
+  const docker = new Docker();
+  const container = docker.getContainer(containerId);
+
+  container.logs({
+    follow: true,
+    stdout: true,
+    stderr: true,
+    tail: 100
+  }, (err, stream) => {
+    if (err) {
+      ws.send('Error: ' + err.message);
+      return;
+    }
+
+    stream.on('data', (chunk) => {
+      // 移除 ANSI 转义序列
+      const cleanedChunk = chunk.toString('utf8').replace(/\x1B\[[0-9;]*[JKmsu]/g, '');
+      // 移除不可打印字符
+      const printableChunk = cleanedChunk.replace(/[^\x20-\x7E\x0A\x0D]/g, '');
+      ws.send(printableChunk);
+    });
+
+    ws.on('close', () => {
+      stream.destroy();
+    });
+  });
+});
+
+
+// API端点:删除容器
+app.post('/api/docker/delete/:id', requireLogin, async (req, res) => {
+  try {
+    const docker = await initDocker();
+    if (!docker) {
+      return res.status(503).json({ error: '无法连接到 Docker 守护进程' });
+    }
+    const container = docker.getContainer(req.params.id);
+    
+    // 首先停止容器(如果正在运行)
+    try {
+      await container.stop();
+    } catch (stopError) {
+      console.log('Container may already be stopped:', stopError.message);
+    }
+
+    // 然后删除容器
+    await container.remove();
+    
+    res.json({ success: true, message: '容器已成功删除' });
+  } catch (error) {
+    console.error('删除容器失败:', error);
+    res.status(500).json({ error: '删除容器失败', details: error.message });
+  }
+});
+
 // 启动服务器
 const PORT = process.env.PORT || 3000;
-app.listen(PORT, () => {
+server.listen(PORT, () => {
   console.log(`Server is running on http://localhost:${PORT}`);
 });

+ 352 - 42
hubcmdui/web/admin.html

@@ -416,9 +416,64 @@
             border-top: 3px solid #0366d6;
             animation: spin 1s linear infinite;
         }
+
+        .loading-spinner {
+            position: fixed;
+            top: 50%;
+            left: 50%;
+            transform: translate(-50%, -50%);
+            width: 50px;
+            height: 50px;
+            border: 5px solid #f3f3f3;
+            border-top: 5px solid #3498db;
+            border-radius: 50%;
+            animation: spin 1s linear infinite;
+            z-index: 9999;
+        }
+
+        @keyframes spin {
+            0% { transform: translate(-50%, -50%) rotate(0deg); }
+            100% { transform: translate(-50%, -50%) rotate(360deg); }
+        }
+
+        .disabled {
+            opacity: 0.5;
+            pointer-events: none;
+        }
+
+        .status-cell {
+            position: relative;
+            min-height: 24px; /* 确保单元格有足够的高度来容纳加载动画 */
+        }
+        .loading-container {
+            display: flex;
+            justify-content: center;
+            align-items: center;
+            position: absolute;
+            top: 0;
+            left: 0;
+            right: 0;
+            bottom: 0;
+        }
+
+        .loading {
+            display: inline-block;
+            width: 20px;
+            height: 20px;
+            border: 3px solid rgba(0, 0, 0, 0.1);
+            border-radius: 50%;
+            border-top: 3px solid #0366d6;
+            animation: spin 1s linear infinite;
+        }
+
+        @keyframes spin {
+            0% { transform: rotate(0deg); }
+            100% { transform: rotate(360deg); }
+        }
     </style>
 </head>
 <body>
+    <div id="loadingSpinner" class="loading-spinner" style="display: none;"></div>
     <div id="loadingIndicator" style="display: flex; justify-content: center; align-items: center; height: 100vh;">
         <p>加载中...</p>
     </div>
@@ -1318,7 +1373,56 @@
             }
         }
 
+        async function refreshDockerStatus() {
+            const spinner = document.getElementById('loadingSpinner');
+            const refreshButton = document.getElementById('refreshDockerStatusButton');
+            const table = document.getElementById('dockerStatusTable');
+            
+            try {
+                spinner.style.display = 'block';
+                refreshButton.classList.add('disabled');
+                table.classList.add('disabled');
+                
+                await loadDockerStatus();
+            } catch (error) {
+                console.error('刷新 Docker 状态失败:', error);
+                alert('刷新 Docker 状态失败: ' + error.message);
+            } finally {
+                spinner.style.display = 'none';
+                refreshButton.classList.remove('disabled');
+                table.classList.remove('disabled');
+            }
+        }
+
+        function saveDockerStatusToCache(containerStatus) {
+            localStorage.setItem('dockerStatus', JSON.stringify(containerStatus));
+            localStorage.setItem('dockerStatusTimestamp', Date.now());
+        }
+
+        function getDockerStatusFromCache() {
+            const cachedStatus = localStorage.getItem('dockerStatus');
+            const timestamp = localStorage.getItem('dockerStatusTimestamp');
+            if (cachedStatus && timestamp) {
+                // 检查缓存是否在过去5分钟内更新过
+                if (Date.now() - parseInt(timestamp) < 5 * 60 * 1000) {
+                    return JSON.parse(cachedStatus);
+                }
+            }
+            return null;
+        }
+
         async function loadDockerStatus() {
+            const tbody = document.getElementById('dockerStatusTableBody');
+            
+            // 尝试从缓存加载数据
+            const cachedStatus = getDockerStatusFromCache();
+            if (cachedStatus) {
+                renderDockerStatus(cachedStatus);
+                isDockerStatusLoaded = true;
+            } else if (!isDockerStatusLoaded) {
+                tbody.innerHTML = '<tr><td colspan="8" style="text-align: center;">加载中...</td></tr>';
+            }
+
             try {
                 const response = await fetch('/api/docker-status');
                 if (!response.ok) {
@@ -1329,21 +1433,22 @@
                 }
                 const containerStatus = await response.json();
                 renderDockerStatus(containerStatus);
+                isDockerStatusLoaded = true;
+                saveDockerStatusToCache(containerStatus);
             } catch (error) {
                 console.error('Error loading Docker status:', error);
-                alert('加载 Docker 状态失败: ' + error.message);
-                // 清空状态表格并显示错误信息
-                const tbody = document.getElementById('dockerStatusTableBody');
-                tbody.innerHTML = `<tr><td colspan="8" style="text-align: center; color: red;">${error.message}</td></tr>`;
+                if (!cachedStatus) {
+                    tbody.innerHTML = `<tr><td colspan="8" style="text-align: center; color: red;">${error.message}</td></tr>`;
+                }
+                isDockerStatusLoaded = false;
             }
         }
 
         function renderDockerStatus(containerStatus) {
+            const table = document.getElementById('dockerStatusTable');
+            const thead = table.getElementsByTagName('thead')[0];
             const tbody = document.getElementById('dockerStatusTableBody');
-            tbody.innerHTML = '';
-            
-            // 添加表头
-            const thead = document.getElementById('dockerStatusTable').getElementsByTagName('thead')[0];
+
             thead.innerHTML = `
                 <tr>
                     <th>容器 ID</th>
@@ -1357,19 +1462,27 @@
                 </tr>
             `;
 
+            tbody.innerHTML = '';
+
             containerStatus.forEach(container => {
                 const row = `
                 <tr>
                     <td>${container.id}</td>
                     <td>${container.name}</td>
                     <td>${container.image}</td>
-                    <td id="status-${container.id}">${container.state}</td>
+                    <td class="status-cell" id="status-${container.id}">${container.state}</td>
                     <td>${container.cpu}</td>
                     <td>${container.memory}</td>
                     <td>${container.created}</td>
                     <td>
-                        <button onclick="restartContainer('${container.id}')" class="action-btn">重启</button>
-                        <button onclick="stopContainer('${container.id}')" class="action-btn">停止</button>
+                        <select onchange="handleContainerAction('${container.id}', '${container.image}', this.value)" class="action-select">
+                            <option value="">选择操作</option>
+                            <option value="restart">重启</option>
+                            <option value="stop">停止</option>
+                            <option value="update">更新</option>
+                            <option value="delete">删除</option>
+                            <option value="logs">查看日志</option>                            
+                        </select>
                     </td>
                 </tr>
                 `;
@@ -1377,12 +1490,115 @@
             });
         }
 
+        function handleContainerAction(id, image, action) {
+            switch(action) {
+                case 'restart':
+                    restartContainer(id);
+                    break;
+                case 'stop':
+                    stopContainer(id);
+                    break;
+                case 'update':
+                    updateContainer(id, image);
+                    break;
+                case 'logs':
+                    viewLogs(id);
+                    break;
+                case 'delete':
+                    deleteContainer(id);
+                    break;
+            }
+            // 重置选择框
+            document.querySelector(`select[onchange*="${id}"]`).value = "";
+        }
+
+        async function viewLogs(id) {
+            try {
+                // 创建模态框
+                const modal = document.createElement('div');
+                modal.style.position = 'fixed';
+                modal.style.left = '0';
+                modal.style.top = '0';
+                modal.style.width = '100%';
+                modal.style.height = '100%';
+                modal.style.backgroundColor = 'rgba(0,0,0,0.5)';
+                modal.style.display = 'flex';
+                modal.style.justifyContent = 'center';
+                modal.style.alignItems = 'center';
+
+                const content = document.createElement('div');
+                content.style.backgroundColor = 'black';
+                content.style.color = 'white';
+                content.style.padding = '20px';
+                content.style.borderRadius = '5px';
+                content.style.width = '80%';
+                content.style.height = '80%';
+                content.style.display = 'flex';
+                content.style.flexDirection = 'column';
+                content.style.position = 'relative';
+
+                const logContent = document.createElement('pre');
+                logContent.style.flex = '1';
+                logContent.style.overflowY = 'auto';
+                logContent.style.padding = '10px';
+                logContent.style.backgroundColor = '#1e1e1e';
+                logContent.style.color = '#d4d4d4';
+                logContent.style.fontFamily = 'monospace';
+                logContent.style.fontSize = '14px';
+                logContent.style.lineHeight = '1.5';
+                logContent.style.whiteSpace = 'pre-wrap';
+                logContent.style.wordBreak = 'break-all';
+
+                content.appendChild(logContent);
+                modal.appendChild(content);
+                document.body.appendChild(modal);
+
+                // 点击模态框外部关闭
+                modal.addEventListener('click', (e) => {
+                    if (e.target === modal) {
+                        document.body.removeChild(modal);
+                    }
+                });
+
+                // 建立WebSocket连接以实时获取日志
+                const ws = new WebSocket(`ws://${window.location.host}/api/docker/logs/${id}`);
+
+                ws.onmessage = (event) => {
+                    // 过滤掉不可打印字符
+                    const filteredData = event.data.replace(/[\x00-\x09\x0B-\x0C\x0E-\x1F\x7F-\x9F]/g, '');
+                    logContent.textContent += filteredData + '\n';
+                    logContent.scrollTop = logContent.scrollHeight;
+                };
+
+                ws.onerror = (error) => {
+                    console.error('WebSocket错误:', error);
+                    logContent.textContent += '连接错误,无法获取实时日志。\n';
+                };
+
+                ws.onclose = () => {
+                    logContent.textContent += '日志连接已关闭。\n';
+                };
+
+                // 当模态框关闭时,关闭WebSocket连接
+                modal.addEventListener('click', (e) => {
+                    if (e.target === modal) {
+                        ws.close();
+                        document.body.removeChild(modal);
+                    }
+                });
+
+            } catch (error) {
+                console.error('查看日志失败:', error);
+                alert('查看日志失败: ' + error.message);
+            }
+        }
+
         async function restartContainer(id) {
             if (confirm('确定要重启这个容器吗?')) {
                 try {
                     const statusCell = document.getElementById(`status-${id}`);
-                    statusCell.innerHTML = '<div class="loading"></div>';
-                    
+                    statusCell.innerHTML = '<div class="loading-container"><div class="loading"></div></div>';
+
                     const response = await fetch(`/api/docker/restart/${id}`, { method: 'POST' });
                     if (response.ok) {
                         await new Promise(resolve => setTimeout(resolve, 2000)); // 等待2秒,确保状态已更新
@@ -1431,43 +1647,137 @@
             }
         }
 
-        function refreshDockerStatus() {
-            loadDockerStatus();
-        }
 
-        document.addEventListener('DOMContentLoaded', function() {
-        const sidebarItems = document.querySelectorAll('.sidebar li');
-        const contentSections = document.querySelectorAll('.content-section');
-        if (isLoggedIn) {
-            initEditor();
+        async function updateContainer(id, currentImage) {
+            const tag = prompt(`请输入 ${currentImage} 的新标签:`, 'latest');
+            if (tag) {
+                try {
+                    const statusCell = document.getElementById(`status-${id}`);
+                    statusCell.textContent = 'Updating';
+                    statusCell.style.color = 'orange';
+
+                    const response = await fetch(`/api/docker/update/${id}`, {
+                        method: 'POST',
+                        headers: { 'Content-Type': 'application/json' },
+                        body: JSON.stringify({ tag })
+                    });
+
+                    if (response.ok) {
+                        const result = await response.json();
+                        alert(result.message || '容器更新成功');
+                    } else {
+                        const errorData = await response.json();
+                        throw new Error(errorData.error || '更新失败');
+                    }
+                } catch (error) {
+                    console.error('更新容器失败:', error);
+                    alert('更新容器失败: ' + error.message);
+                } finally {
+                    loadDockerStatus(); // 重新加载容器状态
+                }
+            }
         }
-        if (isLoggedIn) {
+
+        function refreshDockerStatus() {
+            isDockerStatusLoaded = false;
+            localStorage.removeItem('dockerStatus');
+            localStorage.removeItem('dockerStatusTimestamp');
             loadDockerStatus();
         }
-        function showSection(sectionId) {
-            contentSections.forEach(section => {
-                if (section.id === sectionId) {
-                    section.classList.add('active');
-                } else {
-                    section.classList.remove('active');
+
+        async function deleteContainer(id) {
+            if (confirm('确定要删除这个容器吗?此操作不可逆。')) {
+                try {
+                    const statusCell = document.getElementById(`status-${id}`);
+                    statusCell.textContent = 'Deleting';
+                    statusCell.style.color = 'red';
+
+                    const response = await fetch(`/api/docker/delete/${id}`, { method: 'POST' });
+                    if (response.ok) {
+                        alert('容器删除成功');
+                        loadDockerStatus(); // 重新加载容器状态
+                    } else {
+                        const errorData = await response.json();
+                        throw new Error(errorData.error || '删除失败');
+                    }
+                } catch (error) {
+                    console.error('删除容器失败:', error);
+                    alert('删除容器失败: ' + error.message);
+                    loadDockerStatus(); // 重新加载所有容器状态
                 }
-            });
+            }
         }
 
-        sidebarItems.forEach(item => {
-            item.addEventListener('click', function() {
-                const sectionId = this.getAttribute('data-section');
-                
-                sidebarItems.forEach(si => si.classList.remove('active'));
-                this.classList.add('active');
-                
-                showSection(sectionId);
+        document.addEventListener('DOMContentLoaded', function() {
+            const sidebarItems = document.querySelectorAll('.sidebar li');
+            const contentSections = document.querySelectorAll('.content-section');
+
+            let isDockerStatusLoaded = false;
+
+            function showSection(sectionId) {
+                contentSections.forEach(section => {
+                    if (section.id === sectionId) {
+                        section.classList.add('active');
+                        if (sectionId === 'docker-status') {
+                            loadDockerStatus(); // 每次显示 Docker 状态部分时都尝试加载
+                        }
+                    } else {
+                        section.classList.remove('active');
+                    }
+                });
+                localStorage.setItem('currentSection', sectionId);
+
+                // 更新侧边栏active状态
+                sidebarItems.forEach(item => {
+                    if (item.getAttribute('data-section') === sectionId) {
+                        item.classList.add('active');
+                    } else {
+                        item.classList.remove('active');
+                    }
+                });
+            }
+
+            sidebarItems.forEach(item => {
+                item.addEventListener('click', function() {
+                    const sectionId = this.getAttribute('data-section');
+                    showSection(sectionId);
+                });
             });
-        });
 
-        // 初始化:显示第一个部分
-        showSection(sidebarItems[0].getAttribute('data-section'));
-    });
+            // 页面加载时检查登录状态
+            window.onload = async function() {
+                try {
+                    const response = await fetch('/api/check-session');
+                    if (response.ok) {
+                        isLoggedIn = localStorage.getItem('isLoggedIn') === 'true';
+                        if (isLoggedIn) {
+                            loadDocumentList();
+                            document.getElementById('adminContainer').style.display = 'flex';
+                            await loadConfig();
+                            initEditor();
+
+                            const lastSection = localStorage.getItem('currentSection') || 'basic-config';
+                            showSection(lastSection);
+                            
+                            // 立即尝试加载 Docker 状态
+                            loadDockerStatus();
+                        } else {
+                            document.getElementById('loginModal').style.display = 'flex';
+                            refreshCaptcha();
+                        }
+                    } else {
+                        throw new Error('Session check failed');
+                    }
+                } catch (error) {
+                    console.error('Error during initialization:', error);
+                    localStorage.removeItem('isLoggedIn');
+                    document.getElementById('loginModal').style.display = 'flex';
+                    refreshCaptcha();
+                } finally {
+                    document.getElementById('loadingIndicator').style.display = 'none';
+                }
+            };
+        });
     </script>
 </body>
-</html>
+</html>