Skip to content

Commit c240d2e

Browse files
author
David Courtey
authored
feat: database-driven AppConfig for backup and notification settings (#76)
Replace env-only config files with a DB-backed AppConfig model so users can edit backup and notification settings from the Configuration page without container restarts. - Add AppConfig model, service (with cache + encryption), and facade - Migration seeds 16 config rows from existing env vars with defaults - Replace config() calls with AppConfig::get() across jobs and services - Add editable forms with validation (cron, required fields per channel) - Add channel multi-select for notifications (Email, Slack, Discord) - Restart scheduler via supervisorctl when cron settings change - Remove config/notifications.php (superseded by DB config)
1 parent 682dc83 commit c240d2e

36 files changed

Lines changed: 1247 additions & 546 deletions

app/Facades/AppConfig.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php
2+
3+
namespace App\Facades;
4+
5+
use Illuminate\Support\Facades\Facade;
6+
7+
/**
8+
* @method static mixed get(string $key, mixed $default = null)
9+
* @method static void set(string $key, mixed $value)
10+
* @method static void flush()
11+
*
12+
* @see \App\Services\AppConfigService
13+
*/
14+
class AppConfig extends Facade
15+
{
16+
protected static function getFacadeAccessor(): string
17+
{
18+
return \App\Services\AppConfigService::class;
19+
}
20+
}

app/Jobs/ProcessBackupJob.php

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace App\Jobs;
44

5+
use App\Facades\AppConfig;
56
use App\Models\Snapshot;
67
use App\Services\Backup\BackupTask;
78
use App\Services\FailureNotificationService;
@@ -28,9 +29,9 @@ class ProcessBackupJob implements ShouldQueue
2829
public function __construct(
2930
public string $snapshotId
3031
) {
31-
$this->timeout = config('backup.job_timeout');
32-
$this->backoff = config('backup.job_backoff');
33-
$this->tries = config('backup.job_tries');
32+
$this->timeout = AppConfig::get('backup.job_timeout');
33+
$this->backoff = AppConfig::get('backup.job_backoff');
34+
$this->tries = AppConfig::get('backup.job_tries');
3435
$this->onQueue('backups');
3536
}
3637

app/Jobs/ProcessRestoreJob.php

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace App\Jobs;
44

5+
use App\Facades\AppConfig;
56
use App\Models\Restore;
67
use App\Services\Backup\RestoreTask;
78
use App\Services\FailureNotificationService;
@@ -28,9 +29,9 @@ class ProcessRestoreJob implements ShouldQueue
2829
public function __construct(
2930
public string $restoreId
3031
) {
31-
$this->timeout = config('backup.job_timeout');
32-
$this->backoff = config('backup.job_backoff');
33-
$this->tries = config('backup.job_tries');
32+
$this->timeout = AppConfig::get('backup.job_timeout');
33+
$this->backoff = AppConfig::get('backup.job_backoff');
34+
$this->tries = AppConfig::get('backup.job_tries');
3435
$this->onQueue('backups');
3536
}
3637

app/Livewire/Configuration/Index.php

Lines changed: 91 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,38 @@
22

33
namespace App\Livewire\Configuration;
44

5+
use App\Livewire\Forms\ConfigurationForm;
56
use App\Models\DatabaseServer;
67
use App\Models\Snapshot;
78
use App\Services\FailureNotificationService;
89
use Illuminate\Contracts\View\View;
9-
use Illuminate\Support\Facades\Session;
10+
use Illuminate\Support\Facades\Log;
11+
use Livewire\Attributes\Computed;
1012
use Livewire\Attributes\Title;
1113
use Livewire\Component;
14+
use Lorisleiva\CronTranslator\CronTranslator;
15+
use Mary\Traits\Toast;
16+
use Symfony\Component\HttpFoundation\Response;
17+
use Symfony\Component\Process\Process;
1218

1319
#[Title('Configuration')]
1420
class Index extends Component
1521
{
22+
use Toast;
23+
24+
public ConfigurationForm $form;
25+
26+
public function mount(): void
27+
{
28+
$this->form->loadFromConfig();
29+
}
30+
31+
#[Computed]
32+
public function isAdmin(): bool
33+
{
34+
return auth()->user()->isAdmin();
35+
}
36+
1637
/**
1738
* @return array<int, array{key: string, label: string, class?: string}>
1839
*/
@@ -44,94 +65,6 @@ public function getAppConfig(): array
4465
];
4566
}
4667

