1116 lines
37 KiB
PHP
1116 lines
37 KiB
PHP
<?php // phpcs:ignoreFile
|
|
|
|
require __DIR__ . '/session.php';
|
|
require __DIR__ . '/userAccess.php';
|
|
require __DIR__ . '/includes/log_query.php';
|
|
|
|
$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,
|
|
]
|
|
);
|
|
} catch (PDOException $e) {
|
|
die("Connection failed: " . $e->getMessage());
|
|
}
|
|
|
|
$isAutocomplete = isset($_GET['action']) && $_GET['action'] === 'autocomplete';
|
|
|
|
if ($isAutocomplete) {
|
|
$query = isset($_GET['query']) ? trim($_GET['query']) : '';
|
|
$suggestions = [];
|
|
|
|
if ($query !== '') {
|
|
try {
|
|
$stmt = $pdo->prepare("
|
|
SELECT TOP 20 name
|
|
FROM dbo.id_names
|
|
WHERE name LIKE :query
|
|
ORDER BY name
|
|
");
|
|
$stmt->execute([':query' => '%' . $query . '%']);
|
|
$rows = $stmt->fetchAll();
|
|
|
|
foreach ($rows as $row) {
|
|
if (!empty($row['name'])) {
|
|
$suggestions[] = $row['name'];
|
|
}
|
|
}
|
|
} catch (PDOException $e) {
|
|
error_log('Autocomplete error: ' . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
header('Content-Type: application/json');
|
|
echo json_encode($suggestions);
|
|
exit;
|
|
}
|
|
|
|
$allTags = [];
|
|
try {
|
|
$tagStmt = $pdo->prepare("
|
|
SELECT DISTINCT name
|
|
FROM dbo.id_names
|
|
WHERE name <> ''
|
|
ORDER BY name
|
|
");
|
|
$tagStmt->execute();
|
|
$allTags = $tagStmt->fetchAll();
|
|
} catch (PDOException $e) {
|
|
error_log('Tag preload error: ' . $e->getMessage());
|
|
}
|
|
|
|
$searchType = isset($_GET['search_type']) ? $_GET['search_type'] : 'single_name';
|
|
$searchName = isset($_GET['name']) ? trim($_GET['name']) : '';
|
|
|
|
$selectedTags = [];
|
|
for ($i = 1; $i <= 5; $i++) {
|
|
$param = "tag{$i}";
|
|
if (!empty($_GET[$param])) {
|
|
$selectedTags[] = trim($_GET[$param]);
|
|
}
|
|
}
|
|
|
|
$legacyMultiple = isset($_GET['multiple_names']) ? trim($_GET['multiple_names']) : '';
|
|
if ($legacyMultiple !== '' && empty($selectedTags)) {
|
|
$selectedTags = array_filter(array_map('trim', explode(',', $legacyMultiple)));
|
|
}
|
|
|
|
$startDateInput = isset($_GET['start_date']) ? $_GET['start_date'] : '';
|
|
$endDateInput = isset($_GET['end_date']) ? $_GET['end_date'] : '';
|
|
|
|
$normalizeDateTime = function ($value) {
|
|
if ($value === '') {
|
|
return '';
|
|
}
|
|
|
|
$patterns = ['Y-m-d\TH:i:s', 'Y-m-d\TH:i'];
|
|
|
|
foreach ($patterns as $pattern) {
|
|
$dt = DateTime::createFromFormat($pattern, $value);
|
|
if ($dt instanceof DateTime) {
|
|
return $dt->format('Y-m-d H:i:s');
|
|
}
|
|
}
|
|
|
|
return $value;
|
|
};
|
|
|
|
$startDate = $normalizeDateTime($startDateInput);
|
|
$endDate = $normalizeDateTime($endDateInput);
|
|
|
|
$limit = isset($_GET['limit']) ? (int) $_GET['limit'] : 100;
|
|
if ($limit <= 0) {
|
|
$limit = 100;
|
|
}
|
|
|
|
$showTrend = isset($_GET['show_trend']);
|
|
$timeInterval = isset($_GET['time_interval']) ? (int) $_GET['time_interval'] : 30;
|
|
if ($timeInterval < 1) {
|
|
$timeInterval = 1;
|
|
}
|
|
|
|
$results = [];
|
|
$searchTitle = '';
|
|
$totalCount = 0;
|
|
$tagNames = [];
|
|
$organizedResults = [];
|
|
$canSearch = false;
|
|
|
|
switch ($searchType) {
|
|
case 'single_name':
|
|
$canSearch = $searchName !== '';
|
|
break;
|
|
case 'multiple_names':
|
|
$canSearch = !empty($selectedTags);
|
|
break;
|
|
}
|
|
|
|
if ($canSearch) {
|
|
try {
|
|
$sql = "
|
|
SELECT
|
|
a.ID,
|
|
a.Value,
|
|
a.TimeStamp,
|
|
COALESCE(n.name, CONCAT('ID_', a.ID)) AS name
|
|
FROM dbo.archive a
|
|
LEFT JOIN dbo.id_names n ON a.ID = n.idnumber
|
|
WHERE 1 = 1
|
|
AND a.TimeStamp <> :invalid_timestamp
|
|
";
|
|
$countSql = "
|
|
SELECT COUNT(*)
|
|
FROM dbo.archive a
|
|
LEFT JOIN dbo.id_names n ON a.ID = n.idnumber
|
|
WHERE 1 = 1
|
|
AND a.TimeStamp <> :invalid_timestamp
|
|
";
|
|
|
|
$params = [];
|
|
$params[':invalid_timestamp'] = '1899-12-31 18:00:00';
|
|
|
|
switch ($searchType) {
|
|
case 'single_name':
|
|
$sql .= " AND n.name = :name";
|
|
$countSql .= " AND n.name = :name";
|
|
$params[':name'] = $searchName;
|
|
$searchTitle = $searchName;
|
|
break;
|
|
case 'multiple_names':
|
|
if (!empty($selectedTags)) {
|
|
$placeholders = [];
|
|
foreach ($selectedTags as $index => $tag) {
|
|
$placeholder = ':name' . $index;
|
|
$placeholders[] = $placeholder;
|
|
$params[$placeholder] = $tag;
|
|
}
|
|
$inClause = implode(',', $placeholders);
|
|
$sql .= " AND n.name IN ($inClause)";
|
|
$countSql .= " AND n.name IN ($inClause)";
|
|
|
|
$preview = array_slice($selectedTags, 0, 3);
|
|
$searchTitle = 'Multiple tags (' . count($selectedTags) . '): ' . implode(', ', $preview);
|
|
if (count($selectedTags) > 3) {
|
|
$searchTitle .= '…';
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
|
|
if ($startDate !== '') {
|
|
$sql .= " AND a.TimeStamp >= :start_date";
|
|
$countSql .= " AND a.TimeStamp >= :start_date";
|
|
$params[':start_date'] = $startDate;
|
|
}
|
|
|
|
if ($endDate !== '') {
|
|
$sql .= " AND a.TimeStamp <= :end_date";
|
|
$countSql .= " AND a.TimeStamp <= :end_date";
|
|
$params[':end_date'] = $endDate;
|
|
}
|
|
|
|
if ($timeInterval > 1) {
|
|
if ($timeInterval >= 60) {
|
|
$minutes = (int) ($timeInterval / 60);
|
|
$sql .= "
|
|
AND DATEPART(MINUTE, a.TimeStamp) % :minute_interval = 0
|
|
AND DATEPART(SECOND, a.TimeStamp) = 0
|
|
";
|
|
$countSql .= "
|
|
AND DATEPART(MINUTE, a.TimeStamp) % :minute_interval = 0
|
|
AND DATEPART(SECOND, a.TimeStamp) = 0
|
|
";
|
|
$params[':minute_interval'] = $minutes;
|
|
} else {
|
|
$sql .= " AND DATEPART(SECOND, a.TimeStamp) % :time_interval = 0";
|
|
$countSql .= " AND DATEPART(SECOND, a.TimeStamp) % :time_interval = 0";
|
|
$params[':time_interval'] = $timeInterval;
|
|
}
|
|
}
|
|
|
|
$countStmt = $pdo->prepare($countSql);
|
|
foreach ($params as $key => $value) {
|
|
if ($key === ':limit') {
|
|
continue;
|
|
}
|
|
$countStmt->bindValue($key, $value);
|
|
}
|
|
$countStmt->execute();
|
|
$totalCount = (int) $countStmt->fetchColumn();
|
|
|
|
if ($showTrend) {
|
|
$sql .= "
|
|
ORDER BY a.TimeStamp ASC
|
|
OFFSET 0 ROWS FETCH NEXT :limit ROWS ONLY
|
|
";
|
|
} else {
|
|
$sql .= "
|
|
ORDER BY a.TimeStamp DESC
|
|
OFFSET 0 ROWS FETCH NEXT :limit ROWS ONLY
|
|
";
|
|
}
|
|
$params[':limit'] = $limit;
|
|
|
|
$stmt = $pdo->prepare($sql);
|
|
foreach ($params as $key => $value) {
|
|
if ($key === ':limit') {
|
|
$stmt->bindValue($key, $value, PDO::PARAM_INT);
|
|
} else {
|
|
$stmt->bindValue($key, $value);
|
|
}
|
|
}
|
|
|
|
$queryStartTime = microtime(true);
|
|
$stmt->execute();
|
|
$results = $stmt->fetchAll();
|
|
$queryEndTime = microtime(true);
|
|
$executionMs = (int) round(($queryEndTime - $queryStartTime) * 1000);
|
|
|
|
// Log the query for NL2SQL training
|
|
$userQueryDesc = buildUserQueryDescription(
|
|
$searchType,
|
|
$searchName,
|
|
$selectedTags,
|
|
$startDate,
|
|
$endDate,
|
|
$limit,
|
|
$timeInterval
|
|
);
|
|
$logParams = $params;
|
|
unset($logParams[':invalid_timestamp']); // Remove internal filter param
|
|
logControlsQuery(
|
|
$userQueryDesc,
|
|
$sql,
|
|
$logParams,
|
|
'opcsearch',
|
|
$executionMs,
|
|
count($results)
|
|
);
|
|
|
|
foreach ($results as $row) {
|
|
$timestamp = $row['TimeStamp'];
|
|
$tagName = $row['name'];
|
|
$value = $row['Value'];
|
|
|
|
if (!isset($organizedResults[$timestamp])) {
|
|
$organizedResults[$timestamp] = ['timestamp' => $timestamp];
|
|
}
|
|
|
|
$organizedResults[$timestamp][$tagName] = $value;
|
|
|
|
if (!in_array($tagName, $tagNames, true)) {
|
|
$tagNames[] = $tagName;
|
|
}
|
|
}
|
|
|
|
if (!empty($organizedResults)) {
|
|
if ($showTrend && $searchType === 'single_name') {
|
|
ksort($organizedResults);
|
|
} else {
|
|
krsort($organizedResults);
|
|
}
|
|
}
|
|
} catch (PDOException $e) {
|
|
echo '<div class="results-info"><div class="notice notice--warning">Query error: ' . htmlspecialchars($e->getMessage()) . '</div></div>';
|
|
}
|
|
}
|
|
|
|
function formatTimestamp($timestamp)
|
|
{
|
|
return date('Y-m-d H:i:s', strtotime($timestamp));
|
|
}
|
|
|
|
function formatValue($value)
|
|
{
|
|
if (is_numeric($value)) {
|
|
return number_format((float) $value, 2);
|
|
}
|
|
|
|
return $value;
|
|
}
|
|
|
|
$chartData = [];
|
|
$chartLabels = [];
|
|
|
|
if (!empty($results) && $showTrend && $searchType === 'single_name') {
|
|
foreach ($results as $row) {
|
|
$chartLabels[] = date('M j, Y H:i', strtotime($row['TimeStamp']));
|
|
$chartData[] = is_numeric($row['Value']) ? (float) $row['Value'] : 0;
|
|
}
|
|
}
|
|
|
|
$pageTitle = 'Historian Search';
|
|
$pageSubtitle = 'Query the SQL historian and visualize trends.';
|
|
$pageDescription = 'Search LASUCA historian data by tag, filter results, and export charts.';
|
|
$layoutWithoutSidebar = true;
|
|
$layoutReturnUrl = 'overview.php';
|
|
$layoutReturnLabel = 'Back to overview';
|
|
require __DIR__ . '/includes/layout/header.php';
|
|
?>
|
|
|
|
<div class="app-content search-app">
|
|
<section class="data-panel">
|
|
<div class="panel-intro">
|
|
<h2>Historical data search</h2>
|
|
<p>Query historian tags by name, filter by time range, and export results or trend charts.</p>
|
|
</div>
|
|
|
|
<div class="search-form">
|
|
<div class="search-type-tabs">
|
|
<button type="button" class="tab <?php echo $searchType === 'single_name' ? 'active' : ''; ?>" onclick="switchSearchType('single_name')">Single Tag</button>
|
|
<button type="button" class="tab <?php echo $searchType === 'multiple_names' ? 'active' : ''; ?>" onclick="switchSearchType('multiple_names')">Multiple Tags</button>
|
|
</div>
|
|
|
|
<form method="GET" id="searchForm" onsubmit="showLoading()">
|
|
<input type="hidden" id="search_type" name="search_type" value="<?php echo htmlspecialchars($searchType); ?>">
|
|
|
|
<div id="single_name" class="search-option <?php echo $searchType === 'single_name' ? 'active' : ''; ?>">
|
|
<div class="form-row">
|
|
<label for="name">Tag name</label>
|
|
<div class="autocomplete-container">
|
|
<input type="text" id="name" name="name" value="<?php echo htmlspecialchars($searchName); ?>" placeholder="Start typing tag name..." autocomplete="off">
|
|
<div class="autocomplete-suggestions" id="name-suggestions"></div>
|
|
</div>
|
|
</div>
|
|
<p class="help-text">Search for historian data by tag name with autocomplete.</p>
|
|
</div>
|
|
|
|
<div id="multiple_names" class="search-option <?php echo $searchType === 'multiple_names' ? 'active' : ''; ?>">
|
|
<?php for ($i = 1; $i <= 5; $i++) : ?>
|
|
<?php $tagValue = $selectedTags[$i - 1] ?? ''; ?>
|
|
<div class="form-row">
|
|
<label for="tag<?php echo $i; ?>">Tag <?php echo $i; ?></label>
|
|
<select id="tag<?php echo $i; ?>" name="tag<?php echo $i; ?>">
|
|
<option value="">Select a tag...</option>
|
|
<?php foreach ($allTags as $tag) : ?>
|
|
<?php $tagNameOption = $tag['name']; ?>
|
|
<option value="<?php echo htmlspecialchars($tagNameOption); ?>" <?php echo $tagValue === $tagNameOption ? 'selected' : ''; ?>>
|
|
<?php echo htmlspecialchars($tagNameOption); ?>
|
|
</option>
|
|
<?php endforeach; ?>
|
|
</select>
|
|
</div>
|
|
<?php endfor; ?>
|
|
<p class="help-text">Compare up to five tags side by side.</p>
|
|
</div>
|
|
|
|
<div class="form-row">
|
|
<label for="start_date">Start date</label>
|
|
<input type="datetime-local" id="start_date" name="start_date" value="<?php echo htmlspecialchars($startDateInput); ?>">
|
|
</div>
|
|
|
|
<div class="form-row">
|
|
<label for="end_date">End date</label>
|
|
<input type="datetime-local" id="end_date" name="end_date" value="<?php echo htmlspecialchars($endDateInput); ?>">
|
|
</div>
|
|
|
|
<div class="form-row">
|
|
<label for="limit">Results limit</label>
|
|
<select id="limit" name="limit">
|
|
<?php
|
|
$limitOptions = [50, 100, 500, 1000, 5000, 10000, 43200, 86400];
|
|
foreach ($limitOptions as $option) :
|
|
?>
|
|
<option value="<?php echo $option; ?>" <?php echo $limit === $option ? 'selected' : ''; ?>>
|
|
<?php echo $option; ?>
|
|
</option>
|
|
<?php endforeach; ?>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="form-row">
|
|
<label for="time_interval">Data interval</label>
|
|
<select id="time_interval" name="time_interval">
|
|
<option value="1" <?php echo $timeInterval === 1 ? 'selected' : ''; ?>>Every 1 second</option>
|
|
<option value="10" <?php echo $timeInterval === 10 ? 'selected' : ''; ?>>Every 10 seconds</option>
|
|
<option value="30" <?php echo $timeInterval === 30 ? 'selected' : ''; ?>>Every 30 seconds (default)</option>
|
|
<option value="60" <?php echo $timeInterval === 60 ? 'selected' : ''; ?>>Every 1 minute</option>
|
|
</select>
|
|
</div>
|
|
|
|
<p class="help-text">Sampling interval controls how frequently data points are included in results.</p>
|
|
|
|
<div class="form-row checkbox" id="trend_option" style="<?php echo $searchType === 'single_name' ? '' : 'display: none;'; ?>">
|
|
<input type="checkbox" id="show_trend" name="show_trend" <?php echo $showTrend ? 'checked' : ''; ?>>
|
|
<label for="show_trend">Show trend chart (single tag only)</label>
|
|
</div>
|
|
|
|
<div class="form-row form-row--actions">
|
|
<button type="submit" class="button">Search</button>
|
|
<a href="opcsearch.php" class="button button--ghost">Reset</a>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</section>
|
|
|
|
<?php if ($canSearch) : ?>
|
|
<?php if (!empty($results)) : ?>
|
|
<section class="data-panel">
|
|
<div class="results-info">
|
|
<div class="results-count">
|
|
Showing <?php echo count($organizedResults); ?> timestamp(s) and <?php echo count($results); ?> data point(s) for
|
|
<strong><?php echo htmlspecialchars($searchTitle); ?></strong>
|
|
<?php if ($timeInterval > 1) : ?>
|
|
<br>
|
|
<small>
|
|
Interval:
|
|
<?php
|
|
if ($timeInterval >= 60) {
|
|
$minutes = $timeInterval / 60;
|
|
echo $minutes . ' minute' . ($minutes > 1 ? 's' : '');
|
|
} else {
|
|
echo $timeInterval . ' second' . ($timeInterval > 1 ? 's' : '');
|
|
}
|
|
?>
|
|
</small>
|
|
<?php endif; ?>
|
|
</div>
|
|
|
|
<?php if ($totalCount > $limit) : ?>
|
|
<div class="notice notice--warning">
|
|
Showing first <?php echo $limit; ?> rows of <?php echo $totalCount; ?> total. Narrow the date range or increase the limit for more data.
|
|
</div>
|
|
<?php endif; ?>
|
|
</div>
|
|
|
|
<?php if ($searchType === 'multiple_names') : ?>
|
|
<div class="export-buttons">
|
|
<button type="button" class="button" onclick="exportMultipleTagsCSV()">Export results as CSV</button>
|
|
</div>
|
|
<?php endif; ?>
|
|
|
|
<?php
|
|
$numericValues = [];
|
|
if ($showTrend && $searchType === 'single_name') {
|
|
$numericValues = array_filter($chartData, 'is_numeric');
|
|
}
|
|
?>
|
|
|
|
<?php if ($showTrend && $searchType === 'single_name' && !empty($chartLabels)) : ?>
|
|
<?php if (!empty($numericValues)) : ?>
|
|
<div class="stats-panel">
|
|
<?php
|
|
$min = min($numericValues);
|
|
$max = max($numericValues);
|
|
$avg = array_sum($numericValues) / count($numericValues);
|
|
$firstValue = reset($numericValues);
|
|
$lastValue = end($numericValues);
|
|
$change = $lastValue - $firstValue;
|
|
$changePercent = $firstValue != 0 ? ($change / $firstValue) * 100 : 0;
|
|
?>
|
|
<div class="stat-box">
|
|
<div class="stat-value"><?php echo number_format($avg, 2); ?></div>
|
|
<div class="stat-label">Average</div>
|
|
</div>
|
|
<div class="stat-box">
|
|
<div class="stat-value"><?php echo number_format($min, 2); ?></div>
|
|
<div class="stat-label">Minimum</div>
|
|
</div>
|
|
<div class="stat-box">
|
|
<div class="stat-value"><?php echo number_format($max, 2); ?></div>
|
|
<div class="stat-label">Maximum</div>
|
|
</div>
|
|
<div class="stat-box">
|
|
<div class="stat-value" style="color: <?php echo $change >= 0 ? '#10b981' : '#ef4444'; ?>;">
|
|
<?php echo ($change >= 0 ? '+' : '') . number_format($change, 2); ?>
|
|
(<?php echo ($changePercent >= 0 ? '+' : '') . number_format($changePercent, 1); ?>%)
|
|
</div>
|
|
<div class="stat-label">Net change</div>
|
|
</div>
|
|
</div>
|
|
<?php endif; ?>
|
|
|
|
<div class="chart-container">
|
|
<header>
|
|
<h3>Trend chart for <?php echo htmlspecialchars($searchTitle); ?></h3>
|
|
</header>
|
|
|
|
<div class="export-buttons">
|
|
<button type="button" class="button" onclick="exportChart('png')">Export as PNG</button>
|
|
<button type="button" class="button button--ghost" onclick="exportChart('csv')">Export as CSV</button>
|
|
</div>
|
|
|
|
<div class="chart-wrapper">
|
|
<canvas id="trendChart"></canvas>
|
|
</div>
|
|
|
|
<div class="trend-controls">
|
|
<label><input type="checkbox" id="showDataPoints" checked> Show data points</label>
|
|
<label><input type="checkbox" id="showGrid" checked> Show grid</label>
|
|
<label><input type="checkbox" id="smoothLines"> Smooth lines</label>
|
|
<label><input type="checkbox" id="showAverage" checked> Show average line</label>
|
|
</div>
|
|
</div>
|
|
<?php endif; ?>
|
|
|
|
<div class="table-scroll search-results">
|
|
<table id="dataTable">
|
|
<thead>
|
|
<tr>
|
|
<th>Timestamp</th>
|
|
<?php foreach ($tagNames as $tagName) : ?>
|
|
<th><?php echo htmlspecialchars($tagName); ?></th>
|
|
<?php endforeach; ?>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<?php foreach ($organizedResults as $timestampData) : ?>
|
|
<tr>
|
|
<td><?php echo htmlspecialchars(formatTimestamp($timestampData['timestamp'])); ?></td>
|
|
<?php foreach ($tagNames as $tagName) : ?>
|
|
<td>
|
|
<?php
|
|
if (isset($timestampData[$tagName])) {
|
|
echo htmlspecialchars(formatValue($timestampData[$tagName]));
|
|
} else {
|
|
echo '-';
|
|
}
|
|
?>
|
|
</td>
|
|
<?php endforeach; ?>
|
|
</tr>
|
|
<?php endforeach; ?>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
<?php else : ?>
|
|
<section class="data-panel">
|
|
<div class="no-results">
|
|
<h3>No results found</h3>
|
|
<p>No historian data matched the filters provided. Adjust the tag selection or date range and try again.</p>
|
|
</div>
|
|
</section>
|
|
<?php endif; ?>
|
|
<?php endif; ?>
|
|
</div>
|
|
|
|
<div class="loading-overlay" id="loadingOverlay">
|
|
<div class="loading-card">
|
|
<div class="loading-spinner"></div>
|
|
<div class="loading-text" id="loadingText">Please wait…</div>
|
|
<div class="loading-subtext" id="loadingSubtext">Searching database for historical data</div>
|
|
<div class="loading-progress">
|
|
<span id="progressFill"></span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="search-status" id="searchStatus">
|
|
<div class="mini-spinner"></div>
|
|
<span class="status-text">Searching database…</span>
|
|
</div>
|
|
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
<script>
|
|
class AutoComplete {
|
|
constructor(inputElement, suggestionsElement) {
|
|
this.input = inputElement;
|
|
this.suggestions = suggestionsElement;
|
|
this.selectedIndex = -1;
|
|
this.currentSuggestions = [];
|
|
this.isSelecting = false;
|
|
|
|
this.input.addEventListener('input', this.handleInput.bind(this));
|
|
this.input.addEventListener('keydown', this.handleKeyDown.bind(this));
|
|
this.input.addEventListener('focus', this.handleFocus.bind(this));
|
|
document.addEventListener('click', this.handleDocumentClick.bind(this));
|
|
}
|
|
|
|
async handleInput(event) {
|
|
const query = event.target.value.trim();
|
|
|
|
if (query.length < 2) {
|
|
this.hideSuggestions();
|
|
return;
|
|
}
|
|
|
|
let cursorPos = 0;
|
|
let currentWord = '';
|
|
|
|
try {
|
|
cursorPos = typeof event.target.selectionStart === 'number' ? event.target.selectionStart : query.length;
|
|
} catch (error) {
|
|
cursorPos = query.length;
|
|
}
|
|
|
|
const textBeforeCursor = query.substring(0, cursorPos);
|
|
const lastCommaIndex = textBeforeCursor.lastIndexOf(',');
|
|
|
|
if (lastCommaIndex >= 0) {
|
|
currentWord = textBeforeCursor.substring(lastCommaIndex + 1).trim();
|
|
} else {
|
|
currentWord = textBeforeCursor.trim();
|
|
}
|
|
|
|
if (currentWord.length < 2) {
|
|
this.hideSuggestions();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`?action=autocomplete&query=${encodeURIComponent(currentWord)}`);
|
|
const suggestions = await response.json();
|
|
this.showSuggestions(suggestions);
|
|
} catch (error) {
|
|
console.error('Autocomplete error:', error);
|
|
this.hideSuggestions();
|
|
}
|
|
}
|
|
|
|
showSuggestions(suggestions) {
|
|
this.currentSuggestions = suggestions;
|
|
this.selectedIndex = -1;
|
|
|
|
if (suggestions.length === 0) {
|
|
this.hideSuggestions();
|
|
return;
|
|
}
|
|
|
|
this.suggestions.innerHTML = '';
|
|
suggestions.forEach((suggestion) => {
|
|
const div = document.createElement('div');
|
|
div.className = 'autocomplete-suggestion';
|
|
div.textContent = suggestion;
|
|
|
|
div.addEventListener('mousedown', (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
this.isSelecting = true;
|
|
this.selectSuggestion(suggestion);
|
|
});
|
|
|
|
div.addEventListener('click', (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
if (!this.isSelecting) {
|
|
this.selectSuggestion(suggestion);
|
|
}
|
|
});
|
|
|
|
this.suggestions.appendChild(div);
|
|
});
|
|
|
|
this.suggestions.style.display = 'block';
|
|
}
|
|
|
|
hideSuggestions() {
|
|
this.suggestions.style.display = 'none';
|
|
this.selectedIndex = -1;
|
|
this.isSelecting = false;
|
|
}
|
|
|
|
handleFocus() {
|
|
this.isSelecting = false;
|
|
}
|
|
|
|
handleKeyDown(event) {
|
|
if (this.suggestions.style.display === 'none') {
|
|
return;
|
|
}
|
|
|
|
switch (event.key) {
|
|
case 'ArrowDown':
|
|
event.preventDefault();
|
|
this.selectedIndex = Mata.min(this.selectedIndex + 1, this.currentSuggestions.length - 1);
|
|
this.updateSelection();
|
|
break;
|
|
case 'ArrowUp':
|
|
event.preventDefault();
|
|
this.selectedIndex = Mata.max(this.selectedIndex - 1, -1);
|
|
this.updateSelection();
|
|
break;
|
|
case 'Enter':
|
|
event.preventDefault();
|
|
if (this.selectedIndex >= 0) {
|
|
this.selectSuggestion(this.currentSuggestions[this.selectedIndex]);
|
|
}
|
|
break;
|
|
case 'Escape':
|
|
this.hideSuggestions();
|
|
break;
|
|
}
|
|
}
|
|
|
|
updateSelection() {
|
|
const suggestions = this.suggestions.querySelectorAll('.autocomplete-suggestion');
|
|
suggestions.forEach((suggestion, index) => {
|
|
suggestion.classList.toggle('active', index === this.selectedIndex);
|
|
});
|
|
}
|
|
|
|
selectSuggestion(suggestion) {
|
|
let cursorPos = 0;
|
|
let currentValue = this.input.value || '';
|
|
|
|
try {
|
|
cursorPos = typeof this.input.selectionStart === 'number' ? this.input.selectionStart : currentValue.length;
|
|
} catch (error) {
|
|
cursorPos = currentValue.length;
|
|
}
|
|
|
|
const textBeforeCursor = currentValue.substring(0, cursorPos);
|
|
const textAfterCursor = currentValue.substring(cursorPos);
|
|
const lastCommaIndex = textBeforeCursor.lastIndexOf(',');
|
|
|
|
let newValue;
|
|
let newCursorPos;
|
|
|
|
if (lastCommaIndex >= 0) {
|
|
const beforeComma = textBeforeCursor.substring(0, lastCommaIndex + 1);
|
|
newValue = `${beforeComma} ${suggestion}${textAfterCursor}`;
|
|
newCursorPos = beforeComma.length + 1 + suggestion.length;
|
|
} else {
|
|
newValue = `${suggestion}${textAfterCursor}`;
|
|
newCursorPos = suggestion.length;
|
|
}
|
|
|
|
this.input.value = newValue;
|
|
this.hideSuggestions();
|
|
|
|
setTimeout(() => {
|
|
try {
|
|
this.input.focus();
|
|
if (this.input.setSelectionRange && typeof newCursorPos === 'number') {
|
|
this.input.setSelectionRange(newCursorPos, newCursorPos);
|
|
}
|
|
} catch (error) {
|
|
this.input.focus();
|
|
}
|
|
|
|
this.isSelecting = false;
|
|
}, 50);
|
|
}
|
|
|
|
handleDocumentClick(event) {
|
|
if (this.isSelecting) {
|
|
return;
|
|
}
|
|
|
|
if (!this.input.contains(event.target) && !this.suggestions.contains(event.target)) {
|
|
this.hideSuggestions();
|
|
}
|
|
}
|
|
}
|
|
|
|
const nameInput = document.getElementById('name');
|
|
const nameSuggestions = document.getElementById('name-suggestions');
|
|
|
|
if (nameInput && nameSuggestions) {
|
|
new AutoComplete(nameInput, nameSuggestions);
|
|
}
|
|
|
|
const tagSelects = ['tag1', 'tag2', 'tag3', 'tag4', 'tag5'];
|
|
|
|
tagSelects.forEach((tagId) => {
|
|
const select = document.getElementById(tagId);
|
|
if (select) {
|
|
select.addEventListener('change', updateTagOptions);
|
|
}
|
|
});
|
|
|
|
function updateTagOptions() {
|
|
const selectedValues = [];
|
|
|
|
tagSelects.forEach((tagId) => {
|
|
const select = document.getElementById(tagId);
|
|
if (select && select.value) {
|
|
selectedValues.push(select.value);
|
|
}
|
|
});
|
|
|
|
tagSelects.forEach((tagId) => {
|
|
const select = document.getElementById(tagId);
|
|
if (!select) {
|
|
return;
|
|
}
|
|
|
|
Array.from(select.options).forEach((option) => {
|
|
if (option.value === '') {
|
|
option.disabled = false;
|
|
} else {
|
|
option.disabled = selectedValues.includes(option.value) && option.value !== select.value;
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', updateTagOptions);
|
|
|
|
function switchSearchType(type) {
|
|
document.getElementById('search_type').value = type;
|
|
|
|
document.querySelectorAll('.tab').forEach((tab) => tab.classList.remove('active'));
|
|
const activeTab = document.querySelector(`.tab[onclick="switchSearchType('${type}')"]`);
|
|
if (activeTab) {
|
|
activeTab.classList.add('active');
|
|
}
|
|
|
|
document.querySelectorAll('.search-option').forEach((option) => option.classList.remove('active'));
|
|
const activeSection = document.getElementById(type);
|
|
if (activeSection) {
|
|
activeSection.classList.add('active');
|
|
}
|
|
|
|
const trendOption = document.getElementById('trend_option');
|
|
const showTrendCheckbox = document.getElementById('show_trend');
|
|
|
|
if (type === 'single_name') {
|
|
trendOption.style.display = '';
|
|
showTrendCheckbox.parentElement.style.display = 'flex';
|
|
} else {
|
|
trendOption.style.display = 'none';
|
|
showTrendCheckbox.checked = false;
|
|
}
|
|
}
|
|
|
|
function showLoading() {
|
|
const overlay = document.getElementById('loadingOverlay');
|
|
const loadingText = document.getElementById('loadingText');
|
|
const loadingSubtext = document.getElementById('loadingSubtext');
|
|
const progressFill = document.getElementById('progressFill');
|
|
const submitButton = document.querySelector('button[type="submit"]');
|
|
|
|
if (submitButton) {
|
|
submitButton.disabled = true;
|
|
submitButton.textContent = 'Searching…';
|
|
}
|
|
|
|
overlay.style.display = 'flex';
|
|
|
|
let progress = 0;
|
|
const progressInterval = setInterval(() => {
|
|
progress += Mata.random() * 15;
|
|
if (progress > 90) {
|
|
progress = 90;
|
|
}
|
|
progressFill.style.width = `${progress}%`;
|
|
}, 200);
|
|
|
|
const messages = [
|
|
{ text: 'Please wait…', subtext: 'Connecting to database' },
|
|
{ text: 'Searching…', subtext: 'Querying historical data' },
|
|
{ text: 'Processing…', subtext: 'Organizing results' },
|
|
{ text: 'Almost done…', subtext: 'Preparing data display' },
|
|
];
|
|
|
|
let messageIndex = 0;
|
|
const messageInterval = setInterval(() => {
|
|
if (messageIndex < messages.length) {
|
|
loadingText.textContent = messages[messageIndex].text;
|
|
loadingSubtext.textContent = messages[messageIndex].subtext;
|
|
messageIndex++;
|
|
}
|
|
}, 1000);
|
|
|
|
window.loadingIntervals = {
|
|
progressInterval,
|
|
messageInterval,
|
|
};
|
|
}
|
|
|
|
function hideLoading() {
|
|
const overlay = document.getElementById('loadingOverlay');
|
|
const progressFill = document.getElementById('progressFill');
|
|
const submitButton = document.querySelector('button[type="submit"]');
|
|
|
|
if (window.loadingIntervals) {
|
|
clearInterval(window.loadingIntervals.progressInterval);
|
|
clearInterval(window.loadingIntervals.messageInterval);
|
|
}
|
|
|
|
progressFill.style.width = '100%';
|
|
|
|
setTimeout(() => {
|
|
overlay.style.display = 'none';
|
|
progressFill.style.width = '0%';
|
|
if (submitButton) {
|
|
submitButton.disabled = false;
|
|
submitButton.textContent = 'Search';
|
|
}
|
|
}, 500);
|
|
}
|
|
|
|
window.addEventListener('load', () => {
|
|
setTimeout(hideLoading, 100);
|
|
});
|
|
|
|
window.addEventListener('pageshow', (event) => {
|
|
if (event.persisted) {
|
|
hideLoading();
|
|
}
|
|
});
|
|
|
|
<?php if ($showTrend && !empty($chartLabels) && $searchType === 'single_name') : ?>
|
|
const chartLabels = <?php echo json_encode($chartLabels); ?>;
|
|
const chartData = <?php echo json_encode($chartData); ?>;
|
|
const searchTitle = <?php echo json_encode($searchTitle); ?>;
|
|
|
|
const numericValues = chartData.filter((value) => !Number.isNaN(value) && value !== null);
|
|
const average = numericValues.length > 0 ? numericValues.reduce((a, b) => a + b, 0) / numericValues.length : 0;
|
|
const averageData = new Array(chartLabels.length).fill(average);
|
|
|
|
let chartConfig = {
|
|
type: 'line',
|
|
data: {
|
|
labels: chartLabels,
|
|
datasets: [
|
|
{
|
|
label: searchTitle,
|
|
data: chartData,
|
|
borderColor: 'rgb(14, 165, 233)',
|
|
backgroundColor: 'rgba(14, 165, 233, 0.1)',
|
|
borderWidth: 2,
|
|
pointRadius: 3,
|
|
pointHoverRadius: 6,
|
|
fill: true,
|
|
tension: 0.1,
|
|
order: 1,
|
|
},
|
|
{
|
|
label: `Average (${average.toFixed(2)})`,
|
|
data: averageData,
|
|
borderColor: 'rgb(239, 68, 68)',
|
|
backgroundColor: 'transparent',
|
|
borderWidth: 2,
|
|
borderDash: [5, 5],
|
|
pointRadius: 0,
|
|
pointHoverRadius: 0,
|
|
fill: false,
|
|
tension: 0,
|
|
order: 2,
|
|
},
|
|
],
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
scales: {
|
|
x: {
|
|
display: true,
|
|
title: {
|
|
display: true,
|
|
text: 'Time',
|
|
},
|
|
ticks: {
|
|
maxTicksLimit: 10,
|
|
},
|
|
},
|
|
y: {
|
|
display: true,
|
|
title: {
|
|
display: true,
|
|
text: 'Value',
|
|
},
|
|
},
|
|
},
|
|
plugins: {
|
|
legend: {
|
|
display: true,
|
|
position: 'top',
|
|
},
|
|
tooltip: {
|
|
mode: 'index',
|
|
intersect: false,
|
|
},
|
|
},
|
|
interaction: {
|
|
mode: 'nearest',
|
|
axis: 'x',
|
|
intersect: false,
|
|
},
|
|
},
|
|
};
|
|
|
|
const ctx = document.getElementById('trendChart').getContext('2d');
|
|
let chart = new Chart(ctx, chartConfig);
|
|
|
|
document.getElementById('showDataPoints').addEventListener('change', function () {
|
|
chart.data.datasets.forEach((dataset) => {
|
|
if (!dataset.label.includes('Average')) {
|
|
dataset.pointRadius = this.checked ? 3 : 0;
|
|
}
|
|
});
|
|
chart.update();
|
|
});
|
|
|
|
document.getElementById('showGrid').addEventListener('change', function () {
|
|
chart.options.scales.x.grid = { display: this.checked };
|
|
chart.options.scales.y.grid = { display: this.checked };
|
|
chart.update();
|
|
});
|
|
|
|
document.getElementById('smoothLines').addEventListener('change', function () {
|
|
chart.data.datasets.forEach((dataset) => {
|
|
if (!dataset.label.includes('Average')) {
|
|
dataset.tension = this.checked ? 0.4 : 0.1;
|
|
}
|
|
});
|
|
chart.update();
|
|
});
|
|
|
|
document.getElementById('showAverage').addEventListener('change', function () {
|
|
chart.data.datasets[1].hidden = !this.checked;
|
|
chart.update();
|
|
});
|
|
|
|
function exportChart(format) {
|
|
if (format === 'png') {
|
|
const link = document.createElement('a');
|
|
const timestamp = new Date().getTime();
|
|
link.download = `trend_chart_${timestamp}.png`;
|
|
link.href = chart.toBase64Image();
|
|
link.click();
|
|
} else if (format === 'csv') {
|
|
exportChartToCSV();
|
|
}
|
|
}
|
|
|
|
function exportChartToCSV() {
|
|
let csv = 'Timestamp,Value,Average\n';
|
|
for (let i = 0; i < chartLabels.length; i++) {
|
|
csv += `${chartLabels[i]},${chartData[i]},${average.toFixed(2)}\n`;
|
|
}
|
|
|
|
const blob = new Blob([csv], { type: 'text/csv' });
|
|
const link = document.createElement('a');
|
|
const timestamp = new Date().getTime();
|
|
link.download = `trend_data_${timestamp}.csv`;
|
|
link.href = window.URL.createObjectURL(blob);
|
|
link.click();
|
|
}
|
|
<?php else : ?>
|
|
function exportChart() {
|
|
return false;
|
|
}
|
|
<?php endif; ?>
|
|
|
|
function exportMultipleTagsCSV() {
|
|
const table = document.getElementById('dataTable');
|
|
if (!table) {
|
|
return;
|
|
}
|
|
|
|
let csv = '';
|
|
const headers = [];
|
|
table.querySelectorAll('thead th').forEach((th) => {
|
|
headers.push(ta.textContent.trim());
|
|
});
|
|
csv += `${headers.join(',')}\n`;
|
|
|
|
table.querySelectorAll('tbody tr').forEach((tr) => {
|
|
const row = [];
|
|
tr.querySelectorAll('td').forEach((td) => {
|
|
let cellData = td.textContent.trim();
|
|
if (cellData.includes(',') || cellData.includes('"') || cellData.includes('\n')) {
|
|
cellData = '"' + cellData.replace(/"/g, '""') + '"';
|
|
}
|
|
row.push(cellData);
|
|
});
|
|
csv += `${row.join(',')}\n`;
|
|
});
|
|
|
|
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
|
const link = document.createElement('a');
|
|
const timestamp = new Date().getTime();
|
|
link.download = `multiple_tags_data_${timestamp}.csv`;
|
|
link.href = window.URL.createObjectURL(blob);
|
|
link.style.display = 'none';
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
}
|
|
</script>
|
|
|
|
<?php require __DIR__ . '/includes/layout/footer.php'; ?>
|