Compare commits

..

3 Commits

Author SHA1 Message Date
Lance Pioch
65ed06893b Remove unnecessary nullsafe operator on left side of ?? 2026-02-07 23:42:25 -05:00
Lance Pioch
59f21ff833 Use null-safe operator on user() calls for timezone access 2026-02-07 23:23:02 -05:00
Lance Pioch
f2d6b5af76 Allow schedule cron inputs to use a user-selected timezone (#1228)
Add a timezone dropdown (defaulting to the user's profile timezone) to the schedule form so cron inputs are interpreted in the chosen timezone. The selected timezone is used to compute next_run_at in UTC at save time with no database schema changes required.

Resolves #1228
2026-02-06 02:32:21 -05:00
13 changed files with 33 additions and 165 deletions

View File

@@ -58,9 +58,6 @@ enum SubuserPermission: string
case SettingsDescription = 'settings.description';
case SettingsReinstall = 'settings.reinstall';
case MountRead = 'mount.read';
case MountUpdate = 'mount.update';
/** @return string[] */
public function split(): array
{
@@ -87,7 +84,6 @@ enum SubuserPermission: string
'schedule' => TablerIcon::Clock,
'settings' => TablerIcon::Settings,
'activity' => TablerIcon::Stack,
'mount' => TablerIcon::LayersLinked,
default => null,
};
}

View File

@@ -143,24 +143,6 @@ class MountResource extends Resource
])
->inline()
->default(false),
ToggleButtons::make('user_mountable')
->label(trans('admin/mount.user_mountable'))
->helperText(trans('admin/mount.user_mountable_help'))
->stateCast(new BooleanStateCast(false, true))
->options([
false => trans('admin/mount.toggles.not_user_mountable'),
true => trans('admin/mount.toggles.user_mountable'),
])
->icons([
false => TablerIcon::Users,
true => TablerIcon::Users,
])
->colors([
false => 'warning',
true => 'success',
])
->inline()
->default(true),
TextInput::make('source')
->label(trans('admin/mount.source'))
->required()

View File

@@ -42,6 +42,7 @@ class CreateMount extends CreateRecord
protected function handleRecordCreation(array $data): Model
{
$data['uuid'] ??= Str::uuid()->toString();
$data['user_mountable'] = 1;
return parent::handleRecordCreation($data);
}

View File

@@ -1,114 +0,0 @@
<?php
namespace App\Filament\Server\Pages;
use App\Enums\SubuserPermission;
use App\Enums\TablerIcon;
use App\Facades\Activity;
use App\Models\Mount;
use App\Models\Server;
use BackedEnum;
use Filament\Facades\Filament;
use Filament\Forms\Components\CheckboxList;
use Filament\Notifications\Notification;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
class Mounts extends ServerFormPage
{
protected static string|BackedEnum|null $navigationIcon = TablerIcon::LayersLinked;
protected static ?int $navigationSort = 11;
public static function canAccess(): bool
{
return parent::canAccess() && user()?->can(SubuserPermission::MountRead, Filament::getTenant());
}
protected function authorizeAccess(): void
{
abort_unless(user()?->can(SubuserPermission::MountRead, Filament::getTenant()), 403);
}
protected function fillForm(): void
{
$this->form->fill([
'mounts' => $this->getRecord()->mounts->pluck('id')->toArray(),
]);
}
public function form(Schema $schema): Schema
{
/** @var Server $server */
$server = $this->getRecord();
$allowedMounts = Mount::query()
->where('user_mountable', true)
->where(function ($query) use ($server) {
$query->whereDoesntHave('nodes')
->orWhereHas('nodes', fn ($q) => $q->where('nodes.id', $server->node_id));
})
->where(function ($query) use ($server) {
$query->whereDoesntHave('eggs')
->orWhereHas('eggs', fn ($q) => $q->where('eggs.id', $server->egg_id));
})
->get();
return parent::form($schema)
->components([
Section::make(trans('server/mount.description'))
->schema([
CheckboxList::make('mounts')
->hiddenLabel()
->relationship('mounts')
->options(fn () => $allowedMounts->mapWithKeys(fn (Mount $mount) => [$mount->id => $mount->name]))
->descriptions(fn () => $allowedMounts->mapWithKeys(fn (Mount $mount) => [$mount->id => "$mount->source -> $mount->target"]))
->helperText(fn () => $allowedMounts->isEmpty() ? trans('server/mount.no_mounts') : null)
->disabled(fn (Server $server) => !user()?->can(SubuserPermission::MountUpdate, $server))
->bulkToggleable()
->live()
->afterStateUpdated(function ($state) {
$this->save();
})
->columnSpanFull(),
]),
]);
}
public function save(): void
{
/** @var Server $server */
$server = $this->getRecord();
abort_unless(user()?->can(SubuserPermission::MountUpdate, $server), 403);
try {
$this->form->getState();
$this->form->saveRelationships();
Activity::event('server:mount.update')
->log();
Notification::make()
->title(trans('server/mount.notification_updated'))
->success()
->send();
} catch (\Exception $exception) {
Notification::make()
->title(trans('server/mount.notification_failed'))
->body($exception->getMessage())
->danger()
->send();
}
}
public function getTitle(): string
{
return trans('server/mount.title');
}
public static function getNavigationLabel(): string
{
return trans('server/mount.title');
}
}

