Folder reorganize 1
This commit is contained in:
1297
trends/cohort-archive.php
Normal file
1297
trends/cohort-archive.php
Normal file
File diff suppressed because it is too large
Load Diff
1285
trends/cohort.php
Normal file
1285
trends/cohort.php
Normal file
File diff suppressed because it is too large
Load Diff
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();
|
||||
?>
|
||||
1170
trends/pivot.php
Normal file
1170
trends/pivot.php
Normal file
File diff suppressed because it is too large
Load Diff
489
trends/static/fivechart.php
Normal file
489
trends/static/fivechart.php
Normal file
@@ -0,0 +1,489 @@
|
||||
<?php // phpcs:ignoreFile
|
||||
require __DIR__ . '/../../session.php';
|
||||
require __DIR__ . '/../../userAccess.php';
|
||||
|
||||
$pageTitle = 'Static Multi-Tag Trend';
|
||||
$pageSubtitle = 'Capture a three-hour snapshot for up to five historian tags';
|
||||
$pageDescription = 'Select up to five tags and plot a zoomable three-hour history without auto-refresh.';
|
||||
$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">Static multi-tag snapshot</h2>
|
||||
<p>Pick up to five tags, then load a three-hour trend you can pan and zoom.</p>
|
||||
</div>
|
||||
<span class="trend-status trend-status--stopped" id="chartStatus">IDLE</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>Time window</label>
|
||||
<div class="time-window-display">Last 3 hours (static)</div>
|
||||
</div>
|
||||
<div class="trend-control">
|
||||
<label>Controls</label>
|
||||
<div class="trend-control__actions">
|
||||
<button type="button" class="button button--success" id="loadBtn">Load trend</button>
|
||||
<button type="button" class="button button--ghost" id="resetZoomBtn" disabled>Reset zoom</button>
|
||||
<button type="button" class="button button--ghost" id="clearBtn">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="status" class="trend-connection trend-connection--disconnected">
|
||||
Select at least one tag and click Load trend to build a snapshot
|
||||
</div>
|
||||
|
||||
<div id="statsWrapper" class="trend-stat-grid" hidden></div>
|
||||
|
||||
<div class="trend-chart">
|
||||
<canvas id="staticChart"></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/chartjs-plugin-zoom@2.0.1/dist/chartjs-plugin-zoom.min.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>
|
||||
if (window.Chart && window['chartjs-plugin-zoom']) {
|
||||
Chart.register(window['chartjs-plugin-zoom']);
|
||||
}
|
||||
|
||||
const MAX_TAGS = 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 lastWindow = null;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadTags();
|
||||
document.getElementById('loadBtn').addEventListener('click', handleLoad);
|
||||
document.getElementById('resetZoomBtn').addEventListener('click', resetZoom);
|
||||
document.getElementById('clearBtn').addEventListener('click', clearChart);
|
||||
});
|
||||
|
||||
function getTagSelects() {
|
||||
return Array.from(document.querySelectorAll('.tag-select'));
|
||||
}
|
||||
|
||||
function setStatus(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 = 'READY';
|
||||
} else if (state === 'loading') {
|
||||
chartStatus.className = 'trend-status trend-status--stopped';
|
||||
chartStatus.textContent = 'LOADING';
|
||||
} else if (state === 'error') {
|
||||
chartStatus.className = 'trend-status trend-status--stopped';
|
||||
chartStatus.textContent = 'ERROR';
|
||||
} else {
|
||||
chartStatus.className = 'trend-status trend-status--stopped';
|
||||
chartStatus.textContent = 'IDLE';
|
||||
}
|
||||
}
|
||||
|
||||
function loadTags() {
|
||||
const selects = getTagSelects();
|
||||
const placeholders = [
|
||||
'Select primary tag...',
|
||||
'Optional tag 2...',
|
||||
'Optional tag 3...',
|
||||
'Optional tag 4...',
|
||||
'Optional tag 5...'
|
||||
];
|
||||
|
||||
selects.forEach((select) => {
|
||||
select.innerHTML = '<option value="">Loading tags...</option>';
|
||||
select.disabled = true;
|
||||
});
|
||||
|
||||
fetch('../live/get_tags.php', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
cache: 'no-cache'
|
||||
})
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Tag request failed: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then((payload) => {
|
||||
if (!payload.success || !Array.isArray(payload.tags) || payload.tags.length === 0) {
|
||||
throw new Error(payload.error || 'No tags available');
|
||||
}
|
||||
|
||||
selects.forEach((select, index) => {
|
||||
const placeholder = placeholders[index] || 'Optional tag...';
|
||||
select.innerHTML = `<option value="">${placeholder}</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);
|
||||
});
|
||||
select.disabled = false;
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to load tags:', error);
|
||||
selects.forEach((select) => {
|
||||
select.innerHTML = `<option value="">Error: ${error.message}</option>`;
|
||||
select.disabled = true;
|
||||
});
|
||||
setStatus('error', 'Unable to load tags. Refresh and try again.');
|
||||
});
|
||||
}
|
||||
|
||||
function selectedTags() {
|
||||
return getTagSelects()
|
||||
.map((select) => select.value.trim())
|
||||
.filter((value, index, array) => value !== '' && array.indexOf(value) === index);
|
||||
}
|
||||
|
||||
function handleLoad() {
|
||||
const tags = selectedTags();
|
||||
if (tags.length === 0) {
|
||||
setStatus('error', 'Choose at least one tag to build the chart.');
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus('loading', 'Loading historian data...');
|
||||
document.getElementById('loadBtn').disabled = true;
|
||||
document.getElementById('resetZoomBtn').disabled = true;
|
||||
|
||||
fetch('get_trend_data.php', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ tags })
|
||||
})
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Trend request failed: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then((payload) => {
|
||||
if (!payload.success || !Array.isArray(payload.series) || payload.series.length === 0) {
|
||||
throw new Error(payload.error || 'No data returned for the selected tags.');
|
||||
}
|
||||
|
||||
lastWindow = payload.window || null;
|
||||
renderChart(payload.series);
|
||||
renderStats(payload.series);
|
||||
updateTitle(payload.series);
|
||||
setStatus('connected', buildStatusMessage(payload));
|
||||
document.getElementById('resetZoomBtn').disabled = false;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to load trend data:', error);
|
||||
clearChart();
|
||||
setStatus('error', error.message);
|
||||
})
|
||||
.finally(() => {
|
||||
document.getElementById('loadBtn').disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
function renderChart(series) {
|
||||
const ctx = document.getElementById('staticChart').getContext('2d');
|
||||
|
||||
if (chart) {
|
||||
chart.destroy();
|
||||
}
|
||||
|
||||
const datasets = series.map((entry, index) => {
|
||||
const style = DATASET_STYLES[index % DATASET_STYLES.length];
|
||||
return {
|
||||
label: entry.tag,
|
||||
data: entry.points.map((point) => ({ x: point.timestamp, y: point.value })),
|
||||
borderColor: style.border,
|
||||
backgroundColor: style.background,
|
||||
borderWidth: 2,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 3,
|
||||
pointHitRadius: 6,
|
||||
fill: false,
|
||||
tension: 0.1
|
||||
};
|
||||
});
|
||||
|
||||
const timeBounds = getTimeBounds(series);
|
||||
|
||||
chart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: { datasets },
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: { intersect: false, mode: 'index' },
|
||||
animation: { duration: 200 },
|
||||
scales: {
|
||||
x: {
|
||||
type: 'time',
|
||||
adapters: {
|
||||
date: {
|
||||
zone: 'local'
|
||||
}
|
||||
},
|
||||
time: {
|
||||
unit: 'minute',
|
||||
displayFormats: {
|
||||
minute: 'HH:mm',
|
||||
hour: 'HH:mm',
|
||||
second: 'HH:mm:ss'
|
||||
}
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Timestamp'
|
||||
},
|
||||
min: timeBounds.min,
|
||||
max: timeBounds.max
|
||||
},
|
||||
y: {
|
||||
type: 'linear',
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Value'
|
||||
},
|
||||
ticks: {
|
||||
callback: (value) => Number(value).toFixed(2)
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top'
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label(context) {
|
||||
if (context.parsed.y === null || Number.isNaN(context.parsed.y)) {
|
||||
return `${context.dataset.label}: --`;
|
||||
}
|
||||
return `${context.dataset.label}: ${Number(context.parsed.y).toFixed(2)}`;
|
||||
}
|
||||
}
|
||||
},
|
||||
zoom: {
|
||||
limits: {
|
||||
x: { min: timeBounds.min, max: timeBounds.max }
|
||||
},
|
||||
pan: {
|
||||
enabled: true,
|
||||
modifierKey: 'ctrl'
|
||||
},
|
||||
zoom: {
|
||||
wheel: { enabled: true },
|
||||
pinch: { enabled: true },
|
||||
drag: { enabled: true },
|
||||
mode: 'x'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getTimeBounds(series) {
|
||||
let min = null;
|
||||
let max = null;
|
||||
series.forEach((entry) => {
|
||||
entry.points.forEach((point) => {
|
||||
const ts = new Date(point.timestamp).getTime();
|
||||
if (!Number.isFinite(ts)) {
|
||||
return;
|
||||
}
|
||||
if (min === null || ts < min) {
|
||||
min = ts;
|
||||
}
|
||||
if (max === null || ts > max) {
|
||||
max = ts;
|
||||
}
|
||||
});
|
||||
});
|
||||
if (min === null || max === null) {
|
||||
const now = Date.now();
|
||||
return { min: now - (3 * 60 * 60 * 1000), max: now };
|
||||
}
|
||||
return { min, max };
|
||||
}
|
||||
|
||||
function renderStats(series) {
|
||||
const statsWrapper = document.getElementById('statsWrapper');
|
||||
statsWrapper.innerHTML = '';
|
||||
|
||||
const fragments = document.createDocumentFragment();
|
||||
|
||||
series.forEach((entry, index) => {
|
||||
const styleClass = `trend-stat--series${(index % DATASET_STYLES.length) + 1}`;
|
||||
const statCard = document.createElement('div');
|
||||
statCard.className = `trend-stat ${styleClass}`;
|
||||
statCard.innerHTML = `
|
||||
<span class="trend-stat__value">${formatStat(entry.stats?.latest)}</span>
|
||||
<span class="trend-stat__label">${entry.tag} latest</span>
|
||||
`;
|
||||
fragments.appendChild(statCard);
|
||||
|
||||
const minCard = document.createElement('div');
|
||||
minCard.className = `trend-stat ${styleClass}`;
|
||||
minCard.innerHTML = `
|
||||
<span class="trend-stat__value">${formatStat(entry.stats?.min)}</span>
|
||||
<span class="trend-stat__label">${entry.tag} min</span>
|
||||
`;
|
||||
fragments.appendChild(minCard);
|
||||
|
||||
const maxCard = document.createElement('div');
|
||||
maxCard.className = `trend-stat ${styleClass}`;
|
||||
maxCard.innerHTML = `
|
||||
<span class="trend-stat__value">${formatStat(entry.stats?.max)}</span>
|
||||
<span class="trend-stat__label">${entry.tag} max</span>
|
||||
`;
|
||||
fragments.appendChild(maxCard);
|
||||
|
||||
const countCard = document.createElement('div');
|
||||
countCard.className = 'trend-stat';
|
||||
countCard.innerHTML = `
|
||||
<span class="trend-stat__value">${entry.stats?.count ?? 0}</span>
|
||||
<span class="trend-stat__label">${entry.tag} points</span>
|
||||
`;
|
||||
fragments.appendChild(countCard);
|
||||
});
|
||||
|
||||
if (lastWindow) {
|
||||
const windowCard = document.createElement('div');
|
||||
windowCard.className = 'trend-stat';
|
||||
windowCard.innerHTML = `
|
||||
<span class="trend-stat__value">${formatWindow(lastWindow)}</span>
|
||||
<span class="trend-stat__label">Snapshot window</span>
|
||||
`;
|
||||
fragments.appendChild(windowCard);
|
||||
}
|
||||
|
||||
statsWrapper.appendChild(fragments);
|
||||
statsWrapper.hidden = false;
|
||||
}
|
||||
|
||||
function formatStat(value) {
|
||||
if (value === null || value === undefined) {
|
||||
return '--';
|
||||
}
|
||||
return Number(value).toFixed(2);
|
||||
}
|
||||
|
||||
function formatWindow(window) {
|
||||
if (!window || !window.start || !window.end) {
|
||||
return 'n/a';
|
||||
}
|
||||
const start = moment(window.start).format('YYYY-MM-DD HH:mm');
|
||||
const end = moment(window.end).format('YYYY-MM-DD HH:mm');
|
||||
return `${start} → ${end}`;
|
||||
}
|
||||
|
||||
function updateTitle(series) {
|
||||
const title = document.getElementById('chartTitle');
|
||||
if (!series || series.length === 0) {
|
||||
title.textContent = 'Static multi-tag snapshot';
|
||||
return;
|
||||
}
|
||||
title.textContent = series.map((entry) => entry.tag).join(', ');
|
||||
}
|
||||
|
||||
function buildStatusMessage(payload) {
|
||||
const parts = [];
|
||||
if (payload.series) {
|
||||
const total = payload.series.reduce((sum, entry) => sum + (entry.stats?.count ?? 0), 0);
|
||||
parts.push(`${total} point${total === 1 ? '' : 's'} loaded`);
|
||||
}
|
||||
if (payload.window && payload.window.start && payload.window.end) {
|
||||
parts.push(`Window ${moment(payload.window.start).format('HH:mm')} - ${moment(payload.window.end).format('HH:mm')}`);
|
||||
}
|
||||
return parts.join(' · ') || 'Snapshot ready';
|
||||
}
|
||||
|
||||
function resetZoom() {
|
||||
if (chart) {
|
||||
chart.resetZoom();
|
||||
}
|
||||
}
|
||||
|
||||
function clearChart() {
|
||||
if (chart) {
|
||||
chart.destroy();
|
||||
chart = null;
|
||||
}
|
||||
document.getElementById('statsWrapper').hidden = true;
|
||||
document.getElementById('resetZoomBtn').disabled = true;
|
||||
updateTitle([]);
|
||||
setStatus('idle', 'Chart cleared. Select tags and load a new snapshot.');
|
||||
}
|
||||
</script>
|
||||
|
||||
<?php require __DIR__ . '/../../includes/layout/footer.php'; ?>
|
||||
131
trends/static/get_trend_data.php
Normal file
131
trends/static/get_trend_data.php
Normal file
@@ -0,0 +1,131 @@
|
||||
<?php // phpcs:ignoreFile
|
||||
header('Content-Type: application/json');
|
||||
header('Cache-Control: no-cache, must-revalidate');
|
||||
|
||||
$payload = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
if (!is_array($payload)) {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'Invalid request payload.'
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$tags = $payload['tags'] ?? [];
|
||||
|
||||
if (!is_array($tags) || count($tags) === 0) {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'At least one tag is required.'
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$normalizedTags = [];
|
||||
foreach ($tags as $tag) {
|
||||
$trimmed = trim((string) $tag);
|
||||
if ($trimmed === '') {
|
||||
continue;
|
||||
}
|
||||
$normalizedTags[$trimmed] = true;
|
||||
}
|
||||
|
||||
if (count($normalizedTags) === 0) {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'No valid tag names provided.'
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$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
|
||||
]
|
||||
);
|
||||
|
||||
$endTime = new DateTimeImmutable('now');
|
||||
$startTime = $endTime->modify('-3 hours');
|
||||
|
||||
$series = [];
|
||||
|
||||
$query = $pdo->prepare(
|
||||
'WITH ordered_samples AS (
|
||||
SELECT TOP (7200)
|
||||
a.TimeStamp,
|
||||
a.Value
|
||||
FROM dbo.archive AS a
|
||||
INNER JOIN dbo.id_names AS n
|
||||
ON CAST(n.idnumber AS INT) = a.ID
|
||||
WHERE n.name = :tag_name
|
||||
AND a.TimeStamp BETWEEN :start_time AND :end_time
|
||||
ORDER BY a.TimeStamp DESC
|
||||
)
|
||||
SELECT TimeStamp, Value
|
||||
FROM ordered_samples
|
||||
ORDER BY TimeStamp ASC'
|
||||
);
|
||||
|
||||
foreach (array_keys($normalizedTags) as $tagName) {
|
||||
$query->execute([
|
||||
':tag_name' => $tagName,
|
||||
':start_time' => $startTime->format('Y-m-d H:i:s'),
|
||||
':end_time' => $endTime->format('Y-m-d H:i:s')
|
||||
]);
|
||||
|
||||
$rows = $query->fetchAll();
|
||||
$points = [];
|
||||
$values = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$timestamp = $row['TimeStamp'] ?? null;
|
||||
$value = isset($row['Value']) ? (float) $row['Value'] : null;
|
||||
|
||||
if ($timestamp === null || $value === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$points[] = [
|
||||
'timestamp' => $timestamp,
|
||||
'value' => $value
|
||||
];
|
||||
$values[] = $value;
|
||||
}
|
||||
|
||||
$series[] = [
|
||||
'tag' => $tagName,
|
||||
'points' => $points,
|
||||
'stats' => [
|
||||
'count' => count($values),
|
||||
'min' => count($values) > 0 ? min($values) : null,
|
||||
'max' => count($values) > 0 ? max($values) : null,
|
||||
'latest' => count($values) > 0 ? end($values) : null
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'series' => $series,
|
||||
'window' => [
|
||||
'start' => $startTime->format(DateTimeInterface::ATOM),
|
||||
'end' => $endTime->format(DateTimeInterface::ATOM)
|
||||
]
|
||||
]);
|
||||
} catch (Throwable $exception) {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => $exception->getMessage()
|
||||
]);
|
||||
}
|
||||
981
trends/tag-health.php
Normal file
981
trends/tag-health.php
Normal file
@@ -0,0 +1,981 @@
|
||||
<?php // phpcs:ignoreFile
|
||||
require __DIR__ . '/../session.php';
|
||||
require __DIR__ . '/../userAccess.php';
|
||||
|
||||
$pageTitle = 'Tag Health & Data Quality';
|
||||
$pageSubtitle = 'Monitor historian tag freshness and signal quality.';
|
||||
$pageDescription = 'Audit historian tags for stale data, null rates, and flatlined signals within a configurable window.';
|
||||
$assetBasePath = '../';
|
||||
$layoutWithoutSidebar = true;
|
||||
$layoutReturnUrl = '../overview.php';
|
||||
$layoutReturnLabel = 'Back to overview';
|
||||
|
||||
$servername = '192.168.0.13\\SQLEXPRESS';
|
||||
$username = 'opce';
|
||||
$password = 'opcelasuca';
|
||||
$dbname = 'history';
|
||||
|
||||
$appTimeZone = new DateTimeZone('America/Chicago');
|
||||
|
||||
$windowOptions = [6, 12, 24, 48, 72];
|
||||
$limitOptions = [50, 100, 200, 500];
|
||||
$defaultWindow = 24;
|
||||
$defaultLimit = 200;
|
||||
$defaultTolerance = 0.05;
|
||||
|
||||
function sanitizeWindowHours($value, array $allowed, int $fallback): int
|
||||
{
|
||||
$candidate = (int) ($value ?? 0);
|
||||
if (!in_array($candidate, $allowed, true)) {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
function sanitizeLimit($value, array $allowed, int $fallback): int
|
||||
{
|
||||
$candidate = (int) ($value ?? 0);
|
||||
if (!in_array($candidate, $allowed, true)) {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
function sanitizeTolerance($value, float $fallback): float
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
$candidate = (float) $value;
|
||||
if ($candidate <= 0) {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
function sanitizeSearch($value): string
|
||||
{
|
||||
return trim((string) ($value ?? ''));
|
||||
}
|
||||
|
||||
function createDateTimeFromSql($value, DateTimeZone $tz): ?DateTimeImmutable
|
||||
{
|
||||
if ($value instanceof DateTimeInterface) {
|
||||
return (new DateTimeImmutable($value->format('Y-m-d H:i:s.u')))->setTimezone($tz);
|
||||
}
|
||||
|
||||
if (!is_string($value) || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$patterns = ['Y-m-d H:i:s.u', 'Y-m-d H:i:s'];
|
||||
foreach ($patterns as $pattern) {
|
||||
$dt = DateTimeImmutable::createFromFormat($pattern, $value, $tz);
|
||||
if ($dt instanceof DateTimeImmutable) {
|
||||
return $dt;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return new DateTimeImmutable($value, $tz);
|
||||
} catch (Exception $exception) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
]
|
||||
);
|
||||
} catch (PDOException $exception) {
|
||||
if (isset($_POST['action'])) {
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'Database connection failed: ' . $exception->getMessage(),
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
die('Connection failed: ' . $exception->getMessage());
|
||||
}
|
||||
|
||||
if (isset($_POST['action'])) {
|
||||
ob_clean();
|
||||
header('Content-Type: application/json');
|
||||
|
||||
try {
|
||||
switch ($_POST['action']) {
|
||||
case 'get_tag_health':
|
||||
$windowHours = sanitizeWindowHours(
|
||||
$_POST['window_hours'] ?? null,
|
||||
$windowOptions,
|
||||
$defaultWindow
|
||||
);
|
||||
$limit = sanitizeLimit(
|
||||
$_POST['limit'] ?? null,
|
||||
$limitOptions,
|
||||
$defaultLimit
|
||||
);
|
||||
$flatlineTolerance = sanitizeTolerance(
|
||||
$_POST['flatline_tolerance'] ?? null,
|
||||
$defaultTolerance
|
||||
);
|
||||
$search = sanitizeSearch($_POST['search'] ?? '');
|
||||
|
||||
$params = [-$windowHours];
|
||||
$sql = "
|
||||
WITH recent AS (
|
||||
SELECT
|
||||
n.name,
|
||||
h.TimeStamp,
|
||||
CAST(h.Value AS float) AS value,
|
||||
LAG(CAST(h.Value AS float)) OVER (PARTITION BY n.name ORDER BY h.TimeStamp) AS prev_value
|
||||
FROM dbo.archive AS h
|
||||
INNER JOIN dbo.id_names AS n
|
||||
ON h.ID = n.idnumber
|
||||
WHERE h.TimeStamp >= DATEADD(HOUR, CAST(? AS INT), GETDATE())
|
||||
AND n.name IS NOT NULL
|
||||
AND n.name <> ''
|
||||
";
|
||||
|
||||
if ($search !== '') {
|
||||
$sql .= " AND n.name LIKE ?\n";
|
||||
$params[] = '%' . $search . '%';
|
||||
}
|
||||
|
||||
$sql .= ")
|
||||
,
|
||||
aggregated AS (
|
||||
SELECT
|
||||
name,
|
||||
COUNT(*) AS sample_count,
|
||||
SUM(CASE WHEN value IS NULL THEN 1 ELSE 0 END) AS null_count,
|
||||
SUM(
|
||||
CASE
|
||||
WHEN prev_value IS NOT NULL
|
||||
AND value IS NOT NULL
|
||||
AND ABS(prev_value - value) <= CAST(? AS float)
|
||||
THEN 1
|
||||
ELSE 0
|
||||
END
|
||||
) AS flatline_count,
|
||||
MIN(TimeStamp) AS first_timestamp,
|
||||
MAX(TimeStamp) AS last_timestamp,
|
||||
MIN(value) AS min_value,
|
||||
MAX(value) AS max_value,
|
||||
AVG(value) AS avg_value
|
||||
FROM recent
|
||||
GROUP BY name
|
||||
)
|
||||
SELECT TOP {$limit}
|
||||
name,
|
||||
sample_count,
|
||||
null_count,
|
||||
flatline_count,
|
||||
first_timestamp,
|
||||
last_timestamp,
|
||||
min_value,
|
||||
max_value,
|
||||
avg_value
|
||||
FROM aggregated
|
||||
ORDER BY name ASC;
|
||||
";
|
||||
|
||||
$params[] = $flatlineTolerance;
|
||||
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$rows = $stmt->fetchAll();
|
||||
|
||||
$now = new DateTimeImmutable('now', $appTimeZone);
|
||||
$windowMinutes = $windowHours * 60;
|
||||
$staleThreshold = max(30, (int) round($windowMinutes * 0.2));
|
||||
$tags = [];
|
||||
$healthyCount = 0;
|
||||
$warningCount = 0;
|
||||
$offlineCount = 0;
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$name = (string) $row['name'];
|
||||
$sampleCount = (int) ($row['sample_count'] ?? 0);
|
||||
$nullCount = (int) ($row['null_count'] ?? 0);
|
||||
$flatlineCount = (int) ($row['flatline_count'] ?? 0);
|
||||
|
||||
$lastTimestamp = createDateTimeFromSql($row['last_timestamp'] ?? null, $appTimeZone);
|
||||
$firstTimestamp = createDateTimeFromSql($row['first_timestamp'] ?? null, $appTimeZone);
|
||||
|
||||
$stalenessMinutes = null;
|
||||
if ($lastTimestamp instanceof DateTimeImmutable) {
|
||||
$stalenessMinutes = max(
|
||||
0,
|
||||
(int) floor(($now->getTimestamp() - $lastTimestamp->getTimestamp()) / 60)
|
||||
);
|
||||
}
|
||||
|
||||
$coverageMinutes = null;
|
||||
if (
|
||||
$firstTimestamp instanceof DateTimeImmutable
|
||||
&& $lastTimestamp instanceof DateTimeImmutable
|
||||
) {
|
||||
$coverageMinutes = max(
|
||||
0,
|
||||
(int) floor(($lastTimestamp->getTimestamp() - $firstTimestamp->getTimestamp()) / 60)
|
||||
);
|
||||
}
|
||||
|
||||
$uptimePercent = $sampleCount > 0
|
||||
? max(0, (1 - ($nullCount / $sampleCount)) * 100)
|
||||
: 0.0;
|
||||
$flatlinePercent = $sampleCount > 1
|
||||
? max(0, ($flatlineCount / max(1, $sampleCount - 1)) * 100)
|
||||
: 0.0;
|
||||
|
||||
$minValue = $row['min_value'] !== null ? (float) $row['min_value'] : null;
|
||||
$maxValue = $row['max_value'] !== null ? (float) $row['max_value'] : null;
|
||||
$avgValue = $row['avg_value'] !== null ? (float) $row['avg_value'] : null;
|
||||
$range = null;
|
||||
if ($minValue !== null && $maxValue !== null) {
|
||||
$range = $maxValue - $minValue;
|
||||
}
|
||||
|
||||
$status = 'healthy';
|
||||
$issues = [];
|
||||
|
||||
if ($sampleCount === 0 || $lastTimestamp === null) {
|
||||
$status = 'offline';
|
||||
$issues[] = 'No samples in the selected window.';
|
||||
} elseif ($stalenessMinutes !== null && $stalenessMinutes > $windowMinutes) {
|
||||
$status = 'offline';
|
||||
$issues[] = 'No updates within the entire analysis window.';
|
||||
} else {
|
||||
if ($stalenessMinutes !== null && $stalenessMinutes > $staleThreshold) {
|
||||
$issues[] = 'Data appears stale.';
|
||||
}
|
||||
if ($uptimePercent < 90) {
|
||||
$issues[] = 'High null rate detected.';
|
||||
}
|
||||
if ($flatlinePercent > 80) {
|
||||
$issues[] = 'Likely flatlined signal.';
|
||||
}
|
||||
if ($range !== null && abs($range) <= $flatlineTolerance) {
|
||||
$issues[] = 'Minimal variance across window.';
|
||||
}
|
||||
if (!empty($issues)) {
|
||||
$status = 'warning';
|
||||
}
|
||||
}
|
||||
|
||||
if ($status === 'healthy') {
|
||||
$healthyCount++;
|
||||
} elseif ($status === 'warning') {
|
||||
$warningCount++;
|
||||
} else {
|
||||
$offlineCount++;
|
||||
}
|
||||
|
||||
$averageSpacing = null;
|
||||
if ($coverageMinutes !== null && $sampleCount > 1) {
|
||||
$averageSpacing = $coverageMinutes / max(1, $sampleCount - 1);
|
||||
}
|
||||
|
||||
$tags[] = [
|
||||
'name' => $name,
|
||||
'status' => $status,
|
||||
'issues' => $issues,
|
||||
'sampleCount' => $sampleCount,
|
||||
'nullCount' => $nullCount,
|
||||
'flatlineCount' => $flatlineCount,
|
||||
'uptimePercent' => round($uptimePercent, 1),
|
||||
'flatlinePercent' => round($flatlinePercent, 1),
|
||||
'minValue' => $minValue,
|
||||
'maxValue' => $maxValue,
|
||||
'avgValue' => $avgValue,
|
||||
'range' => $range !== null ? round($range, 6) : null,
|
||||
'lastTimestamp' => $lastTimestamp ? $lastTimestamp->format('Y-m-d H:i:s') : null,
|
||||
'minutesSinceUpdate' => $stalenessMinutes,
|
||||
'firstTimestamp' => $firstTimestamp ? $firstTimestamp->format('Y-m-d H:i:s') : null,
|
||||
'windowCoverageMinutes' => $coverageMinutes,
|
||||
'averageSpacingMinutes' => $averageSpacing !== null ? round($averageSpacing, 1) : null,
|
||||
];
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'windowHours' => $windowHours,
|
||||
'limit' => $limit,
|
||||
'flatlineTolerance' => $flatlineTolerance,
|
||||
'generatedAt' => $now->format('Y-m-d H:i:s'),
|
||||
'totals' => [
|
||||
'total' => count($tags),
|
||||
'healthy' => $healthyCount,
|
||||
'warning' => $warningCount,
|
||||
'offline' => $offlineCount,
|
||||
],
|
||||
'tags' => $tags,
|
||||
]);
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new RuntimeException('Unsupported action.');
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
exit;
|
||||
}
|
||||
|
||||
require __DIR__ . '/../includes/layout/header.php';
|
||||
?>
|
||||
<div
|
||||
class="tag-health-overlay"
|
||||
id="tagHealthLoadingOverlay"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
class="tag-health-overlay__spinner"
|
||||
role="status"
|
||||
aria-label="Loading tag health data"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div class="app-content tag-health-app">
|
||||
<section class="data-panel tag-health-controls">
|
||||
<div class="panel-intro">
|
||||
<h2>Tag health audit</h2>
|
||||
<p>
|
||||
Surface historian tags with stale updates, high null rates, or flatlined
|
||||
readings over the selected time window.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="tag-health-controls__grid">
|
||||
<div class="tag-health-field">
|
||||
<label class="tag-health-field__label" for="tagHealthSearch">Search tags</label>
|
||||
<input
|
||||
type="search"
|
||||
id="tagHealthSearch"
|
||||
class="tag-health-input"
|
||||
placeholder="Filter by tag name"
|
||||
autocomplete="off"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="tag-health-field">
|
||||
<label class="tag-health-field__label" for="tagHealthWindow">Analysis window</label>
|
||||
<select id="tagHealthWindow" class="tag-health-select">
|
||||
<?php foreach ($windowOptions as $option) : ?>
|
||||
<option value="<?php echo (int) $option; ?>" <?php echo $option === $defaultWindow ? 'selected' : ''; ?>>
|
||||
<?php echo (int) $option; ?> hours
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="tag-health-field">
|
||||
<label class="tag-health-field__label" for="tagHealthLimit">Tag limit</label>
|
||||
<select id="tagHealthLimit" class="tag-health-select">
|
||||
<?php foreach ($limitOptions as $option) : ?>
|
||||
<option value="<?php echo (int) $option; ?>" <?php echo $option === $defaultLimit ? 'selected' : ''; ?>>
|
||||
Top <?php echo (int) $option; ?> tags
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="tag-health-field">
|
||||
<label class="tag-health-field__label" for="tagHealthTolerance">Flatline tolerance</label>
|
||||
<input
|
||||
type="number"
|
||||
id="tagHealthTolerance"
|
||||
class="tag-health-input"
|
||||
value="<?php echo htmlspecialchars((string) $defaultTolerance); ?>"
|
||||
min="0.0001"
|
||||
step="0.01"
|
||||
>
|
||||
<p class="tag-health-help">
|
||||
Signals with a change smaller than this value between samples are
|
||||
treated as flatlined.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tag-health-actions">
|
||||
<button type="button" class="button" id="tagHealthRefreshBtn">Refresh audit</button>
|
||||
<button type="button" class="button button--ghost" id="tagHealthExportBtn">Export CSV</button>
|
||||
<button type="button" class="button button--ghost" id="tagHealthToggleDebugBtn">Toggle debug</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="tagHealthStatus" class="tag-health-status" aria-live="polite"></div>
|
||||
|
||||
<section class="tag-health-summary" id="tagHealthSummary" aria-live="polite"></section>
|
||||
|
||||
<section class="data-panel tag-health-table-panel">
|
||||
<div class="tag-health-table__header">
|
||||
<div>
|
||||
<h3>Signal quality table</h3>
|
||||
<p>Sorted by tag name. Use the filters to focus on problem tags.</p>
|
||||
</div>
|
||||
<div class="tag-health-status-filter" id="tagHealthStatusFilter">
|
||||
<label>
|
||||
<input type="checkbox" value="healthy" checked>
|
||||
<span class="tag-health-status-chip tag-health-status-chip--healthy">Healthy</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" value="warning" checked>
|
||||
<span class="tag-health-status-chip tag-health-status-chip--warning">Warning</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" value="offline" checked>
|
||||
<span class="tag-health-status-chip tag-health-status-chip--offline">Offline</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tag-health-table__wrapper">
|
||||
<table class="tag-health-table" id="tagHealthTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Tag</th>
|
||||
<th scope="col">Status</th>
|
||||
<th scope="col">Last update</th>
|
||||
<th scope="col">Staleness</th>
|
||||
<th scope="col">Samples</th>
|
||||
<th scope="col">Uptime %</th>
|
||||
<th scope="col">Flatline %</th>
|
||||
<th scope="col">Range (min → max)</th>
|
||||
<th scope="col">Issues</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tagHealthTableBody">
|
||||
<tr>
|
||||
<td colspan="9">Run the audit to populate results.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="data-panel tag-health-debug is-hidden" id="tagHealthDebugPanel">
|
||||
<div class="tag-health-debug__header">
|
||||
<h3>Debug response</h3>
|
||||
<button type="button" class="button button--ghost" id="tagHealthHideDebugBtn">Hide debug</button>
|
||||
</div>
|
||||
<pre class="tag-health-debug__content" id="tagHealthDebugContent"></pre>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const tagHealthDefaults = <?php echo json_encode(
|
||||
[
|
||||
'windowHours' => $defaultWindow,
|
||||
'limit' => $defaultLimit,
|
||||
'tolerance' => $defaultTolerance,
|
||||
],
|
||||
JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION
|
||||
); ?>;
|
||||
|
||||
const tagHealthState = {
|
||||
tags: [],
|
||||
filtered: [],
|
||||
payload: null,
|
||||
filters: {
|
||||
statuses: new Set(['healthy', 'warning', 'offline']),
|
||||
search: '',
|
||||
},
|
||||
elements: {},
|
||||
};
|
||||
|
||||
let tagHealthSearchTimer = null;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
cacheTagHealthElements();
|
||||
bindTagHealthEvents();
|
||||
loadTagHealth();
|
||||
});
|
||||
|
||||
function cacheTagHealthElements() {
|
||||
tagHealthState.elements = {
|
||||
overlay: document.getElementById('tagHealthLoadingOverlay'),
|
||||
searchInput: document.getElementById('tagHealthSearch'),
|
||||
windowSelect: document.getElementById('tagHealthWindow'),
|
||||
limitSelect: document.getElementById('tagHealthLimit'),
|
||||
toleranceInput: document.getElementById('tagHealthTolerance'),
|
||||
refreshButton: document.getElementById('tagHealthRefreshBtn'),
|
||||
exportButton: document.getElementById('tagHealthExportBtn'),
|
||||
statusContainer: document.getElementById('tagHealthStatus'),
|
||||
summaryContainer: document.getElementById('tagHealthSummary'),
|
||||
tableBody: document.getElementById('tagHealthTableBody'),
|
||||
statusFilter: document.getElementById('tagHealthStatusFilter'),
|
||||
debugPanel: document.getElementById('tagHealthDebugPanel'),
|
||||
debugContent: document.getElementById('tagHealthDebugContent'),
|
||||
toggleDebugBtn: document.getElementById('tagHealthToggleDebugBtn'),
|
||||
hideDebugBtn: document.getElementById('tagHealthHideDebugBtn'),
|
||||
};
|
||||
}
|
||||
|
||||
function bindTagHealthEvents() {
|
||||
const elements = tagHealthState.elements;
|
||||
|
||||
elements.refreshButton.addEventListener('click', () => loadTagHealth());
|
||||
elements.exportButton.addEventListener('click', exportTagHealthCsv);
|
||||
elements.windowSelect.addEventListener('change', () => loadTagHealth());
|
||||
elements.limitSelect.addEventListener('change', () => loadTagHealth());
|
||||
elements.toleranceInput.addEventListener('change', () => loadTagHealth());
|
||||
|
||||
elements.searchInput.addEventListener('input', (event) => {
|
||||
const value = event.target.value || '';
|
||||
tagHealthState.filters.search = value.trim();
|
||||
if (tagHealthSearchTimer) {
|
||||
clearTimeout(tagHealthSearchTimer);
|
||||
}
|
||||
tagHealthSearchTimer = setTimeout(applyTagHealthFilters, 180);
|
||||
});
|
||||
|
||||
elements.statusFilter.querySelectorAll('input[type="checkbox"]').forEach((checkbox) => {
|
||||
checkbox.addEventListener('change', () => {
|
||||
if (checkbox.checked) {
|
||||
tagHealthState.filters.statuses.add(checkbox.value);
|
||||
} else {
|
||||
tagHealthState.filters.statuses.delete(checkbox.value);
|
||||
}
|
||||
applyTagHealthFilters();
|
||||
});
|
||||
});
|
||||
|
||||
elements.toggleDebugBtn.addEventListener('click', () => toggleTagHealthDebug(true));
|
||||
elements.hideDebugBtn.addEventListener('click', () => toggleTagHealthDebug(false));
|
||||
}
|
||||
|
||||
async function loadTagHealth() {
|
||||
const elements = tagHealthState.elements;
|
||||
clearTagHealthStatus();
|
||||
showTagHealthLoading(true);
|
||||
|
||||
const windowHours = Number(elements.windowSelect.value || tagHealthDefaults.windowHours);
|
||||
const limit = Number(elements.limitSelect.value || tagHealthDefaults.limit);
|
||||
const tolerance = Number(elements.toleranceInput.value || tagHealthDefaults.tolerance);
|
||||
const search = tagHealthState.filters.search;
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('action', 'get_tag_health');
|
||||
formData.append('window_hours', windowHours.toString());
|
||||
formData.append('limit', limit.toString());
|
||||
formData.append('flatline_tolerance', tolerance.toString());
|
||||
formData.append('search', search);
|
||||
|
||||
const response = await fetch(window.location.href, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
let payload;
|
||||
|
||||
try {
|
||||
payload = JSON.parse(text);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse audit payload', error, text);
|
||||
throw new Error('Unexpected response when loading tag health data.');
|
||||
}
|
||||
|
||||
if (!payload.success) {
|
||||
throw new Error(payload.error || 'Failed to load tag health data.');
|
||||
}
|
||||
|
||||
tagHealthState.payload = payload;
|
||||
tagHealthState.tags = Array.isArray(payload.tags) ? payload.tags : [];
|
||||
|
||||
applyTagHealthFilters();
|
||||
renderTagHealthSummary(payload);
|
||||
renderTagHealthDebug(payload);
|
||||
setTagHealthStatus('Tag health audit updated.', 'success');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setTagHealthStatus(error.message, 'error');
|
||||
tagHealthState.tags = [];
|
||||
tagHealthState.filtered = [];
|
||||
renderTagHealthTable([]);
|
||||
renderTagHealthSummary({});
|
||||
renderTagHealthDebug(null);
|
||||
} finally {
|
||||
showTagHealthLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function applyTagHealthFilters() {
|
||||
const statuses = tagHealthState.filters.statuses;
|
||||
const searchLower = tagHealthState.filters.search.toLowerCase();
|
||||
|
||||
const filtered = tagHealthState.tags.filter((tag) => {
|
||||
if (!statuses.has(tag.status)) {
|
||||
return false;
|
||||
}
|
||||
if (searchLower && !tag.name.toLowerCase().includes(searchLower)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
tagHealthState.filtered = filtered;
|
||||
renderTagHealthTable(filtered);
|
||||
}
|
||||
|
||||
function renderTagHealthSummary(payload) {
|
||||
const container = tagHealthState.elements.summaryContainer;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const totals = payload?.totals || {};
|
||||
const total = totals.total || 0;
|
||||
const healthy = totals.healthy || 0;
|
||||
const warning = totals.warning || 0;
|
||||
const offline = totals.offline || 0;
|
||||
|
||||
const averageUptime = total
|
||||
? (tagHealthState.tags.reduce((sum, tag) => sum + (tag.uptimePercent || 0), 0) / total)
|
||||
: 0;
|
||||
|
||||
const averageStaleness = tagHealthState.tags.length
|
||||
? (tagHealthState.tags.reduce((sum, tag) => sum + (tag.minutesSinceUpdate || 0), 0) /
|
||||
tagHealthState.tags.length)
|
||||
: 0;
|
||||
|
||||
container.innerHTML = `
|
||||
<article class="tag-health-card">
|
||||
<h4>Total tags</h4>
|
||||
<p class="tag-health-card__value">${total}</p>
|
||||
</article>
|
||||
<article class="tag-health-card tag-health-card--healthy">
|
||||
<h4>Healthy</h4>
|
||||
<p class="tag-health-card__value">${healthy}</p>
|
||||
</article>
|
||||
<article class="tag-health-card tag-health-card--warning">
|
||||
<h4>Warnings</h4>
|
||||
<p class="tag-health-card__value">${warning}</p>
|
||||
</article>
|
||||
<article class="tag-health-card tag-health-card--offline">
|
||||
<h4>Offline</h4>
|
||||
<p class="tag-health-card__value">${offline}</p>
|
||||
</article>
|
||||
<article class="tag-health-card">
|
||||
<h4>Average uptime</h4>
|
||||
<p class="tag-health-card__value">${formatPercent(averageUptime, 1)}</p>
|
||||
</article>
|
||||
<article class="tag-health-card">
|
||||
<h4>Avg staleness</h4>
|
||||
<p class="tag-health-card__value">${formatMinutes(averageStaleness)}</p>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderTagHealthTable(tags) {
|
||||
const tbody = tagHealthState.elements.tableBody;
|
||||
if (!tbody) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!tags.length) {
|
||||
tbody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="9">No tags match the current filters.</td>
|
||||
</tr>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = tags.map((tag) => {
|
||||
const statusBadge = `
|
||||
<span class="tag-health-status-badge tag-health-status-badge--${tag.status}">
|
||||
${capitalize(tag.status)}
|
||||
</span>
|
||||
`;
|
||||
|
||||
const lastUpdate = tag.lastTimestamp
|
||||
? `${tag.lastTimestamp} (${formatRelativeTime(tag.lastTimestamp)})`
|
||||
: '—';
|
||||
const staleness = tag.minutesSinceUpdate !== null && tag.minutesSinceUpdate !== undefined
|
||||
? formatMinutes(tag.minutesSinceUpdate)
|
||||
: '—';
|
||||
const samples = `${tag.sampleCount?.toLocaleString?.() ?? tag.sampleCount} (${tag.nullCount} null)`;
|
||||
const rangeDisplay = formatRange(tag);
|
||||
const issues = Array.isArray(tag.issues) && tag.issues.length
|
||||
? tag.issues.join('; ')
|
||||
: '—';
|
||||
|
||||
return `
|
||||
<tr data-status="${tag.status}">
|
||||
<td>${escapeHtml(tag.name)}</td>
|
||||
<td>${statusBadge}</td>
|
||||
<td>${escapeHtml(lastUpdate)}</td>
|
||||
<td>${staleness}</td>
|
||||
<td>${samples}</td>
|
||||
<td>${formatPercent(tag.uptimePercent, 1)}</td>
|
||||
<td>${formatPercent(tag.flatlinePercent, 1)}</td>
|
||||
<td>${rangeDisplay}</td>
|
||||
<td>${escapeHtml(issues)}</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
|
||||
tbody.innerHTML = rows.join('');
|
||||
}
|
||||
|
||||
function renderTagHealthDebug(payload) {
|
||||
const content = tagHealthState.elements.debugContent;
|
||||
if (!content) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!payload) {
|
||||
content.textContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
content.textContent = JSON.stringify(payload, null, 2);
|
||||
}
|
||||
|
||||
function toggleTagHealthDebug(forceOpen) {
|
||||
const panel = tagHealthState.elements.debugPanel;
|
||||
if (!panel) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldOpen = typeof forceOpen === 'boolean'
|
||||
? forceOpen
|
||||
: panel.classList.contains('is-hidden');
|
||||
|
||||
if (shouldOpen) {
|
||||
panel.classList.remove('is-hidden');
|
||||
} else {
|
||||
panel.classList.add('is-hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function exportTagHealthCsv() {
|
||||
const tags = tagHealthState.filtered.length ? tagHealthState.filtered : tagHealthState.tags;
|
||||
if (!tags.length) {
|
||||
setTagHealthStatus('Nothing to export. Run the audit first.', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
const header = [
|
||||
'Tag name',
|
||||
'Status',
|
||||
'Sample count',
|
||||
'Null count',
|
||||
'Flatline count',
|
||||
'Uptime percent',
|
||||
'Flatline percent',
|
||||
'Min value',
|
||||
'Average value',
|
||||
'Max value',
|
||||
'Range',
|
||||
'Last timestamp',
|
||||
'Minutes since update',
|
||||
'First timestamp',
|
||||
'Coverage minutes',
|
||||
'Average spacing minutes',
|
||||
'Issues',
|
||||
];
|
||||
|
||||
const rows = tags.map((tag) => [
|
||||
tag.name,
|
||||
tag.status,
|
||||
tag.sampleCount,
|
||||
tag.nullCount,
|
||||
tag.flatlineCount,
|
||||
tag.uptimePercent,
|
||||
tag.flatlinePercent,
|
||||
tag.minValue,
|
||||
tag.avgValue,
|
||||
tag.maxValue,
|
||||
tag.range,
|
||||
tag.lastTimestamp,
|
||||
tag.minutesSinceUpdate,
|
||||
tag.firstTimestamp,
|
||||
tag.windowCoverageMinutes,
|
||||
tag.averageSpacingMinutes,
|
||||
Array.isArray(tag.issues) ? tag.issues.join(' | ') : '',
|
||||
]);
|
||||
|
||||
const csvLines = [header.join(',')];
|
||||
rows.forEach((row) => {
|
||||
csvLines.push(row.map((value) => formatCsvValue(value)).join(','));
|
||||
});
|
||||
|
||||
const blob = new Blob([csvLines.join('\n')], { type: 'text/csv;charset=utf-8;' });
|
||||
const timestamp = new Date().toISOString().replace(/[:T]/g, '-').slice(0, 16);
|
||||
const filename = `tag-health-audit-${timestamp}.csv`;
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(link.href);
|
||||
}
|
||||
|
||||
function setTagHealthStatus(message, type = 'info') {
|
||||
const container = tagHealthState.elements.statusContainer;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = '';
|
||||
const div = document.createElement('div');
|
||||
div.className = `status-message status-message--${type}`;
|
||||
div.textContent = message;
|
||||
container.appendChild(div);
|
||||
}
|
||||
|
||||
function clearTagHealthStatus() {
|
||||
const container = tagHealthState.elements.statusContainer;
|
||||
if (container) {
|
||||
container.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
function showTagHealthLoading(show) {
|
||||
const overlay = tagHealthState.elements.overlay;
|
||||
if (!overlay) {
|
||||
return;
|
||||
}
|
||||
|
||||
overlay.style.display = show ? 'flex' : 'none';
|
||||
}
|
||||
|
||||
function formatPercent(value, fractionDigits = 0) {
|
||||
if (value === null || value === undefined || Number.isNaN(value)) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
return `${Number.parseFloat(value).toFixed(fractionDigits)}%`;
|
||||
}
|
||||
|
||||
function formatMinutes(value) {
|
||||
if (value === null || value === undefined || Number.isNaN(value)) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
const minutes = Math.max(0, Math.round(value));
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remainder = minutes % 60;
|
||||
|
||||
if (hours === 0) {
|
||||
return `${remainder}m`;
|
||||
}
|
||||
|
||||
return `${hours}h ${remainder}m`;
|
||||
}
|
||||
|
||||
function formatRelativeTime(timestamp) {
|
||||
if (!timestamp) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const parsed = new Date(timestamp.replace(' ', 'T'));
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - parsed.getTime();
|
||||
if (diffMs < 0) {
|
||||
return 'in the future';
|
||||
}
|
||||
|
||||
const diffMinutes = Math.floor(diffMs / 60000);
|
||||
if (diffMinutes < 1) {
|
||||
return 'just now';
|
||||
}
|
||||
if (diffMinutes < 60) {
|
||||
return `${diffMinutes}m ago`;
|
||||
}
|
||||
|
||||
const diffHours = Math.floor(diffMinutes / 60);
|
||||
if (diffHours < 24) {
|
||||
return `${diffHours}h ago`;
|
||||
}
|
||||
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
return `${diffDays}d ago`;
|
||||
}
|
||||
|
||||
function formatRange(tag) {
|
||||
const min = tag.minValue;
|
||||
const max = tag.maxValue;
|
||||
const range = tag.range;
|
||||
|
||||
if (min === null || max === null || range === null) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
return `${formatNumber(min)} → ${formatNumber(max)} (Δ ${formatNumber(range)})`;
|
||||
}
|
||||
|
||||
function formatNumber(value, fractionDigits = 2) {
|
||||
if (value === null || value === undefined || Number.isNaN(value)) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
return Number.parseFloat(value).toFixed(fractionDigits);
|
||||
}
|
||||
|
||||
function formatCsvValue(value) {
|
||||
if (value === null || value === undefined) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const stringValue = String(value);
|
||||
if (stringValue.includes(',') || stringValue.includes('"') || stringValue.includes('\n')) {
|
||||
return `"${stringValue.replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
return stringValue;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
if (value === null || value === undefined) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return String(value).replace(/[&<>"']/g, (char) => {
|
||||
const entities = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
};
|
||||
return entities[char] || char;
|
||||
});
|
||||
}
|
||||
|
||||
function capitalize(value) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
return value.charAt(0).toUpperCase() + value.slice(1);
|
||||
}
|
||||
</script>
|
||||
|
||||
<?php require __DIR__ . '/../includes/layout/footer.php'; ?>
|
||||
276
trends/targets/grind-target.php
Normal file
276
trends/targets/grind-target.php
Normal file
@@ -0,0 +1,276 @@
|
||||
<?php // phpcs:ignoreFile
|
||||
|
||||
require __DIR__ . '/../../session.php';
|
||||
require __DIR__ . '/../../userAccess.php';
|
||||
require __DIR__ . '/../../items.php';
|
||||
|
||||
$pageTitle = 'Daily Total Target';
|
||||
$pageSubtitle = 'See how hard we need to push to hit today\'s goal';
|
||||
$pageDescription = 'Calculate the tons-per-hour needed to reach the daily tonnage goal before the 5 a.m. reset.';
|
||||
$assetBasePath = '../../';
|
||||
$layoutWithoutSidebar = true;
|
||||
$layoutReturnUrl = '../../overview.php';
|
||||
$layoutReturnLabel = 'Back to overview';
|
||||
|
||||
$appTimeZone = new DateTimeZone('America/Chicago');
|
||||
$dailyResetHour = 5;
|
||||
|
||||
function sanitizeFloat($value, float $default = 0.0): float
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return $default;
|
||||
}
|
||||
|
||||
$filtered = filter_var($value, FILTER_VALIDATE_FLOAT);
|
||||
|
||||
return $filtered === false ? $default : (float) $filtered;
|
||||
}
|
||||
|
||||
function sanitizeTarget($value): float
|
||||
{
|
||||
$target = sanitizeFloat($value, 22000.0);
|
||||
|
||||
return max(0.0, $target);
|
||||
}
|
||||
|
||||
function calculateWindow(DateTimeZone $tz, int $resetHour): array
|
||||
{
|
||||
$now = new DateTimeImmutable('now', $tz);
|
||||
$todayReset = $now->setTime($resetHour, 0, 0);
|
||||
|
||||
if ($now < $todayReset) {
|
||||
$start = $todayReset->modify('-1 day');
|
||||
$end = $todayReset;
|
||||
} else {
|
||||
$start = $todayReset;
|
||||
$end = $todayReset->modify('+1 day');
|
||||
}
|
||||
|
||||
$secondsTotal = max(1, $end->getTimestamp() - $start->getTimestamp());
|
||||
$secondsElapsed = max(
|
||||
0,
|
||||
min($secondsTotal, $now->getTimestamp() - $start->getTimestamp())
|
||||
);
|
||||
$secondsRemaining = $secondsTotal - $secondsElapsed;
|
||||
|
||||
return [
|
||||
'now' => $now,
|
||||
'start' => $start,
|
||||
'end' => $end,
|
||||
'secondsElapsed' => $secondsElapsed,
|
||||
'secondsRemaining' => $secondsRemaining,
|
||||
'hoursElapsed' => $secondsElapsed / 3600,
|
||||
'hoursRemaining' => $secondsRemaining / 3600,
|
||||
'hoursTotal' => $secondsTotal / 3600,
|
||||
];
|
||||
}
|
||||
|
||||
function buildResult(
|
||||
float $targetTons,
|
||||
float $alreadyGround,
|
||||
float $projectedRest,
|
||||
float $hoursRemaining
|
||||
): array {
|
||||
$neededTonnage = max(0.0, $targetTons - $alreadyGround);
|
||||
$requiredRate = $hoursRemaining > 0 ? $neededTonnage / $hoursRemaining : 0.0;
|
||||
$projectedRest = max(0.0, $projectedRest);
|
||||
$projectedRate = $hoursRemaining > 0 ? $projectedRest / $hoursRemaining : 0.0;
|
||||
|
||||
$projectedTotal = $alreadyGround + $projectedRest;
|
||||
$projectedShortfall = max(0.0, $targetTons - $projectedTotal);
|
||||
$deltaRate = max(0.0, $requiredRate - $projectedRate);
|
||||
|
||||
return [
|
||||
'targetTons' => $targetTons,
|
||||
'alreadyGround' => $alreadyGround,
|
||||
'projectedRest' => $projectedRest,
|
||||
'hoursRemaining' => $hoursRemaining,
|
||||
'neededTonnage' => $neededTonnage,
|
||||
'projectedTotal' => $projectedTotal,
|
||||
'projectedShortfall' => $projectedShortfall,
|
||||
'requiredRate' => $requiredRate,
|
||||
'projectedRate' => $projectedRate,
|
||||
'deltaRate' => $deltaRate,
|
||||
];
|
||||
}
|
||||
|
||||
$liveGroundTons = null;
|
||||
if (isset($value['CANETOT'], $value['W TONS GROUND'])) {
|
||||
$liveGroundTons = (float) $value['CANETOT'] + (float) $value['W TONS GROUND'];
|
||||
}
|
||||
|
||||
$defaultGround = $liveGroundTons ?? 0.0;
|
||||
|
||||
$projectedTotal = null;
|
||||
$projectedIncludePath = __DIR__ . '/../../includes/millprojected60min.php';
|
||||
if (is_file($projectedIncludePath)) {
|
||||
// Reuse the existing include by buffering its echoed total and keeping its relative paths intact.
|
||||
$previousCwd = getcwd();
|
||||
$includeDir = dirname($projectedIncludePath);
|
||||
|
||||
if ($includeDir !== false && is_dir($includeDir)) {
|
||||
chdir($includeDir);
|
||||
}
|
||||
|
||||
ob_start();
|
||||
include $projectedIncludePath;
|
||||
$projectedRaw = trim(ob_get_clean());
|
||||
|
||||
if ($previousCwd !== false && is_dir($previousCwd)) {
|
||||
chdir($previousCwd);
|
||||
}
|
||||
|
||||
if ($projectedRaw !== '' && is_numeric($projectedRaw)) {
|
||||
$projectedTotal = (float) $projectedRaw;
|
||||
}
|
||||
}
|
||||
|
||||
$defaultProjectedCarry = 0.0;
|
||||
if ($projectedTotal !== null) {
|
||||
$alreadyGroundForCarry = $liveGroundTons ?? 0.0;
|
||||
$defaultProjectedCarry = max(0.0, $projectedTotal - $alreadyGroundForCarry);
|
||||
}
|
||||
|
||||
$formInput = [
|
||||
'target' => sanitizeTarget($_POST['target'] ?? 22000),
|
||||
'ground' => sanitizeFloat(
|
||||
array_key_exists('ground', $_POST) ? $_POST['ground'] : $defaultGround,
|
||||
$defaultGround
|
||||
),
|
||||
'projected' => sanitizeFloat(
|
||||
array_key_exists('projected', $_POST) ? $_POST['projected'] : $defaultProjectedCarry,
|
||||
$defaultProjectedCarry
|
||||
),
|
||||
];
|
||||
|
||||
$window = calculateWindow($appTimeZone, $dailyResetHour);
|
||||
$result = buildResult(
|
||||
$formInput['target'],
|
||||
$formInput['ground'],
|
||||
$formInput['projected'],
|
||||
$window['hoursRemaining']
|
||||
);
|
||||
|
||||
$targetDeadlineLabel = $window['end']->format('g:i A T');
|
||||
|
||||
require __DIR__ . '/../../includes/layout/header.php';
|
||||
?>
|
||||
|
||||
<div class="app-content">
|
||||
<section class="data-panel">
|
||||
<header class="panel-header">
|
||||
<h2>Daily total target checker</h2>
|
||||
<p>
|
||||
Enter today's ground tonnage, your current projection, and the target
|
||||
to see the tons-per-hour required by
|
||||
<?php echo htmlspecialchars($targetDeadlineLabel); ?>.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<form method="post" class="target-form">
|
||||
<div class="target-grid">
|
||||
<label class="target-field">
|
||||
<span class="target-label">Target tonnage</span>
|
||||
<input
|
||||
type="number"
|
||||
name="target"
|
||||
step="100"
|
||||
min="0"
|
||||
value="<?php echo htmlspecialchars((string) $formInput['target']); ?>"
|
||||
>
|
||||
</label>
|
||||
|
||||
<label class="target-field">
|
||||
<span class="target-label">Tons ground so far</span>
|
||||
<input
|
||||
type="number"
|
||||
name="ground"
|
||||
step="1"
|
||||
min="0"
|
||||
value="<?php echo htmlspecialchars((string) $formInput['ground']); ?>"
|
||||
>
|
||||
</label>
|
||||
|
||||
<label class="target-field">
|
||||
<span class="target-label">Projected tons remaining (current pace)</span>
|
||||
<input
|
||||
type="number"
|
||||
name="projected"
|
||||
step="1"
|
||||
min="0"
|
||||
value="<?php echo htmlspecialchars((string) $formInput['projected']); ?>"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="target-actions">
|
||||
<button type="submit" class="button button--success">
|
||||
Calculate required rate
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="target-results">
|
||||
<div class="target-result-card">
|
||||
<span class="target-result-label">Hours remaining</span>
|
||||
<span class="target-result-value">
|
||||
<?php echo number_format($window['hoursRemaining'], 2); ?> h
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="target-result-card">
|
||||
<span class="target-result-label">Target shortfall</span>
|
||||
<span class="target-result-value">
|
||||
<?php echo number_format($result['neededTonnage'], 0); ?> tons
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="target-result-card">
|
||||
<span class="target-result-label">Projected carry</span>
|
||||
<span class="target-result-value">
|
||||
<?php echo number_format($result['projectedRest'], 0); ?> tons
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="target-result-card">
|
||||
<span class="target-result-label">Projected shortfall</span>
|
||||
<span class="target-result-value">
|
||||
<?php echo number_format($result['projectedShortfall'], 0); ?> tons
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="target-result-card">
|
||||
<span class="target-result-label">Current pace</span>
|
||||
<span class="target-result-value">
|
||||
<?php echo number_format($result['projectedRate'], 2); ?> tph
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="target-result-card target-result-card--primary">
|
||||
<span class="target-result-label">Required rate</span>
|
||||
<span class="target-result-value">
|
||||
<?php echo number_format($result['requiredRate'], 2); ?> tph
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="target-result-card target-result-card--delta">
|
||||
<span class="target-result-label">Additional rate needed</span>
|
||||
<span class="target-result-value">
|
||||
<?php echo number_format($result['deltaRate'], 2); ?> tph
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="target-footnote">
|
||||
<p>
|
||||
Calculations assume the 24-hour window resets at 5:00 a.m. Central.
|
||||
Required rate uses the remaining hours
|
||||
(<?php echo number_format($window['hoursRemaining'], 2); ?> h).
|
||||
Projected shortfall compares the target against the current ground
|
||||
tonnage plus the forecast carry at today's pace.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<?php require __DIR__ . '/../../includes/layout/footer.php'; ?>
|
||||
Reference in New Issue
Block a user