Compare commits
23
Commits
344718007e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
653b39abfa | ||
|
|
d277746c39 | ||
|
|
1b0f83a4ad | ||
|
|
b35796e1a5 | ||
|
|
36e621b153 | ||
|
|
0188de7403 | ||
|
|
717f2c40c5 | ||
|
|
ff2f7fcd4a | ||
|
|
71281bdf91 | ||
|
|
602f27124e | ||
|
|
f4c42f2956 | ||
|
|
3942fad6cd | ||
|
|
3d2db6aca6 | ||
|
|
4e08a444eb | ||
|
|
df29f5728b | ||
|
|
d95e5d452d | ||
|
|
312742cfef | ||
|
|
eb90db64e8 | ||
|
|
a57b32e8cb | ||
|
|
258bfbb500 | ||
|
|
8bb1cf268e | ||
|
|
24a064875e | ||
|
|
461798127a |
No files matched your search
@@ -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,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`, `rpa-dashboard-ai` and `rpa-bot-handoff` (short Handover SOP for ops). See `README.md` and `PLAN.md`.
|
||||
|
||||
## Agent skills
|
||||
|
||||
|
||||
+43
-1
@@ -1,5 +1,47 @@
|
||||
# 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.
|
||||
|
||||
**Handover SOP**: the `<Project Name> Handover SOP` the `rpa-bot-handoff` skill writes in a bot's repo (`HANDOVER-SOP.md` and the PDF ops get), for the operator who runs the bot and does not read code. At most 4 pages, 5 sections in the operator's order: how to run, what you see while it runs, result, when something goes wrong, email us. See ADR-0005. _Avoid_: "ops guide" (the old 11-section format).
|
||||
|
||||
|
||||
**Exception list**: the table in section 4 of the Handover SOP: one numbered row (`E01`...) per message the operator can see, with what it means and what to do, including when to email the dev team.
|
||||
|
||||
**Business exception**: the data says no (cutoff date passed, negative balance, already posted). **System exception**: the bot or the target app failed (window not found, timeout, crash).
|
||||
|
||||
**Don't-miss checks**: the 6 one-line problem types in `rpa-bot-handoff`'s SKILL.md (setup missing, slow or changed app, name mismatch, says done but isn't, stuck or can't stop, cause hidden). They replace the 12 Tebra failure classes. Every SOP covers each one that applies.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,15 @@
|
||||
# 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`. |
|
||||
| `sql-review` | Reviews SQL code (SQL Server + pyodbc) against 10 rules through a subagent and writes every issue with a before/after fix plan to `query_tracker.md`. Read-only. |
|
||||
| `rpa-bot-handoff` | Writes a short `<Project Name> Handover SOP` (at most 4 pages, Markdown and Word) for a bot being handed over: how to run it (URL, what to click), what shows where, where the result is, how a problem shows and what to do, and how to email the dev team. |
|
||||
| `pr` | Writes the PR body (Summary, Evidence, Merge Danger) and raises the PR to `main` with the `tea` CLI, with Murugeswaran as reviewer. Runs only on `/pr`; asks before creating a branch and before raising the PR. |
|
||||
| `read-trans` | Reads a YouTube transcript (pasted or a file) and explains it in Tanglish: 2-line short version, medium explanation, final takeaway, and what to do in your own projects with a ready-to-copy prompt. Runs on `/read-trans`. Eval: `tests/evals/read_trans/`. |
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,15 @@
|
||||
# The handover is an ops guide in the operator's order, with dev notes kept apart
|
||||
|
||||
Superseded by ADR-0005.
|
||||
|
||||
`rpa-bot-handoff` writes two files:
|
||||
- `HANDOVER.md` is for the operator who runs the bot and does not read code. Its 11 sections follow the operator's own questions, in the order they ask them: where is what, how do I run it, how long do I wait, how do I read the result and the report, how do I see an exception, how will I know, how do I tell the dev team, what must I not do, what happened before.
|
||||
- `HANDOVER-DEV.md` is for developers. It holds the failure-class check and the gaps to fix before handover, with file:line.
|
||||
|
||||
Every exception row tells ops whether to wait (the bot retries by itself), act, or call the dev team. Every wait is a number of seconds or minutes taken from the code. The skill changes no code.
|
||||
|
||||
**Why:** the first version was one document sorted by architecture and exception type, with dev sections mixed in. Reviewing it, the user said ops need "which tab shows what, how to run, how many seconds to wait, which tab has the report, how to see an exception, how to tell us". In Tebra Auto Post's first months, most ops questions were exactly these. Ops re-ran items the bot was still retrying. They edited rows that needed a check in Tebra first ("Payment not confirmed after Save" had really posted). They couldn't tell why a batch stayed Pending. Architecture, failure classes and file:line refs don't help an operator, and they bury what does.
|
||||
|
||||
**Lessons as a checklist:** Tebra's incidents are bundled as 12 generic failure classes. Each new bot is checked against every class in `HANDOVER-DEV.md`, and an unused class is written down as "not applicable" with a reason, so nothing is dropped silently. A scrubbed Tebra example shows both files.
|
||||
|
||||
**Subagents are required:** in the first 47 eval runs, 8 wrote the doc without starting the scan subagents. The skill now says to always start them.
|
||||
@@ -0,0 +1,15 @@
|
||||
# The handover is one short SOP, at most 4 pages
|
||||
|
||||
Supersedes ADR-0004.
|
||||
|
||||
`rpa-bot-handoff` now writes one document: `<Project Name> Handover SOP` (`HANDOVER-SOP.md` and a Word copy), at most 4 pages (about 1,400 words). It has 5 sections in the operator's order: how to run (URL, what to click), what you see while it runs, result, when something goes wrong, email us. The dev notes file is gone; problems the scan finds for the dev team are listed in the chat.
|
||||
|
||||
**Why:** the first real handover with ADR-0004's format (MEK Charge Reconciliation, Oct 2026) came out at 9 pages: 11 sections, 19 exception rows, plus a separate dev file. The user said ops need it short: first which URL to open and what to click, then what shows where after the click, then what to do when an exception comes and how to email the dev team, and nothing else. A long guide is not read, so its accuracy doesn't help.
|
||||
|
||||
**Accuracy instead of length:** every quoted message, label and URL is checked against the code before handover, and messages the code builds from parts show those parts in italics. The SOP must say what clicking Run again does to the data (fresh run on current data, previous results deleted or kept).
|
||||
|
||||
**Kept from ADR-0004:** the operator's words only, every wait is a number from the code, misleading statuses are called out where they show, the bot is never run, and the bot is checked for every common problem type.
|
||||
|
||||
**Shorter skill (Oct 2026):** the user found the skill itself too detailed. SKILL.md is now a short checklist (under 500 words), the 12 Tebra failure classes are folded into 6 one-line "don't miss" checks, and the MEK Charge Reconciliation SOP (the 3-page PDF ops liked) replaces the Tebra example as the model.
|
||||
|
||||
**One file (Oct 2026):** the user asked for about 50 lines. The skill is now one `SKILL.md` (under 60 lines) with the SOP shape inline, plus `templates/to_docx.py` for the Word and PDF. The template, the reference example and the README are removed. Later the converter went too: SKILL.md tells Claude to build the PDF itself (styled HTML printed with headless Chrome or Edge), so the skill is the one file.
|
||||
@@ -0,0 +1,194 @@
|
||||
---
|
||||
name: pr
|
||||
description: "Use when writing a PR body."
|
||||
disable-model-invocation: true
|
||||
metadata:
|
||||
credits:
|
||||
skill: show-me
|
||||
author: Dex Horthy
|
||||
organisation: Humanlayer
|
||||
url: "https://github.com/humanlayer/skills/blob/main/plugins/show-me/skills/show-me/SKILL.md"
|
||||
---
|
||||
|
||||
Runs only when the user types `/pr`. Never trigger it automatically.
|
||||
|
||||
Use the template below for the PR body, then create the PR with the `tea` CLI (Gitea). Do not use `gh`.
|
||||
|
||||
## Create the PR
|
||||
|
||||
Always target `main`, and always make `Murugeswaran` the reviewer.
|
||||
|
||||
Steps, in order:
|
||||
|
||||
1. **Branch check.** Run `git branch --show-current`. If there is no feature branch (on `main`, or detached), **ask the user** whether to create one. Only on a yes: create it with a short kebab-case name based on the fix (e.g. `fix/login-timeout`) and move the changes onto it.
|
||||
2. **Push.** Push the branch to the remote (`git push -u origin <branch>`), because `tea` needs the head branch on the remote.
|
||||
3. **Confirm.** Show the title, source branch → `main`, and reviewer, then **ask the user** whether to raise the PR now. Only on a yes, run the commands below.
|
||||
|
||||
```powershell
|
||||
tea pulls create --base main --title "<title>" --description-file <body.md>
|
||||
'{"reviewers":["Murugeswaran"]}' | Set-Content -Encoding ascii reviewers.json
|
||||
tea api -X POST "/repos/<owner>/<repo>/pulls/<index>/requested_reviewers" -d "@reviewers.json"
|
||||
Remove-Item reviewers.json
|
||||
```
|
||||
|
||||
`tea pulls create` has no reviewer flag, so the `tea api` call requests the review (the JSON goes through a file to avoid PowerShell quoting problems). `<index>` is the PR number printed by the first command. Show the PR URL and confirm the reviewer was added.
|
||||
|
||||
## PR body template
|
||||
|
||||
```markdown
|
||||
## Summary
|
||||
|
||||
<diagram, diff-sketch, or tree>
|
||||
|
||||
## Evidence
|
||||
|
||||
- **Before:** <screenshot/output/failing test run>
|
||||
**After:** <screenshot/output/passing test run>
|
||||
|
||||
## Merge Danger
|
||||
|
||||
**Door:** <one-way or two-way>
|
||||
|
||||
<optional: description>
|
||||
|
||||
**Blast Radius:** <one-word description>
|
||||
|
||||
<optional: potential ramifications of merge>
|
||||
```
|
||||
|
||||
## Sections
|
||||
|
||||
Skip all preambles and keep prose brief. Use the user's domain language from `GLOSSARY.md`.
|
||||
|
||||
### Summary
|
||||
|
||||
Pick the smallest view that makes the key point clear.
|
||||
|
||||
- Show logic or an algorithm as pseudocode:
|
||||
|
||||
```text
|
||||
on(save)
|
||||
if content is unchanged
|
||||
return cached result
|
||||
write new content
|
||||
return fresh result
|
||||
```
|
||||
|
||||
- Show runtime control flow as a call tree:
|
||||
|
||||
```text
|
||||
submitForm
|
||||
createSession
|
||||
persistPrompt
|
||||
launchAgent
|
||||
navigateToSession
|
||||
```
|
||||
|
||||
- Show UI structure as a component tree, including state and module boundaries that matter:
|
||||
|
||||
```text
|
||||
<SessionPage> (apps/example/src/routes/session.tsx)
|
||||
useSessionEvents()
|
||||
<SessionToolbar>
|
||||
<RunSkillButton> (packages/ui)
|
||||
```
|
||||
|
||||
- Show file responsibility or a broad refactor as a shallow file tree:
|
||||
|
||||
```text
|
||||
src/
|
||||
├── commands/ # parses user actions
|
||||
├── sessions/ # owns session state
|
||||
└── transport/ # sends API requests
|
||||
```
|
||||
|
||||
- Show component interaction, control flow, or data flow with Mermaid:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant UI
|
||||
participant Daemon
|
||||
User->>UI: choose command
|
||||
UI->>Daemon: send expanded prompt
|
||||
Daemon-->>UI: stream result
|
||||
```
|
||||
|
||||
- Use `diff` when the point is what changes and the surrounding shape already exists. Match the diff shape to the topic.
|
||||
|
||||
For a component change:
|
||||
|
||||
```diff
|
||||
<SessionPage>
|
||||
useSessionEvents()
|
||||
<SessionToolbar>
|
||||
+ <RunSkillButton />
|
||||
<SessionTimeline>
|
||||
+ <SkillResultCard />
|
||||
```
|
||||
|
||||
For a file-layout change:
|
||||
|
||||
```diff
|
||||
src/
|
||||
├── commands/
|
||||
+│ └── show-me.ts # expands the slash command
|
||||
├── sessions/
|
||||
-└── transport.ts
|
||||
+└── transport/
|
||||
+ ├── client.ts
|
||||
+ └── stream.ts
|
||||
```
|
||||
|
||||
For a call-tree or call-stack change:
|
||||
|
||||
```diff
|
||||
submitForm
|
||||
createSession
|
||||
persistPrompt
|
||||
+ expandSkillMention
|
||||
launchAgent
|
||||
- navigateToSession
|
||||
+ navigateToSession
|
||||
+ subscribeToEvents
|
||||
```
|
||||
|
||||
For a state or control-flow change:
|
||||
|
||||
```diff
|
||||
on(save)
|
||||
- write content
|
||||
+ if content is unchanged
|
||||
+ return cached result
|
||||
+ write new content
|
||||
+ invalidate cache
|
||||
```
|
||||
|
||||
- Show the whole block when most of it is new, when omitted context would hide ownership or order, or when the user needs a copyable target shape:
|
||||
|
||||
```ts
|
||||
function expandSkill(command: string): string {
|
||||
const skillName = command.slice(1);
|
||||
return `use the ${skillName} skill`;
|
||||
}
|
||||
```
|
||||
|
||||
#### Guidance
|
||||
|
||||
Place each visual next to the short text it supports. Keep only the calls, files, props, states, and boundaries needed to answer the user's current question or the options to resolve the current discussion point.
|
||||
|
||||
You may use one of these, you may use several, it is unlikely you will use all of them. Use your judgement and don't overwhelm the user.
|
||||
|
||||
### Evidence
|
||||
|
||||
Concrete evidence that the change works. Show a before and after.
|
||||
|
||||
Screenshots are S-tier - when the environment is set up for it and the change is visual.
|
||||
|
||||
Execution-based evidence is A-tier. Test results, console output. Show the exact test that now fails and passes, using pseudocode.
|
||||
|
||||
### Merge Danger
|
||||
|
||||
Describe whether it's a one-way or two-way door. You can walk back through two-way doors, but not one-way doors. A PR that is cheap to roll back is lower risk. Changes that involve destructive actions or hard-to-reverse decisions are one-way doors.
|
||||
|
||||
The blast radius is the potential impact or scope of the changes introduced by this PR. Consider all possibilities. Examples are layout shift, breakages for consumers, mobile responsiveness, etc.
|
||||
@@ -0,0 +1,24 @@
|
||||
# read-trans
|
||||
|
||||
A Claude Code skill that explains a YouTube transcript in **Tanglish** (Tamil in English letters): a 2-line short version, a medium explanation, the final takeaway, and what to do in your own projects.
|
||||
|
||||
## Install
|
||||
|
||||
Copy this folder to `~/.claude/skills/read-trans/` and start a new Claude Code session.
|
||||
|
||||
## Use
|
||||
|
||||
- `/read-trans` then paste the transcript
|
||||
- `/read-trans path/to/transcript.txt`
|
||||
- "intha transcript Tanglish la explain pannu"
|
||||
|
||||
## Output
|
||||
|
||||
1. **Video enna solluthu**: 2 lines
|
||||
2. **Medium explanation**: 4-6 numbered points
|
||||
3. **Final takeaway**: 3-5 bullets
|
||||
4. **Unga projects-ku enna pannanum**: steps plus a ready-to-copy prompt
|
||||
|
||||
## Evals
|
||||
|
||||
See `tests/evals/read_trans/`. Run the check with `python tests/evals/read_trans/check_output.py <output.md>`.
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
name: read-trans
|
||||
description: Reads a YouTube transcript (pasted text or a .txt/.srt/.vtt file) and explains it in simple Tanglish - Tamil written in English letters with technical words kept in English. Gives a 2-line short version, a medium explanation, and a "what do I do in my projects" takeaway with a ready-to-copy prompt. Use whenever the user types /read-trans, pastes a YouTube/video transcript, or says "transcript Tanglish la sollu", "intha video enna solluthu", "video explain pannu", even if they don't say "read-trans".
|
||||
---
|
||||
|
||||
# Read Trans
|
||||
|
||||
Turn a YouTube transcript into a Tanglish explanation the user can read in two minutes and act on. The user is a developer who watches English tech videos and wants to know: what happened, and what should I do in my own projects.
|
||||
|
||||
## Input
|
||||
|
||||
- Transcript pasted after `/read-trans`, or a file path (`.txt`, `.srt`, `.vtt`). Read the file if a path is given.
|
||||
- Only a YouTube link and no transcript: you can't watch the video. Ask the user to paste the transcript. Don't guess from the title.
|
||||
- Strip timestamps (`0:07`, `1 minute, 4 seconds`), `Chapter N:` lines and caption numbering before reading.
|
||||
|
||||
## Output
|
||||
|
||||
Always these 4 sections, in this order, with these headings:
|
||||
|
||||
### 1. Video enna solluthu (short)
|
||||
Exactly 2 lines. What the speaker did and what he found.
|
||||
|
||||
### 2. Medium explanation
|
||||
Numbered points (4-6). Tell it as a sequence: what he set up, what he ran, what happened, what surprised him. Say it so a person who did not watch can picture it. Name the tools, models, numbers and prices he mentions (e.g. "Sonnet ~$22, Opus ~$137").
|
||||
|
||||
### 3. Final takeaway
|
||||
3-5 bullets: only the lessons the speaker himself states or clearly shows. One bullet = one idea. No own arithmetic, no own conclusions here.
|
||||
|
||||
### 4. Unga projects-ku enna pannanum
|
||||
This is the part the user cares about most. Concrete, numbered steps the user can do today, not the speaker's story. Steps that come from the video are plain. Steps that are your own idea end with "(En idea)" so the user knows the speaker didn't say it. End with one ready-to-copy prompt or command in a fenced block, if the video is about prompting or tooling. If the video gives nothing actionable, say so in one line instead of inventing steps.
|
||||
|
||||
## Language rules
|
||||
|
||||
- **Tanglish = spoken Tamil in English letters** ("idhu romba nalla irukku", "panna num"). Not Tamil script, unless the user asks for Tamil script.
|
||||
- Keep technical words in English: prompt, API, model, session, code, test, feature, deploy.
|
||||
- Short sentences. Simple words. No formal Tamil.
|
||||
- Sections 1-3 carry only what the speaker said. Your own comments or ideas go only in section 4, marked "(En idea)".
|
||||
- Copy qualifiers exactly: "did not work" is not "broke", "about $22" is not "$22 exactly".
|
||||
- Skip sponsor plugs, "like and subscribe", and intros that carry no information.
|
||||
- If the speaker's own claim looks doubtful (e.g. a number that doesn't add up), say so in one line.
|
||||
|
||||
## Don't
|
||||
|
||||
- Don't quote the transcript line by line. Explain it.
|
||||
- Don't pad. If the transcript is short, the output is short.
|
||||
- Don't write files unless the user asks. Reply in chat.
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
name: rpa-bot-handoff
|
||||
description: Writes "<Project Name> Handover SOP" - a short PDF (at most 4 pages) that ops keeps open while running an RPA bot - how to run it, what shows where, the result, what to do when something goes wrong, and who to email (developer in To, manager in CC). Reads the code, never runs the bot. Use when the user asks to "hand over a bot", "document a bot for ops", "write a handover doc", "handover SOP", "ops guide", "runbook" or "operator guide".
|
||||
---
|
||||
|
||||
# RPA bot handoff
|
||||
|
||||
Make **`<Project Name> Handover SOP`**: one PDF, at most 4 pages, that ops keeps open while they run the bot. Plain words, no code.
|
||||
|
||||
1. **Scan.** One read-only subagent (never run the bot; no patient data, client names or passwords in its report). Ask for exact on-screen text with file:line: how to start (URL or shortcut, sign-in, Run button, what a new run does to old results), what shows where during a run, every timeout and retry, how a run ends and where the result is, every message ops can see, alerts, how to stop.
|
||||
2. **Ask once:** the developer's email (To), the manager's name and email (CC), and where ops runs it if unclear.
|
||||
3. **Write** `HANDOVER-SOP.md` in this shape:
|
||||
|
||||
```markdown
|
||||
# <Project Name> Handover SOP
|
||||
Version <x> · <date> · Problems: email <developer>, CC <manager> (section 5)
|
||||
<one line: what the bot does, from which app, what it produces>
|
||||
## 1. How to run
|
||||
Numbered steps: URL or shortcut, sign in, the Run button. Then "Before you click": what a new run does to the old results; one run at a time.
|
||||
## 2. What you see while it runs
|
||||
| Where on the screen | What it shows | then a normal run in one line
|
||||
| Step | The bot waits at most | Then | then "Step in only when: ..."
|
||||
## 3. Result
|
||||
Good run / Needs a look (call out a misleading "Finished" or green). Where the result is.
|
||||
| Status | Meaning | What you do |
|
||||
## 4. When something goes wrong
|
||||
How it shows (colour, list, column). Then:
|
||||
| ID | Message you see | What it means | What you do | E01, E02 ... one row per message
|
||||
Don't: (at most 3, with a short reason). To stop it: (the one right way).
|
||||
## 5. Email us
|
||||
Email <developer> with <manager name> (<manager email>) in CC when section 4 says "email us". Answer: <time>.
|
||||
To: <developer>
|
||||
CC: <manager>
|
||||
Subject: <Project Name> - E<number> - <date>
|
||||
Run started / Error: E<number> - <message> / Where / What I did / Attached: screenshot
|
||||
Never send: passwords, patient data, result files that hold patient data.
|
||||
```
|
||||
|
||||
4. **Make the PDF yourself** (no converter script): turn the SOP into one HTML page (Python `markdown` with `tables` + `fenced_code`; add a blank line before every list or it merges into the paragraph above). Style: A4, 16-18 mm margins, Segoe UI 10.5 pt, title 19 pt navy with a teal rule under it and the version line in small grey, navy section headings with a thin rule, tables full width with borders, light-blue header row, 9.5 pt cells, bold E-numbers, `tr { break-inside: avoid }`, the email block in a grey box with a teal left edge. Print with headless Edge or Chrome: `--headless=new --no-pdf-header-footer --print-to-pdf="<Project Name> Handover SOP.pdf"`. Render each page to an image (PyMuPDF) and look at it. Over 4 pages: shorten cells, never drop a message.
|
||||
5. **Hand over** the PDF. Problems for the dev team go in the chat (at most 5 lines), not in the SOP.
|
||||
|
||||
**Rules**
|
||||
- Copy every label and message exactly from the code; variable parts in *italics* ("Couldn't download *Location*").
|
||||
- Every wait is a number. Say plainly when the screen misleads.
|
||||
- One row per message, one short line per cell.
|
||||
- No file names, code, settings names, patient data, client names or passwords.
|
||||
|
||||
**Don't miss:** check each of these and cover every one that applies in section 3 or 4:
|
||||
1. Setup missing: login, file, driver. Which message shows?
|
||||
2. The app or portal is slow or changed: timeouts, "not found".
|
||||
3. Names in the input or mapping don't match the target app.
|
||||
4. Says done but isn't, or did it but says failed: tell ops to check **before** re-running.
|
||||
5. Stuck, can't be stopped, or blocks the next run.
|
||||
6. The cause is only in a raw log, or there is no alert.
|
||||
@@ -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
|
||||
File renamed without changes.
File renamed without changes.
+18
-6
@@ -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);
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+3
-1
@@ -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>
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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).
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+6
@@ -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.
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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 |
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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">⬇ 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">×</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>
|
||||
@@ -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())
|
||||
@@ -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})
|
||||
@@ -0,0 +1,84 @@
|
||||
# sql-review
|
||||
|
||||
A Claude Code skill that reviews SQL code against 10 rules and writes a fix plan to `query_tracker.md`. It never edits code and never runs queries.
|
||||
|
||||
Tuned for **SQL Server + pyodbc** (raw SQL strings in Python, `.sql` files, `CREATE INDEX` definitions). Other databases work too; the rules are applied in that dialect.
|
||||
|
||||
## Install
|
||||
|
||||
Copy this folder to `~/.claude/skills/sql-review/` and start a new Claude Code session.
|
||||
|
||||
## Use
|
||||
|
||||
In any project, type one of these:
|
||||
|
||||
- `/sql-review`
|
||||
- `/sql-review src/superbill/repositories`
|
||||
- "SQL check pannu" / "review the queries in run_repo.py"
|
||||
|
||||
## What it does
|
||||
|
||||
1. Finds the files that contain SQL. Tests and one-off migration scripts are skipped.
|
||||
2. Hands the review to a **subagent**, so file dumps stay out of the main chat. With more than 10 files it runs 2-3 subagents in parallel.
|
||||
3. Opens every HIGH finding at its `file:line` and confirms it. Findings it can't confirm are dropped.
|
||||
4. Writes `query_tracker.md` at the project root.
|
||||
5. Replies in chat with a short summary and a link to the tracker.
|
||||
|
||||
## The 10 rules
|
||||
|
||||
| # | Rule | Severity |
|
||||
|---|---|---|
|
||||
| 1 | Formatting: uppercase keywords, one clause per line | STYLE |
|
||||
| 2 | No `SELECT *`; page unbounded lists with `TOP` / `OFFSET FETCH` | STYLE |
|
||||
| 3 | CTEs instead of nested subqueries (readability only) | STYLE |
|
||||
| 4 | Index-friendly WHERE: no function around a column | PERF |
|
||||
| 5 | `DISTINCT` must not hide a bad join; use `EXISTS` | WRONG-RESULT |
|
||||
| 6 | `COUNT(*)` for total rows, `COUNT(col)` only to skip NULLs | WRONG-RESULT |
|
||||
| 7 | Parameterized queries (`?`): no f-string values | HIGH |
|
||||
| 8 | Composite index column order matches the WHERE | PERF |
|
||||
| 9 | Set-based writes: `executemany`, no per-row loops, no N+1 | PERF |
|
||||
| 10 | `UNION ALL` when duplicates can't happen | STYLE |
|
||||
|
||||
## query_tracker.md
|
||||
|
||||
It opens with a 10-row summary table. After that comes one block per change, ordered HIGH, then WRONG-RESULT, then PERF, then STYLE:
|
||||
|
||||
````markdown
|
||||
### Q1. SQL injection in orders_for_customer (rule 7, HIGH)
|
||||
- [ ] Status: todo
|
||||
- Where: `orders_repo.py:17`
|
||||
- Problem: customer_id goes into the SQL through an f-string.
|
||||
- Before:
|
||||
```sql
|
||||
f"... WHERE CustomerId = {customer_id}"
|
||||
```
|
||||
- After:
|
||||
```sql
|
||||
"... WHERE CustomerId = ?", customer_id
|
||||
```
|
||||
- Check: a valid id returns the same row count as before.
|
||||
````
|
||||
|
||||
Running the skill again updates the tracker without overwriting it:
|
||||
- Existing Q numbers and their `[x]` marks are kept.
|
||||
- Items that are fixed are marked "resolved".
|
||||
- New findings get the next free Q number.
|
||||
|
||||
To apply a fix, tell Claude which item, for example "Q1 fix pannu".
|
||||
|
||||
## Safety
|
||||
|
||||
- It is read-only: it doesn't change code and doesn't connect to any database.
|
||||
- `query_tracker.md` is the only file it writes.
|
||||
- It doesn't guess table sizes. Findings that only matter on large tables say so.
|
||||
|
||||
## Tested
|
||||
|
||||
On 2026-10-21 the skill ran against two sample repos:
|
||||
|
||||
| Sample | Result |
|
||||
|---|---|
|
||||
| A repo with one planted bug per rule | Found all 10, each with a correct `file:line` |
|
||||
| A clean repo with traps: whitelisted f-string column names, `IN (?, ?)` placeholder lists, `COUNT(DISTINCT x)` | 0 false alarms |
|
||||
|
||||
The update-on-re-run behaviour has not been tested yet.
|
||||
@@ -0,0 +1,129 @@
|
||||
---
|
||||
name: sql-review
|
||||
description: Read-only review of SQL code (raw SQL strings in Python repos, .sql files, schema/index definitions; tuned for SQL Server + pyodbc) against 10 rules - formatting, SELECT *, CTEs, index-friendly WHERE, DISTINCT misuse, COUNT(*) vs COUNT(col), parameterized queries, composite index order, row-by-row loops, UNION vs UNION ALL. Reviews through a subagent and writes every issue with a before/after fix plan to query_tracker.md. Use this whenever the user asks to review, check, audit or "SQL check pannu" any query, repository/DAO file, slow query, or DB code before merging, even if they don't say "sql-review".
|
||||
---
|
||||
|
||||
# SQL Review
|
||||
|
||||
Audit SQL code against the 10 rules below. A subagent does the review; you verify its findings and turn them into a fix plan in `query_tracker.md`. The rules are written for SQL Server + pyodbc. When the code uses another database, apply the same rule in that dialect's syntax and say so.
|
||||
|
||||
## Ground rules
|
||||
|
||||
- **Read-only on code and databases.** Don't edit code. Don't run queries against any database, because the connection string may point at production. `query_tracker.md` is the only file you write. Fix code only if the user asks after reading the plan.
|
||||
- **Every finding needs `file:line` evidence.** No evidence, no claim.
|
||||
- **Don't guess table sizes.** If a finding only matters on a big table, say "matters if the table is large".
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Scope.** Grep `*.py` and `*.sql` for `SELECT|INSERT INTO|UPDATE .* SET|DELETE FROM|MERGE|CREATE INDEX|executemany` to list the files with SQL. Skip tests and one-off migration scripts unless the user points at them.
|
||||
2. **Review through a subagent.** Always do this, even for a single file: it keeps file dumps out of the main context. Use a read-only agent (`Explore`). With more than ~10 files, split them by folder across 2-3 subagents and launch them in parallel. Subagents can't write report files, so they must return findings as text. Use the prompt template below.
|
||||
3. **Verify.** Open the `file:line` of every HIGH finding and a few others yourself before trusting them. Drop any finding you can't confirm.
|
||||
4. **Write `query_tracker.md`** at the root of the reviewed project (the git root), in the format below. If the file already exists, update it instead of overwriting (see "Re-runs").
|
||||
5. **Reply in chat** with the 10-row summary table, the count of findings by severity, and a link to the tracker. Nothing more.
|
||||
|
||||
### Subagent prompt template
|
||||
|
||||
```
|
||||
Read-only SQL review. Read the "The 10 rules" section of
|
||||
~/.claude/skills/sql-review/SKILL.md and apply every rule to these files:
|
||||
<file list>
|
||||
Learn the placeholder style from the connection helper (pyodbc ?, psycopg %s, SQLAlchemy :name).
|
||||
Don't edit anything and don't run queries. Return text only.
|
||||
|
||||
First give one line per rule: <rule #> | OK/PARTLY/ISSUE/N/A | one evidence file:line.
|
||||
Then give one block per finding:
|
||||
FINDING
|
||||
rule: <1-10>
|
||||
severity: HIGH (rule 7) | WRONG-RESULT (rules 5, 6) | PERF (rules 4, 8, 9) | STYLE (rules 1, 2, 3, 10)
|
||||
where: <file:line>
|
||||
problem: <one sentence>
|
||||
before: <exact current snippet>
|
||||
after: <proposed replacement>
|
||||
check: <how to prove the change is safe, e.g. same row count on a sample, test name>
|
||||
```
|
||||
|
||||
## The 10 rules
|
||||
|
||||
### 1. Formatting
|
||||
Long queries (3+ clauses) should be multi-line: keywords uppercase, one column or clause per line, consistent identifier casing. Code is written once and read many times. Short one-line queries are fine; don't flag them.
|
||||
|
||||
### 2. Fetch only what you need
|
||||
- `SELECT *` (except inside `EXISTS (SELECT * ...)`): wastes I/O and network, and breaks callers when a column is added.
|
||||
- List queries that can grow without bound and feed a UI or a `fetchall()`: use `TOP (?)` or `OFFSET ... FETCH`. SQL Server has no `LIMIT`.
|
||||
|
||||
### 3. CTE over nested subqueries
|
||||
Two or more levels of nested derived tables → suggest `WITH` steps. This is for readability only; SQL Server inlines CTEs, so don't claim a speed gain.
|
||||
|
||||
### 4. Index-friendly (sargable) predicates
|
||||
Flag any function or expression wrapped around a column in `WHERE` / `JOIN ... ON`: `CAST`/`CONVERT(col)`, `YEAR(col)`, `UPPER`/`LOWER`/`TRIM`/`LTRIM`/`RTRIM(col)`, `ISNULL`/`COALESCE(col, x) = ?`, `col + 1 = ?`, and `LIKE '%x'` with a leading wildcard. The index stores the raw column values, so the optimizer can't seek once the column is transformed.
|
||||
SQL Server exceptions, so don't overstate them: `CAST(datetime_col AS DATE) = ?` can still seek, though row estimates suffer (call it PARTLY, not ISSUE). `UPPER`/`LOWER` are usually pointless anyway because the default collation is case-insensitive.
|
||||
Fixes: a range (`col >= ? AND col < ?`) for dates; store normalized data (backfill + `NOT NULL DEFAULT`) instead of `COALESCE`; or a computed column with an index on it.
|
||||
|
||||
### 5. DISTINCT must not hide a bad join
|
||||
`SELECT DISTINCT` of parent columns after a 1-to-many `JOIN` is hiding duplicates the join created. Suggest `WHERE EXISTS (...)`. `COUNT(DISTINCT x)` and genuine unique-value lists are fine.
|
||||
|
||||
### 6. COUNT(*) vs COUNT(col)
|
||||
`COUNT(col)` skips NULLs. Flag it only when the function name, docstring, variable or comment says "total rows" or "number of X". Counting non-null values on purpose is fine.
|
||||
|
||||
### 7. Parameterized queries (highest severity)
|
||||
Values put into SQL through an f-string, `%`, `.format()` or `+` create a SQL injection risk, and every distinct value is a new statement that defeats plan reuse.
|
||||
Don't flag these: identifiers spliced in from a whitelist or constant (name the whitelist's line), and generated placeholder lists such as `IN (?, ?, ?)`.
|
||||
|
||||
### 8. Composite index column order
|
||||
Read the `CREATE INDEX` statements and compare them with the `WHERE` columns. An index helps only when its **leading** column appears in the query's equality predicate. SQL Server has no skip-scan, so `(A, B)` does not help `WHERE B = ?`.
|
||||
Also flag:
|
||||
- hot, frequently polled queries (queue claims, status polls) whose filter column has no index
|
||||
- indexes that are an exact prefix of another index (redundant)
|
||||
|
||||
Phrase these as "likely", since you don't know the data volume.
|
||||
|
||||
### 9. Set-based, not row-by-row
|
||||
- A loop that calls `cursor.execute` once per row for `INSERT`/`UPDATE` → `cursor.fast_executemany = True` + `executemany`, a single `UPDATE ... WHERE id IN (...)`, or `MERGE`.
|
||||
- **N+1:** a query per item inside a loop over earlier results → one `IN`-list query.
|
||||
|
||||
### 10. UNION ALL over UNION
|
||||
`UNION` sorts and removes duplicates. When the branches can't overlap (disjoint tables or filters) or duplicates don't matter, use `UNION ALL`. Keep `UNION` when deduplication is really needed.
|
||||
|
||||
## query_tracker.md format
|
||||
|
||||
Order the plan by severity: HIGH, then WRONG-RESULT, then PERF, then STYLE. Within a severity level, put hot paths first (queue polls, per-request queries).
|
||||
|
||||
````markdown
|
||||
# Query Tracker
|
||||
|
||||
Reviewed: <YYYY-MM-DD> · Scope: <paths> · DB: <SQL Server / pyodbc `?`>
|
||||
Progress: 0/<N> done
|
||||
|
||||
## Summary
|
||||
|
||||
| # | Rule | Verdict | Evidence |
|
||||
|---|---|---|---|
|
||||
| 1 | Formatting | OK/PARTLY/ISSUE/N/A | file:line |
|
||||
...one row per rule, 10 rows...
|
||||
|
||||
## Plan
|
||||
|
||||
### Q1. <short title> (rule <n>, <SEVERITY>)
|
||||
- [ ] Status: todo
|
||||
- Where: `file:line`
|
||||
- Problem: <one sentence>
|
||||
- Before:
|
||||
```sql
|
||||
<current snippet>
|
||||
```
|
||||
- After:
|
||||
```sql
|
||||
<proposed snippet>
|
||||
```
|
||||
- Check: <how to verify after the change>
|
||||
````
|
||||
|
||||
One `Q` block per change. When the same pattern repeats in several places, use one block and list every `file:line` under "Where".
|
||||
|
||||
### Re-runs
|
||||
|
||||
When `query_tracker.md` already exists:
|
||||
- Keep the existing Q numbers and their `[x]` done marks.
|
||||
- Mark an item the new review no longer finds as `- [x] Status: resolved (not found on <date>)`.
|
||||
- Append new findings with the next free Q number.
|
||||
- Update the Reviewed date, the Progress count and the Summary table.
|
||||
@@ -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,9 @@
|
||||
# read-trans eval
|
||||
|
||||
- `fixture_make_it_better.txt`: short transcript with timestamps, chapter markers and a "like and subscribe" plug.
|
||||
- `results/make_it_better.md`: output of a fresh subagent that followed only `skills/read-trans/SKILL.md`.
|
||||
- `check_output.py <output.md>`: PASS/FAIL on structure (4 headings, a prompt block), Tanglish (no Tamil script, enough Tanglish words), no leaked timestamps/chapters/plugs, costs kept, under 700 words.
|
||||
|
||||
Run: `python tests/evals/read_trans/check_output.py tests/evals/read_trans/results/make_it_better.md`
|
||||
|
||||
History: run 1 passed the check but put its own arithmetic in the takeaway and did not mark its own ideas. SKILL.md was tightened (speaker-only sections 1-3, own ideas marked `(En idea)`); run 2 is the stored result.
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Check a read-trans output file: python check_output.py output.md (exit 1 on failure)."""
|
||||
import re, sys
|
||||
|
||||
text = open(sys.argv[1], encoding="utf-8").read()
|
||||
low = text.lower()
|
||||
fails = []
|
||||
|
||||
def need(ok, msg):
|
||||
if not ok:
|
||||
fails.append(msg)
|
||||
|
||||
for h in ("video enna solluthu", "medium explanation", "final takeaway", "projects-ku enna pannanum"):
|
||||
need(h in low, f"missing section: {h}")
|
||||
need(not re.search(r"[\u0b80-\u0bff]", text), "Tamil script found (should be Tanglish)")
|
||||
need(not re.search(r"\b\d+:\d{2}\b", text), "timestamp leaked into output")
|
||||
need(not re.search(r"chapter \d", low), "chapter marker leaked")
|
||||
need("```" in text, "no ready-to-copy prompt block in the projects section")
|
||||
need(len(re.findall(r"\b(irukku|panna|pannanum|solluthu|romba|idhu|athu|aana|illa)\b", low)) >= 8, "not enough Tanglish words")
|
||||
need("subscribe" not in low, "sponsor/subscribe plug repeated")
|
||||
need("$137" in text and "$22" in text, "model costs missing")
|
||||
need(len(text.split()) < 700, "too long (>700 words)")
|
||||
|
||||
print("\n".join(["FAIL: " + f for f in fails]) or "PASS")
|
||||
sys.exit(1 if fails else 0)
|
||||
@@ -0,0 +1,15 @@
|
||||
0:00 0 seconds Today I gave Claude Code a horrible coffee shop website and told it make it better, 100 times in a row.
|
||||
0:15 15 seconds I did this with Sonnet 5.5 on medium effort and Opus 5.5 on medium effort.
|
||||
Chapter 2: The prompt
|
||||
0:25 25 seconds The prompt was: look at the site first, then innovate. Think about user experience, think outside the box. Make one bold change. You must ship a change every time.
|
||||
0:50 50 seconds I had to force it to change every time because earlier it stopped at round 20 or 25 and said nothing else can be done.
|
||||
Chapter 3: Setup
|
||||
1:20 1 minute, 20 seconds Every round ran in a clean room: fresh session, no skills, no CLAUDE.md, no plugins, no memory. Only the site code carried over from the last round.
|
||||
Chapter 4: Sonnet
|
||||
2:10 2 minutes, 10 seconds Sonnet cost about $22 and ran almost 2 hours. Early rounds were good: sticky order bar, receipt style order, an AI video, an order form. After round 32 there were no big milestones. Lines of code kept rising linearly but quality did not.
|
||||
Chapter 5: Opus
|
||||
7:40 7 minutes, 40 seconds Opus cost $137 and ran 8.5 hours. Round 1 was already better. It added a QR code that texts your order, and a mood-based drink picker. By round 100 it had 21,000 lines: leaves, pumpkin carving, caffeine tracker, music, a Spanish toggle that did not work. Some sections broke. It was AI slop.
|
||||
Chapter 6: Surprises
|
||||
12:30 12 minutes, 30 seconds Neither model used web search, even though I said innovate. They never looked at award winning sites for inspiration.
|
||||
Chapter 7: Final thoughts
|
||||
13:15 13 minutes, 15 seconds The model sets the ceiling and the prompt sets the direction. Innovate without a vision turns into adding. It tried to upsell everything, a hard sell instead of a soft close. Please like and subscribe.
|
||||
@@ -0,0 +1,31 @@
|
||||
### 1. Video enna solluthu (short)
|
||||
Speaker oru horrible coffee shop website-ai Claude Code kitta "make it better" nu 100 times sonnaar, Sonnet 5.5 and Opus 5.5 (rendum medium effort) la.
|
||||
Opus better ideas kudutthuchu aana 100 rounds la AI slop aayiduchu; Sonnet cheap ah irunthuchu aana 32 round-ku apram quality nikkala.
|
||||
|
||||
### 2. Medium explanation
|
||||
1. Setup: oru mosamaana coffee shop website. Prompt: "look at the site first, then innovate, think about user experience, think outside the box, make one bold change, you must ship a change every time." Idhu 100 times loop.
|
||||
2. "You must ship a change" nu force pannaan, because munnadi round 20 or 25 la model "innum onnum panna mudiyaadhu" nu niruthiduchu.
|
||||
3. Every round clean room: fresh session, no skills, no CLAUDE.md, no plugins, no memory. Site code mattum next round-ku poachu.
|
||||
4. Sonnet: about $22, almost 2 hours. Early rounds nalla irunthuchu: sticky order bar, receipt style order, AI video, order form. Round 32-ku apram big milestone illa. Lines of code linear ah ericchu, aana quality ericchila.
|
||||
5. Opus: $137, 8.5 hours. Round 1 la ye better. QR code (scan pannaa order text aagum), mood-based drink picker. Round 100 la 21,000 lines: leaves, pumpkin carving, caffeine tracker, music, Spanish toggle (adhu work aagala). Sila sections broke. Speaker: "AI slop".
|
||||
6. Surprise: rendu model-um web search use pannala, "innovate" nu sonnaalum. Award winning sites paathu inspiration edukkala.
|
||||
|
||||
### 3. Final takeaway
|
||||
- Model dhaan ceiling set pannum; prompt dhaan direction set pannum.
|
||||
- Vision illaama "innovate" nu sonna, model add pannikite irukkum, improve pannaadhu.
|
||||
- Model ellathayum upsell panna paathuchu: hard sell, soft close illa.
|
||||
- Repeat panna panna lines of code ericchu, quality ericchila (Sonnet 32 round-ku apram).
|
||||
- Model ah force pannaama vittaa round 20-25 la niruthidum.
|
||||
|
||||
### 4. Unga projects-ku enna pannanum
|
||||
1. "Make it better" maari vague prompt kudukaadhe. Enna improve venum nu direction sollu (vision, goal, target user).
|
||||
2. Loop la run pannum bodhu "look at the site/code first, then make one bold change" nu one change per round nu limit pannu.
|
||||
3. Model web search use pannala, so inspiration venumna neeye sollu: "search award winning sites first" nu prompt la explicit ah eludhu. (En idea)
|
||||
4. 20-30 rounds-ku mela poga vendaam; Sonnet 32-ku apram gain illa. Oru stop point vechuko. (En idea)
|
||||
5. Ovvoru round-um fresh session, clean context la run pannu, code mattum carry over aagattum. Idhu speaker-oda setup.
|
||||
6. Round mudinjadhum feature work aagudha nu check pannu (Spanish toggle maari broken things pidikka). (En idea)
|
||||
7. Cost paaru: Sonnet ~ $22 vs Opus ~ $137. Chinna improvement-ku Sonnet podhum, Opus ku vision/prompt strong ah irukkanum. (En idea)
|
||||
|
||||
```
|
||||
Look at the current site first. Then make ONE bold change that improves user experience for [target user] toward this goal: [goal]. Before coding, search the web for 2-3 award-winning sites in this space and borrow one idea. Do not add features unrelated to the goal. Do not upsell. Make sure the change actually works, and fix anything broken from earlier rounds before adding new things.
|
||||
```
|
||||
@@ -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=>({'&':'&','<':'<','>':'>','"':'"'}[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>
|
||||
@@ -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)?"
|
||||
}
|
||||
@@ -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 & 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,15 +179,17 @@ 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 API_BASE = ""; // web mode only, e.g. "/api"
|
||||
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=>({'&':'&','<':'<','>':'>','"':'"'}[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>
|
||||
|
||||
@@ -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>
|
||||
Loaded 100 of 253 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user