47-
/**
48-
* @return array<int, array{value: mixed, env: string, description: string}>
49-
*/
50-
public function getBackupConfig(): array
51-
{
52-
return [
53-
[
54-
'env' => 'BACKUP_WORKING_DIRECTORY',
55-
'value' => config('backup.working_directory') ?: '-',
56-
'description' => __('Temporary directory for backup and restore operations.'),
57-
],
58-
[
59-
'env' => 'BACKUP_COMPRESSION',
60-
'value' => config('backup.compression') ?: '-',
61-
'description' => __('Compression algorithm: "gzip", "zstd", or "encrypted".'),
62-
],
63-
[
64-
'env' => 'BACKUP_COMPRESSION_LEVEL',
65-
'value' => config('backup.compression_level') ?: '-',
66-
'description' => __('Compression level: 1-9 for gzip/encrypted, 1-19 for zstd (default: 6).'),
67-
],
68-
[
69-
'env' => 'BACKUP_JOB_TIMEOUT',
70-
'value' => config('backup.job_timeout') ?: '-',
71-
'description' => __('Maximum seconds a job can run.'),
72-
],
73-
[
74-
'env' => 'BACKUP_JOB_TRIES',
75-
'value' => config('backup.job_tries') ?: '-',
76-
'description' => __('Number of times to attempt the job.'),
77-
],
78-
[
79-
'env' => 'BACKUP_JOB_BACKOFF',
80-
'value' => config('backup.job_backoff') ?: '-',
81-
'description' => __('Seconds to wait before retrying.'),
82-
],
83-
[
84-
'env' => 'BACKUP_DAILY_CRON',
85-
'value' => config('backup.daily_cron') ?: '-',
86-
'description' => __('Cron schedule for daily backups.'),
87-
],
88-
[
89-
'env' => 'BACKUP_WEEKLY_CRON',
90-
'value' => config('backup.weekly_cron') ?: '-',
91-
'description' => __('Cron schedule for weekly backups.'),
92-
],
93-
[
94-
'env' => 'BACKUP_CLEANUP_CRON',
95-
'value' => config('backup.cleanup_cron') ?: '-',
96-
'description' => __('Cron schedule for snapshot cleanup.'),
97-
],
98-
];
99-
}
100-
101-
/**
102-
* @return array<int, array{value: mixed, env: string, description: string}>
103-
*/
104-
public function getNotificationConfig(): array
105-
{
106-
return [
107-
[
108-
'env' => 'NOTIFICATION_ENABLED',
109-
'value' => config('notifications.enabled') ? 'true' : 'false',
110-
'description' => __('Enable failure notifications for backup and restore jobs.'),
111-
],
112-
[
113-
'env' => 'NOTIFICATION_MAIL_TO',
114-
'value' => config('notifications.mail.to') ?: '-',
115-
'description' => __('Email address for failure notifications.'),
116-
],
117-
[
118-
'env' => 'NOTIFICATION_SLACK_WEBHOOK_URL',
119-
'value' => $this->maskSensitiveValue(config('notifications.slack.webhook_url')),
120-
'description' => __('Slack webhook URL for failure notifications.'),
121-
],
122-
[
123-
'env' => 'NOTIFICATION_DISCORD_BOT_TOKEN',
124-
'value' => $this->maskSensitiveValue(config('notifications.discord.token')),
125-
'description' => __('Discord bot token for failure notifications.'),
126-
],
127-
[
128-
'env' => 'NOTIFICATION_DISCORD_CHANNEL_ID',
129-
'value' => config('notifications.discord.channel_id') ?: '-',
130-
'description' => __('Discord channel ID for failure notifications.'),
131-
],
132-
];
133-
}
134-
13568
/**
13669
* @return array<int, array{value: mixed, env: string, description: string}>
13770
*/
@@ -176,23 +109,35 @@ public function getSsoConfig(): array
176109
];
177110
}
178111

179-
private function maskSensitiveValue(mixed $value): string
112+
public function saveBackupConfig(): void
180113
{
181-
return $value ? '********' : '-';
114+
abort_unless(auth()->user()->isAdmin(), Response::HTTP_FORBIDDEN);
115+
116+
$this->form->saveBackup();
117+
$this->restartScheduler();
118+
119+
$this->success(__('Backup configuration saved.'), position: 'toast-bottom');
182120
}
183121

