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
16 changed files with 40 additions and 329 deletions

View File

@@ -78,15 +78,11 @@ class Console extends Page
$feature = data_get($data, 'key');
$feature = $this->featureService->get($feature);
if (!$feature) {
if (!$feature || $this->getMountedAction()) {
return;
}
if ($this->getMountedAction()) {
$this->replaceMountedAction($feature->getId());
} else {
$this->mountAction($feature->getId());
}
$this->mountAction($feature->getId());
sleep(2); // TODO find a better way
}
public function getWidgetData(): array

View File

@@ -209,18 +209,16 @@ class ListFiles extends ListRecords
->required()
->live(),
TextEntry::make('new_location')
->state(fn (Get $get, File $file) => resolve_path(join_paths($this->path, str_ends_with($get('location') ?? '/', '/') ? join_paths($get('location') ?? '/', $file->name) : $get('location') ?? '/'))),
->state(fn (Get $get, File $file) => resolve_path(join_paths($this->path, $get('location') ?? '/', $file->name))),
])
->action(function ($data, File $file) {
$location = $data['location'];
$endsWithSlash = str_ends_with($location, '/');
$to = $endsWithSlash ? join_paths($location, $file->name) : $location;
$files = [['to' => $to, 'from' => $file->name]];
$files = [['to' => join_paths($location, $file->name), 'from' => $file->name]];
$this->getDaemonFileRepository()->renameFiles($this->path, $files);
$oldLocation = join_paths($this->path, $file->name);
$newLocation = resolve_path(join_paths($this->path, $to));
$newLocation = resolve_path(join_paths($this->path, $location, $file->name));
Activity::event('server:file.rename')
->property('directory', $this->path)

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

@@ -74,7 +74,7 @@ class OAuthController extends Controller
$email = $oauthUser->getEmail();
if (!$email) {
return $this->errorRedirect('No email was linked to your account on the OAuth provider.');
return $this->errorRedirect();
}
$user = User::whereEmail($email)->first();

View File

@@ -1,72 +0,0 @@
<?php
namespace App\Listeners;
use App\Facades\Activity;
use Filament\Facades\Filament;
use Filament\Resources\Events\RecordCreated;
use Filament\Resources\Events\RecordUpdated;
use Illuminate\Support\Str;
class AdminActivityListener
{
protected const REDACTED_FIELDS = [
'password',
'password_confirmation',
'current_password',
'token',
'secret',
'api_key',
'daemon_token',
'_token',
];
public function handle(RecordCreated|RecordUpdated $event): void
{
if (Filament::getCurrentPanel()?->getId() !== 'admin') {
return;
}
$record = $event->getRecord();
$page = $event->getPage();
$data = $event->getData();
$resourceClass = $page::getResource();
$modelClass = $resourceClass::getModel();
$slug = Str::kebab(class_basename($modelClass));
$action = $event instanceof RecordCreated ? 'create' : 'update';
$properties = $this->redactSensitiveFields($data);
Activity::event("admin:$slug.$action")
->subject($record)
->property($properties)
->log();
}
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
protected function redactSensitiveFields(array $data): array
{
$redacted = [];
foreach ($data as $key => $value) {
if (in_array($key, self::REDACTED_FIELDS, true)) {
$redacted[$key] = '[REDACTED]';
continue;
}
if (is_array($value)) {
$redacted[$key] = $this->redactSensitiveFields($value);
} else {
$redacted[$key] = $value;
}
}
return $redacted;
}
}

View File

@@ -358,7 +358,7 @@ class Node extends Model implements Validatable
'disk_used' => 0,
];
return cache()->flexible("nodes.$this->id.statistics", [5, 30], function () use ($default) {
return cache()->remember("nodes.$this->id.statistics", now()->addSeconds(360), function () use ($default) {
try {
$data = Http::daemon($this)

View File

@@ -2,10 +2,7 @@
namespace App\Providers;
use App\Listeners\AdminActivityListener;
use App\Listeners\DispatchWebhooks;
use Filament\Resources\Events\RecordCreated;
use Filament\Resources\Events\RecordUpdated;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
class EventServiceProvider extends ServiceProvider
@@ -18,7 +15,5 @@ class EventServiceProvider extends ServiceProvider
'eloquent.created*' => [DispatchWebhooks::class],
'eloquent.deleted*' => [DispatchWebhooks::class],
'eloquent.updated*' => [DispatchWebhooks::class],
RecordCreated::class => [AdminActivityListener::class],
RecordUpdated::class => [AdminActivityListener::class],
];
}

View File

@@ -4,14 +4,12 @@ namespace App\Providers\Filament;
use App\Enums\CustomizationKey;
use App\Enums\TablerIcon;
use App\Facades\Activity;
use Filament\Actions\Action;
use Filament\Actions\CreateAction;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Actions\View\ActionsIconAlias;
use Filament\Actions\ViewAction;
use Filament\Facades\Filament;
use Filament\Forms\Components\Field;
use Filament\Forms\Components\KeyValue;
use Filament\Forms\Components\Repeater;
@@ -31,10 +29,8 @@ use Filament\Support\View\SupportIconAlias;
use Filament\Tables\View\TablesIconAlias;
use Filament\View\PanelsIconAlias;
use Filament\View\PanelsRenderHook;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;
use Livewire\Component;
use Livewire\Livewire;
@@ -136,18 +132,6 @@ class FilamentServiceProvider extends ServiceProvider
$action->iconButton();
$action->iconSize(IconSize::ExtraLarge);
}
$action->before(function (Model $record) {
if (Filament::getCurrentPanel()?->getId() !== 'admin') {
return;
}
$slug = Str::kebab(class_basename($record));
Activity::event("admin:$slug.delete")
->subject($record)
->log();
});
});
CreateAction::configureUsing(function (CreateAction $action) {

View File

@@ -122,51 +122,4 @@ return [
],
'crashed' => 'Server crashed',
],
'admin' => [
'user' => [
'create' => 'Created user <b>:username</b>',
'update' => 'Updated user <b>:username</b>',
'delete' => 'Deleted user <b>:username</b>',
],
'server' => [
'create' => 'Created server <b>:name</b>',
'update' => 'Updated server <b>:name</b>',
'delete' => 'Deleted server <b>:name</b>',
],
'node' => [
'create' => 'Created node <b>:name</b>',
'update' => 'Updated node <b>:name</b>',
'delete' => 'Deleted node <b>:name</b>',
],
'egg' => [
'create' => 'Created egg <b>:name</b>',
'update' => 'Updated egg <b>:name</b>',
'delete' => 'Deleted egg <b>:name</b>',
],
'role' => [
'create' => 'Created role <b>:name</b>',
'update' => 'Updated role <b>:name</b>',
'delete' => 'Deleted role <b>:name</b>',
],
'database-host' => [
'create' => 'Created database host <b>:name</b>',
'update' => 'Updated database host <b>:name</b>',
'delete' => 'Deleted database host <b>:name</b>',
],
'mount' => [
'create' => 'Created mount <b>:name</b>',
'update' => 'Updated mount <b>:name</b>',
'delete' => 'Deleted mount <b>:name</b>',
],
'webhook-configuration' => [
'create' => 'Created webhook <b>:description</b>',
'update' => 'Updated webhook <b>:description</b>',
'delete' => 'Deleted webhook <b>:description</b>',
],
'api-key' => [
'create' => 'Created API key',
'update' => 'Updated API key',
'delete' => 'Deleted API key',
],
],
];

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

@@ -5,10 +5,6 @@
RewriteEngine On
# Handle X-Forwarded-Proto Header
RewriteCond %{HTTP:X-Forwarded-Proto} =https [NC]
RewriteRule .* - [E=HTTPS:on]
# Handle Authorization Header
RewriteCond %{HTTP:Authorization} .
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]

