diff --git a/README.md b/README.md index ad1a887..f30b850 100644 --- a/README.md +++ b/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反向代理 diff --git a/hubcmdui/README.md b/hubcmdui/README.md index a8d897b..8d0cd28 100644 --- a/hubcmdui/README.md +++ b/hubcmdui/README.md @@ -133,6 +133,12 @@ docker logs -f [容器ID或名称] + + + + +
+ --- ## 🫶 赞助 diff --git a/hubcmdui/package.json b/hubcmdui/package.json index 1662160..729b065 100644 --- a/hubcmdui/package.json +++ b/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" } } diff --git a/hubcmdui/server.js b/hubcmdui/server.js index cc7da7a..65005af 100644 --- a/hubcmdui/server.js +++ b/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}`); }); \ No newline at end of file diff --git a/hubcmdui/web/admin.html b/hubcmdui/web/admin.html index 119de1a..fbbf5c7 100644 --- a/hubcmdui/web/admin.html +++ b/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); } + } +

加载中...

@@ -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 = '加载中...'; + } + 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 = `${error.message}`; + if (!cachedStatus) { + tbody.innerHTML = `${error.message}`; + } + 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 = ` 容器 ID @@ -1357,19 +1462,27 @@ `; + tbody.innerHTML = ''; + containerStatus.forEach(container => { const row = ` ${container.id} ${container.name} ${container.image} - ${container.state} + ${container.state} ${container.cpu} ${container.memory} ${container.created} - - + `; @@ -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 = '
'; - + statusCell.innerHTML = '
'; + 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 @@ } } + + 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(); // 重新加载容器状态 + } + } + } + function refreshDockerStatus() { + isDockerStatusLoaded = false; + localStorage.removeItem('dockerStatus'); + localStorage.removeItem('dockerStatusTimestamp'); loadDockerStatus(); } + 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(); // 重新加载所有容器状态 + } + } + } + document.addEventListener('DOMContentLoaded', function() { - const sidebarItems = document.querySelectorAll('.sidebar li'); - const contentSections = document.querySelectorAll('.content-section'); - if (isLoggedIn) { - initEditor(); - } - if (isLoggedIn) { - loadDockerStatus(); - } - function showSection(sectionId) { - contentSections.forEach(section => { - if (section.id === sectionId) { - section.classList.add('active'); - } else { - section.classList.remove('active'); + 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); + }); + }); + + // 页面加载时检查登录状态 + 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'; } - }); - } - - 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); - }); + }; }); - - // 初始化:显示第一个部分 - showSection(sidebarItems[0].getAttribute('data-section')); - }); - + \ No newline at end of file