SSR alert mail recipients maintainable in the site settings

New site setting vitec.ssr.alertEmails (Site Management, Settings, VITEC; one address per entry). vitec:ssr-health reads the recipients from there, invalid entries are skipped and logged, SSR_ALERT_EMAIL in the env file stays as fallback. The cockpit shows the recipients in the live band. Pre-filled with the address that was in the env file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
orascheandClaude Opus 5.5 committed 2026-11-11 11:13:37 +01:00
1 parent 994f9f2a1b
commit 90cb2faaf9
6 files changed
+81 -6

No files matched your search

+2
View File
@@ -23,3 +23,5 @@ vitec.ssr.enabled: true
# Shell-Seiten (Produkt-/News-/Story-Detail-Container) nicht in die XML-Sitemap # Shell-Seiten (Produkt-/News-/Story-Detail-Container) nicht in die XML-Sitemap
# aufnehmen; 106 = alter /en/news-Ast (noindex-Duplikate). SEO-Audit 2026-11-09. # aufnehmen; 106 = alter /en/news-Ast (noindex-Duplikate). SEO-Audit 2026-11-09.
seo.sitemap.pages.excludePagesRecursive: '10,21,26,106' seo.sitemap.pages.excludePagesRecursive: '10,21,26,106'
vitec.ssr.alertEmails:
- info@evomedien.de
@@ -206,15 +206,20 @@ final class SsrHealthSampleCommand extends Command
/** /**
* Mails are best effort: a mail problem must never break the sampler * Mails are best effort: a mail problem must never break the sampler
* (the cron would stop logging history). Recipients come from * (the cron would stop logging history). Recipients come from the site
* SSR_ALERT_EMAIL in var/vitec-ssr-purge.env - unset means "alerts * setting vitec.ssr.alertEmails (Site Management › Settings › VITEC),
* off", which the output states so the cron log shows why. * fallback SSR_ALERT_EMAIL in var/vitec-ssr-purge.env - none at all
* means "alerts off", which the output states so the cron log shows why.
*/ */
private function sendAlert(string $subject, string $body, OutputInterface $output): void private function sendAlert(string $subject, string $body, OutputInterface $output): void
{ {
$recipients = GeneralUtility::trimExplode(',', $this->purgeService->getAlertEmail(), true); $alert = $this->purgeService->getAlertRecipients();
if ($alert['invalid'] !== []) {
$output->writeln('<comment>skipped invalid alert addresses: ' . implode(', ', $alert['invalid']) . '</comment>');
}
$recipients = $alert['recipients'];
if ($recipients === []) { if ($recipients === []) {
$output->writeln('<comment>transition detected but SSR_ALERT_EMAIL is not configured - no mail sent</comment>'); $output->writeln('<comment>transition detected but no alert recipients are configured (Site settings › VITEC or SSR_ALERT_EMAIL) - no mail sent</comment>');
return; return;
} }
try { try {
@@ -70,6 +70,7 @@ final class SsrController
$lastSample = $healthLog[0] ?? null; $lastSample = $healthLog[0] ?? null;
$clientErrors = $this->clientErrors(50); $clientErrors = $this->clientErrors(50);
$cockpit = $this->cockpitData($health, $lastSample); $cockpit = $this->cockpitData($health, $lastSample);
$alert = $this->purgeService->getAlertRecipients();
$settingsUri = ''; $settingsUri = '';
if ($GLOBALS['BE_USER']->isAdmin()) { if ($GLOBALS['BE_USER']->isAdmin()) {
@@ -92,6 +93,10 @@ final class SsrController
'cockpitJson' => json_encode($cockpit, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT), 'cockpitJson' => json_encode($cockpit, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT),
'settingsUri' => $settingsUri, 'settingsUri' => $settingsUri,
'failoverAfter' => $this->purgeService->getFailoverAfter(), 'failoverAfter' => $this->purgeService->getFailoverAfter(),
'alert' => $alert,
'alertList' => implode(', ', $alert['recipients']),
'alertCount' => count($alert['recipients']),
'alertInvalid' => implode(', ', $alert['invalid']),
'chunkCount24h' => count(array_filter($clientErrors, static fn(array $r): bool => $r['is_chunk_reload'])), 'chunkCount24h' => count(array_filter($clientErrors, static fn(array $r): bool => $r['is_chunk_reload'])),
'jsErrorCount24h' => count(array_filter($clientErrors, static fn(array $r): bool => !$r['is_chunk_reload'])), 'jsErrorCount24h' => count(array_filter($clientErrors, static fn(array $r): bool => !$r['is_chunk_reload'])),
'flagOn' => file_exists(Environment::getVarPath() . '/vitec-ssr-on.flag'), 'flagOn' => file_exists(Environment::getVarPath() . '/vitec-ssr-on.flag'),
@@ -7,6 +7,8 @@ namespace Evomedien\Vitec\Service;
use TYPO3\CMS\Core\Database\ConnectionPool; use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Core\Environment; use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\Http\RequestFactory; use TYPO3\CMS\Core\Http\RequestFactory;
use TYPO3\CMS\Core\Site\SiteFinder;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/** /**
* Talks to the SSR node's POST /__purge endpoint (contract 0054-FE) and * Talks to the SSR node's POST /__purge endpoint (contract 0054-FE) and
@@ -24,6 +26,7 @@ final class SsrPurgeService
public function __construct( public function __construct(
private readonly RequestFactory $requestFactory, private readonly RequestFactory $requestFactory,
private readonly ConnectionPool $connectionPool, private readonly ConnectionPool $connectionPool,
private readonly SiteFinder $siteFinder,
) {} ) {}
public static function getConfigPath(): string public static function getConfigPath(): string
@@ -66,6 +69,50 @@ final class SsrPurgeService
return trim($this->getConfig()['SSR_ALERT_EMAIL'] ?? ''); return trim($this->getConfig()['SSR_ALERT_EMAIL'] ?? '');
} }
/**
* Who gets the vitec:ssr-health alert mails. Maintained by editors in
* Site Management › Settings › VITEC (vitec.ssr.alertEmails, one
* address per entry); the first site with at least one valid address
* wins. Without such a list, SSR_ALERT_EMAIL from the env file is the
* fallback. Invalid entries are skipped and reported back.
*
* @return array{recipients: list<string>, source: string, invalid: list<string>}
*/
public function getAlertRecipients(): array
{
$invalid = [];
try {
foreach ($this->siteFinder->getAllSites() as $site) {
$list = $site->getSettings()->get('vitec.ssr.alertEmails', []);
if (is_string($list)) {
$list = GeneralUtility::trimExplode(',', $list, true);
}
$valid = [];
foreach ((array)$list as $address) {
$address = trim((string)$address);
if ($address === '') {
continue;
}
if (GeneralUtility::validEmail($address)) {
$valid[] = $address;
} else {
$invalid[] = $address;
}
}
if ($valid !== []) {
return ['recipients' => array_values(array_unique($valid)), 'source' => 'site settings', 'invalid' => $invalid];
}
}
} catch (\Throwable) {
// No site configuration readable (e.g. broken YAML): use the env fallback.
}
$env = array_values(array_filter(
GeneralUtility::trimExplode(',', $this->getAlertEmail(), true),
static fn(string $a): bool => GeneralUtility::validEmail($a)
));
return ['recipients' => $env, 'source' => $env !== [] ? 'var/vitec-ssr-purge.env' : '', 'invalid' => $invalid];
}
/** /**
* Consecutive DOWN samples after which vitec:ssr-health fails over to * Consecutive DOWN samples after which vitec:ssr-health fails over to
* the SPA (SSR_FAILOVER_AFTER, default 2 = ~10 minutes at the 5-minute * the SPA (SSR_FAILOVER_AFTER, default 2 = ~10 minutes at the 5-minute
@@ -134,3 +134,10 @@ settings:
category: VITEC category: VITEC
type: bool type: bool
default: true default: true
vitec.ssr.alertEmails:
label: 'SSR alert mail recipients'
description: 'E-mail addresses that get the alert mails of the SSR health check: node down, node back, automatic fallback to the SPA. One address per entry; invalid addresses are skipped. Empty: the addresses from SSR_ALERT_EMAIL in var/vitec-ssr-purge.env are used.'
category: VITEC
type: stringlist
default: []
@@ -14,7 +14,7 @@
<!-- Live band: node state, release, delivery switch, 24 h availability --> <!-- Live band: node state, release, delivery switch, 24 h availability -->
<section class="vc-card vc-hero vc-s12" aria-label="Live status"> <section class="vc-card vc-hero vc-s12" aria-label="Live status">
<span class="vc-info"><button type="button" class="vc-info-btn" aria-label="About this tile" aria-describedby="vc-tip-live">?</button><span class="vc-tip" role="tooltip" id="vc-tip-live">Live state of the SSR node on the VPS. The dot pulses green while the node answers its health check. Delivery shows whether visitors get server-rendered pages; the switch is in the Site settings. The strip shows the last 24 hours in 15-minute windows: green ok, yellow slower than 400 ms, red down, grey no sample. After {failoverAfter} failed checks in a row the cron switches back to the SPA and sends an alert mail.</span></span> <span class="vc-info"><button type="button" class="vc-info-btn" aria-label="About this tile" aria-describedby="vc-tip-live">?</button><span class="vc-tip" role="tooltip" id="vc-tip-live">Live state of the SSR node on the VPS. The dot pulses green while the node answers its health check. Delivery shows whether visitors get server-rendered pages; the switch is in the Site settings. The strip shows the last 24 hours in 15-minute windows: green ok, yellow slower than 400 ms, red down, grey no sample. After {failoverAfter} failed checks in a row the cron switches back to the SPA. Alert mails go to the addresses under Site settings › VITEC › SSR alert mail recipients; hover “Alert mail” to see them.</span></span>
<div class="vc-hero-top"> <div class="vc-hero-top">
<div class="vc-live"> <div class="vc-live">
<f:if condition="{health}"> <f:if condition="{health}">
@@ -67,6 +67,15 @@
</f:if> </f:if>
</dd> </dd>
</div> </div>
<div class="vc-fact">
<dt>Alert mail</dt>
<dd>
<f:if condition="{alertCount}">
<f:then><span class="vc-pill {f:if(condition: alertInvalid, then: 'is-warn', else: 'is-ok')}" title="{alertList} (from {alert.source}){f:if(condition: alertInvalid, then: ' · skipped, not valid: {alertInvalid}')}">{alertCount} recipient<f:if condition="{alertCount} != 1">s</f:if></span></f:then>
<f:else><span class="vc-pill is-warn" title="No alert mails are sent. Add addresses in Site Management › Settings › VITEC › SSR alert mail recipients.">none</span></f:else>
</f:if>
</dd>
</div>
<div class="vc-fact"> <div class="vc-fact">
<dt>Auto failover</dt> <dt>Auto failover</dt>
<dd><span class="vc-pill is-navy" title="Cron vitec:ssr-health every 5 minutes: alert mail and fallback to the SPA after {failoverAfter} failed checks in a row">after {failoverAfter} failed checks</span></dd> <dd><span class="vc-pill is-navy" title="Cron vitec:ssr-health every 5 minutes: alert mail and fallback to the SPA after {failoverAfter} failed checks in a row">after {failoverAfter} failed checks</span></dd>