mirror of
https://github.com/NNTmux/newznab-tmux.git
synced 2026-08-28 17:01:16 +00:00
Track more statuses and add redis fallback
This commit is contained in:
+11
-1
@@ -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
|
||||
|
||||
@@ -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) => [
|
||||
|
||||
@@ -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<int, mixed>
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Laravel's cache "failover" driver still tries Redis on every read/write, so when Redis is down
|
||||
* each Cache call pays the full connection timeout (very slow + spinners on admin/API-heavy pages).
|
||||
*
|
||||
* This middleware runs first (global stack): quick TCP reachability check to the configured Redis
|
||||
* host/port, then switches default cache + session to file for this request only. terminate()
|
||||
* restores config for Octane/long-lived workers.
|
||||
*/
|
||||
class DegradeWhenRedisUnreachable
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! config('nntmux.redis_fast_degrade', true)) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if ($this->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;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
+90
-41
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<int, ServiceStatus>
|
||||
*/
|
||||
@@ -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}
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\StatusProbes\Contracts;
|
||||
|
||||
use App\Services\StatusProbes\ProbeResult;
|
||||
|
||||
interface ServiceProbeInterface
|
||||
{
|
||||
public function identifier(): string;
|
||||
|
||||
public function probe(): ProbeResult;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\StatusProbes;
|
||||
|
||||
use App\Enums\IncidentImpactEnum;
|
||||
use App\Services\StatusProbes\Contracts\ServiceProbeInterface;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class DatabaseProbe implements ServiceProbeInterface
|
||||
{
|
||||
public function identifier(): string
|
||||
{
|
||||
return 'database';
|
||||
}
|
||||
|
||||
public function probe(): ProbeResult
|
||||
{
|
||||
try {
|
||||
$start = hrtime(true);
|
||||
DB::connection()->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),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\StatusProbes;
|
||||
|
||||
use App\Enums\IncidentImpactEnum;
|
||||
use App\Services\StatusProbes\Contracts\ServiceProbeInterface;
|
||||
|
||||
class DiskProbe implements ServiceProbeInterface
|
||||
{
|
||||
public function identifier(): string
|
||||
{
|
||||
return 'disk';
|
||||
}
|
||||
|
||||
public function probe(): ProbeResult
|
||||
{
|
||||
$mountPoints = (array) config('status-probes.disk.mount_points', ['/']);
|
||||
$warningThresholdGb = (float) config('status-probes.disk.warning_threshold_gb', 5.0);
|
||||
$criticalThresholdGb = (float) config('status-probes.disk.critical_threshold_gb', 1.0);
|
||||
|
||||
$lowestFreeGb = null;
|
||||
$worstMount = null;
|
||||
|
||||
foreach ($mountPoints as $mountPoint) {
|
||||
$freeBytes = @disk_free_space((string) $mountPoint);
|
||||
if ($freeBytes === false) {
|
||||
return new ProbeResult(
|
||||
ok: false,
|
||||
responseTimeMs: 0,
|
||||
impact: IncidentImpactEnum::Major,
|
||||
reason: sprintf('Unable to read disk usage for mount "%s"', $mountPoint),
|
||||
);
|
||||
}
|
||||
|
||||
$freeGb = round(((float) $freeBytes) / 1_073_741_824, 2);
|
||||
|
||||
if ($lowestFreeGb === null || $freeGb < $lowestFreeGb) {
|
||||
$lowestFreeGb = $freeGb;
|
||||
$worstMount = (string) $mountPoint;
|
||||
}
|
||||
}
|
||||
|
||||
if ($lowestFreeGb === null || $worstMount === null) {
|
||||
return new ProbeResult(
|
||||
ok: false,
|
||||
responseTimeMs: 0,
|
||||
impact: IncidentImpactEnum::Major,
|
||||
reason: 'No disk mount points configured',
|
||||
);
|
||||
}
|
||||
|
||||
if ($lowestFreeGb <= $criticalThresholdGb) {
|
||||
return new ProbeResult(
|
||||
ok: false,
|
||||
responseTimeMs: 0,
|
||||
impact: IncidentImpactEnum::Critical,
|
||||
reason: sprintf('Low disk space on %s (%.2f GB free)', $worstMount, $lowestFreeGb),
|
||||
);
|
||||
}
|
||||
|
||||
if ($lowestFreeGb <= $warningThresholdGb) {
|
||||
return new ProbeResult(
|
||||
ok: false,
|
||||
responseTimeMs: 0,
|
||||
impact: IncidentImpactEnum::Minor,
|
||||
reason: sprintf('Disk space warning on %s (%.2f GB free)', $worstMount, $lowestFreeGb),
|
||||
);
|
||||
}
|
||||
|
||||
return new ProbeResult(
|
||||
ok: true,
|
||||
responseTimeMs: 0,
|
||||
impact: null,
|
||||
reason: sprintf('Disk healthy (lowest: %s %.2f GB free)', $worstMount, $lowestFreeGb),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\StatusProbes;
|
||||
|
||||
use App\Enums\IncidentImpactEnum;
|
||||
use App\Services\NNTP\NNTPService;
|
||||
use App\Services\StatusProbes\Contracts\ServiceProbeInterface;
|
||||
|
||||
class NntpProbe implements ServiceProbeInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly NNTPService $nntp,
|
||||
) {}
|
||||
|
||||
public function identifier(): string
|
||||
{
|
||||
return 'nntp';
|
||||
}
|
||||
|
||||
public function probe(): ProbeResult
|
||||
{
|
||||
$checkAlternate = (bool) config('status-probes.nntp.check_alternate', false);
|
||||
|
||||
try {
|
||||
$primary = $this->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];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\StatusProbes;
|
||||
|
||||
use App\Enums\IncidentImpactEnum;
|
||||
|
||||
final readonly class ProbeResult
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $metadata
|
||||
*/
|
||||
public function __construct(
|
||||
public bool $ok,
|
||||
public int $responseTimeMs,
|
||||
public ?IncidentImpactEnum $impact,
|
||||
public string $reason,
|
||||
public array $metadata = [],
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\StatusProbes;
|
||||
|
||||
use App\Enums\IncidentImpactEnum;
|
||||
use App\Services\StatusProbes\Contracts\ServiceProbeInterface;
|
||||
use Illuminate\Contracts\Queue\Factory as QueueFactory;
|
||||
|
||||
class QueueProbe implements ServiceProbeInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly QueueFactory $queue,
|
||||
) {}
|
||||
|
||||
public function identifier(): string
|
||||
{
|
||||
return 'queue';
|
||||
}
|
||||
|
||||
public function probe(): ProbeResult
|
||||
{
|
||||
try {
|
||||
$defaultConnection = (string) config('queue.default', 'sync');
|
||||
$start = hrtime(true);
|
||||
$queueConnection = $this->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),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\StatusProbes;
|
||||
|
||||
use App\Enums\IncidentImpactEnum;
|
||||
use App\Services\StatusProbes\Contracts\ServiceProbeInterface;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class RedisProbe implements ServiceProbeInterface
|
||||
{
|
||||
public function identifier(): string
|
||||
{
|
||||
return 'redis';
|
||||
}
|
||||
|
||||
public function probe(): ProbeResult
|
||||
{
|
||||
if (! config('status-probes.redis.probe', true)) {
|
||||
return new ProbeResult(
|
||||
ok: true,
|
||||
responseTimeMs: 0,
|
||||
impact: null,
|
||||
reason: 'Redis probe disabled (STATUS_PROBE_REDIS=false)',
|
||||
);
|
||||
}
|
||||
|
||||
if (config('status-probes.redis.only_when_used', true) && ! $this->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\StatusProbes;
|
||||
|
||||
use App\Enums\IncidentImpactEnum;
|
||||
use App\Services\Search\SearchService;
|
||||
use App\Services\StatusProbes\Contracts\ServiceProbeInterface;
|
||||
|
||||
class SearchProbe implements ServiceProbeInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SearchService $searchService,
|
||||
) {}
|
||||
|
||||
public function identifier(): string
|
||||
{
|
||||
return 'search';
|
||||
}
|
||||
|
||||
public function probe(): ProbeResult
|
||||
{
|
||||
try {
|
||||
$start = hrtime(true);
|
||||
$available = $this->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),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\StatusProbes;
|
||||
|
||||
use App\Enums\IncidentImpactEnum;
|
||||
use App\Services\StatusProbes\Contracts\ServiceProbeInterface;
|
||||
|
||||
class ServiceProbeRegistry
|
||||
{
|
||||
/**
|
||||
* @var array<string, ServiceProbeInterface>
|
||||
*/
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Cache Store
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Use "failover_redis_file" to keep using Redis when it is up, and
|
||||
| automatically fall back to the file store when Redis is down (DNS/connect).
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => 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-'),
|
||||
|
||||
];
|
||||
@@ -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),
|
||||
],
|
||||
],
|
||||
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'disk' => [
|
||||
'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),
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('service_statuses', static function (Blueprint $table): void {
|
||||
$table->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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -640,29 +640,26 @@
|
||||
<!-- System Status -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm p-6 border border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-4">System Status</h3>
|
||||
@php
|
||||
$systemServiceSlugs = ['database', 'redis', 'queue', 'disk'];
|
||||
$systemServices = $serviceStatuses->keyBy('slug');
|
||||
@endphp
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-400">Database</span>
|
||||
<span class="px-3 py-1 bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 rounded-full text-sm">
|
||||
<i class="fas fa-check-circle"></i> Connected
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-400">Cache</span>
|
||||
<span class="px-3 py-1 bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 rounded-full text-sm">
|
||||
<i class="fas fa-check-circle"></i> Active
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-600">Queue</span>
|
||||
<span class="px-3 py-1 bg-green-100 text-green-800 rounded-full text-sm">
|
||||
<i class="fas fa-check-circle"></i> Running
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-600">Disk Space</span>
|
||||
<span class="text-sm text-gray-600">{{ $stats['disk_free'] ?? 'N/A' }} available</span>
|
||||
</div>
|
||||
@foreach($systemServiceSlugs as $slug)
|
||||
@php $svc = $systemServices->get($slug); @endphp
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-400">{{ $svc?->name ?? ucfirst($slug) }}</span>
|
||||
@if($svc)
|
||||
<span class="{{ $dashBadge($svc->status->value) }}">
|
||||
{{ $svc->status->label() }}
|
||||
</span>
|
||||
@else
|
||||
<span class="px-3 py-1 bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200 rounded-full text-sm">
|
||||
Not configured
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
+1
-1
@@ -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();
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Models;
|
||||
|
||||
use App\Models\AnidbInfo;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AnidbInfoTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/** @test */
|
||||
public function it_returns_anidb_url_when_anidbid_exists(): void
|
||||
{
|
||||
$animeInfo = AnidbInfo::create([
|
||||
'anidbid' => 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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Services\NameFixing;
|
||||
|
||||
use App\Services\NameFixing\FileNameCleaner;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class FileNameCleanerTest extends TestCase
|
||||
{
|
||||
public function test_clean_for_matching_rejects_url_shortcuts(): void
|
||||
{
|
||||
$cleaner = new FileNameCleaner;
|
||||
|
||||
$this->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'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Services\NameFixing;
|
||||
|
||||
use App\Services\NameFixing\FilePrioritizer;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class FilePrioritizerTest extends TestCase
|
||||
{
|
||||
public function test_prioritize_for_matching_skips_url_and_dvd_structure_noise(): void
|
||||
{
|
||||
$prioritizer = new FilePrioritizer;
|
||||
|
||||
$prioritized = $prioritizer->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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user