408 lines
16 KiB
HTML
Executable File
408 lines
16 KiB
HTML
Executable File
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Avcnet 系统运维监控</title>
|
||
<script src="https://cdn.tailwindcss.com"></script>
|
||
<script src="https://unpkg.com/xterm@5.3.0/lib/xterm.js"></script>
|
||
<link href="https://unpkg.com/xterm@5.3.0/css/xterm.css" rel="stylesheet" />
|
||
<style>
|
||
#terminal { height: 400px; }
|
||
#file-list { max-height: 300px; overflow-y: auto; }
|
||
.tooltip {
|
||
display: none;
|
||
position: absolute;
|
||
background: #1a1a1a;
|
||
color: white;
|
||
padding: 6px 10px;
|
||
border-radius: 4px;
|
||
z-index: 10;
|
||
max-width: 320px;
|
||
font-size: 13px;
|
||
line-height: 1.5;
|
||
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
||
pointer-events: none; /* 防止提示框干扰鼠标事件 */
|
||
}
|
||
</style>
|
||
</head>
|
||
<body class="bg-gray-100 font-sans">
|
||
<div class="container mx-auto p-4">
|
||
<h1 class="text-2xl font-bold mb-4">Avcnet 系统运维监控</h1>
|
||
<button id="logout" class="bg-red-500 text-white px-4 py-2 rounded mb-4">登出</button>
|
||
|
||
<!-- 系统信息 -->
|
||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
||
<div class="bg-white p-4 rounded shadow relative" onmouseover="showTooltip('cpu-tooltip', event)" onmouseout="hideTooltip('cpu-tooltip')">
|
||
<h2 class="text-lg font-semibold">CPU 使用率</h2>
|
||
<p id="cpu-usage" class="text-xl">0%</p>
|
||
<div id="cpu-tooltip" class="tooltip"></div>
|
||
</div>
|
||
<div class="bg-white p-4 rounded shadow relative" onmouseover="showTooltip('memory-tooltip', event)" onmouseout="hideTooltip('memory-tooltip')">
|
||
<h2 class="text-lg font-semibold">内存使用率</h2>
|
||
<p id="memory-usage" class="text-xl">0%</p>
|
||
<div id="memory-tooltip" class="tooltip"></div>
|
||
</div>
|
||
<div class="bg-white p-4 rounded shadow relative" onmouseover="showTooltip('disk-tooltip', event)" onmouseout="hideTooltip('disk-tooltip')">
|
||
<h2 class="text-lg font-semibold">磁盘使用率</h2>
|
||
<p id="disk-usage" class="text-xl">0%</p>
|
||
<div id="disk-tooltip" class="tooltip"></div>
|
||
</div>
|
||
<div class="bg-white p-4 rounded shadow relative" onmouseover="showTooltip('load-tooltip', event)" onmouseout="hideTooltip('load-tooltip')">
|
||
<h2 class="text-lg font-semibold">系统负载</h2>
|
||
<p id="load-avg" class="text-xl">0</p>
|
||
<div id="load-tooltip" class="tooltip"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 文件上传 -->
|
||
<div class="bg-white p-4 rounded shadow mb-8">
|
||
<h2 class="text-lg font-semibold mb-2">上传文件</h2>
|
||
<div class="mb-4">
|
||
<label class="block text-gray-700 mb-2" for="target-dir">目标目录(绝对路径,如 /tmp/uploads)</label>
|
||
<input id="target-dir" type="text" class="w-full px-3 py-2 border rounded" placeholder="请输入目标目录">
|
||
</div>
|
||
<div class="mb-4">
|
||
<label class="block text-gray-700 mb-2" for="file">选择文件(最大100MB)</label>
|
||
<input id="file" type="file" class="w-full px-3 py-2 border rounded">
|
||
</div>
|
||
<button id="upload" class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600">上传</button>
|
||
<p id="upload-message" class="mt-4 text-green-500 hidden"></p>
|
||
<p id="upload-error" class="mt-4 text-red-500 hidden"></p>
|
||
</div>
|
||
|
||
<!-- 文件浏览 -->
|
||
<div class="bg-white p-4 rounded shadow mb-8">
|
||
<h2 class="text-lg font-semibold mb-2">浏览文件</h2>
|
||
<div class="mb-4 flex items-center">
|
||
<label class="block text-gray-700 mr-2" for="browse-dir">当前路径:</label>
|
||
<input id="browse-dir" type="text" class="flex-grow px-3 py-2 border rounded" placeholder="请输入目录路径(如 /tmp)">
|
||
<button id="list-files" class="ml-2 bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600">刷新</button>
|
||
<button id="parent-dir" class="ml-2 bg-gray-500 text-white px-4 py-2 rounded hover:bg-gray-600">上一级</button>
|
||
</div>
|
||
<div id="file-list" class="border rounded p-2">
|
||
<table class="w-full">
|
||
<thead>
|
||
<tr>
|
||
<th class="text-left">名称</th>
|
||
<th class="text-left">类型</th>
|
||
<th class="text-left">大小</th>
|
||
<th class="text-left">修改时间</th>
|
||
<th class="text-left">操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="file-table"></tbody>
|
||
</table>
|
||
</div>
|
||
<p id="file-error" class="mt-4 text-red-500 hidden"></p>
|
||
</div>
|
||
|
||
<!-- 终端 -->
|
||
<div class="bg-white p-4 rounded shadow">
|
||
<h2 class="text-lg font-semibold mb-2">交互式终端</h2>
|
||
<div id="terminal"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
// 检查登录状态
|
||
async function checkLoginStatus() {
|
||
const response = await fetch('/api/system-info');
|
||
if (response.status === 401) {
|
||
window.location.href = '/login';
|
||
}
|
||
}
|
||
|
||
// 格式化字节为MB或GB
|
||
function formatBytes(bytes) {
|
||
if (bytes < 1024 * 1024) {
|
||
return (bytes / 1024).toFixed(2) + ' KB';
|
||
} else if (bytes < 1024 * 1024 * 1024) {
|
||
return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
|
||
} else {
|
||
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
|
||
}
|
||
}
|
||
|
||
// 格式化时间(秒)
|
||
function formatUptime(seconds) {
|
||
const days = Math.floor(seconds / (3600 * 24));
|
||
const hours = Math.floor((seconds % (3600 * 24)) / 3600);
|
||
const minutes = Math.floor((seconds % 3600) / 60);
|
||
return `${days}天 ${hours}小时 ${minutes}分钟`;
|
||
}
|
||
|
||
// 显示提示框
|
||
function showTooltip(tooltipId, event) {
|
||
const tooltip = document.getElementById(tooltipId);
|
||
const systemInfo = window.lastSystemInfo || {};
|
||
|
||
// 如果提示框已显示,不重新定位
|
||
if (tooltip.style.display === 'block') {
|
||
return;
|
||
}
|
||
|
||
let content = '';
|
||
if (tooltipId === 'cpu-tooltip') {
|
||
content = `
|
||
使用率: ${systemInfo.cpu_usage ? systemInfo.cpu_usage.toFixed(2) : 0}% <br>
|
||
核心数: ${systemInfo.cpu_cores || '未知'} <br>
|
||
主频: ${systemInfo.cpu_mhz ? systemInfo.cpu_mhz.toFixed(2) : '未知'} MHz
|
||
`;
|
||
} else if (tooltipId === 'memory-tooltip') {
|
||
content = `
|
||
使用率: ${systemInfo.memory_usage ? systemInfo.memory_usage.toFixed(2) : 0}% <br>
|
||
总内存: ${systemInfo.memory_total ? formatBytes(systemInfo.memory_total) : '未知'} <br>
|
||
可用内存: ${systemInfo.memory_free ? formatBytes(systemInfo.memory_free) : '未知'}
|
||
`;
|
||
} else if (tooltipId === 'disk-tooltip') {
|
||
content = systemInfo.disk_partitions ? systemInfo.disk_partitions.map(p => `
|
||
挂载点: ${p.mountpoint} <br>
|
||
使用率: ${p.used_percent.toFixed(2)}% <br>
|
||
总空间: ${formatBytes(p.total)} <br>
|
||
已用: ${formatBytes(p.used)}
|
||
`).join('<hr>') : '无分区信息';
|
||
} else if (tooltipId === 'load-tooltip') {
|
||
content = `
|
||
1分钟负载: ${systemInfo.load_avg ? systemInfo.load_avg.toFixed(2) : 0} <br>
|
||
系统运行时间: ${systemInfo.uptime ? formatUptime(systemInfo.uptime) : '未知'}
|
||
`;
|
||
}
|
||
|
||
tooltip.innerHTML = content;
|
||
tooltip.style.display = 'block';
|
||
|
||
// 使用鼠标坐标定位(仅计算一次)
|
||
const offsetX = 5; // 水平偏移(更近)
|
||
const offsetY = 5; // 垂直偏移(更近)
|
||
let x = event.clientX + window.scrollX + offsetX;
|
||
let y = event.clientY + window.scrollY + offsetY;
|
||
|
||
// 防止提示框超出视口
|
||
const tooltipRect = tooltip.getBoundingClientRect();
|
||
const viewportWidth = window.innerWidth;
|
||
const viewportHeight = window.innerHeight;
|
||
|
||
// 如果右侧超出,移到鼠标左侧
|
||
if (x + tooltipRect.width > viewportWidth) {
|
||
x = event.clientX + window.scrollX - tooltipRect.width - offsetX;
|
||
}
|
||
// 如果下方超出,移到鼠标上方
|
||
if (y + tooltipRect.height > viewportHeight) {
|
||
y = event.clientY + window.scrollY - tooltipRect.height - offsetY;
|
||
}
|
||
|
||
tooltip.style.left = `${x}px`;
|
||
tooltip.style.top = `${y}px`;
|
||
}
|
||
|
||
// 隐藏提示框
|
||
function hideTooltip(tooltipId) {
|
||
const tooltip = document.getElementById(tooltipId);
|
||
tooltip.style.display = 'none';
|
||
}
|
||
|
||
// 更新系统信息
|
||
async function updateSystemInfo() {
|
||
try {
|
||
const response = await fetch('/api/system-info');
|
||
if (response.status === 401) {
|
||
window.location.href = '/login';
|
||
return;
|
||
}
|
||
const data = await response.json();
|
||
window.lastSystemInfo = data; // 缓存最新数据
|
||
document.getElementById('cpu-usage').textContent = data.cpu_usage.toFixed(2) + '%';
|
||
document.getElementById('memory-usage').textContent = data.memory_usage.toFixed(2) + '%';
|
||
document.getElementById('disk-usage').textContent = data.disk_usage.toFixed(2) + '%';
|
||
document.getElementById('load-avg').textContent = data.load_avg.toFixed(2);
|
||
} catch (error) {
|
||
console.error('Failed to fetch system info:', error);
|
||
}
|
||
}
|
||
|
||
// 初始化终端
|
||
const term = new Terminal({
|
||
cursorBlink: true,
|
||
theme: {
|
||
background: '#1a1a1a',
|
||
foreground: '#ffffff',
|
||
},
|
||
});
|
||
term.open(document.getElementById('terminal'));
|
||
|
||
// 连接 WebSocket
|
||
const ws = new WebSocket('ws://' + window.location.host + '/ws/terminal');
|
||
ws.binaryType = 'arraybuffer';
|
||
|
||
ws.onopen = () => {
|
||
console.log('WebSocket connected');
|
||
term.write('Connected to terminal\r\n');
|
||
};
|
||
|
||
ws.onmessage = (event) => {
|
||
const data = new Uint8Array(event.data);
|
||
term.write(new TextDecoder().decode(data));
|
||
};
|
||
|
||
ws.onerror = (event) => {
|
||
term.write('\r\nWebSocket error');
|
||
console.error('WebSocket error:', event);
|
||
};
|
||
|
||
ws.onclose = () => {
|
||
term.write('\r\nWebSocket disconnected');
|
||
window.location.href = '/login';
|
||
};
|
||
|
||
term.onData(data => {
|
||
ws.send(data);
|
||
});
|
||
|
||
// 登出
|
||
document.getElementById('logout').addEventListener('click', async () => {
|
||
await fetch('/api/logout', { method: 'POST' });
|
||
window.location.href = '/login';
|
||
});
|
||
|
||
// 文件上传
|
||
document.getElementById('upload').addEventListener('click', async () => {
|
||
const fileInput = document.getElementById('file');
|
||
const targetDir = document.getElementById('target-dir').value;
|
||
const messageEl = document.getElementById('upload-message');
|
||
const errorEl = document.getElementById('upload-error');
|
||
|
||
messageEl.classList.add('hidden');
|
||
errorEl.classList.add('hidden');
|
||
|
||
if (!fileInput.files[0]) {
|
||
errorEl.textContent = '请选择一个文件';
|
||
errorEl.classList.remove('hidden');
|
||
return;
|
||
}
|
||
|
||
if (!targetDir) {
|
||
errorEl.textContent = '请输入目标目录';
|
||
errorEl.classList.remove('hidden');
|
||
return;
|
||
}
|
||
|
||
const formData = new FormData();
|
||
formData.append('file', fileInput.files[0]);
|
||
formData.append('target_dir', targetDir);
|
||
|
||
try {
|
||
const response = await fetch('/api/upload', {
|
||
method: 'POST',
|
||
body: formData
|
||
});
|
||
const data = await response.json();
|
||
if (response.ok) {
|
||
messageEl.textContent = data.message;
|
||
messageEl.classList.remove('hidden');
|
||
} else {
|
||
errorEl.textContent = data.error || '上传失败';
|
||
errorEl.classList.remove('hidden');
|
||
}
|
||
} catch (error) {
|
||
console.error('Upload error:', error);
|
||
errorEl.textContent = '网络错误';
|
||
errorEl.classList.remove('hidden');
|
||
}
|
||
});
|
||
|
||
// 规范化路径
|
||
function normalizePath(base, name) {
|
||
// 移除多余斜杠并拼接路径
|
||
let path = base.replace(/\/+$/, '') + '/' + name.replace(/^\/+/, '');
|
||
// 确保以单个斜杠开头
|
||
path = '/' + path.replace(/^\/+/, '');
|
||
return path;
|
||
}
|
||
|
||
// 文件浏览
|
||
async function listFiles(dirPath) {
|
||
const fileTable = document.getElementById('file-table');
|
||
const errorEl = document.getElementById('file-error');
|
||
const browseDirInput = document.getElementById('browse-dir');
|
||
|
||
fileTable.innerHTML = '';
|
||
errorEl.classList.add('hidden');
|
||
|
||
if (!dirPath) {
|
||
errorEl.textContent = '请输入目录路径';
|
||
errorEl.classList.remove('hidden');
|
||
return;
|
||
}
|
||
|
||
// 规范化路径
|
||
const normalizedPath = dirPath.replace(/\/+$/, '').replace(/^\/+/, '/');
|
||
console.log('Listing files for path:', normalizedPath);
|
||
|
||
try {
|
||
const response = await fetch(`/api/files?path=${encodeURIComponent(normalizedPath)}`);
|
||
if (response.status === 401) {
|
||
window.location.href = '/login';
|
||
return;
|
||
}
|
||
const data = await response.json();
|
||
if (response.ok) {
|
||
browseDirInput.value = normalizedPath; // 更新输入框路径
|
||
data.files.forEach(file => {
|
||
const row = document.createElement('tr');
|
||
const nextPath = normalizePath(normalizedPath, file.name);
|
||
const nameCell = file.is_dir
|
||
? `<td><a href="#" class="text-blue-500 hover:underline" onclick="listFiles('${nextPath.replace(/'/g, "\\'")}')">${file.name}</a></td>`
|
||
: `<td>${file.name}</td>`;
|
||
row.innerHTML = `
|
||
${nameCell}
|
||
<td>${file.is_dir ? '目录' : '文件'}</td>
|
||
<td>${file.is_dir ? '-' : (file.size / 1024).toFixed(2) + ' KB'}</td>
|
||
<td>${new Date(file.mod_time).toLocaleString()}</td>
|
||
<td><a href="/api/download?path=${encodeURIComponent(nextPath)}" class="text-blue-500 hover:underline">下载</a></td>
|
||
`;
|
||
fileTable.appendChild(row);
|
||
});
|
||
} else {
|
||
errorEl.textContent = data.error || '无法列出文件';
|
||
errorEl.classList.remove('hidden');
|
||
}
|
||
} catch (error) {
|
||
console.error('List files error:', error);
|
||
errorEl.textContent = '网络错误';
|
||
errorEl.classList.remove('hidden');
|
||
}
|
||
}
|
||
|
||
// 刷新文件列表
|
||
document.getElementById('list-files').addEventListener('click', () => {
|
||
const dirPath = document.getElementById('browse-dir').value;
|
||
listFiles(dirPath);
|
||
});
|
||
|
||
// 上一级目录
|
||
document.getElementById('parent-dir').addEventListener('click', () => {
|
||
const currentPath = document.getElementById('browse-dir').value;
|
||
if (currentPath === '/' || !currentPath) {
|
||
return;
|
||
}
|
||
const parentPath = currentPath.substring(0, currentPath.lastIndexOf('/')) || '/';
|
||
listFiles(parentPath);
|
||
});
|
||
|
||
// 设置默认地址
|
||
window.onload = function() {
|
||
const defaultPath = '/tmp/uploads';
|
||
const defaultListPath = '/app/avcnet';
|
||
document.getElementById('target-dir').value = defaultPath; // 上传地址默认值
|
||
document.getElementById('browse-dir').value = defaultListPath; // 浏览地址默认值
|
||
listFiles(defaultListPath); // 自动加载默认路径文件列表
|
||
};
|
||
|
||
// 初始检查登录状态并定期更新系统信息
|
||
checkLoginStatus();
|
||
updateSystemInfo();
|
||
setInterval(updateSystemInfo, 5000);
|
||
</script>
|
||
</body>
|
||
</html> |