feat(restore): add Entra group cache picker modal, Livewire table, wiring and tests
This commit is contained in:
parent
c93b6e8bb5
commit
6ac4b45c68
@ -9,6 +9,7 @@
|
||||
use App\Jobs\ExecuteRestoreRunJob;
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\EntraGroup;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Rules\SkipOrUuidRule;
|
||||
@ -112,7 +113,21 @@ public static function form(Schema $schema): Schema
|
||||
tenant: $tenant
|
||||
);
|
||||
|
||||
return array_map(function (array $group): Forms\Components\TextInput {
|
||||
$groupCacheQuery = EntraGroup::query()->where('tenant_id', $tenant->getKey());
|
||||
$hasCachedGroups = $groupCacheQuery->exists();
|
||||
|
||||
$stalenessDays = (int) config('directory_groups.staleness_days', 30);
|
||||
$cutoff = now('UTC')->subDays(max(1, $stalenessDays));
|
||||
$latestSeen = $groupCacheQuery->max('last_seen_at');
|
||||
$isStale = $hasCachedGroups && (! $latestSeen || $latestSeen < $cutoff);
|
||||
|
||||
$cacheNotice = match (true) {
|
||||
! $hasCachedGroups => 'No cached groups found. Run "Sync Groups" first.',
|
||||
$isStale => "Cached groups may be stale (>${stalenessDays} days). Consider running \"Sync Groups\".",
|
||||
default => null,
|
||||
};
|
||||
|
||||
return array_map(function (array $group) use ($cacheNotice): Forms\Components\TextInput {
|
||||
$groupId = $group['id'];
|
||||
$label = $group['label'];
|
||||
|
||||
@ -121,7 +136,28 @@ public static function form(Schema $schema): Schema
|
||||
->placeholder('SKIP or target group Object ID (GUID)')
|
||||
->rules([new SkipOrUuidRule])
|
||||
->required()
|
||||
->helperText('Paste the target Entra ID group Object ID (GUID). Labels use the cached directory groups only (no live Graph lookups). Use SKIP to omit the assignment.');
|
||||
->suffixAction(
|
||||
Actions\Action::make('select_from_directory_cache_'.str_replace('-', '_', $groupId))
|
||||
->icon('heroicon-o-magnifying-glass')
|
||||
->iconButton()
|
||||
->tooltip('Select from Directory cache')
|
||||
->modalHeading('Select from Directory cache')
|
||||
->modalWidth('5xl')
|
||||
->modalSubmitAction(false)
|
||||
->modalCancelActionLabel('Close')
|
||||
->modalContent(fn () => view('filament.modals.entra-group-cache-picker', [
|
||||
'sourceGroupId' => $groupId,
|
||||
]))
|
||||
)
|
||||
->helperText(fn (): string => $cacheNotice ? ($cacheNotice.' Labels use cached directory groups only (no live Graph lookups). Paste a GUID or use SKIP.') : 'Paste the target Entra ID group Object ID (GUID). Labels use cached directory groups only (no live Graph lookups). Use SKIP to omit the assignment.')
|
||||
->hintAction(
|
||||
Actions\Action::make('open_directory_groups_'.str_replace('-', '_', $groupId))
|
||||
->label('Sync Groups')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('warning')
|
||||
->url(fn (): string => EntraGroupResource::getUrl('index', tenant: Tenant::current()))
|
||||
->visible(fn (): bool => $cacheNotice !== null)
|
||||
);
|
||||
}, $unresolved);
|
||||
})
|
||||
->visible(function (Get $get): bool {
|
||||
@ -318,7 +354,21 @@ public static function getWizardSteps(): array
|
||||
tenant: $tenant
|
||||
);
|
||||
|
||||
return array_map(function (array $group): Forms\Components\TextInput {
|
||||
$groupCacheQuery = EntraGroup::query()->where('tenant_id', $tenant->getKey());
|
||||
$hasCachedGroups = $groupCacheQuery->exists();
|
||||
|
||||
$stalenessDays = (int) config('directory_groups.staleness_days', 30);
|
||||
$cutoff = now('UTC')->subDays(max(1, $stalenessDays));
|
||||
$latestSeen = $groupCacheQuery->max('last_seen_at');
|
||||
$isStale = $hasCachedGroups && (! $latestSeen || $latestSeen < $cutoff);
|
||||
|
||||
$cacheNotice = match (true) {
|
||||
! $hasCachedGroups => 'No cached groups found. Run "Sync Groups" first.',
|
||||
$isStale => "Cached groups may be stale (>${stalenessDays} days). Consider running \"Sync Groups\".",
|
||||
default => null,
|
||||
};
|
||||
|
||||
return array_map(function (array $group) use ($cacheNotice): Forms\Components\TextInput {
|
||||
$groupId = $group['id'];
|
||||
$label = $group['label'];
|
||||
|
||||
@ -336,7 +386,28 @@ public static function getWizardSteps(): array
|
||||
$set('preview_ran_at', null);
|
||||
})
|
||||
->required()
|
||||
->helperText('Paste the target Entra ID group Object ID (GUID). Names are not resolved in this phase. Use SKIP to omit the assignment.');
|
||||
->suffixAction(
|
||||
Actions\Action::make('select_from_directory_cache_'.str_replace('-', '_', $groupId))
|
||||
->icon('heroicon-o-magnifying-glass')
|
||||
->iconButton()
|
||||
->tooltip('Select from Directory cache')
|
||||
->modalHeading('Select from Directory cache')
|
||||
->modalWidth('5xl')
|
||||
->modalSubmitAction(false)
|
||||
->modalCancelActionLabel('Close')
|
||||
->modalContent(fn () => view('filament.modals.entra-group-cache-picker', [
|
||||
'sourceGroupId' => $groupId,
|
||||
]))
|
||||
)
|
||||
->helperText(fn (): string => $cacheNotice ? ($cacheNotice.' Labels use cached directory groups only (no live Graph lookups). Paste a GUID or use SKIP.') : 'Paste the target Entra ID group Object ID (GUID). Labels use cached directory groups only (no live Graph lookups). Use SKIP to omit the assignment.')
|
||||
->hintAction(
|
||||
Actions\Action::make('open_directory_groups_'.str_replace('-', '_', $groupId))
|
||||
->label('Sync Groups')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('warning')
|
||||
->url(fn (): string => EntraGroupResource::getUrl('index', tenant: Tenant::current()))
|
||||
->visible(fn (): bool => $cacheNotice !== null)
|
||||
);
|
||||
}, $unresolved);
|
||||
})
|
||||
->visible(function (Get $get): bool {
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
use Filament\Resources\Pages\Concerns\HasWizard;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Livewire\Attributes\On;
|
||||
|
||||
class CreateRestoreRun extends CreateRecord
|
||||
{
|
||||
@ -119,4 +120,23 @@ protected function handleRecordCreation(array $data): Model
|
||||
{
|
||||
return RestoreRunResource::createRestoreRun($data);
|
||||
}
|
||||
|
||||
#[On('entra-group-cache-picked')]
|
||||
public function applyEntraGroupCachePick(string $sourceGroupId, string $entraId): void
|
||||
{
|
||||
data_set($this->data, "group_mapping.{$sourceGroupId}", $entraId);
|
||||
|
||||
$this->data['check_summary'] = null;
|
||||
$this->data['check_results'] = [];
|
||||
$this->data['checks_ran_at'] = null;
|
||||
$this->data['preview_summary'] = null;
|
||||
$this->data['preview_diffs'] = [];
|
||||
$this->data['preview_ran_at'] = null;
|
||||
|
||||
$this->form->fill($this->data);
|
||||
|
||||
if (method_exists($this, 'unmountAction')) {
|
||||
$this->unmountAction();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
203
app/Livewire/EntraGroupCachePickerTable.php
Normal file
203
app/Livewire/EntraGroupCachePickerTable.php
Normal file
@ -0,0 +1,203 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Filament\Resources\EntraGroupResource;
|
||||
use App\Filament\Resources\EntraGroupSyncRunResource;
|
||||
use App\Models\EntraGroup;
|
||||
use App\Models\Tenant;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Filament\Tables\TableComponent;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class EntraGroupCachePickerTable extends TableComponent
|
||||
{
|
||||
public string $sourceGroupId;
|
||||
|
||||
public function mount(string $sourceGroupId): void
|
||||
{
|
||||
$this->sourceGroupId = $sourceGroupId;
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
$tenantId = Tenant::current()?->getKey();
|
||||
|
||||
$query = EntraGroup::query();
|
||||
|
||||
if ($tenantId) {
|
||||
$query->where('tenant_id', $tenantId);
|
||||
} else {
|
||||
$query->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
$stalenessDays = (int) config('directory_groups.staleness_days', 30);
|
||||
$cutoff = now('UTC')->subDays(max(1, $stalenessDays));
|
||||
|
||||
return $table
|
||||
->queryStringIdentifier('entraGroupCachePicker')
|
||||
->query($query)
|
||||
->defaultSort('display_name')
|
||||
->paginated([10, 25, 50])
|
||||
->defaultPaginationPageOption(10)
|
||||
->searchable()
|
||||
->searchPlaceholder('Search groups…')
|
||||
->deferLoading(! app()->runningUnitTests())
|
||||
->columns([
|
||||
TextColumn::make('display_name')
|
||||
->label('Name')
|
||||
->searchable()
|
||||
->sortable()
|
||||
->wrap()
|
||||
->limit(60),
|
||||
TextColumn::make('type')
|
||||
->label('Type')
|
||||
->badge()
|
||||
->state(fn (EntraGroup $record): string => $this->groupTypeLabel($this->groupType($record)))
|
||||
->color(fn (EntraGroup $record): string => $this->groupTypeColor($this->groupType($record)))
|
||||
->toggleable(),
|
||||
TextColumn::make('entra_id')
|
||||
->label('ID')
|
||||
->formatStateUsing(fn (?string $state): string => filled($state) ? ('…'.substr($state, -8)) : '—')
|
||||
->extraAttributes(['class' => 'font-mono'])
|
||||
->toggleable(),
|
||||
TextColumn::make('last_seen_at')
|
||||
->label('Last seen')
|
||||
->since()
|
||||
->sortable(),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('stale')
|
||||
->label('Stale')
|
||||
->options([
|
||||
'1' => 'Stale',
|
||||
'0' => 'Fresh',
|
||||
])
|
||||
->query(function (Builder $query, array $data) use ($cutoff): Builder {
|
||||
$value = $data['value'] ?? null;
|
||||
|
||||
if ($value === null || $value === '') {
|
||||
return $query;
|
||||
}
|
||||
|
||||
if ((string) $value === '1') {
|
||||
return $query->where(function (Builder $q) use ($cutoff): void {
|
||||
$q->whereNull('last_seen_at')
|
||||
->orWhere('last_seen_at', '<', $cutoff);
|
||||
});
|
||||
}
|
||||
|
||||
return $query->where('last_seen_at', '>=', $cutoff);
|
||||
}),
|
||||
|
||||
SelectFilter::make('group_type')
|
||||
->label('Type')
|
||||
->options([
|
||||
'security' => 'Security',
|
||||
'microsoft365' => 'Microsoft 365',
|
||||
'mail' => 'Mail-enabled',
|
||||
'unknown' => 'Unknown',
|
||||
])
|
||||
->query(function (Builder $query, array $data): Builder {
|
||||
$value = (string) ($data['value'] ?? '');
|
||||
|
||||
if ($value === '') {
|
||||
return $query;
|
||||
}
|
||||
|
||||
return match ($value) {
|
||||
'microsoft365' => $query->whereJsonContains('group_types', 'Unified'),
|
||||
'security' => $query
|
||||
->where('security_enabled', true)
|
||||
->where(function (Builder $q): void {
|
||||
$q->whereNull('group_types')
|
||||
->orWhereJsonDoesntContain('group_types', 'Unified');
|
||||
}),
|
||||
'mail' => $query
|
||||
->where('mail_enabled', true)
|
||||
->where(function (Builder $q): void {
|
||||
$q->whereNull('group_types')
|
||||
->orWhereJsonDoesntContain('group_types', 'Unified');
|
||||
}),
|
||||
'unknown' => $query
|
||||
->where(function (Builder $q): void {
|
||||
$q->whereNull('group_types')
|
||||
->orWhereJsonDoesntContain('group_types', 'Unified');
|
||||
})
|
||||
->where('security_enabled', false)
|
||||
->where('mail_enabled', false),
|
||||
default => $query,
|
||||
};
|
||||
}),
|
||||
])
|
||||
->actions([
|
||||
Action::make('select')
|
||||
->label('Select')
|
||||
->icon('heroicon-o-check')
|
||||
->color('primary')
|
||||
->action(function (EntraGroup $record): void {
|
||||
$this->dispatch('entra-group-cache-picked', sourceGroupId: $this->sourceGroupId, entraId: (string) $record->entra_id);
|
||||
}),
|
||||
])
|
||||
->emptyStateHeading('No cached groups found')
|
||||
->emptyStateDescription('Run “Sync Groups” first, then come back here.')
|
||||
->emptyStateActions([
|
||||
Action::make('open_groups')
|
||||
->label('Directory Groups')
|
||||
->icon('heroicon-o-user-group')
|
||||
->url(fn (): string => EntraGroupResource::getUrl('index', tenant: Tenant::current())),
|
||||
Action::make('open_sync_runs')
|
||||
->label('Group Sync Runs')
|
||||
->icon('heroicon-o-clock')
|
||||
->url(fn (): string => EntraGroupSyncRunResource::getUrl('index', tenant: Tenant::current())),
|
||||
]);
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
return view('livewire.entra-group-cache-picker-table');
|
||||
}
|
||||
|
||||
private function groupType(EntraGroup $record): string
|
||||
{
|
||||
$groupTypes = $record->group_types;
|
||||
|
||||
if (is_array($groupTypes) && in_array('Unified', $groupTypes, true)) {
|
||||
return 'microsoft365';
|
||||
}
|
||||
|
||||
if ($record->security_enabled) {
|
||||
return 'security';
|
||||
}
|
||||
|
||||
if ($record->mail_enabled) {
|
||||
return 'mail';
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
private function groupTypeLabel(string $type): string
|
||||
{
|
||||
return match ($type) {
|
||||
'microsoft365' => 'Microsoft 365',
|
||||
'security' => 'Security',
|
||||
'mail' => 'Mail-enabled',
|
||||
default => 'Unknown',
|
||||
};
|
||||
}
|
||||
|
||||
private function groupTypeColor(string $type): string
|
||||
{
|
||||
return match ($type) {
|
||||
'microsoft365' => 'info',
|
||||
'security' => 'success',
|
||||
'mail' => 'warning',
|
||||
default => 'gray',
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
<div class="space-y-4">
|
||||
<livewire:entra-group-cache-picker-table :sourceGroupId="$sourceGroupId" />
|
||||
</div>
|
||||
@ -0,0 +1,3 @@
|
||||
<div class="space-y-2">
|
||||
{{ $this->table }}
|
||||
</div>
|
||||
@ -202,3 +202,32 @@
|
||||
'action' => 'restore.group_mapping.applied',
|
||||
]);
|
||||
});
|
||||
|
||||
test('restore wizard can fill a group mapping entry from directory cache picker', function () {
|
||||
$tenant = Tenant::create([
|
||||
'tenant_id' => 'tenant-1',
|
||||
'name' => 'Tenant One',
|
||||
'metadata' => [],
|
||||
]);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
$user->tenants()->syncWithoutDetaching([
|
||||
$tenant->getKey() => ['role' => 'owner'],
|
||||
]);
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$sourceGroupId = fake()->uuid();
|
||||
$targetGroupId = fake()->uuid();
|
||||
|
||||
Livewire::test(CreateRestoreRun::class)
|
||||
->set('data.check_summary', 'old')
|
||||
->set('data.check_results', ['x'])
|
||||
->call('applyEntraGroupCachePick', sourceGroupId: $sourceGroupId, entraId: $targetGroupId)
|
||||
->assertSet("data.group_mapping.{$sourceGroupId}", $targetGroupId)
|
||||
->assertSet('data.check_summary', null)
|
||||
->assertSet('data.check_results', []);
|
||||
});
|
||||
|
||||
@ -29,3 +29,33 @@
|
||||
->and($run->failures)->toBeArray()
|
||||
->and($run->failures)->toHaveCount(1);
|
||||
});
|
||||
|
||||
test('bulk operation run total_items is at least the item_ids count', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
$service = app(BulkOperationService::class);
|
||||
$run = $service->createRun($tenant, $user, 'inventory', 'sync', ['a', 'b', 'c', 'd'], 1);
|
||||
|
||||
expect($run->total_items)->toBe(4);
|
||||
});
|
||||
|
||||
test('bulk operation completion clamps total_items up to processed_items', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
$service = app(BulkOperationService::class);
|
||||
$run = $service->createRun($tenant, $user, 'inventory', 'sync', ['only-one'], 1);
|
||||
|
||||
$service->start($run);
|
||||
$service->recordSuccess($run);
|
||||
$service->recordSuccess($run);
|
||||
|
||||
$run->refresh();
|
||||
expect($run->processed_items)->toBe(2)->and($run->total_items)->toBe(1);
|
||||
|
||||
$service->complete($run);
|
||||
$run->refresh();
|
||||
|
||||
expect($run->total_items)->toBe(2);
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user