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