View File

@@ -1,158 +0,0 @@
<?php
use App\Events\ActivityLogged;
use App\Filament\Admin\Resources\Eggs\Pages\CreateEgg;
use App\Filament\Admin\Resources\Eggs\Pages\EditEgg;
use App\Filament\Admin\Resources\Nodes\Pages\CreateNode;
use App\Filament\Admin\Resources\Nodes\Pages\EditNode;
use App\Listeners\AdminActivityListener;
use App\Models\Egg;
use App\Models\Node;
use App\Models\Role;
use Filament\Facades\Filament;
use Filament\Resources\Events\RecordCreated;
use Filament\Resources\Events\RecordUpdated;
use Illuminate\Support\Facades\Event;
function pageInstance(string $class): object
{
return (new ReflectionClass($class))->newInstanceWithoutConstructor();
}
function createEvent(object $record, array $data, object $page): RecordCreated
{
return new RecordCreated($record, $data, $page);
}
function updateEvent(object $record, array $data, object $page): RecordUpdated
{
return new RecordUpdated($record, $data, $page);
}
beforeEach(function () {
[$this->admin] = generateTestAccount([]);
$this->admin = $this->admin->syncRoles(Role::getRootAdmin());
$this->actingAs($this->admin);
Filament::setCurrentPanel('admin');
});
it('logs create activity for an egg', function () {
$egg = Egg::first();
$listener = new AdminActivityListener();
$listener->handle(createEvent($egg, ['name' => 'Test Egg'], pageInstance(CreateEgg::class)));
$this->assertActivityLogged('admin:egg.create');
});
it('logs update activity for an egg', function () {
$egg = Egg::first();
$listener = new AdminActivityListener();
$listener->handle(updateEvent($egg, ['name' => 'Updated Egg'], pageInstance(EditEgg::class)));
$this->assertActivityLogged('admin:egg.update');
});
it('logs create activity for a node', function () {
$node = Node::first();
$listener = new AdminActivityListener();
$listener->handle(createEvent($node, ['name' => 'Test Node'], pageInstance(CreateNode::class)));
$this->assertActivityLogged('admin:node.create');
});
it('logs update activity for a node', function () {
$node = Node::first();
$listener = new AdminActivityListener();
$listener->handle(updateEvent($node, ['name' => 'Updated Node'], pageInstance(EditNode::class)));
$this->assertActivityLogged('admin:node.update');
});
it('does not log activity for non-admin panels', function () {
Filament::setCurrentPanel('app');
$egg = Egg::first();
$listener = new AdminActivityListener();
$listener->handle(createEvent($egg, ['name' => 'Test'], pageInstance(CreateEgg::class)));
Event::assertNotDispatched(ActivityLogged::class);
});
it('sets the record as the activity subject', function () {
$egg = Egg::first();
$listener = new AdminActivityListener();
$listener->handle(createEvent($egg, ['name' => 'Test'], pageInstance(CreateEgg::class)));
$this->assertActivityFor('admin:egg.create', $this->admin, $egg);
});
it('redacts sensitive fields from activity properties', function () {
$egg = Egg::first();
$data = [
'name' => 'Visible',
'password' => 'should-be-redacted',
'password_confirmation' => 'should-be-redacted',
'token' => 'should-be-redacted',
'secret' => 'should-be-redacted',
'api_key' => 'should-be-redacted',
];
$listener = new AdminActivityListener();
$listener->handle(updateEvent($egg, $data, pageInstance(EditEgg::class)));
Event::assertDispatched(ActivityLogged::class, function (ActivityLogged $event) {
$properties = $event->model->properties;
expect($properties)->toHaveKey('name', 'Visible')
->toHaveKey('password', '[REDACTED]')
->toHaveKey('password_confirmation', '[REDACTED]')
->toHaveKey('token', '[REDACTED]')
->toHaveKey('secret', '[REDACTED]')
->toHaveKey('api_key', '[REDACTED]');
return true;
});
});
it('redacts sensitive fields in nested arrays', function () {
$egg = Egg::first();
$data = [
'name' => 'Visible',
'nested' => [
'safe' => 'value',
'password' => 'should-be-redacted',
'token' => 'should-be-redacted',
],
];
$listener = new AdminActivityListener();
$listener->handle(updateEvent($egg, $data, pageInstance(EditEgg::class)));
Event::assertDispatched(ActivityLogged::class, function (ActivityLogged $event) {
$properties = $event->model->properties;
expect($properties['nested'])->toHaveKey('safe', 'value')
->toHaveKey('password', '[REDACTED]')
->toHaveKey('token', '[REDACTED]');
return true;
});
});
it('generates kebab-case event names from model class names', function () {
$node = Node::first();
$listener = new AdminActivityListener();
$listener->handle(createEvent($node, ['name' => 'Test'], pageInstance(CreateNode::class)));
$this->assertActivityLogged('admin:node.create');
});