Initial scaffold: Flask webui + APScheduler + Playwright automation for MassPrice rule scheduling
Some checks failed
Deploy / deploy (push) Failing after 51s
Some checks failed
Deploy / deploy (push) Failing after 51s
Drives massprice.tcgplayer.com's Run Rule action per-rule on a user-configured schedule (multiple times/day, day-of-week toggles), replacing MassPrice's native scheduler (capped at 15 schedules account-wide, one run/day, with observed clock drift). Deployed via Forgejo push-to-deploy on Unraid, matching arr-summary/card-grader. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H6TgffxTvYhqxykAbVQg2v
This commit is contained in:
commit
2fdb9be9b9
19 changed files with 1197 additions and 0 deletions
82
.claude/skills/deploy-massprice/SKILL.md
Normal file
82
.claude/skills/deploy-massprice/SKILL.md
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
---
|
||||
name: deploy-massprice
|
||||
description: Use when deploying MassPrice Scheduler, restarting the massprice container on Unraid, confirming a deploy landed, or troubleshooting its scheduled-rule automation.
|
||||
---
|
||||
|
||||
## Deployment
|
||||
|
||||
**Target:** Unraid server, container path `/mnt/user/appdata/massprice`,
|
||||
built from `Dockerfile`/`docker-compose.yml` in this repo. Container name
|
||||
`massprice`, port `5056` (overridable via `HOST_PORT`) → container port
|
||||
`5000`.
|
||||
|
||||
**Deploy mechanism: Forgejo push-to-deploy**, the same pattern as
|
||||
`arr-summary`, `card-grader`, `lunch-notifier`, `n64-tracker` — see
|
||||
`arr-summary`'s `deploy-arr-summary` skill or `card-grader`'s `CLAUDE.md`
|
||||
for the full runner mechanics. Push to `main` on the `forgejo` remote
|
||||
(`https://git.hippofam.com/ninja_hippo/massprice.git` — create this repo
|
||||
on the Forgejo instance first if it doesn't exist yet) triggers
|
||||
`.forgejo/workflows/deploy.yml`, which syncs into
|
||||
`/mnt/user/appdata/massprice` (excluding `.git`, `.env`, `data/`) and
|
||||
runs `docker compose up -d --build`.
|
||||
|
||||
**Restarting the container manually: use Compose, not the Unraid Docker
|
||||
UI** — same reasoning as the other projects (every deploy recreates the
|
||||
container with a new Docker ID; Unraid's UI caches the old one). From the
|
||||
Unraid terminal:
|
||||
```bash
|
||||
cd /mnt/user/appdata/massprice && docker compose restart
|
||||
```
|
||||
`docker compose up -d --force-recreate` for a guaranteed-clean recreate;
|
||||
`docker ps --filter name=massprice` to check real state.
|
||||
|
||||
**Confirming a deploy landed:** the footer renders
|
||||
`v<APP_VERSION> · <APP_VERSION_DATE>` from constants near the top of
|
||||
`app.py`. Bump them every commit.
|
||||
|
||||
## First deploy checklist
|
||||
|
||||
1. Create `/mnt/user/appdata/massprice` on Unraid and a real `.env`
|
||||
there (copy `.env.example`, fill in `LOGIN_PASSWORD`, `SECRET_KEY`,
|
||||
`MASSPRICE_USERNAME`, `MASSPRICE_PASSWORD`, `TZ`).
|
||||
2. First `docker compose up -d --build` — confirm the container starts
|
||||
and `/login` responds on `HOST_PORT`.
|
||||
3. Log into the webui, click "Refresh Rules" — confirms Playwright can
|
||||
reach massprice.tcgplayer.com and log in automatically. **If this
|
||||
fails**, check `data/run_history.json`/container logs for the login
|
||||
error — the login form locators in `automation._login()` were written
|
||||
generically (never verified against the real logged-out form, see
|
||||
`CLAUDE.md`) and may need adjusting to match TCGplayer's actual field
|
||||
names/labels.
|
||||
4. Set up at least one schedule and use "Run Now" once to confirm a
|
||||
click actually lands — cross-check by looking at MassPrice's own
|
||||
Activity Log (`https://massprice.tcgplayer.com/Activity/List`) for a
|
||||
fresh timestamp on that rule.
|
||||
5. **Only after step 4 succeeds**, go to each of the 5 existing rules on
|
||||
massprice.tcgplayer.com (three-dot menu → Edit Schedule) and set
|
||||
Status to Inactive, so native scheduling stops double-triggering runs
|
||||
and this tool becomes the sole source of scheduled runs.
|
||||
|
||||
## Persistent state, never touched by deploys
|
||||
|
||||
`.env` holds `LOGIN_PASSWORD`, `SECRET_KEY`, and the MassPrice
|
||||
credentials. `data/` (bind-mounted to `/data`) holds `schedules.json`
|
||||
(the schedules set from the webui), `auth_state.json` (the saved
|
||||
MassPrice browser session — losing this just costs one re-login, not
|
||||
data), `run_history.json`, and `rules_cache.json`. Both are excluded
|
||||
from the deploy rsync (which runs `--delete`) and from git.
|
||||
|
||||
## If scheduled runs stop working
|
||||
|
||||
Check, in order:
|
||||
1. **Activity page in the webui** — does the failure message say
|
||||
"still on a login page" (credentials/CAPTCHA problem) or "rule id not
|
||||
found" (a rule was renamed/deleted on MassPrice — click "Refresh
|
||||
Rules")?
|
||||
2. **Is the container still running** — `docker ps --filter
|
||||
name=massprice` — a Chromium crash inside the container (rare, but
|
||||
possible if `shm_size` ever gets dropped from `docker-compose.yml`)
|
||||
would show as the container restarting.
|
||||
3. **Timezone** — `TZ` in `.env` controls what "6:00 AM" means for every
|
||||
schedule; confirm it matches what the user actually expects before
|
||||
assuming a schedule itself is wrong.
|
||||
26
.env.example
Normal file
26
.env.example
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
# ══════════════════════════════════════════════════════════════════
|
||||
# MassPrice Scheduler — Environment Variables
|
||||
# Copy this file to .env and fill in your values.
|
||||
# ══════════════════════════════════════════════════════════════════
|
||||
|
||||
# ── Login (this tool's own login page, not your MassPrice account) ─
|
||||
LOGIN_PASSWORD=changeme
|
||||
|
||||
# Secret key for Flask session signing — generate a random string:
|
||||
# python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||
SECRET_KEY=change_this_to_a_random_secret_key
|
||||
|
||||
# ── MassPrice (TCGplayer) login ─────────────────────────────────────
|
||||
# Used by Playwright to log in automatically the first time, and again
|
||||
# any time the saved session expires. Stored here only — gitignored,
|
||||
# never committed, never logged.
|
||||
MASSPRICE_USERNAME=your_tcgplayer_email@example.com
|
||||
MASSPRICE_PASSWORD=your_tcgplayer_password
|
||||
|
||||
# ── Host port (what port Unraid exposes) ────────────────────────────
|
||||
# Default is 5056 (arr-summary already uses 5055) — change if in use
|
||||
HOST_PORT=5056
|
||||
|
||||
# ── Timezone ─────────────────────────────────────────────────────────
|
||||
# Controls what "6:00 AM" means for your schedules
|
||||
TZ=America/New_York
|
||||
29
.forgejo/workflows/deploy.yml
Normal file
29
.forgejo/workflows/deploy.yml
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install rsync
|
||||
run: apt-get update -qq && apt-get install -y -qq rsync
|
||||
|
||||
# .env and data/ are server-only state and must survive every
|
||||
# deploy — without the excludes, --delete removes them because
|
||||
# they aren't in the repo.
|
||||
- name: Sync source to deploy path
|
||||
run: |
|
||||
rsync -a --delete \
|
||||
--exclude='.git' \
|
||||
--exclude='.env' \
|
||||
--exclude='data' \
|
||||
./ /mnt/user/appdata/massprice/
|
||||
|
||||
- name: Rebuild and restart
|
||||
working-directory: /mnt/user/appdata/massprice
|
||||
run: docker compose up -d --build
|
||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
.env
|
||||
data/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.DS_Store
|
||||
.venv/
|
||||
117
CLAUDE.md
Normal file
117
CLAUDE.md
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
# MassPrice Scheduler
|
||||
|
||||
A Flask app that drives a real headless-Chromium session against
|
||||
`massprice.tcgplayer.com` (TCGplayer's bulk pricing-rule tool) to click
|
||||
"Run Rule" for each of the user's pricing rules on a schedule set through
|
||||
this app's own web UI. Runs as one Docker container on the user's Unraid
|
||||
home server, deployed the same way as the other projects on that box
|
||||
(`arr-summary`, `card-grader`, `lunch-notifier`, `n64-tracker`).
|
||||
|
||||
## Why this exists
|
||||
|
||||
MassPrice has its own native per-rule scheduler (day-of-week + one
|
||||
time-of-day), but it's capped at 15 active schedules account-wide and
|
||||
only allows **one run per day per rule** — no multiple runs/day. Native
|
||||
runs also showed real clock drift during discovery (6–22 minutes late
|
||||
against their scheduled time). This tool **replaces** native scheduling
|
||||
entirely: each rule's native schedule should be set to Inactive once this
|
||||
tool is confirmed working, so there's exactly one thing triggering runs.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **`app.py`** — Flask app, single shared-password session auth
|
||||
(`LOGIN_PASSWORD` env var, `hmac.compare_digest`) — this protects the
|
||||
*admin UI itself*, unrelated to the MassPrice/TCGplayer login below.
|
||||
Routes: `/` (Dashboard — rule cards with schedule editor), `/activity`
|
||||
(run history), and a small JSON API
|
||||
(`/api/rules/refresh`, `/api/rules/<id>/schedule`,
|
||||
`/api/rules/<id>/run`) that the Dashboard's JS calls without full page
|
||||
reloads.
|
||||
- **`scheduler.py`** — an in-process APScheduler `BackgroundScheduler`
|
||||
holding one `CronTrigger` job per `(rule, schedule entry)` pair,
|
||||
rebuilt from `schedules.json` on startup and whenever the webui saves
|
||||
a change. Triggered jobs (and manual "Run Now" clicks) are pushed onto
|
||||
one `queue.Queue`, drained by a single dedicated worker thread — this
|
||||
guarantees only one Playwright browser session is ever active at a
|
||||
time, matching how a human would actually operate the site and
|
||||
avoiding concurrent-session weirdness.
|
||||
- **Must run in exactly one process.** `gunicorn.conf.py` pins
|
||||
`workers = 1` for this reason — a second worker would instantiate a
|
||||
second scheduler and duplicate every run. Fine for a low-traffic
|
||||
single-user tool.
|
||||
- **`automation.py`** — all Playwright logic. `list_rules()` scrapes
|
||||
`https://massprice.tcgplayer.com/Rule/List` for the live rule
|
||||
id/name pairs (cached to `rules_cache.json`, refreshed via the
|
||||
Dashboard's "Refresh Rules" button or on first boot). `run_rule(id)`
|
||||
finds that rule's `li.rule.widget-rule` row, clicks its
|
||||
`.dropdown-ellipsis` three-dot toggle, then clicks the row's
|
||||
`a.run-rule[data-rule-id=...]` item — mirroring exactly what a human
|
||||
does in the UI. **No public API was found or used** — the "Run Rule"
|
||||
click handler is JS/AJAX-bound (no plain href) and its request is
|
||||
almost certainly anti-forgery-token protected, so automation drives
|
||||
the real page rather than replaying a raw HTTP POST.
|
||||
- **Session persistence** — a Playwright `storage_state` (cookies +
|
||||
localStorage) is saved to `/data/auth_state.json` and reused across
|
||||
runs: the tool logs in **once**, then slides the session forward after
|
||||
every successful run, rather than hitting the login form on every
|
||||
scheduled click. A fresh login (via `MASSPRICE_USERNAME` /
|
||||
`MASSPRICE_PASSWORD`) only happens when the saved state is missing or
|
||||
has expired (detected by landing back on a login page).
|
||||
- **`storage.py`** — tiny JSON read/write helpers (temp-file +
|
||||
`os.replace`, so a crash mid-write can't corrupt state) for
|
||||
everything under `/data`: `schedules.json`, `auth_state.json`,
|
||||
`run_history.json` (bounded to the last 200 entries), `rules_cache.json`.
|
||||
- **`templates/` / `static/`** — a small, plain dark dashboard. This is
|
||||
a deliberately different, simpler visual identity than Arr Summary's
|
||||
teletext system — that design language is specific to that app, not a
|
||||
house style to reuse here.
|
||||
|
||||
## Known limitation — automated login
|
||||
|
||||
`MASSPRICE_USERNAME`/`MASSPRICE_PASSWORD` drive a fully automated login
|
||||
(the user's explicit choice over a manual hand-off flow). **If
|
||||
TCGplayer's login ever presents a CAPTCHA or a 2FA challenge, automated
|
||||
login will fail outright** — there is deliberately no fallback UI for
|
||||
this yet; a failed login just shows up as a `failure` entry in Activity
|
||||
with the error message. If this starts happening routinely, it needs a
|
||||
design change (e.g. a Chrome-remote-debugging-based manual re-auth flow),
|
||||
not a bigger retry loop.
|
||||
|
||||
**The real logged-out MassPrice login form was never inspected** during
|
||||
development — the account was already signed in when the site was
|
||||
explored, and logging it out to look at the form would have disrupted
|
||||
the user's real, live MassPrice session. `automation._login()` uses
|
||||
resilient role/label/placeholder-based Playwright locators rather than
|
||||
brittle CSS, but **expect to need a one-time adjustment against the real
|
||||
form on first deploy** if the generic locators don't match.
|
||||
|
||||
## Local development
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
playwright install chromium # only needed if testing automation.py directly
|
||||
python3 app.py
|
||||
```
|
||||
|
||||
Runs on port 5000 (`PORT` env to change). Without `MASSPRICE_USERNAME`/
|
||||
`MASSPRICE_PASSWORD` set and without Chromium installed, the Dashboard
|
||||
still boots — `_bootstrap()` in `app.py` best-effort-catches the initial
|
||||
rule sync, so local UI/schedule-editing work doesn't require Docker or
|
||||
real MassPrice credentials at all. `DATA_DIR` env var overrides where
|
||||
JSON state is read/written (defaults to `/data`, which won't exist
|
||||
locally — point it at a scratch directory).
|
||||
|
||||
**Every commit should be matched with a version bump** —
|
||||
`APP_VERSION`/`APP_VERSION_DATE` near the top of `app.py` — the same
|
||||
"does the footer prove this deploy landed" signal used on Arr Summary.
|
||||
|
||||
## Deployment
|
||||
|
||||
See the `deploy-massprice` skill for the Forgejo push-to-deploy
|
||||
pipeline and manual container restart steps.
|
||||
|
||||
**Never put real credentials in the repo.** `.env` and `data/` are
|
||||
gitignored and excluded from the deploy rsync precisely so
|
||||
`MASSPRICE_USERNAME`/`MASSPRICE_PASSWORD`/`LOGIN_PASSWORD` and the saved
|
||||
MassPrice session (`data/auth_state.json`) have somewhere to live
|
||||
outside git.
|
||||
15
Dockerfile
Normal file
15
Dockerfile
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
FROM mcr.microsoft.com/playwright/python:v1.47.0-jammy
|
||||
|
||||
LABEL net.unraid.docker.webui="http://[IP]:[PORT:5000]/"
|
||||
LABEL maintainer="MassPrice Scheduler"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 5000
|
||||
|
||||
CMD ["gunicorn", "--config=gunicorn.conf.py", "app:app"]
|
||||
157
app.py
Normal file
157
app.py
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import hmac
|
||||
import os
|
||||
from functools import wraps
|
||||
|
||||
from flask import Flask, jsonify, redirect, render_template, request, session, url_for
|
||||
|
||||
import automation
|
||||
import scheduler
|
||||
import storage
|
||||
|
||||
APP_VERSION = "0.1.0"
|
||||
APP_VERSION_DATE = "2026-08-31"
|
||||
|
||||
app = Flask(__name__)
|
||||
app.secret_key = os.environ.get("SECRET_KEY", "dev-secret-change-me")
|
||||
|
||||
LOGIN_PASSWORD = os.environ.get("LOGIN_PASSWORD", "changeme")
|
||||
|
||||
DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
|
||||
DAY_LABELS = {"mon": "Mon", "tue": "Tue", "wed": "Wed", "thu": "Thu", "fri": "Fri", "sat": "Sat", "sun": "Sun"}
|
||||
|
||||
|
||||
def login_required(view):
|
||||
@wraps(view)
|
||||
def wrapped(*args, **kwargs):
|
||||
if not session.get("authenticated"):
|
||||
return redirect(url_for("login", next=request.path))
|
||||
return view(*args, **kwargs)
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
@app.context_processor
|
||||
def inject_version():
|
||||
return {"app_version": APP_VERSION, "app_version_date": APP_VERSION_DATE}
|
||||
|
||||
|
||||
@app.route("/login", methods=["GET", "POST"])
|
||||
def login():
|
||||
error = None
|
||||
if request.method == "POST":
|
||||
password = request.form.get("password", "")
|
||||
if hmac.compare_digest(password, LOGIN_PASSWORD):
|
||||
session["authenticated"] = True
|
||||
return redirect(request.args.get("next") or url_for("dashboard"))
|
||||
error = "Incorrect password"
|
||||
return render_template("login.html", error=error)
|
||||
|
||||
|
||||
@app.route("/logout")
|
||||
def logout():
|
||||
session.clear()
|
||||
return redirect(url_for("login"))
|
||||
|
||||
|
||||
def _merged_rules():
|
||||
"""Combine the cached rule list with saved schedules and last-run status."""
|
||||
cache = storage.load("rules_cache.json", {"updated_at": None, "rules": []})
|
||||
schedules = storage.load("schedules.json", {})
|
||||
history = storage.load("run_history.json", [])
|
||||
|
||||
last_run_by_rule = {}
|
||||
for entry in history:
|
||||
rid = entry.get("rule_id")
|
||||
if rid and rid not in last_run_by_rule:
|
||||
last_run_by_rule[rid] = entry
|
||||
|
||||
rules = []
|
||||
for r in cache.get("rules", []):
|
||||
rid = r["id"]
|
||||
sched = schedules.get(rid, {})
|
||||
rules.append(
|
||||
{
|
||||
"id": rid,
|
||||
"name": r["name"],
|
||||
"enabled": sched.get("enabled", True),
|
||||
"schedules": sched.get("schedules", []),
|
||||
"last_run": last_run_by_rule.get(rid),
|
||||
}
|
||||
)
|
||||
return rules, cache.get("updated_at")
|
||||
|
||||
|
||||
@app.route("/")
|
||||
@login_required
|
||||
def dashboard():
|
||||
rules, rules_updated_at = _merged_rules()
|
||||
return render_template(
|
||||
"dashboard.html",
|
||||
rules=rules,
|
||||
rules_updated_at=rules_updated_at,
|
||||
days=DAYS,
|
||||
day_labels=DAY_LABELS,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/activity")
|
||||
@login_required
|
||||
def activity():
|
||||
history = storage.load("run_history.json", [])
|
||||
return render_template("activity.html", history=history)
|
||||
|
||||
|
||||
@app.route("/api/rules/refresh", methods=["POST"])
|
||||
@login_required
|
||||
def api_refresh_rules():
|
||||
try:
|
||||
rules = automation.list_rules()
|
||||
except Exception as exc:
|
||||
return jsonify({"error": str(exc)}), 502
|
||||
return jsonify({"rules": rules})
|
||||
|
||||
|
||||
@app.route("/api/rules/<rule_id>/schedule", methods=["POST"])
|
||||
@login_required
|
||||
def api_save_schedule(rule_id):
|
||||
body = request.get_json(force=True, silent=True) or {}
|
||||
schedules = storage.load("schedules.json", {})
|
||||
cache = storage.load("rules_cache.json", {"rules": []})
|
||||
name = next((r["name"] for r in cache.get("rules", []) if r["id"] == rule_id), None)
|
||||
|
||||
schedules[rule_id] = {
|
||||
"name": name,
|
||||
"enabled": bool(body.get("enabled", True)),
|
||||
"schedules": body.get("schedules", []),
|
||||
}
|
||||
storage.save("schedules.json", schedules)
|
||||
scheduler.rebuild_jobs()
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@app.route("/api/rules/<rule_id>/run", methods=["POST"])
|
||||
@login_required
|
||||
def api_run_rule(rule_id):
|
||||
cache = storage.load("rules_cache.json", {"rules": []})
|
||||
name = next((r["name"] for r in cache.get("rules", []) if r["id"] == rule_id), None)
|
||||
scheduler.enqueue_run(rule_id, name)
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
def _bootstrap():
|
||||
scheduler.start(tz=os.environ.get("TZ"))
|
||||
# Best-effort initial rule sync so the dashboard isn't blank before
|
||||
# the user ever clicks "Refresh Rules". Silently skipped if
|
||||
# Playwright/credentials aren't available yet (e.g. local dev).
|
||||
if storage.load("rules_cache.json", None) is None:
|
||||
try:
|
||||
automation.list_rules()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
_bootstrap()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=int(os.environ.get("PORT", 5000)), debug=False)
|
||||
202
automation.py
Normal file
202
automation.py
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
"""Playwright automation against massprice.tcgplayer.com.
|
||||
|
||||
Drives the real page rather than replaying a raw HTTP request: "Run Rule"
|
||||
is bound to a JS/AJAX click handler with no plain href, and the request
|
||||
it fires is almost certainly anti-forgery-token protected.
|
||||
|
||||
Session handling: a Playwright `storage_state` (cookies + localStorage)
|
||||
is persisted to AUTH_STATE_PATH and reused across runs, so the tool logs
|
||||
in once and slides the session forward on every successful run rather
|
||||
than hitting the login form every time. A fresh login (via
|
||||
MASSPRICE_USERNAME/MASSPRICE_PASSWORD) only happens when the saved state
|
||||
is missing or has expired.
|
||||
|
||||
NOTE — the real logged-out login form was not inspected during
|
||||
development (the account was already signed in during discovery, and
|
||||
forcing a logout to inspect it would have disrupted the user's real
|
||||
MassPrice session). The locators in _login() are written to be resilient
|
||||
(role/label/placeholder based, not brittle CSS) but may need a one-time
|
||||
adjustment against the live form on first deploy. See CLAUDE.md.
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
import storage
|
||||
|
||||
logger = logging.getLogger("massprice.automation")
|
||||
|
||||
BASE_URL = "https://massprice.tcgplayer.com"
|
||||
AUTH_STATE_PATH = os.path.join(storage.DATA_DIR, "auth_state.json")
|
||||
|
||||
MASSPRICE_USERNAME = os.environ.get("MASSPRICE_USERNAME")
|
||||
MASSPRICE_PASSWORD = os.environ.get("MASSPRICE_PASSWORD")
|
||||
|
||||
# Standard, necessary mitigations for headless Chromium inside Docker.
|
||||
LAUNCH_ARGS = ["--no-sandbox", "--disable-dev-shm-usage"]
|
||||
|
||||
|
||||
class AutomationError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _new_context(browser):
|
||||
if os.path.exists(AUTH_STATE_PATH):
|
||||
return browser.new_context(storage_state=AUTH_STATE_PATH)
|
||||
return browser.new_context()
|
||||
|
||||
|
||||
def _save_state(context):
|
||||
os.makedirs(storage.DATA_DIR, exist_ok=True)
|
||||
context.storage_state(path=AUTH_STATE_PATH)
|
||||
|
||||
|
||||
def _is_logged_out(page):
|
||||
if "login" in page.url.lower() or "signin" in page.url.lower():
|
||||
return True
|
||||
return page.locator('input[type="password"]').count() > 0
|
||||
|
||||
|
||||
def _login(page):
|
||||
if not MASSPRICE_USERNAME or not MASSPRICE_PASSWORD:
|
||||
raise AutomationError(
|
||||
"MASSPRICE_USERNAME/MASSPRICE_PASSWORD not set — cannot log in"
|
||||
)
|
||||
page.goto(f"{BASE_URL}/Dashboard")
|
||||
if not _is_logged_out(page):
|
||||
return
|
||||
|
||||
email_field = (
|
||||
page.get_by_label("Email", exact=False)
|
||||
.or_(page.get_by_placeholder("Email", exact=False))
|
||||
.or_(page.locator('input[type="email"]'))
|
||||
.or_(page.locator('input[name="username" i]'))
|
||||
)
|
||||
password_field = page.locator('input[type="password"]')
|
||||
|
||||
email_field.first.fill(MASSPRICE_USERNAME)
|
||||
password_field.first.fill(MASSPRICE_PASSWORD)
|
||||
|
||||
submit = (
|
||||
page.get_by_role("button", name="Log In", exact=False)
|
||||
.or_(page.get_by_role("button", name="Sign In", exact=False))
|
||||
.or_(page.locator('button[type="submit"]'))
|
||||
)
|
||||
submit.first.click()
|
||||
page.wait_for_load_state("networkidle")
|
||||
|
||||
if _is_logged_out(page):
|
||||
raise AutomationError(
|
||||
"Login failed — still on a login page after submit "
|
||||
"(bad credentials, or a CAPTCHA/2FA challenge that automated "
|
||||
"login cannot get past — see CLAUDE.md)"
|
||||
)
|
||||
|
||||
|
||||
def _ensure_authenticated(page):
|
||||
page.goto(f"{BASE_URL}/Rule/List")
|
||||
if _is_logged_out(page):
|
||||
_login(page)
|
||||
page.goto(f"{BASE_URL}/Rule/List")
|
||||
if _is_logged_out(page):
|
||||
raise AutomationError("Still logged out after a login attempt")
|
||||
|
||||
|
||||
def list_rules():
|
||||
"""Scrape /Rule/List for current rule id/name pairs.
|
||||
|
||||
Returns a list of {"id": ..., "name": ...} dicts and caches it to
|
||||
rules_cache.json.
|
||||
"""
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True, args=LAUNCH_ARGS)
|
||||
try:
|
||||
context = _new_context(browser)
|
||||
page = context.new_page()
|
||||
_ensure_authenticated(page)
|
||||
_save_state(context)
|
||||
|
||||
rows = page.locator("li.rule.widget-rule")
|
||||
rules = []
|
||||
for i in range(rows.count()):
|
||||
row = rows.nth(i)
|
||||
run_link = row.locator("a.run-rule").first
|
||||
rule_id = run_link.get_attribute("data-rule-id")
|
||||
if not rule_id:
|
||||
continue
|
||||
name_text = None
|
||||
try:
|
||||
# The rule name renders as the first line of text in
|
||||
# the row, ahead of the "My Selling Prices" /
|
||||
# "Schedule Active…" detail lines.
|
||||
full_text = row.inner_text().strip()
|
||||
name_text = full_text.splitlines()[0].strip()
|
||||
except Exception:
|
||||
pass
|
||||
rules.append({"id": rule_id, "name": name_text or f"Rule {rule_id}"})
|
||||
|
||||
storage.save(
|
||||
"rules_cache.json",
|
||||
{"updated_at": datetime.now(timezone.utc).isoformat(), "rules": rules},
|
||||
)
|
||||
return rules
|
||||
finally:
|
||||
browser.close()
|
||||
|
||||
|
||||
def run_rule(rule_id, rule_name=None):
|
||||
"""Click "Run Rule" for the given rule id. Logs the outcome to
|
||||
run_history.json and returns (status, message)."""
|
||||
status = "failure"
|
||||
message = ""
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True, args=LAUNCH_ARGS)
|
||||
try:
|
||||
context = _new_context(browser)
|
||||
page = context.new_page()
|
||||
_ensure_authenticated(page)
|
||||
_save_state(context)
|
||||
|
||||
run_selector = f'a.run-rule[data-rule-id="{rule_id}"]'
|
||||
row = page.locator("li.rule.widget-rule").filter(
|
||||
has=page.locator(run_selector)
|
||||
)
|
||||
if row.count() == 0:
|
||||
raise AutomationError(
|
||||
f"Rule id {rule_id} not found on /Rule/List "
|
||||
"(renamed or deleted on the MassPrice side?)"
|
||||
)
|
||||
|
||||
row.first.locator(".dropdown-ellipsis").click()
|
||||
run_link = row.first.locator(run_selector)
|
||||
run_link.wait_for(state="visible", timeout=5000)
|
||||
run_link.click()
|
||||
page.wait_for_timeout(1500) # let the click's AJAX call fire
|
||||
|
||||
status = "success"
|
||||
message = "Run Rule clicked"
|
||||
except Exception as exc:
|
||||
message = str(exc)
|
||||
logger.exception("run_rule failed for rule %s", rule_id)
|
||||
finally:
|
||||
browser.close()
|
||||
|
||||
_log_history(rule_id, rule_name, status, message)
|
||||
return status, message
|
||||
|
||||
|
||||
def _log_history(rule_id, rule_name, status, message):
|
||||
history = storage.load("run_history.json", [])
|
||||
history.insert(
|
||||
0,
|
||||
{
|
||||
"rule_id": rule_id,
|
||||
"rule_name": rule_name,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"status": status,
|
||||
"message": message,
|
||||
},
|
||||
)
|
||||
storage.save("run_history.json", history[:200])
|
||||
21
docker-compose.yml
Normal file
21
docker-compose.yml
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
services:
|
||||
massprice:
|
||||
build: .
|
||||
container_name: massprice
|
||||
# Headless Chromium's default /dev/shm (64MB) is too small in Docker
|
||||
# and causes renderer crashes — this is the standard mitigation.
|
||||
shm_size: "1gb"
|
||||
ports:
|
||||
- "${HOST_PORT:-5056}:5000"
|
||||
volumes:
|
||||
# Schedules, run history, the rule-name cache, and the saved
|
||||
# MassPrice login session must outlive `up -d --build`, which
|
||||
# recreates the container and discards its filesystem.
|
||||
- ./data:/data
|
||||
environment:
|
||||
- LOGIN_PASSWORD=${LOGIN_PASSWORD}
|
||||
- SECRET_KEY=${SECRET_KEY}
|
||||
- MASSPRICE_USERNAME=${MASSPRICE_USERNAME}
|
||||
- MASSPRICE_PASSWORD=${MASSPRICE_PASSWORD}
|
||||
- TZ=${TZ:-America/New_York}
|
||||
restart: unless-stopped
|
||||
13
gunicorn.conf.py
Normal file
13
gunicorn.conf.py
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# Gunicorn configuration
|
||||
import os
|
||||
|
||||
# Exactly one worker: APScheduler and the Playwright run-queue live in
|
||||
# process memory, not a shared store — a second worker would duplicate
|
||||
# every scheduled job and run each rule twice. Fine for a low-traffic
|
||||
# single-user admin tool.
|
||||
workers = 1
|
||||
threads = 4
|
||||
worker_class = "gthread"
|
||||
bind = "0.0.0.0:" + os.environ.get("PORT", "5000")
|
||||
timeout = 120 # a rule run launches Chromium, logs in, and clicks through
|
||||
keepalive = 5
|
||||
4
requirements.txt
Normal file
4
requirements.txt
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
flask==3.1.0
|
||||
gunicorn==23.0.0
|
||||
playwright==1.47.0
|
||||
apscheduler==3.10.4
|
||||
93
scheduler.py
Normal file
93
scheduler.py
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
"""In-process cron scheduling for rule runs.
|
||||
|
||||
A single background worker thread owns every Playwright run — both
|
||||
APScheduler-triggered runs and manual "Run Now" clicks are pushed onto
|
||||
one queue.Queue so exactly one browser session is ever active at a time.
|
||||
This must run in exactly one process; see gunicorn.conf.py's workers=1.
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
import queue
|
||||
import threading
|
||||
|
||||
import pytz
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
|
||||
import automation
|
||||
import storage
|
||||
|
||||
logger = logging.getLogger("massprice.scheduler")
|
||||
|
||||
_work_queue: "queue.Queue" = queue.Queue()
|
||||
# Timezone must be set at construction time, not via a later .configure()
|
||||
# call — APScheduler resolves a timezone immediately in __init__ via
|
||||
# tzlocal's system lookup, which fails inside this container image for
|
||||
# an arbitrary $TZ (no zoneinfo/tzdata wiring), so the explicit TZ env
|
||||
# var has to win before that lookup ever runs.
|
||||
_scheduler = BackgroundScheduler(timezone=pytz.timezone(os.environ.get("TZ", "UTC")))
|
||||
_started = False
|
||||
|
||||
VALID_DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
|
||||
|
||||
|
||||
def _worker_loop():
|
||||
while True:
|
||||
rule_id, rule_name = _work_queue.get()
|
||||
try:
|
||||
automation.run_rule(rule_id, rule_name)
|
||||
except Exception:
|
||||
logger.exception("Unhandled error running rule %s", rule_id)
|
||||
finally:
|
||||
_work_queue.task_done()
|
||||
|
||||
|
||||
def enqueue_run(rule_id, rule_name=None):
|
||||
_work_queue.put((rule_id, rule_name))
|
||||
|
||||
|
||||
def _job_id(rule_id, index):
|
||||
return f"rule-{rule_id}-{index}"
|
||||
|
||||
|
||||
def rebuild_jobs():
|
||||
"""Clear and re-add every APScheduler job from schedules.json."""
|
||||
for job in _scheduler.get_jobs():
|
||||
job.remove()
|
||||
|
||||
schedules = storage.load("schedules.json", {})
|
||||
for rule_id, rule in schedules.items():
|
||||
if not rule.get("enabled", True):
|
||||
continue
|
||||
for idx, entry in enumerate(rule.get("schedules", [])):
|
||||
days = [d for d in entry.get("days", []) if d in VALID_DAYS]
|
||||
time_str = entry.get("time")
|
||||
if not days or not time_str:
|
||||
continue
|
||||
hour, minute = time_str.split(":")
|
||||
trigger = CronTrigger(
|
||||
day_of_week=",".join(days),
|
||||
hour=int(hour),
|
||||
minute=int(minute),
|
||||
)
|
||||
_scheduler.add_job(
|
||||
enqueue_run,
|
||||
trigger=trigger,
|
||||
args=[rule_id, rule.get("name")],
|
||||
id=_job_id(rule_id, idx),
|
||||
replace_existing=True,
|
||||
misfire_grace_time=300,
|
||||
)
|
||||
logger.info("Rebuilt %d scheduled job(s)", len(_scheduler.get_jobs()))
|
||||
|
||||
|
||||
def start(tz=None):
|
||||
# tz is accepted for backwards-compat call sites but is a no-op now
|
||||
# that timezone is fixed at construction time above.
|
||||
global _started
|
||||
if _started:
|
||||
return
|
||||
rebuild_jobs()
|
||||
_scheduler.start()
|
||||
threading.Thread(target=_worker_loop, daemon=True).start()
|
||||
_started = True
|
||||
159
static/css/style.css
Normal file
159
static/css/style.css
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
:root {
|
||||
--bg: #12161c;
|
||||
--panel: #1b212b;
|
||||
--panel-alt: #232a35;
|
||||
--border: #2b3340;
|
||||
--text: #e6e9ef;
|
||||
--text-dim: #9aa4b2;
|
||||
--accent: #3b82f6;
|
||||
--accent-dim: #1e3a5f;
|
||||
--success: #34d399;
|
||||
--failure: #f87171;
|
||||
--radius: 8px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
h1 { font-size: 1.4rem; margin: 0; }
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.brand { font-weight: 700; font-size: 1.1rem; }
|
||||
.brand span { color: var(--text-dim); font-weight: 400; }
|
||||
|
||||
nav a {
|
||||
color: var(--text-dim);
|
||||
text-decoration: none;
|
||||
margin-left: 20px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
nav a:hover, nav a.active { color: var(--text); }
|
||||
|
||||
main { max-width: 1100px; margin: 0 auto; padding: 24px; }
|
||||
|
||||
footer {
|
||||
text-align: center;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.75rem;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
.page-header-meta { display: flex; align-items: center; gap: 12px; }
|
||||
.muted { color: var(--text-dim); }
|
||||
.small { font-size: 0.78rem; }
|
||||
|
||||
.btn {
|
||||
background: var(--panel-alt);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 8px 14px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.btn:hover { border-color: var(--accent); }
|
||||
.btn:disabled { opacity: 0.6; cursor: default; }
|
||||
.btn-primary { background: var(--accent); border-color: var(--accent); color: white; }
|
||||
.btn-small { padding: 6px 10px; font-size: 0.8rem; }
|
||||
|
||||
.rule-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.rule-card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.rule-card-header { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
|
||||
.rule-card-header h2 { font-size: 1.05rem; margin: 0; }
|
||||
|
||||
.switch { position: relative; display: inline-block; width: 40px; height: 22px; flex-shrink: 0; }
|
||||
.switch input { opacity: 0; width: 0; height: 0; position: absolute; }
|
||||
.slider {
|
||||
position: absolute; inset: 0; background: var(--panel-alt);
|
||||
border: 1px solid var(--border); border-radius: 22px; cursor: pointer; transition: .15s;
|
||||
}
|
||||
.slider::before {
|
||||
content: ""; position: absolute; height: 16px; width: 16px; left: 2px; top: 2px;
|
||||
background: var(--text-dim); border-radius: 50%; transition: .15s;
|
||||
}
|
||||
.switch input:checked + .slider { background: var(--accent-dim); border-color: var(--accent); }
|
||||
.switch input:checked + .slider::before { transform: translateX(18px); background: var(--accent); }
|
||||
|
||||
.rule-last-run { font-size: 0.82rem; }
|
||||
.status-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; }
|
||||
.status-dot.status-success { background: var(--success); }
|
||||
.status-dot.status-failure { background: var(--failure); }
|
||||
.status-row-failure td { color: #f2a3a3; }
|
||||
|
||||
.schedule-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||
.schedule-chip {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
background: var(--panel-alt); border: 1px solid var(--border); border-radius: 6px;
|
||||
padding: 6px 10px; font-size: 0.82rem;
|
||||
}
|
||||
.chip-time { font-variant-numeric: tabular-nums; font-weight: 600; }
|
||||
.chip-days { color: var(--text-dim); flex: 1; text-transform: uppercase; letter-spacing: 0.03em; font-size: 0.72rem; }
|
||||
.chip-remove { background: none; border: none; color: var(--text-dim); cursor: pointer; font-size: 1.1rem; line-height: 1; }
|
||||
.chip-remove:hover { color: var(--failure); }
|
||||
|
||||
.schedule-add { display: flex; flex-direction: column; gap: 8px; border-top: 1px dashed var(--border); padding-top: 12px; }
|
||||
.schedule-add-row { display: flex; gap: 8px; }
|
||||
.new-time {
|
||||
background: var(--panel-alt); border: 1px solid var(--border); color: var(--text);
|
||||
border-radius: 6px; padding: 6px 8px; font-size: 0.85rem; flex: 1;
|
||||
}
|
||||
.day-toggles { display: flex; gap: 4px; flex-wrap: wrap; }
|
||||
.day-toggle {
|
||||
background: var(--panel-alt); border: 1px solid var(--border); color: var(--text-dim);
|
||||
border-radius: 6px; padding: 4px 8px; font-size: 0.72rem; cursor: pointer; text-transform: uppercase;
|
||||
}
|
||||
.day-toggle.active { background: var(--accent-dim); border-color: var(--accent); color: var(--text); }
|
||||
|
||||
.rule-actions { display: flex; justify-content: flex-end; }
|
||||
|
||||
.login-card {
|
||||
max-width: 320px; margin: 80px auto; background: var(--panel);
|
||||
border: 1px solid var(--border); border-radius: var(--radius); padding: 28px;
|
||||
display: flex; flex-direction: column; gap: 12px;
|
||||
}
|
||||
.login-card input {
|
||||
background: var(--panel-alt); border: 1px solid var(--border); color: var(--text);
|
||||
border-radius: 6px; padding: 10px; font-size: 1rem;
|
||||
}
|
||||
.error { color: var(--failure); font-size: 0.85rem; }
|
||||
|
||||
.empty-state { color: var(--text-dim); }
|
||||
|
||||
.activity-table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
|
||||
.activity-table th, .activity-table td { text-align: left; padding: 8px 12px; border-bottom: 1px solid var(--border); }
|
||||
.activity-table th { color: var(--text-dim); font-weight: 500; font-size: 0.75rem; text-transform: uppercase; }
|
||||
107
static/js/app.js
Normal file
107
static/js/app.js
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const refreshBtn = document.getElementById("refresh-rules");
|
||||
if (refreshBtn) {
|
||||
refreshBtn.addEventListener("click", async () => {
|
||||
refreshBtn.disabled = true;
|
||||
const original = refreshBtn.textContent;
|
||||
refreshBtn.textContent = "Refreshing…";
|
||||
try {
|
||||
const res = await fetch("/api/rules/refresh", { method: "POST" });
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
alert("Refresh failed: " + (data.error || res.status));
|
||||
return;
|
||||
}
|
||||
window.location.reload();
|
||||
} finally {
|
||||
refreshBtn.disabled = false;
|
||||
refreshBtn.textContent = original;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll(".rule-card").forEach(initRuleCard);
|
||||
});
|
||||
|
||||
function initRuleCard(card) {
|
||||
const ruleId = card.dataset.ruleId;
|
||||
|
||||
card.querySelector(".rule-enabled").addEventListener("change", (e) => {
|
||||
saveSchedule(card, { enabledOverride: e.target.checked });
|
||||
});
|
||||
|
||||
card.querySelectorAll(".day-toggle").forEach((btn) => {
|
||||
btn.addEventListener("click", () => btn.classList.toggle("active"));
|
||||
});
|
||||
|
||||
card.querySelector(".add-schedule").addEventListener("click", () => {
|
||||
const time = card.querySelector(".new-time").value;
|
||||
const days = [...card.querySelectorAll(".day-toggle.active")].map((b) => b.dataset.day);
|
||||
if (!time || days.length === 0) {
|
||||
alert("Pick a time and at least one day.");
|
||||
return;
|
||||
}
|
||||
addChip(card, time, days);
|
||||
card.querySelectorAll(".day-toggle.active").forEach((b) => b.classList.remove("active"));
|
||||
saveSchedule(card);
|
||||
});
|
||||
|
||||
card.querySelectorAll(".chip-remove").forEach((btn) => bindRemove(btn));
|
||||
|
||||
card.querySelector(".run-now").addEventListener("click", async (e) => {
|
||||
const btn = e.currentTarget;
|
||||
const original = btn.textContent;
|
||||
btn.disabled = true;
|
||||
btn.textContent = "Queuing…";
|
||||
try {
|
||||
const res = await fetch(`/api/rules/${ruleId}/run`, { method: "POST" });
|
||||
btn.textContent = res.ok ? "Queued" : "Failed";
|
||||
} catch (err) {
|
||||
btn.textContent = "Failed";
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
btn.textContent = original;
|
||||
btn.disabled = false;
|
||||
}, 2000);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function bindRemove(btn) {
|
||||
btn.addEventListener("click", () => {
|
||||
const card = btn.closest(".rule-card");
|
||||
btn.closest(".schedule-chip").remove();
|
||||
saveSchedule(card);
|
||||
});
|
||||
}
|
||||
|
||||
function addChip(card, time, days) {
|
||||
const list = card.querySelector(".schedule-list");
|
||||
const li = document.createElement("li");
|
||||
li.className = "schedule-chip";
|
||||
li.innerHTML =
|
||||
`<span class="chip-time">${time}</span>` +
|
||||
`<span class="chip-days">${days.join(" ")}</span>` +
|
||||
`<button type="button" class="chip-remove" title="Remove">×</button>`;
|
||||
list.appendChild(li);
|
||||
bindRemove(li.querySelector(".chip-remove"));
|
||||
}
|
||||
|
||||
async function saveSchedule(card, opts = {}) {
|
||||
const ruleId = card.dataset.ruleId;
|
||||
const enabled =
|
||||
opts.enabledOverride !== undefined ? opts.enabledOverride : card.querySelector(".rule-enabled").checked;
|
||||
const schedules = [...card.querySelectorAll(".schedule-chip")].map((chip) => ({
|
||||
time: chip.querySelector(".chip-time").textContent.trim(),
|
||||
days: chip
|
||||
.querySelector(".chip-days")
|
||||
.textContent.trim()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean),
|
||||
}));
|
||||
await fetch(`/api/rules/${ruleId}/schedule`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enabled, schedules }),
|
||||
});
|
||||
}
|
||||
35
storage.py
Normal file
35
storage.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
"""Small JSON read/write helpers for the state files under DATA_DIR.
|
||||
|
||||
Every file is written via a temp-file + os.replace so a crash mid-write
|
||||
never leaves a half-written JSON file behind for the next read.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
|
||||
DATA_DIR = os.environ.get("DATA_DIR", "/data")
|
||||
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
def _path(name):
|
||||
return os.path.join(DATA_DIR, name)
|
||||
|
||||
|
||||
def load(name, default):
|
||||
path = _path(name)
|
||||
with _lock:
|
||||
if not os.path.exists(path):
|
||||
return default
|
||||
with open(path) as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def save(name, data):
|
||||
path = _path(name)
|
||||
with _lock:
|
||||
os.makedirs(DATA_DIR, exist_ok=True)
|
||||
tmp = path + ".tmp"
|
||||
with open(tmp, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
os.replace(tmp, path)
|
||||
26
templates/_shell.html
Normal file
26
templates/_shell.html
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}MassPrice Scheduler{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brand">MassPrice <span>Scheduler</span></div>
|
||||
{% if session.get('authenticated') %}
|
||||
<nav>
|
||||
<a href="{{ url_for('dashboard') }}" class="{{ 'active' if request.endpoint == 'dashboard' else '' }}">Dashboard</a>
|
||||
<a href="{{ url_for('activity') }}" class="{{ 'active' if request.endpoint == 'activity' else '' }}">Activity</a>
|
||||
<a href="{{ url_for('logout') }}">Log Out</a>
|
||||
</nav>
|
||||
{% endif %}
|
||||
</header>
|
||||
<main>
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
<footer>v{{ app_version }} · {{ app_version_date }}</footer>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
24
templates/activity.html
Normal file
24
templates/activity.html
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{% extends "_shell.html" %}
|
||||
{% block title %}Activity · MassPrice Scheduler{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Activity</h1>
|
||||
{% if not history %}
|
||||
<p class="empty-state">No runs recorded yet.</p>
|
||||
{% else %}
|
||||
<table class="activity-table">
|
||||
<thead>
|
||||
<tr><th>Time</th><th>Rule</th><th>Status</th><th>Message</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in history %}
|
||||
<tr class="status-row-{{ entry.status }}">
|
||||
<td>{{ entry.timestamp }}</td>
|
||||
<td>{{ entry.rule_name or entry.rule_id }}</td>
|
||||
<td><span class="status-dot status-{{ entry.status }}"></span>{{ entry.status }}</td>
|
||||
<td class="muted">{{ entry.message }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
68
templates/dashboard.html
Normal file
68
templates/dashboard.html
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
{% extends "_shell.html" %}
|
||||
{% block title %}Dashboard · MassPrice Scheduler{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>Rules</h1>
|
||||
<div class="page-header-meta">
|
||||
{% if rules_updated_at %}<span class="muted">Rules last synced {{ rules_updated_at }}</span>{% endif %}
|
||||
<button id="refresh-rules" class="btn btn-secondary">Refresh Rules</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if not rules %}
|
||||
<p class="empty-state">No rules loaded yet. Click "Refresh Rules" to pull the current rule list from MassPrice.</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="rule-grid">
|
||||
{% for rule in rules %}
|
||||
<div class="rule-card" data-rule-id="{{ rule.id }}">
|
||||
<div class="rule-card-header">
|
||||
<h2>{{ rule.name }}</h2>
|
||||
<label class="switch" title="Enabled">
|
||||
<input type="checkbox" class="rule-enabled" {{ 'checked' if rule.enabled else '' }}>
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="rule-last-run">
|
||||
{% if rule.last_run %}
|
||||
<span class="status-dot status-{{ rule.last_run.status }}"></span>
|
||||
Last run {{ rule.last_run.timestamp }} — {{ rule.last_run.status }}
|
||||
{% if rule.last_run.status == 'failure' %}<div class="muted small">{{ rule.last_run.message }}</div>{% endif %}
|
||||
{% else %}
|
||||
<span class="muted">No runs yet</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<ul class="schedule-list">
|
||||
{% for entry in rule.schedules %}
|
||||
<li class="schedule-chip">
|
||||
<span class="chip-time">{{ entry.time }}</span>
|
||||
<span class="chip-days">{{ entry.days | join(' ') }}</span>
|
||||
<button type="button" class="chip-remove" title="Remove">×</button>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
<div class="schedule-add">
|
||||
<div class="schedule-add-row">
|
||||
<input type="time" class="new-time" value="06:00">
|
||||
<button type="button" class="btn btn-small add-schedule">Add Time</button>
|
||||
</div>
|
||||
<div class="day-toggles">
|
||||
{% for d in days %}
|
||||
<button type="button" class="day-toggle" data-day="{{ d }}">{{ day_labels[d] }}</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rule-actions">
|
||||
<button type="button" class="btn btn-primary run-now">Run Now</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
|
||||
{% endblock %}
|
||||
13
templates/login.html
Normal file
13
templates/login.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{% extends "_shell.html" %}
|
||||
{% block title %}Log In · MassPrice Scheduler{% endblock %}
|
||||
{% block content %}
|
||||
<div class="login-card">
|
||||
<h1>MassPrice Scheduler</h1>
|
||||
{% if error %}<p class="error">{{ error }}</p>{% endif %}
|
||||
<form method="post">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" autofocus required>
|
||||
<button type="submit" class="btn btn-primary">Log In</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Loading…
Add table
Add a link
Reference in a new issue