Folder reorganize 1
This commit is contained in:
489
trends/static/fivechart.php
Normal file
489
trends/static/fivechart.php
Normal file
@@ -0,0 +1,489 @@
|
||||
<?php // phpcs:ignoreFile
|
||||
require __DIR__ . '/../../session.php';
|
||||
require __DIR__ . '/../../userAccess.php';
|
||||
|
||||
$pageTitle = 'Static Multi-Tag Trend';
|
||||
$pageSubtitle = 'Capture a three-hour snapshot for up to five historian tags';
|
||||
$pageDescription = 'Select up to five tags and plot a zoomable three-hour history without auto-refresh.';
|
||||
$assetBasePath = '../../';
|
||||
$layoutWithoutSidebar = true;
|
||||
$layoutReturnUrl = '../../overview.php';
|
||||
$layoutReturnLabel = 'Back to overview';
|
||||
|
||||
require __DIR__ . '/../../includes/layout/header.php';
|
||||
?>
|
||||
|
||||
<div class="app-content">
|
||||
<section class="data-panel trend-panel">
|
||||
<header class="trend-panel__header">
|
||||
<div>
|
||||
<h2 id="chartTitle">Static multi-tag snapshot</h2>
|
||||
<p>Pick up to five tags, then load a three-hour trend you can pan and zoom.</p>
|
||||
</div>
|
||||
<span class="trend-status trend-status--stopped" id="chartStatus">IDLE</span>
|
||||
</header>
|
||||
|
||||
<div class="trend-control-grid">
|
||||
<div class="trend-control">
|
||||
<label for="tagSelect1">Tag 1</label>
|
||||
<select id="tagSelect1" class="tag-select">
|
||||
<option value="">Loading tags...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="trend-control">
|
||||
<label for="tagSelect2">Tag 2</label>
|
||||
<select id="tagSelect2" class="tag-select">
|
||||
<option value="">Loading tags...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="trend-control">
|
||||
<label for="tagSelect3">Tag 3</label>
|
||||
<select id="tagSelect3" class="tag-select">
|
||||
<option value="">Loading tags...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="trend-control">
|
||||
<label for="tagSelect4">Tag 4</label>
|
||||
<select id="tagSelect4" class="tag-select">
|
||||
<option value="">Loading tags...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="trend-control">
|
||||
<label for="tagSelect5">Tag 5</label>
|
||||
<select id="tagSelect5" class="tag-select">
|
||||
<option value="">Loading tags...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="trend-control">
|
||||
<label>Time window</label>
|
||||
<div class="time-window-display">Last 3 hours (static)</div>
|
||||
</div>
|
||||
<div class="trend-control">
|
||||
<label>Controls</label>
|
||||
<div class="trend-control__actions">
|
||||
<button type="button" class="button button--success" id="loadBtn">Load trend</button>
|
||||
<button type="button" class="button button--ghost" id="resetZoomBtn" disabled>Reset zoom</button>
|
||||
<button type="button" class="button button--ghost" id="clearBtn">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="status" class="trend-connection trend-connection--disconnected">
|
||||
Select at least one tag and click Load trend to build a snapshot
|
||||
</div>
|
||||
|
||||
<div id="statsWrapper" class="trend-stat-grid" hidden></div>
|
||||
|
||||
<div class="trend-chart">
|
||||
<canvas id="staticChart"></canvas>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.js"></script>
|
||||
<script
|
||||
src="https://cdn.jsdelivr.net/npm/chartjs-plugin-zoom@2.0.1/dist/chartjs-plugin-zoom.min.js"
|
||||
></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/moment@2.29.4/moment.min.js"></script>
|
||||
<script
|
||||
src="https://cdn.jsdelivr.net/npm/chartjs-adapter-moment@1.0.1/dist/chartjs-adapter-moment.min.js"
|
||||
></script>
|
||||
|
||||
<script>
|
||||
if (window.Chart && window['chartjs-plugin-zoom']) {
|
||||
Chart.register(window['chartjs-plugin-zoom']);
|
||||
}
|
||||
|
||||
const MAX_TAGS = 5;
|
||||
const DATASET_STYLES = [
|
||||
{ border: '#3498db', background: 'rgba(52, 152, 219, 0.15)' },
|
||||
{ border: '#27ae60', background: 'rgba(39, 174, 96, 0.15)' },
|
||||
{ border: '#f39c12', background: 'rgba(243, 156, 18, 0.15)' },
|
||||
{ border: '#e74c3c', background: 'rgba(231, 76, 60, 0.15)' },
|
||||
{ border: '#9b59b6', background: 'rgba(155, 89, 182, 0.15)' }
|
||||
];
|
||||
|
||||
let chart = null;
|
||||
let lastWindow = null;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadTags();
|
||||
document.getElementById('loadBtn').addEventListener('click', handleLoad);
|
||||
document.getElementById('resetZoomBtn').addEventListener('click', resetZoom);
|
||||
document.getElementById('clearBtn').addEventListener('click', clearChart);
|
||||
});
|
||||
|
||||
function getTagSelects() {
|
||||
return Array.from(document.querySelectorAll('.tag-select'));
|
||||
}
|
||||
|
||||
function setStatus(state, message) {
|
||||
const statusElement = document.getElementById('status');
|
||||
const chartStatus = document.getElementById('chartStatus');
|
||||
const classSuffix = state === 'connected' ? 'connected' : 'disconnected';
|
||||
|
||||
statusElement.className = `trend-connection trend-connection--${classSuffix}`;
|
||||
statusElement.textContent = message;
|
||||
|
||||
if (state === 'connected') {
|
||||
chartStatus.className = 'trend-status trend-status--running';
|
||||
chartStatus.textContent = 'READY';
|
||||
} else if (state === 'loading') {
|
||||
chartStatus.className = 'trend-status trend-status--stopped';
|
||||
chartStatus.textContent = 'LOADING';
|
||||
} else if (state === 'error') {
|
||||
chartStatus.className = 'trend-status trend-status--stopped';
|
||||
chartStatus.textContent = 'ERROR';
|
||||
} else {
|
||||
chartStatus.className = 'trend-status trend-status--stopped';
|
||||
chartStatus.textContent = 'IDLE';
|
||||
}
|
||||
}
|
||||
|
||||
function loadTags() {
|
||||
const selects = getTagSelects();
|
||||
const placeholders = [
|
||||
'Select primary tag...',
|
||||
'Optional tag 2...',
|
||||
'Optional tag 3...',
|
||||
'Optional tag 4...',
|
||||
'Optional tag 5...'
|
||||
];
|
||||
|
||||
selects.forEach((select) => {
|
||||
select.innerHTML = '<option value="">Loading tags...</option>';
|
||||
select.disabled = true;
|
||||
});
|
||||
|
||||
fetch('../live/get_tags.php', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
cache: 'no-cache'
|
||||
})
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Tag request failed: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then((payload) => {
|
||||
if (!payload.success || !Array.isArray(payload.tags) || payload.tags.length === 0) {
|
||||
throw new Error(payload.error || 'No tags available');
|
||||
}
|
||||
|
||||
selects.forEach((select, index) => {
|
||||
const placeholder = placeholders[index] || 'Optional tag...';
|
||||
select.innerHTML = `<option value="">${placeholder}</option>`;
|
||||
payload.tags.forEach((tag) => {
|
||||
if (!tag || !tag.name) {
|
||||
return;
|
||||
}
|
||||
const option = document.createElement('option');
|
||||
option.value = tag.name;
|
||||
option.textContent = tag.name;
|
||||
select.appendChild(option);
|
||||
});
|
||||
select.disabled = false;
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to load tags:', error);
|
||||
selects.forEach((select) => {
|
||||
select.innerHTML = `<option value="">Error: ${error.message}</option>`;
|
||||
select.disabled = true;
|
||||
});
|
||||
setStatus('error', 'Unable to load tags. Refresh and try again.');
|
||||
});
|
||||
}
|
||||
|
||||
function selectedTags() {
|
||||
return getTagSelects()
|
||||
.map((select) => select.value.trim())
|
||||
.filter((value, index, array) => value !== '' && array.indexOf(value) === index);
|
||||
}
|
||||
|
||||
function handleLoad() {
|
||||
const tags = selectedTags();
|
||||
if (tags.length === 0) {
|
||||
setStatus('error', 'Choose at least one tag to build the chart.');
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus('loading', 'Loading historian data...');
|
||||
document.getElementById('loadBtn').disabled = true;
|
||||
document.getElementById('resetZoomBtn').disabled = true;
|
||||
|
||||
fetch('get_trend_data.php', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ tags })
|
||||
})
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Trend request failed: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then((payload) => {
|
||||
if (!payload.success || !Array.isArray(payload.series) || payload.series.length === 0) {
|
||||
throw new Error(payload.error || 'No data returned for the selected tags.');
|
||||
}
|
||||
|
||||
lastWindow = payload.window || null;
|
||||
renderChart(payload.series);
|
||||
renderStats(payload.series);
|
||||
updateTitle(payload.series);
|
||||
setStatus('connected', buildStatusMessage(payload));
|
||||
document.getElementById('resetZoomBtn').disabled = false;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to load trend data:', error);
|
||||
clearChart();
|
||||
setStatus('error', error.message);
|
||||
})
|
||||
.finally(() => {
|
||||
document.getElementById('loadBtn').disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
function renderChart(series) {
|
||||
const ctx = document.getElementById('staticChart').getContext('2d');
|
||||
|
||||
if (chart) {
|
||||
chart.destroy();
|
||||
}
|
||||
|
||||
const datasets = series.map((entry, index) => {
|
||||
const style = DATASET_STYLES[index % DATASET_STYLES.length];
|
||||
return {
|
||||
label: entry.tag,
|
||||
data: entry.points.map((point) => ({ x: point.timestamp, y: point.value })),
|
||||
borderColor: style.border,
|
||||
backgroundColor: style.background,
|
||||
borderWidth: 2,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 3,
|
||||
pointHitRadius: 6,
|
||||
fill: false,
|
||||
tension: 0.1
|
||||
};
|
||||
});
|
||||
|
||||
const timeBounds = getTimeBounds(series);
|
||||
|
||||
chart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: { datasets },
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: { intersect: false, mode: 'index' },
|
||||
animation: { duration: 200 },
|
||||
scales: {
|
||||
x: {
|
||||
type: 'time',
|
||||
adapters: {
|
||||
date: {
|
||||
zone: 'local'
|
||||
}
|
||||
},
|
||||
time: {
|
||||
unit: 'minute',
|
||||
displayFormats: {
|
||||
minute: 'HH:mm',
|
||||
hour: 'HH:mm',
|
||||
second: 'HH:mm:ss'
|
||||
}
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Timestamp'
|
||||
},
|
||||
min: timeBounds.min,
|
||||
max: timeBounds.max
|
||||
},
|
||||
y: {
|
||||
type: 'linear',
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Value'
|
||||
},
|
||||
ticks: {
|
||||
callback: (value) => Number(value).toFixed(2)
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top'
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label(context) {
|
||||
if (context.parsed.y === null || Number.isNaN(context.parsed.y)) {
|
||||
return `${context.dataset.label}: --`;
|
||||
}
|
||||
return `${context.dataset.label}: ${Number(context.parsed.y).toFixed(2)}`;
|
||||
}
|
||||
}
|
||||
},
|
||||
zoom: {
|
||||
limits: {
|
||||
x: { min: timeBounds.min, max: timeBounds.max }
|
||||
},
|
||||
pan: {
|
||||
enabled: true,
|
||||
modifierKey: 'ctrl'
|
||||
},
|
||||
zoom: {
|
||||
wheel: { enabled: true },
|
||||
pinch: { enabled: true },
|
||||
drag: { enabled: true },
|
||||
mode: 'x'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getTimeBounds(series) {
|
||||
let min = null;
|
||||
let max = null;
|
||||
series.forEach((entry) => {
|
||||
entry.points.forEach((point) => {
|
||||
const ts = new Date(point.timestamp).getTime();
|
||||
if (!Number.isFinite(ts)) {
|
||||
return;
|
||||
}
|
||||
if (min === null || ts < min) {
|
||||
min = ts;
|
||||
}
|
||||
if (max === null || ts > max) {
|
||||
max = ts;
|
||||
}
|
||||
});
|
||||
});
|
||||
if (min === null || max === null) {
|
||||
const now = Date.now();
|
||||
return { min: now - (3 * 60 * 60 * 1000), max: now };
|
||||
}
|
||||
return { min, max };
|
||||
}
|
||||
|
||||
function renderStats(series) {
|
||||
const statsWrapper = document.getElementById('statsWrapper');
|
||||
statsWrapper.innerHTML = '';
|
||||
|
||||
const fragments = document.createDocumentFragment();
|
||||
|
||||
series.forEach((entry, index) => {
|
||||
const styleClass = `trend-stat--series${(index % DATASET_STYLES.length) + 1}`;
|
||||
const statCard = document.createElement('div');
|
||||
statCard.className = `trend-stat ${styleClass}`;
|
||||
statCard.innerHTML = `
|
||||
<span class="trend-stat__value">${formatStat(entry.stats?.latest)}</span>
|
||||
<span class="trend-stat__label">${entry.tag} latest</span>
|
||||
`;
|
||||
fragments.appendChild(statCard);
|
||||
|
||||
const minCard = document.createElement('div');
|
||||
minCard.className = `trend-stat ${styleClass}`;
|
||||
minCard.innerHTML = `
|
||||
<span class="trend-stat__value">${formatStat(entry.stats?.min)}</span>
|
||||
<span class="trend-stat__label">${entry.tag} min</span>
|
||||
`;
|
||||
fragments.appendChild(minCard);
|
||||
|
||||
const maxCard = document.createElement('div');
|
||||
maxCard.className = `trend-stat ${styleClass}`;
|
||||
maxCard.innerHTML = `
|
||||
<span class="trend-stat__value">${formatStat(entry.stats?.max)}</span>
|
||||
<span class="trend-stat__label">${entry.tag} max</span>
|
||||
`;
|
||||
fragments.appendChild(maxCard);
|
||||
|
||||
const countCard = document.createElement('div');
|
||||
countCard.className = 'trend-stat';
|
||||
countCard.innerHTML = `
|
||||
<span class="trend-stat__value">${entry.stats?.count ?? 0}</span>
|
||||
<span class="trend-stat__label">${entry.tag} points</span>
|
||||
`;
|
||||
fragments.appendChild(countCard);
|
||||
});
|
||||
|
||||
if (lastWindow) {
|
||||
const windowCard = document.createElement('div');
|
||||
windowCard.className = 'trend-stat';
|
||||
windowCard.innerHTML = `
|
||||
<span class="trend-stat__value">${formatWindow(lastWindow)}</span>
|
||||
<span class="trend-stat__label">Snapshot window</span>
|
||||
`;
|
||||
fragments.appendChild(windowCard);
|
||||
}
|
||||
|
||||
statsWrapper.appendChild(fragments);
|
||||
statsWrapper.hidden = false;
|
||||
}
|
||||
|
||||
function formatStat(value) {
|
||||
if (value === null || value === undefined) {
|
||||
return '--';
|
||||
}
|
||||
return Number(value).toFixed(2);
|
||||
}
|
||||
|
||||
function formatWindow(window) {
|
||||
if (!window || !window.start || !window.end) {
|
||||
return 'n/a';
|
||||
}
|
||||
const start = moment(window.start).format('YYYY-MM-DD HH:mm');
|
||||
const end = moment(window.end).format('YYYY-MM-DD HH:mm');
|
||||
return `${start} → ${end}`;
|
||||
}
|
||||
|
||||
function updateTitle(series) {
|
||||
const title = document.getElementById('chartTitle');
|
||||
if (!series || series.length === 0) {
|
||||
title.textContent = 'Static multi-tag snapshot';
|
||||
return;
|
||||
}
|
||||
title.textContent = series.map((entry) => entry.tag).join(', ');
|
||||
}
|
||||
|
||||
function buildStatusMessage(payload) {
|
||||
const parts = [];
|
||||
if (payload.series) {
|
||||
const total = payload.series.reduce((sum, entry) => sum + (entry.stats?.count ?? 0), 0);
|
||||
parts.push(`${total} point${total === 1 ? '' : 's'} loaded`);
|
||||
}
|
||||
if (payload.window && payload.window.start && payload.window.end) {
|
||||
parts.push(`Window ${moment(payload.window.start).format('HH:mm')} - ${moment(payload.window.end).format('HH:mm')}`);
|
||||
}
|
||||
return parts.join(' · ') || 'Snapshot ready';
|
||||
}
|
||||
|
||||
function resetZoom() {
|
||||
if (chart) {
|
||||
chart.resetZoom();
|
||||
}
|
||||
}
|
||||
|
||||
function clearChart() {
|
||||
if (chart) {
|
||||
chart.destroy();
|
||||
chart = null;
|
||||
}
|
||||
document.getElementById('statsWrapper').hidden = true;
|
||||
document.getElementById('resetZoomBtn').disabled = true;
|
||||
updateTitle([]);
|
||||
setStatus('idle', 'Chart cleared. Select tags and load a new snapshot.');
|
||||
}
|
||||
</script>
|
||||
|
||||
<?php require __DIR__ . '/../../includes/layout/footer.php'; ?>
|
||||
131
trends/static/get_trend_data.php
Normal file
131
trends/static/get_trend_data.php
Normal file
@@ -0,0 +1,131 @@
|
||||
<?php // phpcs:ignoreFile
|
||||
header('Content-Type: application/json');
|
||||
header('Cache-Control: no-cache, must-revalidate');
|
||||
|
||||
$payload = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
if (!is_array($payload)) {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'Invalid request payload.'
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$tags = $payload['tags'] ?? [];
|
||||
|
||||
if (!is_array($tags) || count($tags) === 0) {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'At least one tag is required.'
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$normalizedTags = [];
|
||||
foreach ($tags as $tag) {
|
||||
$trimmed = trim((string) $tag);
|
||||
if ($trimmed === '') {
|
||||
continue;
|
||||
}
|
||||
$normalizedTags[$trimmed] = true;
|
||||
}
|
||||
|
||||
if (count($normalizedTags) === 0) {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'No valid tag names provided.'
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$servername = '192.168.0.13\\SQLEXPRESS';
|
||||
$username = 'opce';
|
||||
$password = 'opcelasuca';
|
||||
$dbname = 'history';
|
||||
|
||||
try {
|
||||
$pdo = new PDO(
|
||||
"sqlsrv:Server=$servername;Database=$dbname",
|
||||
$username,
|
||||
$password,
|
||||
[
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC
|
||||
]
|
||||
);
|
||||
|
||||
$endTime = new DateTimeImmutable('now');
|
||||
$startTime = $endTime->modify('-3 hours');
|
||||
|
||||
$series = [];
|
||||
|
||||
$query = $pdo->prepare(
|
||||
'WITH ordered_samples AS (
|
||||
SELECT TOP (7200)
|
||||
a.TimeStamp,
|
||||
a.Value
|
||||
FROM dbo.archive AS a
|
||||
INNER JOIN dbo.id_names AS n
|
||||
ON CAST(n.idnumber AS INT) = a.ID
|
||||
WHERE n.name = :tag_name
|
||||
AND a.TimeStamp BETWEEN :start_time AND :end_time
|
||||
ORDER BY a.TimeStamp DESC
|
||||
)
|
||||
SELECT TimeStamp, Value
|
||||
FROM ordered_samples
|
||||
ORDER BY TimeStamp ASC'
|
||||
);
|
||||
|
||||
foreach (array_keys($normalizedTags) as $tagName) {
|
||||
$query->execute([
|
||||
':tag_name' => $tagName,
|
||||
':start_time' => $startTime->format('Y-m-d H:i:s'),
|
||||
':end_time' => $endTime->format('Y-m-d H:i:s')
|
||||
]);
|
||||
|
||||
$rows = $query->fetchAll();
|
||||
$points = [];
|
||||
$values = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$timestamp = $row['TimeStamp'] ?? null;
|
||||
$value = isset($row['Value']) ? (float) $row['Value'] : null;
|
||||
|
||||
if ($timestamp === null || $value === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$points[] = [
|
||||
'timestamp' => $timestamp,
|
||||
'value' => $value
|
||||
];
|
||||
$values[] = $value;
|
||||
}
|
||||
|
||||
$series[] = [
|
||||
'tag' => $tagName,
|
||||
'points' => $points,
|
||||
'stats' => [
|
||||
'count' => count($values),
|
||||
'min' => count($values) > 0 ? min($values) : null,
|
||||
'max' => count($values) > 0 ? max($values) : null,
|
||||
'latest' => count($values) > 0 ? end($values) : null
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'series' => $series,
|
||||
'window' => [
|
||||
'start' => $startTime->format(DateTimeInterface::ATOM),
|
||||
'end' => $endTime->format(DateTimeInterface::ATOM)
|
||||
]
|
||||
]);
|
||||
} catch (Throwable $exception) {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => $exception->getMessage()
|
||||
]);
|
||||
}
|
||||
Reference in New Issue
Block a user