From 1d66b1a77eb1acb461d4319c9c8b6c0d182c6b11 Mon Sep 17 00:00:00 2001 From: Dmitry Arnaut Date: Mon, 27 Apr 2026 12:24:05 +0300 Subject: [PATCH] feat: add deploy history & timeline with JSONL log --- config/filament-deploy-indicator.php | 17 +++ resources/lang/bg/deploy-indicator.php | 1 + resources/lang/en/deploy-indicator.php | 1 + resources/views/indicator.blade.php | 29 ++++++ src/Commands/WriteDeployInfoCommand.php | 9 +- src/FilamentDeployIndicatorPlugin.php | 5 +- src/Services/DeployHistoryService.php | 131 ++++++++++++++++++++++++ src/Services/DeployInfoService.php | 8 ++ tests/Unit/DeployHistoryServiceTest.php | 104 +++++++++++++++++++ 9 files changed, 302 insertions(+), 3 deletions(-) create mode 100644 src/Services/DeployHistoryService.php create mode 100644 tests/Unit/DeployHistoryServiceTest.php diff --git a/config/filament-deploy-indicator.php b/config/filament-deploy-indicator.php index 29e6b71..eb35357 100644 --- a/config/filament-deploy-indicator.php +++ b/config/filament-deploy-indicator.php @@ -97,6 +97,23 @@ 'local' => ['label' => 'LOCAL', 'color' => 'gray'], ], + /* + |-------------------------------------------------------------------------- + | Deploy history + |-------------------------------------------------------------------------- + | + | Append-only JSONL log of past deploys. Each new deploy is recorded + | (deduplicated by commit hash). Useful to see who/what/when in the + | Filament topbar without leaving the admin. + | + */ + 'history' => [ + 'enabled' => true, + 'path' => storage_path('app/private/deploy-history.jsonl'), + 'max_entries' => 100, + 'show_in_dropdown' => 5, + ], + /* |-------------------------------------------------------------------------- | Topbar hint diff --git a/resources/lang/bg/deploy-indicator.php b/resources/lang/bg/deploy-indicator.php index 1ef787b..cbdf4bb 100644 --- a/resources/lang/bg/deploy-indicator.php +++ b/resources/lang/bg/deploy-indicator.php @@ -12,4 +12,5 @@ 'click_to_view' => 'Кликни, за да видиш информация за деплой', 'copy' => 'Копирай commit хеш', 'copied' => 'Копирано!', + 'recent_deploys' => 'Последни деплойменти', ]; diff --git a/resources/lang/en/deploy-indicator.php b/resources/lang/en/deploy-indicator.php index 23f21a2..2b475a7 100644 --- a/resources/lang/en/deploy-indicator.php +++ b/resources/lang/en/deploy-indicator.php @@ -12,4 +12,5 @@ 'click_to_view' => 'Click to view deployment info', 'copy' => 'Copy commit hash', 'copied' => 'Copied!', + 'recent_deploys' => 'Recent deploys', ]; diff --git a/resources/views/indicator.blade.php b/resources/views/indicator.blade.php index 10f167b..9f93645 100644 --- a/resources/views/indicator.blade.php +++ b/resources/views/indicator.blade.php @@ -114,5 +114,34 @@ class="mt-0.5 flex-shrink-0 rounded p-1 text-gray-400 transition-colors hover:bg @endif + @if (!empty($history)) + +
+ {{ __('filament-deploy-indicator::deploy-indicator.recent_deploys') }} +
+
+ @foreach ($history as $entry) + @php + $entryCommit = data_get($entry, 'commit'); + $entryShort = $entryCommit ? \Illuminate\Support\Str::limit($entryCommit, 7, '') : null; + $entryAuthor = data_get($entry, 'author'); + $entryDate = data_get($entry, 'deployed_at') ?? data_get($entry, 'recorded_at'); + @endphp +
+ @if ($entryShort) + {{ $entryShort }} + @endif + @if ($entryAuthor) + {{ $entryAuthor }} + @endif + @if ($entryDate) + {{ $entryDate }} + @endif +
+ @endforeach +
+
+ @endif + diff --git a/src/Commands/WriteDeployInfoCommand.php b/src/Commands/WriteDeployInfoCommand.php index 96218ca..33faea5 100644 --- a/src/Commands/WriteDeployInfoCommand.php +++ b/src/Commands/WriteDeployInfoCommand.php @@ -2,6 +2,7 @@ namespace Arnautdev\FilamentDeployIndicator\Commands; +use Arnautdev\FilamentDeployIndicator\Services\DeployHistoryService; use Arnautdev\FilamentDeployIndicator\Services\GitDeployInfoGenerator; use Illuminate\Console\Command; use Illuminate\Support\Facades\File; @@ -20,8 +21,10 @@ class WriteDeployInfoCommand extends Command protected $description = 'Write deploy-info.json for Filament Deploy Indicator'; - public function __construct(protected GitDeployInfoGenerator $generator) - { + public function __construct( + protected GitDeployInfoGenerator $generator, + protected DeployHistoryService $history, + ) { parent::__construct(); } @@ -52,6 +55,8 @@ public function handle(): int File::ensureDirectoryExists(dirname((string) $path)); File::put($path, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + $this->history->record($data); + $this->info("Deploy info written to: {$path}"); return self::SUCCESS; diff --git a/src/FilamentDeployIndicatorPlugin.php b/src/FilamentDeployIndicatorPlugin.php index 18b0b88..2dac741 100644 --- a/src/FilamentDeployIndicatorPlugin.php +++ b/src/FilamentDeployIndicatorPlugin.php @@ -39,8 +39,11 @@ function (): string { return ''; } + $service = app(DeployInfoService::class); + return View::make('filament-deploy-indicator::indicator', [ - 'deploy' => app(DeployInfoService::class)->get(), + 'deploy' => $service->get(), + 'history' => $service->recentHistory(), ])->render(); } ); diff --git a/src/Services/DeployHistoryService.php b/src/Services/DeployHistoryService.php new file mode 100644 index 0000000..b84e0f1 --- /dev/null +++ b/src/Services/DeployHistoryService.php @@ -0,0 +1,131 @@ +enabled()) { + return; + } + + $commit = $entry['commit'] ?? null; + + if (! is_string($commit) || $commit === '') { + return; + } + + $path = $this->path(); + + if ($path === '') { + return; + } + + $maxEntries = max(1, (int) config('filament-deploy-indicator.history.max_entries', 100)); + + $existing = $this->readAll($path); + + $lastCommit = $existing !== [] ? ($existing[array_key_last($existing)]['commit'] ?? null) : null; + + if ($lastCommit === $commit) { + return; + } + + $entry['recorded_at'] ??= now()->toDateTimeString(); + + $existing[] = $entry; + + if (count($existing) > $maxEntries) { + $existing = array_slice($existing, -$maxEntries); + } + + $this->writeAll($path, $existing); + } + + public function recent(?int $limit = null): array + { + if (! $this->enabled()) { + return []; + } + + $path = $this->path(); + + if ($path === '' || ! File::exists($path)) { + return []; + } + + $limit ??= (int) config('filament-deploy-indicator.history.show_in_dropdown', 5); + $limit = max(0, $limit); + + if ($limit === 0) { + return []; + } + + $entries = $this->readAll($path); + + return array_reverse(array_slice($entries, -$limit)); + } + + public function path(): string + { + return (string) config('filament-deploy-indicator.history.path', ''); + } + + public function enabled(): bool + { + return (bool) config('filament-deploy-indicator.history.enabled', true); + } + + private function readAll(string $path): array + { + if (! File::exists($path)) { + return []; + } + + $contents = File::get($path); + + if ($contents === '') { + return []; + } + + $entries = []; + + foreach (preg_split("/\r\n|\n|\r/", $contents) ?: [] as $line) { + $line = trim($line); + + if ($line === '') { + continue; + } + + $decoded = json_decode($line, true); + + if (is_array($decoded)) { + $entries[] = $decoded; + } + } + + return $entries; + } + + private function writeAll(string $path, array $entries): void + { + File::ensureDirectoryExists(dirname($path)); + + $lines = array_map( + static fn (array $entry): string => json_encode($entry, JSON_UNESCAPED_SLASHES) ?: '', + $entries, + ); + + $lines = array_filter($lines, static fn (string $line): bool => $line !== ''); + + try { + File::put($path, implode(PHP_EOL, $lines) . ($lines === [] ? '' : PHP_EOL)); + } catch (\Throwable $e) { + Log::warning("filament-deploy-indicator: failed to write deploy history to [{$path}]: {$e->getMessage()}"); + } + } +} diff --git a/src/Services/DeployInfoService.php b/src/Services/DeployInfoService.php index 2245192..b1dceec 100644 --- a/src/Services/DeployInfoService.php +++ b/src/Services/DeployInfoService.php @@ -10,6 +10,7 @@ class DeployInfoService { public function __construct( protected GitDeployInfoGenerator $generator, + protected DeployHistoryService $history, ) {} public function get(): array @@ -39,6 +40,8 @@ public function get(): array json_encode($generated, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) ); + $this->history->record($generated); + $path = $writePath; } @@ -54,4 +57,9 @@ public function get(): array return $data; }); } + + public function recentHistory(?int $limit = null): array + { + return $this->history->recent($limit); + } } diff --git a/tests/Unit/DeployHistoryServiceTest.php b/tests/Unit/DeployHistoryServiceTest.php new file mode 100644 index 0000000..2eb6d76 --- /dev/null +++ b/tests/Unit/DeployHistoryServiceTest.php @@ -0,0 +1,104 @@ +set('filament-deploy-indicator.history.enabled', true); + config()->set('filament-deploy-indicator.history.path', $path); + config()->set('filament-deploy-indicator.history.max_entries', 100); + config()->set('filament-deploy-indicator.history.show_in_dropdown', 5); +}); + +it('appends a deploy entry to the history file', function () { + $service = app(DeployHistoryService::class); + + $service->record([ + 'commit' => 'abc123', + 'author' => 'Dmitry', + 'deployed_at' => '2026-04-27 10:00:00', + ]); + + $recent = $service->recent(); + + expect($recent)->toHaveCount(1) + ->and($recent[0]['commit'])->toBe('abc123') + ->and($recent[0]['author'])->toBe('Dmitry') + ->and($recent[0]['recorded_at'])->not->toBeEmpty(); +}); + +it('skips appending when commit hash matches the last entry', function () { + $service = app(DeployHistoryService::class); + + $service->record(['commit' => 'abc123', 'author' => 'Dmitry']); + $service->record(['commit' => 'abc123', 'author' => 'Dmitry']); + $service->record(['commit' => 'abc123', 'author' => 'Other']); + + expect($service->recent())->toHaveCount(1); +}); + +it('appends when commit hash differs from the last entry', function () { + $service = app(DeployHistoryService::class); + + $service->record(['commit' => 'aaa']); + $service->record(['commit' => 'bbb']); + $service->record(['commit' => 'ccc']); + + $recent = $service->recent(); + + expect($recent)->toHaveCount(3) + ->and(array_column($recent, 'commit'))->toBe(['ccc', 'bbb', 'aaa']); +}); + +it('trims history to max_entries', function () { + config()->set('filament-deploy-indicator.history.max_entries', 3); + + $service = app(DeployHistoryService::class); + + foreach (['a', 'b', 'c', 'd', 'e'] as $commit) { + $service->record(['commit' => $commit]); + } + + $recent = $service->recent(10); + + expect($recent)->toHaveCount(3) + ->and(array_column($recent, 'commit'))->toBe(['e', 'd', 'c']); +}); + +it('returns empty array when history is disabled', function () { + config()->set('filament-deploy-indicator.history.enabled', false); + + $service = app(DeployHistoryService::class); + + $service->record(['commit' => 'abc123']); + + expect($service->recent())->toBe([]); +}); + +it('respects the show_in_dropdown limit', function () { + config()->set('filament-deploy-indicator.history.show_in_dropdown', 2); + + $service = app(DeployHistoryService::class); + + foreach (['a', 'b', 'c', 'd'] as $commit) { + $service->record(['commit' => $commit]); + } + + expect($service->recent())->toHaveCount(2) + ->and(array_column($service->recent(), 'commit'))->toBe(['d', 'c']); +}); + +it('ignores entries without a commit hash', function () { + $service = app(DeployHistoryService::class); + + $service->record(['author' => 'Dmitry']); + $service->record(['commit' => '', 'author' => 'Dmitry']); + + expect($service->recent())->toBe([]); +});