Compare commits

..

4 Commits

Author SHA1 Message Date
10f9113547 Stabilize dashboard loading hotfix
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-17 23:19:22 +02:00
47fa8eb0ce Split dashboard views and compact detail loading
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-17 22:34:38 +02:00
bc4e33ddd8 Localize and streamline dashboard loading
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-17 21:56:24 +02:00
9419a9cd8c Add anomaly and performance monitoring
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-17 18:58:32 +02:00
14 changed files with 976 additions and 68 deletions

View File

@@ -19,6 +19,14 @@ nach einer ausdrücklichen Freigabe ausführen.
[`docs/V1_1_0_OPERATING_GUIDE.md`](docs/V1_1_0_OPERATING_GUIDE.md) [`docs/V1_1_0_OPERATING_GUIDE.md`](docs/V1_1_0_OPERATING_GUIDE.md)
- Version 1.2.0 adaptive Gewichtung, Rollback und Profile: - Version 1.2.0 adaptive Gewichtung, Rollback und Profile:
[`docs/V1_2_0_OPERATING_GUIDE.md`](docs/V1_2_0_OPERATING_GUIDE.md) [`docs/V1_2_0_OPERATING_GUIDE.md`](docs/V1_2_0_OPERATING_GUIDE.md)
- Version 1.3.0 Anomalie- und Performance-Überwachung:
[`docs/V1_3_0_OPERATING_GUIDE.md`](docs/V1_3_0_OPERATING_GUIDE.md)
- Version 1.4.0 deutsches Dashboard und gestufter Datenabruf:
[`docs/V1_4_0_OPERATING_GUIDE.md`](docs/V1_4_0_OPERATING_GUIDE.md)
- Version 1.5.0 Menü-Dashboard und kompakte Detaildaten:
[`docs/V1_5_0_OPERATING_GUIDE.md`](docs/V1_5_0_OPERATING_GUIDE.md)
- Version 1.5.1 Stabilisierung der Dashboard-Ladepfade:
[`docs/V1_5_1_OPERATING_GUIDE.md`](docs/V1_5_1_OPERATING_GUIDE.md)
- Arbeitsregeln für Coding-Agenten: [`AGENTS.md`](AGENTS.md) - Arbeitsregeln für Coding-Agenten: [`AGENTS.md`](AGENTS.md)
## Reifegrad ## Reifegrad

View File

@@ -1,5 +1,5 @@
name: SillyHome Next name: SillyHome Next
version: "1.2.0" version: "1.5.1"
slug: sillyhome_next slug: sillyhome_next
description: Lernt automatisch aus deinem Verhalten und steuert freigegebene Aktoren description: Lernt automatisch aus deinem Verhalten und steuert freigegebene Aktoren
url: http://192.168.6.31:3000/pino/sillyhome-next url: http://192.168.6.31:3000/pino/sillyhome-next

View File

@@ -223,6 +223,16 @@ class AutomationConflict(BaseModel):
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class AnomalyEvent(BaseModel):
anomaly_id: str = Field(pattern=r"^[a-z0-9_.-]{1,120}$")
severity: str = Field(default="info", max_length=20)
category: str = Field(max_length=40)
title: str = Field(min_length=1, max_length=160)
detail: str = Field(min_length=1, max_length=500)
detected_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
resolved: bool = False
class TimeProfile(BaseModel): class TimeProfile(BaseModel):
profile_id: str = Field(pattern=r"^[a-z0-9_-]{1,64}$") profile_id: str = Field(pattern=r"^[a-z0-9_-]{1,64}$")
label: str = Field(min_length=1, max_length=80) label: str = Field(min_length=1, max_length=80)
@@ -270,6 +280,7 @@ class BehaviorState(BaseModel):
adaptive_weight_updates: list[AdaptiveWeightUpdate] = Field(default_factory=list) adaptive_weight_updates: list[AdaptiveWeightUpdate] = Field(default_factory=list)
automation_conflicts: list[AutomationConflict] = Field(default_factory=list) automation_conflicts: list[AutomationConflict] = Field(default_factory=list)
time_profiles: list[TimeProfile] = Field(default_factory=list) time_profiles: list[TimeProfile] = Field(default_factory=list)
anomalies: list[AnomalyEvent] = Field(default_factory=list)
class ActuatorRecord(BaseModel): class ActuatorRecord(BaseModel):

View File

