Merge integration/rpa-dashboard-skills into ticket 04
Keeps both sides: frontend detection and bot wiring from tickets 02/03, optional sections from ticket 04. The a-empty-sections eval now uses the shared "fixture" key. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
3a11e55b58
29 files changed
+1963
-58
No files matched your search
@@ -11,11 +11,14 @@ source projects.
|
|||||||
| File | What it is |
|
| 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/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. It runs the bot as a child process and tails its output. |
|
| `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/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/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/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/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`. |
|
| `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.
|
Follow the steps in order. Ask one question at a time, and wait for the answer.
|
||||||
@@ -24,22 +27,24 @@ If the user's request already answers a question, don't ask it again.
|
|||||||
## 1. Scan the project
|
## 1. Scan the project
|
||||||
|
|
||||||
Before asking anything, look at the project:
|
Before asking anything, look at the project:
|
||||||
- **Frontend:** `*.html` files outside `node_modules`, `.venv` and build output; a
|
- **Frontend:** run `python templates/detect_frontend.py <project root>`. It finds
|
||||||
`web/`, `frontend/` or `static/` folder; `package.json` with react, vue, angular or
|
plain HTML, React/Vue/Angular/Svelte apps and PyQt web views, and prints each
|
||||||
svelte; a PyQt `QWebEngineView`. For any you find, note the location, the stack and
|
one's location, stack and colour tokens. `templates/frontend.md` says what it
|
||||||
the main colour tokens (CSS custom properties or the Tailwind theme).
|
covers and what to do with its report.
|
||||||
- **Bot:** the entry point (for example `main.py`, a `__main__` module or a run
|
- **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.
|
script), what it prints (step markers, status and error lines), and how it starts.
|
||||||
|
|
||||||
## 2. Report the frontend, then ask keep or restyle
|
## 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
|
- **No frontend:** say "No existing frontend found." and go to step 3. Don't
|
||||||
describe a frontend that isn't there.
|
describe a frontend that isn't there.
|
||||||
- **Frontend found:** say "Frontend found at `<path>`, stack `<stack>`, colours
|
- **Frontend found:** say "Frontend found at `<path>`, stack `<stack>`, colours
|
||||||
`<main tokens>`." Then ask: **keep its look**, or **restyle** it?
|
`<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
|
- Keep: skip step 3. Build the page with the project's own tokens and fonts, and
|
||||||
change none of its existing styles.
|
change no existing file.
|
||||||
- Restyle: go to step 3. Change only the theme tokens, never layout or logic.
|
- 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
|
## 3. Ask for colours
|
||||||
|
|
||||||
@@ -51,6 +56,8 @@ Skip this if the user chose to keep the existing look. Offer:
|
|||||||
## 4. Ask desktop or web
|
## 4. Ask desktop or web
|
||||||
|
|
||||||
Ask only if there is no frontend. If there is one, use its stack.
|
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
|
- **Desktop:** a PyQt6 window with the page embedded. The page talks to Python
|
||||||
through a bridge, so no web server is needed.
|
through a bridge, so no web server is needed.
|
||||||
- **Web:** the same page, served by a backend that it polls.
|
- **Web:** the same page, served by a backend that it polls.
|
||||||
@@ -81,6 +88,7 @@ to answer.
|
|||||||
- Desktop: put it next to the host, for example `dashboard/dashboard.html`.
|
- 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
|
- Web: put it in the backend's static folder. With no backend yet, use
|
||||||
`dashboard/dashboard.html`.
|
`dashboard/dashboard.html`.
|
||||||
|
- Existing frontend: put it where `templates/frontend.md` section 3 says.
|
||||||
2. Fill every `{{...}}` placeholder:
|
2. Fill every `{{...}}` placeholder:
|
||||||
|
|
||||||
| Placeholder | Value |
|
| Placeholder | Value |
|
||||||
@@ -94,10 +102,12 @@ to answer.
|
|||||||
3. Apply the theme by replacing the `body{...}` and `body.dark{...}` token blocks
|
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.
|
with the preset from `templates/themes.md`. For Ember, also add its font link.
|
||||||
For a custom colour, run `python templates/palette.py "<hex>"`, paste the
|
For a custom colour, run `python templates/palette.py "<hex>"`, paste the
|
||||||
tokens it prints, and show the user its contrast table.
|
tokens it prints, and show the user its contrast table. If the user chose keep,
|
||||||
4. Desktop: also copy `templates/bridge.py` next to the page, and fill
|
fill the blocks from the project's tokens instead (`templates/frontend.md`
|
||||||
`{{BOT_MODULE}}`, the `STEPS` list (the same steps as the page), `{{ORG}}` and
|
section 4).
|
||||||
`{{APP_NAME}}`.
|
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.
|
5. Search the output for `{{`. None may be left.
|
||||||
|
|
||||||
## 7. Wire the data
|
## 7. Wire the data
|
||||||
@@ -108,10 +118,22 @@ both directions.
|
|||||||
answers its own data actions, so it opens with sample batches and report rows,
|
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.
|
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:
|
- **Bot exists:** set `SAMPLE_DATA = false` and delete the `SAMPLE DATA` block. Then:
|
||||||
- Desktop: in `bridge.py`, set `BOT_CMD` to start the bot as a child process.
|
- Desktop: all the wiring is in `bot_runner.py`.
|
||||||
Fill `STEP_MARKERS` and `FRIENDLY_RULES` from lines the bot really prints. Use
|
1. Get the bot's real output: run it once (for example `python -m bot`), or read
|
||||||
specific substrings (for example `[step 3]`, not a bare word); the first match
|
its `print` and logging calls. Never invent lines it doesn't print.
|
||||||
wins. Unmatched error lines must always reach the log.
|
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`
|
- Web: the backend serves `GET {API_BASE}/dashboard`, `GET {API_BASE}/run/status`
|
||||||
(returning `{running, events[]}`) and `POST {API_BASE}/<action-with-dashes>`.
|
(returning `{running, events[]}`) and `POST {API_BASE}/<action-with-dashes>`.
|
||||||
- Apply the contract's derived rules: batch state from Pending plus date, and
|
- Apply the contract's derived rules: batch state from Pending plus date, and
|
||||||
@@ -119,7 +141,10 @@ both directions.
|
|||||||
|
|
||||||
## 8. Verify
|
## 8. Verify
|
||||||
|
|
||||||
Open the page. On desktop run the host; otherwise open the file or the served URL.
|
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:
|
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;
|
- the light theme, the dark theme (use the toggle), and a window 820px wide or less;
|
||||||
- KPI cards, donut and legend, the Run tab's steps and log, and the Report tab's
|
- KPI cards, donut and legend, the Run tab's steps and log, and the Report tab's
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -1,25 +1,23 @@
|
|||||||
"""Minimal PyQt6 host for dashboard.html (desktop mode). Pattern from Tebra main_window.py.
|
"""Minimal PyQt6 host for dashboard.html (desktop mode). Pattern from Tebra main_window.py.
|
||||||
|
|
||||||
The bot runs as a child process that prints lines, and this window tails them.
|
The bot runs as a child process and this window shows its progress. The bot wiring
|
||||||
Fill in STEP_MARKERS, FRIENDLY_RULES and BOT_CMD for the project.
|
(command, step markers, friendly rules) lives in bot_runner.py, next to this file.
|
||||||
|
Run it with: python dashboard/bridge.py
|
||||||
"""
|
"""
|
||||||
import json, subprocess, sys, threading
|
import json, sys, threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from PyQt6.QtCore import QObject, pyqtSlot, pyqtSignal, QUrl, QSettings
|
from PyQt6.QtCore import QObject, pyqtSlot, pyqtSignal, QUrl, QSettings
|
||||||
from PyQt6.QtWidgets import QApplication, QMainWindow
|
from PyQt6.QtWidgets import QApplication, QMainWindow
|
||||||
from PyQt6.QtWebEngineWidgets import QWebEngineView
|
from PyQt6.QtWebEngineWidgets import QWebEngineView
|
||||||
from PyQt6.QtWebChannel import QWebChannel
|
from PyQt6.QtWebChannel import QWebChannel
|
||||||
|
|
||||||
|
import bot_runner
|
||||||
|
|
||||||
HTML_PATH = Path(__file__).with_name("dashboard.html")
|
HTML_PATH = Path(__file__).with_name("dashboard.html")
|
||||||
BOT_CMD = [sys.executable, "-m", "{{BOT_MODULE}}"] # child process
|
|
||||||
STEPS = ["{{STEP_1}}", "{{STEP_2}}"] # same list as STEPS in dashboard.html
|
|
||||||
STEP_MARKERS = [("[step 1]", 0, 10), ("[all done]", len(STEPS) - 1, 100)] # (substring, step idx, pct)
|
|
||||||
FRIENDLY_RULES = [("[login] done", "Logged in.", "success")] # (substring, friendly msg | None, type)
|
|
||||||
|
|
||||||
|
|
||||||
def classify(line):
|
def settings():
|
||||||
s = line.lower()
|
return QSettings("{{ORG}}", "{{APP_NAME}}")
|
||||||
return "error" if ("error" in s or "traceback" in s) else "warn" if "warn" in s else "info"
|
|
||||||
|
|
||||||
|
|
||||||
class Bridge(QObject):
|
class Bridge(QObject):
|
||||||
@@ -27,8 +25,10 @@ class Bridge(QObject):
|
|||||||
|
|
||||||
def __init__(self, view):
|
def __init__(self, view):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.view = view
|
self.view, self._one_run = view, threading.Lock()
|
||||||
self._emit.connect(lambda js: self.view.page().runJavaScript(f"window.__pyMsg({js})"))
|
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):
|
def send_to_js(self, event, data):
|
||||||
self._emit.emit(json.dumps(json.dumps({"event": event, "data": data})))
|
self._emit.emit(json.dumps(json.dumps({"event": event, "data": data})))
|
||||||
@@ -36,38 +36,29 @@ class Bridge(QObject):
|
|||||||
@pyqtSlot()
|
@pyqtSlot()
|
||||||
def load_dashboard(self):
|
def load_dashboard(self):
|
||||||
self.send_to_js("dashboard_data", {"batches": []}) # TODO: read from DB on a worker thread
|
self.send_to_js("dashboard_data", {"batches": []}) # TODO: read from DB on a worker thread
|
||||||
self.send_to_js("apply_dark", {"on": QSettings("{{ORG}}", "{{APP_NAME}}").value("dark", False, bool)})
|
|
||||||
|
@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)
|
@pyqtSlot(bool)
|
||||||
def set_dark(self, on):
|
def set_dark(self, on):
|
||||||
QSettings("{{ORG}}", "{{APP_NAME}}").setValue("dark", on)
|
settings().setValue("dark", on)
|
||||||
|
|
||||||
@pyqtSlot()
|
@pyqtSlot()
|
||||||
def start_automation(self):
|
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()
|
threading.Thread(target=self._run, daemon=True).start()
|
||||||
|
|
||||||
def _run(self):
|
def _run(self):
|
||||||
self.send_to_js("status", {"state": "running", "msg": "Running…"})
|
try:
|
||||||
p = subprocess.Popen(BOT_CMD, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1)
|
bot_runner.run(self.send_to_js)
|
||||||
for raw in p.stdout:
|
finally:
|
||||||
self._on_line(raw.rstrip())
|
self._one_run.release()
|
||||||
ok = p.wait() == 0
|
|
||||||
self.send_to_js("status", {"state": "done" if ok else "error", "msg": "Completed" if ok else "Failed"})
|
|
||||||
|
|
||||||
def _on_line(self, line):
|
|
||||||
# ponytail: raw line only printed here; persist to file/DB if audit is needed
|
|
||||||
low, kind = line.lower(), classify(line)
|
|
||||||
for marker, idx, pct in STEP_MARKERS:
|
|
||||||
if marker in low:
|
|
||||||
self.send_to_js("step", {"label": STEPS[idx], "pct": pct})
|
|
||||||
break
|
|
||||||
for marker, msg, t in FRIENDLY_RULES:
|
|
||||||
if marker in low:
|
|
||||||
if msg:
|
|
||||||
self.send_to_js("log", {"msg": msg, "type": t})
|
|
||||||
return
|
|
||||||
if kind == "error": # never hide a real failure
|
|
||||||
self.send_to_js("log", {"msg": line, "type": "error"})
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
All updates use one envelope: `{"event": "<name>", "data": {...}}`.
|
All updates use one envelope: `{"event": "<name>", "data": {...}}`.
|
||||||
- **Desktop (PyQt):** Python calls `window.__pyMsg(json)` via `runJavaScript` (see `bridge.py`).
|
- **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
|
- **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.
|
`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.
|
- **No bot yet:** with `SAMPLE_DATA = true`, the page answers its own data actions with sample envelopes.
|
||||||
|
|||||||
@@ -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,114 @@
|
|||||||
|
# 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`). |
|
||||||
|
|
||||||
|
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.
|
||||||
+11
-1
@@ -15,6 +15,11 @@ uv run --with pytest --with playwright pytest tests
|
|||||||
generated page instead, set `PAGE_CHECK_HTML=path/to/dashboard.html`.
|
generated page instead, set `PAGE_CHECK_HTML=path/to/dashboard.html`.
|
||||||
- The page sees a fake Qt bridge. Every action the page sends is recorded in
|
- The page sees a fake Qt bridge. Every action the page sends is recorded in
|
||||||
`window.__calls` as `[slot, ...args]`.
|
`window.__calls` as `[slot, ...args]`.
|
||||||
|
- `PAGE_CHECK_WIRED=1`: the page is wired to a bot (sample data off). Sample-data
|
||||||
|
cases skip, and a case checks that the page's actions go to the bot. With no
|
||||||
|
`PAGE_CHECK_HTML`, the template is checked with `SAMPLE_DATA` switched off.
|
||||||
|
- `PAGE_CHECK_REPLAY=run.jsonl`: replay a real bot run (the JSON lines that
|
||||||
|
`python dashboard/bot_runner.py` prints) instead of the built-in run.
|
||||||
- To add a case, write a `test_*.py` file in `page_check/` that uses the `page`
|
- To add a case, write a `test_*.py` file in `page_check/` that uses the `page`
|
||||||
and `push(event, data)` fixtures from `conftest.py`.
|
and `push(event, data)` fixtures from `conftest.py`.
|
||||||
- Optional-section cases (`test_optional_sections.py`) skip on a generated page
|
- Optional-section cases (`test_optional_sections.py`) skip on a generated page
|
||||||
@@ -30,5 +35,10 @@ uv run --with pytest --with playwright pytest tests
|
|||||||
```
|
```
|
||||||
|
|
||||||
Results go to `evals/results/<fixture>.json`. To add a fixture, add an entry to
|
Results go to `evals/results/<fixture>.json`. To add a fixture, add an entry to
|
||||||
`FIXTURES` in `run_eval.py`.
|
`FIXTURES` in `run_eval.py`. An entry with a `"fixture"` key reuses another
|
||||||
|
fixture folder with different answers: `c-keep` and `c-restyle` both run on
|
||||||
|
`c-web-existing-frontend`, and also check what changed in the files it already had.
|
||||||
|
For a bot fixture (`b-pyqt-bot`), the eval also dry-runs the generated
|
||||||
|
`bot_runner.py`, saves its events to `evals/results/<fixture>.run.jsonl`, and
|
||||||
|
replays them in the page check.
|
||||||
- `fixtures/<letter>-<name>/`: small test projects for the evals.
|
- `fixtures/<letter>-<name>/`: small test projects for the evals.
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
<!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 /dashboard and /run/status).
|
||||||
|
-->
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Work-item follow-up bot</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}
|
||||||
|
@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="" alt="" onerror="this.remove()"></div>
|
||||||
|
<div><div class="logo-title">Work-item follow-up bot</div><div class="logo-sub">Portal work-item updates</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>
|
||||||
|
</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>
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ===== CONFIG (fill per project) =====
|
||||||
|
const STEPS = ["Initializing", "Login", "Process work items", "Write summary", "Completed"]; // e.g. ["Initializing","Login","Process items","Completed"]
|
||||||
|
const REPORT_COLUMNS = [{"key": "item", "label": "Work item"}, {"key": "status", "label": "Status"}]; // e.g. [{key:"item",label:"Item"},{key:"status",label:"Status"}]
|
||||||
|
const API_BASE = ""; // web mode only, e.g. "/api"
|
||||||
|
const SAMPLE_DATA = false; // wired to bot.py through bot_runner.py
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 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){ $('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>`;
|
||||||
|
}
|
||||||
|
renderSteps();
|
||||||
|
startTransport();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"fixture": "b-pyqt-bot",
|
||||||
|
"passed": true,
|
||||||
|
"checks": {
|
||||||
|
"frontend reported correctly": true,
|
||||||
|
"asks /colou?r/": true,
|
||||||
|
"asks /section/": true,
|
||||||
|
"never asks /\\bkeep\\b|restyle/": true,
|
||||||
|
"never asks /desktop|web/": true,
|
||||||
|
"exactly one page built": true,
|
||||||
|
"no placeholders left": true,
|
||||||
|
"page wired to the bot (sample data off)": true,
|
||||||
|
"bot_runner.py built": true,
|
||||||
|
"no placeholders left in the host": true,
|
||||||
|
"bot dry run ends done": true,
|
||||||
|
"real steps reach the page": true,
|
||||||
|
"bot error line reaches the log": true,
|
||||||
|
"page check passes": true
|
||||||
|
},
|
||||||
|
"page_check": [
|
||||||
|
"27 passed, 4 skipped in 13.24s"
|
||||||
|
],
|
||||||
|
"files": [
|
||||||
|
"README.md",
|
||||||
|
"bot.py",
|
||||||
|
"dashboard\\bot_raw.log",
|
||||||
|
"dashboard\\bot_runner.py",
|
||||||
|
"dashboard\\bridge.py",
|
||||||
|
"dashboard\\dashboard.html",
|
||||||
|
"requirements.txt"
|
||||||
|
],
|
||||||
|
"reply": "Dashboard ready aachu, PyQt bot-oda wire pannirukken. Ithu desktop app (PyQt6), local project mattum; cloud/deploy edhuvum illa. Neenga work pannumbothu answer panna mudiyaathu-nu sonneenga, adhaan 3-file change-ku \"go\" wait pannala.\n\n| Item | Result |\n|---|---|\n| Files | `dashboard/dashboard.html`, `dashboard/bridge.py`, `dashboard/bot_runner.py` |\n| Theme / extras | Tebra Sky / onnum illa (optional markers ellam remove pannitten) |\n| Data | Sample data illa. `bot_runner.py` `python -m bot`-a child process-aa run pannum |\n| Steps | Initializing \u2192 Login \u2192 Process work items \u2192 Write summary \u2192 Completed (bot-oda `[step 1-4]`, `[all done]` lines-la irundhu) |\n| Dry run | 5/5 steps order-la vandhuchu, 100%-la end aachu; `ERROR: could not attach\u2026` line `error` log-aa vandhuchu |\n| Qt host (offscreen) | Run click pannadhum button disable aachu, bot mudinjadhum enable; status \"Completed\", 100%; dark mode reopen pannappuram-um on-la irundhuchu |\n| Screenshots | Light, dark, 800px width: KPI, donut, steps, log, report pills ellam correct-aa render aachu; horizontal scroll illa; `{{` onnum meedhi illa |\n\nRun panna: `python dashboard/bridge.py`\n\nAlso noticed (fix pannala):\n- `load_dashboard` / `load_latest_report` empty list-dhaan anuppudhu, bot DB-yo report-o ezhudhala. Adhanaala live data-vula KPI-yum Report tab-um empty-aa irukkum. Test pannumbothu sample rows inject panni dhaan check panninen.\n- `bot.py` `ERROR` print pannaalum exit code 0-dhaan, so status \"Completed\"-nu dhaan kaattum.\n- \"Work item updated.\" log line-la WI number varaadhu; raw line `dashboard/bot_raw.log`-la save aagum.\n- Browser-la file-aa direct-aa open panna fetch errors varum, aana Qt-kulla avai varaadhu.\n\nFRONTEND: none\nQUESTIONS: Which colours: Tebra Sky, Superbill Ember, or Custom (one brand hex)? | Which optional sections to build: checkpoint tracker, admin/roles screen, login page, or none?"
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{"event": "status", "data": {"state": "running", "msg": "Running\u2026"}}
|
||||||
|
{"event": "step", "data": {"label": "Initializing", "pct": 10}}
|
||||||
|
{"event": "log", "data": {"msg": "Starting up.", "type": "info"}}
|
||||||
|
{"event": "log", "data": {"msg": "Work items loaded.", "type": "info"}}
|
||||||
|
{"event": "step", "data": {"label": "Login", "pct": 30}}
|
||||||
|
{"event": "log", "data": {"msg": "Logging in to the portal.", "type": "info"}}
|
||||||
|
{"event": "log", "data": {"msg": "Logged in.", "type": "success"}}
|
||||||
|
{"event": "step", "data": {"label": "Process work items", "pct": 50}}
|
||||||
|
{"event": "log", "data": {"msg": "Processing work items.", "type": "info"}}
|
||||||
|
{"event": "log", "data": {"msg": "Work item updated.", "type": "success"}}
|
||||||
|
{"event": "log", "data": {"msg": "Work item updated.", "type": "success"}}
|
||||||
|
{"event": "log", "data": {"msg": "Work item updated.", "type": "success"}}
|
||||||
|
{"event": "log", "data": {"msg": "The portal is slow to respond.", "type": "warn"}}
|
||||||
|
{"event": "log", "data": {"msg": "ERROR: could not attach the note to WI-1003", "type": "error"}}
|
||||||
|
{"event": "step", "data": {"label": "Write summary", "pct": 85}}
|
||||||
|
{"event": "log", "data": {"msg": "Writing the summary.", "type": "info"}}
|
||||||
|
{"event": "step", "data": {"label": "Completed", "pct": 100}}
|
||||||
|
{"event": "log", "data": {"msg": "All work items processed.", "type": "success"}}
|
||||||
|
{"event": "status", "data": {"state": "done", "msg": "Completed"}}
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
<!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/dashboard and /run/status).
|
||||||
|
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>Field Ops Portal</title>
|
||||||
|
<script src="qrc:///qtwebchannel/qwebchannel.js"></script>
|
||||||
|
<style>
|
||||||
|
/* ===== THEME TOKENS ===== */
|
||||||
|
/* kept from web/css/theme.css :root (accent-2, accent-dark, accent-soft-2 from palette.py "#0f766e": no project match) */
|
||||||
|
body{
|
||||||
|
--bg-top:#f8fafc; --bg-mid:#f8fafc; --bg-bottom:#f8fafc;
|
||||||
|
--glass:#ffffff; --glass-strong:#f1f5f9; --border:#e2e8f0;
|
||||||
|
--ink:#0f172a; --muted:#64748b; --accent-dark:#041e1c;
|
||||||
|
--accent:#0f766e; --accent-2:#338b84; --accent-soft:#ccfbf1; --accent-soft-2:#a4cbc8;
|
||||||
|
--ok:#15803d; --ok-bg:#e6f6ec; --warn:#b45309; --warn-bg:#fbf1dd; --bad:#b91c1c; --bad-bg:#fdecea;
|
||||||
|
--font:"Trebuchet MS","Segoe UI",sans-serif; --font-display:Georgia,"Times New Roman",serif;
|
||||||
|
--header-bg:#0f766e; --header-ink:#ffffff; --header-muted:#ccfbf1;
|
||||||
|
--primary-bg:#115e59;
|
||||||
|
--nav-active-bg:#0f766e;
|
||||||
|
--progress-bg:var(--ok); --blur:none;
|
||||||
|
}
|
||||||
|
/* kept from web/css/theme.css [data-theme="dark"] (accent-2, accent-dark, accent-soft-2 from palette.py) */
|
||||||
|
body.dark{
|
||||||
|
--bg-top:#0b1120; --bg-mid:#0b1120; --bg-bottom:#0b1120;
|
||||||
|
--glass:#111827; --glass-strong:#1f2937; --border:#334155;
|
||||||
|
--ink:#e2e8f0; --muted:#94a3b8; --accent-dark:#104541;
|
||||||
|
--accent:#0d9488; --accent-2:#348b85; --accent-soft:#134e4a; --accent-soft-2:#0c3230;
|
||||||
|
--ok:#22c55e; --ok-bg:rgba(31,157,87,.16); --warn:#f59e0b; --warn-bg:rgba(183,121,31,.16);
|
||||||
|
--bad:#ef4444; --bad-bg:rgba(192,57,43,.16);
|
||||||
|
--header-bg:#0d9488; --header-muted:#ccfbf1;
|
||||||
|
--primary-bg:#134e4a; --nav-active-bg:#0d9488;
|
||||||
|
}
|
||||||
|
/* ===== 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}
|
||||||
|
@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="" alt="" onerror="this.remove()"></div>
|
||||||
|
<div><div class="logo-title">Field Ops Portal</div><div class="logo-sub">Automation dashboard</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>
|
||||||
|
</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>
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ===== CONFIG (fill per project) =====
|
||||||
|
const STEPS = ["Initializing","Load jobs","Process jobs","Completed"];
|
||||||
|
const REPORT_COLUMNS = [{"key":"job","label":"Job"},{"key":"site","label":"Site"},{"key":"status","label":"Status"}];
|
||||||
|
const API_BASE = "/api"; // web mode only
|
||||||
|
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(()=>{
|
||||||
|
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); },
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===== 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 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){ $('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>`;
|
||||||
|
}
|
||||||
|
renderSteps();
|
||||||
|
startTransport();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"fixture": "c-keep",
|
||||||
|
"passed": true,
|
||||||
|
"checks": {
|
||||||
|
"frontend reported correctly": true,
|
||||||
|
"asks /keep|restyle/": true,
|
||||||
|
"asks /section/": true,
|
||||||
|
"never asks /^(?!.*(keep|restyle)).*(colou?r|Tebra Sky|Ember)/": true,
|
||||||
|
"never asks /desktop/": true,
|
||||||
|
"existing files unchanged": true,
|
||||||
|
"exactly one page built": true,
|
||||||
|
"no placeholders left": true,
|
||||||
|
"page has --accent:#0f766e": true,
|
||||||
|
"page has Trebuchet MS": true,
|
||||||
|
"page check passes": true
|
||||||
|
},
|
||||||
|
"page_check": [
|
||||||
|
"22 passed in 18.02s"
|
||||||
|
],
|
||||||
|
"files": [
|
||||||
|
"README.md",
|
||||||
|
"web\\css\\app.css",
|
||||||
|
"web\\css\\theme.css",
|
||||||
|
"web\\dashboard.html",
|
||||||
|
"web\\index.html",
|
||||||
|
"web\\js\\app.js"
|
||||||
|
],
|
||||||
|
"reply": "Dashboard ready \u2014 **`web/dashboard.html`** (web path, plain HTML, sample data).\n\n| Item | Status |\n|---|---|\n| Look | **Kept** \u2014 the colours and fonts come from `web/css/theme.css` (teal brand, Trebuchet/Georgia), for both light and dark |\n| Existing files | **Not touched** (index.html, theme.css, app.css, app.js are unchanged) |\n| Sections | Sidebar, top bar (dark mode toggle + status dot), KPI cards, status donut, Run panel (steps + log), Report table (search + Excel). No optional sections. |\n| Data | `SAMPLE_DATA = true` \u2014 there is no bot yet. Steps are `Initializing \u2192 Load jobs \u2192 Process jobs \u2192 Completed`. Report columns are Job / Site / Status. |\n\n**Checks (headless Playwright):** 6 KPI batches, 4 legend items, 4/4 steps done, 5 log lines, 4/4 report rows with pills. No `{{` left and no console errors. I checked light, dark and 800px wide.\n\n- **Small tweak:** the Run button was the same teal as the top bar, so it was hard to see. I set `--primary-bg` to the project's own `--color-brand-strong` (#115e59).\n- **Palette fill-ins:** the project has no token for `--accent-2`, `--accent-dark` or `--accent-soft-2`, so those come from `palette.py`. All contrast checks pass.\n- **When the bot exists:** it must send the `status`, `step`, `log`, `dashboard_data` and `report_data` events. Set `SAMPLE_DATA=false` and serve `/api/dashboard` and `/api/run/status`.\n- **Navigation:** add a link to `dashboard.html` from your nav in `index.html`. I didn't edit that file.\n\nFRONTEND: web/ \u2014 plain HTML (web), tokens in web/css/theme.css (:root + [data-theme=\"dark\"]), brand #0f766e, fonts \"Trebuchet MS\"/Georgia\nQUESTIONS: Keep its look, or restyle it? | Which optional sections should I add: checkpoint tracker, admin/roles screen, login page, or none?"
|
||||||
|
}
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
<!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/dashboard and /run/status).
|
||||||
|
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>Field Ops Portal</title>
|
||||||
|
<script src="qrc:///qtwebchannel/qwebchannel.js"></script>
|
||||||
|
<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">
|
||||||
|
<style>
|
||||||
|
/* ===== THEME TOKENS: Superbill Ember (see themes.md) ===== */
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
/* ===== 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}
|
||||||
|
@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="" alt="" onerror="this.remove()"></div>
|
||||||
|
<div><div class="logo-title">Field Ops Portal</div><div class="logo-sub">Job automation</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>
|
||||||
|
</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>
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ===== CONFIG (fill per project) =====
|
||||||
|
const STEPS = ["Initializing","Load jobs","Process jobs","Completed"];
|
||||||
|
const REPORT_COLUMNS = [{"key":"job","label":"Job"},{"key":"site","label":"Site"},{"key":"status","label":"Status"}];
|
||||||
|
const API_BASE = "/api"; // web mode only
|
||||||
|
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(()=>{
|
||||||
|
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); },
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===== 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 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){ $('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>`;
|
||||||
|
}
|
||||||
|
renderSteps();
|
||||||
|
startTransport();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"fixture": "c-restyle",
|
||||||
|
"passed": true,
|
||||||
|
"checks": {
|
||||||
|
"frontend reported correctly": true,
|
||||||
|
"asks /keep|restyle/": true,
|
||||||
|
"asks /Tebra Sky|Ember/": true,
|
||||||
|
"asks /section/": true,
|
||||||
|
"never asks /desktop/": true,
|
||||||
|
"existing files: only token lines changed": true,
|
||||||
|
"existing tokens now use #ff3049": true,
|
||||||
|
"exactly one page built": true,
|
||||||
|
"no placeholders left": true,
|
||||||
|
"page has --accent:#ff3049": true,
|
||||||
|
"page check passes": true
|
||||||
|
},
|
||||||
|
"page_check": [
|
||||||
|
"22 passed in 23.29s"
|
||||||
|
],
|
||||||
|
"files": [
|
||||||
|
"README.md",
|
||||||
|
"web\\css\\app.css",
|
||||||
|
"web\\css\\theme.css",
|
||||||
|
"web\\dashboard.html",
|
||||||
|
"web\\index.html",
|
||||||
|
"web\\js\\app.js"
|
||||||
|
],
|
||||||
|
"reply": "Dashboard ready, Superbill Ember theme-la restyle pannirukken. Frontend `web/`-la irundhudhu (plain HTML, web). Andha site-la token lines and oru font link mattum dhaan maathinen.\n\n| Item | Result |\n|---|---|\n| Pudhu page | `web/dashboard.html` (sample data, `SAMPLE_DATA = true`) |\n| Sections | Shell, KPI, donut, run panel, report. Optional sections edhuvum illa; `OPTIONAL` marker-um illa |\n| Restyle | `web/css/theme.css` token values + `web/index.html`-la font link |\n| Check | `{{` 0 left \u00b7 KPI total 6 \u00b7 legend 4 \u00b7 steps 4/4, log 5 lines, status \"Completed\" \u00b7 report 4 rows / 4 pills \u00b7 light, dark, 800px screenshots paathen, ellam correct-ah irukku |\n\n**Existing files-la changed lines:**\n```\ntheme.css :root --color-brand #0f766e\u2192#ff3049 \u00b7 -strong #115e59\u2192#18151f \u00b7 -tint #ccfbf1\u2192#f7f6fa\n --color-bg \u2192#f4f3f7 \u00b7 -surface-alt \u2192#f7f6fa \u00b7 -border \u2192#e2dfe9 \u00b7 -text \u2192#18151f \u00b7 -text-muted \u2192#66607a\n -success \u2192#1f9d57 \u00b7 -warning \u2192#b7791f \u00b7 -danger \u2192#c0392b\n --font-sans \u2192'IBM Plex Sans',\u2026 \u00b7 --font-heading \u2192'Sora',\u2026 (--color-surface, --radius maarala)\ntheme.css [dark] brand \u2192#ff3049 \u00b7 -strong \u2192#100e1a \u00b7 -tint \u2192#1b1727 \u00b7 bg \u2192#0a0912 \u00b7 surface \u2192#151220\n surface-alt \u2192#1b1727 \u00b7 border \u2192#2a2438 \u00b7 text \u2192#f3f1f8 \u00b7 muted \u2192#8d87a0 \u00b7 ok/warn/bad \u2192#2fbf72/#d9a13c/#e05a4b\nindex.html + Google Fonts <link> (IBM Plex Sans + Sora), theme.css link-ku munnaadi\n```\n\nBot innum illa. Bot ready aana, backend-ah wire pannanum: `GET /api/dashboard`, `GET /api/run/status` (idhu `{running, events[]}` return pannanum), and `POST /api/<action>`. Bot anuppa vendiya events: `status`, `step`, `log`, `dashboard_data`, `report_data`. `index.html`-oda nav-la `dashboard.html`-ku link nee dhaan podanum; adha naan touch pannala.\n\n**Also noticed (fix pannala):**\n- `app.css`-la hard-coded `#ffffff` irukku: topbar text, links, `.button`, `.tag`. Token-only restyle-nala adhu maarala.\n- Portal dark mode-la \"Dark mode\" button text theriyala. Text-um background-um ore colour family (`brand-strong` on `brand-tint`). Idhu munnaadiye irundha issue; teal theme-la rendum `#134e4a` dhaan.\n- Dashboard-la file:// vazhiya open pannum bodhu dark toggle `set-dark`-ku POST anuppudhu, adhu console-la oru error kaattudhu. Page-ku edhuvum aagala; server vazhiya serve pannina indha error varaadhu.\n\nFRONTEND: web/ \u2014 plain HTML (web), tokens in web/css/theme.css (:root + [data-theme=\"dark\"]), brand #0f766e teal, fonts \"Trebuchet MS\" / Georgia\nQUESTIONS: Keep its look, or restyle it? | Which colours: Tebra Sky, Superbill Ember, or Custom (one brand hex)? | Which optional sections to add: checkpoint tracker, admin/roles screen, login page, or none?"
|
||||||
|
}
|
||||||
+94
-6
@@ -9,6 +9,7 @@ prompt (`-p` can't answer questions). It then grades the reply, runs the Seam 1
|
|||||||
check on the page it built, and writes tests/evals/results/<fixture>.json.
|
check on the page it built, and writes tests/evals/results/<fixture>.json.
|
||||||
Exit code 0 means every check passed.
|
Exit code 0 means every check passed.
|
||||||
"""
|
"""
|
||||||
|
import difflib
|
||||||
import importlib.util
|
import importlib.util
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@@ -38,7 +39,7 @@ FIXTURES = {
|
|||||||
"sections": [],
|
"sections": [],
|
||||||
},
|
},
|
||||||
"a-empty-sections": {
|
"a-empty-sections": {
|
||||||
"dir": "a-empty",
|
"fixture": "a-empty",
|
||||||
"prompt": "Build me an RPA dashboard for this project. I can't answer questions while you "
|
"prompt": "Build me an RPA dashboard for this project. I can't answer questions while you "
|
||||||
"work, so here are my answers to anything the skill asks. Colours: Superbill Ember. "
|
"work, so here are my answers to anything the skill asks. Colours: Superbill Ember. "
|
||||||
"Desktop or web: web. Optional sections: all three (checkpoint tracker, admin/roles "
|
"Desktop or web: web. Optional sections: all three (checkpoint tracker, admin/roles "
|
||||||
@@ -48,14 +49,87 @@ FIXTURES = {
|
|||||||
"never_asks": [r"\bkeep\b|restyle"],
|
"never_asks": [r"\bkeep\b|restyle"],
|
||||||
"sections": ["checkpoints", "admin", "login"],
|
"sections": ["checkpoints", "admin", "login"],
|
||||||
},
|
},
|
||||||
|
"b-pyqt-bot": {
|
||||||
|
"prompt": "Build me an RPA dashboard for this project. I can't answer questions while you "
|
||||||
|
"work, so here are my answers to anything the skill asks. Colours: Tebra Sky. "
|
||||||
|
"Optional sections: none.",
|
||||||
|
"frontend": r"^\s*(none|no\b)",
|
||||||
|
"asks": [r"colou?r", r"section"],
|
||||||
|
"never_asks": [r"\bkeep\b|restyle", r"desktop|web"],
|
||||||
|
"bot": True,
|
||||||
|
},
|
||||||
|
# (c) existing teal frontend in web/, run once per answer to keep-or-restyle.
|
||||||
|
"c-keep": {
|
||||||
|
"fixture": "c-web-existing-frontend",
|
||||||
|
"prompt": "Build me an RPA dashboard for this project. I can't answer questions while you "
|
||||||
|
"work, so here are my answers to anything the skill asks. Keep or restyle the "
|
||||||
|
"existing frontend: keep its look. Optional sections: none.",
|
||||||
|
"frontend": r"(?=.*\bweb\b)(?=.*html)(?=.*(#0f766e|--color-brand))",
|
||||||
|
"asks": [r"keep|restyle", r"section"],
|
||||||
|
"never_asks": [r"^(?!.*(keep|restyle)).*(colou?r|Tebra Sky|Ember)", r"desktop"],
|
||||||
|
"page_has": ["--accent:#0f766e", "Trebuchet MS"],
|
||||||
|
"existing": "unchanged",
|
||||||
|
},
|
||||||
|
"c-restyle": {
|
||||||
|
"fixture": "c-web-existing-frontend",
|
||||||
|
"prompt": "Build me an RPA dashboard for this project. I can't answer questions while you "
|
||||||
|
"work, so here are my answers to anything the skill asks. Keep or restyle the "
|
||||||
|
"existing frontend: restyle it. Colours: Superbill Ember. Optional sections: none.",
|
||||||
|
"frontend": r"(?=.*\bweb\b)(?=.*html)(?=.*(#0f766e|--color-brand))",
|
||||||
|
"asks": [r"keep|restyle", r"Tebra Sky|Ember", r"section"],
|
||||||
|
"never_asks": [r"desktop"],
|
||||||
|
"page_has": ["--accent:#ff3049"],
|
||||||
|
"existing": "#ff3049", # only token lines may change, and the theme now uses this colour
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
# A line a restyle may change: a custom property, a comment, a blank, or a web-font link.
|
||||||
|
TOKEN_LINE = r"^\s*(--[\w-]+\s*:.*|/\*.*\*/|<link[^>]*fonts\.googleapis\.com.*|@import.*fonts\.googleapis\.com.*)?\s*$"
|
||||||
|
|
||||||
|
|
||||||
|
def squash(s):
|
||||||
|
"""Ignore spacing and case when looking for a token in the built page."""
|
||||||
|
return re.sub(r"\s+", "", s).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def changed_lines(src, project):
|
||||||
|
"""Lines removed or added in the fixture's own files."""
|
||||||
|
out = []
|
||||||
|
for f in src.rglob("*"):
|
||||||
|
if f.is_file():
|
||||||
|
new = project / f.relative_to(src)
|
||||||
|
after = new.read_text(encoding="utf-8").splitlines() if new.exists() else []
|
||||||
|
out += [x[1:] for x in difflib.unified_diff(f.read_text(encoding="utf-8").splitlines(), after, lineterm="", n=0)
|
||||||
|
if x[:1] in "+-" and x[:3] not in ("+++", "---")]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def bot_checks(project, html, checks, replay):
|
||||||
|
"""Bot fixtures: the page is wired, and a dry run of the generated wiring gives real steps and logs.
|
||||||
|
Returns the env that points the page check at that run."""
|
||||||
|
checks["page wired to the bot (sample data off)"] = bool(re.search(r"SAMPLE_DATA\s*=\s*false", html))
|
||||||
|
runners = [p for p in project.rglob("bot_runner.py") if ".claude" not in p.parts]
|
||||||
|
checks["bot_runner.py built"] = len(runners) == 1
|
||||||
|
if not runners:
|
||||||
|
return {}
|
||||||
|
host = [runners[0], runners[0].with_name("bridge.py")]
|
||||||
|
checks["no placeholders left in the host"] = all(p.exists() and "{{" not in p.read_text(encoding="utf-8") for p in host)
|
||||||
|
dry = subprocess.run([sys.executable, str(runners[0])], cwd=project, capture_output=True, text=True,
|
||||||
|
encoding="utf-8", timeout=300)
|
||||||
|
replay.write_text(dry.stdout, encoding="utf-8")
|
||||||
|
events = [json.loads(x) for x in dry.stdout.splitlines() if x.startswith("{")]
|
||||||
|
steps = [e["data"] for e in events if e["event"] == "step"]
|
||||||
|
checks["bot dry run ends done"] = bool(events) and events[-1]["data"].get("state") == "done"
|
||||||
|
checks["real steps reach the page"] = len({s["label"] for s in steps}) >= 3 and steps[-1]["pct"] == 100
|
||||||
|
checks["bot error line reaches the log"] = any(e["event"] == "log" and e["data"]["type"] == "error" for e in events)
|
||||||
|
return {"PAGE_CHECK_WIRED": "1", "PAGE_CHECK_REPLAY": str(replay)}
|
||||||
|
|
||||||
|
|
||||||
def main(fixture):
|
def main(fixture):
|
||||||
cfg = FIXTURES[fixture]
|
cfg = FIXTURES[fixture]
|
||||||
work = Path(tempfile.mkdtemp(prefix=f"rpa-eval-{fixture}-"))
|
work = Path(tempfile.mkdtemp(prefix=f"rpa-eval-{fixture}-"))
|
||||||
project = work / "project"
|
project = work / "project"
|
||||||
shutil.copytree(ROOT / "tests" / "fixtures" / cfg.get("dir", fixture), project)
|
src = ROOT / "tests" / "fixtures" / cfg.get("fixture", fixture)
|
||||||
|
shutil.copytree(src, project)
|
||||||
for skill in ROOT.glob("rpa-dashboard-*"):
|
for skill in ROOT.glob("rpa-dashboard-*"):
|
||||||
shutil.copytree(skill, project / ".claude" / "skills" / skill.name)
|
shutil.copytree(skill, project / ".claude" / "skills" / skill.name)
|
||||||
|
|
||||||
@@ -73,12 +147,21 @@ def main(fixture):
|
|||||||
return m[1] if m else ""
|
return m[1] if m else ""
|
||||||
|
|
||||||
checks = {"frontend reported correctly": bool(re.search(cfg["frontend"], line("FRONTEND"), re.I))}
|
checks = {"frontend reported correctly": bool(re.search(cfg["frontend"], line("FRONTEND"), re.I))}
|
||||||
|
questions = line("QUESTIONS").split("|")
|
||||||
for pat in cfg["asks"]:
|
for pat in cfg["asks"]:
|
||||||
checks[f"asks /{pat}/"] = bool(re.search(pat, line("QUESTIONS"), re.I))
|
checks[f"asks /{pat}/"] = any(re.search(pat, q, re.I) for q in questions)
|
||||||
for pat in cfg["never_asks"]:
|
for pat in cfg["never_asks"]:
|
||||||
checks[f"never asks /{pat}/"] = not re.search(pat, line("QUESTIONS"), re.I)
|
checks[f"never asks /{pat}/"] = not any(re.search(pat, q, re.I) for q in questions)
|
||||||
|
if "existing" in cfg:
|
||||||
|
changed = changed_lines(src, project)
|
||||||
|
if cfg["existing"] == "unchanged":
|
||||||
|
checks["existing files unchanged"] = not changed
|
||||||
|
else:
|
||||||
|
checks["existing files: only token lines changed"] = bool(changed) and all(re.match(TOKEN_LINE, x) for x in changed)
|
||||||
|
checks[f"existing tokens now use {cfg['existing']}"] = any(cfg["existing"] in x.lower() for x in changed)
|
||||||
|
|
||||||
pages = [p for p in project.rglob("*.html") if ".claude" not in p.parts]
|
pages = [p for p in project.rglob("*.html")
|
||||||
|
if ".claude" not in p.parts and not (src / p.relative_to(project)).exists()] # new pages only
|
||||||
checks["exactly one page built"] = len(pages) == 1
|
checks["exactly one page built"] = len(pages) == 1
|
||||||
results = ROOT / "tests" / "evals" / "results"
|
results = ROOT / "tests" / "evals" / "results"
|
||||||
results.mkdir(exist_ok=True)
|
results.mkdir(exist_ok=True)
|
||||||
@@ -99,8 +182,13 @@ def main(fixture):
|
|||||||
for name, trace in traces.TRACES.items():
|
for name, trace in traces.TRACES.items():
|
||||||
want = name in cfg["sections"]
|
want = name in cfg["sections"]
|
||||||
checks[f"{name} {'built' if want else 'left no trace'}"] = bool(trace.search(html)) == want
|
checks[f"{name} {'built' if want else 'left no trace'}"] = bool(trace.search(html)) == want
|
||||||
|
for needle in cfg.get("page_has", []):
|
||||||
|
checks[f"page has {needle}"] = squash(needle) in squash(html)
|
||||||
|
env = {**os.environ, "PAGE_CHECK_HTML": str(pages[0])}
|
||||||
|
if cfg.get("bot"):
|
||||||
|
env.update(bot_checks(project, html, checks, results / f"{fixture}.run.jsonl"))
|
||||||
pc = subprocess.run([sys.executable, "-m", "pytest", str(ROOT / "tests" / "page_check"), "-q"],
|
pc = subprocess.run([sys.executable, "-m", "pytest", str(ROOT / "tests" / "page_check"), "-q"],
|
||||||
env={**os.environ, "PAGE_CHECK_HTML": str(pages[0])}, capture_output=True, text=True)
|
env=env, capture_output=True, text=True)
|
||||||
checks["page check passes"] = pc.returncode == 0
|
checks["page check passes"] = pc.returncode == 0
|
||||||
page_check = pc.stdout.strip().splitlines()[-1:] if pc.stdout else [pc.stderr[-500:]]
|
page_check = pc.stdout.strip().splitlines()[-1:] if pc.stdout else [pc.stderr[-500:]]
|
||||||
if cfg.get("sections"): # every chosen section's page check ran
|
if cfg.get("sections"): # every chosen section's page check ran
|
||||||
|
|||||||
Vendored
+10
@@ -0,0 +1,10 @@
|
|||||||
|
# Work-item follow-up bot
|
||||||
|
|
||||||
|
A desktop RPA bot. It logs in to the portal and updates each work item.
|
||||||
|
The operator window will be a PyQt6 app.
|
||||||
|
|
||||||
|
Run the bot on its own:
|
||||||
|
|
||||||
|
```
|
||||||
|
python -m bot
|
||||||
|
```
|
||||||
Vendored
+29
@@ -0,0 +1,29 @@
|
|||||||
|
"""Work-item follow-up bot. Start it with: python -m bot"""
|
||||||
|
import time
|
||||||
|
|
||||||
|
ITEMS = ["WI-1001", "WI-1002", "WI-1003"]
|
||||||
|
|
||||||
|
|
||||||
|
def say(line):
|
||||||
|
print(line)
|
||||||
|
time.sleep(0.2)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
say("[step 1] Initializing")
|
||||||
|
say("Loading settings from config.ini")
|
||||||
|
say("Completed loading 3 work items")
|
||||||
|
say("[step 2] Logging in to the portal")
|
||||||
|
say("Login page loaded")
|
||||||
|
say("[login] ok")
|
||||||
|
say("[step 3] Processing work items")
|
||||||
|
for i, item in enumerate(ITEMS, 1):
|
||||||
|
say(f"[item] {item} updated ({i}/{len(ITEMS)})")
|
||||||
|
say("WARNING: portal slow to respond, waited 5s")
|
||||||
|
say("ERROR: could not attach the note to WI-1003")
|
||||||
|
say("[step 4] Writing the summary")
|
||||||
|
say(f"[all done] {len(ITEMS)} items processed")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
PyQt6
|
||||||
|
PyQt6-WebEngine
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Fixture (c): web project with an existing frontend
|
||||||
|
|
||||||
|
A plain HTML site in `web/` with its own teal theme: light and dark tokens in
|
||||||
|
`web/css/theme.css`, layout in `web/css/app.css`, logic in `web/js/app.js`. No bot.
|
||||||
|
|
||||||
|
The no-AI skill should report this frontend (location, stack, colours), ask keep or
|
||||||
|
restyle, never ask desktop or web, and build a dashboard page on sample data.
|
||||||
|
- Keep: no colour question; the page uses the theme's tokens and fonts; no existing
|
||||||
|
file changes.
|
||||||
|
- Restyle: asks for colours; only token lines in the existing files change.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/* Layout and components. Colours and fonts come from theme.css. */
|
||||||
|
* { box-sizing: border-box; margin: 0; }
|
||||||
|
body { font-family: var(--font-sans); background: var(--color-bg); color: var(--color-text); }
|
||||||
|
h1, h2 { font-family: var(--font-heading); }
|
||||||
|
.topbar { display: flex; align-items: center; gap: 16px; padding: 14px 24px; background: var(--color-brand); color: #ffffff; }
|
||||||
|
.topbar nav { margin-left: auto; display: flex; gap: 12px; }
|
||||||
|
.topbar a { color: #ffffff; text-decoration: none; }
|
||||||
|
.page { max-width: 960px; margin: 24px auto; padding: 0 16px; }
|
||||||
|
.card { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius); padding: 20px; }
|
||||||
|
.button { background: var(--color-brand); color: #ffffff; border: 0; border-radius: var(--radius); padding: 8px 14px; cursor: pointer; }
|
||||||
|
.button.secondary { background: var(--color-brand-tint); color: var(--color-brand-strong); }
|
||||||
|
table { width: 100%; border-collapse: collapse; margin-top: 12px; }
|
||||||
|
th { text-align: left; background: var(--color-surface-alt); color: var(--color-text-muted); }
|
||||||
|
th, td { padding: 8px 10px; border-bottom: 1px solid var(--color-border); }
|
||||||
|
.tag { padding: 2px 8px; border-radius: 999px; color: #ffffff; font-size: 12px; }
|
||||||
|
.tag.ok { background: var(--color-success); }
|
||||||
|
.tag.late { background: var(--color-warning); }
|
||||||
|
.tag.failed { background: var(--color-danger); }
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/* Field Ops Portal theme. Every colour and font the site uses comes from here. */
|
||||||
|
:root {
|
||||||
|
--color-brand: #0f766e;
|
||||||
|
--color-brand-strong: #115e59;
|
||||||
|
--color-brand-tint: #ccfbf1;
|
||||||
|
--color-bg: #f8fafc;
|
||||||
|
--color-surface: #ffffff;
|
||||||
|
--color-surface-alt: #f1f5f9;
|
||||||
|
--color-border: #e2e8f0;
|
||||||
|
--color-text: #0f172a;
|
||||||
|
--color-text-muted: #64748b;
|
||||||
|
--color-success: #15803d;
|
||||||
|
--color-warning: #b45309;
|
||||||
|
--color-danger: #b91c1c;
|
||||||
|
--font-sans: "Trebuchet MS", "Segoe UI", sans-serif;
|
||||||
|
--font-heading: Georgia, "Times New Roman", serif;
|
||||||
|
--radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--color-brand: #0d9488;
|
||||||
|
--color-brand-strong: #134e4a;
|
||||||
|
--color-brand-tint: #134e4a;
|
||||||
|
--color-bg: #0b1120;
|
||||||
|
--color-surface: #111827;
|
||||||
|
--color-surface-alt: #1f2937;
|
||||||
|
--color-border: #334155;
|
||||||
|
--color-text: #e2e8f0;
|
||||||
|
--color-text-muted: #94a3b8;
|
||||||
|
--color-success: #22c55e;
|
||||||
|
--color-warning: #f59e0b;
|
||||||
|
--color-danger: #ef4444;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Field Ops Portal</title>
|
||||||
|
<link rel="stylesheet" href="css/theme.css">
|
||||||
|
<link rel="stylesheet" href="css/app.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="topbar">
|
||||||
|
<h1>Field Ops Portal</h1>
|
||||||
|
<nav><a href="index.html">Jobs</a><button class="button secondary" id="theme-btn">Dark mode</button></nav>
|
||||||
|
</header>
|
||||||
|
<main class="page">
|
||||||
|
<section class="card">
|
||||||
|
<h2>Today's jobs</h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Job</th><th>Site</th><th>Status</th></tr></thead>
|
||||||
|
<tbody id="jobs"></tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<script src="js/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// Field Ops Portal: job list and dark toggle.
|
||||||
|
const JOBS = [
|
||||||
|
{ job: "J-101", site: "North depot", status: "Done", tag: "ok" },
|
||||||
|
{ job: "J-102", site: "Harbour road", status: "Late", tag: "late" },
|
||||||
|
{ job: "J-103", site: "West yard", status: "Failed", tag: "failed" },
|
||||||
|
];
|
||||||
|
|
||||||
|
document.getElementById("jobs").innerHTML = JOBS.map(
|
||||||
|
(j) => `<tr><td>${j.job}</td><td>${j.site}</td><td><span class="tag ${j.tag}">${j.status}</span></td></tr>`
|
||||||
|
).join("");
|
||||||
|
|
||||||
|
document.getElementById("theme-btn").onclick = () => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
root.dataset.theme = root.dataset.theme === "dark" ? "light" : "dark";
|
||||||
|
};
|
||||||
@@ -4,6 +4,8 @@ through window.__pyMsg, and assert on what is visible.
|
|||||||
Page under test: set PAGE_CHECK_HTML to a generated dashboard.html. Without it, the
|
Page under test: set PAGE_CHECK_HTML to a generated dashboard.html. Without it, the
|
||||||
no-AI template is filled with TEST_FILL. Every test runs in light, dark and narrow mode.
|
no-AI template is filled with TEST_FILL. Every test runs in light, dark and narrow mode.
|
||||||
The page sees a fake Qt bridge: each slot call lands in window.__calls as [slot, ...args].
|
The page sees a fake Qt bridge: each slot call lands in window.__calls as [slot, ...args].
|
||||||
|
PAGE_CHECK_WIRED=1: the page is wired to a bot (SAMPLE_DATA off), so sample-data cases skip.
|
||||||
|
PAGE_CHECK_REPLAY: a JSON-lines file of envelopes a real bot run produced (bot_runner.py's dry run).
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@@ -33,6 +35,14 @@ window.QWebChannel = function (_transport, ready) {
|
|||||||
};
|
};
|
||||||
"""
|
"""
|
||||||
MODES = {"light": (1280, 820, False), "dark": (1280, 820, True), "narrow": (800, 900, False)}
|
MODES = {"light": (1280, 820, False), "dark": (1280, 820, True), "narrow": (800, 900, False)}
|
||||||
|
WIRED = os.environ.get("PAGE_CHECK_WIRED") == "1"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_data():
|
||||||
|
"""Use on cases that need the built-in sample data; they skip on a bot-wired page."""
|
||||||
|
if WIRED:
|
||||||
|
pytest.skip("page is wired to a bot, so it has no sample data")
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
@@ -40,6 +50,8 @@ def page_url(tmp_path_factory):
|
|||||||
if os.environ.get("PAGE_CHECK_HTML"):
|
if os.environ.get("PAGE_CHECK_HTML"):
|
||||||
return Path(os.environ["PAGE_CHECK_HTML"]).resolve().as_uri()
|
return Path(os.environ["PAGE_CHECK_HTML"]).resolve().as_uri()
|
||||||
html = re.sub(r"\{\{(\w+)\}\}", lambda m: TEST_FILL[m[1]], TEMPLATE.read_text(encoding="utf-8"))
|
html = re.sub(r"\{\{(\w+)\}\}", lambda m: TEST_FILL[m[1]], TEMPLATE.read_text(encoding="utf-8"))
|
||||||
|
if WIRED:
|
||||||
|
html = html.replace("const SAMPLE_DATA = true", "const SAMPLE_DATA = false")
|
||||||
out = tmp_path_factory.mktemp("page") / "dashboard.html"
|
out = tmp_path_factory.mktemp("page") / "dashboard.html"
|
||||||
out.write_text(html, encoding="utf-8")
|
out.write_text(html, encoding="utf-8")
|
||||||
return out.as_uri()
|
return out.as_uri()
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""A bot run as the page sees it: status, step and log envelopes in order, from a real bot
|
||||||
|
run (PAGE_CHECK_REPLAY, the JSON lines bot_runner.py's dry run prints) or a built-in one."""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from conftest import WIRED
|
||||||
|
from playwright.sync_api import expect
|
||||||
|
from test_contract import colour_of, kpi, open_tab
|
||||||
|
|
||||||
|
LOG_COLOURS = {"info": "blue", "success": "green", "warn": "amber", "error": "red"}
|
||||||
|
DOT_COLOURS = {"ready": "green", "running": "amber", "done": "green", "error": "red"}
|
||||||
|
|
||||||
|
|
||||||
|
def built_in_run(steps):
|
||||||
|
run = [{"event": "status", "data": {"state": "running", "msg": "Running…"}}]
|
||||||
|
for i, label in enumerate(steps):
|
||||||
|
run.append({"event": "step", "data": {"label": label, "pct": round((i + 1) / len(steps) * 100)}})
|
||||||
|
run.append({"event": "log", "data": {"msg": f"Finished {label}.", "type": "success"}})
|
||||||
|
run.insert(4, {"event": "log", "data": {"msg": "ERROR: portal timed out, retrying", "type": "error"}})
|
||||||
|
return run + [{"event": "status", "data": {"state": "done", "msg": "Completed"}}]
|
||||||
|
|
||||||
|
|
||||||
|
def recorded_run():
|
||||||
|
path = os.environ.get("PAGE_CHECK_REPLAY")
|
||||||
|
return [json.loads(x) for x in Path(path).read_text(encoding="utf-8-sig").splitlines() if x.strip()] if path else None
|
||||||
|
|
||||||
|
|
||||||
|
def expect_status(page, state, msg):
|
||||||
|
# The status indicator has no role of its own, so it's found by its documented ids.
|
||||||
|
expect(page.locator("#st-text")).to_have_text(msg or state)
|
||||||
|
assert colour_of(page.locator("#st-dot"), "backgroundColor") == DOT_COLOURS[state], state
|
||||||
|
run = page.get_by_role("button", name="▶ Run", exact=True)
|
||||||
|
expect(run).to_be_disabled() if state == "running" else expect(run).to_be_enabled()
|
||||||
|
|
||||||
|
|
||||||
|
def test_bot_run_shows_status_steps_and_log(page, push):
|
||||||
|
steps = page.evaluate("STEPS")
|
||||||
|
run = recorded_run() or built_in_run(steps)
|
||||||
|
open_tab(page, "Run")
|
||||||
|
expect_status(page, "ready", "System ready")
|
||||||
|
items = page.get_by_role("list", name="Steps").get_by_role("listitem")
|
||||||
|
log, last_step = page.get_by_role("log"), -1
|
||||||
|
for envelope in run:
|
||||||
|
event, d = envelope["event"], envelope["data"]
|
||||||
|
push(event, d)
|
||||||
|
if event == "status":
|
||||||
|
expect_status(page, d["state"], d.get("msg"))
|
||||||
|
elif event == "step":
|
||||||
|
assert d["label"] in steps, f"step label {d['label']!r} is not one of the page's STEPS {steps}"
|
||||||
|
idx = steps.index(d["label"])
|
||||||
|
assert idx >= last_step, f"progress went back from {steps[last_step]!r} to {d['label']!r}"
|
||||||
|
last_step = idx
|
||||||
|
expect(page.get_by_text(f"{d['pct']}%", exact=True)).to_be_visible()
|
||||||
|
if d["pct"] >= 100:
|
||||||
|
for i in range(len(steps)):
|
||||||
|
expect(items.nth(i)).to_have_text(re.compile(r"^\s*✓"))
|
||||||
|
else:
|
||||||
|
expect(items.nth(idx)).to_have_attribute("aria-current", "step")
|
||||||
|
elif event == "log":
|
||||||
|
line = log.get_by_text(d["msg"], exact=True).last
|
||||||
|
expect(line).to_be_visible()
|
||||||
|
assert colour_of(line) == LOG_COLOURS[d["type"]], d["msg"]
|
||||||
|
assert any(e["event"] == "step" for e in run), "the run never moved the progress bar"
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_run_shows_error_status(page, push):
|
||||||
|
push("status", {"state": "running", "msg": "Running…"})
|
||||||
|
expect_status(page, "running", "Running…")
|
||||||
|
push("status", {"state": "error", "msg": "Failed (exit code 1)"})
|
||||||
|
expect_status(page, "error", "Failed (exit code 1)")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not WIRED, reason="only for a page wired to a bot (PAGE_CHECK_WIRED=1)")
|
||||||
|
def test_wired_page_sends_its_actions_to_the_bot(page):
|
||||||
|
calls = lambda: page.evaluate("window.__calls") # noqa: E731
|
||||||
|
assert ["load_dashboard"] in calls()
|
||||||
|
kpi(page, "Total batches", 0) # no sample batches on a wired page
|
||||||
|
page.get_by_role("button", name="▶ Run", exact=True).click()
|
||||||
|
assert ["start_automation"] in calls()
|
||||||
|
open_tab(page, "Report")
|
||||||
|
assert ["load_latest_report"] in calls()
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"""No-AI contract cases: events in, visible result out (see templates/contract.md)."""
|
"""No-AI contract cases: events in, visible result out (see templates/contract.md)."""
|
||||||
import re
|
import re
|
||||||
|
|
||||||
|
import pytest
|
||||||
from playwright.sync_api import expect
|
from playwright.sync_api import expect
|
||||||
|
|
||||||
|
|
||||||
@@ -146,6 +147,7 @@ def expect_sample_dashboard(page):
|
|||||||
kpi(page, "Total batches", n)
|
kpi(page, "Total batches", n)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("sample_data")
|
||||||
def test_no_bot_page_runs_on_sample_data(page):
|
def test_no_bot_page_runs_on_sample_data(page):
|
||||||
"""No bot yet: the page answers its own data actions, so it works on day one."""
|
"""No bot yet: the page answers its own data actions, so it works on day one."""
|
||||||
expect_sample_dashboard(page)
|
expect_sample_dashboard(page)
|
||||||
@@ -162,6 +164,7 @@ def test_no_bot_page_runs_on_sample_data(page):
|
|||||||
expect(run).to_be_enabled()
|
expect(run).to_be_enabled()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("sample_data")
|
||||||
def test_plain_browser_first_open_shows_sample_data(browser, page_url):
|
def test_plain_browser_first_open_shows_sample_data(browser, page_url):
|
||||||
pg = browser.new_page()
|
pg = browser.new_page()
|
||||||
pg.goto(page_url)
|
pg.goto(page_url)
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"""Bot wiring self-check (templates/bot_runner.py): which page events each raw bot line makes."""
|
||||||
|
import importlib.util
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
RUNNER = Path(__file__).resolve().parents[1] / "rpa-dashboard-no-ai" / "templates" / "bot_runner.py"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def runner():
|
||||||
|
spec = importlib.util.spec_from_file_location("bot_runner", RUNNER)
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
mod.STEP_MARKERS = [("[step 1]", "Initializing", 10), ("[step 2]", "Login", 40),
|
||||||
|
("[step 3]", "Process items", 70), ("[all done]", "Completed", 100)]
|
||||||
|
mod.FRIENDLY_RULES = [("[login] done", "Logged in.", "success"), ("[login]", "Logging in...", "info"),
|
||||||
|
("could not save receipt", "Could not save the receipt.", "error")]
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
def steps(runner, line):
|
||||||
|
return [d for e, d in runner.events_for(line) if e == "step"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_step_marker_moves_progress(runner):
|
||||||
|
assert steps(runner, "[step 2] Opening the login page") == [{"label": "Login", "pct": 40}]
|
||||||
|
assert steps(runner, "[STEP 3] Processing 12 items") == [{"label": "Process items", "pct": 70}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_common_word_does_not_move_progress(runner):
|
||||||
|
assert steps(runner, "Login page loaded slowly") == []
|
||||||
|
assert steps(runner, "Completed loading the config") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_first_step_marker_wins(runner):
|
||||||
|
assert steps(runner, "[step 1] retry after [step 3] timed out") == [{"label": "Initializing", "pct": 10}]
|
||||||
|
|
||||||
|
|
||||||
|
def logs(runner, line):
|
||||||
|
return [d for e, d in runner.events_for(line) if e == "log"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_friendly_rule_rewrites_the_line_and_first_rule_wins(runner):
|
||||||
|
assert logs(runner, "[login] done in 2.1s") == [{"msg": "Logged in.", "type": "success"}]
|
||||||
|
assert logs(runner, "[LOGIN] sending credentials") == [{"msg": "Logging in...", "type": "info"}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_unmatched_non_error_lines_are_hidden(runner):
|
||||||
|
for line in ["Loading config from settings.ini", "[step 2] Opening the login page", "warning: slow page"]:
|
||||||
|
assert logs(runner, line) == [], line
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("line", [
|
||||||
|
"ERROR: portal timed out", "Traceback (most recent call last):", "ValueError: bad date",
|
||||||
|
"Exception: boom", "FATAL disk full", "Login failed for user 3"])
|
||||||
|
def test_unmatched_error_line_is_always_shown_raw(runner, line):
|
||||||
|
assert logs(runner, line) == [{"msg": line, "type": "error"}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_matched_error_line_shows_its_friendly_message(runner):
|
||||||
|
assert logs(runner, "ERROR could not save receipt 42") == [{"msg": "Could not save the receipt.", "type": "error"}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_error_on_a_step_line_moves_progress_and_is_shown(runner):
|
||||||
|
line = "[step 3] ERROR item 2 rejected"
|
||||||
|
assert runner.events_for(line) == [("step", {"label": "Process items", "pct": 70}),
|
||||||
|
("log", {"msg": line, "type": "error"})]
|
||||||
|
|
||||||
|
|
||||||
|
def run_bot(runner, tmp_path, script):
|
||||||
|
"""Run a fake bot through runner.run(); return [(seconds since start, event, data)]."""
|
||||||
|
bot = tmp_path / "fake_bot.py"
|
||||||
|
bot.write_text(script, encoding="utf-8")
|
||||||
|
runner.BOT_CMD, runner.BOT_CWD, runner.RAW_LOG = [sys.executable, str(bot)], tmp_path, tmp_path / "raw.log"
|
||||||
|
t0, sent = time.monotonic(), []
|
||||||
|
runner.run(lambda event, data: sent.append((time.monotonic() - t0, event, data)))
|
||||||
|
return sent
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_streams_bot_output_live_from_a_child_process(runner, tmp_path):
|
||||||
|
sent = run_bot(runner, tmp_path, "import os, time\n"
|
||||||
|
"print('[step 1] pid', os.getpid())\n"
|
||||||
|
"print('[login] done')\n"
|
||||||
|
"time.sleep(1)\n"
|
||||||
|
"print('[all done] ✓ 3 items')\n")
|
||||||
|
events = [(e, d) for _, e, d in sent]
|
||||||
|
assert events == [("status", {"state": "running", "msg": "Running…"}),
|
||||||
|
("step", {"label": "Initializing", "pct": 10}),
|
||||||
|
("log", {"msg": "Logged in.", "type": "success"}),
|
||||||
|
("step", {"label": "Completed", "pct": 100}),
|
||||||
|
("status", {"state": "done", "msg": "Completed"})]
|
||||||
|
assert sent[3][0] - sent[1][0] > 0.5, "lines must reach the page while the bot runs, not at exit"
|
||||||
|
raw = (tmp_path / "raw.log").read_text(encoding="utf-8")
|
||||||
|
assert "[login] done" in raw and "✓ 3 items" in raw # raw lines kept for audit
|
||||||
|
assert f"pid {os.getpid()}" not in raw # the bot ran in its own process
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_ends_in_error_when_the_bot_fails(runner, tmp_path):
|
||||||
|
sent = run_bot(runner, tmp_path, "print('[step 1] start')\nraise SystemExit(3)\n")
|
||||||
|
assert sent[-1][1:] == ("status", {"state": "error", "msg": "Failed (exit code 3)"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_ends_in_error_when_the_bot_cannot_start(runner, tmp_path):
|
||||||
|
runner.BOT_CMD, runner.BOT_CWD, runner.RAW_LOG = ["no-such-bot-xyz"], tmp_path, tmp_path / "raw.log"
|
||||||
|
sent = []
|
||||||
|
runner.run(lambda event, data: sent.append((event, data)))
|
||||||
|
assert sent[-2][0] == "log" and sent[-2][1]["type"] == "error"
|
||||||
|
assert sent[-1] == ("status", {"state": "error", "msg": "Could not start the bot"})
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"""Frontend detection: where an existing frontend is, its stack and its colour tokens."""
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
DETECT = Path(__file__).resolve().parents[1] / "rpa-dashboard-no-ai" / "templates" / "detect_frontend.py"
|
||||||
|
spec = importlib.util.spec_from_file_location("detect_frontend", DETECT)
|
||||||
|
detect_frontend = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(detect_frontend)
|
||||||
|
|
||||||
|
|
||||||
|
def project(tmp_path, files):
|
||||||
|
for name, text in files.items():
|
||||||
|
(tmp_path / name).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
(tmp_path / name).write_text(text, encoding="utf-8")
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
def cli(root):
|
||||||
|
return subprocess.run([sys.executable, str(DETECT), str(root)], capture_output=True, text=True, check=True).stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_frontend_when_only_tooling_and_installed_skills_have_html(tmp_path):
|
||||||
|
root = project(tmp_path, {
|
||||||
|
"main.py": "print('[step 1] Login')\n",
|
||||||
|
"node_modules/pkg/index.html": "<style>:root{--x:#123456}</style>",
|
||||||
|
".venv/lib/site.html": "<p>",
|
||||||
|
".claude/skills/rpa-dashboard-no-ai/templates/dashboard.html": "<style>body{--accent:#3d9bd0}</style>",
|
||||||
|
"dist/index.html": "<p>",
|
||||||
|
})
|
||||||
|
assert detect_frontend.detect(root) == []
|
||||||
|
assert cli(root).strip() == "No existing frontend found."
|
||||||
|
|
||||||
|
|
||||||
|
FIXTURE_C = Path(__file__).resolve().parent / "fixtures" / "c-web-existing-frontend"
|
||||||
|
|
||||||
|
|
||||||
|
def test_plain_html_site_reports_location_stack_light_and_dark_tokens():
|
||||||
|
[f] = detect_frontend.detect(FIXTURE_C)
|
||||||
|
assert f["path"] == "web/"
|
||||||
|
assert f["stack"] == "plain HTML (web)"
|
||||||
|
light, dark = f["blocks"]
|
||||||
|
assert (light["file"], light["selector"], light["dark"]) == ("web/css/theme.css", ":root", False)
|
||||||
|
assert light["tokens"]["--color-brand"] == "#0f766e"
|
||||||
|
assert light["tokens"]["--font-sans"] == '"Trebuchet MS", "Segoe UI", sans-serif'
|
||||||
|
assert (dark["selector"], dark["dark"]) == ('[data-theme="dark"]', True)
|
||||||
|
assert dark["tokens"]["--color-brand"] == "#0d9488"
|
||||||
|
|
||||||
|
out = cli(FIXTURE_C)
|
||||||
|
first = out.splitlines()[0]
|
||||||
|
assert first.startswith("Frontend found at `web/`, stack `plain HTML (web)`, colours `--color-brand #0f766e,")
|
||||||
|
assert 'fonts `"Trebuchet MS", "Segoe UI", sans-serif`' in first
|
||||||
|
assert "web/css/theme.css" in out and '[data-theme="dark"]' in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_react_app_is_one_frontend_with_its_media_query_dark_tokens(tmp_path):
|
||||||
|
root = project(tmp_path, {
|
||||||
|
"server.py": "from fastapi import FastAPI\n",
|
||||||
|
"frontend/package.json": json.dumps({"dependencies": {"react": "^19.0.0", "react-dom": "^19.0.0"},
|
||||||
|
"devDependencies": {"vite": "^6.0.0"}}),
|
||||||
|
"frontend/index.html": '<div id="root"></div><script type="module" src="/src/main.jsx"></script>',
|
||||||
|
"frontend/src/index.css": ":root { --primary: 222.2 47.4% 11.2%; --radius: .5rem }\n"
|
||||||
|
"@media (prefers-color-scheme: dark) { :root { --primary: 210 40% 98% } }",
|
||||||
|
"frontend/node_modules/react/index.html": "<p>",
|
||||||
|
})
|
||||||
|
[f] = detect_frontend.detect(root)
|
||||||
|
assert (f["path"], f["stack"]) == ("frontend/", "React (Vite, web)")
|
||||||
|
light, dark = f["blocks"]
|
||||||
|
assert (light["file"], light["tokens"]["--primary"], light["dark"]) == ("frontend/src/index.css", "222.2 47.4% 11.2%", False)
|
||||||
|
assert (dark["selector"], dark["tokens"], dark["dark"]) == (
|
||||||
|
"@media (prefers-color-scheme: dark) :root", {"--primary": "210 40% 98%"}, True)
|
||||||
|
assert "colours `--primary 222.2 47.4% 11.2%`" in cli(root)
|
||||||
|
|
||||||
|
|
||||||
|
def test_vue_app_reads_component_styles_and_tailwind_theme(tmp_path):
|
||||||
|
root = project(tmp_path, {
|
||||||
|
"package.json": json.dumps({"dependencies": {"vue": "^3.5.0"}, "devDependencies": {"tailwindcss": "^3.4.0"}}),
|
||||||
|
"tailwind.config.js": "module.exports = { theme: { extend: { colors: { brand: '#e11d48', 'brand-dark': \"#9f1239\" } } } }",
|
||||||
|
"src/App.vue": "<template><p>{{ msg }}</p></template>\n<style>:root { --surface: #ffffff; }</style>",
|
||||||
|
})
|
||||||
|
[f] = detect_frontend.detect(root)
|
||||||
|
assert (f["path"], f["stack"]) == ("./", "Vue (Tailwind, web)")
|
||||||
|
blocks = {b["file"]: b for b in f["blocks"]}
|
||||||
|
assert blocks["src/App.vue"]["tokens"] == {"--surface": "#ffffff"}
|
||||||
|
assert blocks["tailwind.config.js"]["tokens"] == {"brand": "#e11d48", "brand-dark": "#9f1239"}
|
||||||
|
assert "brand #e11d48" in cli(root).splitlines()[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_pyqt_web_view_is_a_desktop_frontend(tmp_path):
|
||||||
|
host = "from PyQt6.QtWebEngineWidgets import QWebEngineView\nview = QWebEngineView()\n"
|
||||||
|
root = project(tmp_path / "file", {
|
||||||
|
"app.py": host + "view.load(QUrl.fromLocalFile('ui/page.html'))\n",
|
||||||
|
"bot.py": "print('[step 1] Login')\n",
|
||||||
|
"ui/page.html": "<style>:root { --accent: #2563eb; }</style>",
|
||||||
|
})
|
||||||
|
[f] = detect_frontend.detect(root)
|
||||||
|
assert (f["path"], f["stack"]) == ("ui/", "PyQt web view (desktop, host `app.py`)")
|
||||||
|
assert f["blocks"][0]["tokens"] == {"--accent": "#2563eb"}
|
||||||
|
|
||||||
|
inline = project(tmp_path / "inline", {"main.py": host + "view.setHtml('<style>body{--accent:#16a34a}</style><p>Hi</p>')\n"})
|
||||||
|
[f] = detect_frontend.detect(inline)
|
||||||
|
assert (f["path"], f["stack"]) == ("main.py", "PyQt web view (desktop, host `main.py`)")
|
||||||
|
assert f["blocks"][0]["tokens"] == {"--accent": "#16a34a"}
|
||||||
@@ -9,7 +9,8 @@ ROOT = Path(__file__).resolve().parents[1]
|
|||||||
HOOK_RULES = "E9,F63,F7,F82,F811" # same rule set as the PostToolUse lint hook
|
HOOK_RULES = "E9,F63,F7,F82,F811" # same rule set as the PostToolUse lint hook
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("template", ["rpa-dashboard-no-ai/templates/bridge.py"])
|
@pytest.mark.parametrize("template", ["rpa-dashboard-no-ai/templates/bridge.py",
|
||||||
|
"rpa-dashboard-no-ai/templates/bot_runner.py"])
|
||||||
def test_template_passes_lint_hook_unfilled(template):
|
def test_template_passes_lint_hook_unfilled(template):
|
||||||
ruff = shutil.which("ruff") or pytest.skip("ruff not on PATH")
|
ruff = shutil.which("ruff") or pytest.skip("ruff not on PATH")
|
||||||
r = subprocess.run([ruff, "check", "--select", HOOK_RULES, "--no-cache", str(ROOT / template)],
|
r = subprocess.run([ruff, "check", "--select", HOOK_RULES, "--no-cache", str(ROOT / template)],
|
||||||
|
|||||||
Reference in new issue
Block a user