Release v1.4.0 #146

Merged
myrmidex merged 71 commits from release/v1.4.0 into main 2026-08-15 00:36:54 +02:00
7 changed files with 288 additions and 1 deletions
Showing only changes of commit c986f000f6 - Show all commits

55
app/Livewire/Activity.php Normal file
View file

@ -0,0 +1,55 @@
<?php
namespace App\Livewire;
use App\Enums\ActivityTypeEnum;
use App\Models\ActivityLog;
use App\Services\Activity\ActivitySummary;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Builder;
use Livewire\Component;
use Livewire\WithPagination;
class Activity extends Component
{
use WithPagination;
public string $type = '';
public int $days = 7;
public function updatedType(): void
{
$this->resetPage();
}
public function updatedDays(): void
{
$this->resetPage();
}
/**
* @return Builder<ActivityLog>
*/
private function query(): Builder
{
return ActivityLog::query()
->with('subject')
->since(now()->subDays($this->days))
->when(
$this->type !== '',
fn (Builder $q) => $q->where('type', $this->type),
)
->latestFirst();
}
public function render(ActivitySummary $summary): View
{
return view('livewire.activity', [
'entries' => $this->query()->paginate(25),
'typeOptions' => ActivityTypeEnum::options(),
'summaryDay' => $summary->since(now()->subDay()),
'summaryWeek' => $summary->since(now()->subWeek()),
])->layout('layouts.app');
}
}

View file

@ -2,7 +2,9 @@
namespace App\Livewire;
use App\Enums\ActivityTypeEnum;
use App\Enums\ApprovalStatusEnum;
use App\Events\ActivityLogged;
use App\Jobs\ArticleDiscoveryJob;
use App\Models\Feed;
use App\Models\RouteArticle;
@ -77,7 +79,19 @@ public function restore(int $routeArticleId): void
public function clear(): void
{
$this->clearableQuery()->update(['approval_status' => ApprovalStatusEnum::REJECTED]);
$feed = $this->feedId !== null ? Feed::find($this->feedId) : null;
$cleared = $this->clearableQuery()->update(['approval_status' => ApprovalStatusEnum::REJECTED]);
if ($cleared > 0) {
ActivityLogged::dispatch(
ActivityTypeEnum::REJECT,
$feed !== null
? "Cleared {$cleared} pending articles from {$feed->name}"
: "Cleared {$cleared} pending articles",
['cleared' => $cleared],
$feed,
);
}
$this->expandedFeeds = [];
}

View file

@ -0,0 +1,34 @@
<?php
namespace App\Services\Activity;
use App\Enums\ActivityTypeEnum;
use App\Models\ActivityLog;
use Illuminate\Support\Carbon;
class ActivitySummary
{
/**
* Counts per type since $since, including types with no rows.
*
* @return array<string, int>
*/
public function since(Carbon $since): array
{
/** @var array<string, int> $counts */
$counts = ActivityLog::query()
->since($since)
->selectRaw('type, COUNT(*) as aggregate')
->groupBy('type')
->pluck('aggregate', 'type')
->all();
$summary = [];
foreach (ActivityTypeEnum::cases() as $case) {
$summary[$case->value] = (int) ($counts[$case->value] ?? 0);
}
return $summary;
}
}

View file

@ -5,6 +5,7 @@
['name' => 'Feeds', 'route' => 'feeds', 'icon' => 'rss'],
['name' => 'Channels', 'route' => 'channels', 'icon' => 'hashtag'],
['name' => 'Routes', 'route' => 'routes', 'icon' => 'arrow-path'],
['name' => 'Activity', 'route' => 'activity', 'icon' => 'clock'],
['name' => 'Settings', 'route' => 'settings', 'icon' => 'cog-6-tooth'],
];
@endphp
@ -41,6 +42,11 @@ class="group flex items-center px-2 py-2 text-sm font-medium rounded-md mb-1 {{
<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 21 3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />
</svg>
@break
@case('clock')
<svg class="mr-3 h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
@break
@case('cog-6-tooth')
<svg class="mr-3 h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" />

View file

