Folder reorganize 1
This commit is contained in:
704
trends/live/autochart.php
Normal file
704
trends/live/autochart.php
Normal file
@@ -0,0 +1,704 @@
|
||||
<?php // phpcs:ignoreFile
|
||||
|
||||
require __DIR__ . '/../../session.php';
|
||||
require __DIR__ . '/../../userAccess.php';
|
||||
|
||||
$pageTitle = 'Single Chart Trending';
|
||||
$pageSubtitle = 'Monitor tags in a 60 minute window';
|
||||
$pageDescription = 'Launch the single chart beta to track critical process data in real time.';
|
||||
$assetBasePath = '../../';
|
||||
$layoutWithoutSidebar = true;
|
||||
$layoutReturnUrl = null;
|
||||
$layoutCloseWindowLabel = 'Close chart';
|
||||
|
||||
if (!function_exists('trendsSanitizePresetTag')) {
|
||||
/**
|
||||
* Normalize preset tag values supplied through the query string.
|
||||
*
|
||||
* @param mixed $value Raw query parameter value.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
function trendsSanitizePresetTag($value): string
|
||||
{
|
||||
if ($value === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$tag = trim((string) $value);
|
||||
if ($tag === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return substr($tag, 0, 255);
|
||||
}
|
||||
}
|
||||
|
||||
$presetPrimaryTag = trendsSanitizePresetTag($_GET['primary'] ?? null);
|
||||
$presetSecondaryTag = trendsSanitizePresetTag($_GET['secondary'] ?? null);
|
||||
$presetPrimaryLabel = trendsSanitizePresetTag($_GET['primary_label'] ?? null);
|
||||
$presetSecondaryLabel = trendsSanitizePresetTag($_GET['secondary_label'] ?? null);
|
||||
|
||||
$presetAutoStart = false;
|
||||
if (isset($_GET['autostart'])) {
|
||||
$autoStartValue = strtolower((string) $_GET['autostart']);
|
||||
$presetAutoStart = in_array($autoStartValue, ['1', 'true', 'yes', 'on'], true);
|
||||
}
|
||||
|
||||
$allowedIntervals = [1000, 5000, 10000, 30000];
|
||||
$presetUpdateInterval = null;
|
||||
if (isset($_GET['interval'])) {
|
||||
$candidateInterval = (int) $_GET['interval'];
|
||||
if (in_array($candidateInterval, $allowedIntervals, true)) {
|
||||
$presetUpdateInterval = $candidateInterval;
|
||||
}
|
||||
}
|
||||
|
||||
$presetConfig = [
|
||||
'primary' => $presetPrimaryTag,
|
||||
'secondary' => $presetSecondaryTag,
|
||||
'autoStart' => $presetAutoStart,
|
||||
'updateInterval' => $presetUpdateInterval,
|
||||
'primaryLabel' => $presetPrimaryLabel,
|
||||
'secondaryLabel' => $presetSecondaryLabel,
|
||||
];
|
||||
|
||||
require __DIR__ . '/../../includes/layout/header.php';
|
||||
?>
|
||||
|
||||
<div class="app-content">
|
||||
<section class="data-panel trend-panel">
|
||||
<header class="trend-panel__header">
|
||||
<div>
|
||||
<h2 id="chartTitle">Real-time process trend</h2>
|
||||
<p>This chart automatically trends the tag supplied via the link—adjust the refresh rate below and hit Start when you're ready.</p>
|
||||
</div>
|
||||
<span class="trend-status trend-status--stopped" id="chartStatus">STOPPED</span>
|
||||
</header>
|
||||
|
||||
<div class="trend-control-grid">
|
||||
<div class="trend-control">
|
||||
<label>Primary tag</label>
|
||||
<div id="primaryTagDisplay">--</div>
|
||||
</div>
|
||||
<div class="trend-control" id="secondaryTagContainer" style="display: none;">
|
||||
<label>Secondary tag</label>
|
||||
<div id="secondaryTagDisplay">--</div>
|
||||
</div>
|
||||
<div class="trend-control">
|
||||
<label for="updateInterval">Update interval</label>
|
||||
<select id="updateInterval">
|
||||
<option value="1000">Every 1 second</option>
|
||||
<option value="5000" selected>Every 5 seconds</option>
|
||||
<option value="10000">Every 10 seconds</option>
|
||||
<option value="30000">Every 30 seconds</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="trend-control">
|
||||
<label>Time window</label>
|
||||
<div class="time-window-display">Last 60 minutes (sliding)</div>
|
||||
</div>
|
||||
<div class="trend-control">
|
||||
<label for="startBtn">Controls</label>
|
||||
<div class="trend-control__actions">
|
||||
<button type="button" class="button button--success" id="startBtn" onclick="startRealTime()">Start</button>
|
||||
<button type="button" class="button button--danger" id="stopBtn" onclick="stopRealTime()" disabled>Stop</button>
|
||||
<button type="button" class="button button--ghost" onclick="clearChart()">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="status" class="trend-connection trend-connection--disconnected">
|
||||
Disconnected — Click Start to begin trending the configured tag
|
||||
</div>
|
||||
|
||||
<div class="trend-stat-grid">
|
||||
<div class="trend-stat">
|
||||
<span id="currentValue1" class="trend-stat__value">--</span>
|
||||
<span class="trend-stat__label" id="tag1Label">Primary current</span>
|
||||
</div>
|
||||
<div class="trend-stat">
|
||||
<span id="minValue1" class="trend-stat__value">--</span>
|
||||
<span class="trend-stat__label">Primary min</span>
|
||||
</div>
|
||||
<div class="trend-stat">
|
||||
<span id="maxValue1" class="trend-stat__value">--</span>
|
||||
<span class="trend-stat__label">Primary max</span>
|
||||
</div>
|
||||
<div class="trend-stat">
|
||||
<span id="dataPoints" class="trend-stat__value">0</span>
|
||||
<span class="trend-stat__label">Total points</span>
|
||||
</div>
|
||||
<div class="trend-stat">
|
||||
<span id="lastUpdate" class="trend-stat__value">--</span>
|
||||
<span class="trend-stat__label">Last update</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="trend-chart">
|
||||
<canvas id="realtimeChart"></canvas>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/moment@2.29.4/moment.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-moment@1.0.1/dist/chartjs-adapter-moment.min.js"></script>
|
||||
|
||||
<script>
|
||||
const presetConfig = <?php echo json_encode($presetConfig, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); ?>;
|
||||
const LATENCY_PREFIX = 'latency:';
|
||||
let chart = null;
|
||||
let updateTimer = null;
|
||||
let isRunning = false;
|
||||
let lastTimestamp1 = null;
|
||||
let lastTimestamp2 = null;
|
||||
let chartData1 = [];
|
||||
let chartData2 = [];
|
||||
let timeWindowMinutes = 60;
|
||||
let primaryTag = '';
|
||||
let secondaryTag = '';
|
||||
let primaryLabel = '';
|
||||
let secondaryLabel = '';
|
||||
let presetApplied = false;
|
||||
|
||||
function applyPresetConfiguration() {
|
||||
if (presetApplied) {
|
||||
return;
|
||||
}
|
||||
|
||||
primaryTag = presetConfig.primary || '';
|
||||
secondaryTag = presetConfig.secondary || '';
|
||||
primaryLabel = presetConfig.primaryLabel || primaryTag;
|
||||
secondaryLabel = presetConfig.secondaryLabel || secondaryTag;
|
||||
|
||||
const primaryDisplay = document.getElementById('primaryTagDisplay');
|
||||
const secondaryDisplay = document.getElementById('secondaryTagDisplay');
|
||||
const secondaryContainer = document.getElementById('secondaryTagContainer');
|
||||
const intervalSelect = document.getElementById('updateInterval');
|
||||
const startButton = document.getElementById('startBtn');
|
||||
|
||||
if (primaryDisplay) {
|
||||
primaryDisplay.textContent = primaryLabel || primaryTag || '--';
|
||||
}
|
||||
|
||||
if (secondaryDisplay && secondaryContainer) {
|
||||
if (secondaryTag) {
|
||||
secondaryDisplay.textContent = secondaryLabel || secondaryTag;
|
||||
secondaryContainer.style.display = '';
|
||||
} else {
|
||||
secondaryDisplay.textContent = '--';
|
||||
secondaryContainer.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
if (intervalSelect && presetConfig.updateInterval) {
|
||||
intervalSelect.value = String(presetConfig.updateInterval);
|
||||
}
|
||||
|
||||
if (startButton) {
|
||||
startButton.disabled = !primaryTag;
|
||||
}
|
||||
|
||||
updateChartTitle();
|
||||
|
||||
if (!primaryTag) {
|
||||
updateStatus('disconnected', 'No primary tag supplied. Append ?primary=TAG to the link.');
|
||||
}
|
||||
|
||||
if (presetConfig.autoStart && primaryTag) {
|
||||
setTimeout(() => {
|
||||
if (!isRunning) {
|
||||
startRealTime();
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
|
||||
presetApplied = true;
|
||||
}
|
||||
|
||||
// Initialize Chart.js with dual datasets
|
||||
function initChart() {
|
||||
const ctx = document.getElementById('realtimeChart').getContext('2d');
|
||||
|
||||
chart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
datasets: [{
|
||||
label: 'Primary Tag',
|
||||
data: [],
|
||||
borderColor: '#3498db',
|
||||
backgroundColor: 'rgba(52, 152, 219, 0.1)',
|
||||
borderWidth: 2,
|
||||
fill: false,
|
||||
tension: 0.1,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 0,
|
||||
hitRadius: 0,
|
||||
yAxisID: 'y1'
|
||||
}, {
|
||||
label: 'Secondary Tag',
|
||||
data: [],
|
||||
borderColor: '#27ae60',
|
||||
backgroundColor: 'rgba(39, 174, 96, 0.1)',
|
||||
borderWidth: 2,
|
||||
fill: false,
|
||||
tension: 0.1,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 0,
|
||||
hitRadius: 0,
|
||||
yAxisID: 'y2',
|
||||
hidden: true
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: {
|
||||
duration: 300
|
||||
},
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'index'
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: 'time',
|
||||
time: {
|
||||
unit: 'minute',
|
||||
displayFormats: {
|
||||
second: 'HH:mm:ss',
|
||||
minute: 'HH:mm',
|
||||
hour: 'HH:mm'
|
||||
}
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Time (Last 60 Minutes)',
|
||||
font: { size: 12, weight: 'bold' }
|
||||
},
|
||||
min: function() {
|
||||
return moment().subtract(timeWindowMinutes, 'minutes').toDate();
|
||||
},
|
||||
max: function() {
|
||||
return moment().toDate();
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(0,0,0,0.1)'
|
||||
}
|
||||
},
|
||||
y1: {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'left',
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Primary Tag Value',
|
||||
color: '#3498db',
|
||||
font: { size: 12, weight: 'bold' }
|
||||
},
|
||||
grid: {
|
||||
drawOnChartArea: true,
|
||||
color: 'rgba(52, 152, 219, 0.1)'
|
||||
},
|
||||
ticks: {
|
||||
color: '#3498db',
|
||||
font: { weight: 'bold' }
|
||||
}
|
||||
},
|
||||
y2: {
|
||||
type: 'linear',
|
||||
display: false,
|
||||
position: 'right',
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Secondary Tag Value',
|
||||
color: '#27ae60',
|
||||
font: { size: 12, weight: 'bold' }
|
||||
},
|
||||
grid: {
|
||||
drawOnChartArea: false,
|
||||
},
|
||||
ticks: {
|
||||
color: '#27ae60',
|
||||
font: { weight: 'bold' }
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'top',
|
||||
labels: {
|
||||
font: { size: 12, weight: 'bold' },
|
||||
usePointStyle: true,
|
||||
padding: 20
|
||||
},
|
||||
onClick: function(e, legendItem, legend) {
|
||||
const index = legendItem.datasetIndex;
|
||||
const ci = legend.chart;
|
||||
const dataset = ci.data.datasets[index];
|
||||
|
||||
dataset.hidden = !dataset.hidden;
|
||||
|
||||
if (index === 1) {
|
||||
ci.options.scales.y2.display = !dataset.hidden;
|
||||
}
|
||||
|
||||
ci.update();
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(44, 62, 80, 0.9)',
|
||||
titleColor: '#3498db',
|
||||
bodyColor: '#ecf0f1',
|
||||
borderColor: '#3498db',
|
||||
borderWidth: 1,
|
||||
titleFont: { size: 13, weight: 'bold' },
|
||||
bodyFont: { size: 12 },
|
||||
callbacks: {
|
||||
title: function(context) {
|
||||
return moment(context[0].parsed.x).format('YYYY-MM-DD HH:mm:ss');
|
||||
},
|
||||
afterTitle: function(context) {
|
||||
const now = moment();
|
||||
const pointTime = moment(context[0].parsed.x);
|
||||
const secondsAgo = now.diff(pointTime, 'seconds');
|
||||
return `(${secondsAgo} seconds ago)`;
|
||||
},
|
||||
label: function(context) {
|
||||
const datasetLabel = context.dataset.label;
|
||||
const rawValue = context.parsed.y;
|
||||
if (rawValue === null || Number.isNaN(rawValue)) {
|
||||
return `${datasetLabel}: --`;
|
||||
}
|
||||
const value = Number(rawValue).toFixed(2);
|
||||
return `${datasetLabel}: ${value}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}); // This closing brace and parenthesis were missing!
|
||||
}
|
||||
|
||||
// Fetch data for one or both tags
|
||||
async function fetchTagData(tagName, datasetIndex) {
|
||||
if (!tagName) return null;
|
||||
|
||||
try {
|
||||
if (tagName.toLowerCase().startsWith(LATENCY_PREFIX)) {
|
||||
return await fetchLatencyData(tagName);
|
||||
}
|
||||
|
||||
const endTime = moment().format('YYYY-MM-DD HH:mm:ss');
|
||||
const startTime = moment().subtract(timeWindowMinutes, 'minutes').format('YYYY-MM-DD HH:mm:ss');
|
||||
|
||||
let url = `realtime_data.php?tag=${encodeURIComponent(tagName)}&limit=1500&start_time=${encodeURIComponent(startTime)}&end_time=${encodeURIComponent(endTime)}`;
|
||||
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.data && data.data.length > 0) {
|
||||
const formattedData = data.data.map(point => ({
|
||||
x: new Date(point.x),
|
||||
y: point.y
|
||||
}));
|
||||
|
||||
const cutoffTime = moment().subtract(timeWindowMinutes, 'minutes').toDate();
|
||||
return formattedData.filter(point => point.x >= cutoffTime);
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error(`Error fetching data for ${tagName}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchLatencyData(tagToken) {
|
||||
const deviceId = tagToken.substring(LATENCY_PREFIX.length);
|
||||
if (!deviceId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
device_id: deviceId,
|
||||
minutes: String(timeWindowMinutes),
|
||||
limit: '2000'
|
||||
});
|
||||
|
||||
const response = await fetch(`../../monitoring/latency_data.php?${params.toString()}`, {
|
||||
cache: 'no-store'
|
||||
});
|
||||
const payload = await response.json();
|
||||
|
||||
if (payload.success && Array.isArray(payload.data)) {
|
||||
return payload.data.map(point => ({
|
||||
x: new Date(point.x),
|
||||
y: point.y !== null ? Number(point.y) : null,
|
||||
status: point.status || null
|
||||
}));
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('Latency data fetch error:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Modified fetchData for dual tags
|
||||
async function fetchData() {
|
||||
const tagName1 = primaryTag;
|
||||
const tagName2 = secondaryTag;
|
||||
|
||||
if (!tagName1) {
|
||||
updateStatus('disconnected', 'No primary tag supplied. Append ?primary=TAG to the link.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data1 = await fetchTagData(tagName1, 0);
|
||||
|
||||
if (data1 !== null) {
|
||||
chartData1 = data1;
|
||||
chart.data.datasets[0].data = chartData1;
|
||||
chart.data.datasets[0].label = primaryLabel || tagName1;
|
||||
}
|
||||
|
||||
if (tagName2 && tagName2 !== tagName1) {
|
||||
const data2 = await fetchTagData(tagName2, 1);
|
||||
|
||||
if (data2 !== null) {
|
||||
chartData2 = data2;
|
||||
chart.data.datasets[1].data = chartData2;
|
||||
chart.data.datasets[1].label = secondaryLabel || tagName2;
|
||||
chart.data.datasets[1].hidden = false;
|
||||
chart.options.scales.y2.display = true;
|
||||
}
|
||||
} else {
|
||||
chartData2 = [];
|
||||
chart.data.datasets[1].data = [];
|
||||
chart.data.datasets[1].hidden = true;
|
||||
chart.options.scales.y2.display = false;
|
||||
}
|
||||
|
||||
chart.options.scales.x.min = moment().subtract(timeWindowMinutes, 'minutes').toDate();
|
||||
chart.options.scales.x.max = moment().toDate();
|
||||
|
||||
const isLatencyPrimary = tagName1.toLowerCase().startsWith(LATENCY_PREFIX);
|
||||
chart.options.scales.y1.title.text = isLatencyPrimary ? 'Latency (ms)' : 'Primary Tag Value';
|
||||
|
||||
chart.update('active');
|
||||
|
||||
updateStatus('connected', `Connected - Last update: ${new Date().toLocaleTimeString()}`);
|
||||
updateStatistics();
|
||||
updateChartTitle();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Fetch error:', error);
|
||||
updateStatus('disconnected', `Connection error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Update chart title
|
||||
function updateChartTitle() {
|
||||
let title = 'Real-Time Process Trend';
|
||||
if (primaryTag) {
|
||||
title = primaryLabel || primaryTag;
|
||||
if (secondaryTag) {
|
||||
const secondaryDisplayLabel = secondaryLabel || secondaryTag;
|
||||
title += ` & ${secondaryDisplayLabel}`;
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('chartTitle').textContent = title;
|
||||
}
|
||||
|
||||
// Enhanced statistics for dual tags
|
||||
function updateStatistics() {
|
||||
const tag1LabelEl = document.getElementById('tag1Label');
|
||||
if (tag1LabelEl) {
|
||||
const label = primaryLabel || primaryTag;
|
||||
tag1LabelEl.textContent = label ? `${label} Current` : 'Primary Current';
|
||||
}
|
||||
|
||||
const currentValue1El = document.getElementById('currentValue1');
|
||||
const minValue1El = document.getElementById('minValue1');
|
||||
const maxValue1El = document.getElementById('maxValue1');
|
||||
|
||||
const numericValues1 = chartData1
|
||||
.map(point => point.y)
|
||||
.filter(value => typeof value === 'number' && Number.isFinite(value));
|
||||
const lastPoint1 = chartData1.length > 0 ? chartData1[chartData1.length - 1] : null;
|
||||
|
||||
if (currentValue1El) {
|
||||
if (lastPoint1) {
|
||||
if (lastPoint1.status && lastPoint1.status !== 'up') {
|
||||
currentValue1El.textContent = lastPoint1.status.toUpperCase();
|
||||
} else if (typeof lastPoint1.y === 'number' && Number.isFinite(lastPoint1.y)) {
|
||||
currentValue1El.textContent = lastPoint1.y.toFixed(2);
|
||||
} else {
|
||||
currentValue1El.textContent = '--';
|
||||
}
|
||||
} else {
|
||||
currentValue1El.textContent = '--';
|
||||
}
|
||||
}
|
||||
|
||||
if (minValue1El) {
|
||||
minValue1El.textContent = numericValues1.length > 0
|
||||
? Math.min(...numericValues1).toFixed(2)
|
||||
: '--';
|
||||
}
|
||||
|
||||
if (maxValue1El) {
|
||||
maxValue1El.textContent = numericValues1.length > 0
|
||||
? Math.max(...numericValues1).toFixed(2)
|
||||
: '--';
|
||||
}
|
||||
|
||||
const totalPoints = chartData1.length + chartData2.length;
|
||||
const dataPointsEl = document.getElementById('dataPoints');
|
||||
if (dataPointsEl) {
|
||||
dataPointsEl.textContent = totalPoints;
|
||||
}
|
||||
|
||||
const lastUpdateEl = document.getElementById('lastUpdate');
|
||||
if (lastUpdateEl) {
|
||||
lastUpdateEl.textContent = moment().format('HH:mm:ss');
|
||||
}
|
||||
}
|
||||
|
||||
// Update status display
|
||||
function updateStatus(type, message) {
|
||||
const status = document.getElementById('status');
|
||||
const chartStatus = document.getElementById('chartStatus');
|
||||
const stateClass = type === 'connected' ? 'connected' : 'disconnected';
|
||||
|
||||
status.className = `trend-connection trend-connection--${stateClass}`;
|
||||
status.textContent = message;
|
||||
|
||||
if (stateClass === 'connected') {
|
||||
chartStatus.className = 'trend-status trend-status--running';
|
||||
chartStatus.textContent = 'RUNNING';
|
||||
} else {
|
||||
chartStatus.className = 'trend-status trend-status--stopped';
|
||||
chartStatus.textContent = 'STOPPED';
|
||||
}
|
||||
}
|
||||
|
||||
// Start real-time updates
|
||||
function startRealTime() {
|
||||
if (!primaryTag) {
|
||||
alert('No primary tag configured for this chart.');
|
||||
return;
|
||||
}
|
||||
|
||||
isRunning = true;
|
||||
lastTimestamp1 = null;
|
||||
lastTimestamp2 = null;
|
||||
|
||||
const intervalSelect = document.getElementById('updateInterval');
|
||||
const interval = intervalSelect ? parseInt(intervalSelect.value, 10) : 5000;
|
||||
|
||||
document.getElementById('startBtn').disabled = true;
|
||||
document.getElementById('stopBtn').disabled = false;
|
||||
|
||||
fetchData();
|
||||
|
||||
updateTimer = setInterval(() => {
|
||||
fetchData();
|
||||
}, interval);
|
||||
|
||||
const primaryDisplayName = primaryLabel || primaryTag || 'Primary';
|
||||
const secondaryDisplayName = secondaryLabel || secondaryTag;
|
||||
|
||||
const statusMessage = secondaryTag ?
|
||||
`Trending ${primaryDisplayName} & ${secondaryDisplayName} - Update every ${interval/1000}s` :
|
||||
`Trending ${primaryDisplayName} - Update every ${interval/1000}s`;
|
||||
|
||||
updateStatus('connected', statusMessage);
|
||||
}
|
||||
|
||||
// Stop real-time updates
|
||||
function stopRealTime() {
|
||||
isRunning = false;
|
||||
|
||||
if (updateTimer) {
|
||||
clearInterval(updateTimer);
|
||||
updateTimer = null;
|
||||
}
|
||||
|
||||
document.getElementById('startBtn').disabled = !primaryTag;
|
||||
document.getElementById('stopBtn').disabled = true;
|
||||
|
||||
updateStatus('disconnected', 'Real-time updates stopped - Click Start to resume');
|
||||
}
|
||||
|
||||
// Clear chart data
|
||||
function clearChart() {
|
||||
chartData1 = [];
|
||||
chartData2 = [];
|
||||
lastTimestamp1 = null;
|
||||
lastTimestamp2 = null;
|
||||
|
||||
if (chart) {
|
||||
chart.data.datasets[0].data = [];
|
||||
chart.data.datasets[1].data = [];
|
||||
chart.data.datasets[1].hidden = true;
|
||||
chart.options.scales.y2.display = false;
|
||||
chart.update();
|
||||
}
|
||||
|
||||
// Reset all statistics
|
||||
const currentValue1El = document.getElementById('currentValue1');
|
||||
const minValue1El = document.getElementById('minValue1');
|
||||
const maxValue1El = document.getElementById('maxValue1');
|
||||
const dataPointsEl = document.getElementById('dataPoints');
|
||||
const lastUpdateEl = document.getElementById('lastUpdate');
|
||||
|
||||
if (currentValue1El) currentValue1El.textContent = '--';
|
||||
if (minValue1El) minValue1El.textContent = '--';
|
||||
if (maxValue1El) maxValue1El.textContent = '--';
|
||||
if (dataPointsEl) dataPointsEl.textContent = '0';
|
||||
if (lastUpdateEl) lastUpdateEl.textContent = '--';
|
||||
|
||||
// Reset title
|
||||
document.getElementById('chartTitle').textContent = primaryTag || 'Real-Time Process Trend';
|
||||
}
|
||||
|
||||
// Handle interval changes
|
||||
function setupIntervalListener() {
|
||||
const intervalSelect = document.getElementById('updateInterval');
|
||||
if (!intervalSelect) {
|
||||
return;
|
||||
}
|
||||
|
||||
intervalSelect.addEventListener('change', function() {
|
||||
if (isRunning) {
|
||||
stopRealTime();
|
||||
setTimeout(startRealTime, 100);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initChart();
|
||||
setupIntervalListener();
|
||||
applyPresetConfiguration();
|
||||
});
|
||||
|
||||
// Cleanup on page unload
|
||||
window.addEventListener('beforeunload', function() {
|
||||
if (updateTimer) {
|
||||
clearInterval(updateTimer);
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<?php require __DIR__ . '/../../includes/layout/footer.php'; ?>
|
||||
12
trends/live/composite indexes.txt
Normal file
12
trends/live/composite indexes.txt
Normal file
@@ -0,0 +1,12 @@
|
||||
-- Composite indexes for your use case
|
||||
CREATE INDEX idx_historicaldata_compound ON historicaldata (ID, TimeStamp DESC);
|
||||
CREATE INDEX idx_historicaldata_timestamp ON historicaldata (TimeStamp DESC);
|
||||
CREATE INDEX idx_id_names_lookup ON id_names (name, idnumber);
|
||||
|
||||
-- For date range queries
|
||||
CREATE INDEX idx_historicaldata_daterange ON historicaldata (TimeStamp, ID)
|
||||
WHERE TimeStamp >= '2020-01-01';
|
||||
|
||||
-- Partial indexes for recent data (if most queries are recent)
|
||||
CREATE INDEX idx_recent_data ON historicaldata (ID, TimeStamp DESC)
|
||||
WHERE TimeStamp >= NOW() - INTERVAL 30 DAY;
|
||||
651
trends/live/fivechart.php
Normal file
651
trends/live/fivechart.php
Normal file
@@ -0,0 +1,651 @@
|
||||
<?php // phpcs:ignoreFile
|
||||
|
||||
require __DIR__ . '/../../session.php';
|
||||
require __DIR__ . '/../../userAccess.php';
|
||||
|
||||
$pageTitle = 'Multi-Tag Trend';
|
||||
$pageSubtitle = 'Monitor up to five tags over three hours';
|
||||
$pageDescription = 'Launch the multi-tag trend to track up to five historian tags simultaneously.';
|
||||
$assetBasePath = '../../';
|
||||
$layoutWithoutSidebar = true;
|
||||
$layoutReturnUrl = '../../overview.php';
|
||||
$layoutReturnLabel = 'Back to overview';
|
||||
|
||||
require __DIR__ . '/../../includes/layout/header.php';
|
||||
?>
|
||||
|
||||
<div class="app-content">
|
||||
<section class="data-panel trend-panel">
|
||||
<header class="trend-panel__header">
|
||||
<div>
|
||||
<h2 id="chartTitle">Multi-tag real-time trend</h2>
|
||||
<p>Select up to five tags to stream a three-hour sliding history.</p>
|
||||
</div>
|
||||
<span class="trend-status trend-status--stopped" id="chartStatus">STOPPED</span>
|
||||
</header>
|
||||
|
||||
<div class="trend-control-grid">
|
||||
<div class="trend-control">
|
||||
<label for="tagSelect1">Tag 1</label>
|
||||
<select id="tagSelect1" class="tag-select">
|
||||
<option value="">Loading tags...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="trend-control">
|
||||
<label for="tagSelect2">Tag 2</label>
|
||||
<select id="tagSelect2" class="tag-select">
|
||||
<option value="">Loading tags...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="trend-control">
|
||||
<label for="tagSelect3">Tag 3</label>
|
||||
<select id="tagSelect3" class="tag-select">
|
||||
<option value="">Loading tags...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="trend-control">
|
||||
<label for="tagSelect4">Tag 4</label>
|
||||
<select id="tagSelect4" class="tag-select">
|
||||
<option value="">Loading tags...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="trend-control">
|
||||
<label for="tagSelect5">Tag 5</label>
|
||||
<select id="tagSelect5" class="tag-select">
|
||||
<option value="">Loading tags...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="trend-control">
|
||||
<label for="updateInterval">Update interval</label>
|
||||
<select id="updateInterval">
|
||||
<option value="10700" selected>Every 10 seconds</option>
|
||||
<option value="30000">Every 30 seconds</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="trend-control">
|
||||
<label>Time window</label>
|
||||
<div class="time-window-display">Last 3 hours (sliding)</div>
|
||||
</div>
|
||||
<div class="trend-control">
|
||||
<label for="startBtn">Controls</label>
|
||||
<div class="trend-control__actions">
|
||||
<button type="button" class="button button--success" id="startBtn" onclick="startRealTime()">Start</button>
|
||||
<button type="button" class="button button--danger" id="stopBtn" onclick="stopRealTime()" disabled>Stop</button>
|
||||
<button type="button" class="button button--ghost" onclick="clearChart()">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="status" class="trend-connection trend-connection--disconnected">
|
||||
Disconnected — Select at least one tag and click Start to begin trending
|
||||
</div>
|
||||
|
||||
<div class="trend-stat-grid">
|
||||
<div class="trend-stat trend-stat--series1">
|
||||
<span id="currentValue1" class="trend-stat__value">--</span>
|
||||
<span class="trend-stat__label" id="currentLabel1">Tag 1 current</span>
|
||||
</div>
|
||||
<div class="trend-stat trend-stat--series1">
|
||||
<span id="minValue1" class="trend-stat__value">--</span>
|
||||
<span class="trend-stat__label" id="minLabel1">Tag 1 min</span>
|
||||
</div>
|
||||
<div class="trend-stat trend-stat--series1">
|
||||
<span id="maxValue1" class="trend-stat__value">--</span>
|
||||
<span class="trend-stat__label" id="maxLabel1">Tag 1 max</span>
|
||||
</div>
|
||||
<div class="trend-stat trend-stat--series2">
|
||||
<span id="currentValue2" class="trend-stat__value">--</span>
|
||||
<span class="trend-stat__label" id="currentLabel2">Tag 2 current</span>
|
||||
</div>
|
||||
<div class="trend-stat trend-stat--series2">
|
||||
<span id="minValue2" class="trend-stat__value">--</span>
|
||||
<span class="trend-stat__label" id="minLabel2">Tag 2 min</span>
|
||||
</div>
|
||||
<div class="trend-stat trend-stat--series2">
|
||||
<span id="maxValue2" class="trend-stat__value">--</span>
|
||||
<span class="trend-stat__label" id="maxLabel2">Tag 2 max</span>
|
||||
</div>
|
||||
<div class="trend-stat trend-stat--series3">
|
||||
<span id="currentValue3" class="trend-stat__value">--</span>
|
||||
<span class="trend-stat__label" id="currentLabel3">Tag 3 current</span>
|
||||
</div>
|
||||
<div class="trend-stat trend-stat--series3">
|
||||
<span id="minValue3" class="trend-stat__value">--</span>
|
||||
<span class="trend-stat__label" id="minLabel3">Tag 3 min</span>
|
||||
</div>
|
||||
<div class="trend-stat trend-stat--series3">
|
||||
<span id="maxValue3" class="trend-stat__value">--</span>
|
||||
<span class="trend-stat__label" id="maxLabel3">Tag 3 max</span>
|
||||
</div>
|
||||
<div class="trend-stat trend-stat--series4">
|
||||
<span id="currentValue4" class="trend-stat__value">--</span>
|
||||
<span class="trend-stat__label" id="currentLabel4">Tag 4 current</span>
|
||||
</div>
|
||||
<div class="trend-stat trend-stat--series4">
|
||||
<span id="minValue4" class="trend-stat__value">--</span>
|
||||
<span class="trend-stat__label" id="minLabel4">Tag 4 min</span>
|
||||
</div>
|
||||
<div class="trend-stat trend-stat--series4">
|
||||
<span id="maxValue4" class="trend-stat__value">--</span>
|
||||
<span class="trend-stat__label" id="maxLabel4">Tag 4 max</span>
|
||||
</div>
|
||||
<div class="trend-stat trend-stat--series5">
|
||||
<span id="currentValue5" class="trend-stat__value">--</span>
|
||||
<span class="trend-stat__label" id="currentLabel5">Tag 5 current</span>
|
||||
</div>
|
||||
<div class="trend-stat trend-stat--series5">
|
||||
<span id="minValue5" class="trend-stat__value">--</span>
|
||||
<span class="trend-stat__label" id="minLabel5">Tag 5 min</span>
|
||||
</div>
|
||||
<div class="trend-stat trend-stat--series5">
|
||||
<span id="maxValue5" class="trend-stat__value">--</span>
|
||||
<span class="trend-stat__label" id="maxLabel5">Tag 5 max</span>
|
||||
</div>
|
||||
<div class="trend-stat">
|
||||
<span id="dataPoints" class="trend-stat__value">0</span>
|
||||
<span class="trend-stat__label">Total points</span>
|
||||
</div>
|
||||
<div class="trend-stat">
|
||||
<span id="lastUpdate" class="trend-stat__value">--</span>
|
||||
<span class="trend-stat__label">Last update</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="trend-chart">
|
||||
<canvas id="realtimeChart"></canvas>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/moment@2.29.4/moment.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-moment@1.0.1/dist/chartjs-adapter-moment.min.js"></script>
|
||||
|
||||
<script>
|
||||
const MAX_TAGS = 5;
|
||||
const timeWindowMinutes = 180;
|
||||
const defaultLabels = ['Tag 1', 'Tag 2', 'Tag 3', 'Tag 4', 'Tag 5'];
|
||||
const DATASET_STYLES = [
|
||||
{ border: '#3498db', background: 'rgba(52, 152, 219, 0.15)' },
|
||||
{ border: '#27ae60', background: 'rgba(39, 174, 96, 0.15)' },
|
||||
{ border: '#f39c12', background: 'rgba(243, 156, 18, 0.15)' },
|
||||
{ border: '#e74c3c', background: 'rgba(231, 76, 60, 0.15)' },
|
||||
{ border: '#9b59b6', background: 'rgba(155, 89, 182, 0.15)' }
|
||||
];
|
||||
|
||||
let chart = null;
|
||||
let updateTimer = null;
|
||||
let isRunning = false;
|
||||
let chartData = Array.from({ length: MAX_TAGS }, () => []);
|
||||
|
||||
function getTagSelectElements() {
|
||||
return Array.from(document.querySelectorAll('.tag-select'));
|
||||
}
|
||||
|
||||
function initChart() {
|
||||
const ctx = document.getElementById('realtimeChart').getContext('2d');
|
||||
const datasets = DATASET_STYLES.map((style, index) => ({
|
||||
label: defaultLabels[index],
|
||||
data: [],
|
||||
borderColor: style.border,
|
||||
backgroundColor: style.background,
|
||||
borderWidth: 2,
|
||||
fill: false,
|
||||
tension: 0.1,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 0,
|
||||
hitRadius: 0,
|
||||
hidden: index !== 0
|
||||
}));
|
||||
|
||||
chart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: { datasets },
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: { duration: 300 },
|
||||
interaction: { intersect: false, mode: 'index' },
|
||||
scales: {
|
||||
x: {
|
||||
type: 'time',
|
||||
time: {
|
||||
unit: 'minute',
|
||||
displayFormats: {
|
||||
second: 'HH:mm:ss',
|
||||
minute: 'HH:mm',
|
||||
hour: 'HH:mm'
|
||||
}
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Time (Last 3 Hours)',
|
||||
font: { size: 12, weight: 'bold' }
|
||||
},
|
||||
grid: { color: 'rgba(0, 0, 0, 0.1)' },
|
||||
min: moment().subtract(timeWindowMinutes, 'minutes').toDate(),
|
||||
max: moment().toDate()
|
||||
},
|
||||
y: {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'left',
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Tag value',
|
||||
color: '#2c3e50',
|
||||
font: { size: 12, weight: 'bold' }
|
||||
},
|
||||
grid: { drawOnChartArea: true, color: 'rgba(0, 0, 0, 0.08)' },
|
||||
ticks: { color: '#2c3e50', font: { weight: 'bold' } }
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'top',
|
||||
labels: {
|
||||
font: { size: 12, weight: 'bold' },
|
||||
usePointStyle: true,
|
||||
padding: 18
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(44, 62, 80, 0.9)',
|
||||
titleColor: '#ecf0f1',
|
||||
bodyColor: '#ecf0f1',
|
||||
borderColor: '#2c3e50',
|
||||
borderWidth: 1,
|
||||
titleFont: { size: 13, weight: 'bold' },
|
||||
bodyFont: { size: 12 },
|
||||
callbacks: {
|
||||
title(context) {
|
||||
return moment(context[0].parsed.x).format('YYYY-MM-DD HH:mm:ss');
|
||||
},
|
||||
afterTitle(context) {
|
||||
const now = moment();
|
||||
const pointTime = moment(context[0].parsed.x);
|
||||
const minutesAgo = now.diff(pointTime, 'minutes');
|
||||
if (minutesAgo <= 1) {
|
||||
const secondsAgo = now.diff(pointTime, 'seconds');
|
||||
return `(${secondsAgo} seconds ago)`;
|
||||
}
|
||||
return `(${minutesAgo} minutes ago)`;
|
||||
},
|
||||
label(context) {
|
||||
const datasetLabel = context.dataset.label;
|
||||
const rawValue = context.parsed.y;
|
||||
if (rawValue === null || Number.isNaN(rawValue)) {
|
||||
return `${datasetLabel}: --`;
|
||||
}
|
||||
const value = Number(rawValue).toFixed(2);
|
||||
return `${datasetLabel}: ${value}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function loadTags() {
|
||||
const selects = getTagSelectElements();
|
||||
if (selects.length === 0) {
|
||||
console.error('Tag select elements not found in DOM.');
|
||||
return;
|
||||
}
|
||||
|
||||
const placeholders = [
|
||||
'Select primary tag...',
|
||||
'Optional tag 2...',
|
||||
'Optional tag 3...',
|
||||
'Optional tag 4...',
|
||||
'Optional tag 5...'
|
||||
];
|
||||
|
||||
const setLoadingMessage = (message) => {
|
||||
selects.forEach((select) => {
|
||||
select.innerHTML = `<option value="">${message}</option>`;
|
||||
});
|
||||
};
|
||||
|
||||
setLoadingMessage('Loading tags...');
|
||||
|
||||
try {
|
||||
const response = await fetch('get_tags.php', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
cache: 'no-cache'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
if (!payload.success || !Array.isArray(payload.tags) || payload.tags.length === 0) {
|
||||
const errorText = payload.error || 'No tags available';
|
||||
setLoadingMessage(errorText);
|
||||
return;
|
||||
}
|
||||
|
||||
selects.forEach((select, index) => {
|
||||
select.innerHTML = `<option value="">${placeholders[index] || 'Optional tag...'}</option>`;
|
||||
payload.tags.forEach((tag) => {
|
||||
if (!tag || !tag.name) {
|
||||
return;
|
||||
}
|
||||
const option = document.createElement('option');
|
||||
option.value = tag.name;
|
||||
option.textContent = tag.name;
|
||||
select.appendChild(option);
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load tags:', error);
|
||||
setLoadingMessage(`Error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function getActiveTags() {
|
||||
return getTagSelectElements()
|
||||
.map((select, index) => ({ index, tagName: select.value.trim() }))
|
||||
.filter((item) => item.tagName !== '');
|
||||
}
|
||||
|
||||
function setControlsDisabled(disabled) {
|
||||
getTagSelectElements().forEach((select) => {
|
||||
select.disabled = disabled;
|
||||
});
|
||||
}
|
||||
|
||||
function updateChartTitle() {
|
||||
const activeTags = getActiveTags();
|
||||
if (activeTags.length === 0) {
|
||||
document.getElementById('chartTitle').textContent = 'Multi-tag real-time trend';
|
||||
return;
|
||||
}
|
||||
|
||||
const title = activeTags.map((item) => item.tagName).join(', ');
|
||||
document.getElementById('chartTitle').textContent = title;
|
||||
}
|
||||
|
||||
function updateStatus(state, message) {
|
||||
const statusElement = document.getElementById('status');
|
||||
const chartStatus = document.getElementById('chartStatus');
|
||||
const classSuffix = state === 'connected' ? 'connected' : 'disconnected';
|
||||
|
||||
statusElement.className = `trend-connection trend-connection--${classSuffix}`;
|
||||
statusElement.textContent = message;
|
||||
|
||||
if (state === 'connected') {
|
||||
chartStatus.className = 'trend-status trend-status--running';
|
||||
chartStatus.textContent = 'RUNNING';
|
||||
} else {
|
||||
chartStatus.className = 'trend-status trend-status--stopped';
|
||||
chartStatus.textContent = 'STOPPED';
|
||||
}
|
||||
}
|
||||
|
||||
function updateStatistics() {
|
||||
const selects = getTagSelectElements();
|
||||
let totalPoints = 0;
|
||||
let hasData = false;
|
||||
|
||||
selects.forEach((select, index) => {
|
||||
const labelBase = select.value ? `${select.value}` : defaultLabels[index];
|
||||
const currentLabel = document.getElementById(`currentLabel${index + 1}`);
|
||||
const minLabel = document.getElementById(`minLabel${index + 1}`);
|
||||
const maxLabel = document.getElementById(`maxLabel${index + 1}`);
|
||||
|
||||
if (currentLabel) {
|
||||
currentLabel.textContent = `${labelBase} current`;
|
||||
}
|
||||
if (minLabel) {
|
||||
minLabel.textContent = `${labelBase} min`;
|
||||
}
|
||||
if (maxLabel) {
|
||||
maxLabel.textContent = `${labelBase} max`;
|
||||
}
|
||||
|
||||
const points = chartData[index] || [];
|
||||
totalPoints += points.length;
|
||||
|
||||
const currentValue = document.getElementById(`currentValue${index + 1}`);
|
||||
const minValue = document.getElementById(`minValue${index + 1}`);
|
||||
const maxValue = document.getElementById(`maxValue${index + 1}`);
|
||||
|
||||
if (points.length > 0 && currentValue && minValue && maxValue) {
|
||||
const values = points.map((point) => Number(point.y));
|
||||
const current = values[values.length - 1];
|
||||
const min = Math.min(...values);
|
||||
const max = Math.max(...values);
|
||||
|
||||
currentValue.textContent = current.toFixed(2);
|
||||
minValue.textContent = min.toFixed(2);
|
||||
maxValue.textContent = max.toFixed(2);
|
||||
hasData = true;
|
||||
} else {
|
||||
if (currentValue) {
|
||||
currentValue.textContent = '--';
|
||||
}
|
||||
if (minValue) {
|
||||
minValue.textContent = '--';
|
||||
}
|
||||
if (maxValue) {
|
||||
maxValue.textContent = '--';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const dataPointsElement = document.getElementById('dataPoints');
|
||||
if (dataPointsElement) {
|
||||
dataPointsElement.textContent = totalPoints;
|
||||
}
|
||||
|
||||
const lastUpdateElement = document.getElementById('lastUpdate');
|
||||
if (lastUpdateElement) {
|
||||
lastUpdateElement.textContent = hasData ? moment().format('HH:mm:ss') : '--';
|
||||
}
|
||||
}
|
||||
|
||||
function syncDatasetVisibility() {
|
||||
if (!chart) {
|
||||
return;
|
||||
}
|
||||
|
||||
getTagSelectElements().forEach((select, index) => {
|
||||
const tagName = select.value.trim();
|
||||
const dataset = chart.data.datasets[index];
|
||||
dataset.label = tagName || defaultLabels[index];
|
||||
if (!tagName) {
|
||||
chartData[index] = [];
|
||||
dataset.data = [];
|
||||
dataset.hidden = true;
|
||||
} else if (dataset.data.length === 0) {
|
||||
dataset.hidden = false;
|
||||
}
|
||||
});
|
||||
|
||||
chart.update();
|
||||
updateStatistics();
|
||||
}
|
||||
|
||||
async function fetchTagData(tagName) {
|
||||
if (!tagName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const endTime = moment().format('YYYY-MM-DD HH:mm:ss');
|
||||
const startTime = moment().subtract(timeWindowMinutes, 'minutes').format('YYYY-MM-DD HH:mm:ss');
|
||||
const url = `realtime_data_fivechart.php?tag=${encodeURIComponent(tagName)}&limit=15000&start_time=${encodeURIComponent(startTime)}&end_time=${encodeURIComponent(endTime)}`;
|
||||
|
||||
const response = await fetch(url, { cache: 'no-cache' });
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
if (!payload.success || !Array.isArray(payload.data)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const cutoffTime = moment().subtract(timeWindowMinutes, 'minutes').toDate();
|
||||
return payload.data
|
||||
.map((point) => ({
|
||||
x: new Date(point.x),
|
||||
y: point.y
|
||||
}))
|
||||
.filter((point) => point.x >= cutoffTime);
|
||||
} catch (error) {
|
||||
console.error(`Error fetching data for ${tagName}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchData() {
|
||||
const activeTags = getActiveTags();
|
||||
if (activeTags.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const results = await Promise.all(activeTags.map((item) => fetchTagData(item.tagName)));
|
||||
let successCount = 0;
|
||||
|
||||
activeTags.forEach((item, idx) => {
|
||||
const dataset = chart.data.datasets[item.index];
|
||||
const data = results[idx];
|
||||
if (data === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
chartData[item.index] = data;
|
||||
dataset.data = data;
|
||||
dataset.label = item.tagName;
|
||||
dataset.hidden = false;
|
||||
successCount += 1;
|
||||
});
|
||||
|
||||
getTagSelectElements().forEach((select, index) => {
|
||||
if (!select.value) {
|
||||
const dataset = chart.data.datasets[index];
|
||||
chartData[index] = [];
|
||||
dataset.data = [];
|
||||
dataset.label = defaultLabels[index];
|
||||
dataset.hidden = true;
|
||||
}
|
||||
});
|
||||
|
||||
chart.options.scales.x.min = moment().subtract(timeWindowMinutes, 'minutes').toDate();
|
||||
chart.options.scales.x.max = moment().toDate();
|
||||
chart.update('active');
|
||||
|
||||
updateStatistics();
|
||||
updateChartTitle();
|
||||
|
||||
const tagSummary = activeTags.map((item) => item.tagName).join(', ');
|
||||
if (successCount === 0) {
|
||||
updateStatus('disconnected', 'No data returned for the selected tags.');
|
||||
} else if (successCount < activeTags.length) {
|
||||
updateStatus('connected', `Partial update (${successCount}/${activeTags.length}) for ${tagSummary} at ${new Date().toLocaleTimeString()}`);
|
||||
} else {
|
||||
updateStatus('connected', `Connected to ${tagSummary} - Last update ${new Date().toLocaleTimeString()}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fetch error:', error);
|
||||
updateStatus('disconnected', `Connection error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function startRealTime() {
|
||||
const activeTags = getActiveTags();
|
||||
if (activeTags.length === 0) {
|
||||
alert('Please select at least one tag to start trending.');
|
||||
return;
|
||||
}
|
||||
|
||||
isRunning = true;
|
||||
const interval = parseInt(document.getElementById('updateInterval').value, 10) || 5000;
|
||||
|
||||
document.getElementById('startBtn').disabled = true;
|
||||
document.getElementById('stopBtn').disabled = false;
|
||||
setControlsDisabled(true);
|
||||
|
||||
fetchData();
|
||||
updateTimer = setInterval(fetchData, interval);
|
||||
|
||||
const tagSummary = activeTags.map((item) => item.tagName).join(', ');
|
||||
updateStatus('connected', `Trending ${tagSummary} - Update every ${interval / 1000}s`);
|
||||
}
|
||||
|
||||
function stopRealTime() {
|
||||
isRunning = false;
|
||||
if (updateTimer) {
|
||||
clearInterval(updateTimer);
|
||||
updateTimer = null;
|
||||
}
|
||||
|
||||
document.getElementById('startBtn').disabled = false;
|
||||
document.getElementById('stopBtn').disabled = true;
|
||||
setControlsDisabled(false);
|
||||
|
||||
updateStatus('disconnected', 'Real-time updates stopped - Select tags and click Start to resume');
|
||||
}
|
||||
|
||||
function clearChart() {
|
||||
if (isRunning) {
|
||||
stopRealTime();
|
||||
}
|
||||
|
||||
chartData = Array.from({ length: MAX_TAGS }, () => []);
|
||||
chart.data.datasets.forEach((dataset, index) => {
|
||||
dataset.data = [];
|
||||
dataset.label = defaultLabels[index];
|
||||
dataset.hidden = index !== 0;
|
||||
});
|
||||
|
||||
chart.update();
|
||||
updateStatistics();
|
||||
updateChartTitle();
|
||||
updateStatus('disconnected', 'Chart cleared - Select tags and click Start to resume');
|
||||
setControlsDisabled(false);
|
||||
}
|
||||
|
||||
document.getElementById('updateInterval').addEventListener('change', () => {
|
||||
if (isRunning) {
|
||||
stopRealTime();
|
||||
setTimeout(startRealTime, 150);
|
||||
}
|
||||
});
|
||||
|
||||
getTagSelectElements().forEach((select) => {
|
||||
select.addEventListener('change', () => {
|
||||
updateChartTitle();
|
||||
if (isRunning) {
|
||||
fetchData();
|
||||
} else {
|
||||
syncDatasetVisibility();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initChart();
|
||||
setTimeout(() => {
|
||||
loadTags().then(() => syncDatasetVisibility());
|
||||
}, 100);
|
||||
});
|
||||
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (updateTimer) {
|
||||
clearInterval(updateTimer);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<?php require __DIR__ . '/../../includes/layout/footer.php'; ?>
|
||||
63
trends/live/get_tags.php
Normal file
63
trends/live/get_tags.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
// filepath: v:\controls\trends\live\get_tags.php
|
||||
|
||||
header('Content-Type: application/json');
|
||||
header('Cache-Control: no-cache, must-revalidate');
|
||||
|
||||
// Enable error reporting for debugging
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', 1);
|
||||
|
||||
// Database connection - same as your other files
|
||||
$servername = "192.168.0.13\\SQLEXPRESS";
|
||||
$username = "opce";
|
||||
$password = "opcelasuca";
|
||||
$dbname = "history";
|
||||
|
||||
try {
|
||||
$pdo = new PDO(
|
||||
"sqlsrv:Server=$servername;Database=$dbname",
|
||||
$username,
|
||||
$password,
|
||||
[
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC
|
||||
]
|
||||
);
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT DISTINCT name
|
||||
FROM dbo.id_names
|
||||
WHERE name IS NOT NULL AND name <> ''
|
||||
ORDER BY name
|
||||
");
|
||||
$stmt->execute();
|
||||
$tags = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// Debug: Log the query result
|
||||
error_log("Found " . count($tags) . " tags");
|
||||
|
||||
$response = [
|
||||
'success' => true,
|
||||
'tags' => $tags,
|
||||
'count' => count($tags)
|
||||
];
|
||||
|
||||
} catch (PDOException $e) {
|
||||
error_log("Database error: " . $e->getMessage());
|
||||
$response = [
|
||||
'success' => false,
|
||||
'error' => 'Database error: ' . $e->getMessage(),
|
||||
'tags' => []
|
||||
];
|
||||
} catch (Exception $e) {
|
||||
error_log("General error: " . $e->getMessage());
|
||||
$response = [
|
||||
'success' => false,
|
||||
'error' => 'Error: ' . $e->getMessage(),
|
||||
'tags' => []
|
||||
];
|
||||
}
|
||||
|
||||
echo json_encode($response);
|
||||
?>
|
||||
20
trends/live/get_tags_test.php
Normal file
20
trends/live/get_tags_test.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
// filepath: v:\controls\trends\live\get_tags_test.php
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// Hardcoded test data
|
||||
$response = [
|
||||
'success' => true,
|
||||
'tags' => [
|
||||
['name' => 'Test Tag 1'],
|
||||
['name' => 'Test Tag 2'],
|
||||
['name' => 'Test Tag 3'],
|
||||
['name' => 'Mill Speed 1'],
|
||||
['name' => 'Tank Level A']
|
||||
],
|
||||
'count' => 5
|
||||
];
|
||||
|
||||
echo json_encode($response);
|
||||
?>
|
||||
76
trends/live/index.php
Normal file
76
trends/live/index.php
Normal file
@@ -0,0 +1,76 @@
|
||||
<?php // phpcs:ignoreFile
|
||||
|
||||
require __DIR__ . '/../../session.php';
|
||||
require __DIR__ . '/../../userAccess.php';
|
||||
|
||||
$pageTitle = 'Real-Time Trends';
|
||||
$pageSubtitle = 'Launch live charting applications';
|
||||
$pageDescription = 'Access LASUCA\'s beta real-time trending tools to monitor critical process tags.';
|
||||
$assetBasePath = '../../';
|
||||
$layoutWithoutSidebar = true;
|
||||
$layoutReturnUrl = '../../overview.php';
|
||||
$layoutReturnLabel = 'Back to overview';
|
||||
|
||||
require __DIR__ . '/../../includes/layout/header.php';
|
||||
?>
|
||||
|
||||
<div class="app-content">
|
||||
<section class="data-panel">
|
||||
<div class="panel-intro">
|
||||
<h2>Trending applications</h2>
|
||||
<p>Select a charting experience to monitor live process data.</p>
|
||||
</div>
|
||||
|
||||
<div class="card-grid">
|
||||
<article class="card">
|
||||
<span class="card__badge">Trend</span>
|
||||
<div class="card__icon" aria-hidden="true">📊</div>
|
||||
<h3 class="card__title">Single Chart Trending</h3>
|
||||
<p class="card__body">Focus on one or two tags with a dual-axis chart, quick stats, and flexible windows.</p>
|
||||
<ul class="card__list">
|
||||
<li>Dual Y-axis support</li>
|
||||
<li>Primary & secondary tag pairing</li>
|
||||
<li>10-, 15-, and 30-minute windows</li>
|
||||
<li>Live statistics overlay</li>
|
||||
</ul>
|
||||
<div class="card__actions">
|
||||
<a class="button" href="singlechart.php">Launch single chart</a>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="card">
|
||||
<span class="card__badge">New</span>
|
||||
<div class="card__icon" aria-hidden="true">📊</div>
|
||||
<h3 class="card__title">Multi-Tag Single Chart Trending</h3>
|
||||
<p class="card__body">Focus on up to five tags with a five-axis chart, quick stats, and flexible windows.</p>
|
||||
<ul class="card__list">
|
||||
<li>Five Y-axis support</li>
|
||||
<li>Multi & tag pairing</li>
|
||||
<li>10 and 30-minute windows</li>
|
||||
<li>Live statistics overlay</li>
|
||||
</ul>
|
||||
<div class="card__actions">
|
||||
<a class="button" href="fivechart.php">Launch Milti-Tag chart</a>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="card">
|
||||
<span class="card__badge">Trend</span>
|
||||
<div class="card__icon" aria-hidden="true">📈</div>
|
||||
<h3 class="card__title">Multi-Chart Dashboard</h3>
|
||||
<p class="card__body">Watch multiple areas simultaneously with four synchronized panes and per-panel controls.</p>
|
||||
<ul class="card__list">
|
||||
<li>Four independent chart panes</li>
|
||||
<li>Up to eight live tags (two per pane)</li>
|
||||
<li>Global and per-panel controls</li>
|
||||
<li>Full-screen dashboard layout</li>
|
||||
</ul>
|
||||
<div class="card__actions">
|
||||
<a class="button" href="multichart.php">Launch multi-chart</a>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<?php require __DIR__ . '/../../includes/layout/footer.php'; ?>
|
||||
734
trends/live/multichart.php
Normal file
734
trends/live/multichart.php
Normal file
@@ -0,0 +1,734 @@
|
||||
<?php // phpcs:ignoreFile
|
||||
|
||||
require __DIR__ . '/../../session.php';
|
||||
require __DIR__ . '/../../userAccess.php';
|
||||
|
||||
$pageTitle = 'Multi-Chart Trending';
|
||||
$pageSubtitle = 'Watch up to eight live tags at once';
|
||||
$pageDescription = 'Build a custom grid of synchronized charts, then reorder them to match your shift handoff.';
|
||||
$assetBasePath = '../../';
|
||||
$layoutWithoutSidebar = true;
|
||||
$layoutReturnUrl = '../../overview.php';
|
||||
$layoutReturnLabel = 'Back to overview';
|
||||
|
||||
require __DIR__ . '/../../includes/layout/header.php';
|
||||
?>
|
||||
|
||||
<div class="app-content">
|
||||
<section class="data-panel trend-panel">
|
||||
<header class="trend-panel__header">
|
||||
<div>
|
||||
<h2>Multi-chart dashboard</h2>
|
||||
<p>Start up to four live charts, synchronize update intervals, and drag cards to reorder the layout.</p>
|
||||
</div>
|
||||
<span class="trend-status trend-status--stopped">Manual control</span>
|
||||
</header>
|
||||
|
||||
<div class="trend-global-controls">
|
||||
<div class="trend-control">
|
||||
<label for="globalUpdateInterval">Update interval</label>
|
||||
<select id="globalUpdateInterval">
|
||||
<option value="5000" selected>Every 5 seconds</option>
|
||||
<option value="10000">Every 10 seconds</option>
|
||||
<option value="30000">Every 30 seconds</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="trend-control">
|
||||
<label for="globalTimeWindow">Time window</label>
|
||||
<select id="globalTimeWindow">
|
||||
<option value="10">Last 10 minutes</option>
|
||||
<option value="15" selected>Last 15 minutes</option>
|
||||
<option value="30">Last 30 minutes</option>
|
||||
<option value="60">Last hour</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="trend-actions">
|
||||
<button type="button" class="button button--success" onclick="startAllCharts()">Start all</button>
|
||||
<button type="button" class="button button--danger" onclick="stopAllCharts()">Stop all</button>
|
||||
<button type="button" class="button button--ghost" onclick="clearAllCharts()">Clear all</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="multi-trend-grid" data-multi-trend-grid>
|
||||
<article class="multi-trend-card" id="panel1">
|
||||
<header class="multi-trend-card__header">
|
||||
<h3 class="multi-trend-card__title" id="title1">Chart 1</h3>
|
||||
<span class="trend-status trend-status--stopped" id="status1">STOPPED</span>
|
||||
</header>
|
||||
<div class="multi-trend-card__controls">
|
||||
<div class="trend-control">
|
||||
<label for="tagSelect1_1">Primary tag</label>
|
||||
<select id="tagSelect1_1">
|
||||
<option value="">Loading...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="trend-control">
|
||||
<label for="tagSelect1_2">Secondary tag</label>
|
||||
<select id="tagSelect1_2">
|
||||
<option value="">Optional...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="trend-actions">
|
||||
<button type="button" class="button button--success" onclick="startChart(1)">Start</button>
|
||||
<button type="button" class="button button--danger" onclick="stopChart(1)">Stop</button>
|
||||
<button type="button" class="button button--ghost" onclick="clearChart(1)">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="trend-stat-grid trend-stat-grid--compact">
|
||||
<div class="trend-stat">
|
||||
<span id="current1_1" class="trend-stat__value">--</span>
|
||||
<span class="trend-stat__label">Current 1</span>
|
||||
</div>
|
||||
<div class="trend-stat">
|
||||
<span id="current1_2" class="trend-stat__value trend-stat__value--secondary">--</span>
|
||||
<span class="trend-stat__label">Current 2</span>
|
||||
</div>
|
||||
<div class="trend-stat">
|
||||
<span id="points1" class="trend-stat__value">0</span>
|
||||
<span class="trend-stat__label">Points</span>
|
||||
</div>
|
||||
<div class="trend-stat">
|
||||
<span id="update1" class="trend-stat__value">--</span>
|
||||
<span class="trend-stat__label">Updated</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="trend-chart trend-chart--compact">
|
||||
<canvas id="chart1"></canvas>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="multi-trend-card" id="panel2">
|
||||
<header class="multi-trend-card__header">
|
||||
<h3 class="multi-trend-card__title" id="title2">Chart 2</h3>
|
||||
<span class="trend-status trend-status--stopped" id="status2">STOPPED</span>
|
||||
</header>
|
||||
<div class="multi-trend-card__controls">
|
||||
<div class="trend-control">
|
||||
<label for="tagSelect2_1">Primary tag</label>
|
||||
<select id="tagSelect2_1">
|
||||
<option value="">Loading...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="trend-control">
|
||||
<label for="tagSelect2_2">Secondary tag</label>
|
||||
<select id="tagSelect2_2">
|
||||
<option value="">Optional...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="trend-actions">
|
||||
<button type="button" class="button button--success" onclick="startChart(2)">Start</button>
|
||||
<button type="button" class="button button--danger" onclick="stopChart(2)">Stop</button>
|
||||
<button type="button" class="button button--ghost" onclick="clearChart(2)">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="trend-stat-grid trend-stat-grid--compact">
|
||||
<div class="trend-stat">
|
||||
<span id="current2_1" class="trend-stat__value">--</span>
|
||||
<span class="trend-stat__label">Current 1</span>
|
||||
</div>
|
||||
<div class="trend-stat">
|
||||
<span id="current2_2" class="trend-stat__value trend-stat__value--secondary">--</span>
|
||||
<span class="trend-stat__label">Current 2</span>
|
||||
</div>
|
||||
<div class="trend-stat">
|
||||
<span id="points2" class="trend-stat__value">0</span>
|
||||
<span class="trend-stat__label">Points</span>
|
||||
</div>
|
||||
<div class="trend-stat">
|
||||
<span id="update2" class="trend-stat__value">--</span>
|
||||
<span class="trend-stat__label">Updated</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="trend-chart trend-chart--compact">
|
||||
<canvas id="chart2"></canvas>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="multi-trend-card" id="panel3">
|
||||
<header class="multi-trend-card__header">
|
||||
<h3 class="multi-trend-card__title" id="title3">Chart 3</h3>
|
||||
<span class="trend-status trend-status--stopped" id="status3">STOPPED</span>
|
||||
</header>
|
||||
<div class="multi-trend-card__controls">
|
||||
<div class="trend-control">
|
||||
<label for="tagSelect3_1">Primary tag</label>
|
||||
<select id="tagSelect3_1">
|
||||
<option value="">Loading...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="trend-control">
|
||||
<label for="tagSelect3_2">Secondary tag</label>
|
||||
<select id="tagSelect3_2">
|
||||
<option value="">Optional...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="trend-actions">
|
||||
<button type="button" class="button button--success" onclick="startChart(3)">Start</button>
|
||||
<button type="button" class="button button--danger" onclick="stopChart(3)">Stop</button>
|
||||
<button type="button" class="button button--ghost" onclick="clearChart(3)">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="trend-stat-grid trend-stat-grid--compact">
|
||||
<div class="trend-stat">
|
||||
<span id="current3_1" class="trend-stat__value">--</span>
|
||||
<span class="trend-stat__label">Current 1</span>
|
||||
</div>
|
||||
<div class="trend-stat">
|
||||
<span id="current3_2" class="trend-stat__value trend-stat__value--secondary">--</span>
|
||||
<span class="trend-stat__label">Current 2</span>
|
||||
</div>
|
||||
<div class="trend-stat">
|
||||
<span id="points3" class="trend-stat__value">0</span>
|
||||
<span class="trend-stat__label">Points</span>
|
||||
</div>
|
||||
<div class="trend-stat">
|
||||
<span id="update3" class="trend-stat__value">--</span>
|
||||
<span class="trend-stat__label">Updated</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="trend-chart trend-chart--compact">
|
||||
<canvas id="chart3"></canvas>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="multi-trend-card" id="panel4">
|
||||
<header class="multi-trend-card__header">
|
||||
<h3 class="multi-trend-card__title" id="title4">Chart 4</h3>
|
||||
<span class="trend-status trend-status--stopped" id="status4">STOPPED</span>
|
||||
</header>
|
||||
<div class="multi-trend-card__controls">
|
||||
<div class="trend-control">
|
||||
<label for="tagSelect4_1">Primary tag</label>
|
||||
<select id="tagSelect4_1">
|
||||
<option value="">Loading...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="trend-control">
|
||||
<label for="tagSelect4_2">Secondary tag</label>
|
||||
<select id="tagSelect4_2">
|
||||
<option value="">Optional...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="trend-actions">
|
||||
<button type="button" class="button button--success" onclick="startChart(4)">Start</button>
|
||||
<button type="button" class="button button--danger" onclick="stopChart(4)">Stop</button>
|
||||
<button type="button" class="button button--ghost" onclick="clearChart(4)">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="trend-stat-grid trend-stat-grid--compact">
|
||||
<div class="trend-stat">
|
||||
<span id="current4_1" class="trend-stat__value">--</span>
|
||||
<span class="trend-stat__label">Current 1</span>
|
||||
</div>
|
||||
<div class="trend-stat">
|
||||
<span id="current4_2" class="trend-stat__value trend-stat__value--secondary">--</span>
|
||||
<span class="trend-stat__label">Current 2</span>
|
||||
</div>
|
||||
<div class="trend-stat">
|
||||
<span id="points4" class="trend-stat__value">0</span>
|
||||
<span class="trend-stat__label">Points</span>
|
||||
</div>
|
||||
<div class="trend-stat">
|
||||
<span id="update4" class="trend-stat__value">--</span>
|
||||
<span class="trend-stat__label">Updated</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="trend-chart trend-chart--compact">
|
||||
<canvas id="chart4"></canvas>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/moment@2.29.4/moment.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-moment@1.0.1/dist/chartjs-adapter-moment.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-zoom@2.0.1/dist/chartjs-plugin-zoom.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.2/Sortable.min.js"></script>
|
||||
|
||||
<script>
|
||||
// Global variables for multi-chart management
|
||||
const charts = {};
|
||||
const chartData = {};
|
||||
const updateTimers = {};
|
||||
const chartStates = {};
|
||||
let globalTimeWindow = 15;
|
||||
let globalUpdateInterval = 5000;
|
||||
let availableTags = [];
|
||||
|
||||
// Chart configuration template
|
||||
function getChartConfig(chartId) {
|
||||
return {
|
||||
type: 'line',
|
||||
data: {
|
||||
datasets: [{
|
||||
label: 'Primary Tag',
|
||||
data: [],
|
||||
borderColor: '#3498db',
|
||||
backgroundColor: 'rgba(52, 152, 219, 0.1)',
|
||||
borderWidth: 2,
|
||||
fill: false,
|
||||
tension: 0.1,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 0,
|
||||
hitRadius: 0,
|
||||
yAxisID: 'y1'
|
||||
}, {
|
||||
label: 'Secondary Tag',
|
||||
data: [],
|
||||
borderColor: '#27ae60',
|
||||
backgroundColor: 'rgba(39, 174, 96, 0.1)',
|
||||
borderWidth: 2,
|
||||
fill: false,
|
||||
tension: 0.1,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 0,
|
||||
hitRadius: 0,
|
||||
yAxisID: 'y2',
|
||||
hidden: true
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: {
|
||||
duration: 200
|
||||
},
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'index'
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: 'time',
|
||||
time: {
|
||||
unit: 'minute',
|
||||
displayFormats: {
|
||||
second: 'HH:mm:ss',
|
||||
minute: 'HH:mm'
|
||||
}
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: `Last ${globalTimeWindow} Minutes`,
|
||||
font: { size: 10 }
|
||||
},
|
||||
min: function() {
|
||||
return moment().subtract(globalTimeWindow, 'minutes').toDate();
|
||||
},
|
||||
max: function() {
|
||||
return moment().toDate();
|
||||
},
|
||||
ticks: {
|
||||
font: { size: 9 }
|
||||
}
|
||||
},
|
||||
y1: {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'left',
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Primary',
|
||||
color: '#3498db',
|
||||
font: { size: 10 }
|
||||
},
|
||||
grid: {
|
||||
drawOnChartArea: true,
|
||||
},
|
||||
ticks: {
|
||||
color: '#3498db',
|
||||
font: { size: 9 }
|
||||
}
|
||||
},
|
||||
y2: {
|
||||
type: 'linear',
|
||||
display: false,
|
||||
position: 'right',
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Secondary',
|
||||
color: '#27ae60',
|
||||
font: { size: 10 }
|
||||
},
|
||||
grid: {
|
||||
drawOnChartArea: false,
|
||||
},
|
||||
ticks: {
|
||||
color: '#27ae60',
|
||||
font: { size: 9 }
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'top',
|
||||
labels: {
|
||||
font: { size: 10 },
|
||||
usePointStyle: true
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
titleFont: { size: 11 },
|
||||
bodyFont: { size: 10 },
|
||||
callbacks: {
|
||||
title: function(context) {
|
||||
return moment(context[0].parsed.x).format('HH:mm:ss');
|
||||
},
|
||||
label: function(context) {
|
||||
return `${context.dataset.label}: ${context.parsed.y.toFixed(2)}`;
|
||||
}
|
||||
}
|
||||
},
|
||||
zoom: {
|
||||
pan: {
|
||||
enabled: true,
|
||||
modifierKey: 'ctrl',
|
||||
mode: 'x'
|
||||
},
|
||||
zoom: {
|
||||
wheel: { enabled: true, modifierKey: 'shift' },
|
||||
pinch: { enabled: true },
|
||||
mode: 'x'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Initialize all charts
|
||||
function initializeCharts() {
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
const ctx = document.getElementById(`chart${i}`).getContext('2d');
|
||||
charts[i] = new Chart(ctx, getChartConfig(i));
|
||||
chartData[i] = { primary: [], secondary: [] };
|
||||
chartStates[i] = { running: false, timer: null };
|
||||
}
|
||||
}
|
||||
|
||||
// Load tags for all dropdowns
|
||||
async function loadTags() {
|
||||
const pairs = [];
|
||||
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
const primary = document.getElementById(`tagSelect${i}_1`);
|
||||
const secondary = document.getElementById(`tagSelect${i}_2`);
|
||||
|
||||
if (!primary || !secondary) {
|
||||
console.error(`Missing tag selectors for chart ${i}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
primary.innerHTML = '<option value="">Loading tags...</option>';
|
||||
secondary.innerHTML = '<option value="">Loading tags...</option>';
|
||||
pairs.push({ primary, secondary });
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('get_tags.php', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
cache: 'no-cache'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!(data.success && Array.isArray(data.tags) && data.tags.length > 0)) {
|
||||
throw new Error(data.error || 'No tags available');
|
||||
}
|
||||
|
||||
availableTags = data.tags;
|
||||
|
||||
pairs.forEach(({ primary, secondary }) => {
|
||||
primary.innerHTML = '<option value="">Select primary tag...</option>';
|
||||
secondary.innerHTML = '<option value="">Select secondary tag...</option>';
|
||||
|
||||
availableTags.forEach((tag) => {
|
||||
if (!tag || !tag.name) {
|
||||
return;
|
||||
}
|
||||
|
||||
const option1 = document.createElement('option');
|
||||
option1.value = tag.name;
|
||||
option1.textContent = tag.name;
|
||||
primary.appendChild(option1);
|
||||
|
||||
const option2 = document.createElement('option');
|
||||
option2.value = tag.name;
|
||||
option2.textContent = tag.name;
|
||||
secondary.appendChild(option2);
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load tags for multi-chart:', error);
|
||||
pairs.forEach(({ primary, secondary }) => {
|
||||
const message = `Error: ${error.message}`;
|
||||
primary.innerHTML = `<option value="">${message}</option>`;
|
||||
secondary.innerHTML = `<option value="">${message}</option>`;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch data for a specific chart
|
||||
async function fetchChartData(chartId) {
|
||||
const tag1 = document.getElementById(`tagSelect${chartId}_1`).value;
|
||||
const tag2 = document.getElementById(`tagSelect${chartId}_2`).value;
|
||||
|
||||
if (!tag1) return;
|
||||
|
||||
try {
|
||||
const endTime = moment().format('YYYY-MM-DD HH:mm:ss');
|
||||
const startTime = moment().subtract(globalTimeWindow, 'minutes').format('YYYY-MM-DD HH:mm:ss');
|
||||
|
||||
// Fetch primary tag data
|
||||
const data1 = await fetchTagData(tag1);
|
||||
if (data1) {
|
||||
chartData[chartId].primary = data1;
|
||||
charts[chartId].data.datasets[0].data = data1;
|
||||
charts[chartId].data.datasets[0].label = tag1;
|
||||
}
|
||||
|
||||
// Fetch secondary tag data if selected
|
||||
if (tag2 && tag2 !== tag1) {
|
||||
const data2 = await fetchTagData(tag2);
|
||||
if (data2) {
|
||||
chartData[chartId].secondary = data2;
|
||||
charts[chartId].data.datasets[1].data = data2;
|
||||
charts[chartId].data.datasets[1].label = tag2;
|
||||
charts[chartId].data.datasets[1].hidden = false;
|
||||
charts[chartId].options.scales.y2.display = true;
|
||||
}
|
||||
} else {
|
||||
chartData[chartId].secondary = [];
|
||||
charts[chartId].data.datasets[1].data = [];
|
||||
charts[chartId].data.datasets[1].hidden = true;
|
||||
charts[chartId].options.scales.y2.display = false;
|
||||
}
|
||||
|
||||
// Update time axis
|
||||
charts[chartId].options.scales.x.min = moment().subtract(globalTimeWindow, 'minutes').toDate();
|
||||
charts[chartId].options.scales.x.max = moment().toDate();
|
||||
charts[chartId].options.scales.x.title.text = `Last ${globalTimeWindow} Minutes`;
|
||||
|
||||
charts[chartId].update('none');
|
||||
updateChartStats(chartId);
|
||||
updateChartTitle(chartId);
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error fetching data for chart ${chartId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch data for individual tag
|
||||
async function fetchTagData(tagName) {
|
||||
try {
|
||||
const endTime = moment().format('YYYY-MM-DD HH:mm:ss');
|
||||
const startTime = moment().subtract(globalTimeWindow, 'minutes').format('YYYY-MM-DD HH:mm:ss');
|
||||
|
||||
const url = `realtime_data.php?tag=${encodeURIComponent(tagName)}&limit=7200&start_time=${encodeURIComponent(startTime)}&end_time=${encodeURIComponent(endTime)}`;
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.data && data.data.length > 0) {
|
||||
return data.data.map(point => ({
|
||||
x: new Date(point.x),
|
||||
y: point.y
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error(`Error fetching data for ${tagName}:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Update chart statistics
|
||||
function updateChartStats(chartId) {
|
||||
const primary = chartData[chartId].primary;
|
||||
const secondary = chartData[chartId].secondary;
|
||||
|
||||
// Primary tag stats
|
||||
if (primary.length > 0) {
|
||||
const current = primary[primary.length - 1].y;
|
||||
document.getElementById(`current${chartId}_1`).textContent = current.toFixed(2);
|
||||
} else {
|
||||
document.getElementById(`current${chartId}_1`).textContent = '--';
|
||||
}
|
||||
|
||||
// Secondary tag stats
|
||||
if (secondary.length > 0) {
|
||||
const current = secondary[secondary.length - 1].y;
|
||||
document.getElementById(`current${chartId}_2`).textContent = current.toFixed(2);
|
||||
} else {
|
||||
document.getElementById(`current${chartId}_2`).textContent = '--';
|
||||
}
|
||||
|
||||
// General stats
|
||||
document.getElementById(`points${chartId}`).textContent = primary.length + secondary.length;
|
||||
document.getElementById(`update${chartId}`).textContent = moment().format('HH:mm:ss');
|
||||
}
|
||||
|
||||
// Update chart title
|
||||
function updateChartTitle(chartId) {
|
||||
const tag1 = document.getElementById(`tagSelect${chartId}_1`).value;
|
||||
const tag2 = document.getElementById(`tagSelect${chartId}_2`).value;
|
||||
|
||||
let title = `Chart ${chartId}`;
|
||||
if (tag1) {
|
||||
title += ` - ${tag1}`;
|
||||
if (tag2) {
|
||||
title += ` & ${tag2}`;
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById(`title${chartId}`).textContent = title;
|
||||
}
|
||||
|
||||
// Start individual chart
|
||||
function startChart(chartId) {
|
||||
const tag1 = document.getElementById(`tagSelect${chartId}_1`).value;
|
||||
if (!tag1) {
|
||||
alert(`Please select a primary tag for Chart ${chartId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (chartStates[chartId].running) return;
|
||||
|
||||
chartStates[chartId].running = true;
|
||||
const statusBadge = document.getElementById(`status${chartId}`);
|
||||
if (statusBadge) {
|
||||
statusBadge.textContent = 'RUNNING';
|
||||
statusBadge.className = 'trend-status trend-status--running';
|
||||
}
|
||||
|
||||
// Initial fetch
|
||||
fetchChartData(chartId);
|
||||
|
||||
// Start timer
|
||||
chartStates[chartId].timer = setInterval(() => {
|
||||
fetchChartData(chartId);
|
||||
}, globalUpdateInterval);
|
||||
|
||||
}
|
||||
|
||||
// Stop individual chart
|
||||
function stopChart(chartId) {
|
||||
if (!chartStates[chartId].running) return;
|
||||
|
||||
chartStates[chartId].running = false;
|
||||
const statusBadge = document.getElementById(`status${chartId}`);
|
||||
if (statusBadge) {
|
||||
statusBadge.textContent = 'STOPPED';
|
||||
statusBadge.className = 'trend-status trend-status--stopped';
|
||||
}
|
||||
|
||||
if (chartStates[chartId].timer) {
|
||||
clearInterval(chartStates[chartId].timer);
|
||||
chartStates[chartId].timer = null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Clear individual chart
|
||||
function clearChart(chartId) {
|
||||
stopChart(chartId);
|
||||
|
||||
chartData[chartId] = { primary: [], secondary: [] };
|
||||
charts[chartId].data.datasets[0].data = [];
|
||||
charts[chartId].data.datasets[1].data = [];
|
||||
charts[chartId].data.datasets[1].hidden = true;
|
||||
charts[chartId].options.scales.y2.display = false;
|
||||
charts[chartId].update();
|
||||
|
||||
// Reset stats
|
||||
document.getElementById(`current${chartId}_1`).textContent = '--';
|
||||
document.getElementById(`current${chartId}_2`).textContent = '--';
|
||||
document.getElementById(`points${chartId}`).textContent = '0';
|
||||
document.getElementById(`update${chartId}`).textContent = '--';
|
||||
|
||||
// Reset title
|
||||
document.getElementById(`title${chartId}`).textContent = `Chart ${chartId}`;
|
||||
}
|
||||
|
||||
// Global controls
|
||||
function startAllCharts() {
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
const tag1 = document.getElementById(`tagSelect${i}_1`).value;
|
||||
if (tag1) {
|
||||
startChart(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function stopAllCharts() {
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
stopChart(i);
|
||||
}
|
||||
}
|
||||
|
||||
function clearAllCharts() {
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
clearChart(i);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle global setting changes
|
||||
document.getElementById('globalUpdateInterval').addEventListener('change', function() {
|
||||
globalUpdateInterval = parseInt(this.value);
|
||||
|
||||
// Update all running charts
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
if (chartStates[i].running) {
|
||||
stopChart(i);
|
||||
setTimeout(() => startChart(i), 100);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('globalTimeWindow').addEventListener('change', function() {
|
||||
globalTimeWindow = parseInt(this.value);
|
||||
|
||||
// Update all charts
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
charts[i].options.scales.x.title.text = `Last ${globalTimeWindow} Minutes`;
|
||||
if (chartStates[i].running) {
|
||||
fetchChartData(i);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize everything on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initializeCharts();
|
||||
loadTags();
|
||||
|
||||
const grid = document.querySelector('[data-multi-trend-grid]');
|
||||
if (!grid) {
|
||||
console.warn('Multi-trend grid not found for drag-and-drop.');
|
||||
return;
|
||||
}
|
||||
|
||||
Sortable.create(grid, {
|
||||
animation: 150,
|
||||
handle: '.multi-trend-card__header',
|
||||
draggable: '.multi-trend-card'
|
||||
});
|
||||
});
|
||||
|
||||
// Cleanup on page unload
|
||||
window.addEventListener('beforeunload', function() {
|
||||
stopAllCharts();
|
||||
});
|
||||
</script>
|
||||
|
||||
<?php require __DIR__ . '/../../includes/layout/footer.php'; ?>
|
||||
91
trends/live/realtime_data.php
Normal file
91
trends/live/realtime_data.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
header('Cache-Control: no-cache, must-revalidate');
|
||||
|
||||
$servername = "192.168.0.13\\SQLEXPRESS";
|
||||
$username = "opce";
|
||||
$password = "opcelasuca";
|
||||
$dbname = "history";
|
||||
|
||||
try {
|
||||
$pdo = new PDO(
|
||||
"sqlsrv:Server=$servername;Database=$dbname",
|
||||
$username,
|
||||
$password,
|
||||
[
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC
|
||||
]
|
||||
);
|
||||
|
||||
// Get parameters
|
||||
$tagName = $_GET['tag'] ?? '';
|
||||
$lastTimestamp = $_GET['last_timestamp'] ?? null;
|
||||
$limit = min((int)($_GET['limit'] ?? 500), 7200); // Max 10000 points
|
||||
|
||||
$startTime = $_GET['start_time'] ?? null;
|
||||
$endTime = $_GET['end_time'] ?? null;
|
||||
|
||||
if (empty($tagName)) {
|
||||
throw new Exception('Tag name required');
|
||||
}
|
||||
|
||||
// Build query to get latest data from archive
|
||||
$sql = "SELECT a.TimeStamp, a.Value, a.ID
|
||||
FROM dbo.archive a
|
||||
LEFT JOIN dbo.id_names n ON a.ID = n.idnumber
|
||||
WHERE n.name = :tag_name";
|
||||
|
||||
$params = [':tag_name' => $tagName];
|
||||
|
||||
if ($startTime && $endTime) {
|
||||
$sql .= " AND a.TimeStamp BETWEEN :start_time AND :end_time";
|
||||
$params[':start_time'] = $startTime;
|
||||
$params[':end_time'] = $endTime;
|
||||
} elseif ($lastTimestamp) {
|
||||
$sql .= " AND a.TimeStamp > :last_timestamp";
|
||||
$params[':last_timestamp'] = $lastTimestamp;
|
||||
}
|
||||
|
||||
$sql .= " ORDER BY a.TimeStamp DESC OFFSET 0 ROWS FETCH NEXT :limit ROWS ONLY";
|
||||
|
||||
$stmt = $pdo->prepare($sql);
|
||||
|
||||
foreach ($params as $key => $value) {
|
||||
$stmt->bindValue($key, $value);
|
||||
}
|
||||
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
|
||||
|
||||
$stmt->execute();
|
||||
$results = $stmt->fetchAll();
|
||||
|
||||
// Reverse to get chronological order
|
||||
$results = array_reverse($results);
|
||||
|
||||
// Format data for Chart.js
|
||||
$data = [];
|
||||
foreach ($results as $row) {
|
||||
$data[] = [
|
||||
'x' => $row['TimeStamp'],
|
||||
'y' => (float)$row['Value'],
|
||||
'timestamp' => $row['TimeStamp']
|
||||
];
|
||||
}
|
||||
|
||||
$response = [
|
||||
'success' => true,
|
||||
'data' => $data,
|
||||
'count' => count($data),
|
||||
'latest_timestamp' => !empty($data) ? end($data)['timestamp'] : null,
|
||||
'tag' => $tagName
|
||||
];
|
||||
|
||||
} catch (Exception $e) {
|
||||
$response = [
|
||||
'success' => false,
|
||||
'error' => $e->getMessage()
|
||||
];
|
||||
}
|
||||
|
||||
echo json_encode($response);
|
||||
?>
|
||||
91
trends/live/realtime_data_fivechart.php
Normal file
91
trends/live/realtime_data_fivechart.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
header('Cache-Control: no-cache, must-revalidate');
|
||||
|
||||
$servername = "192.168.0.13\\SQLEXPRESS";
|
||||
$username = "opce";
|
||||
$password = "opcelasuca";
|
||||
$dbname = "history";
|
||||
|
||||
try {
|
||||
$pdo = new PDO(
|
||||
"sqlsrv:Server=$servername;Database=$dbname",
|
||||
$username,
|
||||
$password,
|
||||
[
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC
|
||||
]
|
||||
);
|
||||
|
||||
// Get parameters
|
||||
$tagName = $_GET['tag'] ?? '';
|
||||
$lastTimestamp = $_GET['last_timestamp'] ?? null;
|
||||
$limit = min((int)($_GET['limit'] ?? 500), 32000); // Max 10000 points
|
||||
|
||||
$startTime = $_GET['start_time'] ?? null;
|
||||
$endTime = $_GET['end_time'] ?? null;
|
||||
|
||||
if (empty($tagName)) {
|
||||
throw new Exception('Tag name required');
|
||||
}
|
||||
|
||||
// Build query to get latest data from archive
|
||||
$sql = "SELECT a.TimeStamp, a.Value, a.ID
|
||||
FROM dbo.archive a
|
||||
LEFT JOIN dbo.id_names n ON a.ID = n.idnumber
|
||||
WHERE n.name = :tag_name";
|
||||
|
||||
$params = [':tag_name' => $tagName];
|
||||
|
||||
if ($startTime && $endTime) {
|
||||
$sql .= " AND a.TimeStamp BETWEEN :start_time AND :end_time";
|
||||
$params[':start_time'] = $startTime;
|
||||
$params[':end_time'] = $endTime;
|
||||
} elseif ($lastTimestamp) {
|
||||
$sql .= " AND a.TimeStamp > :last_timestamp";
|
||||
$params[':last_timestamp'] = $lastTimestamp;
|
||||
}
|
||||
|
||||
$sql .= " ORDER BY a.TimeStamp DESC OFFSET 0 ROWS FETCH NEXT :limit ROWS ONLY";
|
||||
|
||||
$stmt = $pdo->prepare($sql);
|
||||
|
||||
foreach ($params as $key => $value) {
|
||||
$stmt->bindValue($key, $value);
|
||||
}
|
||||
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
|
||||
|
||||
$stmt->execute();
|
||||
$results = $stmt->fetchAll();
|
||||
|
||||
// Reverse to get chronological order
|
||||
$results = array_reverse($results);
|
||||
|
||||
// Format data for Chart.js
|
||||
$data = [];
|
||||
foreach ($results as $row) {
|
||||
$data[] = [
|
||||
'x' => $row['TimeStamp'],
|
||||
'y' => (float)$row['Value'],
|
||||
'timestamp' => $row['TimeStamp']
|
||||
];
|
||||
}
|
||||
|
||||
$response = [
|
||||
'success' => true,
|
||||
'data' => $data,
|
||||
'count' => count($data),
|
||||
'latest_timestamp' => !empty($data) ? end($data)['timestamp'] : null,
|
||||
'tag' => $tagName
|
||||
];
|
||||
|
||||
} catch (Exception $e) {
|
||||
$response = [
|
||||
'success' => false,
|
||||
'error' => $e->getMessage()
|
||||
];
|
||||
}
|
||||
|
||||
echo json_encode($response);
|
||||
?>
|
||||
429
trends/live/searchstyle.css
Normal file
429
trends/live/searchstyle.css
Normal file
@@ -0,0 +1,429 @@
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
margin: 20px;
|
||||
background-color: #555555;
|
||||
}
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
background-color: rgb(192, 192, 192);
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
.search-form {
|
||||
margin-bottom: 20px;
|
||||
padding: 15px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 5px;
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
.search-type-tabs {
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 2px solid #ddd;
|
||||
}
|
||||
.tab {
|
||||
display: inline-block;
|
||||
padding: 10px 20px;
|
||||
margin-right: 5px;
|
||||
background-color: #f1f1f1;
|
||||
border: 1px solid #ddd;
|
||||
border-bottom: none;
|
||||
cursor: pointer;
|
||||
border-radius: 5px 5px 0 0;
|
||||
}
|
||||
.tab.active {
|
||||
background-color: #007cba;
|
||||
color: white;
|
||||
border-color: #007cba;
|
||||
}
|
||||
.search-option {
|
||||
display: none;
|
||||
padding: 15px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 0 5px 5px 5px;
|
||||
background-color: white;
|
||||
}
|
||||
.search-option.active {
|
||||
display: block;
|
||||
}
|
||||
.form-row {
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.form-row.checkbox {
|
||||
align-items: flex-start;
|
||||
}
|
||||
label {
|
||||
display: inline-block;
|
||||
width: 120px;
|
||||
font-weight: bold;
|
||||
}
|
||||
label.checkbox {
|
||||
width: auto;
|
||||
margin-left: 5px;
|
||||
}
|
||||
input[type="text"], input[type="datetime-local"], select, textarea {
|
||||
width: 200px;
|
||||
padding: 8px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
}
|
||||
textarea {
|
||||
width: 300px;
|
||||
height: 60px;
|
||||
resize: vertical;
|
||||
}
|
||||
input[type="checkbox"] {
|
||||
width: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Autocomplete styling */
|
||||
.autocomplete-container {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
.autocomplete-suggestions {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: white;
|
||||
border: 1px solid #ccc;
|
||||
border-top: none;
|
||||
border-radius: 0 0 4px 4px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
z-index: 1000;
|
||||
display: none;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||||
}
|
||||
.autocomplete-suggestion {
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid #eee;
|
||||
user-select: none;
|
||||
color: #333;
|
||||
}
|
||||
.autocomplete-suggestion:hover,
|
||||
.autocomplete-suggestion.active {
|
||||
background-color: #f0f8ff;
|
||||
color: #007cba;
|
||||
}
|
||||
.autocomplete-suggestion:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Fix for textarea autocomplete container */
|
||||
.autocomplete-container textarea {
|
||||
width: 300px;
|
||||
height: 60px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
/* Ensure suggestions dropdown matches textarea width */
|
||||
.autocomplete-container:has(textarea) .autocomplete-suggestions {
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 8px 15px;
|
||||
margin-right: 10px;
|
||||
background-color: #007cba;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover {
|
||||
background-color: #005a87;
|
||||
}
|
||||
button[type="button"] {
|
||||
background-color: #6c757d;
|
||||
}
|
||||
button[type="button"]:hover {
|
||||
background-color: #545b62;
|
||||
}
|
||||
.trend-controls {
|
||||
margin: 20px 0;
|
||||
padding: 15px;
|
||||
background-color: #e9ecef;
|
||||
border-radius: 5px;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
.chart-container {
|
||||
margin: 20px 0;
|
||||
padding: 20px;
|
||||
background-color: white;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
.chart-wrapper {
|
||||
position: relative;
|
||||
height: 400px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.stats-panel {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 15px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.stat-box {
|
||||
background-color: #f8f9fa;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
border-left: 4px solid #007cba;
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #007cba;
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin-top: 20px;
|
||||
}
|
||||
th, td {
|
||||
border: 1px solid #ddd;
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
th {
|
||||
background-color: #f2f2f2;
|
||||
font-weight: bold;
|
||||
}
|
||||
th.timestamp-header {
|
||||
background-color: #007cba;
|
||||
color: white;
|
||||
}
|
||||
tr:nth-child(even) {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
tr:hover {
|
||||
background-color: #f0f8ff;
|
||||
}
|
||||
.no-results {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
margin-top: 20px;
|
||||
padding: 20px;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.results-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
padding: 10px;
|
||||
background-color: #e9ecef;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.timestamp {
|
||||
font-family: monospace;
|
||||
font-weight: bold;
|
||||
}
|
||||
.numeric-value {
|
||||
text-align: right;
|
||||
font-family: monospace;
|
||||
}
|
||||
.export-buttons {
|
||||
margin: 10px 0;
|
||||
padding: 10px;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #dee2e6;
|
||||
}
|
||||
.export-buttons button {
|
||||
background-color: #28a745;
|
||||
margin-right: 10px;
|
||||
padding: 8px 15px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
.export-buttons button:hover {
|
||||
background-color: #218838;
|
||||
}
|
||||
.export-buttons button:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
.help-text {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.table-scroll {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
select {
|
||||
width: 250px; /* Wider for tag names */
|
||||
padding: 8px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
background-color: white;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
select:focus {
|
||||
border-color: #007cba;
|
||||
outline: none;
|
||||
box-shadow: 0 0 5px rgba(0, 124, 186, 0.3);
|
||||
}
|
||||
|
||||
/* Remove autocomplete styling for multiple names since we're not using it */
|
||||
#multiple_names .autocomplete-container {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.form-row small {
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.interval-info {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Loading indicator styles */
|
||||
.loading-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: none;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
text-align: center;
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
border: 4px solid #f3f3f3;
|
||||
border-top: 4px solid #007cba;
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 15px auto;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.loading-subtext {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Progress bar styles */
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background-color: #f3f3f3;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #007cba, #005a87);
|
||||
width: 0%;
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
animation: progressPulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes progressPulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
/* Button loading state */
|
||||
button[type="submit"]:disabled {
|
||||
background-color: #6c757d;
|
||||
cursor: not-allowed;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
button[type="submit"]:disabled::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: auto;
|
||||
border: 2px solid transparent;
|
||||
border-top-color: #ffffff;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 10px;
|
||||
}
|
||||
|
||||
/* Inline search status */
|
||||
.search-status {
|
||||
margin: 15px 0;
|
||||
padding: 12px;
|
||||
background-color: #e7f3ff;
|
||||
border: 1px solid #b3d9ff;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.mini-spinner {
|
||||
border: 2px solid #f3f3f3;
|
||||
border-top: 2px solid #007cba;
|
||||
border-radius: 50%;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
color: #007cba;
|
||||
font-weight: 500;
|
||||
}
|
||||
615
trends/live/singlechart.php
Normal file
615
trends/live/singlechart.php
Normal file
@@ -0,0 +1,615 @@
|
||||
<?php // phpcs:ignoreFile
|
||||
|
||||
require __DIR__ . '/../../session.php';
|
||||
require __DIR__ . '/../../userAccess.php';
|
||||
|
||||
$pageTitle = 'Single Chart Trending';
|
||||
$pageSubtitle = 'Monitor one or two tags with dual axes';
|
||||
$pageDescription = 'Launch the single chart beta to track critical process data in real time.';
|
||||
$assetBasePath = '../../';
|
||||
$layoutWithoutSidebar = true;
|
||||
$layoutReturnUrl = '../../overview.php';
|
||||
$layoutReturnLabel = 'Back to overview';
|
||||
|
||||
require __DIR__ . '/../../includes/layout/header.php';
|
||||
?>
|
||||
|
||||
<div class="app-content">
|
||||
<section class="data-panel trend-panel">
|
||||
<header class="trend-panel__header">
|
||||
<div>
|
||||
<h2 id="chartTitle">Real-time process trend</h2>
|
||||
<p>Select a primary tag and optional secondary tag to start streaming live data.</p>
|
||||
</div>
|
||||
<span class="trend-status trend-status--stopped" id="chartStatus">STOPPED</span>
|
||||
</header>
|
||||
|
||||
<div class="trend-control-grid">
|
||||
<div class="trend-control">
|
||||
<label for="tagSelect1">Primary tag</label>
|
||||
<select id="tagSelect1">
|
||||
<option value="">Loading tags...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="trend-control">
|
||||
<label for="tagSelect2">Secondary tag</label>
|
||||
<select id="tagSelect2">
|
||||
<option value="">Optional secondary...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="trend-control">
|
||||
<label for="updateInterval">Update interval</label>
|
||||
<select id="updateInterval">
|
||||
<option value="1000">Every 1 second</option>
|
||||
<option value="5000" selected>Every 5 seconds</option>
|
||||
<option value="10000">Every 10 seconds</option>
|
||||
<option value="30000">Every 30 seconds</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="trend-control">
|
||||
<label>Time window</label>
|
||||
<div class="time-window-display">Last 15 minutes (sliding)</div>
|
||||
</div>
|
||||
<div class="trend-control">
|
||||
<label for="startBtn">Controls</label>
|
||||
<div class="trend-control__actions">
|
||||
<button type="button" class="button button--success" id="startBtn" onclick="startRealTime()">Start</button>
|
||||
<button type="button" class="button button--danger" id="stopBtn" onclick="stopRealTime()" disabled>Stop</button>
|
||||
<button type="button" class="button button--ghost" onclick="clearChart()">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="status" class="trend-connection trend-connection--disconnected">
|
||||
Disconnected — Select a primary tag and click Start to begin trending
|
||||
</div>
|
||||
|
||||
<div class="trend-stat-grid">
|
||||
<div class="trend-stat">
|
||||
<span id="currentValue1" class="trend-stat__value">--</span>
|
||||
<span class="trend-stat__label" id="tag1Label">Primary current</span>
|
||||
</div>
|
||||
<div class="trend-stat">
|
||||
<span id="currentValue2" class="trend-stat__value trend-stat__value--secondary">--</span>
|
||||
<span class="trend-stat__label" id="tag2Label">Secondary current</span>
|
||||
</div>
|
||||
<div class="trend-stat">
|
||||
<span id="minValue1" class="trend-stat__value">--</span>
|
||||
<span class="trend-stat__label">Primary min</span>
|
||||
</div>
|
||||
<div class="trend-stat">
|
||||
<span id="maxValue1" class="trend-stat__value">--</span>
|
||||
<span class="trend-stat__label">Primary max</span>
|
||||
</div>
|
||||
<div class="trend-stat">
|
||||
<span id="minValue2" class="trend-stat__value trend-stat__value--secondary">--</span>
|
||||
<span class="trend-stat__label">Secondary min</span>
|
||||
</div>
|
||||
<div class="trend-stat">
|
||||
<span id="maxValue2" class="trend-stat__value trend-stat__value--secondary">--</span>
|
||||
<span class="trend-stat__label">Secondary max</span>
|
||||
</div>
|
||||
<div class="trend-stat">
|
||||
<span id="dataPoints" class="trend-stat__value">0</span>
|
||||
<span class="trend-stat__label">Total points</span>
|
||||
</div>
|
||||
<div class="trend-stat">
|
||||
<span id="lastUpdate" class="trend-stat__value">--</span>
|
||||
<span class="trend-stat__label">Last update</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="trend-chart">
|
||||
<canvas id="realtimeChart"></canvas>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/moment@2.29.4/moment.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-moment@1.0.1/dist/chartjs-adapter-moment.min.js"></script>
|
||||
|
||||
<script>
|
||||
let chart = null;
|
||||
let updateTimer = null;
|
||||
let isRunning = false;
|
||||
let lastTimestamp1 = null;
|
||||
let lastTimestamp2 = null;
|
||||
let chartData1 = [];
|
||||
let chartData2 = [];
|
||||
let timeWindowMinutes = 15;
|
||||
|
||||
// Initialize Chart.js with dual datasets
|
||||
function initChart() {
|
||||
const ctx = document.getElementById('realtimeChart').getContext('2d');
|
||||
|
||||
chart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
datasets: [{
|
||||
label: 'Primary Tag',
|
||||
data: [],
|
||||
borderColor: '#3498db',
|
||||
backgroundColor: 'rgba(52, 152, 219, 0.1)',
|
||||
borderWidth: 2,
|
||||
fill: false,
|
||||
tension: 0.1,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 0,
|
||||
hitRadius: 0,
|
||||
yAxisID: 'y1'
|
||||
}, {
|
||||
label: 'Secondary Tag',
|
||||
data: [],
|
||||
borderColor: '#27ae60',
|
||||
backgroundColor: 'rgba(39, 174, 96, 0.1)',
|
||||
borderWidth: 2,
|
||||
fill: false,
|
||||
tension: 0.1,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 0,
|
||||
hitRadius: 0,
|
||||
yAxisID: 'y2',
|
||||
hidden: true
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: {
|
||||
duration: 300
|
||||
},
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'index'
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: 'time',
|
||||
time: {
|
||||
unit: 'minute',
|
||||
displayFormats: {
|
||||
second: 'HH:mm:ss',
|
||||
minute: 'HH:mm',
|
||||
hour: 'HH:mm'
|
||||
}
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Time (Last 15 Minutes)',
|
||||
font: { size: 12, weight: 'bold' }
|
||||
},
|
||||
min: function() {
|
||||
return moment().subtract(timeWindowMinutes, 'minutes').toDate();
|
||||
},
|
||||
max: function() {
|
||||
return moment().toDate();
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(0,0,0,0.1)'
|
||||
}
|
||||
},
|
||||
y1: {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'left',
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Primary Tag Value',
|
||||
color: '#3498db',
|
||||
font: { size: 12, weight: 'bold' }
|
||||
},
|
||||
grid: {
|
||||
drawOnChartArea: true,
|
||||
color: 'rgba(52, 152, 219, 0.1)'
|
||||
},
|
||||
ticks: {
|
||||
color: '#3498db',
|
||||
font: { weight: 'bold' }
|
||||
}
|
||||
},
|
||||
y2: {
|
||||
type: 'linear',
|
||||
display: false,
|
||||
position: 'right',
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Secondary Tag Value',
|
||||
color: '#27ae60',
|
||||
font: { size: 12, weight: 'bold' }
|
||||
},
|
||||
grid: {
|
||||
drawOnChartArea: false,
|
||||
},
|
||||
ticks: {
|
||||
color: '#27ae60',
|
||||
font: { weight: 'bold' }
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'top',
|
||||
labels: {
|
||||
font: { size: 12, weight: 'bold' },
|
||||
usePointStyle: true,
|
||||
padding: 20
|
||||
},
|
||||
onClick: function(e, legendItem, legend) {
|
||||
const index = legendItem.datasetIndex;
|
||||
const ci = legend.chart;
|
||||
const dataset = ci.data.datasets[index];
|
||||
|
||||
dataset.hidden = !dataset.hidden;
|
||||
|
||||
if (index === 1) {
|
||||
ci.options.scales.y2.display = !dataset.hidden;
|
||||
}
|
||||
|
||||
ci.update();
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(44, 62, 80, 0.9)',
|
||||
titleColor: '#3498db',
|
||||
bodyColor: '#ecf0f1',
|
||||
borderColor: '#3498db',
|
||||
borderWidth: 1,
|
||||
titleFont: { size: 13, weight: 'bold' },
|
||||
bodyFont: { size: 12 },
|
||||
callbacks: {
|
||||
title: function(context) {
|
||||
return moment(context[0].parsed.x).format('YYYY-MM-DD HH:mm:ss');
|
||||
},
|
||||
afterTitle: function(context) {
|
||||
const now = moment();
|
||||
const pointTime = moment(context[0].parsed.x);
|
||||
const secondsAgo = now.diff(pointTime, 'seconds');
|
||||
return `(${secondsAgo} seconds ago)`;
|
||||
},
|
||||
label: function(context) {
|
||||
const datasetLabel = context.dataset.label;
|
||||
const value = context.parsed.y.toFixed(2);
|
||||
return `${datasetLabel}: ${value}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}); // This closing brace and parenthesis were missing!
|
||||
}
|
||||
|
||||
// Load available tags for both dropdowns
|
||||
async function loadTags() {
|
||||
const select1 = document.getElementById('tagSelect1');
|
||||
const select2 = document.getElementById('tagSelect2');
|
||||
|
||||
if (!select1 || !select2) {
|
||||
console.error('Tag select elements not found in DOM.');
|
||||
return;
|
||||
}
|
||||
|
||||
const setMessage = (message) => {
|
||||
select1.innerHTML = `<option value="">${message}</option>`;
|
||||
select2.innerHTML = `<option value="">${message}</option>`;
|
||||
};
|
||||
|
||||
setMessage('Loading tags...');
|
||||
|
||||
try {
|
||||
const response = await fetch('get_tags.php', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
cache: 'no-cache'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
select1.innerHTML = '<option value="">Select primary tag...</option>';
|
||||
select2.innerHTML = '<option value="">Select secondary tag...</option>';
|
||||
|
||||
if (data.success && Array.isArray(data.tags) && data.tags.length > 0) {
|
||||
data.tags.forEach((tag) => {
|
||||
if (!tag || !tag.name) {
|
||||
return;
|
||||
}
|
||||
|
||||
const option1 = document.createElement('option');
|
||||
option1.value = tag.name;
|
||||
option1.textContent = tag.name;
|
||||
select1.appendChild(option1);
|
||||
|
||||
const option2 = document.createElement('option');
|
||||
option2.value = tag.name;
|
||||
option2.textContent = tag.name;
|
||||
select2.appendChild(option2);
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const errorMessage = data.error || 'No tags available';
|
||||
setMessage(errorMessage);
|
||||
} catch (error) {
|
||||
console.error('Failed to load tags:', error);
|
||||
setMessage(`Error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch data for one or both tags
|
||||
async function fetchTagData(tagName, datasetIndex) {
|
||||
if (!tagName) return null;
|
||||
|
||||
try {
|
||||
const endTime = moment().format('YYYY-MM-DD HH:mm:ss');
|
||||
const startTime = moment().subtract(timeWindowMinutes, 'minutes').format('YYYY-MM-DD HH:mm:ss');
|
||||
|
||||
let url = `realtime_data.php?tag=${encodeURIComponent(tagName)}&limit=1500&start_time=${encodeURIComponent(startTime)}&end_time=${encodeURIComponent(endTime)}`;
|
||||
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.data && data.data.length > 0) {
|
||||
const formattedData = data.data.map(point => ({
|
||||
x: new Date(point.x),
|
||||
y: point.y
|
||||
}));
|
||||
|
||||
const cutoffTime = moment().subtract(timeWindowMinutes, 'minutes').toDate();
|
||||
return formattedData.filter(point => point.x >= cutoffTime);
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error(`Error fetching data for ${tagName}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Modified fetchData for dual tags
|
||||
async function fetchData() {
|
||||
const tagName1 = document.getElementById('tagSelect1').value;
|
||||
const tagName2 = document.getElementById('tagSelect2').value;
|
||||
|
||||
if (!tagName1) return;
|
||||
|
||||
try {
|
||||
const data1 = await fetchTagData(tagName1, 0);
|
||||
|
||||
if (data1 !== null) {
|
||||
chartData1 = data1;
|
||||
chart.data.datasets[0].data = chartData1;
|
||||
chart.data.datasets[0].label = tagName1;
|
||||
}
|
||||
|
||||
if (tagName2 && tagName2 !== tagName1) {
|
||||
const data2 = await fetchTagData(tagName2, 1);
|
||||
|
||||
if (data2 !== null) {
|
||||
chartData2 = data2;
|
||||
chart.data.datasets[1].data = chartData2;
|
||||
chart.data.datasets[1].label = tagName2;
|
||||
chart.data.datasets[1].hidden = false;
|
||||
chart.options.scales.y2.display = true;
|
||||
}
|
||||
} else {
|
||||
chartData2 = [];
|
||||
chart.data.datasets[1].data = [];
|
||||
chart.data.datasets[1].hidden = true;
|
||||
chart.options.scales.y2.display = false;
|
||||
}
|
||||
|
||||
chart.options.scales.x.min = moment().subtract(timeWindowMinutes, 'minutes').toDate();
|
||||
chart.options.scales.x.max = moment().toDate();
|
||||
|
||||
chart.update('active');
|
||||
|
||||
updateStatus('connected', `Connected - Last update: ${new Date().toLocaleTimeString()}`);
|
||||
updateStatistics();
|
||||
updateChartTitle();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Fetch error:', error);
|
||||
updateStatus('disconnected', `Connection error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Update chart title
|
||||
function updateChartTitle() {
|
||||
const tagName1 = document.getElementById('tagSelect1').value;
|
||||
const tagName2 = document.getElementById('tagSelect2').value;
|
||||
|
||||
let title = 'Real-Time Process Trend';
|
||||
if (tagName1) {
|
||||
title = tagName1;
|
||||
if (tagName2) {
|
||||
title += ` & ${tagName2}`;
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('chartTitle').textContent = title;
|
||||
}
|
||||
|
||||
// Enhanced statistics for dual tags
|
||||
function updateStatistics() {
|
||||
const tagName1 = document.getElementById('tagSelect1').value;
|
||||
const tagName2 = document.getElementById('tagSelect2').value;
|
||||
|
||||
document.getElementById('tag1Label').textContent = tagName1 ? `${tagName1} Current` : 'Primary Current';
|
||||
document.getElementById('tag2Label').textContent = tagName2 ? `${tagName2} Current` : 'Secondary Current';
|
||||
|
||||
// Primary tag statistics
|
||||
if (chartData1.length > 0) {
|
||||
const values1 = chartData1.map(point => point.y);
|
||||
const current1 = values1[values1.length - 1];
|
||||
const min1 = Math.min(...values1);
|
||||
const max1 = Math.max(...values1);
|
||||
|
||||
document.getElementById('currentValue1').textContent = current1.toFixed(2);
|
||||
document.getElementById('minValue1').textContent = min1.toFixed(2);
|
||||
document.getElementById('maxValue1').textContent = max1.toFixed(2);
|
||||
} else {
|
||||
document.getElementById('currentValue1').textContent = '--';
|
||||
document.getElementById('minValue1').textContent = '--';
|
||||
document.getElementById('maxValue1').textContent = '--';
|
||||
}
|
||||
|
||||
// Secondary tag statistics
|
||||
if (chartData2.length > 0) {
|
||||
const values2 = chartData2.map(point => point.y);
|
||||
const current2 = values2[values2.length - 1];
|
||||
const min2 = Math.min(...values2);
|
||||
const max2 = Math.max(...values2);
|
||||
|
||||
document.getElementById('currentValue2').textContent = current2.toFixed(2);
|
||||
document.getElementById('minValue2').textContent = min2.toFixed(2);
|
||||
document.getElementById('maxValue2').textContent = max2.toFixed(2);
|
||||
} else {
|
||||
document.getElementById('currentValue2').textContent = '--';
|
||||
document.getElementById('minValue2').textContent = '--';
|
||||
document.getElementById('maxValue2').textContent = '--';
|
||||
}
|
||||
|
||||
const totalPoints = chartData1.length + chartData2.length;
|
||||
document.getElementById('dataPoints').textContent = totalPoints;
|
||||
document.getElementById('lastUpdate').textContent = moment().format('HH:mm:ss');
|
||||
}
|
||||
|
||||
// Update status display
|
||||
function updateStatus(type, message) {
|
||||
const status = document.getElementById('status');
|
||||
const chartStatus = document.getElementById('chartStatus');
|
||||
const stateClass = type === 'connected' ? 'connected' : 'disconnected';
|
||||
|
||||
status.className = `trend-connection trend-connection--${stateClass}`;
|
||||
status.textContent = message;
|
||||
|
||||
if (stateClass === 'connected') {
|
||||
chartStatus.className = 'trend-status trend-status--running';
|
||||
chartStatus.textContent = 'RUNNING';
|
||||
} else {
|
||||
chartStatus.className = 'trend-status trend-status--stopped';
|
||||
chartStatus.textContent = 'STOPPED';
|
||||
}
|
||||
}
|
||||
|
||||
// Start real-time updates
|
||||
function startRealTime() {
|
||||
const tagName1 = document.getElementById('tagSelect1').value;
|
||||
if (!tagName1) {
|
||||
alert('Please select a primary tag first');
|
||||
return;
|
||||
}
|
||||
|
||||
isRunning = true;
|
||||
lastTimestamp1 = null;
|
||||
lastTimestamp2 = null;
|
||||
|
||||
const interval = parseInt(document.getElementById('updateInterval').value);
|
||||
|
||||
document.getElementById('startBtn').disabled = true;
|
||||
document.getElementById('stopBtn').disabled = false;
|
||||
document.getElementById('tagSelect1').disabled = true;
|
||||
document.getElementById('tagSelect2').disabled = true;
|
||||
|
||||
fetchData();
|
||||
|
||||
updateTimer = setInterval(() => {
|
||||
fetchData();
|
||||
}, interval);
|
||||
|
||||
const tagName2 = document.getElementById('tagSelect2').value;
|
||||
const statusMessage = tagName2 ?
|
||||
`Trending ${tagName1} & ${tagName2} - Update every ${interval/1000}s` :
|
||||
`Trending ${tagName1} - Update every ${interval/1000}s`;
|
||||
|
||||
updateStatus('connected', statusMessage);
|
||||
}
|
||||
|
||||
// Stop real-time updates
|
||||
function stopRealTime() {
|
||||
isRunning = false;
|
||||
|
||||
if (updateTimer) {
|
||||
clearInterval(updateTimer);
|
||||
updateTimer = null;
|
||||
}
|
||||
|
||||
document.getElementById('startBtn').disabled = false;
|
||||
document.getElementById('stopBtn').disabled = true;
|
||||
document.getElementById('tagSelect1').disabled = false;
|
||||
document.getElementById('tagSelect2').disabled = false;
|
||||
|
||||
updateStatus('disconnected', 'Real-time updates stopped - Select tags and click Start to resume');
|
||||
}
|
||||
|
||||
// Clear chart data
|
||||
function clearChart() {
|
||||
chartData1 = [];
|
||||
chartData2 = [];
|
||||
lastTimestamp1 = null;
|
||||
lastTimestamp2 = null;
|
||||
|
||||
if (chart) {
|
||||
chart.data.datasets[0].data = [];
|
||||
chart.data.datasets[1].data = [];
|
||||
chart.data.datasets[1].hidden = true;
|
||||
chart.options.scales.y2.display = false;
|
||||
chart.update();
|
||||
}
|
||||
|
||||
// Reset all statistics
|
||||
document.getElementById('currentValue1').textContent = '--';
|
||||
document.getElementById('currentValue2').textContent = '--';
|
||||
document.getElementById('minValue1').textContent = '--';
|
||||
document.getElementById('maxValue1').textContent = '--';
|
||||
document.getElementById('minValue2').textContent = '--';
|
||||
document.getElementById('maxValue2').textContent = '--';
|
||||
document.getElementById('dataPoints').textContent = '0';
|
||||
document.getElementById('lastUpdate').textContent = '--';
|
||||
|
||||
// Reset title
|
||||
document.getElementById('chartTitle').textContent = 'Real-Time Process Trend';
|
||||
}
|
||||
|
||||
// Handle interval changes
|
||||
document.getElementById('updateInterval').addEventListener('change', function() {
|
||||
if (isRunning) {
|
||||
stopRealTime();
|
||||
setTimeout(startRealTime, 100);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle secondary tag changes during runtime
|
||||
document.getElementById('tagSelect2').addEventListener('change', function() {
|
||||
if (isRunning) {
|
||||
fetchData();
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initChart();
|
||||
|
||||
// Add a small delay to ensure DOM is fully ready
|
||||
setTimeout(loadTags, 100);
|
||||
});
|
||||
|
||||
// Cleanup on page unload
|
||||
window.addEventListener('beforeunload', function() {
|
||||
if (updateTimer) {
|
||||
clearInterval(updateTimer);
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<?php require __DIR__ . '/../../includes/layout/footer.php'; ?>
|
||||
78
trends/live/websocket_server.php
Normal file
78
trends/live/websocket_server.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
require_once 'vendor/autoload.php'; // Install ReactPHP via Composer
|
||||
|
||||
use React\Socket\Server;
|
||||
use React\Http\Server as HttpServer;
|
||||
use React\Stream\WritableResourceStream;
|
||||
|
||||
$loop = React\EventLoop\Loop::get();
|
||||
|
||||
// Database connection
|
||||
$pdo = new PDO("mysql:host=192.168.0.13;dbname=history", "opce", "opcelasuca");
|
||||
|
||||
// WebSocket connections
|
||||
$connections = [];
|
||||
|
||||
// Create WebSocket server
|
||||
$server = new HttpServer($loop, function ($request) use (&$connections, $pdo) {
|
||||
// Handle WebSocket upgrade
|
||||
if ($request->getHeaderLine('Upgrade') === 'websocket') {
|
||||
$connection = new WebSocketConnection();
|
||||
$connections[] = $connection;
|
||||
|
||||
// Send initial data
|
||||
$connection->send(json_encode([
|
||||
'type' => 'welcome',
|
||||
'message' => 'Connected to real-time data stream'
|
||||
]));
|
||||
|
||||
return $connection;
|
||||
}
|
||||
|
||||
return new React\Http\Response(404, [], 'WebSocket endpoint only');
|
||||
});
|
||||
|
||||
// Periodic data broadcast
|
||||
$loop->addPeriodicTimer(1.0, function() use (&$connections, $pdo) {
|
||||
if (empty($connections)) return;
|
||||
|
||||
try {
|
||||
// Get latest data for all active tags
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT h.TimeStamp, h.Value, n.name
|
||||
FROM historicaldata h
|
||||
LEFT JOIN id_names n ON h.ID = n.idnumber
|
||||
WHERE h.TimeStamp >= NOW() - INTERVAL 5 SECOND
|
||||
ORDER BY h.TimeStamp DESC
|
||||
");
|
||||
$stmt->execute();
|
||||
$data = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!empty($data)) {
|
||||
$message = json_encode([
|
||||
'type' => 'data_update',
|
||||
'timestamp' => date('c'),
|
||||
'data' => $data
|
||||
]);
|
||||
|
||||
// Broadcast to all connections
|
||||
foreach ($connections as $key => $connection) {
|
||||
if ($connection->isConnected()) {
|
||||
$connection->send($message);
|
||||
} else {
|
||||
unset($connections[$key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("WebSocket error: " . $e->getMessage());
|
||||
}
|
||||
});
|
||||
|
||||
$socket = new Server('0.0.0.0:8080', $loop);
|
||||
$server->listen($socket);
|
||||
|
||||
echo "WebSocket server running on port 8080\n";
|
||||
$loop->run();
|
||||
?>
|
||||
Reference in New Issue
Block a user