From c045dc1e85d9f201989f0eb85dd71c5b1a4962c3 Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Sun, 1 Jun 2025 22:08:37 +0200 Subject: [PATCH] feat: inspect device logs feat: create DeviceLog model on log request feat: implement logs route, logs view feat: implement details on device log timezone, latest 50 log items sort by latest device timestamp cleanup job add tests --- app/Jobs/CleanupDeviceLogsJob.php | 31 +++ app/Models/Device.php | 5 + app/Models/DeviceLog.php | 27 +++ database/factories/DeviceLogFactory.php | 24 +++ ..._06_01_195732_create_device_logs_table.php | 24 +++ .../livewire/devices/configure.blade.php | 1 + .../views/livewire/devices/logs.blade.php | 193 ++++++++++++++++++ routes/api.php | 6 + routes/console.php | 3 + routes/web.php | 1 + .../Feature/Jobs/CleanupDeviceLogsJobTest.php | 44 ++++ tests/Unit/Models/DeviceLogTest.php | 66 ++++++ 12 files changed, 425 insertions(+) create mode 100644 app/Jobs/CleanupDeviceLogsJob.php create mode 100644 app/Models/DeviceLog.php create mode 100644 database/factories/DeviceLogFactory.php create mode 100644 database/migrations/2025_06_01_195732_create_device_logs_table.php create mode 100644 resources/views/livewire/devices/logs.blade.php create mode 100644 tests/Feature/Jobs/CleanupDeviceLogsJobTest.php create mode 100644 tests/Unit/Models/DeviceLogTest.php diff --git a/app/Jobs/CleanupDeviceLogsJob.php b/app/Jobs/CleanupDeviceLogsJob.php new file mode 100644 index 0000000..c1a2096 --- /dev/null +++ b/app/Jobs/CleanupDeviceLogsJob.php @@ -0,0 +1,31 @@ +logs()->latest('device_timestamp')->take(50)->pluck('id'); + + // Delete all other logs for this device + $device->logs() + ->whereNotIn('id', $keepIds) + ->delete(); + }); + } +} diff --git a/app/Models/Device.php b/app/Models/Device.php index beda368..4fe3508 100644 --- a/app/Models/Device.php +++ b/app/Models/Device.php @@ -142,4 +142,9 @@ class Device extends Model { return $this->belongsTo(Firmware::class, 'update_firmware_id'); } + + public function logs(): HasMany + { + return $this->hasMany(DeviceLog::class); + } } diff --git a/app/Models/DeviceLog.php b/app/Models/DeviceLog.php new file mode 100644 index 0000000..6f266ce --- /dev/null +++ b/app/Models/DeviceLog.php @@ -0,0 +1,27 @@ +belongsTo(Device::class); + } + + protected function casts(): array + { + return [ + 'log_entry' => 'array', + 'device_timestamp' => 'datetime', + ]; + } +} diff --git a/database/factories/DeviceLogFactory.php b/database/factories/DeviceLogFactory.php new file mode 100644 index 0000000..1250efc --- /dev/null +++ b/database/factories/DeviceLogFactory.php @@ -0,0 +1,24 @@ + ["creation_timestamp"=>fake()->dateTimeBetween('-1 month', 'now')->getTimestamp(),"device_status_stamp"=>["wifi_rssi_level"=>-65,"wifi_status"=>"connected","refresh_rate"=>900,"time_since_last_sleep_start"=>901,"current_fw_version"=>"1.5.5","special_function"=>"none","battery_voltage"=>4.052,"wakeup_reason"=>"timer","free_heap_size"=>215128,"max_alloc_size"=>192500],"log_id"=>17,"log_message"=>"Error fetching API display: 7, detail: HTTP Client failed with error: connection refused(-1)","log_codeline"=>586,"log_sourcefile"=>"src\/bl.cpp","additional_info"=>["filename_current"=>"UUID.png","filename_new"=>null,"retry_attempt"=>5]], + 'device_timestamp' => fake()->dateTimeBetween('-1 month', 'now'), + 'created_at' => Carbon::now(), + 'updated_at' => Carbon::now(), + 'device_id' => Device::first(), + ]; + } +} diff --git a/database/migrations/2025_06_01_195732_create_device_logs_table.php b/database/migrations/2025_06_01_195732_create_device_logs_table.php new file mode 100644 index 0000000..1fe3122 --- /dev/null +++ b/database/migrations/2025_06_01_195732_create_device_logs_table.php @@ -0,0 +1,24 @@ +id(); + $table->foreignIdFor(Device::class)->constrained('devices')->cascadeOnDelete(); + $table->timestamp('device_timestamp'); + $table->json('log_entry'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('device_logs'); + } +}; diff --git a/resources/views/livewire/devices/configure.blade.php b/resources/views/livewire/devices/configure.blade.php index aabac4a..8e19503 100644 --- a/resources/views/livewire/devices/configure.blade.php +++ b/resources/views/livewire/devices/configure.blade.php @@ -302,6 +302,7 @@ new class extends Component { Update Firmware + Show Logs Delete Device diff --git a/resources/views/livewire/devices/logs.blade.php b/resources/views/livewire/devices/logs.blade.php new file mode 100644 index 0000000..4f46a9f --- /dev/null +++ b/resources/views/livewire/devices/logs.blade.php @@ -0,0 +1,193 @@ +user()->devices->contains($device), 403); + $this->device = $device; + $this->logs = $device->logs()->latest('device_timestamp')->take(50)->get(); + } +} + +?> + +
+
+
+
+

