diff --git a/.conductor/settings.toml b/.conductor/settings.toml new file mode 100644 index 0000000..1ccd8ef --- /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_DATA_PATH=$PWD/data POWERNIGHT_WEB_PORT=$CONDUCTOR_PORT python -m powernight.main" +available_in = ["local"] +default = true +icon = "zap" + +# Frontend hot-reload uses the next allocated port and proxies to this workspace API. +[scripts.run.ui] +command = "POWERNIGHT_API_PROXY_TARGET=http://127.0.0.1:$CONDUCTOR_PORT npm run dev -- --port $((CONDUCTOR_PORT + 1)) --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/.dockerignore b/.dockerignore index d339cba..ee80b5f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,6 +7,8 @@ .env* .vscode/ .idea/ +.context/ +.venv/ *.pyc __pycache__/ *.pyo @@ -81,4 +83,4 @@ yarn-error.log* # Python virtual environments and caches .mypy_cache/ .dmypy.json -dmypy.json \ No newline at end of file +dmypy.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a76ced7 --- /dev/null +++ b/.env.example @@ -0,0 +1,22 @@ +# 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 +# LOG_LEVEL=INFO +# AUTOMATION_ENABLED=true +# Vite-only development proxy (export in the shell; .env is for Compose): +# export POWERNIGHT_API_PROXY_TARGET=http://127.0.0.1:8020 +# Flask generates and persists /data/.flask_secret automatically. +# To override it, explicitly pass FLASK_SECRET_KEY in a custom Compose override. diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 50327f6..b31a1ad 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -25,15 +25,15 @@ This directory contains automated workflows for PowerNight's CI/CD pipeline. 9. Update Docker Hub repository description from docs/README.md 10. Generate build summary -**Docker Tags Created**: +**Versioned Docker Tags Created** (latest is promoted separately): *GitHub Container Registry (Primary):* - `ghcr.io/zaai-com/powernight:` (e.g., `1.0.0`) -- `ghcr.io/zaai-com/powernight:latest` +- `ghcr.io/zaai-com/powernight:latest` (only after Docker Promote) *Docker Hub (Mirror):* - `zaaicom/powernight:` (e.g., `1.0.0`) -- `zaaicom/powernight:latest` +- `zaaicom/powernight:latest` (only after Docker Promote) **Required Secrets** (Organization-level): - `DOCKERHUB_USERNAME` - Docker Hub username (zaaicom) @@ -71,12 +71,12 @@ docker pull zaaicom/powernight:1.0.0 1. Checkout repository 2. Build frontend 3. Build Docker image (amd64 only for speed) -4. Start container with test configuration -5. Wait for container health check to pass -6. Test health endpoint (`/health`) -7. Test web interface accessibility -8. Display container logs -9. Generate test summary +4. Run `scripts/release_smoke.py` with isolated Docker Compose projects +5. Verify open and protected API access, SPA routes, and bundled assets +6. Migrate synthetic legacy data and verify preset conflicts return HTTP 409 +7. Confirm saved configuration excludes environment-provided API keys +8. Recreate containers and verify database, auth artifacts, and Flask secret persist +9. Save container logs and generate a JSON test summary; remove disposable volumes **Why This Matters**: - Catches Dockerfile issues early @@ -172,90 +172,30 @@ trivy image zaaicom/powernight:1.0.0 ## Release Process -### Creating a New Release - -1. **Update Version** - ```bash - # Edit pyproject.toml - version = "0.4.0" - ``` - -2. **Commit Changes** - ```bash - git add pyproject.toml - git commit -m "Bump version to 0.4.0" - git push origin main - ``` - -3. **Create and Push Tag** - ```bash - # Create annotated tag - git tag -a 0.4.0 -m "Release 0.4.0 - Description of changes" - - # Push tag to trigger workflow - git push origin 0.4.0 - ``` - -4. **Monitor Build** - - Go to [GitHub Actions](https://github.com/ZAAI-com/PowerNight/actions) - - Watch "Docker Publish" workflow - - Typical build time: 15-20 minutes - -5. **Verify on Registries** - - **GitHub Container Registry (Primary):** - - Visit [GitHub Packages](https://github.com/ZAAI-com/PowerNight/pkgs/container/powernight) - - Verify new tags appear: - - `ghcr.io/zaai-com/powernight:0.4.0` - - `ghcr.io/zaai-com/powernight:latest` - - **Docker Hub (Mirror):** - - Visit [Docker Hub Repository](https://hub.docker.com/r/zaaicom/powernight) - - Verify new tags appear: - - `zaaicom/powernight:0.4.0` - - `zaaicom/powernight:latest` - - - Check multi-arch manifests on both registries - -6. **Test Pull** - ```bash - # Pull from GHCR (recommended) - docker pull ghcr.io/zaai-com/powernight:0.4.0 - docker run -d -p 8020:8020 ghcr.io/zaai-com/powernight:0.4.0 - - # Or pull from Docker Hub (mirror) - docker pull zaaicom/powernight:0.4.0 - docker run -d -p 8020:8020 zaaicom/powernight:0.4.0 - ``` - -### Hotfix Release - -For urgent fixes: - -1. Create hotfix branch - ```bash - git checkout -b hotfix/0.3.1 - ``` - -2. Make fixes and test - ```bash - # Make changes - git commit -m "Fix critical issue" - ``` - -3. Update version to patch release - ```bash - # pyproject.toml - version = "0.3.1" - ``` - -4. Merge and tag - ```bash - git checkout main - git merge hotfix/0.3.1 - git tag -a 0.3.1 -m "Hotfix: Critical bug fix" - git push origin main 0.3.1 - ``` +Releases are staged: Docker Publish writes version-only tags, and Docker Promote +updates `latest` separately after a verified live canary. Local-only validation +must not dispatch either publishing or promotion. + +1. Complete CI and Docker Compose persistence/migration tests for the release commit. + `python scripts/release_smoke.py --image powernight-release:test` runs the same + isolated smoke checks used by Docker Health Test, with no Tesla access. +2. Verify all version identifiers using `python scripts/check_release_version.py 2.0.0`. +3. Merge to `main` and tag the verified commit as `2.0.0` (no `v` prefix). + Pushing the tag triggers Docker Publish. Manual publication must also select + the release tag as its ref. Only commits contained in `main` are publishable. +4. Record the image digest from the publish summary. Verify both registries have + that version and the required amd64, arm64, and arm/v7 manifests. +5. Back up canary data, pin the version/digest in Docker Compose, and validate + upgrade, restart, reconnect, and one overnight scheduling cycle. +6. Run Docker Promote from `main` with the version, exact digest, and + `canary_verified=true`. It verifies both registry tags before changing either + `latest` and promotes the tested digest without rebuilding. +7. Publish the GitHub release notes and monitor the rollout. Registry promotion + is not atomic; on a partial failure, inspect both registries and finish using + the same verified digest. + +See [2.0.0 release notes and rollback runbook](../../docs/releases/2.0.0.md). +Database rollback requires the pre-upgrade data backup as well as the old image. --- @@ -425,8 +365,9 @@ To add a new architecture (e.g., `linux/riscv64`): 1. **Always test locally first** ```bash - ./build.sh - docker run -d -p 8020:8020 powernight:latest + ./build.sh --no-docker + docker build -t powernight-release:test . + python scripts/release_smoke.py --image powernight-release:test ``` 2. **Use semantic versioning** @@ -452,15 +393,15 @@ To add a new architecture (e.g., `linux/riscv64`): - Watch Actions tab during release - Don't assume success - verify! -5. **Test published images** +5. **Test staged images before promoting latest** ```bash - # Test GHCR image (recommended) - docker pull ghcr.io/zaai-com/powernight:latest - docker run --rm ghcr.io/zaai-com/powernight:latest powernight --version + # After version publication, test in isolated Compose projects (no Tesla access) + docker pull ghcr.io/zaai-com/powernight:2.0.0 + python scripts/release_smoke.py --image ghcr.io/zaai-com/powernight:2.0.0 # Or test Docker Hub image (mirror) - docker pull zaaicom/powernight:latest - docker run --rm zaaicom/powernight:latest powernight --version + docker pull zaaicom/powernight:2.0.0 + python scripts/release_smoke.py --image zaaicom/powernight:2.0.0 ``` --- diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..091d5b4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,80 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + backend: + name: Backend (pytest + ruff) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '26' + 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + 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..bdb01e7 100644 --- a/.github/workflows/docker-health-test.yml +++ b/.github/workflows/docker-health-test.yml @@ -6,7 +6,13 @@ on: - main paths: - 'Dockerfile' + - '.dockerignore' + - 'build.sh' + - 'package*.json' + - 'config/**' - 'docker-compose.yml' + - 'compose.release-test.yml' + - 'scripts/release_smoke.py' - 'src/**' - 'requirements.txt' - 'pyproject.toml' @@ -16,7 +22,13 @@ on: - main paths: - 'Dockerfile' + - '.dockerignore' + - 'build.sh' + - 'package*.json' + - 'config/**' - 'docker-compose.yml' + - 'compose.release-test.yml' + - 'scripts/release_smoke.py' - 'src/**' - 'requirements.txt' - 'pyproject.toml' @@ -36,38 +48,40 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: '20' + node-version: '26' cache: 'npm' cache-dependency-path: 'package-lock.json' - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: - python-version: '3.11' + python-version: '3.14' - name: Install backend dependencies run: | echo "Installing PowerNight package..." - pip install -e . + pip install -e ".[build]" echo "✓ Backend package installed" - name: Install frontend dependencies run: | # Retry npm ci up to 3 times to handle transient network errors for i in 1 2 3; do - npm ci && break || { + if npm ci; then + break + else if [ $i -eq 3 ]; then echo "npm ci failed after 3 attempts" exit 1 fi echo "npm ci failed, retrying ($i/3)..." sleep 5 - } + fi done - name: Build frontend @@ -86,10 +100,10 @@ jobs: echo "✓ Frontend build successful" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - name: Build Docker image (amd64 only for speed) - uses: docker/build-push-action@v5 + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: context: . platforms: linux/amd64 @@ -99,156 +113,22 @@ jobs: cache-from: type=gha cache-to: type=gha,mode=max - - name: Create test data directory - run: | - mkdir -p ${{ github.workspace }}/test-data - # Set proper permissions for the test data directory - chmod 755 ${{ github.workspace }}/test-data - - - name: Start container - run: | - docker run -d \ - --name powernight-test \ - -p 8020:8020 \ - -v ${{ github.workspace }}/test-data:/data \ - -e TESLA_EMAIL=test@example.com \ - -e POWERNIGHT_AUTOMATION_ENABLED=false \ - -e POWERNIGHT_LOG_LEVEL=INFO \ - -e POWERNIGHT_WEB_PORT=8020 \ - -e POWERNIGHT_WEB_HOST=0.0.0.0 \ - -e POWERNIGHT_CONSOLE_OUTPUT=false \ - -e POWERNIGHT_CACHE_TTL=60 \ - --memory=1g \ - --cpus=1.0 \ - ${{ env.DOCKER_IMAGE }}:test - - - name: Fix data directory permissions - run: | - # Ensure the data directory has proper permissions inside the container - docker exec powernight-test chmod -R 755 /data || echo "Permission fix attempted" - - - name: Wait for container startup - run: | - echo "Waiting for container to initialize..." - sleep 10 - echo "Container status:" - docker ps | grep powernight-test || echo "Container not found in running state" - echo "Container logs (first 20 lines):" - docker logs powernight-test --tail 20 - - - name: Wait for container to be healthy - run: | - echo "Waiting for container to start..." - echo "This may take up to 3 minutes on GitHub Actions runners..." - - # Wait for container to be running first - echo "Step 1: Waiting for container to be running..." - for i in {1..30}; do - if docker ps | grep -q powernight-test; then - echo "✓ Container is running" - break - fi - if [ $i -eq 30 ]; then - echo "ERROR: Container failed to start after 60 seconds" - echo "=== Container Status ===" - docker ps -a | grep powernight-test - echo "=== Container Logs ===" - docker logs powernight-test - exit 1 - fi - echo "Attempt $i/30: Waiting for container to start..." - sleep 2 - done - - # Wait for application to be ready - echo "Step 2: Waiting for application to be ready..." - for i in {1..90}; do - # Check if container is still running - if ! docker ps | grep -q powernight-test; then - echo "ERROR: Container stopped unexpectedly!" - echo "=== Container Status ===" - docker ps -a | grep powernight-test - echo "=== Container Logs ===" - docker logs powernight-test - exit 1 - fi - - # Check if the application is ready (not just the container) - if docker exec powernight-test curl -f http://localhost:8020/health > /dev/null 2>&1; then - echo "✓ Container is healthy and responding!" - break - fi - - if [ $i -eq 90 ]; then - echo "ERROR: Container failed to become healthy after 180 seconds" - echo "=== Container Status ===" - docker ps -a | grep powernight-test - echo "=== Container Logs (last 50 lines) ===" - docker logs powernight-test --tail 50 - echo "=== Testing connectivity ===" - docker exec powernight-test curl -v http://localhost:8020/health || echo "Health endpoint not responding" - echo "=== Network connectivity test ===" - docker exec powernight-test curl -v http://localhost:8020/ || echo "Main endpoint not responding" - exit 1 - fi - - if [ $((i % 15)) -eq 0 ]; then - echo "Attempt $i/90: Still waiting... (checking logs)" - docker logs powernight-test --tail 10 - elif [ $((i % 5)) -eq 0 ]; then - echo "Attempt $i/90: Waiting..." - fi - sleep 2 - done + - name: Verify migrations, API auth and persistence with Docker Compose + run: python scripts/release_smoke.py --image "$DOCKER_IMAGE:test" - - name: Test health endpoint - run: | - echo "Testing health endpoint..." - RESPONSE=$(docker exec powernight-test curl -s http://localhost:8020/health) - echo "Health response: $RESPONSE" - if echo "$RESPONSE" | grep -q "status"; then - echo "✓ Health endpoint responding correctly" - else - echo "ERROR: Health endpoint response unexpected" - exit 1 - fi - - - name: Test web interface - run: | - echo "Testing web interface..." - STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8020/) - if [ "$STATUS" -eq 200 ]; then - echo "✓ Web interface accessible (HTTP $STATUS)" - else - echo "ERROR: Web interface returned HTTP $STATUS" - exit 1 - fi - - - name: Check container logs - if: always() - run: | - echo "=== Container Logs ===" - docker logs powernight-test - echo "======================" - - - name: Stop and remove container + - name: Show smoke test logs if: always() run: | - docker stop powernight-test || true - docker rm powernight-test || true + shopt -s nullglob + for LOG in .context/release/compose-smoke/*.log; do + cat "$LOG" + done - name: Generate test summary - if: always() + if: success() run: | - echo "## Docker Health Test Results" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "### Build Status" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "✓ Docker image built successfully" >> $GITHUB_STEP_SUMMARY - echo "✓ Container started successfully" >> $GITHUB_STEP_SUMMARY - echo "✓ Health checks passed" >> $GITHUB_STEP_SUMMARY - echo "✓ Web interface accessible" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "### Image Details" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - docker images ${{ env.DOCKER_IMAGE }}:test --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}" >> $GITHUB_STEP_SUMMARY || true + { + echo '```json' + cat .context/release/compose-smoke/report.json + echo '```' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/docker-hub-readme-sync.yml b/.github/workflows/docker-hub-readme-sync.yml index 4c5e3a2..5a969b0 100644 --- a/.github/workflows/docker-hub-readme-sync.yml +++ b/.github/workflows/docker-hub-readme-sync.yml @@ -8,6 +8,9 @@ on: - 'docs/README.md' workflow_dispatch: # Allow manual trigger +permissions: + contents: read + jobs: sync-readme: name: Update Docker Hub Description @@ -15,10 +18,10 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Update Docker Hub description - uses: peter-evans/dockerhub-description@v4 + uses: peter-evans/dockerhub-description@1b9a80c056b620d92cedb9d9b5a223409c68ddfa # v5.0.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -28,8 +31,9 @@ jobs: - name: Generate sync summary run: | - echo "## README Sync Complete" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "✓ README.md has been synced to Docker Hub" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "[View on Docker Hub](https://hub.docker.com/r/zaaicom/powernight)" >> $GITHUB_STEP_SUMMARY + { + echo "## README Sync Complete" + echo "" + echo "README.md has been synced to Docker Hub." + echo "[View on Docker Hub](https://hub.docker.com/r/zaaicom/powernight)" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/docker-promote.yml b/.github/workflows/docker-promote.yml new file mode 100644 index 0000000..dd3a3be --- /dev/null +++ b/.github/workflows/docker-promote.yml @@ -0,0 +1,87 @@ +name: Docker Promote + +on: + workflow_dispatch: + inputs: + version: + description: 'Published version to promote (for example 2.0.0)' + required: true + type: string + digest: + description: 'Exact sha256 digest from the successful Docker Publish run' + required: true + type: string + canary_verified: + description: 'Canary passed upgrade, restart, reconnect and an overnight scheduling cycle' + required: true + default: false + type: boolean + +concurrency: + group: powernight-release-publication + cancel-in-progress: false + +jobs: + promote: + name: Promote tested digest to latest + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + env: + VERSION: ${{ inputs.version }} + EXPECTED_DIGEST: ${{ inputs.digest }} + CANARY_VERIFIED: ${{ inputs.canary_verified }} + GHCR_IMAGE: ghcr.io/zaai-com/powernight + DOCKERHUB_IMAGE: zaaicom/powernight + steps: + - name: Validate promotion request + run: | + test "$GITHUB_REF" = refs/heads/main + test "$CANARY_VERIFIED" = true + [[ "$VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] + [[ "$EXPECTED_DIGEST" =~ ^sha256:[a-f0-9]{64}$ ]] + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + + - name: Login to GHCR + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Login to Docker Hub + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Verify both versioned images before changing either latest tag + run: | + for IMAGE in "$GHCR_IMAGE" "$DOCKERHUB_IMAGE"; do + ACTUAL=$(docker buildx imagetools inspect "$IMAGE:$VERSION" --format '{{.Manifest.Digest}}') + test "$ACTUAL" = "$EXPECTED_DIGEST" + docker buildx imagetools inspect "$IMAGE@$EXPECTED_DIGEST" --raw > manifest.json + python - <<'PY' + import json + with open('manifest.json') as stream: + manifest = json.load(stream) + platforms = { + (item['platform']['os'], item['platform']['architecture'], item['platform'].get('variant', '')) + for item in manifest['manifests'] + } + assert {('linux', 'amd64'), ('linux', 'arm64')} <= {(os, arch) for os, arch, _ in platforms} + assert ('linux', 'arm', 'v7') in platforms + PY + done + + - name: Promote the verified digest without rebuilding + run: | + for IMAGE in "$GHCR_IMAGE" "$DOCKERHUB_IMAGE"; do + docker buildx imagetools create --prefer-index=false --tag "$IMAGE:latest" "$IMAGE@$EXPECTED_DIGEST" + ACTUAL=$(docker buildx imagetools inspect "$IMAGE:latest" --format '{{.Manifest.Digest}}') + test "$ACTUAL" = "$EXPECTED_DIGEST" + echo "Promoted $IMAGE:latest to $EXPECTED_DIGEST" >> "$GITHUB_STEP_SUMMARY" + done diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 27fa369..445f421 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -12,10 +12,14 @@ on: type: string env: - GHCR_IMAGE: ghcr.io/${{ github.repository_owner }}/powernight + GHCR_IMAGE: ghcr.io/zaai-com/powernight DOCKERHUB_IMAGE: zaaicom/powernight PLATFORMS: linux/amd64,linux/arm64,linux/arm/v7 +concurrency: + group: powernight-release-publication + cancel-in-progress: false + jobs: build-and-publish: name: Build and Publish Multi-Arch Docker Images @@ -27,51 +31,66 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 # Full history for version info - name: Extract version from tag id: version + env: + REQUESTED_VERSION: ${{ inputs.version }} run: | - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - VERSION="${{ github.event.inputs.version }}" + if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then + VERSION="$REQUESTED_VERSION" else VERSION=${GITHUB_REF#refs/tags/} fi - echo "version=$VERSION" >> $GITHUB_OUTPUT + if [ "$GITHUB_REF" != "refs/tags/$VERSION" ]; then + echo "::error::Select refs/tags/$VERSION when dispatching this release workflow (not a branch)." + exit 1 + fi + git merge-base --is-ancestor HEAD origin/main + [[ "$VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] + echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "Extracted version: $VERSION" - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: '20' + node-version: '26' cache: 'npm' cache-dependency-path: 'package-lock.json' - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: - python-version: '3.11' + python-version: '3.14' - name: Install backend dependencies run: | echo "Installing PowerNight package..." - pip install -e . + pip install -e ".[build]" echo "✓ Backend package installed" + - name: Verify release version matches source + env: + RELEASE_VERSION: ${{ steps.version.outputs.version }} + run: python scripts/check_release_version.py "$RELEASE_VERSION" + - name: Install frontend dependencies run: | # Retry npm ci up to 3 times to handle transient network errors for i in 1 2 3; do - npm ci && break || { + if npm ci; then + break + else if [ $i -eq 3 ]; then echo "npm ci failed after 3 attempts" exit 1 fi echo "npm ci failed, retrying ($i/3)..." sleep 5 - } + fi done - name: Build frontend @@ -90,34 +109,34 @@ jobs: echo "✓ Frontend build successful" - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - name: Login to GitHub Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 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@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 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@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 with: images: | ${{ env.GHCR_IMAGE }} ${{ env.DOCKERHUB_IMAGE }} + flavor: latest=false tags: | - type=semver,pattern={{version}} - type=raw,value=latest + type=raw,value=${{ steps.version.outputs.version }} labels: | org.opencontainers.image.title=PowerNight org.opencontainers.image.description=Schedule Tesla Powerwall Grid-Charging during the night @@ -126,7 +145,7 @@ jobs: - name: Build and push Docker image id: build - uses: docker/build-push-action@v5 + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: context: . platforms: ${{ env.PLATFORMS }} @@ -141,7 +160,7 @@ jobs: VCS_REF=${{ github.sha }} - name: Update Docker Hub description - uses: peter-evans/dockerhub-description@v4 + uses: peter-evans/dockerhub-description@1b9a80c056b620d92cedb9d9b5a223409c68ddfa # v5.0.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -150,36 +169,25 @@ jobs: readme-filepath: ./docs/README.md - name: Generate build summary + env: + VERSION: ${{ steps.version.outputs.version }} + DIGEST: ${{ steps.build.outputs.digest }} run: | - echo "## Build Summary" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "### Published Images" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "**GitHub Container Registry (Primary):**" >> $GITHUB_STEP_SUMMARY - echo "- \`${{ env.GHCR_IMAGE }}:${{ steps.version.outputs.version }}\`" >> $GITHUB_STEP_SUMMARY - echo "- \`${{ env.GHCR_IMAGE }}:latest\`" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Docker Hub (Mirror):**" >> $GITHUB_STEP_SUMMARY - echo "- \`${{ env.DOCKERHUB_IMAGE }}:${{ steps.version.outputs.version }}\`" >> $GITHUB_STEP_SUMMARY - echo "- \`${{ env.DOCKERHUB_IMAGE }}:latest\`" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "### Platforms" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "- linux/amd64" >> $GITHUB_STEP_SUMMARY - echo "- linux/arm64" >> $GITHUB_STEP_SUMMARY - echo "- linux/arm/v7" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "### Pull Commands" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "\`\`\`bash" >> $GITHUB_STEP_SUMMARY - echo "# GitHub Container Registry (Recommended)" >> $GITHUB_STEP_SUMMARY - echo "docker pull ${{ env.GHCR_IMAGE }}:${{ steps.version.outputs.version }}" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "# Docker Hub (Mirror)" >> $GITHUB_STEP_SUMMARY - echo "docker pull ${{ env.DOCKERHUB_IMAGE }}:${{ steps.version.outputs.version }}" >> $GITHUB_STEP_SUMMARY - echo "\`\`\`" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "### Links" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "- [GitHub Packages](https://github.com/${{ github.repository }}/pkgs/container/powernight)" >> $GITHUB_STEP_SUMMARY - echo "- [Docker Hub Repository](https://hub.docker.com/r/${{ env.DOCKERHUB_IMAGE }})" >> $GITHUB_STEP_SUMMARY + python - <<'PYTHON' + import os + from pathlib import Path + summary = f"""## Staged PowerNight {os.environ['VERSION']} + + Versioned images: + - `{os.environ['GHCR_IMAGE']}:{os.environ['VERSION']}` + - `{os.environ['DOCKERHUB_IMAGE']}:{os.environ['VERSION']}` + + Digest: `{os.environ['DIGEST']}` + Platforms: `{os.environ['PLATFORMS']}` + + **latest is unchanged.** Complete canary validation, then run Docker Promote + from main with this version, digest, and `canary_verified=true`. Do not rebuild for promotion. + """ + with Path(os.environ['GITHUB_STEP_SUMMARY']).open('a') as stream: + stream.write(summary) + PYTHON diff --git a/.github/workflows/docker-security-scan.yml b/.github/workflows/docker-security-scan.yml index 050cd34..3a813e1 100644 --- a/.github/workflows/docker-security-scan.yml +++ b/.github/workflows/docker-security-scan.yml @@ -37,21 +37,23 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Determine image tag id: tag + env: + REQUESTED_TAG: ${{ inputs.image_tag }} run: | - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - TAG="${{ inputs.image_tag }}" - else - TAG="latest" + TAG="${REQUESTED_TAG:-latest}" + if [[ ! "$TAG" =~ ^[a-zA-Z0-9_][a-zA-Z0-9_.-]{0,127}$ ]]; then + echo "::error::Invalid Docker image tag" + exit 1 fi - echo "tag=$TAG" >> $GITHUB_OUTPUT - echo "Scanning image: ${{ env.DOCKER_IMAGE }}:$TAG" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "Scanning image: $DOCKER_IMAGE:$TAG" - name: Run Trivy vulnerability scanner - uses: aquasecurity/trivy-action@master + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: image-ref: ${{ env.DOCKER_IMAGE }}:${{ steps.tag.outputs.tag }} format: 'sarif' @@ -60,13 +62,13 @@ jobs: continue-on-error: true # Don't fail workflow, but report issues - name: Upload Trivy scan results to GitHub Security - uses: github/codeql-action/upload-sarif@v3 + uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 if: always() && hashFiles('trivy-results.sarif') != '' with: sarif_file: 'trivy-results.sarif' - name: Run Trivy for detailed output - uses: aquasecurity/trivy-action@master + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: image-ref: ${{ env.DOCKER_IMAGE }}:${{ steps.tag.outputs.tag }} format: 'table' @@ -75,27 +77,22 @@ jobs: - name: Generate scan summary if: always() + env: + SCANNED_TAG: ${{ steps.tag.outputs.tag }} run: | - echo "## Security Scan Results" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Image Scanned:** \`${{ env.DOCKER_IMAGE }}:${{ steps.tag.outputs.tag }}\`" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Scan Date:** $(date -u '+%Y-%m-%d %H:%M:%S UTC')" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - if [ -f "trivy-results.sarif" ]; then - echo "✓ Trivy scan completed successfully" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "Results have been uploaded to the [Security tab](https://github.com/${{ github.repository }}/security/code-scanning)." >> $GITHUB_STEP_SUMMARY - else - echo "⚠️ Trivy scan did not complete" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "This may be due to the image not being available on Docker Hub." >> $GITHUB_STEP_SUMMARY - fi - echo "" >> $GITHUB_STEP_SUMMARY - echo "### Manual Scan" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "To scan locally:" >> $GITHUB_STEP_SUMMARY - echo "\`\`\`bash" >> $GITHUB_STEP_SUMMARY - echo "docker pull ${{ env.DOCKER_IMAGE }}:${{ steps.tag.outputs.tag }}" >> $GITHUB_STEP_SUMMARY - echo "trivy image ${{ env.DOCKER_IMAGE }}:${{ steps.tag.outputs.tag }}" >> $GITHUB_STEP_SUMMARY - echo "\`\`\`" >> $GITHUB_STEP_SUMMARY + python - <<'PYTHON' + import os + from datetime import datetime, timezone + from pathlib import Path + image = os.environ['DOCKER_IMAGE'] + ':' + os.environ['SCANNED_TAG'] + available = Path('trivy-results.sarif').exists() + summary = f"## Security Scan Results\n\n**Image:** `{image}`\n\n" + summary += f"**Scan date:** {datetime.now(timezone.utc).isoformat()}\n\n" + summary += 'SARIF report produced.\n' if available else 'Scanner did not produce a SARIF report.\n' + summary += f"\nSee the [Security tab](https://github.com/{os.environ['GITHUB_REPOSITORY']}/security/code-scanning) for uploaded reports.\n" + if not available: + summary += 'Check that the requested image tag is available on Docker Hub and review the scanner logs.\n' + summary += f"\n### Manual scan\n\n```bash\ndocker pull {image}\ntrivy image {image}\n```\n" + with Path(os.environ['GITHUB_STEP_SUMMARY']).open('a') as stream: + stream.write(summary) + PYTHON diff --git a/.gitignore b/.gitignore index 2f21514..8dac923 100644 --- a/.gitignore +++ b/.gitignore @@ -420,4 +420,10 @@ __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/ + +# Workspace artifacts and credential-bearing configuration backups +.context/ +.backups/ diff --git a/AGENTS.md b/AGENTS.md index a3964e5..302d415 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.3 + 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 @@ -390,7 +390,7 @@ docker inspect PowerNight | grep -A 5 "Mounts" - Grid Settings (Backup Reserve, Grid Charging, Grid Export Mode) - Additional Data (Load Power, Site Power, Last Updated timestamp) - Raw Data (Collapsible JSON view of complete site details) -- Manual refresh button (no auto-fetch on mount) +- Refresh every 20 seconds while visible, plus a manual refresh button ### Planner Page (`/planner`) **File:** `src/powernight/web/src/pages/Planner.tsx` @@ -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..ab6924f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,8 +7,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co PowerNight is a Docker container application that automates Tesla Powerwall backup reserve management. It uses a **React SPA frontend** with a **Flask API backend** to provide scheduling, monitoring, and control capabilities. **Technology Stack:** -- Backend: Python 3.10+ (Flask, SQLAlchemy, pypowerwall) -- Frontend: React 18 + TypeScript + Vite + Tailwind CSS +- Backend: Python 3.11+ (Flask, SQLAlchemy, pypowerwall) +- Frontend: React 19.3 + 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..dca99c9 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.14-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.14-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..23e847a 100755 --- a/build.sh +++ b/build.sh @@ -47,10 +47,6 @@ check_requirements() { exit 1 fi - if ! command -v jq &> /dev/null; then - log_warning "jq is not installed. Will use basic parsing (install jq for better results)" - fi - log_success "All requirements satisfied" } @@ -141,23 +137,17 @@ get_backend_dependencies() { return fi - # Extract key dependencies with versions - # This is a simplified parser - ideally use a TOML parser - 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" -} -EOF -) - - echo "$DEPS" + python3 - <<'PY' +import json +import tomllib +from packaging.requirements import Requirement +from pathlib import Path + +dependencies = tomllib.loads(Path("pyproject.toml").read_text())["project"]["dependencies"] +requirements = [Requirement(dependency) for dependency in dependencies] +print(json.dumps({requirement.name.lower(): str(requirement.specifier) + for requirement in requirements})) +PY } # Extract frontend dependencies from package.json @@ -169,38 +159,15 @@ get_frontend_dependencies() { return fi - if command -v jq &> /dev/null; then - # Use jq for proper JSON parsing - DEPS=$(jq -r '{ - react: .dependencies.react, - "react-dom": .dependencies["react-dom"], - "react-router-dom": .dependencies["react-router-dom"], - axios: .dependencies.axios, - "date-fns": .dependencies["date-fns"], - vite: .devDependencies.vite, - typescript: .devDependencies.typescript, - tailwindcss: .devDependencies.tailwindcss, - "@vitejs/plugin-react": .devDependencies["@vitejs/plugin-react"] - }' package.json) - else - # Fallback: Basic parsing without jq - 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" -} -EOF -) - fi + python3 - <<'PY' +import json +from pathlib import Path - echo "$DEPS" +package = json.loads(Path("package.json").read_text()) +dependencies = package["dependencies"] +tools = ("vite", "typescript", "tailwindcss", "@vitejs/plugin-react") +print(json.dumps({**dependencies, **{name: package["devDependencies"][name] for name in tools}})) +PY } # Generate version-info.json diff --git a/compose.release-test.yml b/compose.release-test.yml new file mode 100644 index 0000000..ecd5b20 --- /dev/null +++ b/compose.release-test.yml @@ -0,0 +1,25 @@ +# Isolated disposable deployment used only by scripts/release_smoke.py. +services: + PowerNight: + image: ${POWERNIGHT_TEST_IMAGE:-powernight-release:test} + ports: + - "127.0.0.1::8020" + environment: + POWERNIGHT_DATA_PATH: /data + POWERNIGHT_CONFIG_PATH: /data/config.yaml + POWERNIGHT_WEB_HOST: 0.0.0.0 + POWERNIGHT_WEB_PORT: "8020" + POWERNIGHT_API_KEY: ${POWERNIGHT_TEST_API_KEY:-} + POWERNIGHT_AUTOMATION_ENABLED: "false" + TESLA_EMAIL: test@example.com + volumes: + - release-data:/data + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8020/health"] + interval: 2s + timeout: 5s + retries: 30 + start_period: 5s + +volumes: + release-data: diff --git a/compose.release.yml b/compose.release.yml new file mode 100644 index 0000000..a764608 --- /dev/null +++ b/compose.release.yml @@ -0,0 +1,6 @@ +# Combine with docker-compose.yml; never start this override on its own. +services: + PowerNight: + image: zaaicom/powernight:2.0.0 + environment: + POWERNIGHT_CONFIG_PATH: /data/config.yaml diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..e3ef273 --- /dev/null +++ b/config.example.yaml @@ -0,0 +1,22 @@ +# Copy to PowerNight-Data/config.yaml only for a fresh installation. +# Keep credentials in .env; UI settings are saved into this persistent file. +powerwall: + tesla_email: user@example.com + timeout: 30.0 + verify_ssl: true +automation: + enabled: false + schedule: [] + timezone: UTC + check_interval: 60.0 +web_interface: + enabled: true + host: 0.0.0.0 + port: 8020 + auth_enabled: false +logging: + level: INFO + file_path: /data/logs/powernight.log + file_enabled: true +monitoring: + enabled: false 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..de8b52d 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 @@ -104,13 +104,20 @@ powernight status ## Quick Start -1. **Install & Run PowerNight Docker Container** +1. **Install & Run PowerNight with Docker Compose** + + After 2.0.0 is published, obtain `docker-compose.yml`, `compose.release.yml`, + `.env.example`, and `config.example.yaml` from the tagged repository (or the + release assets with `SHA256SUMS`). The release override pins **2.0.0** and + persists settings under `/data`; the base file alone still uses unchanged `latest`. + Follow [the release runbook](releases/2.0.0.md#persistent-release-configuration) + to seed a fresh config or preserve an upgrade config and verify permissions. + Set your Tesla email and optional API key in `.env` (generate a key with + `openssl rand -hex 32`). Omit the key only on a trusted LAN. + ```bash - docker run -d \ - --name PowerNight \ - -p 8020:8020 \ - -v ./PowerNight-Data:/data \ - zaaicom/powernight:latest + docker compose -f docker-compose.yml -f compose.release.yml pull + docker compose -f docker-compose.yml -f compose.release.yml up -d ``` 2. **Open web interface** @@ -140,46 +147,23 @@ powernight status ### Docker Hub (Recommended) -Docker Hub is recommended for most users, especially Synology NAS deployments. - -```bash -# Run with volume mount (IMPORTANT for data persistence) -docker run -d \ - --name PowerNight \ - -p 8020:8020 \ - -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. +Use `docker-compose.yml` together with `compose.release.yml` after 2.0.0 is +published. Both commands in Quick Start include the override so an unchanged +`latest` cannot be launched accidentally. Keep `PowerNight-Data`, `.env`, and +both Compose files together. Follow the persistent-config steps in the runbook. ### GitHub CR (Container Registry) -Alternative registry hosted on GitHub Packages. - -```bash -# Pull from GHCR -docker pull ghcr.io/zaai-com/powernight:latest - -# Run with volume mount -docker run -d \ - --name PowerNight \ - -p 8020:8020 \ - -v ./PowerNight-Data:/data \ - ghcr.io/zaai-com/powernight:latest -``` +After publication, the same release is mirrored at `ghcr.io/zaai-com/powernight:2.0.0`. To use GHCR, +change only the service image in the Compose file; preserve the existing `/data` +mount and environment settings. ### Docker Compose **Recommended for production deployments** - ensures persistent volumes are always configured. -1. Download `docker-compose.yml` file - -2. Start the application: - -```bash -docker-compose up -d -``` +Use the version-pinned Quick Start and persistent-config runbook above. Do not +run the base file alone for a staged 2.0.0 install. ### Synology NAS @@ -189,7 +173,7 @@ Optimized for Synology DSM Docker integration: 2. **Create Folder** `/docker/PowerNight-Data` via File Station and add Read&Write permission to 'SYSTEM' user -3. **Pull image** from Docker Hub **via Synology DSM UI**: Go to Registry tab → Search for `zaaicom/powernight` → Download `latest` tag +3. **Pull image** from Docker Hub **via Synology DSM UI**: Go to Registry tab → Search for `zaaicom/powernight` → Download `2.0.0` after publication and configure the persistent YAML as described in the release runbook 4. **Create container** with: - **Port Settings**: Local Port 8020 → Container Port 8020 @@ -208,6 +192,10 @@ Optimized for Synology DSM Docker integration: For contributors and developers: +Python 3.11+ is required (Python 3.12+ for the optional documentation tools). +Docker and CI use Python 3.14; frontend CI uses Node.js 26. +TypeScript remains on 6.0.3 until typescript-eslint supports TypeScript 7. + ```bash # Clone repository git clone https://github.com/ZAAI-com/PowerNight.git @@ -217,7 +205,7 @@ cd PowerNight pip install -e ".[dev]" # Install Node.js dependencies -npm install +npm ci # Build frontend (with version metadata) ./build.sh --no-docker @@ -228,20 +216,30 @@ python -m powernight.main Access at [http://localhost:8020](http://localhost:8020) +For frontend hot reload, run `npm run dev` in another terminal. Its `/api` +proxy defaults to `http://localhost:8020`. To use a different backend port, +run `POWERNIGHT_API_PROXY_TARGET=http://127.0.0.1:8123 npm run dev`. +This Vite configuration reads the shell environment, not `.env` files. + + +## Releases +See [2.0.0 release notes, upgrade checks, and rollback](releases/2.0.0.md). +Version tags are staged before `latest`; local testing is not a live canary. ## 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..115c68d 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, `Authorization: Bearer `, or HTTP Basic authentication with the configured username/password pair. `/health`, `/version`, SPA/static assets, `GET /api/v1/version-info.json`, and `GET /api/v1/config/timezone` and `/api/v1/config/timezones` are public metadata. Detailed `/api/v1/health` stays protected. OAuth setup start/callback/complete require app authentication when enabled, except for tokenless first-time bootstrap from loopback; setup-status requires the session identifier. Do not use detailed health as an unauthenticated probe. +- 🛡️ **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..56d63c9 --- /dev/null +++ b/docs/plans/ux-improvement-roadmap.md @@ -0,0 +1,194 @@ +# 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 refreshes every 20 seconds with no interval dropdown and pauses while hidden. +- 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. + +--- + +## Current baseline + +Phase 1 is complete: Planner uses ConfirmDialog and toasts, Header uses authenticated +serial health polling, Dashboard refresh runs every 20 seconds and pauses +while hidden, and History filters status immediately while debouncing task names. +Requests do not overlap; manual Dashboard refresh remains available. + +The following phases are future proposals, not claims about existing endpoints. +Removed endpoints must not be wired up without a separately approved backend. + +--- + +## 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 — DESCOPED + +`/schedules/next-execution` was removed. Any future widget must use the retained +`next_run` in `/api/v1/status` or define a new backend contract first. + +### 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..d617ae6 100644 --- a/src/powernight/web/src/pages/Planner.tsx +++ b/src/powernight/web/src/pages/Planner.tsx @@ -1,12 +1,15 @@ -import React, { useState, useEffect } from 'react'; -import { Task, CommandType, CommandDefinition, TaskFormData } from '../types'; +import React, { useState, useEffect, useRef, useCallback } 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 { PresetDialog } from '../components/PresetDialog'; +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 +18,36 @@ 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(''); + const [presetError, setPresetError] = useState(null); + const [savingPreset, setSavingPreset] = useState(false); + const selectedPreset = presets.find(preset => preset.id === selectedPresetId); + // 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: '', @@ -27,13 +57,6 @@ const Planner: React.FC = () => { enabled: true, }); - // Load tasks and available commands - useEffect(() => { - console.log('Planner component useEffect running'); - loadTasks(); - loadCommands(); - }, []); - const loadTasks = async () => { try { setLoading(true); @@ -59,6 +82,71 @@ const Planner: React.FC = () => { } }; + const loadPresets = useCallback(async () => { + try { + const data = await api.getTaskPresets(); + setPresets(data.presets || []); + } catch (err) { + showToast(err instanceof Error ? err.message : 'Failed to load presets', 'error'); + } + }, [showToast]); + + useEffect(() => { + void loadTasks(); + void loadCommands(); + void loadPresets(); + }, [loadPresets]); + + 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() || savingPreset) return; + setSavingPreset(true); + setPresetError(null); + 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) { + const message = (err as { response?: { data?: { message?: string } } })?.response?.data?.message; + setPresetError(typeof message === 'string' ? message : err instanceof Error ? err.message : 'Failed to save preset'); + } finally { + setSavingPreset(false); + } + }; + + 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 +164,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 +226,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 +272,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 +364,7 @@ const Planner: React.FC = () => { }); setEditingTask(null); setShowForm(false); + setSelectedPresetId(''); }; const handleCommandChange = (command: CommandType) => { @@ -231,7 +375,7 @@ const Planner: React.FC = () => { }); }; - const handleParamChange = (paramName: string, value: any) => { + const handleParamChange = (paramName: string, value: unknown) => { setFormData({ ...formData, command_params: { @@ -268,7 +412,7 @@ const Planner: React.FC = () => { ) : paramDef.options ? ( handleParamChange(paramName, parseFloat(e.target.value))} min={paramDef.min} max={paramDef.max} @@ -291,7 +435,7 @@ const Planner: React.FC = () => { ) : ( handleParamChange(paramName, e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" required={paramDef.required} @@ -316,7 +460,7 @@ const Planner: React.FC = () => {
{/* Header */} -
+