View File

@@ -39,13 +39,17 @@ class CreateSchedule extends CreateRecord
$data['server_id'] = $server->id;
}
$timezone = $data['timezone'] ?? user()->timezone ?? 'UTC';
unset($data['timezone']);
if (!isset($data['next_run_at'])) {
$data['next_run_at'] = ScheduleResource::getNextRun(
$data['cron_minute'],
$data['cron_hour'],
$data['cron_day_of_month'],
$data['cron_month'],
$data['cron_day_of_week']
$data['cron_day_of_week'],
$timezone
);
}

View File

@@ -37,12 +37,16 @@ class EditSchedule extends EditRecord
protected function mutateFormDataBeforeSave(array $data): array
{
$timezone = $data['timezone'] ?? user()->timezone ?? 'UTC';
unset($data['timezone']);
$data['next_run_at'] = ScheduleResource::getNextRun(
$data['cron_minute'],
$data['cron_hour'],
$data['cron_day_of_month'],
$data['cron_month'],
$data['cron_day_of_week']
$data['cron_day_of_week'],
$timezone
);
return $data;

View File

@@ -100,13 +100,15 @@ class ScheduleResource extends Resource
Section::make('Cron')
->label(trans('server/schedule.cron'))
->description(function (Get $get) {
$timezone = $get('timezone') ?? user()->timezone ?? 'UTC';
try {
$nextRun = Utilities::getScheduleNextRunDate($get('cron_minute'), $get('cron_hour'), $get('cron_day_of_month'), $get('cron_month'), $get('cron_day_of_week'))->timezone(user()->timezone ?? 'UTC');
$nextRun = Utilities::getScheduleNextRunDate($get('cron_minute'), $get('cron_hour'), $get('cron_day_of_month'), $get('cron_month'), $get('cron_day_of_week'), $timezone)->timezone($timezone);
} catch (Exception) {
$nextRun = trans('server/schedule.invalid');
}
return new HtmlString(trans('server/schedule.cron_body') . '<br>' . trans('server/schedule.cron_timezone', ['timezone' => user()->timezone ?? 'UTC', 'next_run' => $nextRun]));
return new HtmlString(trans('server/schedule.cron_body', ['timezone' => $timezone]) . '<br>' . trans('server/schedule.cron_timezone', ['timezone' => $timezone, 'next_run' => $nextRun]));
})
->schema([
Actions::make([
@@ -295,6 +297,13 @@ class ScheduleResource extends Resource
'default' => 4,
'lg' => 5,
]),
Select::make('timezone')
->label(trans('server/schedule.timezone'))
->options(fn () => array_combine(timezone_identifiers_list(), timezone_identifiers_list()))
->default(user()->timezone ?? 'UTC')
->searchable()
->live()
->hiddenOn('view'),
])
->columnSpanFull(),
]);
@@ -379,10 +388,10 @@ class ScheduleResource extends Resource
];
}
public static function getNextRun(string $minute, string $hour, string $dayOfMonth, string $month, string $dayOfWeek): Carbon
public static function getNextRun(string $minute, string $hour, string $dayOfMonth, string $month, string $dayOfWeek, string $timezone = 'UTC'): Carbon
{
try {
return Utilities::getScheduleNextRunDate($minute, $hour, $dayOfMonth, $month, $dayOfWeek);
return Utilities::getScheduleNextRunDate($minute, $hour, $dayOfMonth, $month, $dayOfWeek, $timezone);
} catch (Exception) {
Notification::make()
->title(trans('server/schedule.notification_invalid_cron'))

View File

@@ -38,11 +38,11 @@ class Utilities
*
* @throws Exception
*/
public static function getScheduleNextRunDate(string $minute, string $hour, string $dayOfMonth, string $month, string $dayOfWeek): Carbon
public static function getScheduleNextRunDate(string $minute, string $hour, string $dayOfMonth, string $month, string $dayOfWeek, string $timezone = 'UTC'): Carbon
{
return Carbon::instance((new CronExpression(
sprintf('%s %s %s %s %s', $minute, $hour, $dayOfMonth, $month, $dayOfWeek)
))->getNextRunDate(now('UTC')));
))->getNextRunDate(now($timezone)))->setTimezone('UTC');
}
public static function checked(string $name, mixed $default): string

