Files
controls-web/trends/tag-health.php
2026-02-17 12:44:37 -06:00

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 = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;',
};
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'; ?>