Compare commits

...

4 Commits

Author SHA1 Message Date
notCharles
90c50da83b wip 2026-05-12 10:06:33 -04:00
notCharles
396d71c4a5 Updates + stan 2026-05-11 06:38:53 -04:00
notCharles
4e56b857d1 pint 2026-05-09 12:49:30 -04:00
notCharles
e22e07aae0 Add CURD 2026-05-09 12:45:46 -04:00
15 changed files with 514 additions and 4 deletions

View File

@@ -3,9 +3,10 @@
namespace App\Eloquent;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
/**
* @template TModel of \Illuminate\Database\Eloquent\Model
* @template TModel of Model
*
* @extends Builder<TModel>
*/

View File

@@ -6,6 +6,7 @@ use App\Enums\TablerIcon;
use App\Extensions\Avatar\AvatarService;
use App\Extensions\Captcha\CaptchaService;
use App\Extensions\OAuth\OAuthService;
use App\Facades\Activity;
use App\Models\Backup;
use App\Notifications\MailTested;
use App\Traits\EnvironmentWriterTrait;
@@ -46,6 +47,7 @@ use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Notification as MailNotification;
use Illuminate\Support\Str;
use Livewire\Attributes\Locked;
/**
* @property Schema $form
@@ -75,9 +77,14 @@ class Settings extends Page implements HasSchemas
/** @var array<mixed>|null */
public ?array $data = [];
/** @var array<mixed> Snapshot of normalized form state captured on mount, used to detect which settings changed before saving. */
#[Locked]
public array $initialSettings = [];
public function mount(): void
{
$this->form->fill();
$this->initialSettings = $this->sanitizeSettingsData($this->form->getState());
}
public function boot(OAuthService $oauthService, AvatarService $avatarService, CaptchaService $captchaService, IconFactory $iconFactory): void
@@ -865,8 +872,8 @@ class Settings extends Page implements HasSchemas
public function save(): void
{
try {
$data = $this->form->getState();
unset($data['ConsoleFonts']);
$data = $this->sanitizeSettingsData($this->form->getState());
$changedSettings = $this->getChangedSettings($this->initialSettings, $data);
$data = array_map(function ($value) {
// Convert bools to a string, so they are correctly written to the .env file
@@ -886,6 +893,14 @@ class Settings extends Page implements HasSchemas
Artisan::call('queue:restart');
if (!empty($changedSettings)) {
Activity::event('admin:settings.update')
->actor(user())
->property('count', count($changedSettings))
->property('settings', implode(', ', $changedSettings))
->log();
}
$this->redirect($this->getUrl());
Notification::make()
@@ -901,6 +916,63 @@ class Settings extends Page implements HasSchemas
}
}
/**
* @param array<mixed> $data
* @return array<mixed>
*/
private function sanitizeSettingsData(array $data): array
{
unset($data['ConsoleFonts']);
return $data;
}
/**
* @param array<mixed> $before
* @param array<mixed> $after
* @return string[]
*/
private function getChangedSettings(array $before, array $after): array
{
$changed = [];
foreach (array_unique(array_merge(array_keys($before), array_keys($after))) as $key) {
$old = $before[$key] ?? null;
$new = $after[$key] ?? null;
if ($this->normalizeSettingValue($old) !== $this->normalizeSettingValue($new)) {
$changed[] = $key;
}
}
sort($changed);
return $changed;
}
/**
* Converts a setting value to a normalized string for diffing.
* Handles enums, booleans, arrays (JSON-encoded), and scalar values.
*/
private function normalizeSettingValue(mixed $value): string
{
if ($value instanceof BackedEnum) {
return (string) $value->value;
}
if (is_bool($value)) {
return $value ? 'true' : 'false';
}
if (is_array($value)) {
$json = json_encode($value);
return $json === false ? '' : $json;
}
return (string) ($value ?? '');
}
/** @return array<Action|ActionGroup> */
protected function getDefaultHeaderActions(): array
{

View File

@@ -0,0 +1,85 @@
<?php
namespace App\Filament\Admin\Resources\ActivityLogs;
use App\Enums\TablerIcon;
use App\Filament\Admin\Resources\ActivityLogs\Pages\ListActivityLogs;
use App\Models\ActivityLog;
use App\Traits\Filament\CanCustomizePages;
use BackedEnum;
use Filament\Resources\Pages\PageRegistration;
use Filament\Resources\Resource;
use Illuminate\Database\Eloquent\Model;
class ActivityLogResource extends Resource
{
use CanCustomizePages;
protected static ?string $model = ActivityLog::class;
protected static string|BackedEnum|null $navigationIcon = TablerIcon::ShieldSearch;
protected static ?int $navigationSort = 10;
public static function getNavigationLabel(): string
{
return trans('admin/log.navigation.admin_audit_log');
}
public static function getModelLabel(): string
{
return trans('admin/log.model_label');
}
public static function getPluralModelLabel(): string
{
return trans('admin/log.model_label_plural');
}
public static function getNavigationGroup(): ?string
{
return trans('admin/dashboard.advanced');
}
public static function canCreate(): bool
{
return false;
}
public static function canEdit(Model $record): bool
{
return false;
}
public static function canDelete(Model $record): bool
{
return false;
}
public static function canView(Model $record): bool
{
return false;
}
public static function canViewAny(): bool
{
$user = user();
if (!$user) {
return false;
}
if ($user->isRootAdmin()) {
return true;
}
return $user->can('view adminAuditLog') || $user->can('view panelLog');
}
/** @return array<string, PageRegistration> */
public static function getDefaultPages(): array
{
return [
'index' => ListActivityLogs::route('/'),
];
}
}

View File

@@ -0,0 +1,84 @@
<?php
namespace App\Filament\Admin\Resources\ActivityLogs\Pages;
use App\Enums\TablerIcon;
use App\Filament\Admin\Resources\ActivityLogs\ActivityLogResource;
use App\Filament\Components\Tables\Columns\DateTimeColumn;
use App\Models\ActivityLog;
use App\Models\User;
use Filament\Resources\Pages\ListRecords;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\HtmlString;
class ListActivityLogs extends ListRecords
{
protected static string $resource = ActivityLogResource::class;
public function getHeading(): string
{
return trans('admin/log.navigation.admin_audit_log');
}
public function table(Table $table): Table
{
return $table
->query(
ActivityLog::query()
->where('event', 'like', 'admin:%')
->with(['actor'])
->latest('timestamp')
)
->columns([
TextColumn::make('actor.username')
->label(trans('admin/log.table.actor'))
->state(function (ActivityLog $log): string {
if ($log->actor instanceof User) {
return $log->actor->username;
}
return trans('admin/log.table.system');
})
->searchable(query: function (Builder $query, string $search): Builder {
$escapedSearch = addcslashes($search, '%_\\\\');
return $query->whereHas('actor', fn (Builder $q) => $q->where('username', 'like', "%{$escapedSearch}%"));
}),
TextColumn::make('event')
->label(trans('admin/log.table.event'))
->badge()
->searchable(),
TextColumn::make('description')
->label(trans('admin/log.table.description'))
->html()
->state(fn (ActivityLog $log) => new HtmlString($log->getLabel()))
->grow(),
TextColumn::make('ip')
->label(trans('admin/log.table.ip'))
->visibleFrom('lg')
->visible(fn () => user()?->can('seeIps activityLog')),
DateTimeColumn::make('timestamp')
->label(trans('admin/log.table.timestamp'))
->sortable()
->since(),
])
->defaultSort('timestamp', 'desc')
->searchable()
->filters([
SelectFilter::make('event')
->label(trans('admin/log.table.event'))
->options(fn () => ActivityLog::query()
->where('event', 'like', 'admin:%')
->distinct()
->pluck('event')
->mapWithKeys(fn (string $event) => [$event => $event])
->toArray())
->searchable(),
])
->emptyStateHeading(trans('admin/log.empty_audit_log'))
->emptyStateIcon(TablerIcon::ShieldSearch);
}
}

View File

@@ -489,6 +489,34 @@ class UserResource extends Resource
->state(fn (ActivityLog $log) => new HtmlString($log->htmlable())),
]),
]),
Tab::make('admin_log')
->visible(function (?User $user): bool {
if (!$user) {
return false;
}
$viewer = user();
return $viewer?->isRootAdmin() || $viewer?->can('view adminAuditLog');
})
->disabledOn('create')
->label(trans('admin/user.tabs.admin_log'))
->icon(TablerIcon::ShieldSearch)
->schema([
Repeater::make('adminLog')
->hiddenLabel()
->inlineLabel(false)
->deletable(false)
->addable(false)
->relationship('adminLog', function (Builder $query) {
$query->orderBy('timestamp', 'desc');
})
->schema([
TextEntry::make('log')
->hiddenLabel()
->state(fn (ActivityLog $log) => new HtmlString($log->htmlable())),
]),
]),
];
}