View File

@@ -75,7 +75,7 @@ class ScheduleController extends ClientApiController
'cron_minute' => $request->input('minute'),
'is_active' => (bool) $request->input('is_active'),
'only_when_online' => (bool) $request->input('only_when_online'),
'next_run_at' => $this->getNextRunAt($request),
'next_run_at' => $this->getNextRunAt($request, $request->user()->timezone ?? 'UTC'),
]);
Activity::event('server:schedule.create')
@@ -131,7 +131,7 @@ class ScheduleController extends ClientApiController
'cron_minute' => $request->input('minute'),
'is_active' => $active,
'only_when_online' => (bool) $request->input('only_when_online'),
'next_run_at' => $this->getNextRunAt($request),
'next_run_at' => $this->getNextRunAt($request, $request->user()->timezone ?? 'UTC'),
];
// Toggle the processing state of the scheduled task when it is enabled or disabled so that an
@@ -188,7 +188,7 @@ class ScheduleController extends ClientApiController
*
* @throws DisplayException
*/
protected function getNextRunAt(Request $request): Carbon
protected function getNextRunAt(Request $request, string $timezone = 'UTC'): Carbon
{
try {
return Utilities::getScheduleNextRunDate(
@@ -196,7 +196,8 @@ class ScheduleController extends ClientApiController
$request->input('hour'),
$request->input('day_of_month'),
$request->input('month'),
$request->input('day_of_week')
$request->input('day_of_week'),
$timezone
);
} catch (Exception) {
throw new DisplayException('The cron data provided does not evaluate to a valid expression.');

View File

@@ -17,13 +17,9 @@ return [
'no_mounts' => 'No Mounts',
'eggs' => 'Eggs',
'nodes' => 'Nodes',
'user_mountable' => 'User Mountable?',
'user_mountable_help' => 'Allow users to toggle this mount on or off for their servers.',
'toggles' => [
'writable' => 'Writable',
'read_only' => 'Read Only',
'user_mountable' => 'User Mountable',
'not_user_mountable' => 'Admin Only',
],
'table' => [
'name' => 'Name',

View File

@@ -1,9 +0,0 @@
<?php
return [
'title' => 'Mounts',
'description' => 'Manage the mounts attached to your server.',
'no_mounts' => 'There are no user-mountable mounts available for this server.',
'notification_updated' => 'Mounts updated successfully.',
'notification_failed' => 'Failed to update mounts.',
];

View File

@@ -29,7 +29,8 @@ return [
'enabled' => 'Enable Schedule?',
'enabled_hint' => 'This schedule will be executed automatically if enabled.',
'cron_body' => 'Please keep in mind that the cron inputs below always assume UTC.',
'timezone' => 'Timezone',
'cron_body' => 'The cron inputs below use your timezone (:timezone).',
'cron_timezone' => 'Next run in your timezone (:timezone): <b> :next_run </b>',
'invalid' => 'Invalid',

View File

@@ -69,8 +69,5 @@ return [
'backup_delete' => 'Allows a user to remove backups from the system.',
'backup_download' => 'Allows a user to download a backup for the server. Danger: this allows a user to access all files for the server in the backup.',
'backup_restore' => 'Allows a user to restore a backup for the server. Danger: this allows the user to delete all of the server files in the process.',
'mount_desc' => 'Permissions that control a user\'s ability to manage mounts for this server.',
'mount_read' => 'Allows a user to view the mounts page and see available mounts.',
'mount_update' => 'Allows a user to toggle mounts on or off for the server.',
],
];