<!DOCTYPE html>
|
<html lang="zh-CN">
|
<head>
|
<meta charset="utf-8">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<title>GC-110N 联机服务监控</title>
|
<style>
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
body {
|
font-family: -apple-system, 'Microsoft YaHei', sans-serif;
|
background: #0d1117; color: #c9d1d9; min-height: 100vh;
|
}
|
.header {
|
background: #161b22; border-bottom: 1px solid #30363d;
|
padding: 16px 24px; display: flex; justify-content: space-between; align-items: center;
|
}
|
.header h1 { font-size: 20px; font-weight: 600; color: #f0f6fc; }
|
.header .info { font-size: 13px; color: #8b949e; }
|
.header .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 4px; }
|
.header .dot.live { background: #3fb950; animation: pulse 2s infinite; }
|
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
|
|
.cards {
|
display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 16px;
|
padding: 20px 24px;
|
}
|
.card {
|
background: #161b22; border: 1px solid #30363d; border-radius: 8px;
|
padding: 20px; text-align: center;
|
}
|
.card .value { font-size: 36px; font-weight: 700; line-height: 1.2; }
|
.card .label { font-size: 13px; color: #8b949e; margin-top: 4px; }
|
.card .sub { font-size: 12px; color: #484f58; margin-top: 2px; }
|
.card.total .value { color: #58a6ff; }
|
.card.connected .value { color: #3fb950; }
|
.card.data-ok .value { color: #d29922; }
|
.card.abnormal .value { color: #f85149; }
|
.card.mqtt .value { color: #58a6ff; }
|
.card.mqtt.enabled .value { color: #3fb950; }
|
.card.mqtt.error .value { color: #f85149; }
|
.card.aliyun .value { color: #58a6ff; }
|
.card.aliyun.enabled .value { color: #3fb950; }
|
.card.aliyun.error .value { color: #f85149; }
|
.card.last-upload .value { font-size: 24px; color: #d2a8ff; }
|
|
.panel {
|
margin: 0 24px 24px; background: #161b22; border: 1px solid #30363d;
|
border-radius: 8px; overflow: hidden;
|
}
|
.panel-header {
|
padding: 12px 16px; border-bottom: 1px solid #30363d;
|
font-size: 14px; font-weight: 600; color: #f0f6fc;
|
display: flex; justify-content: space-between; align-items: center;
|
}
|
.panel-header .count { font-size: 12px; color: #8b949e; font-weight: normal; }
|
table { width: 100%; border-collapse: collapse; }
|
th, td { padding: 10px 16px; text-align: left; font-size: 13px; }
|
th { background: #0d1117; color: #8b949e; font-weight: 500;
|
border-bottom: 1px solid #30363d; position: sticky; top: 0; }
|
td { border-bottom: 1px solid #21262d; }
|
tr:hover td { background: #1c2128; }
|
tr.expanded td { background: #1c2128; border-bottom: none; }
|
|
.status-dot {
|
display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px;
|
}
|
.status-dot.connected { background: #3fb950; }
|
.status-dot.connecting { background: #d29922; animation: pulse 1s infinite; }
|
.status-dot.idle { background: #58a6ff; }
|
.status-dot.disconnected, .status-dot.error { background: #f85149; }
|
.status-dot.disabled, .status-dot.pending { background: #484f58; }
|
|
.detail-row td { padding: 0; }
|
.detail-panel {
|
background: #0d1117; border-top: 1px solid #30363d; padding: 16px 24px;
|
display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 8px;
|
}
|
.detail-panel .field {
|
display: flex; justify-content: space-between; align-items: center;
|
padding: 4px 8px; border-radius: 4px; background: #161b22;
|
font-size: 12px;
|
}
|
.detail-panel .field .fname { color: #8b949e; flex: 1; margin: 0 8px; }
|
.detail-panel .field .fval { color: #f0f6fc; font-family: 'Consolas', monospace; }
|
.detail-panel .field .funit { color: #484f58; margin-left: 4px; font-size: 11px; }
|
|
.clickable { cursor: pointer; user-select: none; }
|
.clickable td:first-child { color: #58a6ff; }
|
|
.empty {
|
padding: 40px; text-align: center; color: #484f58; font-size: 14px;
|
}
|
.refresh-time { font-size: 12px; color: #484f58; text-align: center; padding: 8px; }
|
</style>
|
</head>
|
<body>
|
<div class="header">
|
<div>
|
<h1>JMS GC-110N 联机服务监控</h1>
|
<div class="info">TCP 长连接 · 定时轮询</div>
|
</div>
|
<div class="info">
|
<span class="dot live"></span>
|
实时监控中 · <span id="connInfo">等待连接</span>
|
</div>
|
</div>
|
|
<div class="cards">
|
<div class="card total">
|
<div class="value" id="cTotal">0</div>
|
<div class="label">设备总数</div>
|
</div>
|
<div class="card connected">
|
<div class="value" id="cConnected">0</div>
|
<div class="label">已连接</div>
|
</div>
|
<div class="card data-ok">
|
<div class="value" id="cData">0</div>
|
<div class="label">数据正常</div>
|
</div>
|
<div class="card abnormal">
|
<div class="value" id="cAbnormal">0</div>
|
<div class="label">异常 / 断线</div>
|
</div>
|
<div class="card mqtt" id="cardMqtt">
|
<div class="value" id="cMqtt">--</div>
|
<div class="label">MQTT 上传</div>
|
<div class="sub" id="cMqttSub"></div>
|
</div>
|
<div class="card aliyun" id="cardAliyun">
|
<div class="value" id="cAliyun">--</div>
|
<div class="label">阿里云上传</div>
|
<div class="sub" id="cAliyunSub"></div>
|
</div>
|
<div class="card last-upload">
|
<div class="value" id="cLastUpload">--</div>
|
<div class="label">最近上传</div>
|
<div class="sub" id="cLastUploadSub"></div>
|
</div>
|
</div>
|
|
<div class="panel">
|
<div class="panel-header">
|
设备列表
|
<span class="count" id="deviceCount">0 台</span>
|
</div>
|
<div style="max-height: calc(100vh - 380px); overflow-y: auto;">
|
<table>
|
<thead>
|
<tr>
|
<th style="width:140px">IP 地址</th>
|
<th style="width:120px">序列号</th>
|
<th style="width:80px">状态</th>
|
<th style="width:140px">最后数据</th>
|
<th style="width:140px">最后上传</th>
|
<th style="width:60px">字段</th>
|
<th style="width:70px">重连</th>
|
<th>最后错误</th>
|
</tr>
|
</thead>
|
<tbody id="deviceTable"></tbody>
|
</table>
|
<div class="empty" id="emptyMsg">暂无设备数据,等待连接...</div>
|
</div>
|
<div class="refresh-time" id="refreshTime">最后更新: --</div>
|
</div>
|
|
<script>
|
let expandedIp = null;
|
|
function statusDot(s) {
|
const cls = s === 'connected' ? 'connected' :
|
s === 'connecting' ? 'connecting' :
|
s === 'idle' ? 'idle' :
|
s === 'disconnected' || s === 'error' ? 'disconnected' :
|
'pending';
|
return `<span class="status-dot ${cls}"></span>`;
|
}
|
|
function statusLabel(s) {
|
const map = { connected: '在线', connecting: '连接中', idle: '等待轮询',
|
disconnected: '断线', error: '异常', pending: '等待', disabled: '已禁用' };
|
return map[s] || s;
|
}
|
|
function formatTime(ts) {
|
if (!ts) return '--';
|
const d = new Date(ts);
|
return d.toLocaleString('zh-CN', { hour12: false });
|
}
|
|
function render(data) {
|
const { summary, devices, timestamp, config, upload } = data;
|
|
document.getElementById('cTotal').textContent = summary.total;
|
document.getElementById('cConnected').textContent = summary.connected;
|
document.getElementById('cData').textContent = summary.hasData;
|
document.getElementById('cAbnormal').textContent = summary.abnormal;
|
document.getElementById('deviceCount').textContent = summary.total + ' 台';
|
document.getElementById('refreshTime').textContent = '最后更新: ' + formatTime(timestamp);
|
document.getElementById('connInfo').textContent =
|
'WS 已连接 · 轮询间隔 ' + (config ? config.pollIntervalMs / 1000 + 's' : '--');
|
|
// ── 上传状态卡片 ──
|
renderUploadCards(upload);
|
|
const tbody = document.getElementById('deviceTable');
|
document.getElementById('emptyMsg').style.display = devices.length === 0 ? '' : 'none';
|
|
let html = '';
|
for (const dev of devices) {
|
const fields = dev.parsed || [];
|
const fieldCount = dev.fieldCount || fields.length || 0;
|
const err = dev.errorMessage || '--';
|
const isExpanded = expandedIp === dev.ip;
|
html += `<tr class="clickable ${isExpanded ? 'expanded' : ''}" data-ip="${dev.ip}" onclick="toggleDetail(this, '${dev.ip}')">
|
<td>${dev.ip}</td>
|
<td>${dev.serialNumber || '--'}</td>
|
<td>${statusDot(dev.status)}${statusLabel(dev.status)}</td>
|
<td>${formatTime(dev.lastDataAt)}</td>
|
<td>${formatTime(dev.lastUploadAt)}</td>
|
<td>${fieldCount}</td>
|
<td>${dev.reconnectCount || 0}</td>
|
<td style="color:#f85149;max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${err}">${err}</td>
|
</tr>`;
|
if (isExpanded) {
|
html += `<tr class="detail-row" id="detail-${dev.ip.replace(/\./g, '_')}">
|
<td colspan="8">
|
<div class="detail-panel">
|
${fields.length === 0 ? '<div style="color:#484f58;grid-column:1/-1;">暂无解析数据</div>' :
|
fields.map(f => `<div class="field">
|
<span class="fname">${f.name||''}</span>
|
<span class="fval">${f.displayValue||f.value||'(空)'}</span>
|
<span class="funit">${f.unit||''}</span>
|
</div>`).join('')}
|
</div>
|
</td>
|
</tr>`;
|
}
|
}
|
tbody.innerHTML = html;
|
}
|
|
function toggleDetail(tr, ip) {
|
expandedIp = expandedIp === ip ? null : ip;
|
// 从缓存的最后一次数据重新渲染
|
if (window._lastData) {
|
render(window._lastData);
|
}
|
}
|
|
function renderUploadCards(upload) {
|
if (!upload) {
|
document.getElementById('cMqtt').textContent = '--';
|
document.getElementById('cAliyun').textContent = '--';
|
document.getElementById('cLastUpload').textContent = '--';
|
return;
|
}
|
|
// MQTT 状态
|
const mqttCard = document.getElementById('cardMqtt');
|
const mqttSub = document.getElementById('cMqttSub');
|
mqttCard.className = 'card mqtt';
|
if (!upload.mqtt.enabled) {
|
document.getElementById('cMqtt').textContent = '未启用';
|
mqttSub.textContent = '';
|
} else if (upload.mqtt.connected) {
|
mqttCard.className += ' enabled';
|
document.getElementById('cMqtt').textContent = '已连接';
|
mqttSub.textContent = 'MQTT Broker 在线';
|
} else {
|
mqttCard.className += ' error';
|
document.getElementById('cMqtt').textContent = '未连接';
|
mqttSub.textContent = '等待重连...';
|
}
|
|
// 阿里云状态
|
const aliyunCard = document.getElementById('cardAliyun');
|
const aliyunSub = document.getElementById('cAliyunSub');
|
aliyunCard.className = 'card aliyun';
|
if (!upload.aliyun.enabled) {
|
document.getElementById('cAliyun').textContent = '未启用';
|
aliyunSub.textContent = '';
|
} else {
|
aliyunCard.className += ' enabled';
|
document.getElementById('cAliyun').textContent = '已启用';
|
aliyunSub.textContent = upload.aliyun.deviceCount + ' 台设备' +
|
(upload.aliyun.connectedCount > 0 ? ' · ' + upload.aliyun.connectedCount + ' 在线' : '');
|
}
|
|
// 最近上传
|
const lu = upload.lastUpload;
|
const luEl = document.getElementById('cLastUpload');
|
const luSub = document.getElementById('cLastUploadSub');
|
if (!lu) {
|
luEl.textContent = '--';
|
luSub.textContent = '暂无上传记录';
|
} else {
|
luEl.textContent = (lu.mqttOk || !upload.mqtt.enabled) && (lu.aliyunOk || !upload.aliyun.enabled) ? '成功' : '失败';
|
luSub.textContent = lu.deviceNo + ' · ' + formatTime(lu.timestamp);
|
}
|
}
|
|
let ws;
|
function connectWS() {
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
ws = new WebSocket(proto + '//' + location.host);
|
|
ws.onopen = () => {
|
document.getElementById('connInfo').innerHTML =
|
'<span class="dot live"></span>实时监控中';
|
};
|
|
ws.onmessage = (e) => {
|
try {
|
const data = JSON.parse(e.data);
|
if (data.type === 'snapshot') {
|
window._lastData = data;
|
render(data);
|
}
|
} catch (_) {}
|
};
|
|
ws.onclose = () => {
|
document.getElementById('connInfo').textContent = 'WS 已断开,3s 后重连...';
|
setTimeout(connectWS, 3000);
|
};
|
|
ws.onerror = () => {
|
ws.close();
|
};
|
}
|
|
connectWS();
|
</script>
|
</body>
|
</html>
|