Implements Spec 098: workspace-level settings slices for Backup retention, Drift severity mapping, and Operations retention/threshold. Spec - specs/098-settings-slices-v1-backup-drift-ops/spec.md What changed - Workspace Settings page: grouped Backup/Drift/Operations sections, unset-input UX w/ helper text, per-setting reset actions (confirmed) - Settings registry: adds/updates validation + normalization (incl. drift severity mapping normalization to lowercase) - Backup retention: adds workspace default + floor clamp; job clamps effective keep-last up to floor - Drift findings: optional workspace severity mapping; adds `critical` severity support + badge mapping - Operations pruning: retention computed per workspace via settings; scheduler unchanged; stuck threshold is storage-only Safety / Compliance notes - Filament v5 / Livewire v4: no Livewire v3 usage; relies on existing Filament v5 + Livewire v4 stack - Provider registration unchanged (Laravel 11+/12 uses bootstrap/providers.php) - Destructive actions: per-setting reset uses Filament actions with confirmation - Global search: not affected (no resource changes) - Assets: no new assets registered; no `filament:assets` changes Tests - vendor/bin/sail artisan test --compact tests/Feature/SettingsFoundation/WorkspaceSettingsManageTest.php \ tests/Feature/SettingsFoundation/WorkspaceSettingsViewOnlyTest.php \ tests/Feature/BackupScheduling/BackupScheduleLifecycleTest.php \ tests/Feature/Drift/DriftPolicySnapshotDriftDetectionTest.php \ tests/Feature/Scheduling/PruneOldOperationRunsScheduleTest.php \ tests/Unit/Badges/FindingBadgesTest.php Formatting - vendor/bin/sail bin pint --dirty Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #120
634 lines
21 KiB
PHP
634 lines
21 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Filament\Pages\Settings;
|
|
|
|
use App\Models\User;
|
|
use App\Models\Workspace;
|
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
|
use App\Services\Settings\SettingsResolver;
|
|
use App\Services\Settings\SettingsWriter;
|
|
use App\Support\Auth\Capabilities;
|
|
use App\Support\Settings\SettingDefinition;
|
|
use App\Support\Settings\SettingsRegistry;
|
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
|
use App\Support\Workspaces\WorkspaceContext;
|
|
use BackedEnum;
|
|
use Filament\Actions\Action;
|
|
use Filament\Forms\Components\Textarea;
|
|
use Filament\Forms\Components\TextInput;
|
|
use Filament\Notifications\Notification;
|
|
use Filament\Pages\Page;
|
|
use Filament\Schemas\Components\Section;
|
|
use Filament\Schemas\Schema;
|
|
use Illuminate\Support\Facades\Validator;
|
|
use Illuminate\Validation\ValidationException;
|
|
use UnitEnum;
|
|
|
|
class WorkspaceSettings extends Page
|
|
{
|
|
protected static bool $isDiscovered = false;
|
|
|
|
protected static bool $shouldRegisterNavigation = false;
|
|
|
|
protected static ?string $slug = 'settings/workspace';
|
|
|
|
protected static ?string $title = 'Workspace settings';
|
|
|
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-cog-6-tooth';
|
|
|
|
protected static string|UnitEnum|null $navigationGroup = 'Settings';
|
|
|
|
protected static ?int $navigationSort = 20;
|
|
|
|
/**
|
|
* @var array<string, array{domain: string, key: string, type: 'int'|'json'}>
|
|
*/
|
|
private const SETTING_FIELDS = [
|
|
'backup_retention_keep_last_default' => ['domain' => 'backup', 'key' => 'retention_keep_last_default', 'type' => 'int'],
|
|
'backup_retention_min_floor' => ['domain' => 'backup', 'key' => 'retention_min_floor', 'type' => 'int'],
|
|
'drift_severity_mapping' => ['domain' => 'drift', 'key' => 'severity_mapping', 'type' => 'json'],
|
|
'operations_operation_run_retention_days' => ['domain' => 'operations', 'key' => 'operation_run_retention_days', 'type' => 'int'],
|
|
'operations_stuck_run_threshold_minutes' => ['domain' => 'operations', 'key' => 'stuck_run_threshold_minutes', 'type' => 'int'],
|
|
];
|
|
|
|
public Workspace $workspace;
|
|
|
|
/**
|
|
* @var array<string, mixed>
|
|
*/
|
|
public array $data = [];
|
|
|
|
/**
|
|
* @var array<string, mixed>
|
|
*/
|
|
public array $workspaceOverrides = [];
|
|
|
|
/**
|
|
* @var array<string, array{source: string, value: mixed, system_default: mixed}>
|
|
*/
|
|
public array $resolvedSettings = [];
|
|
|
|
/**
|
|
* @return array<Action>
|
|
*/
|
|
protected function getHeaderActions(): array
|
|
{
|
|
return [
|
|
Action::make('save')
|
|
->label('Save')
|
|
->action(function (): void {
|
|
$this->save();
|
|
})
|
|
->disabled(fn (): bool => ! $this->currentUserCanManage())
|
|
->tooltip(fn (): ?string => $this->currentUserCanManage()
|
|
? null
|
|
: 'You do not have permission to manage workspace settings.'),
|
|
];
|
|
}
|
|
|
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|
{
|
|
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
|
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Header action saves settings; each setting includes a confirmed reset action.')
|
|
->exempt(ActionSurfaceSlot::InspectAffordance, 'Workspace settings are edited as a singleton form without a record inspect action.')
|
|
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The page does not render table rows with secondary actions.')
|
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The page has no bulk actions because it manages a single settings scope.')
|
|
->exempt(ActionSurfaceSlot::ListEmptyState, 'The settings form is always rendered and has no list empty state.');
|
|
}
|
|
|
|
public function mount(): void
|
|
{
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User) {
|
|
abort(403);
|
|
}
|
|
|
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
|
|
|
if ($workspaceId === null) {
|
|
$this->redirect('/admin/choose-workspace');
|
|
|
|
return;
|
|
}
|
|
|
|
$workspace = Workspace::query()->whereKey($workspaceId)->first();
|
|
|
|
if (! $workspace instanceof Workspace) {
|
|
abort(404);
|
|
}
|
|
|
|
$this->workspace = $workspace;
|
|
|
|
$this->authorizeWorkspaceView($user);
|
|
|
|
$this->loadFormState();
|
|
}
|
|
|
|
public function content(Schema $schema): Schema
|
|
{
|
|
return $schema
|
|
->statePath('data')
|
|
->schema([
|
|
Section::make('Backup settings')
|
|
->description('Workspace defaults used when a schedule has no explicit value.')
|
|
->schema([
|
|
TextInput::make('backup_retention_keep_last_default')
|
|
->label('Default retention keep-last')
|
|
->placeholder('Unset (uses default)')
|
|
->numeric()
|
|
->integer()
|
|
->minValue(1)
|
|
->maxValue(365)
|
|
->disabled(fn (): bool => ! $this->currentUserCanManage())
|
|
->helperText(fn (): string => $this->helperTextFor('backup_retention_keep_last_default'))
|
|
->hintAction($this->makeResetAction('backup_retention_keep_last_default')),
|
|
TextInput::make('backup_retention_min_floor')
|
|
->label('Minimum retention floor')
|
|
->placeholder('Unset (uses default)')
|
|
->numeric()
|
|
->integer()
|
|
->minValue(1)
|
|
->maxValue(365)
|
|
->disabled(fn (): bool => ! $this->currentUserCanManage())
|
|
->helperText(fn (): string => $this->helperTextFor('backup_retention_min_floor'))
|
|
->hintAction($this->makeResetAction('backup_retention_min_floor')),
|
|
]),
|
|
Section::make('Drift settings')
|
|
->description('Map finding types to severities as JSON.')
|
|
->schema([
|
|
Textarea::make('drift_severity_mapping')
|
|
->label('Severity mapping (JSON object)')
|
|
->rows(8)
|
|
->placeholder("{\n \"drift\": \"critical\"\n}")
|
|
->disabled(fn (): bool => ! $this->currentUserCanManage())
|
|
->helperText(fn (): string => $this->helperTextFor('drift_severity_mapping'))
|
|
->hintAction($this->makeResetAction('drift_severity_mapping')),
|
|
]),
|
|
Section::make('Operations settings')
|
|
->description('Workspace controls for operations retention and thresholds.')
|
|
->schema([
|
|
TextInput::make('operations_operation_run_retention_days')
|
|
->label('Operation run retention (days)')
|
|
->placeholder('Unset (uses default)')
|
|
->numeric()
|
|
->integer()
|
|
->minValue(7)
|
|
->maxValue(3650)
|
|
->disabled(fn (): bool => ! $this->currentUserCanManage())
|
|
->helperText(fn (): string => $this->helperTextFor('operations_operation_run_retention_days'))
|
|
->hintAction($this->makeResetAction('operations_operation_run_retention_days')),
|
|
TextInput::make('operations_stuck_run_threshold_minutes')
|
|
->label('Stuck run threshold (minutes)')
|
|
->placeholder('Unset (uses default)')
|
|
->numeric()
|
|
->integer()
|
|
->minValue(0)
|
|
->maxValue(10080)
|
|
->disabled(fn (): bool => ! $this->currentUserCanManage())
|
|
->helperText(fn (): string => $this->helperTextFor('operations_stuck_run_threshold_minutes'))
|
|
->hintAction($this->makeResetAction('operations_stuck_run_threshold_minutes')),
|
|
]),
|
|
]);
|
|
}
|
|
|
|
public function save(): void
|
|
{
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User) {
|
|
abort(403);
|
|
}
|
|
|
|
$this->authorizeWorkspaceManage($user);
|
|
|
|
[$normalizedValues, $validationErrors] = $this->normalizedInputValues();
|
|
|
|
if ($validationErrors !== []) {
|
|
throw ValidationException::withMessages($validationErrors);
|
|
}
|
|
|
|
$writer = app(SettingsWriter::class);
|
|
$changedSettingsCount = 0;
|
|
|
|
foreach (self::SETTING_FIELDS as $field => $setting) {
|
|
$incomingValue = $normalizedValues[$field] ?? null;
|
|
$currentOverride = $this->workspaceOverrideForField($field);
|
|
|
|
if ($incomingValue === null) {
|
|
if ($currentOverride === null) {
|
|
continue;
|
|
}
|
|
|
|
$writer->resetWorkspaceSetting(
|
|
actor: $user,
|
|
workspace: $this->workspace,
|
|
domain: $setting['domain'],
|
|
key: $setting['key'],
|
|
);
|
|
|
|
$changedSettingsCount++;
|
|
|
|
continue;
|
|
}
|
|
|
|
if ($this->valuesEqual($incomingValue, $currentOverride)) {
|
|
continue;
|
|
}
|
|
|
|
$writer->updateWorkspaceSetting(
|
|
actor: $user,
|
|
workspace: $this->workspace,
|
|
domain: $setting['domain'],
|
|
key: $setting['key'],
|
|
value: $incomingValue,
|
|
);
|
|
|
|
$changedSettingsCount++;
|
|
}
|
|
|
|
$this->loadFormState();
|
|
|
|
Notification::make()
|
|
->title($changedSettingsCount > 0 ? 'Workspace settings saved' : 'No settings changes to save')
|
|
->success()
|
|
->send();
|
|
}
|
|
|
|
public function resetSetting(string $field): void
|
|
{
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User) {
|
|
abort(403);
|
|
}
|
|
|
|
$this->authorizeWorkspaceManage($user);
|
|
|
|
$setting = $this->settingForField($field);
|
|
|
|
if ($this->workspaceOverrideForField($field) === null) {
|
|
Notification::make()
|
|
->title('Setting already uses default')
|
|
->success()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
app(SettingsWriter::class)->resetWorkspaceSetting(
|
|
actor: $user,
|
|
workspace: $this->workspace,
|
|
domain: $setting['domain'],
|
|
key: $setting['key'],
|
|
);
|
|
|
|
$this->loadFormState();
|
|
|
|
Notification::make()
|
|
->title('Workspace setting reset to default')
|
|
->success()
|
|
->send();
|
|
}
|
|
|
|
private function loadFormState(): void
|
|
{
|
|
$resolver = app(SettingsResolver::class);
|
|
|
|
$data = [];
|
|
$workspaceOverrides = [];
|
|
$resolvedSettings = [];
|
|
|
|
foreach (self::SETTING_FIELDS as $field => $setting) {
|
|
$resolved = $resolver->resolveDetailed(
|
|
workspace: $this->workspace,
|
|
domain: $setting['domain'],
|
|
key: $setting['key'],
|
|
);
|
|
|
|
$workspaceValue = $resolved['workspace_value'];
|
|
|
|
$workspaceOverrides[$field] = $workspaceValue;
|
|
$resolvedSettings[$field] = [
|
|
'source' => $resolved['source'],
|
|
'value' => $resolved['value'],
|
|
'system_default' => $resolved['system_default'],
|
|
];
|
|
|
|
$data[$field] = $workspaceValue === null
|
|
? null
|
|
: $this->formatValueForInput($field, $workspaceValue);
|
|
}
|
|
|
|
$this->data = $data;
|
|
$this->workspaceOverrides = $workspaceOverrides;
|
|
$this->resolvedSettings = $resolvedSettings;
|
|
}
|
|
|
|
private function makeResetAction(string $field): Action
|
|
{
|
|
return Action::make('reset_'.$field)
|
|
->label('Reset')
|
|
->color('danger')
|
|
->requiresConfirmation()
|
|
->action(function () use ($field): void {
|
|
$this->resetSetting($field);
|
|
})
|
|
->disabled(fn (): bool => ! $this->currentUserCanManage() || ! $this->hasWorkspaceOverride($field))
|
|
->tooltip(function () use ($field): ?string {
|
|
if (! $this->currentUserCanManage()) {
|
|
return 'You do not have permission to manage workspace settings.';
|
|
}
|
|
|
|
if (! $this->hasWorkspaceOverride($field)) {
|
|
return 'No workspace override to reset.';
|
|
}
|
|
|
|
return null;
|
|
});
|
|
}
|
|
|
|
private function helperTextFor(string $field): string
|
|
{
|
|
$resolved = $this->resolvedSettings[$field] ?? null;
|
|
|
|
if (! is_array($resolved)) {
|
|
return '';
|
|
}
|
|
|
|
$effectiveValue = $this->formatValueForDisplay($field, $resolved['value'] ?? null);
|
|
|
|
if (! $this->hasWorkspaceOverride($field)) {
|
|
return sprintf(
|
|
'Unset. Effective value: %s (%s).',
|
|
$effectiveValue,
|
|
$this->sourceLabel((string) ($resolved['source'] ?? 'system_default')),
|
|
);
|
|
}
|
|
|
|
return sprintf('Effective value: %s.', $effectiveValue);
|
|
}
|
|
|
|
/**
|
|
* @return array{0: array<string, mixed>, 1: array<string, array<int, string>>}
|
|
*/
|
|
private function normalizedInputValues(): array
|
|
{
|
|
$normalizedValues = [];
|
|
$validationErrors = [];
|
|
|
|
foreach (self::SETTING_FIELDS as $field => $_setting) {
|
|
try {
|
|
$normalizedValues[$field] = $this->normalizeFieldInput(
|
|
field: $field,
|
|
value: $this->data[$field] ?? null,
|
|
);
|
|
} catch (ValidationException $exception) {
|
|
$messages = [];
|
|
|
|
foreach ($exception->errors() as $errorMessages) {
|
|
foreach ((array) $errorMessages as $message) {
|
|
$messages[] = (string) $message;
|
|
}
|
|
}
|
|
|
|
$validationErrors['data.'.$field] = $messages !== []
|
|
? $messages
|
|
: ['Invalid value.'];
|
|
}
|
|
}
|
|
|
|
return [$normalizedValues, $validationErrors];
|
|
}
|
|
|
|
private function normalizeFieldInput(string $field, mixed $value): mixed
|
|
{
|
|
$setting = $this->settingForField($field);
|
|
|
|
if ($value === null) {
|
|
return null;
|
|
}
|
|
|
|
if (is_string($value) && trim($value) === '') {
|
|
return null;
|
|
}
|
|
|
|
if ($setting['type'] === 'json') {
|
|
$value = $this->normalizeJsonInput($value);
|
|
}
|
|
|
|
$definition = $this->settingDefinition($field);
|
|
|
|
$validator = Validator::make(
|
|
data: ['value' => $value],
|
|
rules: ['value' => $definition->rules],
|
|
);
|
|
|
|
if ($validator->fails()) {
|
|
throw ValidationException::withMessages($validator->errors()->toArray());
|
|
}
|
|
|
|
return $definition->normalize($validator->validated()['value']);
|
|
}
|
|
|
|
private function normalizeJsonInput(mixed $value): array
|
|
{
|
|
if (is_array($value)) {
|
|
return $value;
|
|
}
|
|
|
|
if (! is_string($value)) {
|
|
throw ValidationException::withMessages([
|
|
'value' => ['The value must be valid JSON.'],
|
|
]);
|
|
}
|
|
|
|
$decoded = json_decode($value, true);
|
|
|
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
|
throw ValidationException::withMessages([
|
|
'value' => ['The value must be valid JSON.'],
|
|
]);
|
|
}
|
|
|
|
if (! is_array($decoded)) {
|
|
throw ValidationException::withMessages([
|
|
'value' => ['The value must be a JSON object.'],
|
|
]);
|
|
}
|
|
|
|
return $decoded;
|
|
}
|
|
|
|
private function valuesEqual(mixed $left, mixed $right): bool
|
|
{
|
|
if ($left === null || $right === null) {
|
|
return $left === $right;
|
|
}
|
|
|
|
if (is_array($left) && is_array($right)) {
|
|
return $this->encodeCanonicalArray($left) === $this->encodeCanonicalArray($right);
|
|
}
|
|
|
|
if (is_numeric($left) && is_numeric($right)) {
|
|
return (int) $left === (int) $right;
|
|
}
|
|
|
|
return $left === $right;
|
|
}
|
|
|
|
private function encodeCanonicalArray(array $value): string
|
|
{
|
|
$encoded = json_encode($this->sortNestedArray($value));
|
|
|
|
return is_string($encoded) ? $encoded : '';
|
|
}
|
|
|
|
/**
|
|
* @param array<mixed> $value
|
|
* @return array<mixed>
|
|
*/
|
|
private function sortNestedArray(array $value): array
|
|
{
|
|
foreach ($value as $key => $item) {
|
|
if (! is_array($item)) {
|
|
continue;
|
|
}
|
|
|
|
$value[$key] = $this->sortNestedArray($item);
|
|
}
|
|
|
|
ksort($value);
|
|
|
|
return $value;
|
|
}
|
|
|
|
private function formatValueForInput(string $field, mixed $value): mixed
|
|
{
|
|
$setting = $this->settingForField($field);
|
|
|
|
if ($setting['type'] === 'json') {
|
|
if (! is_array($value)) {
|
|
return null;
|
|
}
|
|
|
|
$encoded = json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
|
|
|
return is_string($encoded) ? $encoded : null;
|
|
}
|
|
|
|
return is_numeric($value) ? (int) $value : null;
|
|
}
|
|
|
|
private function formatValueForDisplay(string $field, mixed $value): string
|
|
{
|
|
$setting = $this->settingForField($field);
|
|
|
|
if ($setting['type'] === 'json') {
|
|
if (! is_array($value) || $value === []) {
|
|
return '{}';
|
|
}
|
|
|
|
$encoded = json_encode($value, JSON_UNESCAPED_SLASHES);
|
|
|
|
return is_string($encoded) ? $encoded : '{}';
|
|
}
|
|
|
|
return is_numeric($value) ? (string) (int) $value : 'null';
|
|
}
|
|
|
|
private function sourceLabel(string $source): string
|
|
{
|
|
return match ($source) {
|
|
'workspace_override' => 'workspace override',
|
|
'tenant_override' => 'tenant override',
|
|
default => 'system default',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @return array{domain: string, key: string, type: 'int'|'json'}
|
|
*/
|
|
private function settingForField(string $field): array
|
|
{
|
|
if (! isset(self::SETTING_FIELDS[$field])) {
|
|
throw ValidationException::withMessages([
|
|
'data' => [sprintf('Unknown settings field: %s', $field)],
|
|
]);
|
|
}
|
|
|
|
return self::SETTING_FIELDS[$field];
|
|
}
|
|
|
|
private function settingDefinition(string $field): SettingDefinition
|
|
{
|
|
$setting = $this->settingForField($field);
|
|
|
|
return app(SettingsRegistry::class)->require($setting['domain'], $setting['key']);
|
|
}
|
|
|
|
private function hasWorkspaceOverride(string $field): bool
|
|
{
|
|
return $this->workspaceOverrideForField($field) !== null;
|
|
}
|
|
|
|
private function workspaceOverrideForField(string $field): mixed
|
|
{
|
|
$setting = $this->settingForField($field);
|
|
$resolved = app(SettingsResolver::class)->resolveDetailed(
|
|
workspace: $this->workspace,
|
|
domain: $setting['domain'],
|
|
key: $setting['key'],
|
|
);
|
|
|
|
return $resolved['workspace_value'];
|
|
}
|
|
|
|
private function currentUserCanManage(): bool
|
|
{
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User || ! $this->workspace instanceof Workspace) {
|
|
return false;
|
|
}
|
|
|
|
/** @var WorkspaceCapabilityResolver $resolver */
|
|
$resolver = app(WorkspaceCapabilityResolver::class);
|
|
|
|
return $resolver->isMember($user, $this->workspace)
|
|
&& $resolver->can($user, $this->workspace, Capabilities::WORKSPACE_SETTINGS_MANAGE);
|
|
}
|
|
|
|
private function authorizeWorkspaceView(User $user): void
|
|
{
|
|
/** @var WorkspaceCapabilityResolver $resolver */
|
|
$resolver = app(WorkspaceCapabilityResolver::class);
|
|
|
|
if (! $resolver->isMember($user, $this->workspace)) {
|
|
abort(404);
|
|
}
|
|
|
|
if (! $resolver->can($user, $this->workspace, Capabilities::WORKSPACE_SETTINGS_VIEW)) {
|
|
abort(403);
|
|
}
|
|
}
|
|
|
|
private function authorizeWorkspaceManage(User $user): void
|
|
{
|
|
/** @var WorkspaceCapabilityResolver $resolver */
|
|
$resolver = app(WorkspaceCapabilityResolver::class);
|
|
|
|
if (! $resolver->isMember($user, $this->workspace)) {
|
|
abort(404);
|
|
}
|
|
|
|
if (! $resolver->can($user, $this->workspace, Capabilities::WORKSPACE_SETTINGS_MANAGE)) {
|
|
abort(403);
|
|
}
|
|
}
|
|
}
|