feat(CORE-001D3B): surface alert history degraded state
This commit is contained in:
@@ -8,6 +8,12 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
class AlertHistoryLoadError(RuntimeError):
|
||||
def __init__(self, history_path: Path, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.history_path = history_path
|
||||
|
||||
|
||||
@dataclass
|
||||
class AlertEvent:
|
||||
severity: str
|
||||
@@ -36,6 +42,8 @@ class AlertStatus:
|
||||
critical_threshold: float
|
||||
email_alerts_enabled: bool
|
||||
history: list[AlertEvent]
|
||||
history_unavailable: bool = False
|
||||
history_notice: str | None = None
|
||||
|
||||
|
||||
class AlertHistoryRepository:
|
||||
@@ -53,10 +61,12 @@ class AlertHistoryRepository:
|
||||
try:
|
||||
with self.history_path.open() as f:
|
||||
data = json.load(f)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return []
|
||||
except json.JSONDecodeError as exc:
|
||||
raise AlertHistoryLoadError(self.history_path, f"Alert history is not valid JSON: {exc}") from exc
|
||||
except OSError as exc:
|
||||
raise AlertHistoryLoadError(self.history_path, f"Alert history could not be read: {exc}") from exc
|
||||
if not isinstance(data, list):
|
||||
return []
|
||||
raise AlertHistoryLoadError(self.history_path, "Alert history payload must be a list")
|
||||
return [AlertEvent.from_dict(item) for item in data if isinstance(item, dict)]
|
||||
|
||||
def save(self, events: list[AlertEvent]) -> None:
|
||||
|
||||
@@ -170,6 +170,8 @@ async def overview_page(workspace_id: str) -> None:
|
||||
f"Warning at {alert_status.warning_threshold:.0%} · Critical at {alert_status.critical_threshold:.0%} · "
|
||||
f"Email alerts {'enabled' if alert_status.email_alerts_enabled else 'disabled'}"
|
||||
).classes("text-sm text-slate-500 dark:text-slate-400")
|
||||
if alert_status.history_notice:
|
||||
ui.label(alert_status.history_notice).classes("text-sm text-amber-700 dark:text-amber-300")
|
||||
if alert_status.history:
|
||||
latest = alert_status.history[0]
|
||||
ui.label(
|
||||
@@ -211,9 +213,7 @@ async def overview_page(workspace_id: str) -> None:
|
||||
ui.label(value).classes("text-2xl font-bold text-slate-900 dark:text-slate-50")
|
||||
ui.label(caption).classes("text-sm text-slate-500 dark:text-slate-400")
|
||||
|
||||
with ui.card().classes(
|
||||
f"w-full rounded-2xl border shadow-sm {recommendation_style('info')}"
|
||||
):
|
||||
with ui.card().classes(f"w-full rounded-2xl border shadow-sm {recommendation_style('info')}"):
|
||||
ui.label("Quick Strategy Recommendations").classes(
|
||||
"text-lg font-semibold text-slate-900 dark:text-slate-100"
|
||||
)
|
||||
@@ -265,6 +265,8 @@ async def overview_page(workspace_id: str) -> None:
|
||||
f"Logged {_format_timestamp(event.updated_at)} · Spot ${event.spot_price:,.2f} · LTV {event.ltv_ratio:.1%}"
|
||||
).classes("text-xs text-slate-500 dark:text-slate-400")
|
||||
ui.label(event.severity.upper()).classes(_alert_badge_classes(event.severity))
|
||||
elif alert_status.history_notice:
|
||||
ui.label(alert_status.history_notice).classes("text-sm text-amber-700 dark:text-amber-300")
|
||||
else:
|
||||
ui.label(
|
||||
"No alert history yet. Alerts will be logged once the warning threshold is crossed."
|
||||
|
||||
@@ -352,7 +352,10 @@ def settings_page(workspace_id: str) -> None:
|
||||
alert_message.set_text(alert_status.message)
|
||||
status.set_text(_save_card_status_text(last_saved_config, preview_config=preview_config))
|
||||
alert_history_column.clear()
|
||||
if alert_status.history:
|
||||
if alert_status.history_notice:
|
||||
with alert_history_column:
|
||||
ui.label(alert_status.history_notice).classes("text-sm text-amber-700 dark:text-amber-300")
|
||||
elif alert_status.history:
|
||||
for event in alert_status.history[:5]:
|
||||
with alert_history_column:
|
||||
with ui.row().classes(
|
||||
|
||||
@@ -2,15 +2,18 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
from typing import Mapping
|
||||
|
||||
from app.domain.portfolio_math import build_alert_context
|
||||
from app.models.alerts import AlertEvent, AlertHistoryRepository, AlertStatus
|
||||
from app.models.alerts import AlertEvent, AlertHistoryLoadError, AlertHistoryRepository, AlertStatus
|
||||
from app.models.portfolio import PortfolioConfig
|
||||
from app.services.boundary_values import boundary_decimal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AlertEvaluationInput:
|
||||
@@ -74,7 +77,18 @@ class AlertService:
|
||||
def evaluate(
|
||||
self, config: PortfolioConfig, portfolio: Mapping[str, object], *, persist: bool = True
|
||||
) -> AlertStatus:
|
||||
history = self.repository.load() if persist else []
|
||||
history: list[AlertEvent] = []
|
||||
history_unavailable = False
|
||||
history_notice: str | None = None
|
||||
try:
|
||||
history = self.repository.load()
|
||||
except AlertHistoryLoadError as exc:
|
||||
history_unavailable = True
|
||||
history_notice = (
|
||||
"Alert history is temporarily unavailable due to a storage error. New alerts are not being recorded."
|
||||
)
|
||||
logger.warning("Alert history unavailable at %s: %s", exc.history_path, exc)
|
||||
|
||||
evaluation = _normalize_alert_evaluation_input(config, portfolio)
|
||||
|
||||
if evaluation.ltv_ratio >= evaluation.critical_threshold:
|
||||
@@ -106,12 +120,21 @@ class AlertService:
|
||||
email_alerts_enabled=evaluation.email_alerts_enabled,
|
||||
)
|
||||
if persist:
|
||||
if self._should_record(history, event):
|
||||
if not history_unavailable and self._should_record(history, event):
|
||||
history.append(event)
|
||||
self.repository.save(history)
|
||||
else:
|
||||
preview_history = [event]
|
||||
|
||||
if not persist:
|
||||
resolved_history = preview_history
|
||||
elif history_unavailable:
|
||||
resolved_history = []
|
||||
elif severity != "ok":
|
||||
resolved_history = list(reversed(self.repository.load()))
|
||||
else:
|
||||
resolved_history = history
|
||||
|
||||
return AlertStatus(
|
||||
severity=severity,
|
||||
message=message,
|
||||
@@ -119,11 +142,9 @@ class AlertService:
|
||||
warning_threshold=float(evaluation.warning_threshold),
|
||||
critical_threshold=float(evaluation.critical_threshold),
|
||||
email_alerts_enabled=evaluation.email_alerts_enabled,
|
||||
history=(
|
||||
preview_history
|
||||
if not persist
|
||||
else list(reversed(self.repository.load() if severity != "ok" else history))
|
||||
),
|
||||
history=resolved_history,
|
||||
history_unavailable=history_unavailable,
|
||||
history_notice=history_notice,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
||||
Reference in New Issue
Block a user