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'));
- });
-