-
- {% for event in events | reverse %}
-
-
-
- {{ event.type }}
-
- {% if event.get("entity_id") %}
{{ event.entity_id }}{% endif %} -
- {% endfor %}
-
diff --git a/.claude/interface-design/system.md b/.claude/interface-design/system.md new file mode 100644 index 00000000..a1876be1 --- /dev/null +++ b/.claude/interface-design/system.md @@ -0,0 +1,238 @@ +# Hassette Design System + +## Intent + +**Who:** A developer running Home Assistant automations — technical, comfortable with code, checking dashboards between tasks or debugging at midnight. + +**Task:** Monitor app health, inspect event flow, review logs, manage scheduled jobs. Rapid scanning of status, not prolonged reading. + +**Feel:** Like a well-lit control room. Cool and composed, with a single warm indicator light that draws the eye to what matters. Dense but never cluttered. The interface should feel like a tool you trust — quiet when things are fine, unmistakable when they're not. + +## Direction + +**Domain:** Home automation control plane — dashboards, status indicators, event streams, entity state, scheduled tasks. + +**Color world:** Cool slate walls of a server room, warm amber of an indicator LED, green/red status lights on equipment racks. + +**Signature:** The breathing pulse dot (`ht-pulse-dot`) — a slow amber inhale/exhale animation on live-connected panels. Static red when disconnected. It could only exist for a system that maintains a persistent WebSocket to a home automation hub. + +**Rejecting:** +- Generic blue primary (every SaaS app) -> warm amber accent (`#D4915C`) for identity +- Drop shadows for depth (Bulma default) -> borders-only depth strategy +- System fonts (invisible) -> Space Grotesk headings (geometric, technical character) +- Standard body copy font -> JetBrains Mono for data/code (this is a developer tool) + +--- + +## Foundation + +**Palette:** Cool slate (`--ht-slate-50` through `--ht-slate-900`) +**Accent:** Warm amber `#D4915C`, hover `#c07e4a`, dim `rgba(212, 145, 92, 0.35)` +**Depth strategy:** Borders-only — `rgba(0, 0, 0, 0.08)` default, `0.15` strong, `0.04` subtle. No box-shadows. + +### Surfaces + +| Level | Token | Value | Use | +| ----- | ----------------------- | ------------------------------ | -------------------------- | +| L0 | `--ht-surface-canvas` | slate-50 (`#f8fafc`) | Page background | +| L1 | `--ht-surface-card` | `#ffffff` | Cards, panels | +| L2 | `--ht-surface-dropdown` | `#ffffff` + strong border | Dropdowns, popovers | +| — | `--ht-surface-sidebar` | slate-900 (`#0f172a`) | Dark sidebar | +| — | `--ht-surface-sticky` | `white` | Sticky table headers | +| — | `--ht-surface-inset` | `rgba(0, 0, 0, 0.05)` | Alert items, nested panels | +| — | `--ht-surface-code` | `#1e1e2e` | Code blocks, tracebacks | + +### Semantic Colors + +Each semantic color has three tokens: base, `-light` (background tint), `-text` (high-contrast label). + +| Semantic | Base | Light | Text | +| -------- | --------- | --------- | --------- | +| Success | `#16a34a` | `#f0fdf4` | `#166534` | +| Danger | `#dc2626` | `#fef2f2` | `#991b1b` | +| Warning | `#ca8a04` | `#fefce8` | `#854d0e` | +| Info | `#2563eb` | `#eff6ff` | `#1e40af` | +| Link | `#7c3aed` | `#f5f3ff` | `#5b21b6` | +| Critical | `#991b1b` | — | — | + +### Alert Tints + +Translucent overlays for alert banners (warning/danger variants): + +| Token | Value | +| ---------------------- | ---------------------------- | +| `--ht-warning-bg` | `rgba(234, 179, 8, 0.1)` | +| `--ht-warning-border` | `rgba(234, 179, 8, 0.3)` | +| `--ht-danger-bg` | `rgba(239, 68, 68, 0.1)` | +| `--ht-danger-border` | `rgba(239, 68, 68, 0.3)` | + +--- + +## Typography + +| Role | Font | Token | +| ----------- | ----------------- | ------------------- | +| Headings | Space Grotesk 600 | `--ht-font-heading` | +| Body | system-ui stack | `--ht-font-body` | +| Data / code | JetBrains Mono | `--ht-font-mono` | + +### Type Scale + +| Token | Size | Use | +| ---------------- | ---- | ---------------------------------------- | +| `--ht-text-xs` | 12px | Table data, timestamps, secondary labels | +| `--ht-text-sm` | 13px | Compact UI, badge text | +| `--ht-text-base` | 14px | Body text, form inputs | +| `--ht-text-lg` | 16px | Section headings, emphasis | +| `--ht-text-xl` | 20px | Page headings | +| `--ht-text-2xl` | 24px | Dashboard hero numbers | + +--- + +## Spacing + +4px grid. Tokens: `--ht-sp-{1,2,3,4,6,8,12}` = 4, 8, 12, 16, 24, 32, 48px. + +--- + +## Radius + +| Token | Value | Use | +| ------------------ | ------ | ---------------------- | +| `--ht-radius-sm` | 3px | Badges, small tags | +| `--ht-radius-md` | 5px | Cards, buttons, inputs | +| `--ht-radius-lg` | 8px | Large panels, modals | +| `--ht-radius-full` | 9999px | Pills, dots | + +--- + +## Component Patterns + +### Card (`ht-card`) + +``` +border: 1px solid var(--ht-border) +border-radius: var(--ht-radius-md) /* 5px */ +padding: var(--ht-sp-4) var(--ht-sp-6) /* 16px 24px */ +background: var(--ht-surface-card) +``` + +### Button (`ht-btn`) + +``` +height: auto (padding-driven) +padding: 0.4em 0.85em +border-radius: var(--ht-radius-md) /* 5px */ +font-size: var(--ht-text-sm) /* 13px */ +font-weight: 500 +border: 1px solid +``` + +Small variant (`ht-btn--sm`): `padding: 0.25em 0.6em`, `font-size: var(--ht-text-xs)`. + +Semantic variants: `--success`, `--danger`, `--warning`, `--info`, `--link`, `--primary` (amber). + +### Badge (`ht-badge`) + +``` +padding: 0.3em 0.5em +border-radius: var(--ht-radius-full) /* pill */ +font-size: var(--ht-text-xs) /* 12px */ +font-weight: 500 +``` + +Small variant (`ht-badge--sm`): `0.1em 0.45em`, `font-size: 0.6875rem (11px)`. +Medium variant (`ht-badge--md`): `0.2em 0.65em`. + +Semantic variants match button patterns. Status-specific: `ht-status-stopped`, `ht-status-disabled`, `ht-status-blocked`. + +### Table (`ht-table`) + +``` +width: 100% +border-bottom: 1px solid var(--ht-border) +th padding: 0.4em 0.85em +td padding: 0.4em 0.85em +``` + +Dense variant (`ht-table--dense`): `0.25em 0.5em` cell padding. +Striped variant (`ht-table--striped`): alternating `var(--ht-surface-canvas)` rows. + +### Input / Select (`ht-input`, `ht-select`) + +``` +padding: 0.35em 0.6em +border: 1px solid var(--ht-border-strong) +border-radius: var(--ht-radius-md) +font-size: var(--ht-text-base) +``` + +Small variants: `font-size: var(--ht-text-xs)`, `padding: 0.25em 0.5em`. + +### Sidebar + +``` +width: 220px (expanded), 56px (collapsed icon rail) +background: var(--ht-surface-sidebar) /* slate-900 */ +transition: width 0.2s ease +``` + +Nav link: `padding: 0.6rem 1.25rem`, active state uses `var(--ht-sidebar-active-bg)` + `var(--ht-sidebar-active-color)`. + +Mobile: collapses to 56px icon rail by default, expands to 260px overlay with backdrop. + +### Pulse Dot (Signature) + +```css +.ht-pulse-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--ht-amber); + animation: ht-breathe 2s ease-in-out infinite; /* slow inhale/exhale */ +} +.ht-pulse-dot--disconnected { + background: var(--ht-danger); + animation: none; +} +``` + +--- + +## Layout + +### Grid (`ht-grid`) + +CSS Grid, 12-column, `gap: var(--ht-sp-4)` (16px). Column classes: `ht-grid-col-{1..12}`. + +### Level (`ht-level`) + +Flexbox row, `justify-content: space-between`, `align-items: center`. Children: `ht-level-start`, `ht-level-end`, `ht-level-item`. + +### Tabs (`ht-tabs`) + +Flex row with bottom border. Active tab: amber bottom border + bold text. + +--- + +## Breakpoints + +| Breakpoint | Behavior | +| ---------- | ---------------------------------------------------------------------------------------------- | +| > 768px | Desktop: sidebar open, 12-col grid | +| <= 768px | Tablet/mobile: sidebar collapses to icon rail, grid becomes single column, status bar compacts | +| <= 480px | Small phone: reduced padding | + +--- + +## Theming + +All tokens live in `static/css/tokens.css` under `:root, [data-theme="default"]`. Components in `style.css` reference only token variables — no hardcoded colors. + +To create a new theme: copy `tokens.css`, change the selector to `[data-theme="your-theme"]`, override values. Set `data-theme` attribute on `` to activate. + +--- + +## CSS Class Prefix + +All classes use `ht-` prefix. The only non-prefixed class is `is-active` (retained for Alpine.js toggle compatibility on nav links and tabs). diff --git a/.claude/research/2026-02-16-sqlite-command-pattern.md b/.claude/research/2026-02-16-sqlite-command-pattern.md new file mode 100644 index 00000000..7cf7bc9d --- /dev/null +++ b/.claude/research/2026-02-16-sqlite-command-pattern.md @@ -0,0 +1,463 @@ +# Research Brief: SQLite Database + Command Executor for Operational Telemetry + +**Date**: 2026-02-16 + +**Status**: Ready for Decision + +**Proposal**: Add a SQLite database for persistent operational telemetry and introduce a Command Executor that consolidates cross-cutting execution concerns (timing, recording, error handling) currently scattered across `BusService` and `SchedulerService`, making those services thinner in the process. + +**Initiated by**: "Adding a SQLite database to power the frontend + using the Command pattern to avoid tight coupling of cross-domain actions" + +## Context + +### What prompted this + +The web frontend (Jinja2 + HTMX) needs richer, persistent data. Today all operational data — listener metrics, job execution history, logs — lives in in-memory Python structures (`dict`, `deque`) that are lost on restart. The frontend can only show what's in RAM right now. + +The specific data needed: +- **Per-invocation handler records**: timestamp, duration, success/failure, traceback for every bus event handler call +- **Job execution history** across restarts: same per-execution detail, surviving process lifecycle +- **Uptime / session tracking**: when the framework started, stopped, crashed +- **Future**: app configuration persistence, UI state/preferences + +The architectural goal is equally important: `BusService._dispatch()` and `SchedulerService.run_job()` currently own too many responsibilities — invoking the handler, timing it, recording metrics, catching and classifying exceptions, logging errors. Adding database persistence on top of that would make them worse. The Command pattern should pull these cross-cutting concerns out of the services and into a dedicated execution layer, making the services thinner rather than fatter. This same executor would be the natural home for the `on_error`/`on_exception` handler hook that's in the backlog — instead of wiring error hooks into both `BusService` and `SchedulerService` independently, the executor handles it once. + +### Current state + +**Data stores (all in-memory, all lost on restart):** + +| Store | Location | Type | Capacity | +|-------|----------|------|----------| +| Listener metrics | `BusService._listener_metrics` | `dict[int, ListenerMetrics]` | Unbounded (per listener) | +| Job execution log | `SchedulerService._execution_log` | `deque[JobExecutionRecord]` | Bounded ring buffer | +| Event buffer | `DataSyncService._event_buffer` | `deque[dict]` | Bounded ring buffer | +| Log buffer | `LogCaptureHandler._buffer` | `deque[LogEntry]` | Bounded (default 2000) | +| Entity state | `StateProxy.states` | `dict[str, HassStateDict]` | Unbounded (mirrors HA) | + +**Current frontend data pipeline:** + +``` +BusService._listener_metrics ──┐ +SchedulerService._execution_log─┤ +StateProxy.states ──────────────┼── DataSyncService ── FastAPI routes ── Jinja2 templates +LogCaptureHandler._buffer ──────┤ ↕ HTMX partials +AppHandler registry ────────────┘ ↕ WebSocket push +``` + +`DataSyncService` is the sole aggregation layer between framework internals and the web tier. All routes inject it via `DataSyncDep`. It pulls data from the in-memory stores on each request. + +**What `BusService._dispatch()` does today (~30 lines in `bus_service.py`):** + +1. Get or create a `ListenerMetrics` object for this listener +2. Start a monotonic timer +3. Call `await listener.invoke(event)` (which runs DI injection → rate limiting → user handler) +4. On success: `metrics.record_success(duration_ms)` +5. On `DependencyError`: `metrics.record_di_failure(duration_ms, ...)` +6. On `HassetteError` or `Exception`: `metrics.record_error(duration_ms, ...)`, `self.logger.exception(...)` +7. On `CancelledError`: `metrics.record_cancelled(duration_ms)`, re-raise +8. If `listener.once`: remove the listener + +**What `SchedulerService.run_job()` does today (~45 lines in `scheduler_service.py`):** + +1. Resolve the callable (sync or async) +2. Use `track_execution()` context manager to time the call and capture errors +3. Call `await async_func(*job.args, **job.kwargs)` +4. On exception: `self.logger.exception(...)` +5. Build a `JobExecutionRecord` from the `ExecutionResult` +6. Append to `self._execution_log` deque + +Both methods mix invocation, timing, error classification, metrics recording, and logging into one place. The Command Executor would own steps 1-2 and 4-6 (everything except the actual invocation target), letting the services focus on dispatch routing and job scheduling. + +**Key observations:** +- `ListenerMetrics` only tracks **aggregates** (total success/fail counts, min/max timing). There are no per-invocation records for bus handlers — this gap must be filled for telemetry. +- `JobExecutionRecord` already has all the fields needed for a DB row (job_id, name, owner, started_at, duration_ms, status, error_type, error_message, error_traceback). +- `track_execution()` in `utils/execution.py` is a reusable async context manager that captures timing and error info — the executor would absorb or replace this. +- `diskcache` (already a dependency) uses SQLite internally, so the project already tolerates SQLite on disk under `data_dir`. +- The backlog has an `on_error`/`on_exception` handler feature. Today there's no single place to wire it — you'd have to add it to both `BusService._dispatch()` and `SchedulerService.run_job()`. The executor provides that single place. + +**Existing persistence:** +- `Resource.cache` — a `diskcache.Cache` property on every `Resource` (lazy, disk-backed key/value store). Available to user apps but unused by framework core services. Uses SQLite under the hood. +- No other persistence. No database, no ORM, no migration tooling. + +### Key constraints + +- **Async-first**: The codebase is entirely async. `sqlite3` is synchronous. The established bridge pattern is `TaskBucket.run_in_thread()` or `asyncio.to_thread()`. `aiosqlite` is the conventional async wrapper. +- **Python 3.11-3.13**: All three versions supported. `aiosqlite` and stdlib `sqlite3` work across all. +- **Zero new system deps**: SQLite is bundled with Python — no external database server needed. +- **`filterwarnings = ["error"]` in pytest**: Currently set to catch missed `await` calls. Can be loosened for DB-specific warnings if needed — not a hard constraint. +- **FastAPI lifespan disabled**: `WebApiService` sets `lifespan="off"` — all lifecycle management goes through the Hassette `Resource`/`Service` system. Database open/close must use `on_initialize`/`on_shutdown`, not FastAPI's lifespan. +- **High write frequency**: Bus handlers fire frequently (every HA state change). Naive per-invocation INSERT would need batching or WAL mode to avoid contention. + +## Feasibility Analysis + +### What would need to change + +| Area | Files affected | Effort | Risk | +|------|---------------|--------|------| +| New `DatabaseService` | 1 new file + `core.py` wiring | Medium | Low — follows existing `Service` pattern | +| New `CommandExecutor` | 1-2 new files (executor + command types) | Medium | Medium — new pattern, but scoped to 2-3 methods | +| Slim down `BusService._dispatch()` | `bus_service.py` | Medium | Medium — hot path, behavioral change | +| Slim down `SchedulerService.run_job()` | `scheduler_service.py` | Low | Low — `JobExecutionRecord` already exists | +| `DataSyncService` read migration | `data_sync_service.py` | Medium | Medium — central aggregation layer | +| `ListenerMetrics` replacement/evolution | `bus/metrics.py` | Medium | Medium — aggregate metrics may be computed from DB | +| Schema / migrations | New `schema.sql` or migration files | Low | Low — greenfield | +| FastAPI DB dependency | `web/dependencies.py` | Low | Low | +| Config additions | `config/config.py` | Low | Low | +| Tests | 5-10 new test files | High | Low — follows existing patterns | +| Template changes | Minimal — existing templates work | Low | Low | + +### What already supports this + +- **`Service` base class** provides `on_initialize`/`on_shutdown` lifecycle hooks — perfect for `DatabaseService` and `CommandExecutor` (if it needs a write-batch loop). +- **`add_child()` + `wait_for_ready()`** wiring in `Hassette.__init__` handles startup ordering — new services slot in naturally. +- **`track_execution()`** context manager already captures timing and error info — the executor can absorb this or delegate to it. +- **`JobExecutionRecord`** dataclass has all fields needed for a DB row — no model redesign needed. +- **`HassetteConfig`** pydantic-settings pattern accommodates new fields (`db_path`, `db_wal_mode`, `run_db`). +- **`data-live-on-app`** HTMX attribute handles live partial refresh — telemetry dashboards would get real-time updates without new WebSocket wiring. +- **Existing `diskcache` dependency** means SQLite is already a tolerated presence in the data directory. +- **`DataSyncService`** is already the read aggregation layer — switching its data source from `dict`/`deque` to DB queries is localized. + +### What works against this + +- **`ListenerMetrics` is aggregate-only**: No per-invocation records exist for bus handlers. The executor must produce these — new data structure regardless of persistence approach. +- **`BusService._listener_metrics` dict is keyed by `listener_id`**: This is an auto-incrementing `int` that resets on restart. Persistent records need a stable identity (handler name + topic, or a UUID). +- **Behavioral change in `_dispatch()`**: Moving execution orchestration out of `_dispatch()` changes exception handling semantics. The core contract (handlers must not crash the bus) must be maintained, but the specific exception classification is worth auditing during the migration. +- **No migration tooling yet**: The project has no Alembic. Adding it is straightforward — Alembic supports raw SQL migrations without requiring SQLAlchemy models (plain `op.execute()` in upgrade/downgrade functions). +- **`diskcache` overlap**: Both `diskcache.Cache` and the new SQLite database write under `data_dir` — separate files, no conflict, but two persistence mechanisms to reason about. + +## The Command Executor Pattern + +### Core idea + +The Command Executor is not a message bus or a persistence queue. It is the **execution layer** — it takes a command (what to run), runs it, and owns everything around that execution: timing, result recording, error classification, error hooks, and persistence. Services that currently orchestrate all of this themselves become thin dispatchers. + +### What changes in the services + +**`BusService._dispatch()` today** (~30 lines, mixed concerns): + +```python +async def _dispatch(self, topic, event, listener): + metrics = self._get_or_create_metrics(listener) + started = time.monotonic() + try: + await listener.invoke(event) + duration = (time.monotonic() - started) * 1000 + metrics.record_success(duration) + except asyncio.CancelledError: + metrics.record_cancelled(...) + raise + except DependencyError as e: + metrics.record_di_failure(...) + self.logger.error(...) + except HassetteError as e: + metrics.record_error(...) + self.logger.error(...) + except Exception as e: + metrics.record_error(...) + self.logger.exception(...) + finally: + if listener.once: + self.remove_listener(listener) +``` + +**`BusService._dispatch()` after** (~5 lines): + +```python +async def _dispatch(self, topic, event, listener): + cmd = InvokeHandler(listener=listener, event=event, topic=topic) + await self._executor.execute(cmd) + if listener.once: + self.remove_listener(listener) +``` + +**`SchedulerService.run_job()` today** (~45 lines): + +```python +async def run_job(self, job): + async_func = self._resolve_callable(job) + result = ExecutionResult() + try: + async with track_execution() as result: + await async_func(*job.args, **job.kwargs) + except asyncio.CancelledError: + raise + except Exception: + self.logger.exception("Error running job %s", job) + finally: + record = JobExecutionRecord( + job_id=job.job_id, job_name=job.name, owner=job.owner, + started_at=timestamp, duration_ms=result.duration_ms, + status=result.status, error_message=result.error_message, + error_type=result.error_type, error_traceback=result.error_traceback, + ) + self._execution_log.append(record) +``` + +**`SchedulerService.run_job()` after** (~5 lines): + +```python +async def run_job(self, job): + cmd = ExecuteJob(job=job, callable=self._resolve_callable(job)) + await self._executor.execute(cmd) +``` + +### The executor itself + +```python +@dataclass(frozen=True) +class InvokeHandler: + listener: Listener + event: Event + topic: str + +@dataclass(frozen=True) +class ExecuteJob: + job: ScheduledJob + callable: AsyncHandlerType + +class CommandExecutor(Service): + """Owns cross-cutting execution concerns: timing, recording, error hooks, persistence.""" + + _write_queue: asyncio.Queue[HandlerInvocationRecord | JobExecutionRecord] + + async def execute(self, cmd: InvokeHandler | ExecuteJob) -> None: + """Single entry point. Dispatches internally based on command type.""" + match cmd: + case InvokeHandler(): + await self._execute_handler(cmd) + case ExecuteJob(): + await self._execute_job(cmd) + + async def _execute_handler(self, cmd: InvokeHandler) -> None: + """Invoke an event handler and record the result.""" + started = time.monotonic() + try: + await cmd.listener.invoke(cmd.event) + duration_ms = (time.monotonic() - started) * 1000 + record = HandlerInvocationRecord( + ..., status="success", duration_ms=duration_ms, + ) + except asyncio.CancelledError: + duration_ms = (time.monotonic() - started) * 1000 + record = HandlerInvocationRecord(..., status="cancelled", ...) + raise # CancelledError must propagate + except Exception as e: + duration_ms = (time.monotonic() - started) * 1000 + record = HandlerInvocationRecord( + ..., status="error", error_type=type(e).__name__, + error_message=str(e), error_traceback=traceback.format_exc(), + ) + await self._run_error_hooks(e, cmd) # on_error/on_exception from backlog + self.logger.exception("Handler error: %s", cmd.listener.handler_name) + finally: + self._write_queue.put_nowait(record) + + async def _execute_job(self, cmd: ExecuteJob) -> None: + """Execute a scheduled job and record the result.""" + started = time.monotonic() + try: + await cmd.callable(*cmd.job.args, **cmd.job.kwargs) + duration_ms = (time.monotonic() - started) * 1000 + record = JobExecutionRecord(..., status="success", ...) + except asyncio.CancelledError: + duration_ms = (time.monotonic() - started) * 1000 + record = JobExecutionRecord(..., status="cancelled", ...) + raise + except Exception as e: + duration_ms = (time.monotonic() - started) * 1000 + record = JobExecutionRecord( + ..., status="error", error_traceback=traceback.format_exc(), ... + ) + await self._run_error_hooks(e, cmd) + self.logger.exception("Job error: %s", cmd.job.name) + finally: + self._write_queue.put_nowait(record) + + async def _run_error_hooks(self, exc: Exception, cmd: InvokeHandler | ExecuteJob) -> None: + """Run registered on_error/on_exception hooks. Backlog item gets wired here.""" + for hook in self._error_hooks: + try: + await hook(exc, cmd) + except Exception: + self.logger.exception("Error hook failed") + + async def serve(self) -> None: + """Drain write queue in batches and persist to SQLite.""" + while True: + batch = await self._drain_queue(max_size=100, timeout_seconds=0.5) + async with self._db.transaction(): + for record in batch: + await self._persist(record) +``` + +### What this gives you + +- **Services get thinner**: `BusService` focuses on topic routing and listener management. `SchedulerService` focuses on the heap queue and rescheduling. Neither knows about timing, metrics, DB, or error hooks. +- **Single place for cross-cutting concerns**: Timing, result recording, error classification, logging, persistence, and error hooks all live in `CommandExecutor`. Adding a new concern means modifying one class, not two services. +- **`on_error`/`on_exception` hooks**: The backlog item drops in naturally as `_run_error_hooks()`. No need to wire it into multiple services. +- **Per-invocation records**: Every handler call and job execution produces a typed record (dataclass), which gets queued for DB persistence. The aggregate `ListenerMetrics` can be computed from these records or maintained in parallel. +- **Testability**: Commands are frozen dataclasses. The executor can be tested with mock listeners/jobs. Error hook behavior is testable in isolation. + +## Options Evaluated + +### Option A: Typed Command Executor (Recommended) + +**How it works:** + +As described above. A `CommandExecutor(Service)` with a single public `execute(cmd)` method that dispatches internally based on command type. A `DatabaseService(Service)` manages the SQLite connection (WAL mode, via `aiosqlite`). Schema migrations managed by Alembic with raw SQL (no SQLAlchemy models). The executor's `serve()` loop drains a write queue in batches and persists to SQLite. `DataSyncService` reads switch directly from in-memory stores to DB queries (clean cutover, no transitional period). + +Commands are frozen dataclasses that encapsulate the inputs to an execution (listener + event, or job + callable). Services call `self._executor.execute(cmd)` — they don't need to know which internal method handles their command type. The executor runs the action, captures the result, fires error hooks, and queues the record for persistence. + +**Pros:** +- Services get dramatically thinner — `_dispatch()` and `run_job()` go from ~30-45 lines to ~5 +- Single `execute(cmd)` entry point — services don't know or care about internal dispatch +- Cross-cutting concerns consolidated: timing, recording, error hooks, persistence, logging — one place +- `on_error`/`on_exception` backlog item fits naturally as a hook on the executor +- Per-invocation records for both bus and scheduler, persisted to SQLite +- Write batching (queue → batch INSERT in transaction) gives high throughput without per-event overhead +- Internal `match` dispatch means no registration machinery — just add a case for new command types +- Follows the existing `Service` pattern — has `on_initialize`, `on_shutdown`, `serve()` loop +- Evolution path: if command types proliferate, promote to registered handlers later + +**Cons:** +- Exception handling semantics need review: the current `_dispatch()` handling is subtle (CancelledError propagates, others are swallowed, DependencyError classified separately). The migration is an opportunity to audit whether these semantics are correct, not just replicate them. With a small userbase, now is the right time to fix any questionable error handling rather than carry it forward. +- Write queue introduces eventual consistency for persistence — a handler invocation may not appear in the DB for up to 500ms. Live WebSocket push can still use in-memory signals. +- Adding a new command type requires adding a `match` case and a private method (acceptable at 2-3 types, less so at 10+). +- New pattern for the codebase — `CommandExecutor` is a concept developers must learn. Mitigated by it being a single concrete class with typed methods, not an abstract framework. + +**Effort estimate:** Large — new service, behavioral migration of two hot-path methods, schema design, per-invocation recording for bus (new), test infrastructure. But the effort is front-loaded; once the executor exists, adding concerns (error hooks, new recording types) is cheap. + +**Dependencies:** `aiosqlite`, `alembic` + +### Option B: Registered Command Bus (more extensible, more indirection) + +**How it works:** + +Same as Option A, but instead of typed methods, the executor dispatches commands to registered handlers based on type: + +```python +class CommandBus(Service): + _handlers: dict[type, Callable] + + def register(self, command_type: type, handler: Callable) -> None: ... + + async def execute(self, command: Command) -> None: + handler = self._handlers[type(command)] + await handler(command) +``` + +Each command type gets a handler registered at startup. The cross-cutting concerns (timing, recording, error hooks) are either in each handler (duplicated) or implemented as middleware/decorators wrapping each handler. + +**Pros:** +- Most extensible — new command type = new registration, no executor modification +- Open/closed principle — the bus itself never changes +- Familiar pattern if coming from CQRS frameworks + +**Cons:** +- Cross-cutting concerns either get duplicated per handler or require a middleware abstraction — which is essentially what Option A's typed methods already are, just more explicit +- More indirection: `execute(cmd)` → handler lookup → handler function → middleware → actual execution +- Registration ceremony at startup adds wiring code +- Overkill for 2-3 command types — the machinery exists to solve a problem you don't have yet + +**Effort estimate:** Large — same as Option A plus registration infrastructure and middleware pattern + +**Dependencies:** `aiosqlite` + +### Option C: Repository Pattern (simpler, does not slim the services) + +**How it works:** + +No executor. Define a `TelemetryRepository` protocol. `BusService._dispatch()` and `SchedulerService.run_job()` keep their current structure but add `await self.telemetry_repo.record(...)` calls after execution. `DataSyncService` reads from the repository. + +```python +class TelemetryRepository(Protocol): + async def record_handler_invocation(self, record: HandlerInvocationRecord) -> None: ... + async def record_job_execution(self, record: JobExecutionRecord) -> None: ... + async def get_handler_history(self, listener_id: str, limit: int) -> list[HandlerInvocationRecord]: ... + async def get_job_history(self, owner: str | None, limit: int) -> list[JobExecutionRecord]: ... +``` + +**Pros:** +- Simplest mental model — repositories are widely understood +- Direct async/await — no write queue, no eventual consistency +- Protocols match existing codebase conventions + +**Cons:** +- **Does not make services thinner** — `BusService._dispatch()` keeps all its current responsibilities and gains a new one (repository call). This is the opposite of the stated goal. +- No write batching — each invocation is a separate INSERT (slower) +- `on_error`/`on_exception` hooks still need to be wired into both services independently +- `_dispatch()` becomes async-dependent on DB availability — if DB is slow, event dispatch slows down +- To add batching later, you'd reinvent the write queue from Option A + +**Effort estimate:** Medium — but doesn't achieve the architectural goal and would likely be reworked later + +**Dependencies:** `aiosqlite` + +## Concerns + +### Technical risks + +- **Behavioral migration of `_dispatch()`**: The exception handling in `BusService._dispatch()` is subtle. `CancelledError` must propagate (re-raised). `DependencyError` is classified differently from general `Exception`. The executor must preserve these exact semantics — this needs thorough testing with the existing bus integration tests. +- **Bus dispatch hot path**: `_dispatch()` fires on every HA state change. The executor adds a method call and a `queue.put_nowait()`. This is O(1) but measurable at high event rates (100+ events/second). Benchmark with realistic loads. +- **SQLite write throughput**: WAL mode with batched transactions handles 50k+ inserts/second. The concern is not raw throughput but ensuring the write queue doesn't back up during event bursts. A bounded queue with backpressure (drop oldest or log warning) is needed. +- **Stable listener identity**: `listener_id` is currently an auto-incrementing `int` that resets on restart. Persistent records need a stable key — `f"{owner}:{handler_name}:{topic_pattern}"` or similar. This is a design decision for the schema, not a blocker. +- **WAL file growth**: High-frequency writes can cause the WAL file to grow indefinitely if checkpoints are blocked by long-running reads. Set `PRAGMA wal_autocheckpoint` and optionally run periodic `PRAGMA wal_checkpoint(TRUNCATE)`. + +### Complexity risks + +- **Clean cutover, not gradual migration**: The in-memory stores get replaced by the DB in one pass — no transitional period with two read paths. This is simpler but means the cutover PR must be thorough. Small userbase makes this the right call. +- **Test infrastructure**: Database tests need fixtures for schema setup/teardown, in-memory vs. on-disk strategies for speed, and isolation under `pytest-xdist` parallel workers. + +### Maintenance risks + +- **Data retention**: Per-invocation records accumulate indefinitely. Need a retention policy (e.g., `DELETE WHERE timestamp < datetime('now', '-7 days')`) run periodically in the executor's `serve()` loop, or the DB file grows without bound. +- **Executor as a God object**: If every cross-cutting concern gets added to the executor (logging, metrics, DB writes, error hooks, audit trails...), it becomes the new monolith. Keep it focused on execution recording + error hooks. Concerns like structured logging or audit trails should be separate services that consume the same records. + +## Open Questions + +- [ ] Should `aiosqlite` be the async bridge, or should we wrap `sqlite3` in `run_in_thread` to stay consistent with how `diskcache` is used? (`aiosqlite` is more idiomatic but adds a dependency; `run_in_thread` reuses the existing pattern.) +- [ ] What retention policy for per-invocation records? 7 days? 30 days? Configurable? Size-based (e.g., max 1M rows)? +- [ ] Should the DB file live at `data_dir/hassette.db` (single file, room for future tables) or `data_dir/telemetry.db` (scoped to this concern)? +- [ ] Alembic configuration: should migrations live under `src/hassette/migrations/` (shipped with the package) or at the project root? Alembic with raw SQL (no SQLAlchemy models) is the plan — just need to decide on the directory layout. +- [ ] Should aggregate `ListenerMetrics` be kept in parallel (fast reads for live dashboard) or computed on-demand from per-invocation DB records (simpler, but slower for aggregate queries)? +- [ ] Should the `diskcache.Cache` on `Resource` be replaced by the new DB, or kept as a separate concern? (They serve different purposes: diskcache is per-resource key/value, the new DB is framework-wide telemetry.) +- [ ] What `DependencyError` classification means for the executor — should DI failures be a distinct status in the DB schema, or just another error type? + +## Recommendation + +**Go with Option A (Typed Command Executor).** + +It directly addresses both goals: + +1. **Persistent telemetry** — per-invocation records for bus handlers and job executions, persisted to SQLite via batched writes, queryable by the frontend for history, filtering, and drill-down. +2. **Thinner services** — `BusService._dispatch()` and `SchedulerService.run_job()` shed their cross-cutting concerns (timing, metrics, error classification, logging) to the executor. They become thin dispatchers focused on their core responsibility (topic routing, job scheduling). +3. **Error hook extensibility** — the `on_error`/`on_exception` backlog item fits naturally as `CommandExecutor._run_error_hooks()`, wired once instead of twice. + +Option B (Registered Command Bus) offers more extensibility but adds indirection that isn't justified at 2-3 command types. If command types later proliferate, Option A's typed methods can be promoted to a registration pattern — the data model and write infrastructure transfer directly. + +Option C (Repository) doesn't achieve the architectural goal of thinning the services. It adds DB calls to already-complex methods. + +### Suggested next steps + +1. **Record the decision** — create an ADR capturing the choice of SQLite + Typed Command Executor (`/mine.adrs`) +2. **Design the schema** — define tables for `handler_invocations`, `job_executions`, `sessions`, with indexes, retention columns, and the stable listener identity scheme +3. **Set up Alembic** — configure with raw SQL migrations (no SQLAlchemy models), decide on migration directory layout +4. **Implement in 1-2 PRs** (clean cutover, no transitional period): + - `DatabaseService` + Alembic + initial schema + - `CommandExecutor` with `execute()` dispatching to `_execute_job()` and `_execute_handler()` + - Migrate `SchedulerService.run_job()` and `BusService._dispatch()` to use the executor + - Switch `DataSyncService` reads from in-memory stores to DB queries + - Add `HandlerInvocationRecord` (new — bus currently only has aggregates) + - Remove replaced in-memory stores (`_listener_metrics`, `_execution_log`) +5. **Follow-up PR(s)**: Wire `on_error`/`on_exception` hooks, session/uptime tracking, retention policy, dashboard enhancements + +## Sources + +- [aiosqlite: asyncio bridge to sqlite3](https://github.com/omnilib/aiosqlite) +- [aiosqlite performance discussion](https://github.com/omnilib/aiosqlite/issues/97) +- [Going Fast with SQLite and Python — Charles Leifer](https://charlesleifer.com/blog/going-fast-with-sqlite-and-python/) +- [SQLite WAL mode documentation](https://sqlite.org/wal.html) +- [SQLite performance tuning — phiresky](https://phiresky.github.io/blog/2020/sqlite-performance-tuning/) +- [SQLite optimizations for ultra high-performance — PowerSync](https://www.powersync.com/blog/sqlite-optimizations-for-ultra-high-performance) +- [CQRS chapter — Architecture Patterns with Python (Cosmic Python)](https://www.cosmicpython.com/book/chapter_12_cqrs.html) +- [CQRS Pattern in Python — OneUptime](https://oneuptime.com/blog/post/2026-01-22-cqrs-pattern-python/view) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 25b464bf..974c044f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -63,11 +63,9 @@ repos: - id: djlint name: Lint Jinja2 templates language: system - entry: uv run djlint --check src/hassette/web/templates/ + entry: uv run djlint --reformat src/hassette/web/templates/ pass_filenames: false files: ^src/hassette/web/templates/ - stages: - - pre-push - id: eslint name: ESLint JS files diff --git a/CHANGELOG.md b/CHANGELOG.md index b3b83c7c..e97e84e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Changed +- Replaced Bulma CSS framework with a custom `ht-` prefixed design system featuring cool slate surfaces, warm amber accent, and Space Grotesk + JetBrains Mono typography (#262) +- Extracted all design tokens into `tokens.css` with `[data-theme]` selector support for future theming (#262) +- Redesigned dashboard with app status chip grid, activity timeline, and streamlined layout (#262) +- App detail pages now use a flat single-page layout with collapsible metadata, inline tracebacks, and instance switcher dropdown (#262) +- Bus listener and scheduler job tables show expanded detail rows with predicate, rate-limiting, and trigger information (#262) +- Replaced hardcoded CSS fallback colors in alerts and detail panels with proper design tokens (`--ht-surface-inset`, `--ht-surface-code`, `--ht-warning-*`, `--ht-danger-*`) +- Toggle buttons now show fallback text before Alpine.js initializes and expose `aria-expanded` for accessibility (#262) + +### Fixed +- App detail page now uses the actual instance index instead of hardcoded 0, fixing data/URL desync for non-zero instances (#262) +- Detail panel labels now have proper text contrast on dark `--ht-surface-code` background (#262) +- Collapsible panels and tracebacks no longer flash visible before Alpine.js initializes (#262) + +### Added +- Global alert banner showing HA disconnect warnings and failed app errors with expandable tracebacks (#262) +- `ht-btn--ghost` and `ht-btn--xs` button modifier classes (#262) + +### Removed +- Bulma CSS CDN dependency (#262) +- Entity Browser page and related partials (#262) + +## Previous Unreleased + ### Changed - E2E tests now run by default with `uv run pytest` instead of requiring `-m e2e`; added `nox -s e2e` session for CI - `HassetteHarness` now uses a fluent builder API (`with_bus()`, `with_state_proxy()`, etc.) with automatic dependency resolution instead of boolean flags (#253) diff --git a/pyproject.toml b/pyproject.toml index 1887868b..e79c979d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,6 +80,7 @@ test = [ "pytest-cov>=7.0.0", "pytest-playwright>=0.6.2", "pytest-randomly>=3.16.0", + "pytest-repeat>=0.9.4", "pytest-timeout>=2.4.0", "pytest-xdist>=3.8.0", "pytest>=8.4.0", diff --git a/src/hassette/bus/metrics.py b/src/hassette/bus/metrics.py index a777de22..8f340f2d 100644 --- a/src/hassette/bus/metrics.py +++ b/src/hassette/bus/metrics.py @@ -32,6 +32,13 @@ class ListenerMetrics: min_duration_ms: float = 0.0 max_duration_ms: float = 0.0 + # Listener configuration + predicate_description: str | None = None + debounce: float | None = None + throttle: float | None = None + once: bool = False + priority: int = 0 + # Recency last_invoked_at: float | None = None last_error_message: str | None = None @@ -92,6 +99,11 @@ def to_dict(self) -> dict[str, Any]: "min_duration_ms": self.min_duration_ms, "max_duration_ms": self.max_duration_ms, "total_duration_ms": self.total_duration_ms, + "predicate_description": self.predicate_description, + "debounce": self.debounce, + "throttle": self.throttle, + "once": self.once, + "priority": self.priority, "last_invoked_at": self.last_invoked_at, "last_error_message": self.last_error_message, "last_error_type": self.last_error_type, diff --git a/src/hassette/core/app_registry.py b/src/hassette/core/app_registry.py index 1e6cc2b1..9d133145 100644 --- a/src/hassette/core/app_registry.py +++ b/src/hassette/core/app_registry.py @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING from hassette.types.enums import BlockReason, ResourceStatus +from hassette.utils.exception_utils import get_traceback_string if TYPE_CHECKING: from hassette import AppConfig @@ -24,6 +25,7 @@ class AppInstanceInfo: status: ResourceStatus error: Exception | None = None error_message: str | None = None + error_traceback: str | None = None owner_id: str | None = None @@ -83,6 +85,7 @@ class AppManifestInfo: instance_count: int = 0 instances: list[AppInstanceInfo] = field(default_factory=list) error_message: str | None = None + error_traceback: str | None = None @dataclass @@ -239,6 +242,7 @@ def get_snapshot(self) -> AppStatusSnapshot: status=ResourceStatus.FAILED, error=error, error_message=str(error), + error_traceback=get_traceback_string(error) if error.__traceback__ else None, ) failed.append(info) @@ -285,8 +289,11 @@ def get_full_snapshot(self) -> AppFullSnapshot: ) ) + error_traceback: str | None = None + if app_key in self._failed_apps: for index, error in self._failed_apps[app_key]: + tb = get_traceback_string(error) if error.__traceback__ else None instances.append( AppInstanceInfo( app_key=app_key, @@ -296,10 +303,12 @@ def get_full_snapshot(self) -> AppFullSnapshot: status=ResourceStatus.FAILED, error=error, error_message=str(error), + error_traceback=tb, ) ) if error_message is None: error_message = str(error) + error_traceback = tb block_reason = self._blocked_apps.get(app_key) @@ -316,6 +325,7 @@ def get_full_snapshot(self) -> AppFullSnapshot: instance_count=len(instances), instances=instances, error_message=error_message, + error_traceback=error_traceback, ) ) diff --git a/src/hassette/core/bus_service.py b/src/hassette/core/bus_service.py index d5521bbc..040f6195 100644 --- a/src/hassette/core/bus_service.py +++ b/src/hassette/core/bus_service.py @@ -182,15 +182,23 @@ async def _get_matching_listeners(self, topic: str, event: "Event[Any]") -> list def _get_or_create_metrics(self, listener: "Listener") -> ListenerMetrics: """Get or create a ListenerMetrics entry for a listener.""" - return self._listener_metrics.setdefault( - listener.listener_id, - ListenerMetrics( - listener_id=listener.listener_id, - owner=listener.owner, - topic=listener.topic, - handler_name=listener.handler_name, - ), + if listener.listener_id in self._listener_metrics: + return self._listener_metrics[listener.listener_id] + + rate_limiter = listener.adapter.rate_limiter + metrics = ListenerMetrics( + listener_id=listener.listener_id, + owner=listener.owner, + topic=listener.topic, + handler_name=listener.handler_name, + predicate_description=repr(listener.predicate) if listener.predicate else None, + debounce=rate_limiter.debounce if rate_limiter else None, + throttle=rate_limiter.throttle if rate_limiter else None, + once=listener.once, + priority=listener.priority, ) + self._listener_metrics[listener.listener_id] = metrics + return metrics async def _dispatch(self, topic: str, event: "Event[Any]", listener: "Listener") -> None: """Dispatch an event to a specific listener.""" diff --git a/src/hassette/core/data_sync_service.py b/src/hassette/core/data_sync_service.py index ec71de26..dd3c895a 100644 --- a/src/hassette/core/data_sync_service.py +++ b/src/hassette/core/data_sync_service.py @@ -208,6 +208,16 @@ async def get_scheduled_jobs_for_instance(self, app_key: str, index: int) -> lis @staticmethod def _serialize_job(job: "ScheduledJob") -> dict: """Convert a scheduled job to a JSON-safe dict.""" + trigger = job.trigger + trigger_type = type(trigger).__name__ if trigger else "once" + trigger_detail: str | None = None + if trigger is not None: + cron_expr = getattr(trigger, "cron_expression", None) + interval = getattr(trigger, "interval", None) + if cron_expr is not None: + trigger_detail = str(cron_expr) + elif interval is not None: + trigger_detail = str(interval) return { "job_id": job.job_id, "name": job.name, @@ -215,7 +225,8 @@ def _serialize_job(job: "ScheduledJob") -> dict: "next_run": str(job.next_run), "repeat": job.repeat, "cancelled": job.cancelled, - "trigger_type": type(job.trigger).__name__ if job.trigger else "once", + "trigger_type": trigger_type, + "trigger_detail": trigger_detail, } # --- Entity state access (delegates to StateProxy) --- @@ -275,11 +286,13 @@ def get_all_manifests_snapshot(self) -> AppManifestListResponse: class_name=inst.class_name, status=str(inst.status), error_message=inst.error_message, + error_traceback=inst.error_traceback, owner_id=inst.owner_id, ) for inst in m.instances ], error_message=m.error_message, + error_traceback=m.error_traceback, ) for m in snapshot.manifests ] diff --git a/src/hassette/core/scheduler_service.py b/src/hassette/core/scheduler_service.py index cd3c0c22..66a14aa1 100644 --- a/src/hassette/core/scheduler_service.py +++ b/src/hassette/core/scheduler_service.py @@ -227,6 +227,7 @@ async def run_job(self, job: "ScheduledJob"): status=result.status, error_message=result.error_message, error_type=result.error_type, + error_traceback=result.error_traceback, ) self._execution_log.append(record) diff --git a/src/hassette/scheduler/classes.py b/src/hassette/scheduler/classes.py index b11605de..6d615f79 100644 --- a/src/hassette/scheduler/classes.py +++ b/src/hassette/scheduler/classes.py @@ -191,3 +191,4 @@ class JobExecutionRecord: status: str # "success", "error", "cancelled" error_message: str | None = None error_type: str | None = None + error_traceback: str | None = None diff --git a/src/hassette/test_utils/web_helpers.py b/src/hassette/test_utils/web_helpers.py index 52ceb95c..707c0cf2 100644 --- a/src/hassette/test_utils/web_helpers.py +++ b/src/hassette/test_utils/web_helpers.py @@ -40,6 +40,7 @@ def make_manifest( instance_count: int = 1, instances: list[AppInstanceInfo] | None = None, error_message: str | None = None, + error_traceback: str | None = None, ) -> AppManifestInfo: """Build an AppManifestInfo with sensible defaults.""" return AppManifestInfo( @@ -54,6 +55,7 @@ def make_manifest( instance_count=instance_count, instances=instances or [], error_message=error_message, + error_traceback=error_traceback, ) @@ -65,6 +67,11 @@ def make_listener_metric( invocations: int = 10, successful: int = 9, failed: int = 1, + predicate_description: str | None = None, + debounce: float | None = None, + throttle: float | None = None, + once: bool = False, + priority: int = 0, ) -> MagicMock: """Build a mock listener metric with `.to_dict()` and direct attribute access.""" d = { @@ -81,6 +88,11 @@ def make_listener_metric( "min_duration_ms": 1.0, "max_duration_ms": 5.0, "avg_duration_ms": 2.0, + "predicate_description": predicate_description, + "debounce": debounce, + "throttle": throttle, + "once": once, + "priority": priority, "last_invoked_at": None, "last_error_message": None, "last_error_type": None, @@ -162,8 +174,16 @@ def make_job( repeat: bool = True, cancelled: bool = False, trigger_type: str = "interval", + trigger_detail: str | None = None, ) -> SimpleNamespace: """Build a ``SimpleNamespace`` scheduler job for test fixtures.""" + trigger_attrs: dict[str, str] = {} + if trigger_detail is not None: + if trigger_type == "cron": + trigger_attrs["cron_expression"] = trigger_detail + else: + trigger_attrs["interval"] = trigger_detail + trigger_cls = type(trigger_type, (), trigger_attrs)() return SimpleNamespace( job_id=job_id, name=name, @@ -171,5 +191,5 @@ def make_job( next_run=next_run, repeat=repeat, cancelled=cancelled, - trigger=type(trigger_type, (), {})(), + trigger=trigger_cls, ) diff --git a/src/hassette/utils/execution.py b/src/hassette/utils/execution.py index 9e23ff6a..47fd6c7c 100644 --- a/src/hassette/utils/execution.py +++ b/src/hassette/utils/execution.py @@ -6,6 +6,7 @@ import asyncio import time +import traceback from collections.abc import AsyncIterator from contextlib import asynccontextmanager from dataclasses import dataclass @@ -20,6 +21,7 @@ class ExecutionResult: status: str = "pending" error_message: str | None = None error_type: str | None = None + error_traceback: str | None = None @property def is_success(self) -> bool: @@ -58,6 +60,7 @@ async def track_execution() -> AsyncIterator[ExecutionResult]: result.status = "error" result.error_message = str(exc) result.error_type = type(exc).__name__ + result.error_traceback = traceback.format_exc() raise finally: result.duration_ms = (time.monotonic() - result.started_at) * 1000 diff --git a/src/hassette/web/CLAUDE.md b/src/hassette/web/CLAUDE.md index 245a86f3..282f77c6 100644 --- a/src/hassette/web/CLAUDE.md +++ b/src/hassette/web/CLAUDE.md @@ -4,37 +4,33 @@ ``` templates/ -├── base.html # Root layout: Bulma, HTMX, Alpine.js, nav +├── base.html # Root layout: HTMX, Alpine.js, nav, alert banner ├── macros/ │ └── ui.html # Shared Jinja2 macros (status_badge, action_buttons, log_table, job_status_badge) ├── components/ │ ├── nav.html # Sidebar navigation -│ └── status_bar.html # Top status bar (health badge) +│ ├── status_bar.html # Top status bar (health badge) +│ └── alert_banner.html # Global alert strip (HA disconnect + failed apps) ├── pages/ # Full-page templates (extend base.html) │ ├── dashboard.html │ ├── apps.html -│ ├── app_detail.html -│ ├── app_instance_detail.html +│ ├── app_instance_detail.html # App/instance detail: flat layout with metadata, listeners, jobs, logs │ ├── logs.html │ ├── scheduler.html -│ ├── bus.html -│ └── entities.html +│ └── bus.html └── partials/ # HTML fragments for HTMX swaps (no , no
) - ├── health_badge.html - ├── event_feed.html + ├── alert_failed_apps.html ├── app_list.html ├── app_row.html ├── manifest_list.html ├── manifest_row.html ├── instance_row.html ├── log_entries.html - ├── entity_list.html ├── scheduler_jobs.html ├── scheduler_history.html ├── bus_listeners.html - ├── bus_metrics.html - ├── apps_summary.html - ├── dashboard_scheduler.html + ├── dashboard_app_grid.html + ├── dashboard_timeline.html ├── dashboard_logs.html ├── app_detail_listeners.html └── app_detail_jobs.html @@ -83,7 +79,6 @@ All custom CSS classes use the `ht-` prefix: ### Alpine.js Components - `logTable(config)` — log viewer (`static/js/log-table.js`). Has `init()` and `destroy()` lifecycle methods. -- `entityBrowser()` — defined inline in `pages/entities.html` `{% block scripts %}`. - Alpine stores: `$store.ws` — WebSocket state (`static/js/ws-handler.js`). ### HTMX diff --git a/src/hassette/web/models.py b/src/hassette/web/models.py index d3707698..d11a8202 100644 --- a/src/hassette/web/models.py +++ b/src/hassette/web/models.py @@ -34,6 +34,7 @@ class AppInstanceResponse(BaseModel): class_name: str status: str error_message: str | None = None + error_traceback: str | None = None owner_id: str | None = None @@ -57,6 +58,7 @@ class AppManifestResponse(BaseModel): instance_count: int = 0 instances: list[AppInstanceResponse] = Field(default_factory=list) error_message: str | None = None + error_traceback: str | None = None class AppManifestListResponse(BaseModel): @@ -101,6 +103,7 @@ class ScheduledJobResponse(BaseModel): repeat: bool cancelled: bool trigger_type: str + trigger_detail: str | None = None class JobExecutionResponse(BaseModel): @@ -112,6 +115,7 @@ class JobExecutionResponse(BaseModel): status: str error_message: str | None = None error_type: str | None = None + error_traceback: str | None = None class ListenerMetricsResponse(BaseModel): @@ -128,6 +132,11 @@ class ListenerMetricsResponse(BaseModel): min_duration_ms: float max_duration_ms: float total_duration_ms: float + predicate_description: str | None = None + debounce: float | None = None + throttle: float | None = None + once: bool = False + priority: int = 0 last_invoked_at: float | None = None last_error_message: str | None = None last_error_type: str | None = None diff --git a/src/hassette/web/static/css/style.css b/src/hassette/web/static/css/style.css index 164e0782..f5bb178a 100644 --- a/src/hassette/web/static/css/style.css +++ b/src/hassette/web/static/css/style.css @@ -1,17 +1,160 @@ -/* Hassette Web UI — custom overrides */ +/* ===================================================== + Hassette Design System — Components + Tokens live in tokens.css; this file is theme-agnostic. + ===================================================== */ + +/* — Reset ——————————————————————————————————————————————— */ +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + -webkit-text-size-adjust: 100%; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; +} + +body { + margin: 0; + line-height: 1.5; +} + +h1, h2, h3, h4, h5, h6 { + margin: 0 0 0.5em; + line-height: 1.25; +} + +p, ul, ol, table, pre, blockquote { + margin: 0 0 1em; +} + +img, svg { + max-width: 100%; + vertical-align: middle; +} + +button { + cursor: pointer; +} + +[x-cloak] { + display: none !important; +} + +table { + border-collapse: collapse; + border-spacing: 0; + width: 100%; +} + +/* — Base Typography ———————————————————————————————————— */ +body { + font-family: var(--ht-font-body); + font-size: var(--ht-text-base); + color: var(--ht-text); + background: var(--ht-surface-canvas); +} -/* Layout: sidebar + main */ +h1, h2, h3, h4, h5, h6 { + font-family: var(--ht-font-heading); + font-weight: 600; + color: var(--ht-text); +} + +code, pre { + font-family: var(--ht-font-mono); + font-size: 0.9em; + color: var(--ht-text); +} + +a { + color: var(--ht-info); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +a code { + color: var(--ht-info); +} + +a:hover code { + text-decoration: underline; +} + +strong { + font-weight: 600; +} + +/* — Layout ————————————————————————————————————————————— */ .ht-layout { display: flex; min-height: 100vh; position: relative; } -/* Sidebar */ +.ht-main { + flex: 1; + background: var(--ht-surface-canvas); + overflow-y: auto; + min-width: 0; +} + +.ht-section { + padding: 1.5rem; +} + +/* 12-column CSS grid */ +.ht-grid { + display: grid; + grid-template-columns: repeat(12, 1fr); + gap: var(--ht-sp-6); +} + +.ht-grid-col-5 { grid-column: span 5; } +.ht-grid-col-6 { grid-column: span 6; } +.ht-grid-col-7 { grid-column: span 7; } +.ht-grid-col-12 { grid-column: span 12; } + +/* Level (horizontal bar) */ +.ht-level { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--ht-sp-4); +} + +.ht-level-start { + display: flex; + align-items: center; + gap: var(--ht-sp-3); + flex-wrap: wrap; +} + +.ht-level-end { + display: flex; + align-items: center; + gap: var(--ht-sp-3); + flex-wrap: wrap; +} + +.ht-level-item { + display: flex; + align-items: center; + flex-direction: column; + text-align: center; +} + +/* — Sidebar ———————————————————————————————————————————— */ .ht-sidebar { width: 220px; - background: #1a1a2e; - color: #e0e0e0; + background: var(--ht-surface-sidebar); + color: var(--ht-sidebar-text); display: flex; flex-direction: column; padding: 1rem 0; @@ -26,6 +169,11 @@ width: 56px; } +/* Pre-paint: prevent flicker when sidebar was closed in previous session */ +html.ht-sidebar-closed .ht-sidebar:not(.is-open) { + transition: none; +} + .ht-sidebar:not(.is-open) .ht-sidebar-brand { justify-content: center; padding: 0.75rem 0 1.5rem; @@ -37,12 +185,12 @@ display: none; } -.ht-sidebar:not(.is-open) .menu-list a { +.ht-sidebar:not(.is-open) .ht-nav-list a { justify-content: center; padding: 0.6rem 0; } -.ht-sidebar:not(.is-open) .menu-list a span:not(.icon) { +.ht-sidebar:not(.is-open) .ht-nav-list a span:not(.ht-icon) { display: none; } @@ -51,9 +199,10 @@ align-items: center; gap: 0.5rem; padding: 0.75rem 1.25rem 1.5rem; + font-family: var(--ht-font-heading); font-size: 1.25rem; font-weight: 700; - color: #fff; + color: var(--ht-sidebar-brand-color); } .ht-brand-link { @@ -66,26 +215,36 @@ .ht-brand-link:hover { opacity: 0.85; + text-decoration: none; +} + +/* Navigation list */ +.ht-nav-list { + list-style: none; + margin: 0; + padding: 0; } -.ht-sidebar .menu-list a { - color: #b0b0c0; +.ht-nav-list a { + color: var(--ht-sidebar-text); display: flex; align-items: center; gap: 0.5rem; padding: 0.6rem 1.25rem; border-radius: 0; transition: background 0.15s, color 0.15s; + text-decoration: none; } -.ht-sidebar .menu-list a:hover { - background: rgba(255, 255, 255, 0.08); - color: #fff; +.ht-nav-list a:hover { + background: var(--ht-sidebar-hover-bg); + color: var(--ht-sidebar-text-hover); + text-decoration: none; } -.ht-sidebar .menu-list a.is-active { - background: rgba(72, 199, 142, 0.15); - color: #48c78e; +.ht-nav-list a.is-active { + background: var(--ht-sidebar-active-bg); + color: var(--ht-sidebar-active-color); font-weight: 600; } @@ -99,7 +258,7 @@ display: none; background: none; border: none; - color: #b0b0c0; + color: var(--ht-sidebar-text); cursor: pointer; margin-left: auto; padding: 0.25rem; @@ -107,15 +266,15 @@ } .ht-sidebar-toggle:hover { - color: #fff; + color: var(--ht-sidebar-text-hover); } -/* Sidebar backdrop overlay (mobile only — hidden on desktop) */ +/* Sidebar backdrop overlay (mobile only) */ .ht-sidebar-backdrop { display: none; position: fixed; inset: 0; - background: rgba(0, 0, 0, 0.5); + background: var(--ht-backdrop); z-index: 20; } @@ -124,32 +283,360 @@ display: inline-flex; background: none; border: none; - color: #4a4a4a; + color: var(--ht-text-muted); cursor: pointer; padding: 0.25rem; font-size: 1.1rem; } .ht-menu-toggle:hover { - color: #1a1a2e; + color: var(--ht-text); } -/* Main content area */ -.ht-main { - flex: 1; - background: #f5f5f5; - overflow-y: auto; - min-width: 0; +/* — Cards —————————————————————————————————————————————— */ +.ht-card { + background: var(--ht-surface-card); + border: 1px solid var(--ht-border); + border-radius: var(--ht-radius-md); + padding: var(--ht-sp-6); +} + +.ht-card--full-height { + height: 100%; +} + +/* — Tables ————————————————————————————————————————————— */ +.ht-table { + width: 100%; + border-collapse: collapse; + background: var(--ht-surface-card); +} + +.ht-table th { + text-align: left; + font-weight: 600; + font-size: var(--ht-text-sm); + color: var(--ht-text-muted); + text-transform: uppercase; + letter-spacing: 0.03em; + padding: var(--ht-sp-2) var(--ht-sp-3); + border-bottom: 1px solid var(--ht-border-strong); +} + +.ht-table td { + padding: var(--ht-sp-2) var(--ht-sp-3); + border-bottom: 1px solid var(--ht-border-subtle); + vertical-align: middle; +} + +.ht-table tbody tr:hover { + background: var(--ht-slate-50); +} + +.ht-table--dense th, +.ht-table--dense td { + padding: 0.3em 0.5em; +} + +.ht-table--striped tbody tr:nth-child(even) { + background: var(--ht-slate-50); +} + +/* Narrow variant (used in health badge config tables) */ +.ht-table--narrow th, +.ht-table--narrow td { + padding: 0.25em 0.5em; +} + +/* — Badges ————————————————————————————————————————————— */ +.ht-badge { + display: inline-flex; + align-items: center; + gap: 0.25em; + font-size: var(--ht-text-xs); + font-weight: 500; + padding: 0.15em 0.55em; + border-radius: var(--ht-radius-full); + line-height: 1.6; + white-space: nowrap; + vertical-align: middle; +} + +.ht-badge--sm { + font-size: 0.6875rem; + padding: 0.1em 0.45em; +} + +.ht-badge--md { + font-size: var(--ht-text-sm); + padding: 0.2em 0.65em; +} + +/* Semantic badge variants */ +.ht-badge--success { + color: var(--ht-success-text); + background: var(--ht-success-light); +} + +.ht-badge--danger { + color: var(--ht-danger-text); + background: var(--ht-danger-light); +} + +.ht-badge--warning { + color: var(--ht-warning-text); + background: var(--ht-warning-light); +} + +.ht-badge--info { + color: var(--ht-info-text); + background: var(--ht-info-light); +} + +.ht-badge--link { + color: var(--ht-link-text); + background: var(--ht-link-light); +} + +.ht-badge--neutral { + color: var(--ht-text-muted); + background: var(--ht-slate-100); +} + +/* App status badges */ +.ht-status-stopped { + color: var(--ht-warning-text); + background: var(--ht-warning-light); +} + +.ht-status-disabled { + color: var(--ht-text-muted); + background: var(--ht-slate-100); +} + +.ht-status-blocked { + color: var(--ht-link-text); + background: var(--ht-link-light); +} + +/* Entity state badges */ +.ht-entity-on { + color: var(--ht-text-on-dark); + background: var(--ht-success); } -/* Status bar */ +.ht-entity-off { + color: var(--ht-text-muted); + background: var(--ht-slate-200); +} + +.ht-entity-unavailable { + color: var(--ht-text-on-dark); + background: var(--ht-danger); +} + +/* Log level badges */ +.ht-log-debug { color: var(--ht-text-muted); } +.ht-log-info { color: var(--ht-info); } +.ht-log-warning { color: var(--ht-text-on-dark); background: var(--ht-warning); } +.ht-log-error { color: var(--ht-text-on-dark); background: var(--ht-danger); } +.ht-log-critical { color: var(--ht-text-on-dark); background: var(--ht-critical); font-weight: 700; } + +/* — Buttons ———————————————————————————————————————————— */ +.ht-btn { + display: inline-flex; + align-items: center; + gap: 0.35em; + font-family: var(--ht-font-body); + font-size: var(--ht-text-sm); + font-weight: 500; + padding: 0.4em 0.85em; + border: 1px solid var(--ht-border-strong); + border-radius: var(--ht-radius-md); + background: var(--ht-surface-card); + color: var(--ht-text); + cursor: pointer; + transition: background 0.15s, border-color 0.15s, color 0.15s; + text-decoration: none; + line-height: 1.5; +} + +.ht-btn:hover { + background: var(--ht-slate-50); + border-color: var(--ht-slate-300); + text-decoration: none; +} + +.ht-btn--sm { + font-size: var(--ht-text-xs); + padding: 0.25em 0.6em; +} + +.ht-btn--xs { + font-size: 0.6875rem; + padding: 0.15em 0.45em; +} + +.ht-btn--ghost { + border-color: transparent; + background: transparent; +} + +.ht-btn--ghost:hover { + background: var(--ht-slate-100); + border-color: transparent; +} + +.ht-btn--primary { + background: var(--ht-amber); + border-color: var(--ht-amber); + color: var(--ht-text-on-dark); +} + +.ht-btn--primary:hover { + background: var(--ht-amber-hover); + border-color: var(--ht-amber-hover); +} + +.ht-btn--success { + border-color: var(--ht-success); + color: var(--ht-success); +} + +.ht-btn--success:hover { + background: var(--ht-success-light); +} + +.ht-btn--warning { + border-color: var(--ht-warning); + color: var(--ht-warning); +} + +.ht-btn--warning:hover { + background: var(--ht-warning-light); +} + +.ht-btn--info { + border-color: var(--ht-info); + color: var(--ht-info); +} + +.ht-btn--info:hover { + background: var(--ht-info-light); +} + +.ht-btn--link { + border-color: var(--ht-link); + color: var(--ht-link); +} + +.ht-btn--link:hover { + background: var(--ht-link-light); +} + +.ht-btn-group { + display: flex; + gap: var(--ht-sp-2); + flex-wrap: wrap; +} + +/* — Forms —————————————————————————————————————————————— */ +.ht-input, +.ht-select select { + font-family: var(--ht-font-body); + font-size: var(--ht-text-sm); + padding: 0.35em 0.6em; + border: 1px solid var(--ht-border-strong); + border-radius: var(--ht-radius-md); + background: var(--ht-surface-card); + color: var(--ht-text); + line-height: 1.5; +} + +.ht-input:focus, +.ht-select select:focus { + outline: none; + border-color: var(--ht-amber); + box-shadow: 0 0 0 2px var(--ht-amber-dim); +} + +.ht-input--sm, +.ht-select--sm select { + font-size: var(--ht-text-xs); + padding: 0.25em 0.5em; +} + +.ht-select { + display: inline-block; + position: relative; +} + +.ht-field-group { + display: flex; + align-items: center; + gap: var(--ht-sp-3); + flex-wrap: wrap; +} + +.ht-control { + display: inline-flex; + align-items: center; +} + +/* — Tabs ——————————————————————————————————————————————— */ +.ht-tabs { + border-bottom: 1px solid var(--ht-border); + margin-bottom: var(--ht-sp-4); +} + +.ht-tabs ul { + display: flex; + list-style: none; + margin: 0; + padding: 0; + gap: 0; +} + +.ht-tabs li a { + display: block; + padding: 0.4em 0.85em; + font-size: var(--ht-text-sm); + color: var(--ht-text-muted); + border-bottom: 2px solid transparent; + margin-bottom: -1px; + text-decoration: none; + cursor: pointer; + transition: color 0.15s, border-color 0.15s; +} + +.ht-tabs li a:hover { + color: var(--ht-text); + text-decoration: none; +} + +.ht-tabs li.is-active a { + color: var(--ht-amber); + border-bottom-color: var(--ht-amber); + font-weight: 600; +} + +/* — Notices ———————————————————————————————————————————— */ +.ht-notice--info { + background: var(--ht-info-light); + color: var(--ht-info-text); + border: 1px solid var(--ht-notice-info-border); + border-radius: var(--ht-radius-md); + padding: var(--ht-sp-4); +} + +/* — Status Bar ————————————————————————————————————————— */ .ht-status-bar { display: flex; justify-content: space-between; align-items: center; padding: 0.4rem 1.5rem; - background: #fff; - border-bottom: 1px solid #e8e8e8; + background: var(--ht-surface-card); + border-bottom: 1px solid var(--ht-border); gap: 0.75rem; } @@ -159,78 +646,131 @@ gap: 0.35rem; } -.ht-ws-dot { +/* — Pulse Dot Signature ———————————————————————————————— */ +.ht-pulse-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; + background: var(--ht-amber); + animation: ht-breathe 2s ease-in-out infinite; } -.is-connected .ht-ws-dot { background: #48c78e; } -.is-disconnected .ht-ws-dot { background: #f14668; } - -/* Compact table rows */ -.table.is-compact td, -.table.is-compact th { - padding: 0.4em 0.6em; +.ht-pulse-dot--disconnected { + background: var(--ht-danger); + animation: none; } -/* Log level badges */ -.ht-log-debug { color: #7a7a7a; } -.ht-log-info { color: #3e8ed0; } -.ht-log-warning { color: #ffe08a; background: #946b00; } -.ht-log-error { color: #fff; background: #f14668; } -.ht-log-critical { color: #fff; background: #cc0f35; font-weight: 700; } - -/* App status badges — stopped, disabled, blocked */ -.ht-status-stopped { - color: #946b00; - background: #ffe08a; +@keyframes ht-breathe { + 0%, 100% { opacity: 0.4; } + 50% { opacity: 1; } } -.ht-status-disabled { - color: #666; - background: #e0e0e0; +/* — Headings ——————————————————————————————————————————— */ +.ht-heading-1 { + font-family: var(--ht-font-heading); + font-size: var(--ht-text-2xl); + font-weight: 600; + color: var(--ht-text-faint); } -.ht-status-blocked { - color: #6b3fa0; - background: #e8d5f5; +.ht-heading-4 { + font-family: var(--ht-font-heading); + font-size: var(--ht-text-lg); + font-weight: 600; + display: flex; + align-items: center; + gap: 0.35em; } -/* Entity state badges */ -.ht-entity-on { - color: #fff; - background: #48c78e; +.ht-heading-5 { + font-family: var(--ht-font-heading); + font-size: var(--ht-text-base); + font-weight: 600; + display: flex; + align-items: center; + gap: 0.35em; } -.ht-entity-off { - color: #363636; - background: #dbdbdb; +.ht-subheading-4 { + font-family: var(--ht-font-heading); + font-size: var(--ht-text-lg); + font-weight: 400; + color: var(--ht-text-muted); } -.ht-entity-unavailable { - color: #fff; - background: #f14668; +.ht-subheading-6 { + font-size: var(--ht-text-sm); + color: var(--ht-text-muted); } -/* Override Bulma's red color */
-code {
- color: #4a4a4a;
+.ht-label {
+ font-size: var(--ht-text-xs);
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--ht-text-muted);
+ margin-bottom: 0.25em;
}
-/* Links wrapping code use blue link color */
-a code {
- color: #3e8ed0;
+/* — Icons —————————————————————————————————————————————— */
+.ht-icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 1.25em;
+ height: 1.25em;
}
-a:hover code {
- text-decoration: underline;
+.ht-icon--sm {
+ width: 1em;
+ height: 1em;
}
-/* Live update pulse animation */
+/* — Utilities —————————————————————————————————————————— */
+/* Text alignment */
+.ht-text-center { text-align: center; }
+.ht-text-right { text-align: right; }
+
+/* Text color */
+.ht-text-muted { color: var(--ht-text-muted); }
+.ht-text-faint { color: var(--ht-text-faint); }
+.ht-text-danger { color: var(--ht-danger); }
+.ht-text-success { color: var(--ht-success); }
+
+/* Text size */
+.ht-text-xs { font-size: var(--ht-text-xs); }
+.ht-text-sm { font-size: var(--ht-text-sm); }
+
+/* Flex */
+.ht-flex { display: flex; }
+.ht-flex-col { flex-direction: column; }
+.ht-flex-grow { flex-grow: 1; }
+
+/* Spacing: margin */
+.ht-mb-3 { margin-bottom: var(--ht-sp-3); }
+.ht-mb-4 { margin-bottom: var(--ht-sp-4); }
+.ht-mb-5 { margin-bottom: 1.25rem; }
+.ht-mb-6 { margin-bottom: var(--ht-sp-6); }
+.ht-ml-2 { margin-left: var(--ht-sp-2); }
+.ht-mr-2 { margin-right: var(--ht-sp-2); }
+.ht-mt-3 { margin-top: var(--ht-sp-3); }
+.ht-mt-4 { margin-top: var(--ht-sp-4); }
+.ht-mt-auto { margin-top: auto; }
+
+/* Spacing: padding */
+.ht-p-hero { padding: var(--ht-sp-12) var(--ht-sp-4); }
+.ht-pl-6 { padding-left: var(--ht-sp-6); }
+
+/* Width */
+.ht-w-full { width: 100%; }
+
+/* Nowrap */
+.ht-nowrap { white-space: nowrap; }
+
+/* — Animations ————————————————————————————————————————— */
@keyframes ht-pulse {
- 0% { opacity: 0.5; }
+ 0% { opacity: 0.5; }
100% { opacity: 1; }
}
@@ -238,11 +778,33 @@ a:hover code {
animation: ht-pulse 0.6s ease-in-out;
}
-/* =====================================================
- Responsive: tablet & mobile (max-width: 768px)
- ===================================================== */
+/* — Log container —————————————————————————————————————— */
+.ht-log-container {
+ overflow-y: auto;
+}
+
+/* — Group / instance rows —————————————————————————————— */
+.ht-group-header {
+ background: var(--ht-slate-50);
+}
+
+.ht-instance-row td:first-child {
+ padding-left: var(--ht-sp-6);
+}
+
+/* Event list (event feed partial) */
+.ht-event-list {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+}
+
+.ht-event-list li {
+ padding: 0.2em 0;
+}
+
+/* — Responsive: tablet & mobile (max-width: 768px) ———— */
@media screen and (max-width: 768px) {
- /* Sidebar: fixed icon rail, expands to full overlay when open */
.ht-sidebar {
position: fixed;
top: 0;
@@ -254,17 +816,14 @@ a:hover code {
width: 260px;
}
- /* Show hamburger toggle inside sidebar when expanded on mobile */
.ht-sidebar.is-open .ht-sidebar-toggle {
display: inline-flex;
}
- /* Show backdrop when sidebar is fully open */
.ht-sidebar-backdrop {
display: block;
}
- /* Offset main content for the icon rail */
.ht-main {
margin-left: 56px;
}
@@ -273,77 +832,262 @@ a:hover code {
padding: 0.4rem 1rem;
}
- /* Main content: reduce section padding on mobile */
- .ht-main .section {
+ .ht-section {
padding: 1rem;
}
- /* Tables: horizontal scroll on mobile */
- .table-container,
- .box > .table,
- .box > div > .table {
+ /* Grid: stack columns */
+ .ht-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .ht-grid-col-5,
+ .ht-grid-col-6,
+ .ht-grid-col-7,
+ .ht-grid-col-12 {
+ grid-column: span 1;
+ }
+
+ /* Tables: horizontal scroll */
+ .ht-card > .ht-table,
+ .ht-card > div > .ht-table {
display: block;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
- /* Dashboard columns: stack vertically on mobile */
- .columns.is-multiline .column.is-half {
- flex: none;
- width: 100%;
- }
-
- /* Bulma .level: wrap on mobile */
- .level {
+ /* Level: wrap */
+ .ht-level {
flex-wrap: wrap;
gap: 0.5rem;
}
- .level-left,
- .level-right {
+ .ht-level-start,
+ .ht-level-end {
flex-wrap: wrap;
gap: 0.5rem;
}
- /* Tabs: scroll horizontally if too many */
- .tabs ul {
+ /* Tabs: scroll horizontally */
+ .ht-tabs ul {
flex-wrap: nowrap;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
- .tabs li {
+ .ht-tabs li {
flex-shrink: 0;
}
- /* Field groups: stack vertically */
- .field.is-grouped {
+ /* Field groups: stack */
+ .ht-field-group {
flex-wrap: wrap;
}
- .field.is-grouped .control {
- margin-bottom: 0.5rem;
- }
-
- /* Page titles: smaller on mobile */
- .title.is-4 {
+ /* Headings: smaller */
+ .ht-heading-4 {
font-size: 1.1rem;
}
- .title.is-5 {
+ .ht-heading-5 {
font-size: 1rem;
}
}
-/* =====================================================
- Responsive: small phones (max-width: 480px)
- ===================================================== */
+/* — Alert Banner ———————————————————————————————————————— */
+.ht-alert-banner {
+ padding: 0 var(--ht-sp-4);
+}
+
+.ht-alert-banner:empty {
+ display: none;
+}
+
+.ht-alert {
+ display: flex;
+ align-items: center;
+ gap: var(--ht-sp-2);
+ padding: var(--ht-sp-2) var(--ht-sp-3);
+ border-radius: var(--ht-radius-md);
+ font-size: var(--ht-text-sm);
+ margin-bottom: var(--ht-sp-2);
+}
+
+.ht-alert--warning {
+ background: var(--ht-warning-bg);
+ border: 1px solid var(--ht-warning-border);
+ color: var(--ht-warning-text);
+}
+
+.ht-alert--danger {
+ background: var(--ht-danger-bg);
+ border: 1px solid var(--ht-danger-border);
+ color: var(--ht-danger-text);
+ flex-direction: column;
+ align-items: stretch;
+}
+
+.ht-alert-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--ht-sp-2);
+}
+
+.ht-alert-details {
+ margin-top: var(--ht-sp-2);
+ display: flex;
+ flex-direction: column;
+ gap: var(--ht-sp-2);
+}
+
+.ht-alert-item {
+ padding: var(--ht-sp-2);
+ background: var(--ht-surface-inset);
+ border-radius: var(--ht-radius-sm);
+}
+
+.ht-alert-item-header {
+ display: flex;
+ align-items: center;
+ gap: var(--ht-sp-2);
+ flex-wrap: wrap;
+}
+
+.ht-alert-item-header a {
+ color: inherit;
+ text-decoration: underline;
+}
+
+.ht-traceback {
+ margin: var(--ht-sp-2) 0 0;
+ padding: var(--ht-sp-2) var(--ht-sp-3);
+ background: var(--ht-surface-code);
+ color: var(--ht-text-code);
+ border-radius: var(--ht-radius-sm);
+ font-family: var(--ht-font-mono);
+ font-size: var(--ht-text-xs);
+ overflow-x: auto;
+ white-space: pre;
+ line-height: 1.6;
+}
+
+/* — App Chips (dashboard) ————————————————————————————— */
+.ht-app-chips {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--ht-sp-2);
+}
+
+.ht-app-chip {
+ display: inline-flex;
+ align-items: center;
+ padding: 0.25em 0.6em;
+ font-size: var(--ht-text-sm);
+ font-weight: 500;
+ border-radius: var(--ht-radius-full);
+ background: var(--ht-slate-100);
+ color: var(--ht-text-muted);
+ text-decoration: none;
+ white-space: nowrap;
+ transition: opacity 0.15s;
+}
+
+.ht-app-chip:hover {
+ opacity: 0.8;
+ text-decoration: none;
+}
+
+.ht-app-chip--running { background: var(--ht-success-light); color: var(--ht-success-text); }
+.ht-app-chip--failed { background: var(--ht-danger-light); color: var(--ht-danger-text); }
+.ht-app-chip--stopped { background: var(--ht-warning-light); color: var(--ht-warning-text); }
+.ht-app-chip--disabled { background: var(--ht-slate-100); color: var(--ht-text-muted); opacity: 0.6; }
+
+/* — Timeline (dashboard) ————————————————————————————— */
+.ht-timeline {
+ display: flex;
+ flex-direction: column;
+ gap: var(--ht-sp-2);
+ max-height: 300px;
+ overflow-y: auto;
+ overflow-x: hidden;
+}
+
+.ht-timeline-entry {
+ display: flex;
+ align-items: center;
+ gap: var(--ht-sp-2);
+ padding: var(--ht-sp-1) 0;
+ border-bottom: 1px solid var(--ht-border-subtle);
+ min-width: 0;
+}
+
+.ht-timeline-entry code {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ min-width: 0;
+ flex: 1;
+}
+
+.ht-timeline-entry:last-child {
+ border-bottom: none;
+}
+
+/* — Detail Row / Panel (expandable table rows) ————————— */
+.ht-detail-row:hover {
+ background: var(--ht-surface-inset);
+}
+
+.ht-detail-panel-row > td {
+ padding: 0;
+ border-top: none;
+ border-bottom: none;
+}
+
+.ht-detail-panel {
+ padding: var(--ht-sp-2) var(--ht-sp-4);
+ background: var(--ht-surface-code);
+ color: var(--ht-text-code);
+ border-radius: var(--ht-radius-sm);
+ margin: 0 var(--ht-sp-2) var(--ht-sp-2);
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--ht-sp-3) var(--ht-sp-6);
+}
+
+.ht-detail-fields {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--ht-sp-3) var(--ht-sp-6);
+}
+
+.ht-detail-field {
+ display: flex;
+ flex-direction: column;
+ gap: var(--ht-sp-1);
+}
+
+.ht-detail-label {
+ font-size: var(--ht-text-xs);
+ color: var(--ht-text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ font-weight: 500;
+}
+
+.ht-detail-panel .ht-detail-label {
+ color: var(--ht-text-code);
+ opacity: 0.65;
+}
+
+/* — Responsive: small phones (max-width: 480px) ———————— */
@media screen and (max-width: 480px) {
- .ht-main .section {
+ .ht-section {
padding: 0.75rem;
}
- .box {
- padding: 0.75rem;
+ .ht-card {
+ padding: var(--ht-sp-3);
}
}
diff --git a/src/hassette/web/static/css/tokens.css b/src/hassette/web/static/css/tokens.css
new file mode 100644
index 00000000..c039dabb
--- /dev/null
+++ b/src/hassette/web/static/css/tokens.css
@@ -0,0 +1,134 @@
+/* =====================================================
+ Hassette Design Tokens — Default Theme
+ Cool slate surfaces · Warm amber accent · Borders-only depth
+
+ To create a new theme, copy this file and override
+ the variables under [data-theme="your-theme"].
+ ===================================================== */
+
+:root, [data-theme="default"] {
+ /* — Palette ————————————————————————————————————————— */
+
+ /* Slate */
+ --ht-slate-50: #f8fafc;
+ --ht-slate-100: #f1f5f9;
+ --ht-slate-200: #e2e8f0;
+ --ht-slate-300: #cbd5e1;
+ --ht-slate-400: #94a3b8;
+ --ht-slate-500: #64748b;
+ --ht-slate-600: #475569;
+ --ht-slate-700: #334155;
+ --ht-slate-800: #1e293b;
+ --ht-slate-900: #0f172a;
+
+ /* Amber accent */
+ --ht-amber: #D4915C;
+ --ht-amber-dim: rgba(212, 145, 92, 0.35);
+ --ht-amber-hover: #c07e4a;
+
+ /* — Semantic Colors ————————————————————————————————— */
+
+ --ht-success: #16a34a;
+ --ht-success-light: #f0fdf4;
+ --ht-success-text: #166534;
+
+ --ht-danger: #dc2626;
+ --ht-danger-light: #fef2f2;
+ --ht-danger-text: #991b1b;
+
+ --ht-warning: #ca8a04;
+ --ht-warning-light: #fefce8;
+ --ht-warning-text: #854d0e;
+
+ --ht-info: #2563eb;
+ --ht-info-light: #eff6ff;
+ --ht-info-text: #1e40af;
+
+ --ht-link: #7c3aed;
+ --ht-link-light: #f5f3ff;
+ --ht-link-text: #5b21b6;
+
+ /* — Surfaces ———————————————————————————————————————— */
+
+ --ht-surface-canvas: var(--ht-slate-50);
+ --ht-surface-card: #ffffff;
+ --ht-surface-dropdown: #ffffff;
+ --ht-surface-sidebar: var(--ht-slate-900);
+ --ht-surface-sticky: white;
+ --ht-surface-inset: rgba(0, 0, 0, 0.05); /* Alert items, nested panels */
+ --ht-surface-code: #1e1e2e; /* Code blocks, tracebacks */
+
+ /* — Borders ————————————————————————————————————————— */
+
+ --ht-border: rgba(0, 0, 0, 0.08);
+ --ht-border-strong: rgba(0, 0, 0, 0.15);
+ --ht-border-subtle: rgba(0, 0, 0, 0.04);
+
+ /* — Text ———————————————————————————————————————————— */
+
+ --ht-text: var(--ht-slate-800);
+ --ht-text-muted: var(--ht-slate-500);
+ --ht-text-faint: var(--ht-slate-400);
+ --ht-text-on-dark: #fff;
+ --ht-text-code: #cdd6f4; /* Light text on dark code surfaces */
+
+ /* — Sidebar ————————————————————————————————————————— */
+
+ --ht-sidebar-text: var(--ht-slate-400);
+ --ht-sidebar-text-hover: #fff;
+ --ht-sidebar-hover-bg: rgba(255, 255, 255, 0.08);
+ --ht-sidebar-active-bg: rgba(212, 145, 92, 0.15);
+ --ht-sidebar-active-color: var(--ht-amber);
+ --ht-sidebar-brand-color: #fff;
+
+ /* — Backdrop ———————————————————————————————————————— */
+
+ --ht-backdrop: rgba(0, 0, 0, 0.5);
+
+ /* — Alert Tints (translucent overlays for alert banners) */
+
+ --ht-warning-bg: rgba(234, 179, 8, 0.1);
+ --ht-warning-border: rgba(234, 179, 8, 0.3);
+ --ht-danger-bg: rgba(239, 68, 68, 0.1);
+ --ht-danger-border: rgba(239, 68, 68, 0.3);
+
+ /* — Notice ——————————————————————————————————————————— */
+
+ --ht-notice-info-border: rgba(37, 99, 235, 0.15);
+
+ /* — Critical (darkened danger for severity) —————————— */
+
+ --ht-critical: #991b1b;
+
+ /* — Typography —————————————————————————————————————— */
+
+ --ht-font-heading: 'Space Grotesk', system-ui, -apple-system, sans-serif;
+ --ht-font-body: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
+ --ht-font-mono: 'JetBrains Mono', ui-monospace, 'Cascadia Code', 'Fira Code', monospace;
+
+ /* — Type Scale —————————————————————————————————————— */
+
+ --ht-text-xs: 0.75rem; /* 12px */
+ --ht-text-sm: 0.8125rem; /* 13px */
+ --ht-text-base: 0.875rem; /* 14px */
+ --ht-text-lg: 1rem; /* 16px */
+ --ht-text-xl: 1.25rem; /* 20px */
+ --ht-text-2xl: 1.5rem; /* 24px */
+
+ /* — Spacing (4px grid) —————————————————————————————— */
+
+ --ht-sp-1: 0.25rem; /* 4px */
+ --ht-sp-2: 0.5rem; /* 8px */
+ --ht-sp-3: 0.75rem; /* 12px */
+ --ht-sp-4: 1rem; /* 16px */
+ --ht-sp-6: 1.5rem; /* 24px */
+ --ht-sp-8: 2rem; /* 32px */
+ --ht-sp-12: 3rem; /* 48px */
+
+ /* — Radius —————————————————————————————————————————— */
+
+ --ht-radius-sm: 3px;
+ --ht-radius-md: 5px;
+ --ht-radius-lg: 8px;
+ --ht-radius-full: 9999px;
+}
diff --git a/src/hassette/web/static/js/entity-browser.js b/src/hassette/web/static/js/entity-browser.js
deleted file mode 100644
index 12cbc0e6..00000000
--- a/src/hassette/web/static/js/entity-browser.js
+++ /dev/null
@@ -1,37 +0,0 @@
-/**
- * Hassette Alpine.js entity browser component.
- *
- * Registers an `entityBrowser` Alpine component that filters entities
- * by domain and search term, fetching results via HTMX partial swap.
- */
-document.addEventListener("alpine:init", function () {
- Alpine.data("entityBrowser", function () {
- return {
- /** @type {string} Selected domain filter (empty = all domains). */
- domain: "",
- /** @type {string} Free-text search filter. */
- search: "",
-
- /** Fetch the initial entity list on mount. */
- init() {
- this.refresh();
- },
-
- /**
- * Build a query string from the current filters and swap the
- * entity list partial into `#entity-list` via HTMX.
- */
- refresh() {
- var params = [];
- if (this.domain)
- params.push("domain=" + encodeURIComponent(this.domain));
- if (this.search)
- params.push("search=" + encodeURIComponent(this.search));
- var url =
- "/ui/partials/entity-list" +
- (params.length ? "?" + params.join("&") : "");
- htmx.ajax("GET", url, { target: "#entity-list", swap: "innerHTML" });
- },
- };
- });
-});
diff --git a/src/hassette/web/static/js/live-updates.js b/src/hassette/web/static/js/live-updates.js
index 0fbde4c3..3d163e45 100644
--- a/src/hassette/web/static/js/live-updates.js
+++ b/src/hassette/web/static/js/live-updates.js
@@ -101,7 +101,7 @@
*/
function norm(p) { return p && p.length > 1 && p.endsWith("/") ? p.slice(0, -1) : p; }
var path = norm(window.location.pathname);
- document.querySelectorAll(".menu-list a").forEach(function (link) {
+ document.querySelectorAll(".ht-nav-list a").forEach(function (link) {
var href = norm(link.getAttribute("href") || "");
var isRoot = href === "/ui";
var isActive = href === path || (!isRoot && href && path.startsWith(href + "/"));
diff --git a/src/hassette/web/static/js/ws-handler.js b/src/hassette/web/static/js/ws-handler.js
index 2c4e701d..827289ef 100644
--- a/src/hassette/web/static/js/ws-handler.js
+++ b/src/hassette/web/static/js/ws-handler.js
@@ -15,6 +15,8 @@ document.addEventListener("alpine:init", () => {
Alpine.store("ws", {
/** @type {boolean} Whether the WebSocket is currently open. */
connected: false,
+ /** @type {boolean} True after first open or close — suppresses banner on initial connect. */
+ _ready: false,
/** @type {WebSocket | null} The underlying WebSocket instance. */
_socket: null,
/** @type {number} Current reconnection delay in milliseconds. */
@@ -48,6 +50,7 @@ document.addEventListener("alpine:init", () => {
socket.addEventListener("open", () => {
this.connected = true;
+ this._ready = true;
this._backoff = 1000;
document.dispatchEvent(new CustomEvent("ht:ws-connected"));
});
@@ -84,6 +87,7 @@ document.addEventListener("alpine:init", () => {
socket.addEventListener("close", () => {
this.connected = false;
+ this._ready = true;
this._reconnect();
});
diff --git a/src/hassette/web/templates/base.html b/src/hassette/web/templates/base.html
index e1d0bf9f..81a4cbe7 100644
--- a/src/hassette/web/templates/base.html
+++ b/src/hassette/web/templates/base.html
@@ -6,18 +6,25 @@
{% block title %}Hassette{% endblock %}
+
+
+ href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap">
+
+
-
@@ -27,7 +34,8 @@
hx-target="#page-content"
hx-select="#page-content"
hx-swap="outerHTML show:window:top"
- x-data="{ sidebarOpen: window.innerWidth >= 769 }"
+ x-data="{ sidebarOpen: (() => { const saved = localStorage.getItem('ht-sidebar'); return saved !== null ? saved === 'open' : window.innerWidth >= 769; })() }"
+ x-effect="localStorage.setItem('ht-sidebar', sidebarOpen ? 'open' : 'closed')"
@keydown.escape.window="sidebarOpen = false"
@resize.window="if (window.innerWidth < 769) sidebarOpen = false">
{% include "components/nav.html" %}
@@ -37,7 +45,8 @@
@click="sidebarOpen = false">
{% include "components/status_bar.html" %}
-
+ {% include "components/alert_banner.html" %}
+
{% block content %}{% endblock %}
diff --git a/src/hassette/web/templates/components/alert_banner.html b/src/hassette/web/templates/components/alert_banner.html
new file mode 100644
index 00000000..bd2e68b4
--- /dev/null
+++ b/src/hassette/web/templates/components/alert_banner.html
@@ -0,0 +1,11 @@
+
diff --git a/src/hassette/web/templates/components/nav.html b/src/hassette/web/templates/components/nav.html
index 03be70b6..03b8715d 100644
--- a/src/hassette/web/templates/components/nav.html
+++ b/src/hassette/web/templates/components/nav.html
@@ -10,57 +10,50 @@
-
diff --git a/src/hassette/web/templates/components/status_bar.html b/src/hassette/web/templates/components/status_bar.html
index 56a8ab17..a004a9ee 100644
--- a/src/hassette/web/templates/components/status_bar.html
+++ b/src/hassette/web/templates/components/status_bar.html
@@ -2,12 +2,13 @@
-
-
+
diff --git a/src/hassette/web/templates/macros/ui.html b/src/hassette/web/templates/macros/ui.html
index d1ea259a..c6c5d379 100644
--- a/src/hassette/web/templates/macros/ui.html
+++ b/src/hassette/web/templates/macros/ui.html
@@ -2,44 +2,44 @@
{# Status badge for apps/instances #}
{% macro status_badge(status, size="", block_reason="") %}
{% if status == "running" %}
- running
+ running
{% elif status == "failed" %}
- failed
+ failed
{% elif status == "stopped" %}
- stopped
+ stopped
{% elif status == "disabled" %}
- disabled
+ disabled
{% elif status == "blocked" %}
- blocked
{% else %}
- {{ status }}
+ {{ status }}
{% endif %}
{% endmacro %}
{# Action buttons for starting/stopping/reloading apps #}
{% macro action_buttons(app_key, status, after_action="location.reload()", show_labels=true) %}
-