count('tx_vitec_ssrpurgelog', 'crdate > ' . $d1);
$purges7d = $this->count('tx_vitec_ssrpurgelog', 'crdate > ' . $d7);
$failed7d = $this->count('tx_vitec_ssrpurgelog', 'crdate > ' . $d7 . ' AND success = 0');
$full7d = $this->count('tx_vitec_ssrpurgelog', "crdate > " . $d7 . " AND paths = '*'");
$avgMs = $this->scalar('SELECT ROUND(AVG(response_ms)) FROM tx_vitec_ssrhealthlog WHERE crdate > ' . $d1 . ' AND ok = 1');
$maxCache = $this->scalar('SELECT MAX(cache_mb) FROM tx_vitec_ssrhealthlog WHERE crdate > ' . $d7);
// Browser-side errors reported via POST /api/vitec/client-error,
// without smoke/curl test reports (0114-FE).
[$clientErrors24h, $clientErrors7d] = $this->realClientErrors($d1, $d7);
$strip = $h::strip($this->connectionPool);
$uptime = $strip['uptime'];
$uptimeTone = $uptime === null ? '' : ($uptime >= 99.5 ? 'vcw-ok' : ($uptime >= 95 ? 'vcw-warn' : 'vcw-bad'));
$html = '
Availability · 24 h
'
. '
' . ($uptime === null ? '–' : number_format($uptime, 1)) . '%
'
. '
' . $strip['samples'] . ' samples
' . $strip['slow'] . ' slow · '
. $strip['down'] . ' down
'
. $strip['html'];
$tile = static fn(string $label, string $value, string $sub = '', string $tone = ''): string =>
'' . $h::e($label) . '
'
. $value . '
' . ($sub !== '' ? '
' . $sub . '
' : '') . '
';
$html .= ''
. $tile('Purges', $purges24h . '/ ' . $purges7d . '', '24 h / 7 days')
. $tile('Full purges', (string)$full7d, 'last 7 days', $full7d > 0 ? 'vcw-warn' : '')
. $tile('Failed purges', (string)$failed7d, 'last 7 days', $failed7d > 0 ? 'vcw-bad' : 'vcw-ok')
. $tile('Avg. response', $avgMs !== null ? $avgMs . 'ms' : '–', 'last 24 h')
. $tile('Max. cache', $maxCache !== null ? $maxCache . 'MB' : '–', 'last 7 days')
. $tile('Client errors', $clientErrors24h . '/ ' . $clientErrors7d . '', '24 h / 7 days, no tests', $clientErrors24h > 0 ? 'vcw-warn' : 'vcw-ok')
. '
';
if ($strip['samples'] === 0) {
$html .= 'No samples yet: the cron job vitec:ssr-health fills this every 5 minutes.
';
}
$html .= '';
return $h::wrap($html, 'Availability of the SSR node over the last 24 hours, one bar per 15 minutes: '
. 'green ok, yellow slower than 400 ms, red down, grey no sample. Below are counts from the purge and '
. 'health logs: purges, full purges and failed purges, average response time, the largest cache size '
. 'and the browser errors reported by the frontend.', 'stats');
}
public function getOptions(): array
{
return [];
}
public function getCssFiles(): array
{
return [CockpitHtml::CSS];
}
/**
* @return array{0: int, 1: int} real client error reports in 24 h / 7 days
*/
private function realClientErrors(int $d1, int $d7): array
{
try {
$rows = $this->connectionPool->getConnectionForTable('tx_vitec_clienterrorlog')->executeQuery(
'SELECT crdate, message, user_agent, `release` FROM tx_vitec_clienterrorlog WHERE crdate > ?',
[$d7]
)->fetchAllAssociative();
} catch (\Throwable) {
return [0, 0];
}
$day = 0;
$week = 0;
foreach ($rows as $r) {
if (ClientErrorClassifier::isTest((string)$r['message'], (string)$r['user_agent'], (string)$r['release'])) {
continue;
}
$week++;
$day += (int)$r['crdate'] > $d1 ? 1 : 0;
}
return [$day, $week];
}
private function count(string $table, string $where): int
{
try {
return (int)$this->connectionPool->getConnectionForTable($table)
->executeQuery('SELECT COUNT(*) FROM ' . $table . ' WHERE ' . $where)->fetchOne();
} catch (\Throwable) {
return 0;
}
}
private function scalar(string $sql): ?string
{
try {
$v = $this->connectionPool->getConnectionForTable('tx_vitec_ssrhealthlog')
->executeQuery($sql)->fetchOne();
return $v === null || $v === false ? null : (string)$v;
} catch (\Throwable) {
return null;
}
}
}