Git-Deploy per Tag, Betriebsüberwachung und Release-Seeder für den Livegang
- dev/betrieb/deploy.sh: Deploy nur per Tag, mit --seed (Seeder im Wartungsmodus) und --initial (Vorschau/Live einmalig an Ort und Stelle auf Git umstellen) - dev/betrieb/vorschau-auffrischen.sh: Live-DB und Live-Medien in die Vorschau (Generalprobe) - InteriorReleaseSeeder: alle Inhaltsschritte des Relaunchs in einem Lauf, idempotent; Displays, Playlists und Mediathek bleiben unberührt - DisplayInteriorBrandingSeeder überschreibt keinen inzwischen gepflegten Claim mehr - Monitoring: GlitchTip (Server + Browser), /health mit Token, Scheduler-Herzschlag, Alarme per Mail und ntfy, Version in about und /health - public/build versioniert; .gitignore: macOS-Muster Icon traf resources/views/flux/icon/ - Runbook: dev/betrieb/README.md Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
fcb10be120
commit
b5ed756d67
98 changed files with 3947 additions and 82 deletions
25
.env.example
25
.env.example
|
|
@ -85,3 +85,28 @@ DISPLAY_PLAYER_URL=https://portal.b2in.test/_cabinet/display
|
|||
SEED_ADMIN_NAME="Admin"
|
||||
SEED_ADMIN_EMAIL=admin@b2in.test
|
||||
SEED_ADMIN_PASSWORD=
|
||||
|
||||
# --- Version (leer lassen: kommt aus der Datei RELEASE, die dev/betrieb/deploy.sh schreibt)
|
||||
APP_VERSION=
|
||||
|
||||
# --- GlitchTip (errors.adametz.media, Projekte server-b2in / browser-b2in) – siehe dev/betrieb/README.md
|
||||
SENTRY_LARAVEL_DSN=
|
||||
SENTRY_BROWSER_DSN=
|
||||
SENTRY_ENVIRONMENT=production # Vorschau: preview, lokal: local
|
||||
SENTRY_RELEASE=
|
||||
|
||||
# --- Betriebs-Alerts (Mail und ntfy-Push)
|
||||
MONITORING_ALERTS_ENABLED=true
|
||||
MONITORING_ALERT_EMAIL=
|
||||
MONITORING_NTFY_URL=https://push.adametz.media
|
||||
MONITORING_NTFY_TOPIC=b2in-ops
|
||||
MONITORING_NTFY_TOKEN=
|
||||
MONITORING_NTFY_PRIORITY=4
|
||||
|
||||
# --- Tiefer Health-Check /health (Bearer-Token für Uptime Kuma) – je Umgebung ein eigener Wert
|
||||
MONITORING_HEALTH_TOKEN=
|
||||
MONITORING_QUEUE_BACKLOG_SECONDS=300
|
||||
MONITORING_DISK_MIN_FREE_PERCENT=10
|
||||
|
||||
# --- Nur lokal: Lesezugriff auf die GlitchTip-API (project:read, event:read) – nie committen
|
||||
GLITCHTIP_API_TOKEN=
|
||||
|
|
|
|||
10
.gitignore
vendored
10
.gitignore
vendored
|
|
@ -1,12 +1,17 @@
|
|||
# Laravel
|
||||
/.phpunit.cache
|
||||
/node_modules
|
||||
/public/build
|
||||
# public/build ist versioniert (Git-Deploy: auf dem Server gibt es kein Node). Nur die
|
||||
# Ausgabe des alten Standard-Builds (`npm run build`) liegt direkt darunter und wird nicht gebraucht.
|
||||
/public/build/assets/
|
||||
/public/build/manifest.json
|
||||
/public/hot
|
||||
/public/storage
|
||||
/public/vendor
|
||||
/public/flux
|
||||
/storage/*.key
|
||||
# schreibt dev/betrieb/deploy.sh beim Deploy (Version des ausgelieferten Tags)
|
||||
/RELEASE
|
||||
/storage/app
|
||||
/storage/framework
|
||||
/storage/language
|
||||
|
|
@ -50,7 +55,8 @@ yarn-error.log
|
|||
Network Trash Folder
|
||||
Temporary Items
|
||||
.apdisk
|
||||
Icon
|
||||
# macOS-Ordnersymbol heißt „Icon\r“ – ohne [\r] träfe das Muster bei core.ignorecase auch resources/views/flux/icon/
|
||||
Icon[\r]
|
||||
|
||||
|
||||
# Project specific
|
||||
|
|
|
|||
|
|
@ -142,6 +142,11 @@ For local development, add domains to your hosts file:
|
|||
127.0.0.1 style2own.test
|
||||
```
|
||||
|
||||
## Deployment & Monitoring
|
||||
- Deploys run via Git tags only: `dev/betrieb/deploy.sh vX.Y.Z` on the server; remotes: `origin` (forge.adametz.media, deploy source), `NAS` (backup only). Runbook: `dev/betrieb/README.md`.
|
||||
- `public/build` is versioned: build locally (`npm run build:web && npm run build:portal`) and commit before tagging.
|
||||
- Errors go to GlitchTip (`errors.adametz.media`, org `adametz-media`, projects `server-b2in` = 8, `browser-b2in` = 7); read-only API token as `GLITCHTIP_API_TOKEN` in `.env`. `/health` (token) for Uptime Kuma, alerts via ntfy topic `b2in-ops`.
|
||||
|
||||
## Important Files
|
||||
- `DOMAINS-CONFIG.md`: Detailed domain configuration guide
|
||||
- `FORTIFY-SANCTUM-SETUP.md`: Authentication setup guide
|
||||
|
|
@ -160,7 +165,7 @@ The Laravel Boost guidelines are specifically curated by Laravel maintainers for
|
|||
## Foundational Context
|
||||
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
|
||||
|
||||
- php - 8.4.18
|
||||
- php - 8.4.26
|
||||
- laravel/fortify (FORTIFY) - v1
|
||||
- laravel/framework (LARAVEL) - v12
|
||||
- laravel/prompts (PROMPTS) - v0
|
||||
|
|
|
|||
25
app/Console/Commands/MonitoringHeartbeat.php
Normal file
25
app/Console/Commands/MonitoringHeartbeat.php
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Monitoring\HealthProbe;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Minütlicher Herzschlag des Schedulers. Bleibt er aus,
|
||||
* melden `/health` und der Systemstatus den Scheduler als ausgefallen —
|
||||
* `onFailure` der einzelnen Tasks greift in diesem Fall nicht mehr.
|
||||
*/
|
||||
class MonitoringHeartbeat extends Command
|
||||
{
|
||||
protected $signature = 'monitoring:heartbeat';
|
||||
|
||||
protected $description = 'Schreibt den Herzschlag des Schedulers (Grundlage für /health und den Systemstatus).';
|
||||
|
||||
public function handle(HealthProbe $probe): int
|
||||
{
|
||||
$probe->recordHeartbeat();
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
50
app/Console/Commands/MonitoringQueueProbe.php
Normal file
50
app/Console/Commands/MonitoringQueueProbe.php
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\Monitoring\QueueProbe;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Sleep;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Prüft, ob ein Queue-Worker läuft: stellt einen Test-Job ein und wartet,
|
||||
* bis er abgeholt ist (Aufruf am Ende von dev/betrieb/deploy.sh).
|
||||
*
|
||||
* Hintergrund: `queue:restart` beendet den Worker, neu starten muss ihn
|
||||
* Supervisor. Läuft Supervisor nicht, bleibt die Queue still stehen.
|
||||
*/
|
||||
class MonitoringQueueProbe extends Command
|
||||
{
|
||||
protected $signature = 'monitoring:queue-probe
|
||||
{--wait=60 : Sekunden, die auf den Worker gewartet wird}';
|
||||
|
||||
protected $description = 'Prüft mit einem Test-Job, ob ein Queue-Worker läuft.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$token = Str::random(16);
|
||||
$wait = max(0, (int) $this->option('wait'));
|
||||
|
||||
QueueProbe::dispatch($token);
|
||||
|
||||
for ($second = 0; $second <= $wait; $second++) {
|
||||
if (Cache::has(QueueProbe::cacheKey($token))) {
|
||||
Cache::forget(QueueProbe::cacheKey($token));
|
||||
$this->info(sprintf('Queue-Worker läuft (Test-Job nach %d s abgeholt).', $second));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if ($second < $wait) {
|
||||
Sleep::for(1)->second();
|
||||
}
|
||||
}
|
||||
|
||||
$this->error(sprintf('Kein Queue-Worker hat den Test-Job innerhalb von %d s abgeholt.', $wait));
|
||||
$this->line('Worker prüfen: läuft Supervisor (bzw. der Worker im Server-Panel)? Notfalls: nohup php artisan queue:work --tries=3 --max-time=3600 &');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
39
app/Http/Controllers/HealthController.php
Normal file
39
app/Http/Controllers/HealthController.php
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\Monitoring\HealthProbe;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Tiefer Health-Check für den externen Monitor.
|
||||
*
|
||||
* `/up` zeigt nur, dass die App bootet. `/health` prüft zusätzlich DB,
|
||||
* Cache, Queue-Rückstau, Scheduler-Herzschlag und Plattenplatz: 200 solange
|
||||
* nichts kritisch ist, sonst 503. Zugang nur mit MONITORING_HEALTH_TOKEN
|
||||
* (Bearer oder `?token=`); ohne konfiguriertes Token gibt es den Endpunkt nicht.
|
||||
*/
|
||||
class HealthController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request, HealthProbe $probe): JsonResponse
|
||||
{
|
||||
$token = (string) config('monitoring.health.token');
|
||||
$given = (string) ($request->bearerToken() ?? $request->query('token', ''));
|
||||
|
||||
abort_if($token === '' || ! hash_equals($token, $given), 404);
|
||||
|
||||
$checks = $probe->checks();
|
||||
$status = HealthProbe::worstStatus($checks);
|
||||
|
||||
return response()->json([
|
||||
'status' => $status,
|
||||
'version' => config('app.version'),
|
||||
'checked_at' => now()->toIso8601String(),
|
||||
'checks' => collect($checks)->mapWithKeys(fn (array $check): array => [
|
||||
$check['key'] => ['status' => $check['status'], 'detail' => $check['detail']],
|
||||
]),
|
||||
], $status === HealthProbe::StatusCritical ? 503 : 200)
|
||||
->header('Cache-Control', 'no-store');
|
||||
}
|
||||
}
|
||||
|
|
@ -32,6 +32,12 @@ class BasicAuthMiddleware
|
|||
return $next($request);
|
||||
}
|
||||
|
||||
// Skip Basic Auth für den Health-Check: Uptime Kuma schickt seinen eigenen
|
||||
// Authorization-Header (Bearer-Token), /health prüft ihn selbst.
|
||||
if ($path === 'health' || $path === 'up') {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// Skip Basic Auth für Flux UI Assets (flux.js, flux.min.js, editor.js, etc.)
|
||||
if (str_starts_with($path, 'flux/')) {
|
||||
return $next($request);
|
||||
|
|
|
|||
96
app/Http/Middleware/ReportErrorSignals.php
Normal file
96
app/Http/Middleware/ReportErrorSignals.php
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Support\Monitoring\ErrorReportScrubber;
|
||||
use App\Support\Monitoring\ErrorSignal;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
use function Illuminate\Support\defer;
|
||||
|
||||
/**
|
||||
* Meldet Antworten, hinter denen ein Bedien- oder Programmierfehler steckt,
|
||||
* obwohl keine Exception gemeldet wird:
|
||||
*
|
||||
* - 404 mit Referrer der eigenen Domain — irgendwo bei uns steht ein toter Link
|
||||
* - 403/429 für angemeldete Nutzer — die Oberfläche führt sie dorthin
|
||||
* - 419 — Formular oder Livewire-Komponente abgelaufen, Eingaben sind verloren
|
||||
*
|
||||
* Gesendet wird nach der Antwort (`defer()`), der Nutzer wartet nicht auf GlitchTip.
|
||||
*/
|
||||
class ReportErrorSignals
|
||||
{
|
||||
public function __construct(private readonly ErrorSignal $errorSignal) {}
|
||||
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$response = $next($request);
|
||||
|
||||
$signal = $this->signalFor($request, $response->getStatusCode());
|
||||
|
||||
if ($signal !== null) {
|
||||
$location = $this->locationOf($request);
|
||||
$context = [
|
||||
'status' => $response->getStatusCode(),
|
||||
'method' => $request->method(),
|
||||
'path' => '/'.ltrim($request->path(), '/'),
|
||||
'referer' => $this->refererPath($request),
|
||||
'livewire' => $request->hasHeader('X-Livewire'),
|
||||
];
|
||||
|
||||
defer(fn () => $this->errorSignal->report($signal, $location, $context));
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
private function signalFor(Request $request, int $status): ?string
|
||||
{
|
||||
return match (true) {
|
||||
$status === 404 && $this->hasInternalReferer($request) => ErrorSignal::DeadInternalLink,
|
||||
$status === 403 && $request->user() !== null => ErrorSignal::AccessDenied,
|
||||
$status === 419 => ErrorSignal::PageExpired,
|
||||
$status === 429 && $request->user() !== null => ErrorSignal::Throttled,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Livewire-Requests laufen alle über denselben Update-Endpunkt — der Ort
|
||||
* ist dort die Seite, auf der der Nutzer stand.
|
||||
*/
|
||||
private function locationOf(Request $request): string
|
||||
{
|
||||
if ($request->hasHeader('X-Livewire')) {
|
||||
return 'livewire '.($this->refererPath($request) ?? '/');
|
||||
}
|
||||
|
||||
$routeName = $request->route()?->getName();
|
||||
|
||||
return $routeName !== null
|
||||
? $routeName
|
||||
: '/'.ltrim($request->path(), '/');
|
||||
}
|
||||
|
||||
private function hasInternalReferer(Request $request): bool
|
||||
{
|
||||
$referer = $request->headers->get('referer');
|
||||
|
||||
return is_string($referer) && parse_url($referer, PHP_URL_HOST) === $request->getHost();
|
||||
}
|
||||
|
||||
private function refererPath(Request $request): ?string
|
||||
{
|
||||
$referer = $request->headers->get('referer');
|
||||
|
||||
if (! is_string($referer) || $referer === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$path = parse_url($referer, PHP_URL_PATH);
|
||||
|
||||
return is_string($path) ? ErrorReportScrubber::scrubPath($path) : null;
|
||||
}
|
||||
}
|
||||
31
app/Jobs/Monitoring/QueueProbe.php
Normal file
31
app/Jobs/Monitoring/QueueProbe.php
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
namespace App\Jobs\Monitoring;
|
||||
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* Test-Job für `monitoring:queue-probe`: tut nichts außer zu quittieren,
|
||||
* dass ein Worker ihn abgeholt hat. Läuft auf der Standard-Queue —
|
||||
* der Worker muss sie bedienen.
|
||||
*/
|
||||
class QueueProbe implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public int $tries = 1;
|
||||
|
||||
public function __construct(public string $token) {}
|
||||
|
||||
public static function cacheKey(string $token): string
|
||||
{
|
||||
return 'monitoring:queue-probe:'.$token;
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
Cache::put(self::cacheKey($this->token), now()->toIso8601String(), now()->addMinutes(10));
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,10 @@
|
|||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Support\OperationalAlert;
|
||||
use Illuminate\Foundation\Console\AboutCommand;
|
||||
use Illuminate\Queue\Events\JobFailed;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
|
|
@ -29,5 +33,17 @@ class AppServiceProvider extends ServiceProvider
|
|||
if ($scheme === 'https') {
|
||||
URL::forceScheme('https');
|
||||
}
|
||||
|
||||
// Betriebs-Alert bei endgültig fehlgeschlagenem Queue-Job
|
||||
Queue::failing(function (JobFailed $event): void {
|
||||
OperationalAlert::send("Queue-Job fehlgeschlagen: {$event->job->resolveName()}", [
|
||||
'connection' => $event->connectionName,
|
||||
'queue' => $event->job->getQueue(),
|
||||
'exception' => $event->exception->getMessage(),
|
||||
]);
|
||||
});
|
||||
|
||||
// Ausgelieferte Version in `php artisan about` (ABOUT_SECTION in dev/betrieb/deploy.sh)
|
||||
AboutCommand::add('B2in', fn (): array => ['Version' => config('app.version') ?? '—']);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
198
app/Services/Monitoring/HealthProbe.php
Normal file
198
app/Services/Monitoring/HealthProbe.php
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Monitoring;
|
||||
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Die Prüfungen, die ein externer Monitor jede Minute abfragt
|
||||
*: Datenbank, Cache, Queue-Rückstau,
|
||||
* Scheduler-Herzschlag, Plattenplatz. Bewusst billig — keine Zählungen über
|
||||
* große Tabellen, damit `/health` selbst keine Last erzeugt.
|
||||
*
|
||||
* Ein Admin-Systemstatus kann `scheduler()` und `disk()` mitbenutzen.
|
||||
*/
|
||||
class HealthProbe
|
||||
{
|
||||
public const StatusOk = 'ok';
|
||||
|
||||
public const StatusWarn = 'warn';
|
||||
|
||||
public const StatusCritical = 'critical';
|
||||
|
||||
/**
|
||||
* Datei statt Datenbank/Cache: übersteht `cache:clear`, braucht keine
|
||||
* Migration. Bei mehreren App-Servern stattdessen eine DB-Zeile nehmen.
|
||||
*/
|
||||
public static function heartbeatPath(): string
|
||||
{
|
||||
return (string) (config('monitoring.scheduler.heartbeat_path') ?: storage_path('framework/scheduler-heartbeat'));
|
||||
}
|
||||
|
||||
public function recordHeartbeat(): void
|
||||
{
|
||||
file_put_contents(self::heartbeatPath(), now()->toIso8601String(), LOCK_EX);
|
||||
}
|
||||
|
||||
public function lastHeartbeat(): ?Carbon
|
||||
{
|
||||
$value = @file_get_contents(self::heartbeatPath());
|
||||
|
||||
return is_string($value) && trim($value) !== '' ? Carbon::parse(trim($value)) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{key: string, label: string, status: string, detail: string}>
|
||||
*/
|
||||
public function checks(): array
|
||||
{
|
||||
return [
|
||||
$this->database(),
|
||||
$this->cache(),
|
||||
$this->queue(),
|
||||
$this->scheduler(),
|
||||
$this->disk(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{status: string}> $checks
|
||||
*/
|
||||
public static function worstStatus(array $checks): string
|
||||
{
|
||||
$statuses = array_column($checks, 'status');
|
||||
|
||||
return match (true) {
|
||||
in_array(self::StatusCritical, $statuses, true) => self::StatusCritical,
|
||||
in_array(self::StatusWarn, $statuses, true) => self::StatusWarn,
|
||||
default => self::StatusOk,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{key: string, label: string, status: string, detail: string}
|
||||
*/
|
||||
public function scheduler(): array
|
||||
{
|
||||
$lastBeat = $this->lastHeartbeat();
|
||||
$warnMinutes = (int) config('monitoring.scheduler.heartbeat_warn_minutes');
|
||||
$criticalMinutes = (int) config('monitoring.scheduler.heartbeat_critical_minutes');
|
||||
$minutesAgo = $lastBeat !== null ? (int) $lastBeat->diffInMinutes(now(), absolute: true) : null;
|
||||
|
||||
return $this->check(
|
||||
'scheduler',
|
||||
'Scheduler',
|
||||
match (true) {
|
||||
$minutesAgo === null, $minutesAgo >= $criticalMinutes => self::StatusCritical,
|
||||
$minutesAgo >= $warnMinutes => self::StatusWarn,
|
||||
default => self::StatusOk,
|
||||
},
|
||||
$lastBeat === null
|
||||
? 'Noch kein Herzschlag — läuft `schedule:run` per Cron (bzw. `schedule:work`)?'
|
||||
: 'Letzter Herzschlag '.$lastBeat->format('d.m.Y H:i:s').' (vor '.$minutesAgo.' Min.).',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{key: string, label: string, status: string, detail: string}
|
||||
*/
|
||||
public function disk(): array
|
||||
{
|
||||
$path = storage_path();
|
||||
$free = @disk_free_space($path);
|
||||
$total = @disk_total_space($path);
|
||||
|
||||
if ($free === false || $total === false || $total <= 0) {
|
||||
return $this->check('disk', 'Plattenplatz', self::StatusWarn, 'Plattenplatz nicht ermittelbar.');
|
||||
}
|
||||
|
||||
$freePercent = round($free / $total * 100, 1);
|
||||
$minPercent = (float) config('monitoring.health.disk_min_free_percent');
|
||||
|
||||
return $this->check(
|
||||
'disk',
|
||||
'Plattenplatz',
|
||||
match (true) {
|
||||
$freePercent < $minPercent => self::StatusCritical,
|
||||
$freePercent < $minPercent * 2 => self::StatusWarn,
|
||||
default => self::StatusOk,
|
||||
},
|
||||
sprintf('%s %% frei (%s GB von %s GB).', $freePercent, round($free / 1024 ** 3, 1), round($total / 1024 ** 3, 1)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{key: string, label: string, status: string, detail: string}
|
||||
*/
|
||||
private function database(): array
|
||||
{
|
||||
try {
|
||||
DB::select('select 1');
|
||||
|
||||
return $this->check('database', 'Datenbank', self::StatusOk, 'Verbindung steht.');
|
||||
} catch (Throwable $exception) {
|
||||
return $this->check('database', 'Datenbank', self::StatusCritical, 'Keine Verbindung: '.Str::limit($exception->getMessage(), 120));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{key: string, label: string, status: string, detail: string}
|
||||
*/
|
||||
private function cache(): array
|
||||
{
|
||||
try {
|
||||
$value = Str::random(8);
|
||||
Cache::put('monitoring:health-probe', $value, 60);
|
||||
|
||||
return Cache::get('monitoring:health-probe') === $value
|
||||
? $this->check('cache', 'Cache', self::StatusOk, 'Schreiben und Lesen funktionieren.')
|
||||
: $this->check('cache', 'Cache', self::StatusCritical, 'Geschriebener Wert kommt nicht zurück.');
|
||||
} catch (Throwable $exception) {
|
||||
return $this->check('cache', 'Cache', self::StatusCritical, 'Nicht erreichbar: '.Str::limit($exception->getMessage(), 120));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{key: string, label: string, status: string, detail: string}
|
||||
*/
|
||||
private function queue(): array
|
||||
{
|
||||
if (config('queue.default') !== 'database') {
|
||||
return $this->check('queue', 'Queue', self::StatusOk, 'Treiber '.config('queue.default').' — Rückstau hier nicht prüfbar.');
|
||||
}
|
||||
|
||||
try {
|
||||
$now = now()->getTimestamp();
|
||||
$oldestAvailable = DB::table('jobs')
|
||||
->whereNull('reserved_at')
|
||||
->where('available_at', '<=', $now)
|
||||
->min('available_at');
|
||||
} catch (Throwable $exception) {
|
||||
return $this->check('queue', 'Queue', self::StatusCritical, 'Jobs-Tabelle nicht lesbar: '.Str::limit($exception->getMessage(), 120));
|
||||
}
|
||||
|
||||
$waitingSeconds = $oldestAvailable !== null ? max(0, $now - (int) $oldestAvailable) : 0;
|
||||
$criticalSeconds = (int) config('monitoring.health.queue_backlog_critical_seconds');
|
||||
|
||||
return $this->check(
|
||||
'queue',
|
||||
'Queue',
|
||||
$waitingSeconds >= $criticalSeconds ? self::StatusCritical : self::StatusOk,
|
||||
$oldestAvailable === null
|
||||
? 'Kein wartender Job.'
|
||||
: 'Ältester wartender Job seit '.$waitingSeconds.' s.'.($waitingSeconds >= $criticalSeconds ? ' Worker gestoppt?' : ''),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{key: string, label: string, status: string, detail: string}
|
||||
*/
|
||||
private function check(string $key, string $label, string $status, string $detail): array
|
||||
{
|
||||
return ['key' => $key, 'label' => $label, 'status' => $status, 'detail' => $detail];
|
||||
}
|
||||
}
|
||||
159
app/Support/Monitoring/ErrorReportScrubber.php
Normal file
159
app/Support/Monitoring/ErrorReportScrubber.php
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
<?php
|
||||
|
||||
namespace App\Support\Monitoring;
|
||||
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Str;
|
||||
use Sentry\Event;
|
||||
use Sentry\EventHint;
|
||||
use Sentry\UserDataBag;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Letzte Station vor GlitchTip (`before_send` in config/sentry.php).
|
||||
*
|
||||
* Personenbezug nur über die interne User-ID. Tokens und Signaturen stecken
|
||||
* in Query-Strings (signierte Links) und in Pfaden
|
||||
* (`/reset-password/{token}`, Magic Links) — beides wird ersetzt, bevor das
|
||||
* Ereignis den Server verlässt. E-Mail-Adressen in Fehlermeldungen (z. B.
|
||||
* QueryException mit eingesetzten Bindings) ebenso.
|
||||
*/
|
||||
class ErrorReportScrubber
|
||||
{
|
||||
public const Filtered = '[Filtered]';
|
||||
|
||||
/**
|
||||
* Query-Parameter, die unverändert mitgehen dürfen (`utm_*` immer).
|
||||
* Je Projekt anpassen — dieselbe Liste steht in error-reporting.js.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
public const AllowedQueryParameters = ['page', 'q', 'tab', 'sort'];
|
||||
|
||||
private const EmailPattern = '/[A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,}/i';
|
||||
|
||||
/** Pfadsegmente, die wie ein Token aussehen (Magic Links, Reset-Links). */
|
||||
private const TokenSegmentPattern = '#(?<=/)[A-Za-z0-9_\-]{32,}(?=/|$)#';
|
||||
|
||||
public static function beforeSend(Event $event, ?EventHint $hint = null): ?Event
|
||||
{
|
||||
$userId = self::authenticatedUserId();
|
||||
|
||||
$event->setUser($userId !== null ? UserDataBag::createFromUserIdentifier($userId) : null);
|
||||
$event->setRequest(self::scrubRequest($event->getRequest()));
|
||||
|
||||
foreach ($event->getExceptions() as $exception) {
|
||||
$exception->setValue(self::scrubText($exception->getValue()));
|
||||
}
|
||||
|
||||
if ($event->getMessage() !== null) {
|
||||
$formatted = $event->getMessageFormatted();
|
||||
|
||||
$event->setMessage(
|
||||
self::scrubText($event->getMessage()),
|
||||
$event->getMessageParams(),
|
||||
$formatted !== null ? self::scrubText($formatted) : null,
|
||||
);
|
||||
}
|
||||
|
||||
return $event;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $request
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function scrubRequest(array $request): array
|
||||
{
|
||||
if (isset($request['url']) && is_string($request['url'])) {
|
||||
$request['url'] = self::scrubUrl($request['url']);
|
||||
}
|
||||
|
||||
if (isset($request['query_string']) && is_string($request['query_string'])) {
|
||||
$request['query_string'] = self::scrubQuery($request['query_string']);
|
||||
}
|
||||
|
||||
unset($request['data'], $request['cookies']);
|
||||
|
||||
foreach ($request['headers'] ?? [] as $name => $values) {
|
||||
if (strtolower((string) $name) === 'referer') {
|
||||
$request['headers'][$name] = array_map(
|
||||
static fn (mixed $value): string => self::scrubUrl((string) $value),
|
||||
(array) $values,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $request;
|
||||
}
|
||||
|
||||
public static function scrubUrl(string $url): string
|
||||
{
|
||||
$query = parse_url($url, PHP_URL_QUERY);
|
||||
$withoutQuery = $query !== null && $query !== false ? Str::before($url, '?') : $url;
|
||||
$scrubbedPath = self::scrubPath(self::scrubPathTokens($withoutQuery));
|
||||
|
||||
return $query !== null && $query !== false && $query !== ''
|
||||
? $scrubbedPath.'?'.self::scrubQuery($query)
|
||||
: $scrubbedPath;
|
||||
}
|
||||
|
||||
public static function scrubQuery(string $query): string
|
||||
{
|
||||
parse_str($query, $parameters);
|
||||
|
||||
$scrubbed = [];
|
||||
foreach ($parameters as $name => $value) {
|
||||
$isAllowed = in_array((string) $name, self::AllowedQueryParameters, true)
|
||||
|| str_starts_with((string) $name, 'utm_');
|
||||
|
||||
$scrubbed[$name] = $isAllowed && is_string($value) ? $value : self::Filtered;
|
||||
}
|
||||
|
||||
return http_build_query($scrubbed, '', '&', PHP_QUERY_RFC3986);
|
||||
}
|
||||
|
||||
public static function scrubPath(string $path): string
|
||||
{
|
||||
return self::scrubText((string) preg_replace(self::TokenSegmentPattern, self::Filtered, $path));
|
||||
}
|
||||
|
||||
public static function scrubText(string $text): string
|
||||
{
|
||||
return (string) preg_replace(self::EmailPattern, '[email]', $text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ersetzt Routenparameter, deren Name auf ein Geheimnis hindeutet
|
||||
* (`token`, `hash`, `signature`), im Pfad der aktuellen Route.
|
||||
*/
|
||||
private static function scrubPathTokens(string $url): string
|
||||
{
|
||||
try {
|
||||
$route = request()->route();
|
||||
} catch (Throwable) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
if ($route === null) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
foreach ($route->parameters() as $name => $value) {
|
||||
if (is_string($value) && $value !== '' && Str::contains(strtolower((string) $name), ['token', 'hash', 'signature'])) {
|
||||
$url = str_replace(rawurlencode($value), self::Filtered, str_replace($value, self::Filtered, $url));
|
||||
}
|
||||
}
|
||||
|
||||
return $url;
|
||||
}
|
||||
|
||||
private static function authenticatedUserId(): int|string|null
|
||||
{
|
||||
try {
|
||||
return Auth::id();
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
68
app/Support/Monitoring/ErrorSignal.php
Normal file
68
app/Support/Monitoring/ErrorSignal.php
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<?php
|
||||
|
||||
namespace App\Support\Monitoring;
|
||||
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Str;
|
||||
use Sentry\Severity;
|
||||
use Sentry\State\Scope;
|
||||
|
||||
use function Sentry\captureMessage;
|
||||
use function Sentry\withScope;
|
||||
|
||||
/**
|
||||
* Fehler ohne Exception als GlitchTip-Warnung: tote
|
||||
* interne Links, verweigerte Zugriffe, abgelaufene Seiten. Gruppiert wird je
|
||||
* Signal und Ort; je Gruppe gehen höchstens fünf Meldungen in zehn Minuten
|
||||
* raus, damit ein Crawler mit gefälschtem Referrer GlitchTip nicht flutet.
|
||||
*/
|
||||
class ErrorSignal
|
||||
{
|
||||
public const DeadInternalLink = 'dead_internal_link';
|
||||
|
||||
public const AccessDenied = 'access_denied';
|
||||
|
||||
public const PageExpired = 'page_expired';
|
||||
|
||||
public const Throttled = 'throttled';
|
||||
|
||||
private const Labels = [
|
||||
self::DeadInternalLink => 'Toter interner Link',
|
||||
self::AccessDenied => 'Zugriff verweigert (angemeldet)',
|
||||
self::PageExpired => 'Seite abgelaufen (419)',
|
||||
self::Throttled => 'Drosselung trifft angemeldeten Nutzer',
|
||||
];
|
||||
|
||||
private const MaxReportsPerWindow = 5;
|
||||
|
||||
private const WindowSeconds = 600;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function report(string $signal, string $location, array $context = []): bool
|
||||
{
|
||||
$location = Str::limit($location, 200, '');
|
||||
|
||||
$reported = RateLimiter::attempt(
|
||||
'error-signal:'.sha1($signal.'|'.$location),
|
||||
self::MaxReportsPerWindow,
|
||||
static fn (): bool => true,
|
||||
self::WindowSeconds,
|
||||
);
|
||||
|
||||
if ($reported === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
withScope(function (Scope $scope) use ($signal, $location, $context): void {
|
||||
$scope->setFingerprint(['error-signal', $signal, $location]);
|
||||
$scope->setTag('signal', $signal);
|
||||
$scope->setContext('signal', $context);
|
||||
|
||||
captureMessage((self::Labels[$signal] ?? $signal).': '.$location, Severity::warning());
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
36
app/Support/Monitoring/Release.php
Normal file
36
app/Support/Monitoring/Release.php
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
namespace App\Support\Monitoring;
|
||||
|
||||
/**
|
||||
* Versionskennung des ausgelieferten Stands — `config('app.version')` und
|
||||
* Release in GlitchTip. Vorrang hat der konfigurierte Wert (APP_VERSION bzw.
|
||||
* SENTRY_RELEASE); sonst die Datei `RELEASE` im Projektwurzelverzeichnis, die
|
||||
* beim Deploy entsteht (`git describe --tags --always --dirty > RELEASE`) —
|
||||
* der Live-Server hat kein Git, solange per SFTP deployt wird.
|
||||
*
|
||||
* Versionen sind Git-Tags nach SemVer: Feature-Deploy erhöht die mittlere,
|
||||
* Hotfix die letzte Stelle. Auf dem Tag ergibt das „v1.1.0“, mit späteren
|
||||
* Commits „v1.1.0-2-gabc1234“, mit uncommitteten Änderungen „…-dirty“.
|
||||
*/
|
||||
class Release
|
||||
{
|
||||
public static function current(?string $configured, ?string $basePath = null): ?string
|
||||
{
|
||||
$configured = trim((string) $configured);
|
||||
|
||||
if ($configured !== '') {
|
||||
return $configured;
|
||||
}
|
||||
|
||||
$file = rtrim($basePath ?? base_path(), '/').'/RELEASE';
|
||||
|
||||
if (! is_file($file)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$release = trim((string) file_get_contents($file));
|
||||
|
||||
return preg_match('/^[A-Za-z0-9._\-]{1,64}$/', $release) === 1 ? $release : null;
|
||||
}
|
||||
}
|
||||
96
app/Support/OperationalAlert.php
Normal file
96
app/Support/OperationalAlert.php
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Str;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Zentrale Betriebs-Alerts: meldet fehlgeschlagene Scheduler-Tasks und
|
||||
* Queue-Jobs (Verdrahtung: siehe 03-betriebsueberwachung.md). Der Log-Eintrag (`critical`) ist die verlässliche Ebene und läuft
|
||||
* immer; Mail und ntfy-Push sind Best-Effort, unabhängig
|
||||
* voneinander, und dürfen den auslösenden Prozess nie mit in den Abgrund
|
||||
* reißen (Mailserver down, ntfy nicht erreichbar o. ä.).
|
||||
*/
|
||||
class OperationalAlert
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public static function send(string $summary, array $context = []): void
|
||||
{
|
||||
Log::channel('monitoring')->critical('[monitoring] '.$summary, $context);
|
||||
|
||||
if (! config('monitoring.alerts.enabled')) {
|
||||
return;
|
||||
}
|
||||
|
||||
self::mail($summary, $context);
|
||||
self::push($summary, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
private static function mail(string $summary, array $context): void
|
||||
{
|
||||
$recipient = config('monitoring.alerts.mail_to');
|
||||
|
||||
if (empty($recipient)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Mail::raw(
|
||||
$summary."\n\n".json_encode($context, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
fn ($message) => $message->to($recipient)->subject('['.config('app.name').'] Betriebs-Alert: '.Str::limit($summary, 80)),
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
Log::channel('monitoring')->error('[monitoring] Alert-Mail konnte nicht versendet werden.', [
|
||||
'summary' => $summary,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON-Publish an die ntfy-Basis-URL (UTF-8-sicher, anders als Header).
|
||||
*
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
private static function push(string $summary, array $context): void
|
||||
{
|
||||
$url = (string) config('monitoring.alerts.ntfy.url');
|
||||
$topic = (string) config('monitoring.alerts.ntfy.topic');
|
||||
|
||||
if ($url === '' || $topic === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$details = collect($context)
|
||||
->map(fn (mixed $value, string $key): string => $key.': '.(is_scalar($value) || $value === null ? (string) $value : json_encode($value)))
|
||||
->implode("\n");
|
||||
|
||||
try {
|
||||
Http::timeout(5)
|
||||
->connectTimeout(3)
|
||||
->when(filled(config('monitoring.alerts.ntfy.token')), fn ($request) => $request->withToken((string) config('monitoring.alerts.ntfy.token')))
|
||||
->post(rtrim($url, '/'), [
|
||||
'topic' => $topic,
|
||||
'title' => config('app.name').' ('.app()->environment().'): Betriebs-Alert',
|
||||
'message' => Str::limit(trim($summary."\n\n".$details), 3500),
|
||||
'priority' => (int) config('monitoring.alerts.ntfy.priority'),
|
||||
'tags' => ['rotating_light'],
|
||||
])
|
||||
->throw();
|
||||
} catch (Throwable $e) {
|
||||
Log::channel('monitoring')->error('[monitoring] ntfy-Push konnte nicht versendet werden.', [
|
||||
'summary' => $summary,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +1,25 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Controllers\HealthController;
|
||||
use App\Http\Middleware\ReportErrorSignals;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Sentry\Laravel\Integration;
|
||||
use Symfony\Component\Console\Exception\CommandNotFoundException;
|
||||
|
||||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->withRouting(
|
||||
web: __DIR__.'/../routes/domains.php',
|
||||
commands: __DIR__.'/../routes/console.php',
|
||||
health: '/up',
|
||||
then: function (): void {
|
||||
// Tiefer Health-Check für den externen Monitor (Uptime Kuma), auf jeder Domain
|
||||
Route::get('/health', HealthController::class)
|
||||
->middleware('throttle:30,1')
|
||||
->name('health.deep');
|
||||
},
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware) {
|
||||
// Traefik Reverse-Proxy vertraut die X-Forwarded-Proto/For Headers
|
||||
|
|
@ -27,11 +38,20 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||
'partner.setup' => \App\Http\Middleware\EnsurePartnerSetupCompleted::class,
|
||||
]);
|
||||
|
||||
// Fehler ohne Exception (toter interner Link, 403/429 für Angemeldete, 419) –
|
||||
// global, weil 404 vor dem Routing entsteht
|
||||
$middleware->append(ReportErrorSignals::class);
|
||||
|
||||
// BasicAuth ganz am Ende, nach Session-Middleware
|
||||
if (env('BASIC_AUTH_ENABLED', true)) {
|
||||
$middleware->append(\App\Http\Middleware\BasicAuthMiddleware::class);
|
||||
}
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions) {
|
||||
//
|
||||
// Gemeldete Exceptions an GlitchTip (ohne DSN ein No-op);
|
||||
// Laravels dontReport (404, Validierung, Auth) gilt weiter
|
||||
Integration::handles($exceptions);
|
||||
|
||||
// Vertippte artisan-Befehle sind kein Anwendungsfehler
|
||||
$exceptions->dontReport(CommandNotFoundException::class);
|
||||
})->create();
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@
|
|||
"livewire/flux": "^2.1.1",
|
||||
"livewire/flux-pro": "^2.6",
|
||||
"livewire/volt": "^1.7.0",
|
||||
"sentry/sentry-laravel": "^4.29",
|
||||
"spatie/laravel-permission": "^6.17"
|
||||
},
|
||||
"require-dev": {
|
||||
|
|
|
|||
553
composer.lock
generated
553
composer.lock
generated
|
|
@ -4,11 +4,11 @@
|
|||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "9e07d437a063526b426b920d400db8b8",
|
||||
"content-hash": "f2a8196dfd6ef735f3e827805a8a1f94",
|
||||
"packages": [
|
||||
{
|
||||
"name": "acme/contact-form",
|
||||
"version": "dev-master",
|
||||
"version": "dev-feature/b2in-interior",
|
||||
"dist": {
|
||||
"type": "path",
|
||||
"url": "packages/acme/contact-form",
|
||||
|
|
@ -55,7 +55,7 @@
|
|||
},
|
||||
{
|
||||
"name": "acme/cookie-consent",
|
||||
"version": "dev-master",
|
||||
"version": "dev-feature/b2in-interior",
|
||||
"dist": {
|
||||
"type": "path",
|
||||
"url": "packages/acme/CookieConsent",
|
||||
|
|
@ -860,7 +860,7 @@
|
|||
},
|
||||
{
|
||||
"name": "flux-cms/core",
|
||||
"version": "dev-master",
|
||||
"version": "dev-feature/b2in-interior",
|
||||
"dist": {
|
||||
"type": "path",
|
||||
"url": "packages/flux-cms/core",
|
||||
|
|
@ -1603,6 +1603,66 @@
|
|||
],
|
||||
"time": "2026-02-19T13:11:17+00:00"
|
||||
},
|
||||
{
|
||||
"name": "jean85/pretty-package-versions",
|
||||
"version": "2.1.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Jean85/pretty-package-versions.git",
|
||||
"reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Jean85/pretty-package-versions/zipball/4d7aa5dab42e2a76d99559706022885de0e18e1a",
|
||||
"reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"composer-runtime-api": "^2.1.0",
|
||||
"php": "^7.4|^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"friendsofphp/php-cs-fixer": "^3.2",
|
||||
"jean85/composer-provided-replaced-stub-package": "^1.0",
|
||||
"phpstan/phpstan": "^2.0",
|
||||
"phpunit/phpunit": "^7.5|^8.5|^9.6",
|
||||
"rector/rector": "^2.0",
|
||||
"vimeo/psalm": "^4.3 || ^5.0"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "1.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Jean85\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Alessandro Lai",
|
||||
"email": "alessandro.lai85@gmail.com"
|
||||
}
|
||||
],
|
||||
"description": "A library to get pretty versions strings of installed dependencies",
|
||||
"keywords": [
|
||||
"composer",
|
||||
"package",
|
||||
"release",
|
||||
"versions"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/Jean85/pretty-package-versions/issues",
|
||||
"source": "https://github.com/Jean85/pretty-package-versions/tree/2.1.1"
|
||||
},
|
||||
"time": "2025-03-19T14:43:43+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/fortify",
|
||||
"version": "v1.35.0",
|
||||
|
|
@ -3493,6 +3553,84 @@
|
|||
],
|
||||
"time": "2026-02-16T23:10:27+00:00"
|
||||
},
|
||||
{
|
||||
"name": "nyholm/psr7",
|
||||
"version": "1.8.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Nyholm/psr7.git",
|
||||
"reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Nyholm/psr7/zipball/a71f2b11690f4b24d099d6b16690a90ae14fc6f3",
|
||||
"reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=7.2",
|
||||
"psr/http-factory": "^1.0",
|
||||
"psr/http-message": "^1.1 || ^2.0"
|
||||
},
|
||||
"provide": {
|
||||
"php-http/message-factory-implementation": "1.0",
|
||||
"psr/http-factory-implementation": "1.0",
|
||||
"psr/http-message-implementation": "1.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"http-interop/http-factory-tests": "^0.9",
|
||||
"php-http/message-factory": "^1.0",
|
||||
"php-http/psr7-integration-tests": "^1.0",
|
||||
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.4",
|
||||
"symfony/error-handler": "^4.4"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "1.8-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Nyholm\\Psr7\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Tobias Nyholm",
|
||||
"email": "tobias.nyholm@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "Martijn van der Ven",
|
||||
"email": "martijn@vanderven.se"
|
||||
}
|
||||
],
|
||||
"description": "A fast PHP7 implementation of PSR-7",
|
||||
"homepage": "https://tnyholm.se",
|
||||
"keywords": [
|
||||
"psr-17",
|
||||
"psr-7"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/Nyholm/psr7/issues",
|
||||
"source": "https://github.com/Nyholm/psr7/tree/1.8.2"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/Zegnat",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nyholm",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2024-09-09T07:06:30+00:00"
|
||||
},
|
||||
{
|
||||
"name": "paragonie/constant_time_encoding",
|
||||
"version": "v3.1.3",
|
||||
|
|
@ -4378,6 +4516,195 @@
|
|||
},
|
||||
"time": "2025-12-14T04:43:48+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sentry/sentry",
|
||||
"version": "4.34.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/getsentry/sentry-php.git",
|
||||
"reference": "3d8f4e56becb10ede64e2ac6f8077f6434715461"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/getsentry/sentry-php/zipball/3d8f4e56becb10ede64e2ac6f8077f6434715461",
|
||||
"reference": "3d8f4e56becb10ede64e2ac6f8077f6434715461",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-curl": "*",
|
||||
"ext-json": "*",
|
||||
"ext-mbstring": "*",
|
||||
"guzzlehttp/psr7": "^1.8.4|^2.1.1|^3.0.0",
|
||||
"jean85/pretty-package-versions": "^1.5|^2.0.4",
|
||||
"php": "^7.2|^8.0",
|
||||
"psr/log": "^1.0|^2.0|^3.0",
|
||||
"symfony/options-resolver": "^4.4.30|^5.0.11|^6.0|^7.0|^8.0"
|
||||
},
|
||||
"conflict": {
|
||||
"raven/raven": "*"
|
||||
},
|
||||
"require-dev": {
|
||||
"carthage-software/mago": "1.30.0",
|
||||
"friendsofphp/php-cs-fixer": "^3.4",
|
||||
"guzzlehttp/guzzle": "^7.0|^8.0",
|
||||
"guzzlehttp/promises": "^2.0.3|^3.0.0",
|
||||
"monolog/monolog": "^1.6|^2.0|^3.0",
|
||||
"nyholm/psr7": "^1.8",
|
||||
"open-telemetry/api": "^1.1",
|
||||
"open-telemetry/context": "^1.1",
|
||||
"open-telemetry/exporter-otlp": "^1.1",
|
||||
"open-telemetry/gen-otlp-protobuf": "^1.2.1",
|
||||
"open-telemetry/sdk": "^1.1",
|
||||
"open-telemetry/sem-conv": "^1.27",
|
||||
"phpstan/phpstan": "^2.2",
|
||||
"phpunit/phpunit": "^8.5.52|^9.6.34",
|
||||
"spiral/roadrunner-http": "^3.6",
|
||||
"spiral/roadrunner-worker": "^3.6"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-excimer": "Enable Sentry profiling with the Excimer PHP extension.",
|
||||
"monolog/monolog": "Allow sending log messages to Sentry by using the included Monolog handler."
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"files": [
|
||||
"src/functions.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Sentry\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Sentry",
|
||||
"email": "accounts@sentry.io"
|
||||
}
|
||||
],
|
||||
"description": "PHP SDK for Sentry (http://sentry.io)",
|
||||
"homepage": "http://sentry.io",
|
||||
"keywords": [
|
||||
"crash-reporting",
|
||||
"crash-reports",
|
||||
"error-handler",
|
||||
"error-monitoring",
|
||||
"log",
|
||||
"logging",
|
||||
"profiling",
|
||||
"sentry",
|
||||
"tracing"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/getsentry/sentry-php/issues",
|
||||
"source": "https://github.com/getsentry/sentry-php/tree/4.34.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://sentry.io/",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://sentry.io/pricing/",
|
||||
"type": "custom"
|
||||
}
|
||||
],
|
||||
"time": "2026-10-08T11:25:58+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sentry/sentry-laravel",
|
||||
"version": "4.29.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/getsentry/sentry-laravel.git",
|
||||
"reference": "4b3deab3d8818490220e1da8e2d7cca971f74410"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/getsentry/sentry-laravel/zipball/4b3deab3d8818490220e1da8e2d7cca971f74410",
|
||||
"reference": "4b3deab3d8818490220e1da8e2d7cca971f74410",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"illuminate/support": "^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0 | ^12.0 | ^13.0",
|
||||
"nyholm/psr7": "^1.0",
|
||||
"php": "^7.2 | ^8.0",
|
||||
"sentry/sentry": "^4.34.0",
|
||||
"symfony/psr-http-message-bridge": "^1.0 | ^2.0 | ^6.0 | ^7.0 | ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"friendsofphp/php-cs-fixer": "^3.11",
|
||||
"guzzlehttp/guzzle": "^7.2",
|
||||
"laravel/ai": "^0.1.5 | ^1.0",
|
||||
"laravel/folio": "^1.1",
|
||||
"laravel/framework": "^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0 | ^12.0 | ^13.0",
|
||||
"laravel/octane": "^2.15",
|
||||
"laravel/pennant": "^1.0",
|
||||
"livewire/livewire": "^2.0 | ^3.0 | ^4.0",
|
||||
"mockery/mockery": "^1.3",
|
||||
"orchestra/testbench": "^4.7 | ^5.1 | ^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0",
|
||||
"phpstan/phpstan": "^1.10 | ^2.2",
|
||||
"phpunit/phpunit": "^8.5 | ^9.6 | ^10.4 | ^11.5"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"aliases": {
|
||||
"Sentry": "Sentry\\Laravel\\Facade"
|
||||
},
|
||||
"providers": [
|
||||
"Sentry\\Laravel\\ServiceProvider",
|
||||
"Sentry\\Laravel\\Tracing\\ServiceProvider"
|
||||
]
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-0": {
|
||||
"Sentry\\Laravel\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Sentry",
|
||||
"email": "accounts@sentry.io"
|
||||
}
|
||||
],
|
||||
"description": "Laravel SDK for Sentry (https://sentry.io)",
|
||||
"homepage": "https://sentry.io",
|
||||
"keywords": [
|
||||
"crash-reporting",
|
||||
"crash-reports",
|
||||
"error-handler",
|
||||
"error-monitoring",
|
||||
"laravel",
|
||||
"log",
|
||||
"logging",
|
||||
"profiling",
|
||||
"sentry",
|
||||
"tracing"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/getsentry/sentry-laravel/issues",
|
||||
"source": "https://github.com/getsentry/sentry-laravel/tree/4.29.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://sentry.io/",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://sentry.io/pricing/",
|
||||
"type": "custom"
|
||||
}
|
||||
],
|
||||
"time": "2026-10-08T12:31:48+00:00"
|
||||
},
|
||||
{
|
||||
"name": "spatie/laravel-package-tools",
|
||||
"version": "1.93.0",
|
||||
|
|
@ -5601,6 +5928,77 @@
|
|||
],
|
||||
"time": "2026-02-05T15:57:06+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/options-resolver",
|
||||
"version": "v8.1.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/options-resolver.git",
|
||||
"reference": "88f9c561f678a02d54b897014049fa839e33ff82"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/options-resolver/zipball/88f9c561f678a02d54b897014049fa839e33ff82",
|
||||
"reference": "88f9c561f678a02d54b897014049fa839e33ff82",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.4.1",
|
||||
"symfony/deprecation-contracts": "^2.5|^3"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\OptionsResolver\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Fabien Potencier",
|
||||
"email": "fabien@symfony.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Provides an improved replacement for the array_replace PHP function",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"config",
|
||||
"configuration",
|
||||
"options"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/options-resolver/tree/v8.1.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-05-29T05:06:50+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/polyfill-ctype",
|
||||
"version": "v1.33.0",
|
||||
|
|
@ -6495,6 +6893,93 @@
|
|||
],
|
||||
"time": "2026-01-26T15:07:59+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/psr-http-message-bridge",
|
||||
"version": "v8.1.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/psr-http-message-bridge.git",
|
||||
"reference": "67fd34de15ded1763aa1e330fe345f080a94022c"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/psr-http-message-bridge/zipball/67fd34de15ded1763aa1e330fe345f080a94022c",
|
||||
"reference": "67fd34de15ded1763aa1e330fe345f080a94022c",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.4.1",
|
||||
"psr/http-message": "^1.0|^2.0",
|
||||
"symfony/http-foundation": "^7.4|^8.0"
|
||||
},
|
||||
"conflict": {
|
||||
"php-http/discovery": "<1.15"
|
||||
},
|
||||
"require-dev": {
|
||||
"nyholm/psr7": "^1.1",
|
||||
"php-http/discovery": "^1.15",
|
||||
"psr/log": "^1.1.4|^2|^3",
|
||||
"symfony/browser-kit": "^7.4|^8.0",
|
||||
"symfony/config": "^7.4|^8.0",
|
||||
"symfony/event-dispatcher": "^7.4|^8.0",
|
||||
"symfony/framework-bundle": "^7.4|^8.0",
|
||||
"symfony/http-kernel": "^7.4|^8.0",
|
||||
"symfony/runtime": "^7.4|^8.0"
|
||||
},
|
||||
"type": "symfony-bridge",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Bridge\\PsrHttpMessage\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Fabien Potencier",
|
||||
"email": "fabien@symfony.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "PSR HTTP message bridge",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"http",
|
||||
"http-message",
|
||||
"psr-17",
|
||||
"psr-7"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/psr-http-message-bridge/tree/v8.1.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-05-29T05:06:50+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/routing",
|
||||
"version": "v7.4.6",
|
||||
|
|
@ -7967,66 +8452,6 @@
|
|||
},
|
||||
"time": "2025-04-30T06:54:44+00:00"
|
||||
},
|
||||
{
|
||||
"name": "jean85/pretty-package-versions",
|
||||
"version": "2.1.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Jean85/pretty-package-versions.git",
|
||||
"reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Jean85/pretty-package-versions/zipball/4d7aa5dab42e2a76d99559706022885de0e18e1a",
|
||||
"reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"composer-runtime-api": "^2.1.0",
|
||||
"php": "^7.4|^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"friendsofphp/php-cs-fixer": "^3.2",
|
||||
"jean85/composer-provided-replaced-stub-package": "^1.0",
|
||||
"phpstan/phpstan": "^2.0",
|
||||
"phpunit/phpunit": "^7.5|^8.5|^9.6",
|
||||
"rector/rector": "^2.0",
|
||||
"vimeo/psalm": "^4.3 || ^5.0"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "1.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Jean85\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Alessandro Lai",
|
||||
"email": "alessandro.lai85@gmail.com"
|
||||
}
|
||||
],
|
||||
"description": "A library to get pretty versions strings of installed dependencies",
|
||||
"keywords": [
|
||||
"composer",
|
||||
"package",
|
||||
"release",
|
||||
"versions"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/Jean85/pretty-package-versions/issues",
|
||||
"source": "https://github.com/Jean85/pretty-package-versions/tree/2.1.1"
|
||||
},
|
||||
"time": "2025-03-19T14:43:43+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/boost",
|
||||
"version": "v1.8.11",
|
||||
|
|
|
|||
|
|
@ -15,6 +15,18 @@ return [
|
|||
|
||||
'name' => env('APP_NAME', 'Laravel'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Ausgelieferte Version
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Git-Tag des Deploys (Datei RELEASE, schreibt dev/betrieb/deploy.sh) oder
|
||||
| APP_VERSION. Erscheint in /health, `php artisan about` und GlitchTip.
|
||||
|
|
||||
*/
|
||||
|
||||
'version' => App\Support\Monitoring\Release::current(env('APP_VERSION')),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Environment
|
||||
|
|
|
|||
|
|
@ -52,6 +52,13 @@ return [
|
|||
|
||||
'channels' => [
|
||||
|
||||
'monitoring' => [
|
||||
'driver' => 'daily',
|
||||
'path' => storage_path('logs/monitoring.log'),
|
||||
'level' => 'debug',
|
||||
'days' => 14,
|
||||
],
|
||||
|
||||
'stack' => [
|
||||
'driver' => 'stack',
|
||||
'channels' => explode(',', env('LOG_STACK', 'single')),
|
||||
|
|
|
|||
75
config/monitoring.php
Normal file
75
config/monitoring.php
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Betriebs-Alerts
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Fehlgeschlagene Scheduler-Tasks und Queue-Jobs werden immer als
|
||||
| `critical` ins Log geschrieben (rotierter `daily`-Channel) und – wenn
|
||||
| aktiviert und eine Adresse gesetzt ist – zusätzlich per Best-Effort-Mail
|
||||
| gemeldet. Die Mail ist bewusst nur die zweite Ebene: schlägt der Versand
|
||||
| fehl, bleibt der Log-Eintrag als verlässliche Spur bestehen.
|
||||
|
|
||||
*/
|
||||
|
||||
'alerts' => [
|
||||
'enabled' => (bool) env('MONITORING_ALERTS_ENABLED', true),
|
||||
|
||||
// Ops-Postfach. Leer = keine Alert-Mail (Log und ntfy laufen trotzdem).
|
||||
'mail_to' => env('MONITORING_ALERT_EMAIL'),
|
||||
|
||||
// Zweiter Alarmweg: Push über ntfy, JSON-POST
|
||||
// an die Basis-URL. Ohne URL oder Topic aus.
|
||||
'ntfy' => [
|
||||
'url' => env('MONITORING_NTFY_URL'),
|
||||
'topic' => env('MONITORING_NTFY_TOPIC'),
|
||||
'token' => env('MONITORING_NTFY_TOKEN'),
|
||||
'priority' => (int) env('MONITORING_NTFY_PRIORITY', 4),
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Browserfehler
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Eigenes GlitchTip-Projekt für JavaScript-Fehler. Die DSN ist öffentlich
|
||||
| (steht im HTML), sie erlaubt nur das Einliefern von Ereignissen. Ohne
|
||||
| DSN wird kein Skript eingebunden.
|
||||
|
|
||||
*/
|
||||
|
||||
'browser' => [
|
||||
'dsn' => env('SENTRY_BROWSER_DSN'),
|
||||
'sample_rate' => (float) env('SENTRY_BROWSER_SAMPLE_RATE', 1.0),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Health-Endpunkt und Scheduler-Herzschlag
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| `/health` antwortet nur mit gültigem Token (Bearer-Header oder
|
||||
| `?token=`); ohne konfiguriertes Token gibt es den Endpunkt nicht (404).
|
||||
| Der Scheduler schreibt jede Minute einen Herzschlag; bleibt er aus, ist
|
||||
| der Cron-Eintrag weg oder der Server hängt.
|
||||
|
|
||||
*/
|
||||
|
||||
'health' => [
|
||||
'token' => env('MONITORING_HEALTH_TOKEN'),
|
||||
'queue_backlog_critical_seconds' => (int) env('MONITORING_QUEUE_BACKLOG_SECONDS', 300),
|
||||
'disk_min_free_percent' => (float) env('MONITORING_DISK_MIN_FREE_PERCENT', 10),
|
||||
],
|
||||
|
||||
'scheduler' => [
|
||||
// Leer = storage/framework/scheduler-heartbeat. Die Tests setzen je Prozess eine eigene Datei.
|
||||
'heartbeat_path' => env('MONITORING_HEARTBEAT_PATH'),
|
||||
'heartbeat_warn_minutes' => 3,
|
||||
'heartbeat_critical_minutes' => 10,
|
||||
],
|
||||
|
||||
];
|
||||
180
config/sentry.php
Normal file
180
config/sentry.php
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
<?php
|
||||
|
||||
use App\Support\Monitoring\ErrorReportScrubber;
|
||||
use App\Support\Monitoring\Release;
|
||||
|
||||
/**
|
||||
* Sentry Laravel SDK configuration file.
|
||||
*
|
||||
* Ziel ist das selbst gehostete GlitchTip, das das
|
||||
* Sentry-Protokoll spricht. Ohne DSN ist das SDK ein No-op. Abweichend vom
|
||||
* Paket-Standard: kein Personenbezug außer der User-ID, keine
|
||||
* Request-Bodys, keine Log-/Cache-Breadcrumbs (Lognachrichten tragen bei uns
|
||||
* E-Mail-Adressen), keine Metriken (versteht GlitchTip nicht).
|
||||
*
|
||||
* @see https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/
|
||||
*/
|
||||
return [
|
||||
|
||||
// @see https://docs.sentry.io/concepts/key-terms/dsn-explainer/
|
||||
'dsn' => env('SENTRY_LARAVEL_DSN', env('SENTRY_DSN')),
|
||||
|
||||
// @see https://spotlightjs.com/
|
||||
// 'spotlight' => env('SENTRY_SPOTLIGHT', false),
|
||||
|
||||
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#logger
|
||||
// 'logger' => Sentry\Logger\DebugFileLogger::class, // By default this will log to `storage_path('logs/sentry.log')`
|
||||
|
||||
// The release version of your application
|
||||
// Example with dynamic git hash: trim(exec('git --git-dir ' . base_path('.git') . ' log --pretty="%h" -n1 HEAD'))
|
||||
// Gleiche Kennung wie config('app.version'): SENTRY_RELEASE bzw.
|
||||
// APP_VERSION, sonst die beim Deploy geschriebene Datei RELEASE.
|
||||
'release' => Release::current(env('SENTRY_RELEASE', env('APP_VERSION'))),
|
||||
|
||||
// When left empty or `null` the Laravel environment will be used (usually discovered from `APP_ENV` in your `.env`)
|
||||
'environment' => env('SENTRY_ENVIRONMENT'),
|
||||
|
||||
// Override the organization ID used for trace continuation checks.
|
||||
'org_id' => env('SENTRY_ORG_ID') === null ? null : (int) env('SENTRY_ORG_ID'),
|
||||
|
||||
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#sample_rate
|
||||
'sample_rate' => env('SENTRY_SAMPLE_RATE') === null ? 1.0 : (float) env('SENTRY_SAMPLE_RATE'),
|
||||
|
||||
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#traces_sample_rate
|
||||
'traces_sample_rate' => env('SENTRY_TRACES_SAMPLE_RATE') === null ? null : (float) env('SENTRY_TRACES_SAMPLE_RATE'),
|
||||
|
||||
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#profiles_sample_rate
|
||||
'profiles_sample_rate' => env('SENTRY_PROFILES_SAMPLE_RATE') === null ? null : (float) env('SENTRY_PROFILES_SAMPLE_RATE'),
|
||||
|
||||
// Only continue incoming traces when the organization IDs are compatible with this SDK instance.
|
||||
'strict_trace_continuation' => env('SENTRY_STRICT_TRACE_CONTINUATION', false),
|
||||
|
||||
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#enable_logs
|
||||
'enable_logs' => env('SENTRY_ENABLE_LOGS', false),
|
||||
|
||||
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#enable_metrics
|
||||
'enable_metrics' => env('SENTRY_ENABLE_METRICS', false),
|
||||
|
||||
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#log_flush_threshold
|
||||
'log_flush_threshold' => env('SENTRY_LOG_FLUSH_THRESHOLD') === null ? null : (int) env('SENTRY_LOG_FLUSH_THRESHOLD'),
|
||||
|
||||
// The minimum log level that will be sent to Sentry as logs using the `sentry_logs` logging channel
|
||||
'logs_channel_level' => env('SENTRY_LOG_LEVEL', env('SENTRY_LOGS_LEVEL', env('LOG_LEVEL', 'debug'))),
|
||||
|
||||
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#send_default_pii
|
||||
'send_default_pii' => false,
|
||||
|
||||
// Formular- und JSON-Inhalte verlassen den Server nie.
|
||||
'max_request_body_size' => 'never',
|
||||
|
||||
// User-ID statt Person, Tokens aus URL/Query/Referer, E-Mails aus Meldungen.
|
||||
'before_send' => [ErrorReportScrubber::class, 'beforeSend'],
|
||||
|
||||
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#ignore_exceptions
|
||||
// 'ignore_exceptions' => [],
|
||||
|
||||
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#ignore_transactions
|
||||
'ignore_transactions' => [
|
||||
// Ignore Laravel's default health URL
|
||||
'/up',
|
||||
'/health',
|
||||
],
|
||||
|
||||
// Breadcrumb specific configuration
|
||||
'breadcrumbs' => [
|
||||
// Capture Laravel logs as breadcrumbs
|
||||
'logs' => env('SENTRY_BREADCRUMBS_LOGS_ENABLED', false),
|
||||
|
||||
// Capture Laravel cache events (hits, writes etc.) as breadcrumbs
|
||||
'cache' => env('SENTRY_BREADCRUMBS_CACHE_ENABLED', false),
|
||||
|
||||
// Capture Livewire components like routes as breadcrumbs
|
||||
'livewire' => env('SENTRY_BREADCRUMBS_LIVEWIRE_ENABLED', true),
|
||||
|
||||
// Capture SQL queries as breadcrumbs
|
||||
'sql_queries' => env('SENTRY_BREADCRUMBS_SQL_QUERIES_ENABLED', true),
|
||||
|
||||
// Capture SQL query bindings (parameters) in SQL query breadcrumbs
|
||||
'sql_bindings' => env('SENTRY_BREADCRUMBS_SQL_BINDINGS_ENABLED', false),
|
||||
|
||||
// Capture queue job information as breadcrumbs
|
||||
'queue_info' => env('SENTRY_BREADCRUMBS_QUEUE_INFO_ENABLED', true),
|
||||
|
||||
// Capture command information as breadcrumbs
|
||||
'command_info' => env('SENTRY_BREADCRUMBS_COMMAND_JOBS_ENABLED', true),
|
||||
|
||||
// Capture HTTP client request information as breadcrumbs
|
||||
'http_client_requests' => env('SENTRY_BREADCRUMBS_HTTP_CLIENT_REQUESTS_ENABLED', true),
|
||||
|
||||
// Capture send notifications as breadcrumbs
|
||||
'notifications' => env('SENTRY_BREADCRUMBS_NOTIFICATIONS_ENABLED', true),
|
||||
],
|
||||
|
||||
// Performance monitoring specific configuration
|
||||
'tracing' => [
|
||||
// Trace queue jobs as their own transactions (this enables tracing for queue jobs)
|
||||
'queue_job_transactions' => env('SENTRY_TRACE_QUEUE_ENABLED', true),
|
||||
|
||||
// Capture queue jobs as spans when executed on the sync driver
|
||||
'queue_jobs' => env('SENTRY_TRACE_QUEUE_JOBS_ENABLED', true),
|
||||
|
||||
// Capture SQL queries as spans
|
||||
'sql_queries' => env('SENTRY_TRACE_SQL_QUERIES_ENABLED', true),
|
||||
|
||||
// Capture SQL query bindings (parameters) in SQL query spans
|
||||
'sql_bindings' => env('SENTRY_TRACE_SQL_BINDINGS_ENABLED', false),
|
||||
|
||||
// Capture where the SQL query originated from on the SQL query spans
|
||||
'sql_origin' => env('SENTRY_TRACE_SQL_ORIGIN_ENABLED', true),
|
||||
|
||||
// Define a threshold in milliseconds for SQL queries to resolve their origin
|
||||
'sql_origin_threshold_ms' => env('SENTRY_TRACE_SQL_ORIGIN_THRESHOLD_MS', 100),
|
||||
|
||||
// Capture views rendered as spans
|
||||
'views' => env('SENTRY_TRACE_VIEWS_ENABLED', true),
|
||||
|
||||
// Capture Livewire components as spans
|
||||
'livewire' => env('SENTRY_TRACE_LIVEWIRE_ENABLED', true),
|
||||
|
||||
// Capture HTTP client requests as spans
|
||||
'http_client_requests' => env('SENTRY_TRACE_HTTP_CLIENT_REQUESTS_ENABLED', true),
|
||||
|
||||
// Capture Laravel cache events (hits, writes etc.) as spans
|
||||
'cache' => env('SENTRY_TRACE_CACHE_ENABLED', true),
|
||||
|
||||
// Capture Redis operations as spans (this enables Redis events in Laravel)
|
||||
'redis_commands' => env('SENTRY_TRACE_REDIS_COMMANDS', false),
|
||||
|
||||
// Capture where the Redis command originated from on the Redis command spans
|
||||
'redis_origin' => env('SENTRY_TRACE_REDIS_ORIGIN_ENABLED', true),
|
||||
|
||||
// Capture send notifications as spans
|
||||
'notifications' => env('SENTRY_TRACE_NOTIFICATIONS_ENABLED', true),
|
||||
|
||||
// Enable tracing for requests without a matching route (404's)
|
||||
'missing_routes' => env('SENTRY_TRACE_MISSING_ROUTES_ENABLED', false),
|
||||
|
||||
// Configures if the performance trace should continue after the response has been sent to the user until the application terminates
|
||||
// This is required to capture any spans that are created after the response has been sent like queue jobs dispatched using `dispatch(...)->afterResponse()` for example
|
||||
'continue_after_response' => env('SENTRY_TRACE_CONTINUE_AFTER_RESPONSE', true),
|
||||
|
||||
// Capture AI agent interactions as spans (requires laravel/ai)
|
||||
'gen_ai' => env('SENTRY_TRACE_GEN_AI_ENABLED', true),
|
||||
|
||||
// Capture AI invoke_agent spans
|
||||
'gen_ai_invoke_agent' => env('SENTRY_TRACE_GEN_AI_INVOKE_AGENT_ENABLED', true),
|
||||
|
||||
// Capture AI chat spans
|
||||
'gen_ai_chat' => env('SENTRY_TRACE_GEN_AI_CHAT_ENABLED', true),
|
||||
|
||||
// Capture AI execute_tool spans
|
||||
'gen_ai_execute_tool' => env('SENTRY_TRACE_GEN_AI_EXECUTE_TOOL_ENABLED', true),
|
||||
|
||||
// Capture AI embeddings spans
|
||||
'gen_ai_embeddings' => env('SENTRY_TRACE_GEN_AI_EMBEDDINGS_ENABLED', true),
|
||||
|
||||
// Enable the tracing integrations supplied by Sentry (recommended)
|
||||
'default_integrations' => env('SENTRY_TRACE_DEFAULT_INTEGRATIONS_ENABLED', true),
|
||||
],
|
||||
|
||||
];
|
||||
|
|
@ -17,6 +17,10 @@ use Illuminate\Database\Seeder;
|
|||
* Gesetzt wird das Logo passend zum Theme des Moduls (dunkel = negativ/weiss, hell = positiv)
|
||||
* sowie der neue Claim. Alle anderen Einstellungen bleiben unangetastet.
|
||||
*
|
||||
* Module, die schon eines der beiden Interior-Logos tragen, bleiben ganz unberührt (08.10.2026):
|
||||
* Sie sind entweder neu angelegt (Standardwerte) oder bereits umgestellt, und ein inzwischen im
|
||||
* Display-CMS gepflegter Claim soll beim Livegang nicht zurückgesetzt werden.
|
||||
*
|
||||
* php artisan db:seed --class=DisplayInteriorBrandingSeeder
|
||||
*/
|
||||
class DisplayInteriorBrandingSeeder extends Seeder
|
||||
|
|
@ -34,6 +38,10 @@ class DisplayInteriorBrandingSeeder extends Seeder
|
|||
foreach (DisplayVersion::query()->where('type', DisplayVersionType::B2in)->get() as $module) {
|
||||
$settings = $module->settings ?? [];
|
||||
|
||||
if (in_array($settings['header_logo_url'] ?? null, [self::LOGO_DARK, self::LOGO_LIGHT], true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$settings['header_logo_url'] = ($settings['theme'] ?? 'dark') === 'light'
|
||||
? self::LOGO_LIGHT
|
||||
: self::LOGO_DARK;
|
||||
|
|
|
|||
69
database/seeders/InteriorReleaseSeeder.php
Normal file
69
database/seeders/InteriorReleaseSeeder.php
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
/**
|
||||
* Alle Inhaltsschritte des Interior-Relaunchs in einem Lauf (08.10.2026).
|
||||
*
|
||||
* Bringt eine Datenbank mit dem alten Website-Stand auf den Stand der Vorschau: zuerst auf der
|
||||
* Vorschau mit frischer Kopie der Live-Datenbank (Generalprobe), nach der Freigabe identisch auf
|
||||
* Live. Displays, Playlists, Display-Mediathek und hochgeladene Medien bleiben, wie sie sind –
|
||||
* angefasst werden nur das Display-Branding (nur Module ohne Interior-Logo) und absolute
|
||||
* Medien-URLs (werden host-unabhängig).
|
||||
*
|
||||
* Reihenfolge wie Abschnitt 8.2 und „Seeder für das nächste Deployment“ im Entwicklungsplan.
|
||||
* Jeder Schritt ist idempotent, ein zweiter Lauf ändert nichts mehr. Ausgeführt von
|
||||
* `dev/betrieb/deploy.sh vX.Y.Z --migrations --seed=InteriorReleaseSeeder` im Wartungsmodus.
|
||||
*
|
||||
* php artisan db:seed --class=InteriorReleaseSeeder --force
|
||||
*/
|
||||
class InteriorReleaseSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* @var list<class-string<Seeder>>
|
||||
*/
|
||||
public const STEPS = [
|
||||
// Fehlende CMS-Einträge aus den Lang-Dateien anlegen – vorhandene bleiben unberührt.
|
||||
CmsContentSeeder::class,
|
||||
CmsBrandSeeder::class,
|
||||
// Die in AP1 neu getexteten Sektionen überschreiben (alter Website-Stand).
|
||||
InteriorRelaunchCmsSeeder::class,
|
||||
CmsArticleSeeder::class,
|
||||
// Displays: nur Branding und Medien-URLs.
|
||||
DisplayInteriorBrandingSeeder::class,
|
||||
DisplayMediaUrlNormalizerSeeder::class,
|
||||
// Marken
|
||||
BrandPageTextSeeder::class,
|
||||
BrandGalleryCaptionNormalizerSeeder::class,
|
||||
WorldSplitCmsSeeder::class,
|
||||
OriginSectionCmsSeeder::class,
|
||||
BrandOriginSeeder::class,
|
||||
OtterpohlBrandSeeder::class,
|
||||
BrandImageSeeder::class,
|
||||
DemoBrandGalleryCleanupSeeder::class,
|
||||
// Texte und Korrekturen
|
||||
WhatsappNumberSeeder::class,
|
||||
AudienceSwitchProjectTextSeeder::class,
|
||||
HomeHeroTextSeeder::class,
|
||||
UnusedCmsContentCleanupSeeder::class,
|
||||
AboutPageTextSeeder::class,
|
||||
ProofreadingCorrectionsSeeder::class,
|
||||
LegalTextUpdateSeeder::class,
|
||||
ReferencesPageSwitchSeeder::class,
|
||||
HomeHeroSlidesSeeder::class,
|
||||
PageHeroEyebrowSeeder::class,
|
||||
HeroTrustItemsSeeder::class,
|
||||
HomeVisionTitleSeeder::class,
|
||||
ContentCorrections20261008Seeder::class,
|
||||
// Zuletzt: fehlende Felder ergänzen, verwendete Bilder in die Mediathek.
|
||||
CmsSectionFieldsSeeder::class,
|
||||
AssetMediaImportSeeder::class,
|
||||
];
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$this->call(self::STEPS);
|
||||
}
|
||||
}
|
||||
|
|
@ -2492,3 +2492,28 @@ korrigiert – u. a. Hinweisleiste (über B2IN_ANNOUNCEMENT_BAR aus, Feld „ena
|
|||
**Nachbesserung 08.10.:** Hero mobil – die Infokarte steht unter dem Bild (Bild 4:3 oben, Karte als weißer
|
||||
Streifen darunter), ab lg schwebt sie wie bisher unten links. Karte weiß statt `bg-card`, damit die
|
||||
Glass-Flow-Regel `[class*="card"]` nicht greift; `absolute!` entfällt.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Nachtrag 32 · Git-Deploy, Monitoring und Ablauf für den Livegang (08.10.2026)
|
||||
|
||||
- **Git-Deploy per Tag:** `dev/betrieb/deploy.sh` (aus `dev/git-deploy-monitoring/`) liefert nur Tags aus,
|
||||
`origin` = forge.adametz.media, `NAS` = Sicherung. `public/build` ist jetzt versioniert. Neu:
|
||||
`--seed=<Klasse>` (Seeder im Wartungsmodus) und `--initial` (Vorschau und Live einmalig an Ort und
|
||||
Stelle auf Git umstellen, mit Code-Sicherung und Parken der Nur-Server-Dateien).
|
||||
`.gitignore`: Das macOS-Muster `Icon` traf `resources/views/flux/icon/`, die vier Icons wären mit
|
||||
Git-Deploy auf Live verloren gegangen; das Muster heißt jetzt `Icon[\r]`.
|
||||
- **Monitoring:** GlitchTip (Server und Browser, Website und Portal), `/health` mit Token (jede Domain,
|
||||
ohne Basic Auth), Scheduler-Herzschlag, Alarme bei fehlgeschlagenen Tasks per Mail und ntfy,
|
||||
Version in `php artisan about` und `/health`. Pakete: `sentry/sentry-laravel`, `@sentry/browser`.
|
||||
- **Livegang ohne Datenmigration:** Die Live-Datenbank bleibt die Grundlage (Displays, Playlists,
|
||||
Mediathek, Benutzer), `InteriorReleaseSeeder` bringt die Website-Inhalte in einem Lauf auf den neuen
|
||||
Stand. Er fasst alle Seeder aus 8.2 und „Seeder für das nächste Deployment“ zusammen, ist idempotent
|
||||
(Test) und lässt Display-Daten unberührt. Ausnahme: Branding, nur bei Modulen ohne Interior-Logo;
|
||||
`DisplayInteriorBrandingSeeder` überschreibt seit heute keinen inzwischen gepflegten Claim mehr.
|
||||
Generalprobe: `dev/betrieb/vorschau-auffrischen.sh` kopiert Live-DB und Live-Medien in die Vorschau.
|
||||
- **Ablauf:** `dev/betrieb/README.md`. Er ersetzt für den Livegang die Einzelbefehle aus 8.2 und die
|
||||
Seeder-Liste oben. Die Prüfliste 8.3 gilt weiter.
|
||||
- **Offen:** Datenschutzerklärung um die Fehlerprotokollierung ergänzen, bevor die DSNs auf Live gesetzt
|
||||
werden (Textvorschlag in `dev/betrieb/README.md`, Abschnitt 6).
|
||||
|
|
|
|||
235
dev/betrieb/README.md
Normal file
235
dev/betrieb/README.md
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
# Betrieb B2in: Git-Deploy, Livegang, Monitoring
|
||||
|
||||
**Stand:** 08.10.2026. Grundlage ist der Baukasten unter `dev/git-deploy-monitoring/`. Dort steht,
|
||||
warum etwas so gebaut ist. Hier steht, wie es für B2in läuft.
|
||||
|
||||
| Was | Wo |
|
||||
|---|---|
|
||||
| Deploy-Skript | `dev/betrieb/deploy.sh`, liefert nur Git-Tags `vX.Y.Z` aus |
|
||||
| Vorschau auffrischen | `dev/betrieb/vorschau-auffrischen.sh` kopiert Live-DB und Live-Medien in die Vorschau |
|
||||
| Inhaltsschritte Relaunch | `InteriorReleaseSeeder`, alle 29 Seeder in einem Lauf, idempotent |
|
||||
| Remote `origin` | lokal `ssh://git@forge.adametz.media:222/kadmin/b2in.git`, auf dem Server `forge-b2in:kadmin/b2in.git` (Host-Alias, Abschnitt 2): Deploy-Quelle für Vorschau und Live |
|
||||
| Remote `NAS` | `ssh://git@git.adametz.media:222/gitmedia/b2in.git`: lokale Sicherung, wird nie deployt |
|
||||
| Fehlerprotokoll | GlitchTip `https://errors.adametz.media`, Organisation `adametz-media`, Projekte `server-b2in` (ID 8) und `browser-b2in` (ID 7) |
|
||||
| Push-Alarme | ntfy `https://push.adametz.media`, Topic `b2in-ops` |
|
||||
| Externer Monitor | Uptime Kuma auf `/health` (mit Token) und `/up` je Domain |
|
||||
|
||||
---
|
||||
|
||||
## 1. Release lokal (jedes Mal)
|
||||
|
||||
```bash
|
||||
git status # nichts offen
|
||||
php artisan test --compact
|
||||
npm ci && npm run build:web && npm run build:portal # public/build ist versioniert, der Server hat kein Node
|
||||
git add public/build && git diff --cached --quiet public/build || git commit -m "build: Assets für vX.Y.Z"
|
||||
|
||||
git tag -a vX.Y.Z -m "vX.Y.Z – <Inhalt>
|
||||
|
||||
Deploy: <keine Migration | N Migrationen>, <Seeder>, <neue .env-Werte>
|
||||
bash dev/betrieb/deploy.sh vX.Y.Z [--migrations] [--seed=…]"
|
||||
git push origin <branch> --follow-tags
|
||||
git push NAS <branch> --follow-tags # Sicherung
|
||||
```
|
||||
|
||||
Versionen folgen SemVer: Ein Feature-Release erhöht die mittlere Stelle, ein Hotfix die letzte.
|
||||
Der Interior-Relaunch ist `v2.0.0`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Einmalig auf dem Server
|
||||
|
||||
Als App-Benutzer, gilt für Vorschau und Live (gleicher Server):
|
||||
|
||||
1. **Deploy-Key**, nur Leserecht am Repository auf forge.adametz.media. Auf dem Server liegen mehrere
|
||||
Deploy-Keys für denselben Forgejo (B2in, Breco), deshalb gibt es je Projekt einen Host-Alias.
|
||||
Die Repository-Adresse heißt dann **`forge-b2in:kadmin/b2in.git`**, nicht `ssh://git@forge.adametz.media:222/…`;
|
||||
mit der vollen Adresse greift kein Alias, und ssh bietet keinen Schlüssel an („Permission denied (publickey)“).
|
||||
```bash
|
||||
ssh-keygen -t ed25519 -f ~/.ssh/deploy_b2in -N "" -C "b2in-server"
|
||||
cat ~/.ssh/deploy_b2in.pub # in Forgejo: kadmin/b2in ▸ Einstellungen ▸ Deploy-Schlüssel, ohne Schreibrecht
|
||||
```
|
||||
`~/.ssh/config`:
|
||||
```
|
||||
Host forge-b2in
|
||||
HostName forge.adametz.media
|
||||
Port 222
|
||||
User git
|
||||
IdentityFile ~/.ssh/deploy_b2in
|
||||
IdentitiesOnly yes
|
||||
HostKeyAlias forge.adametz.media
|
||||
```
|
||||
Test: `git ls-remote forge-b2in:kadmin/b2in.git` zeigt die Tags.
|
||||
2. **Flux Pro für Composer:** `deploy.sh` führt `composer install` aus. Dafür braucht der Server die
|
||||
Flux-Zugangsdaten, also `auth.json` im App-Verzeichnis (ignoriert, bleibt liegen) oder
|
||||
`composer config --global http-basic.composer.fluxui.dev <e-mail> <lizenz>`.
|
||||
3. **PHP-FPM-Reload ohne Passwort testen:** `echo "" | sudo -S service php8.4-fpm reload; echo $?`.
|
||||
Ergibt das Exit 0, lädt das Skript FPM selbst neu. Fragt sudo nach einem Passwort, beim Deploy
|
||||
`DEPLOY_FPM_RELOAD=` setzen; dann wartet das Skript, bis FPM im Panel neu geladen ist. Heißt
|
||||
der Dienst anders: `DEPLOY_PHP_FPM_SERVICE=…`. **Immer Reload, nie Restart.**
|
||||
4. **Cron für den Scheduler:** `* * * * * cd <app> && php artisan schedule:run >> /dev/null 2>&1`.
|
||||
Er trägt den Herzschlag für `/health` und `cabinet:reset-overrides`. Eine Queue braucht B2in
|
||||
nicht; der Worker-Test im Skript ist aus.
|
||||
5. **Server-Panel:** „Quick Deploy“ und Push-Webhooks bleiben aus. Ein Push ist noch kein Release.
|
||||
|
||||
### `.env`-Werte je Umgebung
|
||||
|
||||
| Schlüssel | Live | Vorschau |
|
||||
|---|---|---|
|
||||
| `SENTRY_LARAVEL_DSN`, `SENTRY_BROWSER_DSN` | wie lokal (Projekte server-b2in / browser-b2in), **erst nach der Datenschutzerklärung (Abschnitt 6)** | gleich, optional |
|
||||
| `SENTRY_ENVIRONMENT` | `production` | `preview` |
|
||||
| `MONITORING_NTFY_URL`, `_TOPIC`, `_TOKEN` | wie lokal | leer (keine Push-Alarme aus der Vorschau) |
|
||||
| `MONITORING_ALERT_EMAIL` | Ops-Postfach | leer |
|
||||
| `MONITORING_HEALTH_TOKEN` | **eigener Wert** (`openssl rand -hex 24`), nicht der lokale | eigener Wert oder leer |
|
||||
| `MAIL_MAILER` | wie bisher | `log` (die Vorschau hat Live-Daten) |
|
||||
|
||||
`APP_VERSION` bleibt leer. Die Version kommt aus der Datei `RELEASE`, die das Skript schreibt.
|
||||
|
||||
---
|
||||
|
||||
## 3. Livegang Interior-Relaunch (v2.0.0)
|
||||
|
||||
**Grundidee:** Auf Live hat der Kunde Displays, Playlists und die Mediathek gepflegt, an der Website
|
||||
im CMS aber nichts geändert. Deshalb kopieren wir **nicht** die Vorschau-Datenbank nach Live und
|
||||
übertragen Display-Daten einzeln. Stattdessen bleibt die Live-Datenbank die Grundlage, und
|
||||
`InteriorReleaseSeeder` bringt deren Website-Inhalte auf den neuen Stand. Displays,
|
||||
Display-Mediathek, CMS-Mediathek, Benutzer und hochgeladene Dateien bleiben unberührt. Angepasst
|
||||
werden nur:
|
||||
|
||||
- das Display-Branding, und zwar nur bei Modulen, die noch kein Interior-Logo tragen
|
||||
- absolute Medien-URLs, die auf `b2in.test` oder `b2in.eu` zeigen; sie werden host-unabhängig
|
||||
|
||||
Damit der Kunde genau dieses Ergebnis vorher sieht, läuft derselbe Ablauf zuerst auf der Vorschau
|
||||
mit einer frischen Kopie der Live-Daten (Generalprobe).
|
||||
|
||||
### 3.1 Generalprobe auf der Vorschau
|
||||
|
||||
```bash
|
||||
# Skripte aus dem Tag holen (Vorschau und Live sind noch keine Git-Checkouts)
|
||||
git clone -q --depth 1 --branch v2.0.0 forge-b2in:kadmin/b2in.git /tmp/b2in-v2.0.0
|
||||
|
||||
# 1. Live-Daten und Live-Medien in die Vorschau (Live wird nur gelesen; legt ~/b2in-live-<zeit>.sql.gz an)
|
||||
bash /tmp/b2in-v2.0.0/dev/betrieb/vorschau-auffrischen.sh <live> <vorschau>
|
||||
|
||||
# 2. Vorschau einmalig auf Git umstellen und v2.0.0 ausliefern, mit allen Inhaltsschritten
|
||||
cd <vorschau>
|
||||
bash /tmp/b2in-v2.0.0/dev/betrieb/deploy.sh v2.0.0 --initial --migrations --seed=InteriorReleaseSeeder
|
||||
```
|
||||
|
||||
`--initial` sichert vorher den Code nach `~/<verzeichnis>-vor-git-<zeit>.tgz`. Dann zeigt es die
|
||||
Dateien, die es nur auf dem Server gibt (Reste aus SFTP- und Archiv-Updates). Erst nach einem
|
||||
`ja` verschiebt es sie nach `~/<verzeichnis>-vor-git-<zeit>/`. Danach ist `git status` leer.
|
||||
|
||||
Vorschau prüfen: die Liste unter 8.3 im Entwicklungsplan, dazu im Portal der Vorschau die
|
||||
**Displays und Playlists mit Live vergleichen**. Dort müssen dieselben Module, Bilder und Videos
|
||||
erscheinen. Neue Generalprobe, etwa nach einem Hotfix-Tag oder weil auf Live weiter gepflegt
|
||||
wurde:
|
||||
|
||||
```bash
|
||||
cd <vorschau> && bash dev/betrieb/deploy.sh v2.0.1 --migrations # nur bei neuem Tag
|
||||
bash dev/betrieb/vorschau-auffrischen.sh <live> <vorschau> --seed=InteriorReleaseSeeder
|
||||
```
|
||||
|
||||
### 3.2 Livegang nach der Freigabe
|
||||
|
||||
1. Wartungsfenster mit Marcel abstimmen. Die Displays holen ihre Konfiguration zyklisch, laufen
|
||||
also weiter, ein Wechsel im Laden ist aber sichtbar.
|
||||
2. **DB-Backup von Live:**
|
||||
`mysqldump --single-transaction --no-tablespaces -u … -p b2in_live | gzip > ~/b2in-live-vor-v2.sql.gz`
|
||||
3. Live-`.env` ergänzen (Tabelle oben). `SENTRY_*` erst nach Abschnitt 6.
|
||||
4. Gleicher Befehl wie auf der Vorschau, nur im Live-Verzeichnis:
|
||||
```bash
|
||||
cd <live>
|
||||
bash /tmp/b2in-v2.0.0/dev/betrieb/deploy.sh v2.0.0 --initial --migrations --seed=InteriorReleaseSeeder
|
||||
rm -rf /tmp/b2in-v2.0.0
|
||||
```
|
||||
5. Prüfen: `php artisan about --only=b2in` zeigt `v2.0.0`. `/health` mit Token liefert
|
||||
`"status":"ok"` (der Herzschlag braucht nach dem Deploy bis zu einer Minute). Danach die
|
||||
Liste 8.3 im Entwicklungsplan, die vier Displays sichten, `storage/logs` und GlitchTip auf neue
|
||||
Fehler prüfen.
|
||||
|
||||
**Rückweg** (nur Live, falls etwas grundlegend nicht passt): Seite in den Wartungsmodus nehmen,
|
||||
`.git` entfernen, die Code-Sicherung `~/<live>-vor-git-<zeit>.tgz` zurückspielen, das DB-Backup
|
||||
aus Schritt 2 einspielen und FPM neu laden. Die geparkten Nur-Server-Dateien aus
|
||||
`~/<live>-vor-git-<zeit>/` liegen ebenfalls noch vor.
|
||||
|
||||
---
|
||||
|
||||
## 4. Folge-Deploys (ab v2.0.0)
|
||||
|
||||
```bash
|
||||
cd <app>
|
||||
bash dev/betrieb/deploy.sh vX.Y.Z # ohne Migrationen
|
||||
bash dev/betrieb/deploy.sh vX.Y.Z --migrations # mit Migrationen, vorher DB-Backup
|
||||
bash dev/betrieb/deploy.sh vX.Y.Z --seed=<Seeder> # Inhaltsschritt laut Release-Notiz
|
||||
bash dev/betrieb/deploy.sh <vorheriger Tag> # Rollback
|
||||
```
|
||||
|
||||
Das Skript bricht ab, **bevor** es etwas ändert, wenn:
|
||||
|
||||
- im Verzeichnis jemand von Hand etwas geändert hat
|
||||
- der Tag fehlt
|
||||
- ein Build-Manifest fehlt
|
||||
- neue Migrationen ohne `--migrations` kommen
|
||||
|
||||
Auf dem Server wird nichts von Hand geändert, auch Hotfixes laufen über Commit, Tag und Deploy.
|
||||
Exit-Codes: `0` fertig · `1` abgebrochen · `2` falscher Aufruf.
|
||||
|
||||
---
|
||||
|
||||
## 5. Monitoring
|
||||
|
||||
Eingebaut nach `dev/git-deploy-monitoring/` (02 und 03), Tests unter `tests/Feature/Monitoring/`.
|
||||
|
||||
| Ausfall | Wer meldet | Kanal |
|
||||
|---|---|---|
|
||||
| Exception (Request, Command, Livewire) | Sentry-SDK | GlitchTip `server-b2in` |
|
||||
| JavaScript-Fehler (Website und Portal) | `resources/js/error-reporting.js` | GlitchTip `browser-b2in` (Tag `area`: `web`/`portal`) |
|
||||
| Toter interner Link, 419, 403/429 für Angemeldete | `ReportErrorSignals` | GlitchTip (Warnung) |
|
||||
| Geplanter Task fehlgeschlagen | `onFailure` in `routes/console.php` | Log `monitoring`, Mail, ntfy |
|
||||
| Cron steht, DB/Cache weg, Platte voll | `/health` | Uptime Kuma, dann ntfy und Mail |
|
||||
| Server, FPM, Nginx oder SSL weg | `/up` nicht erreichbar | Uptime Kuma |
|
||||
|
||||
**Uptime Kuma** (status.adametz.media):
|
||||
|
||||
| Monitor | Ziel | Intervall |
|
||||
|---|---|---|
|
||||
| B2in /health | `https://b2in.eu/health`, Header `{"Authorization": "Bearer <MONITORING_HEALTH_TOKEN von Live>"}`, Status 200–299 | 60 s, 2 Wiederholungen |
|
||||
| B2in /up | `https://b2in.eu/up` und `https://portal.b2in.eu/up` | 5 min |
|
||||
| Zertifikate | Ablaufwarnung 14 Tage vorher | – |
|
||||
|
||||
`/health` antwortet auf jeder Domain, ist von der Basic Auth ausgenommen und liefert ohne oder
|
||||
mit falschem Token 404.
|
||||
|
||||
**Abnahme auf Live:**
|
||||
|
||||
- `php artisan tinker --execute 'App\Support\OperationalAlert::send("Test B2in");'`: Push und Mail kommen an
|
||||
- `php artisan tinker --execute 'report(new RuntimeException("Test B2in"));'`: erscheint in `server-b2in`, Umgebung `production`, Release `v2.0.0`
|
||||
- Browser-Konsole auf b2in.eu: `setTimeout(() => { throw new Error('Test test@example.com') })` erscheint in `browser-b2in`, die E-Mail als `[email]`
|
||||
- Uptime Kuma: Ein Test-Monitor mit falschem Token löst einen Alarm aus, danach löschen
|
||||
|
||||
**GlitchTip auswerten** (lokal, Lese-Token `GLITCHTIP_API_TOKEN` in der `.env`): Die Befehle stehen
|
||||
in `dev/git-deploy-monitoring/02-fehlerprotokoll-glitchtip.md`, Abschnitt 7, mit
|
||||
`B=https://errors.adametz.media/api/0`, `ORG=adametz-media`, Projekt `8` (Server) bzw. `7` (Browser).
|
||||
|
||||
Lokal sind die DSNs gesetzt (`SENTRY_ENVIRONMENT=local`). Fehler beim Entwickeln landen also in
|
||||
GlitchTip, aber getrennt nach Umgebung. Die Testsuite meldet nie etwas; `phpunit.xml` leert DSNs
|
||||
und ntfy.
|
||||
|
||||
---
|
||||
|
||||
## 6. Datenschutzerklärung (vor dem Einschalten der DSNs auf Live)
|
||||
|
||||
Die Fehlerprotokollierung muss in die Datenschutzerklärung, bevor auf Live `SENTRY_*` gesetzt wird.
|
||||
Hier ein Textvorschlag, den Marcel bzw. die Rechtsberatung freigeben muss:
|
||||
|
||||
> **Fehlerprotokollierung.** Tritt beim Aufruf unserer Website ein technischer Fehler auf,
|
||||
> speichern wir dazu die aufgerufene Adresse (ohne Formularinhalte und ohne Zugangs-Token), Browser
|
||||
> und Betriebssystem, die Fehlermeldung und, bei angemeldeten Nutzern, deren interne Nutzer-ID.
|
||||
> IP-Adressen und Cookies werden nicht übermittelt. Die Daten verarbeiten wir auf einem eigenen
|
||||
> Server in Deutschland, um Fehler zu erkennen und zu beheben. Rechtsgrundlage ist unser
|
||||
> berechtigtes Interesse am sicheren und fehlerfreien Betrieb (Art. 6 Abs. 1 lit. f DSGVO). Die
|
||||
> Einträge werden nach 90 Tagen gelöscht.
|
||||
|
||||
Vor der Freigabe prüfen: Serverstandort und Aufbewahrungsfrist (`GLITCHTIP_RETENTION_DAYS`).
|
||||
Eingetragen wird der Text in `resources/lang/de/b2in_legal.php` und im CMS unter „Rechtliches“.
|
||||
294
dev/betrieb/deploy.sh
Executable file
294
dev/betrieb/deploy.sh
Executable file
|
|
@ -0,0 +1,294 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# Deploy eines Git-Tags auf Vorschau oder Live (B2in). Grundlage: Baukasten
|
||||
# dev/git-deploy-monitoring, Ablauf für B2in: dev/betrieb/README.md.
|
||||
#
|
||||
# Aufruf im App-Verzeichnis auf dem Server:
|
||||
#
|
||||
# bash dev/betrieb/deploy.sh v2.0.0 # ohne neue Migrationen
|
||||
# bash dev/betrieb/deploy.sh v2.0.0 --migrations # mit Migrationen, DB-Backup vorher!
|
||||
# bash dev/betrieb/deploy.sh v2.0.0 --migrations --seed=InteriorReleaseSeeder
|
||||
#
|
||||
# Optionen:
|
||||
# --migrations Neue Migrationen ausführen. Ohne diese Option bricht das
|
||||
# Skript ab, sobald der Tag neue Migrationen mitbringt.
|
||||
# --clear-schedule php artisan schedule:clear-cache (alte withoutOverlapping-Sperren).
|
||||
# --seed=<Klasse> Seeder im Wartungsmodus ausführen (nach den Migrationen,
|
||||
# vor den Caches) – für Inhaltsschritte, die die Release-Notiz nennt.
|
||||
# --initial Einmalig: ein Verzeichnis ohne Git (bisher SFTP/Archiv) an Ort und
|
||||
# Stelle zum Checkout des Tags machen. Sichert vorher den Code nach
|
||||
# ~/<verzeichnis>-vor-git-<zeit>.tgz, zeigt Dateien, die es nur auf dem
|
||||
# Server gibt, und verschiebt sie nach Rückfrage nach ~/<verzeichnis>-vor-git-<zeit>/.
|
||||
# Verlangt --migrations (vorher DB-Backup).
|
||||
#
|
||||
# Umgebung (optional):
|
||||
# DEPLOY_FPM_RELOAD Befehl zum Neuladen von PHP-FPM (Standard siehe unten).
|
||||
# Leer setzen, wenn FPM von Hand im Server-Panel neu geladen
|
||||
# wird — dann wartet das Skript auf Enter. Nie „Restart“:
|
||||
# der entfernt kurz den Socket, Nginx liefert 502.
|
||||
# DEPLOY_PHP_FPM_SERVICE systemd-Name des FPM-Dienstes (Standard php8.4-fpm).
|
||||
# DEPLOY_REPOSITORY Repository für --initial (Standard: Host-Alias forge-b2in aus ~/.ssh/config).
|
||||
#
|
||||
# Exit-Codes: 0 fertig, 1 abgebrochen (siehe Ausgabe), 2 falscher Aufruf,
|
||||
# 3 live, aber kein Queue-Worker hat den Test-Job abgeholt.
|
||||
#
|
||||
# Rollback: dasselbe Skript mit dem vorherigen Tag. Nach --initial: Sicherung
|
||||
# zurückspielen (Pfad steht in der Ausgabe) und DB-Backup einspielen.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ---- Projekt-Einstellungen --------------------------------------------------
|
||||
DEPLOY_SCRIPT="dev/betrieb/deploy.sh" # Pfad dieses Skripts im Repository
|
||||
PHP_FPM_SERVICE="${DEPLOY_PHP_FPM_SERVICE:-php8.4-fpm}" # systemd-Name des FPM-Dienstes
|
||||
ABOUT_SECTION="b2in" # AboutCommand::add('B2in', …) im AppServiceProvider
|
||||
QUEUE_PROBE=0 # B2in stellt keine Jobs in die Queue (Mails gehen sofort raus)
|
||||
REPOSITORY="${DEPLOY_REPOSITORY:-forge-b2in:kadmin/b2in.git}"
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Git ersetzt diese Datei beim Checkout. Bash liest Skripte stückweise, deshalb
|
||||
# läuft der Deploy aus einer Kopie.
|
||||
if [[ -z "${DEPLOY_COPY:-}" ]]; then
|
||||
copy="$(mktemp)"
|
||||
cp "$0" "$copy"
|
||||
DEPLOY_COPY="$copy" exec bash "$copy" "$@"
|
||||
fi
|
||||
trap 'rm -f "$DEPLOY_COPY"' EXIT
|
||||
|
||||
tag="${1:-}"
|
||||
shift || true
|
||||
run_migrations=0
|
||||
clear_schedule=0
|
||||
initial=0
|
||||
seeders=()
|
||||
|
||||
for option in "$@"; do
|
||||
case "$option" in
|
||||
--migrations) run_migrations=1 ;;
|
||||
--clear-schedule) clear_schedule=1 ;;
|
||||
--initial) initial=1 ;;
|
||||
--seed=*)
|
||||
seeder="${option#--seed=}"
|
||||
if [[ ! "$seeder" =~ ^[A-Za-z][A-Za-z0-9]*$ ]]; then
|
||||
echo "Ungültiger Seeder: $seeder" >&2; exit 2
|
||||
fi
|
||||
seeders+=("$seeder") ;;
|
||||
*) echo "Unbekannte Option: $option" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ ! "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "Aufruf: bash $DEPLOY_SCRIPT vX.Y.Z [--migrations] [--seed=<Klasse>] [--clear-schedule] [--initial]" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ ! -f artisan ]]; then
|
||||
echo "Im App-Verzeichnis aufrufen (artisan fehlt hier)." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
step() { printf '\n==> %s\n' "$*"; }
|
||||
|
||||
if [[ "$initial" -eq 1 ]]; then
|
||||
if [[ -d .git ]]; then
|
||||
echo "--initial nur für ein Verzeichnis ohne Git – hier gibt es .git schon." >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ "$run_migrations" -ne 1 ]]; then
|
||||
echo "--initial verlangt --migrations (vorher DB-Backup ziehen)." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
stamp="$(date +%Y-%m-%d-%H%M)"
|
||||
site="$(basename "$PWD")"
|
||||
backup="$HOME/$site-vor-git-$stamp.tgz"
|
||||
parked="$HOME/$site-vor-git-$stamp"
|
||||
|
||||
step "Sicherung des Codes nach $backup (ohne storage/, node_modules/)"
|
||||
# Exit 1 = „Datei hat sich beim Lesen geändert“ (Logs o. Ä.) – die Sicherung ist trotzdem brauchbar.
|
||||
tar czf "$backup" --exclude=./storage --exclude=./node_modules --exclude=./public/storage . || [[ $? -eq 1 ]]
|
||||
ls -lh "$backup"
|
||||
|
||||
step "Git einrichten, Tag $tag holen"
|
||||
git init -q
|
||||
git remote add origin "$REPOSITORY"
|
||||
if ! git fetch --quiet --tags origin || ! git rev-parse --quiet --verify "refs/tags/$tag^{commit}" > /dev/null; then
|
||||
rm -rf .git
|
||||
echo "Tag $tag nicht abrufbar (Deploy-Key? Tag gepusht?). .git wieder entfernt, nichts verändert." >&2
|
||||
exit 1
|
||||
fi
|
||||
git update-ref --no-deref HEAD "$(git rev-parse "$tag^{commit}")"
|
||||
git read-tree HEAD
|
||||
# Ignorier-Regeln des Tags gelten schon jetzt (die .gitignore im Verzeichnis ist noch die alte).
|
||||
git show "$tag:.gitignore" > .git/info/exclude
|
||||
|
||||
changed="$(git status --porcelain --untracked-files=no | wc -l | tr -d ' ')"
|
||||
server_only="$(git -c core.quotePath=false ls-files --others --exclude-standard)"
|
||||
echo "$changed Dateien weichen vom Tag ab oder fehlen – der Checkout ersetzt sie."
|
||||
if [[ -n "$server_only" ]]; then
|
||||
echo
|
||||
echo "Diese $(echo "$server_only" | wc -l | tr -d ' ') Dateien gibt es nur auf dem Server (nicht im Tag, nicht ignoriert):"
|
||||
echo "$server_only" | sed 's/^/ /' | head -200
|
||||
echo
|
||||
echo "Sie werden nach $parked/ verschoben (Pfade bleiben erhalten). Wird eine davon gebraucht,"
|
||||
echo "jetzt abbrechen und sie ins Repository oder in die .gitignore aufnehmen (neuer Tag)."
|
||||
fi
|
||||
read -r -p "Weiter mit dem Deploy von $tag? [ja/nein] " answer
|
||||
if [[ "$answer" != "ja" ]]; then
|
||||
rm -rf .git
|
||||
echo "Abgebrochen. .git wieder entfernt, nichts verändert (Sicherung bleibt liegen)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
previous="vor-git (Sicherung: $backup)"
|
||||
new_migrations="(Umstellung: alle offenen Migrationen)"
|
||||
else
|
||||
if [[ ! -d .git ]]; then
|
||||
echo "Hier gibt es kein .git – für die einmalige Umstellung --initial verwenden." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
step "Arbeitsverzeichnis prüfen"
|
||||
drift="$(git status --porcelain)"
|
||||
if [[ -n "$drift" ]]; then
|
||||
echo "Das Arbeitsverzeichnis weicht vom ausgecheckten Stand ab:" >&2
|
||||
echo "$drift" >&2
|
||||
echo "Erst klären (git diff), dann deployen. Nichts wurde verändert." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
step "Tags von origin holen"
|
||||
git fetch --quiet --tags origin
|
||||
|
||||
if ! git rev-parse --quiet --verify "refs/tags/$tag^{commit}" > /dev/null; then
|
||||
echo "Tag $tag gibt es auf origin nicht." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
previous="$(cat RELEASE 2>/dev/null || git describe --tags --always)"
|
||||
|
||||
if [[ "$(git rev-parse HEAD)" == "$(git rev-parse "$tag^{commit}")" ]]; then
|
||||
echo "$tag ist bereits ausgecheckt. Nichts zu tun."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Bisher: $previous → Ziel: $tag"
|
||||
|
||||
for build in b2in b2a stileigentum style2own portal; do
|
||||
if ! git cat-file -e "$tag:public/build/$build/manifest.json" 2> /dev/null; then
|
||||
echo "public/build/$build/manifest.json fehlt im Tag $tag – Assets lokal bauen und committen. Nichts wurde verändert." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "$initial" -ne 1 ]]; then
|
||||
new_migrations="$(git diff --name-only --diff-filter=A HEAD "$tag" -- database/migrations)"
|
||||
fi
|
||||
if [[ -n "$new_migrations" ]]; then
|
||||
echo "Neue Migrationen:"
|
||||
echo "$new_migrations" | sed 's/^/ /'
|
||||
if [[ "$run_migrations" -ne 1 ]]; then
|
||||
echo "Abbruch: DB-Backup ziehen und mit --migrations erneut aufrufen. Nichts wurde verändert." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$initial" -eq 1 ]]; then
|
||||
trap 'echo; echo "!! Umstellung abgebrochen. Die Seite bleibt im Wartungsmodus." >&2; echo "!! Rückweg: Verzeichnis aus $backup wiederherstellen (vorher .git entfernen), DB-Backup einspielen." >&2; rm -f "$DEPLOY_COPY"' ERR
|
||||
else
|
||||
trap 'echo; echo "!! Deploy abgebrochen. Die Seite bleibt im Wartungsmodus." >&2; echo "!! Rollback: bash $DEPLOY_SCRIPT $previous" >&2; rm -f "$DEPLOY_COPY"' ERR
|
||||
fi
|
||||
|
||||
step "Wartungsmodus"
|
||||
php artisan down --retry=60
|
||||
|
||||
step "Checkout $tag"
|
||||
if [[ "$initial" -eq 1 ]]; then
|
||||
if [[ -n "$server_only" ]]; then
|
||||
while IFS= read -r file; do
|
||||
mkdir -p "$parked/$(dirname "$file")"
|
||||
mv "$file" "$parked/$file"
|
||||
done <<< "$server_only"
|
||||
echo "Nur-Server-Dateien verschoben nach $parked/"
|
||||
fi
|
||||
git checkout --quiet --force --detach "$tag"
|
||||
: > .git/info/exclude
|
||||
else
|
||||
git checkout --quiet --detach "$tag"
|
||||
fi
|
||||
echo "$tag" > RELEASE
|
||||
|
||||
step "Composer"
|
||||
composer install --no-dev --optimize-autoloader --no-interaction
|
||||
|
||||
if [[ -n "$new_migrations" ]]; then
|
||||
step "Migrationen"
|
||||
php artisan migrate --force
|
||||
fi
|
||||
|
||||
for seeder in "${seeders[@]}"; do
|
||||
step "Seeder $seeder"
|
||||
php artisan db:seed --class="$seeder" --force
|
||||
done
|
||||
|
||||
step "Öffentliche Assets"
|
||||
if [[ ! -e public/storage ]]; then
|
||||
php artisan storage:link
|
||||
fi
|
||||
if [[ -d public/vendor/livewire ]]; then
|
||||
php artisan vendor:publish --tag=livewire:assets --force
|
||||
fi
|
||||
# Statisch ausgelieferte Flux-Skripte passend zur installierten Flux-Version
|
||||
if [[ -d public/flux ]]; then
|
||||
php artisan flux:publish-assets --force
|
||||
fi
|
||||
|
||||
step "Caches"
|
||||
php artisan config:cache
|
||||
php artisan route:cache
|
||||
php artisan view:clear
|
||||
php artisan view:cache
|
||||
php artisan event:cache
|
||||
|
||||
if [[ "$clear_schedule" -eq 1 ]]; then
|
||||
php artisan schedule:clear-cache
|
||||
fi
|
||||
|
||||
step "Worker und PHP-FPM"
|
||||
php artisan queue:restart
|
||||
default_fpm_reload="echo '' | sudo -S service $PHP_FPM_SERVICE reload"
|
||||
fpm_reload="${DEPLOY_FPM_RELOAD-$default_fpm_reload}"
|
||||
if [[ -n "$fpm_reload" ]]; then
|
||||
bash -c "$fpm_reload"
|
||||
else
|
||||
echo "PHP-FPM jetzt im Server-Panel neu laden (Reload, nicht Restart), danach Enter."
|
||||
read -r
|
||||
fi
|
||||
|
||||
step "Wartungsmodus aus"
|
||||
php artisan up
|
||||
|
||||
step "Version"
|
||||
if [[ -n "$ABOUT_SECTION" ]]; then
|
||||
php artisan about --only="$ABOUT_SECTION"
|
||||
else
|
||||
cat RELEASE
|
||||
fi
|
||||
|
||||
trap - ERR
|
||||
|
||||
# Läuft ein Worker? `queue:restart` oben beendet ihn; neu starten muss ihn
|
||||
# Supervisor. Läuft Supervisor nicht, steht die Queue still, ohne dass es
|
||||
# jemand merkt.
|
||||
if [[ "$QUEUE_PROBE" -eq 1 ]]; then
|
||||
step "Queue-Worker"
|
||||
if ! php artisan monitoring:queue-probe --wait=60; then
|
||||
echo
|
||||
echo "!! Deploy $tag ist live, aber KEIN Queue-Worker läuft." >&2
|
||||
echo "!! Jetzt Supervisor und den Worker im Server-Panel prüfen — sonst bleiben Jobs und Mails liegen." >&2
|
||||
exit 3
|
||||
fi
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "Deploy $tag fertig (vorher $previous). Weiter mit den Prüfungen aus der Deploy-Anleitung."
|
||||
175
dev/betrieb/vorschau-auffrischen.sh
Executable file
175
dev/betrieb/vorschau-auffrischen.sh
Executable file
|
|
@ -0,0 +1,175 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# Vorschau mit den aktuellen Live-Daten auffrischen (B2in). Ablauf: dev/betrieb/README.md.
|
||||
#
|
||||
# Kopiert die komplette Live-Datenbank und die hochgeladenen Medien (storage/app/public:
|
||||
# Display-Mediathek, CMS-Mediathek, Videos, QR-Codes) in die Vorschau. Danach steht auf der
|
||||
# Vorschau genau das, was der Kunde auf Live gepflegt hat – Displays, Playlists, Mediathek –,
|
||||
# und der Release-Seeder kann darauf zur Probe laufen.
|
||||
#
|
||||
# Live wird nur gelesen (mysqldump --single-transaction, rsync als Quelle). Die Vorschau-
|
||||
# Datenbank wird vollständig ersetzt.
|
||||
#
|
||||
# Aufruf (auf dem Server, als App-Benutzer):
|
||||
#
|
||||
# bash dev/betrieb/vorschau-auffrischen.sh <live-verzeichnis> <vorschau-verzeichnis> [--seed=<Klasse>]
|
||||
#
|
||||
# --seed=<Klasse> nach dem Import (und den Migrationen) diesen Seeder auf der Vorschau ausführen,
|
||||
# z. B. --seed=InteriorReleaseSeeder für eine erneute Generalprobe.
|
||||
#
|
||||
# Die Live-Sicherung bleibt als ~/b2in-live-<zeit>.sql.gz liegen.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
live="${1:-}"
|
||||
preview="${2:-}"
|
||||
shift 2 || true
|
||||
seeders=()
|
||||
|
||||
for option in "$@"; do
|
||||
case "$option" in
|
||||
--seed=*)
|
||||
seeder="${option#--seed=}"
|
||||
if [[ ! "$seeder" =~ ^[A-Za-z][A-Za-z0-9]*$ ]]; then
|
||||
echo "Ungültiger Seeder: $seeder" >&2; exit 2
|
||||
fi
|
||||
seeders+=("$seeder") ;;
|
||||
*) echo "Unbekannte Option: $option" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$live" || -z "$preview" || ! -f "$live/artisan" || ! -f "$preview/artisan" ]]; then
|
||||
echo "Aufruf: bash $0 <live-verzeichnis> <vorschau-verzeichnis> [--seed=<Klasse>]" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
live="$(cd "$live" && pwd)"
|
||||
preview="$(cd "$preview" && pwd)"
|
||||
|
||||
if [[ "$live" == "$preview" ]]; then
|
||||
echo "Live- und Vorschau-Verzeichnis sind dasselbe." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
step() { printf '\n==> %s\n' "$*"; }
|
||||
|
||||
# Wert aus einer .env lesen (ohne Anführungszeichen, ohne Kommentar am Zeilenende bei unquotierten Werten)
|
||||
env_value() {
|
||||
local file="$1" key="$2" line value
|
||||
line="$(grep -E "^${key}=" "$file" | tail -n 1 || true)"
|
||||
value="${line#*=}"
|
||||
if [[ "$value" =~ ^\"(.*)\"$ || "$value" =~ ^\'(.*)\'$ ]]; then
|
||||
value="${BASH_REMATCH[1]}"
|
||||
else
|
||||
value="${value%% #*}"
|
||||
fi
|
||||
printf '%s' "$value"
|
||||
}
|
||||
|
||||
# MySQL-Zugangsdaten als Datei (chmod 600), damit das Passwort nicht in der Prozessliste steht
|
||||
credentials() {
|
||||
local env="$1/.env" file
|
||||
file="$(mktemp)"
|
||||
chmod 600 "$file"
|
||||
{
|
||||
echo "[client]"
|
||||
echo "host=$(env_value "$env" DB_HOST)"
|
||||
echo "port=$(env_value "$env" DB_PORT)"
|
||||
echo "user=$(env_value "$env" DB_USERNAME)"
|
||||
echo "password=\"$(env_value "$env" DB_PASSWORD)\""
|
||||
} > "$file"
|
||||
printf '%s' "$file"
|
||||
}
|
||||
|
||||
live_db="$(env_value "$live/.env" DB_DATABASE)"
|
||||
preview_db="$(env_value "$preview/.env" DB_DATABASE)"
|
||||
live_host="$(env_value "$live/.env" DB_HOST)"
|
||||
preview_host="$(env_value "$preview/.env" DB_HOST)"
|
||||
|
||||
if [[ -z "$live_db" || -z "$preview_db" ]]; then
|
||||
echo "DB_DATABASE fehlt in einer der beiden .env-Dateien." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$live_db" == "$preview_db" && "$live_host" == "$preview_host" ]]; then
|
||||
echo "STOPP: Vorschau und Live zeigen auf dieselbe Datenbank ($live_db). Nichts wurde verändert." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$(env_value "$preview/.env" APP_ENV)" == "production" ]]; then
|
||||
echo "STOPP: Die Vorschau läuft mit APP_ENV=production – ist das wirklich die Vorschau? Nichts wurde verändert." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mailer="$(env_value "$preview/.env" MAIL_MAILER)"
|
||||
if [[ "$mailer" != "log" && "$mailer" != "array" ]]; then
|
||||
echo "Hinweis: MAIL_MAILER der Vorschau ist „$mailer“. Mit den Live-Daten könnte die Vorschau echte Mails verschicken."
|
||||
echo "Empfohlen: MAIL_MAILER=log in $preview/.env."
|
||||
fi
|
||||
|
||||
echo "Live: $live (Datenbank $live_db)"
|
||||
echo "Vorschau: $preview (Datenbank $preview_db – wird vollständig ersetzt)"
|
||||
echo "Medien: $live/storage/app/public/ → $preview/storage/app/public/ (Vorschau-eigene Dateien werden entfernt)"
|
||||
read -r -p "Weiter? [ja/nein] " answer
|
||||
if [[ "$answer" != "ja" ]]; then
|
||||
echo "Abgebrochen, nichts verändert."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
live_credentials="$(credentials "$live")"
|
||||
preview_credentials="$(credentials "$preview")"
|
||||
trap 'rm -f "$live_credentials" "$preview_credentials"' EXIT
|
||||
|
||||
dump="$HOME/b2in-live-$(date +%Y-%m-%d-%H%M).sql.gz"
|
||||
|
||||
step "Live-Datenbank sichern nach $dump"
|
||||
mysqldump --defaults-extra-file="$live_credentials" --single-transaction --quick --no-tablespaces \
|
||||
--routines --triggers --default-character-set=utf8mb4 "$live_db" | gzip > "$dump"
|
||||
ls -lh "$dump"
|
||||
|
||||
step "Vorschau in den Wartungsmodus"
|
||||
(cd "$preview" && php artisan down --retry=60)
|
||||
|
||||
trap 'echo; echo "!! Abgebrochen. Die Vorschau bleibt im Wartungsmodus; Live ist unverändert." >&2; rm -f "$live_credentials" "$preview_credentials"' ERR
|
||||
|
||||
step "Vorschau-Datenbank leeren und Live-Stand einspielen"
|
||||
(cd "$preview" && php artisan db:wipe --force)
|
||||
gunzip -c "$dump" | mysql --defaults-extra-file="$preview_credentials" --default-character-set=utf8mb4 "$preview_db"
|
||||
|
||||
# Sitzungen und Cache von Live gehören nicht in die Vorschau
|
||||
for table in sessions cache cache_locks jobs; do
|
||||
if mysql --defaults-extra-file="$preview_credentials" -N -e "SHOW TABLES LIKE '$table'" "$preview_db" | grep -q .; then
|
||||
mysql --defaults-extra-file="$preview_credentials" -e "TRUNCATE TABLE \`$table\`" "$preview_db"
|
||||
fi
|
||||
done
|
||||
|
||||
step "Medien übernehmen"
|
||||
mkdir -p "$preview/storage/app/public"
|
||||
if [[ "$(realpath "$live/storage/app/public")" == "$(realpath "$preview/storage/app/public")" ]]; then
|
||||
# Vorschau verlinkt auf die Live-Medien (preview-deployment.md, Schritt 3) – nichts zu kopieren
|
||||
echo "Die Vorschau nutzt die Live-Medien per Link – übersprungen."
|
||||
else
|
||||
rsync -a --delete "$live/storage/app/public/" "$preview/storage/app/public/"
|
||||
du -sh "$preview/storage/app/public"
|
||||
fi
|
||||
|
||||
step "Migrationen und Seeder"
|
||||
cd "$preview"
|
||||
php artisan migrate --force
|
||||
for seeder in "${seeders[@]}"; do
|
||||
php artisan db:seed --class="$seeder" --force
|
||||
done
|
||||
|
||||
step "Caches"
|
||||
[[ -e public/storage ]] || php artisan storage:link
|
||||
php artisan optimize:clear
|
||||
php artisan config:cache
|
||||
php artisan route:cache
|
||||
php artisan view:cache
|
||||
|
||||
step "Vorschau wieder online"
|
||||
php artisan up
|
||||
|
||||
trap - ERR
|
||||
echo
|
||||
echo "Vorschau aufgefrischt: Daten und Medien von Live, Stand $(date '+%d.%m.%Y %H:%M')."
|
||||
|
|
@ -82,28 +82,28 @@ Auf dem Server als App-Benutzer (bei Ploi `ploi`):
|
|||
2. Deploy-Key erzeugen und im Git-Server **nur lesend** am Repository eintragen:
|
||||
|
||||
```bash
|
||||
ssh-keygen -t ed25519 -f ~/.ssh/deploy_meineapp -N "" -C "meineapp-live"
|
||||
cat ~/.ssh/deploy_meineapp.pub
|
||||
ssh-keygen -t ed25519 -f ~/.ssh/deploy_b2inapp -N "" -C "b2inapp-live"
|
||||
cat ~/.ssh/deploy_b2inapp.pub
|
||||
```
|
||||
|
||||
3. `~/.ssh/config`:
|
||||
|
||||
```
|
||||
Host git.example.com
|
||||
Host forge.adametz.media
|
||||
Port 222
|
||||
User git
|
||||
IdentityFile ~/.ssh/deploy_meineapp
|
||||
IdentityFile ~/.ssh/deploy_b2inapp
|
||||
IdentitiesOnly yes
|
||||
```
|
||||
|
||||
4. Host-Key aufnehmen, Fingerprint mit dem im Git-Server angezeigten vergleichen:
|
||||
|
||||
```bash
|
||||
ssh-keyscan -p 222 git.example.com >> ~/.ssh/known_hosts
|
||||
ssh-keygen -lf <(ssh-keyscan -p 222 git.example.com 2>/dev/null)
|
||||
ssh-keyscan -p 222 forge.adametz.media >> ~/.ssh/known_hosts
|
||||
ssh-keygen -lf <(ssh-keyscan -p 222 forge.adametz.media 2>/dev/null)
|
||||
```
|
||||
|
||||
5. Test: `git ls-remote ssh://git@git.example.com:222/team/meineapp.git` zeigt die Tags.
|
||||
5. Test: `git ls-remote ssh://git@forge.adametz.media :222/kadmin/b2in.git` zeigt die Tags.
|
||||
6. **FPM-Reload ohne Passwort testen** (Ploi erlaubt das dem Benutzer `ploi`):
|
||||
|
||||
```bash
|
||||
|
|
|
|||
97
package-lock.json
generated
97
package-lock.json
generated
|
|
@ -8,6 +8,7 @@
|
|||
"@fontsource/instrument-sans": "^5.0.0",
|
||||
"@fontsource/inter": "^5.0.0",
|
||||
"@fontsource/poppins": "^5.0.0",
|
||||
"@sentry/browser": "^11.6.0",
|
||||
"@tailwindcss/vite": "^4.0.7",
|
||||
"alpinejs": "^3.14.9",
|
||||
"autoprefixer": "^10.4.20",
|
||||
|
|
@ -723,6 +724,96 @@
|
|||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@sentry/browser": {
|
||||
"version": "11.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-11.6.0.tgz",
|
||||
"integrity": "sha512-zkqUIbRc9HCBMsoL67HOAr6PSfNzJSMw4JhytCHuDZ5O7KuOwwgBENGhVh3mr5lj2D47Bhv01MpwCJMP3yBnZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/browser-utils": "11.6.0",
|
||||
"@sentry/conventions": "^0.26.0",
|
||||
"@sentry/core": "11.6.0",
|
||||
"@sentry/feedback": "11.6.0",
|
||||
"@sentry/replay": "11.6.0",
|
||||
"@sentry/replay-canvas": "11.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/browser-utils": {
|
||||
"version": "11.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/browser-utils/-/browser-utils-11.6.0.tgz",
|
||||
"integrity": "sha512-s4zuuE71NORfyNd6ciS27/o97qgfu70qDRdPCkLK8UOcs1REsaAQPgmvNBSADRYm4eeFwWejKBCQKjL5kOzLqw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/conventions": "^0.26.0",
|
||||
"@sentry/core": "11.6.0",
|
||||
"web-vitals": "^6.2.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/conventions": {
|
||||
"version": "0.26.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/conventions/-/conventions-0.26.0.tgz",
|
||||
"integrity": "sha512-ttSRcoLf2kIENDIFzH12aJaVMdxnidMYLvVNwEX/JjXfXaQot8u5M47QmgAFOOhVJ4nQ2uU8wTCh+fwSPrrCfg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/core": {
|
||||
"version": "11.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-11.6.0.tgz",
|
||||
"integrity": "sha512-PbITT1kfJYKJiS9fH4ZtOcwIONKyt+tUsTZSPtcAjPdkUDOzMKT61MkUa1Ze07VzNtLALT24ZdUOc6d9K4YoKA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/conventions": "^0.26.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/feedback": {
|
||||
"version": "11.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/feedback/-/feedback-11.6.0.tgz",
|
||||
"integrity": "sha512-V52/+SkMcPdlldExPC9TWS2sZ+CC+8BSzvVbRtkQxvy+jTOXFAJfInaVLPAAVt8W4WbPw9iAxVMzwHGaZ2muBw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/core": "11.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/replay": {
|
||||
"version": "11.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/replay/-/replay-11.6.0.tgz",
|
||||
"integrity": "sha512-cJyPYnF6QJA8ADRfAW06YoU7EVIebtWezBOCU9Vag5+W1VLBkXEvH7C4LbeMUiDmrqw92sumq06NgqipIxA7BA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/browser-utils": "11.6.0",
|
||||
"@sentry/core": "11.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/replay-canvas": {
|
||||
"version": "11.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/replay-canvas/-/replay-canvas-11.6.0.tgz",
|
||||
"integrity": "sha512-cRuiC8N9xXDoaKEDpMPRDtmGAr0G2YMx1m2JrnrmTnMt9z/VcIKNwaiURTTW8W8u96/RyRQfflMHYARX7FG8YQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/core": "11.6.0",
|
||||
"@sentry/replay": "11.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node": {
|
||||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.0.8.tgz",
|
||||
|
|
@ -2295,6 +2386,12 @@
|
|||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/web-vitals": {
|
||||
"version": "6.2.3",
|
||||
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-6.2.3.tgz",
|
||||
"integrity": "sha512-Dmy+vdO1UPMqXiAtZSNayhl4f794Bxfd94W4HIj2HlYtczEQfEOon969memlcpcHrTuOKLa5baFjX7WbgmOq+A==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||
|
|
|
|||
|
|
@ -13,6 +13,10 @@
|
|||
"build:style2own": "THEME=style2own vite build --config vite.web.config.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/instrument-sans": "^5.0.0",
|
||||
"@fontsource/inter": "^5.0.0",
|
||||
"@fontsource/poppins": "^5.0.0",
|
||||
"@sentry/browser": "^11.6.0",
|
||||
"@tailwindcss/vite": "^4.0.7",
|
||||
"alpinejs": "^3.14.9",
|
||||
"autoprefixer": "^10.4.20",
|
||||
|
|
@ -20,10 +24,7 @@
|
|||
"concurrently": "^9.0.1",
|
||||
"laravel-vite-plugin": "^1.0",
|
||||
"tailwindcss": "^4.0.7",
|
||||
"vite": "^6.0",
|
||||
"@fontsource/instrument-sans": "^5.0.0",
|
||||
"@fontsource/inter": "^5.0.0",
|
||||
"@fontsource/poppins": "^5.0.0"
|
||||
"vite": "^6.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-linux-x64-gnu": "4.9.5",
|
||||
|
|
|
|||
|
|
@ -33,5 +33,11 @@
|
|||
<env name="SESSION_DRIVER" value="array"/>
|
||||
<env name="TELESCOPE_ENABLED" value="false"/>
|
||||
<env name="BASIC_AUTH_ENABLED" value="false"/>
|
||||
<!-- Tests provozieren Fehler absichtlich – nie an das echte GlitchTip, nie Push/Mail -->
|
||||
<env name="SENTRY_LARAVEL_DSN" value="" force="true"/>
|
||||
<env name="SENTRY_DSN" value="" force="true"/>
|
||||
<env name="SENTRY_BROWSER_DSN" value="" force="true"/>
|
||||
<env name="MONITORING_NTFY_URL" value="" force="true"/>
|
||||
<env name="MONITORING_ALERT_EMAIL" value="" force="true"/>
|
||||
</php>
|
||||
</phpunit>
|
||||
|
|
|
|||
1
public/build/b2a/app-OG3MHWAj.js
Normal file
1
public/build/b2a/app-OG3MHWAj.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
(function(){let o=null;document.readyState==="loading"?document.addEventListener("DOMContentLoaded",r):r();function r(){s(),u(),c(),document.addEventListener("livewire:navigated",function(){s()}),document.addEventListener("livewire:load",function(){s()}),window.addEventListener("livewire:update",function(){setTimeout(()=>{s()},100)})}function s(){const n={threshold:.15,rootMargin:"0px 0px -80px 0px"};o||(o=new IntersectionObserver(e=>{e.forEach(t=>{t.isIntersecting&&(setTimeout(()=>{t.target.classList.add("is-visible")},50),o.unobserve(t.target))})},n)),document.querySelectorAll(".scroll-animate:not(.is-visible):not(.observed), .fade-in:not(.is-visible):not(.observed), .slide-up:not(.is-visible):not(.observed), .slide-right:not(.is-visible):not(.observed), .slide-left:not(.is-visible):not(.observed), .scale-in:not(.is-visible):not(.observed), .slide-down:not(.is-visible):not(.observed)").forEach(e=>{e.classList.add("observed"),o.observe(e)}),window.appointmentLinksInitialized||(document.addEventListener("click",function(e){const t=e.target.closest("a[data-open-appointment]");t&&(e.preventDefault(),window.dispatchEvent(new CustomEvent("open-appointment",{detail:{topic:t.dataset.appointmentTopic||null}})))}),window.appointmentLinksInitialized=!0),window.smoothScrollInitialized||(document.addEventListener("click",function(e){const t=e.target.closest('a[href^="#"]');if(t&&t.hash){const l=document.querySelector(t.hash);if(l){e.preventDefault();const f=l.getBoundingClientRect().top+window.pageYOffset-80;window.scrollTo({top:f,behavior:"smooth"})}}}),window.smoothScrollInitialized=!0)}function c(){const n=document.getElementById("main-header");if(!n)return;function i(){(window.pageYOffset||document.documentElement.scrollTop)>50?n.classList.add("scrolled"):n.classList.remove("scrolled")}let e=!1;window.addEventListener("scroll",function(){e||(window.requestAnimationFrame(function(){i(),e=!1}),e=!0)}),i()}function u(){const n=document.createElement("div");n.className="scroll-progress-bar",document.body.appendChild(n);function i(){const t=window.innerHeight,l=document.documentElement.scrollHeight,d=window.pageYOffset||document.documentElement.scrollTop,a=d/(l-t)*100;n.style.width=`${Math.min(a,100)}%`,d>100?n.classList.add("visible"):n.classList.remove("visible")}let e=!1;window.addEventListener("scroll",function(){e||(window.requestAnimationFrame(function(){i(),e=!1}),e=!0)}),i()}})();
|
||||
1
public/build/b2a/error-reporting-BiGrjXpD.js
Normal file
1
public/build/b2a/error-reporting-BiGrjXpD.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
const y="modulepreload",A=function(e){return"https://b2in.test/build/"+e},h={},_=function(r,t,a){let u=Promise.resolve();if(t&&t.length>0){let o=function(s){return Promise.all(s.map(c=>Promise.resolve(c).then(f=>({status:"fulfilled",value:f}),f=>({status:"rejected",reason:f}))))};document.getElementsByTagName("link");const n=document.querySelector("meta[property=csp-nonce]"),m=(n==null?void 0:n.nonce)||(n==null?void 0:n.getAttribute("nonce"));u=o(t.map(s=>{if(s=A(s),s in h)return;h[s]=!0;const c=s.endsWith(".css"),f=c?'[rel="stylesheet"]':"";if(document.querySelector(`link[href="${s}"]${f}`))return;const l=document.createElement("link");if(l.rel=c?"stylesheet":y,c||(l.as="script"),l.crossOrigin="",l.href=s,m&&l.setAttribute("nonce",m),document.head.appendChild(l),c)return new Promise((b,S)=>{l.addEventListener("load",b),l.addEventListener("error",()=>S(new Error(`Unable to preload CSS for ${s}`)))})}))}function d(o){const n=new Event("vite:preloadError",{cancelable:!0});if(n.payload=o,window.dispatchEvent(n),!n.defaultPrevented)throw o}return u.then(o=>{for(const n of o||[])n.status==="rejected"&&d(n.reason);return r().catch(d)})},i=(()=>{const e=document.querySelector('meta[name="pk-error-reporting"]');try{return e?JSON.parse(e.content):null}catch{return null}})(),v=["page","q","tab","sort"],P=new RegExp("(?<=\\/)[A-Za-z0-9_-]{32,}(?=\\/|$)","g"),w=/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi;function p(e){try{const r=new URL(e,window.location.origin);r.pathname=r.pathname.replace(P,"[Filtered]");for(const t of[...r.searchParams.keys()])!v.includes(t)&&!t.startsWith("utm_")&&r.searchParams.set(t,"[Filtered]");return r.hash="",r.toString().replace(w,"[email]")}catch{return"[Filtered]"}}let E=null;function R(){return E??(E=_(()=>import("./error-reporting-sdk-CLmutOST.js"),[]).then(e=>(e.init({dsn:i.dsn,release:i.release||void 0,environment:i.environment,sampleRate:i.sampleRate??1,sendDefaultPii:!1,integrations:r=>r.filter(t=>!["GlobalHandlers","Breadcrumbs","BrowserSession","BrowserApiErrors"].includes(t.name)),ignoreErrors:["ResizeObserver loop","Script error.","AbortError"],denyUrls:[/^chrome-extension:\/\//,/^moz-extension:\/\//,/^safari-(web-)?extension:\/\//],beforeSend(r){var t,a,u,d;(t=r.request)!=null&&t.url&&(r.request.url=p(r.request.url)),(u=(a=r.request)==null?void 0:a.headers)!=null&&u.Referer&&(r.request.headers.Referer=p(r.request.headers.Referer));for(const o of((d=r.exception)==null?void 0:d.values)??[])o.value&&(o.value=o.value.replace(w,"[email]"));return r}}),i.userId&&e.setUser({id:String(i.userId)}),e.setTag("area",i.area),e))),E}function k(e){var a;if(e instanceof Error)return e;const r=((a=e==null?void 0:e.constructor)==null?void 0:a.name)??typeof e;let t;try{t=typeof e=="string"?e:JSON.stringify(e)}catch{t=String(e)}return new Error(`Unbehandelte Ablehnung (${r}): ${String(t??"").slice(0,300)}`)}function g(e){R().then(r=>r.captureException(e)).catch(()=>{})}i!=null&&i.dsn&&(window.addEventListener("error",e=>{e.error&&g(e.error)}),window.addEventListener("unhandledrejection",e=>{g(k(e.reason))}));
|
||||
15
public/build/b2a/error-reporting-sdk-CLmutOST.js
Normal file
15
public/build/b2a/error-reporting-sdk-CLmutOST.js
Normal file
File diff suppressed because one or more lines are too long
48
public/build/b2a/manifest.json
Normal file
48
public/build/b2a/manifest.json
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
{
|
||||
"resources/css/web/shared-styles.css": {
|
||||
"file": "shared-styles-BbGt7f_e.css",
|
||||
"src": "resources/css/web/shared-styles.css",
|
||||
"isEntry": true
|
||||
},
|
||||
"resources/css/web/theme-b2a.css": {
|
||||
"file": "theme-b2a-BCAFYDZJ.css",
|
||||
"src": "resources/css/web/theme-b2a.css",
|
||||
"isEntry": true
|
||||
},
|
||||
"resources/css/web/theme-b2in.css": {
|
||||
"file": "theme-b2in-DK_BFMo-.css",
|
||||
"src": "resources/css/web/theme-b2in.css",
|
||||
"isEntry": true
|
||||
},
|
||||
"resources/css/web/theme-stileigentum.css": {
|
||||
"file": "theme-stileigentum-skwGRiP0.css",
|
||||
"src": "resources/css/web/theme-stileigentum.css",
|
||||
"isEntry": true
|
||||
},
|
||||
"resources/css/web/theme-style2own.css": {
|
||||
"file": "theme-style2own-1F1Xts-B.css",
|
||||
"src": "resources/css/web/theme-style2own.css",
|
||||
"isEntry": true
|
||||
},
|
||||
"resources/js/app.js": {
|
||||
"file": "app-OG3MHWAj.js",
|
||||
"name": "app",
|
||||
"src": "resources/js/app.js",
|
||||
"isEntry": true
|
||||
},
|
||||
"resources/js/error-reporting-sdk.js": {
|
||||
"file": "error-reporting-sdk-CLmutOST.js",
|
||||
"name": "error-reporting-sdk",
|
||||
"src": "resources/js/error-reporting-sdk.js",
|
||||
"isDynamicEntry": true
|
||||
},
|
||||
"resources/js/error-reporting.js": {
|
||||
"file": "error-reporting-BiGrjXpD.js",
|
||||
"name": "error-reporting",
|
||||
"src": "resources/js/error-reporting.js",
|
||||
"isEntry": true,
|
||||
"dynamicImports": [
|
||||
"resources/js/error-reporting-sdk.js"
|
||||
]
|
||||
}
|
||||
}
|
||||
1
public/build/b2a/shared-styles-BbGt7f_e.css
Normal file
1
public/build/b2a/shared-styles-BbGt7f_e.css
Normal file
File diff suppressed because one or more lines are too long
1
public/build/b2a/theme-b2a-BCAFYDZJ.css
Normal file
1
public/build/b2a/theme-b2a-BCAFYDZJ.css
Normal file
File diff suppressed because one or more lines are too long
1
public/build/b2a/theme-b2in-DK_BFMo-.css
Normal file
1
public/build/b2a/theme-b2in-DK_BFMo-.css
Normal file
File diff suppressed because one or more lines are too long
1
public/build/b2a/theme-stileigentum-skwGRiP0.css
Normal file
1
public/build/b2a/theme-stileigentum-skwGRiP0.css
Normal file
File diff suppressed because one or more lines are too long
1
public/build/b2a/theme-style2own-1F1Xts-B.css
Normal file
1
public/build/b2a/theme-style2own-1F1Xts-B.css
Normal file
File diff suppressed because one or more lines are too long
1
public/build/b2in/app-OG3MHWAj.js
Normal file
1
public/build/b2in/app-OG3MHWAj.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
(function(){let o=null;document.readyState==="loading"?document.addEventListener("DOMContentLoaded",r):r();function r(){s(),u(),c(),document.addEventListener("livewire:navigated",function(){s()}),document.addEventListener("livewire:load",function(){s()}),window.addEventListener("livewire:update",function(){setTimeout(()=>{s()},100)})}function s(){const n={threshold:.15,rootMargin:"0px 0px -80px 0px"};o||(o=new IntersectionObserver(e=>{e.forEach(t=>{t.isIntersecting&&(setTimeout(()=>{t.target.classList.add("is-visible")},50),o.unobserve(t.target))})},n)),document.querySelectorAll(".scroll-animate:not(.is-visible):not(.observed), .fade-in:not(.is-visible):not(.observed), .slide-up:not(.is-visible):not(.observed), .slide-right:not(.is-visible):not(.observed), .slide-left:not(.is-visible):not(.observed), .scale-in:not(.is-visible):not(.observed), .slide-down:not(.is-visible):not(.observed)").forEach(e=>{e.classList.add("observed"),o.observe(e)}),window.appointmentLinksInitialized||(document.addEventListener("click",function(e){const t=e.target.closest("a[data-open-appointment]");t&&(e.preventDefault(),window.dispatchEvent(new CustomEvent("open-appointment",{detail:{topic:t.dataset.appointmentTopic||null}})))}),window.appointmentLinksInitialized=!0),window.smoothScrollInitialized||(document.addEventListener("click",function(e){const t=e.target.closest('a[href^="#"]');if(t&&t.hash){const l=document.querySelector(t.hash);if(l){e.preventDefault();const f=l.getBoundingClientRect().top+window.pageYOffset-80;window.scrollTo({top:f,behavior:"smooth"})}}}),window.smoothScrollInitialized=!0)}function c(){const n=document.getElementById("main-header");if(!n)return;function i(){(window.pageYOffset||document.documentElement.scrollTop)>50?n.classList.add("scrolled"):n.classList.remove("scrolled")}let e=!1;window.addEventListener("scroll",function(){e||(window.requestAnimationFrame(function(){i(),e=!1}),e=!0)}),i()}function u(){const n=document.createElement("div");n.className="scroll-progress-bar",document.body.appendChild(n);function i(){const t=window.innerHeight,l=document.documentElement.scrollHeight,d=window.pageYOffset||document.documentElement.scrollTop,a=d/(l-t)*100;n.style.width=`${Math.min(a,100)}%`,d>100?n.classList.add("visible"):n.classList.remove("visible")}let e=!1;window.addEventListener("scroll",function(){e||(window.requestAnimationFrame(function(){i(),e=!1}),e=!0)}),i()}})();
|
||||
1
public/build/b2in/error-reporting-BiGrjXpD.js
Normal file
1
public/build/b2in/error-reporting-BiGrjXpD.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
const y="modulepreload",A=function(e){return"https://b2in.test/build/"+e},h={},_=function(r,t,a){let u=Promise.resolve();if(t&&t.length>0){let o=function(s){return Promise.all(s.map(c=>Promise.resolve(c).then(f=>({status:"fulfilled",value:f}),f=>({status:"rejected",reason:f}))))};document.getElementsByTagName("link");const n=document.querySelector("meta[property=csp-nonce]"),m=(n==null?void 0:n.nonce)||(n==null?void 0:n.getAttribute("nonce"));u=o(t.map(s=>{if(s=A(s),s in h)return;h[s]=!0;const c=s.endsWith(".css"),f=c?'[rel="stylesheet"]':"";if(document.querySelector(`link[href="${s}"]${f}`))return;const l=document.createElement("link");if(l.rel=c?"stylesheet":y,c||(l.as="script"),l.crossOrigin="",l.href=s,m&&l.setAttribute("nonce",m),document.head.appendChild(l),c)return new Promise((b,S)=>{l.addEventListener("load",b),l.addEventListener("error",()=>S(new Error(`Unable to preload CSS for ${s}`)))})}))}function d(o){const n=new Event("vite:preloadError",{cancelable:!0});if(n.payload=o,window.dispatchEvent(n),!n.defaultPrevented)throw o}return u.then(o=>{for(const n of o||[])n.status==="rejected"&&d(n.reason);return r().catch(d)})},i=(()=>{const e=document.querySelector('meta[name="pk-error-reporting"]');try{return e?JSON.parse(e.content):null}catch{return null}})(),v=["page","q","tab","sort"],P=new RegExp("(?<=\\/)[A-Za-z0-9_-]{32,}(?=\\/|$)","g"),w=/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi;function p(e){try{const r=new URL(e,window.location.origin);r.pathname=r.pathname.replace(P,"[Filtered]");for(const t of[...r.searchParams.keys()])!v.includes(t)&&!t.startsWith("utm_")&&r.searchParams.set(t,"[Filtered]");return r.hash="",r.toString().replace(w,"[email]")}catch{return"[Filtered]"}}let E=null;function R(){return E??(E=_(()=>import("./error-reporting-sdk-CLmutOST.js"),[]).then(e=>(e.init({dsn:i.dsn,release:i.release||void 0,environment:i.environment,sampleRate:i.sampleRate??1,sendDefaultPii:!1,integrations:r=>r.filter(t=>!["GlobalHandlers","Breadcrumbs","BrowserSession","BrowserApiErrors"].includes(t.name)),ignoreErrors:["ResizeObserver loop","Script error.","AbortError"],denyUrls:[/^chrome-extension:\/\//,/^moz-extension:\/\//,/^safari-(web-)?extension:\/\//],beforeSend(r){var t,a,u,d;(t=r.request)!=null&&t.url&&(r.request.url=p(r.request.url)),(u=(a=r.request)==null?void 0:a.headers)!=null&&u.Referer&&(r.request.headers.Referer=p(r.request.headers.Referer));for(const o of((d=r.exception)==null?void 0:d.values)??[])o.value&&(o.value=o.value.replace(w,"[email]"));return r}}),i.userId&&e.setUser({id:String(i.userId)}),e.setTag("area",i.area),e))),E}function k(e){var a;if(e instanceof Error)return e;const r=((a=e==null?void 0:e.constructor)==null?void 0:a.name)??typeof e;let t;try{t=typeof e=="string"?e:JSON.stringify(e)}catch{t=String(e)}return new Error(`Unbehandelte Ablehnung (${r}): ${String(t??"").slice(0,300)}`)}function g(e){R().then(r=>r.captureException(e)).catch(()=>{})}i!=null&&i.dsn&&(window.addEventListener("error",e=>{e.error&&g(e.error)}),window.addEventListener("unhandledrejection",e=>{g(k(e.reason))}));
|
||||
15
public/build/b2in/error-reporting-sdk-CLmutOST.js
Normal file
15
public/build/b2in/error-reporting-sdk-CLmutOST.js
Normal file
File diff suppressed because one or more lines are too long
48
public/build/b2in/manifest.json
Normal file
48
public/build/b2in/manifest.json
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
{
|
||||
"resources/css/web/shared-styles.css": {
|
||||
"file": "shared-styles-BbGt7f_e.css",
|
||||
"src": "resources/css/web/shared-styles.css",
|
||||
"isEntry": true
|
||||
},
|
||||
"resources/css/web/theme-b2a.css": {
|
||||
"file": "theme-b2a-BCAFYDZJ.css",
|
||||
"src": "resources/css/web/theme-b2a.css",
|
||||
"isEntry": true
|
||||
},
|
||||
"resources/css/web/theme-b2in.css": {
|
||||
"file": "theme-b2in-DK_BFMo-.css",
|
||||
"src": "resources/css/web/theme-b2in.css",
|
||||
"isEntry": true
|
||||
},
|
||||
"resources/css/web/theme-stileigentum.css": {
|
||||
"file": "theme-stileigentum-skwGRiP0.css",
|
||||
"src": "resources/css/web/theme-stileigentum.css",
|
||||
"isEntry": true
|
||||
},
|
||||
"resources/css/web/theme-style2own.css": {
|
||||
"file": "theme-style2own-1F1Xts-B.css",
|
||||
"src": "resources/css/web/theme-style2own.css",
|
||||
"isEntry": true
|
||||
},
|
||||
"resources/js/app.js": {
|
||||
"file": "app-OG3MHWAj.js",
|
||||
"name": "app",
|
||||
"src": "resources/js/app.js",
|
||||
"isEntry": true
|
||||
},
|
||||
"resources/js/error-reporting-sdk.js": {
|
||||
"file": "error-reporting-sdk-CLmutOST.js",
|
||||
"name": "error-reporting-sdk",
|
||||
"src": "resources/js/error-reporting-sdk.js",
|
||||
"isDynamicEntry": true
|
||||
},
|
||||
"resources/js/error-reporting.js": {
|
||||
"file": "error-reporting-BiGrjXpD.js",
|
||||
"name": "error-reporting",
|
||||
"src": "resources/js/error-reporting.js",
|
||||
"isEntry": true,
|
||||
"dynamicImports": [
|
||||
"resources/js/error-reporting-sdk.js"
|
||||
]
|
||||
}
|
||||
}
|
||||
1
public/build/b2in/shared-styles-BbGt7f_e.css
Normal file
1
public/build/b2in/shared-styles-BbGt7f_e.css
Normal file
File diff suppressed because one or more lines are too long
1
public/build/b2in/theme-b2a-BCAFYDZJ.css
Normal file
1
public/build/b2in/theme-b2a-BCAFYDZJ.css
Normal file
File diff suppressed because one or more lines are too long
1
public/build/b2in/theme-b2in-DK_BFMo-.css
Normal file
1
public/build/b2in/theme-b2in-DK_BFMo-.css
Normal file
File diff suppressed because one or more lines are too long
1
public/build/b2in/theme-stileigentum-skwGRiP0.css
Normal file
1
public/build/b2in/theme-stileigentum-skwGRiP0.css
Normal file
File diff suppressed because one or more lines are too long
1
public/build/b2in/theme-style2own-1F1Xts-B.css
Normal file
1
public/build/b2in/theme-style2own-1F1Xts-B.css
Normal file
File diff suppressed because one or more lines are too long
1
public/build/portal/app-OG3MHWAj.js
Normal file
1
public/build/portal/app-OG3MHWAj.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
(function(){let o=null;document.readyState==="loading"?document.addEventListener("DOMContentLoaded",r):r();function r(){s(),u(),c(),document.addEventListener("livewire:navigated",function(){s()}),document.addEventListener("livewire:load",function(){s()}),window.addEventListener("livewire:update",function(){setTimeout(()=>{s()},100)})}function s(){const n={threshold:.15,rootMargin:"0px 0px -80px 0px"};o||(o=new IntersectionObserver(e=>{e.forEach(t=>{t.isIntersecting&&(setTimeout(()=>{t.target.classList.add("is-visible")},50),o.unobserve(t.target))})},n)),document.querySelectorAll(".scroll-animate:not(.is-visible):not(.observed), .fade-in:not(.is-visible):not(.observed), .slide-up:not(.is-visible):not(.observed), .slide-right:not(.is-visible):not(.observed), .slide-left:not(.is-visible):not(.observed), .scale-in:not(.is-visible):not(.observed), .slide-down:not(.is-visible):not(.observed)").forEach(e=>{e.classList.add("observed"),o.observe(e)}),window.appointmentLinksInitialized||(document.addEventListener("click",function(e){const t=e.target.closest("a[data-open-appointment]");t&&(e.preventDefault(),window.dispatchEvent(new CustomEvent("open-appointment",{detail:{topic:t.dataset.appointmentTopic||null}})))}),window.appointmentLinksInitialized=!0),window.smoothScrollInitialized||(document.addEventListener("click",function(e){const t=e.target.closest('a[href^="#"]');if(t&&t.hash){const l=document.querySelector(t.hash);if(l){e.preventDefault();const f=l.getBoundingClientRect().top+window.pageYOffset-80;window.scrollTo({top:f,behavior:"smooth"})}}}),window.smoothScrollInitialized=!0)}function c(){const n=document.getElementById("main-header");if(!n)return;function i(){(window.pageYOffset||document.documentElement.scrollTop)>50?n.classList.add("scrolled"):n.classList.remove("scrolled")}let e=!1;window.addEventListener("scroll",function(){e||(window.requestAnimationFrame(function(){i(),e=!1}),e=!0)}),i()}function u(){const n=document.createElement("div");n.className="scroll-progress-bar",document.body.appendChild(n);function i(){const t=window.innerHeight,l=document.documentElement.scrollHeight,d=window.pageYOffset||document.documentElement.scrollTop,a=d/(l-t)*100;n.style.width=`${Math.min(a,100)}%`,d>100?n.classList.add("visible"):n.classList.remove("visible")}let e=!1;window.addEventListener("scroll",function(){e||(window.requestAnimationFrame(function(){i(),e=!1}),e=!0)}),i()}})();
|
||||
1
public/build/portal/error-reporting-BiGrjXpD.js
Normal file
1
public/build/portal/error-reporting-BiGrjXpD.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
const y="modulepreload",A=function(e){return"https://b2in.test/build/"+e},h={},_=function(r,t,a){let u=Promise.resolve();if(t&&t.length>0){let o=function(s){return Promise.all(s.map(c=>Promise.resolve(c).then(f=>({status:"fulfilled",value:f}),f=>({status:"rejected",reason:f}))))};document.getElementsByTagName("link");const n=document.querySelector("meta[property=csp-nonce]"),m=(n==null?void 0:n.nonce)||(n==null?void 0:n.getAttribute("nonce"));u=o(t.map(s=>{if(s=A(s),s in h)return;h[s]=!0;const c=s.endsWith(".css"),f=c?'[rel="stylesheet"]':"";if(document.querySelector(`link[href="${s}"]${f}`))return;const l=document.createElement("link");if(l.rel=c?"stylesheet":y,c||(l.as="script"),l.crossOrigin="",l.href=s,m&&l.setAttribute("nonce",m),document.head.appendChild(l),c)return new Promise((b,S)=>{l.addEventListener("load",b),l.addEventListener("error",()=>S(new Error(`Unable to preload CSS for ${s}`)))})}))}function d(o){const n=new Event("vite:preloadError",{cancelable:!0});if(n.payload=o,window.dispatchEvent(n),!n.defaultPrevented)throw o}return u.then(o=>{for(const n of o||[])n.status==="rejected"&&d(n.reason);return r().catch(d)})},i=(()=>{const e=document.querySelector('meta[name="pk-error-reporting"]');try{return e?JSON.parse(e.content):null}catch{return null}})(),v=["page","q","tab","sort"],P=new RegExp("(?<=\\/)[A-Za-z0-9_-]{32,}(?=\\/|$)","g"),w=/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi;function p(e){try{const r=new URL(e,window.location.origin);r.pathname=r.pathname.replace(P,"[Filtered]");for(const t of[...r.searchParams.keys()])!v.includes(t)&&!t.startsWith("utm_")&&r.searchParams.set(t,"[Filtered]");return r.hash="",r.toString().replace(w,"[email]")}catch{return"[Filtered]"}}let E=null;function R(){return E??(E=_(()=>import("./error-reporting-sdk-CLmutOST.js"),[]).then(e=>(e.init({dsn:i.dsn,release:i.release||void 0,environment:i.environment,sampleRate:i.sampleRate??1,sendDefaultPii:!1,integrations:r=>r.filter(t=>!["GlobalHandlers","Breadcrumbs","BrowserSession","BrowserApiErrors"].includes(t.name)),ignoreErrors:["ResizeObserver loop","Script error.","AbortError"],denyUrls:[/^chrome-extension:\/\//,/^moz-extension:\/\//,/^safari-(web-)?extension:\/\//],beforeSend(r){var t,a,u,d;(t=r.request)!=null&&t.url&&(r.request.url=p(r.request.url)),(u=(a=r.request)==null?void 0:a.headers)!=null&&u.Referer&&(r.request.headers.Referer=p(r.request.headers.Referer));for(const o of((d=r.exception)==null?void 0:d.values)??[])o.value&&(o.value=o.value.replace(w,"[email]"));return r}}),i.userId&&e.setUser({id:String(i.userId)}),e.setTag("area",i.area),e))),E}function k(e){var a;if(e instanceof Error)return e;const r=((a=e==null?void 0:e.constructor)==null?void 0:a.name)??typeof e;let t;try{t=typeof e=="string"?e:JSON.stringify(e)}catch{t=String(e)}return new Error(`Unbehandelte Ablehnung (${r}): ${String(t??"").slice(0,300)}`)}function g(e){R().then(r=>r.captureException(e)).catch(()=>{})}i!=null&&i.dsn&&(window.addEventListener("error",e=>{e.error&&g(e.error)}),window.addEventListener("unhandledrejection",e=>{g(k(e.reason))}));
|
||||
15
public/build/portal/error-reporting-sdk-CLmutOST.js
Normal file
15
public/build/portal/error-reporting-sdk-CLmutOST.js
Normal file
File diff suppressed because one or more lines are too long
28
public/build/portal/manifest.json
Normal file
28
public/build/portal/manifest.json
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"resources/css/portal.css": {
|
||||
"file": "portal-CtGNvLu1.css",
|
||||
"src": "resources/css/portal.css",
|
||||
"isEntry": true
|
||||
},
|
||||
"resources/js/app.js": {
|
||||
"file": "app-OG3MHWAj.js",
|
||||
"name": "app",
|
||||
"src": "resources/js/app.js",
|
||||
"isEntry": true
|
||||
},
|
||||
"resources/js/error-reporting-sdk.js": {
|
||||
"file": "error-reporting-sdk-CLmutOST.js",
|
||||
"name": "error-reporting-sdk",
|
||||
"src": "resources/js/error-reporting-sdk.js",
|
||||
"isDynamicEntry": true
|
||||
},
|
||||
"resources/js/error-reporting.js": {
|
||||
"file": "error-reporting-BiGrjXpD.js",
|
||||
"name": "error-reporting",
|
||||
"src": "resources/js/error-reporting.js",
|
||||
"isEntry": true,
|
||||
"dynamicImports": [
|
||||
"resources/js/error-reporting-sdk.js"
|
||||
]
|
||||
}
|
||||
}
|
||||
1
public/build/portal/portal-CtGNvLu1.css
Normal file
1
public/build/portal/portal-CtGNvLu1.css
Normal file
File diff suppressed because one or more lines are too long
1
public/build/stileigentum/app-OG3MHWAj.js
Normal file
1
public/build/stileigentum/app-OG3MHWAj.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
(function(){let o=null;document.readyState==="loading"?document.addEventListener("DOMContentLoaded",r):r();function r(){s(),u(),c(),document.addEventListener("livewire:navigated",function(){s()}),document.addEventListener("livewire:load",function(){s()}),window.addEventListener("livewire:update",function(){setTimeout(()=>{s()},100)})}function s(){const n={threshold:.15,rootMargin:"0px 0px -80px 0px"};o||(o=new IntersectionObserver(e=>{e.forEach(t=>{t.isIntersecting&&(setTimeout(()=>{t.target.classList.add("is-visible")},50),o.unobserve(t.target))})},n)),document.querySelectorAll(".scroll-animate:not(.is-visible):not(.observed), .fade-in:not(.is-visible):not(.observed), .slide-up:not(.is-visible):not(.observed), .slide-right:not(.is-visible):not(.observed), .slide-left:not(.is-visible):not(.observed), .scale-in:not(.is-visible):not(.observed), .slide-down:not(.is-visible):not(.observed)").forEach(e=>{e.classList.add("observed"),o.observe(e)}),window.appointmentLinksInitialized||(document.addEventListener("click",function(e){const t=e.target.closest("a[data-open-appointment]");t&&(e.preventDefault(),window.dispatchEvent(new CustomEvent("open-appointment",{detail:{topic:t.dataset.appointmentTopic||null}})))}),window.appointmentLinksInitialized=!0),window.smoothScrollInitialized||(document.addEventListener("click",function(e){const t=e.target.closest('a[href^="#"]');if(t&&t.hash){const l=document.querySelector(t.hash);if(l){e.preventDefault();const f=l.getBoundingClientRect().top+window.pageYOffset-80;window.scrollTo({top:f,behavior:"smooth"})}}}),window.smoothScrollInitialized=!0)}function c(){const n=document.getElementById("main-header");if(!n)return;function i(){(window.pageYOffset||document.documentElement.scrollTop)>50?n.classList.add("scrolled"):n.classList.remove("scrolled")}let e=!1;window.addEventListener("scroll",function(){e||(window.requestAnimationFrame(function(){i(),e=!1}),e=!0)}),i()}function u(){const n=document.createElement("div");n.className="scroll-progress-bar",document.body.appendChild(n);function i(){const t=window.innerHeight,l=document.documentElement.scrollHeight,d=window.pageYOffset||document.documentElement.scrollTop,a=d/(l-t)*100;n.style.width=`${Math.min(a,100)}%`,d>100?n.classList.add("visible"):n.classList.remove("visible")}let e=!1;window.addEventListener("scroll",function(){e||(window.requestAnimationFrame(function(){i(),e=!1}),e=!0)}),i()}})();
|
||||
1
public/build/stileigentum/error-reporting-BiGrjXpD.js
Normal file
1
public/build/stileigentum/error-reporting-BiGrjXpD.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
const y="modulepreload",A=function(e){return"https://b2in.test/build/"+e},h={},_=function(r,t,a){let u=Promise.resolve();if(t&&t.length>0){let o=function(s){return Promise.all(s.map(c=>Promise.resolve(c).then(f=>({status:"fulfilled",value:f}),f=>({status:"rejected",reason:f}))))};document.getElementsByTagName("link");const n=document.querySelector("meta[property=csp-nonce]"),m=(n==null?void 0:n.nonce)||(n==null?void 0:n.getAttribute("nonce"));u=o(t.map(s=>{if(s=A(s),s in h)return;h[s]=!0;const c=s.endsWith(".css"),f=c?'[rel="stylesheet"]':"";if(document.querySelector(`link[href="${s}"]${f}`))return;const l=document.createElement("link");if(l.rel=c?"stylesheet":y,c||(l.as="script"),l.crossOrigin="",l.href=s,m&&l.setAttribute("nonce",m),document.head.appendChild(l),c)return new Promise((b,S)=>{l.addEventListener("load",b),l.addEventListener("error",()=>S(new Error(`Unable to preload CSS for ${s}`)))})}))}function d(o){const n=new Event("vite:preloadError",{cancelable:!0});if(n.payload=o,window.dispatchEvent(n),!n.defaultPrevented)throw o}return u.then(o=>{for(const n of o||[])n.status==="rejected"&&d(n.reason);return r().catch(d)})},i=(()=>{const e=document.querySelector('meta[name="pk-error-reporting"]');try{return e?JSON.parse(e.content):null}catch{return null}})(),v=["page","q","tab","sort"],P=new RegExp("(?<=\\/)[A-Za-z0-9_-]{32,}(?=\\/|$)","g"),w=/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi;function p(e){try{const r=new URL(e,window.location.origin);r.pathname=r.pathname.replace(P,"[Filtered]");for(const t of[...r.searchParams.keys()])!v.includes(t)&&!t.startsWith("utm_")&&r.searchParams.set(t,"[Filtered]");return r.hash="",r.toString().replace(w,"[email]")}catch{return"[Filtered]"}}let E=null;function R(){return E??(E=_(()=>import("./error-reporting-sdk-CLmutOST.js"),[]).then(e=>(e.init({dsn:i.dsn,release:i.release||void 0,environment:i.environment,sampleRate:i.sampleRate??1,sendDefaultPii:!1,integrations:r=>r.filter(t=>!["GlobalHandlers","Breadcrumbs","BrowserSession","BrowserApiErrors"].includes(t.name)),ignoreErrors:["ResizeObserver loop","Script error.","AbortError"],denyUrls:[/^chrome-extension:\/\//,/^moz-extension:\/\//,/^safari-(web-)?extension:\/\//],beforeSend(r){var t,a,u,d;(t=r.request)!=null&&t.url&&(r.request.url=p(r.request.url)),(u=(a=r.request)==null?void 0:a.headers)!=null&&u.Referer&&(r.request.headers.Referer=p(r.request.headers.Referer));for(const o of((d=r.exception)==null?void 0:d.values)??[])o.value&&(o.value=o.value.replace(w,"[email]"));return r}}),i.userId&&e.setUser({id:String(i.userId)}),e.setTag("area",i.area),e))),E}function k(e){var a;if(e instanceof Error)return e;const r=((a=e==null?void 0:e.constructor)==null?void 0:a.name)??typeof e;let t;try{t=typeof e=="string"?e:JSON.stringify(e)}catch{t=String(e)}return new Error(`Unbehandelte Ablehnung (${r}): ${String(t??"").slice(0,300)}`)}function g(e){R().then(r=>r.captureException(e)).catch(()=>{})}i!=null&&i.dsn&&(window.addEventListener("error",e=>{e.error&&g(e.error)}),window.addEventListener("unhandledrejection",e=>{g(k(e.reason))}));
|
||||
15
public/build/stileigentum/error-reporting-sdk-CLmutOST.js
Normal file
15
public/build/stileigentum/error-reporting-sdk-CLmutOST.js
Normal file
File diff suppressed because one or more lines are too long
48
public/build/stileigentum/manifest.json
Normal file
48
public/build/stileigentum/manifest.json
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
{
|
||||
"resources/css/web/shared-styles.css": {
|
||||
"file": "shared-styles-BbGt7f_e.css",
|
||||
"src": "resources/css/web/shared-styles.css",
|
||||
"isEntry": true
|
||||
},
|
||||
"resources/css/web/theme-b2a.css": {
|
||||
"file": "theme-b2a-BCAFYDZJ.css",
|
||||
"src": "resources/css/web/theme-b2a.css",
|
||||
"isEntry": true
|
||||
},
|
||||
"resources/css/web/theme-b2in.css": {
|
||||
"file": "theme-b2in-DK_BFMo-.css",
|
||||
"src": "resources/css/web/theme-b2in.css",
|
||||
"isEntry": true
|
||||
},
|
||||
"resources/css/web/theme-stileigentum.css": {
|
||||
"file": "theme-stileigentum-skwGRiP0.css",
|
||||
"src": "resources/css/web/theme-stileigentum.css",
|
||||
"isEntry": true
|
||||
},
|
||||
"resources/css/web/theme-style2own.css": {
|
||||
"file": "theme-style2own-1F1Xts-B.css",
|
||||
"src": "resources/css/web/theme-style2own.css",
|
||||
"isEntry": true
|
||||
},
|
||||
"resources/js/app.js": {
|
||||
"file": "app-OG3MHWAj.js",
|
||||
"name": "app",
|
||||
"src": "resources/js/app.js",
|
||||
"isEntry": true
|
||||
},
|
||||
"resources/js/error-reporting-sdk.js": {
|
||||
"file": "error-reporting-sdk-CLmutOST.js",
|
||||
"name": "error-reporting-sdk",
|
||||
"src": "resources/js/error-reporting-sdk.js",
|
||||
"isDynamicEntry": true
|
||||
},
|
||||
"resources/js/error-reporting.js": {
|
||||
"file": "error-reporting-BiGrjXpD.js",
|
||||
"name": "error-reporting",
|
||||
"src": "resources/js/error-reporting.js",
|
||||
"isEntry": true,
|
||||
"dynamicImports": [
|
||||
"resources/js/error-reporting-sdk.js"
|
||||
]
|
||||
}
|
||||
}
|
||||
1
public/build/stileigentum/shared-styles-BbGt7f_e.css
Normal file
1
public/build/stileigentum/shared-styles-BbGt7f_e.css
Normal file
File diff suppressed because one or more lines are too long
1
public/build/stileigentum/theme-b2a-BCAFYDZJ.css
Normal file
1
public/build/stileigentum/theme-b2a-BCAFYDZJ.css
Normal file
File diff suppressed because one or more lines are too long
1
public/build/stileigentum/theme-b2in-DK_BFMo-.css
Normal file
1
public/build/stileigentum/theme-b2in-DK_BFMo-.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
public/build/stileigentum/theme-style2own-1F1Xts-B.css
Normal file
1
public/build/stileigentum/theme-style2own-1F1Xts-B.css
Normal file
File diff suppressed because one or more lines are too long
1
public/build/style2own/app-OG3MHWAj.js
Normal file
1
public/build/style2own/app-OG3MHWAj.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
(function(){let o=null;document.readyState==="loading"?document.addEventListener("DOMContentLoaded",r):r();function r(){s(),u(),c(),document.addEventListener("livewire:navigated",function(){s()}),document.addEventListener("livewire:load",function(){s()}),window.addEventListener("livewire:update",function(){setTimeout(()=>{s()},100)})}function s(){const n={threshold:.15,rootMargin:"0px 0px -80px 0px"};o||(o=new IntersectionObserver(e=>{e.forEach(t=>{t.isIntersecting&&(setTimeout(()=>{t.target.classList.add("is-visible")},50),o.unobserve(t.target))})},n)),document.querySelectorAll(".scroll-animate:not(.is-visible):not(.observed), .fade-in:not(.is-visible):not(.observed), .slide-up:not(.is-visible):not(.observed), .slide-right:not(.is-visible):not(.observed), .slide-left:not(.is-visible):not(.observed), .scale-in:not(.is-visible):not(.observed), .slide-down:not(.is-visible):not(.observed)").forEach(e=>{e.classList.add("observed"),o.observe(e)}),window.appointmentLinksInitialized||(document.addEventListener("click",function(e){const t=e.target.closest("a[data-open-appointment]");t&&(e.preventDefault(),window.dispatchEvent(new CustomEvent("open-appointment",{detail:{topic:t.dataset.appointmentTopic||null}})))}),window.appointmentLinksInitialized=!0),window.smoothScrollInitialized||(document.addEventListener("click",function(e){const t=e.target.closest('a[href^="#"]');if(t&&t.hash){const l=document.querySelector(t.hash);if(l){e.preventDefault();const f=l.getBoundingClientRect().top+window.pageYOffset-80;window.scrollTo({top:f,behavior:"smooth"})}}}),window.smoothScrollInitialized=!0)}function c(){const n=document.getElementById("main-header");if(!n)return;function i(){(window.pageYOffset||document.documentElement.scrollTop)>50?n.classList.add("scrolled"):n.classList.remove("scrolled")}let e=!1;window.addEventListener("scroll",function(){e||(window.requestAnimationFrame(function(){i(),e=!1}),e=!0)}),i()}function u(){const n=document.createElement("div");n.className="scroll-progress-bar",document.body.appendChild(n);function i(){const t=window.innerHeight,l=document.documentElement.scrollHeight,d=window.pageYOffset||document.documentElement.scrollTop,a=d/(l-t)*100;n.style.width=`${Math.min(a,100)}%`,d>100?n.classList.add("visible"):n.classList.remove("visible")}let e=!1;window.addEventListener("scroll",function(){e||(window.requestAnimationFrame(function(){i(),e=!1}),e=!0)}),i()}})();
|
||||
1
public/build/style2own/error-reporting-BiGrjXpD.js
Normal file
1
public/build/style2own/error-reporting-BiGrjXpD.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
const y="modulepreload",A=function(e){return"https://b2in.test/build/"+e},h={},_=function(r,t,a){let u=Promise.resolve();if(t&&t.length>0){let o=function(s){return Promise.all(s.map(c=>Promise.resolve(c).then(f=>({status:"fulfilled",value:f}),f=>({status:"rejected",reason:f}))))};document.getElementsByTagName("link");const n=document.querySelector("meta[property=csp-nonce]"),m=(n==null?void 0:n.nonce)||(n==null?void 0:n.getAttribute("nonce"));u=o(t.map(s=>{if(s=A(s),s in h)return;h[s]=!0;const c=s.endsWith(".css"),f=c?'[rel="stylesheet"]':"";if(document.querySelector(`link[href="${s}"]${f}`))return;const l=document.createElement("link");if(l.rel=c?"stylesheet":y,c||(l.as="script"),l.crossOrigin="",l.href=s,m&&l.setAttribute("nonce",m),document.head.appendChild(l),c)return new Promise((b,S)=>{l.addEventListener("load",b),l.addEventListener("error",()=>S(new Error(`Unable to preload CSS for ${s}`)))})}))}function d(o){const n=new Event("vite:preloadError",{cancelable:!0});if(n.payload=o,window.dispatchEvent(n),!n.defaultPrevented)throw o}return u.then(o=>{for(const n of o||[])n.status==="rejected"&&d(n.reason);return r().catch(d)})},i=(()=>{const e=document.querySelector('meta[name="pk-error-reporting"]');try{return e?JSON.parse(e.content):null}catch{return null}})(),v=["page","q","tab","sort"],P=new RegExp("(?<=\\/)[A-Za-z0-9_-]{32,}(?=\\/|$)","g"),w=/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi;function p(e){try{const r=new URL(e,window.location.origin);r.pathname=r.pathname.replace(P,"[Filtered]");for(const t of[...r.searchParams.keys()])!v.includes(t)&&!t.startsWith("utm_")&&r.searchParams.set(t,"[Filtered]");return r.hash="",r.toString().replace(w,"[email]")}catch{return"[Filtered]"}}let E=null;function R(){return E??(E=_(()=>import("./error-reporting-sdk-CLmutOST.js"),[]).then(e=>(e.init({dsn:i.dsn,release:i.release||void 0,environment:i.environment,sampleRate:i.sampleRate??1,sendDefaultPii:!1,integrations:r=>r.filter(t=>!["GlobalHandlers","Breadcrumbs","BrowserSession","BrowserApiErrors"].includes(t.name)),ignoreErrors:["ResizeObserver loop","Script error.","AbortError"],denyUrls:[/^chrome-extension:\/\//,/^moz-extension:\/\//,/^safari-(web-)?extension:\/\//],beforeSend(r){var t,a,u,d;(t=r.request)!=null&&t.url&&(r.request.url=p(r.request.url)),(u=(a=r.request)==null?void 0:a.headers)!=null&&u.Referer&&(r.request.headers.Referer=p(r.request.headers.Referer));for(const o of((d=r.exception)==null?void 0:d.values)??[])o.value&&(o.value=o.value.replace(w,"[email]"));return r}}),i.userId&&e.setUser({id:String(i.userId)}),e.setTag("area",i.area),e))),E}function k(e){var a;if(e instanceof Error)return e;const r=((a=e==null?void 0:e.constructor)==null?void 0:a.name)??typeof e;let t;try{t=typeof e=="string"?e:JSON.stringify(e)}catch{t=String(e)}return new Error(`Unbehandelte Ablehnung (${r}): ${String(t??"").slice(0,300)}`)}function g(e){R().then(r=>r.captureException(e)).catch(()=>{})}i!=null&&i.dsn&&(window.addEventListener("error",e=>{e.error&&g(e.error)}),window.addEventListener("unhandledrejection",e=>{g(k(e.reason))}));
|
||||
15
public/build/style2own/error-reporting-sdk-CLmutOST.js
Normal file
15
public/build/style2own/error-reporting-sdk-CLmutOST.js
Normal file
File diff suppressed because one or more lines are too long
48
public/build/style2own/manifest.json
Normal file
48
public/build/style2own/manifest.json
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
{
|
||||
"resources/css/web/shared-styles.css": {
|
||||
"file": "shared-styles-BbGt7f_e.css",
|
||||
"src": "resources/css/web/shared-styles.css",
|
||||
"isEntry": true
|
||||
},
|
||||
"resources/css/web/theme-b2a.css": {
|
||||
"file": "theme-b2a-BCAFYDZJ.css",
|
||||
"src": "resources/css/web/theme-b2a.css",
|
||||
"isEntry": true
|
||||
},
|
||||
"resources/css/web/theme-b2in.css": {
|
||||
"file": "theme-b2in-DK_BFMo-.css",
|
||||
"src": "resources/css/web/theme-b2in.css",
|
||||
"isEntry": true
|
||||
},
|
||||
"resources/css/web/theme-stileigentum.css": {
|
||||
"file": "theme-stileigentum-skwGRiP0.css",
|
||||
"src": "resources/css/web/theme-stileigentum.css",
|
||||
"isEntry": true
|
||||
},
|
||||
"resources/css/web/theme-style2own.css": {
|
||||
"file": "theme-style2own-1F1Xts-B.css",
|
||||
"src": "resources/css/web/theme-style2own.css",
|
||||
"isEntry": true
|
||||
},
|
||||
"resources/js/app.js": {
|
||||
"file": "app-OG3MHWAj.js",
|
||||
"name": "app",
|
||||
"src": "resources/js/app.js",
|
||||
"isEntry": true
|
||||
},
|
||||
"resources/js/error-reporting-sdk.js": {
|
||||
"file": "error-reporting-sdk-CLmutOST.js",
|
||||
"name": "error-reporting-sdk",
|
||||
"src": "resources/js/error-reporting-sdk.js",
|
||||
"isDynamicEntry": true
|
||||
},
|
||||
"resources/js/error-reporting.js": {
|
||||
"file": "error-reporting-BiGrjXpD.js",
|
||||
"name": "error-reporting",
|
||||
"src": "resources/js/error-reporting.js",
|
||||
"isEntry": true,
|
||||
"dynamicImports": [
|
||||
"resources/js/error-reporting-sdk.js"
|
||||
]
|
||||
}
|
||||
}
|
||||
1
public/build/style2own/shared-styles-BbGt7f_e.css
Normal file
1
public/build/style2own/shared-styles-BbGt7f_e.css
Normal file
File diff suppressed because one or more lines are too long
1
public/build/style2own/theme-b2a-BCAFYDZJ.css
Normal file
1
public/build/style2own/theme-b2a-BCAFYDZJ.css
Normal file
File diff suppressed because one or more lines are too long
1
public/build/style2own/theme-b2in-DK_BFMo-.css
Normal file
1
public/build/style2own/theme-b2in-DK_BFMo-.css
Normal file
File diff suppressed because one or more lines are too long
1
public/build/style2own/theme-stileigentum-skwGRiP0.css
Normal file
1
public/build/style2own/theme-stileigentum-skwGRiP0.css
Normal file
File diff suppressed because one or more lines are too long
1
public/build/style2own/theme-style2own-1F1Xts-B.css
Normal file
1
public/build/style2own/theme-style2own-1F1Xts-B.css
Normal file
File diff suppressed because one or more lines are too long
5
resources/js/error-reporting-sdk.js
Normal file
5
resources/js/error-reporting-sdk.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
/**
|
||||
* Nur die benötigten SDK-Funktionen — der dynamische Import in
|
||||
* error-reporting.js zieht so nicht das ganze Paket (Replay, Feedback …) nach.
|
||||
*/
|
||||
export { captureException, init, setTag, setUser } from '@sentry/browser';
|
||||
128
resources/js/error-reporting.js
Normal file
128
resources/js/error-reporting.js
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
/**
|
||||
* Browserfehler an GlitchTip.
|
||||
*
|
||||
* Fehlerfreie Seiten laden nur diese paar Zeilen: Das Sentry-SDK wird erst
|
||||
* beim ersten Fehler nachgeladen (eigener Chunk) und meldet dann genau diesen
|
||||
* Fehler. Konfiguration kommt aus <meta name="pk-error-reporting">, das die
|
||||
* Blade-Komponente x-monitoring.browser-errors setzt.
|
||||
*
|
||||
* Fehlgeschlagene Livewire-Requests (5xx, 419) meldet bereits der Server.
|
||||
*/
|
||||
const config = (() => {
|
||||
const meta = document.querySelector('meta[name="pk-error-reporting"]');
|
||||
|
||||
try {
|
||||
return meta ? JSON.parse(meta.content) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
const ALLOWED_QUERY = ['page', 'q', 'tab', 'sort']; // wie ErrorReportScrubber::AllowedQueryParameters
|
||||
const TOKEN_SEGMENT = /(?<=\/)[A-Za-z0-9_-]{32,}(?=\/|$)/g;
|
||||
const EMAIL = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi;
|
||||
|
||||
/** Gleiche Regeln wie ErrorReportScrubber auf dem Server. */
|
||||
function scrubUrl(value) {
|
||||
try {
|
||||
const url = new URL(value, window.location.origin);
|
||||
url.pathname = url.pathname.replace(TOKEN_SEGMENT, '[Filtered]');
|
||||
for (const key of [...url.searchParams.keys()]) {
|
||||
if (!ALLOWED_QUERY.includes(key) && !key.startsWith('utm_')) {
|
||||
url.searchParams.set(key, '[Filtered]');
|
||||
}
|
||||
}
|
||||
url.hash = '';
|
||||
|
||||
return url.toString().replace(EMAIL, '[email]');
|
||||
} catch {
|
||||
return '[Filtered]';
|
||||
}
|
||||
}
|
||||
|
||||
let sdk = null;
|
||||
|
||||
function loadSdk() {
|
||||
sdk ??= import('./error-reporting-sdk.js').then((Sentry) => {
|
||||
Sentry.init({
|
||||
dsn: config.dsn,
|
||||
release: config.release || undefined,
|
||||
environment: config.environment,
|
||||
sampleRate: config.sampleRate ?? 1,
|
||||
sendDefaultPii: false,
|
||||
// Eigene Listener unten statt GlobalHandlers (sonst doppelt);
|
||||
// keine Breadcrumbs (Klicktexte, URLs) und keine Sessions.
|
||||
integrations: (defaults) =>
|
||||
defaults.filter(
|
||||
(integration) =>
|
||||
!['GlobalHandlers', 'Breadcrumbs', 'BrowserSession', 'BrowserApiErrors'].includes(integration.name),
|
||||
),
|
||||
ignoreErrors: ['ResizeObserver loop', 'Script error.', 'AbortError'],
|
||||
denyUrls: [/^chrome-extension:\/\//, /^moz-extension:\/\//, /^safari-(web-)?extension:\/\//],
|
||||
beforeSend(event) {
|
||||
if (event.request?.url) {
|
||||
event.request.url = scrubUrl(event.request.url);
|
||||
}
|
||||
if (event.request?.headers?.Referer) {
|
||||
event.request.headers.Referer = scrubUrl(event.request.headers.Referer);
|
||||
}
|
||||
for (const exception of event.exception?.values ?? []) {
|
||||
if (exception.value) {
|
||||
exception.value = exception.value.replace(EMAIL, '[email]');
|
||||
}
|
||||
}
|
||||
|
||||
return event;
|
||||
},
|
||||
});
|
||||
|
||||
if (config.userId) {
|
||||
Sentry.setUser({ id: String(config.userId) });
|
||||
}
|
||||
Sentry.setTag('area', config.area);
|
||||
|
||||
return Sentry;
|
||||
});
|
||||
|
||||
return sdk;
|
||||
}
|
||||
|
||||
/**
|
||||
* Promise-Ablehnungen mit Objekt statt Error kamen als „Error: [object Object]“
|
||||
* an — Typ und Inhalt mitschicken,
|
||||
* damit die Quelle erkennbar ist.
|
||||
*/
|
||||
function rejectionToError(reason) {
|
||||
if (reason instanceof Error) {
|
||||
return reason;
|
||||
}
|
||||
|
||||
const kind = reason?.constructor?.name ?? typeof reason;
|
||||
let text;
|
||||
try {
|
||||
text = typeof reason === 'string' ? reason : JSON.stringify(reason);
|
||||
} catch {
|
||||
text = String(reason);
|
||||
}
|
||||
|
||||
return new Error(`Unbehandelte Ablehnung (${kind}): ${String(text ?? '').slice(0, 300)}`);
|
||||
}
|
||||
|
||||
function capture(error) {
|
||||
loadSdk()
|
||||
.then((Sentry) => Sentry.captureException(error))
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
if (config?.dsn) {
|
||||
window.addEventListener('error', (event) => {
|
||||
// Ressourcenfehler (Bild, Skript) haben kein `error` — nur echte Skriptfehler.
|
||||
if (event.error) {
|
||||
capture(event.error);
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
capture(rejectionToError(event.reason));
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
@props(['build' => 'build', 'area' => 'web'])
|
||||
{{-- Browserfehler an GlitchTip. Ohne SENTRY_BROWSER_DSN
|
||||
wird nichts eingebunden; das SDK selbst lädt erst beim ersten Fehler. --}}
|
||||
@if (filled(config('monitoring.browser.dsn')))
|
||||
<meta name="pk-error-reporting" content="{{ json_encode([
|
||||
'dsn' => config('monitoring.browser.dsn'),
|
||||
'release' => config('sentry.release'),
|
||||
'environment' => config('sentry.environment') ?? app()->environment(),
|
||||
'sampleRate' => config('monitoring.browser.sample_rate'),
|
||||
'userId' => auth()->id(),
|
||||
'area' => $area,
|
||||
]) }}" />
|
||||
@vite(['resources/js/error-reporting.js'], $build)
|
||||
@endif
|
||||
47
resources/views/flux/icon/book-open-text.blade.php
Normal file
47
resources/views/flux/icon/book-open-text.blade.php
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
{{-- Credit: Lucide (https://lucide.dev) --}}
|
||||
|
||||
@props([
|
||||
'variant' => 'outline',
|
||||
])
|
||||
|
||||
@php
|
||||
if ($variant === 'solid') {
|
||||
throw new \Exception('The "solid" variant is not supported in Lucide.');
|
||||
}
|
||||
|
||||
$classes = Flux::classes('shrink-0')->add(
|
||||
match ($variant) {
|
||||
'outline' => '[:where(&)]:size-6',
|
||||
'solid' => '[:where(&)]:size-6',
|
||||
'mini' => '[:where(&)]:size-5',
|
||||
'micro' => '[:where(&)]:size-4',
|
||||
},
|
||||
);
|
||||
|
||||
$strokeWidth = match ($variant) {
|
||||
'outline' => 2,
|
||||
'mini' => 2.25,
|
||||
'micro' => 2.5,
|
||||
};
|
||||
@endphp
|
||||
|
||||
<svg
|
||||
{{ $attributes->class($classes) }}
|
||||
data-flux-icon
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="{{ $strokeWidth }}"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
data-slot="icon"
|
||||
>
|
||||
<path d="M12 7v14" />
|
||||
<path d="M16 12h2" />
|
||||
<path d="M16 8h2" />
|
||||
<path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z" />
|
||||
<path d="M6 12h2" />
|
||||
<path d="M6 8h2" />
|
||||
</svg>
|
||||
43
resources/views/flux/icon/chevrons-up-down.blade.php
Normal file
43
resources/views/flux/icon/chevrons-up-down.blade.php
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
{{-- Credit: Lucide (https://lucide.dev) --}}
|
||||
|
||||
@props([
|
||||
'variant' => 'outline',
|
||||
])
|
||||
|
||||
@php
|
||||
if ($variant === 'solid') {
|
||||
throw new \Exception('The "solid" variant is not supported in Lucide.');
|
||||
}
|
||||
|
||||
$classes = Flux::classes('shrink-0')->add(
|
||||
match ($variant) {
|
||||
'outline' => '[:where(&)]:size-6',
|
||||
'solid' => '[:where(&)]:size-6',
|
||||
'mini' => '[:where(&)]:size-5',
|
||||
'micro' => '[:where(&)]:size-4',
|
||||
},
|
||||
);
|
||||
|
||||
$strokeWidth = match ($variant) {
|
||||
'outline' => 2,
|
||||
'mini' => 2.25,
|
||||
'micro' => 2.5,
|
||||
};
|
||||
@endphp
|
||||
|
||||
<svg
|
||||
{{ $attributes->class($classes) }}
|
||||
data-flux-icon
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="{{ $strokeWidth }}"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
data-slot="icon"
|
||||
>
|
||||
<path d="m7 15 5 5 5-5" />
|
||||
<path d="m7 9 5-5 5 5" />
|
||||
</svg>
|
||||
45
resources/views/flux/icon/folder-git-2.blade.php
Normal file
45
resources/views/flux/icon/folder-git-2.blade.php
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
{{-- Credit: Lucide (https://lucide.dev) --}}
|
||||
|
||||
@props([
|
||||
'variant' => 'outline',
|
||||
])
|
||||
|
||||
@php
|
||||
if ($variant === 'solid') {
|
||||
throw new \Exception('The "solid" variant is not supported in Lucide.');
|
||||
}
|
||||
|
||||
$classes = Flux::classes('shrink-0')->add(
|
||||
match ($variant) {
|
||||
'outline' => '[:where(&)]:size-6',
|
||||
'solid' => '[:where(&)]:size-6',
|
||||
'mini' => '[:where(&)]:size-5',
|
||||
'micro' => '[:where(&)]:size-4',
|
||||
},
|
||||
);
|
||||
|
||||
$strokeWidth = match ($variant) {
|
||||
'outline' => 2,
|
||||
'mini' => 2.25,
|
||||
'micro' => 2.5,
|
||||
};
|
||||
@endphp
|
||||
|
||||
<svg
|
||||
{{ $attributes->class($classes) }}
|
||||
data-flux-icon
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="{{ $strokeWidth }}"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
data-slot="icon"
|
||||
>
|
||||
<path d="M9 20H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H20a2 2 0 0 1 2 2v5" />
|
||||
<circle cx="13" cy="12" r="2" />
|
||||
<path d="M18 19c-2.8 0-5-2.2-5-5v8" />
|
||||
<circle cx="20" cy="19" r="2" />
|
||||
</svg>
|
||||
45
resources/views/flux/icon/layout-grid.blade.php
Normal file
45
resources/views/flux/icon/layout-grid.blade.php
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
{{-- Credit: Lucide (https://lucide.dev) --}}
|
||||
|
||||
@props([
|
||||
'variant' => 'outline',
|
||||
])
|
||||
|
||||
@php
|
||||
if ($variant === 'solid') {
|
||||
throw new \Exception('The "solid" variant is not supported in Lucide.');
|
||||
}
|
||||
|
||||
$classes = Flux::classes('shrink-0')->add(
|
||||
match ($variant) {
|
||||
'outline' => '[:where(&)]:size-6',
|
||||
'solid' => '[:where(&)]:size-6',
|
||||
'mini' => '[:where(&)]:size-5',
|
||||
'micro' => '[:where(&)]:size-4',
|
||||
},
|
||||
);
|
||||
|
||||
$strokeWidth = match ($variant) {
|
||||
'outline' => 2,
|
||||
'mini' => 2.25,
|
||||
'micro' => 2.5,
|
||||
};
|
||||
@endphp
|
||||
|
||||
<svg
|
||||
{{ $attributes->class($classes) }}
|
||||
data-flux-icon
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="{{ $strokeWidth }}"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
data-slot="icon"
|
||||
>
|
||||
<rect width="7" height="7" x="3" y="3" rx="1" />
|
||||
<rect width="7" height="7" x="14" y="3" rx="1" />
|
||||
<rect width="7" height="7" x="14" y="14" rx="1" />
|
||||
<rect width="7" height="7" x="3" y="14" rx="1" />
|
||||
</svg>
|
||||
|
|
@ -24,6 +24,7 @@
|
|||
<meta name="theme-color" content="#ffffff">
|
||||
|
||||
@vite(['resources/css/portal.css', 'resources/js/app.js'], 'build/portal')
|
||||
<x-monitoring.browser-errors build="build/portal" area="portal" />
|
||||
|
||||
@livewireStyles
|
||||
@fluxAppearance
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@
|
|||
\App\Helpers\ThemeHelper::getThemeCssPath(),
|
||||
'resources/js/app.js'
|
||||
], 'build/' . $theme)
|
||||
<x-monitoring.browser-errors :build="'build/' . $theme" area="web" />
|
||||
|
||||
<!-- Sticky Header Script -->
|
||||
<script>
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@
|
|||
\App\Helpers\ThemeHelper::getThemeCssPath(),
|
||||
'resources/js/app.js'
|
||||
], 'build/' . $theme)
|
||||
<x-monitoring.browser-errors :build="'build/' . $theme" area="web" />
|
||||
|
||||
<!-- Sticky Header Script -->
|
||||
<script>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
<?php
|
||||
|
||||
use App\Console\Commands\MonitoringHeartbeat;
|
||||
use App\Support\OperationalAlert;
|
||||
use Illuminate\Console\Scheduling\Event;
|
||||
use Illuminate\Foundation\Inspiring;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Schedule;
|
||||
|
|
@ -8,4 +11,25 @@ Artisan::command('inspire', function () {
|
|||
$this->comment(Inspiring::quote());
|
||||
})->purpose('Display an inspiring quote');
|
||||
|
||||
Schedule::command('cabinet:reset-overrides')->dailyAt('00:00');
|
||||
// Betriebs-Alert, wenn ein geplanter Task fehlschlägt – an JEDEN Task hängen
|
||||
$alertOnFailure = fn (string $task) => function (Event $event) use ($task): void {
|
||||
OperationalAlert::send("Geplanter Task fehlgeschlagen: {$task}", ['exit_code' => $event->exitCode]);
|
||||
};
|
||||
|
||||
Schedule::command('cabinet:reset-overrides')
|
||||
->dailyAt('00:00')
|
||||
->withoutOverlapping(10)
|
||||
->onFailure($alertOnFailure('cabinet:reset-overrides'));
|
||||
|
||||
// Herzschlag: fällt der Scheduler aus (Cron weg, Server hängt), greift kein
|
||||
// onFailure mehr – dann meldet /health den fehlenden Herzschlag
|
||||
Schedule::command(MonitoringHeartbeat::class)
|
||||
->everyMinute()
|
||||
->withoutOverlapping(2)
|
||||
->onFailure($alertOnFailure('monitoring:heartbeat'));
|
||||
|
||||
// Fehlgeschlagene Jobs nach 30 Tagen entfernen
|
||||
Schedule::command('queue:prune-failed', ['--hours=720'])
|
||||
->dailyAt('03:55')
|
||||
->withoutOverlapping(30)
|
||||
->onFailure($alertOnFailure('queue:prune-failed'));
|
||||
|
|
|
|||
|
|
@ -63,6 +63,17 @@ it('leaves the other module settings alone', function () {
|
|||
expect($settings['show_footer'])->toBeFalse();
|
||||
});
|
||||
|
||||
it('keeps modules that already carry the interior logo, including a claim maintained since', function () {
|
||||
$module = DisplayVersion::factory()->create([
|
||||
'type' => DisplayVersionType::B2in,
|
||||
'settings' => ['theme' => 'dark', 'header_logo_url' => '/_cabinet/assets/b2in-interior-logo-negative.svg', 'header_claim' => 'Eigener Claim'],
|
||||
]);
|
||||
|
||||
$this->seed(\Database\Seeders\DisplayInteriorBrandingSeeder::class);
|
||||
|
||||
expect($module->fresh()->settings['header_claim'])->toBe('Eigener Claim');
|
||||
});
|
||||
|
||||
it('does not touch modules of other types', function () {
|
||||
$video = DisplayVersion::factory()->create([
|
||||
'type' => DisplayVersionType::VideoDisplay,
|
||||
|
|
|
|||
88
tests/Feature/InteriorReleaseSeederTest.php
Normal file
88
tests/Feature/InteriorReleaseSeederTest.php
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\DisplayVersionType;
|
||||
use App\Models\Display;
|
||||
use App\Models\DisplayMedia;
|
||||
use App\Models\DisplayPlaylist;
|
||||
use App\Models\DisplayVersion;
|
||||
use App\Models\DisplayVersionItem;
|
||||
use Database\Seeders\InteriorReleaseSeeder;
|
||||
use FluxCms\Core\Models\CmsContent;
|
||||
use FluxCms\Core\Models\CmsMedia;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* Release-Seeder für Vorschau und Live (08.10.2026): bringt den alten Website-Stand auf den
|
||||
* neuen, lässt Displays und Mediathek stehen und ändert beim zweiten Lauf nichts mehr.
|
||||
*/
|
||||
beforeEach(function () {
|
||||
Storage::fake('public');
|
||||
});
|
||||
|
||||
/**
|
||||
* Inhalt der Tabellen, die der Release-Seeder beschreibt – ohne Zeitstempel.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function releaseSnapshot(): array
|
||||
{
|
||||
$tables = ['flux_cms_contents', 'cms_brands', 'cms_articles', 'flux_cms_media', 'display_versions', 'display_version_items', 'display_media', 'displays'];
|
||||
|
||||
return collect($tables)->mapWithKeys(fn (string $table): array => [
|
||||
$table => DB::table($table)->orderBy('id')->get()
|
||||
->map(fn (object $row): array => collect((array) $row)->except(['created_at', 'updated_at'])->all())
|
||||
->all(),
|
||||
])->all();
|
||||
}
|
||||
|
||||
it('brings the old website content up to date and keeps the displays and the media library', function () {
|
||||
// Alter Website-Stand, wie er auf Live liegt
|
||||
CmsContent::create(['group' => 'home', 'key' => 'hero', 'type' => 'json', 'order' => 0, 'value' => [
|
||||
'de' => ['title' => 'Connecting Design and Property', 'subtitle' => 'Alt'],
|
||||
]]);
|
||||
|
||||
// Seit der Vorschau auf Live gepflegt: Display, Modul, Playlist, Display-Mediathek, CMS-Mediathek
|
||||
$module = DisplayVersion::factory()->create([
|
||||
'type' => DisplayVersionType::B2in,
|
||||
'settings' => ['theme' => 'dark', 'header_logo_url' => '/_cabinet/assets/b2in-interior-logo-negative.svg', 'header_claim' => 'Herbstaktion'],
|
||||
]);
|
||||
$item = DisplayVersionItem::factory()->create([
|
||||
'display_version_id' => $module->id,
|
||||
'content' => ['image_url' => '/storage/display-media/herbst.webp', 'duration' => 8],
|
||||
]);
|
||||
$display = Display::factory()->create(['name' => 'Kundenstopper']);
|
||||
$playlist = DisplayPlaylist::factory()->create(['display_id' => $display->id]);
|
||||
$displayMedia = DisplayMedia::factory()->create(['filename' => 'herbst.webp']);
|
||||
$upload = CmsMedia::create([
|
||||
'filename' => 'kunde-upload.jpg', 'disk' => 'public', 'path' => 'cms/media/originals/kunde.jpg',
|
||||
'type' => 'image', 'mime_type' => 'image/jpeg', 'file_size' => 1, 'collection' => 'Website', 'conversions' => [],
|
||||
]);
|
||||
|
||||
$this->seed(InteriorReleaseSeeder::class);
|
||||
|
||||
$hero = CmsContent::query()->where('group', 'home')->where('key', 'hero')->first()->getTranslation('value', 'de');
|
||||
expect($hero['title'])->not->toContain('Connecting Design')
|
||||
->and($hero['slides'])->toHaveCount(11);
|
||||
|
||||
expect($module->fresh()->settings['header_claim'])->toBe('Herbstaktion')
|
||||
->and($item->fresh()->content)->toBe(['image_url' => '/storage/display-media/herbst.webp', 'duration' => 8])
|
||||
->and($display->fresh()->name)->toBe('Kundenstopper')
|
||||
->and($playlist->fresh())->not->toBeNull()
|
||||
->and($displayMedia->fresh()->filename)->toBe('herbst.webp')
|
||||
->and($upload->fresh())->not->toBeNull()
|
||||
->and(CmsMedia::query()->where('filename', 'b2in/slideshow/rotpunkt-fenix-house.webp')->exists())->toBeTrue();
|
||||
|
||||
$this->get('/')->assertSuccessful()->assertSee('B2in Interior');
|
||||
$this->get('/marken')->assertSuccessful();
|
||||
$this->get('/about')->assertSuccessful();
|
||||
});
|
||||
|
||||
it('changes nothing on a second run', function () {
|
||||
$this->seed(InteriorReleaseSeeder::class);
|
||||
$first = releaseSnapshot();
|
||||
|
||||
$this->seed(InteriorReleaseSeeder::class);
|
||||
|
||||
expect(releaseSnapshot())->toEqual($first);
|
||||
});
|
||||
76
tests/Feature/Monitoring/B2inMonitoringWiringTest.php
Normal file
76
tests/Feature/Monitoring/B2inMonitoringWiringTest.php
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
<?php
|
||||
|
||||
use App\Services\Monitoring\HealthProbe;
|
||||
use Illuminate\Console\Scheduling\Schedule;
|
||||
|
||||
/**
|
||||
* Einbau des Monitorings in B2in (08.10.2026): /health auf jeder Domain, auch hinter der
|
||||
* Basic Auth, Browserfehler in Web und Portal, jeder geplante Task mit Alarm.
|
||||
*/
|
||||
beforeEach(function () {
|
||||
config()->set('monitoring.health.token', 'b2in-monitor-token');
|
||||
config()->set('monitoring.health.disk_min_free_percent', 0);
|
||||
app(HealthProbe::class)->recordHeartbeat();
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
@unlink(HealthProbe::heartbeatPath());
|
||||
});
|
||||
|
||||
it('answers the deep health check on every domain', function (string $host) {
|
||||
$this->get("https://{$host}/health", ['Authorization' => 'Bearer b2in-monitor-token'])
|
||||
->assertOk()
|
||||
->assertJsonPath('status', 'ok');
|
||||
|
||||
$this->get("https://{$host}/health")->assertNotFound();
|
||||
})->with(['b2in.test', 'portal.b2in.test', 'stileigentum.test']);
|
||||
|
||||
it('lets the monitor past the basic auth', function () {
|
||||
config()->set('auth.basic', ['user' => 'kunde', 'password' => 'geheim']);
|
||||
|
||||
$request = Illuminate\Http\Request::create('https://irgendwo.example/health');
|
||||
$passed = (new App\Http\Middleware\BasicAuthMiddleware)->handle($request, fn () => response('weiter'));
|
||||
|
||||
expect($passed->getContent())->toBe('weiter');
|
||||
|
||||
$other = Illuminate\Http\Request::create('https://irgendwo.example/admin');
|
||||
expect((new App\Http\Middleware\BasicAuthMiddleware)->handle($other, fn () => response('weiter'))->getStatusCode())->toBe(401);
|
||||
});
|
||||
|
||||
it('embeds browser error reporting in the website only with a dsn', function () {
|
||||
$this->get('/')->assertSuccessful()->assertDontSee('pk-error-reporting', false);
|
||||
|
||||
config()->set('monitoring.browser.dsn', 'https://public@errors.example.com/2');
|
||||
|
||||
$this->get('/')->assertSuccessful()
|
||||
->assertSee('name="pk-error-reporting"', false)
|
||||
->assertSee('"area":"web"', false);
|
||||
});
|
||||
|
||||
it('builds the error reporting script for the website and the portal', function () {
|
||||
expect(file_get_contents(base_path('vite.web.config.js')))->toContain('"resources/js/error-reporting.js"')
|
||||
->and(file_get_contents(base_path('vite.portal.config.js')))->toContain('"resources/js/error-reporting.js"')
|
||||
->and(file_get_contents(resource_path('views/partials/head.blade.php')))
|
||||
->toContain('<x-monitoring.browser-errors build="build/portal" area="portal" />');
|
||||
});
|
||||
|
||||
it('alerts on every scheduled task and keeps the heartbeat running', function () {
|
||||
$events = collect(app(Schedule::class)->events());
|
||||
|
||||
expect($events->map->command->implode("\n"))
|
||||
->toContain('monitoring:heartbeat')
|
||||
->toContain('queue:prune-failed')
|
||||
->toContain('cabinet:reset-overrides');
|
||||
|
||||
foreach ($events as $event) {
|
||||
$callbacks = (fn () => $this->afterCallbacks)->call($event);
|
||||
|
||||
expect($callbacks)->not->toBeEmpty("{$event->command} hat keinen Alarm bei Fehlschlag.");
|
||||
}
|
||||
});
|
||||
|
||||
it('shows the delivered version in artisan about', function () {
|
||||
config()->set('app.version', 'v2.0.0');
|
||||
|
||||
$this->artisan('about', ['--only' => 'b2in'])->expectsOutputToContain('v2.0.0')->assertSuccessful();
|
||||
});
|
||||
38
tests/Feature/Monitoring/BrowserErrorReportingTest.php
Normal file
38
tests/Feature/Monitoring/BrowserErrorReportingTest.php
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
|
||||
/**
|
||||
* Browserfehler: Meta-Konfiguration und Skript nur mit
|
||||
* DSN; die User-ID ist der einzige Personenbezug.
|
||||
*/
|
||||
beforeEach(function () {
|
||||
$this->withoutVite();
|
||||
});
|
||||
|
||||
test('nothing is rendered without a browser dsn', function () {
|
||||
config()->set('monitoring.browser.dsn', null);
|
||||
|
||||
expect(Blade::render('<x-monitoring.browser-errors />'))->toBe('');
|
||||
});
|
||||
|
||||
test('the configuration carries dsn, release and the user id only', function () {
|
||||
config()->set('monitoring.browser.dsn', 'https://public@glitchtip.test/2');
|
||||
config()->set('sentry.release', 'abc1234');
|
||||
$user = User::factory()->create(['email' => 'kunde@example.com']);
|
||||
$this->actingAs($user);
|
||||
|
||||
$html = Blade::render('<x-monitoring.browser-errors build="build/portal" area="portal" />');
|
||||
|
||||
preg_match('/<meta name="pk-error-reporting" content="([^"]+)"/', $html, $match);
|
||||
$config = json_decode(html_entity_decode($match[1]), true);
|
||||
|
||||
expect($config)->toMatchArray([
|
||||
'dsn' => 'https://public@glitchtip.test/2',
|
||||
'release' => 'abc1234',
|
||||
'userId' => $user->id,
|
||||
'area' => 'portal',
|
||||
])
|
||||
->and($html)->not->toContain('kunde@example.com');
|
||||
});
|
||||
95
tests/Feature/Monitoring/ErrorReportingTest.php
Normal file
95
tests/Feature/Monitoring/ErrorReportingTest.php
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use App\Support\Monitoring\ErrorReportScrubber;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Sentry\Event;
|
||||
use Sentry\ExceptionDataBag;
|
||||
use Sentry\SentrySdk;
|
||||
use Symfony\Component\Console\Exception\CommandNotFoundException;
|
||||
use Tests\Support\FakeGlitchTip;
|
||||
|
||||
/**
|
||||
* GlitchTip-Anbindung: gemeldete Exceptions gehen
|
||||
* raus, aber nur mit User-ID, ohne Tokens und ohne E-Mail-Adressen.
|
||||
*/
|
||||
test('reported exceptions reach glitchtip with the user id only', function () {
|
||||
$glitchTip = FakeGlitchTip::install();
|
||||
$user = User::factory()->create(['email' => 'kunde@example.com']);
|
||||
$this->actingAs($user);
|
||||
|
||||
report(new RuntimeException('Import für kunde@example.com fehlgeschlagen'));
|
||||
|
||||
expect($glitchTip->events)->toHaveCount(1);
|
||||
|
||||
$event = $glitchTip->events[0];
|
||||
expect($event->getUser()?->getId())->toBe($user->id)
|
||||
->and($event->getUser()?->getEmail())->toBeNull()
|
||||
->and($event->getExceptions()[0]->getValue())->toBe('Import für [email] fehlgeschlagen');
|
||||
});
|
||||
|
||||
test('exceptions laravel does not report stay out of glitchtip', function () {
|
||||
$glitchTip = FakeGlitchTip::install();
|
||||
|
||||
$this->get('/diese-seite-gibt-es-nicht')->assertNotFound();
|
||||
|
||||
expect($glitchTip->events)->toBe([]);
|
||||
});
|
||||
|
||||
test('a mistyped artisan command is not reported', function () {
|
||||
$glitchTip = FakeGlitchTip::install();
|
||||
|
||||
report(new CommandNotFoundException('Command "config:cachels" is not defined.'));
|
||||
|
||||
expect($glitchTip->events)->toBe([]);
|
||||
});
|
||||
|
||||
test('request data loses tokens, signatures and bodies', function () {
|
||||
$scrubbed = ErrorReportScrubber::scrubRequest([
|
||||
'url' => 'https://app.test/einladung/5?signature=abc123&expires=1700000000&page=2',
|
||||
'query_string' => 'signature=abc123&expires=1700000000&page=2&utm_source=nl',
|
||||
'data' => ['password' => 'geheim'],
|
||||
'cookies' => ['app_session' => 'x'],
|
||||
'headers' => ['referer' => ['https://app.test/reset-password/'.str_repeat('a', 64).'?email=kunde@example.com']],
|
||||
]);
|
||||
|
||||
expect($scrubbed['url'])->toBe('https://app.test/einladung/5?signature=%5BFiltered%5D&expires=%5BFiltered%5D&page=2')
|
||||
->and($scrubbed['query_string'])->toBe('signature=%5BFiltered%5D&expires=%5BFiltered%5D&page=2&utm_source=nl')
|
||||
->and($scrubbed)->not->toHaveKeys(['data', 'cookies'])
|
||||
->and($scrubbed['headers']['referer'][0])->toBe('https://app.test/reset-password/[Filtered]?email=%5BFiltered%5D');
|
||||
});
|
||||
|
||||
test('route parameters named like secrets are removed from the url', function () {
|
||||
Route::middleware('web')->get('/_test/magic/{token}', function () {
|
||||
$event = Event::createEvent();
|
||||
$event->setRequest(['url' => request()->fullUrl()]);
|
||||
|
||||
return ErrorReportScrubber::beforeSend($event)?->getRequest()['url'];
|
||||
});
|
||||
|
||||
$this->get('/_test/magic/kurz123')
|
||||
->assertSuccessful()
|
||||
->assertSee('/_test/magic/[Filtered]', false)
|
||||
->assertDontSee('kurz123');
|
||||
});
|
||||
|
||||
test('messages and exception values lose e-mail addresses', function () {
|
||||
$event = Event::createEvent();
|
||||
$event->setMessage('Bounce von info@firma.de', [], 'Bounce von info@firma.de');
|
||||
$event->setExceptions([new ExceptionDataBag(new RuntimeException('Duplicate entry \'a.b@c.org\''))]);
|
||||
|
||||
$scrubbed = ErrorReportScrubber::beforeSend($event);
|
||||
|
||||
expect($scrubbed?->getMessage())->toBe('Bounce von [email]')
|
||||
->and($scrubbed?->getMessageFormatted())->toBe('Bounce von [email]')
|
||||
->and($scrubbed?->getExceptions()[0]->getValue())->toBe('Duplicate entry \'[email]\'')
|
||||
->and($scrubbed?->getUser())->toBeNull();
|
||||
});
|
||||
|
||||
test('the test suite never reports to the real glitchtip', function () {
|
||||
// Ohne diese Absicherung landeten absichtlich provozierte Fehler
|
||||
// („Queue weg“, „Datenbank nicht erreichbar“) als Issues in GlitchTip.
|
||||
expect(config('sentry.dsn'))->toBeEmpty()
|
||||
->and(config('monitoring.browser.dsn'))->toBeEmpty()
|
||||
->and(SentrySdk::getCurrentHub()->getClient()?->getOptions()->getDsn())->toBeNull();
|
||||
});
|
||||
71
tests/Feature/Monitoring/ErrorSignalsTest.php
Normal file
71
tests/Feature/Monitoring/ErrorSignalsTest.php
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use App\Support\Monitoring\ErrorSignal;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Tests\Support\FakeGlitchTip;
|
||||
|
||||
/**
|
||||
* Fehler ohne Exception: tote interne Links,
|
||||
* 403/429 für Angemeldete und 419 werden als GlitchTip-Warnung gemeldet.
|
||||
*/
|
||||
beforeEach(function () {
|
||||
$this->withoutDefer();
|
||||
$this->glitchTip = FakeGlitchTip::install();
|
||||
|
||||
Route::middleware('web')->get('/_test/forbidden', fn () => abort(403))->name('test.forbidden');
|
||||
Route::middleware('web')->get('/_test/expired', fn () => abort(419))->name('test.expired');
|
||||
Route::middleware('web')->get('/_test/throttled', fn () => abort(429))->name('test.throttled');
|
||||
});
|
||||
|
||||
test('a 404 reached from one of our own pages is reported as a dead internal link', function () {
|
||||
$this->get('/gibt-es-nicht', ['referer' => url('/de/presse')])->assertNotFound();
|
||||
|
||||
expect($this->glitchTip->messages())->toBe(['Toter interner Link: /gibt-es-nicht']);
|
||||
|
||||
$event = $this->glitchTip->events[0];
|
||||
expect($event->getTags())->toMatchArray(['signal' => ErrorSignal::DeadInternalLink])
|
||||
->and($event->getFingerprint())->toBe(['error-signal', ErrorSignal::DeadInternalLink, '/gibt-es-nicht'])
|
||||
->and($event->getContexts()['signal']['referer'])->toBe('/de/presse');
|
||||
});
|
||||
|
||||
test('a 404 without referer or from another site is not reported', function () {
|
||||
$this->get('/gibt-es-nicht')->assertNotFound();
|
||||
$this->get('/gibt-es-nicht', ['referer' => 'https://google.com/search'])->assertNotFound();
|
||||
|
||||
expect($this->glitchTip->events)->toBe([]);
|
||||
});
|
||||
|
||||
test('403 and 429 are reported only for signed-in users', function () {
|
||||
$this->get('/_test/forbidden')->assertForbidden();
|
||||
$this->get('/_test/throttled')->assertTooManyRequests();
|
||||
|
||||
expect($this->glitchTip->events)->toBe([]);
|
||||
|
||||
$this->actingAs(User::factory()->create());
|
||||
$this->get('/_test/forbidden')->assertForbidden();
|
||||
$this->get('/_test/throttled')->assertTooManyRequests();
|
||||
|
||||
expect($this->glitchTip->messages())->toBe([
|
||||
'Zugriff verweigert (angemeldet): test.forbidden',
|
||||
'Drosselung trifft angemeldeten Nutzer: test.throttled',
|
||||
]);
|
||||
});
|
||||
|
||||
test('an expired page is always reported, for livewire with the page as location', function () {
|
||||
$this->get('/_test/expired')->assertStatus(419);
|
||||
$this->get('/_test/expired', ['X-Livewire' => '1', 'referer' => url('/me/pressemitteilungen/neu')])->assertStatus(419);
|
||||
|
||||
expect($this->glitchTip->messages())->toBe([
|
||||
'Seite abgelaufen (419): test.expired',
|
||||
'Seite abgelaufen (419): livewire /me/pressemitteilungen/neu',
|
||||
]);
|
||||
});
|
||||
|
||||
test('each signal group is capped at five reports per window', function () {
|
||||
foreach (range(1, 7) as $attempt) {
|
||||
$this->get('/_test/expired');
|
||||
}
|
||||
|
||||
expect($this->glitchTip->events)->toHaveCount(5);
|
||||
});
|
||||
91
tests/Feature/Monitoring/HealthEndpointTest.php
Normal file
91
tests/Feature/Monitoring/HealthEndpointTest.php
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
<?php
|
||||
|
||||
use App\Services\Monitoring\HealthProbe;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Tiefer Health-Check und Scheduler-Herzschlag. Der Herzschlag liegt in
|
||||
* einer Datei unter storage/ — vor und nach jedem Test entfernen.
|
||||
*/
|
||||
beforeEach(function () {
|
||||
@unlink(HealthProbe::heartbeatPath());
|
||||
config()->set('monitoring.health.token', 'geheimes-monitor-token');
|
||||
config()->set('monitoring.health.disk_min_free_percent', 0);
|
||||
config()->set('queue.default', 'database');
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
@unlink(HealthProbe::heartbeatPath());
|
||||
});
|
||||
|
||||
function healthHeaders(): array
|
||||
{
|
||||
return ['Authorization' => 'Bearer geheimes-monitor-token'];
|
||||
}
|
||||
|
||||
test('the endpoint does not exist without a configured token or with a wrong one', function () {
|
||||
app(HealthProbe::class)->recordHeartbeat();
|
||||
|
||||
$this->get('/health', healthHeaders())->assertOk();
|
||||
|
||||
$this->get('/health', ['Authorization' => 'Bearer falsch'])->assertNotFound();
|
||||
$this->get('/health')->assertNotFound();
|
||||
|
||||
config()->set('monitoring.health.token', null);
|
||||
$this->get('/health', healthHeaders())->assertNotFound();
|
||||
});
|
||||
|
||||
test('a healthy system answers 200 with every check', function () {
|
||||
config()->set('app.version', 'v1.1.0');
|
||||
$this->artisan('monitoring:heartbeat')->assertSuccessful();
|
||||
|
||||
$this->get('/health?token=geheimes-monitor-token')
|
||||
->assertOk()
|
||||
->assertHeader('Cache-Control', 'no-store, private')
|
||||
->assertJsonPath('status', 'ok')
|
||||
->assertJsonPath('version', 'v1.1.0')
|
||||
->assertJsonPath('checks.database.status', 'ok')
|
||||
->assertJsonPath('checks.cache.status', 'ok')
|
||||
->assertJsonPath('checks.queue.status', 'ok')
|
||||
->assertJsonPath('checks.scheduler.status', 'ok')
|
||||
->assertJsonStructure(['checks' => ['disk' => ['status', 'detail']]]);
|
||||
});
|
||||
|
||||
test('a missing or stale heartbeat makes the endpoint fail', function () {
|
||||
$this->get('/health', healthHeaders())
|
||||
->assertStatus(503)
|
||||
->assertJsonPath('checks.scheduler.status', 'critical');
|
||||
|
||||
app(HealthProbe::class)->recordHeartbeat();
|
||||
|
||||
$this->travel(4)->minutes();
|
||||
$this->get('/health', healthHeaders())->assertOk()->assertJsonPath('checks.scheduler.status', 'warn');
|
||||
|
||||
$this->travel(7)->minutes();
|
||||
$this->get('/health', healthHeaders())->assertStatus(503)->assertJsonPath('checks.scheduler.status', 'critical');
|
||||
});
|
||||
|
||||
test('a queue backlog older than five minutes makes the endpoint fail', function () {
|
||||
app(HealthProbe::class)->recordHeartbeat();
|
||||
|
||||
DB::table('jobs')->insert([
|
||||
'queue' => 'default',
|
||||
'payload' => '{}',
|
||||
'attempts' => 0,
|
||||
'reserved_at' => null,
|
||||
'available_at' => now()->subMinutes(6)->getTimestamp(),
|
||||
'created_at' => now()->subMinutes(6)->getTimestamp(),
|
||||
]);
|
||||
|
||||
$this->get('/health', healthHeaders())
|
||||
->assertStatus(503)
|
||||
->assertJsonPath('checks.queue.status', 'critical');
|
||||
});
|
||||
|
||||
test('the endpoint skips the session and the web middleware group', function () {
|
||||
app(HealthProbe::class)->recordHeartbeat();
|
||||
|
||||
$this->get('/health', healthHeaders())
|
||||
->assertOk()
|
||||
->assertCookieMissing(config('session.cookie'));
|
||||
});
|
||||
82
tests/Feature/Monitoring/OperationalAlertTest.php
Normal file
82
tests/Feature/Monitoring/OperationalAlertTest.php
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
<?php
|
||||
|
||||
use App\Support\OperationalAlert;
|
||||
use Illuminate\Contracts\Queue\Job;
|
||||
use Illuminate\Http\Client\Request;
|
||||
use Illuminate\Mail\Events\MessageSent;
|
||||
use Illuminate\Queue\Events\JobFailed;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* Betriebs-Alerts: Log immer, Mail und ntfy unabhängig voneinander und
|
||||
* Best-Effort — ein ausgefallener Kanal darf den auslösenden Prozess nie stören.
|
||||
*/
|
||||
beforeEach(function () {
|
||||
config()->set('monitoring.alerts.enabled', true);
|
||||
config()->set('monitoring.alerts.mail_to', null);
|
||||
config()->set('monitoring.alerts.ntfy', ['url' => null, 'topic' => null, 'token' => null, 'priority' => 4]);
|
||||
});
|
||||
|
||||
test('an alert always logs critical to the monitoring channel', function () {
|
||||
$monitoringLog = Mockery::spy(LoggerInterface::class);
|
||||
Log::shouldReceive('channel')->with('monitoring')->andReturn($monitoringLog);
|
||||
|
||||
OperationalAlert::send('Geplanter Task fehlgeschlagen: demo', ['exit_code' => 1]);
|
||||
|
||||
$monitoringLog->shouldHaveReceived('critical')->once();
|
||||
});
|
||||
|
||||
test('an alert mails the configured recipient', function () {
|
||||
config()->set('monitoring.alerts.mail_to', 'ops@example.com');
|
||||
Event::fake([MessageSent::class]);
|
||||
|
||||
OperationalAlert::send('Etwas ist kaputt', ['exit_code' => 1]);
|
||||
|
||||
Event::assertDispatched(MessageSent::class, function (MessageSent $event): bool {
|
||||
$recipients = array_map(fn ($address): string => $address->getAddress(), $event->message->getTo());
|
||||
|
||||
return in_array('ops@example.com', $recipients, true)
|
||||
&& str_contains((string) $event->message->getSubject(), 'Etwas ist kaputt');
|
||||
});
|
||||
});
|
||||
|
||||
test('an alert is pushed to ntfy as json with token and priority', function () {
|
||||
config()->set('monitoring.alerts.ntfy', ['url' => 'https://push.example.com/', 'topic' => 'app-ops', 'token' => 'tk_geheim', 'priority' => 4]);
|
||||
Http::fake(['push.example.com*' => Http::response(['id' => 'x'])]);
|
||||
|
||||
OperationalAlert::send('Queue-Job fehlgeschlagen: Demo', ['exit_code' => 1]);
|
||||
|
||||
Http::assertSent(fn (Request $request): bool => $request->url() === 'https://push.example.com'
|
||||
&& $request->hasHeader('Authorization', 'Bearer tk_geheim')
|
||||
&& $request['topic'] === 'app-ops'
|
||||
&& $request['priority'] === 4
|
||||
&& str_contains($request['message'], 'exit_code: 1'));
|
||||
});
|
||||
|
||||
test('a failing push never bubbles up and nothing is sent when alerts are off', function () {
|
||||
config()->set('monitoring.alerts.ntfy', ['url' => 'https://push.example.com', 'topic' => 'app-ops', 'token' => null, 'priority' => 4]);
|
||||
Http::fake(['push.example.com*' => Http::response('down', 502)]);
|
||||
|
||||
OperationalAlert::send('Etwas ist kaputt');
|
||||
Http::assertSentCount(1);
|
||||
|
||||
config()->set('monitoring.alerts.enabled', false);
|
||||
OperationalAlert::send('Etwas ist kaputt');
|
||||
Http::assertSentCount(1);
|
||||
});
|
||||
|
||||
test('a failed queue job triggers an alert via the registered listener', function () {
|
||||
config()->set('monitoring.alerts.ntfy', ['url' => 'https://push.example.com', 'topic' => 'app-ops', 'token' => null, 'priority' => 4]);
|
||||
Http::fake();
|
||||
|
||||
$job = Mockery::mock(Job::class);
|
||||
$job->shouldReceive('resolveName')->andReturn('App\\Jobs\\Demo');
|
||||
$job->shouldReceive('getQueue')->andReturn('default');
|
||||
|
||||
event(new JobFailed('database', $job, new RuntimeException('boom')));
|
||||
|
||||
Http::assertSent(fn (Request $request): bool => str_contains($request['message'], 'App\\Jobs\\Demo'));
|
||||
});
|
||||
39
tests/Feature/Monitoring/QueueProbeTest.php
Normal file
39
tests/Feature/Monitoring/QueueProbeTest.php
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
|
||||
use App\Jobs\Monitoring\QueueProbe;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Sleep;
|
||||
|
||||
/**
|
||||
* Worker-Prüfung am Ende von deploy.sh: ohne laufenden Worker (Supervisor
|
||||
* gestoppt) soll der Deploy das sofort melden.
|
||||
*/
|
||||
test('a running worker picks up the probe job', function () {
|
||||
Sleep::fake();
|
||||
|
||||
$this->artisan('monitoring:queue-probe', ['--wait' => 30])
|
||||
->expectsOutputToContain('Queue-Worker läuft (Test-Job nach 0 s abgeholt).')
|
||||
->assertSuccessful();
|
||||
|
||||
Sleep::assertSleptTimes(0);
|
||||
});
|
||||
|
||||
test('without a worker the probe fails after the waiting time with a hint', function () {
|
||||
Sleep::fake();
|
||||
Queue::fake();
|
||||
|
||||
$this->artisan('monitoring:queue-probe', ['--wait' => 5])
|
||||
->expectsOutputToContain('Kein Queue-Worker hat den Test-Job innerhalb von 5 s abgeholt.')
|
||||
->expectsOutputToContain('Worker prüfen')
|
||||
->assertFailed();
|
||||
|
||||
Queue::assertPushed(QueueProbe::class, fn (QueueProbe $job): bool => $job->queue === null);
|
||||
Sleep::assertSleptTimes(5);
|
||||
});
|
||||
|
||||
test('the probe job only acknowledges its own token', function () {
|
||||
(new QueueProbe('abc'))->handle();
|
||||
|
||||
expect(cache()->has(QueueProbe::cacheKey('abc')))->toBeTrue()
|
||||
->and(cache()->has(QueueProbe::cacheKey('xyz')))->toBeFalse();
|
||||
});
|
||||
63
tests/Support/FakeGlitchTip.php
Normal file
63
tests/Support/FakeGlitchTip.php
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Support;
|
||||
|
||||
use Sentry\ClientBuilder;
|
||||
use Sentry\Event;
|
||||
use Sentry\SentrySdk;
|
||||
use Sentry\Transport\Result;
|
||||
use Sentry\Transport\ResultStatus;
|
||||
use Sentry\Transport\TransportInterface;
|
||||
|
||||
/**
|
||||
* Fängt Sentry-Events ab, statt sie an GlitchTip zu senden. Der Client wird
|
||||
* mit den Datenschutz-Optionen aus config/sentry.php gebaut, damit Tests den
|
||||
* echten `before_send`-Weg sehen.
|
||||
*
|
||||
* Nutzung: $glitchTip = FakeGlitchTip::install(); … $glitchTip->events
|
||||
*/
|
||||
class FakeGlitchTip implements TransportInterface
|
||||
{
|
||||
/** @var list<Event> */
|
||||
public array $events = [];
|
||||
|
||||
public static function install(): self
|
||||
{
|
||||
$transport = new self;
|
||||
|
||||
$client = ClientBuilder::create([
|
||||
'dsn' => 'https://public@glitchtip.test/1',
|
||||
'default_integrations' => false,
|
||||
'send_default_pii' => config('sentry.send_default_pii'),
|
||||
'max_request_body_size' => config('sentry.max_request_body_size'),
|
||||
'before_send' => config('sentry.before_send'),
|
||||
])->setTransport($transport)->getClient();
|
||||
|
||||
SentrySdk::getCurrentHub()->bindClient($client);
|
||||
|
||||
return $transport;
|
||||
}
|
||||
|
||||
public function send(Event $event): Result
|
||||
{
|
||||
$this->events[] = $event;
|
||||
|
||||
return new Result(ResultStatus::success(), $event);
|
||||
}
|
||||
|
||||
public function close(?int $timeout = null): Result
|
||||
{
|
||||
return new Result(ResultStatus::success());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return array_values(array_map(
|
||||
static fn (Event $event): string => (string) ($event->getMessage() ?? $event->getExceptions()[0]?->getValue()),
|
||||
$this->events,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -6,5 +6,11 @@ use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
|
|||
|
||||
abstract class TestCase extends BaseTestCase
|
||||
{
|
||||
//
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Eigene Herzschlag-Datei je Testprozess – parallele Läufe kommen sich sonst in die Quere.
|
||||
config()->set('monitoring.scheduler.heartbeat_path', sys_get_temp_dir().'/b2in-test-heartbeat-'.getmypid());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
38
tests/Unit/Monitoring/ReleaseTest.php
Normal file
38
tests/Unit/Monitoring/ReleaseTest.php
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
use App\Support\Monitoring\Release;
|
||||
|
||||
/**
|
||||
* Release-Kennung für GlitchTip: SENTRY_RELEASE vor der
|
||||
* beim SFTP-Deploy hochgeladenen Datei RELEASE.
|
||||
*/
|
||||
beforeEach(function () {
|
||||
$this->dir = sys_get_temp_dir().'/release-test-'.uniqid();
|
||||
mkdir($this->dir);
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
@unlink($this->dir.'/RELEASE');
|
||||
@rmdir($this->dir);
|
||||
});
|
||||
|
||||
test('the configured value wins over the release file', function () {
|
||||
file_put_contents($this->dir.'/RELEASE', "abc1234\n");
|
||||
|
||||
expect(Release::current('v1.2.0', $this->dir))->toBe('v1.2.0');
|
||||
});
|
||||
|
||||
test('the release file is used when nothing is configured', function () {
|
||||
file_put_contents($this->dir.'/RELEASE', "98c465b-dirty\n");
|
||||
|
||||
expect(Release::current(null, $this->dir))->toBe('98c465b-dirty')
|
||||
->and(Release::current(' ', $this->dir))->toBe('98c465b-dirty');
|
||||
});
|
||||
|
||||
test('without file or with garbage there is no release', function () {
|
||||
expect(Release::current(null, $this->dir))->toBeNull();
|
||||
|
||||
file_put_contents($this->dir.'/RELEASE', '<git rev-parse --short HEAD> # bei jedem Deploy');
|
||||
|
||||
expect(Release::current(null, $this->dir))->toBeNull();
|
||||
});
|
||||
|
|
@ -15,7 +15,7 @@ const httpsConfig =
|
|||
export default defineConfig({
|
||||
plugins: [
|
||||
laravel({
|
||||
input: ["resources/css/portal.css", "resources/js/app.js"],
|
||||
input: ["resources/css/portal.css", "resources/js/app.js", "resources/js/error-reporting.js"],
|
||||
refresh: ["resources/views/portal/**/*.blade.php"],
|
||||
}),
|
||||
tailwindcss({
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ export default defineConfig({
|
|||
"resources/css/web/theme-stileigentum.css",
|
||||
"resources/css/web/theme-style2own.css",
|
||||
"resources/js/app.js",
|
||||
"resources/js/error-reporting.js",
|
||||
],
|
||||
refresh: ["resources/views/web/**/*.blade.php"],
|
||||
}),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue