Folder reorganize 1

This commit is contained in:
Rucus
2026-02-17 12:44:37 -06:00
parent ec99d85bc2
commit f0ae0ab905
17427 changed files with 2071 additions and 1059030 deletions

1297
trends/cohort-archive.php Normal file

File diff suppressed because it is too large Load Diff

1285
trends/cohort.php Normal file

File diff suppressed because it is too large Load Diff

704
trends/live/autochart.php Normal file
View 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'; ?>

View 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
View 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
View 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);
?>

View 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
View 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 &amp; 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 &amp; 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
View 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'; ?>

View 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);
?>

View 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
View 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
View 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'; ?>

View 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

File diff suppressed because it is too large Load Diff

489
trends/static/fivechart.php Normal file
View 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'; ?>

View 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
View 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 = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;',
};
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'; ?>

View 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&#39;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&#39;s pace.
</p>
</div>
</section>
</div>
<?php require __DIR__ . '/../../includes/layout/footer.php'; ?>