工业协议转换与数据采集中间件
概述
这是一个交互式的 Web 组件,旨在模拟一个工业数据采集中间件的核心功能。该中间件的概念是连接到工厂中的多种不同品牌、不同协议的 PLC(可编程逻辑控制器),统一采集数据点(标签),并在一个集中的界面进行监控和记录日志。
请注意:这是一个高度简化的前端模拟演示,不涉及真实的协议转换或网络通信。所有 PLC 连接、数据值、状态变化和日志都是在浏览器端通过 JavaScript 模拟生成的。
主要功能
- 连接管理:
- 显示预设的多个 PLC 连接配置(模拟不同品牌如 Siemens, Rockwell, Mitsubishi 等及其常用协议 S7, EtherNet/IP, MELSEC 等)。
- 实时展示每个连接的状态:
DISCONNECTED
(已断开),CONNECTING
(连接中),CONNECTED
(已连接),ERROR
(错误)。状态会基于模拟逻辑动态变化。 - 提供搜索功能,可以按连接名称、IP 地址或协议过滤连接列表。
- (概念性) 包含一个"添加连接"按钮(当前禁用)。
- 数据点监控:
- 以表格形式集中展示从所有已连接的 PLC 中模拟采集到的数据点(标签)。
- 表格列包括:标签名、当前值、来源 PLC、最新时间戳、数据质量状态。
- 实时数据更新: 表格中数据点的值会定期模拟更新(数值型波动、布尔型随机翻转、字符串偶尔变化)。
- 数据质量模拟: 数据质量状态 (
GOOD
,UNCERTAIN
,BAD
) 会模拟变化,例如连接断开或错误时质量变为BAD
,偶尔也会模拟UNCERTAIN
状态。 - 提供搜索功能,可以按标签名或当前值过滤表格内容。
- 提供下拉菜单,可以按来源 PLC 过滤表格内容。
- 事件日志:
- 实时记录中间件运行过程中的关键事件。
- 日志条目包含时间戳、日志级别(INFO, WARN, ERROR)和事件消息。
- 记录的事件类型包括:中间件启动/初始化、尝试连接 PLC、连接成功/失败/断开、数据点添加、数据质量变化等。
- 提供按日志级别 (INFO, WARN, ERROR) 过滤显示日志的功能。
- 提供清除当前显示日志的功能。
- 全局状态: 页眉区域显示中间件的整体连接状态(如:全部已连接、部分连接、连接中、错误、已断开)和状态指示灯。
- 界面风格: 采用苹果科技风格,三栏布局,简洁清晰,支持响应式设计。
如何使用
- 打开页面: 在浏览器中打开
index.html
。 - 观察连接: 左侧"连接管理"面板显示模拟的 PLC 连接及其状态。观察状态指示灯和文字的变化(如从
DISCONNECTED
变为CONNECTING
,然后变为CONNECTED
或ERROR
)。 - 监控数据: 中间的"数据点监控"表格会显示从状态为
CONNECTED
的 PLC 中模拟读取的数据点。观察值的实时变化以及质量状态的变化。 - 过滤/搜索数据:
- 在数据点表格上方的搜索框输入标签名或值的关键字进行过滤。
- 使用下拉菜单选择特定的 PLC 名称,只显示该来源的数据点。
- 查看日志: 右侧"事件日志"面板实时滚动显示中间件的操作记录。
- 过滤日志: 点击日志面板上方的复选框 (INFO, WARN, ERROR) 来选择要显示的日志级别。
- 清除日志: 点击"清除日志"按钮可以清空当前显示的日志记录(内存中的日志条数有上限,旧日志会自动移除)。
- 搜索连接: 在连接管理面板的搜索框输入关键字过滤连接列表。
模拟细节
- 连接配置: 组件初始化时会生成一组模拟的 PLC 连接,随机分配品牌、协议和 IP 地址,并关联一组模拟的数据点标签定义。
- 连接状态转换:
DISCONNECTED
状态下,有一定几率尝试变为CONNECTING
。CONNECTING
状态下,有较大概率变为CONNECTED
,小概率变为ERROR
(模拟连接失败),否则保持CONNECTING
。CONNECTED
状态下,有很小几率变为DISCONNECTED
(模拟断线)或ERROR
(模拟通信异常)。ERROR
状态下,有一定几率恢复为DISCONNECTED
(准备重试)。
- 数据点生成: 当一个连接状态变为
CONNECTED
时,其关联的标签会被添加到中央数据点列表中,并赋予初始值和GOOD
质量。 - 数据点更新: 对于状态为
CONNECTED
的 PLC,其中间件会定期模拟更新其关联数据点的值。数值会小幅波动,布尔值有低概率翻转,字符串偶尔变化。 - 数据质量模拟:
- 连接断开 (
DISCONNECTED
) 或发生错误 (ERROR
) 时,其对应的数据点质量会变为BAD
或UNCERTAIN
,值显示为#BAD
或#UNCERTAIN
。 - 即使连接正常,数据点也有极小概率随机变为
UNCERTAIN
或BAD
(模拟读取失败或校验错误)。 - 质量非
GOOD
的数据点也有一定概率恢复为GOOD
。
- 连接断开 (
- 日志: 在状态转换、数据点添加/移除(或质量变化)、用户操作(清除日志)等时刻生成对应级别的日志条目。
文件结构
数据与物联网组件/protocol-converter-middleware/
├── index.html # 组件的 HTML 结构
├── styles.css # 组件的 CSS 样式 (苹果风格, 三栏响应式)
├── script.js # 组件的 JavaScript 逻辑 (模拟连接, 数据更新, 日志)
└── README.md # 本说明文件
技术栈
- HTML5
- CSS3 (使用了 CSS 变量, Grid 布局, Flexbox, 动画, 媒体查询)
- JavaScript (原生 JS, 无外部库依赖)
效果展示
源码
index.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>工业协议转换与数据采集中间件</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="middleware-container">
<header class="main-header">
<h1>工业协议转换与数据采集中间件 (模拟)</h1>
<div class="header-status">
<span class="status-indicator" id="globalStatusIndicator"></span>
<span id="globalStatusText">初始化中...</span>
</div>
</header>
<main class="main-content">
<!-- Column 1: Connection Management -->
<section class="connection-panel panel">
<h2><i class="icon icon-connect"></i> 连接管理</h2>
<div class="panel-toolbar">
<button class="action-button add-btn" disabled title="功能暂未实现"><i class="icon icon-plus"></i> 添加连接</button>
<input type="search" id="connectionSearch" placeholder="搜索连接...">
</div>
<ul id="connectionList" class="connection-list">
<li class="placeholder">加载连接配置中...</li>
<!-- Connection items populated by JS -->
</ul>
</section>
<!-- Column 2: Data Point Monitoring -->
<section class="data-panel panel">
<h2><i class="icon icon-tags"></i> 数据点监控 (<span id="monitoredPointsCount">0</span> 点)</h2>
<div class="panel-toolbar">
<input type="search" id="dataPointSearch" placeholder="搜索标签名或值...">
<select id="dataSourceFilter" title="按数据源过滤">
<option value="all">所有源</option>
<!-- Options populated by JS -->
</select>
</div>
<div class="data-point-table-container">
<table id="dataPointTable">
<thead>
<tr>
<th>标签名</th>
<th>当前值</th>
<th>来源PLC</th>
<th>时间戳</th>
<th>状态</th>
</tr>
</thead>
<tbody id="dataPointTBody">
<tr class="placeholder-row">
<td colspan="5">等待数据接入...</td>
</tr>
<!-- Data rows populated by JS -->
</tbody>
</table>
</div>
</section>
<!-- Column 3: Event Log -->
<section class="log-panel panel">
<h2><i class="icon icon-log"></i> 事件日志</h2>
<div class="panel-toolbar">
<button class="action-button clear-log-btn" id="clearLogBtn"><i class="icon icon-clear"></i> 清除日志</button>
<span class="log-level-filter">
<label><input type="checkbox" name="logLevel" value="INFO" checked> INFO</label>
<label><input type="checkbox" name="logLevel" value="WARN" checked> WARN</label>
<label><input type="checkbox" name="logLevel" value="ERROR" checked> ERROR</label>
</span>
</div>
<ul id="logList" class="log-list">
<li class="log-item info placeholder">中间件启动</li>
<!-- Log items populated by JS -->
</ul>
</section>
</main>
<footer class="main-footer">
<p>© 2024 工业中间件模拟系统. 概念演示.</p>
</footer>
</div>
<script src="script.js"></script>
</body>
</html>
styles.css
:root {
--bg-color-light: #f9f9f9;
--bg-color-container: #ffffff;
--header-bg: #f5f5f7;
--panel-bg: #ffffff;
--border-color: #e1e1e1;
--text-primary: #1d1d1f;
--text-secondary: #515154;
--text-label: #6e6e73;
--accent-blue: #007aff;
--accent-green: #34c759;
--accent-orange: #ff9500;
--accent-red: #ff3b30;
--accent-grey: #8e8e93;
--status-connecting: var(--accent-blue);
--status-connected: var(--accent-green);
--status-error: var(--accent-red);
--status-disconnected: var(--accent-grey);
--status-quality-good: var(--accent-green);
--status-quality-uncertain: var(--accent-orange);
--status-quality-bad: var(--accent-red);
--log-info: var(--accent-blue);
--log-warn: var(--accent-orange);
--log-error: var(--accent-red);
--list-item-hover-bg: #f0f0f0;
--list-item-selected-bg: #e8f3ff; /* Keep selection subtle */
--list-item-selected-text: var(--text-primary);
--input-bg: #f0f2f5;
--input-border: transparent;
--input-focus-border: var(--accent-blue);
--placeholder-text: #aaaaaa;
--table-header-bg: #f9f9f9;
--table-row-hover-bg: #f5faff;
--shadow-color: rgba(0, 0, 0, 0.05);
--font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
--font-monospace: "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--border-radius: 8px;
--border-radius-small: 4px;
--transition-speed: 0.2s;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: var(--font-family);
background-color: var(--bg-color-light);
color: var(--text-primary);
line-height: 1.4;
overflow-x: hidden;
}
.middleware-container {
max-width: 1800px;
margin: 1rem auto;
background-color: var(--bg-color-container);
border-radius: var(--border-radius);
box-shadow: 0 4px 12px var(--shadow-color);
overflow: hidden;
display: flex;
flex-direction: column;
height: calc(100vh - 2rem); /* Limit height */
min-height: 650px; /* Minimum reasonable height */
}
/* Header */
.main-header {
background-color: var(--header-bg);
padding: 0.75rem 1.5rem;
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
display: flex;
justify-content: space-between;
align-items: center;
}
.main-header h1 {
font-size: 1.3rem;
font-weight: 600;
color: var(--text-primary);
}
.header-status {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
color: var(--text-secondary);
}
.status-indicator {
width: 12px;
height: 12px;
border-radius: 50%;
background-color: var(--status-disconnected);
transition: background-color var(--transition-speed);
animation: pulse-grey 2s infinite ease-in-out;
}
.status-indicator.connecting {
background-color: var(--status-connecting);
animation: pulse-blue 1.5s infinite ease-in-out;
}
.status-indicator.connected {
background-color: var(--status-connected);
animation: none; /* Solid green when connected */
}
.status-indicator.error {
background-color: var(--status-error);
animation: pulse-red 1s infinite ease-in-out;
}
@keyframes pulse-blue {
0%, 100% { box-shadow: 0 0 0 0 rgba(0, 122, 255, 0.4); }
50% { box-shadow: 0 0 0 5px rgba(0, 122, 255, 0); }
}
@keyframes pulse-red {
0%, 100% { box-shadow: 0 0 0 0 rgba(255, 59, 48, 0.5); }
50% { box-shadow: 0 0 0 5px rgba(255, 59, 48, 0); }
}
@keyframes pulse-grey {
0%, 100% { opacity: 0.6; }
50% { opacity: 1; }
}
/* Main Content Layout */
.main-content {
flex-grow: 1;
padding: 1rem;
overflow: hidden;
display: grid;
grid-template-columns: 320px 1fr 450px; /* Connections | Data | Log */
gap: 1rem;
}
/* Panels */
.panel {
background-color: var(--panel-bg);
border-radius: var(--border-radius);
padding: 1rem;
display: flex;
flex-direction: column;
overflow: hidden;
/* Optional subtle border */
/* border: 1px solid var(--border-color); */
}
.panel h2 {
font-size: 1.1rem;
font-weight: 600;
margin-bottom: 1rem;
padding-bottom: 0.6rem;
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
color: var(--text-primary);
flex-shrink: 0;
}
.panel h2 .icon {
margin-right: 0.6rem;
color: var(--accent-blue); /* Default icon color */
}
.panel h2 span {
font-weight: normal;
font-size: 0.9em;
color: var(--text-secondary);
margin-left: 0.3rem;
}
.panel-toolbar {
margin-bottom: 0.75rem;
display: flex;
gap: 0.5rem;
align-items: center;
flex-shrink: 0;
flex-wrap: wrap; /* Allow wrapping on smaller screens if needed */
}
/* Common Input/Button Styles */
input[type="search"],
select {
padding: 0.5rem 0.75rem;
font-size: 0.9rem;
border: 1px solid var(--input-border);
background-color: var(--input-bg);
border-radius: var(--border-radius-small);
outline: none;
transition: border-color var(--transition-speed), box-shadow var(--transition-speed);
color: var(--text-primary);
height: 34px; /* Match button height */
}
input[type="search"] {
flex-grow: 1; /* Allow search to take space */
}
input[type="search"]:focus,
select:focus {
border-color: var(--input-focus-border);
box-shadow: 0 0 0 2px rgba(0, 122, 255, 0.2);
}
select {
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%236e6e73'%3E%3Cpath fill-rule='evenodd' d='M4.22 6.22a.75.75 0 0 1 1.06 0L8 8.94l2.72-2.72a.75.75 0 1 1 1.06 1.06l-3.25 3.25a.75.75 0 0 1-1.06 0L4.22 7.28a.75.75 0 0 1 0-1.06Z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.5rem center;
background-size: 16px 16px;
padding-right: 2rem; /* Make space for arrow */
}
.action-button {
padding: 0 0.75rem;
height: 34px;
font-size: 0.9rem;
border: 1px solid var(--border-color);
background-color: #fff;
color: var(--accent-blue);
border-radius: var(--border-radius-small);
cursor: pointer;
transition: background-color var(--transition-speed), border-color var(--transition-speed);
display: inline-flex;
align-items: center;
gap: 0.4rem;
white-space: nowrap;
}
.action-button:hover {
background-color: #f8f8f8;
border-color: #d1d1d1;
}
.action-button:active {
background-color: #f0f0f0;
}
.action-button:disabled {
color: var(--accent-grey);
cursor: not-allowed;
background-color: #f5f5f5;
opacity: 0.7;
}
.action-button .icon {
font-size: 1.1em;
}
/* Connection Panel */
.connection-list {
list-style: none;
overflow-y: auto;
flex-grow: 1;
}
.connection-item {
padding: 0.75rem 0.5rem;
margin-bottom: 0.2rem;
border-radius: var(--border-radius-small);
cursor: default; /* Not selectable for now */
transition: background-color var(--transition-speed);
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.9rem;
border-bottom: 1px solid #f0f0f0;
}
.connection-item:last-child {
border-bottom: none;
}
/* .connection-item:hover { background-color: var(--list-item-hover-bg); } */
.connection-details {
display: flex;
flex-direction: column;
flex-grow: 1;
margin-right: 0.5rem;
}
.connection-name {
font-weight: 500;
color: var(--text-primary);
}
.connection-name .protocol {
font-weight: normal;
font-size: 0.8em;
color: var(--text-label);
margin-left: 0.3rem;
background-color: #eee;
padding: 0.1rem 0.3rem;
border-radius: 3px;
}
.connection-info {
font-size: 0.8rem;
color: var(--text-secondary);
}
.connection-status {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.8rem;
font-weight: 500;
padding: 0.2rem 0.5rem;
border-radius: var(--border-radius-small);
flex-shrink: 0;
min-width: 90px; /* Ensure consistent width */
justify-content: center;
}
.connection-status .dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.connection-status.connecting {
color: var(--status-connecting);
background-color: rgba(0, 122, 255, 0.1);
}
.connection-status.connecting .dot { background-color: var(--status-connecting); }
.connection-status.connected {
color: var(--status-connected);
background-color: rgba(52, 199, 89, 0.1);
}
.connection-status.connected .dot { background-color: var(--status-connected); }
.connection-status.error {
color: var(--status-error);
background-color: rgba(255, 59, 48, 0.1);
}
.connection-status.error .dot { background-color: var(--status-error); }
.connection-status.disconnected {
color: var(--status-disconnected);
background-color: rgba(142, 142, 147, 0.1);
}
.connection-status.disconnected .dot { background-color: var(--status-disconnected); }
/* Data Point Panel */
.data-panel {
grid-column: span 1; /* Takes middle space */
}
.data-point-table-container {
flex-grow: 1;
overflow: auto; /* Scroll only the table container */
border: 1px solid var(--border-color);
border-radius: var(--border-radius-small);
}
#dataPointTable {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
#dataPointTable th,
#dataPointTable td {
padding: 0.6rem 0.75rem;
text-align: left;
border-bottom: 1px solid var(--border-color);
white-space: nowrap;
}
#dataPointTable th {
background-color: var(--table-header-bg);
font-weight: 500;
color: var(--text-label);
position: sticky;
top: 0;
z-index: 1;
}
#dataPointTable tbody tr:hover {
background-color: var(--table-row-hover-bg);
}
#dataPointTable td:nth-child(1) { /* Tag Name */
font-weight: 500;
font-family: var(--font-monospace);
color: var(--text-primary);
}
#dataPointTable td:nth-child(2) { /* Value */
font-weight: 600;
font-family: var(--font-monospace);
}
#dataPointTable td:nth-child(3) { /* Source PLC */
color: var(--text-secondary);
}
#dataPointTable td:nth-child(4) { /* Timestamp */
font-size: 0.8rem;
color: var(--text-secondary);
}
#dataPointTable td:nth-child(5) { /* Status */
text-align: center;
}
.data-quality-indicator {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
margin-right: 0.3em;
}
.quality-good .data-quality-indicator { background-color: var(--status-quality-good); }
.quality-uncertain .data-quality-indicator { background-color: var(--status-quality-uncertain); }
.quality-bad .data-quality-indicator { background-color: var(--status-quality-bad); }
.quality-good { color: var(--status-quality-good); }
.quality-uncertain { color: var(--status-quality-uncertain); }
.quality-bad { color: var(--status-quality-bad); }
.placeholder-row td {
text-align: center;
color: var(--placeholder-text);
font-style: italic;
height: 100px; /* Give placeholder some height */
}
/* Log Panel */
.log-panel {
grid-column: span 1; /* Takes last column space */
}
.log-level-filter {
display: flex;
gap: 0.75rem;
margin-left: auto; /* Push filter to the right */
font-size: 0.8rem;
}
.log-level-filter label {
display: flex;
align-items: center;
gap: 0.2rem;
cursor: pointer;
color: var(--text-label);
}
.log-level-filter input[type="checkbox"] {
margin-right: 0.1rem;
}
.log-list {
list-style: none;
overflow-y: auto;
flex-grow: 1;
font-family: var(--font-monospace);
font-size: 0.8rem;
line-height: 1.5;
}
.log-item {
padding: 0.3rem 0.5rem;
border-bottom: 1px dashed #f0f0f0;
display: flex;
gap: 0.75rem;
align-items: flex-start;
}
.log-item:last-child {
border-bottom: none;
}
.log-item .timestamp {
color: var(--text-secondary);
flex-shrink: 0;
width: 60px; /* Fixed width for alignment */
}
.log-item .level {
font-weight: 600;
flex-shrink: 0;
width: 45px; /* Fixed width for alignment */
text-align: right;
padding-right: 0.5rem;
}
.log-item.info .level { color: var(--log-info); }
.log-item.warn .level { color: var(--log-warn); }
.log-item.error .level { color: var(--log-error); }
.log-item .message {
color: var(--text-secondary);
flex-grow: 1;
word-break: break-word;
}
/* Footer */
.main-footer {
background-color: var(--header-bg);
padding: 0.6rem 1.5rem;
text-align: center;
font-size: 0.8rem;
color: var(--text-secondary);
border-top: 1px solid var(--border-color);
flex-shrink: 0;
}
/* Icons (Basic Placeholders) */
.icon::before {
display: inline-block;
font-weight: normal;
font-style: normal;
font-variant: normal;
text-rendering: auto;
-webkit-font-smoothing: antialiased;
margin-right: 0.3em;
}
.icon-connect::before { content: "🔌"; }
.icon-tags::before { content: "🏷️"; }
.icon-log::before { content: "📜"; }
.icon-plus::before { content: "+"; font-weight: bold; }
.icon-clear::before { content: "🗑️"; }
/* Placeholder */
.placeholder {
color: var(--placeholder-text);
font-style: italic;
text-align: center;
padding: 1rem;
font-size: 0.9rem;
}
/* Responsive Design */
@media (max-width: 1400px) {
.main-content {
grid-template-columns: 280px 1fr 400px; /* Adjust column widths */
}
}
@media (max-width: 1200px) {
.main-content {
grid-template-columns: 250px 1fr 350px; /* Further adjust */
}
.main-header h1 {
font-size: 1.15rem;
}
.log-panel {
font-size: 0.75rem; /* Smaller log font */
}
}
@media (max-width: 992px) {
.middleware-container {
height: auto;
min-height: 100vh;
margin: 0.5rem;
}
.main-content {
grid-template-columns: 1fr; /* Stack columns */
grid-template-rows: auto auto auto; /* Auto rows */
padding: 0.5rem;
gap: 0.5rem;
}
.panel {
padding: 0.75rem;
overflow-y: visible; /* Allow panels to grow */
}
.connection-list,
.log-list {
max-height: 250px; /* Limit list height */
overflow-y: auto;
}
.data-point-table-container {
max-height: 350px; /* Limit table height */
}
.main-header {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
padding: 0.75rem;
}
.header-status {
align-self: flex-end;
}
.panel-toolbar {
flex-direction: column;
align-items: stretch;
}
.log-level-filter {
justify-content: space-around;
margin-left: 0;
margin-top: 0.5rem;
}
}
@media (max-width: 576px) {
.main-header h1 {
font-size: 1rem;
}
.panel h2 {
font-size: 1rem;
}
#dataPointTable {
font-size: 0.75rem; /* Smaller table font on mobile */
}
#dataPointTable th,
#dataPointTable td {
padding: 0.4rem 0.5rem;
}
.connection-list,
.log-list {
max-height: 200px;
}
.data-point-table-container {
max-height: 300px;
}
}
script.js
// script.js - Industrial Protocol Converter & Data Acquisition Middleware Component
document.addEventListener('DOMContentLoaded', () => {
// --- DOM Elements ---
const globalStatusIndicator = document.getElementById('globalStatusIndicator');
const globalStatusText = document.getElementById('globalStatusText');
const connectionSearchInput = document.getElementById('connectionSearch');
const connectionListUl = document.getElementById('connectionList');
const dataPointSearchInput = document.getElementById('dataPointSearch');
const dataSourceFilterSelect = document.getElementById('dataSourceFilter');
const monitoredPointsCountSpan = document.getElementById('monitoredPointsCount');
const dataPointTableBody = document.getElementById('dataPointTBody');
const clearLogBtn = document.getElementById('clearLogBtn');
const logListUl = document.getElementById('logList');
const logLevelFilters = document.querySelectorAll('input[name="logLevel"]');
// --- Simulation State & Parameters ---
let connections = {};
let dataPoints = {}; // Key: tagId, Value: { tagName, value, sourceId, sourceName, timestamp, quality }
let logs = [];
let connectionSearchTerm = '';
let dataPointSearchTerm = '';
let dataSourceFilter = 'all';
let visibleLogLevels = ['INFO', 'WARN', 'ERROR'];
let logCounter = 0;
const MAX_LOG_ITEMS = 200;
// Update intervals (in ms)
const connectionUpdateInterval = 5000; // Check/update connection status
const dataUpdateInterval = 2000; // Update data point values
// --- Sample Data Generation ---
function generateSampleConnections(count = 5) {
const generatedConnections = {};
const brands = ['Siemens', 'Rockwell', 'Mitsubishi', 'Omron', 'Schneider'];
const protocols = { // Simplified mapping
'Siemens': 'S7',
'Rockwell': 'EtherNet/IP',
'Mitsubishi': 'MELSEC',
'Omron': 'FINS',
'Schneider': 'Modbus TCP'
};
const baseIP = "192.168.1.";
for (let i = 1; i <= count; i++) {
const id = `CONN-${String(i).padStart(3, '0')}`;
const brand = brands[Math.floor(Math.random() * brands.length)];
const protocol = protocols[brand];
const ip = `${baseIP}${10 + i}`;
generatedConnections[id] = {
id: id,
name: `${brand} PLC ${i}`,
brand: brand,
protocol: protocol,
ip: ip,
status: 'disconnected', // Initial: disconnected, connecting, connected, error
tags: generateSampleTags(id, brand, i, 5 + Math.floor(Math.random() * 10))
};
}
return generatedConnections;
}
function generateSampleTags(connId, brand, lineNum, count = 10) {
const tags = {};
const dataTypes = ['DINT', 'REAL', 'BOOL', 'STRING'];
const prefixes = {
'Siemens': 'DB1.DBW',
'Rockwell': `Line${lineNum}.Tag`,
'Mitsubishi': 'D',
'Omron': 'D',
'Schneider': '%MW'
};
for (let i = 0; i < count; i++) {
const dataType = dataTypes[Math.floor(Math.random() * dataTypes.length)];
const address = `${prefixes[brand]}${100 + i * 2}`;
const tagName = `${connId}.${dataType === 'BOOL' ? 'StatusBit' : 'Sensor'}_${i}`;
tags[tagName] = {
tagName: tagName,
address: address,
dataType: dataType,
sourceId: connId,
sourceName: `${brand} PLC ${lineNum}`
// Initial value, quality, timestamp will be added dynamically
};
}
return tags;
}
// --- Logging Utility ---
function addLog(level, message) {
const timestamp = new Date().toLocaleTimeString('zh-CN');
const logEntry = {
id: logCounter++,
level: level.toUpperCase(),
message: message,
timestamp: timestamp
};
logs.push(logEntry);
if (logs.length > MAX_LOG_ITEMS) {
logs.shift(); // Remove oldest log
}
// Only render if the level is visible
if (visibleLogLevels.includes(logEntry.level)) {
renderSingleLog(logEntry, true); // Add to top
}
}
// --- Initialization ---
function initializeMiddleware() {
addLog('info', '中间件服务启动中...');
connections = generateSampleConnections();
setupEventListeners();
// Start simulation loops
updateConnectionStatuses(); // Initial update
setInterval(updateConnectionStatuses, connectionUpdateInterval);
setInterval(updateDataPoints, dataUpdateInterval);
renderConnectionList();
renderDataSourceFilter();
updateGlobalStatus();
renderLogList(); // Render initial logs
addLog('info', '中间件初始化完成。');
console.log("Middleware Monitor Initialized", connections);
}
// --- Event Handlers ---
function setupEventListeners() {
connectionSearchInput.addEventListener('input', handleConnectionSearch);
dataPointSearchInput.addEventListener('input', handleDataPointSearch);
dataSourceFilterSelect.addEventListener('change', handleDataSourceFilter);
clearLogBtn.addEventListener('click', handleClearLogs);
logLevelFilters.forEach(input => {
input.addEventListener('change', handleLogLevelFilterChange);
});
}
function handleConnectionSearch(event) {
connectionSearchTerm = event.target.value.toLowerCase();
renderConnectionList();
}
function handleDataPointSearch(event) {
dataPointSearchTerm = event.target.value.toLowerCase();
renderDataPointTable();
}
function handleDataSourceFilter(event) {
dataSourceFilter = event.target.value;
renderDataPointTable();
}
function handleClearLogs() {
logs = [];
logListUl.innerHTML = ''; // Clear display
addLog('info', '日志已清除。');
}
function handleLogLevelFilterChange() {
visibleLogLevels = Array.from(logLevelFilters)
.filter(input => input.checked)
.map(input => input.value);
renderLogList(); // Re-render logs based on new filter
}
// --- Simulation & Data Update Logic ---
function updateConnectionStatuses() {
let changed = false;
Object.values(connections).forEach(conn => {
const prevState = conn.status;
const rand = Math.random();
switch (conn.status) {
case 'disconnected':
if (rand < 0.3) { // Chance to start connecting
conn.status = 'connecting';
addLog('info', `尝试连接 ${conn.name} (${conn.ip})...`);
}
break;
case 'connecting':
if (rand < 0.7) { // Chance to connect successfully
conn.status = 'connected';
addLog('info', `${conn.name} 连接成功.`);
initializeDataPointsForConnection(conn);
} else if (rand < 0.85) { // Chance to fail
conn.status = 'error';
addLog('error', `${conn.name} 连接失败 (超时或配置错误).`);
removeDataPointsForConnection(conn);
} // Else: remains connecting
break;
case 'connected':
if (rand < 0.03) { // Small chance to disconnect
conn.status = 'disconnected';
addLog('warn', `${conn.name} 连接已断开.`);
removeDataPointsForConnection(conn);
} else if (rand < 0.06) { // Small chance of error state
conn.status = 'error';
addLog('error', `${conn.name} 连接出现错误 (通信异常).`);
// Mark data as uncertain or bad? For now, just show error
setDataPointsQuality(conn.id, 'uncertain');
}
break;
case 'error':
if (rand < 0.2) { // Chance to recover (retry implicitly)
conn.status = 'disconnected'; // Go back to disconnected to retry
addLog('info', `${conn.name} 从错误状态恢复,尝试重新连接.`);
}
break;
}
if (conn.status !== prevState) {
changed = true;
}
});
if (changed) {
renderConnectionList();
updateGlobalStatus();
renderDataPointTable(); // Data points might have been added/removed/quality changed
}
}
function initializeDataPointsForConnection(conn) {
Object.values(conn.tags).forEach(tagDef => {
if (!dataPoints[tagDef.tagName]) {
dataPoints[tagDef.tagName] = {
...tagDef,
value: generateInitialValue(tagDef.dataType),
timestamp: new Date(),
quality: 'good'
};
addLog('info', `从 ${conn.name} 添加数据点: ${tagDef.tagName}`);
} else {
// If tag already exists but connection was re-established
dataPoints[tagDef.tagName].quality = 'good';
dataPoints[tagDef.tagName].timestamp = new Date();
}
});
updateMonitoredPointsCount();
}
function removeDataPointsForConnection(conn) {
Object.keys(conn.tags).forEach(tagName => {
if (dataPoints[tagName]) {
// Instead of deleting, mark as bad quality
dataPoints[tagName].quality = 'bad';
dataPoints[tagName].value = '#BAD'; // Indicate bad value
dataPoints[tagName].timestamp = new Date();
addLog('warn', `数据点 ${tagName} (来自 ${conn.name}) 质量变为 BAD (连接断开).`);
// delete dataPoints[tagName]; // Option: remove completely
}
});
// updateMonitoredPointsCount(); // Don't update count if just marking as bad
}
function setDataPointsQuality(connectionId, quality) {
Object.values(dataPoints).forEach(dp => {
if (dp.sourceId === connectionId) {
dp.quality = quality;
dp.timestamp = new Date();
if(quality !== 'good') dp.value = `#${quality.toUpperCase()}`;
}
});
}
function updateDataPoints() {
let dataChanged = false;
Object.values(dataPoints).forEach(dp => {
const conn = connections[dp.sourceId];
if (conn && conn.status === 'connected') {
const oldValue = dp.value;
const oldQuality = dp.quality;
const rand = Math.random();
// Simulate value change
dp.value = generateNextValue(dp.value, dp.dataType);
// Simulate quality change
if (rand < 0.02 && dp.quality === 'good') {
dp.quality = 'uncertain';
addLog('warn', `数据点 ${dp.tagName} 质量变为 UNCERTAIN.`);
} else if (rand < 0.005 && dp.quality === 'good') {
dp.quality = 'bad';
dp.value = '#BAD';
addLog('error', `数据点 ${dp.tagName} 质量变为 BAD (读取错误).`);
} else if (dp.quality !== 'good' && rand < 0.3) {
// Chance to recover quality
dp.quality = 'good';
}
if (dp.value !== oldValue || dp.quality !== oldQuality) {
dp.timestamp = new Date();
dataChanged = true;
}
}
});
if (dataChanged) {
renderDataPointTable();
}
}
function generateInitialValue(dataType) {
switch (dataType) {
case 'DINT': return Math.floor(Math.random() * 1000);
case 'REAL': return (Math.random() * 100).toFixed(2);
case 'BOOL': return Math.random() < 0.5;
case 'STRING': return `Status_${Math.random().toString(36).substring(2, 7)}`;
default: return null;
}
}
function generateNextValue(currentValue, dataType) {
switch (dataType) {
case 'DINT':
const change = Math.floor((Math.random() - 0.4) * 10);
return currentValue + change;
case 'REAL':
const floatChange = (Math.random() - 0.45) * 5;
let newValue = parseFloat(currentValue) + floatChange;
return newValue.toFixed(2);
case 'BOOL':
// Lower chance to flip bool value
return Math.random() < 0.05 ? !currentValue : currentValue;
case 'STRING':
// Less frequent string changes
return Math.random() < 0.02 ? `Status_${Math.random().toString(36).substring(2, 7)}` : currentValue;
default: return currentValue;
}
}
function updateGlobalStatus() {
const total = Object.keys(connections).length;
const connectedCount = Object.values(connections).filter(c => c.status === 'connected').length;
const errorCount = Object.values(connections).filter(c => c.status === 'error').length;
const connectingCount = Object.values(connections).filter(c => c.status === 'connecting').length;
let statusClass = 'disconnected';
let statusText = `已断开 (${total - connectedCount - errorCount - connectingCount}/${total})`;
if (errorCount > 0) {
statusClass = 'error';
statusText = `错误 (${errorCount}/${total})`;
} else if (connectingCount > 0) {
statusClass = 'connecting';
statusText = `连接中 (${connectingCount}/${total})...`;
} else if (connectedCount === total && total > 0) {
statusClass = 'connected';
statusText = `全部已连接 (${connectedCount}/${total})`;
} else if (connectedCount > 0) {
statusClass = 'connected'; // Show connected even if not all are
statusText = `部分连接 (${connectedCount}/${total})`;
} else if (total === 0) {
statusText = '无连接';
}
globalStatusIndicator.className = `status-indicator ${statusClass}`;
globalStatusText.textContent = statusText;
}
function updateMonitoredPointsCount() {
monitoredPointsCountSpan.textContent = Object.keys(dataPoints).length;
}
// --- Rendering Functions ---
function renderConnectionList() {
connectionListUl.innerHTML = ''; // Clear list
const filteredConnections = Object.values(connections).filter(conn =>
conn.name.toLowerCase().includes(connectionSearchTerm)
|| conn.ip.toLowerCase().includes(connectionSearchTerm)
|| conn.protocol.toLowerCase().includes(connectionSearchTerm)
);
if (filteredConnections.length === 0) {
connectionListUl.innerHTML = '<li class="placeholder">未找到匹配连接</li>';
return;
}
filteredConnections
.sort((a, b) => a.name.localeCompare(b.name))
.forEach(conn => {
const li = document.createElement('li');
li.className = 'connection-item';
li.dataset.id = conn.id;
li.innerHTML = `
<div class="connection-details">
<span class="connection-name">${conn.name} <span class="protocol">${conn.protocol}</span></span>
<span class="connection-info">${conn.ip}</span>
</div>
<span class="connection-status ${conn.status}">
<span class="dot"></span>
${conn.status.toUpperCase()}
</span>
`;
connectionListUl.appendChild(li);
});
}
function renderDataSourceFilter() {
const sources = [...new Set(Object.values(connections).map(c => c.name))].sort();
dataSourceFilterSelect.innerHTML = '<option value="all">所有源</option>'; // Reset
sources.forEach(sourceName => {
const option = document.createElement('option');
option.value = sourceName;
option.textContent = sourceName;
dataSourceFilterSelect.appendChild(option);
});
}
function renderDataPointTable() {
dataPointTableBody.innerHTML = ''; // Clear table
const filteredDataPoints = Object.values(dataPoints).filter(dp => {
const searchTermLower = dataPointSearchTerm.toLowerCase();
const matchesSearch = searchTermLower === ''
|| dp.tagName.toLowerCase().includes(searchTermLower)
|| String(dp.value).toLowerCase().includes(searchTermLower);
const matchesSource = dataSourceFilter === 'all' || dp.sourceName === dataSourceFilter;
return matchesSearch && matchesSource;
});
if (filteredDataPoints.length === 0) {
dataPointTableBody.innerHTML = '<tr class="placeholder-row"><td colspan="5">无匹配数据点</td></tr>';
updateMonitoredPointsCount(); // Reflect filtered count? Or total? Let's show total.
// monitoredPointsCountSpan.textContent = filteredDataPoints.length; // Option: show filtered count
return;
}
filteredDataPoints
.sort((a, b) => a.tagName.localeCompare(b.tagName)) // Sort by tag name
.forEach(dp => {
const tr = document.createElement('tr');
tr.dataset.tagId = dp.tagName;
const qualityClass = `quality-${dp.quality}`;
tr.innerHTML = `
<td>${dp.tagName}</td>
<td>${formatValue(dp.value, dp.dataType)}</td>
<td>${dp.sourceName}</td>
<td>${dp.timestamp.toLocaleTimeString('zh-CN', { hour12: false })}.${String(dp.timestamp.getMilliseconds()).padStart(3, '0')}</td>
<td><span class="${qualityClass}"><span class="data-quality-indicator"></span>${dp.quality.toUpperCase()}</span></td>
`;
dataPointTableBody.appendChild(tr);
});
// updateMonitoredPointsCount(); // Update total count in the header
}
function formatValue(value, dataType) {
if (typeof value === 'boolean') {
return value ? 'TRUE' : 'FALSE';
}
if (value === null || value === undefined) {
return '--';
}
// Handle special quality strings
if (typeof value === 'string' && value.startsWith('#')) {
return `<span style="color: ${value === '#BAD' ? 'var(--status-quality-bad)' : 'var(--status-quality-uncertain)'}">${value}</span>`;
}
return String(value);
}
function renderLogList() {
logListUl.innerHTML = ''; // Clear list
const filteredLogs = logs.filter(log => visibleLogLevels.includes(log.level));
if (filteredLogs.length === 0) {
logListUl.innerHTML = '<li class="log-item placeholder">无匹配日志条目</li>';
return;
}
filteredLogs.forEach(log => renderSingleLog(log, false)); // Add to bottom initially
logListUl.scrollTop = logListUl.scrollHeight; // Scroll to bottom after initial render
}
function renderSingleLog(log, prepend = false) {
const li = document.createElement('li');
li.className = `log-item ${log.level.toLowerCase()}`;
li.dataset.logId = log.id;
li.innerHTML = `
<span class="timestamp">${log.timestamp}</span>
<span class="level">${log.level}</span>
<span class="message">${log.message}</span>
`;
if (prepend) {
logListUl.insertBefore(li, logListUl.firstChild);
// Optional: Trim logs if prepending frequently to avoid infinite growth in view
if (logListUl.children.length > MAX_LOG_ITEMS * 1.2) { // Keep slightly more than max logs in view
logListUl.removeChild(logListUl.lastChild);
}
} else {
logListUl.appendChild(li);
}
}
// --- Initial Call ---
initializeMiddleware();
});