Compare commits

...
10 Commits
Author SHA1 Message Date
VasanthandClaude Sonnet 5.5 4e08a444eb Merge origin/main: keep skills/ layout, take rpa-run-tab ADRs and SKILL.md wording
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 17:25:31 +05:30
VasanthandClaude Sonnet 5.5 df29f5728b Shorten root README to match the repo style
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 17:21:43 +05:30
VasanthandClaude Sonnet 5.5 d95e5d452d Move all three skills into skills/; add root README; fix paths
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 17:04:38 +05:30
VasanthandClaude Sonnet 5.5 312742cfef Eval results for tickets 04-07; grader ignores bot-run skips; resolve tickets 01-07
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 16:56:16 +05:30
VasanthandClaude Opus 5.5 eb90db64e8 rpa-run-tab: short README for the skill folder
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 16:35:48 +05:30
VasanthandClaude Opus 5.5 a57b32e8cb Add rpa-run-tab skill
A Tebra-style Run tab for desktop PyQt6 bots, built in about 5 minutes:
Trigger button, Excel input card, progress with timer, live log, checkpoint
boxes with time limits, and green/amber/red endings. It is checked with a
fake bot (run_tab.py --selfcheck). The design decisions are in rpa-run-tab/docs/adr.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 16:30:59 +05:30
VasanthandClaude Opus 5.5 258bfbb500 Add rpa-run-tab skill: Tebra-style Run tab in 5 minutes
The Run tab from Tebra Auto Post, scrubbed and themable (Tebra Sky, TVL Teal,
or a custom hex checked for contrast). The skill wires the real bot through
tagged log-file lines and checks itself with a fake bot (complete, partial,
stuck, crash) plus light and dark screenshots. Grill decisions R1-R14 are in
PLAN.md, terms in GLOSSARY.md, and ADRs 0001-0003.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 16:29:18 +05:30
VasanthandClaude Opus 5.5 8bb1cf268e SKILL.md: shorter AI description, skill-relative template paths, marker pointers
AI description trimmed to 754 chars (test: both stay <= 1024). Both SKILL.md files say templates/ (and <no-ai>) resolve against the skill's own folder, skills side by side; no-AI commands use <skill>/templates/. AI step 5 names the App.tsx/RunPanel.tsx markers and no longer remembers the model per user.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:59:00 +05:30
VasanthandClaude Opus 5.5 24a064875e AI web: OPTIONAL markers for login and the admin screen; drop per-user model memory
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:58:54 +05:30
VasanthandClaude Opus 5.5 461798127a AI sections fall back to fetch with no Qt bridge, for non-React web frontends
ai_sections.html fetches {API_BASE}+path when window.qt is absent; a page-check case routes those requests to server_stub.dispatch(). AI SKILL.md step 6 and frontend.md send plain-HTML/Vue/Angular/Svelte projects to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:55:30 +05:30
109 changed files with 4486 additions and 66 deletions

No files matched your search

