path; } public static function canCreate(): bool { // These are reported, not written. A hand-typed one would be a // redirect somebody had not got round to creating yet. return false; } public static function table(Table $table): Table { return $table // Worst first: how many people hit it, not when it last happened. // One address requested two hundred times is the whole point of // the screen; a hundred requested once each are noise. ->defaultSort('hits', 'desc') ->columns([ TextColumn::make('path') ->label(__('panel.missing_urls.fields.path')) ->fontFamily('mono') ->searchable() ->wrap() // The strongest signal on the row: a referrer on our own // domain means something we publish links here. ->description(fn (MissingUrl $record) => $record->isInternalReferrer() ? __('panel.missing_urls.linked_from_us') : null), TextColumn::make('hits') ->label(__('panel.missing_urls.fields.hits')) ->badge() ->color(fn (int $state) => match (true) { $state >= 10 => 'danger', $state >= 2 => 'warning', default => 'gray', }) ->sortable(), TextColumn::make('locale') ->label(__('panel.missing_urls.fields.locale')) ->badge() ->color('gray') ->toggleable(), TextColumn::make('status') ->label(__('panel.missing_urls.fields.status')) ->badge(), TextColumn::make('referrer') ->label(__('panel.missing_urls.fields.referrer')) ->placeholder(__('panel.missing_urls.no_referrer')) ->wrap() ->size('xs') ->color(fn (MissingUrl $record) => $record->isInternalReferrer() ? 'danger' : 'gray') ->toggleable(), TextColumn::make('last_seen_at') ->label(__('panel.missing_urls.fields.last_seen')) ->since() ->dateTimeTooltip() ->sortable(), ]) ->filters([ SelectFilter::make('status') ->label(__('panel.missing_urls.fields.status')) ->options(MissingUrlStatus::options()) // The list is a to-do list, so it opens as one. ->default(MissingUrlStatus::Pending->value), Filter::make('internal') ->label(__('panel.missing_urls.filters.internal')) ->query(fn (Builder $query) => $query ->where('referrer', 'like', rtrim((string) config('site.url'), '/').'%')), Filter::make('repeated') ->label(__('panel.missing_urls.filters.repeated')) ->query(fn (Builder $query) => $query->where('hits', '>=', 2)), ]) ->recordActions([ Action::make('redirect') ->label(__('panel.missing_urls.actions.redirect')) ->icon('heroicon-o-arrows-right-left') ->color('primary') ->authorize('redirect') ->visible(fn (MissingUrl $record) => $record->status !== MissingUrlStatus::Resolved) ->modalHeading(__('panel.missing_urls.actions.redirect_heading')) ->modalDescription(__('panel.missing_urls.actions.redirect_hint')) ->schema([ // Read-only: the origin is the address that failed. It // is here to be seen while typing the destination, not // to be edited. TextInput::make('from') ->label(__('panel.redirects.fields.from')) ->disabled() ->dehydrated(false) ->default(fn (MissingUrl $record) => $record->path), TextInput::make('to') ->label(__('panel.redirects.fields.to')) ->required() ->maxLength(500) ->placeholder('/proyectos/nombre-nuevo') ->dehydrateStateUsing(fn (string $state) => Redirect::normalise($state)), Select::make('status') ->label(__('panel.redirects.fields.status')) ->options([ 301 => __('panel.redirects.statuses.301'), 302 => __('panel.redirects.statuses.302'), ]) ->default(301) ->required(), Select::make('locale') ->label(__('panel.redirects.fields.locale')) ->helperText(__('panel.missing_urls.actions.locale_hint')) ->options(fn () => collect(config('locales.supported')) ->map(fn (array $meta, string $code) => $meta['native'] ?? $code) ->all()) ->default(fn (MissingUrl $record) => $record->locale) ->placeholder(__('panel.redirects.every_locale')), ]) ->action(function (MissingUrl $record, array $data): void { // The record is marked resolved by the redirect being // created, not from here, so a rule written anywhere // else closes its report too. Redirect::create([ 'from' => $record->path, 'to' => $data['to'], 'status' => $data['status'], 'locale' => $data['locale'] ?? null, 'is_enabled' => true, ]); Notification::make() ->title(__('panel.missing_urls.actions.redirected')) ->success() ->send(); }), Action::make('ignore') ->label(__('panel.missing_urls.actions.ignore')) ->icon('heroicon-o-eye-slash') ->color('gray') ->authorize('update') ->visible(fn (MissingUrl $record) => $record->status !== MissingUrlStatus::Ignored) ->action(fn (MissingUrl $record) => $record->update([ 'status' => MissingUrlStatus::Ignored, ])), Action::make('unignore') ->label(__('panel.missing_urls.actions.unignore')) ->icon('heroicon-o-eye') ->color('gray') ->authorize('update') ->visible(fn (MissingUrl $record) => $record->status === MissingUrlStatus::Ignored) ->action(fn (MissingUrl $record) => $record->update([ 'status' => MissingUrlStatus::Pending, ])), DeleteAction::make(), ]) ->toolbarActions([ BulkActionGroup::make([ // The bulk action that matters: a scan produces dozens of // rows at once and dismissing them one by one is how a // list stops being read. BulkAction::make('ignore') ->label(__('panel.missing_urls.actions.ignore')) ->icon('heroicon-o-eye-slash') ->authorize('updateAny') ->deselectRecordsAfterCompletion() ->action(fn (Collection $records) => MissingUrl::query() ->whereKey($records->pluck('id')) ->update(['status' => MissingUrlStatus::Ignored])), DeleteBulkAction::make(), ]), ]) ->emptyStateHeading(__('panel.missing_urls.empty.heading')) ->emptyStateDescription(__('panel.missing_urls.empty.description')) ->emptyStateIcon('heroicon-o-magnifying-glass-circle'); } /** * @return array */ public static function getGloballySearchableAttributes(): array { return ['path']; } public static function getPages(): array { return [ 'index' => ListMissingUrls::route('/'), ]; } /** * Only what somebody still has to decide about, and only when it has * happened more than once — a badge counting one-off scans would be a * number that never reaches zero, which is a number nobody looks at. */ public static function getNavigationBadge(): ?string { $pending = static::getModel()::query()->pending()->where('hits', '>=', 2)->count(); return $pending > 0 ? (string) $pending : null; } public static function getNavigationBadgeColor(): ?string { return 'warning'; } }