From 715db9ec36715c55a9b2990e97f5dc67215273ac Mon Sep 17 00:00:00 2001 From: Luna Date: Tue, 30 Jun 2026 06:01:53 -0700 Subject: [PATCH] Add tray status model Co-Authored-By: Claude Opus 4.8 --- enodia_sentinel/tray/__init__.py | 2 ++ enodia_sentinel/tray/state.py | 49 ++++++++++++++++++++++++++++++++ tests/test_tray_state.py | 43 ++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+) create mode 100644 enodia_sentinel/tray/__init__.py create mode 100644 enodia_sentinel/tray/state.py create mode 100644 tests/test_tray_state.py diff --git a/enodia_sentinel/tray/__init__.py b/enodia_sentinel/tray/__init__.py new file mode 100644 index 0000000..65338f6 --- /dev/null +++ b/enodia_sentinel/tray/__init__.py @@ -0,0 +1,2 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +"""Optional desktop tray applet. GUI deps live behind the [tray] extra.""" diff --git a/enodia_sentinel/tray/state.py b/enodia_sentinel/tray/state.py new file mode 100644 index 0000000..736b8d0 --- /dev/null +++ b/enodia_sentinel/tray/state.py @@ -0,0 +1,49 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +"""Pure status model for the tray applet. No GUI or subprocess imports.""" +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class TrayState: + running: bool + total_alerts: int + high_alerts: int + last_alert: str | None + color: str # "green" | "amber" | "grey" + summary: str + + @property + def tooltip(self) -> str: + return f"Enodia Sentinel — {self.summary}" + + +def _high_count(counts: dict) -> int: + # Severity keys are stringified Severity names ("HIGH"/"CRITICAL"); be + # tolerant of case in case a producer lowercases them. + total = 0 + for key, value in counts.items(): + if str(key).upper() in ("HIGH", "CRITICAL"): + total += int(value) + return total + + +def parse_status(status: dict | None) -> TrayState: + if not status: + return TrayState(False, 0, 0, None, "grey", "Unknown") + running = bool(status.get("running")) + total = int(status.get("total_alerts", 0)) + high = _high_count(status.get("counts") or {}) + last = status.get("last_alert") + if not running: + return TrayState(False, total, high, last, "grey", "Stopped") + if high: + return TrayState( + True, total, high, last, "amber", + f"Running — {high} high/critical of {total} alerts", + ) + if total: + return TrayState(True, total, high, last, "green", + f"Running — {total} alerts") + return TrayState(True, total, high, last, "green", "Running — no alerts") diff --git a/tests/test_tray_state.py b/tests/test_tray_state.py new file mode 100644 index 0000000..d16cd58 --- /dev/null +++ b/tests/test_tray_state.py @@ -0,0 +1,43 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +"""Tray status model: maps `status --json` to a TrayState.""" +import unittest + +from enodia_sentinel.tray.state import TrayState, parse_status + + +class TestParseStatus(unittest.TestCase): + def test_none_is_unknown_grey(self): + s = parse_status(None) + self.assertFalse(s.running) + self.assertEqual(s.color, "grey") + self.assertEqual(s.summary, "Unknown") + + def test_stopped_is_grey(self): + s = parse_status({"running": False, "total_alerts": 3, + "counts": {"high": 1}}) + self.assertFalse(s.running) + self.assertEqual(s.color, "grey") + self.assertEqual(s.summary, "Stopped") + + def test_running_clean_is_green(self): + s = parse_status({"running": True, "total_alerts": 0, "counts": {}}) + self.assertEqual(s.color, "green") + self.assertIn("no alerts", s.summary) + + def test_running_with_medium_only_is_green(self): + s = parse_status({"running": True, "total_alerts": 2, + "counts": {"MEDIUM": 2}}) + self.assertEqual(s.color, "green") + self.assertIn("2 alerts", s.summary) + + def test_running_with_high_is_amber(self): + s = parse_status({"running": True, "total_alerts": 4, + "counts": {"HIGH": 1, "CRITICAL": 1, "MEDIUM": 2}}) + self.assertEqual(s.high_alerts, 2) + self.assertEqual(s.color, "amber") + self.assertIn("high/critical", s.summary) + + def test_tooltip_includes_summary(self): + s = parse_status({"running": True, "total_alerts": 0, "counts": {}}) + self.assertIn(s.summary, s.tooltip) + self.assertIn("Enodia Sentinel", s.tooltip)