Compare commits

..

3 Commits

Author SHA1 Message Date
Lance Pioch
da64dbb6b5 Split backup notifications into manual and scheduled settings
Backups now record whether they were initiated by a schedule task via a
new is_scheduled column, set by CreateBackupSchema through the initiate
service. The per-user setting splits into manual_backup_notifications
and scheduled_backup_notifications, both opt-in, so users can subscribe
to each kind independently. The upgrade seeding grants owners both.

The backup origin is also exposed in the client API transformer.
2026-07-17 10:39:39 -04:00
Lance Pioch
0ef479274d Default backup notifications off and remove global email switch
Backup notifications are now opt-in for everyone, owners included; the
per-user toggle is the only control and covers both the database
notification and the email.

Removes the PANEL_SEND_BACKUP_COMPLETED_NOTIFICATION env/config and its
admin settings toggle. To preserve behavior on existing installs, the
migration seeds an opt-in row for each server's owner unless that env
variable is set to false.
2026-07-17 09:30:47 -04:00
Lance Pioch
44077a1b40 Add per-user server settings for backup notifications
Adds a server_user_settings table (user_id, server_id, JSON settings with
enum-whitelisted keys) so each user with access to a server can choose
whether they receive backup notifications from it. Owners default on,
subusers default off. The global config remains a panel-wide email switch.

Toggles live on the server Settings page and the Backups list header.
2026-07-12 15:37:57 -04:00
35 changed files with 675 additions and 286 deletions

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Enums;
enum ServerUserSettingKey: string
{
case ManualBackupNotifications = 'manual_backup_notifications';
case ScheduledBackupNotifications = 'scheduled_backup_notifications';
/**
* The default value for users without an explicit setting.
*/
public function getDefaultValue(): bool
{
return match ($this) {
self::ManualBackupNotifications, self::ScheduledBackupNotifications => false,
};
}
/** @return array<string, bool> */
public static function getDefaultSettings(): array
{
$default = [];
foreach (self::cases() as $key) {
$default[$key->value] = $key->getDefaultValue();
}
return $default;
}
}

View File

@@ -17,7 +17,7 @@ final class CreateBackupSchema extends TaskSchema
public function runTask(Task $task): void
{
$this->initiateService->setIgnoredFiles(explode(PHP_EOL, $task->payload))->handle($task->server, null, true);
$this->initiateService->setIsScheduled(true)->setIgnoredFiles(explode(PHP_EOL, $task->payload))->handle($task->server, null, true);
}
public function canCreate(Schedule $schedule): bool

View File

@@ -689,16 +689,6 @@ class Settings extends Page implements HasSchemas
->formatStateUsing(fn ($state): bool => (bool) $state)
->afterStateUpdated(fn ($state, Set $set) => $set('PANEL_SEND_REINSTALL_NOTIFICATION', (bool) $state))
->default(env('PANEL_SEND_REINSTALL_NOTIFICATION', config('panel.email.send_reinstall_notification'))),
Toggle::make('PANEL_SEND_BACKUP_COMPLETED_NOTIFICATION')
->label(trans('admin/setting.misc.mail_notifications.backup_completed'))
->onIcon(TablerIcon::Check)
->offIcon(TablerIcon::X)
->onColor('success')
->offColor('danger')
->live()
->formatStateUsing(fn ($state): bool => (bool) $state)
->afterStateUpdated(fn ($state, Set $set) => $set('PANEL_SEND_BACKUP_COMPLETED_NOTIFICATION', (bool) $state))
->default(env('PANEL_SEND_BACKUP_COMPLETED_NOTIFICATION', config('panel.email.send_backup_completed_notification'))),
]),
Section::make(trans('admin/setting.misc.connections.title'))
->description(trans('admin/setting.misc.connections.helper'))

View File

