Optional sections: checkpoint tracker, admin/roles, login page
- dashboard.html: each section sits in OPTIONAL:<name> BEGIN/END blocks in the style, markup and script, with sample answers so a page with no bot shows it working. Core change: setUser records the role on <body>, so CSS hides admin-only parts from operators. - templates/sections.py keeps the chosen sections and deletes the rest, markers included; it stops on a broken marker. - templates/sections.md: what each section needs from the host (events, actions, desktop slot snippets, the role re-check, the login hand-off). - SKILL.md step 5 asks one multi-select question and runs sections.py. - Page check: checkpoint states, admin visibility by role and assign/remove, login theme and hand-off to auth or the stub. Cases skip on a generated page without the section; the template must carry all three. The harness signs in when the page has a login screen. - tests/test_sections.py: no sections leave no trace, and that page still passes the page check. - Eval runner: a-empty-sections variant (all three) and per-section checks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3eb3fdf00e
commit
9a5b95448d
10 files changed
+536
-10
No files matched your search
@@ -15,7 +15,8 @@ source projects.
|
||||
| `templates/contract.md` | The data contract: events the bot sends, actions the page sends. |
|
||||
| `templates/themes.md` | Theme presets (Tebra Sky, Superbill Ember) and the custom-colour steps. |
|
||||
| `templates/palette.py` | Derives a custom palette from one brand hex and checks contrast. |
|
||||
| `templates/reference/` | Raw Tebra extracts. Use them only to port optional sections. |
|
||||
| `templates/sections.py`, `templates/sections.md` | Keeps the chosen optional sections in the page and deletes the rest; what each section needs from the host. |
|
||||
| `templates/reference/` | Raw Tebra extracts, for reference only. The optional sections are already in `dashboard.html`. |
|
||||
|
||||
Follow the steps in order. Ask one question at a time, and wait for the answer.
|
||||
If the user's request already answers a question, don't ask it again.
|
||||
@@ -59,10 +60,20 @@ Ask only if there is no frontend. If there is one, use its stack.
|
||||
Always built: the shell (sidebar, top bar, dark toggle, status dot), KPI cards,
|
||||
the status donut, the run panel and the report table.
|
||||
|
||||
Ask which extras to add (choose any, or none): **checkpoint tracker**,
|
||||
**admin/roles screen**, **login page**. Build only the ones chosen. Unchosen
|
||||
sections must leave no markup, script or style behind, so delete their
|
||||
`<!-- OPTIONAL: ... -->` markers.
|
||||
Ask which extras to add, as one multi-select question (choose any, or none):
|
||||
- **Checkpoint tracker**: numbered boxes on the Run tab that show each checkpoint as
|
||||
pending, in progress, done or stuck.
|
||||
- **Admin/roles screen**: an Admin tab, for admins only, to give people admin or
|
||||
operator access.
|
||||
- **Login page**: a sign-in screen in the dashboard's theme. It's UI only and hands
|
||||
off to the project's own auth.
|
||||
|
||||
Build only the chosen ones. In step 6, right after copying the page, run
|
||||
`python templates/sections.py <copied dashboard.html> <chosen: checkpoints admin login>`.
|
||||
It deletes the unchosen sections completely (markup, script and style), so nothing
|
||||
is left behind. Never cut the blocks by hand. Then follow `templates/sections.md` for
|
||||
each kept section: what to fill, which events the bot sends, and which host actions
|
||||
to answer.
|
||||
|
||||
## 6. Build
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ All updates use one envelope: `{"event": "<name>", "data": {...}}`.
|
||||
| `set_user` | `{username, emp_id, role}` |
|
||||
| `apply_dark` | `{on: bool}` |
|
||||
|
||||
Optional (only if the section was chosen): `cpstate {kind: pending|active|done|stuck, cp: int}`, `admin_data`, `batch_detail`.
|
||||
Optional sections (only if chosen) add `cpstate`, `admin_data` and `login_result`, and the actions `load_admin`, `assign_role`, `remove_role` and `login`. See `sections.md`.
|
||||
|
||||
## Page → bot/backend (Qt `@pyqtSlot` names / web `POST {API_BASE}/<slot-with-dashes>`)
|
||||
|
||||
|
||||
@@ -117,6 +117,32 @@ table{border-collapse:collapse;width:100%;font-size:12px;white-space:nowrap}
|
||||
th{background:var(--glass-strong);text-align:left;padding:11px 14px;position:sticky;top:0;font-weight:700}
|
||||
td{padding:10px 14px;border-bottom:1px solid var(--border)}
|
||||
.empty{text-align:center;padding:40px;color:var(--muted);font-size:13px}
|
||||
/* OPTIONAL:checkpoints BEGIN */
|
||||
.cp-track{display:grid;grid-template-columns:repeat(auto-fit,minmax(118px,1fr));gap:8px;margin-bottom:16px}
|
||||
.cp-box{display:flex;flex-direction:column;gap:4px;padding:10px 11px;border-radius:11px;border:1.5px solid var(--border);background:var(--glass-strong);color:var(--muted);font-size:11.5px;font-weight:700}
|
||||
.cp-name{color:var(--ink);font-size:12.5px}
|
||||
.cp-box.active{border-color:var(--warn);background:var(--warn-bg);color:var(--warn)}
|
||||
.cp-box.done{border-color:var(--ok);background:var(--ok-bg);color:var(--ok)}
|
||||
.cp-box.stuck{border-color:var(--bad);background:var(--bad-bg);color:var(--bad)}
|
||||
/* OPTIONAL:checkpoints END */
|
||||
/* OPTIONAL:admin BEGIN */
|
||||
body:not([data-role="admin" i]) .adm-only{display:none!important}
|
||||
.toolbar select{padding:8px 10px;border:1px solid var(--border);border-radius:10px;background:var(--glass-strong);color:var(--ink);font:inherit;font-size:12.5px}
|
||||
.toolbar select option{background:var(--bg-bottom);color:var(--ink)}
|
||||
.pill.adm-role{background:var(--muted);text-transform:capitalize}.pill.adm-role.admin{background:var(--accent)}
|
||||
.adm-note{font-size:12.5px;color:var(--muted)}
|
||||
/* OPTIONAL:admin END */
|
||||
/* OPTIONAL:login BEGIN */
|
||||
.login-screen{position:fixed;inset:0;z-index:50;display:flex;align-items:center;justify-content:center;padding:16px;
|
||||
background:linear-gradient(180deg,var(--bg-top) 0%,var(--bg-mid) 45%,var(--bg-bottom) 100%)}
|
||||
.login-card{width:min(360px,100%);display:flex;flex-direction:column;gap:12px;margin:0}
|
||||
.login-card .c-title{font-size:19px;margin:0}
|
||||
.login-sub{font-size:12.5px;color:var(--muted);margin-top:-8px}
|
||||
.login-card label{display:flex;flex-direction:column;gap:5px;font-size:12px;font-weight:700;color:var(--muted)}
|
||||
.login-card input{padding:10px 12px;border:1px solid var(--border);border-radius:10px;background:var(--glass-strong);color:var(--ink);font:inherit;font-size:13px}
|
||||
.login-err{color:var(--bad);font-size:12.5px;font-weight:600;min-height:1.2em}
|
||||
.login-out svg{width:17px;height:17px;stroke:currentColor;fill:none;stroke-width:2;vertical-align:middle}
|
||||
/* OPTIONAL:login END */
|
||||
@media (max-width:820px){.sidebar{width:66px;min-width:66px}.logo-title,.logo-sub,.nav-item span,.u-name,.u-role{display:none}.kpi-row{grid-template-columns:1fr 1fr}.dash-grid{grid-template-columns:1fr}}
|
||||
</style>
|
||||
</head>
|
||||
@@ -131,7 +157,9 @@ td{padding:10px 14px;border-bottom:1px solid var(--border)}
|
||||
<button class="nav-item active" data-tab="dashboard" title="Dashboard"><svg viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg><span>Dashboard</span></button>
|
||||
<button class="nav-item" data-tab="run" title="Run"><svg viewBox="0 0 24 24"><polygon points="6 4 20 12 6 20 6 4"/></svg><span>Run</span></button>
|
||||
<button class="nav-item" data-tab="report" title="Report"><svg viewBox="0 0 24 24"><path d="M4 4h16v16H4z"/><path d="M8 9h8M8 13h8M8 17h5"/></svg><span>Report</span></button>
|
||||
<!-- OPTIONAL: Admin nav item (roles) goes here -->
|
||||
<!-- OPTIONAL:admin BEGIN -->
|
||||
<button class="nav-item adm-only" id="adm-nav" data-tab="admin" title="Admin"><svg viewBox="0 0 24 24"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg><span>Admin</span></button>
|
||||
<!-- OPTIONAL:admin END -->
|
||||
</nav>
|
||||
<div class="sidebar-foot"><div class="avatar" id="avatar">U</div><div><div class="u-name" id="u-name">User</div><div class="u-role" id="u-role">Operator</div></div></div>
|
||||
</aside>
|
||||
@@ -141,6 +169,9 @@ td{padding:10px 14px;border-bottom:1px solid var(--border)}
|
||||
<div><div class="tb-title" id="tb-title">Dashboard</div><div class="tb-sub" id="tb-sub">Overview of every batch</div></div>
|
||||
<div class="tb-right">
|
||||
<button class="btn primary" id="run-btn" onclick="startRun()">▶ Run</button>
|
||||
<!-- OPTIONAL:login BEGIN -->
|
||||
<button class="mode-btn login-out" title="Sign out" aria-label="Sign out" onclick="loginShow(true); api('logout')"><svg viewBox="0 0 24 24"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg></button>
|
||||
<!-- OPTIONAL:login END -->
|
||||
<button class="mode-btn" id="mode-btn" title="Dark mode" aria-label="Dark mode" aria-pressed="false" onclick="toggleDark()">🌙</button>
|
||||
<div class="indicator"><span class="dot ready" id="st-dot"></span><span id="st-text">System ready</span></div>
|
||||
</div>
|
||||
@@ -169,7 +200,10 @@ td{padding:10px 14px;border-bottom:1px solid var(--border)}
|
||||
<div class="prog-head"><span id="p-label">Waiting to start</span><span id="p-time"></span><span class="pct" id="p-pct">0%</span></div>
|
||||
<div class="prog-bg"><div class="prog-fill" id="p-fill"></div></div>
|
||||
<ol id="steps" aria-label="Steps"></ol>
|
||||
<!-- OPTIONAL: checkpoint tracker (see reference/tebra_dashboard.html .cp-track) -->
|
||||
<!-- OPTIONAL:checkpoints BEGIN -->
|
||||
<div class="section-label">Checkpoints</div>
|
||||
<ol class="cp-track" id="cp-track" aria-label="Checkpoints"></ol>
|
||||
<!-- OPTIONAL:checkpoints END -->
|
||||
<div class="log" id="log" role="log" aria-label="Run log"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -181,10 +215,41 @@ 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>
|
||||
|
||||
<!-- OPTIONAL:admin BEGIN -->
|
||||
<!-- ADMIN: admins only. Hiding it is not security: the host re-checks the role on every admin action. -->
|
||||
<div id="tab-admin" class="hidden adm-only">
|
||||
<div class="card"><div class="c-title">Assign access</div>
|
||||
<form class="toolbar" aria-label="Assign access" onsubmit="admAssign(event)">
|
||||
<input id="adm-user" aria-label="Username or employee ID" placeholder="Username or employee ID" required>
|
||||
<select id="adm-role" aria-label="Role"><option value="operator">Operator</option><option value="admin">Admin</option></select>
|
||||
<button class="btn primary">Assign</button>
|
||||
<span class="adm-note" id="adm-note" role="status"></span>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card"><div class="c-title">Users with access</div>
|
||||
<div class="tbl-wrap"><table aria-label="Users with access"><thead><tr><th>User</th><th>Employee ID</th><th>Role</th><th></th></tr></thead><tbody id="adm-rows" onclick="admRemove(event)"></tbody></table></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- OPTIONAL:admin END -->
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- OPTIONAL:login BEGIN -->
|
||||
<!-- LOGIN: UI only. The host checks the password with the project's own auth (see sections.md). -->
|
||||
<div class="login-screen" id="login-screen">
|
||||
<form class="card login-card" aria-label="Sign in" onsubmit="loginSubmit(event)">
|
||||
<div class="c-title">{{APP_NAME}}</div>
|
||||
<p class="login-sub">Sign in to continue</p>
|
||||
<label>Username<input id="login-user" autocomplete="username" required></label>
|
||||
<label>Password<input id="login-pass" type="password" autocomplete="current-password" required></label>
|
||||
<div class="login-err" id="login-err" role="alert"></div>
|
||||
<button class="btn primary">Sign in</button>
|
||||
</form>
|
||||
</div>
|
||||
<!-- OPTIONAL:login END -->
|
||||
|
||||
<script>
|
||||
// ===== CONFIG (fill per project) =====
|
||||
const STEPS = {{STEPS_JSON}}; // e.g. ["Initializing","Login","Process items","Completed"]
|
||||
@@ -234,9 +299,22 @@ const SAMPLE = {
|
||||
.map(([status,bucket],i)=>({...Object.fromEntries(REPORT_COLUMNS.map(c=>[c.key,`${c.label} ${i+1}`])),status,bucket}))}); },
|
||||
start_automation(){ let i=0; handle('status',{state:'running',msg:'Running (sample)'});
|
||||
const t=setInterval(()=>{
|
||||
// OPTIONAL:checkpoints BEGIN
|
||||
handle('cpstate', i<STEPS.length ? {kind:'active',cp:Math.ceil((i+1)*CHECKPOINTS.length/STEPS.length)} : {kind:'done',cp:CHECKPOINTS.length});
|
||||
// OPTIONAL:checkpoints END
|
||||
if(i<STEPS.length){ handle('step',{label:STEPS[i],pct:Math.round((i+1)/STEPS.length*100)}); handle('log',{msg:`Sample step: ${STEPS[i++]}`,type:'info'}); }
|
||||
else{ clearInterval(t); handle('log',{msg:'Sample run finished.',type:'success'}); handle('status',{state:'done',msg:'Completed'}); }
|
||||
},400); },
|
||||
// OPTIONAL:admin BEGIN
|
||||
adm_users:[{username:'Sample admin',emp_id:'E-1001',role:'admin'},{username:'Sample operator',emp_id:'E-1002',role:'operator'}],
|
||||
load_admin(){ handle('admin_data',{rows:this.adm_users}); },
|
||||
assign_role(who,role){ this.remove_role(who); this.adm_users.push({username:who,emp_id:'',role}); this.load_admin(); },
|
||||
remove_role(who){ this.adm_users=this.adm_users.filter(u=>u.username!==who&&u.emp_id!==who); this.load_admin(); },
|
||||
// OPTIONAL:admin END
|
||||
// OPTIONAL:login BEGIN
|
||||
login(username){ handle('login_result',{ok:true,user:{username,emp_id:'',role:document.body.dataset.role||'operator'}}); }, // stub: anyone, same role
|
||||
logout(){},
|
||||
// OPTIONAL:login END
|
||||
};
|
||||
|
||||
// ===== EVENT DISPATCH (see contract.md) =====
|
||||
@@ -249,6 +327,15 @@ function handle(event, d){
|
||||
case 'report_data': reportRows = d.rows||[]; renderReport(); break;
|
||||
case 'set_user': setUser(d); break;
|
||||
case 'apply_dark': setDark(!!d.on); break;
|
||||
// OPTIONAL:checkpoints BEGIN
|
||||
case 'cpstate': cpState(d.kind, d.cp); break;
|
||||
// OPTIONAL:checkpoints END
|
||||
// OPTIONAL:admin BEGIN
|
||||
case 'admin_data': admRender(d); break;
|
||||
// OPTIONAL:admin END
|
||||
// OPTIONAL:login BEGIN
|
||||
case 'login_result': loginResult(d); break;
|
||||
// OPTIONAL:login END
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,7 +352,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'); }
|
||||
|
||||
@@ -303,6 +390,63 @@ 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>`;
|
||||
}
|
||||
|
||||
// OPTIONAL:checkpoints BEGIN
|
||||
// Checkpoint tracker: numbered boxes driven by cpstate {kind, cp}. cp counts from 1.
|
||||
const CHECKPOINTS = {{CHECKPOINTS_JSON}}; // e.g. ["Open the portal","Load the list","Process items","Finish up"]
|
||||
const CP_LABEL = {pending:'Pending', active:'In progress', done:'Done', stuck:'Stuck'};
|
||||
let cpKinds = CHECKPOINTS.map(()=>'pending');
|
||||
function cpState(kind, n){
|
||||
if(!CP_LABEL[kind] || !(n>=1 && n<=CHECKPOINTS.length)) return;
|
||||
if(kind==='active') cpKinds = cpKinds.map((_,i)=>i<n-1?'done':'pending'); // a new active box resets the rest
|
||||
cpKinds[n-1] = kind;
|
||||
$('cp-track').innerHTML = CHECKPOINTS.map((c,i)=>`<li class="cp-box ${cpKinds[i]}"${cpKinds[i]==='active'?' aria-current="step"':''}>`
|
||||
+`<span>${i+1}</span><span class="cp-name">${esc(c)}</span><span>${CP_LABEL[cpKinds[i]]}</span></li>`).join('');
|
||||
}
|
||||
cpState('pending', 1);
|
||||
// OPTIONAL:checkpoints END
|
||||
|
||||
// OPTIONAL:admin BEGIN
|
||||
// Admin screen: shown only while set_user says role "admin" (CSS on body[data-role]).
|
||||
TABS.admin = ['Admin','Who can use this dashboard'];
|
||||
$('adm-nav').addEventListener('click', ()=>api('load_admin'));
|
||||
function admRender(d){
|
||||
$('adm-note').textContent = d.msg||'';
|
||||
const rows = d.rows||[];
|
||||
$('adm-rows').innerHTML = rows.length ? rows.map(u=>`<tr><td>${esc(u.username)}</td><td>${esc(u.emp_id)}</td>`
|
||||
+`<td><span class="pill adm-role ${/^admin$/i.test(u.role)?'admin':''}">${esc(u.role)}</span></td>`
|
||||
+`<td><button class="btn" data-user="${esc(u.emp_id||u.username)}">Remove</button></td></tr>`).join('')
|
||||
: '<tr><td class="empty" colspan="4">No one has access yet</td></tr>';
|
||||
}
|
||||
function admAssign(e){ e.preventDefault(); api('assign_role', $('adm-user').value.trim(), $('adm-role').value); $('adm-user').value=''; }
|
||||
function admRemove(e){ const who=e.target.dataset.user; if(who) api('remove_role', who); }
|
||||
if(SAMPLE_DATA) setUser({username:'Sample admin', emp_id:'E-1001', role:'admin'}); // demo: show the admin screen
|
||||
// OPTIONAL:admin END
|
||||
|
||||
// OPTIONAL:login BEGIN
|
||||
// Login page (UI only): the host checks the password with the project's own auth and answers login_result.
|
||||
function loginShow(on){
|
||||
$('login-screen').classList.toggle('hidden', !on);
|
||||
document.querySelector('.app').inert = on;
|
||||
if(on){ $('login-err').textContent=''; $('login-user').focus(); }
|
||||
}
|
||||
function loginSubmit(e){
|
||||
e.preventDefault(); $('login-err').textContent='';
|
||||
const u=$('login-user').value.trim(), p=$('login-pass').value;
|
||||
if(SAMPLE_DATA || bridge) return api('login', u, p);
|
||||
fetch(`${API_BASE}/login`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:u,password:p})})
|
||||
.then(r=>r.json()).then(d=>handle('login_result',d))
|
||||
.catch(()=>handle('login_result',{ok:false,msg:'Could not reach the server.'}));
|
||||
}
|
||||
function loginResult(d){
|
||||
$('login-pass').value='';
|
||||
if(!d.ok){ $('login-err').textContent = d.msg||'Sign-in failed.'; return; }
|
||||
if(d.user) setUser(d.user);
|
||||
loginShow(false); showTab('dashboard');
|
||||
}
|
||||
loginShow(true);
|
||||
// OPTIONAL:login END
|
||||
|
||||
renderSteps();
|
||||
startTransport();
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
# Optional sections
|
||||
|
||||
Three extras live in `dashboard.html` as marked blocks (`OPTIONAL:<name> BEGIN` ... `END`)
|
||||
in the style, the markup and the script. Build only the ones the user picked:
|
||||
|
||||
```
|
||||
python <skill>/templates/sections.py <project>/dashboard/dashboard.html checkpoints admin login
|
||||
```
|
||||
|
||||
Name only the chosen sections; name none to remove all three. The script deletes every
|
||||
unchosen block (markup, script and style) and the marker lines of the chosen ones, and it
|
||||
stops if a marker is broken. Never cut the blocks by hand.
|
||||
|
||||
With `SAMPLE_DATA = true`, every section works on its own sample answers. Once the bot is
|
||||
wired, the host must answer the section's actions as below. Desktop hosts add the slots to
|
||||
the `Bridge` class in `bridge.py`. Web hosts serve `POST {API_BASE}/<action-with-dashes>`,
|
||||
like the core actions, and send the events in the `/run/status` events list.
|
||||
|
||||
## Checkpoint tracker (`checkpoints`)
|
||||
|
||||
Numbered boxes on the Run tab. Each one is pending, in progress, done or stuck.
|
||||
|
||||
- Fill `{{CHECKPOINTS_JSON}}` with the checkpoint names, in order, from the bot's real
|
||||
stages, for example `["Open the portal","Load the list","Process items","Finish up"]`.
|
||||
They can be finer than `STEPS`.
|
||||
- Event `cpstate {kind: "pending"|"active"|"done"|"stuck", cp}`. `cp` counts from 1.
|
||||
`active` also marks the earlier boxes done and the later ones pending, so the bot can
|
||||
send `active` for each checkpoint as it starts, and `stuck` when one fails.
|
||||
- Desktop: add a marker table next to `STEP_MARKERS` in `bridge.py`, and send the event
|
||||
from `_on_line`, using specific substrings the bot really prints:
|
||||
|
||||
```python
|
||||
CHECKPOINT_MARKERS = [("[cp 1]", "active", 1), ("[cp 1 failed]", "stuck", 1)] # (substring, kind, cp)
|
||||
|
||||
# in _on_line, after the step markers:
|
||||
for marker, kind, cp in CHECKPOINT_MARKERS:
|
||||
if marker in low:
|
||||
self.send_to_js("cpstate", {"kind": kind, "cp": cp})
|
||||
break
|
||||
```
|
||||
|
||||
## Admin/roles screen (`admin`)
|
||||
|
||||
An Admin tab where an admin assigns or removes admin and operator access. The Admin nav
|
||||
item and the screen appear only while `set_user` says `role: "admin"`; operators never see
|
||||
them. Hiding them is not security: **the host re-checks that the signed-in user is an admin
|
||||
on every admin action**, and refuses otherwise.
|
||||
|
||||
- Event `admin_data {rows: [{username, emp_id, role: "admin"|"operator"}], msg?}`. `msg` is
|
||||
shown above the list, for example "Access assigned." or "Only admins can do that."
|
||||
- Actions: `load_admin()` when the tab opens, `assign_role(who, role)`, `remove_role(who)`.
|
||||
`who` is a username or employee ID. Answer each one with a fresh `admin_data`.
|
||||
- Desktop slots, using the project's own user store:
|
||||
|
||||
```python
|
||||
@pyqtSlot()
|
||||
def load_admin(self):
|
||||
if self.role != "admin": # self.role: set when the user signs in
|
||||
return
|
||||
self.send_to_js("admin_data", {"rows": read_access_list()})
|
||||
|
||||
@pyqtSlot(str, str)
|
||||
def assign_role(self, who, role):
|
||||
if self.role == "admin" and role in ("admin", "operator"):
|
||||
save_access(who, role)
|
||||
self.load_admin()
|
||||
|
||||
@pyqtSlot(str)
|
||||
def remove_role(self, who):
|
||||
if self.role == "admin":
|
||||
delete_access(who)
|
||||
self.load_admin()
|
||||
```
|
||||
|
||||
## Login page (`login`)
|
||||
|
||||
A sign-in screen in the page's own theme, shown over the dashboard until sign-in succeeds,
|
||||
plus a Sign out button in the top bar. It is UI only: it hands the username and password to
|
||||
the project's existing auth. Don't build an auth backend.
|
||||
|
||||
- Desktop action `login(username, password)`. The host checks them and sends
|
||||
`login_result {ok, msg?, user?: {username, emp_id, role}}`. On `ok` the screen closes and
|
||||
`user` fills the sidebar (and the role, if the admin screen is built). Otherwise `msg`
|
||||
shows under the form.
|
||||
- Web: `POST {API_BASE}/login` with JSON `{username, password}`, answered with the same
|
||||
`{ok, msg?, user?}` body.
|
||||
- Action `logout()` when the user signs out. The screen comes back.
|
||||
- The page sits behind the screen, but the host must still refuse data actions until
|
||||
sign-in succeeds.
|
||||
- No auth in the project yet: keep a stub that lets any non-empty username in, and tell the
|
||||
user it is a stub. In sample mode the page has its own stub.
|
||||
|
||||
```python
|
||||
@pyqtSlot(str, str)
|
||||
def login(self, username, password):
|
||||
role = check_login(username, password) # the project's auth: a role, or None
|
||||
self.role = role or ""
|
||||
if role:
|
||||
self.send_to_js("login_result", {"ok": True, "user": {"username": username, "emp_id": "", "role": role}})
|
||||
else:
|
||||
self.send_to_js("login_result", {"ok": False, "msg": "Wrong username or password."})
|
||||
|
||||
@pyqtSlot()
|
||||
def logout(self):
|
||||
self.role = ""
|
||||
```
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Keep the optional sections the user chose in dashboard.html, and delete the rest.
|
||||
|
||||
Usage: python sections.py <dashboard.html> [checkpoints] [admin] [login]
|
||||
|
||||
Each section sits between marker lines, in HTML, CSS and script:
|
||||
<!-- OPTIONAL:admin BEGIN --> /* OPTIONAL:admin BEGIN */ // OPTIONAL:admin BEGIN
|
||||
... ... ...
|
||||
<!-- OPTIONAL:admin END --> /* OPTIONAL:admin END */ // OPTIONAL:admin END
|
||||
Chosen sections lose only their marker lines. Unchosen ones lose everything.
|
||||
The file is rewritten in place. Name no sections to remove all of them.
|
||||
"""
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SECTIONS = ("checkpoints", "admin", "login")
|
||||
BLOCK = re.compile(r"^[ \t]*(?:<!--|/\*|//) OPTIONAL:(\w+) BEGIN.*\n((?:.*\n)*?)[ \t]*(?:<!--|/\*|//) OPTIONAL:\1 END.*\n", re.M)
|
||||
|
||||
|
||||
def keep(html, chosen):
|
||||
unknown = set(chosen) - set(SECTIONS)
|
||||
if unknown:
|
||||
sys.exit(f"unknown section(s) {', '.join(sorted(unknown))}; choose from {', '.join(SECTIONS)}")
|
||||
html = BLOCK.sub(lambda m: m[2] if m[1] in chosen else "", html)
|
||||
if "OPTIONAL:" in html:
|
||||
sys.exit("an OPTIONAL marker has no matching BEGIN/END line; fix the template")
|
||||
return html
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
sys.exit(__doc__)
|
||||
page = Path(sys.argv[1])
|
||||
page.write_text(keep(page.read_text(encoding="utf-8"), sys.argv[2:]), encoding="utf-8")
|
||||
print(f"{page}: kept {', '.join(sys.argv[2:]) or 'no optional sections'}")
|
||||
Reference in new issue
Block a user