diff --git a/.env.example b/.env.example index d31934453..d2dc2202f 100644 --- a/.env.example +++ b/.env.example @@ -103,7 +103,8 @@ LOG_STACK=single PASSWORD_HASH=argon2id BROADCAST_CONNECTION=log -CACHE_STORE=redis +# Use failover_redis_file so Cache falls back to disk when Redis is unreachable (set CACHE_STORE=redis for strict Redis-only) +CACHE_STORE=failover_redis_file QUEUE_CONNECTION=sync REDIS_HOST=127.0.0.1 @@ -111,11 +112,20 @@ REDIS_USERNAME=default REDIS_PASSWORD=null REDIS_PORT=6379 REDIS_CLIENT=phpredis +REDIS_CONNECT_TIMEOUT=2 +REDIS_READ_TIMEOUT=2 + +# Status probes: skip Redis check when cache/session/queue do not use Redis (set false to always probe) +STATUS_PROBE_REDIS=true +STATUS_PROBE_REDIS_ONLY_WHEN_USED=true # Horizon (Redis queue dashboard and workers) HORIZON_PREFIX=horizon: SESSION_DRIVER=redis +# Fast TCP probe before other middleware; switch cache+session to file when Redis host is unreachable (set false to disable) +REDIS_FAST_DEGRADE=true +REDIS_TCP_CHECK_SECONDS=0.2 SESSION_DOMAIN=null SESSION_SECURE_COOKIE=false SESSION_ENCRYPT=false diff --git a/app/Console/Commands/CheckServiceHealth.php b/app/Console/Commands/CheckServiceHealth.php index d405a16eb..75a7fa4d4 100644 --- a/app/Console/Commands/CheckServiceHealth.php +++ b/app/Console/Commands/CheckServiceHealth.php @@ -29,7 +29,20 @@ class CheckServiceHealth extends Command foreach ($services as $service) { if ($service->endpoint_url === null || $service->endpoint_url === '') { - $rows[] = [$service->name, '—', '—', 'Skipped (no endpoint)']; + if ($service->check_type === 'probe') { + $result = $statusService->checkServiceHealth($service); + $statusService->handleHealthCheckResult($service, $result); + + $rows[] = [ + $service->name, + strtoupper($service->check_type), + $result['status_code'] ?: '—', + $result['response_time_ms'] ? $result['response_time_ms'].'ms' : '—', + $result['ok'] ? 'Healthy' : $result['reason'], + ]; + } else { + $rows[] = [$service->name, strtoupper($service->check_type), '—', '—', 'Skipped (no endpoint)']; + } continue; } @@ -39,6 +52,7 @@ class CheckServiceHealth extends Command $rows[] = [ $service->name, + strtoupper($service->check_type), $result['status_code'] ?: '—', $result['response_time_ms'] ? $result['response_time_ms'].'ms' : '—', $result['ok'] ? 'Healthy' : $result['reason'], @@ -47,7 +61,7 @@ class CheckServiceHealth extends Command $statusService->refreshAllServiceUptime($days); - $this->table(['Service', 'HTTP', 'Response', 'Result'], $rows); + $this->table(['Service', 'Type', 'HTTP', 'Response', 'Result'], $rows); $services = $statusService->getEnabledServices(); $summary = $services->map(fn ($s) => [ diff --git a/app/Http/Controllers/BasePageController.php b/app/Http/Controllers/BasePageController.php index dc4f8ad76..f59ddbf59 100644 --- a/app/Http/Controllers/BasePageController.php +++ b/app/Http/Controllers/BasePageController.php @@ -11,6 +11,7 @@ use Illuminate\Http\Request; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Log; use Illuminate\View\View; class BasePageController extends Controller @@ -59,7 +60,7 @@ class BasePageController extends Controller $this->middleware(['auth', 'web', '2fa'])->except('api', 'contact', 'showContactForm', 'callback', 'btcPayCallback', 'getNzb', 'terms', 'privacyPolicy', 'capabilities', 'movie', 'apiSearch', 'tv', 'details', 'failed', 'showRssDesc', 'fullFeedRss', 'categoryFeedRss', 'cartRss', 'myMoviesRss', 'myShowsRss', 'trendingMoviesRss', 'trendingShowsRss', 'release', 'reset', 'showLinkRequestForm', 'showStatusPage'); // Load settings as collection with caching (5 minutes) - $this->settings = Cache::remember('site_settings', 300, function () { + $this->settings = $this->rememberWithCacheFallback('site_settings', 300, function () { return Settings::query()->pluck('value', 'name'); }); @@ -69,7 +70,7 @@ class BasePageController extends Controller ]; // Then add the converted settings array as 'site' with caching - $this->viewData['site'] = Cache::remember('site_settings_converted', 300, function () { + $this->viewData['site'] = $this->rememberWithCacheFallback('site_settings_converted', 300, function () { return $this->settings->map(function ($value) { return Settings::convertValue($value); })->all(); @@ -81,7 +82,7 @@ class BasePageController extends Controller $userId = Auth::id(); $this->userdata = User::find($userId); // Cache category exclusions per user (5 minutes) - $this->userdata->categoryexclusions = Cache::remember( + $this->userdata->categoryexclusions = $this->rememberWithCacheFallback( 'user_category_exclusions_'.$userId, 300, fn () => User::getCategoryExclusionById($userId) @@ -92,6 +93,24 @@ class BasePageController extends Controller }); } + /** + * Use the cache when available; if the store is unreachable (e.g. Redis down), run the callback directly. + * + * @param callable(): mixed $callback + */ + private function rememberWithCacheFallback(string $key, int|\DateInterval $ttl, callable $callback): mixed + { + try { + return Cache::remember($key, $ttl, $callback); + } catch (\Throwable $e) { + if (config('app.debug')) { + Log::debug('BasePageController cache bypassed: '.$e->getMessage()); + } + + return $callback(); + } + } + /** * @return LengthAwarePaginator */ diff --git a/app/Http/Middleware/DegradeWhenRedisUnreachable.php b/app/Http/Middleware/DegradeWhenRedisUnreachable.php new file mode 100644 index 000000000..6ff4c65f2 --- /dev/null +++ b/app/Http/Middleware/DegradeWhenRedisUnreachable.php @@ -0,0 +1,180 @@ +runningUnitTests()) { + return $next($request); + } + + if (! $this->shouldProbeRedis()) { + return $next($request); + } + + $target = $this->redisTcpTarget(); + if ($target === null) { + return $next($request); + } + + if ($this->tcpReachable($target['remote'], (float) config('nntmux.redis_tcp_check_seconds', 0.2))) { + return $next($request); + } + + $request->attributes->set('redis_degrade_original_config', [ + 'cache.default' => config('cache.default'), + 'session.driver' => config('session.driver'), + ]); + + $this->degradeAwayFromRedis(); + + return $next($request); + } + + public function terminate(Request $request, Response $response): void + { + $snapshot = $request->attributes->get('redis_degrade_original_config'); + if (! is_array($snapshot)) { + return; + } + + foreach ($snapshot as $key => $value) { + Config::set($key, $value); + } + } + + private function runningUnitTests(): bool + { + return app()->environment('testing'); + } + + private function shouldProbeRedis(): bool + { + $cacheDefault = (string) config('cache.default', ''); + if ($cacheDefault === 'redis' || $cacheDefault === 'failover_redis_file' || $this->failoverChainContainsRedis($cacheDefault)) { + return true; + } + + return config('session.driver') === 'redis'; + } + + private function failoverChainContainsRedis(string $storeName): bool + { + $store = config('cache.stores.'.$storeName); + if (! is_array($store) || ($store['driver'] ?? '') !== 'failover') { + return false; + } + + foreach ($store['stores'] ?? [] as $sub) { + if (strtolower((string) $sub) === 'redis') { + return true; + } + } + + return false; + } + + private function degradeAwayFromRedis(): void + { + $cacheDefault = (string) config('cache.default', ''); + if ($cacheDefault === 'redis' + || $cacheDefault === 'failover_redis_file' + || $this->failoverChainContainsRedis($cacheDefault)) { + Config::set('cache.default', 'file'); + } + + if (config('session.driver') === 'redis') { + Config::set('session.driver', 'file'); + } + } + + /** + * @return array{remote: string}|null PHP stream remote URL (tcp:// or ssl://) + */ + private function redisTcpTarget(): ?array + { + $cfg = config('database.redis.default'); + if (! is_array($cfg)) { + return null; + } + + if (! empty($cfg['url'])) { + $parts = parse_url((string) $cfg['url']); + if ($parts === false) { + return null; + } + $host = $parts['host'] ?? null; + if ($host === null || $host === '') { + return null; + } + $port = (int) ($parts['port'] ?? 6379); + $scheme = strtolower((string) ($parts['scheme'] ?? 'tcp')); + + return ['remote' => $this->streamRemote($scheme, $host, $port)]; + } + + $host = (string) ($cfg['host'] ?? '127.0.0.1'); + if ($host === '' || str_starts_with($host, '/')) { + return null; + } + + $port = (int) ($cfg['port'] ?? 6379); + + return ['remote' => $this->streamRemote('tcp', $host, $port)]; + } + + private function streamRemote(string $scheme, string $host, int $port): string + { + if ($scheme === 'tls' || $scheme === 'ssl' || $scheme === 'rediss') { + return 'ssl://'.$host.':'.$port; + } + + $wrapped = str_contains($host, ':') && ! str_starts_with($host, '[') + ? '['.$host.']' + : $host; + + return 'tcp://'.$wrapped.':'.$port; + } + + private function tcpReachable(string $remote, float $timeoutSeconds): bool + { + $errno = 0; + $errstr = ''; + $socket = @stream_socket_client( + $remote, + $errno, + $errstr, + $timeoutSeconds, + STREAM_CLIENT_CONNECT + ); + + if (is_resource($socket)) { + fclose($socket); + + return true; + } + + return false; + } +} diff --git a/app/Models/ServiceStatus.php b/app/Models/ServiceStatus.php index 0ac65c432..141b7bb7b 100644 --- a/app/Models/ServiceStatus.php +++ b/app/Models/ServiceStatus.php @@ -14,6 +14,8 @@ use Illuminate\Support\Carbon; * @property string $name * @property string $slug * @property string|null $endpoint_url + * @property string $check_type + * @property string|null $probe_identifier * @property ServiceStatusEnum $status * @property Carbon|null $last_checked_at * @property string $uptime_percentage @@ -32,6 +34,8 @@ class ServiceStatus extends Model 'name', 'slug', 'endpoint_url', + 'check_type', + 'probe_identifier', 'status', 'last_checked_at', 'uptime_percentage', diff --git a/app/Services/NfoService.php b/app/Services/NfoService.php index dd9c11065..fefc7ced2 100644 --- a/app/Services/NfoService.php +++ b/app/Services/NfoService.php @@ -108,24 +108,24 @@ class NfoService protected const SETTINGS_CACHE_TTL = 300; /** - * @var int Number of NFOs to process per batch. + * Lazily loaded from settings + cache when NFO processing runs (see getters). */ - private int $nzbs; + private ?int $nzbs = null; /** - * @var int Maximum release size to process NFO (in GB). + * Lazily loaded from settings + cache when NFO processing runs. */ - protected int $maxSize; + protected ?int $maxSize = null; /** - * @var int Maximum retry attempts for failed NFO fetches. + * Lazily loaded from settings + cache when NFO processing runs. */ - private int $maxRetries; + private ?int $maxRetries = null; /** - * @var int Minimum release size to process NFO (in MB). + * Lazily loaded from settings + cache when NFO processing runs. */ - protected int $minSize; + protected ?int $minSize = null; /** * @var string Temporary path for processing files. @@ -156,42 +156,86 @@ class NfoService /** * Default constructor. * - * Initializes NFO processing settings from database/config with caching. - * - * @throws \Exception + * Heavy settings (batch limits, sizes, retries) load on first NFO processing use via getters, + * so constructing this service does not hit the cache or database. */ public function __construct() { $this->echo = (bool) config('nntmux.echocli'); - // Cache settings to reduce database queries - // Note: Cast after Cache::remember as cached values may be stored as strings - $this->nzbs = (int) Cache::remember('nfo_maxnfoprocessed', self::SETTINGS_CACHE_TTL, function () { - $value = Settings::settingValue('maxnfoprocessed'); - - return $value !== '' ? (int) $value : 100; - }); - - $maxRetries = (int) Cache::remember('nfo_maxnforetries', self::SETTINGS_CACHE_TTL, function () { - return (int) Settings::settingValue('maxnforetries'); - }); - $this->maxRetries = $maxRetries >= 0 ? -($maxRetries + 1) : self::NFO_UNPROC; - $this->maxRetries = max($this->maxRetries, -8); - - $this->maxSize = (int) Cache::remember('nfo_maxsizetoprocessnfo', self::SETTINGS_CACHE_TTL, function () { - return (int) Settings::settingValue('maxsizetoprocessnfo'); - }); - - $this->minSize = (int) Cache::remember('nfo_minsizetoprocessnfo', self::SETTINGS_CACHE_TTL, function () { - return (int) Settings::settingValue('minsizetoprocessnfo'); - }); - $this->tmpPath = rtrim((string) config('nntmux.tmp_unrar_path'), '/\\').'/'; $this->unrarPath = config('nntmux_settings.unrar_path') ?: false; $this->timeoutPath = config('nntmux_settings.timeout_path') ?: false; $this->timeoutSeconds = (int) (Settings::settingValue('timeoutseconds') ?: 60); } + private function getNzbs(): int + { + if ($this->nzbs === null) { + $this->nzbs = (int) $this->rememberNfoSetting('nfo_maxnfoprocessed', function () { + $value = Settings::settingValue('maxnfoprocessed'); + + return $value !== '' ? (int) $value : 100; + }); + } + + return $this->nzbs; + } + + private function getMaxRetries(): int + { + if ($this->maxRetries === null) { + $maxRetries = (int) $this->rememberNfoSetting('nfo_maxnforetries', function () { + return (int) Settings::settingValue('maxnforetries'); + }); + $computed = $maxRetries >= 0 ? -($maxRetries + 1) : self::NFO_UNPROC; + $this->maxRetries = max($computed, -8); + } + + return $this->maxRetries; + } + + private function getMaxSize(): int + { + if ($this->maxSize === null) { + $this->maxSize = (int) $this->rememberNfoSetting('nfo_maxsizetoprocessnfo', function () { + return (int) Settings::settingValue('maxsizetoprocessnfo'); + }); + } + + return $this->maxSize; + } + + private function getMinSize(): int + { + if ($this->minSize === null) { + $this->minSize = (int) $this->rememberNfoSetting('nfo_minsizetoprocessnfo', function () { + return (int) Settings::settingValue('minsizetoprocessnfo'); + }); + } + + return $this->minSize; + } + + /** + * Read a setting through the cache when available; fall back to the callback if the + * cache store is unreachable (e.g. Redis down while CACHE_STORE=redis). + * + * @param callable(): mixed $callback + */ + private function rememberNfoSetting(string $key, callable $callback): mixed + { + try { + return Cache::remember($key, self::SETTINGS_CACHE_TTL, $callback); + } catch (Throwable $e) { + if (config('app.debug')) { + Log::debug('NfoService cache bypassed: '.$e->getMessage()); + } + + return $callback(); + } + } + /** * Look for a TV Show ID or Movie ID in a string. * @@ -688,7 +732,7 @@ class NfoService $releases = $baseQuery->clone() ->orderBy('nfostatus') ->orderByDesc('postdate') - ->limit($this->nzbs) + ->limit($this->getNzbs()) ->get(['id', 'guid', 'groups_id', 'name']); $nfoCount = $releases->count(); @@ -775,7 +819,7 @@ class NfoService private function buildNfoProcessingQuery(string $groupID, string $guidChar): \Illuminate\Database\Eloquent\Builder // @phpstan-ignore class.notFound, missingType.generics, return.phpDocType { $query = Release::query() - ->whereBetween('nfostatus', [$this->maxRetries, self::NFO_UNPROC]); + ->whereBetween('nfostatus', [$this->getMaxRetries(), self::NFO_UNPROC]); if ($guidChar !== '') { $query->where('leftguid', $guidChar); @@ -785,12 +829,12 @@ class NfoService $query->where('groups_id', $groupID); } - if ($this->maxSize > 0) { - $query->where('size', '<', $this->maxSize * 1073741824); + if ($this->getMaxSize() > 0) { + $query->where('size', '<', $this->getMaxSize() * 1073741824); } - if ($this->minSize > 0) { - $query->where('size', '>', $this->minSize * 1048576); + if ($this->getMinSize() > 0) { + $query->where('size', '>', $this->getMinSize() * 1048576); } return $query; @@ -806,7 +850,7 @@ class NfoService ($guidChar === '' ? '' : '['.$guidChar.'] '). ($groupID === '' ? '' : '['.$groupID.'] '). 'Processing '.$nfoCount. - ' NFO(s), starting at '.$this->nzbs. + ' NFO(s), starting at '.$this->getNzbs(). ' * = hidden NFO, + = NFO, - = no NFO, f = download failed.' ); } @@ -862,7 +906,7 @@ class NfoService $query->where('groups_id', $groupID); } - $releases = $query->limit($this->nzbs) + $releases = $query->limit($this->getNzbs()) ->get(['id', 'guid', 'groups_id', 'name']); if ($releases->isEmpty()) { @@ -1694,5 +1738,10 @@ class NfoService Cache::forget('nfo_maxnforetries'); Cache::forget('nfo_maxsizetoprocessnfo'); Cache::forget('nfo_minsizetoprocessnfo'); + + $this->nzbs = null; + $this->maxRetries = null; + $this->maxSize = null; + $this->minSize = null; } } diff --git a/app/Services/SiteStatusService.php b/app/Services/SiteStatusService.php index 63dc16447..773fe275d 100644 --- a/app/Services/SiteStatusService.php +++ b/app/Services/SiteStatusService.php @@ -10,6 +10,8 @@ use App\Enums\ServiceStatusEnum; use App\Mail\IncidentDetected; use App\Models\ServiceIncident; use App\Models\ServiceStatus; +use App\Services\StatusProbes\ProbeResult; +use App\Services\StatusProbes\ServiceProbeRegistry; use Illuminate\Support\Carbon; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Http; @@ -18,6 +20,10 @@ use Illuminate\Support\Facades\Mail; class SiteStatusService { + public function __construct( + private readonly ServiceProbeRegistry $probeRegistry, + ) {} + /** * @return \Illuminate\Database\Eloquent\Collection */ @@ -149,8 +155,14 @@ class SiteStatusService } $uptime = (($totalMinutes - $downtimeMinutes) / $totalMinutes) * 100.0; + $rounded = round(max(0.0, min(100.0, $uptime)), 2); - return round(max(0.0, min(100.0, $uptime)), 2); + // Preserve visibility of brief outages in long windows (e.g. 30-day SLA). + if ($downtimeMinutes > 0.0 && $rounded >= 100.0) { + return 99.99; + } + + return $rounded; } /** @@ -329,6 +341,16 @@ class SiteStatusService */ public function checkServiceHealth(ServiceStatus $service): array { + if ($service->check_type === 'probe') { + if ($service->probe_identifier === null || $service->probe_identifier === '') { + return ['ok' => false, 'status_code' => 0, 'response_time_ms' => 0, 'impact' => IncidentImpactEnum::Major, 'reason' => 'Probe identifier not configured']; + } + + return $this->probeResultToHealthArray( + $this->probeRegistry->run($service->probe_identifier) + ); + } + $baseUrl = rtrim((string) config('app.url'), '/'); if ($baseUrl === '' || ! str_starts_with($baseUrl, 'http')) { @@ -360,6 +382,20 @@ class SiteStatusService return $worstResult ?? ['ok' => false, 'status_code' => 0, 'response_time_ms' => 0, 'impact' => IncidentImpactEnum::Critical, 'reason' => 'All endpoints unreachable']; } + /** + * @return array{ok: bool, status_code: int, response_time_ms: int, impact: IncidentImpactEnum|null, reason: string} + */ + private function probeResultToHealthArray(ProbeResult $result): array + { + return [ + 'ok' => $result->ok, + 'status_code' => 0, + 'response_time_ms' => $result->responseTimeMs, + 'impact' => $result->impact, + 'reason' => $result->reason, + ]; + } + /** * @return array{ok: bool, status_code: int, response_time_ms: int, impact: IncidentImpactEnum|null, reason: string} */ diff --git a/app/Services/StatusProbes/Contracts/ServiceProbeInterface.php b/app/Services/StatusProbes/Contracts/ServiceProbeInterface.php new file mode 100644 index 000000000..7aa5664f3 --- /dev/null +++ b/app/Services/StatusProbes/Contracts/ServiceProbeInterface.php @@ -0,0 +1,14 @@ +getPdo(); + DB::select('SELECT 1'); + $elapsed = (int) ((hrtime(true) - $start) / 1_000_000); + + return new ProbeResult( + ok: true, + responseTimeMs: $elapsed, + impact: null, + reason: 'Connected', + ); + } catch (\Throwable $e) { + return new ProbeResult( + ok: false, + responseTimeMs: 0, + impact: IncidentImpactEnum::Critical, + reason: 'Database unreachable: '.\Str::limit($e->getMessage(), 120), + ); + } + } +} diff --git a/app/Services/StatusProbes/DiskProbe.php b/app/Services/StatusProbes/DiskProbe.php new file mode 100644 index 000000000..496343f4d --- /dev/null +++ b/app/Services/StatusProbes/DiskProbe.php @@ -0,0 +1,79 @@ +probeConnection(false); + if (! $primary['ok']) { + return new ProbeResult( + ok: false, + responseTimeMs: 0, + impact: IncidentImpactEnum::Critical, + reason: 'Primary NNTP failed: '.$primary['reason'], + ); + } + + if ($checkAlternate) { + $alternate = $this->probeConnection(true); + if (! $alternate['ok']) { + return new ProbeResult( + ok: false, + responseTimeMs: 0, + impact: IncidentImpactEnum::Major, + reason: 'Alternate NNTP failed: '.$alternate['reason'], + ); + } + } + + return new ProbeResult( + ok: true, + responseTimeMs: (int) $primary['responseTimeMs'], + impact: null, + reason: $checkAlternate ? 'Primary and alternate NNTP connected' : 'Primary NNTP connected', + ); + } catch (\Throwable $e) { + return new ProbeResult( + ok: false, + responseTimeMs: 0, + impact: IncidentImpactEnum::Critical, + reason: 'NNTP probe failed: '.\Str::limit($e->getMessage(), 120), + ); + } finally { + $this->nntp->doQuit(); + } + } + + /** + * @return array{ok: bool, reason: string, responseTimeMs: int} + */ + private function probeConnection(bool $alternate): array + { + $start = hrtime(true); + $result = $this->nntp->doConnect(compression: false, alternate: $alternate); + $elapsed = (int) ((hrtime(true) - $start) / 1_000_000); + + if ($result === true) { + return ['ok' => true, 'reason' => 'Connected', 'responseTimeMs' => $elapsed]; + } + + if (NNTPService::isError($result)) { + return ['ok' => false, 'reason' => (string) $result->getMessage(), 'responseTimeMs' => $elapsed]; + } + + return ['ok' => false, 'reason' => 'Unknown connection result', 'responseTimeMs' => $elapsed]; + } +} diff --git a/app/Services/StatusProbes/ProbeResult.php b/app/Services/StatusProbes/ProbeResult.php new file mode 100644 index 000000000..8aa78169e --- /dev/null +++ b/app/Services/StatusProbes/ProbeResult.php @@ -0,0 +1,21 @@ + $metadata + */ + public function __construct( + public bool $ok, + public int $responseTimeMs, + public ?IncidentImpactEnum $impact, + public string $reason, + public array $metadata = [], + ) {} +} diff --git a/app/Services/StatusProbes/QueueProbe.php b/app/Services/StatusProbes/QueueProbe.php new file mode 100644 index 000000000..54f673f20 --- /dev/null +++ b/app/Services/StatusProbes/QueueProbe.php @@ -0,0 +1,56 @@ +queue->connection($defaultConnection); + $size = method_exists($queueConnection, 'size') ? (int) $queueConnection->size() : null; + $elapsed = (int) ((hrtime(true) - $start) / 1_000_000); + + if ($defaultConnection === 'sync') { + return new ProbeResult( + ok: true, + responseTimeMs: $elapsed, + impact: null, + reason: 'Queue uses sync driver', + ); + } + + return new ProbeResult( + ok: true, + responseTimeMs: $elapsed, + impact: null, + reason: sprintf('Queue "%s" reachable', $defaultConnection), + metadata: ['queue_size' => $size], + ); + } catch (\Throwable $e) { + return new ProbeResult( + ok: false, + responseTimeMs: 0, + impact: IncidentImpactEnum::Critical, + reason: 'Queue probe failed: '.\Str::limit($e->getMessage(), 120), + ); + } + } +} diff --git a/app/Services/StatusProbes/RedisProbe.php b/app/Services/StatusProbes/RedisProbe.php new file mode 100644 index 000000000..15ae6dcdf --- /dev/null +++ b/app/Services/StatusProbes/RedisProbe.php @@ -0,0 +1,110 @@ +applicationUsesRedis()) { + return new ProbeResult( + ok: true, + responseTimeMs: 0, + impact: null, + reason: 'Skipping: cache/session/queue do not use Redis', + ); + } + + try { + $start = hrtime(true); + $pong = Redis::connection()->ping(); + $elapsed = (int) ((hrtime(true) - $start) / 1_000_000); + $isOk = $pong === true || $pong === '+PONG' || strtoupper((string) $pong) === 'PONG'; + + if (! $isOk) { + return new ProbeResult( + ok: false, + responseTimeMs: $elapsed, + impact: IncidentImpactEnum::Major, + reason: 'Redis ping returned unexpected response', + metadata: ['response' => $pong], + ); + } + + return new ProbeResult( + ok: true, + responseTimeMs: $elapsed, + impact: null, + reason: 'Connected', + ); + } catch (\Throwable $e) { + return new ProbeResult( + ok: false, + responseTimeMs: 0, + impact: IncidentImpactEnum::Critical, + reason: 'Redis unreachable: '.Str::limit($e->getMessage(), 120), + ); + } + } + + private function applicationUsesRedis(): bool + { + $cacheDefault = strtolower((string) config('cache.default', '')); + $sessionDriver = strtolower((string) config('session.driver', '')); + $queueDefault = strtolower((string) config('queue.default', '')); + + if ($cacheDefault === 'redis' || $sessionDriver === 'redis' || $queueDefault === 'redis') { + return true; + } + + if ($this->cacheFailoverUsesRedis()) { + return true; + } + + $pulseIngest = strtolower((string) config('pulse.ingest.driver', '')); + + return $pulseIngest === 'redis'; + } + + /** + * True when the default cache store is a failover chain that includes the redis store. + */ + private function cacheFailoverUsesRedis(): bool + { + $name = (string) config('cache.default', ''); + $store = config('cache.stores.'.$name); + + if (! is_array($store) || ($store['driver'] ?? '') !== 'failover') { + return false; + } + + foreach ($store['stores'] ?? [] as $subStore) { + if (strtolower((string) $subStore) === 'redis') { + return true; + } + } + + return false; + } +} diff --git a/app/Services/StatusProbes/SearchProbe.php b/app/Services/StatusProbes/SearchProbe.php new file mode 100644 index 000000000..98d896811 --- /dev/null +++ b/app/Services/StatusProbes/SearchProbe.php @@ -0,0 +1,53 @@ +searchService->isAvailable(); + $elapsed = (int) ((hrtime(true) - $start) / 1_000_000); + + if (! $available) { + return new ProbeResult( + ok: false, + responseTimeMs: $elapsed, + impact: IncidentImpactEnum::Critical, + reason: sprintf('Search driver "%s" unavailable', $this->searchService->getCurrentDriver()), + ); + } + + return new ProbeResult( + ok: true, + responseTimeMs: $elapsed, + impact: null, + reason: sprintf('Search driver "%s" available', $this->searchService->getCurrentDriver()), + ); + } catch (\Throwable $e) { + return new ProbeResult( + ok: false, + responseTimeMs: 0, + impact: IncidentImpactEnum::Critical, + reason: 'Search check failed: '.\Str::limit($e->getMessage(), 120), + ); + } + } +} diff --git a/app/Services/StatusProbes/ServiceProbeRegistry.php b/app/Services/StatusProbes/ServiceProbeRegistry.php new file mode 100644 index 000000000..32a6bcd76 --- /dev/null +++ b/app/Services/StatusProbes/ServiceProbeRegistry.php @@ -0,0 +1,49 @@ + + */ + private array $probes; + + public function __construct( + DatabaseProbe $databaseProbe, + RedisProbe $redisProbe, + SearchProbe $searchProbe, + NntpProbe $nntpProbe, + QueueProbe $queueProbe, + DiskProbe $diskProbe, + ) { + $this->probes = [ + $databaseProbe->identifier() => $databaseProbe, + $redisProbe->identifier() => $redisProbe, + $searchProbe->identifier() => $searchProbe, + $nntpProbe->identifier() => $nntpProbe, + $queueProbe->identifier() => $queueProbe, + $diskProbe->identifier() => $diskProbe, + ]; + } + + public function run(string $identifier): ProbeResult + { + $probe = $this->probes[$identifier] ?? null; + if ($probe === null) { + return new ProbeResult( + ok: false, + responseTimeMs: 0, + impact: IncidentImpactEnum::Major, + reason: sprintf('Unknown probe identifier "%s"', $identifier), + ); + } + + return $probe->probe(); + } +} diff --git a/app/View/Composers/GlobalDataComposer.php b/app/View/Composers/GlobalDataComposer.php index 7a41e6b5c..b5e912072 100644 --- a/app/View/Composers/GlobalDataComposer.php +++ b/app/View/Composers/GlobalDataComposer.php @@ -11,6 +11,7 @@ use App\Models\Settings; use App\Models\User; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Log; use Illuminate\View\View; class GlobalDataComposer @@ -48,7 +49,7 @@ class GlobalDataComposer private function resolveData(): array { // Cached site settings (shared across all requests) - $siteArray = Cache::remember('site_settings_array', self::CACHE_TTL, function () { + $siteArray = $this->rememberWithCacheFallback('site_settings_array', self::CACHE_TTL, function () { return Settings::query() ->pluck('value', 'name') ->map(fn ($value) => Settings::convertValue($value)) @@ -61,7 +62,7 @@ class GlobalDataComposer ]; // Cached useful links for sidebar - $viewData['usefulLinks'] = Cache::remember('content_useful_links', self::CACHE_TTL, function () { + $viewData['usefulLinks'] = $this->rememberWithCacheFallback('content_useful_links', self::CACHE_TTL, function () { return Content::active() ->ofType(Content::TYPE_USEFUL) ->ordered() // @phpstan-ignore method.notFound @@ -72,7 +73,7 @@ class GlobalDataComposer $userId = Auth::id(); // User data with category exclusions (short cache per user) - $userdata = Cache::remember('composer_user_'.$userId, self::CACHE_TTL, function () use ($userId) { + $userdata = $this->rememberWithCacheFallback('composer_user_'.$userId, self::CACHE_TTL, function () use ($userId) { $user = User::find($userId); $user->categoryexclusions = User::getCategoryExclusionById($userId); @@ -85,7 +86,7 @@ class GlobalDataComposer } // Cached menu categories per user (depends on their exclusions) - $parentcatlist = Cache::remember('menu_user_'.$userId, self::CACHE_TTL, function () use ($userdata) { + $parentcatlist = $this->rememberWithCacheFallback('menu_user_'.$userId, self::CACHE_TTL, function () use ($userdata) { return Category::getForMenu($userdata->categoryexclusions); }); @@ -107,4 +108,20 @@ class GlobalDataComposer return $viewData; } + + /** + * @param callable(): mixed $callback + */ + private function rememberWithCacheFallback(string $key, int|\DateInterval $ttl, callable $callback): mixed + { + try { + return Cache::remember($key, $ttl, $callback); + } catch (\Throwable $e) { + if (config('app.debug')) { + Log::debug('GlobalDataComposer cache bypassed: '.$e->getMessage()); + } + + return $callback(); + } + } } diff --git a/bootstrap/app.php b/bootstrap/app.php index 467663009..fac724808 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -3,6 +3,7 @@ use App\Http\Middleware\BlockAbusiveServices; use App\Http\Middleware\ClearanceMiddleware; use App\Http\Middleware\ContentSecurityPolicy; +use App\Http\Middleware\DegradeWhenRedisUnreachable; use App\Http\Middleware\EnsureAuthenticatedUsersAreVerified; use App\Http\Middleware\ForceJsonOnAPI; use App\Http\Middleware\Google2FAMiddleware; @@ -62,6 +63,8 @@ return Application::configure(basePath: dirname(__DIR__)) 'cart/*', ]); + $middleware->prepend(DegradeWhenRedisUnreachable::class); + $middleware->append([ PreventRequestsDuringMaintenance::class, ForceJsonOnAPI::class, diff --git a/config/cache.php b/config/cache.php new file mode 100644 index 000000000..85afe3ab1 --- /dev/null +++ b/config/cache.php @@ -0,0 +1,103 @@ + env('CACHE_STORE', 'database'), + + 'stores' => [ + + 'array' => [ + 'driver' => 'array', + 'serialize' => false, + ], + + 'session' => [ + 'driver' => 'session', + 'key' => env('SESSION_CACHE_KEY', '_cache'), + ], + + 'database' => [ + 'driver' => 'database', + 'connection' => env('DB_CACHE_CONNECTION'), + 'table' => env('DB_CACHE_TABLE', 'cache'), + 'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'), + 'lock_table' => env('DB_CACHE_LOCK_TABLE'), + ], + + 'file' => [ + 'driver' => 'file', + 'path' => storage_path('framework/cache/data'), + 'lock_path' => storage_path('framework/cache/data'), + ], + + 'memcached' => [ + 'driver' => 'memcached', + 'persistent_id' => env('MEMCACHED_PERSISTENT_ID'), + 'sasl' => [ + env('MEMCACHED_USERNAME'), + env('MEMCACHED_PASSWORD'), + ], + 'options' => [ + // Memcached::OPT_CONNECT_TIMEOUT => 2000, + ], + 'servers' => [ + [ + 'host' => env('MEMCACHED_HOST', '127.0.0.1'), + 'port' => env('MEMCACHED_PORT', 11211), + 'weight' => 100, + ], + ], + ], + + 'redis' => [ + 'driver' => 'redis', + 'connection' => env('REDIS_CACHE_CONNECTION', 'cache'), + 'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'), + ], + + 'dynamodb' => [ + 'driver' => 'dynamodb', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + 'table' => env('DYNAMODB_CACHE_TABLE', 'cache'), + 'endpoint' => env('DYNAMODB_ENDPOINT'), + ], + + 'octane' => [ + 'driver' => 'octane', + ], + + 'failover' => [ + 'driver' => 'failover', + 'stores' => [ + 'database', + 'array', + ], + ], + + 'failover_redis_file' => [ + 'driver' => 'failover', + 'stores' => [ + 'redis', + 'file', + ], + ], + + ], + + 'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'), + +]; diff --git a/config/database.php b/config/database.php index 80480b53d..7b61579ce 100644 --- a/config/database.php +++ b/config/database.php @@ -87,6 +87,8 @@ return [ 'password' => env('REDIS_PASSWORD', null), 'port' => env('REDIS_PORT', 6379), 'database' => env('REDIS_DB', 0), + 'timeout' => (float) env('REDIS_CONNECT_TIMEOUT', 2.0), + 'read_timeout' => (float) env('REDIS_READ_TIMEOUT', 2.0), ], 'cache' => [ 'url' => env('REDIS_URL'), @@ -95,6 +97,8 @@ return [ 'password' => env('REDIS_PASSWORD', null), 'port' => env('REDIS_PORT', 6379), 'database' => env('REDIS_CACHE_DB', 1), + 'timeout' => (float) env('REDIS_CONNECT_TIMEOUT', 2.0), + 'read_timeout' => (float) env('REDIS_READ_TIMEOUT', 2.0), ], 'session' => [ 'url' => env('REDIS_URL'), @@ -103,6 +107,8 @@ return [ 'password' => env('REDIS_PASSWORD', null), 'port' => env('REDIS_PORT', 6379), 'database' => env('REDIS_CACHE_DB', 2), + 'timeout' => (float) env('REDIS_CONNECT_TIMEOUT', 2.0), + 'read_timeout' => (float) env('REDIS_READ_TIMEOUT', 2.0), ], ], diff --git a/config/nntmux.php b/config/nntmux.php index cc45f27e7..e6af1efe2 100644 --- a/config/nntmux.php +++ b/config/nntmux.php @@ -25,4 +25,6 @@ return [ 'tmp_unzip_path' => env('TEMP_UNZIP_PATH', storage_path('tmp/unzip/')), 'nzb_import_folder' => env('NZB_IMPORT_FOLDER'), 'nzb_upload_folder' => env('NZB_UPLOAD_FOLDER'), + 'redis_fast_degrade' => (bool) env('REDIS_FAST_DEGRADE', true), + 'redis_tcp_check_seconds' => (float) env('REDIS_TCP_CHECK_SECONDS', 0.2), ]; diff --git a/config/status-probes.php b/config/status-probes.php new file mode 100644 index 000000000..3dc993f71 --- /dev/null +++ b/config/status-probes.php @@ -0,0 +1,19 @@ + [ + 'mount_points' => array_filter(array_map('trim', explode(',', (string) env('STATUS_PROBE_DISK_MOUNT_POINTS', '/')))), + 'warning_threshold_gb' => (float) env('STATUS_PROBE_DISK_WARNING_THRESHOLD_GB', 5), + 'critical_threshold_gb' => (float) env('STATUS_PROBE_DISK_CRITICAL_THRESHOLD_GB', 1), + ], + 'nntp' => [ + 'check_alternate' => (bool) env('STATUS_PROBE_NNTP_CHECK_ALTERNATE', false), + 'timeout_seconds' => (int) env('STATUS_PROBE_NNTP_TIMEOUT_SECONDS', 10), + ], + 'redis' => [ + 'probe' => (bool) env('STATUS_PROBE_REDIS', true), + 'only_when_used' => (bool) env('STATUS_PROBE_REDIS_ONLY_WHEN_USED', true), + ], +]; diff --git a/database/migrations/2026_04_13_000002_add_probe_fields_to_service_statuses_table.php b/database/migrations/2026_04_13_000002_add_probe_fields_to_service_statuses_table.php new file mode 100644 index 000000000..9fe7625e7 --- /dev/null +++ b/database/migrations/2026_04_13_000002_add_probe_fields_to_service_statuses_table.php @@ -0,0 +1,69 @@ +string('check_type')->default('http')->after('endpoint_url'); + $table->string('probe_identifier')->nullable()->after('check_type'); + + $table->index('check_type'); + $table->index('probe_identifier'); + }); + + DB::table('service_statuses')->update([ + 'check_type' => 'http', + 'probe_identifier' => null, + ]); + + $now = now(); + $nextOrder = (int) DB::table('service_statuses')->max('sort_order') + 1; + + $probeServices = [ + ['name' => 'Database', 'slug' => 'database', 'probe_identifier' => 'database'], + ['name' => 'Redis', 'slug' => 'redis', 'probe_identifier' => 'redis'], + ['name' => 'Search Engine', 'slug' => 'search', 'probe_identifier' => 'search'], + ['name' => 'NNTP', 'slug' => 'nntp', 'probe_identifier' => 'nntp'], + ['name' => 'Queue', 'slug' => 'queue', 'probe_identifier' => 'queue'], + ['name' => 'Disk Space', 'slug' => 'disk', 'probe_identifier' => 'disk'], + ]; + + foreach ($probeServices as $service) { + DB::table('service_statuses')->updateOrInsert( + ['slug' => $service['slug']], + [ + 'name' => $service['name'], + 'endpoint_url' => null, + 'check_type' => 'probe', + 'probe_identifier' => $service['probe_identifier'], + 'status' => 'operational', + 'is_enabled' => true, + 'sort_order' => $nextOrder++, + 'updated_at' => $now, + 'created_at' => $now, + ] + ); + } + } + + public function down(): void + { + DB::table('service_statuses') + ->whereIn('slug', ['database', 'redis', 'search', 'nntp', 'queue', 'disk']) + ->delete(); + + Schema::table('service_statuses', static function (Blueprint $table): void { + $table->dropIndex(['check_type']); + $table->dropIndex(['probe_identifier']); + $table->dropColumn(['check_type', 'probe_identifier']); + }); + } +}; diff --git a/resources/views/admin/dashboard.blade.php b/resources/views/admin/dashboard.blade.php index b97df82b5..f9d3284d1 100644 --- a/resources/views/admin/dashboard.blade.php +++ b/resources/views/admin/dashboard.blade.php @@ -640,29 +640,26 @@

