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'; ?>

Tag health audit

Surface historian tags with stale updates, high null rates, or flatlined readings over the selected time window.

Signals with a change smaller than this value between samples are treated as flatlined.

Signal quality table

Sorted by tag name. Use the filters to focus on problem tags.

Tag Status Last update Staleness Samples Uptime % Flatline % Range (min → max) Issues
Run the audit to populate results.