β This page is gated client-side only β that stops casual access, not a determined user. For real protection, restrict this host/route at the network or reverse-proxy layer.
+
+
+
+
API connectivity
+
+
β
+
+
+
+
+
Database snapshot
+
+
Click refresh to read row counts from SQL via the API.
+
+
+
+
+
End-to-end smoke test
+
Creates a throwaway project, exercises the issue gate / status / metrics / comments, then deletes it (cascade). Mirrors server/smoketest.py.
+
+
Ready.
+
+
+
+
+
Demo data
+
Seed a realistic DEMO project (SOP + a spread of Work Packages) into SQL, or remove all DEMO-/SMOKE- projects.
+
+
+
+
+
Ready.
+
Note: the seeded project appears in the home picker; its SOP/WPs live in SQL but won't render in the Creator/Dashboard until the front end is wired to the API (Phase 2).
+
+
+
+
+
+
diff --git a/html/admin.js b/html/admin.js
new file mode 100644
index 0000000..ae0f6b2
--- /dev/null
+++ b/html/admin.js
@@ -0,0 +1,170 @@
+/* Admin console for the Work Package Suite.
+ Browser-side diagnostics + tests that call the same /api on this host.
+
+ PASSPHRASE GATE (lightweight / obfuscation only):
+ The gate compares a SHA-256 hash so the passphrase isn't in the source, but a
+ determined user can still bypass client-side JS. For real protection, restrict
+ this host/route at the network or reverse-proxy layer.
+
+ Default passphrase: "prime-admin"
+ To change it: compute a new hash and replace ADMIN_PASSPHRASE_SHA256 below β
+ python3 -c "import hashlib,sys;print(hashlib.sha256(sys.argv[1].encode()).hexdigest())" "your-new-passphrase"
+ or in a browser console:
+ crypto.subtle.digest('SHA-256', new TextEncoder().encode('your-new-passphrase'))
+ .then(b=>console.log([...new Uint8Array(b)].map(x=>x.toString(16).padStart(2,'0')).join('')));
+*/
+const ADMIN_PASSPHRASE_SHA256 = 'ae1fb92c43fccbad26f05434a194f574ec98a2197e0ff4080f84e6e26a8dd00f';
+
+// ββ gate ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+async function sha256hex(s){
+ const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(s));
+ return [...new Uint8Array(buf)].map(b=>b.toString(16).padStart(2,'0')).join('');
+}
+async function tryUnlock(){
+ const v = document.getElementById('gate-input').value || '';
+ const msg = document.getElementById('gate-msg');
+ if(!v){ msg.textContent='Enter the passphrase.'; return; }
+ let h;
+ try { h = await sha256hex(v); }
+ catch(e){ msg.textContent='This page must be served over HTTPS (or localhost) to unlock.'; return; }
+ if(h === ADMIN_PASSPHRASE_SHA256){ sessionStorage.setItem('wp_admin_ok','1'); reveal(); }
+ else { msg.textContent='Incorrect passphrase.'; }
+}
+function reveal(){
+ document.getElementById('admin-gate').style.display='none';
+ document.getElementById('admin-main').style.display='';
+ checkHealth();
+}
+function lock(){ sessionStorage.removeItem('wp_admin_ok'); location.reload(); }
+
+// ββ api helper ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+async function api(method, path, body){
+ const opt = { method, headers:{ 'Accept':'application/json' } };
+ if(body !== undefined){ opt.headers['Content-Type']='application/json'; opt.body=JSON.stringify(body); }
+ try {
+ const r = await fetch(path, opt);
+ const t = await r.text();
+ let json; try { json = t ? JSON.parse(t) : null; } catch(_){ json = t; }
+ return { status:r.status, json };
+ } catch(e){ return { status:0, json:String(e) }; }
+}
+
+// ββ connectivity ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+async function checkHealth(){
+ const b = document.getElementById('health-banner');
+ b.className='banner'; b.textContent='Checkingβ¦';
+ const { status, json } = await api('GET','/api/health');
+ if(status===200 && json && json.ok){
+ b.className='banner ok'; b.textContent='β API reachable β /api/health returned ok.';
+ } else if(status===404){
+ b.className='banner bad'; b.textContent='β /api/ returns 404 β the reverse proxy is not routing /api/ to the API. The site loads but the API is unreachable from the browser.';
+ } else if(status===0){
+ b.className='banner bad'; b.textContent='β Could not reach the server: '+json;
+ } else {
+ b.className='banner bad'; b.textContent='β Unexpected response: HTTP '+status;
+ }
+}
+
+// ββ db snapshot βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+async function snapshot(){
+ const out = document.getElementById('snapshot-out'); out.textContent='Loadingβ¦';
+ const [p,s,w,c] = await Promise.all([
+ api('GET','/api/projects'), api('GET','/api/sops'),
+ api('GET','/api/wps'), api('GET','/api/comments')]);
+ if(p.status!==200){
+ out.innerHTML = `
API not reachable (HTTP ${p.status}). Fix /api/ routing first.
`; return;
+ }
+ const n = r => Array.isArray(r.json) ? r.json.length : ('err '+r.status);
+ out.innerHTML = `
+
Projects
${n(p)}
+
SOPs
${n(s)}
+
Work Packages
${n(w)}
+
Comments
${n(c)}
`;
+}
+
+// ββ smoke test ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+function smLog(html){ const o=document.getElementById('smoke-out'); o.innerHTML += html + '\n'; o.scrollTop=o.scrollHeight; }
+async function runSmokeTest(){
+ const o=document.getElementById('smoke-out'); o.innerHTML=''; let pass=0, fail=0, pid=null;
+ const chk=(name,cond,detail)=>{ if(cond){ pass++; smLog('PASS '+name); }
+ else { fail++; smLog('FAIL '+name+(detail?' ('+detail+')':'')); } return cond; };
+ try {
+ let r = await api('GET','/api/health');
+ if(!chk('health endpoint ok', r.status===200 && r.json && r.json.ok, 'status '+r.status)){
+ smLog('\nAborting β API unreachable (fix /api/ routing).'); return finishSmoke(pass,fail);
+ }
+ r = await api('POST','/api/projects',{name:'ZZ Smoke Test Project',number:'SMOKE-001',client:'Internal QA',created_by:'admin-console'});
+ pid = r.json && r.json.id; chk('create project', r.status===200 && !!pid, 'status '+r.status);
+ r = await api('GET','/api/projects/'+pid); chk('fetch project by id', r.status===200 && r.json.number==='SMOKE-001');
+ r = await api('GET','/api/projects'); chk('project in list', r.status===200 && r.json.some(p=>p.id===pid));
+ r = await api('POST','/api/sops',{project_id:pid,name:'ZZ Smoke SOP',number:'SMOKE-001',complete:true,data:{governance:{disciplines:['Mechanical','Electrical','Tech']}}});
+ const sid = r.json && r.json.id; chk('create SOP linked to project', r.status===200 && !!sid && r.json.project_id===pid);
+ r = await api('GET','/api/sops/latest?project_id='+pid); chk('latest SOP resolves', r.status===200 && r.json.id===sid);
+ r = await api('POST','/api/wps',{project_id:pid,sop_id:sid,number:'WP01-SMOKE',subject:'Smoke test package',type:'Conduit Install',status:'Scheduled',data:{disciplines:['Electrical'],hours:'40',constraints:[{name:'Materials',status:'open',comment:'awaiting delivery'},{name:'Safety',status:'cleared',comment:''}]}});
+ const wid = r.json && r.json.id; chk('create work package', r.status===200 && !!wid);
+ r = await api('POST','/api/wps/'+wid+'/issue'); chk('issue blocked while a constraint is open (409)', r.status===409, 'status '+r.status);
+ await api('POST','/api/wps',{id:wid,project_id:pid,sop_id:sid,number:'WP01-SMOKE',subject:'Smoke test package',type:'Conduit Install',status:'Scheduled',data:{disciplines:['Electrical'],hours:'40',constraints:[{name:'Materials',status:'cleared',comment:''},{name:'Safety',status:'cleared',comment:''}]}});
+ r = await api('POST','/api/wps/'+wid+'/issue'); chk('issue succeeds once cleared', r.status===200 && r.json.status==='Issued', 'status '+r.status);
+ chk('issued_at timestamp set', !!(r.json && r.json.issued_at));
+ r = await api('POST','/api/wps/'+wid+'/status',{status:'In Progress'}); chk('status transition', r.status===200 && r.json.status==='In Progress');
+ r = await api('GET','/api/wps/metrics?project_id='+pid); chk('metrics aggregate', r.status===200 && r.json && r.json.total>=1, JSON.stringify(r.json));
+ r = await api('POST','/api/feedback',{type:'wp_review_comment',name:'admin-console',wp_id:wid,text:'SMOKE TEST comment β safe to delete'}); chk('post comment', r.status===200 && !!(r.json && r.json.id));
+ r = await api('GET','/api/wps?project_id='+pid); chk('list WPs by project', r.status===200 && r.json.some(w=>w.id===wid));
+ } catch(e){ chk('unexpected error', false, String(e)); }
+ finally {
+ if(pid){ const r=await api('DELETE','/api/projects/'+pid); chk('cleanup β delete project (cascades SOP+WPs)', r.status===200, 'status '+r.status); }
+ finishSmoke(pass,fail);
+ }
+}
+function finishSmoke(pass,fail){
+ const total=pass+fail;
+ smLog('\n'+pass+'/'+total+' checks passed.');
+ smLog(fail ? 'RESULT: FAIL ('+fail+')' : 'RESULT: ALL PASS β API, Python logic, and SQL are working.');
+}
+
+// ββ demo data βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+function demoLog(s){ const o=document.getElementById('demo-out'); o.innerHTML += s + '\n'; o.scrollTop=o.scrollHeight; }
+function stdConstraints(open){ return ['Safety & Permitting','Quality Control / Inspection','IFC Drawings & Specs','Schedule','Materials (on site, bagged & tagged)']
+ .map(n=>({name:n, status:(open&&open.includes(n))?'open':'cleared', comment:''})); }
+async function seedDemo(){
+ const o=document.getElementById('demo-out'); o.innerHTML='';
+ let r = await api('GET','/api/health');
+ if(!(r.status===200 && r.json && r.json.ok)){ demoLog('β API unreachable β fix /api/ routing first.'); return; }
+ r = await api('POST','/api/projects',{name:'DEMO β Micron INC (test data)',number:'DEMO-001',client:'Micron Technology, Inc.',division:'Semiconductor',site:'Boise, ID β Fab',created_by:'admin-console'});
+ if(r.status!==200){ demoLog('β create project failed (HTTP '+r.status+')'); return; }
+ const pid=r.json.id; demoLog('Project created: '+r.json.name);
+ r = await api('POST','/api/sops',{project_id:pid,name:'DEMO SOP',number:'DEMO-001',complete:true,data:{governance:{woFormat:'WP##-[Sector]-[TYPE]',disciplines:['Mechanical','Electrical','Tech'],discMode:'choice',instanceSuffix:'letter',woSize:'Standard β 3β5 days (β40β80 hrs)',sizeHoursMax:'80'}}});
+ const sid=r.json && r.json.id; demoLog('SOP created (complete).');
+ const mk=async(num,subj,typ,status,data,parent)=>{ const body={project_id:pid,sop_id:sid,number:num,subject:subj,type:typ,status,created_by:'admin-console',data}; if(parent)body.parent_id=parent; const rr=await api('POST','/api/wps',body); demoLog(' WP '+num+' ['+status+']'); return rr.json; };
+ await mk('WP01-1P-CONDUIT','1P horn/strobe conduit','Conduit Install','Issued',{disciplines:['Electrical'],hours:'40',constraints:stdConstraints(),due:'2026-06-30'});
+ await mk('WP02-1P-WIRE','1P wire pull','Wire Pull','Scheduled',{disciplines:['Electrical'],hours:'60',constraints:stdConstraints(['Materials (on site, bagged & tagged)']),due:'2026-07-04'});
+ const masterId='wp_demo_master_chiller';
+ const kids=[['WP03-CHILLER_Mech','Mechanical','A','Mechanical Install','In Progress'],['WP03-CHILLER_Elec','Electrical','B','Wire Pull','Scheduled'],['WP03-CHILLER_Tech','Tech','C','Terminations','Draft']];
+ const kidIds=[];
+ for(const [num,disc,label,typ,status] of kids){ const id='wp_demo_'+label.toLowerCase(); kidIds.push(id);
+ await api('POST','/api/wps',{id,project_id:pid,sop_id:sid,parent_id:masterId,number:num,subject:'Chiller skid β '+disc,type:typ,status,created_by:'admin-console',data:{disciplines:[disc],instanceOf:masterId,instanceLabel:label,parentNumber:'WP03-CHILLER',hours:'50',constraints:stdConstraints(),due:'2026-07-10'}});
+ demoLog(' WP '+num+' ['+status+'] (instance '+label+')'); }
+ await api('POST','/api/wps',{id:masterId,project_id:pid,sop_id:sid,number:'WP03-CHILLER',subject:'Chiller skid (multi-discipline master)',type:'Mechanical Install',status:'Scheduled',created_by:'admin-console',data:{disciplines:['Mechanical','Electrical','Tech'],split:true,children:kidIds,hours:'150',constraints:stdConstraints(),due:'2026-07-10'}});
+ demoLog(' WP WP03-CHILLER [master, split into A/B/C]');
+ await mk('WP04-2P-TERM','2P terminations','Terminations','In Progress',{disciplines:['Tech'],hours:'30',actualHrs:'20',constraints:stdConstraints(),due:'2026-06-10'});
+ await mk('WP05-3P-PANEL','3P panel install','Panel Install','Draft',{disciplines:['Electrical'],hours:'120',constraints:stdConstraints(['Schedule']),due:'2026-07-20'});
+ r = await api('GET','/api/wps/metrics?project_id='+pid);
+ demoLog('\nMetrics (masters excluded): '+JSON.stringify(r.json));
+ demoLog('\nβ Done β "DEMO β Micron INC (test data)" now appears in the home picker.');
+ snapshot();
+}
+async function cleanDemo(){
+ if(!confirm('Delete ALL projects whose number starts with DEMO- or SMOKE- (and their SOPs/WPs via cascade)?')) return;
+ const o=document.getElementById('demo-out'); o.innerHTML='';
+ const r = await api('GET','/api/projects');
+ if(r.status!==200){ demoLog('β API unreachable (HTTP '+r.status+').'); return; }
+ const targets=(r.json||[]).filter(p=>/^(DEMO-|SMOKE-)/.test(String(p.number||'')));
+ if(!targets.length){ demoLog('Nothing to remove.'); return; }
+ for(const p of targets){ await api('DELETE','/api/projects/'+p.id); demoLog('Deleted: '+p.name+' ('+p.number+')'); }
+ demoLog('\nβ Removed '+targets.length+' project(s).');
+ snapshot();
+}
+
+// reveal immediately if already unlocked this session
+if(sessionStorage.getItem('wp_admin_ok')==='1'){ reveal(); }
+else { const i=document.getElementById('gate-input'); if(i) i.focus(); }