presseportale/resources/views/web/layouts/web-master.blade.php
Kevin Adametz d884eb7482 monitoring: Betriebsüberwachung Phase 1 — GlitchTip (Server- und Browserfehler), Fehlersignale ohne Exception, /health mit Scheduler-Herzschlag, ntfy-Alarme, fehlgeschlagene Anmeldungen im Verlauf, Umami im Kundenbereich, Versionsnummer
- GlitchTip über die Sentry-SDKs; Datenschutzfilter (nur User-ID, keine
  Request-Bodys, Tokens aus URL/Query/Referer, E-Mails aus Meldungen).
  Browser-SDK erst beim ersten Fehler nachgeladen.
- ReportErrorSignals: tote interne Links (404 mit eigenem Referrer), 403/429
  für Angemeldete, 419 als gedrosselte Warnung.
- /health (Bearer-Token): DB, Cache, Queue-Rückstau, Scheduler-Herzschlag,
  Plattenplatz → 200/503; Scheduler und Plattenplatz auch im Systemstatus.
- OperationalAlert zusätzlich per ntfy (JSON-POST), unabhängig von der Mail.
- Verlauf: login_failed (Grund unbekannt/kein Passwort/falsch),
  login_lockout, two_factor_failed; IP nur als gekürzter HMAC.
- Umami: Kundenbereich mit eigenem Schalter und identify('user-<id>'),
  nie im Admin/bei Impersonation; Query-Filter auch auf den Webseiten
  (signierte Links gingen bisher an Umami).
- queue:prune-failed nach 30 Tagen.
- Versionsnummer: config('app.version') aus APP_VERSION oder Datei RELEASE
  (git describe --tags), in GlitchTip, /health, Systemstatus, artisan about.

Plan: dev/betrieb/monitoring-plan.md (Entscheidungen M1–M9).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 12:53:00 +00:00

