diff --git a/.conductor/settings.toml b/.conductor/settings.toml new file mode 100644 index 0000000..b159587 --- /dev/null +++ b/.conductor/settings.toml @@ -0,0 +1,41 @@ +"$schema" = "https://conductor.build/schemas/settings.repo.schema.json" + +# PowerNight serves the built React UI from Flask. Preview opens the active +# workspace's web interface (health: /health). +[[preview_urls]] +name = "PowerNight" +url = "http://localhost:$CONDUCTOR_PORT" + +[scripts] +# Matches CI: editable Python install + locked npm deps, then frontend build. +# Creates workspace-local data/logs and seeds .env from the example when missing. +setup = '''pip install -e ".[dev]" && npm ci && mkdir -p data logs && (test -f .env || cp .env.example .env) && ./build.sh --no-docker --skip-validation''' + +# Dev servers are workspace-local; SQLite lives under ./data with no Docker services. +archive = "echo 'PowerNight uses workspace-local SQLite under ./data; no external resources to clean up.'" + +# Backend (Flask) and Vite dev server can run side by side in different workspaces. +run_mode = "concurrent" + +# Full app: Flask API + built React assets. POWERNIGHT_WEB_PORT maps to CONDUCTOR_PORT. +[scripts.run.app] +command = "POWERNIGHT_WEB_PORT=$CONDUCTOR_PORT python -m powernight.main" +default = true +icon = "zap" + +# Frontend hot-reload (Vite). API proxy in vite.config.ts targets localhost:8020; +# run the app script on 8020 in another terminal when exercising live API calls. +[scripts.run.ui] +command = "npm run dev -- --port $CONDUCTOR_PORT --strictPort" +icon = "layout" +available_in = "local" + +# Quick backend verification (same pytest scope as CI). +[scripts.run.test-backend] +command = 'pytest -q -m "not slow"' +icon = "test-tube" + +# Frontend unit tests (vitest, one-shot). +[scripts.run.test-frontend] +command = "npm run test:run" +icon = "braces" diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..aea7a4e --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +# PowerNight environment configuration +# Copy to .env and fill in real values; docker-compose reads this file. + +# OPTIONAL: API key protecting the web interface and API. Leave unset for a +# trusted-LAN deployment. Any nonempty key automatically enables authentication. +# Generate a strong key with: openssl rand -hex 32 +# POWERNIGHT_API_KEY= + +# Optional explicit switch. Setting this to true without a key (or complete +# username/password configuration) makes startup fail closed. +# POWERNIGHT_AUTH_ENABLED=true + +# Tesla account email used for the Powerwall cloud connection +TESLA_EMAIL= + +# Optional overrides +# POWERNIGHT_LOG_LEVEL=INFO +# POWERNIGHT_AUTOMATION_ENABLED=true +# FLASK_SECRET_KEY= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..93d473f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,77 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +jobs: + backend: + name: Backend (pytest + ruff) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.13" + cache: pip + + - name: Install dependencies + run: pip install -e ".[dev]" + + - name: Ruff (syntax errors and undefined names) + run: ruff check src/ tests/ --select E9,F63,F7,F82 + + - name: Run tests + run: pytest -q -m "not slow" --cov=src/powernight --cov-report=term + + frontend: + name: Frontend (lint + type-check + vitest + build) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Set up Node + uses: actions/setup-node@v6 + with: + node-version: 24 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Lint + run: npm run lint + + - name: Type check + run: npm run type-check + + - name: Unit tests + run: npm run test:run + + - name: Build + run: npm run build + + mypy: + name: Type check backend (informational) + runs-on: ubuntu-latest + continue-on-error: true + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.13" + cache: pip + + - name: Install dependencies + run: pip install -e ".[dev]" + + - name: mypy + run: mypy src/ diff --git a/.github/workflows/docker-health-test.yml b/.github/workflows/docker-health-test.yml index 975c808..e0644b0 100644 --- a/.github/workflows/docker-health-test.yml +++ b/.github/workflows/docker-health-test.yml @@ -36,19 +36,19 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: - node-version: '20' + node-version: '24' cache: 'npm' cache-dependency-path: 'package-lock.json' - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: - python-version: '3.11' + python-version: '3.13' - name: Install backend dependencies run: | @@ -86,10 +86,10 @@ jobs: echo "✓ Frontend build successful" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Build Docker image (amd64 only for speed) - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v7 with: context: . platforms: linux/amd64 diff --git a/.github/workflows/docker-hub-readme-sync.yml b/.github/workflows/docker-hub-readme-sync.yml index 4c5e3a2..64a03eb 100644 --- a/.github/workflows/docker-hub-readme-sync.yml +++ b/.github/workflows/docker-hub-readme-sync.yml @@ -15,10 +15,10 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Update Docker Hub description - uses: peter-evans/dockerhub-description@v4 + uses: peter-evans/dockerhub-description@v5 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 27fa369..f2dfed3 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -27,7 +27,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: fetch-depth: 0 # Full history for version info @@ -43,16 +43,16 @@ jobs: echo "Extracted version: $VERSION" - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: - node-version: '20' + node-version: '24' cache: 'npm' cache-dependency-path: 'package-lock.json' - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: - python-version: '3.11' + python-version: '3.13' - name: Install backend dependencies run: | @@ -90,27 +90,27 @@ jobs: echo "✓ Frontend build successful" - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@v4 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Login to GitHub Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Login to Docker Hub - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Extract metadata for Docker id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v6 with: images: | ${{ env.GHCR_IMAGE }} @@ -126,7 +126,7 @@ jobs: - name: Build and push Docker image id: build - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v7 with: context: . platforms: ${{ env.PLATFORMS }} @@ -141,7 +141,7 @@ jobs: VCS_REF=${{ github.sha }} - name: Update Docker Hub description - uses: peter-evans/dockerhub-description@v4 + uses: peter-evans/dockerhub-description@v5 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} diff --git a/.github/workflows/docker-security-scan.yml b/.github/workflows/docker-security-scan.yml index 050cd34..fda3906 100644 --- a/.github/workflows/docker-security-scan.yml +++ b/.github/workflows/docker-security-scan.yml @@ -37,7 +37,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Determine image tag id: tag diff --git a/.gitignore b/.gitignore index 2f21514..89bc336 100644 --- a/.gitignore +++ b/.gitignore @@ -420,4 +420,6 @@ __marimo__/ # Build Metadata # ============================================================================= # Generated version info (created during build) -version-info.json \ No newline at end of file +version-info.json +# Config backups may contain credentials +.config_backups/ diff --git a/AGENTS.md b/AGENTS.md index a3964e5..485d480 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,10 +8,11 @@ This document provides specific instructions for AI agents working on the PowerN PowerNight uses a **React SPA (Single Page Application)** with a Flask backend. There is ONLY ONE frontend implementation. **Frontend Stack:** -- React 18 + TypeScript +- React 19.2 + TypeScript - Vite (build tool) - Tailwind CSS (styling) -- React Router v6 (client-side routing) +- React Router 7 (client-side routing) +- react-hook-form (forms); state is React hooks + localStorage (no React Query) - Axios (API client) **Backend Stack:** @@ -51,7 +52,8 @@ src/powernight/web/ │ │ ├── useApi.ts # API integration hook │ │ └── useLocalStorage.ts # LocalStorage hook │ ├── contexts/ # React contexts -│ │ └── TimezoneContext.tsx # Timezone management context +│ │ ├── TimezoneContext.tsx # Timezone management context +│ │ └── ToastContext.tsx # Toast notification context │ ├── utils/ # Utilities │ │ ├── api.ts # Axios API client with typed methods │ │ └── helpers.ts # Helper functions @@ -61,23 +63,20 @@ src/powernight/web/ │ ├── main.tsx # React entry point │ └── index.css # Tailwind CSS imports ├── api/ # Flask API (Backend only) -│ ├── routes.py # Main routes + SPA catch-all +│ ├── routes.py # Main routes + SPA catch-all (main_blueprint) │ ├── api.py # Main API blueprint (/api/v1/*) -│ ├── auth_api.py # Auth endpoints -│ ├── config_api.py # Config endpoints -│ ├── logs_api.py # Logs endpoints -│ ├── tasks_api.py # Tasks/cronjobs endpoints -│ ├── docs.py # API documentation (Swagger/ReDoc) -│ ├── auth.py # Authentication utilities -│ ├── config_manager.py # Configuration management -│ ├── decorators.py # API decorators +│ ├── auth_api.py # Tesla OAuth + auth endpoints (/api/auth/*) +│ ├── config_api.py # Config endpoints (/api/v1/config/*) +│ ├── logs_api.py # Execution log endpoint (/api/v1/logs/executions) +│ ├── tasks_api.py # Task + preset endpoints (/api/v1/tasks/*) +│ ├── auth.py # Authentication utilities (@require_auth) +│ ├── decorators.py # API decorators (re-exports require_auth) │ ├── errors.py # Error handling -│ ├── middleware.py # Flask middleware -│ ├── monitoring.py # Performance monitoring +│ ├── monitoring.py # Performance monitoring (not a blueprint) │ ├── schemas.py # Data schemas │ └── validation.py # Input validation ├── app.py # Flask app factory -├── middleware.py # Flask middleware +├── middleware.py # Flask middleware (CORS, security headers, rate limiting) └── index.html # HTML entry point for Vite dist/ # Built React app (git-ignored) @@ -199,11 +198,11 @@ python -m powernight.main # Full app with web interface ### Authentication -The React app uses **Tesla authentication** via pypowerwall framework in cloud mode: -- Tesla tokens stored in `.pypowerwall.auth` file -- Managed by `useAuth` hook for authentication state +PowerNight uses **Tesla authentication** via pypowerwall framework in cloud mode: +- Tesla tokens stored in `.pypowerwall.auth` file (plain JSON, mode `0o600`; not encrypted, because pypowerwall reads/rewrites it directly) - Tesla login flow handled through Settings page -- Authentication state managed by `useAuth` hook with loading states + +Separately, the `useAuth` hook manages optional app-level authentication for the PowerNight web API. With no credentials configured, authentication is off for trusted-LAN use. A nonempty API key or complete username/password pair automatically enables it. Sensitive endpoints then require an API key sent as `X-API-Key` or `Authorization: Bearer `; only `/health` and `/version` are public. Explicitly enabling authentication without a usable credential fails closed at startup. **Authentication Flow:** 1. User initiates Tesla login via Settings page @@ -287,7 +286,7 @@ pytest tests/ # Python unit/integration tests ### Data Storage Architecture -**Container Path:** `/app/data/` +**Container Path:** `/data/` **Host Path:** `./PowerNight-Data/` **Files Stored:** @@ -331,8 +330,9 @@ services: ports: - "8020:8020" environment: - - POWERNIGHT_AUTH_ENABLED=true - - POWERNIGHT_API_KEY=${POWERNIGHT_API_KEY:-your-secure-api-key-here} + # Optional on trusted LANs; a nonempty key automatically enables auth + - POWERNIGHT_AUTH_ENABLED + - POWERNIGHT_API_KEY - TESLA_CLIENT_ID=${TESLA_CLIENT_ID:-ownerapi} - TESLA_EMAIL=${TESLA_EMAIL:-your-email@example.com} - POWERNIGHT_AUTOMATION_ENABLED=${AUTOMATION_ENABLED:-true} @@ -369,7 +369,7 @@ docker inspect PowerNight | grep -A 5 "Mounts" # Should show: # "Source": "/path/to/PowerNight-Data" -# "Destination": "/app/data" +# "Destination": "/data" ``` **Problem:** Lost Tesla OAuth tokens after restart @@ -454,6 +454,7 @@ docker inspect PowerNight | grep -A 5 "Mounts" ### React Contexts - **TimezoneContext.tsx** - Timezone management and display +- **ToastContext.tsx** - Toast notification provider used across pages ### TypeScript Types - **AuthStatus** - Authentication state interface @@ -489,7 +490,7 @@ When working on PowerNight: 5. ❌ NEVER ignore volume configuration **Data Persistence:** -- All critical data in `/app/data` (container) → `./PowerNight-Data/` (host) +- All critical data in `/data` (container) → `./PowerNight-Data/` (host) - SQLite database, OAuth tokens, logs all require persistent storage - Data loss occurs if container runs without volume mounts diff --git a/CLAUDE.md b/CLAUDE.md index e66ef9d..42a4d49 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ PowerNight is a Docker container application that automates Tesla Powerwall back **Technology Stack:** - Backend: Python 3.10+ (Flask, SQLAlchemy, pypowerwall) -- Frontend: React 18 + TypeScript + Vite + Tailwind CSS +- Frontend: React 19.2 + TypeScript + Vite + Tailwind CSS + React Router 7 - Database: SQLite (file-based) - Deployment: Docker (multi-stage build) - Task Scheduling: Background thread with `schedule` library @@ -145,19 +145,19 @@ powernight-cli --config config.yaml --verbose **1. Configuration System** (`src/powernight/core/config/`) - **Pattern:** Singleton with thread-safe access - **Components:** `ConfigManager` (singleton) → validation → backup/recovery -- **File Search Order:** Env var → `./config.yaml` → `./config/` → `~/.powernight/` -- **Features:** Auto-backup before changes, environment variable overrides, dummy mode fallback +- **File Search Order:** `POWERNIGHT_CONFIG_PATH` → `./config.json` → `./config.yaml` → `./config/config.json` → `./config/config.yaml` → `~/.powernight/config.{json,yaml}` +- **Features:** Auto-backup before changes, environment variable overrides, fail-fast on missing/invalid config (no dummy-mode fallback); auto-recovery only restores a verified backup **2. Powerwall Integration** (`src/powernight/core/powerwall/`) -- **Pattern:** Connector with retry logic and circuit breaker +- **Pattern:** Connector guarded by a circuit breaker - **Flow:** `PowerwallConnector` → Tesla OAuth → `pypowerwall` lib → Tesla Cloud API -- **Resilience:** Exponential backoff (3 retries), circuit breaker, 30s cache TTL +- **Resilience:** Circuit breaker (per site), 30s data cache TTL (serves stale cache on failure) - **Auth:** OAuth 2.0 with PKCE, automatic token refresh **3. Task Scheduling** (`src/powernight/core/planner/`) - **Pattern:** Background thread with `schedule` library (lightweight, no external deps) -- **Flow:** Bootstrap from DB → Register with scheduler → 1s check loop → Execute → Update DB -- **Models:** `CronJob` table with execution tracking (last_execution, last_status, execution_count) +- **Flow:** Bootstrap from DB → Register with scheduler → 20s check loop → Execute in a background thread → Update DB +- **Models:** `Task` table with execution tracking (last_execution, last_status, execution_count), plus `TaskExecution` history and `TaskPreset` templates - **Important:** NOT cron-based; uses Python `schedule` library for daily task execution **4. Web Interface** (`src/powernight/web/`) @@ -165,10 +165,10 @@ powernight-cli --config config.yaml --verbose - `main_blueprint`: React SPA routing (catch-all `/`) - `api_blueprint` (`/api/v1/*`): Core API endpoints - `auth_blueprint`, `config_blueprint`, `logs_blueprint`, `tasks_blueprint` -- **Frontend:** React SPA with React Router - - Routes: `/` (Dashboard), `/settings`, `/scheduling` (formerly Planner), `/logs` +- **Frontend:** React SPA with React Router 7 + - Routes: `/` (Dashboard), `/planner` (task management), `/history` (execution history), `/settings` - API Client: Axios with typed methods in `src/utils/api.ts` - - State: React hooks + React Query + localStorage + - State: React hooks + localStorage + react-hook-form (no React Query) - **Dashboard Page:** Displays Powerwall System Status (fetched from `/api/auth/site-details`) - System Information: Site Name, Site ID, Operating Mode, Battery Level - Power Data: Grid, Home, Battery, Solar power readings @@ -178,8 +178,8 @@ powernight-cli --config config.yaml --verbose **5. Database** (`src/powernight/core/database/`) - **ORM:** SQLAlchemy with SQLite -- **Models:** `ScheduleEntry` (legacy), `CronJob` (current task system) -- **Migration:** Auto-migration on startup via `migration.py` +- **Models:** `Task`, `TaskExecution`, `TaskPreset` (`database/models.py`). The legacy `ScheduleEntry` and `CronJob` DB models are gone (a config-schema dataclass named `ScheduleEntry` still exists in `core/config/schema.py` for `config.yaml` schedule entries) +- **Migration:** Auto-migration on startup via `migration.py` (drops the legacy `schedule_entries` table and seeds built-in task presets) - **Sessions:** Thread-safe session management with context managers ### Flask Routing Configuration @@ -273,6 +273,13 @@ PowerNight uses automated GitHub Actions workflows for continuous integration an - **Trigger:** Changes to `docs/README.md` - **Purpose:** Keeps Docker Hub description synchronized +**5. CI** (`.github/workflows/ci.yml`) +- **Trigger:** Pull requests and pushes to `main` +- **Purpose:** Runs the test/lint gate on every change: + - **Backend:** `pip install -e ".[dev]"`, `ruff check` (syntax/undefined-name selectors), `pytest -m "not slow"` with coverage + - **Frontend:** `npm ci`, `npm run lint`, `npm run type-check`, `npm run test:run` (vitest), `npm run build` + - **mypy:** backend type check (informational; `continue-on-error`) + ### Workflow Documentation For detailed workflow documentation, troubleshooting, and configuration: @@ -340,19 +347,26 @@ For detailed release procedures and troubleshooting: **Rationale:** Complete duplication was removed to maintain single source of truth. -### 2. Configuration Fallback Strategy +### 2. Configuration Loading (Fail-Fast) + +Configuration loading fails closed. There is NO dummy-mode fallback anymore (that code was deleted): +1. Load the user config using the search order above +2. If the file is missing, unparseable, or fails validation → raise `ConfigurationError` and refuse to start +3. Auto-recovery (`ConfigRecoveryManager`) only ever restores from a verified backup; it never fabricates a default config over the user's file +4. `create_default_config()` is used only by the explicit `create-config` CLI command, never on the load path + +**Environment Variable Overrides** -When Powerwall is unavailable: -1. Attempt to load user config -2. If fails or Powerwall unreachable → switch to dummy config -3. Dummy config sets: `automation.enabled=False`, `debug=True` -4. Application continues running for testing/development +`ConfigManager._apply_env_overrides` maps 12 environment variables onto config sections: +- `POWERNIGHT_LOG_LEVEL` → `logging.level` +- `POWERNIGHT_WEB_PORT`, `POWERNIGHT_WEB_HOST`, `POWERNIGHT_WEB_DEBUG` → `web_interface.*` +- `POWERNIGHT_AUTH_ENABLED`, `POWERNIGHT_API_KEY` → `web_interface.*` +- `TESLA_EMAIL` and `POWERNIGHT_POWERWALL_EMAIL` → `powerwall.tesla_email` +- `POWERNIGHT_POWERWALL_TIMEOUT` → `powerwall.timeout` +- `POWERNIGHT_AUTOMATION_ENABLED`, `POWERNIGHT_AUTOMATION_INTERVAL` → `automation.*` +- `POWERNIGHT_MONITORING_ENABLED` → `monitoring.enabled` -**Environment Variables** (12 supported overrides): -- `POWERNIGHT_CONFIG_PATH`, `POWERNIGHT_DATA_PATH`, `POWERNIGHT_LOGS_PATH` -- `POWERNIGHT_WEB_HOST`, `POWERNIGHT_WEB_PORT` -- `TESLA_EMAIL`, `TESLA_CLIENT_ID` -- `AUTOMATION_ENABLED`, `POWERNIGHT_LOG_LEVEL` +Four more environment variables are read outside that map: `POWERNIGHT_CONFIG_PATH` (config search), `POWERNIGHT_DATA_PATH` (data/token/secret location), `POWERNIGHT_STATIC_PATH` (React build dir), and `FLASK_SECRET_KEY` (session secret). ### 3. Resilience Patterns @@ -361,14 +375,9 @@ When Powerwall is unavailable: - Prevents cascading failures when Tesla API is unreliable - Configurable failure threshold and recovery timeout -**Retry Logic:** -```python -@retry( - stop=stop_after_attempt(3), - wait=wait_exponential(multiplier=1, min=1, max=10), - retry=retry_if_exception_type((ConnectionError, TimeoutError)) -) -``` +**Retry Settings:** +- The Powerwall connector exposes `retry_attempts` / `retry_delay` / `max_retry_delay` config knobs. +- The former `tenacity`-based `@retry` decorator has been removed (tenacity is no longer a dependency); resilience is provided by the circuit breaker and the stale-cache fallback rather than an automatic retry loop. **Data Caching:** - Powerwall data cached for 30s TTL @@ -378,8 +387,9 @@ When Powerwall is unavailable: **Important:** PowerNight uses the Python `schedule` library, NOT traditional cron: - Tasks execute once daily at specified time (HH:MM format) -- Background thread checks every 1 second -- Execution tracked in `CronJob` table (last_execution, execution_count) +- Background thread checks every 20 seconds (`planner.py`, `_check_interval = 20.0`) +- Each due task runs in its own background thread, so execution never blocks the check loop +- Execution tracked in the `Task` table (last_execution, execution_count), with a per-run row in `TaskExecution` - Tasks loaded from database on startup ("bootstrapping") **Why Not Cron:** @@ -425,10 +435,13 @@ def create_app(config: Config, testing=False, ### Database Session Management ```python -# Thread-safe session context +# Services manage their own thread-safe session context internally +task_service = TaskService() +tasks = task_service.list_tasks(enabled_only=True) + +# Or open a session context directly when needed with get_db_session_context() as session: - schedule_service = ScheduleService(session) - schedules = schedule_service.get_all_schedules() + ... ``` ## Testing Architecture @@ -462,13 +475,16 @@ tests/ main() [src/powernight/main.py] ├─ setup_logging() ├─ if CLI args → cli() -└─ else → PowerNightApp() - ├─ load_config() [ConfigManager.load_config()] - ├─ init_database() [migration.py auto-migration] - ├─ create_powerwall_connector() [PowerwallConnector] - ├─ create_flask_app() [create_app() factory] - ├─ start_planner() [Planner.start(), bootstraps from DB] - └─ app.run(host='0.0.0.0', port=8020) +└─ else → PowerNightApp().run() [src/powernight/app.py] + ├─ initialize() + │ ├─ load_config() [ConfigManager.load_config(), fail-fast] + │ ├─ apply configured log level + │ ├─ fail-closed auth check (if explicitly enabled, require an API key or complete Basic credentials) + │ ├─ db_migration.upgrade() [migration.py auto-migration] + │ └─ initialize_powerwall_connector() [+ connect attempt] + ├─ start_planner() [Planner.start(), bootstraps tasks from DB] + ├─ start_web_interface() [create_app() factory; Flask runs in a daemon thread] + └─ main-thread sleep loop until shutdown (Flask does NOT block the main thread) ``` ## Configuration Structure @@ -495,6 +511,7 @@ PowerNight uses a YAML-based configuration system with the following sections: - `host` (str) - Host to bind to (default: 0.0.0.0) - `port` (int) - Port to listen on (default: 8020) - `debug` (bool) - Flask debug mode +- `auth_enabled` (bool) - Explicitly require authentication; defaults to false, while configured credentials automatically enable it - `auth_required` (bool) - Require authentication (backward compatibility) - `username` (str) - Basic auth username - `password` (str) - Basic auth password @@ -553,7 +570,7 @@ PowerNight uses a YAML-based configuration system with the following sections: ### Debugging Powerwall Connection Issues 1. Check logs: `docker logs powernight` or application console -2. Verify OAuth tokens: Check `data/` directory for `.teslapy/cache.json` +2. Verify OAuth tokens: Check the data directory (`/data`) for `.pypowerwall.auth` and `.pypowerwall.site` 3. Test connection: `powernight test-connection` 4. Check circuit breaker state: Monitor `/api/v1/health` endpoint 5. Enable debug mode: Set `POWERNIGHT_LOG_LEVEL=DEBUG` @@ -610,9 +627,10 @@ PowerNight uses a YAML-based configuration system with the following sections: **Data Storage Structure:** ``` Host: ./PowerNight-Data/ Container: /data/ -├── powernight.db SQLite database (schedules, config) -├── .pypowerwall.auth Tesla OAuth tokens (PKCE) +├── powernight.db SQLite database (tasks, executions, presets) +├── .pypowerwall.auth Tesla OAuth tokens (plain JSON, mode 0o600) ├── .pypowerwall.site Selected Powerwall site ID +├── .flask_secret Persisted Flask session secret (mode 0o600) ├── logs/ Application logs └── tokens/ Token cache directory ``` @@ -675,16 +693,21 @@ class LogEntry: **Tesla OAuth 2.0:** - PKCE (Proof Key for Code Exchange) for enhanced security -- Tokens stored encrypted in filesystem +- Tokens stored as plain JSON in `.pypowerwall.auth` (teslapy cache format). They are NOT encrypted at rest: `pypowerwall`/`teslapy` read and rewrite this file directly, so encrypting it would break the Tesla cloud connection. It is protected with owner-only permissions instead (auth/site files `0o600`, data directory `0o700`) - Automatic token refresh before expiration -**API Authentication:** -- API key in `X-API-Key` header (configurable) -- Optional authentication (can be disabled for development) +**Web/API Authentication (optional for trusted LANs):** +- `web_interface.auth_enabled` defaults to `False`; a nonempty API key or complete username/password pair automatically enables authentication +- Explicitly enabling authentication without a usable credential still refuses startup (fail closed) +- Credentials accepted via `X-API-Key` header or `Authorization: Bearer ` +- When authentication is enabled, sensitive endpoints require it (`/api/v1/status`, tasks, `logs/executions`, all `/api/auth/tesla/*`, `site-details`, `tesla/info`, `POST config/timezone`); only `/health` and `/version` stay public +- Security headers (`X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`, `Content-Security-Policy`) and rate limiting on auth/setup endpoints are applied in `web/middleware.py` +- Flask `SECRET_KEY` comes from `FLASK_SECRET_KEY` or is persisted under the data path (`.flask_secret`, mode `0o600`) so sessions survive restarts +- `docker-compose.yml` accepts an optional `POWERNIGHT_API_KEY`; omit it for a trusted-LAN deployment or set it to enable protection automatically **Docker Security:** - Non-root user (`powernight:powernight`) -- Read-only filesystem (except /data) +- `no-new-privileges` enabled in `docker-compose.yml`; `/data` is the only writable volume - No unnecessary capabilities **Environment Variables:** @@ -695,7 +718,7 @@ class LogEntry: ## Performance Characteristics **Task Scheduling:** -- Check interval: 1 second +- Check interval: 20 seconds - Execution: Once daily per task at specified time - Overhead: Minimal (background thread, <1% CPU) @@ -720,31 +743,26 @@ class LogEntry: 2. **SQLite Concurrency:** Single-writer limitation (adequate for use case) 3. **No User Management:** Single-tenant application (one user per instance) 4. **Time Zone:** Uses system timezone for scheduling (configure via environment or config) -5. **Tesla API Rate Limits:** Respects Tesla's rate limits with caching and retry logic +5. **Tesla API Rate Limits:** Respects Tesla's rate limits with caching, connector-side rate limiting, and a circuit breaker ## Monitoring and Observability -**Health Check Endpoint:** `GET /health` +**Health Check Endpoint:** `GET /health` (public; `GET /version` is also public) ```json { - "status": "healthy|degraded|unhealthy", - "timestamp": "2025-10-18T10:00:00Z", - "version": "1.0.0", - "checks": { - "configuration": true, - "powerwall": false, - "scheduler": true - } + "status": "healthy", + "timestamp": "2026-01-01T10:00:00Z", + "version": "2.0.0" } ``` +(`version` is read from `__version__`, currently `2.0.0`. `/health` returns `503` with `status: "unhealthy"` on error.) -**Status Endpoint:** `GET /api/v1/status` +**Status Endpoint:** `GET /api/v1/status` (requires auth) - Comprehensive system status - Powerwall connection state - Scheduler state (job count, next run) - Configuration state -**Logs API:** `GET /api/logs` -- Retrieve application logs -- Filter by level, component, time range -- Export capabilities +**Execution Log API:** `GET /api/v1/logs/executions` (requires auth) +- Returns task execution history (there is no `/api/logs` endpoint) +- Filters: `limit`, `offset`, `status`, `task_name`, `execution_type`, `start_date`, `end_date` diff --git a/Dockerfile b/Dockerfile index c4be112..8a24671 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ # Multi-stage build for optimized image size # Build stage - Install dependencies and build requirements -FROM python:3.11-slim AS builder +FROM python:3.13-slim AS builder # Set environment variables for build ENV PYTHONDONTWRITEBYTECODE=1 \ @@ -28,7 +28,7 @@ RUN pip install --upgrade pip setuptools wheel && \ pip install -r requirements.txt # Production stage - Runtime environment -FROM python:3.11-slim AS production +FROM python:3.13-slim AS production # Set environment variables ENV PYTHONDONTWRITEBYTECODE=1 \ @@ -78,7 +78,7 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ CMD ["python", "-m", "powernight.main"] # Build arguments for metadata -ARG VERSION=1.0.0 +ARG VERSION=2.0.0 ARG BUILD_DATE ARG VCS_REF diff --git a/build.sh b/build.sh index 69930d0..16e8117 100755 --- a/build.sh +++ b/build.sh @@ -141,18 +141,17 @@ get_backend_dependencies() { return fi - # Extract key dependencies with versions - # This is a simplified parser - ideally use a TOML parser + # Key runtime dependencies (kept in sync with pyproject.toml) DEPS=$(cat <<'EOF' { - "flask": ">=2.3.0", - "pypowerwall": ">=0.10.5", - "schedule": ">=1.2.0", - "pyyaml": ">=6.0.1", - "sqlalchemy": "Implicit (via Flask-SQLAlchemy)", - "requests": ">=2.31.0", - "structlog": ">=23.1.0", - "tenacity": ">=8.2.0" + "flask": ">=3.1.3", + "pypowerwall": ">=0.16.0", + "sqlalchemy": ">=2.0.51", + "schedule": ">=1.2.2", + "pyyaml": ">=6.0.3", + "requests": ">=2.34.2", + "click": ">=8.4.2", + "cryptography": ">=49.0.0" } EOF ) @@ -183,18 +182,18 @@ get_frontend_dependencies() { "@vitejs/plugin-react": .devDependencies["@vitejs/plugin-react"] }' package.json) else - # Fallback: Basic parsing without jq + # Fallback without jq (kept in sync with package.json) DEPS=$(cat <<'EOF' { - "react": "^18.2.0", - "react-dom": "^18.2.0", - "react-router-dom": "^6.20.1", - "axios": "^1.6.2", - "date-fns": "^2.30.0", - "vite": "^7.1.9", - "typescript": "^5.2.2", - "tailwindcss": "^4.1.14", - "@vitejs/plugin-react": "^4.2.1" + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-router-dom": "^7.18.1", + "axios": "^1.18.1", + "date-fns": "^4.4.0", + "vite": "^8.1.3", + "typescript": "^6.0.3", + "tailwindcss": "^4.3.2", + "@vitejs/plugin-react": "^6.0.3" } EOF ) diff --git a/config/config.yaml b/config/config.yaml index 98c2fc6..afb0402 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -3,7 +3,6 @@ powerwall: powerwall_id: "" timeout: 10.0 retry_attempts: 3 - cloud_mode_enabled: true verify_ssl: true automation: @@ -17,7 +16,10 @@ web_interface: host: 0.0.0.0 port: 8020 debug: false - auth_required: false + # Authentication is optional for trusted local networks. Supplying an + # api_key (or POWERNIGHT_API_KEY) automatically enables it. + auth_enabled: false + api_key: "" logging: level: INFO diff --git a/docker-compose.yml b/docker-compose.yml index f8f81b6..cff8265 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,8 +22,11 @@ services: # Environment variables - configure via .env file environment: # Security settings - - POWERNIGHT_AUTH_ENABLED=true - - POWERNIGHT_API_KEY=${POWERNIGHT_API_KEY:-your-secure-api-key-here} + # Authentication is optional on trusted LANs. Providing a key + # automatically enables it; explicitly enabling auth without a usable + # credential makes PowerNight fail closed at startup. + - POWERNIGHT_AUTH_ENABLED + - POWERNIGHT_API_KEY # Tesla OAuth configuration - TESLA_CLIENT_ID=${TESLA_CLIENT_ID:-ownerapi} @@ -64,7 +67,7 @@ services: # Labels for container management labels: - "com.zaai.powernight.description=Schedule Tesla Powerwall Grid-Charging during the night" - - "com.zaai.powernight.version=1.0.0" + - "com.zaai.powernight.version=2.0.0" # Named volumes for data persistence @@ -74,4 +77,4 @@ volumes: driver_opts: type: none o: bind - device: ${PWD}/PowerNight-Data \ No newline at end of file + device: ${PWD}/PowerNight-Data diff --git a/docs/FAQ.md b/docs/FAQ.md index 0cd1156..285f1c1 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -93,8 +93,8 @@ docker logs PowerNight | grep "Task execution" # 3. Verify timezone configuration # Ensure automation.timezone matches your local timezone -# 4. Check scheduler status -curl http://localhost:8020/api/v1/tasks +# 4. Check scheduler status (include the header only when auth is configured) +curl -H "X-API-Key: $POWERNIGHT_API_KEY" http://localhost:8020/api/v1/tasks ``` ### OAuth Token Expired @@ -193,7 +193,7 @@ Response: { "status": "healthy", "timestamp": "2025-10-22T10:30:00Z", - "version": "1.0.0" + "version": "2.0.0" } ``` diff --git a/docs/README.md b/docs/README.md index 7a3388e..41188dc 100644 --- a/docs/README.md +++ b/docs/README.md @@ -31,7 +31,7 @@ PowerNight is a lightweight Docker-based application with a web interface that a - 🐳 **Docker-First Design** - Multi-stage builds, non-root user, security hardening - 🏗️ **Multi-Architecture** - Supports AMD64, ARM64, ARM/v7 (Raspberry Pi compatible) - 💾 **Persistent Storage** - SQLite database with Docker volume mounting -- 🔄 **Auto-Recovery** - Circuit breaker pattern, exponential backoff, health monitoring +- 🔄 **Resilience** - Circuit breaker pattern, 30s response caching, health monitoring - 📱 **Modern Web UI** - React SPA with responsive design - 📜 **Structured Logging** - JSON logs with component filtering and real-time updates @@ -105,10 +105,14 @@ powernight status ## Quick Start 1. **Install & Run PowerNight Docker Container** + + Authentication is optional on a trusted LAN. This example enables it; omit the `POWERNIGHT_API_KEY` line to run without a login. ```bash + # Generate a strong API key first: openssl rand -hex 32 docker run -d \ --name PowerNight \ -p 8020:8020 \ + -e POWERNIGHT_API_KEY=your-strong-key \ -v ./PowerNight-Data:/data \ zaaicom/powernight:latest ``` @@ -144,15 +148,19 @@ Docker Hub is recommended for most users, especially Synology NAS deployments. ```bash # Run with volume mount (IMPORTANT for data persistence) +# Generate a strong API key first: openssl rand -hex 32 docker run -d \ --name PowerNight \ -p 8020:8020 \ + -e POWERNIGHT_API_KEY=your-strong-key \ -v ./PowerNight-Data:/data \ zaaicom/powernight:latest ``` **⚠️ IMPORTANT**: Always use volume mounts (`-v ./PowerNight-Data:/data`) to persist OAuth tokens, database, and configuration. Without volumes, data is lost after container restart. +**🔐 OPTIONAL**: A nonempty `POWERNIGHT_API_KEY` automatically enables authentication. Omit it for an open trusted-LAN deployment. When enabled, send the key as an `X-API-Key` header (or `Authorization: Bearer `). + ### GitHub CR (Container Registry) Alternative registry hosted on GitHub Packages. @@ -161,10 +169,11 @@ Alternative registry hosted on GitHub Packages. # Pull from GHCR docker pull ghcr.io/zaai-com/powernight:latest -# Run with volume mount +# Run with volume mount (the API key is optional and enables authentication) docker run -d \ --name PowerNight \ -p 8020:8020 \ + -e POWERNIGHT_API_KEY=your-strong-key \ -v ./PowerNight-Data:/data \ ghcr.io/zaai-com/powernight:latest ``` @@ -175,7 +184,15 @@ docker run -d \ 1. Download `docker-compose.yml` file -2. Start the application: +2. For a protected deployment, create a `.env` file next to it (see `.env.example`) with a strong API key: + +```bash +echo "POWERNIGHT_API_KEY=$(openssl rand -hex 32)" > .env +``` + + Skip this step on a trusted LAN to run without authentication. + +3. Start the application: ```bash docker-compose up -d @@ -232,16 +249,17 @@ Access at [http://localhost:8020](http://localhost:8020) ## Security -### Local Access Only +### Optional Authentication -**PowerNight is designed for trusted local networks only** +**PowerNight expects a trusted local network; enable authentication when other clients can reach it** -- 🏠 **Local Network Only**: Deploy within your home/private network behind a firewall -- 🔓 **Web UI Access**: The web interface is **NOT password protected** +- 🔐 **Credential-driven protection**: No credential means open LAN access. Setting `POWERNIGHT_API_KEY` (generate with `openssl rand -hex 32`) automatically enables authentication. +- 🔑 **Sending the key**: When enabled, include it as an `X-API-Key` header or `Authorization: Bearer `. Only `/health` and `/version` are public. +- 🏠 **Local Network Preferred**: Deploy within your home/private network behind a firewall - ❌ **Do NOT expose to internet**: Never expose port 8020 directly to the public internet -- 🔐 **Network Security Required**: Use firewall rules, VPN, or reverse proxy for access control +- 🔐 **Defense in depth**: Use firewall rules, VPN, or reverse proxy in addition to the API key -> **Warning**: Exposing PowerNight to the internet without additional security could allow unauthorized control of your Tesla Powerwall. +> **Warning**: Even with the API key set, avoid exposing PowerNight directly to the internet; place it behind a VPN or reverse proxy to protect control of your Tesla Powerwall. For detailed security guidance, see [SECURITY.md](SECURITY.md). diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 335592b..969ed7c 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -2,18 +2,25 @@ ## ⚠️ **IMPORTANT: Security Assumptions** -PowerNight is designed for deployment in **trusted local networks only**: +PowerNight is intended for **trusted local networks** and supports optional API authentication: +- 🔐 **Credential-Driven Authentication**: With no configured credential, authentication is off. A nonempty `POWERNIGHT_API_KEY` or complete username/password pair automatically enables it. Explicitly enabling auth without usable credentials fails closed. +- 🔑 **Sending Credentials**: When authentication is enabled, use an `X-API-Key` header or `Authorization: Bearer `. Only `/health` and `/version` are public. +- 🛡️ **Hardening Applied**: Security headers (`X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`, `Content-Security-Policy`) and rate limiting on auth/setup endpoints are applied in `web/middleware.py`. The Flask session secret is set from `FLASK_SECRET_KEY` or persisted under the data path. - ✅ **Intended Use**: Deploy within your home/private network behind a firewall -- ❌ **NOT for Public Internet**: Do not expose PowerNight directly to the internet -- 🔓 **Web UI Access**: The web interface is **NOT password protected** by default -- 🌐 **Network Security**: Rely on network-level security (firewall, VPN) for access control +- ❌ **NOT for Public Internet**: Do not expose PowerNight directly to the internet, even with the API key set + +### Token Storage + +- Tesla OAuth tokens are stored as plain JSON in `.pypowerwall.auth` (teslapy cache format). They are **not encrypted at rest**: `pypowerwall`/`teslapy` read and rewrite this file directly, so encryption would break the Tesla cloud connection. +- Instead, tokens are protected with owner-only permissions: the auth/site files are `0o600` and the data directory is `0o700`. ### Deployment Security Best Practices -1. **Local Network Only**: Only accessible from your trusted home/office network -2. **Firewall Protection**: Use router/firewall rules to block external access to port 8020 -3. **VPN for Remote Access**: Use VPN (WireGuard, OpenVPN) for secure remote access +1. **Protect Shared Networks**: Set `POWERNIGHT_API_KEY` using `openssl rand -hex 32` when guests, IoT devices, VPN peers, or other untrusted clients can reach PowerNight +2. **Local Network Only**: Only accessible from your trusted home/office network +3. **Firewall Protection**: Use router/firewall rules to block external access to port 8020 +4. **VPN for Remote Access**: Use VPN (WireGuard, OpenVPN) for secure remote access ⚠️ **Warning**: Exposing PowerNight directly to the internet without additional security measures could allow unauthorized control of your Tesla Powerwall. diff --git a/docs/plans/2026-04-25-security-remediation.md b/docs/plans/2026-04-25-security-remediation.md new file mode 100644 index 0000000..3ef73b2 --- /dev/null +++ b/docs/plans/2026-04-25-security-remediation.md @@ -0,0 +1,222 @@ +# PowerNight Security Remediation Plan + +## Status: IMPLEMENTED, THEN SUPERSEDED (with corrections) + +This plan shipped, but its auth-on-by-default deployment policy was superseded in July 2026. Authentication is now credential-driven: no credential means open trusted-LAN access, while a configured API key or complete Basic-auth pair automatically enables protection. Explicitly enabling authentication without credentials still fails closed. + +- **Auth is optional by default** (`schema.py` `WebInterfaceSettings.auth_enabled=False`), with credentials automatically opting in and an explicit misconfigured opt-in still failing closed (`app.py`). +- **Sensitive endpoints require auth** via `@require_auth` (`/api/v1/status`, all `tasks/*`, `logs/executions`, all `/api/auth/tesla/*`, `site-details`, `tesla/info`, `POST /api/v1/config/timezone`); `/health` and `/version` remain public. OAuth setup endpoints are gated by an internal `_setup_allowed()` (loopback bootstrap or authenticated). +- **Flask `SECRET_KEY`** is set from `FLASK_SECRET_KEY` or persisted to `/.flask_secret` (mode `0o600`). +- **Security headers + rate limiting + restricted CORS** are applied in `web/middleware.py`; HSTS is conditional on HTTPS. +- **docker-compose accepts an optional `POWERNIGHT_API_KEY`**; setting one automatically enables authentication. + +Corrections found while implementing (the plan's assumptions did not all hold): + +1. **Password hashing (Phase 3.1) was dropped, not added.** `passlib` 1.7.4 is incompatible with `bcrypt` >= 4.1, so rather than hash passwords with passlib+bcrypt, `bcrypt`, `passlib`, `pyjwt`, and `authlib` were **removed** from runtime dependencies entirely. Authentication relies on the API key / password comparison with `hmac.compare_digest`. +2. **Tokens were hardened with file permissions, not Fernet (Phase 1.4).** `pypowerwall`/`teslapy` read and rewrite the `.pypowerwall.auth` file directly (the connector passes `authpath=...`), so encrypting it at rest would break the Tesla cloud connection. Instead the auth/site files are `0o600` and the data directory is `0o700`. The file stays plain JSON by necessity. +3. **The "enterprise" modules were deleted, not wired up.** The `web/api/middleware.py`, `web/api/config_manager.py`, and `web/api/docs.py` (OpenAPI) modules referenced throughout this plan no longer exist. Security headers and rate limiting now live in a new, smaller `web/middleware.py`, and there is no `/docs` OpenAPI blueprint. + +The remaining sections below are the original plan as written, kept for historical context. + +--- + +## Context + +A security review of the PowerNight codebase (a Tesla Powerwall management web app exposing port 8020) surfaced **5 critical**, **3 high**, **6 medium**, and **3 low** severity findings. The most consequential issues stem from: + +1. Authentication being **disabled by default** (`auth_enabled=False`), making the entire API world-readable in a default deployment. +2. **6 endpoints in the auth blueprint** that have no `@require_auth` decorator regardless of the auth setting: including OAuth setup, Tesla credential info, and live site sensor data. +3. **Tesla OAuth tokens stored in plaintext** on disk despite `cryptography` (Fernet) already being a dependency. +4. **Implemented-but-unused security middleware** (`@rate_limit`, `@secure_endpoint`, `SecurityMiddleware.add_security_headers`) that is never wired to any endpoint. + +The intended outcome is a hardened default deployment that does not require user configuration to be safe, and which fails closed rather than open. + +--- + +## Phase 1: Critical: Close the open-by-default holes + +### 1.1 Default authentication ON +**File:** `src/powernight/core/config/schema.py:140-146` (`WebInterfaceSettings`) +- Change `auth_enabled: bool = False` → `auth_enabled: bool = True` +- Change `cors_origins: List[str] = ["*"]` → `["http://localhost:3000", "http://localhost:8020"]` +- Add startup-time validation: if `auth_enabled=True` and `api_key`/`password` are both `None`, refuse to start (log instructions). + +**File:** `src/powernight/web/api/auth.py:17-57` (`@require_auth`) +- Keep the existing `auth_enabled=False` bypass but log a `WARNING` once at startup so disabled auth is visible in logs/health checks. + +### 1.2 Add `@require_auth` to all auth-blueprint endpoints +**File:** `src/powernight/web/api/auth_api.py` +Add `@require_auth` to: +- `GET /tesla/status` (line ~33) +- `GET /tesla/info` (line ~80) +- `GET /site-details` (line ~484) +- `GET /setup/status/` (line ~296) + +For OAuth setup endpoints (`/setup/start`, `/setup/callback`, `/setup/complete`), apply this rule: +- If valid Tesla tokens already exist OR `auth_enabled=True` and the user is unauthenticated → return 401. +- Only allow first-time setup when no tokens exist AND request comes from `127.0.0.1` (loopback bootstrap), OR when authenticated. + +Implement a new helper `_setup_allowed()` in `auth_api.py` and gate the three setup endpoints with it. + +### 1.3 Remove `?include_sensitive=true` exposure +**File:** `src/powernight/web/api/api.py:124` and `src/powernight/web/api/config_manager.py:396-428` +- Delete the `include_sensitive` query parameter handling entirely. +- Always redact `password`, `api_key`, `tesla_email`, `powerwall_id` in `_filter_sensitive_data`. +- Fix the field-name bug: replace `email` → `tesla_email` in the redaction list so the email is actually masked. + +### 1.4 Encrypt Tesla OAuth tokens at rest +**File:** `src/powernight/core/auth/token_storage.py:46-171` +- Use the already-imported `cryptography.fernet.Fernet`. +- Derive a per-machine key from a stable identifier (e.g., a `key.bin` written under `/data` on first run with `chmod 0600`). +- `store_auth_data()`: serialize JSON → `Fernet(key).encrypt(...)` → write bytes to `.pypowerwall.auth`. +- `load_auth_data()`: read bytes → `Fernet(key).decrypt(...)` → JSON parse. Provide one-shot migration: if the file parses as plain JSON, re-encrypt and overwrite. +- Tighten file mode `0o640` → `0o600`. + +### 1.5 Remove default API key placeholder +**File:** `docker-compose.yml:26` +- Change `POWERNIGHT_API_KEY=${POWERNIGHT_API_KEY:-your-secure-api-key-here}` → `POWERNIGHT_API_KEY=${POWERNIGHT_API_KEY:?POWERNIGHT_API_KEY must be set}`. Compose will refuse to start without it. +- Update `docs/README.md` and `.env.example` (create if absent) to instruct users to generate a strong key. + +--- + +## Phase 2: High: Authentication and session integrity + +### 2.1 Protect logs and config-write endpoints +**File:** `src/powernight/web/api/logs_api.py:24-26` +- Remove `@cross_origin()` from `GET /api/v1/logs/executions`. +- Add `@require_auth`. +- Audit the rest of `logs_api.py` and `config_api.py` for other unprotected mutating endpoints (`POST /api/v1/config/timezone` at `config_api.py:84-118` is confirmed missing auth). + +### 2.2 Set Flask `SECRET_KEY` +**File:** `src/powernight/web/app.py:52-55` +- After `app = Flask(...)`, add: + ```python + app.secret_key = os.environ.get("FLASK_SECRET_KEY") or secrets.token_hex(32) + ``` +- If env var unset, persist the generated value to `/data/.flask_secret` (mode `0o600`) so it survives restarts. + +--- + +## Phase 3: Medium: Defense in depth + +### 3.1 Hash passwords with bcrypt +**Files:** `src/powernight/core/config/schema.py:144`, `src/powernight/web/api/auth.py:143-152` +- Add a `password_hash: Optional[str]` field; deprecate `password`. +- On first config load, if `password` is set and `password_hash` is empty, hash with `bcrypt` and rewrite config; null out `password`. +- Use `passlib.hash.bcrypt.verify(submitted, stored_hash)` for comparison. + +### 3.2 Constant-time comparison +**File:** `src/powernight/web/api/auth.py:192-198` +- Replace the custom `_constant_time_compare` with `hmac.compare_digest(a, b)`. + +### 3.3 Restrict CORS in debug fallback +**File:** `src/powernight/web/middleware.py:11-12` +- Replace wildcard CORS with explicit allow-list `["http://localhost:3000"]`, even in debug. + +### 3.4 Wire up rate limiting + security headers +**File:** `src/powernight/web/api/middleware.py` + `src/powernight/web/app.py` +- Apply `@rate_limit('auth')` to `/api/v1/auth/login`, `/setup/start`, `/setup/callback`, `/setup/complete`, `/tesla/test-connection`. +- Register `SecurityMiddleware.add_security_headers` via `app.after_request` so all responses get `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`, `Content-Security-Policy`, etc. +- Tighten the existing CSP to remove `unsafe-inline` for `script-src` (Vite emits hashed bundles, no inline scripts needed). + +### 3.5 Generic error responses +**Files:** `src/powernight/web/api/auth_api.py`, `config_api.py`, `logs_api.py`, `api.py` +- Replace patterns like `return jsonify({"error": str(e)}), 500` with a helper `error_response(public_msg, exc, status)` that: + - Logs full traceback server-side. + - Returns `{"error": "", "request_id": ""}` to the client. + +### 3.6 Protect config backups +**Files:** `.gitignore`, `src/powernight/web/api/config_manager.py:449-454` +- Add `.config_backups/` to `.gitignore`. +- Strip `password`, `password_hash`, `api_key`, `tesla_email`, `powerwall_id` from the dict before writing each backup. +- Set backup file mode `0o600`. + +--- + +## Phase 4: Low: Hygiene + +### 4.1 Validate `auth_url` client-side +**File:** `src/powernight/web/src/pages/Login.tsx:27-28` +- Before `window.location.href = data.auth_url`, parse with `new URL(...)` and assert `host === "auth.tesla.com"`. + +### 4.2 Remove dead import +**File:** `src/powernight/web/api/docs.py:12` +- Delete the unused `render_template_string` import. + +### 4.3 Make HSTS conditional +**File:** `src/powernight/web/api/middleware.py:483-484` +- Only emit `Strict-Transport-Security` when `request.is_secure` or `X-Forwarded-Proto: https`. + +--- + +## Files to be modified (summary) + +| File | Phase(s) | +|---|---| +| `src/powernight/core/config/schema.py` | 1.1, 3.1 | +| `src/powernight/web/api/auth.py` | 1.1, 3.1, 3.2 | +| `src/powernight/web/api/auth_api.py` | 1.2, 3.5 | +| `src/powernight/web/api/api.py` | 1.3, 3.5 | +| `src/powernight/web/api/config_manager.py` | 1.3, 3.6 | +| `src/powernight/web/api/config_api.py` | 2.1, 3.5 | +| `src/powernight/web/api/logs_api.py` | 2.1, 3.5 | +| `src/powernight/web/api/middleware.py` | 3.4, 4.3 | +| `src/powernight/web/api/docs.py` | 4.2 | +| `src/powernight/web/app.py` | 2.2, 3.4 | +| `src/powernight/web/middleware.py` | 3.3 | +| `src/powernight/core/auth/token_storage.py` | 1.4 | +| `src/powernight/web/src/pages/Login.tsx` | 4.1 | +| `docker-compose.yml` | 1.5 | +| `.gitignore` | 3.6 | + +Existing utilities to reuse (no new code needed): +- `cryptography.fernet.Fernet`: already imported in `token_storage.py` +- `bcrypt` 4.2.1 + `passlib` 1.7.4: already in `pyproject.toml` +- `rate_limit()`, `secure_endpoint()`, `SecurityMiddleware.add_security_headers()`: already implemented in `src/powernight/web/api/middleware.py`, just unused + +No new dependencies are required. + +--- + +## Verification + +After each phase, run: + +1. **Static checks** + ``` + flake8 src/ + mypy src/ + bandit -r src/ + pytest tests/ + ``` + +2. **Auth-on-by-default smoke test** + ``` + POWERNIGHT_API_KEY="$(openssl rand -hex 32)" docker-compose up -d + curl -i http://localhost:8020/api/v1/status # expect 401 + curl -i http://localhost:8020/api/auth/site-details # expect 401 (Phase 1.2) + curl -i http://localhost:8020/api/v1/logs/executions # expect 401 (Phase 2.1) + curl -i http://localhost:8020/api/v1/config?include_sensitive=true # expect no password/api_key in body (Phase 1.3) + curl -i -H "X-API-Key: $POWERNIGHT_API_KEY" http://localhost:8020/api/v1/status # expect 200 + ``` + +3. **Token encryption verification** + - After OAuth setup, `xxd /data/.pypowerwall.auth | head` should not show readable JSON (Phase 1.4). + - Stop and restart the container; tokens still load (decryption works). + +4. **Rate limit verification** + - Loop 20 wrong-password POSTs to `/api/v1/auth/login`: expect HTTP 429 after the threshold (Phase 3.4). + +5. **Security headers** + - `curl -I http://localhost:8020/` shows `X-Frame-Options`, `X-Content-Type-Options: nosniff`, `Content-Security-Policy`, `Referrer-Policy` (Phase 3.4). + +6. **Browser end-to-end** + - Run `./build.sh --no-docker && python -m powernight.main`, log in via Tesla OAuth, verify dashboard loads, verify no plaintext credentials in DevTools network tab. + +--- + +## Out of scope + +- Adding multi-user / role-based access: single-tenant is by design (per CLAUDE.md "Known Limitations"). +- Migrating away from SQLite: also called out as a known limitation. +- Adding TLS termination: expected to be handled by a reverse proxy in production. diff --git a/docs/plans/ux-improvement-roadmap.md b/docs/plans/ux-improvement-roadmap.md new file mode 100644 index 0000000..00e173d --- /dev/null +++ b/docs/plans/ux-improvement-roadmap.md @@ -0,0 +1,236 @@ +# PowerNight UX Improvement Roadmap + +## Status Update + +**Phase 1 (Quick Wins) is now IMPLEMENTED:** +- 1.1 `ConfirmDialog` is wired into the Planner page (replacing native `confirm()`/`alert()`). +- 1.2 A toast notification system exists (`contexts/ToastContext.tsx`) and is used across pages. +- 1.3 A system health indicator is in the `Header`, polling `/api/v1/health` every 30s (the originally-planned `/health/detailed` endpoint no longer exists, so this was implemented against `/api/v1/health`). +- 1.4 Dashboard auto-refreshes on an interval. +- 1.5 History page supports server-side filtering (status and task name) via `GET /api/v1/logs/executions`. + +**Descoped / needs new backend** (the endpoints these items assumed have since been removed from the API): +- 2.4 Circuit breaker & diagnostics panel: `/circuit-breakers`, `/health/detailed`, `/degradation` are gone. Needs a new backend endpoint. +- 5.2 Notification configuration UI: the `/notifications/*` API was removed. Needs a new backend. +- 5.4 Configuration history & rollback: `/config/history` and `/config/rollback` were removed. Needs a new backend. +- 2.3 Next scheduled task widget relied on `/schedules/next-execution`, which was also removed; it would need a new endpoint (planner does expose a `next_run` via `/api/v1/status`). + +Items still valid as written include 3.1 (per-task sparkline uses the still-present `GET /api/v1/tasks//executions`) and the purely-frontend polish in Phases 3-5. + +--- + +## Context + +PowerNight's React frontend is functional but basic. Exploration of the codebase revealed that the backend exposes ~60+ API endpoints across 5 blueprints, but the frontend uses only ~25-40% of them. Many high-impact UX improvements are simply "wire up endpoints that already exist" rather than new backend work. + +Notable findings driving this plan: +- A `ConfirmDialog` component already exists at `src/powernight/web/src/components/ConfirmDialog.tsx` but is unused: Planner uses native `confirm()`/`alert()` instead. +- `/health/detailed`, `/schedules/next-execution`, `/tasks//executions`, `/circuit-breakers`, `/notifications/*`, `/config/history` are all implemented but never called. +- Dashboard requires a manual "Update Data" click: no auto-refresh. +- History page has no filtering despite the backend supporting filters by status, execution_type, date range, task_name, and pagination. +- App is named "PowerNight" but has no dark mode. + +The goal is to lift the perceived quality and depth of PowerNight's UI by exposing existing backend capability and polishing high-touch interactions. + +--- + +## Phase 1: Quick Wins (High Impact, Low Effort) + +These leverage existing backend APIs and unused frontend components. + +### 1.1 Replace browser `alert()`/`confirm()` with custom dialogs +- **Why:** Browser dialogs are jarring, block the thread, and look unprofessional +- **How:** A `ConfirmDialog` component already exists at `src/powernight/web/src/components/ConfirmDialog.tsx` but is **unused**. Wire it into the Planner page for delete/execute confirmations. +- **Files:** `src/powernight/web/src/pages/Planner.tsx` +- **Effort:** ~30 min + +### 1.2 Add toast notification system +- **Why:** Success/error feedback currently uses inline banners or browser alerts +- **How:** Add a lightweight toast component (or use `react-hot-toast`). Replace inline success messages and alerts with toasts. +- **Files:** New `Toast.tsx` component, update `Planner.tsx`, `Settings.tsx` +- **Effort:** ~1-2 hours + +### 1.3 System health indicator in header +- **Why:** Users have no at-a-glance system status; backend `/health/detailed` is unused +- **How:** Add a colored dot (green/yellow/red) in the Header that polls `/health/detailed` every 30s. Click to expand details. +- **Files:** `src/powernight/web/src/components/Header.tsx`, `src/powernight/web/src/utils/api.ts` +- **Effort:** ~1 hour + +### 1.4 Auto-refresh Dashboard data +- **Why:** Dashboard requires manual "Update Data" click; stale data is confusing +- **How:** Add auto-refresh with configurable interval (off/10s/30s/60s). Show countdown to next refresh. Persist preference in localStorage. +- **Files:** `src/powernight/web/src/pages/Dashboard.tsx` +- **Effort:** ~1-2 hours + +### 1.5 Add log filtering to History page +- **Why:** Backend already supports status, date range, task name, and execution type filters - frontend exposes none +- **How:** Add filter bar above the logs table with dropdowns for status, execution type, and date range picker. +- **Files:** `src/powernight/web/src/pages/History.tsx` +- **Effort:** ~2-3 hours + +--- + +## Phase 2: Dashboard & Monitoring (High Impact, Medium Effort) + +### 2.1 Battery level gauge visualization +- **Why:** A percentage number is easy to miss; a visual gauge with color coding is immediately understood +- **How:** SVG-based circular or linear gauge. Color: green (>50%), yellow (20-50%), red (<20%). +- **Files:** New `BatteryGauge.tsx` component, update `Dashboard.tsx` +- **Effort:** ~2-3 hours + +### 2.2 Power flow diagram +- **Why:** Core value prop - users want to see energy flowing between Solar, Battery, Home, Grid at a glance +- **How:** SVG diagram with animated flow lines. Direction and thickness based on power values. Show wattage labels. +- **Files:** New `PowerFlow.tsx` component, update `Dashboard.tsx` +- **Effort:** ~4-6 hours + +### 2.3 Next scheduled task widget +- **Why:** Users want to know "when will something happen next?" without navigating to Planner +- **How:** Dashboard card showing next task name, time, and countdown. Uses `/schedules/next-execution`. +- **Files:** New `NextTask.tsx` component, update `Dashboard.tsx`, `api.ts` +- **Effort:** ~1-2 hours + +### 2.4 Circuit breaker & diagnostics panel +> DESCOPED: the `/circuit-breakers`, `/health/detailed`, and `/degradation` endpoints were removed. Needs a new backend before this can be built. +- **Why:** When things go wrong, users need to understand why - backend tracks all this but frontend hides it +- **How:** Expandable panel on Dashboard or Settings showing circuit breaker state, failure rates, cache stats, last communication time. +- **Files:** New `Diagnostics.tsx` component, update `api.ts` +- **Effort:** ~3-4 hours + +--- + +## Phase 3: Task Management Enhancements (Medium Impact, Medium Effort) + +### 3.1 Per-task execution sparkline +- **Why:** See at a glance if a task has been succeeding or failing recently +- **How:** Tiny inline chart (last 10 executions as green/red dots) in the task table. Uses `/tasks//executions`. +- **Files:** New `Sparkline.tsx` component, update `Planner.tsx`, `api.ts` +- **Effort:** ~3-4 hours + +### 3.2 Task timeline view +- **Why:** A table of times is hard to mentally map; a visual timeline shows the full day's schedule +- **How:** 24-hour horizontal timeline with task blocks positioned at their scheduled times. Toggle between table and timeline view. +- **Files:** New `TaskTimeline.tsx` component, update `Planner.tsx` +- **Effort:** ~4-6 hours + +### 3.3 Execution analytics on History page +- **Why:** Users want trends - is the system getting more reliable or less? +- **How:** Summary cards at top of History page: total executions, success rate %, tasks run today. Optional: simple bar chart of daily success/failure counts. +- **Files:** Update `History.tsx`, possibly `api.ts` for aggregated data +- **Effort:** ~3-4 hours + +### 3.4 Task cloning +- **Why:** Creating similar tasks from scratch is tedious +- **How:** "Clone" button on each task that pre-fills the form with that task's values (with modified name). +- **Files:** Update `Planner.tsx` +- **Effort:** ~1 hour + +--- + +## Phase 4: Visual Polish & Dark Mode (Medium Impact, Higher Effort) + +### 4.1 Dark mode +- **Why:** App is literally called "PowerNight" - dark mode is thematically perfect and reduces eye strain +- **How:** Tailwind `dark:` variant classes. Toggle in header. Persist preference in localStorage. Use CSS custom properties for theme colors. +- **Files:** `tailwind.config.js`, all page/component files, `Header.tsx` (toggle), `index.html` (class on html element) +- **Effort:** ~6-8 hours (touches many files) + +### 4.2 Skeleton loading states +- **Why:** Spinners feel slower than skeleton placeholders; skeletons show layout structure while loading +- **How:** Replace `LoadingSpinner` usage on pages with skeleton components that match the page layout. +- **Files:** New `Skeleton.tsx` component, update `Dashboard.tsx`, `Planner.tsx`, `History.tsx` +- **Effort:** ~3-4 hours + +### 4.3 Searchable timezone picker +- **Why:** Current dropdown has hundreds of entries with no way to search +- **How:** Replace ` setStatusFilter(e.target.value as StatusFilter)} + className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + > + + + + + +
+ + setTaskNameFilter(e.target.value)} + placeholder="Filter by task name..." + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+ {(statusFilter !== 'all' || taskNameFilter) && ( + + )} + + + {/* Results Summary */}

