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']);
+ }
+}