176 lines
7 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
@php
$siteName = $domainName ?? config('app.name', 'Laravel');
// Inline-@section('title', …)-Werte kommen von Blade bereits escaped an —
// einmal dekodieren, damit {{ }} unten nicht doppelt escapt („&amp;amp;").
$pageTitle = html_entity_decode(trim($__env->yieldContent('title')), ENT_QUOTES, 'UTF-8') ?: $siteName;
$pageDescription = html_entity_decode(trim($__env->yieldContent('meta_description')), ENT_QUOTES, 'UTF-8') ?: $siteName;
// Self-Canonical als Default aus der Portal-URL + Request-Pfad (ohne Query):
// pro Portal/Domain getrennt und unabhängig von URL::forceRootUrl – verhindert Cross-Portal-Duplicate.
$portalBase = \App\Support\StaticPageCanonical::selfBase($domainUrl ?? null, ($webPortalContextApplied ?? false) === true);
// Statische Seiten liegen auf Hub UND beiden Portalen mit identischem
// Text (Anhang 2, E10): dort führt ein Host, die anderen verweisen
// dorthin. Rechtstexte sind ausgenommen — siehe config/canonical.php.
$canonicalUrl = trim($__env->yieldContent('canonical'))
?: (\App\Support\StaticPageCanonical::urlFor(request()->route()?->getName(), request()->getPathInfo())
?: $portalBase.request()->getPathInfo());
$ogType = trim($__env->yieldContent('og_type')) ?: 'website';
$ogImage = trim($__env->yieldContent('og_image'));
@endphp
<meta name="description" content="{{ $pageDescription }}">
<title>{{ $pageTitle }}</title>
@php
$robotsDirective = trim($__env->yieldContent('robots'));
@endphp
{{-- Robots-Anweisung (z. B. noindex für private Vorschauen) --}}
@if ($robotsDirective !== '')
<meta name="robots" content="{{ $robotsDirective }}">
@endif
{{-- Kanonische URL (entfällt bei noindex — es gibt keine öffentliche URL) --}}
@if (! str_contains($robotsDirective, 'noindex'))
<link rel="canonical" href="{{ $canonicalUrl }}">
@endif
{{-- Sprach-Alternates (de/en) – pro Seite optional gesetzt --}}
@hasSection('hreflang')
@yield('hreflang')
@endif
{{-- RSS-Autodiscovery (Roadmap 3.1) – Portal-Feed und, auf Branchenseiten, der Feed der Branche --}}
@yield('feed_links')
{{-- OpenGraph / Social --}}
<meta property="og:type" content="{{ $ogType }}">
<meta property="og:site_name" content="{{ $siteName }}">
<meta property="og:title" content="{{ $pageTitle }}">
<meta property="og:description" content="{{ $pageDescription }}">
<meta property="og:url" content="{{ $canonicalUrl }}">
<meta property="og:locale" content="{{ app()->getLocale() === 'de' ? 'de_DE' : 'en_US' }}">
@if ($ogImage)
<meta property="og:image" content="{{ $ogImage }}">
@endif
<meta name="twitter:card" content="{{ $ogImage ? 'summary_large_image' : 'summary' }}">
<meta name="twitter:title" content="{{ $pageTitle }}">
<meta name="twitter:description" content="{{ $pageDescription }}">
@if ($ogImage)
<meta name="twitter:image" content="{{ $ogImage }}">
@endif
{{-- Seitenspezifische Zusatz-Metas (z. B. article:* auf PM-Detailseiten) --}}
@yield('meta_extra')
{{-- Strukturierte Daten (JSON-LD) – pro Seite optional gesetzt --}}
@yield('structured_data')
<!-- Domain-spezifisches Favicon -->
<link rel="icon" href="{{ asset(\App\Helpers\ThemeHelper::getFaviconPath()) }}" type="image/svg+xml">
<link rel="apple-touch-icon" href="{{ asset(\App\Helpers\ThemeHelper::getAppIconPath()) }}">
<!-- Fonts -->
@include('partials.local-fonts')
@php
$font = \App\Helpers\ThemeHelper::getFont();
$theme = config('app.theme', 'businessportal24');
@endphp
{{-- app.js bündelt Livewire + Alpine (livewire.esm) — @livewireScriptConfig
unten verhindert, dass Livewire zusätzlich sein eigenes Script injiziert
(sonst zwei Alpine-Instanzen und kaputte wire:model-Bindings). --}}
@livewireStyles
@vite([\App\Helpers\ThemeHelper::getThemeCssPath(), 'resources/js/app.js'], $domainConfig['assets_dir'] ?? 'build/web')
<!-- Sticky Header Script -->
<script>
document.addEventListener('DOMContentLoaded', function() {
const topbar = document.getElementById('topbar');
const header = document.getElementById('header');
if (!topbar || !header) return;
let topbarHeight = topbar.offsetHeight;
let isHeaderSticky = false;
// Platzhalter hält im Fluss die Header-Höhe frei, sobald der
// Header per position:fixed aus dem Fluss genommen wird —
// ohne den Spacer würde der Inhalt beim Fixieren nach oben springen.
const spacer = document.createElement('div');
spacer.setAttribute('aria-hidden', 'true');
function makeSticky() {
spacer.style.height = header.offsetHeight + 'px';
header.parentNode.insertBefore(spacer, header);
header.classList.remove('header-normal');
header.classList.add('header-sticky');
isHeaderSticky = true;
}
function clearSticky() {
header.classList.remove('header-sticky');
header.classList.add('header-normal');
if (spacer.parentNode) {
spacer.parentNode.removeChild(spacer);
}
isHeaderSticky = false;
}
function updateHeaderPosition() {
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
if (scrollTop >= topbarHeight && !isHeaderSticky) {
makeSticky();
} else if (scrollTop < topbarHeight && isHeaderSticky) {
clearSticky();
}
}
updateHeaderPosition();
window.addEventListener('scroll', updateHeaderPosition, { passive: true });
window.addEventListener('resize', function() {
topbarHeight = topbar.offsetHeight;
if (isHeaderSticky) {
spacer.style.height = header.offsetHeight + 'px';
}
updateHeaderPosition();
});
});
</script>
<!-- Additional Styles -->
@stack('styles')
<x-web.umami />
<x-monitoring.browser-errors :build="$domainConfig['assets_dir'] ?? 'build/web'" area="web" />
</head>
<body class="antialiased" style="background-color: hsl(var(--background)); color: hsl(var(--foreground));">
<!-- TopBar - statisch oben -->
@yield('content')
{{-- Schwebendes Kontakt-Widget (unten rechts, alle Web-Domains) --}}
<x-web.contact-launcher />
<!-- Additional Scripts -->
@stack('scripts')
@livewireScriptConfig
</body>
</html>