Device Logs - {{ $device->name }}

+
+ + + + + + + + + + + + + @foreach ($logs as $log) + + + + + + + + @if(isset($log->log_entry['device_status_stamp'])) + +
+
+ Device Status Details +
+ +
+
+
WiFi Status:
+
{{ $log->log_entry['device_status_stamp']['wifi_status'] ?? 'Unknown' }}
+
+
+
WiFi RSSI:
+
{{ $log->log_entry['device_status_stamp']['wifi_rssi_level'] ?? 'Unknown' }} dBm
+
+
+
Refresh Rate:
+
{{ $log->log_entry['device_status_stamp']['refresh_rate'] ?? 'Unknown' }}s
+
+
+
Time Since Sleep:
+
{{ $log->log_entry['device_status_stamp']['time_since_last_sleep_start'] ?? 'Unknown' }}s
+
+
+
Firmware Version:
+
{{ $log->log_entry['device_status_stamp']['current_fw_version'] ?? 'Unknown' }}
+
+
+
Special Function:
+
{{ $log->log_entry['device_status_stamp']['special_function'] ?? 'None' }}
+
+
+
Battery Voltage:
+
{{ $log->log_entry['device_status_stamp']['battery_voltage'] ?? 'Unknown' }}V
+
+
+
Wakeup Reason:
+
{{ $log->log_entry['device_status_stamp']['wakeup_reason'] ?? 'Unknown' }}
+
+
+
Free Heap:
+
{{ $log->log_entry['device_status_stamp']['free_heap_size'] ?? 'Unknown' }} bytes
+
+
+
Max Alloc Size:
+
{{ $log->log_entry['device_status_stamp']['max_alloc_size'] ?? 'Unknown' }} bytes
+
+
+ +
+ + + Close + +
+
+
+ @endif + + +
+
+ Log Details +
+ +
+
+
Source File:
+
{{ $log->log_entry['log_sourcefile'] ?? 'Unknown' }}
+
+
+
Line Number:
+
{{ $log->log_entry['log_codeline'] ?? 'Unknown' }}
+
+ @if(isset($log->log_entry['additional_info'])) +
+
Additional Info
+
+ @foreach($log->log_entry['additional_info'] as $key => $value) +
+ {{ str_replace('_', ' ', ucfirst($key)) }}: + {{ is_null($value) ? 'None' : $value }} +
+ @endforeach +
+
+ @endif +
+ +
+ + + Close + +
+
+
+ @endforeach + +
+
Device Time
+
+
Log Level
+
+
Device Status
+
+
Message
+
+ {{ \Carbon\Carbon::createFromTimestamp($log->log_entry['creation_timestamp'])->setTimezone(config('app.timezone'))->format('Y-m-d H:i:s') }} + +
+ {{ str_contains(strtolower($log->log_entry['log_message']), 'error') ? 'Error' : + (str_contains(strtolower($log->log_entry['log_message']), 'warning') ? 'Warning' : 'Info') }} +
+
+
+
+ {{ $log->log_entry['device_status_stamp']['wifi_status'] ?? 'Unknown' }} + @if(isset($log->log_entry['device_status_stamp']['wifi_rssi_level'])) + ({{ $log->log_entry['device_status_stamp']['wifi_rssi_level'] }}dBm) + @endif +
+ @if(isset($log->log_entry['device_status_stamp'])) + + + + @endif +
+
+
+ {{ $log->log_entry['log_message'] }} + + + +
+
+
+
+
diff --git a/routes/api.php b/routes/api.php index 8556505..76610d7 100644 --- a/routes/api.php +++ b/routes/api.php @@ -2,6 +2,7 @@ use App\Jobs\GenerateScreenJob; use App\Models\Device; +use App\Models\DeviceLog; use App\Models\User; use App\Services\ImageGenerationService; use Illuminate\Http\Request; @@ -185,6 +186,11 @@ Route::post('/log', function (Request $request) { $logs = $request->json('log.logs_array', []); foreach ($logs as $log) { \Log::info('Device Log', $log); + DeviceLog::create([ + 'device_id' => $device->id, + 'device_timestamp' => $log['creation_timestamp'] ?? now(), + 'log_entry' => $log, + ]); } return response()->json([ diff --git a/routes/console.php b/routes/console.php index 8265a81..b0c43f3 100644 --- a/routes/console.php +++ b/routes/console.php @@ -1,7 +1,9 @@ cron( config('services.trmnl.proxy_refresh_cron') ? config('services.trmnl.proxy_refresh_cron') : @@ -9,3 +11,4 @@ Schedule::job(FetchProxyCloudResponses::class, [])->cron( ); Schedule::job(FirmwarePollJob::class)->daily(); +Schedule::job(CleanupDeviceLogsJob::class)->daily(); diff --git a/routes/web.php b/routes/web.php index c9210da..d2887e6 100644 --- a/routes/web.php +++ b/routes/web.php @@ -18,6 +18,7 @@ Route::middleware(['auth'])->group(function () { Volt::route('/devices', 'devices.manage')->name('devices'); Volt::route('/devices/{device}/configure', 'devices.configure')->name('devices.configure'); + Volt::route('/devices/{device}/logs', 'devices.logs')->name('devices.logs'); Volt::route('plugins', 'plugins.index')->name('plugins.index'); diff --git a/tests/Feature/Jobs/CleanupDeviceLogsJobTest.php b/tests/Feature/Jobs/CleanupDeviceLogsJobTest.php new file mode 100644 index 0000000..5d675f5 --- /dev/null +++ b/tests/Feature/Jobs/CleanupDeviceLogsJobTest.php @@ -0,0 +1,44 @@ +create(); + $device2 = Device::factory()->create(); + + // Create 60 logs for each device with different timestamps + for ($i = 0; $i < 60; $i++) { + DeviceLog::factory()->create([ + 'device_id' => $device1->id, + 'device_timestamp' => now()->subMinutes($i), + ]); + + DeviceLog::factory()->create([ + 'device_id' => $device2->id, + 'device_timestamp' => now()->subMinutes($i), + ]); + } + + // Run the cleanup job + CleanupDeviceLogsJob::dispatchSync(); + + // Assert each device has exactly 50 logs + expect($device1->logs()->count())->toBe(50) + ->and($device2->logs()->count())->toBe(50); + + // Assert the remaining logs are the most recent ones + $device1Logs = $device1->logs()->orderByDesc('device_timestamp')->get(); + $device2Logs = $device2->logs()->orderByDesc('device_timestamp')->get(); + + // Check that the timestamps are in descending order + for ($i = 0; $i < 49; $i++) { + expect($device1Logs[$i]->device_timestamp->gt($device1Logs[$i + 1]->device_timestamp))->toBeTrue() + ->and($device2Logs[$i]->device_timestamp->gt($device2Logs[$i + 1]->device_timestamp))->toBeTrue(); + } +}); diff --git a/tests/Unit/Models/DeviceLogTest.php b/tests/Unit/Models/DeviceLogTest.php new file mode 100644 index 0000000..545641f --- /dev/null +++ b/tests/Unit/Models/DeviceLogTest.php @@ -0,0 +1,66 @@ +create(); + $log = DeviceLog::factory()->create(['device_id' => $device->id]); + + expect($log->device)->toBeInstanceOf(Device::class) + ->and($log->device->id)->toBe($device->id); +}); + +test('device log casts log_entry to array', function () { + Device::factory()->create(); + $log = DeviceLog::factory()->create([ + 'log_entry' => [ + 'message' => 'test message', + 'level' => 'info', + 'timestamp' => time() + ] + ]); + + expect($log->log_entry)->toBeArray() + ->and($log->log_entry['message'])->toBe('test message') + ->and($log->log_entry['level'])->toBe('info'); +}); + +test('device log casts device_timestamp to datetime', function () { + Device::factory()->create(); + $timestamp = now(); + $log = DeviceLog::factory()->create([ + 'device_timestamp' => $timestamp + ]); + + expect($log->device_timestamp)->toBeInstanceOf(\Carbon\Carbon::class) + ->and($log->device_timestamp->timestamp)->toBe($timestamp->timestamp); +}); + +test('device log factory creates valid data', function () { + Device::factory()->create(); + $log = DeviceLog::factory()->create(); + + expect($log->device_id)->toBeInt() + ->and($log->device_timestamp)->toBeInstanceOf(\Carbon\Carbon::class) + ->and($log->log_entry)->toBeArray() + ->and($log->log_entry)->toHaveKeys(['creation_timestamp', 'device_status_stamp', 'log_id', 'log_message', 'log_codeline', 'log_sourcefile', 'additional_info']); +}); + +test('device log can be created with minimal required fields', function () { + $device = Device::factory()->create(); + $log = DeviceLog::create([ + 'device_id' => $device->id, + 'device_timestamp' => now(), + 'log_entry' => [ + 'message' => 'test message' + ] + ]); + + expect($log->exists)->toBeTrue() + ->and($log->device_id)->toBe($device->id) + ->and($log->log_entry['message'])->toBe('test message'); +});