| | |
| | | .status::before { width: 8px; height: 8px; content: ""; border-radius: 50%; background: #c43d4b; } |
| | | .status.online::before { background: #218a5b; } |
| | | .add-form, .device-settings { display: grid; grid-template-columns: minmax(180px, 1fr) 150px auto; gap: 12px; align-items: end; padding: 16px; } |
| | | .device-settings { grid-template-columns: minmax(180px, 1fr) 130px 140px auto auto; } |
| | | .collector-settings { display: grid; grid-template-columns: minmax(160px, 1fr) 110px 130px 140px auto auto; gap: 12px; align-items: end; padding: 16px; } |
| | | .sampling-settings { display: grid; grid-template-columns: repeat(4, minmax(120px, 1fr)) auto; gap: 12px; align-items: end; padding: 0 16px 16px; } |
| | | .device-settings { grid-template-columns: minmax(130px, 1fr) 90px minmax(150px, 1fr) 100px auto auto; } |
| | | .collector-settings { display: grid; grid-template-columns: minmax(130px, 1fr) 90px 110px auto auto; gap: 12px; align-items: end; padding: 16px; } |
| | | |
| | | label { color: #5e6a73; font-size: 12px; } |
| | | label > span { display: block; margin-bottom: 6px; } |
| | | input[type="text"], input[type="number"], select, textarea { width: 100%; height: 34px; padding: 0 9px; color: #26323d; background: #fff; border: 1px solid #bdc8d0; border-radius: 3px; } |
| | |
| | | td select { width: 150px; } |
| | | .request-time { width: 185px; font: 12px Consolas, monospace; color: #53616b; } |
| | | .request-command { width: 130px; font: 12px Consolas, monospace; font-weight: 600; } |
| | | .request-xml { overflow: hidden; color: #5c6871; font: 11px Consolas, monospace; text-overflow: ellipsis; white-space: nowrap; } |
| | | .request-xml { overflow: hidden; color: #5c6871; font: 11px Consolas, monospace; text-overflow: ellipsis; white-space: pre-wrap; overflow-wrap: anywhere; } |
| | | .collector-actions { display: flex; gap: 8px; padding: 12px 16px; border-top: 1px solid #e3e9ed; } |
| | | .collector-output { min-height: 46px; margin: 0 16px 16px; padding: 10px; overflow: auto; color: #34434d; background: #f7f9fa; border: 1px solid #d5dde3; font: 11px Consolas, monospace; white-space: pre-wrap; } |
| | | .empty { height: 96px; color: #7d8992; text-align: center; } |
| | |
| | | main { width: calc(100% - 20px); margin-top: 12px; } |
| | | .summary { grid-template-columns: 1fr; } |
| | | .summary > div { border-right: 0; border-bottom: 1px solid #e2e8ec; } |
| | | .add-form, .device-settings, .collector-settings, .sampling-settings { grid-template-columns: 1fr 1fr; } |
| | | .add-form, .device-settings, .collector-settings { grid-template-columns: 1fr 1fr; } |
| | | .device-settings button, .collector-settings button { width: 100%; } |
| | | .device { margin: 10px; overflow-x: auto; } |
| | | .device table { min-width: 520px; } |
| | | .device table { min-width: 620px; } |
| | | .section-title, .device-title { flex-wrap: wrap; padding: 10px; } |
| | | .device-title .label { overflow-wrap: anywhere; } |
| | | th, td { padding: 8px; } |
| | | .request-time { width: 130px; } |
| | | .request-command { width: 90px; } |
| | |
| | | <div id="error" class="error"></div> |
| | | <section> |
| | | <div class="section-title"> |
| | | <h2>运行状态</h2> |
| | | <h2>软件连接</h2> |
| | | <div class="power-actions"> |
| | | <button id="power-on" type="button">一键模拟开机</button> |
| | | <button id="power-off" class="danger" type="button">一键模拟关机</button> |
| | | <label class="switch-label"><input id="software-accessible" type="checkbox"><span>新威软件可访问</span></label> |
| | | <button id="power-on" type="button">全部设备上线</button> |
| | | <button id="power-off" class="danger" type="button">全部设备离线</button> |
| | | <button id="reset-simulator" class="danger" type="button">重置模拟器</button> |
| | | </div> |
| | | </div> |
| | |
| | | <section> |
| | | <div class="section-title"><h2>最近请求</h2><span id="request-count" class="label">0 条</span></div> |
| | | <table> |
| | | <thead><tr><th class="request-time">接收时间</th><th class="request-command">命令</th><th>请求 XML</th></tr></thead> |
| | | <thead><tr><th class="request-time">接收时间</th><th class="request-command">命令</th><th>请求 / 响应</th></tr></thead> |
| | | <tbody id="requests"></tbody> |
| | | </table> |
| | | </section> |
| | | </main> |
| | | <script> |
| | | const api = '/dev/bts-simulator'; |
| | | const labels = { STOPPED: '空闲', WORKING: '工作中', PAUSED: '暂停', FINISHED: '完成' }; |
| | | const labels = { STOPPED: '空闲', WORKING: '运行', PAUSED: '暂停', FINISHED: '结束', PROTECTED: '异常', UNOBSERVABLE: '无法观测' }; |
| | | const errorBox = document.querySelector('#error'); |
| | | |
| | | async function request(path, options) { |
| | |
| | | collectors: Array.isArray(state.collectors) ? state.collectors : [], |
| | | recentRequests: Array.isArray(state.recentRequests) ? state.recentRequests : [], |
| | | }; |
| | | document.querySelector('#software-accessible').checked = state.softwareAccessible; |
| | | const runtime = document.querySelector('#runtime'); |
| | | runtime.textContent = state.running ? '运行中' : '已停止'; |
| | | runtime.classList.toggle('online', state.running); |
| | | const devicesPoweredOn = state.devices.length + state.collectors.length > 0 |
| | | && state.devices.every(device => device.online |
| | | && device.channels.every(channel => channel.online)) |
| | | && state.devices.every(device => device.online) |
| | | && state.collectors.every(collector => collector.online); |
| | | const devicesPoweredOff = state.devices.every(device => !device.online |
| | | && device.channels.every(channel => !channel.online && channel.workStatus === 'STOPPED')) |
| | | const devicesPoweredOff = state.devices.every(device => !device.online) |
| | | && state.collectors.every(collector => !collector.online); |
| | | const devicePower = document.querySelector('#device-power'); |
| | | devicePower.textContent = devicesPoweredOn ? '已开机' : devicesPoweredOff ? '已关机' : '部分开启'; |
| | | devicePower.textContent = devicesPoweredOn ? '全部在线' : devicesPoweredOff ? '全部离线' : '部分在线'; |
| | | devicePower.classList.toggle('online', devicesPoweredOn); |
| | | document.querySelector('#power-on').disabled = devicesPoweredOn; |
| | | document.querySelector('#power-off').disabled = devicesPoweredOff; |
| | |
| | | heading.textContent = `检测柜 ${device.deviceId}`; |
| | | const identity = document.createElement('span'); |
| | | identity.className = 'label'; |
| | | identity.textContent = `devtype ${device.profile.devtype} / devid ${device.deviceId} / ${device.profile.model}`; |
| | | identity.textContent = `devtype ${device.profile.devtype} / devid ${device.deviceId} / subdevid ${device.profile.subDeviceId} / ${device.profile.model}`; |
| | | title.append(heading, identity); |
| | | |
| | | const settings = document.createElement('div'); |
| | |
| | | settings.append(ip.label, channelCount.label, model, onlineLabel, save, remove); |
| | | |
| | | const table = document.createElement('table'); |
| | | table.innerHTML = '<thead><tr><th style="width:18%">通道</th><th style="width:22%">在线状态</th><th style="width:20%">工作状态</th><th>模拟播放</th></tr></thead>'; |
| | | table.innerHTML = '<thead><tr><th>通道</th><th>在线状态</th><th>工作状态</th></tr></thead>'; |
| | | const body = document.createElement('tbody'); |
| | | body.append(...device.channels.map(channel => channelRow(device.deviceId, channel))); |
| | | table.append(body); |
| | |
| | | const channelCount = field('通道数', 'number', collector.channelCount); |
| | | channelCount.input.min = '1'; |
| | | channelCount.input.max = '99'; |
| | | const sampleInterval = field('采样间隔(毫秒)', 'number', collector.sampleIntervalMs); |
| | | sampleInterval.input.min = '100'; |
| | | sampleInterval.input.max = '60000'; |
| | | const onlineLabel = document.createElement('label'); |
| | | onlineLabel.className = 'switch-label'; |
| | | const online = document.createElement('input'); |
| | |
| | | save.textContent = '保存配置'; |
| | | save.addEventListener('click', async () => { |
| | | try { |
| | | await request(`/collectors/${collector.collectorId}`, { method: 'PUT', body: JSON.stringify({ ip: ip.input.value, channelCount: Number(channelCount.input.value), online: online.checked }) }); |
| | | render(await request(`/collectors/${collector.collectorId}/sampling`, { method: 'PUT', body: JSON.stringify({ sampleIntervalMs: Number(sampleInterval.input.value), ...collector.historyFaults }) })); |
| | | render(await request(`/collectors/${collector.collectorId}`, { method: 'PUT', body: JSON.stringify({ ip: ip.input.value, channelCount: Number(channelCount.input.value), online: online.checked }) })); |
| | | } |
| | | catch (error) { showError(error); } |
| | | }); |
| | |
| | | try { render(await request(`/collectors/${collector.collectorId}`, { method: 'DELETE' })); } |
| | | catch (error) { showError(error); } |
| | | }); |
| | | settings.append(ip.label, channelCount.label, sampleInterval.label, onlineLabel, save, remove); |
| | | |
| | | const faults = document.createElement('div'); |
| | | faults.className = 'sampling-settings'; |
| | | const missing = field('缺失序号(逗号分隔)', 'text', collector.historyFaults.missingSequences.join(',')); |
| | | const duplicate = field('重复序号', 'number', collector.historyFaults.duplicateSequence || ''); |
| | | const conflict = field('冲突序号', 'number', collector.historyFaults.conflictingSequence || ''); |
| | | const outOfOrderLabel = document.createElement('label'); |
| | | outOfOrderLabel.className = 'switch-label'; |
| | | const outOfOrder = document.createElement('input'); |
| | | outOfOrder.type = 'checkbox'; |
| | | outOfOrder.checked = collector.historyFaults.outOfOrder; |
| | | const outOfOrderText = document.createElement('span'); |
| | | outOfOrderText.textContent = '历史乱序'; |
| | | outOfOrderLabel.append(outOfOrder, outOfOrderText); |
| | | const saveFaults = document.createElement('button'); |
| | | saveFaults.type = 'button'; |
| | | saveFaults.textContent = '保存历史场景'; |
| | | saveFaults.addEventListener('click', async () => { |
| | | const missingSequences = missing.input.value.split(',').map(value => value.trim()).filter(Boolean).map(Number); |
| | | try { render(await request(`/collectors/${collector.collectorId}/sampling`, { method: 'PUT', body: JSON.stringify({ sampleIntervalMs: Number(sampleInterval.input.value), missingSequences, duplicateSequence: numberOrNull(duplicate.input.value), conflictingSequence: numberOrNull(conflict.input.value), outOfOrder: outOfOrder.checked }) })); } |
| | | catch (error) { showError(error); } |
| | | }); |
| | | faults.append(missing.label, duplicate.label, conflict.label, outOfOrderLabel, saveFaults); |
| | | settings.append(ip.label, channelCount.label, onlineLabel, save, remove); |
| | | |
| | | const table = document.createElement('table'); |
| | | table.innerHTML = '<thead><tr><th style="width:110px">稳定通道</th><th>测量项(每行:指标编码=值 单位)</th><th style="width:130px">操作</th></tr></thead>'; |
| | | table.innerHTML = '<thead><tr><th style="width:100px">稳定通道</th><th style="width:170px">工作状态</th><th>测量项</th><th style="width:130px">操作</th></tr></thead>'; |
| | | const channelBody = document.createElement('tbody'); |
| | | channelBody.append(...collector.channels.map(channel => collectorChannelRow(collector.collectorId, channel))); |
| | | table.append(channelBody); |
| | |
| | | const realtimeButton = document.createElement('button'); |
| | | realtimeButton.type = 'button'; |
| | | realtimeButton.textContent = '查看实时'; |
| | | const historyButton = document.createElement('button'); |
| | | historyButton.type = 'button'; |
| | | historyButton.textContent = '下载历史'; |
| | | const historyCount = document.createElement('span'); |
| | | historyCount.className = 'label'; |
| | | historyCount.textContent = `原始历史 ${collector.historyRecordCount} 条`; |
| | | const output = document.createElement('pre'); |
| | | output.className = 'collector-output'; |
| | | output.textContent = collector.latestMeasurements.length |
| | | ? measurementLines(collector.latestMeasurements) |
| | | : '等待第一条采样'; |
| | | : '暂无成功观测'; |
| | | realtimeButton.addEventListener('click', async () => { |
| | | try { output.textContent = measurementLines((await request(`/collectors/${collector.collectorId}/realtime`)).measurements); } |
| | | catch (error) { showError(error); } |
| | | }); |
| | | historyButton.addEventListener('click', async () => { |
| | | try { |
| | | const page = await request(`/collectors/${collector.collectorId}/history?offset=0&limit=20`); |
| | | output.textContent = `历史 ${page.total} 条 / 本页 ${page.records.length} 条\n${measurementLines(page.records)}`; |
| | | } catch (error) { showError(error); } |
| | | }); |
| | | actions.append(realtimeButton, historyButton, historyCount); |
| | | panel.append(title, settings, faults, table, actions, output); |
| | | actions.append(realtimeButton); |
| | | panel.append(title, settings, table, actions, output); |
| | | return panel; |
| | | })); |
| | | } |
| | |
| | | catch (error) { showError(error); } |
| | | }); |
| | | action.append(save); |
| | | row.append(identity, metricsCell, action); |
| | | const statusCell = document.createElement('td'); |
| | | const status = document.createElement('select'); |
| | | status.setAttribute('aria-label', channel.channelCode + ' 工作状态'); |
| | | Object.entries(labels).forEach(([value, label]) => status.add(new Option(label, value))); |
| | | status.value = channel.workStatus; |
| | | status.addEventListener('change', async () => { |
| | | try { render(await request(`/collectors/${collectorId}/channels/${channel.channelCode}/status`, { method: 'PUT', body: JSON.stringify({ workStatus: status.value }) })); } |
| | | catch (error) { showError(error); } |
| | | }); |
| | | statusCell.append(status); |
| | | row.append(identity, statusCell, metricsCell, action); |
| | | return row; |
| | | } |
| | | |
| | |
| | | function measurementLines(items) { |
| | | if (!items.length) return '暂无测量'; |
| | | return items.map(item => `${item.collectorCode} #${item.sequenceId} ${item.channelCode} ${item.metricCode}=${item.value} ${item.unit} 设备 ${item.deviceTimeRaw} / 接收 ${item.serverReceivedAt}`).join('\n'); |
| | | } |
| | | |
| | | function numberOrNull(value) { |
| | | return value.trim() ? Number(value) : null; |
| | | } |
| | | |
| | | function field(labelText, type, value) { |
| | |
| | | const status = document.createElement('select'); |
| | | Object.entries(labels).forEach(([value, label]) => status.add(new Option(label, value))); |
| | | status.value = channel.workStatus; |
| | | status.setAttribute('aria-label', `检测柜 ${deviceId} 通道 ${channel.channelId} 工作状态`); |
| | | const update = async () => { |
| | | try { render(await request(`/devices/${deviceId}/channels/${channel.channelId}`, { method: 'PUT', body: JSON.stringify({ online: online.checked, workStatus: status.value }) })); } |
| | | catch (error) { showError(error); await refresh(); } |
| | |
| | | online.addEventListener('change', update); |
| | | status.addEventListener('change', update); |
| | | statusCell.append(status); |
| | | const playbackCell = document.createElement('td'); |
| | | const speed = document.createElement('select'); |
| | | [['1','1x'], ['60','60x'], ['600','600x']].forEach(([value, label]) => speed.add(new Option(label, value))); |
| | | const controls = document.createElement('span'); |
| | | controls.style.cssText = 'display:inline-flex;gap:5px;margin-left:6px'; |
| | | const action = (label, path) => { const button = document.createElement('button'); button.type = 'button'; button.textContent = label; button.addEventListener('click', async () => { try { await request(`/devices/${deviceId}/channels/${channel.channelId}/playback/${path}`, { method: 'POST' }); await refresh(); } catch (error) { showError(error); } }); return button; }; |
| | | speed.addEventListener('change', async () => { try { await request(`/devices/${deviceId}/channels/${channel.channelId}/playback`, { method: 'PUT', body: JSON.stringify({ speedMultiplier: Number(speed.value) }) }); await refresh(); } catch (error) { showError(error); } }); |
| | | controls.append(action('播放', 'play'), action('暂停', 'pause'), action('下一工步', 'next-step'), action('下一循环', 'next-cycle'), action('重置', 'reset')); |
| | | playbackCell.append(speed, controls); |
| | | row.append(identity, onlineCell, statusCell, playbackCell); |
| | | row.append(identity, onlineCell, statusCell); |
| | | return row; |
| | | } |
| | | |
| | |
| | | command.textContent = item.command; |
| | | const xml = document.createElement('td'); |
| | | xml.className = 'request-xml'; |
| | | xml.textContent = item.request; |
| | | xml.title = item.request; |
| | | xml.textContent = `${item.request}\n${item.response || '暂无响应'}`; |
| | | xml.title = xml.textContent; |
| | | row.append(time, command, xml); |
| | | return row; |
| | | })); |
| | |
| | | try { render(await request('/reset', { method: 'POST' })); } catch (error) { showError(error); } |
| | | }); |
| | | |
| | | document.getElementById('software-accessible').addEventListener('change', async event => { |
| | | try { render(await request('/software', { method: 'PUT', body: JSON.stringify({ accessible: event.target.checked }) })); } |
| | | catch (error) { showError(error); } |
| | | }); |
| | | |
| | | document.getElementById('power-on').addEventListener('click', async () => { |
| | | try { render(await request('/power', { method: 'POST', body: JSON.stringify({ poweredOn: true }) })); } |
| | | catch (error) { showError(error); } |
| | | }); |
| | | |
| | | document.getElementById('power-off').addEventListener('click', async () => { |
| | | if (!confirm('确认关闭全部模拟检测柜和采集器?已有历史会保留,停止增长。')) return; |
| | | if (!confirm('确认将全部模拟检测柜和采集器设为离线?')) return; |
| | | try { render(await request('/power', { method: 'POST', body: JSON.stringify({ poweredOn: false }) })); } |
| | | catch (error) { showError(error); } |
| | | }); |