1237 lines
52 KiB
PHP
1237 lines
52 KiB
PHP
<?php
|
|
// Database connection
|
|
$servername = "192.168.0.13";
|
|
$username = "opce";
|
|
$password = "opcelasuca";
|
|
$dbname = "history";
|
|
|
|
try {
|
|
$pdo = new PDO("mysql:host=$servername;dbname=$dbname", $username, $password);
|
|
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
|
} catch (PDOException $e) {
|
|
die("Connection failed: " . $e->getMessage());
|
|
}
|
|
|
|
// Handle AJAX request for autocomplete
|
|
if (isset($_GET['action']) && $_GET['action'] === 'autocomplete') {
|
|
$query = isset($_GET['query']) ? trim($_GET['query']) : '';
|
|
$suggestions = [];
|
|
|
|
if (!empty($query)) {
|
|
try {
|
|
// Make sure we're using the same column name as the main query
|
|
$stmt = $pdo->prepare("SELECT DISTINCT name FROM id_names WHERE name LIKE :query ORDER BY name LIMIT 20");
|
|
$stmt->execute([':query' => '%' . $query . '%']);
|
|
$results = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
|
|
|
foreach ($results as $row) {
|
|
$suggestions[] = $row['name'];
|
|
}
|
|
} catch (PDOException $e) {
|
|
// Handle error silently for AJAX
|
|
error_log("Autocomplete error: " . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
header('Content-Type: application/json');
|
|
echo json_encode($suggestions);
|
|
exit;
|
|
}
|
|
|
|
// Handle search parameters
|
|
$searchType = isset($_GET['search_type']) ? $_GET['search_type'] : 'single_name';
|
|
$searchName = isset($_GET['name']) ? trim($_GET['name']) : '';
|
|
|
|
// Handle multiple tag dropdowns
|
|
$selectedTags = [];
|
|
for ($i = 1; $i <= 5; $i++) {
|
|
$tagParam = "tag{$i}";
|
|
if (isset($_GET[$tagParam]) && !empty(trim($_GET[$tagParam]))) {
|
|
$selectedTags[] = trim($_GET[$tagParam]);
|
|
}
|
|
}
|
|
$multipleNames = implode(', ', $selectedTags); // For backward compatibility
|
|
|
|
$startDate = isset($_GET['start_date']) ? $_GET['start_date'] : '';
|
|
$endDate = isset($_GET['end_date']) ? $_GET['end_date'] : '';
|
|
$limit = isset($_GET['limit']) ? (int)$_GET['limit'] : 100;
|
|
$showTrend = isset($_GET['show_trend']) ? true : false;
|
|
|
|
// Change from fixed value to variable with default
|
|
$timeInterval = isset($_GET['time_interval']) ? (int)$_GET['time_interval'] : 30; // Default 30 seconds
|
|
|
|
// Add validation
|
|
if ($timeInterval < 1) {
|
|
$timeInterval = 1; // Minimum 1 second
|
|
}
|
|
|
|
$results = [];
|
|
$searchTitle = '';
|
|
$totalCount = 0;
|
|
$tagNames = [];
|
|
$organizedResults = [];
|
|
|
|
// Only search if required fields are provided
|
|
$canSearch = false;
|
|
switch ($searchType) {
|
|
case 'single_name':
|
|
$canSearch = !empty($searchName);
|
|
break;
|
|
case 'multiple_names':
|
|
$canSearch = !empty($selectedTags); // Use selectedTags instead of multipleNames
|
|
break;
|
|
}
|
|
|
|
if ($canSearch) {
|
|
try {
|
|
// Base queries - no time interval filtering
|
|
$sql = "SELECT h.ID, h.Value, h.TimeStamp, COALESCE(n.name, CONCAT('ID_', h.ID)) as name FROM historicaldata h LEFT JOIN id_names n ON h.ID = n.idnumber WHERE 1=1";
|
|
$countSql = "SELECT COUNT(*) FROM historicaldata h LEFT JOIN id_names n ON h.ID = n.idnumber WHERE 1=1";
|
|
$params = [];
|
|
|
|
// Build query based on search type
|
|
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 = [];
|
|
for ($i = 0; $i < count($selectedTags); $i++) {
|
|
$placeholder = ':name' . $i;
|
|
$placeholders[] = $placeholder;
|
|
$params[$placeholder] = $selectedTags[$i];
|
|
}
|
|
$placeholderString = implode(',', $placeholders);
|
|
$sql .= " AND n.name IN ($placeholderString)";
|
|
$countSql .= " AND n.name IN ($placeholderString)";
|
|
$searchTitle = "Multiple Tags (" . count($selectedTags) . "): " . implode(', ', array_slice($selectedTags, 0, 3)) . (count($selectedTags) > 3 ? '...' : '');
|
|
}
|
|
break;
|
|
}
|
|
|
|
// Add date filters
|
|
if (!empty($startDate)) {
|
|
$sql .= " AND h.TimeStamp >= :start_date";
|
|
$countSql .= " AND h.TimeStamp >= :start_date";
|
|
$params[':start_date'] = $startDate;
|
|
}
|
|
|
|
if (!empty($endDate)) {
|
|
$sql .= " AND h.TimeStamp <= :end_date";
|
|
$countSql .= " AND h.TimeStamp <= :end_date";
|
|
$params[':end_date'] = $endDate;
|
|
}
|
|
|
|
// Add time interval filtering
|
|
if ($timeInterval > 1) {
|
|
if ($timeInterval >= 60) {
|
|
// For minute intervals, align to minute boundaries
|
|
$minutes = $timeInterval / 60;
|
|
$sql .= " AND (MINUTE(h.TimeStamp) % :minute_interval = 0) AND (SECOND(h.TimeStamp) = 0)";
|
|
$countSql .= " AND (MINUTE(h.TimeStamp) % :minute_interval = 0) AND (SECOND(h.TimeStamp) = 0)";
|
|
$params[':minute_interval'] = $minutes;
|
|
} else {
|
|
// For second intervals, align to second boundaries
|
|
$sql .= " AND (SECOND(h.TimeStamp) % :time_interval = 0)";
|
|
$countSql .= " AND (SECOND(h.TimeStamp) % :time_interval = 0)";
|
|
$params[':time_interval'] = $timeInterval;
|
|
}
|
|
}
|
|
|
|
// Get total count
|
|
$countStmt = $pdo->prepare($countSql);
|
|
$countStmt->execute($params);
|
|
$totalCount = $countStmt->fetchColumn();
|
|
|
|
// Add ordering and limit
|
|
if ($showTrend) {
|
|
// For trend charts, always get chronological order (oldest to newest)
|
|
$sql .= " ORDER BY h.TimeStamp ASC LIMIT :limit";
|
|
} else {
|
|
// For table display, get newest first
|
|
$sql .= " ORDER BY h.TimeStamp DESC LIMIT :limit";
|
|
}
|
|
$params[':limit'] = $limit;
|
|
|
|
$stmt = $pdo->prepare($sql);
|
|
|
|
// Bind all parameters
|
|
foreach ($params as $key => $value) {
|
|
if ($key === ':limit') {
|
|
$stmt->bindValue($key, $value, PDO::PARAM_INT);
|
|
} else {
|
|
$stmt->bindValue($key, $value);
|
|
}
|
|
}
|
|
|
|
$stmt->execute();
|
|
$results = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
|
|
|
// Organize results by timestamp
|
|
if (!empty($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)) {
|
|
$tagNames[] = $tagName;
|
|
}
|
|
}
|
|
|
|
// Sort timestamps
|
|
if ($showTrend && $searchType == 'single_name') {
|
|
ksort($organizedResults);
|
|
} else {
|
|
krsort($organizedResults);
|
|
}
|
|
}
|
|
} catch (PDOException $e) {
|
|
echo "<div style='color: red; padding: 10px; border: 1px solid red; margin: 10px 0;'>Query error: " . htmlspecialchars($e->getMessage()) . "</div>";
|
|
}
|
|
}
|
|
|
|
// Functions
|
|
function formatTimestamp($timestamp)
|
|
{
|
|
return date('Y-m-d H:i:s', strtotime($timestamp));
|
|
}
|
|
|
|
function formatValue($value)
|
|
{
|
|
if (is_numeric($value)) {
|
|
return number_format($value, 2);
|
|
}
|
|
return $value;
|
|
}
|
|
|
|
// Prepare chart data for single tag charts only
|
|
$chartData = [];
|
|
$chartLabels = [];
|
|
|
|
if (!empty($results) && $showTrend && $searchType == 'single_name') {
|
|
// Single tag chart data only
|
|
foreach ($results as $row) {
|
|
$chartLabels[] = date('M j, Y H:i', strtotime($row['TimeStamp']));
|
|
$chartData[] = is_numeric($row['Value']) ? (float)$row['Value'] : 0;
|
|
}
|
|
}
|
|
?>
|
|
|
|
<!DOCTYPE html>
|
|
<html>
|
|
|
|
<head>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Historical Data Search & Trend</title>
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
<link href="searchstyle.css" rel="stylesheet" type="text/css" />
|
|
</head>
|
|
|
|
<body>
|
|
<!-- Theme Toggle Button -->
|
|
<button class="theme-toggle" onclick="toggleTheme()" title="Toggle Dark/Light Theme">
|
|
<svg id="theme-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
<!-- Sun icon (shown in dark mode) -->
|
|
<g id="sun-icon">
|
|
<circle cx="12" cy="12" r="5" stroke="currentColor" stroke-width="2"/>
|
|
<path d="m12 1 0 2M12 21l0 2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12l2 0M21 12l2 0M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
|
</g>
|
|
<!-- Moon icon (shown in light mode) -->
|
|
<g id="moon-icon" style="display: none;">
|
|
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" stroke="currentColor" stroke-width="2"/>
|
|
</g>
|
|
</svg>
|
|
</button>
|
|
|
|
<!-- Loading Overlay -->
|
|
<div class="loading-overlay" id="loadingOverlay">
|
|
<div class="loading-container">
|
|
<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="progress-bar">
|
|
<div class="progress-fill" id="progressFill"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="container">
|
|
<h1>LASUCA Controls Historical Data Search & Trend</h1>
|
|
|
|
<div class="search-form">
|
|
<!-- Search Type Tabs -->
|
|
<div class="search-type-tabs">
|
|
<div class="tab <?php echo $searchType == 'single_name' ? 'active' : ''; ?>" onclick="switchSearchType('single_name')">Single Tag</div>
|
|
<div class="tab <?php echo $searchType == 'multiple_names' ? 'active' : ''; ?>" onclick="switchSearchType('multiple_names')">Multiple Tags</div>
|
|
</div>
|
|
|
|
<form method="GET" id="searchForm" onsubmit="showLoading()">
|
|
<input type="hidden" id="search_type" name="search_type" value="<?php echo $searchType; ?>">
|
|
|
|
<!-- Single Name Search -->
|
|
<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>
|
|
<div class="help-text">Search for historical data by tag name (with auto-complete)</div>
|
|
</div>
|
|
|
|
<!-- Multiple Names Search -->
|
|
<div id="multiple_names" class="search-option <?php echo $searchType == 'multiple_names' ? 'active' : ''; ?>">
|
|
<div class="form-row">
|
|
<label for="tag1">Tag 1:</label>
|
|
<select id="tag1" name="tag1">
|
|
<option value="">Select a tag...</option>
|
|
<?php
|
|
// Get all available tag names for dropdowns
|
|
try {
|
|
$tagStmt = $pdo->prepare("SELECT DISTINCT name FROM id_names ORDER BY name");
|
|
$tagStmt->execute();
|
|
$allTags = $tagStmt->fetchAll(PDO::FETCH_ASSOC);
|
|
|
|
// Parse existing multiple_names for pre-selection
|
|
$selectedTags = [];
|
|
if (!empty($multipleNames)) {
|
|
$selectedTags = array_map('trim', explode(',', $multipleNames));
|
|
}
|
|
|
|
foreach ($allTags as $tag) {
|
|
$tagName = htmlspecialchars($tag['name']);
|
|
$selected = isset($selectedTags[0]) && $selectedTags[0] === $tag['name'] ? 'selected' : '';
|
|
echo "<option value=\"{$tagName}\" {$selected}>{$tagName}</option>";
|
|
}
|
|
} catch (PDOException $e) {
|
|
echo "<option value=\"\">Error loading tags</option>";
|
|
}
|
|
?>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="form-row">
|
|
<label for="tag2">Tag 2:</label>
|
|
<select id="tag2" name="tag2">
|
|
<option value="">Select a tag...</option>
|
|
<?php
|
|
foreach ($allTags as $tag) {
|
|
$tagName = htmlspecialchars($tag['name']);
|
|
$selected = isset($selectedTags[1]) && $selectedTags[1] === $tag['name'] ? 'selected' : '';
|
|
echo "<option value=\"{$tagName}\" {$selected}>{$tagName}</option>";
|
|
}
|
|
?>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="form-row">
|
|
<label for="tag3">Tag 3:</label>
|
|
<select id="tag3" name="tag3">
|
|
<option value="">Select a tag...</option>
|
|
<?php
|
|
foreach ($allTags as $tag) {
|
|
$tagName = htmlspecialchars($tag['name']);
|
|
$selected = isset($selectedTags[2]) && $selectedTags[2] === $tag['name'] ? 'selected' : '';
|
|
echo "<option value=\"{$tagName}\" {$selected}>{$tagName}</option>";
|
|
}
|
|
?>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="form-row">
|
|
<label for="tag4">Tag 4:</label>
|
|
<select id="tag4" name="tag4">
|
|
<option value="">Select a tag...</option>
|
|
<?php
|
|
foreach ($allTags as $tag) {
|
|
$tagName = htmlspecialchars($tag['name']);
|
|
$selected = isset($selectedTags[3]) && $selectedTags[3] === $tag['name'] ? 'selected' : '';
|
|
echo "<option value=\"{$tagName}\" {$selected}>{$tagName}</option>";
|
|
}
|
|
?>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="form-row">
|
|
<label for="tag5">Tag 5:</label>
|
|
<select id="tag5" name="tag5">
|
|
<option value="">Select a tag...</option>
|
|
<?php
|
|
foreach ($allTags as $tag) {
|
|
$tagName = htmlspecialchars($tag['name']);
|
|
$selected = isset($selectedTags[4]) && $selectedTags[4] === $tag['name'] ? 'selected' : '';
|
|
echo "<option value=\"{$tagName}\" {$selected}>{$tagName}</option>";
|
|
}
|
|
?>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="help-text">Select up to 5 different tags to compare their historical data</div>
|
|
</div>
|
|
|
|
<!-- Common Date Filters -->
|
|
<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($startDate); ?>">
|
|
</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($endDate); ?>">
|
|
</div>
|
|
|
|
<div class="form-row">
|
|
<label for="limit">Results Limit:</label>
|
|
<select id="limit" name="limit">
|
|
<option value="50" <?php echo $limit == 50 ? 'selected' : ''; ?>>50</option>
|
|
<option value="100" <?php echo $limit == 100 ? 'selected' : ''; ?>>100</option>
|
|
<option value="500" <?php echo $limit == 500 ? 'selected' : ''; ?>>500</option>
|
|
<option value="1000" <?php echo $limit == 1000 ? 'selected' : ''; ?>>1000</option>
|
|
<option value="5000" <?php echo $limit == 5000 ? 'selected' : ''; ?>>5000</option>
|
|
</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="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>
|
|
<div class="help-text">Choose data sampling interval. 30 seconds shows data every 30 seconds for balanced detail and performance.</div>
|
|
|
|
<div class="form-row checkbox" id="trend_option" style="display: <?php echo $searchType == 'single_name' ? 'flex' : 'none'; ?>;">
|
|
<input type="checkbox" id="show_trend" name="show_trend" <?php echo $showTrend ? 'checked' : ''; ?>>
|
|
<label for="show_trend" class="checkbox">Show Trend Chart (Single Tag only)</label>
|
|
</div>
|
|
|
|
<div class="form-row">
|
|
<button type="submit">Search</button>
|
|
<a href="opcsearch.php"><button type="button">Clear</button></a>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
<?php if ($canSearch) : ?>
|
|
<?php if (!empty($results)) : ?>
|
|
<div class="results-info">
|
|
<div class="results-count">
|
|
Showing <?php echo count($organizedResults); ?> timestamp(s) with <?php echo count($results); ?> data points for
|
|
<strong><?php echo htmlspecialchars($searchTitle); ?></strong>
|
|
<?php if ($timeInterval > 1): ?>
|
|
<br><small>Data interval: Every <?php
|
|
if ($timeInterval >= 60) {
|
|
echo ($timeInterval / 60) . ' minute' . ($timeInterval > 60 ? 's' : '');
|
|
} else {
|
|
echo $timeInterval . ' second' . ($timeInterval > 1 ? 's' : '');
|
|
}
|
|
?></small>
|
|
<?php endif; ?>
|
|
</div>
|
|
<?php if ($totalCount > $limit): ?>
|
|
<div style="color: #856404; background-color: #fff3cd; padding: 5px 10px; border-radius: 3px;">
|
|
Note: Only showing first <?php echo $limit; ?> results. Total: <?php echo $totalCount; ?>. Use date filters to narrow search.
|
|
</div>
|
|
<?php endif; ?>
|
|
</div>
|
|
|
|
<!-- Export buttons for multiple tags - MOVED TO TOP -->
|
|
<?php if ($searchType == 'multiple_names' && !empty($results)) : ?>
|
|
<div class="export-buttons">
|
|
<button onclick="exportMultipleTagsCSV()">Export Multiple Tags as CSV</button>
|
|
</div>
|
|
<?php endif; ?>
|
|
|
|
<?php if ($showTrend && !empty($chartLabels) && $searchType == 'single_name') : ?>
|
|
<!-- Statistics Panel -->
|
|
<?php
|
|
$numericValues = array_filter($chartData, 'is_numeric');
|
|
if (!empty($numericValues)) {
|
|
$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;
|
|
}
|
|
?>
|
|
|
|
<?php if (!empty($numericValues)) : ?>
|
|
<div class="stats-panel">
|
|
<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 ? '#28a745' : '#dc3545'; ?>">
|
|
<?php echo ($change >= 0 ? '+' : '') . number_format($change, 2); ?>
|
|
(<?php echo ($changePercent >= 0 ? '+' : '') . number_format($changePercent, 1); ?>%)
|
|
</div>
|
|
<div class="stat-label">Total Change</div>
|
|
</div>
|
|
</div>
|
|
<?php endif; ?>
|
|
|
|
<!-- Chart Container -->
|
|
<div class="chart-container">
|
|
<h3>Trend Chart for <?php echo htmlspecialchars($searchTitle); ?></h3>
|
|
|
|
<div class="export-buttons">
|
|
<button onclick="exportChart('png')">Export as PNG</button>
|
|
<button 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">
|
|
<table id="dataTable">
|
|
<thead>
|
|
<tr>
|
|
<th class="timestamp-header">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 class="timestamp"><?php echo htmlspecialchars(formatTimestamp($timestampData['timestamp'])); ?></td>
|
|
<?php foreach ($tagNames as $tagName): ?>
|
|
<td class="numeric-value">
|
|
<?php
|
|
if (isset($timestampData[$tagName])) {
|
|
echo htmlspecialchars(formatValue($timestampData[$tagName]));
|
|
} else {
|
|
echo '-';
|
|
}
|
|
?>
|
|
</td>
|
|
<?php endforeach; ?>
|
|
</tr>
|
|
<?php endforeach; ?>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<?php else: ?>
|
|
<div class="no-results">
|
|
<h3>No results found</h3>
|
|
<p>No data found for the specified search criteria.</p>
|
|
</div>
|
|
<?php endif; ?>
|
|
<?php endif; ?>
|
|
</div>
|
|
|
|
<!-- Inline Status Indicator (alternative/additional to overlay) -->
|
|
<div class="search-status" id="searchStatus" style="display: none;">
|
|
<div class="status-content">
|
|
<div class="mini-spinner"></div>
|
|
<span class="status-text">Searching database...</span>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
// Autocomplete functionality
|
|
class AutoComplete {
|
|
constructor(inputElement, suggestionsElement) {
|
|
this.input = inputElement;
|
|
this.suggestions = suggestionsElement;
|
|
this.selectedIndex = -1;
|
|
this.currentSuggestions = [];
|
|
this.isSelecting = false; // Flag to prevent conflicts
|
|
|
|
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(e) {
|
|
const query = e.target.value.trim();
|
|
|
|
if (query.length < 2) {
|
|
this.hideSuggestions();
|
|
return;
|
|
}
|
|
|
|
let cursorPos = 0;
|
|
let currentWord = '';
|
|
|
|
// Get cursor position safely
|
|
try {
|
|
if (typeof e.target.selectionStart === 'number') {
|
|
cursorPos = e.target.selectionStart;
|
|
} else {
|
|
cursorPos = query.length;
|
|
}
|
|
} catch (error) {
|
|
cursorPos = query.length;
|
|
}
|
|
|
|
// Get the current word being typed (for multiple names)
|
|
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, currentWord);
|
|
} catch (error) {
|
|
console.error('Autocomplete error:', error);
|
|
this.hideSuggestions();
|
|
}
|
|
}
|
|
|
|
showSuggestions(suggestions, currentWord) {
|
|
this.currentSuggestions = suggestions;
|
|
this.selectedIndex = -1;
|
|
|
|
if (suggestions.length === 0) {
|
|
this.hideSuggestions();
|
|
return;
|
|
}
|
|
|
|
this.suggestions.innerHTML = '';
|
|
suggestions.forEach((suggestion, index) => {
|
|
const div = document.createElement('div');
|
|
div.className = 'autocomplete-suggestion';
|
|
div.textContent = suggestion;
|
|
|
|
// Use both mousedown and click for better compatibility
|
|
div.addEventListener('mousedown', (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
this.isSelecting = true;
|
|
this.selectSuggestion(suggestion);
|
|
});
|
|
|
|
div.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
e.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(e) {
|
|
// Reset selection flag when input gains focus
|
|
this.isSelecting = false;
|
|
}
|
|
|
|
handleKeyDown(e) {
|
|
if (this.suggestions.style.display === 'none') return;
|
|
|
|
switch (e.key) {
|
|
case 'ArrowDown':
|
|
e.preventDefault();
|
|
this.selectedIndex = Math.min(this.selectedIndex + 1, this.currentSuggestions.length - 1);
|
|
this.updateSelection();
|
|
break;
|
|
case 'ArrowUp':
|
|
e.preventDefault();
|
|
this.selectedIndex = Math.max(this.selectedIndex - 1, -1);
|
|
this.updateSelection();
|
|
break;
|
|
case 'Enter':
|
|
e.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) {
|
|
console.log('Selecting suggestion:', suggestion); // Debug log
|
|
|
|
let cursorPos = 0;
|
|
let currentValue = this.input.value || '';
|
|
|
|
// Handle cursor position differently for textarea vs input
|
|
if (this.input.tagName.toLowerCase() === 'textarea') {
|
|
// For textarea, use different approach
|
|
try {
|
|
if (typeof this.input.selectionStart === 'number') {
|
|
cursorPos = this.input.selectionStart;
|
|
} else {
|
|
// Fallback for older browsers
|
|
cursorPos = currentValue.length;
|
|
}
|
|
} catch (e) {
|
|
cursorPos = currentValue.length;
|
|
}
|
|
} else {
|
|
// For regular input elements
|
|
try {
|
|
cursorPos = this.input.selectionStart || currentValue.length;
|
|
} catch (e) {
|
|
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) {
|
|
// Replace the current word in a comma-separated list
|
|
const beforeComma = textBeforeCursor.substring(0, lastCommaIndex + 1);
|
|
newValue = beforeComma + ' ' + suggestion + textAfterCursor;
|
|
newCursorPos = beforeComma.length + 1 + suggestion.length;
|
|
} else {
|
|
// Replace the entire input
|
|
newValue = suggestion + textAfterCursor;
|
|
newCursorPos = suggestion.length;
|
|
}
|
|
|
|
console.log('New value:', newValue); // Debug log
|
|
console.log('New cursor position:', newCursorPos); // Debug log
|
|
|
|
// Update the value
|
|
this.input.value = newValue;
|
|
this.hideSuggestions();
|
|
|
|
// Set cursor position and focus
|
|
setTimeout(() => {
|
|
try {
|
|
// Focus first
|
|
this.input.focus();
|
|
|
|
// Then try to set cursor position
|
|
if (this.input.setSelectionRange && typeof newCursorPos === 'number') {
|
|
this.input.setSelectionRange(newCursorPos, newCursorPos);
|
|
}
|
|
} catch (e) {
|
|
console.log('Cursor positioning failed:', e);
|
|
// Just ensure focus if cursor positioning fails
|
|
this.input.focus();
|
|
}
|
|
|
|
this.isSelecting = false;
|
|
}, 50); // Increased timeout for better compatibility
|
|
}
|
|
|
|
handleDocumentClick(e) {
|
|
// Don't hide if we're in the process of selecting
|
|
if (this.isSelecting) {
|
|
return;
|
|
}
|
|
|
|
// Only hide if clicking outside both input and suggestions
|
|
if (!this.input.contains(e.target) && !this.suggestions.contains(e.target)) {
|
|
this.hideSuggestions();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Only initialize autocomplete for single name search
|
|
const nameInput = document.getElementById('name');
|
|
const nameSuggestions = document.getElementById('name-suggestions');
|
|
|
|
if (nameInput && nameSuggestions) {
|
|
new AutoComplete(nameInput, nameSuggestions);
|
|
}
|
|
|
|
// Add change event handlers for the tag dropdowns to prevent duplicates
|
|
const tagSelects = ['tag1', 'tag2', 'tag3', 'tag4', 'tag5'];
|
|
|
|
tagSelects.forEach(tagId => {
|
|
const select = document.getElementById(tagId);
|
|
if (select) {
|
|
select.addEventListener('change', function() {
|
|
updateTagOptions();
|
|
});
|
|
}
|
|
});
|
|
|
|
function updateTagOptions() {
|
|
// Get all currently selected values
|
|
const selectedValues = [];
|
|
tagSelects.forEach(tagId => {
|
|
const select = document.getElementById(tagId);
|
|
if (select && select.value) {
|
|
selectedValues.push(select.value);
|
|
}
|
|
});
|
|
|
|
// Update each dropdown to disable already selected options
|
|
tagSelects.forEach(tagId => {
|
|
const select = document.getElementById(tagId);
|
|
if (select) {
|
|
Array.from(select.options).forEach(option => {
|
|
if (option.value === '') {
|
|
option.disabled = false; // Always allow "Select a tag..."
|
|
} else {
|
|
// Disable if selected in another dropdown, but not in this one
|
|
option.disabled = selectedValues.includes(option.value) && option.value !== select.value;
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
// Initialize on page load
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
updateTagOptions();
|
|
});
|
|
|
|
// Tab switching functionality
|
|
function switchSearchType(type) {
|
|
document.getElementById('search_type').value = type;
|
|
|
|
document.querySelectorAll('.tab').forEach(tab => tab.classList.remove('active'));
|
|
document.querySelector(`.tab[onclick="switchSearchType('${type}')"]`).classList.add('active');
|
|
|
|
document.querySelectorAll('.search-option').forEach(option => option.classList.remove('active'));
|
|
document.getElementById(type).classList.add('active');
|
|
|
|
// Show trend option only for single tag search
|
|
const trendOption = document.getElementById('trend_option');
|
|
const showTrendCheckbox = document.getElementById('show_trend');
|
|
|
|
if (type === 'single_name') {
|
|
trendOption.style.display = 'flex';
|
|
document.querySelector('label[for="show_trend"]').textContent = 'Show Trend Chart (Single Tag only)';
|
|
} else {
|
|
trendOption.style.display = 'none';
|
|
showTrendCheckbox.checked = false; // Uncheck when hidden
|
|
}
|
|
}
|
|
|
|
// Loading indicator functions
|
|
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"]');
|
|
|
|
// Disable submit button
|
|
submitButton.disabled = true;
|
|
submitButton.textContent = 'Searching...';
|
|
|
|
// Show overlay
|
|
overlay.style.display = 'flex';
|
|
|
|
// Animate progress bar
|
|
let progress = 0;
|
|
const progressInterval = setInterval(() => {
|
|
progress += Math.random() * 15;
|
|
if (progress > 90) progress = 90;
|
|
progressFill.style.width = progress + '%';
|
|
}, 200);
|
|
|
|
// Update loading messages
|
|
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);
|
|
|
|
// Store intervals for cleanup
|
|
window.loadingIntervals = {
|
|
progressInterval,
|
|
messageInterval
|
|
};
|
|
}
|
|
|
|
function hideLoading() {
|
|
const overlay = document.getElementById('loadingOverlay');
|
|
const submitButton = document.querySelector('button[type="submit"]');
|
|
const progressFill = document.getElementById('progressFill');
|
|
|
|
// Clear intervals
|
|
if (window.loadingIntervals) {
|
|
clearInterval(window.loadingIntervals.progressInterval);
|
|
clearInterval(window.loadingIntervals.messageInterval);
|
|
}
|
|
|
|
// Complete progress bar
|
|
progressFill.style.width = '100%';
|
|
|
|
// Hide overlay after a short delay
|
|
setTimeout(() => {
|
|
overlay.style.display = 'none';
|
|
submitButton.disabled = false;
|
|
submitButton.textContent = 'Search';
|
|
progressFill.style.width = '0%';
|
|
}, 500);
|
|
}
|
|
|
|
// Auto-hide loading on page load (for when search completes)
|
|
window.addEventListener('load', function() {
|
|
// Small delay to show completion
|
|
setTimeout(hideLoading, 100);
|
|
});
|
|
|
|
// Also hide loading if user navigates back
|
|
window.addEventListener('pageshow', function(event) {
|
|
if (event.persisted) {
|
|
hideLoading();
|
|
}
|
|
});
|
|
|
|
<?php if ($showTrend && !empty($chartLabels) && $searchType == 'single_name') : ?>
|
|
// Single tag chart functionality only
|
|
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(val => !isNaN(val) && val !== 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(0, 124, 186)',
|
|
backgroundColor: 'rgba(0, 124, 186, 0.1)',
|
|
borderWidth: 2,
|
|
pointRadius: 3,
|
|
pointHoverRadius: 6,
|
|
fill: true,
|
|
tension: 0.1,
|
|
order: 1
|
|
}, {
|
|
label: 'Average (' + average.toFixed(2) + ')',
|
|
data: averageData,
|
|
borderColor: 'rgb(220, 53, 69)',
|
|
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);
|
|
|
|
// Chart controls
|
|
document.getElementById('showDataPoints').addEventListener('change', function() {
|
|
chart.data.datasets.forEach(dataset => {
|
|
if (dataset.label.indexOf('Average') === -1) {
|
|
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.indexOf('Average') === -1) {
|
|
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();
|
|
});
|
|
|
|
// Export functions
|
|
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') {
|
|
exportToCSV();
|
|
}
|
|
}
|
|
|
|
function exportToCSV() {
|
|
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 endif; ?>
|
|
|
|
// Export functions for multiple tags - MOVED TO MAIN SCRIPT AREA
|
|
function exportMultipleTagsCSV() {
|
|
const table = document.getElementById('dataTable');
|
|
if (!table) return;
|
|
|
|
let csv = '';
|
|
|
|
// Export headers
|
|
const headers = [];
|
|
table.querySelectorAll('thead th').forEach(th => {
|
|
headers.push(th.textContent.trim());
|
|
});
|
|
csv += headers.join(',') + '\n';
|
|
|
|
// Export data rows
|
|
table.querySelectorAll('tbody tr').forEach(tr => {
|
|
const row = [];
|
|
tr.querySelectorAll('td').forEach(td => {
|
|
let cellData = td.textContent.trim();
|
|
// Escape commas and quotes in CSV
|
|
if (cellData.includes(',') || cellData.includes('"') || cellData.includes('\n')) {
|
|
cellData = '"' + cellData.replace(/"/g, '""') + '"';
|
|
}
|
|
row.push(cellData);
|
|
});
|
|
csv += row.join(',') + '\n';
|
|
});
|
|
|
|
// Download the CSV
|
|
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);
|
|
}
|
|
|
|
// Theme Toggle Functionality
|
|
function toggleTheme() {
|
|
const body = document.body;
|
|
const currentTheme = body.getAttribute('data-theme');
|
|
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
|
|
|
|
body.setAttribute('data-theme', newTheme);
|
|
localStorage.setItem('theme', newTheme);
|
|
updateThemeIcon(newTheme);
|
|
}
|
|
|
|
function updateThemeIcon(theme) {
|
|
const sunIcon = document.getElementById('sun-icon');
|
|
const moonIcon = document.getElementById('moon-icon');
|
|
|
|
if (theme === 'dark') {
|
|
sunIcon.style.display = 'block';
|
|
moonIcon.style.display = 'none';
|
|
} else {
|
|
sunIcon.style.display = 'none';
|
|
moonIcon.style.display = 'block';
|
|
}
|
|
}
|
|
|
|
// Mobile-specific improvements
|
|
function initializeMobileFeatures() {
|
|
// Prevent zoom on input focus for iOS
|
|
const inputs = document.querySelectorAll('input, select, textarea');
|
|
inputs.forEach(input => {
|
|
if (input.style.fontSize === '' || parseInt(input.style.fontSize) < 16) {
|
|
input.style.fontSize = '16px';
|
|
}
|
|
});
|
|
|
|
// Improve autocomplete for touch devices
|
|
if ('ontouchstart' in window) {
|
|
const suggestions = document.getElementById('name-suggestions');
|
|
if (suggestions) {
|
|
suggestions.addEventListener('touchstart', function(e) {
|
|
e.preventDefault(); // Prevent scrolling when touching suggestions
|
|
});
|
|
}
|
|
}
|
|
|
|
// Better table scrolling on mobile
|
|
const tableScroll = document.querySelector('.table-scroll');
|
|
if (tableScroll) {
|
|
let isScrolling = false;
|
|
tableScroll.addEventListener('touchstart', function() {
|
|
isScrolling = false;
|
|
});
|
|
|
|
tableScroll.addEventListener('touchmove', function() {
|
|
isScrolling = true;
|
|
});
|
|
|
|
tableScroll.addEventListener('touchend', function() {
|
|
// Add momentum scrolling indicator
|
|
if (isScrolling) {
|
|
tableScroll.style.scrollBehavior = 'smooth';
|
|
setTimeout(() => {
|
|
tableScroll.style.scrollBehavior = 'auto';
|
|
}, 300);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// Initialize mobile features on DOM load
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
const savedTheme = localStorage.getItem('theme') ||
|
|
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
|
|
|
document.body.setAttribute('data-theme', savedTheme);
|
|
updateThemeIcon(savedTheme);
|
|
updateTagOptions();
|
|
initializeMobileFeatures(); // Add this line
|
|
});
|
|
|
|
// Handle orientation changes
|
|
window.addEventListener('orientationchange', function() {
|
|
setTimeout(function() {
|
|
// Recalculate chart if it exists
|
|
if (typeof chart !== 'undefined') {
|
|
chart.resize();
|
|
}
|
|
}, 100);
|
|
});
|
|
</script>
|
|
</body>
|
|
|
|
</html>
|