safesight-control/internal/web/ui/templates/dashboard.html

232 lines
9.1 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="/ui/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='/ui/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="/ui/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="/ui/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="/ui/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="/ui/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}}