Move all three skills into skills/; add root README; fix paths
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
312742cfef
commit
d95e5d452d
84 files changed
+97
-31
No files matched your search
@@ -0,0 +1,163 @@
|
||||
---
|
||||
name: rpa-dashboard-no-ai
|
||||
description: Builds a working dashboard page for an RPA bot that does not use AI. It always includes a sidebar and top bar with a dark toggle and status dot, KPI cards, a batch status donut, a run panel with steps and a live log, and a report table with search and Excel export. The page is wired to the bot through one data contract, or runs on sample data when there is no bot yet. Supports a desktop path (PyQt6 with an embedded page) and a web path. Use when the user asks for an "RPA dashboard", "automation dashboard", "bot dashboard", a dashboard "without AI", or a status/run/report page for a bot. For bots that use an LLM or OCR extraction, use rpa-dashboard-ai instead.
|
||||
---
|
||||
|
||||
# RPA dashboard (no AI)
|
||||
|
||||
Everything this skill needs is bundled in `templates/`. Never read the original
|
||||
source projects.
|
||||
|
||||
**Paths:** `templates/...` is in this skill's own folder (the one holding this SKILL.md),
|
||||
not in the user's project. Below, `<skill>` is that folder's absolute path: run the
|
||||
scripts as `python <skill>/templates/<script>.py`, from the user's project.
|
||||
|
||||
| File | What it is |
|
||||
|---|---|
|
||||
| `templates/dashboard.html` | The single-file page: theme tokens, shell, KPI, donut, run panel, report, sample data. Works in PyQt (QWebChannel) or on the web (polling). |
|
||||
| `templates/bridge.py` | Minimal PyQt6 host: the window, the bridge slots, and the saved dark-mode choice. |
|
||||
| `templates/bot_runner.py` | The bot wiring, without Qt. It runs the bot as a child process, tails its output, and turns lines into step and log events. |
|
||||
| `templates/contract.md` | The data contract: events the bot sends, actions the page sends. |
|
||||
| `templates/themes.md` | Theme presets (Tebra Sky, Superbill Ember) and the custom-colour steps. |
|
||||
| `templates/palette.py` | Derives a custom palette from one brand hex and checks contrast. |
|
||||
| `templates/sections.py`, `templates/sections.md` | Keeps the chosen optional sections in the page and deletes the rest; what each section needs from the host. |
|
||||
| `templates/frontend.md` | Existing frontend: detect it, report it, then keep or restyle. Shared with `rpa-dashboard-ai`. |
|
||||
| `templates/detect_frontend.py` | Finds an existing frontend and prints its location, stack and token blocks. |
|
||||
| `templates/reference/` | Raw Tebra extracts, for reference only. The optional sections are already in `dashboard.html`. |
|
||||
|
||||
Follow the steps in order. Ask one question at a time, and wait for the answer.
|
||||
If the user's request already answers a question, don't ask it again.
|
||||
|
||||
## 1. Scan the project
|
||||
|
||||
Before asking anything, look at the project:
|
||||
- **Frontend:** run `python <skill>/templates/detect_frontend.py <project root>`. It finds
|
||||
plain HTML, React/Vue/Angular/Svelte apps and PyQt web views, and prints each
|
||||
one's location, stack and colour tokens. `templates/frontend.md` says what it
|
||||
covers and what to do with its report.
|
||||
- **Bot:** the entry point (for example `main.py`, a `__main__` module or a run
|
||||
script), what it prints (step markers, status and error lines), and how it starts.
|
||||
|
||||
## 2. Report the frontend, then ask keep or restyle
|
||||
|
||||
Follow `templates/frontend.md` sections 2 to 5. In short:
|
||||
- **No frontend:** say "No existing frontend found." and go to step 3. Don't
|
||||
describe a frontend that isn't there.
|
||||
- **Frontend found:** say "Frontend found at `<path>`, stack `<stack>`, colours
|
||||
`<main tokens>`." Then ask: **keep its look**, or **restyle** it?
|
||||
- Keep: skip step 3. Build the page with the project's own tokens and fonts, and
|
||||
change no existing file.
|
||||
- Restyle: go to step 3. In the existing files, change only the token blocks,
|
||||
never layout or logic, and show the user the diff.
|
||||
|
||||
## 3. Ask for colours
|
||||
|
||||
Skip this if the user chose to keep the existing look. Offer:
|
||||
1. **Tebra Sky**: blue glass cards on a sky gradient.
|
||||
2. **Superbill Ember**: flat surfaces with a red-orange brand gradient.
|
||||
3. **Custom**: the user gives one brand hex colour.
|
||||
|
||||
## 4. Ask desktop or web
|
||||
|
||||
Ask only if there is no frontend. If there is one, use its stack.
|
||||
Don't ask if the project already uses PyQt (`PyQt6` or `PyQt5` in its requirements,
|
||||
`pyproject.toml` or imports): choose desktop and say so.
|
||||
- **Desktop:** a PyQt6 window with the page embedded. The page talks to Python
|
||||
through a bridge, so no web server is needed.
|
||||
- **Web:** the same page, served by a backend that it polls.
|
||||
|
||||
## 5. Ask which optional sections to build
|
||||
|
||||
Always built: the shell (sidebar, top bar, dark toggle, status dot), KPI cards,
|
||||
the status donut, the run panel and the report table.
|
||||
|
||||
Ask which extras to add, as one multi-select question (choose any, or none):
|
||||
- **Checkpoint tracker**: numbered boxes in the run panel that show each checkpoint as
|
||||
pending, in progress, done or stuck.
|
||||
- **Admin/roles screen**: an Admin tab, for admins only, to give people admin or
|
||||
operator access.
|
||||
- **Login page**: a sign-in screen in the dashboard's theme. It's UI only and hands
|
||||
off to the project's own auth.
|
||||
|
||||
Build only the chosen ones. In step 6, right after copying the page, run
|
||||
`python <skill>/templates/sections.py <copied dashboard.html> <chosen: checkpoints admin login>`.
|
||||
It deletes the unchosen sections completely (markup, script and style), so nothing
|
||||
is left behind. Never cut the blocks by hand. Then follow `templates/sections.md` for
|
||||
each kept section: what to fill, which events the bot sends, and which host actions
|
||||
to answer.
|
||||
|
||||
## 6. Build
|
||||
|
||||
1. Copy `templates/dashboard.html` into the project:
|
||||
- Desktop: put it next to the host, for example `dashboard/dashboard.html`.
|
||||
- Web: put it in the backend's static folder. With no backend yet, use
|
||||
`dashboard/dashboard.html`.
|
||||
- Existing frontend: put it where `templates/frontend.md` section 3 says.
|
||||
|
||||
Then run `python <skill>/templates/sections.py <copied dashboard.html> <chosen sections>` (step 5).
|
||||
2. Fill every `{{...}}` placeholder:
|
||||
|
||||
| Placeholder | Value |
|
||||
|---|---|
|
||||
| `APP_NAME`, `APP_SUBTITLE` | The project's name and a short tagline. |
|
||||
| `LOGO_SRC` | The logo's path, or empty if there is none. |
|
||||
| `STEPS_JSON` | The run steps as a JSON list, from the bot's real stages, for example `["Initializing","Login","Process items","Completed"]`. |
|
||||
| `REPORT_COLUMNS_JSON` | `[{"key":..., "label":...}]`. It must include a column with key `status`, which shows the bucket pill. |
|
||||
| `API_BASE` | Web only, for example `/api`. Leave it empty for desktop. |
|
||||
|
||||
3. Apply the theme by replacing the `body{...}` and `body.dark{...}` token blocks
|
||||
with the preset from `templates/themes.md`. For Ember, also add its font link.
|
||||
For a custom colour, run `python <skill>/templates/palette.py "<hex>"`, paste the
|
||||
tokens it prints, and show the user its contrast table. If the user chose keep,
|
||||
fill the blocks from the project's tokens instead (`templates/frontend.md`
|
||||
section 4).
|
||||
4. Desktop: also copy `templates/bridge.py` and `templates/bot_runner.py` next to
|
||||
the page. Fill `{{ORG}}` and `{{APP_NAME}}` in `bridge.py`, and `{{BOT_MODULE}}` in
|
||||
`bot_runner.py` (with no bot yet, use the module the bot will have, for example `bot`).
|
||||
5. Search the output for `{{`. None may be left.
|
||||
|
||||
## 7. Wire the data
|
||||
|
||||
The contract is in `templates/contract.md`: one envelope, `{event, data}`, in
|
||||
both directions.
|
||||
- **No bot yet:** keep `SAMPLE_DATA = true` and the `SAMPLE DATA` block. The page
|
||||
answers its own data actions, so it opens with sample batches and report rows,
|
||||
and Run plays a demo run. Tell the user which events their bot must send later.
|
||||
- **Bot exists:** set `SAMPLE_DATA = false` and delete the `SAMPLE DATA` block. Then:
|
||||
- Desktop: all the wiring is in `bot_runner.py`.
|
||||
1. Get the bot's real output: run it once (for example `python -m bot`), or read
|
||||
its `print` and logging calls. Never invent lines it doesn't print.
|
||||
2. Set `BOT_CMD` to start the bot as a child process, and `BOT_CWD` to the folder
|
||||
it runs from.
|
||||
3. Set `STEPS_JSON` in the page to the bot's real stages. Fill `STEP_MARKERS` with
|
||||
`(substring, step label, pct)`: one specific substring per stage (for example
|
||||
`[step 3]`, never a bare word like `login`), each label exactly one of the
|
||||
page's STEPS, and pct rising to 100 on the bot's last line. The first match wins.
|
||||
4. Fill `FRIENDLY_RULES` with `(substring, plain-English message, type)` for the
|
||||
lines an operator should see. Unmatched lines are hidden, except error lines
|
||||
(any line with a word from `ERROR_WORDS`), which are always shown as they are.
|
||||
Add the bot's own failure words to `ERROR_WORDS` if it uses others.
|
||||
5. Dry run without the window: `python dashboard/bot_runner.py`. It prints each
|
||||
event the page would get. Check that the steps arrive in order and end at
|
||||
100, and that the bot's error lines appear as `error` log events.
|
||||
- Web: the backend serves `GET {API_BASE}/dashboard`, `GET {API_BASE}/run/status`
|
||||
(returning `{running, events[]}`) and `POST {API_BASE}/<action-with-dashes>`.
|
||||
- Apply the contract's derived rules: batch state from Pending plus date, and
|
||||
report buckets from status.
|
||||
|
||||
## 8. Verify
|
||||
|
||||
Open the page. On desktop run the host (`python dashboard/bridge.py`), click Run,
|
||||
and check that the Run button stays disabled until the bot finishes, and that the
|
||||
dark-mode choice is still set after you close and reopen the window. Otherwise
|
||||
open the file or the served URL.
|
||||
With sample data, opening the file in a browser is enough. Check:
|
||||
- the light theme, the dark theme (use the toggle), and a window 820px wide or less;
|
||||
- KPI cards, donut and legend, the run panel's steps and log, and the Report tab's
|
||||
rows and pills all show data;
|
||||
- no `{{` placeholders are left and the console shows no script errors.
|
||||
|
||||
If Python Playwright is installed, take headless screenshots of each check and
|
||||
look at them. Otherwise, ask the user to open the page. Finish by telling the user
|
||||
where the files are, which theme and sections were built, and whether the page is
|
||||
on sample data or wired to the bot.
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Bot wiring for the desktop host: runs the bot and turns its output into page events.
|
||||
|
||||
No Qt here, so it can be tested and dry-run on its own. Fill BOT_CMD, STEP_MARKERS and
|
||||
FRIENDLY_RULES from lines the bot really prints.
|
||||
|
||||
Dry run, no window: python bot_runner.py (prints each event the page would get)
|
||||
"""
|
||||
import json, os, subprocess, sys
|
||||
from pathlib import Path
|
||||
|
||||
BOT_CMD = [sys.executable, "-m", "{{BOT_MODULE}}"] # the bot, started as a child process
|
||||
BOT_CWD = Path(__file__).resolve().parents[1] # folder BOT_CMD runs in (the project root)
|
||||
RAW_LOG = Path(__file__).with_name("bot_raw.log") # every raw bot line, kept for audit
|
||||
# (specific substring, step label from STEPS in dashboard.html, pct). First match wins.
|
||||
# e.g. ("[step 2]", "Login", 40). Never a bare word like "login": it matches unrelated lines.
|
||||
STEP_MARKERS = []
|
||||
# (specific substring, plain-English message, type: info|success|warn|error). First match wins.
|
||||
# e.g. ("[login] done", "Logged in.", "success"). Unmatched lines are hidden, except error lines.
|
||||
FRIENDLY_RULES = []
|
||||
# A line holding any of these is an error line. Unmatched error lines are shown raw, never hidden.
|
||||
ERROR_WORDS = ("error", "exception", "traceback", "fatal", "failed")
|
||||
|
||||
|
||||
def _first(rules, line):
|
||||
low = line.lower()
|
||||
return next((r for r in rules if r[0].lower() in low), None)
|
||||
|
||||
|
||||
def events_for(line):
|
||||
"""The page events, as (event, data) pairs, for one raw bot line."""
|
||||
out = []
|
||||
if marker := _first(STEP_MARKERS, line):
|
||||
out.append(("step", {"label": marker[1], "pct": marker[2]}))
|
||||
if rule := _first(FRIENDLY_RULES, line):
|
||||
out.append(("log", {"msg": rule[1], "type": rule[2]}))
|
||||
elif any(w in line.lower() for w in ERROR_WORDS): # never hide a real failure
|
||||
out.append(("log", {"msg": line, "type": "error"}))
|
||||
return out
|
||||
|
||||
|
||||
def run(send):
|
||||
"""Run the bot in its own process and send(event, data) for each line it prints, as it prints it."""
|
||||
send("status", {"state": "running", "msg": "Running…"})
|
||||
env = {**os.environ, "PYTHONUNBUFFERED": "1", "PYTHONIOENCODING": "utf-8"} # live lines; no cp1252 crash
|
||||
try:
|
||||
raw = open(RAW_LOG, "a", encoding="utf-8")
|
||||
p = subprocess.Popen(BOT_CMD, cwd=BOT_CWD, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
text=True, encoding="utf-8", errors="replace")
|
||||
except OSError as e:
|
||||
send("log", {"msg": f"Could not start the bot: {e}", "type": "error"})
|
||||
send("status", {"state": "error", "msg": "Could not start the bot"})
|
||||
return
|
||||
with p, raw:
|
||||
for line in p.stdout:
|
||||
raw.write(line)
|
||||
for event, data in events_for(line.rstrip()):
|
||||
send(event, data)
|
||||
ok = p.returncode == 0
|
||||
send("status", {"state": "done", "msg": "Completed"} if ok else
|
||||
{"state": "error", "msg": f"Failed (exit code {p.returncode})"})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run(lambda event, data: print(json.dumps({"event": event, "data": data}), flush=True))
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Minimal PyQt6 host for dashboard.html (desktop mode). Pattern from Tebra main_window.py.
|
||||
|
||||
The bot runs as a child process and this window shows its progress. The bot wiring
|
||||
(command, step markers, friendly rules) lives in bot_runner.py, next to this file.
|
||||
Run it with: python dashboard/bridge.py
|
||||
"""
|
||||
import json, sys, threading
|
||||
from pathlib import Path
|
||||
from PyQt6.QtCore import QObject, pyqtSlot, pyqtSignal, QUrl, QSettings
|
||||
from PyQt6.QtWidgets import QApplication, QMainWindow
|
||||
from PyQt6.QtWebEngineWidgets import QWebEngineView
|
||||
from PyQt6.QtWebChannel import QWebChannel
|
||||
|
||||
import bot_runner
|
||||
|
||||
HTML_PATH = Path(__file__).with_name("dashboard.html")
|
||||
|
||||
|
||||
def settings():
|
||||
return QSettings("{{ORG}}", "{{APP_NAME}}")
|
||||
|
||||
|
||||
class Bridge(QObject):
|
||||
_emit = pyqtSignal(str) # thread-safe hop to the GUI thread
|
||||
|
||||
def __init__(self, view):
|
||||
super().__init__()
|
||||
self.view, self._one_run = view, threading.Lock()
|
||||
self._emit.connect(lambda js: self.view.page().runJavaScript(f"window.__pyMsg({js})"))
|
||||
# Restore the saved dark-mode choice on every page load, sample-data mode included.
|
||||
view.loadFinished.connect(lambda _ok: self.send_to_js("apply_dark", {"on": settings().value("dark", False, bool)}))
|
||||
|
||||
def send_to_js(self, event, data):
|
||||
self._emit.emit(json.dumps(json.dumps({"event": event, "data": data})))
|
||||
|
||||
@pyqtSlot()
|
||||
def load_dashboard(self):
|
||||
self.send_to_js("dashboard_data", {"batches": []}) # TODO: read from DB on a worker thread
|
||||
|
||||
@pyqtSlot()
|
||||
def load_latest_report(self):
|
||||
self.send_to_js("report_data", {"rows": []}) # TODO: read the latest report rows
|
||||
|
||||
@pyqtSlot()
|
||||
def download_report(self):
|
||||
pass # TODO: export the report rows to .xlsx
|
||||
|
||||
@pyqtSlot(bool)
|
||||
def set_dark(self, on):
|
||||
settings().setValue("dark", on)
|
||||
|
||||
@pyqtSlot()
|
||||
def start_automation(self):
|
||||
if self._one_run.acquire(blocking=False): # a second click can't start a second bot
|
||||
threading.Thread(target=self._run, daemon=True).start()
|
||||
|
||||
def _run(self):
|
||||
try:
|
||||
bot_runner.run(self.send_to_js)
|
||||
finally:
|
||||
self._one_run.release()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = QApplication(sys.argv)
|
||||
win, view = QMainWindow(), QWebEngineView()
|
||||
bridge, channel = Bridge(view), QWebChannel()
|
||||
channel.registerObject("pyBridge", bridge)
|
||||
view.page().setWebChannel(channel)
|
||||
view.setUrl(QUrl.fromLocalFile(str(HTML_PATH)))
|
||||
win.setCentralWidget(view); win.resize(1280, 820); win.setWindowTitle("{{APP_NAME}}"); win.show()
|
||||
sys.exit(app.exec())
|
||||
@@ -0,0 +1,33 @@
|
||||
# Dashboard data contract (no-AI)
|
||||
|
||||
All updates use one envelope: `{"event": "<name>", "data": {...}}`.
|
||||
- **Desktop (PyQt):** Python calls `window.__pyMsg(json)` via `runJavaScript` (see `bridge.py`).
|
||||
`bot_runner.py` turns the bot's output lines into `status`, `step` and `log` events, and keeps every raw line in `bot_raw.log`.
|
||||
- **Web:** the page polls `GET {API_BASE}/dashboard` (returns `dashboard_data`) and
|
||||
`GET {API_BASE}/run/status` → `{"running": bool, "events": [envelope, ...]}`. Poll fast (2s) only while running.
|
||||
- **No bot yet:** with `SAMPLE_DATA = true`, the page answers its own data actions with sample envelopes.
|
||||
|
||||
## Bot/backend → page
|
||||
|
||||
| event | data |
|
||||
|---|---|
|
||||
| `status` | `{state: "ready"\|"running"\|"done"\|"error", msg}` |
|
||||
| `step` | `{label: <one of STEPS>, pct: 0-100}` |
|
||||
| `log` | `{msg: <plain English>, type: "info"\|"success"\|"warn"\|"error"}` |
|
||||
| `dashboard_data` | `{batches: [{batch_id, date, state: "completed"\|"inprogress"\|"notcompleted"\|"cleared", items_total, items_done, ...}]}` |
|
||||
| `report_data` | `{rows: [{...REPORT_COLUMNS keys, status, bucket: "success"\|"attention"\|"failed"}]}` |
|
||||
| `set_user` | `{username, emp_id, role}` |
|
||||
| `apply_dark` | `{on: bool}` |
|
||||
|
||||
Optional sections (only if chosen) add `cpstate`, `admin_data` and `login_result`, and the actions `load_admin`, `assign_role`, `remove_role` and `login`. See `sections.md`.
|
||||
|
||||
## Page → bot/backend (Qt `@pyqtSlot` names / web `POST {API_BASE}/<slot-with-dashes>`)
|
||||
|
||||
`load_dashboard`, `start_automation`, `request_stop`, `load_latest_report`, `download_report`, `set_dark(on)`, `logout`.
|
||||
|
||||
## Rules (learned on Tebra)
|
||||
- Log the raw bot line to a file/DB; show only **friendly** text on screen. Never hide an error line.
|
||||
- Step markers: match the bot's printed lines by **specific** substring (e.g. `[step 3]`), not a bare word. A bare word matches unrelated lines.
|
||||
- Derive `state`: Pending + today = `inprogress`; Pending + older day = `notcompleted`.
|
||||
- Report `bucket`: success = {processed successfully, posted, completed}; failed = {error, failed}; everything else = attention.
|
||||
- Run the bot in a **separate process** that writes to a log file, and have the UI tail that file. When pywinauto/UIA runs inside a Qt worker thread, its reads are unreliable (Tebra bug-094).
|
||||
@@ -0,0 +1,454 @@
|
||||
<!DOCTYPE html>
|
||||
<!--
|
||||
Generic RPA dashboard (no AI). Derived from Tebra ERA (reference/tebra_dashboard.html).
|
||||
One file: works inside PyQt6 QWebEngineView (QWebChannel bridge) OR served by any web
|
||||
backend (falls back to polling GET {{API_BASE}}/dashboard and /run/status).
|
||||
Replace every {{...}} placeholder. Theme = the body / body.dark token blocks only (see themes.md).
|
||||
-->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>{{APP_NAME}}</title>
|
||||
<script src="qrc:///qtwebchannel/qwebchannel.js"></script>
|
||||
<style>
|
||||
/* ===== THEME TOKENS (swap these for a preset/custom palette, see themes.md) ===== */
|
||||
body{
|
||||
--bg-top:#8fc9e8; --bg-mid:#bfe3f2; --bg-bottom:#eaf6fb;
|
||||
--glass:rgba(255,255,255,.60); --glass-strong:rgba(255,255,255,.78); --border:rgba(255,255,255,.9);
|
||||
--ink:#1c2b33; --muted:#5c7382; --accent-dark:#16202a;
|
||||
--accent:#3d9bd0; --accent-2:#5cb2e2; --accent-soft:#d4edfa; --accent-soft-2:#a9d8ee;
|
||||
--ok:#1f9d57; --ok-bg:#e6f6ec; --warn:#b7791f; --warn-bg:#fbf1dd; --bad:#c0392b; --bad-bg:#fdecea;
|
||||
--font:'Segoe UI',-apple-system,BlinkMacSystemFont,sans-serif; --font-display:var(--font);
|
||||
--header-bg:var(--glass-strong); --header-ink:var(--ink); --header-muted:var(--muted);
|
||||
--primary-bg:linear-gradient(180deg,var(--accent-2),var(--accent));
|
||||
--nav-active-bg:linear-gradient(135deg,var(--accent),var(--accent-dark));
|
||||
--progress-bg:var(--ok); --blur:blur(14px);
|
||||
}
|
||||
body.dark{
|
||||
--bg-top:#0f1b26; --bg-mid:#13212e; --bg-bottom:#182a38;
|
||||
--glass:rgba(22,34,44,.72); --glass-strong:rgba(30,45,58,.85); --border:rgba(120,160,190,.22);
|
||||
--ink:#e6eef4; --muted:#8fa6b4; --accent-dark:#2e5d80;
|
||||
--accent-soft:#2e5d90; --accent-soft-2:#1d3d5e;
|
||||
--ok:#2fbf72; --ok-bg:rgba(31,157,87,.16); --warn:#d9a13c; --warn-bg:rgba(183,121,31,.16);
|
||||
--bad:#e05a4b; --bad-bg:rgba(192,57,43,.16);
|
||||
}
|
||||
/* ===== BASE ===== */
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
html,body{height:100%;overflow:hidden}
|
||||
body{font-family:var(--font);color:var(--ink);
|
||||
background:linear-gradient(180deg,var(--bg-top) 0%,var(--bg-mid) 45%,var(--bg-bottom) 100%)}
|
||||
.app{display:flex;height:100vh}
|
||||
.hidden{display:none!important}
|
||||
/* sidebar */
|
||||
.sidebar{width:230px;min-width:230px;background:var(--glass);border-right:1px solid var(--border);backdrop-filter:var(--blur);display:flex;flex-direction:column}
|
||||
.logo-area{padding:22px 20px;display:flex;align-items:center;gap:11px;border-bottom:1px solid var(--border)}
|
||||
.logo-box{width:40px;height:40px;border-radius:12px;background:var(--glass-strong);display:flex;align-items:center;justify-content:center;border:1px solid var(--border)}
|
||||
.logo-box img{width:28px;height:28px;object-fit:contain}
|
||||
.logo-title{font-size:15px;font-weight:700}
|
||||
.logo-sub{font-size:10px;color:var(--muted);margin-top:2px;letter-spacing:.5px;text-transform:uppercase;font-weight:600}
|
||||
.nav{padding:16px 12px;flex:1}
|
||||
.nav-item{border:0;background:none;width:100%;font:inherit;text-align:left;display:flex;align-items:center;gap:11px;padding:11px 13px;border-radius:10px;cursor:pointer;margin-bottom:3px;color:var(--muted);font-size:13.5px;font-weight:600;transition:all .15s;user-select:none}
|
||||
.nav-item svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:2}
|
||||
.nav-item:hover{background:var(--glass-strong);color:var(--ink)}
|
||||
.nav-item.active{background:var(--nav-active-bg);color:#fff;box-shadow:0 4px 12px rgba(20,50,70,.22)}
|
||||
.sidebar-foot{padding:14px;border-top:1px solid var(--border);display:flex;align-items:center;gap:10px}
|
||||
.avatar{width:34px;height:34px;border-radius:50%;background:var(--nav-active-bg);display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;color:#fff}
|
||||
.u-name{font-size:13px;font-weight:700}.u-role{font-size:10.5px;color:var(--muted)}
|
||||
/* main + topbar */
|
||||
.main{flex:1;display:flex;flex-direction:column;overflow:hidden}
|
||||
.topbar{background:var(--header-bg);color:var(--header-ink);border-bottom:1px solid var(--border);backdrop-filter:var(--blur);padding:20px 28px;display:flex;align-items:center;gap:14px;box-shadow:0 6px 18px rgba(20,50,70,.10)}
|
||||
.tb-title{font-size:21px;font-weight:800}.tb-sub{font-size:12.5px;color:var(--header-muted);margin-top:3px}
|
||||
.tb-right{margin-left:auto;display:flex;align-items:center;gap:12px}
|
||||
.indicator{color:var(--ink);display:flex;align-items:center;gap:7px;background:var(--glass-strong);padding:7px 13px;border-radius:20px;border:1px solid var(--border);font-size:12px;font-weight:700}
|
||||
.dot{width:8px;height:8px;border-radius:50%}
|
||||
.dot.ready,.dot.done{background:var(--ok);box-shadow:0 0 7px var(--ok)}
|
||||
.dot.running{background:var(--warn);box-shadow:0 0 7px var(--warn);animation:blink 1.2s infinite}
|
||||
.dot.error{background:var(--bad);box-shadow:0 0 7px var(--bad)}
|
||||
@keyframes blink{50%{opacity:.4}}
|
||||
.mode-btn{color:var(--ink);width:38px;height:38px;border-radius:11px;border:1px solid var(--border);background:var(--glass-strong);cursor:pointer;font-size:16px}
|
||||
.content{flex:1;overflow-y:auto;padding:26px 28px;border-top-left-radius:30px}
|
||||
/* cards, buttons, pills */
|
||||
.section-label{font-size:11px;text-transform:uppercase;letter-spacing:1px;color:var(--muted);font-weight:700;margin-bottom:14px}
|
||||
.card{background:var(--glass);border:1px solid var(--border);border-radius:16px;padding:22px;margin-bottom:16px;backdrop-filter:var(--blur);box-shadow:0 10px 30px rgba(20,50,70,.08)}
|
||||
.c-title{font-size:15px;font-weight:700;margin-bottom:14px}
|
||||
.btn{padding:9px 15px;border-radius:11px;border:1px solid var(--accent-soft-2);background:linear-gradient(180deg,var(--accent-soft),var(--accent-soft-2));color:var(--ink);font-size:12.5px;font-weight:600;cursor:pointer;transition:all .18s}
|
||||
.btn:hover{transform:translateY(-1px);box-shadow:0 4px 12px rgba(20,50,70,.2)}
|
||||
.btn.primary{background:var(--primary-bg);color:#fff;border-color:var(--accent)}
|
||||
.btn:disabled{opacity:.45;cursor:not-allowed;transform:none;box-shadow:none}
|
||||
.pill{display:inline-block;padding:4px 11px;border-radius:20px;font-size:11px;font-weight:700;color:#fff}
|
||||
.pill.success{background:var(--ok)}.pill.attention{background:var(--warn)}.pill.failed{background:var(--bad)}.pill.cleared{background:var(--muted)}
|
||||
ul,ol{list-style:none}
|
||||
.logo-title,.tb-title,.c-title,.kpi-val,.dh-num,.btn{font-family:var(--font-display)}
|
||||
/* KPI */
|
||||
.kpi-row{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:16px}
|
||||
.kpi{display:flex;align-items:center;gap:13px;padding:16px 18px;border-radius:16px;background:var(--glass-strong);border:1px solid var(--border);box-shadow:0 4px 14px rgba(20,50,70,.10)}
|
||||
.kpi-ic{width:42px;height:42px;border-radius:12px;position:relative}
|
||||
.kpi.total .kpi-ic{background:var(--accent)}
|
||||
.kpi.done .kpi-ic{background:var(--ok)}.kpi.prog .kpi-ic{background:var(--warn)}.kpi.notdone .kpi-ic{background:var(--bad)}
|
||||
.kpi.prog.live .kpi-ic::after{content:'';position:absolute;inset:-3px;border-radius:14px;border:2px solid var(--warn);animation:kpulse 1.6s ease-out infinite}
|
||||
@keyframes kpulse{0%{transform:scale(.9);opacity:.6}100%{transform:scale(1.25);opacity:0}}
|
||||
.kpi-val{font-size:26px;font-weight:800;line-height:1.1;font-variant-numeric:tabular-nums}
|
||||
.kpi-lbl{font-size:11.5px;color:var(--muted);font-weight:600;margin-top:2px}
|
||||
/* donut */
|
||||
.dash-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}
|
||||
.donut-wrap{display:flex;align-items:center;gap:26px;flex-wrap:wrap}
|
||||
.donut{width:150px;height:150px;border-radius:50%;position:relative;background:conic-gradient(var(--border) 0 100%)}
|
||||
.donut-hole{position:absolute;inset:22px;border-radius:50%;background:var(--bg-bottom);display:flex;flex-direction:column;align-items:center;justify-content:center}
|
||||
.dh-num{font-size:26px;font-weight:800}.dh-lbl{font-size:10px;color:var(--muted);font-weight:700;text-transform:uppercase}
|
||||
.dl-row{display:flex;align-items:center;gap:9px;font-size:12.5px;font-weight:600;margin-bottom:9px}
|
||||
.dl-dot{width:10px;height:10px;border-radius:3px}
|
||||
/* run panel */
|
||||
.prog-head{display:flex;gap:12px;margin-bottom:8px;font-size:13px;font-weight:700}
|
||||
.prog-head .pct{margin-left:auto;color:var(--accent)}
|
||||
.prog-bg{height:8px;background:var(--glass-strong);border-radius:8px;overflow:hidden;margin-bottom:18px}
|
||||
.prog-fill{height:100%;width:0;background:var(--progress-bg);transition:width .5s}
|
||||
.step{display:flex;align-items:center;gap:12px;padding:10px 13px;border-radius:10px;margin-bottom:6px;font-size:13px;font-weight:600}
|
||||
.step.done{background:var(--ok-bg);color:var(--ok)}.step.active{background:var(--warn-bg);color:var(--warn)}.step.pending{opacity:.5}
|
||||
.step-dot{width:22px;height:22px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:11px;background:var(--glass-strong)}
|
||||
.step.done .step-dot{background:var(--ok);color:#fff}.step.active .step-dot{background:var(--warn);color:#fff}
|
||||
.log{background:var(--accent-dark);border-radius:12px;padding:12px 14px;max-height:220px;overflow-y:auto;font-family:Consolas,'Courier New',monospace;font-size:13px;line-height:1.85;color:#9fb6c4}
|
||||
.log .info{color:#7fc4f0}.log .success{color:#5fe0a0}.log .warn{color:#f5c563}.log .error{color:#f68c82}
|
||||
/* report table */
|
||||
.toolbar{display:flex;gap:10px;align-items:center;margin-bottom:14px;flex-wrap:wrap}
|
||||
.toolbar input{padding:8px 10px;border:1px solid var(--border);border-radius:10px;background:var(--glass-strong);color:var(--ink);font:inherit;font-size:12.5px}
|
||||
.tbl-wrap{border:1px solid var(--border);border-radius:14px;overflow:auto;max-height:60vh;background:var(--glass)}
|
||||
table{border-collapse:collapse;width:100%;font-size:12px;white-space:nowrap}
|
||||
th{background:var(--glass-strong);text-align:left;padding:11px 14px;position:sticky;top:0;font-weight:700}
|
||||
td{padding:10px 14px;border-bottom:1px solid var(--border)}
|
||||
.empty{text-align:center;padding:40px;color:var(--muted);font-size:13px}
|
||||
/* OPTIONAL:checkpoints BEGIN */
|
||||
.cp-track{display:grid;grid-template-columns:repeat(auto-fit,minmax(118px,1fr));gap:8px;margin-bottom:16px}
|
||||
.cp-box{display:flex;flex-direction:column;gap:4px;padding:10px 11px;border-radius:11px;border:1.5px solid var(--border);background:var(--glass-strong);color:var(--muted);font-size:11.5px;font-weight:700}
|
||||
.cp-name{color:var(--ink);font-size:12.5px}
|
||||
.cp-box.active{border-color:var(--warn);background:var(--warn-bg);color:var(--warn)}
|
||||
.cp-box.done{border-color:var(--ok);background:var(--ok-bg);color:var(--ok)}
|
||||
.cp-box.stuck{border-color:var(--bad);background:var(--bad-bg);color:var(--bad)}
|
||||
/* OPTIONAL:checkpoints END */
|
||||
/* OPTIONAL:admin BEGIN */
|
||||
body:not([data-role="admin" i]) .adm-only{display:none!important}
|
||||
.toolbar select{padding:8px 10px;border:1px solid var(--border);border-radius:10px;background:var(--glass-strong);color:var(--ink);font:inherit;font-size:12.5px}
|
||||
.toolbar select option{background:var(--bg-bottom);color:var(--ink)}
|
||||
.pill.adm-role{background:var(--muted);text-transform:capitalize}.pill.adm-role.admin{background:var(--accent)}
|
||||
.adm-note{font-size:12.5px;color:var(--muted)}
|
||||
/* OPTIONAL:admin END */
|
||||
/* OPTIONAL:login BEGIN */
|
||||
.login-screen{position:fixed;inset:0;z-index:50;display:flex;align-items:center;justify-content:center;padding:16px;
|
||||
background:linear-gradient(180deg,var(--bg-top) 0%,var(--bg-mid) 45%,var(--bg-bottom) 100%)}
|
||||
.login-card{width:min(360px,100%);display:flex;flex-direction:column;gap:12px;margin:0}
|
||||
.login-card .c-title{font-size:19px;margin:0}
|
||||
.login-sub{font-size:12.5px;color:var(--muted);margin-top:-8px}
|
||||
.login-card label{display:flex;flex-direction:column;gap:5px;font-size:12px;font-weight:700;color:var(--muted)}
|
||||
.login-card input{padding:10px 12px;border:1px solid var(--border);border-radius:10px;background:var(--glass-strong);color:var(--ink);font:inherit;font-size:13px}
|
||||
.login-err{color:var(--bad);font-size:12.5px;font-weight:600;min-height:1.2em}
|
||||
.login-out svg{width:17px;height:17px;stroke:currentColor;fill:none;stroke-width:2;vertical-align:middle}
|
||||
/* OPTIONAL:login END */
|
||||
@media (max-width:820px){.sidebar{width:66px;min-width:66px}.logo-title,.logo-sub,.nav-item span,.u-name,.u-role{display:none}.kpi-row{grid-template-columns:1fr 1fr}.dash-grid{grid-template-columns:1fr}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<aside class="sidebar">
|
||||
<div class="logo-area">
|
||||
<div class="logo-box"><img src="{{LOGO_SRC}}" alt="" onerror="this.remove()"></div>
|
||||
<div><div class="logo-title">{{APP_NAME}}</div><div class="logo-sub">{{APP_SUBTITLE}}</div></div>
|
||||
</div>
|
||||
<nav class="nav">
|
||||
<button class="nav-item active" data-tab="dashboard" title="Dashboard"><svg viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg><span>Dashboard</span></button>
|
||||
<button class="nav-item" data-tab="run" title="Run"><svg viewBox="0 0 24 24"><polygon points="6 4 20 12 6 20 6 4"/></svg><span>Run</span></button>
|
||||
<button class="nav-item" data-tab="report" title="Report"><svg viewBox="0 0 24 24"><path d="M4 4h16v16H4z"/><path d="M8 9h8M8 13h8M8 17h5"/></svg><span>Report</span></button>
|
||||
<!-- OPTIONAL:admin BEGIN -->
|
||||
<button class="nav-item adm-only" id="adm-nav" data-tab="admin" title="Admin"><svg viewBox="0 0 24 24"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg><span>Admin</span></button>
|
||||
<!-- OPTIONAL:admin END -->
|
||||
</nav>
|
||||
<div class="sidebar-foot"><div class="avatar" id="avatar">U</div><div><div class="u-name" id="u-name">User</div><div class="u-role" id="u-role">Operator</div></div></div>
|
||||
</aside>
|
||||
|
||||
<main class="main">
|
||||
<header class="topbar">
|
||||
<div><div class="tb-title" id="tb-title">Dashboard</div><div class="tb-sub" id="tb-sub">Overview of every batch</div></div>
|
||||
<div class="tb-right">
|
||||
<button class="btn primary" id="run-btn" onclick="startRun()">▶ Run</button>
|
||||
<!-- OPTIONAL:login BEGIN -->
|
||||
<button class="mode-btn login-out" title="Sign out" aria-label="Sign out" onclick="loginShow(true); api('logout')"><svg viewBox="0 0 24 24"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg></button>
|
||||
<!-- OPTIONAL:login END -->
|
||||
<button class="mode-btn" id="mode-btn" title="Dark mode" aria-label="Dark mode" aria-pressed="false" onclick="toggleDark()">🌙</button>
|
||||
<div class="indicator"><span class="dot ready" id="st-dot"></span><span id="st-text">System ready</span></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="content">
|
||||
<!-- DASHBOARD -->
|
||||
<div id="tab-dashboard">
|
||||
<div class="kpi-row">
|
||||
<div class="kpi total" role="group" aria-label="Total batches"><div class="kpi-ic"></div><div><div class="kpi-val" id="k-total">0</div><div class="kpi-lbl">Total batches</div></div></div>
|
||||
<div class="kpi done" role="group" aria-label="Completed"><div class="kpi-ic"></div><div><div class="kpi-val" id="k-done">0</div><div class="kpi-lbl">Completed</div></div></div>
|
||||
<div class="kpi prog" id="kpi-prog" role="group" aria-label="In progress"><div class="kpi-ic"></div><div><div class="kpi-val" id="k-prog">0</div><div class="kpi-lbl">In progress</div></div></div>
|
||||
<div class="kpi notdone" role="group" aria-label="Not completed"><div class="kpi-ic"></div><div><div class="kpi-val" id="k-fail">0</div><div class="kpi-lbl">Not completed</div></div></div>
|
||||
</div>
|
||||
<div class="dash-grid">
|
||||
<div class="card"><div class="c-title">Batch status split</div>
|
||||
<div class="donut-wrap"><div class="donut" id="donut" role="img" aria-label="Batch status: no batches yet"><div class="donut-hole"><div class="dh-num" id="dh-num">0</div><div class="dh-lbl">batches</div></div></div><ul id="legend" aria-label="Batch status"></ul></div>
|
||||
</div>
|
||||
<div class="card"><div class="c-title">Recent batches</div><div id="recent" class="empty">No batches yet</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RUN -->
|
||||
<div id="tab-run" class="hidden">
|
||||
<div class="card"><div class="c-title">Run automation</div>
|
||||
<div class="prog-head"><span id="p-label">Waiting to start</span><span id="p-time"></span><span class="pct" id="p-pct">0%</span></div>
|
||||
<div class="prog-bg"><div class="prog-fill" id="p-fill"></div></div>
|
||||
<ol id="steps" aria-label="Steps"></ol>
|
||||
<!-- OPTIONAL:checkpoints BEGIN -->
|
||||
<div class="section-label">Checkpoints</div>
|
||||
<ol class="cp-track" id="cp-track" aria-label="Checkpoints"></ol>
|
||||
<!-- OPTIONAL:checkpoints END -->
|
||||
<div class="log" id="log" role="log" aria-label="Run log"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- REPORT -->
|
||||
<div id="tab-report" class="hidden">
|
||||
<div class="card">
|
||||
<div class="toolbar"><input id="r-search" type="search" aria-label="Search report" placeholder="Search…" oninput="renderReport()"><span style="margin-left:auto"></span><button class="btn primary" onclick="api('download_report')">⬇ Download Excel</button></div>
|
||||
<div class="tbl-wrap"><table><thead id="r-head"></thead><tbody id="r-body"></tbody></table></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OPTIONAL:admin BEGIN -->
|
||||
<!-- ADMIN: admins only. Hiding it is not security: the host re-checks the role on every admin action. -->
|
||||
<div id="tab-admin" class="hidden adm-only">
|
||||
<div class="card"><div class="c-title">Assign access</div>
|
||||
<form class="toolbar" aria-label="Assign access" onsubmit="admAssign(event)">
|
||||
<input id="adm-user" aria-label="Username or employee ID" placeholder="Username or employee ID" required>
|
||||
<select id="adm-role" aria-label="Role"><option value="operator">Operator</option><option value="admin">Admin</option></select>
|
||||
<button class="btn primary">Assign</button>
|
||||
<span class="adm-note" id="adm-note" role="status"></span>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card"><div class="c-title">Users with access</div>
|
||||
<div class="tbl-wrap"><table aria-label="Users with access"><thead><tr><th>User</th><th>Employee ID</th><th>Role</th><th></th></tr></thead><tbody id="adm-rows" onclick="admRemove(event)"></tbody></table></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- OPTIONAL:admin END -->
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- OPTIONAL:login BEGIN -->
|
||||
<!-- LOGIN: UI only. The host checks the password with the project's own auth (see sections.md). -->
|
||||
<div class="login-screen" id="login-screen">
|
||||
<form class="card login-card" aria-label="Sign in" onsubmit="loginSubmit(event)">
|
||||
<div class="c-title">{{APP_NAME}}</div>
|
||||
<p class="login-sub">Sign in to continue</p>
|
||||
<label>Username<input id="login-user" autocomplete="username" required></label>
|
||||
<label>Password<input id="login-pass" type="password" autocomplete="current-password" required></label>
|
||||
<div class="login-err" id="login-err" role="alert"></div>
|
||||
<button class="btn primary">Sign in</button>
|
||||
</form>
|
||||
</div>
|
||||
<!-- OPTIONAL:login END -->
|
||||
|
||||
<script>
|
||||
// ===== CONFIG (fill per project) =====
|
||||
const STEPS = {{STEPS_JSON}}; // e.g. ["Initializing","Login","Process items","Completed"]
|
||||
const REPORT_COLUMNS = {{REPORT_COLUMNS_JSON}}; // e.g. [{key:"item",label:"Item"},{key:"status",label:"Status"}]
|
||||
const API_BASE = "{{API_BASE}}"; // web mode only, e.g. "/api"
|
||||
const SAMPLE_DATA = true; // no bot yet: the page answers its own data actions. Set false once wired.
|
||||
const TABS = {dashboard:["Dashboard","Overview of every batch"],run:["Run","Live progress and log"],report:["Report","Result of every processed item"]};
|
||||
|
||||
// ===== TRANSPORT: sample data, else Qt bridge, else HTTP polling =====
|
||||
let bridge = null, reportRows = [];
|
||||
function api(slot, ...args){
|
||||
if(SAMPLE_DATA && SAMPLE[slot]) return SAMPLE[slot](...args);
|
||||
if(bridge) return bridge[slot](...args);
|
||||
return fetch(`${API_BASE}/${slot.replace(/_/g,'-')}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(args)}).catch(()=>{});
|
||||
}
|
||||
window.__pyMsg = (json)=>{ const m=JSON.parse(json); handle(m.event,m.data); };
|
||||
function startTransport(){
|
||||
if(window.qt && window.qt.webChannelTransport){
|
||||
new QWebChannel(qt.webChannelTransport, ch=>{ bridge=ch.objects.pyBridge; api('load_dashboard'); });
|
||||
}else if(SAMPLE_DATA){
|
||||
api('load_dashboard');
|
||||
}else{
|
||||
const poll = async()=>{
|
||||
try{
|
||||
const d = await (await fetch(`${API_BASE}/dashboard`)).json(); handle('dashboard_data',d);
|
||||
const s = await (await fetch(`${API_BASE}/run/status`)).json();
|
||||
(s.events||[]).forEach(e=>handle(e.event,e.data));
|
||||
setTimeout(poll, s.running ? 2000 : 10000); // poll fast only while busy
|
||||
}catch(e){ setTimeout(poll, 10000); }
|
||||
};
|
||||
poll();
|
||||
}
|
||||
}
|
||||
|
||||
// ===== SAMPLE DATA: delete this block and set SAMPLE_DATA=false once the bot is wired =====
|
||||
const SAMPLE = {
|
||||
load_dashboard(){ const day=n=>new Date(Date.now()-n*864e5).toISOString().slice(0,10);
|
||||
handle('dashboard_data',{batches:[
|
||||
{batch_id:1006,date:day(0),state:'inprogress',items_total:40,items_done:18},
|
||||
{batch_id:1005,date:day(1),state:'completed',items_total:35,items_done:35},
|
||||
{batch_id:1004,date:day(2),state:'completed',items_total:52,items_done:52},
|
||||
{batch_id:1003,date:day(3),state:'notcompleted',items_total:20,items_done:7},
|
||||
{batch_id:1002,date:day(4),state:'completed',items_total:28,items_done:28},
|
||||
{batch_id:1001,date:day(6),state:'cleared',items_total:12,items_done:0}]}); },
|
||||
load_latest_report(){
|
||||
handle('report_data',{rows:[['Processed successfully','success'],['Posted','success'],['Needs review','attention'],['Error','failed']]
|
||||
.map(([status,bucket],i)=>({...Object.fromEntries(REPORT_COLUMNS.map(c=>[c.key,`${c.label} ${i+1}`])),status,bucket}))}); },
|
||||
start_automation(){ let i=0; handle('status',{state:'running',msg:'Running (sample)'});
|
||||
const t=setInterval(()=>{
|
||||
// OPTIONAL:checkpoints BEGIN
|
||||
handle('cpstate', i<STEPS.length ? {kind:'active',cp:Math.ceil((i+1)*CHECKPOINTS.length/STEPS.length)} : {kind:'done',cp:CHECKPOINTS.length});
|
||||
// OPTIONAL:checkpoints END
|
||||
if(i<STEPS.length){ handle('step',{label:STEPS[i],pct:Math.round((i+1)/STEPS.length*100)}); handle('log',{msg:`Sample step: ${STEPS[i++]}`,type:'info'}); }
|
||||
else{ clearInterval(t); handle('log',{msg:'Sample run finished.',type:'success'}); handle('status',{state:'done',msg:'Completed'}); }
|
||||
},400); },
|
||||
// OPTIONAL:admin BEGIN
|
||||
adm_users:[{username:'Sample admin',emp_id:'E-1001',role:'admin'},{username:'Sample operator',emp_id:'E-1002',role:'operator'}],
|
||||
load_admin(){ handle('admin_data',{rows:this.adm_users}); },
|
||||
assign_role(who,role){ this.remove_role(who); this.adm_users.push({username:who,emp_id:'',role}); this.load_admin(); },
|
||||
remove_role(who){ this.adm_users=this.adm_users.filter(u=>u.username!==who&&u.emp_id!==who); this.load_admin(); },
|
||||
// OPTIONAL:admin END
|
||||
// OPTIONAL:login BEGIN
|
||||
login(username){ handle('login_result',{ok:true,user:{username,emp_id:'',role:document.body.dataset.role||'operator'}}); }, // stub: anyone, same role
|
||||
logout(){},
|
||||
// OPTIONAL:login END
|
||||
};
|
||||
|
||||
// ===== EVENT DISPATCH (see contract.md) =====
|
||||
function handle(event, d){
|
||||
switch(event){
|
||||
case 'status': setStatus(d.state, d.msg); break;
|
||||
case 'step': setStep(d.label, d.pct); break;
|
||||
case 'log': addLog(d.msg, d.type); break;
|
||||
case 'dashboard_data': renderDashboard(d.batches||[]); break;
|
||||
case 'report_data': reportRows = d.rows||[]; renderReport(); break;
|
||||
case 'set_user': setUser(d); break;
|
||||
case 'apply_dark': setDark(!!d.on); break;
|
||||
// OPTIONAL:checkpoints BEGIN
|
||||
case 'cpstate': cpState(d.kind, d.cp); break;
|
||||
// OPTIONAL:checkpoints END
|
||||
// OPTIONAL:admin BEGIN
|
||||
case 'admin_data': admRender(d); break;
|
||||
// OPTIONAL:admin END
|
||||
// OPTIONAL:login BEGIN
|
||||
case 'login_result': loginResult(d); break;
|
||||
// OPTIONAL:login END
|
||||
}
|
||||
}
|
||||
|
||||
// ===== UI =====
|
||||
document.querySelectorAll('.nav-item').forEach(n=>n.onclick=()=>showTab(n.dataset.tab));
|
||||
function showTab(t){
|
||||
document.querySelectorAll('.nav-item').forEach(n=>n.classList.toggle('active',n.dataset.tab===t));
|
||||
Object.keys(TABS).forEach(k=>document.getElementById('tab-'+k).classList.toggle('hidden',k!==t));
|
||||
tbTitle.textContent=TABS[t][0]; tbSub.textContent=TABS[t][1];
|
||||
if(t==='dashboard') api('load_dashboard');
|
||||
if(t==='report') api('load_latest_report');
|
||||
}
|
||||
const $=id=>document.getElementById(id), tbTitle=$('tb-title'), tbSub=$('tb-sub');
|
||||
function esc(s){return String(s??'').replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
|
||||
function setDark(on){ document.body.classList.toggle('dark',on); $('mode-btn').setAttribute('aria-pressed',on); $('mode-btn').textContent=on?'☀':'🌙'; }
|
||||
function toggleDark(){ const on=!document.body.classList.contains('dark'); setDark(on); api('set_dark',on); }
|
||||
function setUser(u){ document.body.dataset.role=u.role||''; $('u-name').textContent=u.username; $('u-role').textContent=u.role; $('avatar').textContent=(u.username||'U')[0].toUpperCase(); }
|
||||
function setStatus(state,msg){ $('st-dot').className='dot '+state; $('st-text').textContent=msg||state; $('run-btn').disabled=state==='running'; }
|
||||
function startRun(){ showTab('run'); $('log').innerHTML=''; setStep(STEPS[0],0); api('start_automation'); }
|
||||
|
||||
function renderSteps(active=-1){
|
||||
$('steps').innerHTML=STEPS.map((s,i)=>{const c=i<active?'done':i===active?'active':'pending';
|
||||
return `<li class="step ${c}"${c==='active'?' aria-current="step"':''}><span class="step-dot">${c==='done'?'✓':i+1}</span>${esc(s)}</li>`}).join('');
|
||||
}
|
||||
function setStep(label,pct){ $('p-label').textContent=label; $('p-pct').textContent=pct+'%'; $('p-fill').style.width=pct+'%';
|
||||
renderSteps(pct>=100?STEPS.length:STEPS.indexOf(label)); }
|
||||
function addLog(msg,type='info'){ const l=$('log'); l.insertAdjacentHTML('beforeend',`<div class="${type}">${esc(msg)}</div>`); l.scrollTop=l.scrollHeight; }
|
||||
|
||||
function countUp(el,to){ const from=+el.textContent||0,t0=performance.now();
|
||||
const f=t=>{const p=Math.min((t-t0)/500,1); el.textContent=Math.round(from+(to-from)*p); if(p<1)requestAnimationFrame(f)}; requestAnimationFrame(f); }
|
||||
// batch state -> [label, colour token, pill class]
|
||||
const STATES={completed:['Completed','var(--ok)','success'],inprogress:['In progress','var(--warn)','attention'],
|
||||
notcompleted:['Not completed','var(--bad)','failed'],cleared:['Cleared','var(--muted)','cleared']};
|
||||
function renderDashboard(b){
|
||||
const c={}; Object.keys(STATES).forEach(k=>c[k]=b.filter(x=>x.state===k).length);
|
||||
countUp($('k-total'),b.length); countUp($('k-done'),c.completed); countUp($('k-prog'),c.inprogress); countUp($('k-fail'),c.notcompleted);
|
||||
$('kpi-prog').classList.toggle('live',c.inprogress>0); $('dh-num').textContent=b.length;
|
||||
const tot=Object.values(c).reduce((a,x)=>a+x,0); let acc=0; const stops=[];
|
||||
Object.keys(c).forEach(k=>{ if(!c[k])return; const a=acc/tot*100; acc+=c[k]; stops.push(`${STATES[k][1]} ${a}% ${acc/tot*100}%`); });
|
||||
$('donut').style.background = tot?`conic-gradient(${stops.join(',')})`:'conic-gradient(var(--border) 0 100%)';
|
||||
$('donut').setAttribute('aria-label','Batch status: '+Object.keys(c).map(k=>`${STATES[k][0]} ${c[k]}`).join(', '));
|
||||
$('legend').innerHTML=Object.keys(c).map(k=>`<li class="dl-row"><span class="dl-dot" style="background:${STATES[k][1]}"></span>${STATES[k][0]} · ${c[k]}</li>`).join('');
|
||||
$('recent').className = b.length?'':'empty';
|
||||
$('recent').innerHTML = b.length? b.slice(0,6).map(x=>{const s=STATES[x.state]||[esc(x.state),'','attention'];
|
||||
return `<div class="dl-row">#${esc(x.batch_id)} · ${esc(x.date)} <span class="pill ${s[2]}" style="margin-left:auto">${s[0]}</span></div>`}).join('') : 'No batches yet';
|
||||
}
|
||||
function renderReport(){
|
||||
const q=$('r-search').value.toLowerCase();
|
||||
$('r-head').innerHTML='<tr>'+REPORT_COLUMNS.map(c=>`<th>${esc(c.label)}</th>`).join('')+'</tr>';
|
||||
const rows=reportRows.filter(r=>!q||Object.values(r).join(' ').toLowerCase().includes(q));
|
||||
$('r-body').innerHTML = rows.length? rows.map(r=>'<tr>'+REPORT_COLUMNS.map(c=>c.key==='status'
|
||||
?`<td><span class="pill ${esc(r.bucket||'attention')}">${esc(r.status)}</span></td>`:`<td>${esc(r[c.key])}</td>`).join('')+'</tr>').join('')
|
||||
: `<tr><td class="empty" colspan="${REPORT_COLUMNS.length}">No rows yet</td></tr>`;
|
||||
}
|
||||
|
||||
// OPTIONAL:checkpoints BEGIN
|
||||
// Checkpoint tracker: numbered boxes driven by cpstate {kind, cp}. cp counts from 1.
|
||||
const CHECKPOINTS = {{CHECKPOINTS_JSON}}; // e.g. ["Open the portal","Load the list","Process items","Finish up"]
|
||||
const CP_LABEL = {pending:'Pending', active:'In progress', done:'Done', stuck:'Stuck'};
|
||||
let cpKinds = CHECKPOINTS.map(()=>'pending');
|
||||
function cpState(kind, n){
|
||||
if(!CP_LABEL[kind] || !(n>=1 && n<=CHECKPOINTS.length)) return;
|
||||
if(kind==='active') cpKinds = cpKinds.map((_,i)=>i<n-1?'done':'pending'); // a new active box resets the rest
|
||||
cpKinds[n-1] = kind;
|
||||
$('cp-track').innerHTML = CHECKPOINTS.map((c,i)=>`<li class="cp-box ${cpKinds[i]}"${cpKinds[i]==='active'?' aria-current="step"':''}>`
|
||||
+`<span>${i+1}</span><span class="cp-name">${esc(c)}</span><span>${CP_LABEL[cpKinds[i]]}</span></li>`).join('');
|
||||
}
|
||||
cpState('pending', 1);
|
||||
// OPTIONAL:checkpoints END
|
||||
|
||||
// OPTIONAL:admin BEGIN
|
||||
// Admin screen: shown only while set_user says role "admin" (CSS on body[data-role]).
|
||||
TABS.admin = ['Admin','Who can use this dashboard'];
|
||||
$('adm-nav').addEventListener('click', ()=>api('load_admin'));
|
||||
function admRender(d){
|
||||
$('adm-note').textContent = d.msg||'';
|
||||
const rows = d.rows||[];
|
||||
$('adm-rows').innerHTML = rows.length ? rows.map(u=>`<tr><td>${esc(u.username)}</td><td>${esc(u.emp_id)}</td>`
|
||||
+`<td><span class="pill adm-role ${/^admin$/i.test(u.role)?'admin':''}">${esc(u.role)}</span></td>`
|
||||
+`<td><button class="btn" data-user="${esc(u.emp_id||u.username)}">Remove</button></td></tr>`).join('')
|
||||
: '<tr><td class="empty" colspan="4">No one has access yet</td></tr>';
|
||||
}
|
||||
function admAssign(e){ e.preventDefault(); api('assign_role', $('adm-user').value.trim(), $('adm-role').value); $('adm-user').value=''; }
|
||||
function admRemove(e){ const who=e.target.dataset.user; if(who) api('remove_role', who); }
|
||||
if(SAMPLE_DATA) setUser({username:'Sample admin', emp_id:'E-1001', role:'admin'}); // demo: show the admin screen
|
||||
// OPTIONAL:admin END
|
||||
|
||||
// OPTIONAL:login BEGIN
|
||||
// Login page (UI only): the host checks the password with the project's own auth and answers login_result.
|
||||
function loginShow(on){
|
||||
$('login-screen').classList.toggle('hidden', !on);
|
||||
document.querySelector('.app').inert = on;
|
||||
if(on){ $('login-err').textContent=''; $('login-user').focus(); }
|
||||
}
|
||||
function loginSubmit(e){
|
||||
e.preventDefault(); $('login-err').textContent='';
|
||||
const u=$('login-user').value.trim(), p=$('login-pass').value;
|
||||
if(SAMPLE_DATA || bridge) return api('login', u, p);
|
||||
fetch(`${API_BASE}/login`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:u,password:p})})
|
||||
.then(r=>r.json()).then(d=>handle('login_result',d))
|
||||
.catch(()=>handle('login_result',{ok:false,msg:'Could not reach the server.'}));
|
||||
}
|
||||
function loginResult(d){
|
||||
$('login-pass').value='';
|
||||
if(!d.ok){ $('login-err').textContent = d.msg||'Sign-in failed.'; return; }
|
||||
if(d.user) setUser(d.user);
|
||||
loginShow(false); showTab('dashboard');
|
||||
}
|
||||
loginShow(true);
|
||||
// OPTIONAL:login END
|
||||
|
||||
renderSteps();
|
||||
startTransport();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Find an existing frontend in a project: where it is, its stack and its colour tokens.
|
||||
|
||||
Usage: python detect_frontend.py [project dir]
|
||||
|
||||
Prints "No existing frontend found.", or one "Frontend found at ..." line per
|
||||
frontend followed by its token blocks (file, selector, tokens). The keep and
|
||||
restyle steps in frontend.md work from those blocks.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Not the project's own frontend: dependencies, build output, coverage, docs. Dot folders
|
||||
# (.git, .venv, .claude with the installed skills) are skipped too.
|
||||
SKIP = {"node_modules", "venv", "env", "site-packages", "__pycache__", "dist", "build", "out",
|
||||
"coverage", "htmlcov", "docs"}
|
||||
PAGE = {".html", ".htm"}
|
||||
STYLE = {".css", ".scss", ".sass", ".less"}
|
||||
MARKUP = PAGE | {".vue", ".svelte"} # files whose <style> tags hold CSS
|
||||
# A colour value: hex, a colour function, or a bare HSL triplet (shadcn/ui style "222 47% 11%").
|
||||
COLOUR = re.compile(r"#[0-9a-f]{3,8}\b|\b(rgba?|hsla?|oklch|oklab|lab|lch|color-mix)\(|^[\d.]+(deg)?\s+[\d.]+%\s+[\d.]+%", re.IGNORECASE)
|
||||
BRAND = re.compile(r"primary|brand|accent", re.IGNORECASE)
|
||||
FONT_TOKEN = re.compile(r"font(?!.*(size|weight|height))", re.IGNORECASE)
|
||||
FRAMEWORKS = [("@angular/core", "Angular"), ("vue", "Vue"), ("svelte", "Svelte"), ("react", "React")]
|
||||
TOOLS = [("next", "Next.js"), ("nuxt", "Nuxt"), ("vite", "Vite"), ("tailwindcss", "Tailwind")]
|
||||
|
||||
|
||||
def walk(root):
|
||||
for d, dirs, names in os.walk(root):
|
||||
dirs[:] = sorted(x for x in dirs if x not in SKIP and not x.startswith("."))
|
||||
for n in sorted(names):
|
||||
yield Path(d, n)
|
||||
|
||||
|
||||
def css_of(path):
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
if path.suffix.lower() in STYLE:
|
||||
return text
|
||||
return "\n".join(re.findall(r"<style[^>]*>(.*?)</style>", text, re.DOTALL | re.IGNORECASE))
|
||||
|
||||
|
||||
def token_blocks(css):
|
||||
"""[(selector path, {token: value})] for each innermost rule that declares custom properties."""
|
||||
css = re.sub(r"/\*.*?\*/", "", css, flags=re.DOTALL)
|
||||
out, stack, last = [], [], 0
|
||||
for m in re.finditer(r"[{}]", css):
|
||||
if m[0] == "{":
|
||||
if stack:
|
||||
stack[-1][2] = True # has a nested rule
|
||||
stack.append([css[last:m.start()].split(";")[-1].strip(), m.end(), False])
|
||||
elif stack:
|
||||
sel, start, nested = stack.pop()
|
||||
# ponytail: declarations next to a nested rule (CSS nesting) are skipped; token blocks rarely nest
|
||||
tokens = {} if nested else {k: v.strip() for k, v in re.findall(r"(--[\w-]+)\s*:\s*([^;]+)", css[start:m.start()])}
|
||||
if tokens:
|
||||
out.append((" ".join([s for s, *_ in stack] + [sel]), tokens))
|
||||
last = m.end()
|
||||
return out
|
||||
|
||||
|
||||
def rel(root, p):
|
||||
r = p.relative_to(root).as_posix()
|
||||
return "./" if r == "." else r + "/" if (root / r).is_dir() else r
|
||||
|
||||
|
||||
def tailwind_colours(path):
|
||||
"""Quoted hex colours in a Tailwind v3 config, by key."""
|
||||
# ponytail: regex, not a JS parser; nested shades keep only their own key (e.g. "500")
|
||||
return dict(re.findall(r"""['"]?([\w-]+)['"]?\s*:\s*['"](#[0-9a-fA-F]{3,8})['"]""", path.read_text(encoding="utf-8")))
|
||||
|
||||
|
||||
def styled(p):
|
||||
return p.suffix.lower() in MARKUP | STYLE or p.name.startswith("tailwind.config.")
|
||||
|
||||
|
||||
def frontend(root, path, stack, files):
|
||||
"""files: the frontend's style-bearing files (see styled) plus any PyQt host."""
|
||||
blocks = []
|
||||
for f in files:
|
||||
found = [("theme", tailwind_colours(f))] if f.name.startswith("tailwind.config.") else token_blocks(css_of(f))
|
||||
blocks += [{"file": rel(root, f), "selector": sel, "dark": "dark" in sel.lower(), "tokens": t} for sel, t in found if t]
|
||||
return {"path": rel(root, path), "stack": stack, "blocks": blocks}
|
||||
|
||||
|
||||
def detect(root):
|
||||
root = Path(root).resolve()
|
||||
files = list(walk(root))
|
||||
found = []
|
||||
for p in files:
|
||||
if p.name != "package.json":
|
||||
continue
|
||||
try:
|
||||
pkg = json.loads(p.read_text(encoding="utf-8"))
|
||||
except ValueError:
|
||||
continue
|
||||
deps = {**pkg.get("dependencies", {}), **pkg.get("devDependencies", {})}
|
||||
name = next((n for k, n in FRAMEWORKS if k in deps), None)
|
||||
if name:
|
||||
stack = f"{name} ({', '.join([n for k, n in TOOLS if k in deps] + ['web'])})"
|
||||
found.append(frontend(root, p.parent, stack, [f for f in files if p.parent in f.parents and styled(f)]))
|
||||
apps = [root / f["path"] for f in found]
|
||||
rest = [p for p in files if not any(a in p.parents for a in apps)]
|
||||
pages = [p for p in rest if p.suffix.lower() in PAGE]
|
||||
hosts = [p for p in rest if p.suffix == ".py" and "QWebEngineView" in p.read_text(encoding="utf-8", errors="replace")]
|
||||
if pages or hosts:
|
||||
styles = [p for p in rest if styled(p) and p not in pages]
|
||||
# ponytail: one plain-HTML/PyQt frontend per project, at the common folder of its pages and styles
|
||||
home = Path(os.path.commonpath([p.parent for p in pages + styles])) if pages else hosts[0]
|
||||
stack = f"PyQt web view (desktop, host `{rel(root, hosts[0])}`)" if hosts else "plain HTML (web)"
|
||||
found.append(frontend(root, home, stack, pages + styles + hosts))
|
||||
return found
|
||||
|
||||
|
||||
def report(found):
|
||||
if not found:
|
||||
return "No existing frontend found."
|
||||
lines = []
|
||||
for f in found:
|
||||
light = [b["tokens"] for b in f["blocks"] if not b["dark"]]
|
||||
colours = sorted(((k, v) for t in light for k, v in t.items() if COLOUR.search(v)), key=lambda kv: not BRAND.search(kv[0]))
|
||||
fonts = [v for t in light for k, v in t.items() if FONT_TOKEN.search(k)]
|
||||
shown = ", ".join(f"{k} {v}" for k, v in colours[:6]) + (f" (+{len(colours) - 6} more)" if len(colours) > 6 else "")
|
||||
lines.append(f"Frontend found at `{f['path']}`, stack `{f['stack']}`, colours `{shown or 'none found'}`"
|
||||
+ (f", fonts `{fonts[0]}`" if fonts else "") + ".")
|
||||
for b in f["blocks"]:
|
||||
lines.append(f" {b['file']} {b['selector']}{' (dark)' if b['dark'] else ''}")
|
||||
lines.append(" " + "; ".join(f"{k}:{v}" for k, v in b["tokens"].items()))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(report(detect(sys.argv[1] if len(sys.argv) > 1 else ".")))
|
||||
@@ -0,0 +1,120 @@
|
||||
# Existing frontend: detect, report, keep or restyle
|
||||
|
||||
Shared by `rpa-dashboard-no-ai` and `rpa-dashboard-ai`. The files named here sit
|
||||
next to this one, in `rpa-dashboard-no-ai/templates/`. From the AI skill, reach them
|
||||
through `../rpa-dashboard-no-ai/templates/`.
|
||||
|
||||
## 1. Detect
|
||||
|
||||
Run this before asking anything:
|
||||
|
||||
```
|
||||
python <rpa-dashboard-no-ai>/templates/detect_frontend.py <project root>
|
||||
```
|
||||
|
||||
It finds:
|
||||
- React, Vue, Angular and Svelte apps (a `package.json` that lists one);
|
||||
- a PyQt `QWebEngineView` host and the pages it shows;
|
||||
- plain HTML pages and their stylesheets.
|
||||
|
||||
It skips `node_modules`, virtualenvs, dot folders (including `.claude`), build
|
||||
output, coverage and `docs`. For each frontend it prints:
|
||||
- one line: location, stack (with `web` or `desktop`), main colours and font;
|
||||
- then every token block: the file, the selector (dark blocks are marked) and
|
||||
the tokens.
|
||||
|
||||
Tokens come from:
|
||||
- CSS custom properties, including Tailwind v4 `@theme`;
|
||||
- `<style>` tags in pages, components and PyQt hosts;
|
||||
- the colours in a Tailwind v3 config.
|
||||
|
||||
It doesn't read theme objects in JS/TS (MUI, styled-components). If it prints
|
||||
`colours none found`, look for one of those, or for colours hard-coded in rules,
|
||||
before you report.
|
||||
|
||||
## 2. Report, then ask keep or restyle
|
||||
|
||||
- **No frontend:** say "No existing frontend found." Don't ask keep or restyle.
|
||||
- **Frontend found:** say the script's first line: "Frontend found at `<path>`,
|
||||
stack `<stack>`, colours `<main tokens>`." If it found more than one, report
|
||||
each and ask which one the dashboard joins.
|
||||
- Then ask one question: **keep its look, or restyle it?** Don't name presets in
|
||||
this question. Colours are asked next, and only for restyle.
|
||||
- Never ask desktop or web. The stack comes from the report: `web` means the
|
||||
web path, `desktop` means the PyQt path.
|
||||
- **No token blocks** (every colour is hard-coded): say so. A restyle can't be
|
||||
token-only then, so recommend keep, or ask before you pull the colours into a
|
||||
new token block.
|
||||
|
||||
## 3. Where the page goes
|
||||
|
||||
| Stack | Put the page |
|
||||
|---|---|
|
||||
| plain HTML | Next to the existing pages, for example `web/dashboard.html`. |
|
||||
| React, Vue, Angular, Svelte | In the app's `public/` folder (`public/dashboard.html`), so it is served as-is. Port it into the app's components only if the user asks: best effort, with the page as reference. |
|
||||
| PyQt web view | Next to the host's page. Use the desktop path (`bridge.py`). |
|
||||
|
||||
**AI skill (`rpa-dashboard-ai`):** a React app gets the React starter's components
|
||||
ported in. Plain HTML, Vue, Angular and Svelte get this same page with the AI skill's
|
||||
`templates/desktop/ai_sections.html` pasted in before `</body>`, placed as above. With no
|
||||
Qt bridge it fetches `{API_BASE}` + the AI contract's endpoints. A port into components
|
||||
uses `ai_sections.html` as the reference.
|
||||
|
||||
Don't edit existing pages to link to it. Tell the user the path to link from
|
||||
their nav.
|
||||
|
||||
## 4. Keep
|
||||
|
||||
The new page uses the project's look. **No existing file changes.** Skip the colour
|
||||
question.
|
||||
|
||||
- Fill the page's `body{}` block from the project's light tokens, and `body.dark{}`
|
||||
from its dark tokens. Copy the **values**, not `var(--…)` references: the page
|
||||
turns dark mode on with `body.dark`, which the project's own dark selector
|
||||
doesn't match. Above each block, note where the values came from, for example
|
||||
`/* kept from web/css/theme.css */`.
|
||||
- Map each page token to the project token with the same role:
|
||||
|
||||
| Page token | Project token for |
|
||||
|---|---|
|
||||
| `--accent`, `--accent-2` | the primary/brand colour, and its lighter or hover shade |
|
||||
| `--accent-dark` | the darkest brand shade (the log box background) |
|
||||
| `--accent-soft`, `--accent-soft-2` | the brand tint (secondary buttons) and its border |
|
||||
| `--bg-top`, `--bg-mid`, `--bg-bottom` | the page background; use the same value three times for a flat page |
|
||||
| `--glass`, `--glass-strong` | the card surface, and the raised or alternate surface |
|
||||
| `--border`, `--ink`, `--muted` | the border, the text and the muted text |
|
||||
| `--ok`, `--warn`, `--bad` and their `-bg` | success, warning and danger; keep the template's values if the project has none |
|
||||
| `--font`, `--font-display` | the body font and the heading font |
|
||||
| `--header-*`, `--primary-bg`, `--nav-active-bg`, `--progress-bg` | wherever the project shows its brand. A solid brand top bar, for example, is `--header-bg:<brand>; --header-ink:#ffffff`. |
|
||||
| `--blur` | `none`, unless the project uses frosted glass |
|
||||
|
||||
- **No match:** run `palette.py` on the brand colour to get the accent family.
|
||||
Keep the Tebra Sky value for anything else.
|
||||
- **No dark tokens:** put `palette.py`'s dark accents on the Tebra Sky dark block.
|
||||
- **Web fonts:** if the project loads its font with a `<link>` or `@import`, add
|
||||
the same line to the page's `<head>`.
|
||||
|
||||
## 5. Restyle
|
||||
|
||||
The existing files change **only inside their token blocks**.
|
||||
|
||||
1. Ask for colours (the skill's colour step): Tebra Sky, Superbill Ember or a
|
||||
custom hex.
|
||||
2. Edit only the token lines that the detect report listed. Give each project
|
||||
token the chosen palette's value for its role (read the keep table from right
|
||||
to left):
|
||||
- the light block gets values from the palette's `body{}` block;
|
||||
- the dark block gets values from its `body.dark{}` block.
|
||||
|
||||
Keep every token name, and keep any token the palette has no match for. Don't
|
||||
add blocks the project doesn't have.
|
||||
3. **Fonts:** change only the font tokens. If the new font needs loading (Ember),
|
||||
add its `<link>` next to the project's other stylesheet links. That is the
|
||||
only other line that may change.
|
||||
4. Leave hard-coded colours outside the token blocks alone, and list them for
|
||||
the user.
|
||||
5. Build the new page with the palette's blocks from `themes.md`, the same way as
|
||||
when there is no frontend.
|
||||
6. Show the user the diff of the existing files: `git diff` in a repo, otherwise
|
||||
each changed line. It must show only token lines, plus the font link if one
|
||||
was added.
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Derive a custom dashboard palette from one brand hex, then check text contrast.
|
||||
|
||||
Usage: python palette.py "#7a3cff"
|
||||
|
||||
Prints the accent tokens for the body{} (light) and body.dark{} blocks of
|
||||
dashboard.html, which keeps the Tebra Sky base for everything else, then a
|
||||
contrast table for light and dark. A brand colour that is too light or too dark
|
||||
is nudged until text on it is readable. Exits 1 if any check still fails.
|
||||
"""
|
||||
import sys
|
||||
|
||||
WHITE, BLACK = "#ffffff", "#000000"
|
||||
# Tebra Sky base colours the accents sit on. Keep in step with dashboard.html.
|
||||
BASE = {"light": {"bg": "#eaf6fb", "ink": "#1c2b33"}, "dark": {"bg": "#182a38", "ink": "#e6eef4"}}
|
||||
LOG_TEXT = "#f68c82" # dimmest log line colour; the log box background is --accent-dark
|
||||
|
||||
|
||||
def rgb(h):
|
||||
return [int(h[i:i + 2], 16) for i in (1, 3, 5)]
|
||||
|
||||
|
||||
def mix(a, b, t):
|
||||
"""Blend hex colour a toward b by t (0..1)."""
|
||||
return "#" + "".join(f"{round(x + (y - x) * t):02x}" for x, y in zip(rgb(a), rgb(b)))
|
||||
|
||||
|
||||
def contrast(a, b):
|
||||
"""WCAG 2 contrast ratio between two hex colours."""
|
||||
def lum(h):
|
||||
c = [v / 255 for v in rgb(h)]
|
||||
c = [x / 12.92 if x <= 0.03928 else ((x + 0.055) / 1.055) ** 2.4 for x in c]
|
||||
return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]
|
||||
hi, lo = sorted((lum(a), lum(b)), reverse=True)
|
||||
return (hi + 0.05) / (lo + 0.05)
|
||||
|
||||
|
||||
def checks(t):
|
||||
"""(mode, what, ratio, needed) for every text pair the accents affect."""
|
||||
rows = []
|
||||
for mode, m in t.items():
|
||||
bg, ink = BASE[mode]["bg"], BASE[mode]["ink"]
|
||||
rows += [(mode, "white button text on --accent", contrast(WHITE, m["--accent"]), 3),
|
||||
(mode, "white button text on --accent-2", contrast(WHITE, m["--accent-2"]), 3),
|
||||
(mode, "--accent text on the page", contrast(m["--accent"], bg), 3),
|
||||
(mode, "ink on --accent-soft buttons", contrast(ink, m["--accent-soft"]), 4.5),
|
||||
(mode, "log text on --accent-dark", contrast(LOG_TEXT, m["--accent-dark"]), 4.5)]
|
||||
return rows
|
||||
|
||||
|
||||
def nudge(colour, fix):
|
||||
"""Step colour toward whatever fix() returns (WHITE/BLACK) until it returns None."""
|
||||
for _ in range(60):
|
||||
toward = fix(colour)
|
||||
if toward is None:
|
||||
break
|
||||
colour = mix(colour, toward, 0.06)
|
||||
return colour
|
||||
|
||||
|
||||
def derive(brand):
|
||||
brand = brand.strip().lower()
|
||||
if len(brand) == 4: # #abc -> #aabbcc
|
||||
brand = "#" + "".join(ch * 2 for ch in brand[1:])
|
||||
out = {}
|
||||
for mode, lift in [("light", 0.15), ("dark", 0.10)]:
|
||||
bg, ink = BASE[mode]["bg"], BASE[mode]["ink"]
|
||||
dark_page = mode == "dark"
|
||||
|
||||
def fix_accent(c):
|
||||
if contrast(c, bg) < 3: # accent text must stand out from the page
|
||||
return WHITE if dark_page else BLACK
|
||||
if contrast(WHITE, mix(c, WHITE, lift)) < 3: # white text on the button
|
||||
return BLACK
|
||||
return None
|
||||
|
||||
a = nudge(brand, fix_accent)
|
||||
soft = mix(a, BLACK, 0.35) if dark_page else mix(a, WHITE, 0.82)
|
||||
out[mode] = {
|
||||
"--accent": a,
|
||||
"--accent-2": mix(a, WHITE, lift),
|
||||
"--accent-dark": nudge(mix(a, BLACK, 0.45 if dark_page else 0.75),
|
||||
lambda c: BLACK if contrast(LOG_TEXT, c) < 4.5 else None),
|
||||
"--accent-soft": nudge(soft, lambda c: None if contrast(ink, c) >= 4.5 else BLACK if dark_page else WHITE),
|
||||
"--accent-soft-2": mix(a, BLACK, 0.6) if dark_page else mix(a, WHITE, 0.62),
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) != 2 or not sys.argv[1].startswith("#"):
|
||||
sys.exit(__doc__)
|
||||
t = derive(sys.argv[1])
|
||||
for mode, block in [("light", "body{"), ("dark", "body.dark{")]:
|
||||
print(f"/* {mode}: replace these tokens in the {block} block */")
|
||||
print(block + " " + "; ".join(f"{k}:{v}" for k, v in t[mode].items()) + "; }")
|
||||
print()
|
||||
rows = checks(t)
|
||||
for mode, what, ratio, need in rows:
|
||||
print(f"{mode:<6} {what:<34} {ratio:5.2f}:1 needs {need}:1 {'PASS' if ratio >= need else 'FAIL'}")
|
||||
sys.exit(0 if all(r >= n for *_, r, n in rows) else 1)
|
||||
@@ -0,0 +1,251 @@
|
||||
def categorize_status(status):
|
||||
s = (status or "").strip().lower()
|
||||
if s in SUCCESS_STATUSES:
|
||||
return "success"
|
||||
if s in FAILED_STATUSES:
|
||||
return "failed"
|
||||
return "attention"
|
||||
|
||||
|
||||
# ── Progress heuristic: map printed markers -> step index + % ─────────────────
|
||||
# (run_multi is one long call, so we infer progress from the lines it prints.)
|
||||
STEP_MARKERS = [
|
||||
("multi-company / multi-location", 0, 5),
|
||||
("[input] login sheet", 1, 12),
|
||||
("########## company", 2, 25),
|
||||
("[choose company]", 2, 32),
|
||||
("practice home", 3, 45),
|
||||
("clearinghouse", 4, 55),
|
||||
("unreviewed", 5, 62),
|
||||
("posting", 5, 70),
|
||||
("processed successfully", 5, 74),
|
||||
("test-notposted", 5, 74),
|
||||
("medicalperformer", 6, 82), # ERA -> EOB (PDF) phase
|
||||
("era import ->", 6, 84),
|
||||
("print-to-pdf", 6, 84),
|
||||
("[all done]", 7, 100),
|
||||
]
|
||||
|
||||
STEPS = [
|
||||
"Initializing",
|
||||
"Reading Excel",
|
||||
"Login to Tebra",
|
||||
"Select company",
|
||||
"Open Clearinghouse",
|
||||
"Post payments",
|
||||
"ERA → EOB conversion",
|
||||
"Completed",
|
||||
]
|
||||
|
||||
|
||||
# ── Friendly log translation ──────────────────────────────────────────────────
|
||||
# The on-screen log is read by medical-billing users, not developers, so raw
|
||||
# automation lines are translated to plain English here. Every RAW line is
|
||||
# still stored in full in the BatchLog table (per batch) for debugging.
|
||||
#
|
||||
# Rules are (marker-substring, friendly-message, type) matched on the
|
||||
# lowercased line, first match wins. friendly-message None = keep the line off
|
||||
# the screen. Unmatched lines are hidden too, UNLESS they are errors — a real
|
||||
# failure must never be invisible.
|
||||
# IMPORTANT: markers are matched by SUBSTRING against the lowercased line, first
|
||||
# match wins, so they MUST be specific enough not to catch unrelated lines. e.g.
|
||||
# a bare "clearinghouse" wrongly matched "Total unreviewed clearinghouse reports:
|
||||
# 10852" and produced a duplicate "Opening Clearinghouse" — always anchor on the
|
||||
# distinct "[step N]" / "[medicalperformer] <verb>" prefix instead of a bare word.
|
||||
_FRIENDLY_RULES = [
|
||||
("multi-company / multi-location", "Starting the automation…", "info"),
|
||||
("[input] login sheet", "Reading the Excel input file…", "info"),
|
||||
("[choose company] screen", "Selecting the company in Tebra…", "info"),
|
||||
("[login] done", "Logged in to Tebra.", "success"),
|
||||
("[step 3] confirmed", "Practice opened.", "info"),
|
||||
("[step 4+5] encounters", "Opening Clearinghouse Reports…", "info"),
|
||||
("[step 6] checking", "Searching for unreviewed ERAs…", "info"),
|
||||
("[step 9] downloading + posting", "Checking for ERAs to post…", "info"),
|
||||
("no more unprocessed era rows", "No more ERAs to post here.", "info"),
|
||||
("grid still not found", "The ERA list did not load — moving on.", "error"),
|
||||
("grid not found", "The ERA list is slow to load — retrying…", "warn"),
|
||||
("[step 9] downloaded:", "ERA file downloaded.", "info"),
|
||||
("download failed", "Could not download the ERA file — continuing.", "warn"),
|
||||
("didn't open", "The ERA is slow to open — trying again…", "warn"),
|
||||
("still could not open view", "Could not open the ERA — marked as Error.", "error"),
|
||||
("clicking 'post payment", "Clicking Post Payment…", "info"),
|
||||
("post payment button not found", "Post Payment button not found.", "error"),
|
||||
("-> era errors screen", "Tebra reported ERA Errors for this payment — cancelling it.", "warn"),
|
||||
("-> payment screen", "Payment screen opened.", "info"),
|
||||
("post-payment retry", "The payment screen didn't load — trying again…", "warn"),
|
||||
("unrecognized screen after post payment", "Unexpected screen after Post Payment — retrying…", "warn"),
|
||||
("batch # field / reference # not ready", "Batch # field not ready — retrying…", "warn"),
|
||||
("batch# field or reference# value not found", "Batch # field not found — marked as Error.", "error"),
|
||||
("filling batch #", "Entering the Batch number…", "info"),
|
||||
("test mode — would have posted", "Test mode: payment ready — clicking Cancel instead of Save.", "warn"),
|
||||
("save button not found", "Save button element not found.", "error"),
|
||||
("[step 9] posted era", "Payment saved — ERA posted.", "success"),
|
||||
("done posting era payments", "Finished posting for this practice.", "info"),
|
||||
("[medicalperformer] launching", "Converting ERA files to EOB PDFs…", "info"),
|
||||
("[medicalperformer] no .txt", "No ERA files to convert.", "info"),
|
||||
("[done] no-location company complete", "Company finished.", "success"),
|
||||
# The final line is emitted by Python (_run_automation) from the real run
|
||||
# summary — hide the raw markers so a run that FAILED can never print a
|
||||
# hardcoded green "completed".
|
||||
("[run summary]", None, "info"),
|
||||
("[all done]", None, "info"),
|
||||
]
|
||||
|
||||
# Dynamic rules that pull a value out of the line (checked before the static
|
||||
# list). Each is (compiled-regex, lambda match -> (msg, type)).
|
||||
_FRIENDLY_DYNAMIC = [
|
||||
(re.compile(r"#+\s*COMPANY\s+(\d+)/(\d+):\s*(.+?)\s*#*\s*$", re.I),
|
||||
lambda m: (f"Working on company {m.group(1)} of {m.group(2)}: "
|
||||
f"{m.group(3)}", "info")),
|
||||
(re.compile(r"PROCESSING LOCATION:\s*(.+)", re.I),
|
||||
lambda m: (f"Working on location: {m.group(1).strip()}", "info")),
|
||||
# Login failure — make it clearly visible (the COM/network error the user
|
||||
# sees when Tebra pops "connection error" and they click OK).
|
||||
(re.compile(r"\[login\] FAILED for company '(.+?)':", re.I),
|
||||
lambda m: (f"Could not log in to Tebra for {m.group(1)} — connection or "
|
||||
f"Tebra error. This company was not processed.", "error")),
|
||||
(re.compile(r"Unreviewed reports total\s*:\s*(\d+)", re.I),
|
||||
lambda m: (f"Found {m.group(1)} unreviewed report(s) to check.", "info")),
|
||||
(re.compile(r"\[Step 9\] Opening ERA row id=(\S+)", re.I),
|
||||
lambda m: (f"Opening ERA {m.group(1)}…", "info")),
|
||||
(re.compile(r"Check ID=(\S*)\s+Date=(\S*)\s+Method=.*Amount=(\S*)", re.I),
|
||||
lambda m: (f"Check {m.group(1) or '—'} · Date {m.group(2) or '—'} · "
|
||||
f"Amount {m.group(3) or '—'}", "info")),
|
||||
(re.compile(r"\[Step 9\] Skipping\s+\S+:\s*(.+)", re.I),
|
||||
lambda m: (f"Skipped — {m.group(1).strip()}", "warn")),
|
||||
(re.compile(r"\[Done\] Location '(.+?)' complete", re.I),
|
||||
lambda m: (f"Location finished: {m.group(1)}", "success")),
|
||||
]
|
||||
|
||||
|
||||
# Error-level lines that are pure developer noise — the friendly login-error
|
||||
# line and the Python run-summary already tell the user what failed, so these
|
||||
# raw dumps are kept in the BatchLog table but never shown on screen.
|
||||
_ERROR_NOISE = (
|
||||
"traceback (most recent call last)",
|
||||
"during handling of the above",
|
||||
"another exception occurred",
|
||||
"comerror",
|
||||
"[error] company '", # per-company COM dump — summary covers it
|
||||
)
|
||||
|
||||
|
||||
def _is_error_noise(low_line):
|
||||
return any(marker in low_line for marker in _ERROR_NOISE)
|
||||
|
||||
|
||||
def friendly_log_line(line):
|
||||
"""Translate one raw automation line for the user-facing log.
|
||||
Returns (message, type) to display, or None to keep it off the screen."""
|
||||
for rx, fmt in _FRIENDLY_DYNAMIC:
|
||||
m = rx.search(line)
|
||||
if m:
|
||||
return fmt(m)
|
||||
low = line.lower()
|
||||
for marker, msg, ltype in _FRIENDLY_RULES:
|
||||
if marker in low:
|
||||
return (msg, ltype) if msg else None
|
||||
return None
|
||||
|
||||
|
||||
class _StreamToBridge:
|
||||
"""A stdout/stderr replacement that tees to the real stream AND forwards
|
||||
every completed line to the UI as a log event."""
|
||||
|
||||
def __init__(self, bridge, original, level="info"):
|
||||
self.bridge = bridge
|
||||
self.original = original
|
||||
self.level = level
|
||||
self._buf = ""
|
||||
|
||||
def write(self, text):
|
||||
try:
|
||||
if self.original:
|
||||
self.original.write(text)
|
||||
except Exception:
|
||||
pass
|
||||
self._buf += text
|
||||
while "\n" in self._buf:
|
||||
line, self._buf = self._buf.split("\n", 1)
|
||||
self._emit_line(line)
|
||||
|
||||
def _emit_line(self, line):
|
||||
line = line.rstrip("\r")
|
||||
if not line.strip():
|
||||
return
|
||||
self.bridge._on_automation_line(line, self.level)
|
||||
|
||||
def flush(self):
|
||||
try:
|
||||
if self.original:
|
||||
self.original.flush()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def isatty(self):
|
||||
return False
|
||||
|
||||
|
||||
class Bridge(QObject):
|
||||
"""Exposed to JavaScript as 'pyBridge'."""
|
||||
|
||||
js_message = pyqtSignal(str)
|
||||
|
||||
def __init__(self, window):
|
||||
super().__init__()
|
||||
self.window = window
|
||||
self.username = "User"
|
||||
self._max_pct = 0
|
||||
self._cur_step = 0
|
||||
self.batch_id = None # set on a fresh Trigger or from a resume check
|
||||
self._batch_log = None # BatchLogWriter while a run is active
|
||||
self._run_log_file = None # plain-text run log (DB-independent) while running
|
||||
self._last_friendly = None # dedupe consecutive identical friendly lines
|
||||
self._auto_proc = None # the separate automation process (bug-094) while running
|
||||
|
||||
# ── Python -> JS ────────────────────────────────────────────────────────────
|
||||
def send_to_js(self, event, data):
|
||||
self.js_message.emit(json.dumps({"event": event, "data": data}))
|
||||
|
||||
def _log(self, msg, type="info"):
|
||||
self.send_to_js("log", {"msg": msg, "type": type})
|
||||
|
||||
def _step(self, label, pct):
|
||||
self.send_to_js("step", {"label": label, "pct": pct})
|
||||
|
||||
def _batch_chip(self, batch_id, live=False):
|
||||
"""Persistent Run-tab chip showing the batch this run is tracked under.
|
||||
`batch_id` None/0 CLEARS it. A stale number is worse than none, so every
|
||||
site that assigns self.batch_id must call this in the same breath."""
|
||||
self.send_to_js("batch_state", {
|
||||
"batch_id": str(batch_id) if batch_id else "",
|
||||
"live": bool(live)})
|
||||
|
||||
# ── JS -> Python ────────────────────────────────────────────────────────────
|
||||
@pyqtSlot(str)
|
||||
def log(self, msg):
|
||||
print(f"[JS] {msg}")
|
||||
|
||||
@pyqtSlot(bool)
|
||||
def set_dark(self, on):
|
||||
"""Persist the dark-mode choice so the login page (a separate window)
|
||||
can pick it up too."""
|
||||
try:
|
||||
from src.ui.prefs import set_dark
|
||||
set_dark(on)
|
||||
except Exception as e:
|
||||
print(f"[prefs] could not save dark mode: {e}")
|
||||
|
||||
@pyqtSlot()
|
||||
def logout(self):
|
||||
"""JS clicked Logout — defer to the next event-loop tick so we don't
|
||||
run a nested dialog from inside the web-channel callback."""
|
||||
from PyQt6.QtCore import QTimer
|
||||
QTimer.singleShot(0, self.window.do_logout)
|
||||
|
||||
@pyqtSlot()
|
||||
def browse_file(self):
|
||||
path, _ = QFileDialog.getOpenFileName(
|
||||
self.window, "Select Input Excel File",
|
||||
os.path.expanduser("~"),
|
||||
"Excel files (*.xlsx *.xlsm *.xls)"
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Drive the Run tab's 7-box checkpoint tracker.
|
||||
kind: 'active' (box cp is now running) | 'done' (box cp finished) |
|
||||
'stuck' (box cp overran its max time)."""
|
||||
self.send_to_js("cpstate", {"kind": kind, "cp": int(cp)})
|
||||
|
||||
def _on_automation_line(self, line, level):
|
||||
"""Called (from the worker thread) for every printed line."""
|
||||
# Watchdog checkpoint state marker -> 7-box tracker in the Run tab
|
||||
# (raw line still stored in the DB log below).
|
||||
if "[CPSTATE]" in line:
|
||||
try:
|
||||
kind, cp = line.split("[CPSTATE]", 1)[1].split()
|
||||
self._cpstate(kind, cp)
|
||||
# Feed the parent-side stall watchdog. The automation runs in a
|
||||
# CHILD process, so the in-process performer.WD_STATE the old
|
||||
# watchdog read is ALWAYS empty here (it never fired — a stuck
|
||||
# checkpoint never restarted). Track the active checkpoint + its
|
||||
# start time from the STREAMED [CPSTATE] line instead.
|
||||
if kind == "active":
|
||||
self._wd_cp = int(cp)
|
||||
self._wd_since = time.time()
|
||||
except Exception:
|
||||
pass
|
||||
low = line.lower()
|
||||
ltype = "info"
|
||||
if "[error]" in low or "[fatal]" in low or "traceback" in low or "exception" in low:
|
||||
ltype = "error"
|
||||
elif "warning" in low or "[warn" in low:
|
||||
ltype = "warn"
|
||||
elif "processed successfully" in low or "[all done]" in low or "success" in low:
|
||||
ltype = "success"
|
||||
|
||||
# The FULL raw technical line always goes to the per-batch DB log AND to
|
||||
# the DB-independent file log (the file works even when the DB is down).
|
||||
if self._batch_log is not None:
|
||||
self._batch_log.log(ltype, line)
|
||||
if self._run_log_file is not None:
|
||||
try:
|
||||
self._run_log_file.write(line + "\n")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# The on-screen log gets the plain-English translation only; raw lines
|
||||
# stay off the screen — except untranslated ERRORS, which are shown
|
||||
# as-is (a failure must never be invisible to the user).
|
||||
shown = friendly_log_line(line)
|
||||
if shown is not None:
|
||||
if shown[0] != self._last_friendly: # collapse repeated polling lines
|
||||
self._log(shown[0], shown[1])
|
||||
self._last_friendly = shown[0]
|
||||
elif ltype == "error" and not _is_error_noise(low):
|
||||
# An untranslated real error still shows (a failure must never be
|
||||
# invisible) — but raw traceback/COM noise is filtered out.
|
||||
self._log(line, "error")
|
||||
self._last_friendly = None
|
||||
|
||||
# advance the progress bar / step list on known markers
|
||||
for marker, step_idx, pct in STEP_MARKERS:
|
||||
if marker in low:
|
||||
if step_idx > self._cur_step:
|
||||
self._cur_step = step_idx
|
||||
if pct > self._max_pct:
|
||||
self._max_pct = pct
|
||||
self._step(STEPS[self._cur_step], self._max_pct)
|
||||
break
|
||||
|
||||
def _resolve_input_path(self, p):
|
||||
"""Drag-drop in QWebEngine often hides the real disk path, so we may only
|
||||
get a bare filename. Resolve it against the known input folders so the
|
||||
automation always receives a real, absolute path."""
|
||||
if p and os.path.isabs(p) and os.path.exists(p):
|
||||
return p
|
||||
base = os.path.basename(p) if p else ""
|
||||
for folder in (INPUT_DIR, _ROOT):
|
||||
cand = os.path.join(folder, base) if base else ""
|
||||
if cand and os.path.exists(cand):
|
||||
return cand
|
||||
return p # unresolved — caller will report a clear error
|
||||
|
||||
def _run_multi_subprocess(self, excel_path, emp):
|
||||
"""Run performer.run_multi in a SEPARATE PROCESS (its own MAIN thread,
|
||||
no Qt) and stream its stdout back through this run's log pipeline.
|
||||
|
||||
Why a subprocess: pywinauto/UIA reads are reliable on a process MAIN
|
||||
thread but flaky on a Qt BACKGROUND thread (STA-without-a-message-pump
|
||||
for cross-process UIA; forcing MTA dead-locks against the Qt main
|
||||
thread's UIA object). The reference tool works because it is a separate
|
||||
process with no Qt — this reproduces that. Every child stdout line is
|
||||
re-printed here so it flows through _StreamToBridge → _on_automation_line
|
||||
exactly as an in-process print did (friendly log, [CPSTATE] tracker,
|
||||
per-batch DB log, file log). Returns the run_multi summary dict.
|
||||
"""
|
||||
import subprocess
|
||||
import time as _time
|
||||
proj_root = os.path.dirname(os.path.dirname(os.path.dirname(
|
||||
os.path.abspath(__file__))))
|
||||
|
||||
# The child writes ALL its output to THIS file and we tail it live. A
|
||||
# --windowed PyInstaller child's stdout PIPE is block-buffered/unreliable
|
||||
# (v1.1.5: nothing streamed back), but a line-buffered file always
|
||||
# flushes per line and can be read while the process runs.
|
||||
try:
|
||||
os.makedirs(LOGS_DIR, exist_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
child_log = os.path.join(
|
||||
LOGS_DIR,
|
||||
f"auto_child_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
||||
f"_{self.batch_id or 0}.log")
|
||||
try:
|
||||
open(child_log, "w", encoding="utf-8").close() # start empty
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if getattr(sys, "frozen", False):
|
||||
cmd = [sys.executable, "--run-automation"]
|
||||
else:
|
||||
cmd = [sys.executable, os.path.join(proj_root, "run_app.py"),
|
||||
"--run-automation"]
|
||||
cmd += ["--excel", str(excel_path),
|
||||
"--batch", str(self.batch_id or ""),
|
||||
"--emp", str(emp or ""),
|
||||
"--log", child_log,
|
||||
# Parent-death watchdog: the child exits itself the instant this
|
||||
# GUI process dies (crash, taskkill, or a closeEvent we couldn't
|
||||
# run) so automation can never keep driving Tebra orphaned.
|
||||
"--parent-pid", str(os.getpid())]
|
||||
|
||||
env = dict(os.environ)
|
||||
env["PYTHONUNBUFFERED"] = "1"
|
||||
creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0) \
|
||||
if sys.platform.startswith("win") else 0
|
||||
|
||||
print(f"[Automation] Launching automation in its own process "
|
||||
f"(reliable UIA) — batch {self.batch_id} …")
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
cwd=proj_root, env=env, creationflags=creationflags)
|
||||
except Exception as e:
|
||||
print(f"[Automation] Could not launch automation process: {e}")
|
||||
return {"total": 0, "ok": [],
|
||||
"failed": [("Automation", f"launch failed: {e}")],
|
||||
"stopped": False}
|
||||
self._auto_proc = proc
|
||||
|
||||
summary = {}
|
||||
got_summary = False
|
||||
|
||||
def _handle(line):
|
||||
nonlocal summary, got_summary
|
||||
if line.startswith("__WF_SUMMARY__"):
|
||||
try:
|
||||
summary = json.loads(line[len("__WF_SUMMARY__"):]) or {}
|
||||
got_summary = True
|
||||
except Exception:
|
||||
pass
|
||||
elif line.strip():
|
||||
print(line) # → _StreamToBridge tee → _on_automation_line
|
||||
|
||||
# Tail the child log until the process exits, then drain the rest.
|
||||
try:
|
||||
with open(child_log, "r", encoding="utf-8", errors="replace") as f:
|
||||
while True:
|
||||
pos = f.tell()
|
||||
line = f.readline()
|
||||
if line and line.endswith("\n"):
|
||||
_handle(line.rstrip("\r\n"))
|
||||
elif line: # partial line (no newline yet)
|
||||
if proc.poll() is not None:
|
||||
_handle(line.rstrip("\r\n"))
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,106 @@
|
||||
# Optional sections
|
||||
|
||||
Three extras live in `dashboard.html` as marked blocks (`OPTIONAL:<name> BEGIN` ... `END`)
|
||||
in the style, the markup and the script. Build only the ones the user picked:
|
||||
|
||||
```
|
||||
python <skill>/templates/sections.py <project>/dashboard/dashboard.html checkpoints admin login
|
||||
```
|
||||
|
||||
Name only the chosen sections; name none to remove all three. The script deletes every
|
||||
unchosen block (markup, script and style) and the marker lines of the chosen ones, and it
|
||||
stops if a marker is broken. Never cut the blocks by hand.
|
||||
|
||||
With `SAMPLE_DATA = true`, every section works on its own sample answers. Once the bot is
|
||||
wired, the host must answer the section's actions as below. Desktop hosts add the slots to
|
||||
the `Bridge` class in `bridge.py`. Web hosts serve `POST {API_BASE}/<action-with-dashes>`,
|
||||
like the core actions, and send the events in the `/run/status` events list.
|
||||
|
||||
## Checkpoint tracker (`checkpoints`)
|
||||
|
||||
Numbered boxes in the run panel. Each one is pending, in progress, done or stuck.
|
||||
|
||||
- Fill `{{CHECKPOINTS_JSON}}` with the checkpoint names, in order, from the bot's real
|
||||
stages, for example `["Open the portal","Load the list","Process items","Finish up"]`.
|
||||
They can be finer than `STEPS`.
|
||||
- Event `cpstate {kind: "pending"|"active"|"done"|"stuck", cp}`. `cp` counts from 1.
|
||||
`active` also marks the earlier boxes done and the later ones pending, so the bot can
|
||||
send `active` for each checkpoint as it starts, and `stuck` when one fails.
|
||||
- Desktop: add a marker table next to `STEP_MARKERS` in `bot_runner.py`, and add the event
|
||||
in `events_for`, using specific substrings the bot really prints. The dry run then shows
|
||||
the `cpstate` events too:
|
||||
|
||||
```python
|
||||
# (specific substring, kind, cp). First match wins.
|
||||
CHECKPOINT_MARKERS = [("[cp 1]", "active", 1), ("[cp 1 failed]", "stuck", 1)]
|
||||
|
||||
# in events_for, right after the step marker:
|
||||
if cp := _first(CHECKPOINT_MARKERS, line):
|
||||
out.append(("cpstate", {"kind": cp[1], "cp": cp[2]}))
|
||||
```
|
||||
|
||||
## Admin/roles screen (`admin`)
|
||||
|
||||
An Admin tab where an admin assigns or removes admin and operator access. The Admin nav
|
||||
item and the screen appear only while `set_user` says `role: "admin"`; operators never see
|
||||
them. Hiding them is not security: **the host re-checks that the signed-in user is an admin
|
||||
on every admin action**, and refuses otherwise.
|
||||
|
||||
- Event `admin_data {rows: [{username, emp_id, role: "admin"|"operator"}], msg?}`. `msg` is
|
||||
shown above the list, for example "Access assigned." or "Only admins can do that."
|
||||
- Actions: `load_admin()` when the tab opens, `assign_role(who, role)`, `remove_role(who)`.
|
||||
`who` is a username or employee ID. Answer each one with a fresh `admin_data`.
|
||||
- Desktop slots, using the project's own user store:
|
||||
|
||||
```python
|
||||
@pyqtSlot()
|
||||
def load_admin(self):
|
||||
if self.role != "admin": # self.role: set when the user signs in
|
||||
return
|
||||
self.send_to_js("admin_data", {"rows": read_access_list()})
|
||||
|
||||
@pyqtSlot(str, str)
|
||||
def assign_role(self, who, role):
|
||||
if self.role == "admin" and role in ("admin", "operator"):
|
||||
save_access(who, role)
|
||||
self.load_admin()
|
||||
|
||||
@pyqtSlot(str)
|
||||
def remove_role(self, who):
|
||||
if self.role == "admin":
|
||||
delete_access(who)
|
||||
self.load_admin()
|
||||
```
|
||||
|
||||
## Login page (`login`)
|
||||
|
||||
A sign-in screen in the page's own theme, shown over the dashboard until sign-in succeeds,
|
||||
plus a Sign out button in the top bar. It is UI only: it hands the username and password to
|
||||
the project's existing auth. Don't build an auth backend.
|
||||
|
||||
- Desktop action `login(username, password)`. The host checks them and sends
|
||||
`login_result {ok, msg?, user?: {username, emp_id, role}}`. On `ok` the screen closes and
|
||||
`user` fills the sidebar (and the role, if the admin screen is built). Otherwise `msg`
|
||||
shows under the form.
|
||||
- Web: `POST {API_BASE}/login` with JSON `{username, password}`, answered with the same
|
||||
`{ok, msg?, user?}` body.
|
||||
- Action `logout()` when the user signs out. The screen comes back.
|
||||
- The page sits behind the screen, but the host must still refuse data actions until
|
||||
sign-in succeeds.
|
||||
- No auth in the project yet: keep a stub that lets any non-empty username in, and tell the
|
||||
user it is a stub. In sample mode the page has its own stub.
|
||||
|
||||
```python
|
||||
@pyqtSlot(str, str)
|
||||
def login(self, username, password):
|
||||
role = check_login(username, password) # the project's auth: a role, or None
|
||||
self.role = role or ""
|
||||
if role:
|
||||
self.send_to_js("login_result", {"ok": True, "user": {"username": username, "emp_id": "", "role": role}})
|
||||
else:
|
||||
self.send_to_js("login_result", {"ok": False, "msg": "Wrong username or password."})
|
||||
|
||||
@pyqtSlot()
|
||||
def logout(self):
|
||||
self.role = ""
|
||||
```
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Keep the optional sections the user chose in dashboard.html, and delete the rest.
|
||||
|
||||
Usage: python sections.py <dashboard.html> [checkpoints] [admin] [login]
|
||||
|
||||
Each section sits between marker lines, in HTML, CSS and script:
|
||||
<!-- OPTIONAL:admin BEGIN --> /* OPTIONAL:admin BEGIN */ // OPTIONAL:admin BEGIN
|
||||
... ... ...
|
||||
<!-- OPTIONAL:admin END --> /* OPTIONAL:admin END */ // OPTIONAL:admin END
|
||||
Chosen sections lose only their marker lines. Unchosen ones lose everything.
|
||||
The file is rewritten in place. Name no sections to remove all of them.
|
||||
"""
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SECTIONS = ("checkpoints", "admin", "login")
|
||||
BLOCK = re.compile(r"^[ \t]*(?:<!--|/\*|//) OPTIONAL:(\w+) BEGIN.*\n((?:.*\n)*?)[ \t]*(?:<!--|/\*|//) OPTIONAL:\1 END.*\n", re.M)
|
||||
|
||||
|
||||
def keep(html, chosen):
|
||||
unknown = set(chosen) - set(SECTIONS)
|
||||
if unknown:
|
||||
sys.exit(f"unknown section(s) {', '.join(sorted(unknown))}; choose from {', '.join(SECTIONS)}")
|
||||
html = BLOCK.sub(lambda m: m[2] if m[1] in chosen else "", html)
|
||||
if "OPTIONAL:" in html:
|
||||
sys.exit("an OPTIONAL marker has no matching BEGIN/END line; fix the template")
|
||||
return html
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
sys.exit(__doc__)
|
||||
page = Path(sys.argv[1])
|
||||
page.write_text(keep(page.read_text(encoding="utf-8"), sys.argv[2:]), encoding="utf-8")
|
||||
print(f"{page}: kept {', '.join(sys.argv[2:]) or 'no optional sections'}")
|
||||
@@ -0,0 +1,99 @@
|
||||
# Theme presets
|
||||
|
||||
The theme lives **only** in the two token blocks at the top of `dashboard.html`:
|
||||
`body{...}` (light) and `body.dark{...}` (dark). To apply a theme, replace both
|
||||
blocks. Never edit component CSS, layout or script to restyle. Every palette
|
||||
ships with both blocks, so dark mode never looks broken.
|
||||
|
||||
Status colours `--ok`, `--warn` and `--bad` are semantic (done / in progress or
|
||||
attention / failed). Keep them unless the user gives their own.
|
||||
|
||||
Component tokens say where a preset puts its brand:
|
||||
`--header-bg`, `--header-ink`, `--header-muted` (top bar), `--primary-bg`
|
||||
(primary buttons), `--nav-active-bg` (active nav row and avatar),
|
||||
`--progress-bg` (progress fill), `--blur` (glass blur, or `none` for flat),
|
||||
`--font` (body) and `--font-display` (headings, figures, buttons).
|
||||
|
||||
## 1. Tebra Sky (the template default)
|
||||
|
||||
Blue glass cards on a sky gradient. Segoe UI. Accent `#3d9bd0`.
|
||||
|
||||
```css
|
||||
body{
|
||||
--bg-top:#8fc9e8; --bg-mid:#bfe3f2; --bg-bottom:#eaf6fb;
|
||||
--glass:rgba(255,255,255,.60); --glass-strong:rgba(255,255,255,.78); --border:rgba(255,255,255,.9);
|
||||
--ink:#1c2b33; --muted:#5c7382; --accent-dark:#16202a;
|
||||
--accent:#3d9bd0; --accent-2:#5cb2e2; --accent-soft:#d4edfa; --accent-soft-2:#a9d8ee;
|
||||
--ok:#1f9d57; --ok-bg:#e6f6ec; --warn:#b7791f; --warn-bg:#fbf1dd; --bad:#c0392b; --bad-bg:#fdecea;
|
||||
--font:'Segoe UI',-apple-system,BlinkMacSystemFont,sans-serif; --font-display:var(--font);
|
||||
--header-bg:var(--glass-strong); --header-ink:var(--ink); --header-muted:var(--muted);
|
||||
--primary-bg:linear-gradient(180deg,var(--accent-2),var(--accent));
|
||||
--nav-active-bg:linear-gradient(135deg,var(--accent),var(--accent-dark));
|
||||
--progress-bg:var(--ok); --blur:blur(14px);
|
||||
}
|
||||
body.dark{
|
||||
--bg-top:#0f1b26; --bg-mid:#13212e; --bg-bottom:#182a38;
|
||||
--glass:rgba(22,34,44,.72); --glass-strong:rgba(30,45,58,.85); --border:rgba(120,160,190,.22);
|
||||
--ink:#e6eef4; --muted:#8fa6b4; --accent-dark:#2e5d80;
|
||||
--accent-soft:#2e5d90; --accent-soft-2:#1d3d5e;
|
||||
--ok:#2fbf72; --ok-bg:rgba(31,157,87,.16); --warn:#d9a13c; --warn-bg:rgba(183,121,31,.16);
|
||||
--bad:#e05a4b; --bad-bg:rgba(192,57,43,.16);
|
||||
}
|
||||
```
|
||||
|
||||
## 2. Superbill Ember
|
||||
|
||||
Flat surfaces. The brand gradient `#ff3049` → `#f26b21` appears **only** on the
|
||||
top bar, primary buttons, the active nav row and the progress fill. Nothing else
|
||||
gets it. Sora for display, IBM Plex Sans for body.
|
||||
|
||||
Also add this line inside `<head>`, before `<style>`. Offline (for example in a
|
||||
PyQt app with no internet), the fonts fall back to Segoe UI, which is fine.
|
||||
|
||||
```html
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=Sora:wght@600;700;800&display=swap">
|
||||
```
|
||||
|
||||
```css
|
||||
body{
|
||||
--bg-top:#f4f3f7; --bg-mid:#f4f3f7; --bg-bottom:#f4f3f7;
|
||||
--glass:#ffffff; --glass-strong:#f7f6fa; --border:#e2dfe9;
|
||||
--ink:#18151f; --muted:#66607a; --accent-dark:#18151f;
|
||||
--accent:#ff3049; --accent-2:#f26b21; --accent-soft:#f7f6fa; --accent-soft-2:#e2dfe9;
|
||||
--ok:#1f9d57; --ok-bg:#e6f6ec; --warn:#b7791f; --warn-bg:#fbf1dd; --bad:#c0392b; --bad-bg:#fdecea;
|
||||
--font:'IBM Plex Sans','Segoe UI',-apple-system,sans-serif; --font-display:'Sora','Segoe UI',sans-serif;
|
||||
--header-bg:linear-gradient(100deg,#ff3049,#f26b21); --header-ink:#ffffff; --header-muted:rgba(255,255,255,.85);
|
||||
--primary-bg:linear-gradient(115deg,#ff3049,#f26b21);
|
||||
--nav-active-bg:linear-gradient(100deg,#ff3049,#f26b21);
|
||||
--progress-bg:linear-gradient(90deg,#ff3049,#f26b21); --blur:none;
|
||||
}
|
||||
body.dark{
|
||||
--bg-top:#0a0912; --bg-mid:#0a0912; --bg-bottom:#0a0912;
|
||||
--glass:#151220; --glass-strong:#1b1727; --border:#2a2438;
|
||||
--ink:#f3f1f8; --muted:#8d87a0; --accent-dark:#100e1a;
|
||||
--accent-soft:#1b1727; --accent-soft-2:#2a2438;
|
||||
--ok:#2fbf72; --ok-bg:rgba(31,157,87,.16); --warn:#d9a13c; --warn-bg:rgba(183,121,31,.16);
|
||||
--bad:#e05a4b; --bad-bg:rgba(192,57,43,.16);
|
||||
}
|
||||
```
|
||||
|
||||
## 3. Custom (one brand hex)
|
||||
|
||||
Start from the Tebra Sky blocks above, then derive the accent family from the
|
||||
user's brand colour:
|
||||
|
||||
```
|
||||
python <skill>/templates/palette.py "#7a3cff"
|
||||
```
|
||||
|
||||
It prints the `--accent`, `--accent-2`, `--accent-dark`, `--accent-soft` and
|
||||
`--accent-soft-2` values for both blocks, then a contrast table for light and dark:
|
||||
- white button text on the accents, at least 3:1;
|
||||
- accent-coloured text on the page, at least 3:1;
|
||||
- ink on the soft buttons, at least 4.5:1;
|
||||
- log text on the log box, at least 4.5:1.
|
||||
|
||||
If the brand colour is too light or too dark to read, the script nudges it until
|
||||
every check passes. Paste the printed tokens over the same tokens in both blocks.
|
||||
Show the user the contrast table, and say so if their colour was nudged.
|
||||
Keep ok/warn/bad unless the user gives their own.
|
||||
Reference in new issue
Block a user