184-
public function isNotificationEnabled(): bool
122+
public function saveNotificationConfig(): void
185123
{
186-
return (bool) config('notifications.enabled');
124+
abort_unless(auth()->user()->isAdmin(), Response::HTTP_FORBIDDEN);
125+
126+
$this->form->saveNotifications();
127+
128+
$this->dispatch('notification-saved');
129+
$this->success(__('Notification configuration saved.'), position: 'toast-bottom');
187130
}
188131

189132
public function sendTestNotification(): void
190133
{
134+
abort_unless(auth()->user()->isAdmin(), Response::HTTP_FORBIDDEN);
135+
191136
$service = app(FailureNotificationService::class);
192137
$routes = $service->getNotificationRoutes();
193138

194139
if (empty($routes)) {
195-
Session::flash('notification-error', __('No notification channels configured. Please set at least one of: NOTIFICATION_MAIL_TO, NOTIFICATION_SLACK_WEBHOOK_URL, or NOTIFICATION_DISCORD_BOT_TOKEN and NOTIFICATION_DISCORD_CHANNEL_ID.'));
140+
$this->error(__('No notification channels configured. Please set at least one of: mail recipient, Slack webhook URL, or Discord bot token and channel ID.'), position: 'toast-bottom');
196141

197142
return;
198143
}
@@ -210,20 +155,68 @@ public function sendTestNotification(): void
210155
$service->notifyBackupFailed($snapshot, $exception);
211156

212157
$channelNames = implode(', ', array_keys($routes));
213-
Session::flash('notification-success', __('Test notification sent to: :channels', ['channels' => $channelNames]));
158+
$this->success(__('Test notification sent to: :channels', ['channels' => $channelNames]), position: 'toast-bottom');
214159
} catch (\Throwable $e) {
215-
Session::flash('notification-error', __('Failed to send test notification: :message', ['message' => $e->getMessage()]));
160+
$this->error(__('Failed to send test notification: :message', ['message' => $e->getMessage()]), position: 'toast-bottom');
161+
}
162+
}
163+
164+
public function translateCron(string $expression): string
165+
{
166+
try {
167+
return CronTranslator::translate($expression);
168+
} catch (\Throwable) {
169+
return '';
170+
}
171+
}
172+
173+
/**
174+
* @return array<int, array{id: string, name: string}>
175+
*/
176+
public function getCompressionOptions(): array
177+
{
178+
return [
179+
['id' => 'gzip', 'name' => 'gzip'],
180+
['id' => 'zstd', 'name' => 'zstd'],
181+
['id' => 'encrypted', 'name' => 'encrypted'],
182+
];
183+
}
184+
185+
private function restartScheduler(): void
186+
{
187+
$process = new Process(['supervisorctl', 'restart', 'schedule-run']);
188+
$process->setTimeout(10);
189+
$process->run();
190+
191+
if (! $process->isSuccessful()) {
192+
Log::warning('Failed to restart schedule-run', [
193+
'exit_code' => $process->getExitCode(),
194+
'error' => $process->getErrorOutput(),
195+
]);
196+
$this->warning(__('Saved, but scheduler restart failed. Schedule changes take effect after container restart.'), position: 'toast-bottom');
216197
}
217198
}
218199

200+
/**
201+
* @return array<int, array{id: string, name: string}>
202+
*/
203+
public function getChannelOptions(): array
204+
{
205+
return [
206+
['id' => 'email', 'name' => __('Email')],
207+
['id' => 'slack', 'name' => __('Slack')],
208+
['id' => 'discord', 'name' => __('Discord')],
209+
];
210+
}
211+
219212
public function render(): View
220213
{
221214
return view('livewire.configuration.index', [
222215
'headers' => $this->getHeaders(),
223216
'appConfig' => $this->getAppConfig(),
224-
'backupConfig' => $this->getBackupConfig(),
225-
'notificationConfig' => $this->getNotificationConfig(),
226217
'ssoConfig' => $this->getSsoConfig(),
218+
'compressionOptions' => $this->getCompressionOptions(),
219+
'channelOptions' => $this->getChannelOptions(),
227220
]);
228221
}
229222
}

0 commit comments

Comments
 (0)