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 }}
+
+
+
+
+
+ |
+ Device Time
+ |
+
+ Log Level
+ |
+
+ Device Status
+ |
+
+ Message
+ |
+
+
+
+
+ @foreach ($logs as $log)
+
+ |
+ {{ \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'] }}
+
+
+
+
+ |
+
+
+ @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
+
+
+
+
+
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');
+});