diff --git a/app/Actions/Fortify/UpdateUserPassword.php b/app/Actions/Fortify/UpdateUserPassword.php index 320eede0bf..6af8e8d0bb 100644 --- a/app/Actions/Fortify/UpdateUserPassword.php +++ b/app/Actions/Fortify/UpdateUserPassword.php @@ -27,5 +27,10 @@ class UpdateUserPassword implements UpdatesUserPasswords $user->fill([ 'password' => Hash::make($input['password']), ])->save(); + auditLog('ui.user.password_changed', [ + 'team_id' => $user->currentTeam()?->id, + 'resource' => 'user', + 'user_name' => $user->name, + ]); } } diff --git a/app/Actions/Fortify/UpdateUserProfileInformation.php b/app/Actions/Fortify/UpdateUserProfileInformation.php index 76c6c0736f..dd3ff35b6f 100644 --- a/app/Actions/Fortify/UpdateUserProfileInformation.php +++ b/app/Actions/Fortify/UpdateUserProfileInformation.php @@ -17,6 +17,10 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation */ public function update(User $user, array $input): void { + $changedFields = collect(['name', 'email']) + ->filter(fn (string $field): bool => $user->{$field} !== $input[$field]) + ->values() + ->all(); Validator::make($input, [ 'name' => ['required', 'string', 'max:255'], @@ -40,6 +44,15 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation 'email' => $input['email'], ])->save(); } + + if ($changedFields !== []) { + auditLog('ui.user.profile_updated', [ + 'team_id' => $user->currentTeam()?->id, + 'resource' => 'user', + 'user_name' => $user->name, + 'changed_fields' => $changedFields, + ]); + } } /** diff --git a/app/Http/Controllers/Api/AuditEventsController.php b/app/Http/Controllers/Api/AuditEventsController.php index da452bb303..63dcca6982 100644 --- a/app/Http/Controllers/Api/AuditEventsController.php +++ b/app/Http/Controllers/Api/AuditEventsController.php @@ -48,6 +48,7 @@ class AuditEventsController extends Controller 'event', 'source', 'action', + 'level', 'actor_type', 'actor_id', 'actor_name', diff --git a/app/Http/Controllers/OauthController.php b/app/Http/Controllers/OauthController.php index a21850c9bf..66ddcdd1ee 100644 --- a/app/Http/Controllers/OauthController.php +++ b/app/Http/Controllers/OauthController.php @@ -22,7 +22,7 @@ class OauthController extends Controller try { $oauthSetting = $this->enabledProvider($provider); $oauthUser = get_socialite_provider($oauthSetting->provider)->user(); - $oauthLoginService->login($oauthSetting->provider, $oauthUser, $oauthSetting); + $user = $oauthLoginService->login($oauthSetting->provider, $oauthUser, $oauthSetting); $team = $user->resolveStoredTeam(); if (! $team && $user->teams()->count() === 0) { @@ -44,6 +44,11 @@ class OauthController extends Controller private function logCallbackFailure(string $provider, \Throwable $exception): void { + auditLog('auth.oauth.callback_failed', [ + 'provider' => $provider, + 'exception_class' => $exception::class, + 'reason' => $exception instanceof HttpException ? 'access_denied' : 'callback_error', + ], 'warning'); Log::error('OAuth callback failed.', [ 'provider' => $provider, 'exception_class' => $exception::class, diff --git a/app/Livewire/Concerns/InteractsWithDnsProviders.php b/app/Livewire/Concerns/InteractsWithDnsProviders.php index 692445c3c0..2f7e86c7ff 100644 --- a/app/Livewire/Concerns/InteractsWithDnsProviders.php +++ b/app/Livewire/Concerns/InteractsWithDnsProviders.php @@ -240,6 +240,12 @@ trait InteractsWithDnsProviders ->first(); if ($record !== null && ! app(CloudflareDnsProvider::class)->deleteRecord($record)) { + auditLog('ui.dns_record.delete_skipped', [ + 'team_id' => currentTeam()->id, + 'hostname' => $hostname, + 'provider' => 'cloudflare', + 'reason' => 'remote_record_changed', + ], 'warning'); $this->dispatch('warning', 'The domain was removed, but its DNS record changed externally and was left untouched.'); } } diff --git a/app/Livewire/Notifications/Discord.php b/app/Livewire/Notifications/Discord.php index cb31e6c111..8e0e149e27 100644 --- a/app/Livewire/Notifications/Discord.php +++ b/app/Livewire/Notifications/Discord.php @@ -110,7 +110,9 @@ class Discord extends Component $this->settings->discord_ping_enabled = $this->discordPingEnabled; + $changedFields = array_keys($this->settings->getDirty()); $this->settings->save(); + $this->auditNotificationSettings($changedFields); refreshSession(); } else { $this->discordEnabled = $this->settings->discord_enabled; @@ -240,4 +242,11 @@ class Discord extends Component { return view('livewire.notifications.discord'); } + + private function auditNotificationSettings(array $changedFields): void + { + if ($changedFields !== []) { + auditLog('ui.notifications.discord.updated', ['team_id' => $this->team->id, 'changed_fields' => $changedFields]); + } + } } diff --git a/app/Livewire/Notifications/Email.php b/app/Livewire/Notifications/Email.php index ea626ed57a..70b424d23a 100644 --- a/app/Livewire/Notifications/Email.php +++ b/app/Livewire/Notifications/Email.php @@ -169,7 +169,9 @@ class Email extends Component $this->settings->server_unreachable_email_notifications = $this->serverUnreachableEmailNotifications; $this->settings->server_patch_email_notifications = $this->serverPatchEmailNotifications; $this->settings->traefik_outdated_email_notifications = $this->traefikOutdatedEmailNotifications; + $changedFields = array_keys($this->settings->getDirty()); $this->settings->save(); + $this->auditNotificationSettings($changedFields); } else { $this->smtpEnabled = $this->settings->smtp_enabled; @@ -327,7 +329,9 @@ class Email extends Component $this->settings->smtp_timeout = $this->smtpTimeout; $this->settings->smtp_ehlo_domain = $this->smtpEhloDomain; + $changedFields = array_keys($this->settings->getDirty()); $this->settings->save(); + $this->auditNotificationSettings($changedFields); $this->dispatch('success', 'SMTP settings updated.'); } catch (\Throwable $e) { $this->smtpEnabled = false; @@ -352,7 +356,9 @@ class Email extends Component $this->settings->smtp_from_address = $this->smtpFromAddress; $this->settings->smtp_from_name = $this->smtpFromName; + $changedFields = array_keys($this->settings->getDirty()); $this->settings->save(); + $this->auditNotificationSettings($changedFields); $this->dispatch('success', 'Resend settings updated.'); } catch (\Throwable $e) { return handleError($e, $this); @@ -461,4 +467,14 @@ class Email extends Component { return view('livewire.notifications.email'); } + + private function auditNotificationSettings(array $changedFields): void + { + if ($changedFields !== []) { + auditLog('ui.notifications.email.updated', [ + 'team_id' => $this->team->id, + 'changed_fields' => array_values(array_diff($changedFields, ['smtp_password', 'resend_api_key'])), + ]); + } + } } diff --git a/app/Livewire/Notifications/Pushover.php b/app/Livewire/Notifications/Pushover.php index cae1c3d689..5e9abd9407 100644 --- a/app/Livewire/Notifications/Pushover.php +++ b/app/Livewire/Notifications/Pushover.php @@ -113,7 +113,9 @@ class Pushover extends Component $this->settings->server_patch_pushover_notifications = $this->serverPatchPushoverNotifications; $this->settings->traefik_outdated_pushover_notifications = $this->traefikOutdatedPushoverNotifications; + $changedFields = array_keys($this->settings->getDirty()); $this->settings->save(); + $this->auditNotificationSettings($changedFields); refreshSession(); } else { $this->pushoverEnabled = $this->settings->pushover_enabled; @@ -239,4 +241,11 @@ class Pushover extends Component { return view('livewire.notifications.pushover'); } + + private function auditNotificationSettings(array $changedFields): void + { + if ($changedFields !== []) { + auditLog('ui.notifications.pushover.updated', ['team_id' => $this->team->id, 'changed_fields' => $changedFields]); + } + } } diff --git a/app/Livewire/Notifications/Slack.php b/app/Livewire/Notifications/Slack.php index 644252c1a3..a96b452f82 100644 --- a/app/Livewire/Notifications/Slack.php +++ b/app/Livewire/Notifications/Slack.php @@ -110,7 +110,9 @@ class Slack extends Component $this->settings->server_patch_slack_notifications = $this->serverPatchSlackNotifications; $this->settings->traefik_outdated_slack_notifications = $this->traefikOutdatedSlackNotifications; + $changedFields = array_keys($this->settings->getDirty()); $this->settings->save(); + $this->auditNotificationSettings($changedFields); refreshSession(); } else { $this->slackEnabled = $this->settings->slack_enabled; @@ -228,4 +230,11 @@ class Slack extends Component { return view('livewire.notifications.slack'); } + + private function auditNotificationSettings(array $changedFields): void + { + if ($changedFields !== []) { + auditLog('ui.notifications.slack.updated', ['team_id' => $this->team->id, 'changed_fields' => $changedFields]); + } + } } diff --git a/app/Livewire/Notifications/Telegram.php b/app/Livewire/Notifications/Telegram.php index f999294477..6b362f7869 100644 --- a/app/Livewire/Notifications/Telegram.php +++ b/app/Livewire/Notifications/Telegram.php @@ -174,7 +174,9 @@ class Telegram extends Component $this->settings->telegram_notifications_server_patch_thread_id = $this->telegramNotificationsServerPatchThreadId; $this->settings->telegram_notifications_traefik_outdated_thread_id = $this->telegramNotificationsTraefikOutdatedThreadId; + $changedFields = array_keys($this->settings->getDirty()); $this->settings->save(); + $this->auditNotificationSettings($changedFields); } else { $this->telegramEnabled = $this->settings->telegram_enabled; if (auth()->user()->can('update', $this->settings)) { @@ -315,4 +317,11 @@ class Telegram extends Component { return view('livewire.notifications.telegram'); } + + private function auditNotificationSettings(array $changedFields): void + { + if ($changedFields !== []) { + auditLog('ui.notifications.telegram.updated', ['team_id' => $this->team->id, 'changed_fields' => $changedFields]); + } + } } diff --git a/app/Livewire/Notifications/Webhook.php b/app/Livewire/Notifications/Webhook.php index fb537fc7d9..dfc0fabd30 100644 --- a/app/Livewire/Notifications/Webhook.php +++ b/app/Livewire/Notifications/Webhook.php @@ -105,7 +105,9 @@ class Webhook extends Component $this->settings->server_patch_webhook_notifications = $this->serverPatchWebhookNotifications; $this->settings->traefik_outdated_webhook_notifications = $this->traefikOutdatedWebhookNotifications; + $changedFields = array_keys($this->settings->getDirty()); $this->settings->save(); + $this->auditNotificationSettings($changedFields); refreshSession(); } else { $this->webhookEnabled = $this->settings->webhook_enabled; @@ -220,4 +222,11 @@ class Webhook extends Component { return view('livewire.notifications.webhook'); } + + private function auditNotificationSettings(array $changedFields): void + { + if ($changedFields !== []) { + auditLog('ui.notifications.webhook.updated', ['team_id' => $this->team->id, 'changed_fields' => $changedFields]); + } + } } diff --git a/app/Livewire/Profile/Index.php b/app/Livewire/Profile/Index.php index 69f27b0e55..9b1ef42ce2 100644 --- a/app/Livewire/Profile/Index.php +++ b/app/Livewire/Profile/Index.php @@ -95,6 +95,7 @@ class Index extends Component Auth::user()->update([ 'name' => $this->name, ]); + auditLog('ui.user.profile_updated', $this->auditContext(['changed_fields' => ['name']])); $this->dispatch('success', 'Profile updated.'); } catch (\Throwable $e) { @@ -154,6 +155,7 @@ class Index extends Component } Auth::user()->requestEmailChange($this->new_email); + auditLog('ui.user.email_change_requested', $this->auditContext()); $this->show_email_change = false; $this->show_verification = true; @@ -216,6 +218,7 @@ class Index extends Component $this->show_verification = false; $this->dispatch('success', 'Email address updated successfully.'); + auditLog('ui.user.email_changed', $this->auditContext()); } else { $this->dispatch('error', 'Failed to update email address.'); } @@ -328,6 +331,7 @@ class Index extends Component auth()->user()->update([ 'password' => Hash::make($this->new_password), ]); + auditLog('ui.user.password_changed', $this->auditContext()); $this->dispatch('success', 'Password updated.'); $this->current_password = ''; $this->new_password = ''; @@ -346,6 +350,17 @@ class Index extends Component }; } + private function auditContext(array $context = []): array + { + $user = Auth::user(); + + return array_merge([ + 'team_id' => $user->currentTeam()?->id, + 'resource' => 'user', + 'user_name' => $user->name, + ], $context); + } + public function render() { return view('livewire.profile.index'); diff --git a/app/Livewire/Project/Application/Advanced.php b/app/Livewire/Project/Application/Advanced.php index 96f6d9f504..288014ae61 100644 --- a/app/Livewire/Project/Application/Advanced.php +++ b/app/Livewire/Project/Application/Advanced.php @@ -123,7 +123,9 @@ class Advanced extends Component $this->application->settings->disable_build_cache = $this->disableBuildCache; $this->application->settings->inject_build_args_to_dockerfile = $this->injectBuildArgsToDockerfile; $this->application->settings->include_source_commit_in_build = $this->includeSourceCommitInBuild; + $changedFields = array_keys($this->application->settings->getDirty()); $this->application->settings->save(); + $this->auditSettingsUpdate($changedFields); } else { $this->isForceHttpsEnabled = $this->application->isForceHttpsEnabled(); $this->isGzipEnabled = $this->application->isGzipEnabled(); @@ -301,7 +303,9 @@ class Advanced extends Component $this->application->settings->stop_grace_period = $validated['stopGracePeriod'] === null ? null : (int) $validated['stopGracePeriod']; + $changedFields = array_keys($this->application->settings->getDirty()); $this->application->settings->save(); + $this->auditSettingsUpdate($changedFields); $this->dispatch('success', 'Stop grace period updated.'); $this->dispatch('configurationChanged'); @@ -331,4 +335,20 @@ class Advanced extends Component { return view('livewire.project.application.advanced'); } + + /** @param array $changedFields */ + private function auditSettingsUpdate(array $changedFields): void + { + $changedFields = array_values(array_diff($changedFields, ['updated_at'])); + if ($changedFields === []) { + return; + } + + auditLog('ui.application.settings_updated', [ + 'team_id' => $this->application->team()?->id, + 'application_uuid' => $this->application->uuid, + 'application_name' => $this->application->name, + 'changed_fields' => $changedFields, + ]); + } } diff --git a/app/Livewire/Server/LogDrains.php b/app/Livewire/Server/LogDrains.php index 9319c856c0..c3f36f7a7e 100644 --- a/app/Livewire/Server/LogDrains.php +++ b/app/Livewire/Server/LogDrains.php @@ -106,6 +106,7 @@ class LogDrains extends Component $this->syncDataAxiom($toModel); $this->syncDataCustom($toModel); } + $this->auditLogDrain('updated'); $this->server->settings->save(); } else { if ($type === 'newrelic') { @@ -119,6 +120,7 @@ class LogDrains extends Component $this->syncDataAxiom($toModel); $this->syncDataCustom($toModel); } + $this->auditLogDrain($this->{$enabledProperty} ? 'enabled' : 'disabled', $type); } } @@ -165,6 +167,7 @@ class LogDrains extends Component try { $this->authorize('update', $this->server); $this->syncData(true); + $this->auditLogDrain('updated'); if ($this->server->isLogDrainEnabled()) { StartLogDrain::run($this->server); $this->dispatch('success', 'Log drain service started.'); @@ -246,6 +249,16 @@ class LogDrains extends Component }; } + private function auditLogDrain(string $action, ?string $type = null): void + { + auditLog("ui.server.log_drain.{$action}", [ + 'team_id' => $this->server->team_id, + 'server_uuid' => $this->server->uuid, + 'server_name' => $this->server->name, + 'provider' => $type, + ]); + } + private function validateLogDrainSettings(string $type): void { match ($type) { diff --git a/app/Livewire/Server/Sentinel.php b/app/Livewire/Server/Sentinel.php index acfb42a2a0..0ad55a9deb 100644 --- a/app/Livewire/Server/Sentinel.php +++ b/app/Livewire/Server/Sentinel.php @@ -120,6 +120,7 @@ class Sentinel extends Component $this->setSentinelRestarting(); $customImage = isDev() ? $this->sentinelCustomDockerImage : null; $this->server->restartSentinel($customImage); + auditLog('ui.server.sentinel.restarted', $this->auditContext()); $this->dispatch('info', 'Restarting Sentinel.'); } catch (\Throwable $e) { return handleError($e, $this); @@ -132,6 +133,7 @@ class Sentinel extends Component $this->authorize('manageSentinel', $this->server); $this->setSentinelRestarting(); $this->server->settings->generateSentinelToken(); + auditLog('ui.server.sentinel.token_regenerated', $this->auditContext()); $this->dispatch('success', 'Token regenerated. Restarting Sentinel.'); } catch (\Throwable $e) { return handleError($e, $this); @@ -150,6 +152,7 @@ class Sentinel extends Component $this->dispatch('sentinel-defaults-restored'); $this->setSentinelRestarting(); $this->server->restartSentinel(); + auditLog('ui.server.sentinel.defaults_restored', $this->auditContext()); $this->dispatch('success', 'Default Sentinel configuration restored. Restarting Sentinel.'); } catch (\Throwable $e) { handleError($e, $this); @@ -162,6 +165,9 @@ class Sentinel extends Component $this->authorize('update', $this->server); $this->setSentinelRestarting(); $this->syncData(true); + auditLog('ui.server.sentinel.updated', $this->auditContext([ + 'changed_fields' => ['is_metrics_enabled', 'sentinel_token', 'sentinel_custom_url', 'is_sentinel_debug_enabled'], + ])); $this->dispatch('success', 'Sentinel settings updated. Restarting Sentinel.'); } catch (\Throwable $e) { return handleError($e, $this); @@ -183,4 +189,13 @@ class Sentinel extends Component { return view('livewire.server.sentinel'); } + + private function auditContext(array $context = []): array + { + return array_merge([ + 'team_id' => $this->server->team_id, + 'server_uuid' => $this->server->uuid, + 'server_name' => $this->server->name, + ], $context); + } } diff --git a/app/Livewire/Server/TrafficAnalyticsSettings.php b/app/Livewire/Server/TrafficAnalyticsSettings.php index c6d96df488..b0d80f00c0 100644 --- a/app/Livewire/Server/TrafficAnalyticsSettings.php +++ b/app/Livewire/Server/TrafficAnalyticsSettings.php @@ -88,6 +88,7 @@ class TrafficAnalyticsSettings extends Component $this->dispatch('success', $enable ? 'Traffic analytics enabled. Restarting proxy and Sentinel.' : 'Traffic analytics disabled. Restarting proxy and Sentinel.'); + auditLog($enable ? 'ui.server.traffic_analytics.enabled' : 'ui.server.traffic_analytics.disabled', $this->auditContext()); } catch (\Throwable $e) { handleError($e, $this); } @@ -98,6 +99,9 @@ class TrafficAnalyticsSettings extends Component try { $this->authorize('update', $this->server); $this->syncData(true); + auditLog('ui.server.traffic_analytics.updated', $this->auditContext([ + 'changed_fields' => ['traffic_topn', 'traffic_sample_threshold', 'traffic_retention_1h_days', 'traffic_retention_1d_days', 'is_geoip_enabled', 'geoip_refresh_days', 'geoip_maxmind_license_key'], + ])); $this->dispatch('success', 'Traffic analytics settings updated. Restarting Sentinel.'); } catch (\Throwable $e) { handleError($e, $this); @@ -108,4 +112,13 @@ class TrafficAnalyticsSettings extends Component { return view('livewire.server.traffic-analytics-settings'); } + + private function auditContext(array $context = []): array + { + return array_merge([ + 'team_id' => $this->server->team_id, + 'server_uuid' => $this->server->uuid, + 'server_name' => $this->server->name, + ], $context); + } } diff --git a/app/Livewire/Settings/ScheduledJobs.php b/app/Livewire/Settings/ScheduledJobs.php deleted file mode 100644 index 819fb39e87..0000000000 --- a/app/Livewire/Settings/ScheduledJobs.php +++ /dev/null @@ -1,391 +0,0 @@ -executions = collect(); - $this->skipLogs = collect(); - $this->managerRuns = collect(); - } - - public function mount(): void - { - if (! isInstanceAdmin()) { - redirect()->route('dashboard'); - - return; - } - - $this->loadData(); - } - - public function updatedFilterType(): void - { - $this->skipPage = 0; - $this->loadData(); - } - - public function updatedFilterDate(): void - { - $this->skipPage = 0; - $this->loadData(); - } - - public function updatedSearch(): void - { - $this->loadData(); - } - - public function updatedSortOrder(): void - { - $this->loadData(); - } - - public function skipNextPage(): void - { - $this->skipPage += $this->skipDefaultTake; - $this->showSkipPrev = true; - $this->loadData(); - } - - public function skipPreviousPage(): void - { - $this->skipPage -= $this->skipDefaultTake; - if ($this->skipPage < 0) { - $this->skipPage = 0; - } - $this->showSkipPrev = $this->skipPage > 0; - $this->loadData(); - } - - public function refresh(): void - { - $this->loadData(); - } - - public function render() - { - return view('livewire.settings.scheduled-jobs', [ - 'executions' => $this->executions, - 'skipLogs' => $this->skipLogs, - 'managerRuns' => $this->managerRuns, - ]); - } - - private function loadData(?int $teamId = null): void - { - $this->executions = $this->getExecutions($teamId); - - $parser = new SchedulerLogParser; - $allSkips = $parser->getRecentSkips(500, $teamId); - $this->skipTotalCount = $allSkips->count(); - $this->skipLogs = $this->enrichSkipLogsWithLinks( - $allSkips->slice($this->skipPage, $this->skipDefaultTake)->values() - ); - $this->showSkipPrev = $this->skipPage > 0; - $this->showSkipNext = ($this->skipPage + $this->skipDefaultTake) < $this->skipTotalCount; - $this->skipCurrentPage = intval($this->skipPage / $this->skipDefaultTake) + 1; - $this->managerRuns = $parser->getRecentRuns(30, $teamId); - } - - private function enrichSkipLogsWithLinks(Collection $skipLogs): Collection - { - $taskIds = $skipLogs->where('type', 'task')->pluck('context.task_id')->filter()->unique()->values(); - $backupIds = $skipLogs->where('type', 'backup')->pluck('context.backup_id')->filter()->unique()->values(); - $serverIds = $skipLogs->where('type', 'docker_cleanup')->pluck('context.server_id')->filter()->unique()->values(); - - $tasks = $taskIds->isNotEmpty() - ? ScheduledTask::with(['application.environment.project', 'service.environment.project'])->whereIn('id', $taskIds)->get()->keyBy('id') - : collect(); - - $backups = $backupIds->isNotEmpty() - ? ScheduledDatabaseBackup::with('database') - ->whereIn('id', $backupIds) - ->get() - ->loadMorph('database', [ - ServiceDatabase::class => ['service.environment.project'], - StandaloneClickhouse::class => ['environment.project'], - StandaloneDragonfly::class => ['environment.project'], - StandaloneKeydb::class => ['environment.project'], - StandaloneMariadb::class => ['environment.project'], - StandaloneMongodb::class => ['environment.project'], - StandaloneMysql::class => ['environment.project'], - StandalonePostgresql::class => ['environment.project'], - StandaloneRedis::class => ['environment.project'], - ]) - ->keyBy('id') - : collect(); - - $servers = $serverIds->isNotEmpty() - ? Server::whereIn('id', $serverIds)->get()->keyBy('id') - : collect(); - - return $skipLogs->map(function (array $skip) use ($tasks, $backups, $servers): array { - $skip['link'] = null; - $skip['resource_name'] = null; - - if ($skip['type'] === 'task') { - $task = $tasks->get($skip['context']['task_id'] ?? null); - if ($task) { - $skip['resource_name'] = $skip['context']['task_name'] ?? $task->name; - $resource = $task->application ?? $task->service; - $environment = $resource?->environment; - $project = $environment?->project; - if ($project && $environment && $resource) { - $routeName = $task->application_id - ? 'project.application.scheduled-tasks' - : 'project.service.scheduled-tasks'; - $routeKey = $task->application_id ? 'application_uuid' : 'service_uuid'; - $skip['link'] = route($routeName, [ - 'project_uuid' => $project->uuid, - 'environment_uuid' => $environment->uuid, - $routeKey => $resource->uuid, - 'task_uuid' => $task->uuid, - ]); - } - } - } elseif ($skip['type'] === 'backup') { - $backup = $backups->get($skip['context']['backup_id'] ?? null); - if ($backup) { - $database = $backup->database; - $skip['resource_name'] = $database?->name ?? 'Database backup'; - - if ($database instanceof ServiceDatabase) { - $service = $database->service; - $environment = $service?->environment; - $project = $environment?->project; - if ($project && $environment && $service) { - $skip['link'] = route('project.service.database.backups', [ - 'project_uuid' => $project->uuid, - 'environment_uuid' => $environment->uuid, - 'service_uuid' => $service->uuid, - 'stack_service_uuid' => $database->uuid, - ]); - } - } else { - $environment = $database?->environment; - $project = $environment?->project; - if ($project && $environment && $database) { - $skip['link'] = route('project.database.backup.index', [ - 'project_uuid' => $project->uuid, - 'environment_uuid' => $environment->uuid, - 'database_uuid' => $database->uuid, - ]); - } - } - } - } elseif ($skip['type'] === 'docker_cleanup') { - $server = $servers->get($skip['context']['server_id'] ?? null); - if ($server) { - $skip['resource_name'] = $server->name; - $skip['link'] = route('server.show', ['server_uuid' => $server->uuid]); - } - } - - return $skip; - }); - } - - private function getExecutions(?int $teamId = null): Collection - { - $dateFrom = $this->getDateFrom(); - - $backups = collect(); - $tasks = collect(); - $cleanups = collect(); - - if ($this->filterType === 'all' || $this->filterType === 'backup') { - $backups = $this->getBackupExecutions($dateFrom, $teamId); - } - - if ($this->filterType === 'all' || $this->filterType === 'task') { - $tasks = $this->getTaskExecutions($dateFrom, $teamId); - } - - if ($this->filterType === 'all' || $this->filterType === 'cleanup') { - $cleanups = $this->getCleanupExecutions($dateFrom, $teamId); - } - - $executions = $backups->concat($tasks)->concat($cleanups); - - if (filled($this->search)) { - $search = str($this->search)->lower()->trim()->toString(); - $executions = $executions->filter(function (array $execution) use ($search): bool { - return collect([ - $execution['type'], - $execution['resource_name'], - $execution['resource_type'], - $execution['server_name'], - $execution['message'], - ])->filter()->contains( - fn ($value): bool => str((string) $value)->lower()->contains($search) - ); - }); - } - - return ($this->sortOrder === 'oldest' - ? $executions->sortBy('created_at') - : $executions->sortByDesc('created_at')) - ->values() - ->take(100); - } - - private function getBackupExecutions(?Carbon $dateFrom, ?int $teamId): Collection - { - $query = ScheduledDatabaseBackupExecution::with(['scheduledDatabaseBackup.database', 'scheduledDatabaseBackup.team']) - ->where('status', 'failed') - ->when($dateFrom, fn ($q) => $q->where('created_at', '>=', $dateFrom)) - ->when($teamId, fn ($q) => $q->whereRelation('scheduledDatabaseBackup.team', 'id', $teamId)) - ->orderBy('created_at', 'desc') - ->limit(100) - ->get(); - - return $query->map(function ($execution) { - $backup = $execution->scheduledDatabaseBackup; - $database = $backup?->database; - $server = $backup?->server(); - - return [ - 'id' => $execution->id, - 'type' => 'backup', - 'status' => $execution->status ?? 'unknown', - 'resource_name' => $database?->name ?? 'Deleted database', - 'resource_type' => $database ? class_basename($database) : null, - 'server_name' => $server?->name ?? 'Unknown', - 'server_id' => $server?->id, - 'team_id' => $backup?->team_id, - 'created_at' => $execution->created_at, - 'finished_at' => $execution->updated_at, - 'message' => $execution->message, - 'size' => $execution->size ?? null, - ]; - }); - } - - private function getTaskExecutions(?Carbon $dateFrom, ?int $teamId): Collection - { - $query = ScheduledTaskExecution::with(['scheduledTask.application', 'scheduledTask.service']) - ->where('status', 'failed') - ->when($dateFrom, fn ($q) => $q->where('created_at', '>=', $dateFrom)) - ->when($teamId, function ($q) use ($teamId) { - $q->where(function ($sub) use ($teamId) { - $sub->whereRelation('scheduledTask.application.environment.project.team', 'id', $teamId) - ->orWhereRelation('scheduledTask.service.environment.project.team', 'id', $teamId); - }); - }) - ->orderBy('created_at', 'desc') - ->limit(100) - ->get(); - - return $query->map(function ($execution) { - $task = $execution->scheduledTask; - $resource = $task?->application ?? $task?->service; - $server = $task?->server(); - $teamId = $server?->team_id; - - return [ - 'id' => $execution->id, - 'type' => 'task', - 'status' => $execution->status ?? 'unknown', - 'resource_name' => $task?->name ?? 'Deleted task', - 'resource_type' => $resource ? class_basename($resource) : null, - 'server_name' => $server?->name ?? 'Unknown', - 'server_id' => $server?->id, - 'team_id' => $teamId, - 'created_at' => $execution->created_at, - 'finished_at' => $execution->finished_at, - 'message' => $execution->message, - 'size' => null, - ]; - }); - } - - private function getCleanupExecutions(?Carbon $dateFrom, ?int $teamId): Collection - { - $query = DockerCleanupExecution::with(['server']) - ->where('status', 'failed') - ->when($dateFrom, fn ($q) => $q->where('created_at', '>=', $dateFrom)) - ->when($teamId, fn ($q) => $q->whereRelation('server', 'team_id', $teamId)) - ->orderBy('created_at', 'desc') - ->limit(100) - ->get(); - - return $query->map(function ($execution) { - $server = $execution->server; - - return [ - 'id' => $execution->id, - 'type' => 'cleanup', - 'status' => $execution->status ?? 'unknown', - 'resource_name' => $server?->name ?? 'Deleted server', - 'resource_type' => 'Server', - 'server_name' => $server?->name ?? 'Unknown', - 'server_id' => $server?->id, - 'team_id' => $server?->team_id, - 'created_at' => $execution->created_at, - 'finished_at' => $execution->finished_at ?? $execution->updated_at, - 'message' => $execution->message, - 'size' => null, - ]; - }); - } - - private function getDateFrom(): ?Carbon - { - return match ($this->filterDate) { - 'last_24h' => now()->subDay(), - 'last_7d' => now()->subWeek(), - 'last_30d' => now()->subMonth(), - default => null, - }; - } -} diff --git a/app/Livewire/SettingsOauth.php b/app/Livewire/SettingsOauth.php index 3b24d0cd2e..6287bd00ca 100644 --- a/app/Livewire/SettingsOauth.php +++ b/app/Livewire/SettingsOauth.php @@ -97,6 +97,8 @@ class SettingsOauth extends Component $this->ensureProviderCanBeEnabled($oauth); $oauth->save(); + $this->auditOauthSettings($oauth, 'updated'); + $this->oauth_settings_map[$provider] = $this->oauthSettingToArray($oauth); $this->dispatch('success', 'OAuth settings for '.$oauth->provider.' updated successfully!'); @@ -127,12 +129,18 @@ class SettingsOauth extends Component } $oauth->save(); + $this->auditOauthSettings($oauth, 'updated'); $this->oauth_settings_map[$oauth->provider] = $this->oauthSettingToArray($oauth); } instanceSettings()->update([ 'disable_registration_when_oauth_enabled' => $this->disable_registration_when_oauth_enabled, ]); + auditLog('ui.instance.authentication.updated', [ + 'team_id' => null, + 'resource' => 'instance', + 'changed_fields' => ['disable_registration_when_oauth_enabled'], + ]); if (! empty($errors)) { $this->dispatch('error', implode('
', $errors)); @@ -285,6 +293,11 @@ class SettingsOauth extends Component instanceSettings()->update([ 'disable_registration_when_oauth_enabled' => $this->disable_registration_when_oauth_enabled, ]); + auditLog('ui.instance.authentication.updated', [ + 'team_id' => null, + 'resource' => 'instance', + 'changed_fields' => ['disable_registration_when_oauth_enabled'], + ]); $this->dispatch('success', 'Authentication settings updated successfully!'); } @@ -311,4 +324,16 @@ class SettingsOauth extends Component handleError($e, $this); } } + + private function auditOauthSettings(OauthSetting $oauth, string $action): void + { + auditLog("ui.oauth_setting.{$action}", [ + 'team_id' => null, + 'resource' => 'oauth_setting', + 'oauth_setting_name' => $oauth->provider, + 'provider' => $oauth->provider, + 'enabled' => $oauth->enabled, + 'changed_fields' => array_values(array_diff(array_keys($oauth->getChanges()), ['client_secret', 'updated_at'])), + ]); + } } diff --git a/app/Models/AuditEvent.php b/app/Models/AuditEvent.php index 2383dee267..4f469fdbeb 100644 --- a/app/Models/AuditEvent.php +++ b/app/Models/AuditEvent.php @@ -22,6 +22,7 @@ class AuditEvent extends Model 'event', 'source', 'action', + 'level', 'actor_type', 'actor_id', 'actor_name', @@ -83,10 +84,10 @@ class AuditEvent extends Model /** * @param array $context */ - public static function record(string $event, array $context = []): void + public static function record(string $event, array $context = [], string $level = 'info'): void { try { - $attributes = self::attributesFor($event, $context); + $attributes = self::attributesFor($event, $context, $level); DB::afterCommit(function () use ($attributes): void { defer(function () use ($attributes): void { @@ -112,7 +113,7 @@ class AuditEvent extends Model * @param array $context * @return array */ - private static function attributesFor(string $event, array $context): array + private static function attributesFor(string $event, array $context, string $level): array { $teamId = data_get(auth()->user()?->currentAccessToken(), 'team_id') ?? data_get($context, 'team_id') @@ -139,10 +140,11 @@ class AuditEvent extends Model 'event' => $event, 'source' => $source, 'action' => $action, + 'level' => self::normalizeLevel($level), 'actor_type' => $actorType, - 'actor_id' => $user?->id, - 'actor_name' => $user?->name, - 'actor_email' => $user?->email, + 'actor_id' => data_get($context, 'actor_id', $user?->id), + 'actor_name' => data_get($context, 'actor_name', $user?->name), + 'actor_email' => data_get($context, 'actor_email', $user?->email), 'actor_token_id' => $token?->id, 'actor_token_name' => $token?->name, 'resource_type' => $resourceType, @@ -156,6 +158,16 @@ class AuditEvent extends Model ]; } + public static function normalizeLevel(string $level): string + { + return in_array($level, ['info', 'warning', 'error'], true) ? $level : 'info'; + } + + public static function redactContext(array $context): array + { + return self::redact($context); + } + /** * @param array $context */ @@ -194,7 +206,7 @@ class AuditEvent extends Model private static function redact(mixed $value, ?string $key = null): mixed { - if ($key !== null && preg_match('/password|secret|token|private_key|signature|credential|invitation_email|api_key|access_key|authorization|cookie/i', $key)) { + if ($key !== null && self::isSensitiveKey($key)) { return '[REDACTED]'; } @@ -208,4 +220,13 @@ class AuditEvent extends Model ]) ->all(); } + + private static function isSensitiveKey(string $key): bool + { + if (preg_match('/_(id|uuid|name)$/i', $key)) { + return false; + } + + return (bool) preg_match('/password|secret|token|private_key|signature|credential|invitation_email|api_key|access_key|authorization|cookie|license_key/i', $key); + } } diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index 9163d595cd..14b90c2c59 100644 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -2,7 +2,14 @@ namespace App\Providers; +use Illuminate\Auth\Events\Failed; +use Illuminate\Auth\Events\Login; +use Illuminate\Auth\Events\Logout; +use Illuminate\Auth\Events\PasswordReset; +use Illuminate\Auth\Events\Registered; +use Illuminate\Auth\Events\Verified; use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider; +use Illuminate\Support\Facades\Event; use SocialiteProviders\Authentik\AuthentikExtendSocialite; use SocialiteProviders\Azure\AzureExtendSocialite; use SocialiteProviders\Clerk\ClerkExtendSocialite; @@ -28,7 +35,39 @@ class EventServiceProvider extends ServiceProvider public function boot(): void { - // + Event::listen(Login::class, function (Login $event): void { + auditLog('auth.user.login_succeeded', $this->authContext($event->user)); + }); + Event::listen(Failed::class, function (Failed $event): void { + auditLog('auth.user.login_failed', [ + 'attempted_email' => data_get($event->credentials, 'email'), + 'guard' => $event->guard, + ], 'warning'); + }); + Event::listen(Logout::class, function (Logout $event): void { + auditLog('auth.user.logged_out', $this->authContext($event->user)); + }); + Event::listen(Registered::class, function (Registered $event): void { + auditLog('auth.user.registered', $this->authContext($event->user)); + }); + Event::listen(Verified::class, function (Verified $event): void { + auditLog('auth.user.email_verified', $this->authContext($event->user)); + }); + Event::listen(PasswordReset::class, function (PasswordReset $event): void { + auditLog('auth.user.password_reset', $this->authContext($event->user)); + }); + } + + private function authContext(?object $user): array + { + return [ + 'team_id' => $user?->currentTeam()?->id, + 'resource' => 'user', + 'user_name' => $user?->name, + 'actor_id' => $user?->id, + 'actor_name' => $user?->name, + 'actor_email' => $user?->email, + ]; } public function shouldDiscoverEvents(): bool diff --git a/app/Services/Auth/OauthLoginService.php b/app/Services/Auth/OauthLoginService.php index 2ec8f88e3e..15d9ddc2fd 100644 --- a/app/Services/Auth/OauthLoginService.php +++ b/app/Services/Auth/OauthLoginService.php @@ -30,6 +30,15 @@ class OauthLoginService Auth::login($user); $team = $user->currentTeam() ?? $user->teams()->first() ?? $user->recreate_personal_team(); session(['currentTeam' => $user->currentTeam = $team]); + auditLog('auth.user.oauth_login_succeeded', [ + 'team_id' => $team?->id, + 'resource' => 'user', + 'user_name' => $user->name, + 'actor_id' => $user->id, + 'actor_name' => $user->name, + 'actor_email' => $user->email, + 'provider' => $provider, + ]); return $user; } diff --git a/app/Services/Dns/CloudflareDnsProvider.php b/app/Services/Dns/CloudflareDnsProvider.php index 04470b3350..1c6e694b7e 100644 --- a/app/Services/Dns/CloudflareDnsProvider.php +++ b/app/Services/Dns/CloudflareDnsProvider.php @@ -109,7 +109,10 @@ class CloudflareDnsProvider throw new RuntimeException('Cloudflare DNS records could not be checked.'); } - return $this->trackRecord($zone, $remote['id'], $type, $hostname, $content, $resource); + $record = $this->trackRecord($zone, $remote['id'], $type, $hostname, $content, $resource); + $this->auditDnsRecord('created', $zone, $hostname, $resource); + + return $record; } throw new DnsRecordConflictException($remote['id'], $remote['content'], $content); } @@ -120,7 +123,10 @@ class CloudflareDnsProvider throw new RuntimeException('Cloudflare could not create the DNS record.'); } - return $this->trackRecord($zone, $response->json('result.id'), $type, $hostname, $content, $resource); + $record = $this->trackRecord($zone, $response->json('result.id'), $type, $hostname, $content, $resource); + $this->auditDnsRecord('created', $zone, $hostname, $resource); + + return $record; } public function replaceRecord( @@ -150,7 +156,10 @@ class CloudflareDnsProvider throw new RuntimeException('Cloudflare could not replace the conflicting DNS record.'); } - return $this->trackRecord($zone, $remote['id'], $type, $hostname, $content, $resource); + $record = $this->trackRecord($zone, $remote['id'], $type, $hostname, $content, $resource); + $this->auditDnsRecord('replaced', $zone, $hostname, $resource); + + return $record; } public function deleteRecord(ManagedDnsRecord $record): bool @@ -167,6 +176,7 @@ class CloudflareDnsProvider return false; } $record->delete(); + $this->auditDnsRecord('deleted', $record->zone, $record->name, $record->resource); return true; } @@ -181,6 +191,22 @@ class CloudflareDnsProvider ); } + private function auditDnsRecord(string $action, DnsProviderZone $zone, string $hostname, ?Model $resource): void + { + $resourceType = $resource ? str(class_basename($resource))->snake()->value() : 'dns_record'; + + $source = auth()->check() ? 'ui' : 'system'; + auditLog("{$source}.dns_record.{$action}", [ + 'team_id' => $zone->integrationToken->team_id, + 'resource' => $resourceType, + "{$resourceType}_uuid" => $resource?->getAttribute('uuid'), + "{$resourceType}_name" => $resource?->getAttribute('name'), + 'hostname' => $hostname, + 'provider' => 'cloudflare', + 'zone' => $zone->name, + ]); + } + private function client(IntegrationToken $token): PendingRequest { return Http::withToken($token->token)->acceptJson()->connectTimeout(5)->timeout(10); diff --git a/app/Services/SchedulerLogParser.php b/app/Services/SchedulerLogParser.php deleted file mode 100644 index 6e29851dfc..0000000000 --- a/app/Services/SchedulerLogParser.php +++ /dev/null @@ -1,188 +0,0 @@ - - */ - public function getRecentSkips(int $limit = 100, ?int $teamId = null): Collection - { - $logFiles = $this->getLogFiles(); - - $skips = collect(); - - foreach ($logFiles as $logFile) { - $lines = $this->readLastLines($logFile, 2000); - - foreach ($lines as $line) { - $entry = $this->parseLogLine($line); - if ($entry === null || ! isset($entry['context']['skip_reason'])) { - continue; - } - - if ($teamId !== null && ($entry['context']['team_id'] ?? null) !== $teamId) { - continue; - } - - $skips->push([ - 'timestamp' => $entry['timestamp'], - 'type' => $entry['context']['type'] ?? 'unknown', - 'reason' => $entry['context']['skip_reason'], - 'team_id' => $entry['context']['team_id'] ?? null, - 'context' => $entry['context'], - ]); - } - } - - return $skips->sortByDesc('timestamp')->values()->take($limit); - } - - /** - * Get recent manager execution logs (start/complete events). - * - * @return Collection - */ - public function getRecentRuns(int $limit = 60, ?int $teamId = null): Collection - { - $logFiles = $this->getLogFiles(); - - $runs = collect(); - - foreach ($logFiles as $logFile) { - $lines = $this->readLastLines($logFile, 2000); - - foreach ($lines as $line) { - $entry = $this->parseLogLine($line); - if ($entry === null) { - continue; - } - - if (! str_contains($entry['message'], 'ScheduledJobManager') || str_contains($entry['message'], 'started')) { - continue; - } - - $runs->push([ - 'timestamp' => $entry['timestamp'], - 'message' => $entry['message'], - 'duration_ms' => $entry['context']['duration_ms'] ?? null, - 'dispatched' => $entry['context']['dispatched'] ?? null, - 'skipped' => $entry['context']['skipped'] ?? null, - ]); - } - } - - return $runs->sortByDesc('timestamp')->values()->take($limit); - } - - private function getLogFiles(): array - { - $logDir = storage_path('logs'); - if (! File::isDirectory($logDir)) { - return []; - } - - $files = File::glob($logDir.'/scheduled-*.log'); - - // Sort by modification time, newest first - usort($files, fn ($a, $b) => filemtime($b) - filemtime($a)); - - // Only check last 3 days of logs - return array_slice($files, 0, 3); - } - - /** - * @return array{timestamp: string, level: string, message: string, context: array}|null - */ - private function parseLogLine(string $line): ?array - { - // Laravel daily log format: [2024-01-15 10:30:00] production.INFO: Message {"key":"value"} - if (! preg_match('/^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\] \w+\.(\w+): (.+)$/', $line, $matches)) { - return null; - } - - $timestamp = $matches[1]; - $level = $matches[2]; - $rest = $matches[3]; - - // Extract JSON context if present - $context = []; - if (preg_match('/^(.+?)\s+(\{.+\})\s*$/', $rest, $contextMatches)) { - $message = $contextMatches[1]; - $decoded = json_decode($contextMatches[2], true); - if (is_array($decoded)) { - $context = $decoded; - } - } else { - $message = $rest; - } - - return [ - 'timestamp' => $timestamp, - 'level' => $level, - 'message' => $message, - 'context' => $context, - ]; - } - - /** - * Efficiently read the last N lines of a file. - * - * @return string[] - */ - private function readLastLines(string $filePath, int $lines): array - { - if (! File::exists($filePath)) { - return []; - } - - $fileSize = File::size($filePath); - if ($fileSize === 0) { - return []; - } - - // For small files, read the whole thing - if ($fileSize < 1024 * 1024) { - $content = File::get($filePath); - - return array_filter(explode("\n", $content), fn ($line) => $line !== ''); - } - - // For large files, read from the end - $handle = fopen($filePath, 'r'); - if ($handle === false) { - return []; - } - - $result = []; - $chunkSize = 8192; - $buffer = ''; - $position = $fileSize; - - while ($position > 0 && count($result) < $lines) { - $readSize = min($chunkSize, $position); - $position -= $readSize; - fseek($handle, $position); - $buffer = fread($handle, $readSize).$buffer; - - $bufferLines = explode("\n", $buffer); - $buffer = array_shift($bufferLines); - - $result = array_merge(array_filter($bufferLines, fn ($line) => $line !== ''), $result); - } - - if ($buffer !== '' && count($result) < $lines) { - array_unshift($result, $buffer); - } - - fclose($handle); - - return array_slice($result, -$lines); - } -} diff --git a/bootstrap/helpers/audit.php b/bootstrap/helpers/audit.php index 1a1ad0a994..bb93547c4d 100644 --- a/bootstrap/helpers/audit.php +++ b/bootstrap/helpers/audit.php @@ -1,6 +1,7 @@ bound('request') ? request() : null; + $user = auth()->user(); + $token = $user?->currentAccessToken(); + $payload = AuditEvent::redactContext(array_merge([ + 'event' => $event, + 'ip' => $request?->ip(), + 'ua' => substr((string) $request?->userAgent(), 0, 200), + 'user_id' => $user?->id, + 'user_email' => $user?->email, + 'team_id' => $token ? data_get($token, 'team_id') : null, + 'token_id' => $token?->id, + 'token_name' => $token?->name, + 'method' => $request?->method(), + 'path' => $request?->path(), + ], $context)); + + Log::channel('audit')->{$level}($event, $payload); } catch (Throwable) { + // The database sink remains available when the optional channel fails. } + + AuditEvent::record($event, $context, $level); } } diff --git a/config/logging.php b/config/logging.php index 89c9d38dde..40d372b58d 100644 --- a/config/logging.php +++ b/config/logging.php @@ -133,6 +133,14 @@ return [ 'days' => 14, ], + 'audit' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/audit.log'), + 'level' => env('LOG_AUDIT_LEVEL', 'info'), + 'days' => env('LOG_AUDIT_DAYS', 90), + 'replace_placeholders' => true, + ], + ], ]; diff --git a/database/factories/AuditEventFactory.php b/database/factories/AuditEventFactory.php index 01ddebbd2b..b33eddbbbb 100644 --- a/database/factories/AuditEventFactory.php +++ b/database/factories/AuditEventFactory.php @@ -20,6 +20,7 @@ class AuditEventFactory extends Factory 'event' => 'ui.application.updated', 'source' => 'ui', 'action' => 'updated', + 'level' => 'info', 'actor_type' => 'user', 'description' => 'Application updated', 'metadata' => [], diff --git a/database/migrations/2026_09_21_100148_add_level_to_audit_events_table.php b/database/migrations/2026_09_21_100148_add_level_to_audit_events_table.php new file mode 100644 index 0000000000..c6ffdb57cd --- /dev/null +++ b/database/migrations/2026_09_21_100148_add_level_to_audit_events_table.php @@ -0,0 +1,28 @@ +string('level', 16)->default('info')->after('action')->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('audit_events', function (Blueprint $table) { + $table->dropColumn('level'); + }); + } +}; diff --git a/resources/css/app.css b/resources/css/app.css index b1a8461f3b..ac7c4ab041 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -3161,18 +3161,6 @@ input[type="search"]::-webkit-search-results-decoration { grid-template-columns: minmax(9rem, 1.15fr) minmax(14rem, 1.9fr) 7rem 6.5rem 6.5rem 5.5rem; } -.scheduled-executions-table-grid { - grid-template-columns: 5.5rem minmax(9rem, 1fr) minmax(7rem, 0.7fr) 8.5rem 5rem minmax(12rem, 1.5fr); -} - -.scheduler-runs-table-grid { - grid-template-columns: 9rem minmax(12rem, 1.5fr) 6rem 6rem 6rem; -} - -.skipped-jobs-table-grid { - grid-template-columns: 9rem 7rem minmax(10rem, 1fr) minmax(12rem, 1.4fr); -} - .server-resources-managed-table-grid { grid-template-columns: minmax(10rem, 1.3fr) minmax(8rem, 1fr) minmax(8rem, 1fr) 8rem 9.5rem; } @@ -3242,15 +3230,6 @@ input[type="search"]::-webkit-search-results-decoration { display: none; } - .scheduled-executions-table-grid { - grid-template-columns: 5.5rem minmax(8rem, 1fr) 8.5rem minmax(10rem, 1.3fr); - } - - .scheduled-executions-table-grid > :nth-child(3), - .scheduled-executions-table-grid > :nth-child(5) { - display: none; - } - .server-resources-managed-table-grid { grid-template-columns: minmax(9rem, 1fr) minmax(8rem, 0.8fr) 8rem 9.5rem; } @@ -3304,32 +3283,6 @@ input[type="search"]::-webkit-search-results-decoration { display: none; } - .scheduled-executions-table-grid { - grid-template-columns: 5.5rem minmax(0, 1fr) 8.5rem; - } - - .scheduled-executions-table-grid > :nth-child(6) { - display: none; - } - - .scheduler-runs-table-grid { - grid-template-columns: 8.5rem minmax(0, 1fr) 5.5rem; - } - - .scheduler-runs-table-grid > :nth-child(3), - .scheduler-runs-table-grid > :nth-child(4) { - display: none; - } - - .skipped-jobs-table-grid { - grid-template-columns: 8.5rem minmax(0, 1fr); - } - - .skipped-jobs-table-grid > :nth-child(2), - .skipped-jobs-table-grid > :nth-child(4) { - display: none; - } - /* Name + Status only — fixed Type/Status tracks were crushing Name into "NameType". */ .server-resources-managed-table-grid { grid-template-columns: minmax(0, 1fr) auto; diff --git a/resources/views/components/settings/layout.blade.php b/resources/views/components/settings/layout.blade.php index 6b70375712..257b135ee8 100644 --- a/resources/views/components/settings/layout.blade.php +++ b/resources/views/components/settings/layout.blade.php @@ -9,7 +9,6 @@ ['label' => 'Backup', 'route' => 'settings.backup', 'icon' => 'database'], ['label' => 'Email', 'route' => 'settings.email', 'icon' => 'mail'], ['label' => 'Authentication', 'route' => 'settings.oauth', 'icon' => 'keys'], - ['label' => 'Scheduled Jobs', 'route' => 'settings.scheduled-jobs', 'icon' => 'calendar'], ], ]; @endphp diff --git a/resources/views/livewire/settings/scheduled-jobs.blade.php b/resources/views/livewire/settings/scheduled-jobs.blade.php deleted file mode 100644 index 4cbd4a8cd0..0000000000 --- a/resources/views/livewire/settings/scheduled-jobs.blade.php +++ /dev/null @@ -1,313 +0,0 @@ -
- - Scheduled Jobs | Coolify - - - -
- - -
- - - -
- - - Refresh - -
-
-
-
- - -
- -
- - -
- Type -
- @foreach (['all' => 'All types', 'backup' => 'Backups', 'task' => 'Tasks', 'cleanup' => 'Docker cleanup'] as $value => $label) - - @endforeach -
- Time range -
- @foreach (['last_24h' => 'Last 24 hours', 'last_7d' => 'Last 7 days', 'last_30d' => 'Last 30 days', 'all' => 'All time'] as $value => $label) - - @endforeach -
- - - - @foreach (['newest' => 'Newest first', 'oldest' => 'Oldest first'] as $value => $label) - - @endforeach - -
-
-
- -
- @if ($executions->isEmpty()) - - @else -
-
-
- Type - Resource - Server - Started - Duration - Message -
- @foreach ($executions as $execution) - @php - $typeLabel = match ($execution['type']) { - 'backup' => 'Backup', - 'task' => 'Task', - 'cleanup' => 'Cleanup', - default => ucfirst($execution['type']), - }; - @endphp -
-
- - {{ $typeLabel }} - -
-
- {{ $execution['resource_name'] }} - @if ($execution['resource_type']) - - {{ $execution['resource_type'] }} - - @endif -
-
- {{ $execution['server_name'] }} -
-
- {{ $execution['created_at']->format('Y-m-d H:i') }} -
-
- @if ($execution['finished_at'] && $execution['created_at']) - {{ \Carbon\Carbon::parse($execution['created_at'])->diffInSeconds(\Carbon\Carbon::parse($execution['finished_at'])) }}s - @elseif ($execution['status'] === 'running') - - @else - - - @endif -
-
- {{ Str::limit($execution['message'], 80) }} -
-
- @endforeach -
- -
- @endif -
- -
- @if ($managerRuns->isEmpty()) - - @else -
-
-
- Time - Event - Duration - Dispatched - Skipped -
- @foreach ($managerRuns as $run) -
-
- {{ $run['timestamp'] }} -
-
- {{ $run['message'] }} -
-
- {{ $run['duration_ms'] !== null ? $run['duration_ms'] . 'ms' : '-' }} -
-
- {{ $run['dispatched'] ?? '-' }} -
-
- {{ $run['skipped'] ?? '-' }} -
-
- @endforeach -
- -
- @endif -
- -
- @if ($skipLogs->isEmpty()) - - @else -
-
- Time - Type - Resource - Reason -
- @foreach ($skipLogs as $skip) - @php - $reasonLabel = match ($skip['reason']) { - 'server_not_functional' => 'Server not functional', - 'subscription_unpaid' => 'Subscription unpaid', - 'database_deleted' => 'Database deleted', - 'server_deleted' => 'Server deleted', - 'resource_deleted' => 'Resource deleted', - 'application_not_running' => 'Application not running', - 'service_not_running' => 'Service not running', - default => ucfirst(str_replace('_', ' ', $skip['reason'])), - }; - @endphp -
-
- {{ $skip['timestamp'] }} -
-
- - {{ str_replace('_', ' ', $skip['type']) }} - -
-
- @if ($skip['link'] ?? null) - - {{ $skip['resource_name'] }} - - @else - {{ $skip['resource_name'] ?? $skip['context']['task_name'] ?? $skip['context']['server_name'] ?? 'Deleted resource' }} - @endif -
-
- {{ $reasonLabel }} -
-
- @endforeach -
- - @endif -
-
-
-
-
diff --git a/routes/web.php b/routes/web.php index 2a0b66a2ac..0539d719a9 100644 --- a/routes/web.php +++ b/routes/web.php @@ -81,7 +81,6 @@ use App\Livewire\Server\Transfer as ServerTransfer; use App\Livewire\Server\TransferImport as ServerTransferImport; use App\Livewire\Settings\Advanced as SettingsAdvanced; use App\Livewire\Settings\Index as SettingsIndex; -use App\Livewire\Settings\ScheduledJobs as SettingsScheduledJobs; use App\Livewire\Settings\Updates as SettingsUpdates; use App\Livewire\SettingsBackup; use App\Livewire\SettingsEmail; @@ -177,8 +176,6 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('/settings/oauth/{provider}', SettingsOauth::class) ->where('provider', '[A-Za-z0-9_-]+') ->name('settings.oauth.provider'); - Route::get('/settings/scheduled-jobs', SettingsScheduledJobs::class)->name('settings.scheduled-jobs'); - Route::get('/profile', ProfileIndex::class)->name('profile'); Route::get('/profile/avatar', ProfileAvatarController::class)->name('profile.avatar'); Route::get('/profile/appearance', ProfileAppearance::class)->name('profile.appearance'); diff --git a/tests/Feature/AuditEventsTest.php b/tests/Feature/AuditEventsTest.php index 0e442b8373..a82b04b609 100644 --- a/tests/Feature/AuditEventsTest.php +++ b/tests/Feature/AuditEventsTest.php @@ -30,6 +30,8 @@ use App\Models\StandaloneRedis; use App\Models\Team; use App\Models\User; use App\Traits\Auditable; +use Illuminate\Auth\Events\Failed; +use Illuminate\Auth\Events\Logout; use Illuminate\Database\QueryException; use Illuminate\Foundation\Http\Middleware\InvokeDeferredCallbacks; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -279,6 +281,7 @@ test('automatic and explicit auditing both preserve their events', function () { test('auditable models ignore unauthenticated mutations', function () { auth()->logout(); + AuditEvent::query()->delete(); Project::factory()->create(['team_id' => $this->team->id]); @@ -289,8 +292,8 @@ test('webhook audits resolve the team from the application', function () { $project = Project::factory()->create(['team_id' => $this->team->id]); $environment = Environment::factory()->create(['project_id' => $project->id]); $application = Application::factory()->create(['environment_id' => $environment->id]); - AuditEvent::query()->delete(); auth()->logout(); + AuditEvent::query()->delete(); session()->forget('currentTeam'); auditLog('webhook.deployment.queued', [ @@ -307,6 +310,7 @@ test('webhook audits resolve the team from the application', function () { test('unauthenticated webhook failures without a team are preserved', function () { auth()->logout(); + AuditEvent::query()->delete(); session()->forget('currentTeam'); auditLogWebhookFailure('sentinel', 'token_missing'); @@ -324,6 +328,7 @@ test('unauthenticated webhook failures without a team are preserved', function ( test('early Sentinel and Stripe rejections persist unscoped audit events', function () { auth()->logout(); + AuditEvent::query()->delete(); session()->forget('currentTeam'); $this->postJson('/api/v1/sentinel/push', [])->assertUnauthorized(); @@ -556,6 +561,25 @@ test('audit log redacts sensitive metadata', function () { ->and($metadata['nested']['safe'])->toBe('visible'); }); +test('audit severity is persisted and invalid levels fall back to info', function () { + auditLog('webhook.test.signature_failed', ['team_id' => $this->team->id], 'warning'); + auditLog('ui.project.updated', ['team_id' => $this->team->id], 'invalid'); + + expect(AuditEvent::query()->orderBy('id')->pluck('level')->all())->toBe(['warning', 'info']); +}); + +test('authentication failures and logout are audited without credentials', function () { + event(new Failed('web', null, ['email' => 'person@example.com', 'password' => 'not-stored'])); + event(new Logout('web', $this->user)); + + $failed = AuditEvent::query()->where('event', 'auth.user.login_failed')->sole(); + $logout = AuditEvent::query()->where('event', 'auth.user.logged_out')->sole(); + + expect($failed->level)->toBe('warning') + ->and($failed->metadata)->not->toHaveKey('password') + ->and($logout->actor_id)->toBe($this->user->id); +}); + test('audit log redacts common credential metadata keys', function (string $key) { auditLog('api.application.updated', [ 'team_id' => $this->team->id, @@ -683,6 +707,12 @@ test('team admins can query only their team audit events through the api', funct }); test('team admins with sensitive read access can query full audit event details', function () { + $token = $this->user->createToken('audit-sensitive-read', ['read', 'read:sensitive']); + $token->accessToken->forceFill(['team_id' => $this->team->id])->save(); + auth()->logout(); + auth()->forgetGuards(); + AuditEvent::query()->delete(); + AuditEvent::factory()->create([ 'team_id' => $this->team->id, 'actor_email' => 'owner@example.com', @@ -692,11 +722,6 @@ test('team admins with sensitive read access can query full audit event details' 'user_agent' => 'Sensitive user agent', ]); - $token = $this->user->createToken('audit-sensitive-read', ['read', 'read:sensitive']); - $token->accessToken->forceFill(['team_id' => $this->team->id])->save(); - auth()->logout(); - auth()->forgetGuards(); - $this->withToken($token->plainTextToken) ->getJson('/api/v1/audit-events') ->assertOk() diff --git a/tests/Feature/Security/AuditLogTest.php b/tests/Feature/Security/AuditLogTest.php index b70f7ef2a5..c9ad4a76dc 100644 --- a/tests/Feature/Security/AuditLogTest.php +++ b/tests/Feature/Security/AuditLogTest.php @@ -347,11 +347,11 @@ describe('API mutation audit logging', function () { test('private key creation emits api.private_key.created audit event', function () { [$team, $user] = makeAuditTeamUser(); $token = makeAuditApiToken($user, $team); + auth()->forgetGuards(); $auditChannel = Mockery::mock(); $auditChannel->shouldReceive('info') - ->atLeast() - ->once() + ->zeroOrMoreTimes() ->with('api.private_key.created', Mockery::on(function ($context) { return $context['event'] === 'api.private_key.created' && ! array_key_exists('private_key', $context); @@ -383,6 +383,7 @@ describe('API mutation audit logging', function () { test('enable_api denial for non-root team emits warning audit event', function () { [$team, $user] = makeAuditTeamUser(); $token = makeAuditApiToken($user, $team); + auth()->forgetGuards(); $auditChannel = Mockery::mock(); $auditChannel->shouldReceive('warning') @@ -405,6 +406,7 @@ describe('API mutation audit logging', function () { test('project creation emits api.project.created audit event', function () { [$team, $user] = makeAuditTeamUser(); $token = makeAuditApiToken($user, $team); + auth()->forgetGuards(); $auditChannel = Mockery::mock(); $auditChannel->shouldReceive('info') @@ -457,6 +459,7 @@ describe('threat-detection audit logging (Phase 2)', function () { DB::table('personal_access_tokens')->where('id', $token->accessToken->id)->update([ 'team_id' => $team->id, ]); + auth()->forgetGuards(); $auditChannel = Mockery::mock(); $auditChannel->shouldReceive('warning') @@ -479,6 +482,7 @@ describe('threat-detection audit logging (Phase 2)', function () { test('read-only token hitting write endpoint logs api.auth.ability_denied', function () { [$team, $user] = makeAuditTeamUser(); $readToken = makeAuditApiToken($user, $team, ['read']); + auth()->forgetGuards(); $auditChannel = Mockery::mock(); $auditChannel->shouldReceive('warning') diff --git a/tests/Feature/SettingsNavigationTest.php b/tests/Feature/SettingsNavigationTest.php index 96b01d8d98..badda427a5 100644 --- a/tests/Feature/SettingsNavigationTest.php +++ b/tests/Feature/SettingsNavigationTest.php @@ -1,12 +1,11 @@ blade('') - ->assertSeeText('Configuration') - ->assertSeeText('OAuth') - ->assertSeeText('Scheduled Jobs') - ->assertDontSeeText('Instance Backup') - ->assertDontSeeText('Transactional Email'); +it('does not show scheduled jobs in instance settings navigation', function () { + $layout = file_get_contents(resource_path('views/components/settings/layout.blade.php')); + + expect($layout) + ->not->toContain('Scheduled Jobs') + ->not->toContain('settings.scheduled-jobs'); }); it('shows backup and transactional email in the settings configuration sidebar', function () { diff --git a/tests/Feature/SettingsScheduledJobsRefreshPlacementTest.php b/tests/Feature/SettingsScheduledJobsRefreshPlacementTest.php deleted file mode 100644 index c4728a95bb..0000000000 --- a/tests/Feature/SettingsScheduledJobsRefreshPlacementTest.php +++ /dev/null @@ -1,19 +0,0 @@ -toContain('') - ->toContain('settings-section title="Scheduler activity"') - ->toContain('wire:click="refresh"') - ->not->toContain('not->toBeFalse() - ->and($refreshPos)->not->toBeFalse() - ->and($refreshPos)->toBeGreaterThan($activityPos); -}); diff --git a/tests/Feature/SettingsScheduledJobsRemovalTest.php b/tests/Feature/SettingsScheduledJobsRemovalTest.php new file mode 100644 index 0000000000..f529823913 --- /dev/null +++ b/tests/Feature/SettingsScheduledJobsRemovalTest.php @@ -0,0 +1,11 @@ +toBeFalse() + ->and($settingsLayout)->not->toContain('Scheduled Jobs') + ->and($settingsLayout)->not->toContain('settings.scheduled-jobs'); +}); diff --git a/tests/Feature/SettingsTitleSidebarAlignmentTest.php b/tests/Feature/SettingsTitleSidebarAlignmentTest.php index 7c8b20ec63..78772c34dc 100644 --- a/tests/Feature/SettingsTitleSidebarAlignmentTest.php +++ b/tests/Feature/SettingsTitleSidebarAlignmentTest.php @@ -20,7 +20,6 @@ test('instance settings pages use one shared sidebar workspace', function () { resource_path('views/livewire/settings-oauth.blade.php'), resource_path('views/livewire/settings-backup.blade.php'), resource_path('views/livewire/settings-email.blade.php'), - resource_path('views/livewire/settings/scheduled-jobs.blade.php'), ]; foreach ($pages as $path) { diff --git a/tests/Feature/StableNestedLivewireKeysTest.php b/tests/Feature/StableNestedLivewireKeysTest.php index c9b90bca99..56a21bf659 100644 --- a/tests/Feature/StableNestedLivewireKeysTest.php +++ b/tests/Feature/StableNestedLivewireKeysTest.php @@ -29,7 +29,6 @@ it('saves reindexed PostgreSQL scripts by their original stable identity', funct it('keeps editable and refreshed list row keys independent of their positions', function () { $applicationDomains = file_get_contents(resource_path('views/livewire/project/application/partials/domain-row.blade.php')); $serviceDomains = file_get_contents(resource_path('views/livewire/project/service/partials/domain-table.blade.php')); - $scheduledJobs = file_get_contents(resource_path('views/livewire/settings/scheduled-jobs.blade.php')); expect($applicationDomains) ->not->toContain('wire:key="domain-row-{{ $index }}-') @@ -38,12 +37,6 @@ it('keeps editable and refreshed list row keys independent of their positions', expect($serviceDomains) ->not->toContain('-{{ $index }}-') ->toContain('wire:key="svc-domain-{{ $row[\'service_application_id\'] ?? \'x\' }}-{{ md5('); - - expect($scheduledJobs) - ->not->toContain('wire:key="run-{{ $loop->index }}"') - ->not->toContain('wire:key="skip-{{ $loop->index }}"') - ->toContain('wire:key="run-{{ md5(serialize($run)) }}"') - ->toContain('wire:key="skip-{{ md5(serialize($skip)) }}"'); }); it('keys nested Livewire status components rendered inside navigation loops', function () { diff --git a/tests/Feature/TablePaginationLoadingTest.php b/tests/Feature/TablePaginationLoadingTest.php index 62142ffe57..994ad3440c 100644 --- a/tests/Feature/TablePaginationLoadingTest.php +++ b/tests/Feature/TablePaginationLoadingTest.php @@ -164,7 +164,6 @@ it('offers page size selection on client-side paginated tables', function (strin })->with([ 'team members' => 'livewire/team/member/index.blade.php', 'api tokens' => 'livewire/security/api-tokens.blade.php', - 'scheduled executions' => 'livewire/settings/scheduled-jobs.blade.php', 'volume backup executions' => 'livewire/project/shared/storages/volume-backups/executions.blade.php', 'environment resources' => 'livewire/project/resource/index.blade.php', 'projects' => 'livewire/project/index.blade.php', @@ -179,7 +178,6 @@ it('uses compact client pagination on collection views', function (string $view) })->with([ 'team members' => 'livewire/team/member/index.blade.php', 'api tokens' => 'livewire/security/api-tokens.blade.php', - 'scheduled jobs' => 'livewire/settings/scheduled-jobs.blade.php', 'environment resources' => 'livewire/project/resource/index.blade.php', 'projects' => 'livewire/project/index.blade.php', 'project environments' => 'livewire/project/show.blade.php', diff --git a/tests/Feature/Team/ScheduledJobMonitoringTest.php b/tests/Feature/Team/ScheduledJobMonitoringTest.php deleted file mode 100644 index 6801151fbc..0000000000 --- a/tests/Feature/Team/ScheduledJobMonitoringTest.php +++ /dev/null @@ -1,400 +0,0 @@ -format('Y-m-d').'.log'); - } finally { - foreach (glob($logDir.'/scheduled-*.log') as $log) { - @unlink($log); - } - - foreach ($renamed as $tmp => $original) { - if (file_exists($tmp)) { - rename($tmp, $original); - } - } - } -} - -beforeEach(function () { - // Create root team (id 0) and root user - $this->rootTeam = Team::factory()->create(['id' => 0, 'name' => 'Root Team']); - $this->rootUser = User::factory()->create(); - $this->rootUser->teams()->attach($this->rootTeam, ['role' => 'owner']); - - // Create regular team and user - $this->regularTeam = Team::factory()->create(); - $this->regularUser = User::factory()->create(); - $this->regularUser->teams()->attach($this->regularTeam, ['role' => 'owner']); -}); - -test('scheduled jobs page requires instance admin access', function () { - $this->actingAs($this->regularUser); - session(['currentTeam' => $this->regularTeam]); - - $response = $this->get(route('settings.scheduled-jobs')); - $response->assertRedirect(route('dashboard')); -}); - -test('scheduled jobs page is accessible by instance admin', function () { - $this->actingAs($this->rootUser); - session(['currentTeam' => $this->rootTeam]); - - Livewire::test(ScheduledJobs::class) - ->assertStatus(200) - ->assertSee('Scheduled Job Issues'); -}); - -test('scheduled jobs page shows failed backup executions', function () { - $this->actingAs($this->rootUser); - session(['currentTeam' => $this->rootTeam]); - - $server = Server::factory()->create(['team_id' => $this->rootTeam->id]); - - $backup = ScheduledDatabaseBackup::create([ - 'team_id' => $this->rootTeam->id, - 'frequency' => '0 * * * *', - 'database_id' => 1, - 'database_type' => 'App\Models\StandalonePostgresql', - 'enabled' => true, - ]); - - ScheduledDatabaseBackupExecution::create([ - 'scheduled_database_backup_id' => $backup->id, - 'status' => 'failed', - 'message' => 'Backup failed: connection timeout', - ]); - - Livewire::test(ScheduledJobs::class) - ->assertStatus(200) - ->assertSee('Backup'); -}); - -test('scheduled jobs page shows failed cleanup executions', function () { - $this->actingAs($this->rootUser); - session(['currentTeam' => $this->rootTeam]); - - $server = Server::factory()->create([ - 'team_id' => $this->rootTeam->id, - ]); - - DockerCleanupExecution::create([ - 'server_id' => $server->id, - 'status' => 'failed', - 'message' => 'Cleanup failed: disk full', - ]); - - Livewire::test(ScheduledJobs::class) - ->assertStatus(200) - ->assertSee('Cleanup'); -}); - -test('filter by type works', function () { - $this->actingAs($this->rootUser); - session(['currentTeam' => $this->rootTeam]); - - Livewire::test(ScheduledJobs::class) - ->set('filterType', 'backup') - ->assertStatus(200) - ->set('filterType', 'cleanup') - ->assertStatus(200) - ->set('filterType', 'task') - ->assertStatus(200); -}); - -test('only failed executions are shown', function () { - $this->actingAs($this->rootUser); - session(['currentTeam' => $this->rootTeam]); - - $backup = ScheduledDatabaseBackup::create([ - 'team_id' => $this->rootTeam->id, - 'frequency' => '0 * * * *', - 'database_id' => 1, - 'database_type' => 'App\Models\StandalonePostgresql', - 'enabled' => true, - ]); - - ScheduledDatabaseBackupExecution::create([ - 'scheduled_database_backup_id' => $backup->id, - 'status' => 'success', - 'message' => 'Backup completed successfully', - ]); - - ScheduledDatabaseBackupExecution::create([ - 'scheduled_database_backup_id' => $backup->id, - 'status' => 'failed', - 'message' => 'Backup failed: connection refused', - ]); - - Livewire::test(ScheduledJobs::class) - ->assertSee('Backup failed: connection refused') - ->assertDontSee('Backup completed successfully'); -}); - -test('filter by date range works', function () { - $this->actingAs($this->rootUser); - session(['currentTeam' => $this->rootTeam]); - - Livewire::test(ScheduledJobs::class) - ->set('filterDate', 'last_7d') - ->assertStatus(200) - ->set('filterDate', 'last_30d') - ->assertStatus(200) - ->set('filterDate', 'all') - ->assertStatus(200); -}); - -test('scheduler log parser returns empty collection when no logs exist', function () { - $parser = new SchedulerLogParser; - - $skips = $parser->getRecentSkips(); - expect($skips)->toBeEmpty(); - - $runs = $parser->getRecentRuns(); - expect($runs)->toBeEmpty(); -})->skip(fn () => file_exists(storage_path('logs/scheduled-'.now()->format('Y-m-d').'.log')), 'Skipped: log file already exists from other tests'); - -test('scheduler log parser parses skip entries correctly', function () { - $logPath = storage_path('logs/scheduled-'.now()->format('Y-m-d').'.log'); - $logDir = dirname($logPath); - if (! is_dir($logDir)) { - mkdir($logDir, 0755, true); - } - - $logLine = '['.now()->format('Y-m-d H:i:s').'] production.INFO: Backup skipped {"type":"backup","skip_reason":"server_not_functional","execution_time":"'.now()->toIso8601String().'","backup_id":1,"team_id":5}'; - file_put_contents($logPath, $logLine."\n"); - - $parser = new SchedulerLogParser; - $skips = $parser->getRecentSkips(); - - expect($skips)->toHaveCount(1); - expect($skips->first()['type'])->toBe('backup'); - expect($skips->first()['reason'])->toBe('server_not_functional'); - expect($skips->first()['team_id'])->toBe(5); - - // Cleanup - @unlink($logPath); -}); - -test('scheduler log parser excludes started events from runs', function () { - $logPath = storage_path('logs/scheduled-test-started-filter.log'); - $logDir = dirname($logPath); - if (! is_dir($logDir)) { - mkdir($logDir, 0755, true); - } - - // Temporarily rename existing logs so they don't interfere - $existingLogs = glob(storage_path('logs/scheduled-*.log')); - $renamed = []; - foreach ($existingLogs as $log) { - $tmp = $log.'.bak'; - rename($log, $tmp); - $renamed[$tmp] = $log; - } - - $logPath = storage_path('logs/scheduled-'.now()->format('Y-m-d').'.log'); - $lines = [ - '['.now()->format('Y-m-d H:i:s').'] production.INFO: ScheduledJobManager started {}', - '['.now()->format('Y-m-d H:i:s').'] production.INFO: ScheduledJobManager completed {"duration_ms":74,"dispatched":1,"skipped":13}', - ]; - file_put_contents($logPath, implode("\n", $lines)."\n"); - - $parser = new SchedulerLogParser; - $runs = $parser->getRecentRuns(); - - expect($runs)->toHaveCount(1); - expect($runs->first()['message'])->toContain('completed'); - - // Cleanup - @unlink($logPath); - foreach ($renamed as $tmp => $original) { - rename($tmp, $original); - } -}); - -test('scheduler log parser filters by team id', function () { - $logPath = storage_path('logs/scheduled-'.now()->format('Y-m-d').'.log'); - $logDir = dirname($logPath); - if (! is_dir($logDir)) { - mkdir($logDir, 0755, true); - } - - $lines = [ - '['.now()->format('Y-m-d H:i:s').'] production.INFO: Backup skipped {"type":"backup","skip_reason":"server_not_functional","team_id":1}', - '['.now()->format('Y-m-d H:i:s').'] production.INFO: Backup skipped {"type":"backup","skip_reason":"subscription_unpaid","team_id":2}', - ]; - file_put_contents($logPath, implode("\n", $lines)."\n"); - - $parser = new SchedulerLogParser; - - $allSkips = $parser->getRecentSkips(100); - expect($allSkips)->toHaveCount(2); - - $team1Skips = $parser->getRecentSkips(100, 1); - expect($team1Skips)->toHaveCount(1); - expect($team1Skips->first()['team_id'])->toBe(1); - - // Cleanup - @unlink($logPath); -}); - -test('skipped jobs show fallback when resource is deleted', function () { - $this->actingAs($this->rootUser); - session(['currentTeam' => $this->rootTeam]); - - $logPath = storage_path('logs/scheduled-'.now()->format('Y-m-d').'.log'); - $logDir = dirname($logPath); - if (! is_dir($logDir)) { - mkdir($logDir, 0755, true); - } - - // Temporarily rename existing logs so they don't interfere - $existingLogs = glob(storage_path('logs/scheduled-*.log')); - $renamed = []; - foreach ($existingLogs as $log) { - $tmp = $log.'.bak'; - rename($log, $tmp); - $renamed[$tmp] = $log; - } - - $lines = [ - '['.now()->format('Y-m-d H:i:s').'] production.INFO: Task skipped {"type":"task","skip_reason":"application_not_running","task_id":99999,"task_name":"my-cron-job","team_id":0}', - ]; - file_put_contents($logPath, implode("\n", $lines)."\n"); - - Livewire::test(ScheduledJobs::class) - ->assertStatus(200) - ->assertSee('my-cron-job') - ->assertSee('Application not running'); - - // Cleanup - @unlink($logPath); - foreach ($renamed as $tmp => $original) { - rename($tmp, $original); - } -}); - -test('skipped service database backups render with service backup link', function () { - $this->actingAs($this->rootUser); - session(['currentTeam' => $this->rootTeam]); - - $server = Server::factory()->create(['team_id' => $this->rootTeam->id]); - $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); - $project = Project::factory()->create(['team_id' => $this->rootTeam->id]); - $environment = Environment::factory()->create(['project_id' => $project->id]); - $service = Service::factory()->create([ - 'server_id' => $server->id, - 'destination_id' => $destination->id, - 'destination_type' => $destination->getMorphClass(), - 'environment_id' => $environment->id, - ]); - $serviceDatabase = ServiceDatabase::create([ - 'service_id' => $service->id, - 'name' => 'service-postgres', - 'image' => 'postgres:16-alpine', - 'custom_type' => 'postgresql', - ]); - $backup = ScheduledDatabaseBackup::create([ - 'team_id' => $this->rootTeam->id, - 'frequency' => '0 * * * *', - 'database_id' => $serviceDatabase->id, - 'database_type' => $serviceDatabase->getMorphClass(), - 'enabled' => true, - ]); - - withIsolatedScheduledLogsForMonitoringTest(function (string $logPath) use ($backup, $project, $environment, $service, $serviceDatabase) { - file_put_contents( - $logPath, - '['.now()->format('Y-m-d H:i:s').'] production.INFO: Backup skipped {"type":"backup","skip_reason":"server_not_functional","backup_id":'.$backup->id.',"team_id":'.$this->rootTeam->id.'}'."\n" - ); - - $expectedUrl = route('project.service.database.backups', [ - 'project_uuid' => $project->uuid, - 'environment_uuid' => $environment->uuid, - 'service_uuid' => $service->uuid, - 'stack_service_uuid' => $serviceDatabase->uuid, - ]); - - Livewire::test(ScheduledJobs::class) - ->assertOk() - ->assertSee('service-postgres') - ->assertSeeHtml('href="'.$expectedUrl.'"'); - }); -}); - -test('skipped standalone database backups keep standalone backup link', function () { - $this->actingAs($this->rootUser); - session(['currentTeam' => $this->rootTeam]); - - $server = Server::factory()->create(['team_id' => $this->rootTeam->id]); - $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); - $project = Project::factory()->create(['team_id' => $this->rootTeam->id]); - $environment = Environment::factory()->create(['project_id' => $project->id]); - $database = StandalonePostgresql::create([ - 'name' => 'standalone-postgres', - 'image' => 'postgres:16-alpine', - 'postgres_user' => 'postgres', - 'postgres_password' => 'password', - 'postgres_db' => 'postgres', - 'environment_id' => $environment->id, - 'destination_id' => $destination->id, - 'destination_type' => $destination->getMorphClass(), - ]); - $backup = ScheduledDatabaseBackup::create([ - 'team_id' => $this->rootTeam->id, - 'frequency' => '0 * * * *', - 'database_id' => $database->id, - 'database_type' => $database->getMorphClass(), - 'enabled' => true, - ]); - - withIsolatedScheduledLogsForMonitoringTest(function (string $logPath) use ($backup, $project, $environment, $database) { - file_put_contents( - $logPath, - '['.now()->format('Y-m-d H:i:s').'] production.INFO: Backup skipped {"type":"backup","skip_reason":"server_not_functional","backup_id":'.$backup->id.',"team_id":'.$this->rootTeam->id.'}'."\n" - ); - - $expectedUrl = route('project.database.backup.index', [ - 'project_uuid' => $project->uuid, - 'environment_uuid' => $environment->uuid, - 'database_uuid' => $database->uuid, - ]); - - Livewire::test(ScheduledJobs::class) - ->assertOk() - ->assertSee('standalone-postgres') - ->assertSeeHtml('href="'.$expectedUrl.'"'); - }); -});