@@ -9,7 +9,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from app.actuators.lifecycle import ActuatorReconciliationService from app.actuators.lifecycle import ActuatorReconciliationService
from app.actuators.models import ActuatorRecord, ReconciliationState, SensorWeightGroup from app.actuators.models import ActuatorRecord, AnomalyEvent, ReconciliationState, SensorWeightGroup
from app.actuators.models import JobQueueItem, JobQueueState, JobStatus, SafetyProfile from app.actuators.models import JobQueueItem, JobQueueState, JobStatus, SafetyProfile
from app.actuators.store import ActuatorStore from app.actuators.store import ActuatorStore
from app.behavior.engine import BehaviorEngine from app.behavior.engine import BehaviorEngine
@@ -89,6 +89,8 @@ class ActuatorSummary(BaseModel):
activation_ready: bool activation_ready: bool
activation_reason: str activation_reason: str
sample_count: int sample_count: int
anomaly_count: int = 0
critical_anomaly_count: int = 0
prediction_target_state: str | None = None prediction_target_state: str | None = None
prediction_confidence: float | None = None prediction_confidence: float | None = None
updated_at: str updated_at: str
@@ -108,6 +110,12 @@ class DashboardSystemStatus(BaseModel):
configured_actuators: int = 0 configured_actuators: int = 0
trained_models: int = 0 trained_models: int = 0
review_required: int = 0 review_required: int = 0
performance_budget_ms: int = 3000
job_p95_duration_ms: int | None = None
slow_job_count: int = 0
performance_status: str = "unknown"
anomaly_count: int = 0
critical_anomaly_count: int = 0
class DashboardDiscoveryGroup(BaseModel): class DashboardDiscoveryGroup(BaseModel):
@@ -124,6 +132,12 @@ class DashboardOverview(BaseModel):
jobs: JobQueueState = Field(default_factory=JobQueueState) jobs: JobQueueState = Field(default_factory=JobQueueState)
class AnomalyOverview(BaseModel):
actuator_entity_id: str
friendly_name: str | None = None
anomalies: list[AnomalyEvent] = Field(default_factory=list)
@router.get("/discovery", response_model=list[HaEntitySummary]) @router.get("/discovery", response_model=list[HaEntitySummary])
def discover_actuators( def discover_actuators(
request: Request, request: Request,
@@ -266,6 +280,14 @@ def list_configured_summary(request: Request) -> list[ActuatorSummary]:
activation_ready=record.behavior.activation_ready, activation_ready=record.behavior.activation_ready,
activation_reason=record.behavior.activation_reason, activation_reason=record.behavior.activation_reason,
sample_count=record.behavior.sample_count, sample_count=record.behavior.sample_count,
anomaly_count=len([item for item in record.behavior.anomalies if not item.resolved]),
critical_anomaly_count=len(
[
item
for item in record.behavior.anomalies
if not item.resolved and item.severity == "critical"
]
),
prediction_target_state=( prediction_target_state=(
record.behavior.prediction.target_state record.behavior.prediction.target_state
if record.behavior.prediction is not None if record.behavior.prediction is not None
@@ -284,6 +306,25 @@ def list_configured_summary(request: Request) -> list[ActuatorSummary]:
@router.get("/dashboard", response_model=DashboardOverview) @router.get("/dashboard", response_model=DashboardOverview)
def dashboard_overview(request: Request) -> DashboardOverview: def dashboard_overview(request: Request) -> DashboardOverview:
return _dashboard_overview(request, include_background=True, include_actuators=True)
@router.get("/dashboard/start", response_model=DashboardOverview)
def dashboard_start(request: Request) -> DashboardOverview:
return _dashboard_overview(request, include_background=False, include_actuators=True)
@router.get("/dashboard/system", response_model=DashboardOverview)
def dashboard_system(request: Request) -> DashboardOverview:
return _dashboard_overview(request, include_background=False, include_actuators=False)
def _dashboard_overview(
request: Request,
*,
include_background: bool,
include_actuators: bool,
) -> DashboardOverview:
cache_payload = _load_entity_cache_payload(request) cache_payload = _load_entity_cache_payload(request)
raw_entities = cache_payload.get("entities", []) raw_entities = cache_payload.get("entities", [])
if not isinstance(raw_entities, list): if not isinstance(raw_entities, list):
@@ -295,16 +336,19 @@ def dashboard_overview(request: Request) -> DashboardOverview:
DashboardDiscoveryGroup.model_validate(group) DashboardDiscoveryGroup.model_validate(group)
for group in raw_groups for group in raw_groups
if isinstance(group, dict) if isinstance(group, dict)
] if isinstance(raw_groups, list) else [] ] if include_background and isinstance(raw_groups, list) else []
reconciliation = _reconciliation_state_or_default(request) reconciliation = _reconciliation_state_or_default(request)
ws_status = getattr(request.app.state, "ws_status", None) ws_status = getattr(request.app.state, "ws_status", None)
actuators = list_configured_summary(request) actuators = list_configured_summary(request) if include_actuators else []
store = getattr(request.app.state, "actuator_store", None) store = getattr(request.app.state, "actuator_store", None)
jobs = ( jobs = (
store.load_job_queue() store.load_job_queue()
if isinstance(store, ActuatorStore) if include_background and isinstance(store, ActuatorStore)
else JobQueueState() else JobQueueState()
) )
job_p95_duration_ms, slow_job_count, performance_status = _performance_status(jobs)
anomaly_count = sum(record.anomaly_count for record in actuators)
critical_anomaly_count = sum(record.critical_anomaly_count for record in actuators)
return DashboardOverview( return DashboardOverview(
system=DashboardSystemStatus( system=DashboardSystemStatus(
websocket_status=getattr(ws_status, "status", "unavailable"), websocket_status=getattr(ws_status, "status", "unavailable"),
@@ -314,9 +358,18 @@ def dashboard_overview(request: Request) -> DashboardOverview:
if reconciliation.last_completed_at is not None if reconciliation.last_completed_at is not None
else None else None
), ),
configured_actuators=len(actuators), configured_actuators=(
len(actuators)
if include_actuators
else reconciliation.configured_actuators
),
trained_models=reconciliation.trained_models, trained_models=reconciliation.trained_models,
review_required=reconciliation.review_required, review_required=reconciliation.review_required,
job_p95_duration_ms=job_p95_duration_ms,
slow_job_count=slow_job_count,
performance_status=performance_status,
anomaly_count=anomaly_count,
critical_anomaly_count=critical_anomaly_count,
), ),
cache=EntityCacheStatus( cache=EntityCacheStatus(
available=bool(raw_entities), available=bool(raw_entities),
@@ -329,6 +382,29 @@ def dashboard_overview(request: Request) -> DashboardOverview:
) )
@router.get("/anomalies", response_model=list[AnomalyOverview])
def list_anomalies(request: Request) -> list[AnomalyOverview]:
records = _service(request).list_configured()
entity_map = _load_cached_entity_map(
request,
{record.actuator_entity_id for record in records},
)
overview: list[AnomalyOverview] = []
for record in records:
active = [item for item in record.behavior.anomalies if not item.resolved]
if not active:
continue
entity = entity_map.get(record.actuator_entity_id)
overview.append(
AnomalyOverview(
actuator_entity_id=record.actuator_entity_id,
friendly_name=entity.friendly_name if entity is not None else None,
anomalies=active,
)
)
return overview
@router.get("", response_model=list[ActuatorRecord]) @router.get("", response_model=list[ActuatorRecord])
def list_configured(request: Request) -> list[ActuatorRecord]: def list_configured(request: Request) -> list[ActuatorRecord]:
return _service(request).list_configured() return _service(request).list_configured()
@@ -355,6 +431,47 @@ def get_actuator(actuator_entity_id: str, request: Request) -> ActuatorRecord:
raise HTTPException(status_code=404, detail=str(exc)) from exc raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.get("/{actuator_entity_id}/detail", response_model=ActuatorRecord)
def get_actuator_detail(actuator_entity_id: str, request: Request) -> ActuatorRecord:
try:
record = _service(request).get_actuator(actuator_entity_id)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
selected_ids = {
entity_id
for entity_id in [
record.assignment.selected_numeric_entity_id,
*record.assignment.selected_context_entity_ids,
]
if entity_id
}
compact_snapshots = [
snapshot.model_copy(update={"patterns": []})
for snapshot in record.behavior.model_snapshots[-5:]
]
compact_behavior = record.behavior.model_copy(
update={
"patterns": [],
"model_snapshots": compact_snapshots,
}
)
return record.model_copy(
update={
"behavior": compact_behavior,
"numeric_candidates": [
candidate
for candidate in record.numeric_candidates
if candidate.entity_id in selected_ids
],
"context_candidates": [
candidate
for candidate in record.context_candidates
if candidate.entity_id in selected_ids
],
}
)
@router.delete("/{actuator_entity_id}", status_code=204) @router.delete("/{actuator_entity_id}", status_code=204)
def delete_actuator(actuator_entity_id: str, request: Request) -> None: def delete_actuator(actuator_entity_id: str, request: Request) -> None:
_service(request).delete_actuator(actuator_entity_id) _service(request).delete_actuator(actuator_entity_id)
@@ -635,6 +752,26 @@ def _finish_job(
store.finish_job(job.job_id, status=status, summary=summary, error=error) store.finish_job(job.job_id, status=status, summary=summary, error=error)
def _performance_status(jobs: JobQueueState) -> tuple[int | None, int, str]:
budget_ms = 3000
durations = sorted(
job.duration_ms
for job in jobs.jobs
if job.status is JobStatus.COMPLETED and job.duration_ms is not None
)
slow_count = sum(1 for duration in durations if duration >= budget_ms)
if durations:
index = min(len(durations) - 1, int(round((len(durations) - 1) * 0.95)))
p95: int | None = durations[index]
status_value = "slow" if slow_count else "ok"
else:
p95 = None
status_value = "unknown"
if any(job.status is JobStatus.RUNNING for job in jobs.jobs):
status_value = "running" if status_value == "unknown" else status_value
return p95, slow_count, status_value
def _service(request: Request) -> ActuatorReconciliationService: def _service(request: Request) -> ActuatorReconciliationService:
service = getattr(request.app.state, "actuator_service", None) service = getattr(request.app.state, "actuator_service", None)
if not isinstance(service, ActuatorReconciliationService): if not isinstance(service, ActuatorReconciliationService):

View File

@@ -8,6 +8,7 @@ from zoneinfo import ZoneInfo
from app.actuators.models import ( from app.actuators.models import (
ActuatorRecord, ActuatorRecord,
AdaptiveWeightUpdate, AdaptiveWeightUpdate,
AnomalyEvent,
AutomationConflict, AutomationConflict,
BehaviorMode, BehaviorMode,
BehaviorPattern, BehaviorPattern,
@@ -88,6 +89,16 @@ class BehaviorEngine:
), ),
"last_trained_at": now, "last_trained_at": now,
"reason": "Noch kein geeigneter Kontext für Verhaltenslernen vorhanden.", "reason": "Noch kein geeigneter Kontext für Verhaltenslernen vorhanden.",
"anomalies": _detect_anomalies(
record,
now=now,
min_behavior_actions=self._settings.min_behavior_actions,
stale_hours=self._settings.retrain_stale_hours,
sample_count=0,
trusted_actions=0,
prediction=None,
safety_blockers=[],
),
} }
), ),
) )
@@ -128,6 +139,16 @@ class BehaviorEngine:
"patterns": [], "patterns": [],
"last_trained_at": now, "last_trained_at": now,
"reason": "Noch keine historischen Aktorhandlungen gefunden.", "reason": "Noch keine historischen Aktorhandlungen gefunden.",
"anomalies": _detect_anomalies(
record,
now=now,
min_behavior_actions=self._settings.min_behavior_actions,
stale_hours=self._settings.retrain_stale_hours,
sample_count=0,
trusted_actions=0,
prediction=None,
safety_blockers=[],
),
} }
), ),
) )
@@ -200,6 +221,16 @@ class BehaviorEngine:
reason, reason,
), ),
"active_model_version": model_version_id, "active_model_version": model_version_id,
"anomalies": _detect_anomalies(
record,
now=now,
min_behavior_actions=self._settings.min_behavior_actions,
stale_hours=self._settings.retrain_stale_hours,
sample_count=len(patterns),
trusted_actions=trusted_actions,
prediction=record.behavior.prediction,
safety_blockers=record.behavior.safety_blockers,
),
} }
) )
return self._save_behavior(record, behavior) return self._save_behavior(record, behavior)
@@ -326,6 +357,16 @@ class BehaviorEngine:
"assumptions": _assumption_lines(record), "assumptions": _assumption_lines(record),
"uncertainties": _uncertainty_lines(record, record.behavior.sample_count, record.behavior.high_confidence_sample_count), "uncertainties": _uncertainty_lines(record, record.behavior.sample_count, record.behavior.high_confidence_sample_count),
"safety_blockers": safety_blockers if prediction is not None else [], "safety_blockers": safety_blockers if prediction is not None else [],
"anomalies": _detect_anomalies(
record,
now=now,
min_behavior_actions=self._settings.min_behavior_actions,
stale_hours=self._settings.retrain_stale_hours,
sample_count=record.behavior.sample_count,
trusted_actions=record.behavior.high_confidence_sample_count,
prediction=prediction,
safety_blockers=safety_blockers if prediction is not None else [],
),
"confidence_trend": ( "confidence_trend": (
[*record.behavior.confidence_trend, round(prediction.confidence, 4)][-30:] [*record.behavior.confidence_trend, round(prediction.confidence, 4)][-30:]
if prediction is not None if prediction is not None
@@ -493,6 +534,18 @@ class BehaviorEngine:
*record.behavior.adaptive_weight_updates, *record.behavior.adaptive_weight_updates,
*adaptive_updates, *adaptive_updates,
][-50:], ][-50:],
"anomalies": _detect_anomalies(
record,
now=now,
min_behavior_actions=self._settings.min_behavior_actions,
stale_hours=self._settings.retrain_stale_hours,
sample_count=len(patterns),
trusted_actions=record.behavior.high_confidence_sample_count,
prediction=prediction,
safety_blockers=record.behavior.safety_blockers,
correct_feedback_count=correct_count,
incorrect_feedback_count=incorrect_count,
),
} }
) )
record_for_save = ( record_for_save = (
@@ -560,6 +613,20 @@ class BehaviorEngine:
"automation_conflicts": _automation_conflicts(record, related), "automation_conflicts": _automation_conflicts(record, related),
} }
) )
behavior = behavior.model_copy(
update={
"anomalies": _detect_anomalies(
record.model_copy(update={"behavior": behavior}),
now=datetime.now(timezone.utc),
min_behavior_actions=self._settings.min_behavior_actions,
stale_hours=self._settings.retrain_stale_hours,
sample_count=behavior.sample_count,
trusted_actions=behavior.high_confidence_sample_count,
prediction=behavior.prediction,
safety_blockers=behavior.safety_blockers,
)
}
)
return self._save_behavior(record, behavior) return self._save_behavior(record, behavior)
def set_automation_enabled( def set_automation_enabled(
@@ -1208,6 +1275,115 @@ def _automation_conflicts(
return conflicts return conflicts
def _detect_anomalies(
record: ActuatorRecord,
*,
now: datetime,
min_behavior_actions: int,
stale_hours: int,
sample_count: int,
trusted_actions: int,
prediction: BehaviorPrediction | None,
safety_blockers: list[str],
correct_feedback_count: int | None = None,
incorrect_feedback_count: int | None = None,
) -> list[AnomalyEvent]:
anomalies: list[AnomalyEvent] = []
def add(category: str, severity: str, title: str, detail: str) -> None:
anomalies.append(
AnomalyEvent(
anomaly_id=f"{record.actuator_entity_id}.{category}",
category=category,
severity=severity,
title=title,
detail=detail,
detected_at=now,
)
)
if not record.assignment.selected_context_entity_ids and not record.assignment.selected_numeric_entity_id:
add(
"missing_context",
"warning",
"Kein Kontext verbunden",
"Der Aktor hat keine Sensor-/Kontextbasis. Entscheidungen bleiben unsicher.",
)
if sample_count < min_behavior_actions:
add(
"low_samples",
"info",
"Zu wenig Lernbeispiele",
f"{sample_count} von {min_behavior_actions} benoetigten Handlungen gelernt.",
)
if trusted_actions < sample_count:
add(
"unclear_sources",
"info",
"Unklare Aktorhandlungen",
"Ein Teil der gelernten Handlungen stammt nicht eindeutig von Nutzer oder Automation.",
)
if record.behavior.last_trained_at is not None:
age = now - record.behavior.last_trained_at
if age > timedelta(hours=stale_hours):
add(
"stale_training",
"warning",
"Training ist veraltet",
f"Letztes Training liegt mehr als {stale_hours} Stunden zurueck.",
)
if prediction is not None and prediction.matching_patterns and prediction.confidence < record.behavior.safety.min_confidence:
add(
"low_confidence_prediction",
"warning",
"Vorhersage unter Sicherheitsgrenze",
(
f"Confidence {prediction.confidence:.0%} liegt unter "
f"{record.behavior.safety.min_confidence:.0%}."
),
)
if record.behavior.safety.manual_block:
add(
"manual_block",
"info",
"Manuelle Sicherheitssperre aktiv",
"Der Aktor ist bewusst gegen automatisches Schalten gesperrt.",
)
if safety_blockers:
add(
"safety_blockers",
"info",
"Safety blockiert aktuelle Aktion",
" ".join(safety_blockers)[:500],
)
if any(conflict.severity == "warning" for conflict in record.behavior.automation_conflicts):
add(
"automation_conflict",
"critical",
"Parallele Automation erkannt",
"SillyHome und mindestens eine passende HA-Automation koennen parallel schalten.",
)
correct = (
record.behavior.correct_feedback_count
if correct_feedback_count is None
else correct_feedback_count
)
incorrect = (
record.behavior.incorrect_feedback_count
if incorrect_feedback_count is None
else incorrect_feedback_count
)
total = correct + incorrect
if total >= 3 and incorrect / total >= 0.35:
add(
"feedback_error_rate",
"critical",
"Viele falsche Vorhersagen",
f"{incorrect} von {total} Feedbacks waren negativ. Modell pruefen oder Rollback nutzen.",
)
return anomalies[-30:]
def predict_behavior( def predict_behavior(
patterns: list[BehaviorPattern], patterns: list[BehaviorPattern],
*, *,

View File

@@ -105,7 +105,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
app = FastAPI( app = FastAPI(
title="SillyHome Next API", title="SillyHome Next API",
description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.", description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.",
version="1.2.0", version="1.5.1",
lifespan=lifespan, lifespan=lifespan,
) )
app.state.settings = load_settings() app.state.settings = load_settings()

View File

@@ -39,8 +39,10 @@
.header-actions label { margin:0; font-size:.82rem; } .header-actions label { margin:0; font-size:.82rem; }
.status-pill { display:flex; align-items:center; gap:8px; padding:8px 10px; border:1px solid var(--border); border-radius:8px; background:#101722; color:#d9e6f0; white-space:nowrap; } .status-pill { display:flex; align-items:center; gap:8px; padding:8px 10px; border:1px solid var(--border); border-radius:8px; background:#101722; color:#d9e6f0; white-space:nowrap; }
.dot { width:9px; height:9px; border-radius:50%; background:var(--complement); box-shadow:0 0 0 3px rgba(28,199,255,.15); } .dot { width:9px; height:9px; border-radius:50%; background:var(--complement); box-shadow:0 0 0 3px rgba(28,199,255,.15); }
main { display:grid; grid-template-columns:minmax(270px,.72fr) minmax(0,1.58fr); grid-template-areas:"control board" "control detail" "status status" "guide guide"; gap:12px; padding:12px; max-width:1480px; margin:0 auto; } main { display:block; padding:12px; max-width:1480px; margin:0 auto; }
section { background:var(--panel); border:1px solid var(--border); border-radius:8px; padding:12px; min-width:0; } section { background:var(--panel); border:1px solid var(--border); border-radius:8px; padding:12px; min-width:0; }
.app-view { display:none; }
.app-view.active { display:block; }
section:target { outline:2px solid var(--complement); outline-offset:2px; } section:target { outline:2px solid var(--complement); outline-offset:2px; }
.control-panel { grid-area:control; align-self:start; position:sticky; top:58px; } .control-panel { grid-area:control; align-self:start; position:sticky; top:58px; }
.board-panel { grid-area:board; } .board-panel { grid-area:board; }
@@ -54,6 +56,8 @@
.manual-context > summary, .manual-context > summary,
.group-panel > summary { cursor:pointer; font-weight:800; color:#eaf1f8; } .group-panel > summary { cursor:pointer; font-weight:800; color:#eaf1f8; }
details.collapsible > summary { list-style:none; display:flex; justify-content:space-between; gap:10px; } details.collapsible > summary { list-style:none; display:flex; justify-content:space-between; gap:10px; }
.manual-context > summary,
.group-panel > summary { display:flex; justify-content:space-between; gap:10px; align-items:center; }
details.collapsible > summary::-webkit-details-marker, details.collapsible > summary::-webkit-details-marker,
.manual-context > summary::-webkit-details-marker, .manual-context > summary::-webkit-details-marker,
.group-panel > summary::-webkit-details-marker { display:none; } .group-panel > summary::-webkit-details-marker { display:none; }
@@ -99,6 +103,8 @@
.metric strong { display:block; margin-bottom:4px; color:#cfe0ec; font-size:.84rem; } .metric strong { display:block; margin-bottom:4px; color:#cfe0ec; font-size:.84rem; }
.decision-list { display:grid; gap:8px; margin:10px 0; } .decision-list { display:grid; gap:8px; margin:10px 0; }
.decision-row { background:#121922; border:1px solid var(--border); border-radius:8px; padding:9px; min-width:0; overflow-wrap:anywhere; } .decision-row { background:#121922; border:1px solid var(--border); border-radius:8px; padding:9px; min-width:0; overflow-wrap:anywhere; }
.decision-row.slow,
.decision-row.critical { border-color:var(--warn); box-shadow:0 0 0 1px rgba(243,201,105,.25); }
.decision-row header { padding:0; border:0; background:transparent; display:flex; justify-content:space-between; gap:10px; flex-wrap:wrap; } .decision-row header { padding:0; border:0; background:transparent; display:flex; justify-content:space-between; gap:10px; flex-wrap:wrap; }
.actions { display:flex; flex-wrap:wrap; gap:8px; margin-top:10px; } .actions { display:flex; flex-wrap:wrap; gap:8px; margin-top:10px; }
.actions button { flex:1 1 180px; margin-top:0; } .actions button { flex:1 1 180px; margin-top:0; }
@@ -116,7 +122,7 @@
.topbar { display:grid; } .topbar { display:grid; }
.header-actions { min-width:0; } .header-actions { min-width:0; }
.status-pill { width:max-content; max-width:100%; white-space:normal; } .status-pill { width:max-content; max-width:100%; white-space:normal; }
main { display:block; padding:8px; } main { padding:8px; }
.control-panel { position:static; } .control-panel { position:static; }
section { margin-bottom:10px; padding:10px; border-radius:8px; } section { margin-bottom:10px; padding:10px; border-radius:8px; }
.steps { grid-template-columns:1fr; } .steps { grid-template-columns:1fr; }
@@ -145,19 +151,20 @@
</div> </div>
<div class="header-actions"> <div class="header-actions">
<label for="section-jump">Menü</label> <label for="section-jump">Menü</label>
<select id="section-jump" onchange="jumpToSection(this.value)"> <select id="section-jump" onchange="showView(this.value)">
<option value="#choose">Steuerung</option> <option value="status-section">Startseite / System</option>
<option value="#observed">Geräte</option> <option value="observed">Lernen</option>
<option value="#detail">Freigabe</option> <option value="detail">Details</option>
<option value="#status-section">System</option> <option value="choose">Discovery & Einrichtung</option>
<option value="#guide">Ablauf</option> <option value="settings">Einstellungen</option>
<option value="guide">Ablauf</option>
</select> </select>
<div class="status-pill"><span class="dot"></span><span id="load-budget">Seite bereit, Status folgt ...</span></div> <div class="status-pill"><span class="dot"></span><span id="load-budget">Seite bereit, Status folgt ...</span></div>
</div> </div>
</div> </div>
</header> </header>
<main> <main>
<section class="control-panel" id="choose"> <section class="control-panel app-view" id="choose">
<div class="panel-title"> <div class="panel-title">
<h2>Steuerung</h2> <h2>Steuerung</h2>
<span class="chip">v1</span> <span class="chip">v1</span>
@@ -208,7 +215,7 @@
<div id="actuator-suggestions" class="card-list"></div> <div id="actuator-suggestions" class="card-list"></div>
</section> </section>
<section class="board-panel" id="observed"> <section class="board-panel app-view" id="observed">
<div class="panel-title"> <div class="panel-title">
<div> <div>
<h2>Beobachtete Geräte</h2> <h2>Beobachtete Geräte</h2>
@@ -219,13 +226,13 @@
<div id="configured-actuators">Noch nicht geladen.</div> <div id="configured-actuators">Noch nicht geladen.</div>
</section> </section>
<section class="detail-panel" id="detail"> <section class="detail-panel app-view" id="detail">
<h2>Lernfortschritt und Freigabe</h2> <h2>Lernfortschritt und Freigabe</h2>
<p class="muted">Die Freigabe erscheint erst, wenn genug eindeutig zugeordnete Handlungen gelernt wurden. Vorher bleibt das Gerät sicher im Beobachtungsmodus.</p> <p class="muted">Die Freigabe erscheint erst, wenn genug eindeutig zugeordnete Handlungen gelernt wurden. Vorher bleibt das Gerät sicher im Beobachtungsmodus.</p>
<div id="actuator-detail" class="muted">Öffne bei einem beobachteten Gerät die Details.</div> <div id="actuator-detail" class="muted">Öffne bei einem beobachteten Gerät die Details.</div>
</section> </section>
<section class="status-panel" id="status-section"> <section class="status-panel app-view active" id="status-section">
<div class="panel-title"> <div class="panel-title">
<div> <div>
<h2>System & Cache</h2> <h2>System & Cache</h2>
@@ -239,7 +246,30 @@
<div id="job-queue" class="decision-list"></div> <div id="job-queue" class="decision-list"></div>
</section> </section>
<section class="guide-panel" id="guide"> <section class="guide-panel app-view" id="settings">
<div class="panel-title">
<div>
<h2>Einstellungen</h2>
<p class="muted">Sprache und Standardwerte für die Bedienoberfläche.</p>
</div>
</div>
<div class="grid-two">
<div>
<label for="language-select">Sprache</label>
<select id="language-select" onchange="setLanguage(this.value)">
<option value="de">Deutsch</option>
<option value="en">English</option>
</select>
<p class="muted">Die API speichert stabile technische Werte. Die Oberfläche übersetzt sie in die gewählte Sprache.</p>
</div>
<div>
<h3>Performance-Standard</h3>
<p>Startansichten dürfen maximal 3 Sekunden brauchen. Schwere Daten werden nur nach Menüwechsel oder bei Bearbeitung geladen.</p>
</div>
</div>
</section>
<section class="guide-panel app-view" id="guide">
<details class="collapsible"> <details class="collapsible">
<summary><span>So gehst du vor</span></summary> <summary><span>So gehst du vor</span></summary>
<div class="steps"> <div class="steps">
@@ -281,15 +311,201 @@ let cachedEntities = null;
let cachedDiscovery = null; let cachedDiscovery = null;
let discoveryLoadPromise = null; let discoveryLoadPromise = null;
let currentSensorWeightGroups = []; let currentSensorWeightGroups = [];
let visibleActuatorLimit = 24;
const ACTUATOR_RESULT_LIMIT = 50; const ACTUATOR_RESULT_LIMIT = 50;
const STATUS_TIMEOUT_MS = 2000; const STATUS_TIMEOUT_MS = 2000;
const DASHBOARD_TIMEOUT_MS = 4500; const DASHBOARD_TIMEOUT_MS = 3000;
const I18N = {
de: {
safety_stage: {
observe: "Nur beobachten",
suggest: "Vorschläge anzeigen",
shadow: "Prüfmodus ohne Schalten",
partial: "Teilfreigabe",
active: "Aktiv freigegeben",
},
behavior_mode: {
shadow: "Prüfmodus",
active: "Aktiv",
paused: "Pausiert",
},
behavior_status: {
collecting: "Sammelt Lernbeispiele",
trained: "Gelernt",
blocked: "Blockiert",
},
lifecycle_status: {
trained: "gelernt",
pending_history: "sammelt Historie",
pending_assignment: "sucht Kontext",
review_required: "bitte prüfen",
archived: "wartet",
orphaned: "Aktor fehlt",
stale: "Training veraltet",
invalid: "ungültig",
},
job_status: {
pending: "wartet",
running: "läuft",
completed: "abgeschlossen",
failed: "fehlgeschlagen",
},
job_kind: {
discovery: "Geräte-Erkennung",
reconciliation: "Abgleich",
training: "Training",
evaluation: "Auswertung",
automation_refresh: "Automation-Prüfung",
},
severity: {
info: "Hinweis",
warning: "Warnung",
critical: "Kritisch",
},
anomaly_category: {
missing_context: "fehlender Kontext",
low_samples: "zu wenig Lernbeispiele",
unclear_sources: "unklare Quellen",
stale_training: "veraltetes Training",
low_confidence_prediction: "geringe Sicherheit",
manual_block: "manuelle Sperre",
safety_blockers: "Sicherheitsblocker",
automation_conflict: "Automation-Konflikt",
feedback_error_rate: "Feedback-Fehlerquote",
},
performance_status: {
ok: "schnell",
slow: "zu langsam",
running: "läuft",
unknown: "noch offen",
},
connection_status: {
connected: "verbunden",
disconnected: "getrennt",
unavailable: "nicht verfügbar",
error: "Fehler",
},
},
en: {
safety_stage: {
observe: "Observe only",
suggest: "Show suggestions",
shadow: "Review mode without switching",
partial: "Partial approval",
active: "Active approval",
},
behavior_mode: {
shadow: "Review mode",
active: "Active",
paused: "Paused",
},
behavior_status: {
collecting: "Collecting examples",
trained: "Learned",
blocked: "Blocked",
},
lifecycle_status: {
trained: "learned",
pending_history: "collecting history",
pending_assignment: "finding context",
review_required: "review required",
archived: "waiting",
orphaned: "actuator missing",
stale: "training stale",
invalid: "invalid",
},
job_status: {
pending: "waiting",
running: "running",
completed: "completed",
failed: "failed",
},
job_kind: {
discovery: "Discovery",
reconciliation: "Reconciliation",
training: "Training",
evaluation: "Evaluation",
automation_refresh: "Automation check",
},
severity: {
info: "Info",
warning: "Warning",
critical: "Critical",
},
anomaly_category: {
missing_context: "missing context",
low_samples: "not enough samples",
unclear_sources: "unclear sources",
stale_training: "stale training",
low_confidence_prediction: "low confidence",
manual_block: "manual block",
safety_blockers: "safety blockers",
automation_conflict: "automation conflict",
feedback_error_rate: "feedback error rate",
},
performance_status: {
ok: "fast",
slow: "too slow",
running: "running",
unknown: "unknown",
},
connection_status: {
connected: "connected",
disconnected: "disconnected",
unavailable: "unavailable",
error: "error",
},
},
};
let uiLang = localStorage.getItem("sillyhome.ui.language") || "de";
function jumpToSection(target) { function jumpToSection(target) {
if (!target) return; if (!target) return;
document.querySelector(target)?.scrollIntoView({behavior: "smooth", block: "start"}); document.querySelector(target)?.scrollIntoView({behavior: "smooth", block: "start"});
} }
function showView(viewId) {
for (const section of document.querySelectorAll(".app-view")) {
section.classList.toggle("active", section.id === viewId);
}
localStorage.setItem("sillyhome.ui.view", viewId);
if (viewId === "status-section") {
void loadSystemOverview();
} else if (viewId === "observed") {
void loadOverview();
} else if (viewId === "settings") {
syncSettingsView();
}
document.getElementById(viewId)?.scrollIntoView({behavior: "smooth", block: "start"});
}
function setLanguage(language) {
uiLang = I18N[language] ? language : "de";
localStorage.setItem("sillyhome.ui.language", uiLang);
syncSettingsView();
if (cachedActuators) renderConfiguredActuators();
void loadSystemOverview();
if (currentActuatorId) void showActuator(currentActuatorId);
}
function syncSettingsView() {
const select = document.getElementById("language-select");
if (select) select.value = uiLang;
}
function translate(group, value, fallback = "") {
if (value == null || value === "") return fallback || "offen";
return I18N[uiLang]?.[group]?.[value] || fallback || String(value);
}
function formatDateTime(value) {
if (!value) return "noch offen";
const parsed = new Date(value);
return Number.isNaN(parsed.getTime())
? String(value)
: parsed.toLocaleString("de-DE");
}
function uniqueValues(values) { function uniqueValues(values) {
return [...new Set(values.filter(Boolean))]; return [...new Set(values.filter(Boolean))];
} }
@@ -312,6 +528,11 @@ async function apiWithTimeout(path, timeoutMs = STATUS_TIMEOUT_MS) {
const timeout = setTimeout(() => controller.abort(), timeoutMs); const timeout = setTimeout(() => controller.abort(), timeoutMs);
try { try {
return await api(path, {signal: controller.signal}); return await api(path, {signal: controller.signal});
} catch (error) {
if (error?.name === "AbortError") {
throw new Error("Zeitlimit erreicht; Daten laden im Hintergrund weiter.");
}
throw error;
} finally { } finally {
clearTimeout(timeout); clearTimeout(timeout);
} }
@@ -322,12 +543,12 @@ function lifecycleLabel(record) {
const lifecycleStatus = record.lifecycle_status || record.lifecycle?.status; const lifecycleStatus = record.lifecycle_status || record.lifecycle?.status;
if (behaviorStatus === "trained") return "Kontext erkannt"; if (behaviorStatus === "trained") return "Kontext erkannt";
const labels = { const labels = {
trained: "lernt", trained: translate("lifecycle_status", "trained"),
pending_history: "sammelt Historie", pending_history: translate("lifecycle_status", "pending_history"),
pending_assignment: "sucht Kontext", pending_assignment: translate("lifecycle_status", "pending_assignment"),
review_required: "geringe Zuordnungssicherheit", review_required: translate("lifecycle_status", "review_required"),
archived: "wartet auf Kontext", archived: translate("lifecycle_status", "archived"),
orphaned: "Aktor nicht gefunden", orphaned: translate("lifecycle_status", "orphaned"),
}; };
return labels[lifecycleStatus] || lifecycleStatus; return labels[lifecycleStatus] || lifecycleStatus;
} }
@@ -345,7 +566,7 @@ function behaviorLabel(record) {
const mode = record.behavior_mode || record.behavior?.mode; const mode = record.behavior_mode || record.behavior?.mode;
const status = record.behavior_status || record.behavior?.status; const status = record.behavior_status || record.behavior?.status;
if (mode === "active") return "aktiv freigegeben"; if (mode === "active") return "aktiv freigegeben";
if (status === "trained") return "Shadow-Vorhersage"; if (status === "trained") return "Prüfmodus mit Vorhersage";
if (status === "blocked") return "Lernen blockiert"; if (status === "blocked") return "Lernen blockiert";
return "sammelt Handlungen"; return "sammelt Handlungen";
} }
@@ -438,12 +659,18 @@ async function loadOverview() {
if (budget) budget.textContent = "Startdaten laden ..."; if (budget) budget.textContent = "Startdaten laden ...";
document.getElementById("configured-actuators").innerHTML = "<p class='muted'>Beobachtete Geräte werden geladen ...</p>"; document.getElementById("configured-actuators").innerHTML = "<p class='muted'>Beobachtete Geräte werden geladen ...</p>";
try { try {
const dashboard = await apiWithTimeout("v1/actuators/dashboard", DASHBOARD_TIMEOUT_MS); const dashboard = await api("v1/actuators/dashboard/start");
dashboard._load_elapsed_ms = Math.round(performance.now() - startedAt);
cachedActuators = dashboard.actuators || []; cachedActuators = dashboard.actuators || [];
cachedEntities = []; cachedEntities = [];
renderDashboardStatus(dashboard); renderDashboardStatus(dashboard);
renderConfiguredActuators(); renderConfiguredActuators();
if (budget) budget.textContent = `Bereit in ${Math.round(performance.now() - startedAt)} ms`; if (budget) {
const loadMs = dashboard._load_elapsed_ms;
budget.textContent = loadMs <= DASHBOARD_TIMEOUT_MS
? `Bereit in ${loadMs} ms`
: `Langsam: ${loadMs} ms`;
}
} catch (error) { } catch (error) {
document.getElementById("configured-actuators").innerHTML = `<p class="bad">${escapeHtml(error.message)}</p>`; document.getElementById("configured-actuators").innerHTML = `<p class="bad">${escapeHtml(error.message)}</p>`;
if (budget) budget.textContent = "Startdaten verzögert"; if (budget) budget.textContent = "Startdaten verzögert";
@@ -454,6 +681,60 @@ async function loadOverview() {
document.getElementById("configured-actuators").innerHTML = "<div class='empty-state'>Startdaten sind gerade nicht verfügbar.</div>"; document.getElementById("configured-actuators").innerHTML = "<div class='empty-state'>Startdaten sind gerade nicht verfügbar.</div>";
} }
} }
scheduleDashboardExtras();
}
async function loadSystemOverview() {
const startedAt = performance.now();
const budget = document.getElementById("load-budget");
if (budget) budget.textContent = "Systemübersicht lädt ...";
try {
const dashboard = await api("v1/actuators/dashboard/system");
dashboard._load_elapsed_ms = Math.round(performance.now() - startedAt);
cachedActuators = dashboard.actuators || cachedActuators;
renderDashboardStatus(dashboard);
if (budget) {
const loadMs = dashboard._load_elapsed_ms;
budget.textContent = loadMs <= DASHBOARD_TIMEOUT_MS
? `Systemübersicht bereit in ${loadMs} ms`
: `Systemübersicht langsam: ${loadMs} ms`;
}
scheduleDashboardExtras();
} catch (error) {
document.getElementById("status").innerHTML = `<p class="warn">Systemübersicht verzögert: ${escapeHtml(error.message)}</p>`;
if (budget) budget.textContent = "Systemübersicht verzögert";
}
}
function scheduleDashboardExtras() {
const run = () => {
void loadDashboardExtras();
};
if ("requestIdleCallback" in window) {
window.requestIdleCallback(run, {timeout: 1800});
} else {
setTimeout(run, 250);
}
}
async function loadDashboardExtras() {
try {
const [jobs, reconciliation] = await Promise.allSettled([
apiWithTimeout("v1/actuators/job-queue/state", STATUS_TIMEOUT_MS),
apiWithTimeout("v1/actuators/reconciliation/state", STATUS_TIMEOUT_MS),
]);
if (jobs.status === "fulfilled") {
renderJobQueue(jobs.value.jobs || []);
}
if (reconciliation.status === "fulfilled") {
const text = document.getElementById("reconciliation-status");
if (text) {
text.textContent = `Letzte automatische Prüfung: ${formatDateTime(reconciliation.value.last_completed_at)}`;
}
}
} catch (_) {
// Die Startansicht bleibt auch ohne Hintergrunddaten bedienbar.
}
} }
async function loadStatus() { async function loadStatus() {
@@ -474,10 +755,10 @@ async function loadStatus() {
const hasError = values.some(value => value === null); const hasError = values.some(value => value === null);
status.innerHTML = hasError status.innerHTML = hasError
? "<p class='warn'>Status teilweise verfügbar. Das Dashboard bleibt bedienbar.</p>" ? "<p class='warn'>Status teilweise verfügbar. Das Dashboard bleibt bedienbar.</p>"
: `<p class="ok">System bereit</p><p>Letzte automatische Prüfung: ${escapeHtml(reconciliationValue.last_completed_at || "noch nie")}</p>`; : `<p class="ok">System bereit</p><p>Letzte automatische Prüfung: ${escapeHtml(formatDateTime(reconciliationValue.last_completed_at))}</p>`;
chips.innerHTML = [ chips.innerHTML = [
`<span class="chip">API: ${escapeHtml(healthValue?.status || "offen")}</span>`, `<span class="chip">API: ${escapeHtml(healthValue?.status || "offen")}</span>`,
`<span class="chip">WebSocket: ${escapeHtml(websocketValue?.status || "offen")}</span>`, `<span class="chip">WebSocket: ${escapeHtml(translate("connection_status", websocketValue?.status, websocketValue?.status || "offen"))}</span>`,
`<span class="chip">Lernsystem: ${escapeHtml(mlValue?.status || "offen")}</span>`, `<span class="chip">Lernsystem: ${escapeHtml(mlValue?.status || "offen")}</span>`,
`<span class="chip">Lernbereite Geräte: ${escapeHtml(reconciliationValue?.trained_models ?? "offen")}</span>`, `<span class="chip">Lernbereite Geräte: ${escapeHtml(reconciliationValue?.trained_models ?? "offen")}</span>`,
].join(""); ].join("");
@@ -491,7 +772,6 @@ function renderDashboardStatus(dashboard) {
const status = document.getElementById("status"); const status = document.getElementById("status");
const chips = document.getElementById("status-chips"); const chips = document.getElementById("status-chips");
const stats = document.getElementById("dashboard-stats"); const stats = document.getElementById("dashboard-stats");
const jobsBox = document.getElementById("job-queue");
const system = dashboard.system || {}; const system = dashboard.system || {};
const cache = dashboard.cache || {}; const cache = dashboard.cache || {};
const actuators = dashboard.actuators || []; const actuators = dashboard.actuators || [];
@@ -504,16 +784,27 @@ function renderDashboardStatus(dashboard) {
).length; ).length;
const trainedCount = actuators.filter(record => record.behavior_status === "trained").length; const trainedCount = actuators.filter(record => record.behavior_status === "trained").length;
const sampleTotal = actuators.reduce((sum, record) => sum + Number(record.sample_count || 0), 0); const sampleTotal = actuators.reduce((sum, record) => sum + Number(record.sample_count || 0), 0);
const anomalyTotal = Number(system.anomaly_count || 0);
const criticalAnomalyTotal = Number(system.critical_anomaly_count || 0);
const loadMs = Number(dashboard._load_elapsed_ms || 0);
const jobs = dashboard.jobs?.jobs || []; const jobs = dashboard.jobs?.jobs || [];
const runningJobs = jobs.filter(job => job.status === "running").length; const runningJobs = jobs.filter(job => job.status === "running").length;
const slowJobs = Number(system.slow_job_count || 0);
const p95 = system.job_p95_duration_ms == null ? "offen" : `${system.job_p95_duration_ms} ms`;
const performanceClass = (
loadMs > DASHBOARD_TIMEOUT_MS
|| slowJobs > 0
|| system.performance_status === "slow"
) ? "warn" : "ok";
const cacheLabel = cache.available const cacheLabel = cache.available
? `Cache aktuell mit ${cache.entity_count} Entities` ? `Cache aktuell mit ${cache.entity_count} Entities`
: "Cache wird nach Discovery aufgebaut"; : "Cache wird nach Discovery aufgebaut";
status.innerHTML = ` status.innerHTML = `
<p class="${system.websocket_status === "connected" ? "ok" : "warn"}"> <p class="${performanceClass}">
Dashboard bereit. WebSocket: ${escapeHtml(system.websocket_status || "unbekannt")} Dashboard bereit in ${escapeHtml(loadMs || "offen")} ms. Budget: ${escapeHtml(system.performance_budget_ms || DASHBOARD_TIMEOUT_MS)} ms.
</p> </p>
<p class="muted">Letzte automatische Prüfung: ${escapeHtml(system.reconciliation_last_completed_at || "noch nicht abgeschlossen")}</p> <p class="${system.websocket_status === "connected" ? "ok" : "warn"}">WebSocket: ${escapeHtml(translate("connection_status", system.websocket_status, system.websocket_status || "unbekannt"))}</p>
<p class="muted" id="reconciliation-status">Letzte automatische Prüfung: ${escapeHtml(formatDateTime(system.reconciliation_last_completed_at))}</p>
`; `;
chips.innerHTML = [ chips.innerHTML = [
`<span class="chip">API: ${escapeHtml(system.api_status || "ok")}</span>`, `<span class="chip">API: ${escapeHtml(system.api_status || "ok")}</span>`,
@@ -522,28 +813,40 @@ function renderDashboardStatus(dashboard) {
`<span class="chip">Lernbereit: ${escapeHtml(system.trained_models ?? 0)}</span>`, `<span class="chip">Lernbereit: ${escapeHtml(system.trained_models ?? 0)}</span>`,
`<span class="chip">Prüfen: ${escapeHtml(system.review_required ?? 0)}</span>`, `<span class="chip">Prüfen: ${escapeHtml(system.review_required ?? 0)}</span>`,
`<span class="chip">Jobs aktiv: ${escapeHtml(runningJobs)}</span>`, `<span class="chip">Jobs aktiv: ${escapeHtml(runningJobs)}</span>`,
`<span class="chip">Anomalien: ${escapeHtml(anomalyTotal)}</span>`,
`<span class="chip">Kritisch: ${escapeHtml(criticalAnomalyTotal)}</span>`,
].join(""); ].join("");
stats.innerHTML = [ stats.innerHTML = [
`<div class="metric"><strong>Geladene Startdaten</strong>${escapeHtml(actuators.length)} Geräte</div>`, `<div class="metric"><strong>Geladene Startdaten</strong>${escapeHtml(actuators.length)} Geräte</div>`,
`<div class="metric"><strong>Freigabebereit</strong>${escapeHtml(readyCount)} Geräte</div>`, `<div class="metric"><strong>Freigabebereit</strong>${escapeHtml(readyCount)} Geräte</div>`,
`<div class="metric"><strong>Aktiv / Shadow</strong>${escapeHtml(activeCount)} / ${escapeHtml(shadowCount)}</div>`, `<div class="metric"><strong>Aktiv / Prüfmodus</strong>${escapeHtml(activeCount)} / ${escapeHtml(shadowCount)}</div>`,
`<div class="metric"><strong>Gelernt / Wartet</strong>${escapeHtml(trainedCount)} / ${escapeHtml(pendingCount)}</div>`, `<div class="metric"><strong>Gelernt / Wartet</strong>${escapeHtml(trainedCount)} / ${escapeHtml(pendingCount)}</div>`,
`<div class="metric"><strong>Gelernte Handlungen</strong>${escapeHtml(sampleTotal)}</div>`, `<div class="metric"><strong>Gelernte Handlungen</strong>${escapeHtml(sampleTotal)}</div>`,
`<div class="metric"><strong>Performance-Budget</strong>${escapeHtml(system.performance_budget_ms || 3000)} ms</div>`,
`<div class="metric"><strong>Job p95</strong>${escapeHtml(p95)}</div>`,
`<div class="metric"><strong>Langsame Jobs</strong>${escapeHtml(slowJobs)}</div>`,
`<div class="metric"><strong>Anomalien</strong>${escapeHtml(anomalyTotal)} offen</div>`,
`<div class="metric"><strong>Discovery-Gruppen</strong>${escapeHtml(discoveryGroups.length)} Kategorien</div>`, `<div class="metric"><strong>Discovery-Gruppen</strong>${escapeHtml(discoveryGroups.length)} Kategorien</div>`,
`<div class="metric"><strong>Cache-Zeitpunkt</strong>${escapeHtml(cache.updated_at || "noch offen")}</div>`, `<div class="metric"><strong>Cache-Zeitpunkt</strong>${escapeHtml(cache.updated_at || "noch offen")}</div>`,
].join(""); ].join("");
renderJobQueue(jobs);
}
function renderJobQueue(jobs) {
const jobsBox = document.getElementById("job-queue");
if (!jobsBox) return;
jobsBox.innerHTML = jobs.length ? ` jobsBox.innerHTML = jobs.length ? `
<h3>Job-Queue</h3> <h3>Aufgabenliste</h3>
${jobs.slice(-6).reverse().map(job => ` ${jobs.slice(-6).reverse().map(job => `
<div class="decision-row"> <div class="decision-row ${Number(job.duration_ms || 0) >= DASHBOARD_TIMEOUT_MS ? "slow" : ""}">
<header> <header>
<strong>${escapeHtml(job.kind)}${job.target ? `: ${escapeHtml(job.target)}` : ""}</strong> <strong>${escapeHtml(translate("job_kind", job.kind, job.kind))}${job.target ? `: ${escapeHtml(job.target)}` : ""}</strong>
<span class="chip">${escapeHtml(job.status)}</span> <span class="chip">${escapeHtml(translate("job_status", job.status, job.status))}${Number(job.duration_ms || 0) >= DASHBOARD_TIMEOUT_MS ? " · langsam" : ""}</span>
</header> </header>
<p class="muted">${escapeHtml(job.summary || "Keine Zusammenfassung")}</p> <p class="muted">${escapeHtml(job.summary || "Keine Zusammenfassung")}</p>
<p class="muted">Start: ${escapeHtml(job.started_at || "offen")} · Dauer: ${escapeHtml(job.duration_ms == null ? "läuft/offen" : `${job.duration_ms} ms`)}</p> <p class="muted">Start: ${escapeHtml(formatDateTime(job.started_at))} · Dauer: ${escapeHtml(job.duration_ms == null ? "läuft/offen" : `${job.duration_ms} ms`)}</p>
${job.error ? `<p class="bad">${escapeHtml(job.error)}</p>` : ""} ${job.error ? `<p class="bad">${escapeHtml(job.error)}</p>` : ""}
${job.status === "failed" ? "<p class='warn'>Retry: Aktion im Dashboard erneut starten; der nächste Lauf schreibt einen neuen Queue-Eintrag.</p>" : ""} ${job.status === "failed" ? "<p class='warn'>Erneut versuchen: Aktion im Dashboard noch einmal starten; der nächste Lauf schreibt einen neuen Eintrag.</p>" : ""}
</div> </div>
`).join("")} `).join("")}
` : ""; ` : "";
@@ -771,13 +1074,17 @@ function renderConfiguredActuators() {
const box = document.getElementById("configured-actuators"); const box = document.getElementById("configured-actuators");
try { try {
const rows = cachedActuators || []; const rows = cachedActuators || [];
const visibleRows = rows.slice(0, visibleActuatorLimit);
const groups = new Map(); const groups = new Map();
for (const record of rows) { for (const record of visibleRows) {
const group = record.area_name || actuatorGroupLabel(record.domain || record.actuator_entity_id.split(".", 1)[0]); const group = record.area_name || actuatorGroupLabel(record.domain || record.actuator_entity_id.split(".", 1)[0]);
if (!groups.has(group)) groups.set(group, []); if (!groups.has(group)) groups.set(group, []);
groups.get(group).push({record}); groups.get(group).push({record});
} }
const groupedRows = [...groups.entries()].sort(([left], [right]) => left.localeCompare(right)); const groupedRows = [...groups.entries()].sort(([left], [right]) => left.localeCompare(right));
const moreButton = rows.length > visibleRows.length
? `<button class="secondary" onclick="visibleActuatorLimit += 24; renderConfiguredActuators()">Weitere ${Math.min(24, rows.length - visibleRows.length)} Geräte anzeigen</button>`
: "";
box.innerHTML = rows.length ? ` box.innerHTML = rows.length ? `
${groupedRows.map(([group, items]) => ` ${groupedRows.map(([group, items]) => `
<details class="group-panel"> <details class="group-panel">
@@ -811,7 +1118,9 @@ function renderConfiguredActuators() {
`).join("")} `).join("")}
</div> </div>
</details> </details>
`).join("")}` : "<p>Noch keine Aktoren ausgewählt.</p>"; `).join("")}
${moreButton}
` : "<p>Noch keine Aktoren ausgewählt.</p>";
} catch (error) { } catch (error) {
box.textContent = error.message; box.textContent = error.message;
} }
@@ -819,10 +1128,15 @@ function renderConfiguredActuators() {
async function showActuator(actuatorId, evaluationMessage = "") { async function showActuator(actuatorId, evaluationMessage = "") {
currentActuatorId = actuatorId; currentActuatorId = actuatorId;
for (const section of document.querySelectorAll(".app-view")) {
section.classList.toggle("active", section.id === "detail");
}
document.getElementById("section-jump").value = "detail";
localStorage.setItem("sillyhome.ui.view", "detail");
const box = document.getElementById("actuator-detail"); const box = document.getElementById("actuator-detail");
renderActuatorDetailShell(actuatorId); renderActuatorDetailShell(actuatorId);
try { try {
const record = await api(`v1/actuators/${encodeURIComponent(actuatorId)}`); const record = await api(`v1/actuators/${encodeURIComponent(actuatorId)}/detail`);
contextOptions = []; contextOptions = [];
const contexts = [ const contexts = [
record.assignment.selected_numeric_entity_id, record.assignment.selected_numeric_entity_id,
@@ -908,6 +1222,7 @@ async function showActuator(actuatorId, evaluationMessage = "") {
const adaptiveUpdates = record.behavior.adaptive_weight_updates || []; const adaptiveUpdates = record.behavior.adaptive_weight_updates || [];
const automationConflicts = record.behavior.automation_conflicts || []; const automationConflicts = record.behavior.automation_conflicts || [];
const timeProfiles = record.behavior.time_profiles || []; const timeProfiles = record.behavior.time_profiles || [];
const anomalies = (record.behavior.anomalies || []).filter(item => !item.resolved);
const safetyControls = ` const safetyControls = `
<details class="manual-context" open> <details class="manual-context" open>
<summary>Sicherheit und manuelles Gegensteuern</summary> <summary>Sicherheit und manuelles Gegensteuern</summary>
@@ -916,7 +1231,7 @@ async function showActuator(actuatorId, evaluationMessage = "") {
<label for="safety-stage">Freigabestufe</label> <label for="safety-stage">Freigabestufe</label>
<select id="safety-stage"> <select id="safety-stage">
${["observe", "suggest", "shadow", "partial", "active"].map(stage => ` ${["observe", "suggest", "shadow", "partial", "active"].map(stage => `
<option value="${stage}" ${safety.stage === stage ? "selected" : ""}>${stage}</option> <option value="${stage}" ${safety.stage === stage ? "selected" : ""}>${escapeHtml(translate("safety_stage", stage))}</option>
`).join("")} `).join("")}
</select> </select>
</div> </div>
@@ -975,7 +1290,7 @@ async function showActuator(actuatorId, evaluationMessage = "") {
${timeProfiles.length ? timeProfiles.map(profile => ` ${timeProfiles.length ? timeProfiles.map(profile => `
<div class="metric"> <div class="metric">
<strong>${escapeHtml(profile.label)}</strong> <strong>${escapeHtml(profile.label)}</strong>
${escapeHtml(profile.sample_count)} Samples · ${escapeHtml(profile.dominant_state || "offen")} ${escapeHtml(profile.sample_count)} Beispiele · ${escapeHtml(profile.dominant_state || "offen")}
<p class="muted">${Math.round((profile.confidence || 0) * 100)} % Profilklarheit</p> <p class="muted">${Math.round((profile.confidence || 0) * 100)} % Profilklarheit</p>
</div> </div>
`).join("") : "<div class='metric'><strong>Zeitprofile</strong>Noch keine Daten</div>"} `).join("") : "<div class='metric'><strong>Zeitprofile</strong>Noch keine Daten</div>"}
@@ -988,7 +1303,7 @@ async function showActuator(actuatorId, evaluationMessage = "") {
<strong>${escapeHtml(snapshot.version_id)}</strong> <strong>${escapeHtml(snapshot.version_id)}</strong>
<span class="chip">${snapshot.version_id === activeModelVersion ? "aktiv" : "Rollback möglich"}</span> <span class="chip">${snapshot.version_id === activeModelVersion ? "aktiv" : "Rollback möglich"}</span>
</header> </header>
<p class="muted">${escapeHtml(snapshot.sample_count)} Samples · ${escapeHtml(snapshot.high_confidence_sample_count)} eindeutig · Ø ${Math.round((snapshot.average_confidence || 0) * 100)} %</p> <p class="muted">${escapeHtml(snapshot.sample_count)} Beispiele · ${escapeHtml(snapshot.high_confidence_sample_count)} eindeutig · Ø ${Math.round((snapshot.average_confidence || 0) * 100)} %</p>
<p>${escapeHtml(snapshot.reason || "Kein Kommentar")}</p> <p>${escapeHtml(snapshot.reason || "Kein Kommentar")}</p>
${snapshot.version_id !== activeModelVersion ? `<button class="secondary compact" onclick="rollbackModel('${escapeHtml(record.actuator_entity_id)}', '${escapeHtml(snapshot.version_id)}')">Rollback</button>` : ""} ${snapshot.version_id !== activeModelVersion ? `<button class="secondary compact" onclick="rollbackModel('${escapeHtml(record.actuator_entity_id)}', '${escapeHtml(snapshot.version_id)}')">Rollback</button>` : ""}
</div> </div>
@@ -1000,13 +1315,28 @@ async function showActuator(actuatorId, evaluationMessage = "") {
`).join("") : "<li>Noch keine automatische Gewichtsanpassung.</li>"}</ul> `).join("") : "<li>Noch keine automatische Gewichtsanpassung.</li>"}</ul>
<h3>Automation-Konflikte</h3> <h3>Automation-Konflikte</h3>
<ul>${automationConflicts.length ? automationConflicts.map(conflict => ` <ul>${automationConflicts.length ? automationConflicts.map(conflict => `
<li><code>${escapeHtml(conflict.automation_entity_id)}</code>: <span class="${conflict.severity === "warning" ? "warn" : "muted"}">${escapeHtml(conflict.status)}</span> ${escapeHtml(conflict.reason)}</li> <li><code>${escapeHtml(conflict.automation_entity_id)}</code>: <span class="${conflict.severity === "warning" ? "warn" : "muted"}">${escapeHtml(translate("severity", conflict.severity, conflict.status))}</span> ${escapeHtml(conflict.reason)}</li>
`).join("") : "<li>Keine aktiven Automation-Konflikte erkannt.</li>"}</ul> `).join("") : "<li>Keine aktiven Automation-Konflikte erkannt.</li>"}</ul>
</details> </details>
`; `;
const learnedAutomationActions = record.behavior.patterns.filter( const anomalyPanel = `
pattern => pattern.source === "automation", <details class="manual-context" ${anomalies.length ? "open" : ""}>
).length; <summary>v1.3 Anomalie- und Performance-Hinweise</summary>
<div class="decision-list">
${anomalies.length ? anomalies.map(anomaly => `
<div class="decision-row ${anomaly.severity === "critical" ? "critical" : ""}">
<header>
<strong>${escapeHtml(anomaly.title)}</strong>
<span class="chip">${escapeHtml(translate("severity", anomaly.severity))} · ${escapeHtml(translate("anomaly_category", anomaly.category, anomaly.category))}</span>
</header>
<p>${escapeHtml(anomaly.detail)}</p>
<p class="muted">Erkannt: ${escapeHtml(formatDateTime(anomaly.detected_at))}</p>
</div>
`).join("") : "<p class='ok'>Keine offenen Anomalien fuer diesen Aktor.</p>"}
</div>
</details>
`;
const learnedAutomationActions = "wird bei Bedarf im Training ausgewertet";
const relatedAutomations = record.behavior.related_automations || []; const relatedAutomations = record.behavior.related_automations || [];
const manualContextIds = new Set(record.assignment.selected_context_entity_ids || []); const manualContextIds = new Set(record.assignment.selected_context_entity_ids || []);
const numericOptions = contextOptions.filter(entity => entity.domain === "sensor"); const numericOptions = contextOptions.filter(entity => entity.domain === "sensor");
@@ -1095,8 +1425,8 @@ async function showActuator(actuatorId, evaluationMessage = "") {
<p><strong>Betriebsart:</strong> ${escapeHtml(behaviorLabel(record))}</p> <p><strong>Betriebsart:</strong> ${escapeHtml(behaviorLabel(record))}</p>
<p><strong>Gelernte Handlungen:</strong> ${record.behavior.sample_count}</p> <p><strong>Gelernte Handlungen:</strong> ${record.behavior.sample_count}</p>
<p><strong>Davon eindeutig geregelt:</strong> ${record.behavior.high_confidence_sample_count}</p> <p><strong>Davon eindeutig geregelt:</strong> ${record.behavior.high_confidence_sample_count}</p>
<p><strong>Davon erkannte HA-Automationen:</strong> ${learnedAutomationActions}</p> <p><strong>Erkannte HA-Automationen:</strong> ${escapeHtml(learnedAutomationActions)}</p>
<p><strong>Letztes Training:</strong> ${escapeHtml(record.behavior.last_trained_at || "noch nicht")}</p> <p><strong>Letztes Training:</strong> ${escapeHtml(formatDateTime(record.behavior.last_trained_at))}</p>
<p><strong>Was noch passiert:</strong> ${escapeHtml(record.behavior.reason)}</p> <p><strong>Was noch passiert:</strong> ${escapeHtml(record.behavior.reason)}</p>
<p><strong>Freigabestatus:</strong> <span class="${record.behavior.activation_ready ? "ok" : "warn"}">${escapeHtml(record.behavior.activation_reason)}</span></p> <p><strong>Freigabestatus:</strong> <span class="${record.behavior.activation_ready ? "ok" : "warn"}">${escapeHtml(record.behavior.activation_reason)}</span></p>
<div class="actions">${activationButton}</div> <div class="actions">${activationButton}</div>
@@ -1116,6 +1446,7 @@ async function showActuator(actuatorId, evaluationMessage = "") {
${safetyControls} ${safetyControls}
${decisionArchive} ${decisionArchive}
${adaptivePanel} ${adaptivePanel}
${anomalyPanel}
<h3>Passende Home-Assistant-Automationen</h3> <h3>Passende Home-Assistant-Automationen</h3>
<p class="muted">Bei einer Übernahme pausiert SillyHome diese Automationen. Beim Stoppen können sie gezielt fortgesetzt werden.</p> <p class="muted">Bei einer Übernahme pausiert SillyHome diese Automationen. Beim Stoppen können sie gezielt fortgesetzt werden.</p>
<button class="secondary compact" onclick="refreshRelatedAutomations('${escapeHtml(record.actuator_entity_id)}')">Automationen neu suchen</button> <button class="secondary compact" onclick="refreshRelatedAutomations('${escapeHtml(record.actuator_entity_id)}')">Automationen neu suchen</button>
@@ -1130,7 +1461,6 @@ async function showActuator(actuatorId, evaluationMessage = "") {
${currentContextControls} ${currentContextControls}
${manualAssignment} ${manualAssignment}
`; `;
void hydrateContextOptions(record);
document.getElementById("detail").scrollIntoView({behavior: "smooth", block: "start"}); document.getElementById("detail").scrollIntoView({behavior: "smooth", block: "start"});
} catch (error) { } catch (error) {
box.textContent = error.message; box.textContent = error.message;
@@ -1329,7 +1659,7 @@ async function saveSafetyProfile(actuatorId) {
cooldown_seconds: Number.isFinite(cooldown) ? cooldown : null, cooldown_seconds: Number.isFinite(cooldown) ? cooldown : null,
rules: [ rules: [
{rule_id: "activation_ready", label: "Nur nach Lernfreigabe aktiv schalten", enabled: true, blocking: true, reason: "Der Aktor muss genug eindeutiges Verhalten gelernt haben."}, {rule_id: "activation_ready", label: "Nur nach Lernfreigabe aktiv schalten", enabled: true, blocking: true, reason: "Der Aktor muss genug eindeutiges Verhalten gelernt haben."},
{rule_id: "confidence_threshold", label: "Mindest-Sicherheit einhalten", enabled: true, blocking: true, reason: "Vorhersagen unter der Schaltschwelle bleiben im Shadow-Modus."}, {rule_id: "confidence_threshold", label: "Mindest-Sicherheit einhalten", enabled: true, blocking: true, reason: "Vorhersagen unter der Schaltschwelle bleiben im Prüfmodus."},
{rule_id: "cooldown", label: "Sicherheits-Cooldown gegen Hin-und-her-Schalten", enabled: true, blocking: true, reason: "Gleiche Zielzustände werden nicht zu schnell wiederholt."}, {rule_id: "cooldown", label: "Sicherheits-Cooldown gegen Hin-und-her-Schalten", enabled: true, blocking: true, reason: "Gleiche Zielzustände werden nicht zu schnell wiederholt."},
{rule_id: "manual_block", label: "Manuelle Sperre respektieren", enabled: true, blocking: true, reason: "Nutzer können jeden Aktor sofort blockieren."}, {rule_id: "manual_block", label: "Manuelle Sperre respektieren", enabled: true, blocking: true, reason: "Nutzer können jeden Aktor sofort blockieren."},
], ],
@@ -1435,11 +1765,13 @@ async function removeActuator(actuatorId) {
async function startDashboard() { async function startDashboard() {
document.getElementById("status").innerHTML = "<p class='muted'>Status lädt nach ...</p>"; document.getElementById("status").innerHTML = "<p class='muted'>Status lädt nach ...</p>";
document.getElementById("configured-actuators").innerHTML = "<div class='empty-state'>Geräte werden nach dem Status geladen.</div>"; document.getElementById("configured-actuators").innerHTML = "<div class='empty-state'>Öffne „Lernen“, um Geräte zu laden.</div>";
document.getElementById("actuator-detail").innerHTML = "<div class='empty-state'>Wähle später ein Gerät aus der Übersicht.</div>"; document.getElementById("actuator-detail").innerHTML = "<div class='empty-state'>Wähle später ein Gerät aus der Übersicht.</div>";
syncSettingsView();
document.getElementById("section-jump").value = "status-section";
showView("status-section");
await new Promise(resolve => requestAnimationFrame(resolve)); await new Promise(resolve => requestAnimationFrame(resolve));
await loadStatus(); setTimeout(() => void loadStatus(), 100);
await loadOverview();
} }
void startDashboard(); void startDashboard();

View File

@@ -0,0 +1,68 @@
# SillyHome Next v1.3.0 Operating Guide
v1.3.0 ergänzt die v1.2-Lernfunktionen um Anomalie-Erkennung und
Performance-Überwachung. Das Dashboard bleibt Visualisierung und Einrichtung;
der direkte Schaltpfad bleibt kurz und führt vor dem Home-Assistant-Service-Call
keine Discovery, kein Training und keine Modellanalyse aus.
## Performance-Budget
- Dashboard-Start und `/v1/actuators/dashboard` haben ein Budget von 3000 ms.
- Das Dashboard zeigt die eigene Ladezeit, das aktive Budget, Job-p95 und die
Anzahl langsamer Jobs.
- Jobs ab 3000 ms werden in der Job-Queue als langsam markiert.
- Der automatisierte API-Test prüft den Root- und Dashboard-Startpfad gegen das
3-Sekunden-Budget.
## Anomalie-Erkennung
Anomalien werden pro Aktor gespeichert und im Aktor-Detail angezeigt. Erkannt
werden aktuell:
- fehlender Sensor-/Kontextbezug
- zu wenige Lernbeispiele
- unklare Quellen historischer Schaltungen
- veraltetes Training
- Vorhersagen unter der Sicherheitsgrenze
- aktive manuelle Sicherheitssperren
- Safety-Blocker
- parallele HA-Automationen bei aktivem SillyHome
- hohe negative Feedbackquote
Die Anomalien sind Hinweise für Setup und manuelles Gegensteuern. Sie lösen
keine automatische Eskalation und keine langsamere Schaltung aus.
## API
- `GET /v1/actuators/dashboard` liefert jetzt zusätzlich:
- `performance_budget_ms`
- `job_p95_duration_ms`
- `slow_job_count`
- `performance_status`
- `anomaly_count`
- `critical_anomaly_count`
- `GET /v1/actuators/anomalies` liefert offene Anomalien gruppiert nach Aktor.
## Betrieb
Bei Ladezeiten ab 3 Sekunden gilt die Seite als nicht performant. Dann zuerst
prüfen:
1. Dashboard-Statistik: Ladezeit, Job-p95, langsame Jobs.
2. Job-Queue: welche Aktion langsam war.
3. Aktor-Detail: Anomalien, Safety-Blocker und Automation-Konflikte.
4. Falls Discovery oder Training langsam war: nicht in den Startpfad ziehen,
sondern geplant, manuell oder über Queue laufen lassen.
## Qualität
Vor Release/Installation ausführen:
```bash
pytest -q
ruff check .
mypy app backend tests
git diff --check
```
Zusätzlich das eingebettete Dashboard-JavaScript mit `node --check` prüfen.

View File

@@ -0,0 +1,42 @@
# SillyHome Next v1.4.0 Operating Guide
v1.4.0 überarbeitet das Dashboard für mobile Nutzung, deutsche Verständlichkeit
und stabileren Datenabruf.
## Schneller Startpfad
- Die Startseite lädt zuerst nur die Bedienoberfläche und den kompakten
Dashboard-Startdatensatz.
- Neuer Start-Endpunkt: `GET /v1/actuators/dashboard/start`.
- Der Start-Endpunkt liefert keine Discovery-Gruppen und keine Aufgabenliste.
- Status, Aufgabenliste, Reconciliation-Zeitpunkt und Detail-Kontext werden
danach im Hintergrund geladen.
- Auf der Startansicht werden zunächst nur die ersten 24 Aktoren gerendert.
Weitere Geräte werden auf Knopfdruck nachgerendert.
## Deutsche Oberfläche
Interne Protokollwerte bleiben stabil, werden in der Oberfläche aber übersetzt:
- `observe` -> `Nur beobachten`
- `suggest` -> `Vorschläge anzeigen`
- `shadow` -> `Prüfmodus ohne Schalten`
- `partial` -> `Teilfreigabe`
- `active` -> `Aktiv freigegeben`
- Job-Status wie `running`, `completed`, `failed` erscheinen als `läuft`,
`abgeschlossen`, `fehlgeschlagen`.
- Anomalie-Schweregrade erscheinen als `Hinweis`, `Warnung`, `Kritisch`.
## Stabilität
- Startdaten und Statusdaten sind getrennt. Ein langsamer Statuscheck blockiert
nicht mehr die Geräteübersicht.
- Die Aufgabenliste wird separat geladen und kann ausfallen, ohne die
Bedienoberfläche zu blockieren.
- Detaildaten bleiben gestuft: zuerst Shell und gespeicherte Werte, danach
Kontextvorschläge.
## Performance-Regel
3 Sekunden bleiben die harte Grenze für den Startpfad. Alles, was schwerer ist
als Startdaten, muss nachgelagert oder auf Nutzeraktion geladen werden.

View File

@@ -0,0 +1,47 @@
# SillyHome Next v1.5.0 Operating Guide
v1.5.0 trennt Dashboard-Ansichten, Datenabruf und Detaildaten weiter auf. Ziel
ist, dass die Seite auf mobiler Datenverbindung schneller nutzbar wird und keine
schweren Lern-, Discovery- oder Detaildaten beim Start lädt.
## Menüstruktur
- Startseite / System: Systemübersicht, Cache, Performance, Status.
- Lernen: konfigurierte Aktoren und Lernstand.
- Details: genau ein ausgewählter Aktor.
- Discovery & Einrichtung: Geräteliste, Vorschläge und neue Aktoren.
- Einstellungen: Sprache und Standardverhalten.
- Ablauf: Bedienhinweise.
Beim Öffnen der Seite wird immer nur die Startseite geladen. Andere Ansichten
laden erst beim Öffnen.
## Kompakte Detaildaten
Neuer Endpunkt:
```text
GET /v1/actuators/{actuator_entity_id}/detail
```
Dieser Endpunkt entfernt große Musterlisten und Snapshot-Muster aus dem ersten
Detailabruf. Geladen werden nur die Werte, die für die erste Detailansicht
benötigt werden. Kontextvorschläge bleiben ein separater Abruf und laufen erst
auf Nutzeraktion.
## Sprache
Die Sprache kann unter `Einstellungen` gewählt werden. Deutsch ist Standard.
Technische API-Werte bleiben stabil, werden aber im Dashboard über die
Sprachschicht angezeigt.
## Performance-Regeln
- Kein Discovery beim Start.
- Keine Aufgabenliste beim Start.
- Keine Kontextvorschläge beim Öffnen eines Aktors.
- Keine Musterlisten im ersten Detailabruf.
- Geräteübersicht rendert begrenzt und lädt weitere Karten per Button nach.
Die Angabe „bereit in X ms“ beschreibt nur den jeweiligen API-/Ansichtsabruf.
Sie ist nicht gleichzusetzen mit der kompletten HA/Ingress-Navigationszeit.

View File

@@ -0,0 +1,32 @@
# SillyHome Next v1.5.1 Operating Guide
v1.5.1 ist ein Stabilisierungshotfix für die nach v1.2.0 entstandenen
Dashboard-Änderungen. Fachlich gehört diese Arbeit zur v1.2.x-Patchlinie; die
höhere technische Versionsnummer ist nur nötig, weil Home Assistant bereits
v1.5.0 installiert hat und Add-on-Updates monoton nach oben laufen.
## Korrekturen
- Die System-Startseite nutzt `GET /v1/actuators/dashboard/system` und lädt
keine Aktorenliste.
- Sichtbare 3-Sekunden-Abbrüche mit Browsertexten wie `signal is aborted
without reason` wurden entfernt.
- Startdaten und Detaildaten werden ohne künstlichen Frontend-Abbruch geladen.
- Timeout-Meldungen werden deutsch und verständlich angezeigt, wenn sie bei
Nebenprüfungen auftreten.
- `summary`-Zeilen wie `anzeigenaufklappen` haben jetzt Abstand und Layout.
## Ladeverhalten
- Statische Seite wird sofort gerendert.
- Systemdaten laden im Hintergrund.
- Lernen/Geräte laden nur im Menü `Lernen`.
- Discovery lädt nur im Menü `Discovery & Einrichtung`.
- Aktorwerte laden erst beim Öffnen der Detailansicht.
- Kontextvorschläge laden erst auf Nutzeraktion.
## Hinweis zur Performance-Anzeige
Die App zeigt keine echte HA/Ingress-Navigationszeit an. Gemessen werden nur
einzelne interne Abrufe nach Start der Seite. Aussagen zur gesamten Ladezeit
müssen über Browser/Ingress oder HA-Messung geprüft werden.

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "sillyhome-next" name = "sillyhome-next"
version = "1.2.0" version = "1.5.1"
description = "Lokales Smart-Home-Intelligenzsystem für Home Assistant" description = "Lokales Smart-Home-Intelligenzsystem für Home Assistant"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [

View File

@@ -1,13 +1,13 @@
from __future__ import annotations from __future__ import annotations
from time import perf_counter from time import perf_counter
from datetime import datetime, timedelta from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from app.actuators.lifecycle import ActuatorReconciliationService from app.actuators.lifecycle import ActuatorReconciliationService
from app.actuators.models import ModelSnapshot from app.actuators.models import JobStatus, ModelSnapshot
from app.actuators.store import ActuatorStore from app.actuators.store import ActuatorStore
from app.behavior.engine import BehaviorEngine from app.behavior.engine import BehaviorEngine
from app.config import Settings from app.config import Settings
@@ -407,7 +407,7 @@ def test_reconciliation_run_records_visible_job_queue(tmp_path: Path) -> None:
assert payload["jobs"][-1]["status"] == "completed" assert payload["jobs"][-1]["status"] == "completed"
def test_dashboard_start_path_stays_within_five_second_budget(tmp_path: Path) -> None: def test_dashboard_start_path_stays_within_three_second_budget(tmp_path: Path) -> None:
with TestClient(app) as client: with TestClient(app) as client:
_install_service(tmp_path) _install_service(tmp_path)
client.get("/v1/actuators/discovery") client.get("/v1/actuators/discovery")
@@ -418,13 +418,68 @@ def test_dashboard_start_path_stays_within_five_second_budget(tmp_path: Path) ->
root_elapsed = perf_counter() - root_started_at root_elapsed = perf_counter() - root_started_at
dashboard_started_at = perf_counter() dashboard_started_at = perf_counter()
dashboard_response = client.get("/v1/actuators/dashboard") dashboard_response = client.get("/v1/actuators/dashboard/start")
dashboard_elapsed = perf_counter() - dashboard_started_at dashboard_elapsed = perf_counter() - dashboard_started_at
assert root_response.status_code == 200 assert root_response.status_code == 200
assert dashboard_response.status_code == 200 assert dashboard_response.status_code == 200
assert root_elapsed < 5.0 assert root_elapsed < 3.0
assert dashboard_elapsed < 5.0 assert dashboard_elapsed < 3.0
def test_dashboard_reports_performance_budget_and_anomalies(tmp_path: Path) -> None:
with TestClient(app) as client:
_install_service(tmp_path)
client.get("/v1/actuators/discovery")
client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
store = app.state.actuator_store
job = store.start_job(kind="training", trigger="test", summary="Langsamer Testjob")
queue = store.load_job_queue()
queue.jobs = [
item.model_copy(update={"started_at": datetime.now(timezone.utc) - timedelta(seconds=4)})
if item.job_id == job.job_id
else item
for item in queue.jobs
]
store._persist_job_queue(queue)
store.finish_job(job.job_id, status=JobStatus.COMPLETED, summary="Fertig")
dashboard_response = client.get("/v1/actuators/dashboard")
start_response = client.get("/v1/actuators/dashboard/start")
system_response = client.get("/v1/actuators/dashboard/system")
anomalies_response = client.get("/v1/actuators/anomalies")
assert dashboard_response.status_code == 200
assert start_response.status_code == 200
assert system_response.status_code == 200
system = dashboard_response.json()["system"]
start_payload = start_response.json()
assert start_payload["jobs"]["jobs"] == []
assert start_payload["discovery_groups"] == []
assert system_response.json()["actuators"] == []
assert system["performance_budget_ms"] == 3000
assert system["slow_job_count"] == 1
assert system["performance_status"] == "slow"
assert system["anomaly_count"] >= 1
assert anomalies_response.status_code == 200
assert anomalies_response.json()
def test_actuator_detail_uses_compact_payload(tmp_path: Path) -> None:
with TestClient(app) as client:
_install_service(tmp_path)
client.get("/v1/actuators/discovery")
client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
response = client.get("/v1/actuators/light.abstellkammer/detail")
assert response.status_code == 200
payload = response.json()
assert payload["behavior"]["patterns"] == []
assert all(
snapshot["patterns"] == []
for snapshot in payload["behavior"]["model_snapshots"]
)
def test_discovery_reads_entities_once_and_reuses_them(tmp_path: Path) -> None: def test_discovery_reads_entities_once_and_reuses_them(tmp_path: Path) -> None:

View File

@@ -24,7 +24,7 @@ def test_dashboard_is_served_at_root() -> None:
assert "SillyHome übernehmen lassen" in response.text assert "SillyHome übernehmen lassen" in response.text
assert "Passende Home-Assistant-Automationen" in response.text assert "Passende Home-Assistant-Automationen" in response.text
assert "Pausieren" in response.text assert "Pausieren" in response.text
assert "Davon erkannte HA-Automationen" in response.text assert "Erkannte HA-Automationen" in response.text
assert "Aktuelle Situation auswerten" in response.text assert "Aktuelle Situation auswerten" in response.text
assert "Kontext selbst festlegen" in response.text assert "Kontext selbst festlegen" in response.text
assert "Entity-IDs manuell ergänzen" in response.text assert "Entity-IDs manuell ergänzen" in response.text