System Status

+ @php + $systemServiceSlugs = ['database', 'redis', 'queue', 'disk']; + $systemServices = $serviceStatuses->keyBy('slug'); + @endphp
-
- Database - - Connected - -
-
- Cache - - Active - -
-
- Queue - - Running - -
-
- Disk Space - {{ $stats['disk_free'] ?? 'N/A' }} available -
+ @foreach($systemServiceSlugs as $slug) + @php $svc = $systemServices->get($slug); @endphp +
+ {{ $svc?->name ?? ucfirst($slug) }} + @if($svc) + + {{ $svc->status->label() }} + + @else + + Not configured + + @endif +
+ @endforeach
diff --git a/routes/console.php b/routes/console.php index f972938d0..0c2fe211e 100644 --- a/routes/console.php +++ b/routes/console.php @@ -59,4 +59,4 @@ Schedule::call(function () { })->name('cleanup-api-request-logs')->hourly()->withoutOverlapping(); // Check tmux health and auto-restart if monitor pane is dead Schedule::command('tmux:health-check --auto-restart')->everyThirtyMinutes()->withoutOverlapping(); -Schedule::command('nntmux:check-service-health')->everyFiveMinutes()->withoutOverlapping(); +Schedule::command('nntmux:check-service-health')->everyMinute()->withoutOverlapping(); diff --git a/tests/Unit/Models/AnidbInfoTest.php b/tests/Unit/Models/AnidbInfoTest.php new file mode 100644 index 000000000..614589a18 --- /dev/null +++ b/tests/Unit/Models/AnidbInfoTest.php @@ -0,0 +1,83 @@ + 12345, + 'type' => 'TV Series', + ]); + + $url = $animeInfo->getAnidbUrl(); + + $this->assertEquals('https://anidb.net/anime/12345', $url); + } + + /** @test */ + public function it_returns_anilist_url_when_anilist_id_exists(): void + { + $animeInfo = AnidbInfo::create([ + 'anidbid' => 1, + 'anilist_id' => 9253, + 'type' => 'TV Series', + ]); + + $url = $animeInfo->getAnilistUrl(); + + $this->assertEquals('https://anilist.co/anime/9253', $url); + } + + /** @test */ + public function it_returns_myanimelist_url_when_myanimelist_id_exists(): void + { + $animeInfo = AnidbInfo::create([ + 'anidbid' => 1, + 'myanimelist_id' => 9253, + 'type' => 'TV Series', + ]); + + $url = $animeInfo->getMyAnimeListUrl(); + + $this->assertEquals('https://myanimelist.net/anime/9253', $url); + } + + /** @test */ + public function it_returns_all_external_links(): void + { + $animeInfo = AnidbInfo::create([ + 'anidbid' => 12345, + 'anilist_id' => 9253, + 'myanimelist_id' => 9253, + 'type' => 'TV Series', + ]); + + $links = $animeInfo->getExternalLinks(); + + $this->assertIsArray($links); + $this->assertArrayHasKey('anidb', $links); + $this->assertArrayHasKey('anilist', $links); + $this->assertArrayHasKey('myanimelist', $links); + } + + /** @test */ + public function it_checks_if_external_links_exist(): void + { + $animeInfo = AnidbInfo::create([ + 'anidbid' => 1, + 'anilist_id' => 123, + 'type' => 'TV Series', + ]); + + $this->assertTrue($animeInfo->hasExternalLinks()); + } +} diff --git a/tests/Unit/Services/NameFixing/FileNameCleanerTest.php b/tests/Unit/Services/NameFixing/FileNameCleanerTest.php new file mode 100644 index 000000000..0dbd70ba8 --- /dev/null +++ b/tests/Unit/Services/NameFixing/FileNameCleanerTest.php @@ -0,0 +1,34 @@ +assertFalse($cleaner->cleanForMatching('Film ;-)/Extreem Online Contact.url')); + } + + public function test_clean_for_matching_rejects_generic_dvd_structure_files(): void + { + $cleaner = new FileNameCleaner; + + $this->assertFalse($cleaner->cleanForMatching('Film ;-)/VIDEO_TS/VIDEO_TS.VOB')); + $this->assertFalse($cleaner->cleanForMatching('Film ;-)/VIDEO_TS/VTS_01_1.VOB')); + $this->assertFalse($cleaner->cleanForMatching('Film ;-)/1.jpg')); + } + + public function test_clean_for_matching_keeps_useful_cover_names(): void + { + $cleaner = new FileNameCleaner; + + $this->assertSame('cover the fisher king', $cleaner->cleanForMatching('Film ;-)/cover the fisher king.jpg')); + } +} diff --git a/tests/Unit/Services/NameFixing/FilePrioritizerTest.php b/tests/Unit/Services/NameFixing/FilePrioritizerTest.php new file mode 100644 index 000000000..fdf758397 --- /dev/null +++ b/tests/Unit/Services/NameFixing/FilePrioritizerTest.php @@ -0,0 +1,30 @@ +prioritizeForMatching([ + 'Film ;-)/VIDEO_TS/VIDEO_TS.VOB', + 'Film ;-)/VIDEO_TS/VTS_01_1.VOB', + 'Film ;-)/1.jpg', + 'Film ;-)/cover the fisher king.jpg', + 'Film ;-)/Extreem Online Contact.url', + 'Film ;-)/release.nfo', + ]); + + $this->assertSame([ + 'Film ;-)/release.nfo', + 'Film ;-)/cover the fisher king.jpg', + ], array_values($prioritized)); + } +}