982 lines
31 KiB
PHP
982 lines
31 KiB
PHP
<?php // phpcs:ignoreFile
|
|
require __DIR__ . '/../session.php';
|
|
require __DIR__ . '/../userAccess.php';
|
|
|
|
$pageTitle = 'Tag Health & Data Quality';
|
|
$pageSubtitle = 'Monitor historian tag freshness and signal quality.';
|
|
$pageDescription = 'Audit historian tags for stale data, null rates, and flatlined signals within a configurable window.';
|
|
$assetBasePath = '../';
|
|
$layoutWithoutSidebar = true;
|
|
$layoutReturnUrl = '../overview.php';
|
|
$layoutReturnLabel = 'Back to overview';
|
|
|
|
$servername = '192.168.0.13\\SQLEXPRESS';
|
|
$username = 'opce';
|
|
$password = 'opcelasuca';
|
|
$dbname = 'history';
|
|
|
|
$appTimeZone = new DateTimeZone('America/Chicago');
|
|
|
|
$windowOptions = [6, 12, 24, 48, 72];
|
|
$limitOptions = [50, 100, 200, 500];
|
|
$defaultWindow = 24;
|
|
$defaultLimit = 200;
|
|
$defaultTolerance = 0.05;
|
|
|
|
function sanitizeWindowHours($value, array $allowed, int $fallback): int
|
|
{
|
|
$candidate = (int) ($value ?? 0);
|
|
if (!in_array($candidate, $allowed, true)) {
|
|
return $fallback;
|
|
}
|
|
|
|
return $candidate;
|
|
}
|
|
|
|
function sanitizeLimit($value, array $allowed, int $fallback): int
|
|
{
|
|
$candidate = (int) ($value ?? 0);
|
|
if (!in_array($candidate, $allowed, true)) {
|
|
return $fallback;
|
|
}
|
|
|
|
return $candidate;
|
|
}
|
|
|
|
function sanitizeTolerance($value, float $fallback): float
|
|
{
|
|
if ($value === null || $value === '') {
|
|
return $fallback;
|
|
}
|
|
|
|
$candidate = (float) $value;
|
|
if ($candidate <= 0) {
|
|
return $fallback;
|
|
}
|
|
|
|
return $candidate;
|
|
}
|
|
|
|
function sanitizeSearch($value): string
|
|
{
|
|
return trim((string) ($value ?? ''));
|
|
}
|
|
|
|
function createDateTimeFromSql($value, DateTimeZone $tz): ?DateTimeImmutable
|
|
{
|
|
if ($value instanceof DateTimeInterface) {
|
|
return (new DateTimeImmutable($value->format('Y-m-d H:i:s.u')))->setTimezone($tz);
|
|
}
|
|
|
|
if (!is_string($value) || $value === '') {
|
|
return null;
|
|
}
|
|
|
|
$patterns = ['Y-m-d H:i:s.u', 'Y-m-d H:i:s'];
|
|
foreach ($patterns as $pattern) {
|
|
$dt = DateTimeImmutable::createFromFormat($pattern, $value, $tz);
|
|
if ($dt instanceof DateTimeImmutable) {
|
|
return $dt;
|
|
}
|
|
}
|
|
|
|
try {
|
|
return new DateTimeImmutable($value, $tz);
|
|
} catch (Exception $exception) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
try {
|
|
$pdo = new PDO(
|
|
"sqlsrv:Server=$servername;Database=$dbname",
|
|
$username,
|
|
$password,
|
|
[
|
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
|
]
|
|
);
|
|
} catch (PDOException $exception) {
|
|
if (isset($_POST['action'])) {
|
|
header('Content-Type: application/json');
|
|
echo json_encode([
|
|
'success' => false,
|
|
'error' => 'Database connection failed: ' . $exception->getMessage(),
|
|
]);
|
|
exit;
|
|
}
|
|
|
|
die('Connection failed: ' . $exception->getMessage());
|
|
}
|
|
|
|
if (isset($_POST['action'])) {
|
|
ob_clean();
|
|
header('Content-Type: application/json');
|
|
|
|
try {
|
|
switch ($_POST['action']) {
|
|
case 'get_tag_health':
|
|
$windowHours = sanitizeWindowHours(
|
|
$_POST['window_hours'] ?? null,
|
|
$windowOptions,
|
|
$defaultWindow
|
|
);
|
|
$limit = sanitizeLimit(
|
|
$_POST['limit'] ?? null,
|
|
$limitOptions,
|
|
$defaultLimit
|
|
);
|
|
$flatlineTolerance = sanitizeTolerance(
|
|
$_POST['flatline_tolerance'] ?? null,
|
|
$defaultTolerance
|
|
);
|
|
$search = sanitizeSearch($_POST['search'] ?? '');
|
|
|
|
$params = [-$windowHours];
|
|
$sql = "
|
|
WITH recent AS (
|
|
SELECT
|
|
n.name,
|
|
h.TimeStamp,
|
|
CAST(h.Value AS float) AS value,
|
|
LAG(CAST(h.Value AS float)) OVER (PARTITION BY n.name ORDER BY h.TimeStamp) AS prev_value
|
|
FROM dbo.archive AS h
|
|
INNER JOIN dbo.id_names AS n
|
|
ON h.ID = n.idnumber
|
|
WHERE h.TimeStamp >= DATEADD(HOUR, CAST(? AS INT), GETDATE())
|
|
AND n.name IS NOT NULL
|
|
AND n.name <> ''
|
|
";
|
|
|
|
if ($search !== '') {
|
|
$sql .= " AND n.name LIKE ?\n";
|
|
$params[] = '%' . $search . '%';
|
|
}
|
|
|
|
$sql .= ")
|
|
,
|
|
aggregated AS (
|
|
SELECT
|
|
name,
|
|
COUNT(*) AS sample_count,
|
|
SUM(CASE WHEN value IS NULL THEN 1 ELSE 0 END) AS null_count,
|
|
SUM(
|
|
CASE
|
|
WHEN prev_value IS NOT NULL
|
|
AND value IS NOT NULL
|
|
AND ABS(prev_value - value) <= CAST(? AS float)
|
|
THEN 1
|
|
ELSE 0
|
|
END
|
|
) AS flatline_count,
|
|
MIN(TimeStamp) AS first_timestamp,
|
|
MAX(TimeStamp) AS last_timestamp,
|
|
MIN(value) AS min_value,
|
|
MAX(value) AS max_value,
|
|
AVG(value) AS avg_value
|
|
FROM recent
|
|
GROUP BY name
|
|
)
|
|
SELECT TOP {$limit}
|
|
name,
|
|
sample_count,
|
|
null_count,
|
|
flatline_count,
|
|
first_timestamp,
|
|
last_timestamp,
|
|
min_value,
|
|
max_value,
|
|
avg_value
|
|
FROM aggregated
|
|
ORDER BY name ASC;
|
|
";
|
|
|
|
$params[] = $flatlineTolerance;
|
|
|
|
$stmt = $pdo->prepare($sql);
|
|
$stmt->execute($params);
|
|
$rows = $stmt->fetchAll();
|
|
|
|
$now = new DateTimeImmutable('now', $appTimeZone);
|
|
$windowMinutes = $windowHours * 60;
|
|
$staleThreshold = max(30, (int) round($windowMinutes * 0.2));
|
|
$tags = [];
|
|
$healthyCount = 0;
|
|
$warningCount = 0;
|
|
$offlineCount = 0;
|
|
|
|
foreach ($rows as $row) {
|
|
$name = (string) $row['name'];
|
|
$sampleCount = (int) ($row['sample_count'] ?? 0);
|
|
$nullCount = (int) ($row['null_count'] ?? 0);
|
|
$flatlineCount = (int) ($row['flatline_count'] ?? 0);
|
|
|
|
$lastTimestamp = createDateTimeFromSql($row['last_timestamp'] ?? null, $appTimeZone);
|
|
$firstTimestamp = createDateTimeFromSql($row['first_timestamp'] ?? null, $appTimeZone);
|
|
|
|
$stalenessMinutes = null;
|
|
if ($lastTimestamp instanceof DateTimeImmutable) {
|
|
$stalenessMinutes = max(
|
|
0,
|
|
(int) floor(($now->getTimestamp() - $lastTimestamp->getTimestamp()) / 60)
|
|
);
|
|
}
|
|
|
|
$coverageMinutes = null;
|
|
if (
|
|
$firstTimestamp instanceof DateTimeImmutable
|
|
&& $lastTimestamp instanceof DateTimeImmutable
|
|
) {
|
|
$coverageMinutes = max(
|
|
0,
|
|
(int) floor(($lastTimestamp->getTimestamp() - $firstTimestamp->getTimestamp()) / 60)
|
|
);
|
|
}
|
|
|
|
$uptimePercent = $sampleCount > 0
|
|
? max(0, (1 - ($nullCount / $sampleCount)) * 100)
|
|
: 0.0;
|
|
$flatlinePercent = $sampleCount > 1
|
|
? max(0, ($flatlineCount / max(1, $sampleCount - 1)) * 100)
|
|
: 0.0;
|
|
|
|
$minValue = $row['min_value'] !== null ? (float) $row['min_value'] : null;
|
|
$maxValue = $row['max_value'] !== null ? (float) $row['max_value'] : null;
|
|
$avgValue = $row['avg_value'] !== null ? (float) $row['avg_value'] : null;
|
|
$range = null;
|
|
if ($minValue !== null && $maxValue !== null) {
|
|
$range = $maxValue - $minValue;
|
|
}
|
|
|
|
$status = 'healthy';
|
|
$issues = [];
|
|
|
|
if ($sampleCount === 0 || $lastTimestamp === null) {
|
|
$status = 'offline';
|
|
$issues[] = 'No samples in the selected window.';
|
|
} elseif ($stalenessMinutes !== null && $stalenessMinutes > $windowMinutes) {
|
|
$status = 'offline';
|
|
$issues[] = 'No updates within the entire analysis window.';
|
|
} else {
|
|
if ($stalenessMinutes !== null && $stalenessMinutes > $staleThreshold) {
|
|
$issues[] = 'Data appears stale.';
|
|
}
|
|
if ($uptimePercent < 90) {
|
|
$issues[] = 'High null rate detected.';
|
|
}
|
|
if ($flatlinePercent > 80) {
|
|
$issues[] = 'Likely flatlined signal.';
|
|
}
|
|
if ($range !== null && abs($range) <= $flatlineTolerance) {
|
|
$issues[] = 'Minimal variance across window.';
|
|
}
|
|
if (!empty($issues)) {
|
|
$status = 'warning';
|
|
}
|
|
}
|
|
|
|
if ($status === 'healthy') {
|
|
$healthyCount++;
|
|
} elseif ($status === 'warning') {
|
|
$warningCount++;
|
|
} else {
|
|
$offlineCount++;
|
|
}
|
|
|
|
$averageSpacing = null;
|
|
if ($coverageMinutes !== null && $sampleCount > 1) {
|
|
$averageSpacing = $coverageMinutes / max(1, $sampleCount - 1);
|
|
}
|
|
|
|
$tags[] = [
|
|
'name' => $name,
|
|
'status' => $status,
|
|
'issues' => $issues,
|
|
'sampleCount' => $sampleCount,
|
|
'nullCount' => $nullCount,
|
|
'flatlineCount' => $flatlineCount,
|
|
'uptimePercent' => round($uptimePercent, 1),
|
|
'flatlinePercent' => round($flatlinePercent, 1),
|
|
'minValue' => $minValue,
|
|
'maxValue' => $maxValue,
|
|
'avgValue' => $avgValue,
|
|
'range' => $range !== null ? round($range, 6) : null,
|
|
'lastTimestamp' => $lastTimestamp ? $lastTimestamp->format('Y-m-d H:i:s') : null,
|
|
'minutesSinceUpdate' => $stalenessMinutes,
|
|
'firstTimestamp' => $firstTimestamp ? $firstTimestamp->format('Y-m-d H:i:s') : null,
|
|
'windowCoverageMinutes' => $coverageMinutes,
|
|
'averageSpacingMinutes' => $averageSpacing !== null ? round($averageSpacing, 1) : null,
|
|
];
|
|
}
|
|
|
|
echo json_encode([
|
|
'success' => true,
|
|
'windowHours' => $windowHours,
|
|
'limit' => $limit,
|
|
'flatlineTolerance' => $flatlineTolerance,
|
|
'generatedAt' => $now->format('Y-m-d H:i:s'),
|
|
'totals' => [
|
|
'total' => count($tags),
|
|
'healthy' => $healthyCount,
|
|
'warning' => $warningCount,
|
|
'offline' => $offlineCount,
|
|
],
|
|
'tags' => $tags,
|
|
]);
|
|
break;
|
|
|
|
default:
|
|
throw new RuntimeException('Unsupported action.');
|
|
}
|
|
} catch (Throwable $exception) {
|
|
echo json_encode([
|
|
'success' => false,
|
|
'error' => $exception->getMessage(),
|
|
]);
|
|
}
|
|
|
|
exit;
|
|
}
|
|
|
|
require __DIR__ . '/../includes/layout/header.php';
|
|
?>
|
|
<div
|
|
class="tag-health-overlay"
|
|
id="tagHealthLoadingOverlay"
|
|
aria-hidden="true"
|
|
>
|
|
<div
|
|
class="tag-health-overlay__spinner"
|
|
role="status"
|
|
aria-label="Loading tag health data"
|
|
></div>
|
|
</div>
|
|
|
|
<div class="app-content tag-health-app">
|
|
<section class="data-panel tag-health-controls">
|
|
<div class="panel-intro">
|
|
<h2>Tag health audit</h2>
|
|
<p>
|
|
Surface historian tags with stale updates, high null rates, or flatlined
|
|
readings over the selected time window.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="tag-health-controls__grid">
|
|
<div class="tag-health-field">
|
|
<label class="tag-health-field__label" for="tagHealthSearch">Search tags</label>
|
|
<input
|
|
type="search"
|
|
id="tagHealthSearch"
|
|
class="tag-health-input"
|
|
placeholder="Filter by tag name"
|
|
autocomplete="off"
|
|
>
|
|
</div>
|
|
|
|
<div class="tag-health-field">
|
|
<label class="tag-health-field__label" for="tagHealthWindow">Analysis window</label>
|
|
<select id="tagHealthWindow" class="tag-health-select">
|
|
<?php foreach ($windowOptions as $option) : ?>
|
|
<option value="<?php echo (int) $option; ?>" <?php echo $option === $defaultWindow ? 'selected' : ''; ?>>
|
|
<?php echo (int) $option; ?> hours
|
|
</option>
|
|
<?php endforeach; ?>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="tag-health-field">
|
|
<label class="tag-health-field__label" for="tagHealthLimit">Tag limit</label>
|
|
<select id="tagHealthLimit" class="tag-health-select">
|
|
<?php foreach ($limitOptions as $option) : ?>
|
|
<option value="<?php echo (int) $option; ?>" <?php echo $option === $defaultLimit ? 'selected' : ''; ?>>
|
|
Top <?php echo (int) $option; ?> tags
|
|
</option>
|
|
<?php endforeach; ?>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="tag-health-field">
|
|
<label class="tag-health-field__label" for="tagHealthTolerance">Flatline tolerance</label>
|
|
<input
|
|
type="number"
|
|
id="tagHealthTolerance"
|
|
class="tag-health-input"
|
|
value="<?php echo htmlspecialchars((string) $defaultTolerance); ?>"
|
|
min="0.0001"
|
|
step="0.01"
|
|
>
|
|
<p class="tag-health-help">
|
|
Signals with a change smaller than this value between samples are
|
|
treated as flatlined.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="tag-health-actions">
|
|
<button type="button" class="button" id="tagHealthRefreshBtn">Refresh audit</button>
|
|
<button type="button" class="button button--ghost" id="tagHealthExportBtn">Export CSV</button>
|
|
<button type="button" class="button button--ghost" id="tagHealthToggleDebugBtn">Toggle debug</button>
|
|
</div>
|
|
</section>
|
|
|
|
<div id="tagHealthStatus" class="tag-health-status" aria-live="polite"></div>
|
|
|
|
<section class="tag-health-summary" id="tagHealthSummary" aria-live="polite"></section>
|
|
|
|
<section class="data-panel tag-health-table-panel">
|
|
<div class="tag-health-table__header">
|
|
<div>
|
|
<h3>Signal quality table</h3>
|
|
<p>Sorted by tag name. Use the filters to focus on problem tags.</p>
|
|
</div>
|
|
<div class="tag-health-status-filter" id="tagHealthStatusFilter">
|
|
<label>
|
|
<input type="checkbox" value="healthy" checked>
|
|
<span class="tag-health-status-chip tag-health-status-chip--healthy">Healthy</span>
|
|
</label>
|
|
<label>
|
|
<input type="checkbox" value="warning" checked>
|
|
<span class="tag-health-status-chip tag-health-status-chip--warning">Warning</span>
|
|
</label>
|
|
<label>
|
|
<input type="checkbox" value="offline" checked>
|
|
<span class="tag-health-status-chip tag-health-status-chip--offline">Offline</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="tag-health-table__wrapper">
|
|
<table class="tag-health-table" id="tagHealthTable">
|
|
<thead>
|
|
<tr>
|
|
<th scope="col">Tag</th>
|
|
<th scope="col">Status</th>
|
|
<th scope="col">Last update</th>
|
|
<th scope="col">Staleness</th>
|
|
<th scope="col">Samples</th>
|
|
<th scope="col">Uptime %</th>
|
|
<th scope="col">Flatline %</th>
|
|
<th scope="col">Range (min → max)</th>
|
|
<th scope="col">Issues</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="tagHealthTableBody">
|
|
<tr>
|
|
<td colspan="9">Run the audit to populate results.</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="data-panel tag-health-debug is-hidden" id="tagHealthDebugPanel">
|
|
<div class="tag-health-debug__header">
|
|
<h3>Debug response</h3>
|
|
<button type="button" class="button button--ghost" id="tagHealthHideDebugBtn">Hide debug</button>
|
|
</div>
|
|
<pre class="tag-health-debug__content" id="tagHealthDebugContent"></pre>
|
|
</section>
|
|
</div>
|
|
|
|
<script>
|
|
const tagHealthDefaults = <?php echo json_encode(
|
|
[
|
|
'windowHours' => $defaultWindow,
|
|
'limit' => $defaultLimit,
|
|
'tolerance' => $defaultTolerance,
|
|
],
|
|
JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION
|
|
); ?>;
|
|
|
|
const tagHealthState = {
|
|
tags: [],
|
|
filtered: [],
|
|
payload: null,
|
|
filters: {
|
|
statuses: new Set(['healthy', 'warning', 'offline']),
|
|
search: '',
|
|
},
|
|
elements: {},
|
|
};
|
|
|
|
let tagHealthSearchTimer = null;
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
cacheTagHealthElements();
|
|
bindTagHealthEvents();
|
|
loadTagHealth();
|
|
});
|
|
|
|
function cacheTagHealthElements() {
|
|
tagHealthState.elements = {
|
|
overlay: document.getElementById('tagHealthLoadingOverlay'),
|
|
searchInput: document.getElementById('tagHealthSearch'),
|
|
windowSelect: document.getElementById('tagHealthWindow'),
|
|
limitSelect: document.getElementById('tagHealthLimit'),
|
|
toleranceInput: document.getElementById('tagHealthTolerance'),
|
|
refreshButton: document.getElementById('tagHealthRefreshBtn'),
|
|
exportButton: document.getElementById('tagHealthExportBtn'),
|
|
statusContainer: document.getElementById('tagHealthStatus'),
|
|
summaryContainer: document.getElementById('tagHealthSummary'),
|
|
tableBody: document.getElementById('tagHealthTableBody'),
|
|
statusFilter: document.getElementById('tagHealthStatusFilter'),
|
|
debugPanel: document.getElementById('tagHealthDebugPanel'),
|
|
debugContent: document.getElementById('tagHealthDebugContent'),
|
|
toggleDebugBtn: document.getElementById('tagHealthToggleDebugBtn'),
|
|
hideDebugBtn: document.getElementById('tagHealthHideDebugBtn'),
|
|
};
|
|
}
|
|
|
|
function bindTagHealthEvents() {
|
|
const elements = tagHealthState.elements;
|
|
|
|
elements.refreshButton.addEventListener('click', () => loadTagHealth());
|
|
elements.exportButton.addEventListener('click', exportTagHealthCsv);
|
|
elements.windowSelect.addEventListener('change', () => loadTagHealth());
|
|
elements.limitSelect.addEventListener('change', () => loadTagHealth());
|
|
elements.toleranceInput.addEventListener('change', () => loadTagHealth());
|
|
|
|
elements.searchInput.addEventListener('input', (event) => {
|
|
const value = event.target.value || '';
|
|
tagHealthState.filters.search = value.trim();
|
|
if (tagHealthSearchTimer) {
|
|
clearTimeout(tagHealthSearchTimer);
|
|
}
|
|
tagHealthSearchTimer = setTimeout(applyTagHealthFilters, 180);
|
|
});
|
|
|
|
elements.statusFilter.querySelectorAll('input[type="checkbox"]').forEach((checkbox) => {
|
|
checkbox.addEventListener('change', () => {
|
|
if (checkbox.checked) {
|
|
tagHealthState.filters.statuses.add(checkbox.value);
|
|
} else {
|
|
tagHealthState.filters.statuses.delete(checkbox.value);
|
|
}
|
|
applyTagHealthFilters();
|
|
});
|
|
});
|
|
|
|
elements.toggleDebugBtn.addEventListener('click', () => toggleTagHealthDebug(true));
|
|
elements.hideDebugBtn.addEventListener('click', () => toggleTagHealthDebug(false));
|
|
}
|
|
|
|
async function loadTagHealth() {
|
|
const elements = tagHealthState.elements;
|
|
clearTagHealthStatus();
|
|
showTagHealthLoading(true);
|
|
|
|
const windowHours = Number(elements.windowSelect.value || tagHealthDefaults.windowHours);
|
|
const limit = Number(elements.limitSelect.value || tagHealthDefaults.limit);
|
|
const tolerance = Number(elements.toleranceInput.value || tagHealthDefaults.tolerance);
|
|
const search = tagHealthState.filters.search;
|
|
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append('action', 'get_tag_health');
|
|
formData.append('window_hours', windowHours.toString());
|
|
formData.append('limit', limit.toString());
|
|
formData.append('flatline_tolerance', tolerance.toString());
|
|
formData.append('search', search);
|
|
|
|
const response = await fetch(window.location.href, {
|
|
method: 'POST',
|
|
body: formData,
|
|
});
|
|
|
|
const text = await response.text();
|
|
let payload;
|
|
|
|
try {
|
|
payload = JSON.parse(text);
|
|
} catch (error) {
|
|
console.error('Failed to parse audit payload', error, text);
|
|
throw new Error('Unexpected response when loading tag health data.');
|
|
}
|
|
|
|
if (!payload.success) {
|
|
throw new Error(payload.error || 'Failed to load tag health data.');
|
|
}
|
|
|
|
tagHealthState.payload = payload;
|
|
tagHealthState.tags = Array.isArray(payload.tags) ? payload.tags : [];
|
|
|
|
applyTagHealthFilters();
|
|
renderTagHealthSummary(payload);
|
|
renderTagHealthDebug(payload);
|
|
setTagHealthStatus('Tag health audit updated.', 'success');
|
|
} catch (error) {
|
|
console.error(error);
|
|
setTagHealthStatus(error.message, 'error');
|
|
tagHealthState.tags = [];
|
|
tagHealthState.filtered = [];
|
|
renderTagHealthTable([]);
|
|
renderTagHealthSummary({});
|
|
renderTagHealthDebug(null);
|
|
} finally {
|
|
showTagHealthLoading(false);
|
|
}
|
|
}
|
|
|
|
function applyTagHealthFilters() {
|
|
const statuses = tagHealthState.filters.statuses;
|
|
const searchLower = tagHealthState.filters.search.toLowerCase();
|
|
|
|
const filtered = tagHealthState.tags.filter((tag) => {
|
|
if (!statuses.has(tag.status)) {
|
|
return false;
|
|
}
|
|
if (searchLower && !tag.name.toLowerCase().includes(searchLower)) {
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
tagHealthState.filtered = filtered;
|
|
renderTagHealthTable(filtered);
|
|
}
|
|
|
|
function renderTagHealthSummary(payload) {
|
|
const container = tagHealthState.elements.summaryContainer;
|
|
if (!container) {
|
|
return;
|
|
}
|
|
|
|
const totals = payload?.totals || {};
|
|
const total = totals.total || 0;
|
|
const healthy = totals.healthy || 0;
|
|
const warning = totals.warning || 0;
|
|
const offline = totals.offline || 0;
|
|
|
|
const averageUptime = total
|
|
? (tagHealthState.tags.reduce((sum, tag) => sum + (tag.uptimePercent || 0), 0) / total)
|
|
: 0;
|
|
|
|
const averageStaleness = tagHealthState.tags.length
|
|
? (tagHealthState.tags.reduce((sum, tag) => sum + (tag.minutesSinceUpdate || 0), 0) /
|
|
tagHealthState.tags.length)
|
|
: 0;
|
|
|
|
container.innerHTML = `
|
|
<article class="tag-health-card">
|
|
<h4>Total tags</h4>
|
|
<p class="tag-health-card__value">${total}</p>
|
|
</article>
|
|
<article class="tag-health-card tag-health-card--healthy">
|
|
<h4>Healthy</h4>
|
|
<p class="tag-health-card__value">${healthy}</p>
|
|
</article>
|
|
<article class="tag-health-card tag-health-card--warning">
|
|
<h4>Warnings</h4>
|
|
<p class="tag-health-card__value">${warning}</p>
|
|
</article>
|
|
<article class="tag-health-card tag-health-card--offline">
|
|
<h4>Offline</h4>
|
|
<p class="tag-health-card__value">${offline}</p>
|
|
</article>
|
|
<article class="tag-health-card">
|
|
<h4>Average uptime</h4>
|
|
<p class="tag-health-card__value">${formatPercent(averageUptime, 1)}</p>
|
|
</article>
|
|
<article class="tag-health-card">
|
|
<h4>Avg staleness</h4>
|
|
<p class="tag-health-card__value">${formatMinutes(averageStaleness)}</p>
|
|
</article>
|
|
`;
|
|
}
|
|
|
|
function renderTagHealthTable(tags) {
|
|
const tbody = tagHealthState.elements.tableBody;
|
|
if (!tbody) {
|
|
return;
|
|
}
|
|
|
|
if (!tags.length) {
|
|
tbody.innerHTML = `
|
|
<tr>
|
|
<td colspan="9">No tags match the current filters.</td>
|
|
</tr>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
const rows = tags.map((tag) => {
|
|
const statusBadge = `
|
|
<span class="tag-health-status-badge tag-health-status-badge--${tag.status}">
|
|
${capitalize(tag.status)}
|
|
</span>
|
|
`;
|
|
|
|
const lastUpdate = tag.lastTimestamp
|
|
? `${tag.lastTimestamp} (${formatRelativeTime(tag.lastTimestamp)})`
|
|
: '—';
|
|
const staleness = tag.minutesSinceUpdate !== null && tag.minutesSinceUpdate !== undefined
|
|
? formatMinutes(tag.minutesSinceUpdate)
|
|
: '—';
|
|
const samples = `${tag.sampleCount?.toLocaleString?.() ?? tag.sampleCount} (${tag.nullCount} null)`;
|
|
const rangeDisplay = formatRange(tag);
|
|
const issues = Array.isArray(tag.issues) && tag.issues.length
|
|
? tag.issues.join('; ')
|
|
: '—';
|
|
|
|
return `
|
|
<tr data-status="${tag.status}">
|
|
<td>${escapeHtml(tag.name)}</td>
|
|
<td>${statusBadge}</td>
|
|
<td>${escapeHtml(lastUpdate)}</td>
|
|
<td>${staleness}</td>
|
|
<td>${samples}</td>
|
|
<td>${formatPercent(tag.uptimePercent, 1)}</td>
|
|
<td>${formatPercent(tag.flatlinePercent, 1)}</td>
|
|
<td>${rangeDisplay}</td>
|
|
<td>${escapeHtml(issues)}</td>
|
|
</tr>
|
|
`;
|
|
});
|
|
|
|
tbody.innerHTML = rows.join('');
|
|
}
|
|
|
|
function renderTagHealthDebug(payload) {
|
|
const content = tagHealthState.elements.debugContent;
|
|
if (!content) {
|
|
return;
|
|
}
|
|
|
|
if (!payload) {
|
|
content.textContent = '';
|
|
return;
|
|
}
|
|
|
|
content.textContent = JSON.stringify(payload, null, 2);
|
|
}
|
|
|
|
function toggleTagHealthDebug(forceOpen) {
|
|
const panel = tagHealthState.elements.debugPanel;
|
|
if (!panel) {
|
|
return;
|
|
}
|
|
|
|
const shouldOpen = typeof forceOpen === 'boolean'
|
|
? forceOpen
|
|
: panel.classList.contains('is-hidden');
|
|
|
|
if (shouldOpen) {
|
|
panel.classList.remove('is-hidden');
|
|
} else {
|
|
panel.classList.add('is-hidden');
|
|
}
|
|
}
|
|
|
|
function exportTagHealthCsv() {
|
|
const tags = tagHealthState.filtered.length ? tagHealthState.filtered : tagHealthState.tags;
|
|
if (!tags.length) {
|
|
setTagHealthStatus('Nothing to export. Run the audit first.', 'info');
|
|
return;
|
|
}
|
|
|
|
const header = [
|
|
'Tag name',
|
|
'Status',
|
|
'Sample count',
|
|
'Null count',
|
|
'Flatline count',
|
|
'Uptime percent',
|
|
'Flatline percent',
|
|
'Min value',
|
|
'Average value',
|
|
'Max value',
|
|
'Range',
|
|
'Last timestamp',
|
|
'Minutes since update',
|
|
'First timestamp',
|
|
'Coverage minutes',
|
|
'Average spacing minutes',
|
|
'Issues',
|
|
];
|
|
|
|
const rows = tags.map((tag) => [
|
|
tag.name,
|
|
tag.status,
|
|
tag.sampleCount,
|
|
tag.nullCount,
|
|
tag.flatlineCount,
|
|
tag.uptimePercent,
|
|
tag.flatlinePercent,
|
|
tag.minValue,
|
|
tag.avgValue,
|
|
tag.maxValue,
|
|
tag.range,
|
|
tag.lastTimestamp,
|
|
tag.minutesSinceUpdate,
|
|
tag.firstTimestamp,
|
|
tag.windowCoverageMinutes,
|
|
tag.averageSpacingMinutes,
|
|
Array.isArray(tag.issues) ? tag.issues.join(' | ') : '',
|
|
]);
|
|
|
|
const csvLines = [header.join(',')];
|
|
rows.forEach((row) => {
|
|
csvLines.push(row.map((value) => formatCsvValue(value)).join(','));
|
|
});
|
|
|
|
const blob = new Blob([csvLines.join('\n')], { type: 'text/csv;charset=utf-8;' });
|
|
const timestamp = new Date().toISOString().replace(/[:T]/g, '-').slice(0, 16);
|
|
const filename = `tag-health-audit-${timestamp}.csv`;
|
|
|
|
const link = document.createElement('a');
|
|
link.href = URL.createObjectURL(blob);
|
|
link.download = filename;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
URL.revokeObjectURL(link.href);
|
|
}
|
|
|
|
function setTagHealthStatus(message, type = 'info') {
|
|
const container = tagHealthState.elements.statusContainer;
|
|
if (!container) {
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = '';
|
|
const div = document.createElement('div');
|
|
div.className = `status-message status-message--${type}`;
|
|
div.textContent = message;
|
|
container.appendChild(div);
|
|
}
|
|
|
|
function clearTagHealthStatus() {
|
|
const container = tagHealthState.elements.statusContainer;
|
|
if (container) {
|
|
container.innerHTML = '';
|
|
}
|
|
}
|
|
|
|
function showTagHealthLoading(show) {
|
|
const overlay = tagHealthState.elements.overlay;
|
|
if (!overlay) {
|
|
return;
|
|
}
|
|
|
|
overlay.style.display = show ? 'flex' : 'none';
|
|
}
|
|
|
|
function formatPercent(value, fractionDigits = 0) {
|
|
if (value === null || value === undefined || Number.isNaN(value)) {
|
|
return '—';
|
|
}
|
|
|
|
return `${Number.parseFloat(value).toFixed(fractionDigits)}%`;
|
|
}
|
|
|
|
function formatMinutes(value) {
|
|
if (value === null || value === undefined || Number.isNaN(value)) {
|
|
return '—';
|
|
}
|
|
|
|
const minutes = Math.max(0, Math.round(value));
|
|
const hours = Math.floor(minutes / 60);
|
|
const remainder = minutes % 60;
|
|
|
|
if (hours === 0) {
|
|
return `${remainder}m`;
|
|
}
|
|
|
|
return `${hours}h ${remainder}m`;
|
|
}
|
|
|
|
function formatRelativeTime(timestamp) {
|
|
if (!timestamp) {
|
|
return '';
|
|
}
|
|
|
|
const parsed = new Date(timestamp.replace(' ', 'T'));
|
|
if (Number.isNaN(parsed.getTime())) {
|
|
return '';
|
|
}
|
|
|
|
const now = new Date();
|
|
const diffMs = now.getTime() - parsed.getTime();
|
|
if (diffMs < 0) {
|
|
return 'in the future';
|
|
}
|
|
|
|
const diffMinutes = Math.floor(diffMs / 60000);
|
|
if (diffMinutes < 1) {
|
|
return 'just now';
|
|
}
|
|
if (diffMinutes < 60) {
|
|
return `${diffMinutes}m ago`;
|
|
}
|
|
|
|
const diffHours = Math.floor(diffMinutes / 60);
|
|
if (diffHours < 24) {
|
|
return `${diffHours}h ago`;
|
|
}
|
|
|
|
const diffDays = Math.floor(diffHours / 24);
|
|
return `${diffDays}d ago`;
|
|
}
|
|
|
|
function formatRange(tag) {
|
|
const min = tag.minValue;
|
|
const max = tag.maxValue;
|
|
const range = tag.range;
|
|
|
|
if (min === null || max === null || range === null) {
|
|
return '—';
|
|
}
|
|
|
|
return `${formatNumber(min)} → ${formatNumber(max)} (Δ ${formatNumber(range)})`;
|
|
}
|
|
|
|
function formatNumber(value, fractionDigits = 2) {
|
|
if (value === null || value === undefined || Number.isNaN(value)) {
|
|
return '—';
|
|
}
|
|
|
|
return Number.parseFloat(value).toFixed(fractionDigits);
|
|
}
|
|
|
|
function formatCsvValue(value) {
|
|
if (value === null || value === undefined) {
|
|
return '';
|
|
}
|
|
|
|
const stringValue = String(value);
|
|
if (stringValue.includes(',') || stringValue.includes('"') || stringValue.includes('\n')) {
|
|
return `"${stringValue.replace(/"/g, '""')}"`;
|
|
}
|
|
|
|
return stringValue;
|
|
}
|
|
|
|
function escapeHtml(value) {
|
|
if (value === null || value === undefined) {
|
|
return '';
|
|
}
|
|
|
|
return String(value).replace(/[&<>"']/g, (char) => {
|
|
const entities = {
|
|
'&': '&',
|
|
'<': '<',
|
|
'>': '>',
|
|
'"': '"',
|
|
"'": ''',
|
|
};
|
|
return entities[char] || char;
|
|
});
|
|
}
|
|
|
|
function capitalize(value) {
|
|
if (!value) {
|
|
return '';
|
|
}
|
|
return value.charAt(0).toUpperCase() + value.slice(1);
|
|
}
|
|
</script>
|
|
|
|
<?php require __DIR__ . '/../includes/layout/footer.php'; ?>
|