@ -0,0 +1,72 @@
<div class="p-6">
<x-page-header title="Activity" subtitle="What FFR has been doing automatically" />
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6 mb-6">
@foreach ($typeOptions as $value => $label)
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm px-4 py-3">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">{{ $label }}</p>
<p class="mt-1 text-2xl font-semibold text-gray-900 dark:text-gray-100">{{ $summaryDay[$value] }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ $summaryWeek[$value] }} in 7d</p>
</div>
@endforeach
</div>
<div class="mb-4 flex flex-wrap items-center gap-3">
<select
wire:model.live="type"
aria-label="Filter by activity type"
class="w-56 shrink-0 pl-3 pr-10 py-2 border border-gray-300 rounded-md text-sm focus:outline-hidden focus:ring-2 focus:ring-blue-500 dark:border-gray-600"
>
<option value="">All types</option>
@foreach ($typeOptions as $value => $label)
<option value="{{ $value }}">{{ $label }}</option>
@endforeach
</select>
<select
wire:model.live="days"
aria-label="Filter by time range"
class="w-44 shrink-0 pl-3 pr-10 py-2 border border-gray-300 rounded-md text-sm focus:outline-hidden focus:ring-2 focus:ring-blue-500 dark:border-gray-600"
>
<option value="1">Last 24 hours</option>
<option value="7">Last 7 days</option>
<option value="30">Last 30 days</option>
<option value="90">Last 90 days</option>
</select>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm overflow-hidden">
@forelse ($entries as $entry)
<div class="flex items-start gap-x-3 px-5 py-4 border-b border-gray-100 dark:border-gray-700 last:border-b-0">
<span class="mt-0.5 inline-flex shrink-0 items-center px-2.5 py-0.5 rounded-full text-xs font-medium {{ $entry->type === App\Enums\ActivityTypeEnum::ERROR ? 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300' : 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300' }}">
{{ $entry->type->label() }}
</span>
<div class="min-w-0 flex-1">
<p class="text-sm text-gray-900 dark:text-gray-100">{{ $entry->message }}</p>
@if (($entry->context['reason'] ?? null) !== null)
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">{{ $entry->context['reason'] }}</p>
@endif
</div>
<time
datetime="{{ $entry->logged_at->toIso8601String() }}"
title="{{ $entry->logged_at->toDayDateTimeString() }}"
class="shrink-0 text-xs text-gray-500 dark:text-gray-400"
>
{{ $entry->logged_at->diffForHumans() }}
</time>
</div>
@empty
<div class="px-5 py-12 text-center">
<p class="text-sm text-gray-500 dark:text-gray-400">No activity in this period.</p>
</div>
@endforelse
</div>
@if ($entries->hasPages())
<div class="mt-4">
{{ $entries->links() }}
</div>
@endif
</div>

View file

@ -1,6 +1,7 @@
<?php
use App\Http\Controllers\ProfileController;
use App\Livewire\Activity;
use App\Livewire\Articles;
use App\Livewire\Channels;
use App\Livewire\Dashboard;
@ -24,6 +25,7 @@
Route::middleware(['auth', 'onboarding.complete'])->group(function () {
Route::get('/dashboard', Dashboard::class)->name('dashboard');
Route::get('/articles', Articles::class)->name('articles');
Route::get('/activity', Activity::class)->name('activity');
Route::get('/feeds', Feeds::class)->name('feeds');
Route::get('/channels', Channels::class)->name('channels');
Route::get('/routes', Routes::class)->name('routes');

View file

@ -0,0 +1,104 @@
<?php
namespace Tests\Feature\Livewire;
use App\Enums\ActivityTypeEnum;
use App\Livewire\Activity;
use App\Models\ActivityLog;
use App\Models\Article;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\TestCase;
class ActivityTest extends TestCase
{
use RefreshDatabase;
public function test_renders_successfully(): void
{
Livewire::test(Activity::class)->assertStatus(200);
}
public function test_shows_recent_entries(): void
{
ActivityLog::factory()->create(['message' => 'Published something']);
Livewire::test(Activity::class)->assertSee('Published something');
}
public function test_filtering_by_type_hides_other_types(): void
{
ActivityLog::factory()->type(ActivityTypeEnum::PUBLISH)->create(['message' => 'A publish entry']);
ActivityLog::factory()->type(ActivityTypeEnum::FETCH)->create(['message' => 'A fetch entry']);
Livewire::test(Activity::class)
->set('type', ActivityTypeEnum::PUBLISH->value)
->assertSee('A publish entry')
->assertDontSee('A fetch entry');
}
public function test_entries_outside_the_day_range_are_hidden(): void
{
ActivityLog::factory()->loggedAt(now()->subHours(2))->create(['message' => 'Recent entry']);
ActivityLog::factory()->loggedAt(now()->subDays(10))->create(['message' => 'Old entry']);
Livewire::test(Activity::class)
->set('days', 1)
->assertSee('Recent entry')
->assertDontSee('Old entry');
}
public function test_changing_a_filter_resets_pagination(): void
{
ActivityLog::factory()->count(30)->create();
Livewire::test(Activity::class)
->call('setPage', 2)
->set('type', ActivityTypeEnum::PUBLISH->value)
->assertSet('paginators.page', 1);
}
public function test_entry_without_a_subject_renders(): void
{
ActivityLog::factory()->create(['message' => 'No subject entry', 'subject_type' => null, 'subject_id' => null]);
Livewire::test(Activity::class)
->assertStatus(200)
->assertSee('No subject entry');
}
public function test_entry_with_a_deleted_subject_renders(): void
{
$article = Article::factory()->create();
ActivityLog::factory()->for_subject($article)->create(['message' => 'Dangling subject']);
$article->delete();
Livewire::test(Activity::class)
->assertStatus(200)
->assertSee('Dangling subject');
}
public function test_empty_state_is_shown_when_there_is_no_activity(): void
{
Livewire::test(Activity::class)->assertSee('No activity in this period.');
}
public function test_a_reason_in_context_is_shown_under_the_message(): void
{
ActivityLog::factory()->create([
'message' => 'Failed to publish something',
'context' => ['reason' => 'Could not recover the article content'],
]);
Livewire::test(Activity::class)->assertSee('Could not recover the article content');
}
public function test_a_message_containing_markup_is_escaped(): void
{
ActivityLog::factory()->create(['message' => 'Published "<script>alert(1)</script>"']);
Livewire::test(Activity::class)
->assertDontSee('<script>alert(1)</script>', false)
->assertSee('&lt;script&gt;', false);
}
}