mirror of
https://github.com/coollabsio/coolify.git
synced 2026-09-25 07:50:35 -05:00
feat(audit): expand event tracking and remove scheduled job monitoring
Add audit levels and record user, OAuth, DNS, notification, and settings changes while removing the obsolete scheduled job monitoring UI and services.
This commit is contained in:
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -48,6 +48,7 @@ class AuditEventsController extends Controller
|
||||
'event',
|
||||
'source',
|
||||
'action',
|
||||
'level',
|
||||
'actor_type',
|
||||
'actor_id',
|
||||
'actor_name',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'])),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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<int, string> $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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,391 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Settings;
|
||||
|
||||
use App\Models\DockerCleanupExecution;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\ScheduledDatabaseBackupExecution;
|
||||
use App\Models\ScheduledTask;
|
||||
use App\Models\ScheduledTaskExecution;
|
||||
use App\Models\Server;
|
||||
use App\Models\ServiceDatabase;
|
||||
use App\Models\StandaloneClickhouse;
|
||||
use App\Models\StandaloneDragonfly;
|
||||
use App\Models\StandaloneKeydb;
|
||||
use App\Models\StandaloneMariadb;
|
||||
use App\Models\StandaloneMongodb;
|
||||
use App\Models\StandaloneMysql;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\StandaloneRedis;
|
||||
use App\Services\SchedulerLogParser;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Livewire\Component;
|
||||
|
||||
class ScheduledJobs extends Component
|
||||
{
|
||||
public string $filterType = 'all';
|
||||
|
||||
public string $filterDate = 'last_24h';
|
||||
|
||||
public string $search = '';
|
||||
|
||||
public string $sortOrder = 'newest';
|
||||
|
||||
public int $skipPage = 0;
|
||||
|
||||
public int $skipDefaultTake = 20;
|
||||
|
||||
public bool $showSkipNext = false;
|
||||
|
||||
public bool $showSkipPrev = false;
|
||||
|
||||
public int $skipCurrentPage = 1;
|
||||
|
||||
public int $skipTotalCount = 0;
|
||||
|
||||
protected Collection $executions;
|
||||
|
||||
protected Collection $skipLogs;
|
||||
|
||||
protected Collection $managerRuns;
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
$this->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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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('<br/>', $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'])),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, mixed> $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<string, mixed> $context
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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<string, mixed> $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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
class SchedulerLogParser
|
||||
{
|
||||
/**
|
||||
* Get recent skip events from the scheduled log files.
|
||||
*
|
||||
* @return Collection<int, array{timestamp: string, type: string, reason: string, team_id: ?int, context: array}>
|
||||
*/
|
||||
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<int, array{timestamp: string, message: string, duration_ms: ?int, dispatched: ?int, skipped: ?int}>
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Models\AuditEvent;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
if (! function_exists('auditLog')) {
|
||||
/**
|
||||
@@ -12,10 +13,31 @@ if (! function_exists('auditLog')) {
|
||||
*/
|
||||
function auditLog(string $event, array $context = [], string $level = 'info'): void
|
||||
{
|
||||
$level = AuditEvent::normalizeLevel($level);
|
||||
|
||||
try {
|
||||
AuditEvent::record($event, $context);
|
||||
$request = app()->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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -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' => [],
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('audit_events', function (Blueprint $table) {
|
||||
$table->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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,313 +0,0 @@
|
||||
<div>
|
||||
<x-slot:title>
|
||||
Scheduled Jobs | Coolify
|
||||
</x-slot>
|
||||
|
||||
<x-settings.layout>
|
||||
<div class="application-settings-form mx-auto w-full max-w-none min-w-0" x-data="{
|
||||
activeTab: ['executions', 'scheduler-runs', 'skipped-jobs'].includes(location.hash.slice(1))
|
||||
? location.hash.slice(1)
|
||||
: 'executions',
|
||||
filterOpen: false,
|
||||
sortOpen: false,
|
||||
select(tab) {
|
||||
this.activeTab = tab;
|
||||
history.replaceState(null, '', `#${tab}`);
|
||||
}
|
||||
}">
|
||||
<x-application.settings-section title="Scheduler activity" flush>
|
||||
<x-slot:actions>
|
||||
<div
|
||||
class="flex items-center gap-0.5 rounded-[10px] border border-neutral-200 bg-neutral-100 p-1 dark:border-white/[0.07] dark:bg-white/[0.035]">
|
||||
<button type="button" class="app-tab"
|
||||
:class="activeTab === 'executions' &&
|
||||
'bg-coollabs/10 text-coollabs ring-1 ring-coollabs/25 dark:bg-warning/15 dark:text-warning dark:ring-warning/25'"
|
||||
@click="select('executions')">
|
||||
Failures <span class="ml-1 opacity-60">{{ $executions->count() }}</span>
|
||||
</button>
|
||||
<button type="button" class="app-tab"
|
||||
:class="activeTab === 'scheduler-runs' &&
|
||||
'bg-coollabs/10 text-coollabs ring-1 ring-coollabs/25 dark:bg-warning/15 dark:text-warning dark:ring-warning/25'"
|
||||
@click="select('scheduler-runs')">
|
||||
Scheduler runs <span class="ml-1 opacity-60">{{ $managerRuns->count() }}</span>
|
||||
</button>
|
||||
<button type="button" class="app-tab"
|
||||
:class="activeTab === 'skipped-jobs' &&
|
||||
'bg-coollabs/10 text-coollabs ring-1 ring-coollabs/25 dark:bg-warning/15 dark:text-warning dark:ring-warning/25'"
|
||||
@click="select('skipped-jobs')">
|
||||
Skipped <span class="ml-1 opacity-60">{{ $skipTotalCount }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<x-forms.button type="button" wire:click="refresh">
|
||||
<x-reicon name="refresh" class="size-3.5" />
|
||||
Refresh
|
||||
</x-forms.button>
|
||||
</x-slot:actions>
|
||||
<div
|
||||
class="flex flex-col gap-3 border-b border-neutral-200 p-3 dark:border-white/[0.08]">
|
||||
<div x-show="activeTab === 'executions'"
|
||||
class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="relative w-full sm:max-w-sm">
|
||||
<x-reicon name="search"
|
||||
class="pointer-events-none absolute top-1/2 left-2.5 z-10 size-3.5 -translate-y-1/2 text-neutral-400 dark:text-fg-faint" />
|
||||
<input wire:model.live.debounce.250ms="search" type="search"
|
||||
placeholder="Search scheduled jobs"
|
||||
class="h-8! w-full rounded-lg! border-neutral-200! bg-white! py-0! pr-3! pl-8! text-[12px]! shadow-none! placeholder:text-neutral-400 focus:border-accent! focus:ring-0! dark:border-white/[0.08]! dark:bg-white/[0.035]! dark:text-fg! dark:placeholder:text-fg-faint">
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<x-table.dropdown panel-class="w-52!">
|
||||
<x-slot:trigger><button type="button" class="button" aria-haspopup="listbox" :aria-expanded="open">
|
||||
<x-reicon name="filter" class="size-3.5" />
|
||||
Filter
|
||||
</button></x-slot:trigger>
|
||||
<div
|
||||
class="px-2 py-1 text-[10px] font-semibold tracking-wide text-neutral-400 uppercase dark:text-fg-faint">
|
||||
Type
|
||||
</div>
|
||||
@foreach (['all' => 'All types', 'backup' => 'Backups', 'task' => 'Tasks', 'cleanup' => 'Docker cleanup'] as $value => $label)
|
||||
<button type="button" class="listbox-option"
|
||||
wire:click="$set('filterType', '{{ $value }}')"
|
||||
@click="close()">
|
||||
<span>{{ $label }}</span>
|
||||
@if ($filterType === $value)
|
||||
<span class="text-accent">✓</span>
|
||||
@endif
|
||||
</button>
|
||||
@endforeach
|
||||
<div
|
||||
class="mt-1 border-t border-neutral-200 px-2 pt-2 pb-1 text-[10px] font-semibold tracking-wide text-neutral-400 uppercase dark:border-white/[0.08] dark:text-fg-faint">
|
||||
Time range
|
||||
</div>
|
||||
@foreach (['last_24h' => 'Last 24 hours', 'last_7d' => 'Last 7 days', 'last_30d' => 'Last 30 days', 'all' => 'All time'] as $value => $label)
|
||||
<button type="button" class="listbox-option"
|
||||
wire:click="$set('filterDate', '{{ $value }}')"
|
||||
@click="close()">
|
||||
<span>{{ $label }}</span>
|
||||
@if ($filterDate === $value)
|
||||
<span class="text-accent">✓</span>
|
||||
@endif
|
||||
</button>
|
||||
@endforeach
|
||||
</x-table.dropdown>
|
||||
|
||||
<x-table.dropdown panel-class="w-44!">
|
||||
<x-slot:trigger><button type="button" class="button" aria-haspopup="listbox" :aria-expanded="open">
|
||||
<x-reicon name="sort-direction" class="size-3.5" />
|
||||
Sort
|
||||
</button></x-slot:trigger>
|
||||
@foreach (['newest' => 'Newest first', 'oldest' => 'Oldest first'] as $value => $label)
|
||||
<button type="button" class="listbox-option"
|
||||
wire:click="$set('sortOrder', '{{ $value }}')"
|
||||
@click="close()">
|
||||
<span>{{ $label }}</span>
|
||||
@if ($sortOrder === $value)
|
||||
<span class="text-accent">✓</span>
|
||||
@endif
|
||||
</button>
|
||||
@endforeach
|
||||
</x-table.dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-cloak x-show="activeTab === 'executions'">
|
||||
@if ($executions->isEmpty())
|
||||
<x-empty title="No failures"
|
||||
description="No failed scheduled executions match the current filters."
|
||||
icon-name="check-circle" size="sm" />
|
||||
@else
|
||||
<div x-data="{
|
||||
page: 1,
|
||||
perPage: 10,
|
||||
total: {{ $executions->count() }},
|
||||
get lastPage() { return Math.max(1, Math.ceil(this.total / this.perPage)); },
|
||||
get firstVisibleRow() { return ((this.page - 1) * this.perPage) + 1; },
|
||||
get lastVisibleRow() { return Math.min(this.page * this.perPage, this.total); },
|
||||
goToPage(page) { this.page = Math.min(Math.max(page, 1), this.lastPage); }
|
||||
}">
|
||||
<div class="data-table">
|
||||
<div class="data-table-header scheduled-executions-table-grid">
|
||||
<span>Type</span>
|
||||
<span>Resource</span>
|
||||
<span>Server</span>
|
||||
<span>Started</span>
|
||||
<span>Duration</span>
|
||||
<span>Message</span>
|
||||
</div>
|
||||
@foreach ($executions as $execution)
|
||||
@php
|
||||
$typeLabel = match ($execution['type']) {
|
||||
'backup' => 'Backup',
|
||||
'task' => 'Task',
|
||||
'cleanup' => 'Cleanup',
|
||||
default => ucfirst($execution['type']),
|
||||
};
|
||||
@endphp
|
||||
<div wire:key="exec-{{ $execution['type'] }}-{{ $execution['id'] }}"
|
||||
x-show="{{ $loop->index }} >= (page - 1) * perPage && {{ $loop->index }} < page * perPage"
|
||||
class="data-table-row scheduled-executions-table-grid border-b border-neutral-200 last:border-b-0 dark:border-white/[0.07]">
|
||||
<div>
|
||||
<span
|
||||
class="rounded-full bg-neutral-100 px-2 py-0.5 text-[10px] font-medium text-neutral-600 dark:bg-white/[0.06] dark:text-fg-dim">
|
||||
{{ $typeLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="min-w-0 truncate text-[12px] font-medium text-black dark:text-fg">
|
||||
{{ $execution['resource_name'] }}
|
||||
@if ($execution['resource_type'])
|
||||
<span class="text-[10px] font-normal text-neutral-400">
|
||||
{{ $execution['resource_type'] }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
<div class="truncate text-[11px] text-neutral-500 dark:text-fg-dim">
|
||||
{{ $execution['server_name'] }}
|
||||
</div>
|
||||
<div class="text-[11px] text-neutral-500 dark:text-fg-dim">
|
||||
{{ $execution['created_at']->format('Y-m-d H:i') }}
|
||||
</div>
|
||||
<div class="text-[11px] text-neutral-500 dark:text-fg-dim">
|
||||
@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')
|
||||
<x-loading class="size-3.5" />
|
||||
@else
|
||||
-
|
||||
@endif
|
||||
</div>
|
||||
<div class="min-w-0 truncate text-[11px] text-neutral-500 dark:text-fg-dim"
|
||||
title="{{ $execution['message'] }}">
|
||||
{{ Str::limit($execution['message'], 80) }}
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
<x-client-pagination
|
||||
summary="`${firstVisibleRow}-${lastVisibleRow} of ${total}`"
|
||||
page-size-model="perPage" storage-key="coolify.page-size.scheduled-jobs"
|
||||
previous-action="goToPage(page - 1)" next-action="goToPage(page + 1)"
|
||||
next-disabled="page >= lastPage" />
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div x-cloak x-show="activeTab === 'scheduler-runs'">
|
||||
@if ($managerRuns->isEmpty())
|
||||
<x-empty title="No manager runs"
|
||||
description="Scheduler manager activity appears here after its next run."
|
||||
icon-name="refresh" size="sm" />
|
||||
@else
|
||||
<div x-data="{
|
||||
page: 1,
|
||||
perPage: 10,
|
||||
total: {{ $managerRuns->count() }},
|
||||
get lastPage() { return Math.max(1, Math.ceil(this.total / this.perPage)); },
|
||||
get firstVisibleRow() { return ((this.page - 1) * this.perPage) + 1; },
|
||||
get lastVisibleRow() { return Math.min(this.page * this.perPage, this.total); },
|
||||
goToPage(page) { this.page = Math.min(Math.max(page, 1), this.lastPage); }
|
||||
}">
|
||||
<div class="data-table">
|
||||
<div class="data-table-header scheduler-runs-table-grid">
|
||||
<span>Time</span>
|
||||
<span>Event</span>
|
||||
<span>Duration</span>
|
||||
<span>Dispatched</span>
|
||||
<span>Skipped</span>
|
||||
</div>
|
||||
@foreach ($managerRuns as $run)
|
||||
<div wire:key="run-{{ md5(serialize($run)) }}"
|
||||
x-show="{{ $loop->index }} >= (page - 1) * perPage && {{ $loop->index }} < page * perPage"
|
||||
class="data-table-row scheduler-runs-table-grid border-b border-neutral-200 last:border-b-0 dark:border-white/[0.07]">
|
||||
<div class="font-mono text-[11px] text-neutral-500 dark:text-fg-dim">
|
||||
{{ $run['timestamp'] }}
|
||||
</div>
|
||||
<div class="min-w-0 truncate text-[12px] text-black dark:text-fg">
|
||||
{{ $run['message'] }}
|
||||
</div>
|
||||
<div class="text-[11px] text-neutral-500 dark:text-fg-dim">
|
||||
{{ $run['duration_ms'] !== null ? $run['duration_ms'] . 'ms' : '-' }}
|
||||
</div>
|
||||
<div class="text-[11px] text-neutral-500 dark:text-fg-dim">
|
||||
{{ $run['dispatched'] ?? '-' }}
|
||||
</div>
|
||||
<div class="text-[11px] text-neutral-500 dark:text-fg-dim">
|
||||
{{ $run['skipped'] ?? '-' }}
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
<x-client-pagination
|
||||
summary="`${firstVisibleRow}-${lastVisibleRow} of ${total}`"
|
||||
page-size-model="perPage" storage-key="coolify.page-size.scheduled-jobs"
|
||||
previous-action="goToPage(page - 1)" next-action="goToPage(page + 1)"
|
||||
next-disabled="page >= lastPage" />
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div x-cloak x-show="activeTab === 'skipped-jobs'">
|
||||
@if ($skipLogs->isEmpty())
|
||||
<x-empty title="No skipped jobs"
|
||||
description="All scheduled jobs met their dispatch conditions."
|
||||
icon-name="check-circle" size="sm" />
|
||||
@else
|
||||
<div class="data-table">
|
||||
<div class="data-table-header skipped-jobs-table-grid">
|
||||
<span>Time</span>
|
||||
<span>Type</span>
|
||||
<span>Resource</span>
|
||||
<span>Reason</span>
|
||||
</div>
|
||||
@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
|
||||
<div wire:key="skip-{{ md5(serialize($skip)) }}"
|
||||
class="data-table-row skipped-jobs-table-grid border-b border-neutral-200 last:border-b-0 dark:border-white/[0.07]">
|
||||
<div class="font-mono text-[11px] text-neutral-500 dark:text-fg-dim">
|
||||
{{ $skip['timestamp'] }}
|
||||
</div>
|
||||
<div>
|
||||
<span
|
||||
class="rounded-full bg-neutral-100 px-2 py-0.5 text-[10px] font-medium capitalize text-neutral-600 dark:bg-white/[0.06] dark:text-fg-dim">
|
||||
{{ str_replace('_', ' ', $skip['type']) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="min-w-0 truncate text-[12px] text-black dark:text-fg">
|
||||
@if ($skip['link'] ?? null)
|
||||
<a href="{{ $skip['link'] }}"
|
||||
class="font-medium text-coollabs hover:underline dark:text-warning">
|
||||
{{ $skip['resource_name'] }}
|
||||
</a>
|
||||
@else
|
||||
{{ $skip['resource_name'] ?? $skip['context']['task_name'] ?? $skip['context']['server_name'] ?? 'Deleted resource' }}
|
||||
@endif
|
||||
</div>
|
||||
<div class="min-w-0 truncate text-[11px] text-neutral-500 dark:text-fg-dim">
|
||||
{{ $reasonLabel }}
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
<x-table-pagination :from="$skipTotalCount === 0 ? 0 : $skipPage + 1"
|
||||
:to="min($skipPage + $skipLogs->count(), $skipTotalCount)" :total="$skipTotalCount"
|
||||
:current-page="$skipCurrentPage"
|
||||
:last-page="max(1, (int) ceil($skipTotalCount / $skipDefaultTake))"
|
||||
wire-target="skipPreviousPage,skipNextPage" previous-action="skipPreviousPage"
|
||||
next-action="skipNextPage" />
|
||||
@endif
|
||||
</div>
|
||||
</x-application.settings-section>
|
||||
</div>
|
||||
</x-settings.layout>
|
||||
</div>
|
||||
@@ -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');
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
<?php
|
||||
|
||||
it('keeps backup and transactional email out of the settings top navigation', function () {
|
||||
$this->blade('<x-settings.navbar />')
|
||||
->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 () {
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
<?php
|
||||
|
||||
test('scheduled jobs refresh control lives on the activity section not the navbar', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/settings/scheduled-jobs.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('<x-settings.layout>')
|
||||
->toContain('settings-section title="Scheduler activity"')
|
||||
->toContain('wire:click="refresh"')
|
||||
->not->toContain('<x-settings.navbar');
|
||||
|
||||
// Refresh should appear after the activity section opens, not before.
|
||||
$activityPos = strpos($view, 'settings-section title="Scheduler activity"');
|
||||
$refreshPos = strpos($view, 'wire:click="refresh"');
|
||||
|
||||
expect($activityPos)->not->toBeFalse()
|
||||
->and($refreshPos)->not->toBeFalse()
|
||||
->and($refreshPos)->toBeGreaterThan($activityPos);
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
test('instance settings no longer expose scheduled jobs monitoring', function () {
|
||||
$settingsLayout = file_get_contents(resource_path('views/components/settings/layout.blade.php'));
|
||||
|
||||
expect(Route::has('settings.scheduled-jobs'))->toBeFalse()
|
||||
->and($settingsLayout)->not->toContain('Scheduled Jobs')
|
||||
->and($settingsLayout)->not->toContain('settings.scheduled-jobs');
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1,400 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Settings\ScheduledJobs;
|
||||
use App\Models\DockerCleanupExecution;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\ScheduledDatabaseBackupExecution;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceDatabase;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use App\Services\SchedulerLogParser;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function withIsolatedScheduledLogsForMonitoringTest(callable $callback): mixed
|
||||
{
|
||||
$logDir = storage_path('logs');
|
||||
if (! is_dir($logDir)) {
|
||||
mkdir($logDir, 0755, true);
|
||||
}
|
||||
|
||||
$renamed = [];
|
||||
foreach (glob($logDir.'/scheduled-*.log') as $log) {
|
||||
$tmp = $log.'.scheduled-jobs-test-bak';
|
||||
rename($log, $tmp);
|
||||
$renamed[$tmp] = $log;
|
||||
}
|
||||
|
||||
try {
|
||||
return $callback($logDir.'/scheduled-'.now()->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.'"');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user