@@ -2,7 +2,6 @@
namespace App\Filament\Admin\Resources\DatabaseHosts\RelationManagers;
use App\Filament\Admin\Resources\Servers\Pages\EditServer;
use App\Filament\Components\Actions\RotateDatabasePasswordAction;
use App\Filament\Components\Tables\Columns\DateTimeColumn;
use App\Models\Database;
@@ -51,7 +50,7 @@ class DatabasesRelationManager extends RelationManager
{
return $table
->recordTitleAttribute('database')
->heading(null)
->heading('')
->columns([
TextColumn::make('database'),
TextColumn::make('username')
@@ -60,7 +59,7 @@ class DatabasesRelationManager extends RelationManager
->label(trans('admin/databasehost.table.remote'))
->formatStateUsing(fn (Database $record) => $record->remote === '%' ? trans('admin/databasehost.anywhere'). ' ( % )' : $record->remote),
TextColumn::make('server.name')
->url(fn (Database $database) => user()?->can('update', $database->server) ? EditServer::getUrl(['record' => $database->server]) : null),
->url(fn (Database $database) => route('filament.admin.resources.servers.edit', ['record' => $database->server_id])),
TextColumn::make('max_connections')
->label(trans('admin/databasehost.table.max_connections'))
->formatStateUsing(fn ($record) => $record->max_connections ?: trans('server/database.unlimited')),

View File

@@ -43,8 +43,12 @@ class ListEggs extends ListRecords
$defaultEggIcon = 'data:image/svg+xml;base64,' . base64_encode(file_get_contents(public_path($defaultEggIcon)));
return $table
->searchable(true)
->defaultPaginationPageOption(25)
->columns([
TextColumn::make('id')
->label('Id')
->hidden(),
ImageColumn::make('icon')
->label('')
->alignCenter()
@@ -52,7 +56,7 @@ class ListEggs extends ListRecords
->getStateUsing(fn (Egg $record) => $record->icon ?: $defaultEggIcon),
TextColumn::make('name')
->label(trans('admin/egg.name'))
->description(fn ($record) => (strlen($record->description) > 120) ? substr($record->description, 0, 120).'...' : $record->description)
->description(fn ($record): ?string => (strlen($record->description) > 120) ? substr($record->description, 0, 120).'...' : $record->description)
->wrap()
->searchable()
->sortable(),

View File

@@ -2,13 +2,9 @@
namespace App\Filament\Admin\Resources\Eggs\RelationManagers;
use App\Filament\Admin\Resources\Nodes\Pages\EditNode;
use App\Filament\Admin\Resources\Servers\Pages\EditServer;
use App\Filament\Admin\Resources\Users\Pages\EditUser;
use App\Filament\Components\Actions\ViewConsoleAction;
use App\Models\Server;
use Filament\Actions\EditAction;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables\Columns\SelectColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
@@ -22,35 +18,27 @@ class ServersRelationManager extends RelationManager
->recordTitleAttribute('servers')
->emptyStateDescription(trans('admin/egg.no_servers'))
->emptyStateHeading(trans('admin/egg.no_servers_help'))
->searchable(false)
->heading(trans('admin/egg.servers'))
->columns([
TextColumn::make('user.username')
->label(trans('admin/server.owner'))
->url(fn (Server $server) => user()?->can('update', $server->user) ? EditUser::getUrl(['record' => $server->user]) : null)
->sortable()
->searchable(),
->url(fn (Server $server): string => route('filament.admin.resources.users.edit', ['record' => $server->user]))
->sortable(),
TextColumn::make('name')
->label(trans('admin/server.name'))
->url(fn (Server $server) => user()?->can('update', $server) ? EditServer::getUrl(['record' => $server]) : null)
->sortable()
->searchable(),
->url(fn (Server $server): string => route('filament.admin.resources.servers.edit', ['record' => $server]))
->sortable(),
TextColumn::make('node.name')
->label(trans('admin/server.node'))
->url(fn (Server $server) => user()?->can('update', $server->node) ? EditNode::getUrl(['record' => $server->node]) : null)
->sortable()
->searchable(),
->url(fn (Server $server): string => route('filament.admin.resources.nodes.edit', ['record' => $server->node])),
TextColumn::make('image')
->label(trans('admin/server.docker_image'))
->badge(),
TextColumn::make('allocation.address')
->label(trans('admin/server.docker_image')),
SelectColumn::make('allocation.id')
->label(trans('admin/server.primary_allocation'))
->disabled()
->options(fn (Server $server) => $server->allocations->take(1)->mapWithKeys(fn ($allocation) => [$allocation->id => $allocation->address]))
->placeholder(trans('admin/server.none'))
->sortable(),
])
->recordActions([
ViewConsoleAction::make(),
EditAction::make()
->url(fn (Server $server) => EditServer::getUrl(['record' => $server], panel: 'admin')),
]);
}
}

View File

@@ -27,6 +27,7 @@ class ListNodes extends ListRecords
public function table(Table $table): Table
{
return $table
->searchable(false)
->checkIfRecordIsSelectableUsing(fn (Node $node) => $node->servers_count <= 0)
->columns([
TextColumn::make('uuid')

View File

@@ -4,7 +4,6 @@ namespace App\Filament\Admin\Resources\Nodes\RelationManagers;
use App\Enums\TablerIcon;
use App\Filament\Admin\Resources\Servers\Pages\CreateServer;
use App\Filament\Admin\Resources\Servers\Pages\EditServer;
use App\Filament\Components\Actions\UpdateNodeAllocations;
use App\Models\Allocation;
use App\Models\Node;
@@ -12,8 +11,6 @@ use App\Services\Allocations\AssignmentService;
use BackedEnum;
use Exception;
use Filament\Actions\Action;
use Filament\Actions\CreateAction;
use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TagsInput;
@@ -33,7 +30,7 @@ class AllocationsRelationManager extends RelationManager
{
protected static string $relationship = 'allocations';
protected static string|BackedEnum|null $icon = TablerIcon::Network;
protected static string|BackedEnum|null $icon = TablerIcon::PlugConnected;
public function setTitle(): string
{
@@ -49,14 +46,28 @@ class AllocationsRelationManager extends RelationManager
->recordTitleAttribute('address')
->checkIfRecordIsSelectableUsing(fn (Allocation $allocation) => $allocation->server_id === null)
->paginationPageOptions([10, 20, 50, 100, 200, 500])
->heading(null)
->searchable()
->heading('')
->selectCurrentPageOnly() //Prevent people from trying to nuke 30,000 ports at once.... -,-
->columns([
TextColumn::make('id')
->label('ID')
->sortable()
->toggleable()
->toggledHiddenByDefault(),
TextColumn::make('port')
->searchable()
->label(trans('admin/node.ports')),
TextColumn::make('server.name')
->label(trans('admin/node.table.servers'))
->icon(TablerIcon::BrandDocker)
->visibleFrom('md')
->searchable()
->url(fn (Allocation $allocation): string => $allocation->server ? route('filament.admin.resources.servers.edit', ['record' => $allocation->server]) : ''),
TextInputColumn::make('ip_alias')
->searchable()
->label(trans('admin/node.table.alias')),
TextInputColumn::make('notes')
->label(trans('admin/node.table.allocation_notes'))
->placeholder(trans('admin/node.table.no_notes')),
SelectColumn::make('ip')
->options(function (Allocation $allocation) {
$ips = Allocation::where('port', $allocation->port)->pluck('ip');
@@ -70,35 +81,15 @@ class AllocationsRelationManager extends RelationManager
})
->selectablePlaceholder(false)
->searchable()
->sortable()
->label(trans('admin/node.table.ip')),
TextColumn::make('port')
->searchable()
->sortable()
->label(trans('admin/node.port')),
TextInputColumn::make('ip_alias')
->searchable()
->sortable()
->label(trans('admin/node.table.alias'))
->placeholder(trans('admin/node.table.no_alias')),
TextInputColumn::make('notes')
->label(trans('admin/node.table.allocation_notes'))
->placeholder(trans('admin/node.table.no_notes')),
TextColumn::make('server.name')
->label(trans('admin/node.table.servers'))
->placeholder(trans('admin/node.table.no_server'))
->visibleFrom('md')
->searchable()
->url(fn (Allocation $allocation) => $allocation->server && user()?->can('update', $allocation->server) ? EditServer::getUrl(['record' => $allocation->server]) : null),
])
->emptyStateHeading(trans('admin/node.no_allocations'))
->recordActions([
DeleteAction::make()
->visible(fn (Allocation $allocation) => $allocation->server_id === null),
])
->toolbarActions([
CreateAction::make()
->createAnother(false)
DeleteBulkAction::make()
->authorize(fn () => user()?->can('update', $this->getOwnerRecord())),
Action::make('create new allocation')
->label(trans('admin/node.create_allocation'))
->tooltip(trans('admin/node.create_allocation'))
->icon(TablerIcon::WorldPlus)
->schema(fn () => [
Select::make('allocation_ip')
@@ -153,7 +144,6 @@ class AllocationsRelationManager extends RelationManager
UpdateNodeAllocations::make()
->nodeRecord($this->getOwnerRecord())
->authorize(fn () => user()?->can('update', $this->getOwnerRecord())),
DeleteBulkAction::make(),
]);
}
}

View File

@@ -4,13 +4,8 @@ namespace App\Filament\Admin\Resources\Nodes\RelationManagers;
use App\Enums\ServerResourceType;
use App\Enums\TablerIcon;
use App\Filament\Admin\Resources\Eggs\Pages\EditEgg;
use App\Filament\Admin\Resources\Servers\Pages\EditServer;
use App\Filament\Admin\Resources\Users\Pages\EditUser;
use App\Filament\Components\Actions\ViewConsoleAction;
use App\Models\Server;
use BackedEnum;
use Filament\Actions\EditAction;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables\Columns\SelectColumn;
use Filament\Tables\Columns\TextColumn;
@@ -30,27 +25,29 @@ class ServersRelationManager extends RelationManager
public function table(Table $table): Table
{
return $table
->heading(null)
->searchable(false)
->heading('')
->columns([
TextColumn::make('user.username')
->label(trans('admin/node.table.owner'))
->url(fn (Server $server) => user()?->can('update', $server->user) ? EditUser::getUrl(['record' => $server->user]) : null)
->url(fn (Server $server): string => route('filament.admin.resources.users.edit', ['record' => $server->user]))
->searchable(),
TextColumn::make('name')
->label(trans('admin/node.table.name'))
->url(fn (Server $server) => user()?->can('update', $server) ? EditServer::getUrl(['record' => $server]) : null)
->url(fn (Server $server): string => route('filament.admin.resources.servers.edit', ['record' => $server]))
->searchable()
->sortable(),
TextColumn::make('egg.name')
->label(trans('admin/node.table.egg'))
->url(fn (Server $server) => user()?->can('update', $server->egg) ? EditEgg::getUrl(['record' => $server->egg]) : null)
->url(fn (Server $server): string => route('filament.admin.resources.eggs.edit', ['record' => $server->user]))
->sortable(),
SelectColumn::make('allocation.id')
->label(trans('admin/node.primary_allocation'))
->disabled(fn (Server $server) => $server->allocations->count() <= 1)
->options(fn (Server $server) => $server->allocations->take(1)->mapWithKeys(fn ($allocation) => [$allocation->id => $allocation->address]))
->selectablePlaceholder(fn (Server $server) => $server->allocations->count() <= 1)
->placeholder(trans('admin/server.none')),
->placeholder(trans('admin/server.none'))
->sortable(),
TextColumn::make('cpu')
->label(trans('admin/node.cpu'))
->state(fn (Server $server) => $server->formatResource(ServerResourceType::CPULimit)),
@@ -64,22 +61,13 @@ class ServersRelationManager extends RelationManager
->counts('databases')
->label(trans('admin/node.databases'))
->numeric()
->sortable()
->toggleable()
->toggledHiddenByDefault(),
->sortable(),
TextColumn::make('backups_count')
->counts('backups')
->label(trans('admin/node.backups'))
->numeric()
->sortable()
->toggleable()
->toggledHiddenByDefault(),
->sortable(),
])
->emptyStateHeading(trans('admin/server.no_servers'))
->recordActions([
ViewConsoleAction::make(),
EditAction::make()
->url(fn (Server $server) => EditServer::getUrl(['record' => $server], panel: 'admin')),
]);
->emptyStateHeading(trans('admin/server.no_servers'));
}
}

View File

@@ -3,14 +3,12 @@
namespace App\Filament\Admin\Resources\Servers\Pages;
use App\Enums\TablerIcon;
use App\Filament\Admin\Resources\Eggs\Pages\EditEgg;
use App\Filament\Admin\Resources\Nodes\Pages\EditNode;
use App\Filament\Admin\Resources\Servers\ServerResource;
use App\Filament\Admin\Resources\Users\Pages\EditUser;
use App\Filament\Components\Actions\ViewConsoleAction;
use App\Filament\Server\Pages\Console;
use App\Models\Server;
use App\Traits\Filament\CanCustomizeHeaderActions;
use App\Traits\Filament\CanCustomizeHeaderWidgets;
use Filament\Actions\Action;
use Filament\Actions\CreateAction;
use Filament\Actions\EditAction;
use Filament\Resources\Pages\ListRecords;
@@ -30,11 +28,12 @@ class ListServers extends ListRecords
public function table(Table $table): Table
{
return $table
->searchable(false)
->defaultGroup('node.name')
->groups([
Group::make('node.name')->getDescriptionFromRecordUsing(fn (Server $server) => str($server->node->description)->limit(150)),
Group::make('user.username')->getDescriptionFromRecordUsing(fn (Server $server) => $server->user->email),
Group::make('egg.name')->getDescriptionFromRecordUsing(fn (Server $server) => str($server->egg->description)->limit(150)),
Group::make('node.name')->getDescriptionFromRecordUsing(fn (Server $server): string => str($server->node->description)->limit(150)),
Group::make('user.username')->getDescriptionFromRecordUsing(fn (Server $server): string => $server->user->email),
Group::make('egg.name')->getDescriptionFromRecordUsing(fn (Server $server): string => str($server->egg->description)->limit(150)),
])
->columns([
TextColumn::make('condition')
@@ -55,19 +54,19 @@ class ListServers extends ListRecords
->sortable(),
TextColumn::make('node.name')
->label(trans('admin/server.node'))
->url(fn (Server $server) => user()?->can('update', $server->node) ? EditNode::getUrl(['record' => $server->node]) : null)
->url(fn (Server $server) => route('filament.admin.resources.nodes.edit', ['record' => $server->node]))
->hidden(fn (Table $table) => $table->getGrouping()?->getId() === 'node.name')
->sortable()
->searchable(),
TextColumn::make('egg.name')
->label(trans('admin/server.egg'))
->url(fn (Server $server) => user()?->can('update', $server->egg) ? EditEgg::getUrl(['record' => $server->egg]) : null)
->url(fn (Server $server) => route('filament.admin.resources.eggs.edit', ['record' => $server->egg]))
->hidden(fn (Table $table) => $table->getGrouping()?->getId() === 'egg.name')
->sortable()
->searchable(),
TextColumn::make('user.username')
->label(trans('admin/user.username'))
->url(fn (Server $server) => user()?->can('update', $server->user) ? EditUser::getUrl(['record' => $server->user]) : null)
->url(fn (Server $server) => route('filament.admin.resources.users.edit', ['record' => $server->user]))
->hidden(fn (Table $table) => $table->getGrouping()?->getId() === 'user.username')
->sortable()
->searchable(),
@@ -83,23 +82,19 @@ class ListServers extends ListRecords
->label(trans('admin/server.primary_allocation'))
->hidden(fn () => user()?->can('update server')) // TODO: update to policy check (fn (Server $server) --> $server is empty)
->state(fn (Server $server) => $server->allocation->address ?? trans('admin/server.none')),
TextColumn::make('databases_count')
->counts('databases')
->label(trans('admin/server.databases'))
->numeric()
->sortable()
->toggleable()
->toggledHiddenByDefault(),
TextColumn::make('image')->hidden(),
TextColumn::make('backups_count')
->counts('backups')
->label(trans('admin/server.backups'))
->numeric()
->sortable()
->toggleable()
->toggledHiddenByDefault(),
->sortable(),
])
->recordActions([
ViewConsoleAction::make(),
Action::make('view')
->tooltip(trans('admin/server.view'))
->icon(TablerIcon::Terminal)
->url(fn (Server $server) => Console::getUrl(panel: 'server', tenant: $server))
->authorize(fn (Server $server) => user()?->canAccessTenant($server)),
EditAction::make(),
])
->toolbarActions([

View File

@@ -7,7 +7,6 @@ use App\Filament\Admin\Resources\Servers\Pages\CreateServer;
use App\Models\Allocation;
use App\Models\Server;
use App\Services\Allocations\AssignmentService;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Actions\AssociateAction;
use Filament\Actions\CreateAction;
@@ -20,6 +19,7 @@ use Filament\Forms\Components\TextInput;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set;
use Filament\Support\Enums\IconSize;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\TextInputColumn;
@@ -32,30 +32,21 @@ class AllocationsRelationManager extends RelationManager
{
protected static string $relationship = 'allocations';
protected static string|BackedEnum|null $icon = TablerIcon::Network;
public function table(Table $table): Table
{
return $table
->heading(null)
->heading('')
->selectCurrentPageOnly()
->recordTitleAttribute('address')
->recordTitle(fn (Allocation $allocation) => $allocation->address)
->inverseRelationship('server')
->columns([
TextColumn::make('ip')
->label(trans('admin/server.ip_address'))
->searchable()
->sortable(),
->label(trans('admin/server.ip_address')),
TextColumn::make('port')
->label(trans('admin/server.port'))
->searchable()
->sortable(),
->label(trans('admin/server.port')),
TextInputColumn::make('ip_alias')
->label(trans('admin/server.alias'))
->placeholder(trans('admin/server.no_alias'))
->searchable()
->sortable(),
->label(trans('admin/server.alias')),
TextInputColumn::make('notes')
->label(trans('admin/server.notes'))
->placeholder(trans('admin/server.no_notes')),
@@ -81,26 +72,18 @@ class AllocationsRelationManager extends RelationManager
->emptyStateHeading(trans('admin/server.no_allocations'))
->recordActions([
Action::make('make-primary')
->authorize(fn (Allocation $allocation) => user()?->can('update', $allocation))
->tooltip(trans('admin/server.make_primary'))
->icon(TablerIcon::Star)
->label(trans('admin/server.make_primary'))
->action(fn (Allocation $allocation) => $this->getOwnerRecord()->update(['allocation_id' => $allocation->id]) && $this->deselectAllTableRecords())
->hidden(fn (Allocation $allocation) => $allocation->id === $this->getOwnerRecord()->allocation_id),
Action::make('lock')
->authorize(fn (Allocation $allocation) => user()?->can('update', $allocation))
->tooltip(trans('admin/server.lock'))
->icon(TablerIcon::Lock)
->label(trans('admin/server.lock'))
->action(fn (Allocation $allocation) => $allocation->update(['is_locked' => true]) && $this->deselectAllTableRecords())
->hidden(fn (Allocation $allocation) => $allocation->is_locked),
Action::make('unlock')
->authorize(fn (Allocation $allocation) => user()?->can('update', $allocation))
->tooltip(trans('admin/server.unlock'))
->icon(TablerIcon::LockOpen)
->label(trans('admin/server.unlock'))
->action(fn (Allocation $allocation) => $allocation->update(['is_locked' => false]) && $this->deselectAllTableRecords())
->visible(fn (Allocation $allocation) => $allocation->is_locked),
DissociateAction::make()
->authorize(fn (Allocation $allocation) => user()?->can('update', $allocation))
->tooltip(trans('admin/server.remove_allocation'))
->after(function (Allocation $allocation) {
$allocation->update([
'notes' => null,
@@ -127,7 +110,7 @@ class AllocationsRelationManager extends RelationManager
CreateAction::make()
->hiddenLabel()
->tooltip(trans('admin/server.create_allocation'))
->icon(TablerIcon::WorldPlus)
->icon(TablerIcon::Network)
->createAnother(false)
->schema(fn () => [
Select::make('allocation_ip')
@@ -180,6 +163,8 @@ class AllocationsRelationManager extends RelationManager
])
->action(fn (array $data, AssignmentService $service) => $service->handle($this->getOwnerRecord()->node, $data, $this->getOwnerRecord())),
AssociateAction::make()
->icon(TablerIcon::FilePlus)
->iconButton()->iconSize(IconSize::ExtraLarge)
->multiple()
->associateAnother(false)
->preloadRecordSelect()

View File

@@ -3,7 +3,6 @@
namespace App\Filament\Admin\Resources\Servers\RelationManagers;
use App\Enums\TablerIcon;
use App\Filament\Admin\Resources\Servers\Pages\EditServer;
use App\Filament\Components\Actions\RotateDatabasePasswordAction;
use App\Filament\Components\Tables\Columns\DateTimeColumn;
use App\Models\Database;
@@ -11,7 +10,6 @@ use App\Models\DatabaseHost;
use App\Models\Server;
use App\Services\Databases\DatabaseManagementService;
use App\Services\Servers\RandomWordService;
use BackedEnum;
use Exception;
use Filament\Actions\CreateAction;
use Filament\Actions\DeleteAction;
@@ -32,8 +30,6 @@ class DatabasesRelationManager extends RelationManager
{
protected static string $relationship = 'databases';
protected static string|BackedEnum|null $icon = TablerIcon::Database;
public function form(Schema $schema): Schema
{
return $schema
@@ -66,7 +62,7 @@ class DatabasesRelationManager extends RelationManager
public function table(Table $table): Table
{
return $table
->heading(null)
->heading('')
->recordTitleAttribute('database')
->columns([
TextColumn::make('database'),
@@ -76,7 +72,7 @@ class DatabasesRelationManager extends RelationManager
->label(trans('admin/databasehost.table.remote'))
->formatStateUsing(fn (Database $record) => $record->remote === '%' ? trans('admin/databasehost.anywhere'). ' ( % )' : $record->remote),
TextColumn::make('server.name')
->url(fn (Database $database) => user()?->can('update', $database->server) ? EditServer::getUrl(['record' => $database->server]) : null),
->url(fn (Database $database) => route('filament.admin.resources.servers.edit', ['record' => $database->server_id])),
TextColumn::make('max_connections')
->label(trans('admin/databasehost.table.max_connections'))
->formatStateUsing(fn ($record) => $record->max_connections ?: trans('admin/databasehost.unlimited')),

View File

@@ -4,15 +4,10 @@ namespace App\Filament\Admin\Resources\Users\RelationManagers;
use App\Enums\ServerState;
use App\Enums\SuspendAction;
use App\Filament\Admin\Resources\Eggs\Pages\EditEgg;
use App\Filament\Admin\Resources\Nodes\Pages\EditNode;
use App\Filament\Admin\Resources\Servers\Pages\EditServer;
use App\Filament\Components\Actions\ViewConsoleAction;
use App\Models\Server;
use App\Models\User;
use App\Services\Servers\SuspensionService;
use Filament\Actions\Action;
use Filament\Actions\EditAction;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables\Columns\SelectColumn;
use Filament\Tables\Columns\TextColumn;
@@ -28,6 +23,7 @@ class ServersRelationManager extends RelationManager
$user = $this->getOwnerRecord();
return $table
->searchable(false)
->heading(trans('admin/user.servers'))
->headerActions([
Action::make('toggle_suspend')
@@ -54,20 +50,22 @@ class ServersRelationManager extends RelationManager
}),
])
->columns([
TextColumn::make('uuid')
->hidden()
->label('UUID')
->searchable(),
TextColumn::make('name')
->label(trans('admin/server.name'))
->url(fn (Server $server) => user()?->can('update', $server) ? EditServer::getUrl(['record' => $server]) : null)
->url(fn (Server $server) => route('filament.admin.resources.servers.edit', ['record' => $server]))
->searchable()
->sortable(),
TextColumn::make('node.name')
->label(trans('admin/server.node'))
->url(fn (Server $server) => user()?->can('update', $server->node) ? EditNode::getUrl(['record' => $server->node]) : null)
->searchable()
->url(fn (Server $server) => route('filament.admin.resources.nodes.edit', ['record' => $server->node]))
->sortable(),
TextColumn::make('egg.name')
->label(trans('admin/server.egg'))
->url(fn (Server $server) => user()?->can('update', $server->egg) ? EditEgg::getUrl(['record' => $server->egg]) : null)
->searchable()
->url(fn (Server $server) => route('filament.admin.resources.eggs.edit', ['record' => $server->egg]))
->sortable(),
SelectColumn::make('allocation_id')
->label(trans('admin/server.primary_allocation'))
@@ -85,22 +83,13 @@ class ServersRelationManager extends RelationManager
->counts('databases')
->label(trans('admin/server.databases'))
->numeric()
->sortable()
->toggleable()
->toggledHiddenByDefault(),
->sortable(),
TextColumn::make('backups_count')
->counts('backups')
->label(trans('admin/server.backups'))
->numeric()
->sortable()
->toggleable()
->toggledHiddenByDefault(),
->sortable(),
])
->emptyStateHeading(trans('admin/server.no_servers'))
->recordActions([
ViewConsoleAction::make(),
EditAction::make()
->url(fn (Server $server) => EditServer::getUrl(['record' => $server], panel: 'admin')),
]);
->emptyStateHeading(trans('admin/server.no_servers'));
}
}

View File

@@ -118,12 +118,10 @@ class UserResource extends Resource
->defaultImageUrl(fn (User $user) => Filament::getUserAvatarUrl($user)),
TextColumn::make('username')
->label(trans('admin/user.username'))
->searchable()
->sortable(),
->searchable(),
TextColumn::make('email')
->label(trans('admin/user.email'))
->searchable()
->sortable(),
->searchable(),
IconColumn::make('mfa_email_enabled')
->label(trans('profile.tabs.2fa'))
->visibleFrom('lg')

View File

@@ -1,47 +0,0 @@
<?php
namespace App\Filament\Components\Actions;
use App\Enums\TablerIcon;
use App\Filament\Server\Pages\Console;
use App\Models\Server;
use Filament\Actions\Action;
use Filament\Facades\Filament;
class ViewConsoleAction extends Action
{
protected ?Server $server = null;
public static function getDefaultName(): ?string
{
return 'view_console';
}
protected function setUp(): void
{
parent::setUp();
$this->tooltip(trans('admin/server.view_console'));
$this->icon(TablerIcon::Terminal);
$this->url(fn (?Server $server) => Console::getUrl(panel: 'server', tenant: $this->getServer() ?? $server));
$this->authorize(fn (?Server $server) => user()?->canAccessTenant($this->getServer() ?? $server));
}
public function server(?Server $server): static
{
$this->server = $server;
return $this;
}
public function getServer(): ?Server
{
/** @var ?Server $tenant */
$tenant = Filament::getTenant();
return $this->server ?? $tenant;
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Filament\Server\Pages;
use App\Enums\ServerUserSettingKey;
use App\Enums\SubuserPermission;
use App\Enums\TablerIcon;
use App\Facades\Activity;
@@ -14,6 +15,7 @@ use Exception;
use Filament\Actions\Action;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Infolists\Components\TextEntry;
use Filament\Notifications\Notification;
use Filament\Schemas\Components\Fieldset;
@@ -254,9 +256,44 @@ class Settings extends ServerFormPage
TextEntry::make('files_info')
->label(trans('server/setting.reinstall.body2')),
]),
Section::make(trans('server/setting.notifications.title'))
->columnSpanFull()
->schema([
Toggle::make(ServerUserSettingKey::ManualBackupNotifications->value)
->label(trans('server/setting.notifications.backup_manual.label'))
->helperText(trans('server/setting.notifications.backup_manual.helper'))
->live()
->afterStateUpdated(fn ($state, Server $server) => $this->updateNotificationSetting(ServerUserSettingKey::ManualBackupNotifications, (bool) $state, $server)),
Toggle::make(ServerUserSettingKey::ScheduledBackupNotifications->value)
->label(trans('server/setting.notifications.backup_scheduled.label'))
->helperText(trans('server/setting.notifications.backup_scheduled.helper'))
->live()
->afterStateUpdated(fn ($state, Server $server) => $this->updateNotificationSetting(ServerUserSettingKey::ScheduledBackupNotifications, (bool) $state, $server)),
]),
]);
}
protected function fillForm(): void
{
$data = $this->getRecord()->attributesToArray();
foreach (ServerUserSettingKey::cases() as $key) {
$data[$key->value] = (bool) user()?->getServerSetting($this->getRecord(), $key);
}
$this->form->fill($data);
}
public function updateNotificationSetting(ServerUserSettingKey $key, bool $state, Server $server): void
{
user()?->updateServerSetting($server, $key, $state);
Notification::make()
->title(trans('server/setting.notifications.saved'))
->success()
->send();
}
public function updateName(string $name, Server $server): void
{
abort_unless(user()?->can(SubuserPermission::SettingsRename, $server), 403);

View File

@@ -2,9 +2,16 @@
namespace App\Filament\Server\Resources\Backups\Pages;
use App\Enums\ServerUserSettingKey;
use App\Enums\TablerIcon;
use App\Filament\Server\Resources\Backups\BackupResource;
use App\Models\Server;
use App\Traits\Filament\CanCustomizeHeaderActions;
use App\Traits\Filament\CanCustomizeHeaderWidgets;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ListRecords;
class ListBackups extends ListRecords
@@ -14,6 +21,39 @@ class ListBackups extends ListRecords
protected static string $resource = BackupResource::class;
/** @return Action[] */
protected function getDefaultHeaderActions(): array
{
/** @var Server $server */
$server = Filament::getTenant();
return [
Action::make('notification_settings')
->label(trans('server/backup.notifications.action'))
->icon(TablerIcon::Bell)
->schema([
Toggle::make(ServerUserSettingKey::ManualBackupNotifications->value)
->label(trans('server/backup.notifications.toggle_manual'))
->helperText(trans('server/backup.notifications.helper_manual'))
->default(fn () => (bool) user()?->getServerSetting($server, ServerUserSettingKey::ManualBackupNotifications)),
Toggle::make(ServerUserSettingKey::ScheduledBackupNotifications->value)
->label(trans('server/backup.notifications.toggle_scheduled'))
->helperText(trans('server/backup.notifications.helper_scheduled'))
->default(fn () => (bool) user()?->getServerSetting($server, ServerUserSettingKey::ScheduledBackupNotifications)),
])
->action(function (array $data) use ($server) {
foreach (ServerUserSettingKey::cases() as $key) {
user()?->updateServerSetting($server, $key, (bool) $data[$key->value]);
}
Notification::make()
->title(trans('server/backup.notifications.saved'))
->success()
->send();
}),
];
}
public function getBreadcrumbs(): array
{
return [];

View File

@@ -2,8 +2,11 @@
namespace App\Listeners\Server;
use App\Enums\ServerUserSettingKey;
use App\Events\Server\BackupCompleted;
use App\Filament\Server\Resources\Backups\Pages\ListBackups;
use App\Models\ServerUserSettings;
use App\Models\User;
use App\Notifications\BackupCompleted as BackupCompletedNotification;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
@@ -12,26 +15,47 @@ class BackupCompletedListener
{
public function handle(BackupCompleted $event): void
{
$event->backup->loadMissing('server');
$event->backup->server->loadMissing('user');
$event->backup->loadMissing(['server.user', 'server.subusers.user']);
$locale = $event->backup->server->user->language ?? 'en';
$server = $event->backup->server;
Notification::make()
->status($event->backup->is_successful ? 'success' : 'danger')
->title(trans('notifications.backup_' . ($event->backup->is_successful ? 'completed' : 'failed'), locale: $locale))
->body(trans('notifications.backup_body', ['name' => $event->backup->name, 'server' => $event->backup->server->name], $locale))
->actions([
Action::make('exclude_view')
->button()
->label(trans('notifications.view_backups', locale: $locale))
->markAsRead()
->url(fn () => ListBackups::getUrl(panel: 'server', tenant: $event->backup->server)),
])
->sendToDatabase($event->backup->server->user);
$candidates = collect([$server->user])
->concat($server->subusers->map->user)
->filter()
->unique('id');
if (config()->get('panel.email.send_backup_completed_notification', true)) {
$event->backup->server->user->notify(new BackupCompletedNotification($event->backup));
$settings = ServerUserSettings::query()
->where('server_id', $server->id)
->whereIn('user_id', $candidates->pluck('id'))
->get()
->keyBy('user_id');
// Users only receive notifications if they opted in for this kind of backup.
$key = $event->backup->is_scheduled ? ServerUserSettingKey::ScheduledBackupNotifications : ServerUserSettingKey::ManualBackupNotifications;
$recipients = $candidates->filter(function (User $user) use ($settings, $key) {
$userSettings = $settings->get($user->id)->settings ?? [];
return (bool) ($userSettings[$key->value] ?? $key->getDefaultValue());
});
foreach ($recipients as $user) {
$locale = $user->language ?? 'en';
Notification::make()
->status($event->backup->is_successful ? 'success' : 'danger')
->title(trans('notifications.backup_' . ($event->backup->is_successful ? 'completed' : 'failed'), locale: $locale))
->body(trans('notifications.backup_body', ['name' => $event->backup->name, 'server' => $server->name], $locale))
->actions([
Action::make('exclude_view')
->button()
->label(trans('notifications.view_backups', locale: $locale))
->markAsRead()
->url(fn () => ListBackups::getUrl(panel: 'server', tenant: $server)),
])
->sendToDatabase($user);
$user->notify(new BackupCompletedNotification($event->backup));
}
}
}

View File

@@ -31,6 +31,7 @@ use Illuminate\Database\Query\Builder;
* @property bool $is_successful
* @property string|null $upload_id
* @property bool $is_locked
* @property bool $is_scheduled
* @property-read Server $server
* @property-read BackupStatus $status
*
@@ -69,6 +70,7 @@ class Backup extends Model implements Validatable
protected $attributes = [
'is_successful' => false,
'is_locked' => false,
'is_scheduled' => false,
'checksum' => null,
'bytes' => 0,
'upload_id' => null,
@@ -82,6 +84,7 @@ class Backup extends Model implements Validatable
'uuid' => ['required', 'uuid'],
'is_successful' => ['boolean'],
'is_locked' => ['boolean'],
'is_scheduled' => ['boolean'],
'name' => ['required', 'string'],
'ignored_files' => ['array'],
'backup_host_id' => ['required', 'integer', 'exists:backup_hosts,id'],
@@ -96,6 +99,7 @@ class Backup extends Model implements Validatable
'id' => 'int',
'is_successful' => 'bool',
'is_locked' => 'bool',
'is_scheduled' => 'bool',
'ignored_files' => 'array',
'bytes' => 'int',
'completed_at' => 'immutable_datetime',

View File

@@ -90,6 +90,8 @@ use Psr\Http\Message\ResponseInterface;
* @property-read int|null $server_variables_count
* @property-read Collection<int, Subuser> $subusers
* @property-read int|null $subusers_count
* @property-read Collection<int, ServerUserSettings> $userSettings
* @property-read int|null $user_settings_count
* @property-read ServerTransfer|null $transfer
* @property-read User $user
* @property-read Collection<int, EggVariable> $variables
@@ -271,6 +273,16 @@ class Server extends Model implements HasAvatar, Validatable
return $this->hasMany(Subuser::class, 'server_id', 'id');
}
/**
* Gets the per-user settings associated with a server.
*
* @return HasMany<ServerUserSettings, $this>
*/
public function userSettings(): HasMany
{
return $this->hasMany(ServerUserSettings::class);
}
/**
* Gets the default allocation for a server.
*/

View File

@@ -0,0 +1,84 @@
<?php
namespace App\Models;
use App\Contracts\Validatable;
use App\Traits\HasValidation;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
/**
* @property int $id
* @property int $user_id
* @property int $server_id
* @property array<string, string|int|bool>|null $settings
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property-read Server $server
* @property-read User $user
*
* @method static \Database\Factories\ServerUserSettingsFactory factory($count = null, $state = [])
* @method static \Illuminate\Database\Eloquent\Builder<static>|ServerUserSettings newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|ServerUserSettings newQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|ServerUserSettings query()
* @method static \Illuminate\Database\Eloquent\Builder<static>|ServerUserSettings whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ServerUserSettings whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ServerUserSettings whereServerId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ServerUserSettings whereSettings($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ServerUserSettings whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ServerUserSettings whereUserId($value)
*/
class ServerUserSettings extends Model implements Validatable
{
use HasFactory;
use HasValidation;
/**
* The resource name for this model when it is transformed into an
* API representation using fractal.
*/
public const RESOURCE_NAME = 'server_user_settings';
protected $table = 'server_user_settings';
/**
* Fields that are not mass assignable.
*/
protected $guarded = ['id', 'created_at', 'updated_at'];
/** @var array<array-key, string[]> */
public static array $validationRules = [
'user_id' => ['required', 'numeric', 'exists:users,id'],
'server_id' => ['required', 'numeric', 'exists:servers,id'],
'settings' => ['nullable', 'array'],
'settings.manual_backup_notifications' => ['boolean'],
'settings.scheduled_backup_notifications' => ['boolean'],
];
protected function casts(): array
{
return [
'user_id' => 'int',
'server_id' => 'int',
'settings' => 'array',
];
}
/**
* Gets the server these settings are associated with.
*/
public function server(): BelongsTo
{
return $this->belongsTo(Server::class);
}
/**
* Gets the user these settings are associated with.
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -4,6 +4,7 @@ namespace App\Models;
use App\Contracts\Validatable;
use App\Enums\CustomizationKey;
use App\Enums\ServerUserSettingKey;
use App\Enums\SubuserPermission;
use App\Events\User\Deleting;
use App\Exceptions\DisplayException;
@@ -86,6 +87,8 @@ use Spatie\Permission\Traits\HasRoles;
* @property-read int|null $sub_servers_count
* @property-read \Illuminate\Database\Eloquent\Collection<int, Subuser> $subusers
* @property-read int|null $subusers_count
* @property-read \Illuminate\Database\Eloquent\Collection<int, ServerUserSettings> $serverSettings
* @property-read int|null $server_settings_count
* @property-read \Illuminate\Database\Eloquent\Collection<int, ApiKey> $tokens
* @property-read int|null $tokens_count
*
@@ -351,6 +354,30 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
return $this->belongsToMany(Server::class, 'subusers');
}
/** @return HasMany<ServerUserSettings, $this> */
public function serverSettings(): HasMany
{
return $this->hasMany(ServerUserSettings::class);
}
public function getServerSetting(Server $server, ServerUserSettingKey $key): string|int|bool
{
$settings = $this->serverSettings()->where('server_id', $server->id)->first()->settings ?? [];
return $settings[$key->value] ?? $key->getDefaultValue();
}
public function updateServerSetting(Server $server, ServerUserSettingKey $key, string|int|bool $value): void
{
$row = $this->serverSettings()->firstOrNew(['server_id' => $server->id]);
$settings = $row->settings ?? [];
$settings[$key->value] = $value;
$row->settings = array_intersect_key($settings, ServerUserSettingKey::getDefaultSettings());
$row->save();
}
/** @return ($key is null ? array<string, string|int|bool> : string|int|bool) */
public function getCustomization(?CustomizationKey $key = null): array|string|int|bool|null
{

View File

@@ -6,11 +6,8 @@ use App\Enums\CustomizationKey;
use App\Enums\TablerIcon;
use App\Livewire\Passkeys;
use Filament\Actions\Action;
use Filament\Actions\AssociateAction;
use Filament\Actions\CreateAction;
use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\DissociateAction;
use Filament\Actions\EditAction;
use Filament\Actions\View\ActionsIconAlias;
use Filament\Actions\ViewAction;
@@ -30,7 +27,6 @@ use Filament\Support\Facades\FilamentColor;
use Filament\Support\Facades\FilamentIcon;
use Filament\Support\Facades\FilamentView;
use Filament\Support\View\SupportIconAlias;
use Filament\Tables\Columns\SelectColumn;
use Filament\Tables\View\TablesIconAlias;
use Filament\View\PanelsIconAlias;
use Filament\View\PanelsRenderHook;
@@ -108,8 +104,6 @@ class FilamentServiceProvider extends ServiceProvider
Select::configureUsing(fn (Select $select) => $select->native(false));
SelectColumn::configureUsing(fn (SelectColumn $select) => $select->native(false));
KeyValue::configureUsing(fn (KeyValue $keyValue) => $keyValue
->addAction(function (Action $action) {
$action->tooltip(fn () => $action->getLabel());
@@ -167,18 +161,6 @@ class FilamentServiceProvider extends ServiceProvider
}
});
DeleteBulkAction::configureUsing(function (DeleteBulkAction $action) {
$action->icon(TablerIcon::Trash);
$action->tooltip(fn () => $action->getLabel());
$action->hiddenLabel();
$action->iconSize(IconSize::Large);
if (user()?->getCustomization(CustomizationKey::ButtonStyle)) {
$action->iconButton();
$action->iconSize(IconSize::ExtraLarge);
}
});
CreateAction::configureUsing(function (CreateAction $action) {
$action->icon(TablerIcon::Plus);
$action->tooltip(fn () => $action->getLabel());
@@ -215,30 +197,6 @@ class FilamentServiceProvider extends ServiceProvider
}
});
AssociateAction::configureUsing(function (AssociateAction $action) {
$action->icon(TablerIcon::Link);
$action->tooltip(fn () => $action->getLabel());
$action->hiddenLabel();
$action->iconSize(IconSize::Large);
if (user()?->getCustomization(CustomizationKey::ButtonStyle)) {
$action->iconButton();
$action->iconSize(IconSize::ExtraLarge);
}
});
DissociateAction::configureUsing(function (DissociateAction $action) {
$action->icon(TablerIcon::Unlink);
$action->tooltip(fn () => $action->getLabel());
$action->hiddenLabel();
$action->iconSize(IconSize::Large);
if (user()?->getCustomization(CustomizationKey::ButtonStyle)) {
$action->iconButton();
$action->iconSize(IconSize::ExtraLarge);
}
});
Action::configureUsing(function (Action $action) {
$action->iconSize(IconSize::Large);

View File

@@ -21,6 +21,8 @@ class InitiateBackupService
private bool $isLocked = false;
private bool $isScheduled = false;
/**
* InitiateBackupService constructor.
*/
@@ -41,6 +43,16 @@ class InitiateBackupService
return $this;
}
/**
* Set if the backup was initiated by a schedule rather than a user.
*/
public function setIsScheduled(bool $isScheduled): self
{
$this->isScheduled = $isScheduled;
return $this;
}
/**
* Sets the files to be ignored by this backup.
*
@@ -125,6 +137,7 @@ class InitiateBackupService
'ignored_files' => array_values($this->ignoredFiles ?? []),
'backup_host_id' => $backupHost->id,
'is_locked' => $this->isLocked,
'is_scheduled' => $this->isScheduled,
]);
$schema->createBackup($backup);

View File

@@ -20,6 +20,7 @@ class BackupTransformer extends BaseClientTransformer
'uuid' => $backup->uuid,
'is_successful' => $backup->is_successful,
'is_locked' => $backup->is_locked,
'is_scheduled' => $backup->is_scheduled,
'name' => $backup->name,
'ignored_files' => $backup->ignored_files,
'checksum' => $backup->checksum,

View File

@@ -55,8 +55,6 @@ return [
'send_install_notification' => env('PANEL_SEND_INSTALL_NOTIFICATION', true),
// Should an email be sent to a server owner whenever their server is reinstalled?
'send_reinstall_notification' => env('PANEL_SEND_REINSTALL_NOTIFICATION', true),
// Should an email be sent to a server owner whenever a backup is completed?
'send_backup_completed_notification' => env('PANEL_SEND_BACKUP_COMPLETED_NOTIFICATION', true),
],
'filament' => [

View File

@@ -0,0 +1,56 @@
<?php
namespace Database\Factories;
use App\Enums\ServerUserSettingKey;
use App\Models\ServerUserSettings;
use Illuminate\Database\Eloquent\Factories\Factory;
class ServerUserSettingsFactory extends Factory
{
/**
* The name of the factory's corresponding model.
*
* @var string
*/
protected $model = ServerUserSettings::class;
/**
* Define the model's default state.
*/
public function definition(): array
{
return [
'settings' => [
ServerUserSettingKey::ManualBackupNotifications->value => true,
ServerUserSettingKey::ScheduledBackupNotifications->value => true,
],
];
}
/**
* Indicate the user has opted in to all backup notifications.
*/
public function optedIn(): static
{
return $this->state([
'settings' => [
ServerUserSettingKey::ManualBackupNotifications->value => true,
ServerUserSettingKey::ScheduledBackupNotifications->value => true,
],
]);
}
/**
* Indicate the user has opted out of all backup notifications.
*/
public function optedOut(): static
{
return $this->state([
'settings' => [
ServerUserSettingKey::ManualBackupNotifications->value => false,
ServerUserSettingKey::ScheduledBackupNotifications->value => false,
],
]);
}
}

View File

@@ -0,0 +1,48 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('server_user_settings', function (Blueprint $table) {
$table->id();
$table->unsignedInteger('user_id');
$table->unsignedInteger('server_id');
$table->json('settings')->nullable();
$table->timestamps();
$table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete();
$table->foreign('server_id')->references('id')->on('servers')->cascadeOnDelete();
$table->unique(['user_id', 'server_id']);
});
// Backup notifications now default off for everyone. Owners on existing
// installs previously always received them, so seed an opt-in row for each
// server's owner unless the panel-wide email switch was already disabled.
if (!filter_var(env('PANEL_SEND_BACKUP_COMPLETED_NOTIFICATION', true), FILTER_VALIDATE_BOOL)) {
return;
}
$now = now();
DB::table('servers')->select(['id', 'owner_id'])->orderBy('id')->chunkById(250, function ($servers) use ($now) {
DB::table('server_user_settings')->insert($servers->map(fn ($server) => [
'user_id' => $server->owner_id,
'server_id' => $server->id,
'settings' => json_encode(['manual_backup_notifications' => true, 'scheduled_backup_notifications' => true]),
'created_at' => $now,
'updated_at' => $now,
])->all());
});
}
public function down(): void
{
Schema::dropIfExists('server_user_settings');
}
};

View File

@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('backups', function (Blueprint $table) {
$table->boolean('is_scheduled')->default(false)->after('is_locked');
});
}
public function down(): void
{
Schema::table('backups', function (Blueprint $table) {
$table->dropColumn('is_scheduled');
});
}
};

View File

@@ -25,8 +25,6 @@ return [
'owner' => 'Owner',
'allocation_notes' => 'Notes',
'no_notes' => 'No notes',
'no_server' => 'No server',
'no_alias' => 'No alias',
],
'node_info' => 'Node Information',
'wings_version' => 'Wings Version',

View File

@@ -15,7 +15,6 @@ return [
'ports' => 'Ports',
'alias' => 'Alias',
'alias_helper' => 'Optional display name to help you remember what these are.',
'no_alias' => 'No Alias',
'locked' => 'Locked?',
'locked_helper' => 'Users won\'t be able to delete locked allocations',
'lock' => 'Lock',
@@ -111,8 +110,7 @@ return [
'keep_old_variables' => 'Keep old variables if possible?',
'create_allocation' => 'Create Allocation',
'add_allocation' => 'Add Allocation',
'remove_allocation' => 'Remove Allocation',
'view_console' => 'View Console',
'view' => 'View',
'no_log' => 'No Log Available',
'select_backups' => 'Select Backups',
'warning_backups' => 'Be aware, not transferred backups will be deleted.',

View File

@@ -121,7 +121,6 @@ return [
'removed_from_server' => 'Removed from Server',
'server_installed' => 'Server Installed',
'server_reinstalled' => 'Server Reinstalled',
'backup_completed' => 'Backup Completed',
],
'connections' => [
'title' => 'Connections',

View File

@@ -12,6 +12,14 @@ return [
'successful' => 'Successful',
'failed' => 'Failed',
],
'notifications' => [
'action' => 'Notification Settings',
'toggle_manual' => 'Manual Backup Notifications',
'helper_manual' => 'Receive a notification and email when a manually created backup for this server completes or fails.',
'toggle_scheduled' => 'Scheduled Backup Notifications',
'helper_scheduled' => 'Receive a notification and email when a scheduled backup for this server completes or fails.',
'saved' => 'Notification Settings Updated',
],
'actions' => [
'create' => [
'title' => 'Create Backup',

View File

@@ -41,6 +41,18 @@ return [
'password_body' => 'Your SFTP password is the same as the password you use to access this panel.',
],
],
'notifications' => [
'title' => 'Notifications',
'saved' => 'Notification Settings Updated',
'backup_manual' => [
'label' => 'Manual Backup Notifications',
'helper' => 'Receive a notification and email when a manually created backup for this server completes or fails.',
],
'backup_scheduled' => [
'label' => 'Scheduled Backup Notifications',
'helper' => 'Receive a notification and email when a scheduled backup for this server completes or fails.',
],
],
'reinstall' => [
'title' => 'Reinstall Server',
'body' => 'Reinstalling your server will stop it, and then re-run the installation script that initially set it up.',

View File

@@ -0,0 +1,143 @@
<?php
use App\Enums\ServerUserSettingKey;
use App\Enums\SubuserPermission;
use App\Events\Server\BackupCompleted;
use App\Listeners\Server\BackupCompletedListener;
use App\Models\Backup;
use App\Models\BackupHost;
use App\Models\Server;
use App\Models\ServerUserSettings;
use App\Models\User;
use App\Notifications\BackupCompleted as BackupCompletedNotification;
use Filament\Notifications\DatabaseNotification;
use Illuminate\Support\Facades\Notification;
function runBackupCompletedListener(Server $server, bool $scheduled = false): void
{
/** @var Backup $backup */
$backup = Backup::factory()->create([
'server_id' => $server->id,
'backup_host_id' => BackupHost::factory()->create()->id,
'is_scheduled' => $scheduled,
]);
(new BackupCompletedListener())->handle(new BackupCompleted($backup));
}
it('does not notify anyone by default', function () {
Notification::fake();
[$subuser, $server] = generateTestAccount([SubuserPermission::WebsocketConnect]);
runBackupCompletedListener($server);
runBackupCompletedListener($server, scheduled: true);
Notification::assertNotSentTo($server->user, DatabaseNotification::class);
Notification::assertNotSentTo($server->user, BackupCompletedNotification::class);
Notification::assertNotSentTo($subuser, DatabaseNotification::class);
Notification::assertNotSentTo($subuser, BackupCompletedNotification::class);
});
it('notifies owners who opted in to manual backups only for manual backups', function () {
Notification::fake();
[$owner, $server] = generateTestAccount();
$owner->updateServerSetting($server, ServerUserSettingKey::ManualBackupNotifications, true);
runBackupCompletedListener($server, scheduled: true);
Notification::assertNotSentTo($owner, DatabaseNotification::class);
Notification::assertNotSentTo($owner, BackupCompletedNotification::class);
runBackupCompletedListener($server);
Notification::assertSentTo($owner, DatabaseNotification::class);
Notification::assertSentTo($owner, BackupCompletedNotification::class);
});
it('notifies owners who opted in to scheduled backups only for scheduled backups', function () {
Notification::fake();
[$owner, $server] = generateTestAccount();
$owner->updateServerSetting($server, ServerUserSettingKey::ScheduledBackupNotifications, true);
runBackupCompletedListener($server);
Notification::assertNotSentTo($owner, DatabaseNotification::class);
Notification::assertNotSentTo($owner, BackupCompletedNotification::class);
runBackupCompletedListener($server, scheduled: true);
Notification::assertSentTo($owner, DatabaseNotification::class);
Notification::assertSentTo($owner, BackupCompletedNotification::class);
});
it('notifies subusers who opted in', function () {
Notification::fake();
[$subuser, $server] = generateTestAccount([SubuserPermission::WebsocketConnect]);
$subuser->updateServerSetting($server, ServerUserSettingKey::ManualBackupNotifications, true);
runBackupCompletedListener($server);
Notification::assertSentTo($subuser, DatabaseNotification::class);
Notification::assertSentTo($subuser, BackupCompletedNotification::class);
});
it('does not notify users who opted back out', function () {
Notification::fake();
[$owner, $server] = generateTestAccount();
$owner->updateServerSetting($server, ServerUserSettingKey::ManualBackupNotifications, true);
$owner->updateServerSetting($server, ServerUserSettingKey::ManualBackupNotifications, false);
runBackupCompletedListener($server);
Notification::assertNotSentTo($owner, DatabaseNotification::class);
Notification::assertNotSentTo($owner, BackupCompletedNotification::class);
});
it('defaults backup notifications off for everyone', function () {
[$owner, $server] = generateTestAccount();
/** @var User $subuser */
$subuser = User::factory()->create();
foreach (ServerUserSettingKey::cases() as $key) {
expect($owner->getServerSetting($server, $key))->toBeFalse()
->and($subuser->getServerSetting($server, $key))->toBeFalse();
}
});
it('updates a single settings row per user and server', function () {
[$owner, $server] = generateTestAccount();
$owner->updateServerSetting($server, ServerUserSettingKey::ManualBackupNotifications, false);
$owner->updateServerSetting($server, ServerUserSettingKey::ManualBackupNotifications, true);
$owner->updateServerSetting($server, ServerUserSettingKey::ScheduledBackupNotifications, true);
expect(ServerUserSettings::query()->where('user_id', $owner->id)->where('server_id', $server->id)->count())->toBe(1)
->and($owner->getServerSetting($server, ServerUserSettingKey::ManualBackupNotifications))->toBeTrue()
->and($owner->getServerSetting($server, ServerUserSettingKey::ScheduledBackupNotifications))->toBeTrue();
});
it('drops unknown keys when updating settings', function () {
[$owner, $server] = generateTestAccount();
ServerUserSettings::factory()->create([
'user_id' => $owner->id,
'server_id' => $server->id,
'settings' => ['unknown_key' => 'value'],
]);
$owner->updateServerSetting($server, ServerUserSettingKey::ManualBackupNotifications, true);
$settings = ServerUserSettings::query()->where('user_id', $owner->id)->where('server_id', $server->id)->value('settings');
expect($settings)->toBe([ServerUserSettingKey::ManualBackupNotifications->value => true]);
});