From f547ae42bb7483dba5a6ab21454381c0eb012143 Mon Sep 17 00:00:00 2001 From: KodeStar Date: Wed, 8 Jul 2026 15:21:39 +0100 Subject: [PATCH] Return graceful output from get_stats instead of a 500 get_stats/{id} fataled when the item id was missing and 500'd whenever an enhanced app's livestats() threw - a broken or updated remote app definition (e.g. Komga) took the whole request down, and the frontend then stopped refreshing that tile entirely. getStats now returns valid JSON (200) with an inactive/empty payload when the item is missing, has no class, references a stale class, or throws, logging the failure for diagnosis. The successful path is unchanged and returns the livestats output verbatim. Resolves #1558 --- app/Http/Controllers/ItemController.php | 38 +++++++++-- tests/Feature/GetStatsTest.php | 87 +++++++++++++++++++++++++ 2 files changed, 120 insertions(+), 5 deletions(-) create mode 100644 tests/Feature/GetStatsTest.php diff --git a/app/Http/Controllers/ItemController.php b/app/Http/Controllers/ItemController.php index cd5492b1e..46a8ec238 100644 --- a/app/Http/Controllers/ItemController.php +++ b/app/Http/Controllers/ItemController.php @@ -587,18 +587,46 @@ public function websitelookup($url): StreamInterface } /** - * @param $id - * @return void + * Return live stats for an enhanced application tile. + * + * Always responds with HTTP 200 and valid JSON so the frontend refresh + * loop (liveStatRefresh.js) keeps re-queueing the tile. On any failure we + * degrade gracefully to an inactive, empty tile instead of a 500. + * + * @param int|string $id + * @return \Illuminate\Http\JsonResponse|\Illuminate\Http\Response */ public function getStats($id) { + $graceful = response()->json(['status' => 'inactive', 'html' => '']); + $item = Item::find($id); + if ($item === null) { + return $graceful; + } + + // Non-enhanced items (or stale records) have no live-stats class. + if (empty($item->class)) { + return $graceful; + } + + try { + $config = $item->getconfig(); + + // Guard against a stale/renamed class string from the remote apps repo. + if (! class_exists($item->class)) { + return $graceful; + } - $config = $item->getconfig(); - if (isset($item->class)) { $application = new $item->class; $application->config = $config; - echo $application->livestats(); + + // livestats() returns a JSON string; return it verbatim (no re-encoding). + return response($application->livestats()); + } catch (\Throwable $e) { + Log::error('getStats failed for item '.$id.' ('.$item->class.'): '.$e->getMessage()); + + return $graceful; } } diff --git a/tests/Feature/GetStatsTest.php b/tests/Feature/GetStatsTest.php new file mode 100644 index 000000000..de9d7bed0 --- /dev/null +++ b/tests/Feature/GetStatsTest.php @@ -0,0 +1,87 @@ + 'active', 'html' => 'ok']); + } +} + +class GetStatsTest extends TestCase +{ + use RefreshDatabase; + + public function test_missing_item_id_does_not_500(): void + { + $response = $this->get('get_stats/999999'); + + $response->assertStatus(200); + $response->assertJson(['status' => 'inactive', 'html' => '']); + } + + public function test_throwing_app_degrades_gracefully(): void + { + $item = Item::factory()->create([ + 'class' => ThrowingStatApp::class, + ]); + + $response = $this->get('get_stats/'.$item->id); + + $response->assertStatus(200); + $response->assertJson(['status' => 'inactive', 'html' => '']); + } + + public function test_item_with_no_class_degrades_gracefully(): void + { + $item = Item::factory()->create([ + 'class' => null, + ]); + + $response = $this->get('get_stats/'.$item->id); + + $response->assertStatus(200); + $response->assertJson(['status' => 'inactive', 'html' => '']); + } + + public function test_happy_path_returns_livestats_output_verbatim(): void + { + $item = Item::factory()->create([ + 'class' => HappyStatApp::class, + ]); + + $expected = json_encode(['status' => 'active', 'html' => 'ok']); + + $response = $this->get('get_stats/'.$item->id); + + $response->assertStatus(200); + $this->assertSame($expected, $response->getContent()); + $response->assertJson(['status' => 'active', 'html' => 'ok']); + } +}