video_monitor/templates/app/index.html

606 lines
24 KiB
HTML
Raw Permalink Normal View History

2026-08-30 22:22:11 +08:00
{% extends "app/base.html" %}
{% load static %}
{% block title %}{{ T.page_dashboard|default:'控制面板' }} - {{ settings.name|default:"Monitor" }}{% endblock %}
{% block nav_home %}active{% endblock %}
{% block topbar_left %}
<span class="topbar-title"> {{ T.page_dashboard|default:'控制面板' }}</span>
{% endblock %}
{% block content %}
<div class="dash-page">
<!-- ============== 4 个统计卡 ============== -->
<div class="stat-grid">
<a href="/stream/index" class="stat-card">
<div class="stat-label">{{ T.stat_total|default:'接入总数' }}</div>
<div class="stat-value"><span id="statTotal">--</span><span class="stat-unit">{{ T.stat_unit_stream|default:'路' }}</span></div>
<div class="stat-foot">{{ T.stat_all_cameras|default:'所有摄像头' }}</div>
</a>
<a href="/stream/online" class="stat-card">
<div class="stat-label">{{ T.stat_online|default:'在线数量' }}</div>
<div class="stat-value"><span id="statOnline">--</span><span class="stat-unit">{{ T.stat_unit_stream|default:'路' }}</span></div>
<div class="stat-foot">{{ T.stat_realtime_pulling|default:'实时拉流中' }}</div>
</a>
<a href="/stream/online" class="stat-card">
<div class="stat-label">{{ T.stat_offline|default:'离线数量' }}</div>
<div class="stat-value"><span id="statOffline">--</span><span class="stat-unit">{{ T.stat_unit_stream|default:'路' }}</span></div>
<div class="stat-foot">{{ T.stat_not_pulling|default:'未拉流' }}</div>
</a>
<div class="stat-card">
<div class="stat-label">{{ T.stat_uptime|default:'服务运行时长' }}</div>
<div class="stat-value stat-value-md" id="statUptime">--</div>
<div class="stat-foot" id="statStartTime">{{ T.stat_start_time|default:'启动时间:' }} --</div>
</div>
</div>
<!-- ============== 资源监控 + 系统信息 ============== -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px" class="resp-2col">
<!-- 资源监控 -->
<div class="card">
<div class="card-header">
<span class="card-title">{{ T.card_resource_monitor|default:'资源监控' }}</span>
<span class="card-extra" id="resRefreshTime">{{ T.just_now|default:'刚刚' }}</span>
</div>
<div class="card-body">
<!-- CPU -->
<div class="metric">
<div class="metric-head">
<span class="metric-name">CPU</span>
<span class="metric-val"><span id="cpuText">0</span>%</span>
</div>
<div class="progress"><div class="progress-bar" id="cpuBar" style="width:0%"></div></div>
<div class="metric-foot" id="cpuCore">--</div>
</div>
<!-- 内存 -->
<div class="metric">
<div class="metric-head">
<span class="metric-name">{{ T.metric_memory|default:'内存' }}</span>
<span class="metric-val"><span id="memText">0</span>%</span>
</div>
<div class="progress"><div class="progress-bar" id="memBar" style="width:0%"></div></div>
<div class="metric-foot" id="memTotal">--</div>
</div>
<!-- 磁盘 -->
<div class="metric">
<div class="metric-head">
<span class="metric-name">{{ T.metric_disk|default:'磁盘' }}</span>
<span class="metric-val"><span id="diskText">0</span>%</span>
</div>
<div class="progress"><div class="progress-bar" id="diskBar" style="width:0%"></div></div>
<div class="metric-foot" id="diskTotal">--</div>
</div>
<!-- 显卡(动态渲染) -->
<div id="gpuMetrics"></div>
</div>
</div>
<!-- 系统信息 -->
<div class="card">
<div class="card-header">
<span class="card-title">{{ T.card_system_info|default:'系统信息' }}</span>
<span class="card-extra" id="appVersion">--</span>
</div>
<div class="card-body">
<div class="info-row">
<span class="info-label">{{ T.info_app_name|default:'应用名称' }}</span>
<span class="info-value">{{ settings.name|default:"Monitor" }}</span>
</div>
<div class="info-row">
<span class="info-label">{{ T.info_software_flag|default:'软件标记' }}</span>
<span class="info-value mono" id="appFlag">--</span>
</div>
<div class="info-row">
<span class="info-label">{{ T.info_build_time|default:'构建时间' }}</span>
<span class="info-value" id="appBuilt">--</span>
</div>
<div class="info-row">
<span class="info-label">{{ T.info_os|default:'操作系统' }}</span>
<span class="info-value" id="osSystem">--</span>
</div>
<div class="info-row">
<span class="info-label">{{ T.info_machine_name|default:'机器名称' }}</span>
<span class="info-value" id="osMachine">--</span>
</div>
</div>
</div>
</div>
<!-- ============== 流媒体服务 ZLM ============== -->
<div class="card" style="margin-top:16px" id="zlmCard">
<div class="card-header">
<span class="card-title">{{ T.dash_zlm_title|default:'流媒体服务' }}</span>
<span class="card-extra" id="zlmStatusBadge">{{ T.dash_zlm_checking|default:'检测中…' }}</span>
</div>
<div class="card-body">
<div class="quick-actions">
<button type="button" class="btn btn-sm btn-primary" id="btnZlmStart" onclick="mediaControl('start')">{{ T.dash_zlm_start|default:'启动' }}</button>
<button type="button" class="btn btn-sm" id="btnZlmStop" onclick="mediaControl('stop')">{{ T.dash_zlm_stop|default:'停止' }}</button>
<button type="button" class="btn btn-sm btn-ghost" id="btnZlmRestart" onclick="mediaControl('restart')">{{ T.dash_zlm_restart|default:'重启' }}</button>
<span class="quick-action-loading" id="zlmLoading"></span>
</div>
</div>
</div>
<!-- ============== 日志与刷新 ============== -->
<div class="card" style="margin-top:16px">
<div class="card-header">
<span class="card-title">{{ T.card_log_tools|default:'日志与刷新' }}</span>
</div>
<div class="card-body">
<div class="quick-actions">
<button type="button" class="btn btn-sm" id="btnExportLogs" onclick="f_openExportLogs()">{{ T.btn_export_logs|default:'导出日志' }}</button>
<span class="quick-action-loading" id="exportLogsLoading">{{ T.processing|default:'处理中...' }}</span>
<button type="button" class="btn btn-sm btn-ghost" onclick="refreshDashboard()">{{ T.btn_refresh_symbol|default:'↻ 刷新' }}</button>
</div>
</div>
</div>
</div>
<!-- ============== 版本检测弹窗 ============== -->
<div id="checkVersionDialog" style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.4);z-index:9999;align-items:center;justify-content:center">
<div style="background:#fff;border-radius:8px;width:420px;max-width:90%;box-shadow:0 8px 30px rgba(0,0,0,.15);overflow:hidden">
<div style="padding:16px 20px;border-bottom:1px solid #eef2f5;display:flex;align-items:center;justify-content:space-between">
<h4 style="margin:0;font-size:15px;font-weight:600;color:#1f2937">{{ T.index_new_version|default:'发现新版本' }}</h4>
<span onclick="f_closeCheckVersionDialog()" style="cursor:pointer;font-size:18px;color:#9ca3af;line-height:1">&times;</span>
</div>
<div id="checkVersionContent" style="padding:20px;max-height:400px;overflow-y:auto"></div>
<div style="padding:12px 20px;border-top:1px solid #eef2f5;display:flex;justify-content:flex-end;gap:8px">
<button onclick="f_closeCheckVersionDialog()" style="padding:6px 16px;border:1px solid #d1d5db;border-radius:4px;background:#fff;color:#4b5563;cursor:pointer;font-size:13px">{{ T.index_cancel|default:'取消' }}</button>
<button onclick="f_downloadNewVersion()" style="padding:6px 16px;border:none;border-radius:4px;background:#169F85;color:#fff;cursor:pointer;font-size:13px">{{ T.index_download_version|default:'下载新版本' }}</button>
</div>
</div>
</div>
{% endblock %}
{% block extra_head %}
<style>
.dash-page { max-width: 1400px; margin: 0 auto; }
/* ========== 统计卡(统一灰) ========== */
.stat-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 16px;
}
.stat-card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 18px 20px;
transition: all .15s;
color: inherit;
text-decoration: none;
display: block;
}
.stat-card:hover {
border-color: #169F85;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(22,159,133,.08);
}
.stat-label {
font-size: 13px;
color: #6b7280;
margin-bottom: 6px;
}
.stat-value {
font-size: 28px;
font-weight: 600;
color: #1f2937;
line-height: 1.2;
font-family: 'Consolas', 'Courier New', monospace;
}
.stat-value-md { font-size: 18px; letter-spacing: .3px; }
.stat-unit {
font-size: 12px;
color: #9ca3af;
font-weight: 400;
margin-left: 4px;
}
.stat-foot {
margin-top: 8px;
font-size: 12px;
color: #9ca3af;
}
/* ========== 卡片通用 ========== */
.card-extra {
font-size: 12px;
color: #9ca3af;
font-family: 'Consolas', 'Courier New', monospace;
}
/* ========== 资源监控 ========== */
.metric { margin-bottom: 18px; }
.metric:last-child { margin-bottom: 0; }
.metric-head {
display: flex;
justify-content: space-between;
margin-bottom: 6px;
}
.metric-name {
font-size: 13px;
color: #4b5563;
}
.metric-val {
font-size: 13px;
color: #1f2937;
font-weight: 600;
font-family: 'Consolas', 'Courier New', monospace;
}
.metric-foot {
font-size: 12px;
color: #9ca3af;
margin-top: 4px;
}
/* ========== 进度条(单色) ========== */
.progress {
background: #f1f5f9;
height: 6px;
border-radius: 3px;
overflow: hidden;
}
.progress-bar {
background: #169F85; /* 统一绿色,去掉蓝/黄/红多色 */
height: 100%;
border-radius: 3px;
transition: width .4s ease;
}
.gpu-metric .gpu-name { display: inline-flex; align-items: center; gap: 6px; flex-wrap: wrap; max-width: 78%; line-height: 1.35; }
.gpu-tag {
display: inline-block;
padding: 1px 6px;
border-radius: 4px;
font-size: 10px;
font-weight: 700;
letter-spacing: .3px;
vertical-align: middle;
}
.gpu-tag-nvidia { background: #dcfce7; color: #166534; }
.gpu-tag-intel { background: #dbeafe; color: #1e40af; }
.gpu-tag-amd { background: #fee2e2; color: #991b1b; }
.gpu-tag-other { background: #f3f4f6; color: #6b7280; }
.gpu-bar-nvidia { background: #22c55e; }
.gpu-bar-intel { background: #3b82f6; }
.gpu-bar-amd { background: #ef4444; }
.gpu-empty .metric-foot { color: #9ca3af; }
#zlmStatusBadge.running { color: #169F85; font-weight: 600; }
#zlmStatusBadge.stopped { color: #b91c1c; font-weight: 600; }
/* ========== 等宽字体 ========== */
.mono { font-family: 'Consolas', 'Courier New', monospace; }
/* ========== 日志操作栏 ========== */
.quick-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.quick-action-opt {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--c-text-secondary, #64748b);
cursor: pointer;
user-select: none;
padding: 6px 10px;
border: 1px solid var(--c-border, #e2e8f0);
border-radius: var(--r-md, 6px);
background: var(--c-surface, #fff);
}
.quick-action-opt input { margin: 0; accent-color: #169F85; }
.quick-action-loading {
display: none;
font-size: 12px;
color: #169F85;
}
.quick-action-loading.show { display: inline; }
.btn-ghost {
background: var(--c-surface, #fff);
color: var(--c-text-secondary, #64748b);
border: 1px solid var(--c-border, #e2e8f0);
}
.btn-ghost:hover {
color: #169F85;
border-color: #169F85;
background: #f0f9f6;
}
/* ========== 响应式 ========== */
@media (max-width: 1100px) {
.stat-grid { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 700px) {
.stat-grid { grid-template-columns: 1fr; }
}
</style>
{% endblock %}
{% block extra_js %}
<script>
/* ========== 时长格式化 ========== */
function formatUptime(sec) {
if (!sec || sec < 0) return '--';
var d = Math.floor(sec / 86400);
var h = Math.floor((sec % 86400) / 3600);
var m = Math.floor((sec % 3600) / 60);
var s = sec % 60;
var parts = [];
if (d > 0) parts.push(d + _t('time_day', '天'));
if (h > 0 || d > 0) parts.push(h + _t('time_hour', '小时'));
if (m > 0 || h > 0 || d > 0) parts.push(m + _t('time_minute', '分'));
parts.push(s + _t('time_second', '秒'));
return parts.join(' ');
}
/* ========== 加载系统信息 ========== */
function gpuVendorLabel(vendor) {
var m = {
nvidia: _t('gpu_vendor_nvidia', 'NVIDIA'),
intel: _t('gpu_vendor_intel', 'Intel'),
amd: _t('gpu_vendor_amd', 'AMD')
};
return m[vendor] || _t('gpu_vendor_other', 'GPU');
}
function renderGpuMetrics(gpus, loaded) {
var box = document.getElementById('gpuMetrics');
if (!box) return;
gpus = gpus || [];
if (!gpus.length) {
if (!loaded) {
box.innerHTML =
'<div class="metric gpu-metric">' +
'<div class="metric-head"><span class="metric-name">' + _t('metric_gpu', '显卡') + '</span><span class="metric-val">0%</span></div>' +
'<div class="progress"><div class="progress-bar gpu-bar" style="width:0%"></div></div>' +
'<div class="metric-foot">' + _t('gpu_loading', '正在读取显卡信息…') + '</div></div>';
} else {
box.innerHTML =
'<div class="metric gpu-empty">' +
'<div class="metric-head"><span class="metric-name">' + _t('metric_gpu', '显卡') + '</span><span class="metric-val">0%</span></div>' +
'<div class="metric-foot">' + _t('gpu_not_detected', '未检测到可用显卡或暂无法读取占用') + '</div></div>';
}
return;
}
var html = '';
gpus.forEach(function(g, i) {
var util = g.util_percent;
var utilShow = util != null && !isNaN(util) ? Math.round(util) : 0;
var barW = util != null && !isNaN(util) ? Math.min(100, Math.max(0, util)) : 0;
var name = escapeHtml(g.name || ('GPU ' + (i + 1)));
var vendor = g.vendor || 'other';
var vendorText = gpuVendorLabel(vendor);
html +=
'<div class="metric gpu-metric">' +
'<div class="metric-head">' +
'<span class="metric-name gpu-name"><span class="gpu-tag gpu-tag-' + vendor + '">' + vendorText + '</span> ' + name + '</span>' +
'<span class="metric-val">' + utilShow + '%</span>' +
'</div>' +
'<div class="progress"><div class="progress-bar gpu-bar gpu-bar-' + vendor + '" style="width:' + barW + '%"></div></div>' +
'<div class="metric-foot">' + escapeHtml(g.detail_str || '--') + '</div>' +
'</div>';
});
box.innerHTML = html;
}
function loadGpuData() {
Api.get('/index/openGpuInfo').then(function(res) {
if (res.code !== 1000) return;
renderGpuMetrics(res.os_gpus || [], true);
}).catch(function() {
renderGpuMetrics([], true);
});
}
function loadIndexData() {
Api.get('/index/openIndex').then(function(res) {
if (res.code !== 1000) return;
var app = res.appInfo || {};
var os = res.osInfo || {};
// 系统信息
document.getElementById('appVersion').textContent = app.project_version || '--';
document.getElementById('appFlag').textContent = app.project_flag || '--';
document.getElementById('appBuilt').textContent = app.project_built || '--';
document.getElementById('osSystem').textContent = os.system_name || os.os || '--';
document.getElementById('osMachine').textContent = os.machine_node || os.hostname || '--';
// 运行时长
if (app.start_timestamp) {
window._appStartTs = app.start_timestamp;
var uptimeSec = Math.floor(Date.now() / 1000) - app.start_timestamp;
document.getElementById('statUptime').textContent = formatUptime(uptimeSec);
var dt = new Date(app.start_timestamp * 1000);
var pad = function(n) { return n < 10 ? '0' + n : '' + n; };
document.getElementById('statStartTime').textContent =
_t('started_at', '启动于') + ' ' + dt.getFullYear() + '-' + pad(dt.getMonth()+1) + '-' + pad(dt.getDate()) +
' ' + pad(dt.getHours()) + ':' + pad(dt.getMinutes());
}
// CPU
var cpuRate = Math.round((os.os_cpu_used_rate || 0) * 100);
document.getElementById('cpuText').textContent = cpuRate;
document.getElementById('cpuBar').style.width = cpuRate + '%';
document.getElementById('cpuCore').textContent = os.os_cpu_used_rate_str || '--';
// 内存
var memRate = Math.round((os.os_virtual_mem_used_rate || 0) * 100);
document.getElementById('memText').textContent = memRate;
document.getElementById('memBar').style.width = memRate + '%';
document.getElementById('memTotal').textContent = os.os_virtual_mem_used_rate_str || '--';
// 磁盘
var diskRate = Math.round((os.os_disk_used_rate || 0) * 100);
document.getElementById('diskText').textContent = diskRate;
document.getElementById('diskBar').style.width = diskRate + '%';
document.getElementById('diskTotal').textContent = os.os_disk_used_rate_str || '--';
// 刷新时间
var now = new Date();
var p = function(n) { return n < 10 ? '0' + n : '' + n; };
document.getElementById('resRefreshTime').textContent =
_t('updated_at', '更新于') + ' ' + p(now.getHours()) + ':' + p(now.getMinutes()) + ':' + p(now.getSeconds());
});
}
/* ========== 加载流统计 ========== */
function loadStreamData() {
Api.get('/open/getStatisticsStream').then(function(res) {
if (res.code !== 1000) return;
var info = res.info || {};
var total = info.count || 0;
var online = info.forwardCount || 0;
var offline = total - online;
document.getElementById('statTotal').textContent = total;
document.getElementById('statOnline').textContent = online;
document.getElementById('statOffline').textContent = offline > 0 ? offline : 0;
});
}
function loadMediaStatus() {
Api.get('/index/openMediaStatus').then(function(res) {
var badge = document.getElementById('zlmStatusBadge');
if (!badge) return;
if (!res || res.code !== 1000) {
badge.textContent = _t('dash_zlm_unknown', '未知');
badge.className = 'card-extra';
return;
}
var d = res.data || {};
if (d.running) {
badge.textContent = _t('dash_zlm_running', '运行中') + (d.pid ? ' · PID ' + d.pid : '');
badge.className = 'card-extra running';
} else {
badge.textContent = _t('dash_zlm_stopped', '未运行');
badge.className = 'card-extra stopped';
}
var startBtn = document.getElementById('btnZlmStart');
var stopBtn = document.getElementById('btnZlmStop');
if (startBtn) startBtn.disabled = !!d.running;
if (stopBtn) stopBtn.disabled = !d.running;
}).catch(function() {});
}
function mediaControl(action) {
var loading = document.getElementById('zlmLoading');
if (loading) { loading.classList.add('show'); loading.textContent = _t('processing', '处理中...'); }
Api.post('/index/openMediaControl', { action: action }).then(function(res) {
if (loading) loading.classList.remove('show');
showToast((res && res.msg) || _t('msg_unknown_error', '失败'), res && res.code === 1000 ? 'success' : 'error');
loadMediaStatus();
if (action !== 'stop') loadStreamData();
}).catch(function() {
if (loading) loading.classList.remove('show');
showToast(_t('msg_unknown_error', '网络错误'), 'error');
});
}
/* ========== 刷新 ========== */
function refreshDashboard() {
loadIndexData();
loadGpuData();
loadStreamData();
loadMediaStatus();
}
/* ========== 初始化 ========== */
renderGpuMetrics([], false);
refreshDashboard();
/* ========== 版本检测 ========== */
var downloadNewVersionUrl = "";
function f_closeCheckVersionDialog() {
document.getElementById('checkVersionDialog').style.display = 'none';
}
function f_downloadNewVersion() {
if (downloadNewVersionUrl) {
window.open(downloadNewVersionUrl);
}
}
function f_openCheckVersion() {
fetch('/version/openCheckVersion', {
method: 'GET',
headers: {'Content-Type': 'application/json'}
}).then(function(res) {
return res.json();
}).then(function(res) {
if (1000 === res.code) {
var info = res.info || {};
var version = (info.version || '').toString();
var updateContent = info.updateContent || [];
var pubdate = info.pubdate || '';
downloadNewVersionUrl = info.url || '';
var html = '<div style="margin-bottom:12px;display:flex;align-items:center;gap:8px">' +
'<span style="display:inline-block;padding:2px 8px;background:#eab308;color:#fff;border-radius:3px;font-size:11px;font-weight:600">NEW</span>' +
'<span style="font-size:14px;font-weight:600;color:#1f2937">v' + version + '</span>' +
'<span style="font-size:12px;color:#9ca3af;margin-left:auto">' + pubdate + '</span>' +
'</div>' +
'<ul style="margin:0;padding-left:20px">';
for (var i = 0; i < updateContent.length; i++) {
html += '<li style="padding:3px 0;font-size:13px;color:#4b5563;line-height:1.6">' + updateContent[i] + '</li>';
}
html += '</ul>';
document.getElementById('checkVersionContent').innerHTML = html;
document.getElementById('checkVersionDialog').style.display = 'flex';
}
}).catch(function() {});
}
f_openCheckVersion();
/* ========== 导出日志 ========== */
function f_openExportLogs() {
var btn = document.getElementById('btnExportLogs');
var loading = document.getElementById('exportLogsLoading');
btn.disabled = true;
loading.classList.add('show');
Api.post('/system/openExportLogs', {}).then(function(res) {
btn.disabled = false;
loading.classList.remove('show');
if (res.code === 1000) {
var info = res.info || {};
var download_file_id = info.download_file_id;
if (download_file_id) {
window.open('/storage/openDownload?file_id=' + encodeURIComponent(download_file_id));
showToast(res.msg, 'success');
} else {
showToast(_t('toast_export_no_filename', '导出失败:未获取到文件名'), 'error');
}
} else {
showToast(res.msg, 'error');
}
}).catch(function() {
btn.disabled = false;
loading.classList.remove('show');
showToast(_t('msg_unknown_error', '失败'), 'error');
});
}
/* ========== 定时刷新(系统信息 10s运行时长 1s ========== */
setInterval(loadIndexData, 10000);
setInterval(loadGpuData, 10000);
setInterval(loadStreamData, 10000);
setInterval(loadMediaStatus, 8000);
setInterval(function() {
if (window._appStartTs) {
var sec = Math.floor(Date.now() / 1000) - window._appStartTs;
document.getElementById('statUptime').textContent = formatUptime(sec);
}
}, 1000);
</script>
{% endblock %}