232 lines
9.0 KiB
HTML
232 lines
9.0 KiB
HTML
{{define "dashboard"}}
|
|
<div class="stats">
|
|
<div class="stat accent-red">
|
|
<div class="k">{{icon "bell"}}<span>今日告警</span></div>
|
|
<div class="v">{{.TodayAlarmCount}}</div>
|
|
<div class="hint">今日告警总数</div>
|
|
</div>
|
|
<div class="stat accent-amber">
|
|
<div class="k">{{icon "bell"}}<span>未处理告警</span></div>
|
|
<div class="v" {{if gt .UnacknowledgedAlarmCount 0}}style="color:var(--amber)"{{end}}>{{.UnacknowledgedAlarmCount}}</div>
|
|
<div class="hint">待确认和关闭的告警</div>
|
|
</div>
|
|
<div class="stat accent-green">
|
|
<div class="k">{{icon "devices"}}<span>在线设备</span></div>
|
|
<div class="v">{{.OnlineCount}}<span style="font-size:14px;color:var(--muted)"> / {{.DeviceCount}}</span></div>
|
|
<div class="hint">设备在线率</div>
|
|
</div>
|
|
<div class="stat accent-slate">
|
|
<div class="k">{{icon "apply"}}<span>检测路数</span></div>
|
|
<div class="v">{{.DetectionChannelCount}}</div>
|
|
<div class="hint">已配置的检测通道</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card" id="trend-card">
|
|
<h3 class="title-with-icon" style="margin-top:0;margin-bottom:12px">{{icon "result"}}<span>近 7 日告警趋势</span></h3>
|
|
<canvas id="alarm-trend-chart" style="width:100%;height:160px"></canvas>
|
|
</div>
|
|
|
|
<div class="row">
|
|
<div style="flex:1 1 480px">
|
|
<div class="card" style="border-left:3px solid var(--red);min-height:200px">
|
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">
|
|
<h3 class="title-with-icon" style="margin:0">{{icon "bell"}}<span>实时告警</span></h3>
|
|
<a class="btn ghost" href="/alarms" style="font-size:11px">查看全部 →</a>
|
|
</div>
|
|
<div id="live-alarms" style="display:flex;flex-direction:column;gap:6px">
|
|
{{range .AlarmRecords}}
|
|
<div class="live-alarm-card" onclick="location.href='/alarms'" style="padding:8px 10px;border:1px solid var(--border);border-radius:var(--radius);background:var(--surface-soft);cursor:pointer;display:flex;align-items:center;gap:10px">
|
|
<span class="pill {{severityClass .Severity}}" style="flex-shrink:0">{{severityLabel .Severity}}</span>
|
|
<span style="font-weight:500;font-size:12px">{{ruleLabel .RuleName}}</span>
|
|
<span class="muted small mono" style="margin-left:auto;flex-shrink:0">{{shortID .DeviceID}}</span>
|
|
</div>
|
|
{{else}}
|
|
<div class="empty-state compact" style="border-style:solid">
|
|
<div class="empty-title">暂无告警</div>
|
|
<div class="muted">有新告警时将实时显示在这里。</div>
|
|
</div>
|
|
{{end}}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div style="flex:1 1 380px">
|
|
<div class="card" style="min-height:200px">
|
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">
|
|
<h3 style="margin:0">{{icon "online"}}<span style="margin-left:6px">异常设备</span></h3>
|
|
</div>
|
|
{{if .AttentionDevices}}
|
|
{{range .AttentionDevices}}
|
|
<div style="padding:8px 10px;display:flex;align-items:center;gap:8px;border-bottom:1px solid var(--border)">
|
|
<span class="pill bad">离线</span>
|
|
<a class="mono" href="/devices/{{.DeviceID}}" style="font-size:12px">{{.DeviceName}}</a>
|
|
<span class="muted small" style="margin-left:auto">{{ago .LastSeenMs}}</span>
|
|
</div>
|
|
{{end}}
|
|
{{else}}
|
|
<div class="empty-state compact" style="border-style:solid">
|
|
<div class="muted">所有设备运行正常。</div>
|
|
</div>
|
|
{{end}}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
|
<h2 style="margin:0">最近任务</h2>
|
|
<a class="btn ghost" href="/tasks" style="font-size:11px">更多 →</a>
|
|
</div>
|
|
<div class="table-wrap" style="margin-top:10px">
|
|
<table>
|
|
<thead><tr><th>任务</th><th>操作</th><th>状态</th><th>创建时间</th><th>完成时间</th></tr></thead>
|
|
<tbody>
|
|
{{range $i, $t := .Tasks}}
|
|
{{if lt $i 5}}
|
|
<tr>
|
|
<td><a class="mono" href="/tasks/{{$t.ID}}">{{shortID $t.ID}}</a></td>
|
|
<td>{{taskActionLabel $t.Type}}</td>
|
|
<td><span class="{{taskStatusClass $t.Status}}">{{taskStatusLabel $t.Status}}</span></td>
|
|
<td class="muted small">{{$t.CreatedAt}}</td>
|
|
<td class="muted small">{{$t.CompletedAt}}</td>
|
|
</tr>
|
|
{{end}}
|
|
{{else}}
|
|
<tr><td colspan="5" class="muted">暂无任务记录。</td></tr>
|
|
{{end}}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
(function(){
|
|
var alarmContainer = document.getElementById("live-alarms");
|
|
var maxCards = 10;
|
|
var emptyEl = alarmContainer.querySelector(".empty-state");
|
|
|
|
function addAlarmCard(alarm) {
|
|
if (emptyEl) {
|
|
emptyEl.remove();
|
|
emptyEl = null;
|
|
}
|
|
var card = document.createElement("div");
|
|
card.className = "live-alarm-card";
|
|
card.style.cssText = "padding:8px 10px;border:1px solid var(--border);border-radius:var(--radius);background:var(--surface-soft);cursor:pointer;display:flex;align-items:center;gap:10px;animation:fadeIn .3s ease";
|
|
card.onclick = function(){ location.href="/alarms"; };
|
|
|
|
var sevMap = {critical:"严重",warning:"警告",info:"信息"};
|
|
var sevClassMap = {critical:"bad",warning:"warn",info:"ok"};
|
|
var sevLabel = sevMap[alarm.severity] || alarm.severity || "警告";
|
|
var sevClass = sevClassMap[alarm.severity] || "warn";
|
|
|
|
var ruleMap = {
|
|
"unknown_face":"陌生人员告警","known_person":"已登记人员",
|
|
"non_compliant_workshoe":"未穿劳保鞋","non_black_shoe":"工鞋不合规"
|
|
};
|
|
var ruleLabel = ruleMap[alarm.rule_name] || alarm.rule_name;
|
|
|
|
var deviceID = alarm.device_id || "";
|
|
var devShort = deviceID.length > 8 ? deviceID.substring(0,8) : deviceID;
|
|
|
|
card.innerHTML =
|
|
'<span class="pill ' + sevClass + '" style="flex-shrink:0">' + sevLabel + '</span>' +
|
|
'<span style="font-weight:500;font-size:12px">' + ruleLabel + '</span>' +
|
|
'<span class="muted small mono" style="margin-left:auto;flex-shrink:0">' + devShort + '</span>';
|
|
|
|
alarmContainer.insertBefore(card, alarmContainer.firstChild);
|
|
while (alarmContainer.children.length > maxCards) {
|
|
alarmContainer.removeChild(alarmContainer.lastChild);
|
|
}
|
|
}
|
|
|
|
try {
|
|
var es = new EventSource("/api/alarms/stream");
|
|
es.onmessage = function(e) {
|
|
try {
|
|
var alarm = JSON.parse(e.data);
|
|
addAlarmCard(alarm);
|
|
} catch(ignored) {}
|
|
};
|
|
} catch(ignored) {}
|
|
})();
|
|
</script>
|
|
|
|
<style>
|
|
.live-alarm-card:hover{background:var(--sidebar-hover)!important}
|
|
@keyframes fadeIn{from{opacity:0;transform:translateY(-8px)}to{opacity:1;transform:translateY(0)}}
|
|
</style>
|
|
|
|
<script>
|
|
(function(){
|
|
var canvas = document.getElementById("alarm-trend-chart");
|
|
if (!canvas) return;
|
|
var ctx = canvas.getContext("2d");
|
|
var dpr = window.devicePixelRatio || 1;
|
|
|
|
fetch("/api/alarms/daily-count?days=7", {credentials:"same-origin"})
|
|
.then(function(r){return r.json()})
|
|
.then(function(data){
|
|
if (!data || data.length === 0) return;
|
|
var values = data.map(function(d){return d.count});
|
|
var labels = data.map(function(d){return d.date.substring(5)});
|
|
var maxVal = Math.max.apply(null, values.concat([1]));
|
|
var w = canvas.clientWidth;
|
|
var h = 160;
|
|
canvas.width = w * dpr;
|
|
canvas.height = h * dpr;
|
|
ctx.scale(dpr, dpr);
|
|
|
|
var pad = {top:16, bottom:24, left:4, right:4};
|
|
var chartW = w - pad.left - pad.right;
|
|
var chartH = h - pad.top - pad.bottom;
|
|
var barW = Math.min(36, chartW / values.length - 8);
|
|
var gap = (chartW - barW * values.length) / (values.length + 1);
|
|
|
|
var style = getComputedStyle(document.body);
|
|
var textColor = style.getPropertyValue("--text").trim() || "#f4f4f4";
|
|
var mutedColor = style.getPropertyValue("--muted").trim() || "#bcbcbc";
|
|
var primary = style.getPropertyValue("--primary").trim() || "#ddd";
|
|
var primaryStrong = style.getPropertyValue("--primary-strong").trim() || "#2b2b2b";
|
|
|
|
// Y-axis lines
|
|
ctx.strokeStyle = "rgba(255,255,255,.06)";
|
|
ctx.lineWidth = 1;
|
|
for (var i = 0; i <= 3; i++) {
|
|
var y = pad.top + chartH * (1 - i/3);
|
|
ctx.beginPath();
|
|
ctx.moveTo(pad.left, y);
|
|
ctx.lineTo(pad.left + chartW, y);
|
|
ctx.stroke();
|
|
}
|
|
|
|
// Bars
|
|
values.forEach(function(v, i){
|
|
var barH = v > 0 ? Math.max(2, chartH * v / maxVal) : 0;
|
|
var x = pad.left + gap + i * (barW + gap);
|
|
var y = pad.top + chartH - barH;
|
|
ctx.fillStyle = primaryStrong;
|
|
ctx.fillRect(x, y, barW, barH);
|
|
ctx.fillStyle = textColor;
|
|
ctx.font = "10px sans-serif";
|
|
ctx.textAlign = "center";
|
|
if (v > 0) {
|
|
ctx.fillText(v, x + barW/2, y - 4);
|
|
}
|
|
});
|
|
|
|
// Labels
|
|
ctx.fillStyle = mutedColor;
|
|
ctx.font = "10px sans-serif";
|
|
ctx.textAlign = "center";
|
|
labels.forEach(function(l, i){
|
|
var x = pad.left + gap + i * (barW + gap) + barW/2;
|
|
ctx.fillText(l, x, h - 4);
|
|
});
|
|
});
|
|
})();
|
|
</script>
|
|
|
|
{{end}}
|