S7_snap7_Stremer_n_Recorder/templates/index.html

2606 lines
111 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="light dark">
<title>PLC S7-315 Streamer & Logger</title>
<!-- Pico.css offline -->
<link rel="stylesheet" href="/static/css/pico.min.css">
<!-- Custom styles for specific components -->
<style>
/* Header with logo */
.header {
text-align: center;
margin-bottom: 2rem;
}
.header h1 {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
flex-wrap: wrap;
}
.header-logo {
height: 1.2em;
width: auto;
vertical-align: middle;
}
/* Status grid */
.status-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.status-item {
padding: 1rem;
border-radius: var(--pico-border-radius);
text-align: center;
font-weight: bold;
background: var(--pico-card-background-color);
border: var(--pico-border-width) solid var(--pico-border-color);
}
.status-connected {
background: var(--pico-primary-background);
color: var(--pico-primary-inverse);
}
.status-disconnected {
background: var(--pico-secondary-background);
color: var(--pico-secondary-inverse);
}
.status-streaming {
background: var(--pico-primary-background);
color: var(--pico-primary-inverse);
}
.status-idle {
background: var(--pico-muted-background-color);
color: var(--pico-muted-color);
}
/* Configuration grid */
.config-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 1.5rem;
margin-bottom: 1.5rem;
}
/* Form styling */
.form-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
.controls {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 1rem;
}
/* Variables table */
.variables-table {
width: 100%;
margin-top: 1rem;
}
/* Variable value cells styling */
.variables-table td[id^="value-"] {
font-family: var(--pico-font-family-monospace);
font-weight: bold;
text-align: center;
min-width: 100px;
}
/* Refresh button styling */
#refresh-values-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Diagnose button styling */
#diagnose-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Last refresh time styling */
#last-refresh-time {
font-style: italic;
font-size: 0.85rem;
}
/* Error cell tooltips */
.variables-table td[id^="value-"]:hover {
position: relative;
}
/* Variable value status colors */
.value-success {
color: var(--pico-color-green-600) !important;
}
.value-error {
color: var(--pico-color-red-500) !important;
}
.value-warning {
color: var(--pico-color-amber-600) !important;
}
.value-offline {
color: var(--pico-muted-color) !important;
}
/* Alert styles */
.alert {
padding: 1rem;
border-radius: var(--pico-border-radius);
margin: 1rem 0;
font-weight: bold;
}
.alert-success {
background-color: var(--pico-primary-background);
color: var(--pico-primary-inverse);
}
.alert-error {
background-color: var(--pico-color-red-400);
color: white;
}
.alert-warning {
background-color: var(--pico-color-amber-100);
color: var(--pico-color-amber-800);
border: 1px solid var(--pico-color-amber-300);
}
.alert-info {
background-color: var(--pico-color-blue-100);
color: var(--pico-color-blue-800);
border: 1px solid var(--pico-color-blue-300);
}
/* Dataset controls */
.dataset-controls {
background: var(--pico-card-background-color);
border: var(--pico-border-width) solid var(--pico-border-color);
padding: 1.5rem;
border-radius: var(--pico-border-radius);
margin-bottom: 1.5rem;
}
/* Info section */
.info-section {
background: var(--pico-muted-background-color);
border: var(--pico-border-width) solid var(--pico-muted-border-color);
border-radius: var(--pico-border-radius);
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.info-section p {
margin: 0.5rem 0;
}
/* Log container */
.log-container {
max-height: 400px;
overflow-y: auto;
background: var(--pico-background-color);
border: var(--pico-border-width) solid var(--pico-border-color);
border-radius: var(--pico-border-radius);
padding: 1rem;
font-family: var(--pico-font-family-monospace);
}
.log-entry {
display: flex;
flex-direction: column;
margin-bottom: 1rem;
padding: 0.75rem;
border-radius: var(--pico-border-radius);
font-size: 0.875rem;
border-left: 3px solid transparent;
}
.log-entry.log-info {
background: var(--pico-card-background-color);
border-left-color: var(--pico-primary);
}
.log-entry.log-warning {
background: var(--pico-color-amber-50);
border-left-color: var(--pico-color-amber-500);
}
.log-entry.log-error {
background: var(--pico-color-red-50);
border-left-color: var(--pico-color-red-500);
}
.log-header {
display: flex;
justify-content: space-between;
align-items: center;
font-weight: bold;
margin-bottom: 0.25rem;
}
.log-timestamp {
font-size: 0.75rem;
opacity: 0.7;
}
.log-message {
margin-bottom: 0.25rem;
word-wrap: break-word;
}
.log-details {
font-size: 0.75rem;
opacity: 0.8;
background: rgba(0, 0, 0, 0.1);
padding: 0.5rem;
border-radius: var(--pico-border-radius);
margin-top: 0.25rem;
white-space: pre-wrap;
}
.log-controls {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
flex-wrap: wrap;
align-items: center;
}
.log-stats {
background: var(--pico-muted-background-color);
border: var(--pico-border-width) solid var(--pico-muted-border-color);
border-radius: var(--pico-border-radius);
padding: 0.5rem 1rem;
font-size: 0.875rem;
}
/* Utility classes */
.status-active {
color: var(--pico-primary);
font-weight: bold;
}
.status-inactive {
color: var(--pico-muted-color);
}
.status-error {
color: var(--pico-color-red-500);
}
/* Modal improvements */
.modal {
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(5px);
}
.modal article {
margin: 5% auto;
width: 90%;
max-width: 600px;
}
.modal header {
display: flex;
justify-content: space-between;
align-items: center;
}
.close {
font-size: 1.5rem;
font-weight: bold;
cursor: pointer;
border: none;
background: none;
color: inherit;
}
.close:hover {
opacity: 0.7;
}
/* Mobile responsive */
@media (max-width: 768px) {
.header h1 {
flex-direction: column;
gap: 0.5rem;
}
.header-logo {
height: 1.2em;
}
.config-grid {
grid-template-columns: 1fr;
}
.form-row {
grid-template-columns: 1fr;
}
.controls {
flex-direction: column;
}
.status-grid {
grid-template-columns: 1fr;
}
}
/* Theme selector */
.theme-selector {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 1000;
background: var(--pico-card-background-color);
border: var(--pico-border-width) solid var(--pico-border-color);
border-radius: var(--pico-border-radius);
padding: 0.5rem;
display: flex;
gap: 0.5rem;
align-items: center;
box-shadow: var(--pico-box-shadow);
}
.theme-selector button {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
border-radius: var(--pico-border-radius);
cursor: pointer;
transition: all 0.2s ease;
}
.theme-selector button.active {
background: var(--pico-primary-background);
color: var(--pico-primary-inverse);
}
.theme-selector button:not(.active) {
background: var(--pico-muted-background-color);
color: var(--pico-muted-color);
}
.theme-selector button:hover:not(.active) {
background: var(--pico-primary-hover);
color: var(--pico-primary-inverse);
}
/* Font size reduction - 15% smaller (more balanced) */
html {
font-size: 85%;
/* 15% reduction from 100% - more balanced */
}
/* Adjust specific elements that might need fine-tuning */
.header h1 {
font-size: 2.2rem;
/* Adjusted for smaller base font */
}
.header p {
font-size: 1.1rem;
}
.status-item {
font-size: 1rem;
}
.log-entry {
font-size: 0.9rem;
/* Adjusted for smaller base font */
}
.log-timestamp {
font-size: 0.8rem;
/* Adjusted for smaller base font */
}
.log-details {
font-size: 0.8rem;
/* Adjusted for smaller base font */
}
.log-stats {
font-size: 0.9rem;
/* Adjusted for smaller base font */
}
/* Ensure buttons and inputs remain readable */
button,
input,
select,
textarea {
font-size: 1rem;
}
/* Table adjustments */
.variables-table th,
.variables-table td {
font-size: 0.95rem;
}
/* Modal adjustments */
.modal article {
font-size: 1rem;
}
.modal h3 {
font-size: 1.4rem;
}
/* CSV Configuration styles */
.csv-config-display {
background: var(--pico-card-background-color);
border: var(--pico-border-width) solid var(--pico-border-color);
border-radius: var(--pico-border-radius);
padding: 1.5rem;
margin-bottom: 1rem;
}
.config-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1rem;
}
.config-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0;
border-bottom: 1px solid var(--pico-muted-border-color);
}
.config-item:last-child {
border-bottom: none;
}
.config-item span {
font-weight: normal;
color: var(--pico-primary);
}
.directory-stats {
padding: 1rem;
background: var(--pico-muted-background-color);
border-radius: var(--pico-border-radius);
margin-top: 0.5rem;
}
.directory-stats .stat-item {
display: flex;
justify-content: space-between;
margin: 0.5rem 0;
}
.day-folder-item {
background: var(--pico-card-background-color);
border: 1px solid var(--pico-border-color);
border-radius: var(--pico-border-radius);
padding: 0.75rem;
margin: 0.5rem 0;
display: flex;
justify-content: space-between;
align-items: center;
}
.cleanup-status {
padding: 0.5rem;
border-radius: var(--pico-border-radius);
margin: 0.5rem 0;
font-weight: bold;
}
.cleanup-success {
background-color: var(--pico-color-green-100);
color: var(--pico-color-green-800);
border: 1px solid var(--pico-color-green-300);
}
.cleanup-error {
background-color: var(--pico-color-red-100);
color: var(--pico-color-red-800);
border: 1px solid var(--pico-color-red-300);
}
</style>
</head>
<body>
<!-- Theme Selector -->
<div class="theme-selector">
<button id="theme-light" class="active" onclick="setTheme('light')">☀️ Light</button>
<button id="theme-dark" onclick="setTheme('dark')">🌙 Dark</button>
<button id="theme-auto" onclick="setTheme('auto')">🔄 Auto</button>
</div>
<main class="container">
<!-- Header -->
<header class="header">
<h1>
<img src="/images/SIDEL.png" alt="SIDEL Logo" class="header-logo">
-- PLC S7-31x Streamer & Logger
</h1>
<p>Real-time monitoring and streaming system</p>
</header>
<!-- Status Bar -->
<section class="status-grid">
<div class="status-item" id="plc-status">
🔌 PLC: Disconnected
<div style="margin-top: 8px;">
<button type="button" id="status-connect-btn">🔗 Connect</button>
</div>
</div>
<div class="status-item" id="stream-status">
📡 Streaming: Inactive
<div style="margin-top: 8px;">
<button type="button" id="status-streaming-btn">⏹️ Stop</button>
</div>
</div>
<div class="status-item status-idle">
📊 Datasets: {{ status.datasets_count }} ({{ status.active_datasets_count }} active)
</div>
<div class="status-item status-idle">
📋 Variables: {{ status.total_variables }}
</div>
<div class="status-item status-idle">
⏱️ Interval: {{ status.sampling_interval }}s
</div>
<div class="status-item" id="csv-status">
💾 CSV: Inactive
</div>
<div class="status-item" id="disk-space">
💽 Disk Space: Calculating...
</div>
</section>
<!-- Status messages -->
<div id="messages"></div>
<!-- Configuration Grid -->
<div class="config-grid">
<!-- PLC Configuration -->
<article>
<header>⚙️ PLC S7-315 Configuration</header>
<form id="plc-config-form">
<div class="form-row">
<label>
PLC IP Address:
<input type="text" id="plc-ip" value="{{ status.plc_config.ip }}"
placeholder="192.168.1.100">
</label>
<label>
Rack:
<input type="number" id="plc-rack" value="{{ status.plc_config.rack }}" min="0" max="7">
</label>
<label>
Slot:
<input type="number" id="plc-slot" value="{{ status.plc_config.slot }}" min="0" max="31">
</label>
</div>
<div class="controls">
<button type="submit">💾 Save Configuration</button>
<button type="button" id="connect-btn">🔗 Connect PLC</button>
<button type="button" id="disconnect-btn" class="secondary">❌ Disconnect PLC</button>
</div>
</form>
</article>
<!-- UDP Configuration -->
<article>
<header>🌐 UDP Gateway Configuration (PlotJuggler)</header>
<form id="udp-config-form">
<div class="form-row">
<label>
UDP Host:
<input type="text" id="udp-host" value="{{ status.udp_config.host }}"
placeholder="127.0.0.1">
</label>
<label>
UDP Port:
<input type="number" id="udp-port" value="{{ status.udp_config.port }}" min="1" max="65535">
</label>
<label>
Sampling Interval (s):
<input type="number" id="sampling-interval" value="{{ status.sampling_interval }}"
min="0.01" max="10" step="0.01">
</label>
</div>
<div class="controls">
<button type="submit">💾 Save Configuration</button>
<button type="button" id="update-sampling-btn" class="secondary">⏱️ Update Interval</button>
</div>
</form>
</article>
</div>
<!-- Integrated Dataset & Variables Management -->
<article>
<header>
<div style="display: flex; justify-content: space-between; align-items: center;">
<span>📊 Dataset & Variables Management</span>
<div style="display: flex; gap: 0.5rem; align-items: center;">
<select id="dataset-selector" style="min-width: 200px;">
<option value="">Select a dataset...</option>
</select>
<button type="button" id="new-dataset-btn" class="outline"> New</button>
<button type="button" id="delete-dataset-btn" class="secondary">🗑️ Delete</button>
</div>
</div>
</header>
<!-- Dataset Status Bar -->
<div id="dataset-status-bar" style="display: none; margin-bottom: 1rem;">
<div class="info-section" style="margin-bottom: 0;">
<div
style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 1rem;">
<div style="display: flex; gap: 2rem; flex-wrap: wrap;">
<span><strong>📂 Name:</strong> <span id="dataset-name"></span></span>
<span><strong>🏷️ Prefix:</strong> <span id="dataset-prefix"></span></span>
<span><strong>⏱️ Sampling:</strong> <span id="dataset-sampling"></span></span>
<span><strong>📊 Variables:</strong> <span id="dataset-var-count"></span></span>
<span><strong>📡 Streaming:</strong> <span id="dataset-stream-count"></span></span>
</div>
<div style="display: flex; gap: 0.5rem; align-items: center;">
<span id="dataset-status" class="status-item"></span>
<button type="button" id="activate-dataset-btn" class="outline">▶️ Activate</button>
<button type="button" id="deactivate-dataset-btn" class="secondary">⏹️ Deactivate</button>
</div>
</div>
</div>
</div>
<!-- Variables Management Section -->
<div id="variables-management" style="display: none;">
<!-- Add Variable Form -->
<form id="variable-form" style="margin-bottom: 1.5rem;">
<div class="form-row">
<label>
Variable Name:
<input type="text" id="var-name" placeholder="temperature" required>
</label>
<label>
Memory Area:
<select id="var-area" required onchange="toggleFields()">
<option value="db">DB (Data Block)</option>
<option value="mw">MW (Memory Words)</option>
<option value="pew">PEW (Process Input Words)</option>
<option value="paw">PAW (Process Output Words)</option>
<option value="e">E (Input Bits)</option>
<option value="a">A (Output Bits)</option>
<option value="mb">MB (Memory Bits)</option>
</select>
</label>
<label id="db-field">
Data Block (DB):
<input type="number" id="var-db" min="1" max="9999" value="1" required>
</label>
<label>
Offset:
<input type="number" id="var-offset" min="0" max="8192" value="0" required>
</label>
<label id="bit-field" style="display: none;">
Bit Position:
<select id="var-bit">
<option value="0">0</option>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
<option value="5">5</option>
<option value="6">6</option>
<option value="7">7</option>
</select>
</label>
<label>
Data Type:
<select id="var-type" required>
<option value="real">REAL (Float 32-bit)</option>
<option value="int">INT (16-bit Signed)</option>
<option value="uint">UINT (16-bit Unsigned)</option>
<option value="dint">DINT (32-bit Signed)</option>
<option value="udint">UDINT (32-bit Unsigned)</option>
<option value="word">WORD (16-bit)</option>
<option value="byte">BYTE (8-bit)</option>
<option value="sint">SINT (8-bit Signed)</option>
<option value="usint">USINT (8-bit Unsigned)</option>
<option value="bool">BOOL</option>
</select>
</label>
</div>
<button type="submit"> Add Variable</button>
</form>
<!-- Variables Table -->
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h4 style="margin: 0;">📊 Variables in Dataset</h4>
<div style="display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;">
<button type="button" id="refresh-values-btn" class="outline" onclick="refreshVariableValues()">
🔄 Refresh Values
</button>
<button type="button" id="diagnose-btn" class="secondary" onclick="diagnoseConnection()"
title="Run connection and variable diagnostics">
🔍 Diagnose
</button>
<div style="margin-left: 1rem;">
<span id="last-refresh-time"
style="color: var(--pico-muted-color); font-size: 0.9rem;"></span>
</div>
</div>
</div>
<table class="variables-table">
<thead>
<tr>
<th>Name</th>
<th>Memory Area</th>
<th>Offset</th>
<th>Type</th>
<th>Current Value</th>
<th>Stream to PlotJuggler</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="variables-tbody">
{% for name, var in variables.items() %}
<tr>
<td>{{ name }}</td>
<td>
{% if var.area == 'db' or var.get('db') %}
DB{{ var.get('db', 'N/A') }}.{{ var.offset }}
{% elif var.area == 'mw' or var.area == 'm' %}
MW{{ var.offset }}
{% elif var.area == 'pew' or var.area == 'pe' %}
PEW{{ var.offset }}
{% elif var.area == 'paw' or var.area == 'pa' %}
PAW{{ var.offset }}
{% elif var.area == 'e' %}
E{{ var.offset }}.{{ var.bit }}
{% elif var.area == 'a' %}
A{{ var.offset }}.{{ var.bit }}
{% elif var.area == 'mb' %}
M{{ var.offset }}.{{ var.bit }}
{% else %}
DB{{ var.get('db', 'N/A') }}.{{ var.offset }}
{% endif %}
</td>
<td>{{ var.offset }}</td>
<td>{{ var.type.upper() }}</td>
<td id="value-{{ name }}"
style="font-family: monospace; font-weight: bold; color: var(--pico-muted-color);">
--
</td>
<td>
<label>
<input type="checkbox" id="stream-{{ name }}" role="switch"
onchange="toggleStreaming('{{ name }}', this.checked)">
Enable
</label>
</td>
<td>
<button class="outline" onclick="editVariable('{{ name }}')">✏️ Edit</button>
<button class="secondary" onclick="removeVariable('{{ name }}')">🗑️ Remove</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- No Dataset Selected Message -->
<div id="no-dataset-message" style="text-align: center; padding: 2rem; color: var(--pico-muted-color);">
<p>📊 Please select a dataset to manage its variables</p>
<p>Or create a new dataset to get started</p>
</div>
</article>
<!-- Edit Variable Modal -->
<div id="edit-modal" class="modal" style="display: none;">
<article>
<header>
<h3>✏️ Edit Variable</h3>
<button class="close" onclick="closeEditModal()" aria-label="Close">&times;</button>
</header>
<form id="edit-variable-form">
<div class="form-row">
<label>
Variable Name:
<input type="text" id="edit-var-name" required>
</label>
<label>
Memory Area:
<select id="edit-var-area" required onchange="toggleEditFields()">
<option value="db">DB (Data Block)</option>
<option value="mw">MW (Memory Words)</option>
<option value="pew">PEW (Process Input Words)</option>
<option value="paw">PAW (Process Output Words)</option>
<option value="e">E (Input Bits)</option>
<option value="a">A (Output Bits)</option>
<option value="mb">MB (Memory Bits)</option>
</select>
</label>
<label id="edit-db-field">
Data Block (DB):
<input type="number" id="edit-var-db" min="1" max="9999" value="1" required>
</label>
<label>
Offset:
<input type="number" id="edit-var-offset" min="0" max="8192" value="0" required>
</label>
<label id="edit-bit-field" style="display: none;">
Bit Position:
<select id="edit-var-bit">
<option value="0">0</option>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
<option value="5">5</option>
<option value="6">6</option>
<option value="7">7</option>
</select>
</label>
<label>
Data Type:
<select id="edit-var-type" required>
<option value="real">REAL (Float 32-bit)</option>
<option value="int">INT (16-bit Signed)</option>
<option value="uint">UINT (16-bit Unsigned)</option>
<option value="dint">DINT (32-bit Signed)</option>
<option value="udint">UDINT (32-bit Unsigned)</option>
<option value="word">WORD (16-bit)</option>
<option value="byte">BYTE (8-bit)</option>
<option value="sint">SINT (8-bit Signed)</option>
<option value="usint">USINT (8-bit Unsigned)</option>
<option value="bool">BOOL (Boolean)</option>
</select>
</label>
</div>
<footer>
<button type="button" class="secondary" onclick="closeEditModal()">❌ Cancel</button>
<button type="submit">💾 Update Variable</button>
</footer>
</form>
</article>
</div>
<!-- New Dataset Modal -->
<div id="dataset-modal" class="modal" style="display: none;">
<article>
<header>
<h3>Create New Dataset</h3>
<button class="close" id="close-dataset-modal" aria-label="Close">&times;</button>
</header>
<form id="dataset-form">
<label>
Dataset ID:
<input type="text" id="dataset-id" placeholder="e.g., temperature_sensors" required>
<small>Used internally, no spaces or special characters</small>
</label>
<label>
Dataset Name:
<input type="text" id="dataset-name-input" placeholder="e.g., Temperature Sensors" required>
</label>
<label>
CSV Prefix:
<input type="text" id="dataset-prefix-input" placeholder="e.g., temp" required>
<small>Files will be named: prefix_hour.csv (e.g., temp_14.csv)</small>
</label>
<label>
Sampling Interval (seconds):
<input type="number" id="dataset-sampling-input"
placeholder="Leave empty to use global interval" min="0.01" step="0.01">
<small>Leave empty to use global sampling interval</small>
</label>
<footer>
<button type="button" class="secondary" id="cancel-dataset-btn">Cancel</button>
<button type="submit">Create Dataset</button>
</footer>
</form>
</article>
</div>
<!-- Multi-Dataset Streaming Control -->
<article>
<header>🚀 Multi-Dataset Streaming Control</header>
<div class="info-section">
<p><strong>📡 Streaming Mode:</strong> Only variables marked for streaming in active datasets are sent
to PlotJuggler</p>
<p><strong>💾 CSV Recording:</strong> Each active dataset automatically records ALL its variables to
separate CSV files</p>
<p><strong>📁 File Organization:</strong> records/[dd-mm-yyyy]/[prefix]_[hour].csv (e.g., temp_14.csv,
pressure_14.csv)</p>
<p><strong>⏱️ Individual Sampling:</strong> Each dataset can have its own sampling interval or use the
global one</p>
</div>
<div class="controls">
<button id="start-streaming-btn">▶️ Start All Active Datasets</button>
<button class="secondary" id="stop-streaming-btn">⏹️ Stop All Streaming</button>
<button class="outline" onclick="location.reload()">🔄 Refresh Status</button>
</div>
</article>
<!-- CSV Recording Configuration -->
<article>
<header>📁 CSV Recording Configuration</header>
<div class="info-section">
<p><strong>📂 Directory Management:</strong> Configure where CSV files are saved and manage file
rotation</p>
<p><strong>🔄 Automatic Cleanup:</strong> Set limits by size, days, or hours to automatically delete old
files</p>
<p><strong>💿 Disk Space:</strong> Monitor available space and estimated recording time remaining</p>
</div>
<!-- Current Configuration Display -->
<div class="csv-config-display" id="csv-config-display">
<div class="config-grid">
<div class="config-item">
<strong>📁 Records Directory:</strong>
<span id="csv-directory-path">Loading...</span>
</div>
<div class="config-item">
<strong>🔄 Rotation Enabled:</strong>
<span id="csv-rotation-enabled">Loading...</span>
</div>
<div class="config-item">
<strong>📊 Max Size:</strong>
<span id="csv-max-size">Loading...</span>
</div>
<div class="config-item">
<strong>📅 Max Days:</strong>
<span id="csv-max-days">Loading...</span>
</div>
<div class="config-item">
<strong>⏰ Max Hours:</strong>
<span id="csv-max-hours">Loading...</span>
</div>
<div class="config-item">
<strong>🧹 Cleanup Interval:</strong>
<span id="csv-cleanup-interval">Loading...</span>
</div>
</div>
</div>
<!-- Directory Information -->
<div class="csv-directory-info" id="csv-directory-info" style="margin-top: 1rem;">
<details>
<summary><strong>📊 Directory Information</strong></summary>
<div class="directory-stats" id="directory-stats">
<p>Loading directory information...</p>
</div>
</details>
</div>
<!-- Configuration Form -->
<details style="margin-top: 1rem;">
<summary><strong>⚙️ Modify Configuration</strong></summary>
<form id="csv-config-form" style="margin-top: 1rem;">
<div class="grid">
<div>
<label for="records-directory">📁 Records Directory:</label>
<input type="text" id="records-directory" name="records_directory" placeholder="records"
required>
<small>Base directory where CSV files will be saved</small>
</div>
<div>
<label>
<input type="checkbox" id="rotation-enabled" name="rotation_enabled">
🔄 Enable File Rotation
</label>
<small>Automatically delete old files based on limits below</small>
</div>
</div>
<div class="grid">
<div>
<label for="max-size-mb">📊 Max Total Size (MB):</label>
<input type="number" id="max-size-mb" name="max_size_mb" min="1" step="1"
placeholder="1000">
<small>Maximum total size of all CSV files in MB (leave empty for no limit)</small>
</div>
<div>
<label for="max-days">📅 Max Days to Keep:</label>
<input type="number" id="max-days" name="max_days" min="1" step="1" placeholder="30">
<small>Delete files older than this many days (leave empty for no limit)</small>
</div>
</div>
<div class="grid">
<div>
<label for="max-hours">⏰ Max Hours to Keep:</label>
<input type="number" id="max-hours" name="max_hours" min="1" step="1" placeholder="">
<small>Delete files older than this many hours (overrides days setting)</small>
</div>
<div>
<label for="cleanup-interval">🧹 Cleanup Interval (hours):</label>
<input type="number" id="cleanup-interval" name="cleanup_interval_hours" min="1" step="1"
value="24" required>
<small>How often to run automatic cleanup</small>
</div>
</div>
<div class="controls">
<button type="submit">💾 Save Configuration</button>
<button type="button" class="secondary" onclick="loadCsvConfig()">🔄 Reload</button>
<button type="button" class="outline" onclick="triggerManualCleanup()">🧹 Manual
Cleanup</button>
</div>
</form>
</details>
</article>
<!-- Application Events Log -->
<article>
<header>📋 Application Events Log</header>
<div class="info-section">
<p><strong>📝 Event Tracking:</strong> Connection events, configuration changes, errors and system
status</p>
<p><strong>💾 Persistent Storage:</strong> Events are saved to disk and persist between application
restarts</p>
</div>
<div class="log-controls">
<button class="outline" onclick="refreshEventLog()">🔄 Refresh Log</button>
<button class="outline" onclick="clearLogView()">🧹 Clear View</button>
<select id="log-limit" onchange="refreshEventLog()">
<option value="25">Last 25 events</option>
<option value="50" selected>Last 50 events</option>
<option value="100">Last 100 events</option>
<option value="200">Last 200 events</option>
</select>
<div class="log-stats" id="log-stats">
Loading log statistics...
</div>
</div>
<div class="log-container" id="events-log">
<div class="log-entry log-info">
<div class="log-header">
<span>📡 System</span>
<span class="log-timestamp">Loading...</span>
</div>
<div class="log-message">Loading application events...</div>
</div>
</div>
</article>
</main>
<script>
// Theme management
function setTheme(theme) {
const html = document.documentElement;
const buttons = document.querySelectorAll('.theme-selector button');
// Remove active class from all buttons
buttons.forEach(btn => btn.classList.remove('active'));
// Set theme
html.setAttribute('data-theme', theme);
// Add active class to selected button
document.getElementById(`theme-${theme}`).classList.add('active');
// Save preference to localStorage
localStorage.setItem('theme', theme);
}
// Load saved theme on page load
function loadTheme() {
const savedTheme = localStorage.getItem('theme') || 'light';
setTheme(savedTheme);
}
// Initialize theme on page load
document.addEventListener('DOMContentLoaded', function () {
loadTheme();
});
// Function to display messages
function showMessage(message, type = 'success') {
const messagesDiv = document.getElementById('messages');
let alertClass;
switch (type) {
case 'success':
alertClass = 'alert-success';
break;
case 'warning':
alertClass = 'alert-warning';
break;
case 'info':
alertClass = 'alert-info';
break;
case 'error':
default:
alertClass = 'alert-error';
break;
}
messagesDiv.innerHTML = `<div class="alert ${alertClass}">${message}</div>`;
setTimeout(() => {
messagesDiv.innerHTML = '';
}, 5000);
}
// Update status display
function updateStatus() {
fetch('/api/status')
.then(response => response.json())
.then(data => {
const plcStatus = document.getElementById('plc-status');
const streamStatus = document.getElementById('stream-status');
const csvStatus = document.getElementById('csv-status');
const diskSpaceStatus = document.getElementById('disk-space');
// Update PLC connection status
if (data.plc_connected) {
plcStatus.innerHTML = '🔌 PLC: Connected <div style="margin-top: 8px;"><button type="button" id="status-disconnect-btn">❌ Disconnect</button></div>';
plcStatus.className = 'status-item status-connected';
// Add event listener to the new disconnect button
document.getElementById('status-disconnect-btn').addEventListener('click', function () {
fetch('/api/plc/disconnect', { method: 'POST' })
.then(response => response.json())
.then(data => {
showMessage(data.message, data.success ? 'success' : 'error');
updateStatus();
});
});
} else {
plcStatus.innerHTML = '🔌 PLC: Disconnected <div style="margin-top: 8px;"><button type="button" id="status-connect-btn">🔗 Connect</button></div>';
plcStatus.className = 'status-item status-disconnected';
// Add event listener to the connect button
document.getElementById('status-connect-btn').addEventListener('click', function () {
fetch('/api/plc/connect', { method: 'POST' })
.then(response => response.json())
.then(data => {
showMessage(data.message, data.success ? 'success' : 'error');
updateStatus();
});
});
}
// Update streaming status
if (data.streaming) {
streamStatus.innerHTML = '📡 Streaming: Active <div style="margin-top: 8px;"><button type="button" id="status-streaming-btn">⏹️ Stop</button></div>';
streamStatus.className = 'status-item status-streaming';
// Add event listener to the stop streaming button
document.getElementById('status-streaming-btn').addEventListener('click', function () {
fetch('/api/streaming/stop', { method: 'POST' })
.then(response => response.json())
.then(data => {
showMessage(data.message, data.success ? 'success' : 'error');
updateStatus();
});
});
} else {
streamStatus.innerHTML = '📡 Streaming: Inactive <div style="margin-top: 8px;"><button type="button" id="status-start-btn">▶️ Start</button></div>';
streamStatus.className = 'status-item status-idle';
// Add event listener to the start streaming button
document.getElementById('status-start-btn').addEventListener('click', function () {
fetch('/api/streaming/start', { method: 'POST' })
.then(response => response.json())
.then(data => {
showMessage(data.message, data.success ? 'success' : 'error');
updateStatus();
});
});
}
// Update CSV recording status
if (data.csv_recording) {
csvStatus.textContent = `💾 CSV: Recording`;
csvStatus.className = 'status-item status-streaming';
} else {
csvStatus.textContent = `💾 CSV: Inactive`;
csvStatus.className = 'status-item status-idle';
}
// Update disk space status
if (data.disk_space_info) {
diskSpaceStatus.innerHTML = `💽 Disk: ${data.disk_space_info.free_space} free<br>
⏱️ ~${data.disk_space_info.recording_time_left}`;
diskSpaceStatus.className = 'status-item status-idle';
} else {
diskSpaceStatus.textContent = '💽 Disk Space: Calculating...';
diskSpaceStatus.className = 'status-item status-idle';
}
})
.catch(error => console.error('Error updating status:', error));
}
// PLC Configuration
document.getElementById('plc-config-form').addEventListener('submit', function (e) {
e.preventDefault();
const data = {
ip: document.getElementById('plc-ip').value,
rack: parseInt(document.getElementById('plc-rack').value),
slot: parseInt(document.getElementById('plc-slot').value)
};
fetch('/api/plc/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
})
.then(response => response.json())
.then(data => {
showMessage(data.message, data.success ? 'success' : 'error');
});
});
// UDP Configuration
document.getElementById('udp-config-form').addEventListener('submit', function (e) {
e.preventDefault();
const data = {
host: document.getElementById('udp-host').value,
port: parseInt(document.getElementById('udp-port').value)
};
fetch('/api/udp/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
})
.then(response => response.json())
.then(data => {
showMessage(data.message, data.success ? 'success' : 'error');
});
});
// Connect PLC
document.getElementById('connect-btn').addEventListener('click', function () {
fetch('/api/plc/connect', { method: 'POST' })
.then(response => response.json())
.then(data => {
showMessage(data.message, data.success ? 'success' : 'error');
updateStatus();
});
});
// Status grid connect button
document.getElementById('status-connect-btn').addEventListener('click', function () {
fetch('/api/plc/connect', { method: 'POST' })
.then(response => response.json())
.then(data => {
showMessage(data.message, data.success ? 'success' : 'error');
updateStatus();
});
});
// Disconnect PLC
document.getElementById('disconnect-btn').addEventListener('click', function () {
fetch('/api/plc/disconnect', { method: 'POST' })
.then(response => response.json())
.then(data => {
showMessage(data.message, data.success ? 'success' : 'error');
updateStatus();
});
});
// Toggle DB and Bit field visibility based on memory area selection
function toggleFields() {
const area = document.getElementById('var-area').value;
const dbField = document.getElementById('db-field');
const dbInput = document.getElementById('var-db');
const bitField = document.getElementById('bit-field');
const typeSelect = document.getElementById('var-type');
// Handle DB field
if (area === 'db') {
dbField.style.display = 'block';
dbInput.required = true;
} else {
dbField.style.display = 'none';
dbInput.required = false;
dbInput.value = 1; // Default value for non-DB areas
}
// Handle Bit field and data type restrictions
if (area === 'e' || area === 'a' || area === 'mb') {
bitField.style.display = 'block';
// For bit areas, force data type to bool
typeSelect.value = 'bool';
// Disable other data types for bit areas
Array.from(typeSelect.options).forEach(option => {
option.disabled = (option.value !== 'bool');
});
} else {
bitField.style.display = 'none';
// Re-enable all data types for non-bit areas
Array.from(typeSelect.options).forEach(option => {
option.disabled = false;
});
}
}
// Add variable to current dataset
document.getElementById('variable-form').addEventListener('submit', function (e) {
e.preventDefault();
if (!currentDatasetId) {
showMessage('No dataset selected. Please select a dataset first.', 'error');
return;
}
const area = document.getElementById('var-area').value;
const data = {
name: document.getElementById('var-name').value,
area: area,
db: area === 'db' ? parseInt(document.getElementById('var-db').value) : 1,
offset: parseInt(document.getElementById('var-offset').value),
type: document.getElementById('var-type').value,
streaming: false // Default to not streaming
};
// Add bit parameter for bit areas
if (area === 'e' || area === 'a' || area === 'mb') {
data.bit = parseInt(document.getElementById('var-bit').value);
}
fetch(`/api/datasets/${currentDatasetId}/variables`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
})
.then(response => response.json())
.then(data => {
showMessage(data.message, data.success ? 'success' : 'error');
if (data.success) {
document.getElementById('variable-form').reset();
loadDatasets(); // Reload to update counts
updateStatus();
}
});
});
// Toggle streaming for variable in current dataset
function toggleStreaming(varName, enabled) {
if (!currentDatasetId) {
showMessage('No dataset selected', 'error');
return;
}
fetch(`/api/datasets/${currentDatasetId}/variables/${varName}/streaming`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: enabled })
})
.then(response => response.json())
.then(data => {
showMessage(data.message, data.success ? 'success' : 'error');
updateStatus(); // Update streaming variable count
})
.catch(error => {
console.error('Error toggling streaming:', error);
showMessage('Error updating streaming setting', 'error');
});
}
// Refresh variable values from PLC
function refreshVariableValues() {
if (!currentDatasetId) {
showMessage('No dataset selected', 'error');
return;
}
const refreshBtn = document.getElementById('refresh-values-btn');
const lastRefreshTime = document.getElementById('last-refresh-time');
// Disable button and show loading state
refreshBtn.disabled = true;
refreshBtn.innerHTML = '⏳ Reading...';
fetch(`/api/datasets/${currentDatasetId}/variables/values`)
.then(response => response.json())
.then(data => {
if (data.success) {
// Update variable values in the table
Object.keys(data.values).forEach(varName => {
const valueCell = document.getElementById(`value-${varName}`);
if (valueCell) {
const value = data.values[varName];
valueCell.textContent = value;
// Color coding and tooltip based on value status
if (value === 'ERROR' || value === 'FORMAT_ERROR') {
valueCell.style.color = 'var(--pico-color-red-500)';
// Add tooltip with detailed error if available
const errorDetail = data.detailed_errors && data.detailed_errors[varName];
if (errorDetail) {
valueCell.title = `Error: ${errorDetail}`;
valueCell.style.cursor = 'help';
}
} else {
valueCell.style.color = 'var(--pico-color-green-600)';
valueCell.title = `Value: ${value}`;
valueCell.style.cursor = 'default';
}
}
});
// Update timestamp, stats, and source information
if (data.timestamp) {
const stats = data.stats;
const source = data.source || 'unknown';
const isCache = data.is_cached;
// Create source indicator
let sourceIcon = '';
let sourceText = '';
if (isCache) {
sourceIcon = '📊';
sourceText = 'from streaming cache';
} else if (source === 'plc_direct') {
sourceIcon = '🔗';
sourceText = 'direct PLC read';
} else {
sourceIcon = '❓';
sourceText = 'unknown source';
}
if (stats && stats.failed > 0) {
lastRefreshTime.innerHTML = `
Last refresh: ${data.timestamp}<br/>
<small style="color: var(--pico-color-amber-600);">
⚠️ ${stats.success}/${stats.total} variables read (${stats.failed} failed)
</small><br/>
<small style="color: var(--pico-muted-color);">
${sourceIcon} ${sourceText}
</small>
`;
} else {
lastRefreshTime.innerHTML = `
Last refresh: ${data.timestamp}<br/>
<small style="color: var(--pico-color-green-600);">
✅ All ${stats ? stats.success : 'N/A'} variables read successfully
</small><br/>
<small style="color: var(--pico-muted-color);">
${sourceIcon} ${sourceText}
</small>
`;
}
}
// Show appropriate message
if (data.warning) {
showMessage(data.warning, 'warning');
// Show detailed error information in console for debugging
if (data.detailed_errors && Object.keys(data.detailed_errors).length > 0) {
console.warn('Variable read errors:', data.detailed_errors);
}
} else {
showMessage(data.message, 'success');
}
} else {
// Complete failure case
showMessage(data.message, 'error');
clearVariableValues('ERROR');
// Update timestamp with error info
const source = data.source || 'unknown';
const isCache = data.is_cached;
let sourceIcon = '';
let sourceText = '';
if (isCache) {
sourceIcon = '📊';
sourceText = 'from streaming cache';
} else if (source === 'plc_direct') {
sourceIcon = '🔗';
sourceText = 'direct PLC read';
} else {
sourceIcon = '❓';
sourceText = 'unknown source';
}
lastRefreshTime.innerHTML = `
Last refresh attempt: ${data.timestamp}<br/>
<small style="color: var(--pico-color-red-500);">
❌ Failed to read any variables
</small><br/>
<small style="color: var(--pico-muted-color);">
${sourceIcon} ${sourceText}
</small>
`;
// Show detailed error information if available
if (data.detailed_errors && Object.keys(data.detailed_errors).length > 0) {
console.error('Detailed variable errors:', data.detailed_errors);
// Create a detailed error message for the user
const errorSummary = Object.keys(data.detailed_errors).map(varName =>
`${varName}: ${data.detailed_errors[varName]}`
).join('\n');
// Show in console and optionally as a detailed error dialog
console.error('Variable Error Details:\n' + errorSummary);
}
// Handle specific error types
if (data.error_type === 'connection_error') {
clearVariableValues('PLC OFFLINE');
} else if (data.error_type === 'all_failed') {
clearVariableValues('READ FAILED');
}
}
})
.catch(error => {
console.error('Error refreshing variable values:', error);
showMessage('Network error reading variable values from PLC', 'error');
clearVariableValues('COMM ERROR');
lastRefreshTime.innerHTML = `
Last refresh attempt: ${new Date().toLocaleString()}<br/>
<small style="color: var(--pico-color-red-500);">
❌ Network/Communication error
</small>
`;
})
.finally(() => {
// Re-enable button
refreshBtn.disabled = false;
refreshBtn.innerHTML = '🔄 Refresh Values';
});
}
// Clear all variable values and set status message
function clearVariableValues(statusMessage = '--') {
// Find all value cells and clear them
const valueCells = document.querySelectorAll('[id^="value-"]');
valueCells.forEach(cell => {
cell.textContent = statusMessage;
cell.style.color = 'var(--pico-muted-color)';
});
}
// Auto-refresh values when dataset changes (optional)
function autoRefreshOnDatasetChange() {
if (currentDatasetId) {
// Small delay to ensure table is loaded
setTimeout(() => {
refreshVariableValues();
}, 500);
}
}
// Diagnostic function for connection and variable issues
function diagnoseConnection() {
if (!currentDatasetId) {
showMessage('No dataset selected for diagnosis', 'error');
return;
}
const diagnoseBtn = document.getElementById('diagnose-btn');
const originalText = diagnoseBtn.innerHTML;
// Disable button and show diagnostic state
diagnoseBtn.disabled = true;
diagnoseBtn.innerHTML = '🔍 Diagnosing...';
// Create diagnostic report
let diagnosticReport = [];
diagnosticReport.push('=== PLC CONNECTION DIAGNOSTICS ===');
diagnosticReport.push(`Dataset: ${currentDatasetId}`);
diagnosticReport.push(`Timestamp: ${new Date().toLocaleString()}`);
diagnosticReport.push('');
// Step 1: Check PLC connection status
fetch('/api/status')
.then(response => response.json())
.then(statusData => {
diagnosticReport.push('1. PLC Connection Status:');
diagnosticReport.push(` Connected: ${statusData.plc_connected ? 'YES' : 'NO'}`);
diagnosticReport.push(` PLC IP: ${statusData.plc_config.ip}`);
diagnosticReport.push(` Rack: ${statusData.plc_config.rack}`);
diagnosticReport.push(` Slot: ${statusData.plc_config.slot}`);
diagnosticReport.push('');
if (!statusData.plc_connected) {
diagnosticReport.push(' ❌ PLC is not connected. Please check:');
diagnosticReport.push(' - Network connectivity to PLC');
diagnosticReport.push(' - PLC IP address, rack, and slot configuration');
diagnosticReport.push(' - PLC is powered on and operational');
diagnosticReport.push('');
showDiagnosticResults(diagnosticReport);
return;
}
// Step 2: Get dataset information
return fetch('/api/datasets')
.then(response => response.json())
.then(datasetData => {
const dataset = datasetData.datasets[currentDatasetId];
if (!dataset) {
diagnosticReport.push('2. Dataset Status:');
diagnosticReport.push(' ❌ Dataset not found');
showDiagnosticResults(diagnosticReport);
return;
}
diagnosticReport.push('2. Dataset Information:');
diagnosticReport.push(` Name: ${dataset.name}`);
diagnosticReport.push(` Variables: ${Object.keys(dataset.variables).length}`);
diagnosticReport.push(` Active: ${dataset.enabled ? 'YES' : 'NO'}`);
diagnosticReport.push('');
// Step 3: Test variable reading with diagnostics
diagnosticReport.push('3. Variable Reading Test:');
return fetch(`/api/datasets/${currentDatasetId}/variables/values`)
.then(response => response.json())
.then(valueData => {
if (valueData.success) {
const stats = valueData.stats || {};
diagnosticReport.push(` ✅ Success: ${stats.success || 0}/${stats.total || 0} variables read`);
if (stats.failed > 0) {
diagnosticReport.push(` ⚠️ Failed: ${stats.failed} variables had errors`);
diagnosticReport.push('');
diagnosticReport.push('4. Variable-Specific Errors:');
if (valueData.detailed_errors) {
Object.keys(valueData.detailed_errors).forEach(varName => {
diagnosticReport.push(` ${varName}: ${valueData.detailed_errors[varName]}`);
});
}
} else {
diagnosticReport.push(' ✅ All variables read successfully');
}
} else {
diagnosticReport.push(` ❌ Complete failure: ${valueData.message}`);
diagnosticReport.push('');
diagnosticReport.push('4. Detailed Error Information:');
if (valueData.detailed_errors) {
Object.keys(valueData.detailed_errors).forEach(varName => {
diagnosticReport.push(` ${varName}: ${valueData.detailed_errors[varName]}`);
});
}
diagnosticReport.push('');
diagnosticReport.push('5. Troubleshooting Suggestions:');
if (valueData.error_type === 'connection_error') {
diagnosticReport.push(' - Check PLC network connection');
diagnosticReport.push(' - Verify PLC is responding to network requests');
diagnosticReport.push(' - Check firewall settings');
} else if (valueData.error_type === 'all_failed') {
diagnosticReport.push(' - Verify variable memory addresses are correct');
diagnosticReport.push(' - Check if data blocks exist in PLC program');
diagnosticReport.push(' - Ensure variable types match PLC configuration');
}
}
showDiagnosticResults(diagnosticReport);
});
});
})
.catch(error => {
diagnosticReport.push('❌ Diagnostic failed with network error:');
diagnosticReport.push(` ${error.message}`);
diagnosticReport.push('');
diagnosticReport.push('Troubleshooting:');
diagnosticReport.push(' - Check web server connection');
diagnosticReport.push(' - Refresh the page and try again');
showDiagnosticResults(diagnosticReport);
})
.finally(() => {
// Re-enable button
diagnoseBtn.disabled = false;
diagnoseBtn.innerHTML = originalText;
});
}
// Show diagnostic results in console and as a message
function showDiagnosticResults(diagnosticReport) {
const reportText = diagnosticReport.join('\n');
// Log to console for detailed analysis
console.log(reportText);
// Show summary message to user
const errorCount = reportText.match(/❌/g)?.length || 0;
const warningCount = reportText.match(/⚠️/g)?.length || 0;
const successCount = reportText.match(/✅/g)?.length || 0;
let summaryMessage = 'Diagnosis completed. ';
if (errorCount > 0) {
summaryMessage += `${errorCount} errors found. `;
}
if (warningCount > 0) {
summaryMessage += `${warningCount} warnings found. `;
}
if (successCount > 0) {
summaryMessage += `${successCount} checks passed. `;
}
summaryMessage += 'Check browser console (F12) for detailed report.';
const messageType = errorCount > 0 ? 'error' : (warningCount > 0 ? 'warning' : 'success');
showMessage(summaryMessage, messageType);
}
// Load streaming variables status
function loadStreamingStatus() {
fetch('/api/variables/streaming')
.then(response => response.json())
.then(data => {
if (data.success) {
data.streaming_variables.forEach(varName => {
const checkbox = document.getElementById(`stream-${varName}`);
if (checkbox) {
checkbox.checked = true;
}
});
}
})
.catch(error => console.error('Error loading streaming status:', error));
}
// Remove variable from current dataset
function removeVariable(name) {
if (!currentDatasetId) {
showMessage('No dataset selected', 'error');
return;
}
if (confirm(`Are you sure you want to remove the variable "${name}" from this dataset?`)) {
fetch(`/api/datasets/${currentDatasetId}/variables/${name}`, { method: 'DELETE' })
.then(response => response.json())
.then(data => {
showMessage(data.message, data.success ? 'success' : 'error');
if (data.success) {
loadDatasets(); // Reload to update counts
updateStatus();
}
});
}
}
// Start streaming
document.getElementById('start-streaming-btn').addEventListener('click', function () {
fetch('/api/streaming/start', { method: 'POST' })
.then(response => response.json())
.then(data => {
showMessage(data.message, data.success ? 'success' : 'error');
updateStatus();
});
});
// Stop streaming
document.getElementById('stop-streaming-btn').addEventListener('click', function () {
fetch('/api/streaming/stop', { method: 'POST' })
.then(response => response.json())
.then(data => {
showMessage(data.message, data.success ? 'success' : 'error');
updateStatus();
});
});
// Status grid stop streaming button
document.getElementById('status-streaming-btn').addEventListener('click', function () {
fetch('/api/streaming/stop', { method: 'POST' })
.then(response => response.json())
.then(data => {
showMessage(data.message, data.success ? 'success' : 'error');
updateStatus();
});
});
// Update interval
document.getElementById('update-sampling-btn').addEventListener('click', function () {
const interval = parseFloat(document.getElementById('sampling-interval').value);
fetch('/api/sampling', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ interval: interval })
})
.then(response => response.json())
.then(data => {
showMessage(data.message, data.success ? 'success' : 'error');
});
});
// Application Events Log Functions
function formatTimestamp(isoString) {
const date = new Date(isoString);
return date.toLocaleString('es-ES', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
}
function getEventIcon(eventType) {
const icons = {
'plc_connection': '🔗',
'plc_connection_failed': '❌',
'plc_disconnection': '🔌',
'plc_disconnection_error': '⚠️',
'streaming_started': '▶️',
'streaming_stopped': '⏹️',
'streaming_error': '❌',
'csv_started': '💾',
'csv_stopped': '📁',
'csv_error': '❌',
'config_change': '⚙️',
'variable_added': '',
'variable_removed': '',
'application_started': '🚀'
};
return icons[eventType] || '📋';
}
function createLogEntry(event) {
const logEntry = document.createElement('div');
logEntry.className = `log-entry log-${event.level}`;
const hasDetails = event.details && Object.keys(event.details).length > 0;
logEntry.innerHTML = `
<div class="log-header">
<span>${getEventIcon(event.event_type)} ${event.event_type.replace(/_/g, ' ').toUpperCase()}</span>
<span class="log-timestamp">${formatTimestamp(event.timestamp)}</span>
</div>
<div class="log-message">${event.message}</div>
${hasDetails ? `<div class="log-details">${JSON.stringify(event.details, null, 2)}</div>` : ''}
`;
return logEntry;
}
function refreshEventLog() {
const limit = document.getElementById('log-limit').value;
fetch(`/api/events?limit=${limit}`)
.then(response => response.json())
.then(data => {
if (data.success) {
const logContainer = document.getElementById('events-log');
const logStats = document.getElementById('log-stats');
// Clear existing entries
logContainer.innerHTML = '';
// Update statistics
logStats.textContent = `Showing ${data.showing} of ${data.total_events} events`;
// Add events (reverse order to show newest first)
const events = data.events.reverse();
if (events.length === 0) {
logContainer.innerHTML = `
<div class="log-entry log-info">
<div class="log-header">
<span>📋 System</span>
<span class="log-timestamp">${new Date().toLocaleString('es-ES')}</span>
</div>
<div class="log-message">No events found</div>
</div>
`;
} else {
events.forEach(event => {
logContainer.appendChild(createLogEntry(event));
});
}
// Auto-scroll to top to show newest events
logContainer.scrollTop = 0;
} else {
console.error('Error loading events:', data.error);
showMessage('Error loading events log', 'error');
}
})
.catch(error => {
console.error('Error fetching events:', error);
showMessage('Error fetching events log', 'error');
});
}
function clearLogView() {
const logContainer = document.getElementById('events-log');
logContainer.innerHTML = `
<div class="log-entry log-info">
<div class="log-header">
<span>🧹 System</span>
<span class="log-timestamp">${new Date().toLocaleString('es-ES')}</span>
</div>
<div class="log-message">Log view cleared. Click refresh to reload events.</div>
</div>
`;
const logStats = document.getElementById('log-stats');
logStats.textContent = 'Log view cleared';
}
// Update status every 5 seconds
setInterval(updateStatus, 5000);
// Update event log every 10 seconds
setInterval(refreshEventLog, 10000);
// Initial update
updateStatus();
loadStreamingStatus();
refreshEventLog();
// Edit Variable Functions
let currentEditingVariable = null;
function editVariable(name) {
if (!currentDatasetId) {
showMessage('No dataset selected', 'error');
return;
}
currentEditingVariable = name;
// Get variable data from current dataset
const dataset = currentDatasets[currentDatasetId];
if (dataset && dataset.variables && dataset.variables[name]) {
const variable = dataset.variables[name];
const streamingVars = dataset.streaming_variables || [];
// Create variable object with the same structure as the API
const variableData = {
name: name,
area: variable.area,
db: variable.db,
offset: variable.offset,
type: variable.type,
bit: variable.bit,
streaming: streamingVars.includes(name)
};
populateEditForm(variableData);
document.getElementById('edit-modal').style.display = 'block';
} else {
showMessage('Variable not found in current dataset', 'error');
}
}
function populateEditForm(variable) {
document.getElementById('edit-var-name').value = variable.name;
document.getElementById('edit-var-area').value = variable.area;
document.getElementById('edit-var-offset').value = variable.offset;
document.getElementById('edit-var-type').value = variable.type;
if (variable.db) {
document.getElementById('edit-var-db').value = variable.db;
}
if (variable.bit !== undefined) {
document.getElementById('edit-var-bit').value = variable.bit;
}
// Update field visibility based on area
toggleEditFields();
}
function toggleEditFields() {
const area = document.getElementById('edit-var-area').value;
const dbField = document.getElementById('edit-db-field');
const dbInput = document.getElementById('edit-var-db');
const bitField = document.getElementById('edit-bit-field');
const typeSelect = document.getElementById('edit-var-type');
// Handle DB field
if (area === 'db') {
dbField.style.display = 'block';
dbInput.required = true;
} else {
dbField.style.display = 'none';
dbInput.required = false;
dbInput.value = 1; // Default value for non-DB areas
}
// Handle Bit field and data type restrictions
if (area === 'e' || area === 'a' || area === 'mb') {
bitField.style.display = 'block';
// For bit areas, force data type to bool
typeSelect.value = 'bool';
// Disable other data types for bit areas
Array.from(typeSelect.options).forEach(option => {
option.disabled = (option.value !== 'bool');
});
} else {
bitField.style.display = 'none';
// Re-enable all data types for non-bit areas
Array.from(typeSelect.options).forEach(option => {
option.disabled = false;
});
}
}
function closeEditModal() {
document.getElementById('edit-modal').style.display = 'none';
currentEditingVariable = null;
}
// Handle edit form submission
document.getElementById('edit-variable-form').addEventListener('submit', function (e) {
e.preventDefault();
if (!currentEditingVariable || !currentDatasetId) {
showMessage('No variable or dataset selected for editing', 'error');
return;
}
const area = document.getElementById('edit-var-area').value;
const newName = document.getElementById('edit-var-name').value;
// First remove the old variable
fetch(`/api/datasets/${currentDatasetId}/variables/${currentEditingVariable}`, {
method: 'DELETE'
})
.then(response => response.json())
.then(deleteResult => {
if (deleteResult.success) {
// Then add the updated variable
const data = {
name: newName,
area: area,
db: area === 'db' ? parseInt(document.getElementById('edit-var-db').value) : 1,
offset: parseInt(document.getElementById('edit-var-offset').value),
type: document.getElementById('edit-var-type').value,
streaming: false // Will be restored below if it was enabled
};
// Add bit parameter for bit areas
if (area === 'e' || area === 'a' || area === 'mb') {
data.bit = parseInt(document.getElementById('edit-var-bit').value);
}
return fetch(`/api/datasets/${currentDatasetId}/variables`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
} else {
throw new Error(deleteResult.message);
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
showMessage('Variable updated successfully', 'success');
closeEditModal();
loadDatasets();
updateStatus();
} else {
showMessage(data.message, 'error');
}
})
.catch(error => {
showMessage(`Error updating variable: ${error}`, 'error');
});
});
// Close modal when clicking outside of it
window.onclick = function (event) {
const editModal = document.getElementById('edit-modal');
const datasetModal = document.getElementById('dataset-modal');
if (event.target === editModal) {
closeEditModal();
}
if (event.target === datasetModal) {
datasetModal.style.display = 'none';
}
}
// Initialize field visibility on page load
toggleFields();
// ===== DATASET MANAGEMENT FUNCTIONS =====
// Load datasets on page load
loadDatasets();
// Dataset management variables
let currentDatasets = {};
let currentDatasetId = null;
// Load all datasets from API
function loadDatasets() {
fetch('/api/datasets')
.then(response => response.json())
.then(data => {
if (data.success) {
currentDatasets = data.datasets;
currentDatasetId = data.current_dataset_id;
updateDatasetSelector();
updateDatasetInfo();
}
})
.catch(error => {
console.error('Error loading datasets:', error);
showMessage('Error loading datasets', 'error');
});
}
// Update the dataset selector dropdown
function updateDatasetSelector() {
const selector = document.getElementById('dataset-selector');
selector.innerHTML = '<option value="">Select a dataset...</option>';
Object.keys(currentDatasets).forEach(datasetId => {
const dataset = currentDatasets[datasetId];
const option = document.createElement('option');
option.value = datasetId;
option.textContent = `${dataset.name} (${dataset.prefix})`;
if (datasetId === currentDatasetId) {
option.selected = true;
}
selector.appendChild(option);
});
}
// Update dataset information display
function updateDatasetInfo() {
const statusBar = document.getElementById('dataset-status-bar');
const variablesManagement = document.getElementById('variables-management');
const noDatasetMessage = document.getElementById('no-dataset-message');
if (currentDatasetId && currentDatasets[currentDatasetId]) {
const dataset = currentDatasets[currentDatasetId];
// Show dataset info in status bar
document.getElementById('dataset-name').textContent = dataset.name;
document.getElementById('dataset-prefix').textContent = dataset.prefix;
document.getElementById('dataset-sampling').textContent =
dataset.sampling_interval ? `${dataset.sampling_interval}s` : 'Global interval';
document.getElementById('dataset-var-count').textContent = Object.keys(dataset.variables).length;
document.getElementById('dataset-stream-count').textContent = dataset.streaming_variables.length;
// Update dataset status
const statusSpan = document.getElementById('dataset-status');
const isActive = dataset.enabled;
statusSpan.textContent = isActive ? '🟢 Active' : '⭕ Inactive';
statusSpan.className = `status-item ${isActive ? 'status-active' : 'status-inactive'}`;
// Update action buttons
document.getElementById('activate-dataset-btn').style.display = isActive ? 'none' : 'inline-block';
document.getElementById('deactivate-dataset-btn').style.display = isActive ? 'inline-block' : 'none';
// Show sections
statusBar.style.display = 'block';
variablesManagement.style.display = 'block';
noDatasetMessage.style.display = 'none';
// Load variables for this dataset
loadDatasetVariables(currentDatasetId);
} else {
statusBar.style.display = 'none';
variablesManagement.style.display = 'none';
noDatasetMessage.style.display = 'block';
}
}
// Load variables for a specific dataset
function loadDatasetVariables(datasetId) {
if (!datasetId || !currentDatasets[datasetId]) {
// Clear the table if no valid dataset
document.getElementById('variables-tbody').innerHTML = '';
return;
}
const dataset = currentDatasets[datasetId];
const variables = dataset.variables || {};
const streamingVars = dataset.streaming_variables || [];
const tbody = document.getElementById('variables-tbody');
// Clear existing rows
tbody.innerHTML = '';
// Add a row for each variable
Object.keys(variables).forEach(varName => {
const variable = variables[varName];
const row = document.createElement('tr');
// Format memory area display
let memoryAreaDisplay = '';
if (variable.area === 'db') {
memoryAreaDisplay = `DB${variable.db || 'N/A'}.${variable.offset}`;
} else if (variable.area === 'mw' || variable.area === 'm') {
memoryAreaDisplay = `MW${variable.offset}`;
} else if (variable.area === 'pew' || variable.area === 'pe') {
memoryAreaDisplay = `PEW${variable.offset}`;
} else if (variable.area === 'paw' || variable.area === 'pa') {
memoryAreaDisplay = `PAW${variable.offset}`;
} else if (variable.area === 'e') {
memoryAreaDisplay = `E${variable.offset}.${variable.bit}`;
} else if (variable.area === 'a') {
memoryAreaDisplay = `A${variable.offset}.${variable.bit}`;
} else if (variable.area === 'mb') {
memoryAreaDisplay = `M${variable.offset}.${variable.bit}`;
} else {
memoryAreaDisplay = `${variable.area.toUpperCase()}${variable.offset}`;
}
// Check if variable is in streaming list
const isStreaming = streamingVars.includes(varName);
row.innerHTML = `
<td>${varName}</td>
<td>${memoryAreaDisplay}</td>
<td>${variable.offset}</td>
<td>${variable.type.toUpperCase()}</td>
<td id="value-${varName}" style="font-family: monospace; font-weight: bold; color: var(--pico-muted-color);">
--
</td>
<td>
<label>
<input type="checkbox" id="stream-${varName}" role="switch" ${isStreaming ? 'checked' : ''}
onchange="toggleStreaming('${varName}', this.checked)">
Enable
</label>
</td>
<td>
<button class="outline" onclick="editVariable('${varName}')">✏️ Edit</button>
<button class="secondary" onclick="removeVariable('${varName}')">🗑️ Remove</button>
</td>
`;
tbody.appendChild(row);
});
}
// Dataset selector change handler
document.getElementById('dataset-selector').addEventListener('change', function () {
const selectedDatasetId = this.value;
if (selectedDatasetId) {
// Set as current dataset
fetch('/api/datasets/current', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dataset_id: selectedDatasetId })
})
.then(response => response.json())
.then(data => {
if (data.success) {
currentDatasetId = selectedDatasetId;
// Reload datasets to get fresh data, then update info
loadDatasets();
// Auto-refresh values for the new dataset
autoRefreshOnDatasetChange();
} else {
showMessage(data.message, 'error');
}
})
.catch(error => {
showMessage('Error setting current dataset', 'error');
});
} else {
currentDatasetId = null;
updateDatasetInfo();
// Clear values when no dataset is selected
clearVariableValues();
}
});
// New dataset button
document.getElementById('new-dataset-btn').addEventListener('click', function () {
document.getElementById('dataset-modal').style.display = 'block';
});
// Close dataset modal
document.getElementById('close-dataset-modal').addEventListener('click', function () {
document.getElementById('dataset-modal').style.display = 'none';
});
document.getElementById('cancel-dataset-btn').addEventListener('click', function () {
document.getElementById('dataset-modal').style.display = 'none';
});
// Create new dataset
document.getElementById('dataset-form').addEventListener('submit', function (e) {
e.preventDefault();
const data = {
dataset_id: document.getElementById('dataset-id').value.trim(),
name: document.getElementById('dataset-name-input').value.trim(),
prefix: document.getElementById('dataset-prefix-input').value.trim(),
sampling_interval: document.getElementById('dataset-sampling-input').value || null
};
if (data.sampling_interval) {
data.sampling_interval = parseFloat(data.sampling_interval);
}
fetch('/api/datasets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
})
.then(response => response.json())
.then(data => {
if (data.success) {
showMessage(data.message, 'success');
document.getElementById('dataset-modal').style.display = 'none';
document.getElementById('dataset-form').reset();
loadDatasets();
} else {
showMessage(data.message, 'error');
}
})
.catch(error => {
showMessage('Error creating dataset', 'error');
});
});
// Delete dataset button
document.getElementById('delete-dataset-btn').addEventListener('click', function () {
if (!currentDatasetId) {
showMessage('No dataset selected', 'error');
return;
}
const dataset = currentDatasets[currentDatasetId];
if (confirm(`Are you sure you want to delete dataset "${dataset.name}"?`)) {
fetch(`/api/datasets/${currentDatasetId}`, {
method: 'DELETE'
})
.then(response => response.json())
.then(data => {
if (data.success) {
showMessage(data.message, 'success');
loadDatasets();
} else {
showMessage(data.message, 'error');
}
})
.catch(error => {
showMessage('Error deleting dataset', 'error');
});
}
});
// Activate dataset button
document.getElementById('activate-dataset-btn').addEventListener('click', function () {
if (!currentDatasetId) return;
fetch(`/api/datasets/${currentDatasetId}/activate`, {
method: 'POST'
})
.then(response => response.json())
.then(data => {
if (data.success) {
showMessage(data.message, 'success');
loadDatasets();
} else {
showMessage(data.message, 'error');
}
})
.catch(error => {
showMessage('Error activating dataset', 'error');
});
});
// Deactivate dataset button
document.getElementById('deactivate-dataset-btn').addEventListener('click', function () {
if (!currentDatasetId) return;
fetch(`/api/datasets/${currentDatasetId}/deactivate`, {
method: 'POST'
})
.then(response => response.json())
.then(data => {
if (data.success) {
showMessage(data.message, 'success');
loadDatasets();
} else {
showMessage(data.message, 'error');
}
})
.catch(error => {
showMessage('Error deactivating dataset', 'error');
});
});
// CSV Configuration Functions
function loadCsvConfig() {
fetch('/api/csv/config')
.then(response => response.json())
.then(data => {
if (data.success) {
const config = data.config;
// Update display elements
document.getElementById('csv-directory-path').textContent = config.current_directory || 'N/A';
document.getElementById('csv-rotation-enabled').textContent = config.rotation_enabled ? '✅ Yes' : '❌ No';
document.getElementById('csv-max-size').textContent = config.max_size_mb ? `${config.max_size_mb} MB` : 'No limit';
document.getElementById('csv-max-days').textContent = config.max_days ? `${config.max_days} days` : 'No limit';
document.getElementById('csv-max-hours').textContent = config.max_hours ? `${config.max_hours} hours` : 'No limit';
document.getElementById('csv-cleanup-interval').textContent = `${config.cleanup_interval_hours} hours`;
// Update form fields
document.getElementById('records-directory').value = config.records_directory || '';
document.getElementById('rotation-enabled').checked = config.rotation_enabled || false;
document.getElementById('max-size-mb').value = config.max_size_mb || '';
document.getElementById('max-days').value = config.max_days || '';
document.getElementById('max-hours').value = config.max_hours || '';
document.getElementById('cleanup-interval').value = config.cleanup_interval_hours || 24;
// Load directory information
loadCsvDirectoryInfo();
} else {
showMessage('Error loading CSV configuration: ' + data.message, 'error');
}
})
.catch(error => {
showMessage('Error loading CSV configuration', 'error');
});
}
function loadCsvDirectoryInfo() {
fetch('/api/csv/directory/info')
.then(response => response.json())
.then(data => {
if (data.success) {
const info = data.info;
const statsDiv = document.getElementById('directory-stats');
let html = `
<div class="stat-item">
<strong>📁 Directory:</strong>
<span>${info.base_directory}</span>
</div>
<div class="stat-item">
<strong>📊 Total Files:</strong>
<span>${info.total_files}</span>
</div>
<div class="stat-item">
<strong>💾 Total Size:</strong>
<span>${info.total_size_mb} MB</span>
</div>
`;
if (info.oldest_file) {
html += `
<div class="stat-item">
<strong>📅 Oldest File:</strong>
<span>${new Date(info.oldest_file).toLocaleString()}</span>
</div>
`;
}
if (info.newest_file) {
html += `
<div class="stat-item">
<strong>🆕 Newest File:</strong>
<span>${new Date(info.newest_file).toLocaleString()}</span>
</div>
`;
}
if (info.day_folders && info.day_folders.length > 0) {
html += '<h4>📂 Day Folders:</h4>';
info.day_folders.forEach(folder => {
html += `
<div class="day-folder-item">
<span><strong>${folder.name}</strong></span>
<span>${folder.files} files, ${folder.size_mb} MB</span>
</div>
`;
});
}
statsDiv.innerHTML = html;
}
})
.catch(error => {
document.getElementById('directory-stats').innerHTML = '<p>Error loading directory information</p>';
});
}
function triggerManualCleanup() {
if (!confirm('¿Estás seguro de que quieres ejecutar la limpieza manual? Esto eliminará archivos antiguos según la configuración actual.')) {
return;
}
fetch('/api/csv/cleanup', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
showMessage('Limpieza ejecutada correctamente', 'success');
loadCsvDirectoryInfo(); // Reload directory info
} else {
showMessage('Error en la limpieza: ' + data.message, 'error');
}
})
.catch(error => {
showMessage('Error ejecutando la limpieza', 'error');
});
}
// CSV Configuration form handler
document.addEventListener('DOMContentLoaded', function () {
// Load CSV configuration on page load
loadCsvConfig();
// Handle CSV config form submission
document.getElementById('csv-config-form').addEventListener('submit', function (e) {
e.preventDefault();
const formData = new FormData(e.target);
const configData = {};
// Convert form data to object, handling empty values
for (let [key, value] of formData.entries()) {
if (key === 'rotation_enabled') {
configData[key] = document.getElementById('rotation-enabled').checked;
} else if (value.trim() === '') {
configData[key] = null;
} else if (key.includes('max_') || key.includes('cleanup_interval')) {
configData[key] = parseFloat(value) || null;
} else {
configData[key] = value.trim();
}
}
fetch('/api/csv/config', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(configData)
})
.then(response => response.json())
.then(data => {
if (data.success) {
showMessage('Configuración CSV actualizada correctamente', 'success');
loadCsvConfig(); // Reload to show updated values
} else {
showMessage('Error actualizando configuración CSV: ' + data.message, 'error');
}
})
.catch(error => {
showMessage('Error actualizando configuración CSV', 'error');
});
});
});
</script>
</body>
</html>