View File

@@ -228,7 +228,7 @@ class ActivityLog extends Model implements HasIcon, HasLabel
});
$keys = $properties->keys()->filter(fn ($key) => Str::endsWith($key, '_count'))->values();
if ($keys->containsOneItem()) {
if ($keys->hasSole()) {
$properties = $properties->merge(['count' => $properties->get($keys[0])])->except([$keys[0]]);
}

View File

@@ -6,6 +6,7 @@ use App\Contracts\Validatable;
use App\Exceptions\Service\Egg\HasChildrenException;
use App\Exceptions\Service\HasActiveServersException;
use App\Models\Traits\HasIcon;
use App\Traits\HasAdminActivityLogging;
use App\Traits\HasValidation;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -93,6 +94,7 @@ use Illuminate\Support\Str;
*/
class Egg extends Model implements Validatable
{
use HasAdminActivityLogging;
use HasFactory;
use HasIcon;
use HasValidation;

View File

@@ -5,6 +5,7 @@ namespace App\Models;
use App\Contracts\Validatable;
use App\Exceptions\Service\HasActiveServersException;
use App\Repositories\Daemon\DaemonSystemRepository;
use App\Traits\HasAdminActivityLogging;
use App\Traits\HasValidation;
use Exception;
use Illuminate\Database\Eloquent\Collection;
@@ -95,6 +96,7 @@ use Symfony\Component\Yaml\Yaml;
*/
class Node extends Model implements Validatable
{
use HasAdminActivityLogging;
use HasFactory;
use HasValidation;
use Notifiable;

View File

@@ -5,6 +5,7 @@ namespace App\Models;
use App\Enums\RolePermissionModels;
use App\Enums\RolePermissionPrefixes;
use App\Enums\TablerIcon;
use App\Traits\HasAdminActivityLogging;
use BackedEnum;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -41,6 +42,7 @@ use Spatie\Permission\Models\Role as BaseRole;
*/
class Role extends BaseRole
{
use HasAdminActivityLogging;
use HasFactory;
public const RESOURCE_NAME = 'role';
@@ -70,6 +72,9 @@ class Role extends BaseRole
'panelLog' => [
'view',
],
'adminAuditLog' => [
'view',
],
'plugin' => [
'viewList',
'create',
@@ -82,6 +87,7 @@ class Role extends BaseRole
'health' => TablerIcon::Heart,
'activityLog' => TablerIcon::Stack,
'panelLog' => TablerIcon::FileInfo,
'adminAuditLog' => TablerIcon::ShieldSearch,
];
/** @var array<string, array<string>> */

View File

@@ -10,6 +10,7 @@ use App\Exceptions\Http\Server\ServerStateConflictException;
use App\Models\Traits\HasIcon;
use App\Repositories\Daemon\DaemonServerRepository;
use App\Services\Subusers\SubuserDeletionService;
use App\Traits\HasAdminActivityLogging;
use App\Traits\HasValidation;
use Carbon\CarbonInterface;
use Exception;
@@ -129,6 +130,7 @@ use Psr\Http\Message\ResponseInterface;
*/
class Server extends Model implements HasAvatar, Validatable
{
use HasAdminActivityLogging;
use HasFactory;
use HasIcon;
use HasValidation;

View File

@@ -9,6 +9,7 @@ use App\Events\User\Deleting;
use App\Exceptions\DisplayException;
use App\Extensions\Avatar\AvatarService;
use App\Models\Traits\HasAccessTokens;
use App\Traits\HasAdminActivityLogging;
use App\Traits\HasValidation;
use BackedEnum;
use DateTimeZone;
@@ -119,6 +120,7 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
use Authorizable { can as protected canned; }
use CanResetPassword;
use HasAccessTokens;
use HasAdminActivityLogging;
use HasFactory;
use HasRoles;
use HasValidation { getRules as getValidationRules; }
@@ -289,6 +291,18 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
return $this->morphToMany(ActivityLog::class, 'subject', 'activity_log_subjects');
}
/**
* Returns activity logs where this user is the actor and the event is an admin action.
*
* @return HasMany<ActivityLog, $this>
*/
public function adminLog(): HasMany
{
return $this->hasMany(ActivityLog::class, 'actor_id')
->where('actor_type', 'user')
->where('event', 'like', 'admin:%');
}
/**
* Returns all the servers that a user can access.
* Either because they are an admin or because they are the owner/ a subuser of the server.
@@ -526,4 +540,9 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
{
$this->update(['mfa_email_enabled' => $condition]);
}
public function getAdminActivityName(): string
{
return $this->username;
}
}

View File

@@ -0,0 +1,141 @@
<?php
namespace App\Observers;
use App\Facades\Activity;
use Filament\Facades\Filament;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
class AdminActivityObserver
{
/**
* Tracks events already logged in this request to avoid duplicates.
*
* @var array<string, bool>
*/
private static array $logged = [];
/**
* Determines if the current request is being handled by the admin panel.
*/
private function isAdminPanel(): bool
{
return Filament::getCurrentPanel()?->getId() === 'admin';
}
/**
* Logs an admin activity event for the given model, deduplicating within a single request.
*
* @param string $event The event name (e.g. 'admin:user.create')
* @param Model $model The model being acted upon
* @param array<string, mixed> $properties Additional properties to log
*
* @throws \Throwable
*/
private function log(string $event, Model $model, array $properties = []): void
{
if (!$this->isAdminPanel()) {
return;
}
$actor = user();
if (!$actor) {
return;
}
// Deduplicate identical events for the same record within a single request.
$key = $event . ':' . $model::class . ':' . $model->getKey();
if (isset(self::$logged[$key])) {
return;
}
self::$logged[$key] = true;
$log = Activity::event($event)
->actor($actor)
->subject($model);
foreach ($properties as $propKey => $propValue) {
$log->property($propKey, $propValue);
}
$log->log();
}
public function created(Model $model): void
{
$this->log($this->eventFor($model, 'create'), $model, [
'name' => $this->displayNameFor($model),
]);
}
public function updated(Model $model): void
{
$changedFields = $this->changedFieldsFor($model);
$name = $this->displayNameFor($model);
$this->log($this->eventFor($model, 'update'), $model, [
'name' => empty($changedFields) ? $name : sprintf('%s (%s)', $name, implode(', ', $changedFields)),
'count' => count($changedFields),
'changes' => implode(', ', $changedFields),
]);
}
public function deleted(Model $model): void
{
$this->log($this->eventFor($model, 'delete'), $model, [
'name' => $this->displayNameFor($model),
]);
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private function eventFor(Model $model, string $action): string
{
return sprintf('admin:%s.%s', $this->resourceNameFor($model), $action);
}
private function resourceNameFor(Model $model): string
{
$constant = $model::class . '::RESOURCE_NAME';
if (defined($constant)) {
$value = constant($constant);
if (is_string($value) && $value !== '') {
return $value;
}
}
return Str::of(class_basename($model))->snake()->toString();
}
private function displayNameFor(Model $model): string
{
if (method_exists($model, 'getAdminActivityName')) {
return $model->getAdminActivityName();
}
return (string) $model->getKey();
}
/**
* Returns the sorted list of attribute names that changed on the given model,
* excluding internal timestamps.
*
* @return string[]
*/
private function changedFieldsFor(Model $model): array
{
$fields = collect(array_keys($model->getChanges()))
->reject(fn (string $field) => $field === 'updated_at')
->values()
->all();
sort($fields);
return $fields;
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Traits;
use App\Observers\AdminActivityObserver;
trait HasAdminActivityLogging
{
public static function bootHasAdminActivityLogging(): void
{
$observer = new AdminActivityObserver();
static::created(fn ($model) => $observer->created($model));
static::updated(fn ($model) => $observer->updated($model));
static::deleted(fn ($model) => $observer->deleted($model));
}
public function getAdminActivityName(): string
{
if (isset($this->attributes['name'])) {
return (string) $this->attributes['name'];
}
return (string) $this->getKey();
}
}

View File

@@ -125,4 +125,34 @@ return [
],
'crashed' => 'Server crashed',
],
'admin' => [
'settings' => [
'update' => 'Updated panel settings (<b>:count</b>): <b>:settings</b>',
],
'user' => [
'create' => 'Created user <b>:name</b>',
'update' => 'Updated user <b>:name</b>',
'delete' => 'Deleted user <b>:name</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>',
],
],
];

View File

@@ -2,6 +2,9 @@
return [
'empty_table' => 'Yay! No Errors!',
'empty_audit_log' => 'No admin actions have been logged yet.',
'model_label' => 'Admin Audit Log',
'model_label_plural' => 'Admin Audit Logs',
'total_logs' => 'Total Logs',
'error' => 'Error',
'warning' => 'Warning',
@@ -10,6 +13,15 @@ return [
'debug' => 'Debug',
'navigation' => [
'panel_logs' => 'Panel Logs',
'admin_audit_log' => 'Admin Audit',
],
'table' => [
'actor' => 'Actor',
'event' => 'Event',
'description' => 'Description',
'ip' => 'IP Address',
'timestamp' => 'Time',
'system' => 'System',
],
'actions' => [
'upload_logs' => 'Upload Logs?',