video_monitor/templates/app/index.html
2026-08-30 22:23:12 +08:00

606 lines
24 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% 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 %}