# UI Stock Tracker — Project Documentation **Status:** ✅ Complete and running **Last updated:** 2026-03-29 --- ## Purpose Monitors product pages on [store.ui.com](https://store.ui.com/us/en) for stock availability and sends a Telegram alert the moment a watched item comes back in stock. Runs as a single persistent Docker container on Unraid with a clean web UI for managing tracked products. --- ## Stack | Layer | Technology | |---|---| | Frontend | React 18 + TypeScript, Vite, Lucide icons | | Backend | Node.js 20 + TypeScript, Express | | Database | SQLite via `better-sqlite3` (WAL mode) | | Scraper | Puppeteer-core + system Chromium | | Alerts | Telegram Bot API (direct HTTP) | | Container | Single Docker container — nginx + Node.js managed by supervisord | | Web server | nginx — serves frontend, proxies `/api/` to Node.js on port 3001 | --- ## Container Architecture One container runs three processes via **supervisord**: ``` supervisord ├── nginx → serves React build on :8080, proxies /api/ → localhost:3001 └── node → Express API, SQLite, Puppeteer scheduler, Telegram sender ``` The SQLite database is stored on a mounted volume at `/app/data/tracker.db` so it persists across rebuilds. --- ## Key Features ### Stock Detection Puppeteer navigates to each product URL and waits for React hydration (2.5s delay), then scans all `` elements for exact text matches: - `"Add to Cart"` → `in_stock` - `"Sold Out"` → `sold_out` - Neither found → `unknown` Product name is pulled from the `og:title` meta tag (with Ubiquiti store suffix stripped). Thumbnail is pulled from `og:image`. ### Alert Logic - Alert fires **once** when an item transitions to `in_stock` and `alert_sent = 0` - `alert_sent` is set to `1` after firing — no repeat alerts for the same in-stock event - User clicks **Re-arm** in the UI to reset `alert_sent = 0`, enabling a new alert next time it comes back in stock - This covers the "missed the buy window" scenario without spamming ### Scheduler - Each watched item runs its own `setInterval` timer in memory - Minimum interval enforced at **30 seconds** (backend + frontend) - On startup, all active items are loaded from the DB and timers are started automatically - Pause/Resume/Edit immediately updates the in-memory timer map and the DB - A semaphore limits concurrent Puppeteer instances to **2** to prevent OOM on Unraid ### Frontend Polling The UI polls `GET /api/items` every **10 seconds** to keep check counts and statuses current without requiring a page refresh. --- ## Database Schema ```sql watched_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, url TEXT NOT NULL UNIQUE, name TEXT, -- populated after first scrape thumbnail_url TEXT, -- populated after first scrape check_interval INTEGER NOT NULL DEFAULT 60, -- seconds, min 30 is_active INTEGER NOT NULL DEFAULT 1, -- 0 = paused last_status TEXT NOT NULL DEFAULT 'unknown', -- in_stock | sold_out | unknown alert_sent INTEGER NOT NULL DEFAULT 0, -- 1 = alert fired, awaiting re-arm check_count INTEGER NOT NULL DEFAULT 0, last_checked_at TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) ) settings ( key TEXT PRIMARY KEY, -- telegram_bot_token | telegram_chat_id value TEXT NOT NULL DEFAULT '' ) ``` --- ## API Endpoints | Method | Path | Description | |---|---|---| | GET | `/api/items` | List all watched items | | POST | `/api/items` | Add new item (url, check_interval) | | PUT | `/api/items/:id` | Update interval or active state | | DELETE | `/api/items/:id` | Remove item | | POST | `/api/items/:id/pause` | Stop checking | | POST | `/api/items/:id/resume` | Resume checking | | POST | `/api/items/:id/reset` | Clear alert_sent (re-arm) | | GET | `/api/settings` | Get Telegram credentials | | PUT | `/api/settings` | Save Telegram credentials | | POST | `/api/settings/test-telegram` | Send a test Telegram message | | GET | `/api/health` | Health check | --- ## Telegram Configuration | Field | Value | |---|---| | Bot Token | `8769097441:AAFBqPlSTcTIi3I-F5ZIN9EEpwbNDzHg8hM` | | Chat ID | `8435449432` | Configured via **Settings** in the UI. Use **Test Alert** to verify the connection after saving. --- ## Dockerfile Build Stages ``` Stage 1 — frontend-builder node:20-slim → npm install → vite build → /build/frontend/dist Stage 2 — backend-builder node:20-slim + python3/make/g++ → npm install → tsc → npm prune --production Stage 3 — runtime node:20-slim + nginx + chromium + supervisor ├── Copies /build/frontend/dist → /usr/share/nginx/html ├── Copies /build/backend/dist → /app/backend/dist ├── Copies /build/backend/node_modules → /app/backend/node_modules ├── nginx.conf → /etc/nginx/conf.d/default.conf └── supervisord.conf → /etc/supervisor/conf.d/supervisord.conf ``` --- ## CI/CD — Gitea Actions A workflow at `.gitea/workflows/docker-build.yml` automatically builds and pushes the Docker image on every push to `main`. **What it does:** 1. Checks out the repo 2. Logs in to the private container registry at `registry.alwisp.com` using `gitea.REGISTRY_USER` + `secrets.REGISTRY_TOKEN` 3. Builds and pushes `registry.alwisp.com/{owner}/{repo}:latest` **Runner:** `catthehacker/ubuntu:act-latest` (self-hosted compatible) After a push triggers the build, pull the updated image on Unraid: ```bash docker pull registry.alwisp.com/{owner}/{repo}:latest ``` --- ## Build & Deploy ```bash # Initial build and start cd /mnt/user/appdata/ui-tracker docker build -t ui-tracker . # Then add container via Unraid Docker GUI — see UNRAID.md # Rebuild after code changes docker stop ui-tracker && docker rm ui-tracker docker build -t ui-tracker . # Re-add in Unraid GUI or: docker run ... (see UNRAID.md) ``` --- ## Fixes Applied During Development ### 1. `npm ci` → `npm install` (Dockerfile) `npm ci` requires a `package-lock.json` to exist. Since lockfiles were not committed, the build failed immediately. Switched both frontend and backend build stages to `npm install`. ### 2. TypeScript DOM lib missing (backend) `scraper.ts` uses `page.evaluate()` which runs a callback in the browser context. TypeScript flagged `document`, `navigator`, and `HTMLMetaElement` as unknown because the backend `tsconfig.json` only included `"lib": ["ES2020"]`. Fixed by adding `"DOM"` and `"DOM.Iterable"`: ```json "lib": ["ES2020", "DOM", "DOM.Iterable"] ``` `DOM.Iterable` was specifically required to allow `for...of` iteration over `NodeListOf`. ### 3. Two containers → one container Original design used separate `frontend` and `backend` Docker services in docker-compose. Consolidated into a single container using **supervisord** to run nginx and Node.js side by side, with nginx proxying `/api/` to `localhost:3001`. Removed `backend/Dockerfile`, `frontend/Dockerfile`, and `frontend/nginx.conf`. Root-level `Dockerfile`, `nginx.conf`, and `supervisord.conf` replaced them. --- ## File Structure ``` ui-tracker/ ├── .gitea/ │ └── workflows/ │ └── docker-build.yml # Build + push to registry.alwisp.com on push to main ├── Dockerfile # 3-stage single-container build ├── docker-compose.yml # Single service, port 8080, ./data volume ├── nginx.conf # Serves frontend, proxies /api/ to Node ├── supervisord.conf # Keeps nginx + node alive ├── PROJECT.md # This file ├── UNRAID.md # Installation guide ├── data/ # SQLite DB lives here (volume mount) ├── backend/ │ ├── package.json │ ├── tsconfig.json │ └── src/ │ ├── index.ts # Express entry + scheduler init │ ├── database.ts # Schema, getDb(), WatchedItem type │ ├── scraper.ts # Puppeteer stock check + semaphore │ ├── scheduler.ts # Per-item interval timers + alert logic │ ├── telegram.ts # Bot API HTTP calls │ └── routes/ │ ├── items.ts # Item CRUD + pause/resume/reset │ └── settings.ts # Telegram config + test endpoint └── frontend/ ├── package.json ├── tsconfig.json ├── tsconfig.node.json ├── vite.config.ts ├── index.html └── src/ ├── main.tsx ├── App.tsx # Root, 10s polling, connection indicator ├── index.css # Dark theme, CSS variables, responsive grid ├── types/index.ts ├── api/client.ts # Typed fetch wrappers └── components/ ├── ItemCard.tsx # Thumbnail, badges, Re-arm, actions ├── AddItemModal.tsx ├── EditItemModal.tsx └── SettingsModal.tsx # Token + chat ID + test alert ```