8.9 KiB
UI Stock Tracker — Project Documentation
Status: ✅ Complete and running Last updated: 2026-03-29
Purpose
Monitors product pages on store.ui.com 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 <span> 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_stockandalert_sent = 0 alert_sentis set to1after 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
setIntervaltimer 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
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:
- Checks out the repo
- Logs in to the private container registry at
registry.alwisp.comusinggitea.REGISTRY_USER+secrets.REGISTRY_TOKEN - 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:
docker pull registry.alwisp.com/{owner}/{repo}:latest
Build & Deploy
# 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":
"lib": ["ES2020", "DOM", "DOM.Iterable"]
DOM.Iterable was specifically required to allow for...of iteration over NodeListOf<HTMLSpanElement>.
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