diff --git a/src/powernight/web/src/pages/Login.tsx b/src/powernight/web/src/pages/Login.tsx index 301fe44..9a81232 100644 --- a/src/powernight/web/src/pages/Login.tsx +++ b/src/powernight/web/src/pages/Login.tsx @@ -1,37 +1,27 @@ import React, { useState } from 'react'; -import { useAuth } from '../hooks/useAuth'; import { LoadingSpinner } from '../components/LoadingSpinner'; import { cn } from '../utils/helpers'; -const Login: React.FC = () => { - const { error, clearError } = useAuth(); - const [email, setEmail] = useState(''); +interface LoginProps { + error?: string; + onLogin: (apiKey: string) => Promise; + onClearError: () => void; +} + +const Login: React.FC = ({ error, onLogin, onClearError }) => { + const [apiKey, setApiKey] = useState(''); const [isLoading, setIsLoading] = useState(false); - const handleTeslaLogin = async (e: React.FormEvent) => { + const handleLogin = async (e: React.FormEvent) => { e.preventDefault(); - if (!email.trim()) return; + const key = apiKey.trim(); + if (!key) return; setIsLoading(true); - clearError(); + onClearError(); try { - // Start Tesla OAuth flow - const response = await fetch('/api/auth/setup/start', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email }), - }); - - const data = await response.json(); - if (data.success && data.auth_url) { - window.location.href = data.auth_url; - } else { - throw new Error(data.error || 'Failed to initiate Tesla login'); - } - } catch (err) { - console.error('Tesla login error:', err); - // Error will be handled by the error state + await onLogin(key); } finally { setIsLoading(false); } @@ -60,24 +50,25 @@ const Login: React.FC = () => { PowerNight

- Tesla Powerwall Automation System + Enter the API key configured for this PowerNight server

-
+
-
@@ -113,10 +104,10 @@ const Login: React.FC = () => {

- You'll be redirected to Tesla to authorize PowerNight + Tesla account authorization is managed separately in Settings.

diff --git a/src/powernight/web/src/pages/Planner.tsx b/src/powernight/web/src/pages/Planner.tsx index 38577bb..48b808e 100644 --- a/src/powernight/web/src/pages/Planner.tsx +++ b/src/powernight/web/src/pages/Planner.tsx @@ -1,12 +1,14 @@ -import React, { useState, useEffect } from 'react'; -import { Task, CommandType, CommandDefinition, TaskFormData } from '../types'; +import React, { useState, useEffect, useRef } from 'react'; +import { Task, CommandType, CommandDefinition, TaskFormData, TaskPreset } from '../types'; import { api } from '../utils/api'; import LoadingSpinner from '../components/LoadingSpinner'; import StatusBadge from '../components/StatusBadge'; +import ConfirmDialog from '../components/ConfirmDialog'; +import { useToast } from '../contexts/ToastContext'; import { formatDate } from '../utils/helpers'; const Planner: React.FC = () => { - console.log('Planner component is rendering!'); + const { showToast } = useToast(); const [tasks, setTasks] = useState([]); const [commands, setCommands] = useState>({}); @@ -15,9 +17,33 @@ const Planner: React.FC = () => { const [showForm, setShowForm] = useState(false); const [editingTask, setEditingTask] = useState(null); + // Preset state + const [presets, setPresets] = useState([]); + const [selectedPresetId, setSelectedPresetId] = useState(''); + const [showSavePresetModal, setShowSavePresetModal] = useState(false); + const [presetName, setPresetName] = useState(''); + // Execution tracking state const [executingTasks, setExecutingTasks] = useState>({}); // taskId -> executionId + // Delete confirmation state + const [taskToDelete, setTaskToDelete] = useState(null); + + // Track pending poll timeouts and mount state so polling cannot leak + // timers or update state after the component unmounts. + const pollTimeoutsRef = useRef>(new Set()); + const isMountedRef = useRef(true); + + useEffect(() => { + isMountedRef.current = true; + const pollTimeouts = pollTimeoutsRef.current; + return () => { + isMountedRef.current = false; + pollTimeouts.forEach((timeoutId) => window.clearTimeout(timeoutId)); + pollTimeouts.clear(); + }; + }, []); + // Form state const [formData, setFormData] = useState({ name: '', @@ -29,9 +55,9 @@ const Planner: React.FC = () => { // Load tasks and available commands useEffect(() => { - console.log('Planner component useEffect running'); loadTasks(); loadCommands(); + loadPresets(); }, []); const loadTasks = async () => { @@ -59,6 +85,60 @@ const Planner: React.FC = () => { } }; + const loadPresets = async () => { + try { + const data = await api.getTaskPresets(); + setPresets(data.presets || []); + } catch (err) { + console.error('Failed to load presets:', err); + } + }; + + const handlePresetSelect = (presetId: string) => { + setSelectedPresetId(presetId); + if (!presetId) return; + + const preset = presets.find(p => p.id === presetId); + if (!preset) return; + + setFormData({ + name: preset.name, + time: preset.default_time || '00:00', + command: preset.command, + command_params: preset.command_params || {}, + enabled: true, + }); + }; + + const handleSavePreset = async () => { + if (!presetName.trim()) return; + try { + await api.createTaskPreset({ + name: presetName.trim(), + command: formData.command, + command_params: formData.command_params, + default_time: formData.time !== '00:00' ? formData.time : undefined, + }); + setShowSavePresetModal(false); + setPresetName(''); + await loadPresets(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to save preset'); + } + }; + + const handleDeletePreset = async (presetId: string) => { + try { + await api.deleteTaskPreset(presetId); + await loadPresets(); + if (selectedPresetId === presetId) { + setSelectedPresetId(''); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to delete preset'); + } + }; + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -76,14 +156,54 @@ const Planner: React.FC = () => { return; } - console.log('Submitting form data:', formData); + // Validate required command parameters (as marked by the command + // definitions from the API and rendered by renderCommandParams). + const commandDef = commands[formData.command]; + const submitParams = { ...formData.command_params }; + const missingParams: string[] = []; + + if (commandDef) { + for (const [paramName, paramDef] of Object.entries(commandDef.params)) { + if (!paramDef.required) continue; + + const value = submitParams[paramName]; + + if (paramDef.type === 'boolean') { + // The boolean select renders an unset value as "Off", so + // normalize it to false instead of treating it as missing. + if (value === undefined || value === null) { + submitParams[paramName] = false; + } + continue; + } + + const isMissing = + value === undefined || + value === null || + (typeof value === 'string' && value.trim() === '') || + (typeof value === 'number' && Number.isNaN(value)); + + if (isMissing) { + missingParams.push(paramName); + } + } + } + + if (missingParams.length > 0) { + setError(`Missing required parameter${missingParams.length > 1 ? 's' : ''}: ${missingParams.join(', ')}`); + return; + } + + const taskData = { ...formData, command_params: submitParams }; if (editingTask) { // Update existing task - await api.updateTask(editingTask.id, formData); + await api.updateTask(editingTask.id, taskData); + showToast(`Task "${taskData.name}" updated`, 'success'); } else { // Create new task - await api.createTask(formData); + await api.createTask(taskData); + showToast(`Task "${taskData.name}" created`, 'success'); } // Reload tasks @@ -98,17 +218,22 @@ const Planner: React.FC = () => { } }; - const handleDelete = async (id: string) => { - if (!confirm('Are you sure you want to delete this task?')) { - return; - } - + const handleDelete = (id: string) => { + setTaskToDelete(id); + }; + + const confirmDelete = async () => { + if (!taskToDelete) return; + try { - await api.deleteTask(id); + await api.deleteTask(taskToDelete); + showToast('Task deleted', 'success'); await loadTasks(); } catch (err) { - setError('Failed to delete task'); + showToast('Failed to delete task', 'error'); console.error(err); + } finally { + setTaskToDelete(null); } }; @@ -139,64 +264,74 @@ const Planner: React.FC = () => { } }; - const pollExecutionStatus = async (taskId: string, executionId: string) => { + const pollExecutionStatus = (taskId: string, executionId: string) => { const pollInterval = 2000; // Poll every 2 seconds const maxPolls = 45; // Max 90 seconds (45 * 2s) let pollCount = 0; + // Schedule a poll step and track the timeout id so it can be + // cancelled if the component unmounts. + const schedulePoll = (fn: () => void) => { + const timeoutId = window.setTimeout(() => { + pollTimeoutsRef.current.delete(timeoutId); + fn(); + }, pollInterval); + pollTimeoutsRef.current.add(timeoutId); + }; + + const removeFromExecuting = () => { + setExecutingTasks(prev => { + const newState = { ...prev }; + delete newState[taskId]; + return newState; + }); + }; + const poll = async () => { try { const execution = await api.getTaskExecution(taskId, executionId); - + + // Never update state after unmount + if (!isMountedRef.current) return; + // Check if execution is complete if (execution.status === 'success' || execution.status === 'error') { - // Remove from executing tasks - setExecutingTasks(prev => { - const newState = { ...prev }; - delete newState[taskId]; - return newState; - }); - + removeFromExecuting(); + // Show result if (execution.status === 'success') { - alert(execution.result?.message || 'Task executed successfully'); + const result = execution.result as { message?: string } | undefined; + showToast(result?.message || 'Task executed successfully', 'success'); } else { - alert(`Task execution failed: ${execution.error_message || 'Unknown error'}`); + showToast(`Task execution failed: ${execution.error_message || 'Unknown error'}`, 'error'); } - + // Reload tasks to update status await loadTasks(); return; } - + // Continue polling if not complete and under limit pollCount++; if (pollCount < maxPolls) { - setTimeout(poll, pollInterval); + schedulePoll(poll); } else { // Timeout - remove from executing tasks - setExecutingTasks(prev => { - const newState = { ...prev }; - delete newState[taskId]; - return newState; - }); + removeFromExecuting(); setError('Task execution timed out after 90 seconds'); } - + } catch (err) { console.error('Error polling execution status:', err); + if (!isMountedRef.current) return; // Remove from executing tasks on error - setExecutingTasks(prev => { - const newState = { ...prev }; - delete newState[taskId]; - return newState; - }); + removeFromExecuting(); setError('Failed to check execution status'); } }; // Start polling - setTimeout(poll, pollInterval); + schedulePoll(poll); }; const handleEdit = (task: Task) => { @@ -221,6 +356,7 @@ const Planner: React.FC = () => { }); setEditingTask(null); setShowForm(false); + setSelectedPresetId(''); }; const handleCommandChange = (command: CommandType) => { @@ -231,7 +367,7 @@ const Planner: React.FC = () => { }); }; - const handleParamChange = (paramName: string, value: any) => { + const handleParamChange = (paramName: string, value: unknown) => { setFormData({ ...formData, command_params: { @@ -316,7 +452,7 @@ const Planner: React.FC = () => {
{/* Header */} -
+

Planner

@@ -345,6 +481,51 @@ const Planner: React.FC = () => { {editingTask ? 'Edit Task' : 'Create New Task'}

+ {/* Preset Selector - shown only when creating, not editing */} + {!editingTask && presets.length > 0 && ( +
+ +
+ + {selectedPresetId && (() => { + const selected = presets.find(p => p.id === selectedPresetId); + return selected && !selected.is_builtin ? ( + + ) : null; + })()} +
+
+ )} +
+ + {/* Save as Preset Modal */} + {showSavePresetModal && ( +
+
+

Save as Preset

+
+
+ + setPresetName(e.target.value)} + placeholder="Enter preset name" + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + autoFocus + /> +
+

+ Command: {formData.command} + {formData.time !== '00:00' && ( + <> | Time: {formData.time} + )} + {Object.keys(formData.command_params).length > 0 && ( + <> | Params: + {Object.entries(formData.command_params).map(([k, v]) => `${k}=${v}`).join(', ')} + + )} +

+
+ + +
+
+
+
+ )} + + {/* Delete Task Confirmation */} + setTaskToDelete(null)} + />
); diff --git a/src/powernight/web/src/pages/Settings.tsx b/src/powernight/web/src/pages/Settings.tsx index 3b27537..db5c65b 100644 --- a/src/powernight/web/src/pages/Settings.tsx +++ b/src/powernight/web/src/pages/Settings.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect } from 'react'; import { formatDateTimeWithTimezone } from '../utils/dateTimeFormatter'; import { useTimezone } from '../contexts/TimezoneContext'; +import { useToast } from '../contexts/ToastContext'; import api from '../utils/api'; import { getAllCommonTimezones } from '../utils/timezones'; @@ -51,6 +52,7 @@ type FlowStep = 'initial' | 'awaiting_login' | 'awaiting_callback' | 'selecting_ const Settings: React.FC = () => { // Get timezone context const { timezoneInfo, currentTime, isLoading: timezoneLoading, refreshTimezone } = useTimezone(); + const { showToast } = useToast(); // OAuth Flow State const [currentStep, setCurrentStep] = useState('initial'); @@ -69,9 +71,8 @@ const Settings: React.FC = () => { // Timezone State const [availableTimezones, setAvailableTimezones] = useState(getAllCommonTimezones()); // Initialize with local timezones - const [selectedTimezone, setSelectedTimezone] = useState('Europe/Berlin'); // Default to Berlin + const [selectedTimezone, setSelectedTimezone] = useState('Europe/Berlin'); // Pre-load placeholder only; replaced by the configured timezone on mount const [timezoneSaving, setTimezoneSaving] = useState(false); - const [timezoneSuccess, setTimezoneSuccess] = useState(null); const [isEditingTimezone, setIsEditingTimezone] = useState(false); // UI State @@ -104,7 +105,7 @@ const Settings: React.FC = () => { setIsLoading(true); try { - const response = await fetch('/api/auth/setup/start', { + const response = await api.authenticatedFetch('/api/auth/setup/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email }), @@ -117,11 +118,14 @@ const Settings: React.FC = () => { setCurrentStep('awaiting_login'); // Open Tesla auth in NEW browser tab - window.open(data.auth_url, '_blank'); + const authWindow = window.open(data.auth_url, '_blank'); + if (!authWindow) { + setError('The Tesla authorization popup was blocked by your browser. Please allow popups for this site and try again.'); + } } else { setError(data.error || 'Failed to start authentication'); } - } catch (err) { + } catch { setError('Failed to connect to server'); } finally { setIsLoading(false); @@ -145,7 +149,7 @@ const Settings: React.FC = () => { setIsLoading(true); try { - const response = await fetch('/api/auth/setup/callback', { + const response = await api.authenticatedFetch('/api/auth/setup/callback', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: sessionId, callback_url: callbackUrl }), @@ -165,7 +169,7 @@ const Settings: React.FC = () => { } else { setError(data.error || 'Failed to verify callback URL'); } - } catch (err) { + } catch { setError('Failed to connect to server'); } finally { setIsLoading(false); @@ -176,7 +180,7 @@ const Settings: React.FC = () => { if (!sessionId) return; try { - const response = await fetch('/api/auth/setup/complete', { + const response = await api.authenticatedFetch('/api/auth/setup/complete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: sessionId, site_id: siteId }), @@ -189,7 +193,7 @@ const Settings: React.FC = () => { } else { setError(data.error || 'Failed to complete setup'); } - } catch (err) { + } catch { setError('Failed to complete setup'); } }; @@ -197,7 +201,7 @@ const Settings: React.FC = () => { const fetchAuthInfo = async () => { setAuthInfoLoading(true); try { - const response = await fetch('/api/auth/tesla/info'); + const response = await api.authenticatedFetch('/api/auth/tesla/info'); const data = await response.json(); if (data.success) { @@ -218,7 +222,7 @@ const Settings: React.FC = () => { const fetchVersionInfo = async () => { setVersionLoading(true); try { - const response = await fetch('/api/v1/version-info.json'); + const response = await api.authenticatedFetch('/api/v1/version-info.json'); if (!response.ok) { console.error(`Failed to fetch version info: HTTP ${response.status} ${response.statusText}`); @@ -262,38 +266,44 @@ const Settings: React.FC = () => { const handleSaveAndReloadTimezone = async () => { setTimezoneSaving(true); - setTimezoneSuccess(null); setError(null); try { // Save timezone await api.updateTimezone(selectedTimezone); - + // Reload all tasks with new timezone const reloadResult = await api.reloadAllTasks(); - setTimezoneSuccess(reloadResult.message); + showToast(reloadResult.message || 'Timezone saved and tasks reloaded', 'success'); // Refresh timezone info refreshTimezone(); - + // Exit edit mode setIsEditingTimezone(false); - - // Clear success message after 5 seconds - setTimeout(() => { - setTimezoneSuccess(null); - }, 5000); } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to save timezone'); + showToast(err instanceof Error ? err.message : 'Failed to save timezone', 'error'); } finally { setTimezoneSaving(false); } }; - // Load auth info, version info, and available timezones on component mount + const fetchCurrentTimezone = async () => { + try { + const data = await api.getTimezone(); + setSelectedTimezone(data.timezone); + } catch (err) { + console.error('Failed to fetch current timezone:', err); + // Keep the pre-load placeholder; the timezoneInfo effect below will + // correct it once the context loads. + } + }; + + // Load auth info, version info, timezone, and available timezones on component mount useEffect(() => { fetchAuthInfo(); fetchVersionInfo(); + fetchCurrentTimezone(); fetchAvailableTimezones(); }, []); @@ -327,19 +337,19 @@ const Settings: React.FC = () => { -
+
setEmail(e.target.value)} placeholder="your-email@example.com" - className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + className="flex-1 min-w-0 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" /> @@ -360,19 +370,19 @@ const Settings: React.FC = () => { -
+
setCallbackUrl(e.target.value)} placeholder="https://auth.tesla.com/void/callback?code=abc123&state=xyz789" - className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + className="flex-1 min-w-0 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" /> @@ -598,13 +608,13 @@ const Settings: React.FC = () => { -
+
' in response.data - assert b'' in response.data - assert b'' in response.data - assert b'