Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Git
.git
.github

# Python virtual environments
.venv
src/backend/.venv
src/backend/__pycache__
src/backend/.pytest_cache
**/__pycache__
**/.pytest_cache

# Frontend build intermediates
src/frontend/node_modules
src/frontend/dist

# Distribution artifacts
nutwatch.tar.gz

# Editor / local config
.opencode
.claude
.coderabbit.yaml
.playwright-cli
.vscode
.idea

# Documentation assets (not needed at runtime)
docs/screenshots
8 changes: 6 additions & 2 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ on:
branches: [main]
pull_request:

permissions:
contents: read

jobs:
lint-shell:
runs-on: ubuntu-latest
Expand All @@ -14,11 +17,12 @@ jobs:
- name: Install tools
run: sudo apt-get install -y shellcheck shfmt

# Same file set as `make lint` / `make fmt` (see Makefile SHELL_FILES).
- name: shellcheck
run: find vm/ -name "*.sh" -print0 | xargs -0 shellcheck
run: find vm/ src/backend/ scripts/ -name "*.sh" -print0 | xargs -0 shellcheck

- name: shfmt
run: find vm/ -name "*.sh" -print0 | xargs -0 shfmt -d -i 2
run: find vm/ src/backend/ scripts/ -name "*.sh" -print0 | xargs -0 shfmt -d -i 2