+2
View File
@@ -3,3 +3,5 @@ __pycache__/
node_modules/
.venv/
.claude/worktrees/
rpa-run-tab/logs/
skills/rpa-run-tab/logs/
@@ -12,7 +12,7 @@ An automated page check proves the page renders the standard data contract corre
**Blocked by:** None (can start immediately)
**Status:** ready-for-agent
**Status:** resolved
- [ ] The desktop host template passes the lint hook unfilled. Its placeholders go inside string literals.
- [ ] The no-AI skill file exists with a trigger description ("RPA dashboard", "automation dashboard", "bot dashboard", "without AI") and the workflow in spec order: scan → report/keep-or-restyle → colours → stack → optional sections → build → wire → verify.
@@ -28,3 +28,7 @@ An automated page check proves the page renders the standard data contract corre
- the dark toggle flips the theme.
- [ ] The page check runs in light, dark and narrow width (≤820px).
- [ ] Skill eval on test project (a), an empty project: the skill asks colours, stack and sections, never reports a frontend, and its output passes the page check.
## Comments
Resolved on branch `integration/rpa-dashboard-skills` (Oct 16, 2026). Tests: `uv run --with pytest --with playwright pytest tests` all green; skill eval results in `tests/evals/results/`.
@@ -11,7 +11,7 @@ Part of: ../spec.md
**Blocked by:** 01
**Status:** ready-for-agent
**Status:** resolved
- [ ] The skill detects the PyQt project and the bot entry point, and fills in the host template's bot command, steps, step markers and friendly rules from the bot's real output.
- [ ] Clicking Run starts the bot in a separate process. The Run button disables itself while running, and the status dot goes ready → running → done/error.
@@ -20,3 +20,7 @@ Part of: ../spec.md
- [ ] The dark-mode choice persists across app restarts.
- [ ] A Python assert-based self-check covers line classification, step-marker matching and friendly-rule matching, including the "never hide an error" case.
- [ ] Skill eval on test project (b), a PyQt project with a step-marker bot: the skill doesn't ask the stack question, and the running page shows real steps and log lines.
## Comments
Resolved on branch `integration/rpa-dashboard-skills` (Oct 16, 2026). Tests: `uv run --with pytest --with playwright pytest tests` all green; skill eval results in `tests/evals/results/`.
@@ -6,10 +6,14 @@ Part of: ../spec.md
**Blocked by:** 01
**Status:** ready-for-agent
**Status:** resolved
- [ ] Detection covers plain HTML, React/Vue projects and an embedded PyQt web view, and reports location, stack and the main colour tokens.
- [ ] "Keep": the new dashboard page uses the project's existing tokens and fonts. No existing styles are changed.
- [ ] "Restyle": only token blocks change. A diff shows no layout or logic edits.
- [ ] The colour question is skipped when the user keeps the existing look. The stack question is skipped whenever a frontend exists.
- [ ] Skill eval on test project (c), a web project with an existing frontend and its own colours: the skill reports the frontend correctly. Both branches produce a page that passes the page check.
## Comments
Resolved on branch `integration/rpa-dashboard-skills` (Oct 16, 2026). Tests: `uv run --with pytest --with playwright pytest tests` all green; skill eval results in `tests/evals/results/`.
@@ -11,10 +11,14 @@ Sections that aren't picked leave no code behind.
**Blocked by:** 01
**Status:** ready-for-agent
**Status:** resolved
- [ ] The skill asks for the optional sections with a multi-select and builds only the chosen ones.
- [ ] Checkpoint tracker: the page check pushes checkpoint-state events and asserts each box shows pending, active, done or stuck.
- [ ] Admin/roles: the page check asserts the admin nav and screen appear for the admin role and are hidden for operators.
- [ ] Login page: renders in the chosen theme, light and dark, and hands off to the project's auth or a stub.
- [ ] With no optional sections picked, the output contains no leftover markup, scripts or styles for them.
## Comments
Resolved on branch `integration/rpa-dashboard-skills` (Oct 16, 2026). Tests: `uv run --with pytest --with playwright pytest tests` all green; skill eval results in `tests/evals/results/`.
@@ -9,7 +9,7 @@ Part of: ../spec.md
**Blocked by:** 01
**Status:** ready-for-agent
**Status:** resolved
- [ ] The AI skill file exists with a trigger description ("AI RPA dashboard", "extraction dashboard", "LLM/OCR review dashboard") and the shared workflow.
- [ ] An AI contract extends the no-AI contract. These shapes came from the Superbill prototype:
@@ -25,3 +25,7 @@ Part of: ../spec.md
- approve stays blocked while rows are unverified (the server stub refuses);
- "page X of Y" updates while running.
- [ ] Skill eval on test project (d), a project with an LLM extraction call: the skill picks the AI path, asks the shared questions, and its output passes the page check with sample data.
## Comments
Resolved on branch `integration/rpa-dashboard-skills` (Oct 16, 2026). Tests: `uv run --with pytest --with playwright pytest tests` all green; skill eval results in `tests/evals/results/`.
@@ -12,7 +12,7 @@ Cost comes from a pricing table that records its source and its as-of date. Opti
**Blocked by:** 05
**Status:** ready-for-agent
**Status:** resolved
- [ ] Cost per call = input tokens × input rate + (output + thinking tokens) × output rate, per million tokens. Long-context rates apply above the threshold.
- [ ] An unknown model raises an error that names the known models. Cost never silently defaults to zero.
@@ -20,3 +20,7 @@ Cost comes from a pricing table that records its source and its as-of date. Opti
- [ ] Contract: `RoiRun{model,input_tokens,output_tokens,thinking_tokens,total_cost}` and a fetch-ROI action. (Shape from the Superbill prototype.)
- [ ] The page check asserts that the spend tiles sum the runs, the coverage pill counts priced vs. total documents, and the month breakdown groups runs correctly.
- [ ] The model picker and retry-on-other-model are offered as optional sections and built only when chosen.
## Comments
Resolved on branch `integration/rpa-dashboard-skills` (Oct 16, 2026). Tests: `uv run --with pytest --with playwright pytest tests` all green; skill eval results in `tests/evals/results/`.
@@ -6,10 +6,14 @@ Part of: ../spec.md
**Blocked by:** 02, 05
**Status:** ready-for-agent
**Status:** resolved
- [ ] Plain-HTML AI sections exist, use the shared theme tokens, and render in light, dark and narrow width.
- [ ] The AI events and actions travel over the desktop bridge using the same envelope as the no-AI path.
- [ ] Approval is still enforced in Python on the bridge side. Changing the page cannot bypass it.
- [ ] The page check runs the AI cases from tickets 05 and 06 against the desktop page and they pass.
- [ ] Skill eval on a PyQt test project with an LLM extraction call: the skill picks the desktop AI path, and its output passes the page check.
## Comments
Resolved on branch `integration/rpa-dashboard-skills` (Oct 16, 2026). Tests: `uv run --with pytest --with playwright pytest tests` all green; skill eval results in `tests/evals/results/`.
+1 -1
View File
@@ -1,6 +1,6 @@
# RPA SKILLS
Shared Claude skills for building RPA dashboards: `rpa-dashboard-no-ai` and `rpa-dashboard-ai`. See `PLAN.md`.
Shared Claude skills for RPA bots, all in `skills/`: `rpa-run-tab` (Tebra-style Run tab), `rpa-dashboard-no-ai` and `rpa-dashboard-ai`. See `README.md` and `PLAN.md`.
## Agent skills
+34 -1
View File
@@ -1,5 +1,38 @@
# Glossary
**Run tab**: the full screen an operator uses to start and watch one bot run, modelled on Tebra Auto Post. It has the Trigger button, the input card and the run card. Built by its own skill.
**Run tab**: the full screen an operator uses to start and watch one bot run, modelled on Tebra Auto Post. It has the Trigger button, the input card and the run card. Built by the `rpa-run-tab` skill.
**Run panel**: the smaller run section inside `rpa-dashboard-no-ai`: progress bar, steps and live log. It is a subset of the Run tab. Don't use the two names for the same thing.
**Trigger button**: the single start button in the top bar. It has three states:
- off: "Upload a file to continue"
- on: "Trigger automation"
- going: "Running…"
There is no Stop or Pause.
**Input card**: the Excel upload part of the Run tab:
- sample download
- drop zone
- file row
- sheet tabs
- preview of up to 200 rows, with password columns masked
- Clear file
It is hidden for bots that take no input file.
**Run card**: the progress part of the Run tab:
- progress bar with elapsed timer
- step list
- on-screen log
- checkpoint boxes
**Checkpoint**: one named stage of a bot run, with a time limit in seconds (0 = no limit). The bot announces it with `[CPSTATE] active N` and `[CPSTATE] done N`. A project's checkpoint list drives the step list, the checkpoint boxes and the progress % on the Run tab. _Avoid_: "step" for a separate concept. In the Run tab, the steps are the checkpoints.
**Stuck**: a checkpoint that has been active for longer than its time limit. A stuck run is killed and ends as failed (ADR-0003).
**Outcome**: how a run ended, read from the bot's `__RUN_SUMMARY__` line:
- **completed** (green)
- **partial**: some items failed (amber)
- **failed**: crash, no summary, or stuck (red)
**Fake bot**: a bundled script that prints the Run tab protocol lines, used to verify the page. The skill never runs the real bot.
+58 -7
View File
@@ -73,12 +73,12 @@ Build both in `D:\RPA SKILLS\<skill-name>\`. The user installs them into Claude
## Status: what already exists
```
rpa-dashboard-no-ai/
skills/rpa-dashboard-no-ai/
templates/dashboard.html DONE: generic page, works in Qt (QWebChannel) or web (polling)
templates/contract.md DONE: event/JSON contract + Tebra lessons
templates/bridge.py DONE: minimal PyQt6 host + subprocess log tailing
templates/reference/ DONE: tebra_dashboard.html, tebra_bridge_part1/2.py (raw extracts)
rpa-dashboard-ai/
skills/rpa-dashboard-ai/
templates/reference/web/ DONE: Superbill index.css, DESIGN.md, bands.ts, theme.tsx, types.ts, api.ts,
components (BrandHeader, Sidebar, StatsBar, Donut, SplitPane,
RunPanel, ReviewPane, RoiTab, PdfPane, Tooltip, icons)
@@ -86,24 +86,24 @@ rpa-dashboard-ai/
```
## TODO for the implement skill
1. **Fix the lint error in `rpa-dashboard-no-ai/templates/bridge.py:15`.** `STEPS = {{STEPS_PY}}` is not valid Python, so the lint hook flags it (F821). Change it to `STEPS = ["{{STEP_1}}", "{{STEP_2}}"]` or similar.
2. **Write `rpa-dashboard-no-ai/SKILL.md`.**
1. **Fix the lint error in `skills/rpa-dashboard-no-ai/templates/bridge.py:15`.** `STEPS = {{STEPS_PY}}` is not valid Python, so the lint hook flags it (F821). Change it to `STEPS = ["{{STEP_1}}", "{{STEP_2}}"]` or similar.
2. **Write `skills/rpa-dashboard-no-ai/SKILL.md`.**
- Frontmatter `name` and `description`. The trigger should cover "RPA dashboard", "automation dashboard", "bot dashboard", and "without AI".
- The workflow above.
- Point to `templates/*`.
3. **Write `rpa-dashboard-ai/SKILL.md`.**
3. **Write `skills/rpa-dashboard-ai/SKILL.md`.**
- Trigger: "AI RPA dashboard", "extraction dashboard", "LLM/OCR review dashboard".
- The workflow above.
- Web path: React + Vite + Tailwind v4, adapted from `reference/web`.
- Desktop path: copy the no-AI `dashboard.html`, then add the AI sections.
4. **Write `templates/themes.md` in both skills.** Content is in the Theme presets section above.
5. **Write `rpa-dashboard-ai/templates/contract.md`.** It extends the no-AI contract with:
5. **Write `skills/rpa-dashboard-ai/templates/contract.md`.** It extends the no-AI contract with:
- `Run{job_id,status:pending|queued|running|done|failed,model,pages_done,pages_total,summary:{green,amber,red,unscored}}`
- `ReviewRow{row_id,field,value,band,score,reason,verified,corrected_value}`
- `RoiRun{model,input_tokens,output_tokens,thinking_tokens,total_cost}`
- Endpoints: `PATCH /review/{row_id}`, `POST /runs/{id}/approve` (server gate), `GET /roi`.
- Polling: 1.5–2s only while a run is busy.
6. **Write `rpa-dashboard-ai/templates/ai-sections.html`.** Plain HTML/CSS/JS versions of the band legend, the review split pane and the ROI tiles, for the desktop path.
6. **Write `skills/rpa-dashboard-ai/templates/ai-sections.html`.** Plain HTML/CSS/JS versions of the band legend, the review split pane and the ROI tiles, for the desktop path.
7. **Strip project-specific bits from the AI reference files.** Remove ECW/Tebra project names, SPB_* tables and patient/ticket wording, or mark them as such. **Never** ship real patient data (PHI), internal IPs or keys. The reference copies have already been checked for these, but check again after editing.
8. **Verify.** Open `dashboard.html` in a browser using sample data. Check light, dark and narrow width. Load each SKILL.md and make sure every path it references exists.
@@ -113,3 +113,54 @@ rpa-dashboard-ai/
- asks the questions in order,
- reports any existing frontend,
- produces a working dashboard page that renders in light and dark.
---
# Plan: `rpa-run-tab` skill (third skill)
Goal: a skill that adds Tebra Auto Post's **Run tab** to any desktop RPA project in **5 minutes**. The look is the same as Tebra; only the colours are the user's choice.
- Source: `D:\Tebra_Auto_Post` `src/ui/main_window.py`. The Run tab code is at lines 1871–3979 (page) and 268–1142 (bridge).
- Also `run_app.py` (the `--run-automation` child process) and `core.py` (`wd_mark`).
- Take the tokenised accent from `D:\Tebra_Auto_Post_TVL`. Bundle a scrubbed template; never read D:\ at runtime.
- Terms are in `GLOSSARY.md`; decisions are in `docs/adr/0001`–`0003`.
## Decisions (from the grill)
| # | Question | Decision |
|---|---|---|
| R1 | Skill shape | A new standalone skill, `D:\RPA SKILLS\rpa-run-tab\`. Later, `rpa-dashboard-no-ai` points to it instead of keeping its own Run panel. |
| R2 | DB parts (batch chip, resume, clear-approval, roles) | **Left out.** No DB needed. Clear file just clears the file. |
| R3 | Stack | **Desktop only**: PyQt6 + QWebEngine. Runs from Python or a PyInstaller exe. |
| R4 | Input | Excel input card by default, with sheet tabs, a 200-row preview and password columns masked. **Hidden** for bots that take no file; the Trigger button is then ready straight away. |
| R5 | Steps vs checkpoints | **One checkpoint list** (name + time limit) drives the steps, the boxes and the progress %. See ADR-0001. |
| R6 | Stuck | Kill the process tree and end as failed "Stuck at X". No auto-restart. See ADR-0003. |
| R7 | On-screen log | The bot tags lines `[UI]`, `[UI:ok]`, `[UI:warn]`, `[UI:error]`. Errors and tracebacks always show. All raw lines go to the run log file. See ADR-0002. |
| R8 | Host | **Detect.** Add a tab to an existing PyQt web-view page or dashboard. Otherwise build a new window: Tebra top bar + Run tab, no sidebar. |
| R9 | Colours | Presets: 1) Tebra Sky, 2) TVL Teal, 3) Custom hex. A custom hex is turned into gradient and accent colours for light and dark, with a contrast check. ok/warn/bad and the log colours stay fixed. |
| R10 | Scope of the 5 minutes | Page **plus the real bot wired**. The skill reads the bot and proposes a checkpoint list for the user to confirm in one reply. It then adds the `[CPSTATE]`, `[UI]` and `__RUN_SUMMARY__` prints. |
| R11 | Endings | Green "Completed in …". Amber "Finished: N of M failed", with the failed items in the log. Red "Failed after …" or "Stuck at X". |
| R12 | Stop | **No Stop or Pause**, same as Tebra. Closing the window asks first, then kills the process tree. |
| R13 | Sample input | A headers-only sample built from the sheets and columns the bot reads, plus one fake row. The button is hidden if the skill can't tell. Never real data. |
| R14 | Verify | **Fake bot only.** It covers completed, partial and stuck runs in light and dark. The skill never presses Trigger on the real bot. |
## Kept from Tebra as is
- Glass cards and the sky gradient. The Segoe UI font. The Consolas log with no timestamps.
- The dark toggle, saved to QSettings.
- The status dot: ready / running / done / error.
- Elapsed timers on the progress bar and on each checkpoint box.
- Progress only ever moves forward. Consecutive duplicate log lines are dropped.
- A double-run guard. The parent-death watcher in the child process.
- Python → page messages go through `window.__pyMsg` only, never also through `js_message.connect`, which delivers every message twice.
## Tebra bugs not to copy
- A partial failure shows "Completed" at 100%. Fixed by R11.
- `STOP_REQUESTED` set only in the parent process. There is no Stop, and reload no longer fakes "Stopped by user".
- The checkpoint time limit in JS (300) and in Python (600) disagree. One list (R5) fixes this.
- Dead CSS and JS: `.top-ctrl-btn`, `.run-btn`, `showRerunModal`, `#rerun-mask`.
## Runtime questions (keep to ≤3 for the 5-minute budget)
1. Colour: preset or custom hex.
2. Confirm the proposed checkpoint list, with names and time limits.
3. Excel input or no input. Skip this when the bot reading clearly answers it.
The title and subtitle come from the project name. The user can edit them later.
+11
View File
@@ -0,0 +1,11 @@
# RPA Skills
Claude skills for RPA projects. Each folder in `skills/` is one skill: copy it into `~/.claude/skills/` to install it.
| Skill | What it does |
|---|---|
| `rpa-run-tab` | Adds a Tebra-style Run tab (Trigger button, Excel input, progress, live log, checkpoints) to a desktop PyQt6 bot in about 5 minutes. |
| `rpa-dashboard-no-ai` | Builds a full dashboard (run panel, overview, report, optional checkpoints, admin and login) for a bot with no AI. Desktop or web. |
| `rpa-dashboard-ai` | The same dashboard, plus confidence bands, review-and-approve and a cost/ROI page, for a bot that uses an LLM or OCR. Install it together with `rpa-dashboard-no-ai`. |
Run all tests: `uv run --with pytest --with playwright pytest tests`
@@ -0,0 +1,11 @@
# One checkpoint list drives the whole Run tab
Tebra Auto Post keeps two overlapping lists:
- 8 **steps**, which move forward when known phrases appear in the log (`STEP_MARKERS`).
- 7 **checkpoints**, which move forward on `[CPSTATE]` lines and have time limits.
The Run tab skill keeps one checkpoint list. The bot's `[CPSTATE] active|done N` lines move the step list, the checkpoint boxes and the progress % together. The screen still looks like Tebra.
**Why:** there is one list to ask the user for, so the skill fits in 5 minutes. And a typo in a log phrase can no longer stall the progress bar without anyone noticing.
**Cost:** the progress bar moves in whole checkpoints, not Tebra's finer per-phrase percentages.
@@ -0,0 +1,11 @@
# The bot talks to the Run tab through tagged lines in a log file
The bot runs as a **separate process** with no Qt. The Run tab starts it with `--log <file>` and reads that file every 0.2 s. It does not read stdout. The bot prints:
- `[CPSTATE] active N` / `[CPSTATE] done N`: checkpoint progress (see ADR-0001).
- `[UI] text`, `[UI:ok]`, `[UI:warn]`, `[UI:error]`: lines the operator sees. Error lines and tracebacks always show, even without a tag. Every raw line stays in the run log file.
- `__RUN_SUMMARY__{json}` as its last line. The outcome comes from this line, not from the exit code. If the line is missing, the run counts as failed, so a crash never shows green.
**Why:** this comes from Tebra Auto Post. UI automation inside a Qt worker thread was unreliable (bug-094). A windowed PyInstaller child's stdout pipe is block-buffered, so log lines arrived in late bursts; reading the log file fixed that. Tagging lines saves each project from writing a table of phrase→friendly-text rules.
**Conflict:** `skills/rpa-dashboard-no-ai/templates/bot_runner.py` reads `stdout=PIPE`, which is the approach Tebra dropped. Fix it there, or point it at the Run tab skill's runner.
@@ -0,0 +1,7 @@
# A stuck run is killed and fails; no auto-restart, no Stop button
When a checkpoint passes its time limit, the Run tab kills the bot's whole process tree and ends the run as failed: "Stuck at <checkpoint>, run again". It does not restart. There is no Stop or Pause button. A run also ends early when the operator closes the window, which asks first and then kills the process tree.
**Why:** Tebra Auto Post restarts a stuck bot up to 3 times. That is only safe because its SQL batch table lets a restarted bot skip work it already did. The Run tab skill has no DB (decision Q2), so a restart could do work twice, for example post a payment twice. Tebra removed Stop on purpose ("a run always goes to completion"), and the Run tab keeps the same look.
**Reopen if:** a project's bot is rerun-safe (it skips items already done). Then auto-restart ×3 can come back as an option.
@@ -1,6 +1,6 @@
---
name: rpa-dashboard-ai
description: Builds a working dashboard for an RPA bot or service that uses AI (an LLM or OCR extraction call). It has everything from rpa-dashboard-no-ai (sidebar, top bar with dark toggle and status dot, KPI cards, batch donut, run panel with steps and live log, report table with search and export) plus confidence bands, a review split pane (source document beside the extracted fields, band filters, draft edits, Verify), an approve gate the server enforces, and a cost/ROI page (tokens and cost per run, total and monthly spend, priced coverage, an against-doing-it-by-hand panel). Web path is React + Vite + Tailwind with a sample-data server stub; desktop path (PyQt) is the no-AI page plus plain-HTML AI sections over the Qt bridge, gate enforced in Python. Use when the user asks for an "AI RPA dashboard", an "extraction dashboard", an "LLM/OCR review dashboard", or any RPA/bot dashboard in a project that calls an LLM or OCR to extract fields. Needs rpa-dashboard-no-ai installed next to it.
description: Builds a working dashboard for an RPA bot or service that uses AI (an LLM or OCR extraction call). It has everything from rpa-dashboard-no-ai plus confidence bands, a review split pane (source document beside the extracted fields, band filters, drafts, Verify), an approve gate the backend enforces, and a cost/ROI page (tokens and cost per run, monthly spend, an against-doing-it-by-hand panel). Web: React + Vite + Tailwind with a sample-data server stub; desktop (PyQt): plain-HTML AI sections over the Qt bridge. Use when the user asks for an "AI RPA dashboard", an "extraction dashboard", an "LLM/OCR review dashboard", or any RPA/bot dashboard in a project that calls an LLM or OCR to extract fields. Needs rpa-dashboard-no-ai installed next to it.
---
# RPA dashboard (AI)
@@ -9,13 +9,19 @@ Everything this skill needs is bundled. Never read the original source projects.
Shared files live in the no-AI skill, installed next to this one: `<no-ai>` below
means `../rpa-dashboard-no-ai/templates/`.
**Paths:** both skills must be installed side by side (same parent folder). Resolve
`templates/...` against this skill's own folder (the one holding this SKILL.md), and
`<no-ai>` against it too (`<this skill>/../rpa-dashboard-no-ai/templates/`), never against
the user's project. Run the scripts with those absolute paths, from the user's project, for
example `python <this skill>/../rpa-dashboard-no-ai/templates/detect_frontend.py <project root>`.
| File | What it is |
|---|---|
| `templates/web/` | The React + Vite + Tailwind starter: shell, KPI, donut, run panel, report, **band legend, review split pane, approve gate, cost page**, sample data. |
| `templates/web/pricing.py` | The pricing table (with its source and as-of date) and `cost_for_call`. The backend imports it. |
| `templates/web/server_stub.py` | Sample-data backend for runs, review rows and the approve gate. Standard library only. |
| `templates/web/bands.py` | The band cut points and rules. The one source of truth: the backend imports it. |
| `templates/desktop/ai_sections.html` | Desktop: the Review tab (band legend, review split pane, drafts, Verify, approve gate) and the Cost tab in plain HTML, pasted into `<no-ai>dashboard.html`. |
| `templates/desktop/ai_sections.html` | Desktop, and web projects with a non-React frontend: the Review tab (band legend, review split pane, drafts, Verify, approve gate) and the Cost tab in plain HTML, pasted into `<no-ai>dashboard.html`. Talks over the Qt bridge, or with no Qt, fetches the `templates/contract.md` endpoints. |
| `templates/desktop/ai_bridge.py` | Desktop: the no-AI `Bridge` plus the `ai_request` slot that hands every AI request to the Python review backend. |
| `templates/contract.md` | The AI contract: runs, review rows, endpoints and the gate, over HTTP or the Qt bridge. Extends `<no-ai>contract.md`. |
| `templates/reference/` | Superbill Review extracts, marked EXAMPLE ONLY. Use them to port extra polish, never copy their project names. |
@@ -59,7 +65,8 @@ Skip this if the user chose keep. Offer:
Ask only if there is no frontend. If there is one, use its stack. If the project
uses PyQt, choose desktop and say so.
- **Web:** the React starter in `templates/web/`, served by the backend.
- **Web:** the React starter in `templates/web/`, served by the backend. An existing
non-React frontend gets the plain-HTML page instead (step 6, web).
- **Desktop:** the no-AI PyQt page with `templates/desktop/ai_sections.html` pasted
in, hosted by `templates/desktop/ai_bridge.py`. No web server, no npm.
@@ -71,11 +78,12 @@ pane, the approve gate and the cost page. Ask which extras to add (choose any, o
the model per batch), **retry on another model** (for failed runs). Build only the
chosen ones at their `OPTIONAL:` markers, and delete the markers.
- **Checkpoint tracker, admin/roles, login:** web: port them from `<no-ai>dashboard.html`'s
`OPTIONAL:` blocks. Desktop: they're already in the no-AI page; `<no-ai>sections.py` keeps
`OPTIONAL:` blocks to the markers in `RunPanel.tsx` (checkpoints) and `App.tsx` (admin tab
and screen, login). Desktop: they're already in the no-AI page; `<no-ai>sections.py` keeps
the chosen ones (no-AI step 6), and `<no-ai>sections.md` says what each needs from the host.
- **Model picker:** a `<select>` filled from `GET /models` (the pricing table's keys), at
the marker in `Cost.tsx` (desktop: `ai_sections.html`) or next to the Run button; the chosen model goes with the
batch's start action. Remember the last choice per user.
batch's start action.
- **Retry on another model:** on a `failed` run, at the marker in `Review.tsx` (desktop: `ai_sections.html`): a model
`<select>` (from `GET /models`, defaulting to the run's own model) and a Retry button
that sends `POST /runs/{job_id}/retry {model}`, then polls the run as usual.
@@ -83,9 +91,17 @@ Both need the endpoints in `templates/contract.md`; add them to the backend (and
## 6. Build (web)
1. Copy `templates/web/` into the project as `dashboard/` (an existing frontend:
see `<no-ai>frontend.md` section 3; in an existing React app, port the
`src/` components into it). Don't copy `node_modules/` or `dist/`.
**Existing plain-HTML, Vue, Angular or Svelte frontend:** don't use the React starter.
Build the no-AI page as `../rpa-dashboard-no-ai/SKILL.md` step 6 says (web, with
`API_BASE` filled, for example `/api`), paste the whole of `templates/desktop/ai_sections.html`
into it just before `</body>`, and put it where `<no-ai>frontend.md` section 3 says. With no
Qt bridge, its AI sections fetch `{API_BASE}` + the paths in `templates/contract.md`, so
the backend serves those (step 7). Port it into the app's components only if the user asks,
with `ai_sections.html` as the reference. Then skip to step 7.
1. Copy `templates/web/` into the project as `dashboard/` (an existing React app:
port the `src/` components into it, see `<no-ai>frontend.md` section 3).
Don't copy `node_modules/` or `dist/`.
2. Fill every `{{...}}` in `dashboard/index.html`'s CONFIG block:
| Placeholder | Value |
@@ -122,7 +138,7 @@ Both need the endpoints in `templates/contract.md`; add them to the backend (and
The contracts: `<no-ai>contract.md` for the shell, `templates/contract.md` for runs,
review rows and the gate.
- **Sample data (no backend for these endpoints yet):** keep `SAMPLE_DATA = true` in
`src/store.ts` and use `server_stub.py`. Replace its `SAMPLE_FIELDS` with the
`src/store.ts` (a plain-HTML page: in `dashboard.html`) and use `server_stub.py`. Replace its `SAMPLE_FIELDS` with the
fields the project's extraction really returns (name, a sample value, word scores,
mandatory). Tell the user which endpoints their backend must serve later.
- **Backend exists:** add the endpoints from `templates/contract.md` to it. Copy
@@ -2,9 +2,11 @@
Paste this whole block into the no-AI dashboard.html, just before </body>. It adds a Review
tab: band legend, band filters, the source document beside its extracted fields, drafts,
Verify, and Approve. Plain HTML, the page's own theme tokens, no build step.
Every request goes over the Qt bridge as one slot call, ai_request(id, method, path, body),
and comes back as an `ai_response` envelope (see ../contract.md, "Desktop"). The approve
gate is the Python backend's: this page only asks, and shows the refusal.
In PyQt, every request goes over the Qt bridge as one slot call, ai_request(id, method, path,
body), and comes back as an `ai_response` envelope (see ../contract.md, "Desktop"). With no
Qt (a plain-HTML web project), it fetches {API_BASE}{path} from the backend instead, the
web endpoints in ../contract.md. The approve gate is the backend's: this page only asks,
and shows the refusal.
-->
<style>
/* BAND COLOURS: semantic only (swatches, the rule down a field). The same in every preset and both modes. */
@@ -117,10 +119,19 @@ showTab=t=>{
};
window.addEventListener('beforeunload',e=>{ if(dirty().length) e.preventDefault(); });
// ----- transport: one bridge slot out, one `ai_response` envelope back -----
// ----- transport: in PyQt, one bridge slot out, one `ai_response` envelope back;
// with no Qt (a plain web page), fetch {API_BASE}{path} from the backend -----
const pending={}; let seq=0;
async function aiFetch(method,path,body){
const r=await fetch(API_BASE+path,{method,headers:{'Content-Type':'application/json'},
body:method==='GET'?undefined:JSON.stringify(body||{})});
const b=await r.json().catch(()=>({}));
if(!r.ok) throw new Error(b.detail||`Error ${r.status}`);
return b;
}
function ai(method,path,body){
if(!bridge) return Promise.reject(new Error('Review needs the desktop app (no Qt bridge here).'));
if(!window.qt) return aiFetch(method,path,body);
if(!bridge) return Promise.reject(new Error('The Qt bridge is not ready yet. Try again.'));
return new Promise((ok,fail)=>{ const id=++seq; pending[id]=[ok,fail];
bridge.ai_request(id,method,path,JSON.stringify(body||{})); });
}
@@ -149,7 +160,8 @@ function openRun(id){
clearTimeout(timer);
jobId=id; run=null; rows=[]; drafts={}; refusal=null;
$('rv-doc-pick').value=id;
ai('GET',`/runs/${id}/document`).then(doc=>{ if(id===jobId) $('rv-doc').src=
if(!window.qt) $('rv-doc').src=`${API_BASE}/runs/${encodeURIComponent(id)}/document`; // web: the URL itself
else ai('GET',`/runs/${id}/document`).then(doc=>{ if(id===jobId) $('rv-doc').src=
String(doc).startsWith('<') ? 'data:image/svg+xml;charset=utf-8,'+encodeURIComponent(doc) : doc; }).catch(()=>{});
render(); tick(id);
}
@@ -12,7 +12,7 @@ const TABS = {
review: ['Review', 'Check extracted fields against the document', <><path d="M4 4h10l6 6v10H4z" /><path d="m8 14 3 3 5-6" /></>],
report: ['Report', 'Result of every processed item', <><path d="M4 4h16v16H4z" /><path d="M8 9h8M8 13h8M8 17h5" /></>],
cost: ['Cost', 'Tokens, spend and the case against doing it by hand', <><path d="M12 2v20" /><path d="M17 6H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" /></>],
// OPTIONAL: admin/roles tab goes here
// OPTIONAL: admin/roles tab goes here (its nav entry; the screen is below, in <section>)
} as const
type Tab = keyof typeof TABS
@@ -37,6 +37,7 @@ export function App() {
act('start_automation')
}
// OPTIONAL: login page goes here (until signed in, return the sign-in screen instead of the shell)
return (
<div className="flex h-screen">
<aside className="flex w-[230px] min-w-[230px] flex-col border-r border-line bg-glass max-[820px]:w-[66px] max-[820px]:min-w-[66px]" style={{ backdropFilter: 'var(--blur)' }}>
@@ -101,6 +102,7 @@ export function App() {
{tab === 'review' && <Review onUnsaved={setUnsaved} />}
{tab === 'report' && <Report />}
{tab === 'cost' && <Cost />}
{/* OPTIONAL: admin/roles screen goes here (admins only) */}
</section>
</main>
</div>
@@ -8,6 +8,10 @@ description: Builds a working dashboard page for an RPA bot that does not use AI
Everything this skill needs is bundled in `templates/`. Never read the original
source projects.
**Paths:** `templates/...` is in this skill's own folder (the one holding this SKILL.md),
not in the user's project. Below, `<skill>` is that folder's absolute path: run the
scripts as `python <skill>/templates/<script>.py`, from the user's project.
| File | What it is |
|---|---|
| `templates/dashboard.html` | The single-file page: theme tokens, shell, KPI, donut, run panel, report, sample data. Works in PyQt (QWebChannel) or on the web (polling). |
@@ -27,7 +31,7 @@ If the user's request already answers a question, don't ask it again.
## 1. Scan the project
Before asking anything, look at the project:
- **Frontend:** run `python templates/detect_frontend.py <project root>`. It finds
- **Frontend:** run `python <skill>/templates/detect_frontend.py <project root>`. It finds
plain HTML, React/Vue/Angular/Svelte apps and PyQt web views, and prints each
one's location, stack and colour tokens. `templates/frontend.md` says what it
covers and what to do with its report.
@@ -76,7 +80,7 @@ Ask which extras to add, as one multi-select question (choose any, or none):
off to the project's own auth.
Build only the chosen ones. In step 6, right after copying the page, run
`python templates/sections.py <copied dashboard.html> <chosen: checkpoints admin login>`.
`python <skill>/templates/sections.py <copied dashboard.html> <chosen: checkpoints admin login>`.
It deletes the unchosen sections completely (markup, script and style), so nothing
is left behind. Never cut the blocks by hand. Then follow `templates/sections.md` for
each kept section: what to fill, which events the bot sends, and which host actions
@@ -90,7 +94,7 @@ to answer.
`dashboard/dashboard.html`.
- Existing frontend: put it where `templates/frontend.md` section 3 says.
Then run `python templates/sections.py <copied dashboard.html> <chosen sections>` (step 5).
Then run `python <skill>/templates/sections.py <copied dashboard.html> <chosen sections>` (step 5).
2. Fill every `{{...}}` placeholder:
| Placeholder | Value |
@@ -103,7 +107,7 @@ to answer.
3. Apply the theme by replacing the `body{...}` and `body.dark{...}` token blocks
with the preset from `templates/themes.md`. For Ember, also add its font link.
For a custom colour, run `python templates/palette.py "<hex>"`, paste the
For a custom colour, run `python <skill>/templates/palette.py "<hex>"`, paste the
tokens it prints, and show the user its contrast table. If the user chose keep,
fill the blocks from the project's tokens instead (`templates/frontend.md`
section 4).
@@ -54,6 +54,12 @@ before you report.
| React, Vue, Angular, Svelte | In the app's `public/` folder (`public/dashboard.html`), so it is served as-is. Port it into the app's components only if the user asks: best effort, with the page as reference. |
| PyQt web view | Next to the host's page. Use the desktop path (`bridge.py`). |
**AI skill (`rpa-dashboard-ai`):** a React app gets the React starter's components
ported in. Plain HTML, Vue, Angular and Svelte get this same page with the AI skill's
`templates/desktop/ai_sections.html` pasted in before `</body>`, placed as above. With no
Qt bridge it fetches `{API_BASE}` + the AI contract's endpoints. A port into components
uses `ai_sections.html` as the reference.
Don't edit existing pages to link to it. Tell the user the path to link from
their nav.
+35
View File
@@ -0,0 +1,35 @@
# rpa-run-tab
A Claude skill that adds a **Tebra-style Run tab** to any desktop RPA bot (PyQt6) in about 5 minutes.
## What you get
- **Trigger button** with three states: Upload a file / Trigger automation / Running...
- **Excel input card:** drop zone, sheet tabs, a 200-row preview with passwords masked, and a Sample input download
- **Progress bar** with an elapsed timer, a step list and a friendly live log
- **Checkpoint boxes** with time limits. A stuck checkpoint is killed and shown in red.
- **Endings:** green Completed, amber "N of M failed", red Failed or Stuck
- **Dark mode** and a close-while-running guard. The bot runs in its own process.
## Install
Copy this folder to `~/.claude/skills/rpa-run-tab/`.
## Use
In your bot project, ask Claude: *"add the run tab"*. The skill:
1. Scans your bot. It never runs it.
2. Asks three things: **colour** (Tebra Sky, TVL Teal, or your hex), **checkpoints** (names and time limits), and **Excel input or none**.
3. Builds `run_tab/` and adds a few `rt.checkpoint()` / `rt.ui()` lines to your bot.
4. Checks the result with a fake bot (`python run_tab/run_tab.py --selfcheck`).
Start it with `python run_tab/run_tab.py`.
## Files
| File | What it is |
|---|---|
| `SKILL.md` | Steps Claude follows |
| `templates/run_tab.html` | The page (layout fixed, colours themable) |
| `templates/run_tab.py` | PyQt6 window and bridge, plus the CONFIG block and `--selfcheck` |
| `templates/runner.py` | Starts the bot, reads its log, handles stuck checkpoints and the ending |
| `templates/rt.py` | Helpers the bot calls |
| `templates/fake_bot.py` | Fake bot used by the self-check |
| `templates/palette.py` | Colour presets or a custom hex, with a contrast check |
| `docs/adr/` | Why it works this way |
+141
View File
@@ -0,0 +1,141 @@
---
name: rpa-run-tab
description: Adds a Tebra-style Run tab to a desktop RPA bot in about 5 minutes. The Run tab is a PyQt6 window (or a tab in an existing PyQt web page) with a Trigger button, an Excel input card (drop zone, sheet tabs, preview with masked passwords, sample download), a progress bar with a timer, a step list, a friendly live log, checkpoint boxes with time limits, and green/amber/red endings. It also wires the real bot to the page. Use when the user asks for a "Run tab", "run screen", "trigger screen", "start/run page for my bot", or "the Tebra run tab" for an RPA or automation project. For a whole dashboard (KPIs, report table), use rpa-dashboard-no-ai.
---
# RPA Run tab
Build the Run tab, wire the real bot to it and check it with the fake bot. The whole job should take about 5 minutes.
The look is fixed. It is Tebra Auto Post's Run tab, and only the colours change.
Everything needed is in `templates/`. Never read the original Tebra projects.
| File | What it is |
|---|---|
| `templates/run_tab.html` | The page. Its config comes from `run_tab.py`, and its theme is between `THEME:START` and `THEME:END`. Each part is fenced with `RUN-TAB:CSS`, `TOPBAR`, `MARKUP` and `JS` markers. |
| `templates/run_tab.py` | The PyQt6 window and bridge. It is also the bot's process (`--run-bot`, which loads no Qt). It holds the **CONFIG** block you fill in and `--selfcheck`. |
| `templates/runner.py` | Qt-free. It starts the bot, reads its log file every 0.2 s, turns lines into events, kills stuck runs and decides the ending. |
| `templates/rt.py` | The protocol helpers the bot calls: `checkpoint`, `checkpoint_done`, `ui`, `result`, `summary`. |
| `templates/fake_bot.py` | Prints the protocol for the endings complete, partial, stuck and crash. Used only by the self-check. |
| `templates/palette.py` | Theme: `sky`, `teal` or a brand hex. Use `--write` to put it into the page. It checks contrast. |
The rules come from the ADRs in `docs/adr/`. Keep to them:
- One checkpoint list drives the steps, the boxes and the progress %.
- The bot talks only through tagged lines in a log file.
- A stuck checkpoint kills the run, and the run fails.
- There is no auto-restart and no Stop or Pause button.
- There is no database, no resume and no roles.
## 1. Scan (about 1 minute, ask nothing yet)
**Never run the bot.** Real bots act on live systems.
1. **Bot entry point.**
- Look for a `main(...)` function, an `if __name__ == "__main__"` script, or a `run_*` function.
- Note its loop over input rows or items. Those become the summary's `total` and `failed`.
- Note its big stages, such as sign in, open a page, process rows, export. Those become the checkpoints.
2. **Input.**
- Does the bot read an Excel file (`openpyxl`, `pandas.read_excel`, `xlrd`)?
- If yes, note the sheet names and column names it reads, for `SAMPLE_SHEETS`.
- No Excel file means `HAS_INPUT = False`.
3. **Host.** Search for `QWebEngineView`, `setHtml` or a `.html` page loaded by PyQt, or an `rpa-dashboard-no-ai` `dashboard.html`. If you find one, the Run tab becomes a tab in it (step 4b). Otherwise it gets its own window (step 4a).
4. **Processes.** Note any extra programs the bot starts and that must die with it, such as `chromedriver.exe` or a desktop app. These go in `KILL_ALSO`.
## 2. Ask once (one message, at most 3 questions)
Send the scan result and these questions in one message, then wait:
1. **Colour:**
- 1) Tebra Sky (blue)
- 2) TVL Teal (green)
- 3) Custom: send one brand hex
2. **Checkpoints:** propose 3–8, each with a name of 2–3 plain words and a time limit, for example `Sign in, max 5 min`. Use `no limit` for long loops over rows. The user confirms or edits the list.
3. **Input:** "Excel input, or no input file?" Skip this question when the scan already answered it.
Default the title and subtitle from the project name, for example `Run automation` and `Upload the input Excel and start <project>`. Don't ask about them.
## 3. Build
1. Copy `run_tab.html`, `run_tab.py`, `runner.py` and `fake_bot.py` into `<project>/run_tab/`.
2. Copy `rt.py` there and next to the bot's entry file.
3. Fill the **CONFIG** block at the top of `run_tab/run_tab.py`:
- `TITLE`, `SUBTITLE`
- `BOT`: `"bot.py"` relative to the project root, or `"package.module:function"`
- `HAS_INPUT`, `INPUT_HINT`
- `CHECKPOINTS`
- `SAMPLE_SHEETS`
- `KILL_ALSO`
- `APP_ID`: the project name, no spaces
4. Apply the theme: `python <skill>/templates/palette.py <sky|teal|#hex> --write <project>/run_tab/run_tab.html`.
- It must print PASS for every row.
- If a row fails, tell the user and suggest a darker hex.
5. Make sure `openpyxl`, `PyQt6` and `PyQt6-WebEngine` are in the project's requirements.
## 4. Wire the bot
Keep the bot's logic as it is. Only add these lines:
```python
import rt
def main(excel_path): # BOT = "bot:main"; the Run tab passes the Excel path
rt.checkpoint(1); rt.ui("Signing in"); ...; rt.ui("Signed in", "ok"); rt.checkpoint_done(1)
failed = []
rt.checkpoint(2)
for i, row in enumerate(rows, start=2):
try: process(row)
except Exception as e: failed.append((f"Row {i}", str(e))); rt.ui(f"Row {i} failed", "warn")
rt.checkpoint_done(2)
return rt.result(total=len(rows), failed=failed)
```
- **Checkpoint numbers** are 1-based positions in `CHECKPOINTS`. A loop may go back to an earlier checkpoint for the next item; the later boxes reset and the bar never moves back.
- **Returning a summary.** A plain script with no entry function prints `rt.summary(total, failed)` as its last line instead. If no summary arrives, the run counts as **failed**.
- **What the operator sees.** Only `[UI]` lines and error lines reach the screen. Everything else goes only to `logs/run_<time>.log`.
- Keep `[UI]` text to progress words and row numbers.
- **Never put** patient or customer names, amounts, account numbers, passwords or other PHI or secrets on screen, or in the failed reasons.
- **Bot output.** Leave the bot's existing prints. They go to the run log.
### 4a. Its own window
There is nothing more to do. Start it with `python run_tab/run_tab.py`.
### 4b. A tab in an existing PyQt page
Copy the four fenced parts of `run_tab.html` into the host page:
- `RUN-TAB:CSS`, `RUN-TAB:TOPBAR`, `RUN-TAB:MARKUP`, `RUN-TAB:JS`
- the `THEME` block, if the host has no tokens of its own
Then wire the host:
- **Page config:** add `const CFG = ...` to the host page; `page_html()` in `run_tab.py` shows its shape.
- **Messages:** route the host's Python→page messages through one `window.__pyMsg`, and merge the `handlePyMessage` branches.
- **Bridge:** give the host's bridge the `Bridge` slots from `run_tab.py`. They are `browse_file`, `process_dropped_path`, `read_sheet`, `download_sample_input`, `start_automation` and `set_dark`.
- **Window:** add the close guard from `RunTabWindow.closeEvent`.
- **Don't connect `js_message` in the page** as well as using `__pyMsg`. Doing both shows every message twice.
## 5. Verify with the fake bot
```
python run_tab/run_tab.py --selfcheck
```
It runs the fake bot through the real runner four times:
- complete → green
- partial → amber
- stuck → red, with box 2 marked stuck
- crash → red
Each line must print `ok`. It then saves the window as `logs/selfcheck_complete_light.png`, `logs/selfcheck_partial_dark.png` and `logs/selfcheck_stuck_light.png`. **Look at all three.** Check:
- the Tebra layout
- your colours, readable in both themes
- the checkpoint names
- no horizontal scroll
**Never press Trigger on the real bot yourself.** Tell the user to start the first real run.
## 6. Hand over (two lines)
Tell the user:
- how to start it (`python run_tab/run_tab.py`)
- where raw run logs go (`logs/`)
If the project ships as a PyInstaller exe:
- the entry point is `run_tab/run_tab.py`
- add `run_tab/run_tab.html` as data
- add the bot module as a hidden import, because the exe starts itself with `--run-bot`
@@ -0,0 +1,11 @@
# One checkpoint list drives the whole Run tab
Tebra Auto Post keeps two overlapping lists:
- 8 **steps**, which move forward when known phrases appear in the log (`STEP_MARKERS`).
- 7 **checkpoints**, which move forward on `[CPSTATE]` lines and have time limits.
The Run tab skill keeps one checkpoint list. The bot's `[CPSTATE] active|done N` lines move the step list, the checkpoint boxes and the progress % together. The screen still looks like Tebra.
**Why:** there is one list to ask the user for, so the skill fits in 5 minutes. And a typo in a log phrase can no longer stall the progress bar without anyone noticing.
**Cost:** the progress bar moves in whole checkpoints, not Tebra's finer per-phrase percentages.
@@ -0,0 +1,11 @@
# The bot talks to the Run tab through tagged lines in a log file
The bot runs as a **separate process** with no Qt. The Run tab starts it with `--log <file>` and reads that file every 0.2 s. It does not read stdout. The bot prints:
- `[CPSTATE] active N` / `[CPSTATE] done N`: checkpoint progress (see ADR-0001).
- `[UI] text`, `[UI:ok]`, `[UI:warn]`, `[UI:error]`: lines the operator sees. Error lines and tracebacks always show, even without a tag. Every raw line stays in the run log file.
- `__RUN_SUMMARY__{json}` as its last line. The outcome comes from this line, not from the exit code. If the line is missing, the run counts as failed, so a crash never shows green.
**Why:** this comes from Tebra Auto Post. UI automation inside a Qt worker thread was unreliable (bug-094). A windowed PyInstaller child's stdout pipe is block-buffered, so log lines arrived in late bursts; reading the log file fixed that. Tagging lines saves each project from writing a table of phrase→friendly-text rules.
**Conflict:** `rpa-dashboard-no-ai/templates/bot_runner.py` reads `stdout=PIPE`, which is the approach Tebra dropped. Fix it there, or point it at the Run tab skill's runner.
@@ -0,0 +1,7 @@
# A stuck run is killed and fails; no auto-restart, no Stop button
When a checkpoint passes its time limit, the Run tab kills the bot's whole process tree and ends the run as failed: "Stuck at <checkpoint>, run again". It does not restart. There is no Stop or Pause button. A run also ends early when the operator closes the window, which asks first and then kills the process tree.
**Why:** Tebra Auto Post restarts a stuck bot up to 3 times. That is only safe because its SQL batch table lets a restarted bot skip work it already did. The Run tab skill has no DB (decision Q2), so a restart could do work twice, for example post a payment twice. Tebra removed Stop on purpose ("a run always goes to completion"), and the Run tab keeps the same look.
**Reopen if:** a project's bot is rerun-safe (it skips items already done). Then auto-restart ×3 can come back as an option.
+31
View File
@@ -0,0 +1,31 @@
"""Fake bot for checking the Run tab. Prints the same protocol lines a real bot does (ADR-0002).
FAKE_MODE: complete | partial | stuck | crash FAKE_CPS: how many checkpoints
Never touches anything outside its own log.
"""
import os
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import rt # noqa: E402
mode = os.environ.get("FAKE_MODE", "complete")
cps = int(os.environ.get("FAKE_CPS", "3"))
print("fake bot starting (this line stays in the log file only)")
for n in range(1, cps + 1):
rt.checkpoint(n)
rt.ui(f"Working on checkpoint {n}")
if mode == "stuck" and n == 2:
time.sleep(3600) # the Run tab must kill this
if mode == "crash" and n == 2:
raise RuntimeError("Simulated crash")
time.sleep(0.4)
rt.ui(f"Checkpoint {n} finished", "ok")
rt.checkpoint_done(n)
if mode == "partial":
rt.ui("2 rows could not be processed", "warn")
rt.summary(5, [("Row 4", "Account not found"), ("Row 5", "Timed out")])
else:
rt.summary(5)
+99
View File
@@ -0,0 +1,99 @@
"""Theme the Run tab: a preset or one brand hex -> the THEME block of run_tab.html.
python palette.py sky|teal|"#7a3cff" print the block and a contrast table
python palette.py "#7a3cff" --write run_tab/run_tab.html also write it into the page
Only the sky gradient and the accent change. ok/warn/bad and the log colours stay fixed (R9).
A brand hex that is too light for white button text is darkened until it passes.
Exits 1 if a contrast check still fails.
"""
import re
import sys
WHITE, BLACK = "#ffffff", "#000000"
INK = {"light": "#1c2b33", "dark": "#e6eef4"}
PRESETS = {
"sky": {"light": {"--sky-top": "#8fc9e8", "--sky-mid": "#bfe3f2", "--sky-bottom": "#eaf6fb",
"--accent": "#3d9bd0", "--accent-lite": "#5cb2e2", "--accent-dark": "#16202a"},
"dark": {"--sky-top": "#0f1b26", "--sky-mid": "#13212e", "--sky-bottom": "#182a38",
"--accent": "#3d9bd0", "--accent-lite": "#5cb2e2", "--accent-dark": "#2e5d80"}},
"teal": {"light": {"--sky-top": "#8fe8ad", "--sky-mid": "#bff2d0", "--sky-bottom": "#eafbf0",
"--accent": "#0d9488", "--accent-lite": "#2dd4bf", "--accent-dark": "#16202a"},
"dark": {"--sky-top": "#0f2617", "--sky-mid": "#132e1c", "--sky-bottom": "#183823",
"--accent": "#0d9488", "--accent-lite": "#2dd4bf", "--accent-dark": "#2e5d80"}},
}
def rgb(h):
return [int(h[i:i + 2], 16) for i in (1, 3, 5)]
def mix(a, b, t):
"""Blend hex colour a toward b by t (0..1)."""
return "#" + "".join(f"{round(x + (y - x) * t):02x}" for x, y in zip(rgb(a), rgb(b)))
def contrast(a, b):
"""WCAG 2 contrast ratio between two hex colours."""
def lum(h):
c = [v / 255 for v in rgb(h)]
c = [x / 12.92 if x <= 0.03928 else ((x + 0.055) / 1.055) ** 2.4 for x in c]
return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]
hi, lo = sorted((lum(a), lum(b)), reverse=True)
return (hi + 0.05) / (lo + 0.05)
def derive(brand):
brand = brand.strip().lower()
if len(brand) == 4: # #abc -> #aabbcc
brand = "#" + "".join(ch * 2 for ch in brand[1:])
accent = brand
for _ in range(60): # white text on the Trigger button must stay readable
if contrast(WHITE, accent) >= 3:
break
accent = mix(accent, BLACK, 0.06)
base = {"--accent": accent, "--accent-lite": mix(accent, WHITE, 0.2)}
return {
"light": {"--sky-top": mix(accent, WHITE, 0.55), "--sky-mid": mix(accent, WHITE, 0.72),
"--sky-bottom": mix(accent, WHITE, 0.9), **base, "--accent-dark": mix(accent, "#0b1218", 0.85)},
"dark": {"--sky-top": mix(accent, BLACK, 0.85), "--sky-mid": mix(accent, BLACK, 0.82),
"--sky-bottom": mix(accent, BLACK, 0.78), **base, "--accent-dark": mix(accent, BLACK, 0.4)},
}
def checks(t):
rows = []
for mode, m in t.items():
rows += [(mode, "white text on the Trigger button", contrast(WHITE, m["--accent"]), 3),
(mode, "ink on the top of the sky", contrast(INK[mode], m["--sky-top"]), 4.5),
(mode, "ink on the bottom of the sky", contrast(INK[mode], m["--sky-bottom"]), 4.5)]
rows.append(("light", "progress % on the sky", contrast(t["light"]["--accent-dark"], t["light"]["--sky-bottom"]), 4.5))
return rows
def block(t):
def line(m):
r = ",".join(str(v) for v in rgb(m["--accent"]))
return "; ".join(f"{k}:{v}" for k, v in m.items()) + f"; --accent-rgb:{r};"
return ("/* THEME:START (palette.py --write replaces everything down to THEME:END) */\n"
f":root{{\n {line(t['light'])}\n}}\nbody.dark{{\n {line(t['dark'])}\n}}\n/* THEME:END */")
if __name__ == "__main__":
if len(sys.argv) < 2 or not (sys.argv[1] in PRESETS or sys.argv[1].startswith("#")):
sys.exit(__doc__)
t = PRESETS.get(sys.argv[1]) or derive(sys.argv[1])
css = block(t)
print(css + "\n")
rows = checks(t)
for mode, what, ratio, need in rows:
print(f"{mode:<6} {what:<34} {ratio:5.2f}:1 needs {need}:1 {'PASS' if ratio >= need else 'FAIL'}")
if "--write" in sys.argv:
page = sys.argv[sys.argv.index("--write") + 1]
html = open(page, encoding="utf-8").read()
new, n = re.subn(r"/\* THEME:START.*?THEME:END \*/", lambda _: css, html, flags=re.S)
if n != 1:
sys.exit(f"THEME:START/END markers not found in {page}")
open(page, "w", encoding="utf-8").write(new)
print(f"\nwrote the theme into {page}")
sys.exit(0 if all(r >= n for *_, r, n in rows) else 1)
+36
View File
@@ -0,0 +1,36 @@
"""Run tab protocol for the bot (ADR-0002). Copy this file next to the bot.
import rt
rt.checkpoint(1) # checkpoint 1 starts (its time limit starts counting)
rt.ui("Signed in", "ok") # a line the operator sees: kind "", "ok", "warn" or "error"
rt.checkpoint_done(1)
...
return rt.result(total=40, failed=[("Row 7", "Account not found")])
The bot's entry function returns rt.result(...), and the Run tab prints the summary line.
A bot that is a plain script (no entry function) calls rt.summary(...) as its last line.
"""
import json
SUMMARY = "__RUN_SUMMARY__"
def checkpoint(n):
print(f"[CPSTATE] active {n}", flush=True)
def checkpoint_done(n):
print(f"[CPSTATE] done {n}", flush=True)
def ui(msg, kind=""):
print(f"[UI{':' + kind if kind else ''}] {msg}", flush=True)
def result(total, failed=()):
failed = [[str(item), str(reason)] for item, reason in failed]
return {"total": total, "ok": total - len(failed), "failed": failed}
def summary(total, failed=()):
print(SUMMARY + json.dumps(result(total, failed)), flush=True)
+592
View File
@@ -0,0 +1,592 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<script src="qrc:///qtwebchannel/qwebchannel.js"></script>
<style>
/* THEME:START (palette.py --write replaces everything down to THEME:END) */
:root{
--sky-top:#8fc9e8; --sky-mid:#bfe3f2; --sky-bottom:#eaf6fb;
--accent:#3d9bd0; --accent-lite:#5cb2e2; --accent-rgb:61,155,208; --accent-dark:#16202a;
}
body.dark{
--sky-top:#0f1b26; --sky-mid:#13212e; --sky-bottom:#182a38;
--accent:#3d9bd0; --accent-lite:#5cb2e2; --accent-rgb:61,155,208; --accent-dark:#2e5d80;
}
/* THEME:END */
:root{
--glass:rgba(255,255,255,0.60); --glass-strong:rgba(255,255,255,0.78);
--border:rgba(255,255,255,0.9);
--ink:#1c2b33; --muted:#5c7382;
--ok:#1f9d57; --ok-bg:#e6f6ec; --warn:#b7791f; --warn-bg:#fbf1dd;
--bad:#c0392b; --bad-bg:#fdecea;
}
body.dark{
--glass:rgba(22,34,44,.72); --glass-strong:rgba(30,45,58,.85);
--border:rgba(120,160,190,.22);
--ink:#e6eef4; --muted:#8fa6b4;
--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);
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%;font-family:'Segoe UI',-apple-system,BlinkMacSystemFont,sans-serif;
color:var(--ink);overflow:hidden;
background:linear-gradient(180deg,var(--sky-top) 0%,var(--sky-mid) 45%,var(--sky-bottom) 100%)}
.hidden{display:none!important}
input[type=file]{display:none}
@keyframes blink{0%,100%{opacity:1}50%{opacity:.4}}
/* RUN-TAB:CSS:START */
.main{height:100vh;display:flex;flex-direction:column;overflow:hidden}
.topbar{background:linear-gradient(180deg,rgba(255,255,255,.82),rgba(255,255,255,.55));
border-bottom:1px solid var(--border);backdrop-filter:blur(14px);
padding:20px 28px;display:flex;align-items:center;gap:14px;flex-shrink:0;flex-wrap:wrap;
box-shadow:0 6px 18px rgba(20,50,70,.10)}
.tb-title{font-size:21px;font-weight:800;color:var(--ink)}
.tb-sub{font-size:12.5px;color:var(--muted);margin-top:3px}
.tb-right{margin-left:auto;display:flex;align-items:center;gap:12px}
.top-run-btn{display:flex;align-items:center;gap:8px;padding:10px 20px;border-radius:11px;border:none;
font-size:13px;font-weight:600;cursor:pointer;transition:all .18s ease;font-family:inherit}
.top-run-btn svg{width:16px;height:16px;stroke:currentColor}
.top-run-btn.off{background:rgba(255,255,255,.55);color:var(--muted);cursor:not-allowed}
.top-run-btn.on{background:linear-gradient(180deg,var(--accent-lite),var(--accent));color:#fff;
box-shadow:0 2px 6px rgba(var(--accent-rgb),.35)}
.top-run-btn.on:hover{box-shadow:0 6px 16px rgba(var(--accent-rgb),.4);transform:translateY(-1px)}
.top-run-btn.on:active{transform:scale(.97);box-shadow:0 1px 3px rgba(var(--accent-rgb),.3)}
.top-run-btn.going{background:var(--warn-bg);color:var(--warn);cursor:not-allowed}
.mode-btn{width:38px;height:38px;border-radius:11px;border:1px solid var(--border);
background:var(--glass-strong);cursor:pointer;font-size:16px;line-height:1;
display:flex;align-items:center;justify-content:center;transition:all .18s ease;
box-shadow:0 1px 3px rgba(20,50,70,.12)}
.mode-btn:hover{transform:translateY(-1px);box-shadow:0 4px 10px rgba(20,50,70,.2)}
.mode-btn:active{transform:scale(.94)}
.indicator{display:flex;align-items:center;gap:7px;background:var(--glass-strong);padding:7px 13px;border-radius:20px;border:1px solid var(--border)}
.dot{width:8px;height:8px;border-radius:50%;transition:all .3s}
.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.partial{background:var(--warn);box-shadow:0 0 7px var(--warn)}
.dot.error{background:var(--bad);box-shadow:0 0 7px var(--bad)}
.ind-text{font-size:12px;font-weight:700;color:var(--ink)}
.content{flex:1;overflow-y:auto;padding:26px 28px;border-top-left-radius:30px;
background:linear-gradient(180deg,rgba(255,255,255,.18),rgba(255,255,255,0));
box-shadow:inset 2px 2px 0 rgba(255,255,255,.35)}
.content::-webkit-scrollbar{width:8px}
.content::-webkit-scrollbar-thumb{background:rgba(255,255,255,.8);border-radius:8px}
.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:blur(14px);box-shadow:0 10px 30px rgba(20,50,70,.08)}
.card-head{display:flex;align-items:center;gap:12px;margin-bottom:18px}
.c-icon{width:40px;height:40px;border-radius:11px;display:flex;align-items:center;justify-content:center;flex-shrink:0;
background:var(--glass-strong)}
.c-icon svg{width:20px;height:20px;stroke:var(--accent-dark)}
.c-title{font-size:15px;font-weight:700;color:var(--ink)}
.c-sub{font-size:12px;color:var(--muted);margin-top:2px}
.btn{padding:9px 15px;border-radius:11px;border:1px solid rgba(var(--accent-rgb),.45);
background:linear-gradient(180deg,rgba(var(--accent-rgb),.16),rgba(var(--accent-rgb),.32));
color:var(--ink);font-size:12.5px;font-weight:600;cursor:pointer;font-family:inherit;
transition:all .18s ease;box-shadow:0 1px 3px rgba(20,50,70,.14)}
.btn:hover{background:linear-gradient(180deg,rgba(var(--accent-rgb),.24),rgba(var(--accent-rgb),.42));transform:translateY(-1px);
box-shadow:0 4px 12px rgba(var(--accent-rgb),.28)}
.btn:active{transform:translateY(0) scale(.97);box-shadow:0 1px 2px rgba(20,50,70,.12)}
.drop-zone{border:2px dashed rgba(var(--accent-rgb),.45);border-radius:14px;padding:36px 20px;text-align:center;cursor:pointer;transition:all .2s;background:rgba(255,255,255,.35)}
.drop-zone:hover,.drop-zone.over{border-color:var(--accent-dark);background:rgba(255,255,255,.6)}
.drop-icon{width:44px;height:44px;stroke:rgba(var(--accent-rgb),.8);margin:0 auto 12px;display:block}
.drop-zone:hover .drop-icon,.drop-zone.over .drop-icon{stroke:var(--accent-dark)}
.drop-title{font-size:15px;font-weight:700;color:var(--ink);margin-bottom:5px}
.drop-sub{font-size:12.5px;color:var(--muted)}
.browse-link{color:var(--accent-dark);cursor:pointer;font-weight:700;text-decoration:underline}
.tags{display:flex;gap:7px;justify-content:center;margin-top:14px;flex-wrap:wrap}
.tag{font-size:11px;color:var(--muted);background:rgba(255,255,255,.6);border:1px solid var(--border);padding:4px 11px;border-radius:6px;font-weight:600}
.file-row{display:flex;align-items:center;gap:14px;padding:15px 16px;background:var(--ok-bg);border-radius:12px;border:1px solid #bfe6cc;margin-top:14px}
.f-details{flex:1;min-width:0}
.f-name{font-size:13.5px;font-weight:700;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.f-path{font-size:11px;color:var(--muted);margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.f-badge{display:inline-flex;align-items:center;gap:5px;margin-top:5px;font-size:11px;color:var(--ok);font-weight:700}
.f-badge::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--ok);display:inline-block}
.f-remove{color:var(--muted);cursor:pointer;font-size:20px;padding:3px 9px;border-radius:8px;flex-shrink:0}
.f-remove:hover{color:var(--bad);background:var(--bad-bg)}
.sheet-tabs{display:flex;gap:8px;margin-top:16px;flex-wrap:wrap}
.sheet-btn{padding:9px 15px;border-radius:10px;border:1px solid var(--border);background:rgba(255,255,255,.5);color:var(--ink);font-size:12.5px;font-weight:600;cursor:pointer;transition:all .18s ease;box-shadow:0 1px 3px rgba(20,50,70,.08)}
.sheet-btn:hover{background:rgba(255,255,255,.8);transform:translateY(-1px);box-shadow:0 4px 10px rgba(20,50,70,.15)}
.sheet-btn:active{transform:scale(.97)}
.sheet-btn.active{background:linear-gradient(180deg,var(--accent-lite),var(--accent));border-color:var(--accent);color:#fff}
.sheet-preview{margin-top:14px;display:none}
.sheet-preview.show{display:block}
.preview-head{display:flex;align-items:center;gap:8px;margin-bottom:10px}
.preview-title{font-size:13px;font-weight:700;color:var(--ink)}
.preview-count{font-size:11px;color:var(--muted);font-weight:600}
.tbl-wrap{border:1px solid var(--border);border-radius:12px;overflow:auto;max-height:280px;background:rgba(255,255,255,.5)}
.tbl-wrap::-webkit-scrollbar{width:8px;height:8px}
.tbl-wrap::-webkit-scrollbar-thumb{background:rgba(var(--accent-rgb),.3);border-radius:8px}
table.data-tbl{width:100%;border-collapse:collapse;font-size:12px}
table.data-tbl th{background:rgba(255,255,255,.85);color:var(--ink);font-weight:700;text-align:left;padding:9px 12px;position:sticky;top:0;border-bottom:1px solid var(--border);white-space:nowrap}
table.data-tbl td{padding:8px 12px;border-bottom:1px solid rgba(255,255,255,.7);color:#2b3f4a;white-space:nowrap}
table.data-tbl .mask{color:#9fb0b8;letter-spacing:2px}
.preview-empty{padding:24px;text-align:center;color:var(--muted);font-size:12px}
.clear-bar{display:none;align-items:center;gap:12px;margin-top:14px;padding-top:14px;border-top:1px solid rgba(255,255,255,.7)}
.clear-bar.show{display:flex}
.clear-btn{padding:9px 16px;border-radius:10px;border:1px solid #f0c6c0;background:var(--bad-bg);color:var(--bad);font-size:12.5px;font-weight:600;cursor:pointer;font-family:inherit;transition:all .18s ease;box-shadow:0 1px 3px rgba(192,57,43,.12)}
.clear-btn:hover{background:var(--bad);color:#fff;transform:translateY(-1px);box-shadow:0 4px 10px rgba(192,57,43,.25)}
.clear-btn:active{transform:scale(.97)}
.clear-hint{font-size:11.5px;color:var(--muted)}
.prog-wrap{display:none;margin-bottom:6px}
.prog-wrap.show{display:block}
.prog-head{display:flex;align-items:center;gap:12px;margin-bottom:8px}
.prog-lbl{font-size:13px;font-weight:700;color:var(--ink);margin-right:auto}
.prog-time{font-size:12.5px;font-weight:700;color:var(--muted);
background:rgba(var(--accent-rgb),.12);padding:2px 9px;border-radius:8px;white-space:nowrap}
.prog-pct{font-size:13px;color:var(--accent-dark);font-weight:800}
.prog-bg{height:8px;background:rgba(255,255,255,.6);border-radius:8px;overflow:hidden;margin-bottom:18px}
.prog-fill{height:100%;background:linear-gradient(90deg,#28c26f,#1f9d57);border-radius:8px;width:0%;transition:width .5s ease}
.prog-fill.partial{background:linear-gradient(90deg,#e0a53c,#b7791f)}
.prog-fill.failed{background:linear-gradient(90deg,#e8695b,#c0392b)}
.steps{display:flex;flex-direction:column;gap:6px;margin-bottom:16px}
.step{display:flex;align-items:center;gap:12px;padding:10px 13px;border-radius:10px;border:1px solid transparent}
.step.done{background:var(--ok-bg);border-color:#cdeed8}
.step.active{background:var(--warn-bg);border-color:#f0dcae}
.step.stuck{background:var(--bad-bg);border-color:#f0b6b0}
.step.pending{opacity:.5}
.step-dot{width:22px;height:22px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:11px;flex-shrink:0;font-weight:700}
.step.done .step-dot{background:var(--ok);color:#fff}
.step.active .step-dot{background:var(--warn);color:#fff}
.step.stuck .step-dot{background:var(--bad);color:#fff}
.step.pending .step-dot{background:rgba(255,255,255,.7);color:var(--muted)}
.step-lbl{font-size:13px;font-weight:600}
.step.done .step-lbl{color:var(--ok)}
.step.active .step-lbl{color:var(--warn)}
.step.stuck .step-lbl{color:var(--bad)}
.step.pending .step-lbl{color:var(--muted)}
.log{background:var(--accent-dark);border-radius:12px;padding:12px 14px;max-height:200px;overflow-y:auto;
font-family:'Consolas','Courier New',monospace;font-size:13.5px;line-height:1.85}
.log::-webkit-scrollbar{width:6px}
.log::-webkit-scrollbar-thumb{background:#3a4a55;border-radius:6px}
.log-line{color:#9fb6c4;overflow-wrap:anywhere}
.log-line.info{color:#7fc4f0}
.log-line.success{color:#5fe0a0}
.log-line.warn{color:#f5c563}
.log-line.error{color:#f68c82}
.cp-wrap{margin-top:14px}
#cp-elapsed{margin-left:auto;font-weight:700;color:var(--accent-dark)}
.cp-title{font-size:12px;font-weight:800;letter-spacing:.4px;text-transform:uppercase;
color:var(--muted);margin-bottom:10px;display:flex;align-items:center;gap:8px}
.cp-track{display:grid;grid-template-columns:repeat(auto-fit,minmax(110px,1fr));gap:8px}
.cp-box{position:relative;border-radius:11px;padding:11px 10px 10px;
border:1.5px solid rgba(var(--accent-rgb),.20);background:rgba(var(--accent-rgb),.06);
display:flex;flex-direction:column;gap:5px;min-height:74px;transition:all .25s}
.cp-box .cp-num{width:20px;height:20px;border-radius:50%;display:flex;align-items:center;
justify-content:center;font-size:11px;font-weight:800;flex-shrink:0;
background:rgba(255,255,255,.6);color:var(--muted)}
.cp-box .cp-name{font-size:12px;font-weight:700;color:var(--ink);line-height:1.25}
.cp-box .cp-meta{font-size:10.5px;font-weight:700;color:var(--muted);margin-top:auto}
.cp-box.pending{opacity:.55}
.cp-box.active{border-color:var(--warn);background:var(--warn-bg);box-shadow:0 0 0 3px rgba(245,197,99,.14)}
.cp-box.active .cp-num{background:var(--warn);color:#fff;animation:cp-pulse 1.2s ease-in-out infinite}
.cp-box.active .cp-meta{color:var(--warn)}
.cp-box.done{border-color:#cdeed8;background:var(--ok-bg)}
.cp-box.done .cp-num{background:var(--ok);color:#fff}
.cp-box.done .cp-meta{color:var(--ok)}
.cp-box.stuck{border-color:#f0b6b0;background:var(--bad-bg)}
.cp-box.stuck .cp-num{background:var(--bad);color:#fff}
.cp-box.stuck .cp-meta{color:var(--bad)}
@keyframes cp-pulse{0%,100%{box-shadow:0 0 0 0 rgba(245,197,99,.5)}50%{box-shadow:0 0 0 6px rgba(245,197,99,0)}}
body.dark .topbar{background:linear-gradient(180deg,rgba(30,45,58,.92),rgba(22,34,44,.7));box-shadow:0 6px 18px rgba(0,0,0,.28)}
body.dark .content{background:linear-gradient(180deg,rgba(255,255,255,.05),rgba(255,255,255,0));box-shadow:inset 2px 2px 0 rgba(255,255,255,.08)}
body.dark .content::-webkit-scrollbar-thumb{background:rgba(255,255,255,.18)}
body.dark .btn{background:linear-gradient(180deg,rgba(var(--accent-rgb),.22),rgba(var(--accent-rgb),.12));color:var(--ink)}
body.dark .drop-zone{background:rgba(255,255,255,.04);border-color:rgba(var(--accent-rgb),.35)}
body.dark .drop-zone:hover,body.dark .drop-zone.over{background:rgba(255,255,255,.08);border-color:var(--accent-lite)}
body.dark .tag{background:rgba(255,255,255,.07)}
body.dark .top-run-btn.off{background:rgba(255,255,255,.08)}
body.dark .file-row{background:var(--ok-bg);border-color:rgba(47,191,114,.35)}
body.dark .sheet-btn{background:rgba(255,255,255,.07)}
body.dark .sheet-btn:hover{background:rgba(255,255,255,.14)}
body.dark .tbl-wrap{background:rgba(255,255,255,.03)}
body.dark table.data-tbl th{background:#1d2f3c;color:var(--ink)}
body.dark table.data-tbl td{color:#c9d8e2;border-bottom:1px solid rgba(255,255,255,.06)}
body.dark .clear-bar{border-top-color:rgba(120,160,190,.15)}
body.dark .clear-btn{border-color:rgba(224,90,75,.4)}
body.dark .prog-bg{background:rgba(255,255,255,.12)}
body.dark .step.pending .step-dot{background:rgba(255,255,255,.12)}
body.dark .step.done{border-color:rgba(47,191,114,.3)}
body.dark .step.active{border-color:rgba(217,161,60,.3)}
body.dark .step.stuck{border-color:rgba(224,90,75,.35)}
body.dark .log{background:#0d161e}
body.dark .prog-pct,body.dark .browse-link,body.dark #cp-elapsed{color:var(--accent-lite)}
body.dark .c-icon svg{stroke:var(--accent-lite)}
body.dark .cp-box .cp-name{color:#cfe0ec}
body.dark .cp-box.pending .cp-num{background:rgba(255,255,255,.12)}
body.dark .cp-box.done{border-color:rgba(47,191,114,.3)}
body.dark .cp-box.stuck{border-color:rgba(224,90,75,.35)}
@media (max-width:820px){
.topbar{padding:16px}
.content{padding:18px 16px}
}
/* RUN-TAB:CSS:END */
</style>
</head>
<body>
<div class="main">
<!-- RUN-TAB:TOPBAR:START -->
<div class="topbar">
<div>
<div class="tb-title" id="tb-title">Run automation</div>
<div class="tb-sub" id="tb-sub"></div>
</div>
<div class="tb-right">
<button class="top-run-btn off" id="top-run-btn" onclick="doRun()">
<svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>
<span id="top-run-lbl">Upload a file to continue</span>
</button>
<button class="mode-btn" id="mode-btn" onclick="toggleDark()" title="Toggle dark mode">🌙</button>
<div class="indicator">
<div class="dot ready" id="sys-dot"></div>
<div class="ind-text" id="sys-txt">System ready</div>
</div>
</div>
</div>
<!-- RUN-TAB:TOPBAR:END -->
<div class="content">
<!-- RUN-TAB:MARKUP:START -->
<div id="tab-run">
<div id="input-card">
<div class="section-label">Input configuration</div>
<div class="card">
<div class="card-head">
<div class="c-icon"><svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg></div>
<div>
<div class="c-title">Input Excel upload</div>
<div class="c-sub" id="input-hint"></div>
</div>
<button class="btn" id="sample-btn" style="margin-left:auto;flex-shrink:0"
onclick="pyBridge&&pyBridge.download_sample_input()"
title="Save an example workbook with the columns this bot reads">&#11015; Sample input</button>
</div>
<div class="drop-zone" id="dz" ondragover="dzOver(event)" ondragleave="dzLeave()" ondrop="dzDrop(event)"
onclick="pyBridge&&pyBridge.browse_file()">
<svg class="drop-icon" viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
<div class="drop-title">Drop your Excel file here</div>
<div class="drop-sub">or <span class="browse-link" onclick="event.stopPropagation();pyBridge&&pyBridge.browse_file()">browse files</span></div>
<div class="tags"><span class="tag">.xlsx</span><span class="tag">.xlsm</span><span class="tag">.xls</span></div>
</div>
<input type="file" id="fi" accept=".xlsx,.xlsm,.xls" onchange="fileChosen(this.files[0])">
<div class="file-row hidden" id="file-row">
<div class="c-icon"><svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg></div>
<div class="f-details">
<div class="f-name" id="f-name">—</div>
<div class="f-path" id="f-path">—</div>
<div class="f-badge">Ready to process</div>
</div>
<div class="f-remove" onclick="removeFile()" title="Remove">&times;</div>
</div>
<div class="sheet-tabs hidden" id="sheet-tabs"></div>
<div class="sheet-preview" id="sheet-preview">
<div class="preview-head">
<div class="preview-title" id="preview-title">Sheet</div>
<div class="preview-count" id="preview-count"></div>
</div>
<div class="tbl-wrap" id="tbl-wrap"></div>
</div>
<div class="clear-bar" id="clear-bar">
<button class="clear-btn" onclick="removeFile()">Clear file</button>
<span class="clear-hint">Uploaded the wrong file? Clear and choose again.</span>
</div>
</div>
</div>
<div class="card">
<div class="card-head">
<div class="c-icon"><svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg></div>
<div>
<div class="c-title">Run automation</div>
<div class="c-sub" id="run-flow"></div>
</div>
</div>
<div class="prog-wrap" id="prog-wrap">
<div class="prog-head">
<span class="prog-lbl" id="prog-lbl">Starting...</span>
<span class="prog-time" id="prog-time"></span>
<span class="prog-pct" id="prog-pct">0%</span>
</div>
<div class="prog-bg"><div class="prog-fill" id="prog-fill"></div></div>
<div class="steps" id="steps"></div>
<div class="log" id="log"></div>
<div class="cp-wrap">
<div class="cp-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
Progress checkpoints
<span id="cp-elapsed"></span>
</div>
<div class="cp-track" id="cp-track"></div>
</div>
</div>
</div>
</div>
<!-- RUN-TAB:MARKUP:END -->
</div>
</div>
<script>
/* Filled by run_tab.py from its config: {title, sub, input, inputHint, sample, darkKey,
checkpoints:[{name, max}]}. max = seconds before the checkpoint counts as stuck, 0 = no limit. */
const CFG = %%CONFIG%%;
let pyBridge=null;
/* RUN-TAB:JS:START */
let fileReady=false, filePath='', running=false;
const CPS=CFG.checkpoints;
function escapeHtml(s){ const d=document.createElement('div'); d.textContent=(s==null?'':String(s)); return d.innerHTML; }
/* Python -> page. Messages arrive ONLY through window.__pyMsg (run_tab.py calls it).
Never also connect js_message in QWebChannel: Tebra did both and every line showed twice. */
window.__pyMsg = function(s){ const m=JSON.parse(s); handlePyMessage(m.event,m.data); };
function handlePyMessage(event,data){
if(event==='file_selected') setFile(data.name,data.path,data.sheets);
else if(event==='sheet_data') renderSheetData(data);
else if(event==='log') addLog(data.msg,data.type);
else if(event==='cpstate') cpState(data.kind, data.cp);
else if(event==='status') onStatus(data.state, data.msg);
else if(event==='apply_dark') applyDark(data.on, true);
}
function initRunTab(){
document.getElementById('tb-title').textContent=CFG.title;
document.getElementById('tb-sub').textContent=CFG.sub;
document.getElementById('run-flow').textContent=CPS.map(c=>c.name).join(' → ');
document.getElementById('input-hint').textContent=CFG.inputHint||'';
document.getElementById('input-card').classList.toggle('hidden', !CFG.input);
document.getElementById('sample-btn').classList.toggle('hidden', !CFG.sample);
fileReady=!CFG.input;
renderCpTrack(); renderSteps(); updateRunBtn();
}
/* ---- dark mode: saved in localStorage and, through the bridge, in QSettings ---- */
function applyDark(on, fromPy){
document.body.classList.toggle('dark', !!on);
const b=document.getElementById('mode-btn'); if(b) b.textContent=on?'☀️':'🌙';
try{ localStorage.setItem(CFG.darkKey, on?'1':'0'); }catch(e){}
if(pyBridge && !fromPy) pyBridge.set_dark(!!on);
}
function toggleDark(){ applyDark(!document.body.classList.contains('dark')); }
/* ---- input file ---- */
function dzOver(e){e.preventDefault();document.getElementById('dz').classList.add('over');}
function dzLeave(){document.getElementById('dz').classList.remove('over');}
function dzDrop(e){e.preventDefault();dzLeave();const f=e.dataTransfer.files[0];if(f)fileChosen(f);}
function fileChosen(f){
if(!f||running)return;
const ext='.'+f.name.split('.').pop().toLowerCase();
if(!['.xlsx','.xlsm','.xls'].includes(ext)){addLog('Invalid file type — use .xlsx .xlsm .xls','warn');return;}
// QWebEngine hides the real path of a dropped file, so Python resolves the name.
if(pyBridge) pyBridge.process_dropped_path(f.path || f.name);
else setFile(f.name, f.path || f.name, []);
}
function setFile(name,path,sheets){
if(!path){ addLog('Could not find "'+name+'" on disk — use browse files instead.','warn'); return; }
fileReady=true; filePath=path;
document.getElementById('dz').style.display='none';
document.getElementById('file-row').classList.remove('hidden');
document.getElementById('f-name').textContent=name;
document.getElementById('f-path').textContent=path;
document.getElementById('clear-bar').classList.add('show');
updateRunBtn();
renderSheetButtons(sheets||[]);
}
function renderSheetButtons(sheets){
const tabs=document.getElementById('sheet-tabs');
document.getElementById('sheet-preview').classList.remove('show');
if(!sheets.length){tabs.classList.add('hidden');tabs.innerHTML='';return;}
tabs.classList.remove('hidden');
tabs.innerHTML=sheets.map((s,i)=>`<div class="sheet-btn" id="sb-${i}" data-sheet="${escapeHtml(s)}" onclick="openSheet(this.dataset.sheet,${i})">${escapeHtml(s)}</div>`).join('');
openSheet(sheets[0], 0); // show the first sheet right after upload
}
function openSheet(name,idx){
document.querySelectorAll('.sheet-btn').forEach(b=>b.classList.remove('active'));
const b=document.getElementById('sb-'+idx); if(b)b.classList.add('active');
document.getElementById('tbl-wrap').innerHTML='<div class="preview-empty">Loading...</div>';
document.getElementById('sheet-preview').classList.add('show');
document.getElementById('preview-title').textContent=name;
document.getElementById('preview-count').textContent='';
if(pyBridge)pyBridge.read_sheet(filePath,name);
}
function renderSheetData(data){
const wrap=document.getElementById('tbl-wrap');
if(data.error){wrap.innerHTML='<div class="preview-empty">Error: '+escapeHtml(data.error)+'</div>';return;}
document.getElementById('preview-count').textContent=data.total+' row(s)'+(data.more?' — first '+data.rows.length+' shown':'');
if(!data.header.length){wrap.innerHTML='<div class="preview-empty">Sheet is empty</div>';return;}
const secret=data.header.map(x=>/pass(word)?|pwd|secret|pin\b/i.test(String(x)));
let h='<table class="data-tbl"><thead><tr>';
data.header.forEach(x=>h+='<th>'+escapeHtml(x)+'</th>');
h+='</tr></thead><tbody>';
data.rows.forEach(row=>{
h+='<tr>';
row.forEach((cell,ci)=>{
if(secret[ci] && String(cell)) h+='<td class="mask">'+'•'.repeat(Math.min(String(cell).length,10))+'</td>';
else h+='<td>'+escapeHtml(cell)+'</td>';
});
h+='</tr>';
});
wrap.innerHTML=h+'</tbody></table>';
}
function removeFile(){
if(running) return;
fileReady=!CFG.input; filePath='';
document.getElementById('dz').style.display='';
document.getElementById('file-row').classList.add('hidden');
document.getElementById('fi').value='';
document.getElementById('sheet-tabs').classList.add('hidden');
document.getElementById('sheet-tabs').innerHTML='';
document.getElementById('sheet-preview').classList.remove('show');
document.getElementById('clear-bar').classList.remove('show');
document.getElementById('tbl-wrap').innerHTML='';
updateRunBtn();
}
/* ---- run ---- */
function updateRunBtn(){
const btn=document.getElementById('top-run-btn'),lbl=document.getElementById('top-run-lbl');
if(running){btn.className='top-run-btn going';lbl.textContent='Running...';}
else if(fileReady){btn.className='top-run-btn on';lbl.textContent='Trigger automation';}
else{btn.className='top-run-btn off';lbl.textContent='Upload a file to continue';}
}
function doRun(){
if(!fileReady||running)return;
running=true; maxPct=0;
document.getElementById('prog-wrap').classList.add('show');
document.getElementById('log').innerHTML='';
document.getElementById('prog-fill').className='prog-fill';
setProgress(0,'Starting...');
resetCheckpoints(); startRunTimer(); updateRunBtn();
updateStatus('running','Running automation...');
if(pyBridge)pyBridge.start_automation(filePath);
else addLog('No Python bridge — page preview only','warn');
}
/* ---- elapsed timers ---- */
let runTimer=null, runStart=0;
function fmtElapsed(sec){ if(sec<60) return sec+'s'; return Math.floor(sec/60)+'m '+(sec%60)+'s ('+sec+'s)'; }
function fmtClock(sec){ return String(Math.floor(sec/60)).padStart(2,'0')+':'+String(sec%60).padStart(2,'0'); }
function tickRunTimer(){
const secs=Math.round((Date.now()-runStart)/1000);
document.getElementById('prog-time').textContent='⏱ '+fmtElapsed(secs);
document.getElementById('cp-elapsed').textContent='⏱ '+fmtClock(secs);
tickCpTimer();
}
function startRunTimer(){ runStart=Date.now(); if(runTimer) clearInterval(runTimer); tickRunTimer(); runTimer=setInterval(tickRunTimer,1000); }
function stopRunTimer(prefix){
if(runTimer){ clearInterval(runTimer); runTimer=null; }
if(runStart) document.getElementById('prog-time').textContent=prefix+fmtElapsed(Math.round((Date.now()-runStart)/1000));
}
/* ---- checkpoints: one list drives the boxes, the step list and the progress % (ADR-0001) ---- */
let cpActive=0, cpActiveStart=0, maxPct=0;
const cpStates=()=>CPS.map((_,i)=>{ const b=cpBox(i+1); return b?b.className.replace('cp-box ',''):'pending'; });
function fmtMax(sec){ if(!sec) return 'no limit'; return 'max '+(sec%60===0 ? (sec/60)+' min' : sec+'s'); }
function renderCpTrack(){
document.getElementById('cp-track').innerHTML=CPS.map((d,i)=>
'<div class="cp-box pending" id="cpbox-'+(i+1)+'">'+
'<div class="cp-num">'+(i+1)+'</div>'+
'<div class="cp-name">'+escapeHtml(d.name)+'</div>'+
'<div class="cp-meta" id="cpmeta-'+(i+1)+'">'+fmtMax(d.max)+'</div>'+
'</div>').join('');
}
function resetCheckpoints(){ cpActive=0; cpActiveStart=0; renderCpTrack(); renderSteps(); document.getElementById('cp-elapsed').textContent=''; }
function cpBox(n){ return document.getElementById('cpbox-'+n); }
function setCpMeta(n,txt){ const m=document.getElementById('cpmeta-'+n); if(m) m.textContent=txt; }
function cpState(kind,n){
if(!n||n>CPS.length) return;
if(kind==='active'){
// below n = done, n = active, above n = pending (a loop back to an earlier checkpoint resets the later ones)
CPS.forEach((d,i)=>{
const k=i+1, b=cpBox(k);
if(k<n){ b.className='cp-box done'; setCpMeta(k,'✓ done'); }
else if(k===n){ b.className='cp-box active'; }
else { b.className='cp-box pending'; setCpMeta(k,fmtMax(d.max)); }
});
cpActive=n; cpActiveStart=Date.now(); tickCpTimer();
setProgress(Math.round(100*(n-1)/CPS.length), CPS[n-1].name+'...');
} else if(kind==='done'){
cpBox(n).className='cp-box done'; setCpMeta(n,'✓ done');
if(cpActive===n) cpActive=0;
setProgress(Math.round(100*n/CPS.length), CPS[n-1].name+' done');
} else if(kind==='stuck'){
cpBox(n).className='cp-box stuck'; setCpMeta(n,'⚠ stuck — stopped'); cpActive=0;
}
renderSteps();
}
function tickCpTimer(){
if(!cpActive||!cpActiveStart) return;
const secs=Math.round((Date.now()-cpActiveStart)/1000), def=CPS[cpActive-1];
setCpMeta(cpActive, def.max ? fmtClock(secs)+' / '+fmtClock(def.max) : fmtClock(secs));
}
function renderSteps(){
const st=cpStates();
document.getElementById('steps').innerHTML=CPS.map((d,i)=>{
const s=st[i], dot=s==='done'?'✓':s==='stuck'?'!':(i+1);
return `<div class="step ${s}"><div class="step-dot">${dot}</div><div class="step-lbl">${escapeHtml(d.name)}</div></div>`;
}).join('');
}
function setProgress(pct,label){
maxPct=Math.max(maxPct,pct); // the bar only moves forward, even when a loop restarts a checkpoint
document.getElementById('prog-fill').style.width=maxPct+'%';
document.getElementById('prog-pct').textContent=maxPct+'%';
if(label)document.getElementById('prog-lbl').textContent=label;
}
/* ---- log: no timestamps on screen, by design; the raw log file has everything ---- */
function addLog(msg,type){
const box=document.getElementById('log'), last=box.lastElementChild;
if(last && last.textContent===msg) return; // drop consecutive duplicates
const d=document.createElement('div');
d.className='log-line '+(type||'');
d.textContent=msg;
box.appendChild(d); box.scrollTop=box.scrollHeight;
}
function updateStatus(state,msg){
document.getElementById('sys-dot').className='dot '+state;
document.getElementById('sys-txt').textContent=msg;
}
/* ---- endings: green completed / amber partial / red failed (ADR-0003, R11) ---- */
function onStatus(state,msg){
if(state==='running'){ updateStatus('running',msg||'Running automation...'); return; }
running=false; cpActive=0;
const fill=document.getElementById('prog-fill');
if(state==='done'){
CPS.forEach((_,i)=>{ cpBox(i+1).className='cp-box done'; setCpMeta(i+1,'✓ done'); });
renderSteps(); maxPct=0; setProgress(100,'Completed');
stopRunTimer('✓ Completed in '); updateStatus('done',msg||'Completed');
} else if(state==='partial'){
fill.className='prog-fill partial'; maxPct=0; setProgress(100,msg);
stopRunTimer('⚠ Finished in '); updateStatus('partial',msg);
} else {
fill.className='prog-fill failed'; setProgress(maxPct,msg);
stopRunTimer('✕ Failed after '); updateStatus('error',msg); addLog(msg,'error');
}
updateRunBtn();
}
if(window.qt && window.QWebChannel){
new QWebChannel(qt.webChannelTransport, function(channel){ pyBridge = channel.objects.pyBridge; });
}
initRunTab();
(function(){ let on=false; try{ on=localStorage.getItem(CFG.darkKey)==='1'; }catch(e){} if(on) applyDark(true, true); })();
/* RUN-TAB:JS:END */
</script>
</body>
</html>
+345
View File
@@ -0,0 +1,345 @@
"""Run tab: PyQt6 window + bridge for the page in run_tab.html.
python run_tab/run_tab.py open the Run tab
python run_tab/run_tab.py --selfcheck run the fake bot through every ending, save screenshots
The same file is also the bot's process: the window starts `run_tab.py --run-bot ...`
(or `<app>.exe --run-bot ...` when frozen). That branch loads no Qt (ADR-0002).
"""
import json
import os
import sys
import threading
from datetime import datetime
from pathlib import Path
# ---- CONFIG: the rpa-run-tab skill fills these in ------------------------------------
TITLE = "Run automation"
SUBTITLE = "Upload the input Excel and start the bot"
BOT = "bot.py" # "path/to/bot.py" (from the project root) or "package.module:function"
HAS_INPUT = True # False hides the input card; Trigger is ready straight away
INPUT_HINT = "Excel workbook with the input sheets"
CHECKPOINTS = [ # (name, seconds before it counts as stuck; 0 = no limit)
("Sign in", 300),
("Process items", 0),
("Finish up", 300),
]
SAMPLE_SHEETS = {} # {"Sheet name": ["Column", ...]}; empty hides the Sample input button
KILL_ALSO = [] # extra .exe names to kill with the bot, e.g. ["chromedriver.exe"]
APP_ID = "RunTab" # QSettings name and the page's dark-mode key
# ---------------------------------------------------------------------------------------
FROZEN = getattr(sys, "frozen", False)
HERE = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parent))
ROOT = Path(sys.executable).parent if FROZEN else Path(__file__).resolve().parent.parent
LOGS = ROOT / "logs"
def _arg(flag, default=""):
return sys.argv[sys.argv.index(flag) + 1] if flag in sys.argv else default
def _watch_parent(ppid):
"""Exit the bot the moment the window's process dies, so it never runs orphaned."""
try:
if sys.platform.startswith("win"):
import ctypes
k32 = ctypes.windll.kernel32
h = k32.OpenProcess(0x00100000, False, ppid) # SYNCHRONIZE
if h:
k32.WaitForSingleObject(h, 0xFFFFFFFF)
else:
import time
while os.getppid() == ppid:
time.sleep(1)
except Exception:
return # can't watch: keep running
os._exit(1)
def run_bot_child():
"""The bot's process: all output goes to --log, the bot runs, its result becomes the summary line."""
log = open(_arg("--log"), "a", encoding="utf-8", buffering=1)
sys.stdout = sys.stderr = log
threading.Thread(target=_watch_parent, args=(int(_arg("--parent-pid", "0")),), daemon=True).start()
excel, bot = _arg("--excel"), _arg("--bot", BOT)
sys.path.insert(0, str(ROOT))
os.chdir(ROOT)
if bot.endswith(".py"):
import runpy
sys.argv = [bot] + ([excel] if excel else [])
sys.path.insert(0, str((ROOT / bot).parent))
runpy.run_path(str(ROOT / bot), run_name="__main__") # a plain script prints its own summary
else:
import importlib
mod, func = bot.split(":")
sys.path.insert(0, str(HERE))
result = getattr(importlib.import_module(mod), func)(*([excel] if excel else []))
if isinstance(result, dict):
print("__RUN_SUMMARY__" + json.dumps(result))
log.flush()
os._exit(0)
if "--run-bot" in sys.argv:
run_bot_child()
# ---- everything below runs only in the window's process --------------------------------
sys.path.insert(0, str(HERE))
from PyQt6.QtCore import QObject, QSettings, Qt, QTimer, QUrl, pyqtSignal, pyqtSlot # noqa: E402
from PyQt6.QtWebChannel import QWebChannel # noqa: E402
from PyQt6.QtWebEngineWidgets import QWebEngineView # noqa: E402
from PyQt6.QtWidgets import QApplication, QFileDialog, QMainWindow, QMessageBox # noqa: E402
import runner # noqa: E402
SEARCH_DIRS = [ROOT, ROOT / "input", Path.home() / "Downloads", Path.home() / "Desktop", Path.home() / "Documents"]
def page_html():
cfg = {"title": TITLE, "sub": SUBTITLE, "input": HAS_INPUT, "inputHint": INPUT_HINT,
"sample": bool(SAMPLE_SHEETS), "darkKey": APP_ID + "_dark",
"checkpoints": [{"name": n, "max": m} for n, m in CHECKPOINTS]}
return (HERE / "run_tab.html").read_text(encoding="utf-8").replace("%%CONFIG%%", json.dumps(cfg))
def bot_cmd(excel, log, bot=BOT):
me = [sys.executable] if FROZEN else [sys.executable, str(Path(__file__).resolve())]
return me + ["--run-bot", "--bot", bot, "--excel", excel or "", "--log", str(log),
"--parent-pid", str(os.getpid())]
def build_sample(path, sheets):
"""Headers-only sample workbook with one fake row. Never real data (R13)."""
from openpyxl import Workbook
wb = Workbook()
wb.remove(wb.active)
for name, cols in sheets.items():
ws = wb.create_sheet(name[:31])
ws.append(cols)
ws.append([f"Example {c}".strip() for c in cols])
wb.save(path)
def read_sheet_rows(path, sheet, cap=200):
"""(header, rows, more): first `cap` non-empty rows as text, Excel date serials as m/d/yyyy."""
from datetime import date, timedelta
from openpyxl import load_workbook
def cell(c):
if c is None:
return ""
if isinstance(c, datetime):
c = c.date()
if isinstance(c, date):
return f"{c.month}/{c.day}/{c.year}"
if isinstance(c, (int, float)) and not isinstance(c, bool) and 36526 <= c <= 73050: # 2000..2099
d = date(1899, 12, 30) + timedelta(days=int(c))
return f"{d.month}/{d.day}/{d.year}"
return str(c)
wb = load_workbook(path, read_only=True, data_only=True)
try:
ws = wb[sheet] if sheet in wb.sheetnames else wb.active
rows, more = [], False
for r in ws.iter_rows(values_only=True):
cells = [cell(c) for c in r]
if not any(x.strip() for x in cells):
continue
if len(rows) >= cap:
more = True
break
rows.append(cells)
finally:
wb.close()
return (rows[0] if rows else []), rows[1:], more
class Bridge(QObject):
"""Exposed to the page as pyBridge."""
js_message = pyqtSignal(str)
def __init__(self, window, bot=BOT, checkpoints=CHECKPOINTS):
super().__init__()
self.window, self.bot, self.checkpoints = window, bot, checkpoints
self.run = None
@property
def running(self):
return self.run is not None and self.run.thread.is_alive()
def send(self, event, data):
self.js_message.emit(json.dumps({"event": event, "data": data}))
def _file(self, path):
sheets = []
try:
from openpyxl import load_workbook
wb = load_workbook(path, read_only=True)
sheets = list(wb.sheetnames)
wb.close()
except Exception as e:
self.send("log", {"msg": f"Could not open the workbook: {e}", "type": "warn"})
self.send("file_selected", {"path": path, "name": os.path.basename(path), "sheets": sheets})
@pyqtSlot()
def browse_file(self):
path, _ = QFileDialog.getOpenFileName(self.window, "Select input Excel file", str(Path.home()),
"Excel files (*.xlsx *.xlsm *.xls)")
if path:
self._file(path)
@pyqtSlot(str)
def process_dropped_path(self, path):
# The page only knows the dropped file's name, so look for it in the usual folders.
if not os.path.isabs(path):
path = next((str(d / path) for d in SEARCH_DIRS if (d / path).is_file()), "")
if path and os.path.isfile(path):
self._file(path)
else:
self.send("file_selected", {"path": "", "name": os.path.basename(path) or "file", "sheets": []})
@pyqtSlot(str, str)
def read_sheet(self, path, sheet):
try:
header, rows, more = read_sheet_rows(path, sheet)
self.send("sheet_data", {"sheet": sheet, "header": header, "rows": rows,
"total": len(rows), "more": more})
except Exception as e:
self.send("sheet_data", {"sheet": sheet, "error": str(e), "header": [], "rows": [], "total": 0})
@pyqtSlot()
def download_sample_input(self):
path, _ = QFileDialog.getSaveFileName(self.window, "Save sample input",
str(Path.home() / f"{APP_ID}_sample_input.xlsx"),
"Excel files (*.xlsx)")
if path:
build_sample(path, SAMPLE_SHEETS)
self.send("log", {"msg": f"Sample saved: {path}", "type": "success"})
@pyqtSlot(str)
def start_automation(self, excel):
if self.running: # double-run guard
return
LOGS.mkdir(exist_ok=True)
log = LOGS / f"run_{datetime.now():%Y%m%d_%H%M%S}.log"
self.run = runner.Run(bot_cmd(excel if HAS_INPUT else "", log, self.bot), log, self.checkpoints,
self.send, KILL_ALSO, cwd=str(ROOT)).start()
@pyqtSlot(bool)
def set_dark(self, on):
QSettings("RPA", APP_ID).setValue("dark", on)
class RunTabWindow(QMainWindow):
def __init__(self, bot=BOT, checkpoints=CHECKPOINTS):
super().__init__()
self.setWindowTitle(TITLE)
self.setMinimumSize(940, 620)
self.resize(1320, 820)
self.view = QWebEngineView()
self.view.setContextMenuPolicy(Qt.ContextMenuPolicy.NoContextMenu) # no Reload mid-run
self.bridge = Bridge(self, bot, checkpoints)
self.channel = QWebChannel()
self.channel.registerObject("pyBridge", self.bridge)
self.view.page().setWebChannel(self.channel)
self.bridge.js_message.connect(self._push)
self.view.loadFinished.connect(self._loaded)
self.view.setHtml(page_html(), QUrl("qrc:///"))
self.setCentralWidget(self.view)
def _push(self, s):
self.view.page().runJavaScript(f"window.__pyMsg && window.__pyMsg({json.dumps(s)})")
def _loaded(self, ok):
if ok:
on = QSettings("RPA", APP_ID).value("dark", False, type=bool)
self.bridge.send("apply_dark", {"on": on})
def closeEvent(self, event):
if self.bridge.running:
ans = QMessageBox.question(
self, f"Close {TITLE}?",
"The automation is still running.\n\nClosing will STOP it now. Close anyway?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No)
if ans != QMessageBox.StandardButton.Yes:
event.ignore()
return
self.bridge.run.kill()
event.accept()
def selfcheck():
"""Fake bot through every ending with the real runner, then window screenshots of a green
run (light), an amber run (dark) and a stuck run (light), saved to logs/ (R14).
Never runs the real bot."""
import time
fake = str((HERE / "fake_bot.py").relative_to(ROOT))
os.environ["FAKE_CPS"] = str(len(CHECKPOINTS))
quick = [(n, 2 if i == 1 else 0) for i, (n, _) in enumerate(CHECKPOINTS)] # checkpoint 2 sticks after 2 s
want = {"complete": "done", "partial": "partial", "stuck": "error", "crash": "error"}
LOGS.mkdir(exist_ok=True)
for mode, state in want.items():
os.environ["FAKE_MODE"] = mode
events = []
log = LOGS / f"selfcheck_{mode}.log"
r = runner.Run(bot_cmd("", log, fake), log, quick, lambda e, d: events.append((e, d)), cwd=str(ROOT)).start()
r.thread.join(60)
end = events[-1][1] if events and events[-1][0] == "status" else {}
assert end.get("state") == state, f"{mode}: expected {state}, got {end} (log: {log})"
if mode == "stuck":
assert ("cpstate", {"kind": "stuck", "cp": 2}) in events, f"stuck box missing: {events}"
print(f"ok {mode:<8} -> {end['state']}: {end['msg']}")
# The window: one green run in light, one amber run in dark, saved as PNGs to look at.
app = QApplication.instance() or QApplication(sys.argv)
w = RunTabWindow(fake, quick)
w.resize(1280, 900)
w.show()
shots = [("complete", False), ("partial", True), ("stuck", False)]
def wait_for_end(then):
def poll():
if w.bridge.running or w.bridge.run is None:
QTimer.singleShot(200, poll)
else:
QTimer.singleShot(800, then)
poll()
def next_shot():
if not shots:
app.quit()
return
mode, dark = shots.pop(0)
os.environ["FAKE_MODE"] = mode
w.bridge.run = None
w.bridge.send("apply_dark", {"on": dark})
w.view.page().runJavaScript("fileReady=true; filePath=''; doRun();")
def save():
out = LOGS / f"selfcheck_{mode}_{'dark' if dark else 'light'}.png"
w.view.grab().save(str(out))
print(f"ok screenshot {out}")
next_shot()
def scrolled_save():
w.view.page().runJavaScript("document.getElementById('prog-wrap').scrollIntoView()")
QTimer.singleShot(400, save)
QTimer.singleShot(300, lambda: wait_for_end(scrolled_save))
w.view.loadFinished.connect(lambda ok: QTimer.singleShot(500, next_shot))
QTimer.singleShot(90_000, app.quit) # never hang
t = time.monotonic()
app.exec()
print(f"selfcheck done in {time.monotonic() - t:.0f}s")
if __name__ == "__main__":
if "--selfcheck" in sys.argv:
selfcheck()
sys.exit(0)
app = QApplication(sys.argv)
app.setApplicationName(TITLE)
w = RunTabWindow()
w.show()
sys.exit(app.exec())
+152
View File
@@ -0,0 +1,152 @@
"""Runs the bot in its own process and turns its log file into Run tab events.
No Qt in here (ADR-0002): the bot runs as a separate process with no Qt, writes every line
to a log file, and this module reads that file every 0.2 s. It does not read a stdout pipe,
because a windowed PyInstaller child's pipe is block-buffered and lines arrive late.
Bot lines it understands:
[CPSTATE] active N / [CPSTATE] done N checkpoint progress (ADR-0001)
[UI] text, [UI:ok] / [UI:warn] / [UI:error] text lines the operator sees
Error / exception lines always shown, even untagged
__RUN_SUMMARY__{"total":n,"ok":n,"failed":[[item, reason], ...]} the outcome
Everything else stays in the log file only.
"""
import json
import re
import subprocess
import sys
import threading
import time
SUMMARY = "__RUN_SUMMARY__"
_CP = re.compile(r"^\[CPSTATE\]\s+(active|done)\s+(\d+)")
_UI = re.compile(r"^\[UI(?::(ok|warn|error))?\]\s?(.*)$")
# Untagged lines that still reach the screen: the last line of a traceback
# ("ValueError: ...") and lines starting with ERROR.
# ponytail: a regex guess, so add a [UI:error] line to the bot when an error shows badly.
_ERR = re.compile(r"^([A-Za-z_][\w.]*(Error|Exception)\b|ERROR\b)")
_UI_TYPE = {None: "info", "ok": "success", "warn": "warn", "error": "error"}
_NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0)
def parse(line):
"""One bot line -> (event, data), or None when it stays off screen."""
m = _CP.match(line)
if m:
return "cpstate", {"kind": m[1], "cp": int(m[2])}
m = _UI.match(line)
if m:
return "log", {"msg": m[2], "type": _UI_TYPE[m[1]]}
if _ERR.match(line):
return "log", {"msg": line, "type": "error"}
return None
def outcome(summary, stuck_name=None):
"""(state, message) for the page: done (green), partial (amber) or error (red)."""
if stuck_name:
return "error", f"Stuck at {stuck_name}, run again"
if summary is None:
return "error", "Failed: the bot stopped without a summary (see the run log)"
total = int(summary.get("total") or 0)
failed = summary.get("failed") or []
if failed and len(failed) >= total:
return "error", f"Failed: all {len(failed)} item(s) failed"
if failed:
return "partial", f"Finished: {len(failed)} of {total} failed"
return "done", f"Completed: {total} item(s)" if total else "Completed"
def kill_tree(proc, kill_also=()):
"""Kill the bot and everything it started, then any extra .exe names (e.g. a driver)."""
if proc is not None and proc.poll() is None:
if sys.platform.startswith("win"):
subprocess.run(["taskkill", "/PID", str(proc.pid), "/T", "/F"],
capture_output=True, timeout=15, creationflags=_NO_WINDOW)
else:
proc.kill() # ponytail: direct child only off Windows; use a process group if bots spawn children there
for image in kill_also:
subprocess.run(["taskkill", "/F", "/IM", image],
capture_output=True, timeout=10, creationflags=_NO_WINDOW)
class Run:
"""One bot run. emit(event, data) is called from the run thread."""
def __init__(self, cmd, log_path, checkpoints, emit, kill_also=(), cwd=None):
self.cmd, self.log_path, self.emit = cmd, str(log_path), emit
self.checkpoints = checkpoints # [(name, max_seconds)], 0 = no limit
self.kill_also, self.cwd = kill_also, cwd
self.proc = None
self.summary = None
self.active, self.since, self.stuck = 0, 0.0, None
self.thread = threading.Thread(target=self._run, daemon=True)
def start(self):
self.thread.start()
return self
def kill(self):
kill_tree(self.proc, self.kill_also)
def _line(self, line):
if line.startswith(SUMMARY):
try:
self.summary = json.loads(line[len(SUMMARY):])
except ValueError:
self.emit("log", {"msg": "Could not read the bot's summary line", "type": "warn"})
return
ev = parse(line)
if not ev:
return
if ev[0] == "cpstate":
kind, n = ev[1]["kind"], ev[1]["cp"]
if kind == "active":
self.active, self.since = n, time.monotonic()
elif n == self.active:
self.active = 0
self.emit(*ev)
def _check_stuck(self):
if not self.active or self.active > len(self.checkpoints):
return False
name, limit = self.checkpoints[self.active - 1]
if limit and time.monotonic() - self.since > limit:
self.stuck = name
self.emit("cpstate", {"kind": "stuck", "cp": self.active})
self.emit("log", {"msg": f"{name} took longer than {limit}s, stopping the run", "type": "error"})
self.kill()
return True
return False
def _run(self):
self.emit("status", {"state": "running", "msg": "Running automation..."})
open(self.log_path, "w", encoding="utf-8").close()
try:
self.proc = subprocess.Popen(self.cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
cwd=self.cwd, creationflags=_NO_WINDOW)
except OSError as e:
self.emit("status", {"state": "error", "msg": f"Could not start the bot: {e}"})
return
buf = ""
with open(self.log_path, encoding="utf-8", errors="replace") as f:
while True:
exited = self.proc.poll() is not None # check before reading, so the last lines are never lost
chunk = f.read()
if chunk:
buf += chunk
*lines, buf = buf.split("\n")
for ln in lines:
self._line(ln.rstrip("\r"))
elif exited or self.stuck:
break
elif self._check_stuck():
self.proc.wait(timeout=30)
else:
time.sleep(0.2)
if buf.strip():
self._line(buf.rstrip("\r"))
for item, reason in (self.summary or {}).get("failed") or []:
self.emit("log", {"msg": f"✕ {item}: {reason}", "type": "error"})
state, msg = outcome(None if self.stuck else self.summary, self.stuck)
self.emit("status", {"state": state, "msg": msg})
+10
View File
@@ -40,6 +40,10 @@ uv run --with pytest --with playwright pytest tests
does). Runs the AI web page's review and cost cases and the no-AI cases unchanged, plus
a tampered page calling the slot directly (still refused). To check a generated folder,
set `PAGE_CHECK_AI_DESKTOP_DIR=path/to/dashboard`; `PAGE_CHECK_WIRED=1` as above.
`test_ai_fetch.py` runs the review cases on the same page with no Qt bridge (a plain-HTML
web project): the AI sections fetch `/api/...`, routed to `server_stub.dispatch()`.
- `page_check_common.py`: what the three page checks share (modes, test fill, fake Qt
bridge, stub loader, page opener, the list of no-AI cases the AI pages rerun).
- `test_pricing.py`: the AI cost function (normal, long-context, thinking tokens,
unknown model).
- `test_reference_scan.py`: no patient data, internal IPs, credentials or keys in any
@@ -63,3 +67,9 @@ uv run --with pytest --with playwright pytest tests
`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.
- `page_check_run_tab/` + `test_run_tab.py`: **Run tab skill.** The page filled with a test
config and driven through `window.__pyMsg`: Trigger states, sheet preview with masked
passwords, checkpoints driving steps/boxes/progress, green/amber/red endings, the no-input
mode, the dark toggle and no horizontal scroll, each in light, dark and narrow mode. The
Python side covers line parsing, endings, the sample workbook and `palette.py --write`.
The end-to-end fake-bot check lives in the skill itself: `python skills/rpa-run-tab/templates/run_tab.py --selfcheck`.
@@ -0,0 +1,421 @@
<!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>RPA Dashboard</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}
.cp-track{display:grid;grid-template-columns:repeat(auto-fit,minmax(118px,1fr));gap:8px;margin-bottom:16px}
.cp-box{display:flex;flex-direction:column;gap:4px;padding:10px 11px;border-radius:11px;border:1.5px solid var(--border);background:var(--glass-strong);color:var(--muted);font-size:11.5px;font-weight:700}
.cp-name{color:var(--ink);font-size:12.5px}
.cp-box.active{border-color:var(--warn);background:var(--warn-bg);color:var(--warn)}
.cp-box.done{border-color:var(--ok);background:var(--ok-bg);color:var(--ok)}
.cp-box.stuck{border-color:var(--bad);background:var(--bad-bg);color:var(--bad)}
body:not([data-role="admin" i]) .adm-only{display:none!important}
.toolbar select{padding:8px 10px;border:1px solid var(--border);border-radius:10px;background:var(--glass-strong);color:var(--ink);font:inherit;font-size:12.5px}
.toolbar select option{background:var(--bg-bottom);color:var(--ink)}
.pill.adm-role{background:var(--muted);text-transform:capitalize}.pill.adm-role.admin{background:var(--accent)}
.adm-note{font-size:12.5px;color:var(--muted)}
.login-screen{position:fixed;inset:0;z-index:50;display:flex;align-items:center;justify-content:center;padding:16px;
background:linear-gradient(180deg,var(--bg-top) 0%,var(--bg-mid) 45%,var(--bg-bottom) 100%)}
.login-card{width:min(360px,100%);display:flex;flex-direction:column;gap:12px;margin:0}
.login-card .c-title{font-size:19px;margin:0}
.login-sub{font-size:12.5px;color:var(--muted);margin-top:-8px}
.login-card label{display:flex;flex-direction:column;gap:5px;font-size:12px;font-weight:700;color:var(--muted)}
.login-card input{padding:10px 12px;border:1px solid var(--border);border-radius:10px;background:var(--glass-strong);color:var(--ink);font:inherit;font-size:13px}
.login-err{color:var(--bad);font-size:12.5px;font-weight:600;min-height:1.2em}
.login-out svg{width:17px;height:17px;stroke:currentColor;fill:none;stroke-width:2;vertical-align:middle}
@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">RPA Dashboard</div><div class="logo-sub">Bot status and runs</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>
<button class="nav-item adm-only" id="adm-nav" data-tab="admin" title="Admin"><svg viewBox="0 0 24 24"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg><span>Admin</span></button>
</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 login-out" title="Sign out" aria-label="Sign out" onclick="loginShow(true); api('logout')"><svg viewBox="0 0 24 24"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg></button>
<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="section-label">Checkpoints</div>
<ol class="cp-track" id="cp-track" aria-label="Checkpoints"></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>
<!-- ADMIN: admins only. Hiding it is not security: the host re-checks the role on every admin action. -->
<div id="tab-admin" class="hidden adm-only">
<div class="card"><div class="c-title">Assign access</div>
<form class="toolbar" aria-label="Assign access" onsubmit="admAssign(event)">
<input id="adm-user" aria-label="Username or employee ID" placeholder="Username or employee ID" required>
<select id="adm-role" aria-label="Role"><option value="operator">Operator</option><option value="admin">Admin</option></select>
<button class="btn primary">Assign</button>
<span class="adm-note" id="adm-note" role="status"></span>
</form>
</div>
<div class="card"><div class="c-title">Users with access</div>
<div class="tbl-wrap"><table aria-label="Users with access"><thead><tr><th>User</th><th>Employee ID</th><th>Role</th><th></th></tr></thead><tbody id="adm-rows" onclick="admRemove(event)"></tbody></table></div>
</div>
</div>
</section>
</main>
</div>
<!-- LOGIN: UI only. The host checks the password with the project's own auth (see sections.md). -->
<div class="login-screen" id="login-screen">
<form class="card login-card" aria-label="Sign in" onsubmit="loginSubmit(event)">
<div class="c-title">RPA Dashboard</div>
<p class="login-sub">Sign in to continue</p>
<label>Username<input id="login-user" autocomplete="username" required></label>
<label>Password<input id="login-pass" type="password" autocomplete="current-password" required></label>
<div class="login-err" id="login-err" role="alert"></div>
<button class="btn primary">Sign in</button>
</form>
</div>
<script>
// ===== CONFIG (fill per project) =====
const STEPS = ["Initializing","Login","Process items","Completed"];
const REPORT_COLUMNS = [{"key":"item","label":"Item"},{"key":"batch_id","label":"Batch"},{"key":"status","label":"Status"}];
const API_BASE = "/api"; // web mode
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(()=>{
handle('cpstate', i<STEPS.length ? {kind:'active',cp:Math.ceil((i+1)*CHECKPOINTS.length/STEPS.length)} : {kind:'done',cp:CHECKPOINTS.length});
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); },
adm_users:[{username:'Sample admin',emp_id:'E-1001',role:'admin'},{username:'Sample operator',emp_id:'E-1002',role:'operator'}],
load_admin(){ handle('admin_data',{rows:this.adm_users}); },
assign_role(who,role){ this.remove_role(who); this.adm_users.push({username:who,emp_id:'',role}); this.load_admin(); },
remove_role(who){ this.adm_users=this.adm_users.filter(u=>u.username!==who&&u.emp_id!==who); this.load_admin(); },
login(username){ handle('login_result',{ok:true,user:{username,emp_id:'',role:document.body.dataset.role||'operator'}}); }, // stub: anyone, same role
logout(){},
};
// ===== 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;
case 'cpstate': cpState(d.kind, d.cp); break;
case 'admin_data': admRender(d); break;
case 'login_result': loginResult(d); 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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
function setDark(on){ document.body.classList.toggle('dark',on); $('mode-btn').setAttribute('aria-pressed',on); $('mode-btn').textContent=on?'☀':'🌙'; }
function toggleDark(){ const on=!document.body.classList.contains('dark'); setDark(on); api('set_dark',on); }
function setUser(u){ document.body.dataset.role=u.role||''; $('u-name').textContent=u.username; $('u-role').textContent=u.role; $('avatar').textContent=(u.username||'U')[0].toUpperCase(); }
function setStatus(state,msg){ $('st-dot').className='dot '+state; $('st-text').textContent=msg||state; $('run-btn').disabled=state==='running'; }
function startRun(){ showTab('run'); $('log').innerHTML=''; setStep(STEPS[0],0); api('start_automation'); }
function renderSteps(active=-1){
$('steps').innerHTML=STEPS.map((s,i)=>{const c=i<active?'done':i===active?'active':'pending';
return `<li class="step ${c}"${c==='active'?' aria-current="step"':''}><span class="step-dot">${c==='done'?'✓':i+1}</span>${esc(s)}</li>`}).join('');
}
function setStep(label,pct){ $('p-label').textContent=label; $('p-pct').textContent=pct+'%'; $('p-fill').style.width=pct+'%';
renderSteps(pct>=100?STEPS.length:STEPS.indexOf(label)); }
function addLog(msg,type='info'){ const l=$('log'); l.insertAdjacentHTML('beforeend',`<div class="${type}">${esc(msg)}</div>`); l.scrollTop=l.scrollHeight; }
function countUp(el,to){ const from=+el.textContent||0,t0=performance.now();
const f=t=>{const p=Math.min((t-t0)/500,1); el.textContent=Math.round(from+(to-from)*p); if(p<1)requestAnimationFrame(f)}; requestAnimationFrame(f); }
// batch state -> [label, colour token, pill class]
const STATES={completed:['Completed','var(--ok)','success'],inprogress:['In progress','var(--warn)','attention'],
notcompleted:['Not completed','var(--bad)','failed'],cleared:['Cleared','var(--muted)','cleared']};
function renderDashboard(b){
const c={}; Object.keys(STATES).forEach(k=>c[k]=b.filter(x=>x.state===k).length);
countUp($('k-total'),b.length); countUp($('k-done'),c.completed); countUp($('k-prog'),c.inprogress); countUp($('k-fail'),c.notcompleted);
$('kpi-prog').classList.toggle('live',c.inprogress>0); $('dh-num').textContent=b.length;
const tot=Object.values(c).reduce((a,x)=>a+x,0); let acc=0; const stops=[];
Object.keys(c).forEach(k=>{ if(!c[k])return; const a=acc/tot*100; acc+=c[k]; stops.push(`${STATES[k][1]} ${a}% ${acc/tot*100}%`); });
$('donut').style.background = tot?`conic-gradient(${stops.join(',')})`:'conic-gradient(var(--border) 0 100%)';
$('donut').setAttribute('aria-label','Batch status: '+Object.keys(c).map(k=>`${STATES[k][0]} ${c[k]}`).join(', '));
$('legend').innerHTML=Object.keys(c).map(k=>`<li class="dl-row"><span class="dl-dot" style="background:${STATES[k][1]}"></span>${STATES[k][0]} · ${c[k]}</li>`).join('');
$('recent').className = b.length?'':'empty';
$('recent').innerHTML = b.length? b.slice(0,6).map(x=>{const s=STATES[x.state]||[esc(x.state),'','attention'];
return `<div class="dl-row">#${esc(x.batch_id)} · ${esc(x.date)} <span class="pill ${s[2]}" style="margin-left:auto">${s[0]}</span></div>`}).join('') : 'No batches yet';
}
function renderReport(){
const q=$('r-search').value.toLowerCase();
$('r-head').innerHTML='<tr>'+REPORT_COLUMNS.map(c=>`<th>${esc(c.label)}</th>`).join('')+'</tr>';
const rows=reportRows.filter(r=>!q||Object.values(r).join(' ').toLowerCase().includes(q));
$('r-body').innerHTML = rows.length? rows.map(r=>'<tr>'+REPORT_COLUMNS.map(c=>c.key==='status'
?`<td><span class="pill ${esc(r.bucket||'attention')}">${esc(r.status)}</span></td>`:`<td>${esc(r[c.key])}</td>`).join('')+'</tr>').join('')
: `<tr><td class="empty" colspan="${REPORT_COLUMNS.length}">No rows yet</td></tr>`;
}
// Checkpoint tracker: numbered boxes driven by cpstate {kind, cp}. cp counts from 1.
const CHECKPOINTS = ["Open the portal","Load the list","Process items","Finish up"];
const CP_LABEL = {pending:'Pending', active:'In progress', done:'Done', stuck:'Stuck'};
let cpKinds = CHECKPOINTS.map(()=>'pending');
function cpState(kind, n){
if(!CP_LABEL[kind] || !(n>=1 && n<=CHECKPOINTS.length)) return;
if(kind==='active') cpKinds = cpKinds.map((_,i)=>i<n-1?'done':'pending'); // a new active box resets the rest
cpKinds[n-1] = kind;
$('cp-track').innerHTML = CHECKPOINTS.map((c,i)=>`<li class="cp-box ${cpKinds[i]}"${cpKinds[i]==='active'?' aria-current="step"':''}>`
+`<span>${i+1}</span><span class="cp-name">${esc(c)}</span><span>${CP_LABEL[cpKinds[i]]}</span></li>`).join('');
}
cpState('pending', 1);
// Admin screen: shown only while set_user says role "admin" (CSS on body[data-role]).
TABS.admin = ['Admin','Who can use this dashboard'];
$('adm-nav').addEventListener('click', ()=>api('load_admin'));
function admRender(d){
$('adm-note').textContent = d.msg||'';
const rows = d.rows||[];
$('adm-rows').innerHTML = rows.length ? rows.map(u=>`<tr><td>${esc(u.username)}</td><td>${esc(u.emp_id)}</td>`
+`<td><span class="pill adm-role ${/^admin$/i.test(u.role)?'admin':''}">${esc(u.role)}</span></td>`
+`<td><button class="btn" data-user="${esc(u.emp_id||u.username)}">Remove</button></td></tr>`).join('')
: '<tr><td class="empty" colspan="4">No one has access yet</td></tr>';
}
function admAssign(e){ e.preventDefault(); api('assign_role', $('adm-user').value.trim(), $('adm-role').value); $('adm-user').value=''; }
function admRemove(e){ const who=e.target.dataset.user; if(who) api('remove_role', who); }
if(SAMPLE_DATA) setUser({username:'Sample admin', emp_id:'E-1001', role:'admin'}); // demo: show the admin screen
// Login page (UI only): the host checks the password with the project's own auth and answers login_result.
function loginShow(on){
$('login-screen').classList.toggle('hidden', !on);
document.querySelector('.app').inert = on;
if(on){ $('login-err').textContent=''; $('login-user').focus(); }
}
function loginSubmit(e){
e.preventDefault(); $('login-err').textContent='';
const u=$('login-user').value.trim(), p=$('login-pass').value;
if(SAMPLE_DATA || bridge) return api('login', u, p);
fetch(`${API_BASE}/login`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:u,password:p})})
.then(r=>r.json()).then(d=>handle('login_result',d))
.catch(()=>handle('login_result',{ok:false,msg:'Could not reach the server.'}));
}
function loginResult(d){
$('login-pass').value='';
if(!d.ok){ $('login-err').textContent = d.msg||'Sign-in failed.'; return; }
if(d.user) setUser(d.user);
loginShow(false); showTab('dashboard');
}
loginShow(true);
renderSteps();
startTransport();
</script>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
{
"fixture": "a-empty-sections",
"passed": true,
"checks": {
"frontend reported correctly": true,
"asks /colou?r/": true,
"asks /desktop|web/": true,
"asks /section/": true,
"never asks /\\bkeep\\b|restyle/": true,
"exactly one page built": true,
"no placeholders left": true,
"checkpoints built": true,
"admin built": true,
"login built": true,
"page check passes": true,
"no section checks skipped": true
},
"page_check": [
"43 passed, 3 skipped in 34.31s"
],
"files": [
"README.md",
"dashboard\\dashboard.html"
],
"reply": "Dashboard ready. Sample data la ok-aa run aagudhu, ippo bot connect pannala.\n\n| Item | Details |\n|---|---|\n| File | `dashboard/dashboard.html` (single file) |\n| Theme | Superbill Ember, light + dark |\n| Stack | Web, `API_BASE = \"/api\"`. Backend ippo illa, so `SAMPLE_DATA = true` |\n| Sections | Shell, KPI cards, donut, Run panel, Report + Excel button, **Checkpoint tracker, Admin/roles, Login** |\n| Login | **Stub** dhaan: password check illa, empty illadha username kudutha yaarum ulla poga mudiyum. Unga project la auth illa, adhaan |\n\n**Check pannadhu (headless Playwright screenshots):**\n- Login, Dashboard (6 batches, 4 KPI), Run (4/4 steps, 4/4 checkpoints, log), Report (4/4 rows, pills), Admin (2 users), dark mode, 800px width: ellam render aagudhu.\n- `{{` placeholder ethuvum baakki illa. Script errors um illa.\n\n**Bot wire panna:** backend `GET /api/dashboard`, `GET /api/run/status` (`{running, events[]}`), `POST /api/<action>` serve pannanum, apram `/api/login` um venum. Bot anuppa vendiya events: `status`, `step`, `log`, `cpstate`, `admin_data`, `login_result`.\n\n**Also noticed (fix pannala):** `file://` la open panna console la rendu harmless errors varum. Onnu: `qwebchannel.js` load aagala, adhu Qt ku mattum dhaan venum. Rendu: dark toggle `/api/set-dark` POST panna try pannum, adhu fail aagi silent-aa catch aagidum. Backend la serve panna rendum varaadhu.\n\nFRONTEND: none (detect_frontend.py: \"No existing frontend found.\")\nQUESTIONS: Which colours: Tebra Sky, Superbill Ember, or Custom (one brand hex)? | Desktop (PyQt6) or web? | Which optional sections: Checkpoint tracker, Admin/roles screen, Login page (any or none)?"
}
+13 -7
View File
@@ -2,14 +2,14 @@
<!--
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).
Theme = the body / body.dark token blocks only (see themes.md). Custom brand #7a3cff.
backend (falls back to polling GET <API_BASE>/dashboard and /run/status).
Placeholders filled. 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>RPA Dashboard</title>
<title>RPA Bot</title>
<script src="qrc:///qtwebchannel/qwebchannel.js"></script>
<style>
/* ===== THEME TOKENS (swap these for a preset/custom palette, see themes.md) ===== */
@@ -125,7 +125,7 @@ td{padding:10px 14px;border-bottom:1px solid var(--border)}
<aside class="sidebar">
<div class="logo-area">
<div class="logo-box"><img src="" alt="" onerror="this.remove()"></div>
<div><div class="logo-title">RPA Dashboard</div><div class="logo-sub">Bot status &amp; reports</div></div>
<div><div class="logo-title">RPA Bot</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>
@@ -179,14 +179,16 @@ td{padding:10px 14px;border-bottom:1px solid var(--border)}
<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 items","Completed"];
const REPORT_COLUMNS = [{"key":"item","label":"Item"},{"key":"batch","label":"Batch"},{"key":"status","label":"Status"}];
const STEPS = ["Initializing","Login","Process items","Completed"]; // e.g. ["Initializing","Login","Process items","Completed"]
const REPORT_COLUMNS = [{"key":"item","label":"Item"},{"key":"date","label":"Date"},{"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 = 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"]};
@@ -263,7 +265,7 @@ const $=id=>document.getElementById(id), tbTitle=$('tb-title'), tbSub=$('tb-sub'
function esc(s){return String(s??'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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 setUser(u){ document.body.dataset.role=u.role||''; $('u-name').textContent=u.username; $('u-role').textContent=u.role; $('avatar').textContent=(u.username||'U')[0].toUpperCase(); }
function setStatus(state,msg){ $('st-dot').className='dot '+state; $('st-text').textContent=msg||state; $('run-btn').disabled=state==='running'; }
function startRun(){ showTab('run'); $('log').innerHTML=''; setStep(STEPS[0],0); api('start_automation'); }
@@ -301,6 +303,10 @@ function renderReport(){
?`<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>
+6 -2
View File
@@ -10,15 +10,19 @@
"exactly one page built": true,
"no placeholders left": true,
"custom palette applied": true,
"checkpoints left no trace": true,
"admin left no trace": true,
"login left no trace": true,
"page check passes": true
},
"page_check": [
"22 passed in 17.68s"
"28 passed, 18 skipped in 21.91s"
],
"files": [
"README.md",
"dashboard\\bot_runner.py",
"dashboard\\bridge.py",
"dashboard\\dashboard.html"
],
"reply": "Dashboard ready ah iruku. Sample data la run aagudhu, headless browser la check panniten.\n\n| Item | Details |\n|---|---|\n| Files | `dashboard/dashboard.html`, `dashboard/bridge.py` (PyQt6 desktop host) |\n| Theme | Custom `#7a3cff`. Contrast check 10/10 PASS, colour-ah change panna thevai illa. Dark mode la button colour konjam light ah `#8248ff` nu irukum. Background innum template oda sky-blue gradient dhaan, purple accents mattum dhaan maarinadhu. |\n| Sections | Core mattum: sidebar, top bar, dark toggle, status dot, KPI cards, donut, run panel, report table. Optional edhuvum add pannala. |\n| Data | `SAMPLE_DATA = true`. Project la bot illa. |\n\n**Check results (Playwright, browser la open panni):**\n- KPI: 6 total / 3 completed / 1 in progress / 1 not completed. Donut legend la 4 rows.\n- Run: 4/4 steps done, 5 log lines, status \"Completed\".\n- Report: 4/4 rows la status pill varudhu.\n- Light, dark, 800px width moonum sariya varudhu. Script errors 0, `{{` placeholders 0.\n- **UNVERIFIED:** `bridge.py` la irukura PyQt window-ah naan open pannala. Adhu real ah run aagudhanu check pannala.\n\n**Bot wire pannumbodhu:**\n- Bot indha events anuppanum: `status`, `step`, `log`, `dashboard_data`, `report_data`.\n- `bridge.py` la `BOT_CMD` (ippo `bot` nu placeholder), `STEP_MARKERS`, `FRIENDLY_RULES` set pannunga.\n- Apram `SAMPLE_DATA = false` panni, SAMPLE block-ah delete pannunga.\n\n**Also noticed:** steps (`Initializing \u2192 Login \u2192 Process items \u2192 Completed`) and report columns (Item/Batch/Status) placeholder dhaan, ungaloda real bot stages vandha maathanum.\n\nFRONTEND: none\nQUESTIONS: Which colours: Tebra Sky, Superbill Ember, or Custom (one brand hex)? | Desktop or web? | Which optional sections: checkpoint tracker, admin/roles screen, login page, or none?"
"reply": "Dashboard ready, sample data-la odudhu. Project-la frontend-um illa, bot-um illa.\n\n**Files** (`dashboard/` folder):\n| File | Enna |\n|---|---|\n| `dashboard/dashboard.html` | Page: custom #7a3cff theme, optional sections ellam remove pannitten |\n| `dashboard/bridge.py` | PyQt6 desktop window |\n| `dashboard/bot_runner.py` | Bot wiring. `BOT_MODULE = bot` placeholder; unga bot vandhadhum idha set pannunga |\n\n**Contrast** (ellam pass): light accent 5.36:1, dark accent 4.86:1, dark accent text 3.03:1 (3:1 venum, edge-la dhaan pass aagudhu).\n\n**Browser-la test pannen** (headless Playwright, screenshots paathen): light/dark theme rendering, KPI 6 batches, donut legend 4, Run click pannumbodhu button disabled-a irundhu 100%-la thirumba enable aachu, Report 4 rows/4 pills, 800px-la horizontal scroll illa, console errors illa.\n\n**Test pannala:** `python dashboard/bridge.py` PyQt window open pannala (session non-interactive). Adhanala window close panni reopen pannumbodhu dark-mode save aagudha nu check pannala. Neenga oru dhadava run panni paarunga.\n\n**Bot vandha apram:** bot ivvalavu events anuppanum: `status`, `step`, `log`, `dashboard_data`, `report_data` (details `contract.md`-la irukku). Adhoda `SAMPLE_DATA = false` set pannanum.\n\nFRONTEND: none\nQUESTIONS: Colours \u2014 Tebra Sky, Superbill Ember, or Custom (one brand hex)? | Desktop or web? | Which optional sections \u2014 Checkpoint tracker, Admin/roles screen, Login page (any or none)?"
}
@@ -0,0 +1,28 @@
<!doctype html>
<!--
AI RPA dashboard (React + Vite + Tailwind). Everything on the no-AI page, plus
confidence bands, the review split pane and the approve gate.
Fill every placeholder in the CONFIG block; they mean the same as on the
no-AI page. Theme = the body / body.dark token blocks in src/index.css only.
-->
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Invoice Extraction</title>
<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">
<script>
// ===== CONFIG (fill per project) =====
const APP_NAME = "Invoice Extraction";
const APP_SUBTITLE = "AI invoice field review";
const LOGO_SRC = "";
const STEPS = ["Initializing","Extract","Review","Completed"];
const REPORT_COLUMNS = [{"key":"document","label":"Document"},{"key":"invoice_number","label":"Invoice number"},{"key":"vendor","label":"Vendor"},{"key":"total","label":"Total"},{"key":"status","label":"Status"}];
const API_BASE = "/api";
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,568 @@
<!DOCTYPE html>
<!--
Generic RPA dashboard (no AI). Derived from Tebra ERA (reference/tebra_dashboard.html).
One file: works inside PyQt6 QWebEngineView (QWebChannel bridge) OR served by any web
backend (falls back to polling GET {API_BASE}/dashboard and /run/status).
Theme = the body / body.dark token blocks only (Tebra Sky, see themes.md).
-->
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Invoice Intake</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">Invoice Intake</div><div class="logo-sub">Scanned invoice extraction</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","Extract","Review","Completed"]; // bot.py's [step N] stages
const REPORT_COLUMNS = [{key:"document",label:"Invoice"},{key:"fields",label:"Fields extracted"},{key:"status",label:"Status"}];
const API_BASE = ""; // desktop: the Qt bridge, no web server
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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
function setDark(on){ document.body.classList.toggle('dark',on); $('mode-btn').setAttribute('aria-pressed',on); $('mode-btn').textContent=on?'☀':'🌙'; }
function toggleDark(){ const on=!document.body.classList.contains('dark'); setDark(on); api('set_dark',on); }
function setUser(u){ document.body.dataset.role=u.role||''; $('u-name').textContent=u.username; $('u-role').textContent=u.role; $('avatar').textContent=(u.username||'U')[0].toUpperCase(); }
function setStatus(state,msg){ $('st-dot').className='dot '+state; $('st-text').textContent=msg||state; $('run-btn').disabled=state==='running'; }
function startRun(){ showTab('run'); $('log').innerHTML=''; setStep(STEPS[0],0); api('start_automation'); }
function renderSteps(active=-1){
$('steps').innerHTML=STEPS.map((s,i)=>{const c=i<active?'done':i===active?'active':'pending';
return `<li class="step ${c}"${c==='active'?' aria-current="step"':''}><span class="step-dot">${c==='done'?'✓':i+1}</span>${esc(s)}</li>`}).join('');
}
function setStep(label,pct){ $('p-label').textContent=label; $('p-pct').textContent=pct+'%'; $('p-fill').style.width=pct+'%';
renderSteps(pct>=100?STEPS.length:STEPS.indexOf(label)); }
function addLog(msg,type='info'){ const l=$('log'); l.insertAdjacentHTML('beforeend',`<div class="${type}">${esc(msg)}</div>`); l.scrollTop=l.scrollHeight; }
function countUp(el,to){ const from=+el.textContent||0,t0=performance.now();
const f=t=>{const p=Math.min((t-t0)/500,1); el.textContent=Math.round(from+(to-from)*p); if(p<1)requestAnimationFrame(f)}; requestAnimationFrame(f); }
// batch state -> [label, colour token, pill class]
const STATES={completed:['Completed','var(--ok)','success'],inprogress:['In progress','var(--warn)','attention'],
notcompleted:['Not completed','var(--bad)','failed'],cleared:['Cleared','var(--muted)','cleared']};
function renderDashboard(b){
const c={}; Object.keys(STATES).forEach(k=>c[k]=b.filter(x=>x.state===k).length);
countUp($('k-total'),b.length); countUp($('k-done'),c.completed); countUp($('k-prog'),c.inprogress); countUp($('k-fail'),c.notcompleted);
$('kpi-prog').classList.toggle('live',c.inprogress>0); $('dh-num').textContent=b.length;
const tot=Object.values(c).reduce((a,x)=>a+x,0); let acc=0; const stops=[];
Object.keys(c).forEach(k=>{ if(!c[k])return; const a=acc/tot*100; acc+=c[k]; stops.push(`${STATES[k][1]} ${a}% ${acc/tot*100}%`); });
$('donut').style.background = tot?`conic-gradient(${stops.join(',')})`:'conic-gradient(var(--border) 0 100%)';
$('donut').setAttribute('aria-label','Batch status: '+Object.keys(c).map(k=>`${STATES[k][0]} ${c[k]}`).join(', '));
$('legend').innerHTML=Object.keys(c).map(k=>`<li class="dl-row"><span class="dl-dot" style="background:${STATES[k][1]}"></span>${STATES[k][0]} · ${c[k]}</li>`).join('');
$('recent').className = b.length?'':'empty';
$('recent').innerHTML = b.length? b.slice(0,6).map(x=>{const s=STATES[x.state]||[esc(x.state),'','attention'];
return `<div class="dl-row">#${esc(x.batch_id)} · ${esc(x.date)} <span class="pill ${s[2]}" style="margin-left:auto">${s[0]}</span></div>`}).join('') : 'No batches yet';
}
function renderReport(){
const q=$('r-search').value.toLowerCase();
$('r-head').innerHTML='<tr>'+REPORT_COLUMNS.map(c=>`<th>${esc(c.label)}</th>`).join('')+'</tr>';
const rows=reportRows.filter(r=>!q||Object.values(r).join(' ').toLowerCase().includes(q));
$('r-body').innerHTML = rows.length? rows.map(r=>'<tr>'+REPORT_COLUMNS.map(c=>c.key==='status'
?`<td><span class="pill ${esc(r.bucket||'attention')}">${esc(r.status)}</span></td>`:`<td>${esc(r[c.key])}</td>`).join('')+'</tr>').join('')
: `<tr><td class="empty" colspan="${REPORT_COLUMNS.length}">No rows yet</td></tr>`;
}
renderSteps();
startTransport();
</script>
<!-- ===== AI SECTIONS (desktop) =====
Paste this whole block into the no-AI dashboard.html, just before </body>. It adds a Review
tab: band legend, band filters, the source document beside its extracted fields, drafts,
Verify, and Approve. Plain HTML, the page's own theme tokens, no build step.
In PyQt, every request goes over the Qt bridge as one slot call, ai_request(id, method, path,
body), and comes back as an `ai_response` envelope (see ../contract.md, "Desktop"). With no
Qt (a plain-HTML web project), it fetches {API_BASE}{path} from the backend instead, the
web endpoints in ../contract.md. The approve gate is the backend's: this page only asks,
and shows the refusal.
-->
<style>
/* BAND COLOURS: semantic only (swatches, the rule down a field). The same in every preset and both modes. */
body{--clean:#51a68f;--verify:#e39b34;--review:#de5243;--absent:#6b6580}
.rv-bar{display:flex;flex-wrap:wrap;align-items:center;gap:10px 14px;padding:14px 18px}
.rv-bar label{display:flex;align-items:center;gap:8px;font-size:12.5px;font-weight:600}
.rv-bar select{padding:7px 10px;border:1px solid var(--border);border-radius:10px;background:var(--glass-strong);color:var(--ink);font:inherit;font-size:12.5px}
.rv-muted{font-size:12px;color:var(--muted)}
.rv-right{margin-left:auto;display:flex;align-items:center;gap:8px}
.rv-alert{width:100%;border-radius:10px;padding:7px 12px;background:var(--bad-bg);color:var(--bad);font-size:12px;font-weight:700}
.rv-legend{display:flex;flex-wrap:wrap;gap:4px 16px}
.rv-legend li{display:flex;align-items:center;gap:6px;font-size:12px;font-weight:600}
.rv-sw{width:10px;height:10px;border-radius:3px;display:inline-block}
.rv-filters{margin-left:auto;display:flex;gap:4px}
.rv-filters .btn,.rv-bar .btn,.rv-fields .btn,.rv-save .btn{padding:6px 11px;font-size:12px}
.rv-split{display:flex;gap:14px;min-height:460px}
.rv-doc{flex:1;min-height:420px;border:1px solid var(--border);border-radius:16px;background:#fff}
.rv-pane{flex:1;display:flex;flex-direction:column;padding:0;overflow:hidden;margin-bottom:0}
.rv-fields{flex:1;overflow-y:auto;padding:16px}
.rv-fields li{display:grid;grid-template-columns:minmax(80px,130px) 1fr auto auto;align-items:center;gap:8px;margin-bottom:9px}
.rv-fields li.done{opacity:.7}
.rv-name{font-size:12px;font-weight:600;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.rv-fields input{min-width:0;padding:7px 10px;border:1px solid var(--border);border-left-width:4px;border-radius:10px;background:var(--glass-strong);color:var(--ink);font:inherit;font-size:12.5px}
.rv-fields input.dirty{outline:2px solid var(--warn)}
.rv-score{width:36px;text-align:right;font-size:11px;color:var(--muted);font-variant-numeric:tabular-nums}
.rv-note{grid-column:1/-1;margin-top:-4px;font-size:11px;color:var(--muted)}
.rv-fields .btn{width:92px}
.rv-save{display:flex;align-items:center;gap:10px;border-top:1px solid var(--border);padding:10px 16px}
.rv-save .rv-unsaved{font-size:12px;font-weight:700;color:var(--warn)}
.co-tile{padding:16px 18px;border-radius:16px;background:var(--glass-strong);border:1px solid var(--border)}
.co-tile .kpi-val{margin-top:4px}
.co-r{text-align:right}
@media (max-width:820px){.rv-split{flex-direction:column}.rv-filters{margin-left:0}}
</style>
<template id="ai-review-tab">
<div id="tab-review" class="hidden">
<div class="card rv-bar" id="rv-head">
<label>Document <select id="rv-doc-pick" aria-label="Document"></select></label>
<span id="rv-state"></span>
<div class="rv-right" id="rv-actions"></div>
<p class="rv-alert hidden" id="rv-alert" role="alert"></p>
</div>
<div class="card rv-bar">
<ul class="rv-legend" id="rv-legend" aria-label="Confidence bands"></ul>
<span class="rv-muted">Colour reflects print clarity, not correctness. A cleanly printed value can still be misread.</span>
<div class="rv-filters" id="rv-filters" role="group" aria-label="Filter by band"></div>
</div>
<div class="rv-split">
<iframe class="rv-doc" id="rv-doc" title="Source document"></iframe>
<div class="card rv-pane">
<p class="rv-muted hidden" id="rv-wait" style="padding:18px">Fields appear when extraction finishes.</p>
<ul class="rv-fields" id="rv-fields" aria-label="Extracted fields"></ul>
<div class="rv-save hidden" id="rv-save"></div>
</div>
</div>
</div>
</template>
<template id="ai-cost-tab">
<div id="tab-cost" class="hidden">
<div class="toolbar"><span class="pill" id="co-cover"></span><span class="rv-muted" id="co-source"></span></div>
<div class="kpi-row" id="co-tiles"></div>
<div class="card"><div class="c-title">Month by month</div>
<div class="tbl-wrap"><table aria-label="Spend by month"><thead><tr><th>Month</th><th>Documents priced</th><th class="co-r">Spend</th></tr></thead><tbody id="co-months"></tbody></table></div>
</div>
<section class="card" aria-labelledby="co-by-hand">
<div class="c-title" id="co-by-hand">Against doing it by hand</div>
<p class="rv-muted" style="margin-bottom:12px">These are your assumptions, not a measurement. Change them to match the team.</p>
<div class="toolbar">
<label class="rv-muted">Minutes per document <input id="co-min" type="number" min="0" value="5" style="width:80px"></label>
<label class="rv-muted">Hourly rate <input id="co-rate" type="number" min="0" value="20" style="width:80px"></label>
</div>
<p id="co-hand" style="font-size:13px"></p>
</section>
<div class="card"><div class="c-title">Cost per run</div>
<div class="tbl-wrap"><table aria-label="Cost per run"><thead><tr><th>Document</th><th>Date</th><th>Model</th><th class="co-r">Input</th><th class="co-r">Output</th><th class="co-r">Thinking</th><th class="co-r">Cost</th></tr></thead><tbody id="co-runs"></tbody></table></div>
</div>
</div>
</template>
<script>
(()=>{
// Legend order, label and colour token. Cut points come from bands.py on the Python side.
const BANDS=[['green','Clean ≥0.95','var(--clean)'],['amber','Check 0.90–0.95','var(--verify)'],
['red','Review <0.90','var(--review)'],['blank','Missing (required)','var(--absent)'],['unscored','Unscored','var(--absent)']];
const FILTERS=[['All','all'],['Red','red'],['Amber','amber'],['Missing','blank'],['Unscored','unscored']];
const BUSY=['queued','running']; // polled every 1.5s while in one of these
const colourOf=b=>BANDS.find(x=>x[0]===b)[2];
const settled=r=>r.corrected_value??r.value??'';
// ----- the tabs: nav buttons after Report, sections in the content area -----
TABS.review=['Review','Check extracted fields, then approve'];
TABS.cost=['Cost','What the AI cost, run by run'];
document.querySelector('.nav-item[data-tab="report"]').insertAdjacentHTML('afterend',
'<button class="nav-item" data-tab="review" title="Review"><svg viewBox="0 0 24 24"><path d="M9 11l3 3 8-8"/><path d="M20 12v7a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h11"/></svg><span>Review</span></button>'
+'<button class="nav-item" data-tab="cost" title="Cost"><svg viewBox="0 0 24 24"><path d="M12 2v20M17 6H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg><span>Cost</span></button>');
['review','cost'].forEach(t=>{ document.querySelector(`.nav-item[data-tab="${t}"]`).onclick=()=>showTab(t);
document.querySelector('.content').append($(`ai-${t}-tab`).content.cloneNode(true)); });
const baseShow=showTab;
showTab=t=>{
const onReview=!$('tab-review').classList.contains('hidden');
if(onReview && t!=='review' && !leaveOk()) return;
baseShow(t);
if(t==='review') loadRuns();
if(t==='cost') loadRoi();
};
window.addEventListener('beforeunload',e=>{ if(dirty().length) e.preventDefault(); });
// ----- transport: in PyQt, one bridge slot out, one `ai_response` envelope back;
// with no Qt (a plain web page), fetch {API_BASE}{path} from the backend -----
const pending={}; let seq=0;
async function aiFetch(method,path,body){
const r=await fetch(API_BASE+path,{method,headers:{'Content-Type':'application/json'},
body:method==='GET'?undefined:JSON.stringify(body||{})});
const b=await r.json().catch(()=>({}));
if(!r.ok) throw new Error(b.detail||`Error ${r.status}`);
return b;
}
function ai(method,path,body){
if(!window.qt) return aiFetch(method,path,body);
if(!bridge) return Promise.reject(new Error('The Qt bridge is not ready yet. Try again.'));
return new Promise((ok,fail)=>{ const id=++seq; pending[id]=[ok,fail];
bridge.ai_request(id,method,path,JSON.stringify(body||{})); });
}
const baseHandle=handle;
handle=(event,d)=>{
if(event!=='ai_response') return baseHandle(event,d);
const p=pending[d.id]; delete pending[d.id];
if(p) d.status<300 ? p[0](d.body) : p[1](new Error((d.body&&d.body.detail)||`Error ${d.status}`));
};
// ----- state -----
let runs=[], jobId=null, run=null, rows=[], drafts={}, filter='all', refusal=null, timer=null;
const dirty=()=>rows.filter(r=>r.row_id in drafts && drafts[r.row_id]!==settled(r));
const leaveOk=()=>!dirty().length || confirm(`${dirty().length} unsaved change(s) on this document. Discard them and leave?`);
const fail=e=>{ refusal=e.message; renderHead(); };
async function loadRuns(){
try{ runs=(await ai('GET','/runs')).runs; }catch(e){ return fail(e); }
$('rv-doc-pick').innerHTML=runs.map(r=>`<option value="${esc(r.job_id)}">${esc(r.document)}</option>`).join('');
if(!runs.length){ refusal=null; $('rv-state').innerHTML='<span class="rv-muted">No documents extracted yet.</span>'; return; }
openRun(runs.some(r=>r.job_id===jobId) ? jobId : runs[0].job_id);
}
$('rv-doc-pick').onchange=e=>{ if(leaveOk()) openRun(e.target.value); else e.target.value=jobId; };
function openRun(id){
clearTimeout(timer);
jobId=id; run=null; rows=[]; drafts={}; refusal=null;
$('rv-doc-pick').value=id;
if(!window.qt) $('rv-doc').src=`${API_BASE}/runs/${encodeURIComponent(id)}/document`; // web: the URL itself
else ai('GET',`/runs/${id}/document`).then(doc=>{ if(id===jobId) $('rv-doc').src=
String(doc).startsWith('<') ? 'data:image/svg+xml;charset=utf-8,'+encodeURIComponent(doc) : doc; }).catch(()=>{});
render(); tick(id);
}
async function tick(id){ // load the run, and poll it while it is still extracting
try{
const r=await ai('GET',`/runs/${id}`); if(id!==jobId) return;
run=r;
if(BUSY.includes(r.status)) timer=setTimeout(()=>tick(id),1500);
else{ const d=await ai('GET',`/runs/${id}/review`); if(id!==jobId) return; rows=d.rows; }
render();
}catch(e){ if(id===jobId) fail(e); }
}
async function reload(){
const id=jobId, [r,d]=await Promise.all([ai('GET',`/runs/${id}`),ai('GET',`/runs/${id}/review`)]);
if(id===jobId){ run=r; rows=d.rows; render(); }
}
// ----- actions -----
async function act(fn){ refusal=null; try{ await fn(); }catch(e){ refusal=e.message; } await reload().catch(fail); }
const save=()=>act(()=>Promise.all(dirty().map(r=>{ const text=drafts[r.row_id].trim();
// Same as extracted (or emptied) means "the extracted value was right", not an edit.
return ai('PATCH',`/review/${r.row_id}`,{corrected_value: text===''||text===(r.value??'') ? null : text, verified:true});
})).then(()=>{ drafts={}; }));
const verify=r=>act(()=>ai('PATCH',`/review/${r.row_id}`,{verified:!r.verified}));
const verifyRemaining=()=>act(()=>ai('POST',`/runs/${jobId}/verify-remaining`));
const approve=()=>act(()=>ai('POST',`/runs/${jobId}/approve`).catch(e=>{ throw new Error('Not approved: '+e.message); }));
// ----- render -----
function render(){ renderHead(); renderLegend(); renderFields(); renderSave(); }
function renderHead(){
const busy=run && BUSY.includes(run.status), pill=!run?'':run.status==='done'?'success':run.status==='failed'?'failed':'attention';
$('rv-state').innerHTML = run ? `<span class="pill ${pill}">${esc(run.status)}</span>`
+ (busy ? ` <span class="rv-muted">page ${esc(run.pages_done)} of ${esc(run.pages_total||'?')}</span>` : '')
+ (run.error ? ` <span class="rv-muted" style="color:var(--bad)">${esc(run.error)}</span>` : '') : '';
const left=rows.filter(r=>r.band!=='green' && !r.verified).length, n=dirty().length, acts=$('rv-actions');
acts.innerHTML = run && run.status==='done' ? `<span class="rv-muted">${left} to verify</span>
<button class="btn" id="rv-vr"${!left||run.approved?' disabled':''}>Verify remaining</button>`
+ (run.approved ? '<span class="pill success">✓ Approved</span>'
: `<button class="btn primary" id="rv-ap"${n?' disabled title="Save or discard your edits first"':''}>Approve</button>`) : '';
if($('rv-vr')) $('rv-vr').onclick=verifyRemaining;
if($('rv-ap')) $('rv-ap').onclick=approve;
$('rv-alert').textContent=refusal||''; $('rv-alert').classList.toggle('hidden',!refusal);
}
function renderLegend(){
const s=(run&&run.summary)||{};
$('rv-legend').innerHTML=BANDS.map(([b,label,c])=>`<li><i class="rv-sw" style="background:${c}"></i>${label} · ${s[b]||0}</li>`).join('');
$('rv-filters').innerHTML=FILTERS.map(([label,b])=>`<button class="btn${filter===b?' primary':''}" aria-pressed="${filter===b}" data-band="${b}">${label}</button>`).join('');
$('rv-filters').querySelectorAll('button').forEach(btn=>btn.onclick=()=>{ filter=btn.dataset.band; renderLegend(); renderFields(); });
}
function renderFields(){
$('rv-wait').classList.toggle('hidden',!run||run.status==='done');
const shown=filter==='all'?rows:rows.filter(r=>r.band===filter);
$('rv-fields').innerHTML=shown.map(r=>{ const draft=drafts[r.row_id]??settled(r), isDirty=draft!==settled(r);
const note=[r.reason, r.corrected_value!=null ? `extracted <s>${esc(r.value||'empty')}</s>` : ''].filter(Boolean);
return `<li data-row="${r.row_id}" class="${r.verified&&!isDirty?'done':''}">
<span class="rv-name" title="${esc(r.field)}">${esc(r.field)}</span>
<input aria-label="${esc(r.field)}" value="${esc(draft)}" placeholder="empty" title="${esc(r.reason||'')}" class="${isDirty?'dirty':''}" style="border-left-color:${colourOf(r.band)}">
<span class="rv-score" title="Legibility score: how clearly it was read, not whether it is right">${r.score==null?'—':Number(r.score).toFixed(2)}</span>
<button class="btn" aria-label="Verify ${esc(r.field)}" aria-pressed="${!!r.verified}"${isDirty?' disabled':''}>${r.verified?'✓ Verified':'Verify'}</button>
${note.length?`<span class="rv-note">${note.map(x=>x===r.reason?esc(x):x).join(' · ')}</span>`:''}</li>`; }).join('');
$('rv-fields').querySelectorAll('li').forEach(li=>{ const r=rows.find(x=>x.row_id===+li.dataset.row), box=li.querySelector('input');
box.oninput=()=>{ drafts[r.row_id]=box.value; const d=box.value!==settled(r); // a draft: nothing is sent until Save
box.classList.toggle('dirty',d); li.querySelector('button').disabled=d; renderSave(); renderHead(); };
box.onkeydown=e=>{ if(e.key==='Escape'){ box.value=settled(r); box.oninput(); } };
li.querySelector('button').onclick=()=>verify(r); });
}
function renderSave(){
const n=dirty().length;
$('rv-save').classList.toggle('hidden',!n);
$('rv-save').innerHTML = n ? `<span class="rv-unsaved">${n} unsaved change${n===1?'':'s'}</span>
<button class="btn" id="rv-discard" style="margin-left:auto">Discard</button><button class="btn primary" id="rv-save-btn">Save</button>` : '';
if(n){ $('rv-discard').onclick=()=>{ drafts={}; render(); }; $('rv-save-btn').onclick=save; }
}
// ----- cost: measured (tokens and cost per run, priced in Python) kept apart from assumed (typed here) -----
// A run with no recorded token usage is "not priced", never $0, and the coverage pill says how many.
let roi=[];
const money=n=>`$${n.toFixed(n>0&&n<0.01?4:2)}`, num=n=>n==null?'—':n.toLocaleString('en-US');
const monthOf=iso=>iso.slice(0,7); // 'YYYY-MM', UTC
const monthLabel=ym=>new Date(`${ym}-01T00:00:00Z`).toLocaleString('en-US',{month:'short',year:'numeric',timeZone:'UTC'});
const sum=rs=>rs.reduce((n,r)=>n+(r.total_cost??0),0);
const tileHtml=(name,value,note)=>`<div class="co-tile" role="group" aria-label="${name}"><div class="kpi-lbl">${name}</div><div class="kpi-val">${value}</div><div class="kpi-lbl">${esc(note)}</div></div>`;
async function loadRoi(){
let d; try{ d=await ai('GET','/roi'); }catch(e){ $('co-source').textContent=`Can't load costs: ${e.message}`; return; }
roi=d.runs; const p=d.pricing||{};
$('co-source').innerHTML=`Rates from ${esc(p.source)}, as of ${esc(p.as_of)}`;
renderCost();
}
function renderCost(){
const priced=roi.filter(r=>r.total_cost!=null), thisMonth=new Date().toISOString().slice(0,7);
const tok=k=>priced.reduce((n,r)=>n+(r[k]??0),0);
$('co-cover').textContent=`${priced.length} of ${roi.length} documents priced`;
$('co-cover').className='pill '+(priced.length===roi.length?'success':'attention');
$('co-tiles').innerHTML=tileHtml('Total spend',money(sum(priced)),`${priced.length} priced runs`)
+tileHtml('This month',money(sum(roi.filter(r=>monthOf(r.created_at)===thisMonth))),monthLabel(thisMonth))
+tileHtml('Tokens',num(tok('input_tokens')+tok('output_tokens')+tok('thinking_tokens')),`in ${num(tok('input_tokens'))} · out ${num(tok('output_tokens'))} · thinking ${num(tok('thinking_tokens'))}`)
+tileHtml('Per document',priced.length?money(sum(priced)/priced.length):'—','average, priced runs');
$('co-months').innerHTML=[...new Set(roi.map(r=>monthOf(r.created_at)))].sort().reverse().map(m=>{
const rs=roi.filter(r=>monthOf(r.created_at)===m), n=rs.filter(r=>r.total_cost!=null).length;
return `<tr><td>${monthLabel(m)}${m===thisMonth?' <span class="rv-muted">this month</span>':''}</td><td>${n} of ${rs.length}</td><td class="co-r">${n?money(sum(rs)):'—'}</td></tr>`; }).join('');
$('co-runs').innerHTML=roi.map(r=>`<tr><td>${esc(r.document)}</td><td>${esc(r.created_at.slice(0,10))}</td><td>${esc(r.model)}</td>
<td class="co-r">${num(r.input_tokens)}</td><td class="co-r">${num(r.output_tokens)}</td><td class="co-r">${num(r.thinking_tokens)}</td>
<td class="co-r">${r.total_cost==null?'<span class="rv-muted">not priced</span>':money(r.total_cost)}</td></tr>`).join('');
renderByHand();
}
function renderByHand(){
const n=roi.filter(r=>r.total_cost!=null).length, min=+$('co-min').value||0, rate=+$('co-rate').value||0;
$('co-hand').innerHTML=`By hand, ${n} documents would take ${num(Math.round(n*min/6)/10)} h, about <b>${money(n*min/60*rate)}</b>. The AI spent <b>${money(sum(roi))}</b> on them.`;
}
$('co-min').oninput=$('co-rate').oninput=renderByHand;
})();
</script>
</body>
</html>
+42
View File
@@ -0,0 +1,42 @@
{
"fixture": "e-pyqt-ai",
"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,
"AI desktop path: sections, ai_bridge and review backend built": true,
"no placeholders left in ai_bridge.py": 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": [
"99 passed, 23 skipped in 61.26s (0:01:01)"
],
"files": [
"README.md",
"bot.py",
"dashboard\\ai_bridge.py",
"dashboard\\bands.py",
"dashboard\\bot_raw.log",
"dashboard\\bot_runner.py",
"dashboard\\bridge.py",
"dashboard\\dashboard.html",
"dashboard\\pricing.py",
"dashboard\\server_stub.py",
"extractor.py",
"inbox\\invoice-2201.pdf",
"inbox\\invoice-2202.pdf",
"requirements.txt"
],
"reply": "Dashboard ready \u2014 **desktop (PyQt6)** path, because `requirements.txt` already has PyQt6. Theme Tebra Sky, no optional sections. Start it with `python dashboard/ai_bridge.py`.\n\n| Check (headless Qt run of the real window) | Result |\n|---|---|\n| Bot dry run (`python dashboard/bot_runner.py`) | 4 steps in order, 10\u219230\u219290\u2192**100**, both `ERROR:` lines shown |\n| Review: band legend | 3 clean \u00b7 1 check \u00b7 1 review \u00b7 1 missing (6/6 fields) |\n| Approve gate | Refused: \"3 flagged field(s) still need verifying.\" \u2192 after Verify remaining \u2192 **\u2713 Approved** |\n| Draft edit | Shows \"1 unsaved change\" until Save; Discard clears it |\n| Running invoice | \"page 1 of 3\" |\n| Cost page | \"4 of 5 documents priced\", one run \"not priced\", by-hand figure goes $6.67 \u2192 $13.33 when minutes change |\n| Dark mode / 800px width / `{{` left / console errors | ok / ok / 0 / **0** |\n\n- **Files:** everything is in `dashboard/`: `dashboard.html`, `bridge.py`, `bot_runner.py`, `ai_bridge.py`, `server_stub.py`, `bands.py`, `pricing.py`.\n- **Wiring:** the Run tab is wired to the real `bot.py`. The Review and Cost tabs still show **sample data** from `server_stub.py`, using your 6 real fields and `gemini-2.5-flash`. To make them real, the project needs to save its extraction results and expose `dispatch()` over that store.\n- **UNVERIFIED:** the Gemini rate ($0.30 in / $2.50 out per 1M tokens) comes from the skill's bundled table (as of 2026-08-11). I wasn't allowed to fetch the pricing page, so please check it.\n- **Not tested:** I didn't click Run inside the window. The bot was only dry-run through `bot_runner.py`.\n\n**Also noticed (not fixed):**\n- `extractor.py` doesn't keep `usage_metadata`, so real runs will show up as \"not priced\".\n- With no API key set, both invoices fail, but the bot still exits 0, so the run shows \"Completed\".\n- The Dashboard KPIs and Report tab are empty, because `bridge.py`'s `load_dashboard` and `load_latest_report` are still TODO.\n\nFRONTEND: none (detect_frontend.py: \"No existing frontend found.\"; PyQt6 in requirements, so desktop path)\nQUESTIONS: Which colours: Tebra Sky, Superbill Ember, or Custom (one brand hex)? | Which optional sections to add: checkpoint tracker, admin/roles screen, login page, model picker, retry on another model (any, or none)?"
}
+13
View File
@@ -0,0 +1,13 @@
{"event": "status", "data": {"state": "running", "msg": "Running\u2026"}}
{"event": "step", "data": {"label": "Initializing", "pct": 10}}
{"event": "log", "data": {"msg": "Starting the invoice bot.", "type": "info"}}
{"event": "log", "data": {"msg": "Checked the inbox for invoices.", "type": "info"}}
{"event": "step", "data": {"label": "Extract", "pct": 30}}
{"event": "log", "data": {"msg": "Reading invoice fields with the AI model.", "type": "info"}}
{"event": "log", "data": {"msg": "ERROR: extraction failed for invoice-2201.pdf: ValueError", "type": "error"}}
{"event": "log", "data": {"msg": "ERROR: extraction failed for invoice-2202.pdf: ValueError", "type": "error"}}
{"event": "step", "data": {"label": "Review", "pct": 90}}
{"event": "log", "data": {"msg": "Queueing invoices for review.", "type": "info"}}
{"event": "step", "data": {"label": "Completed", "pct": 100}}
{"event": "log", "data": {"msg": "All invoices processed.", "type": "success"}}
{"event": "status", "data": {"state": "done", "msg": "Completed"}}
+4 -3
View File
@@ -202,7 +202,7 @@ def main(fixture):
shutil.copy(pages[0], results / f"{fixture}.dashboard.html")
checks["no placeholders left"] = "{{" not in html
if cfg.get("brand"):
spec = importlib.util.spec_from_file_location("palette", ROOT / "rpa-dashboard-no-ai/templates/palette.py")
spec = importlib.util.spec_from_file_location("palette", ROOT / "skills/rpa-dashboard-no-ai/templates/palette.py")
palette = importlib.util.module_from_spec(spec)
spec.loader.exec_module(palette)
t = palette.derive(cfg["brand"])
@@ -225,12 +225,13 @@ def main(fixture):
if cfg.get("ai_desktop"):
env["PAGE_CHECK_AI_DESKTOP_DIR"] = str(pages[0].parent)
check_dirs.append(ROOT / "tests" / "page_check_ai_desktop")
pc = subprocess.run([sys.executable, "-m", "pytest", *map(str, check_dirs), "-q"],
pc = subprocess.run([sys.executable, "-m", "pytest", *map(str, check_dirs), "-q", "-rs"],
env=env, capture_output=True, text=True)
checks["page check passes"] = pc.returncode == 0
page_check = pc.stdout.strip().splitlines()[-1:] if pc.stdout else [pc.stderr[-500:]]
built_without = "page built without" in pc.stdout # a chosen section whose cases skipped
if cfg.get("sections"): # every chosen section's page check ran
checks["no section checks skipped"] = "skipped" not in page_check[0]
checks["no section checks skipped"] = not built_without
else:
page_check = ["no page"]
+1 -1
View File
@@ -16,7 +16,7 @@ import pytest
from page_check_common import FAKE_BRIDGE, MODES, TEST_FILL, WIRED, open_page
ROOT = Path(__file__).resolve().parents[2]
TEMPLATE = ROOT / "rpa-dashboard-no-ai" / "templates" / "dashboard.html"
TEMPLATE = ROOT / "skills" / "rpa-dashboard-no-ai" / "templates" / "dashboard.html"
@pytest.fixture(scope="session")
+1 -1
View File
@@ -23,7 +23,7 @@ import pytest
from page_check_common import MODES, TEST_FILL, load_stub, open_page
ROOT = Path(__file__).resolve().parents[2]
TEMPLATE = ROOT / "rpa-dashboard-ai" / "templates" / "web"
TEMPLATE = ROOT / "skills" / "rpa-dashboard-ai" / "templates" / "web"
TEST_FILL = {**TEST_FILL, "APP_NAME": "Test Extractor", "API_BASE": "/api"}
RECORD_ACTIONS = """
window.__calls = [];
+2 -2
View File
@@ -22,8 +22,8 @@ import pytest
from page_check_common import FAKE_BRIDGE, MODES, TEST_FILL, WIRED, load_stub, open_page
ROOT = Path(__file__).resolve().parents[2]
NO_AI = ROOT / "rpa-dashboard-no-ai" / "templates" / "dashboard.html"
AI = ROOT / "rpa-dashboard-ai" / "templates"
NO_AI = ROOT / "skills" / "rpa-dashboard-no-ai" / "templates" / "dashboard.html"
AI = ROOT / "skills" / "rpa-dashboard-ai" / "templates"
@pytest.fixture(scope="session")
@@ -0,0 +1,54 @@
"""The same AI sections with no Qt bridge: a plain-HTML (or Vue/Angular) web project.
The page is served over HTTP with API_BASE "/api", and every AI request goes out as
fetch({API_BASE}{path}). Playwright routes the API to the review backend's dispatch()
in this process (what a real backend serves; server_stub.py over HTTP does the same).
This module's `page` replaces the folder's bridge one; the review cases run unchanged.
"""
import json
import sys
from pathlib import Path
import pytest
from page_check_common import MODES, open_page
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "page_check_ai"))
from test_ai_review import ( # noqa: E402,F401
test_approve_stays_blocked_while_rows_are_unverified,
test_band_counts_and_legend_match,
test_band_filters_show_the_right_rows,
test_draft_edit_is_not_saved_until_save,
test_page_x_of_y_updates_while_running,
)
from test_ai_cost import cost_page, test_coverage_pill_counts_priced_of_all_documents # noqa: E402,F401
ORIGIN = "http://dashboard.test"
NO_BRIDGE = "window.__calls = [];"
@pytest.fixture(params=list(MODES))
def page(request, browser, site, stub):
html = (site / "dashboard.html").read_text(encoding="utf-8").replace(
'const API_BASE = "";', 'const API_BASE = "/api";')
def serve(route):
path = route.request.url[len(ORIGIN):].split("?")[0]
if not path.startswith("/api/"):
return route.fulfill(body=html, content_type="text/html")
body = json.loads(route.request.post_data or "{}")
status, payload = stub.dispatch(route.request.method, path[len("/api"):], body)
if isinstance(payload, str):
return route.fulfill(status=status, body=payload, content_type="image/svg+xml")
route.fulfill(status=status, body=json.dumps(payload), content_type="application/json")
yield from open_page(browser, ORIGIN + "/dashboard.html", request.param, NO_BRIDGE,
route=(ORIGIN + "/**", serve))
def test_ai_requests_go_over_fetch(page, stub):
"""With no Qt bridge, nothing reaches a bridge slot: the review loads over HTTP."""
from test_ai_review import FIELDS, open_review
stub.put_run("job-f", "fetch.pdf", FIELDS)
open_review(page, "job-f")
assert page.evaluate("window.__calls") == []
assert page.locator("#rv-doc").get_attribute("src") == "/api/runs/job-f/document"
+3 -1
View File
@@ -63,13 +63,15 @@ def push_to(pg, event, data):
pg.evaluate("([event, data]) => window.__pyMsg(JSON.stringify({event, data}))", [event, data])
def open_page(browser, url, mode, init_script, expose=None):
def open_page(browser, url, mode, init_script, expose=None, route=None):
"""Generator for a `page` fixture: opens url in one mode, signs in through a login
section if the page has one, switches to dark, and fails on any page script error."""
width, height, dark = MODES[mode]
ctx = browser.new_context(viewport={"width": width, "height": height})
if expose:
ctx.expose_function(*expose)
if route:
ctx.route(*route)
ctx.add_init_script(init_script)
pg = ctx.new_page()
pg.set_default_timeout(5000)
+44
View File
@@ -0,0 +1,44 @@
"""Seam 1 page check for the Run tab: run_tab.html filled with a test config, opened with
the fake Qt bridge, driven through window.__pyMsg. Every case runs in light, dark and narrow.
PAGE_CHECK_RUN_TAB_HTML: check a generated page instead (its config must already be filled)."""
import json
import os
from pathlib import Path
import pytest
from page_check_common import FAKE_BRIDGE, MODES, open_page
ROOT = Path(__file__).resolve().parents[2]
TEMPLATE = ROOT / "skills" / "rpa-run-tab" / "templates" / "run_tab.html"
CFG = {"title": "Test Bot", "sub": "Page check", "input": True, "inputHint": "Test workbook",
"sample": True, "darkKey": "test_dark",
"checkpoints": [{"name": "Sign in", "max": 300}, {"name": "Process rows", "max": 0},
{"name": "Finish up", "max": 120}]}
def _url(tmp, cfg):
if os.environ.get("PAGE_CHECK_RUN_TAB_HTML"):
return Path(os.environ["PAGE_CHECK_RUN_TAB_HTML"]).resolve().as_uri()
out = tmp / f"run_tab_{cfg['input']}.html"
out.write_text(TEMPLATE.read_text(encoding="utf-8").replace("%%CONFIG%%", json.dumps(cfg)), encoding="utf-8")
return out.as_uri()
@pytest.fixture(scope="session")
def page_url(tmp_path_factory):
return _url(tmp_path_factory.mktemp("run_tab"), CFG)
@pytest.fixture(scope="session")
def no_input_url(tmp_path_factory):
return _url(tmp_path_factory.mktemp("run_tab_noinput"), {**CFG, "input": False, "sample": False})
@pytest.fixture(params=list(MODES))
def page(request, browser, page_url):
yield from open_page(browser, page_url, request.param, FAKE_BRIDGE)
@pytest.fixture(params=list(MODES))
def no_input_page(request, browser, no_input_url):
yield from open_page(browser, no_input_url, request.param, FAKE_BRIDGE)
@@ -0,0 +1,101 @@
"""What the operator sees on the Run tab, for each message the bot side sends."""
from page_check_common import push_to
def push(pg, event, data):
push_to(pg, event, data)
def btn(pg):
return pg.locator("#top-run-btn")
def test_trigger_waits_for_a_file_then_runs(page):
assert "off" in btn(page).get_attribute("class")
assert btn(page).inner_text() == "Upload a file to continue"
push(page, "file_selected", {"path": "C:/in/input.xlsx", "name": "input.xlsx", "sheets": ["Accounts", "Settings"]})
assert btn(page).inner_text() == "Trigger automation"
assert ["read_sheet", "C:/in/input.xlsx", "Accounts"] in page.evaluate("window.__calls")
btn(page).click()
assert btn(page).inner_text() == "Running..."
assert ["start_automation", "C:/in/input.xlsx"] in page.evaluate("window.__calls")
assert page.locator("#prog-wrap").is_visible()
def test_sheet_preview_masks_passwords(page):
push(page, "file_selected", {"path": "C:/in/a.xlsx", "name": "a.xlsx", "sheets": ["Login"]})
push(page, "sheet_data", {"sheet": "Login", "header": ["User", "Password"],
"rows": [["ann", "hunter22"]], "total": 1, "more": False})
cells = page.locator("table.data-tbl td").all_inner_texts()
assert cells[0] == "ann" and "hunter22" not in cells[1] and set(cells[1]) == {"•"}
def test_dropped_file_not_found_warns_and_stays_off(page):
push(page, "file_selected", {"path": "", "name": "lost.xlsx", "sheets": []})
assert "off" in btn(page).get_attribute("class")
assert "lost.xlsx" in page.locator("#log").inner_text()
def test_clear_file_resets_the_input(page):
push(page, "file_selected", {"path": "C:/in/a.xlsx", "name": "a.xlsx", "sheets": []})
page.get_by_role("button", name="Clear file").click()
assert page.locator("#dz").is_visible() and "off" in btn(page).get_attribute("class")
def test_checkpoints_drive_steps_boxes_and_progress(page):
push(page, "file_selected", {"path": "C:/a.xlsx", "name": "a.xlsx", "sheets": []})
btn(page).click()
push(page, "cpstate", {"kind": "active", "cp": 2})
assert "active" in page.locator("#cpbox-2").get_attribute("class")
assert "done" in page.locator("#cpbox-1").get_attribute("class")
assert page.locator(".step.active").inner_text().endswith("Process rows")
assert page.locator("#prog-pct").inner_text() == "33%"
push(page, "cpstate", {"kind": "active", "cp": 1}) # a loop back resets later boxes, the bar stays
assert "pending" in page.locator("#cpbox-2").get_attribute("class")
assert page.locator("#prog-pct").inner_text() == "33%"
def test_log_drops_consecutive_duplicates(page):
for msg in ["Signed in", "Signed in", "Row 1 done"]:
push(page, "log", {"msg": msg, "type": "success"})
assert page.locator(".log-line").all_inner_texts()[-2:] == ["Signed in", "Row 1 done"]
def test_endings_green_amber_red(page):
push(page, "file_selected", {"path": "C:/a.xlsx", "name": "a.xlsx", "sheets": []})
for state, dot, fill, prefix in [("done", "done", "prog-fill", "✓ Completed in"),
("partial", "partial", "prog-fill partial", "⚠ Finished in"),
("error", "error", "prog-fill failed", "✕ Failed after")]:
btn(page).click()
push(page, "status", {"state": state, "msg": f"ended {state}"})
assert page.locator("#sys-dot").get_attribute("class") == f"dot {dot}"
assert page.locator("#prog-fill").get_attribute("class") == fill
assert page.locator("#prog-time").inner_text().startswith(prefix)
assert btn(page).inner_text() == "Trigger automation" # ready to run again
def test_stuck_box_turns_red(page):
push(page, "file_selected", {"path": "C:/a.xlsx", "name": "a.xlsx", "sheets": []})
btn(page).click()
push(page, "cpstate", {"kind": "active", "cp": 3})
push(page, "cpstate", {"kind": "stuck", "cp": 3})
assert "stuck" in page.locator("#cpbox-3").get_attribute("class")
assert "stuck" in page.locator("#cpmeta-3").inner_text()
def test_no_input_bot_is_ready_straight_away(no_input_page):
assert not no_input_page.locator("#input-card").is_visible()
assert no_input_page.locator("#top-run-btn").inner_text() == "Trigger automation"
def test_dark_toggle_saves_choice(page):
dark = page.evaluate("document.body.classList.contains('dark')")
page.locator("#mode-btn").click()
assert page.evaluate("document.body.classList.contains('dark')") != dark
assert ["set_dark", not dark] in page.evaluate("window.__calls")
def test_no_horizontal_scroll(page):
push(page, "file_selected", {"path": "C:/a.xlsx", "name": "a.xlsx", "sheets": []})
btn(page).click()
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
Loaded 100 of 109 files, more files were not shown because too many files have changed in this diff. Show more