Planner

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

+ {/* Preset Selector - shown only when creating, not editing */} + {!editingTask && presets.length > 0 && ( +
+ +
+ + {selectedPreset && !selectedPreset.is_builtin && ( + + )} +
+
+ )} +
+ + {/* Save as Preset Modal */} + {showSavePresetModal && ( + setShowSavePresetModal(false)}> +

Save as Preset

+ {presetError &&

{presetError}

} +
+
+ + 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..be1e37f 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'; @@ -25,6 +26,10 @@ interface AuthInfo { access_token_masked?: string; refresh_token_masked?: string; message?: string; + connected?: boolean; + connection_status?: 'connected' | 'disconnected' | 'unknown'; + last_connection_attempt?: string | null; + connection_error?: string | null; } interface VersionInfo { @@ -51,6 +56,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'); @@ -62,6 +68,7 @@ const Settings: React.FC = () => { // Auth Info State const [authInfo, setAuthInfo] = useState(null); const [authInfoLoading, setAuthInfoLoading] = useState(false); + const [connectionTesting, setConnectionTesting] = useState(false); // Version Info State const [versionInfo, setVersionInfo] = useState(null); @@ -69,9 +76,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 +110,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 +123,15 @@ 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) { + setCurrentStep('initial'); + 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 +155,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 +175,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 +186,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 }), @@ -186,10 +196,15 @@ const Settings: React.FC = () => { if (data.success) { setCurrentStep('complete'); + await fetchAuthInfo(); + } else if (data.credentials_saved) { + setCurrentStep('complete'); + await fetchAuthInfo(); + setError(data.connection_error || data.message || 'Tesla credentials were saved, but the live connection failed.'); } else { setError(data.error || 'Failed to complete setup'); } - } catch (err) { + } catch { setError('Failed to complete setup'); } }; @@ -197,7 +212,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 +233,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}`); @@ -240,6 +255,30 @@ const Settings: React.FC = () => { } }; + const testTeslaConnection = async () => { + setConnectionTesting(true); + setError(null); + try { + const response = await api.authenticatedFetch('/api/auth/tesla/test-connection', { + method: 'POST', + }); + const data = await response.json(); + if (data.success) { + setAuthInfo((current) => current ? { ...current, ...data.data } : current); + showToast('Tesla cloud connection successful', 'success'); + } else { + setAuthInfo((current) => current ? { ...current, ...data.data, connected: false } : current); + setError(data.error || 'Tesla cloud connection failed'); + showToast(data.error || 'Tesla cloud connection failed', 'error'); + } + } catch { + setError('Failed to test Tesla cloud connection'); + showToast('Failed to test Tesla cloud connection', 'error'); + } finally { + setConnectionTesting(false); + } + }; + const fetchAvailableTimezones = async () => { try { const data = await api.getAvailableTimezones(); @@ -262,38 +301,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 +372,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 +405,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" /> @@ -383,9 +428,17 @@ const Settings: React.FC = () => { {(currentStep === 'selecting_site' || currentStep === 'complete') && (
-
-

✅ Tesla Account connected

-

Email: {email}

+
+

+ {authInfo?.connected ? '✅ Tesla cloud connected' : '⚠️ Tesla credentials stored; live connection unavailable'} +

+

+ Email: {email} +

)} @@ -455,9 +508,9 @@ const Settings: React.FC = () => {
) : authInfo.authenticated ? (
-
-

✅ PyPowerwall authentication file found

-

Authentication data is available and ready to use

+
+

Tesla credentials stored

+

Token validity and live Tesla cloud connectivity are shown separately below.

@@ -517,6 +570,40 @@ const Settings: React.FC = () => {
+
+

Live Connection

+
+
+
Status
+
+ + {authInfo.connected ? 'Connected' : authInfo.connection_status === 'disconnected' ? 'Disconnected' : 'Unknown'} + +
+
+ {authInfo.last_connection_attempt && ( +
+
Last Attempt
+
+ {formatDateTimeWithTimezone(authInfo.last_connection_attempt, timezoneInfo?.timezone)} +
+
+ )} + {authInfo.connection_error && ( +
+
Connection Error
+
{authInfo.connection_error}
+
+ )} +
+
+

Token Information

@@ -561,9 +648,19 @@ const Settings: React.FC = () => {
-
+
+