lint-python:
runs-on: ubuntu-latest
Expand Down
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ make install-tools # apt-get shellcheck shfmt python3-pytest
# python3 -m venv .venv && source .venv/bin/activate && pip install -r src/backend/requirements.txt
```

CI runs `shellcheck` + `shfmt -d -i 2` on `vm/*.sh` and Python lint + tests (see `.github/workflows/lint.yml`). `make check` reproduces the full local suite.
CI runs `shellcheck` + `shfmt -d -i 2` on `vm/`, `src/backend/`, and `scripts/` (same set as the Makefile's `SHELL_FILES`) plus Python lint + tests (see `.github/workflows/lint.yml`). `make check` reproduces the full local suite.

## Shell Conventions

Expand Down Expand Up @@ -63,6 +63,7 @@ CI runs `shellcheck` + `shfmt -d -i 2` on `vm/*.sh` and Python lint + tests (see
- Slow DHCP / guest agent: retries for up to 5 minutes.
- virt-customize network failure on Debian 13 (Proxmox VE 9): auto-installs `dhcpcd-base` when missing.
- NUT service enablement varies by distro: `nut-driver-enumerator` → `nut-driver@` → `nut-driver`. Each unit is enabled individually with `|| true` so missing units don't abort the whole run.
- NUT 2.8.x has no bare `nut-driver.service` on most distros (drivers run as `nut-driver@<name>` instances); `services/system.py::_driver_status_units()` resolves the right unit for status/restart and `routes/logs.py::_journal_units()` uses it for journalctl, falling back to pid-file checks or `upsdrvctl`.
- NutWatch install failure inside virt-customize: wrapped in `&& ... || echo` so a download failure doesn't abort the VM setup.
- Script interruption: `trap ERR` calls `error_handler`, `trap EXIT` runs `cleanup` (removes temp dir and working disk image), and `trap SIGINT/SIGTERM` posts failure to the API before exiting.
- Hook ownership: per-UPS hook scripts must be `root:nut 750` so `upsmon` (running as the `nut` user) can execute them. `services/hooks.py::put_hook()` explicitly `chown`s to `root:nut` after writing.
Expand Down
73 changes: 73 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# syntax=docker/dockerfile:1

# Multi-stage build for NutWatch.
# Stage 1 builds the React frontend; Stage 2 is the runtime image with NUT.

FROM node:22-slim AS frontend-builder
# Build the React SPA. Vite writes to ../backend/static, so we copy the
# backend tree into the same relative location before building.
WORKDIR /build/src/frontend
COPY src/frontend/package.json src/frontend/package-lock.json ./
RUN npm ci
COPY src/frontend/ ./
COPY src/backend/ /build/src/backend/
RUN npm run build

FROM ubuntu:24.04

ENV DEBIAN_FRONTEND=noninteractive
ENV NUTWATCH_DIR=/opt/nutwatch
ENV NUTWATCH_HOST=0.0.0.0
ENV NUTWATCH_PORT=8081
ENV NUT_LISTEN_ADDR=0.0.0.0
ENV NUT_LISTEN_PORT=3493
ENV PYTHONUNBUFFERED=1

RUN apt-get update && apt-get install -y --no-install-recommends \
nut-server \
nut-client \
usbutils \
busybox-syslogd \
iproute2 \
iputils-arping \
iputils-ping \
python3 \
python3-venv \
supervisor \
tini \
curl \
openssl \
&& rm -rf /var/lib/apt/lists/* \
&& rm -f /etc/nut/ups.conf /etc/nut/upsd.conf /etc/nut/upsd.users \
/etc/nut/upsmon.conf /etc/nut/nut.conf \
&& rm -f /etc/syslog.conf

WORKDIR $NUTWATCH_DIR

# Copy backend application with freshly built frontend static files.
COPY --from=frontend-builder /build/src/backend/ $NUTWATCH_DIR/

# Install Python dependencies.
RUN python3 -m venv $NUTWATCH_DIR/venv \
&& $NUTWATCH_DIR/venv/bin/pip install --no-cache-dir -r $NUTWATCH_DIR/requirements.txt

# Copy Docker runtime helpers.
COPY scripts/docker/entrypoint.sh /entrypoint.sh
COPY scripts/docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
COPY scripts/docker/systemctl-shim.sh /usr/local/bin/systemctl
COPY scripts/docker/upsmon-wrapper.sh /usr/local/bin/upsmon-wrapper

RUN chmod +x /entrypoint.sh /usr/local/bin/systemctl /usr/local/bin/upsmon-wrapper \
&& mkdir -p /etc/nut/notify.d /var/log/nut /var/run/nut /var/lib/nutwatch /var/log/supervisor \
&& chown -R root:nut /etc/nut \
&& chmod 750 /etc/nut /etc/nut/notify.d \
&& chown nut:nut /var/log/nut /var/run/nut \
&& chmod 755 /var/lib/nutwatch

# Persist NUT configuration and NutWatch data (accounts, history, API keys).
VOLUME ["/etc/nut", "/var/lib/nutwatch"]

EXPOSE 8081 3493

ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["/entrypoint.sh"]
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 nutwatch contributors

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
71 changes: 71 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,77 @@ curl -fsSL https://raw.githubusercontent.com/JuanCF/nutwatch/main/scripts/setup.

Set the `NUTWATCH_REF` env var to pin a specific release version.

### Docker

A multi-stage `Dockerfile` and `docker-compose.yml` are included. The image
bundles NUT, the NutWatch backend, and a built React frontend.

```bash
# Build and run with Docker Compose
docker compose up -d

# Or build and run manually
docker build -t nutwatch .
docker run -d \
--name nutwatch \
--device /dev/bus/usb:/dev/bus/usb \
--device-cgroup-rule 'c 189:* rwm' \
-p 8081:8081 \
-p 3493:3493 \
-v nutwatch-config:/etc/nut \
-v nutwatch-data:/var/lib/nutwatch \
nutwatch
```

**USB access.** NUT drivers need to talk to the UPS over USB. The container
gets the host USB bus plus an allow-rule for USB character devices:

```bash
docker run -d \
--device /dev/bus/usb:/dev/bus/usb \
--device-cgroup-rule 'c 189:* rwm' \
... nutwatch
```

The cgroup rule (major number 189 is USB) is evaluated when devices appear,
so UPS hotplug works without privileged mode. If USB access still fails on
your setup, `--privileged` (or `privileged: true` in compose) is the
documented fallback.

**Credentials.** On first start the entrypoint generates random passwords for
the NUT `admin` and `monuser` users and writes them to `/etc/nut/upsd.users`
inside the `nutwatch-config` volume; the Flask session key is generated by the
backend and persisted in the auth database inside `nutwatch-data`. Generated
values are not printed to logs. To use your own credentials, set
`NUT_ADMIN_PASS` / `NUT_MONITOR_PASS` (and optionally `NUT_ADMIN_USER` /
`NUT_MONITOR_USER` or `NUTWATCH_SECRET_KEY`, at least 32 characters) before
the first start. Because `upsd.users` is only initialized on first start,
rotate existing credentials via the NutWatch UI (NUT Users tab, which
restarts NUT automatically) or the Config Files tab instead of environment
variables.

**Persistent data.** Two volumes are used:

- `/etc/nut` — NUT configuration files (`ups.conf`, `upsd.users`, `upsmon.conf`,
hooks in `notify.d/`, etc.)
- `/var/lib/nutwatch` — NutWatch account/API-key database and UPS history SQLite
database

**Container notes.**

- The container uses `supervisord` instead of `systemd`. A small `systemctl`
shim maps the UI's service-restart actions to `supervisorctl` so NUT service
restarts still work.
- System-level actions (reboot/shutdown) are disabled inside the container.
- Live log streaming relies on `journalctl`; in the container this is not
available. Use `docker exec nutwatch tail -f /var/log/supervisor/upsd.log` or
inspect the individual supervisor log files instead.
- Wake on LAN: magic packets default to the `255.255.255.255` broadcast,
which does not leave Docker's default bridge network. For WOL to reach
hosts on your LAN, either run the container with `--network host`
(`network_mode: host` in compose) or set a directed broadcast address
(e.g. `192.168.1.255`) on each WOL target.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

### Proxmox VM (One-Liner)

```bash
Expand Down
45 changes: 45 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
services:
nutwatch:
build: .
container_name: nutwatch
# USB access: mount the host USB bus and allow USB character devices via
# the device cgroup. The cgroup rule is evaluated when devices appear, so
# UPS hotplug works without privileged mode. Trust boundary: the container
# can then drive every USB device on the host bus. To scope it down,
# replace the mount with a specific /dev/bus/usb/<bus>/<dev> device when
# your UPS always reappears at the same path.
devices:
- /dev/bus/usb:/dev/bus/usb
device_cgroup_rules:
- 'c 189:* rwm'
Comment thread
coderabbitai[bot] marked this conversation as resolved.
# Fallback: replace the two blocks above with `privileged: true` if USB
# access still fails on your setup.
# Share the host network stack: WOL device discovery needs the LAN's ARP
# table, and magic-packet broadcasts must reach the physical network.
# With host networking, `ports:` is ignored; NutWatch serves directly on
# host ports 8081 (UI) and 3493 (NUT). Note: until the first admin
# account is created on the Setup page, the UI is open — run the first
# `docker compose up` on a trusted network and finish setup promptly.
network_mode: host
Comment thread
JuanCF marked this conversation as resolved.
volumes:
- nutwatch-config:/etc/nut
- nutwatch-data:/var/lib/nutwatch
environment:
- NUT_UPS_NAME=ups
- NUT_UPS_DESC=My UPS
- NUT_DRIVER=usbhid-ups
- NUT_ADMIN_USER=admin
- NUT_MONITOR_USER=monuser
# Optional. Credentials are generated on first start and persisted in
# the nutwatch-config volume. Set these before the first
# `docker compose up` to use your own:
# - NUT_ADMIN_PASS=your-admin-password
# - NUT_MONITOR_PASS=your-monitor-password
# Optional. Flask session signing key (>= 32 characters). If unset, the
# backend generates a random key and persists it in nutwatch-data.
# - NUTWATCH_SECRET_KEY=your-long-random-secret
restart: unless-stopped

volumes:
nutwatch-config:
nutwatch-data:
Loading
Loading