feat: add deployment metadata display for teacher panel with versioning and commit info
Browse files- .env.example +7 -0
- app/main.py +34 -1
- static/index.html +25 -1
.env.example
CHANGED
|
@@ -27,3 +27,10 @@ SUPABASE_SECRET_KEY=
|
|
| 27 |
# right and the system scored wrong. A flag is kept in the Supabase table `flags` with its recording (see the README),
|
| 28 |
# so it needs SUPABASE_SECRET_KEY. These accounts may sign in without being in a class.
|
| 29 |
TUTORS_EMAIL=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
# right and the system scored wrong. A flag is kept in the Supabase table `flags` with its recording (see the README),
|
| 28 |
# so it needs SUPABASE_SECRET_KEY. These accounts may sign in without being in a class.
|
| 29 |
TUTORS_EMAIL=
|
| 30 |
+
|
| 31 |
+
# Optional deployment metadata shown to teachers at the bottom of the management panel.
|
| 32 |
+
# Without these, the app uses a content hash, the newest source-file time, and the local git commit when available.
|
| 33 |
+
APP_VERSION=
|
| 34 |
+
BUILD_DATE=
|
| 35 |
+
GIT_COMMIT=
|
| 36 |
+
GIT_COMMIT_MESSAGE=
|
app/main.py
CHANGED
|
@@ -14,6 +14,7 @@ import hashlib
|
|
| 14 |
import logging
|
| 15 |
import os
|
| 16 |
import re
|
|
|
|
| 17 |
import time
|
| 18 |
import urllib.parse
|
| 19 |
import uuid
|
|
@@ -248,6 +249,38 @@ def build_version() -> str:
|
|
| 248 |
return f"{h.hexdigest()[:7]} ยท {time.strftime('%Y-%m-%d %H:%M', time.gmtime(newest))} UTC"
|
| 249 |
|
| 250 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 251 |
def utc(stamp: str) -> datetime:
|
| 252 |
"""A Supabase timestamp. The seconds part only: the fraction's length varies, which Python 3.10 does not parse."""
|
| 253 |
return datetime.strptime(stamp[:19], "%Y-%m-%dT%H:%M:%S").replace(tzinfo=timezone.utc)
|
|
@@ -1114,7 +1147,7 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
|
|
| 1114 |
@app.get("/api/health")
|
| 1115 |
def health():
|
| 1116 |
s: Scorer = app.state.scorer
|
| 1117 |
-
return {"version": build_version(), "model": MODEL_ID, "tts": app.state.tts is not None,
|
| 1118 |
"references": app.state.refs is not None, "sign_in": app.state.auth is not None,
|
| 1119 |
"phoneme_fallbacks": s.vocab.downgraded}
|
| 1120 |
|
|
|
|
| 14 |
import logging
|
| 15 |
import os
|
| 16 |
import re
|
| 17 |
+
import subprocess
|
| 18 |
import time
|
| 19 |
import urllib.parse
|
| 20 |
import uuid
|
|
|
|
| 249 |
return f"{h.hexdigest()[:7]} ยท {time.strftime('%Y-%m-%d %H:%M', time.gmtime(newest))} UTC"
|
| 250 |
|
| 251 |
|
| 252 |
+
def build_metadata() -> dict[str, str]:
|
| 253 |
+
"""Deployment details for the teacher panel, with useful local-development fallbacks."""
|
| 254 |
+
files = sorted(Path(__file__).resolve().parent.glob("*.py")) + [STATIC / "index.html"]
|
| 255 |
+
h = hashlib.sha1()
|
| 256 |
+
for f in files:
|
| 257 |
+
h.update(f.read_bytes())
|
| 258 |
+
newest = max(f.stat().st_mtime for f in files)
|
| 259 |
+
commit = next((os.environ.get(name, "").strip() for name in
|
| 260 |
+
("GIT_COMMIT", "COMMIT_SHA", "SOURCE_VERSION", "SPACE_REVISION")
|
| 261 |
+
if os.environ.get(name, "").strip()), "")
|
| 262 |
+
message = os.environ.get("GIT_COMMIT_MESSAGE", "").strip()
|
| 263 |
+
if not commit or not message:
|
| 264 |
+
try:
|
| 265 |
+
git = subprocess.run(
|
| 266 |
+
["git", "log", "-1", "--format=%H%n%s"], cwd=STATIC.parent, check=True,
|
| 267 |
+
capture_output=True, text=True, timeout=2,
|
| 268 |
+
).stdout.splitlines()
|
| 269 |
+
if not commit and git:
|
| 270 |
+
commit = git[0].strip()
|
| 271 |
+
if not message and len(git) > 1:
|
| 272 |
+
message = git[1].strip()
|
| 273 |
+
except (OSError, subprocess.SubprocessError):
|
| 274 |
+
pass
|
| 275 |
+
return {
|
| 276 |
+
"version": os.environ.get("APP_VERSION", "").strip() or h.hexdigest()[:7],
|
| 277 |
+
"updated_at": os.environ.get("BUILD_DATE", "").strip()
|
| 278 |
+
or datetime.fromtimestamp(newest, timezone.utc).isoformat(timespec="seconds"),
|
| 279 |
+
"commit": commit or "unknown",
|
| 280 |
+
"commit_message": message,
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
|
| 284 |
def utc(stamp: str) -> datetime:
|
| 285 |
"""A Supabase timestamp. The seconds part only: the fraction's length varies, which Python 3.10 does not parse."""
|
| 286 |
return datetime.strptime(stamp[:19], "%Y-%m-%dT%H:%M:%S").replace(tzinfo=timezone.utc)
|
|
|
|
| 1147 |
@app.get("/api/health")
|
| 1148 |
def health():
|
| 1149 |
s: Scorer = app.state.scorer
|
| 1150 |
+
return {"version": build_version(), "build": build_metadata(), "model": MODEL_ID, "tts": app.state.tts is not None,
|
| 1151 |
"references": app.state.refs is not None, "sign_in": app.state.auth is not None,
|
| 1152 |
"phoneme_fallbacks": s.vocab.downgraded}
|
| 1153 |
|
static/index.html
CHANGED
|
@@ -308,6 +308,16 @@ body:not(.manage) #manage,body.manage #wrap>header,body.manage #path,body.manage
|
|
| 308 |
.mtitle{font:400 24px/1 var(--f-display);margin:0}
|
| 309 |
#mState.setup{padding:10px 12px;border:1px solid var(--sumac);border-radius:10px;color:var(--ink)}
|
| 310 |
#mState.setup code{display:block;margin-top:6px;font-size:12px;overflow-wrap:anywhere;user-select:all}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 311 |
.hard>.note{margin:0;padding:4px 16px 8px;color:var(--muted);font-size:14px}
|
| 312 |
/* inside a student, each part (the best sentences, the weakest, every lesson) opens and closes on its heading */
|
| 313 |
.sec>summary{cursor:pointer;list-style:none;display:grid;grid-template-columns:1fr auto 3em;gap:4px 10px;align-items:center;padding:8px 16px;border-top:1px solid var(--line);font:700 15px/1.4 var(--f-body)}
|
|
@@ -839,6 +849,7 @@ body.quiet :is(#mic,#playMine,#driveBtn,.phone-btn),body.driving :is(#quietBtn,#
|
|
| 839 |
<div class="card hard" id="mLessons" hidden></div>
|
| 840 |
<div class="card hard roster" id="mRoster" hidden></div>
|
| 841 |
<div id="mStudent"></div>
|
|
|
|
| 842 |
</section>
|
| 843 |
</div>
|
| 844 |
<dialog id="listDlg" aria-labelledby="listTitle">
|
|
@@ -2634,7 +2645,8 @@ async function loadManage(){
|
|
| 2634 |
const state=$("mState");
|
| 2635 |
state.textContent="ืืืขืโฆ"; state.classList.remove("setup");
|
| 2636 |
try{
|
| 2637 |
-
const res=await api("/api/class"); await ready;
|
|
|
|
| 2638 |
if(!res.ok){
|
| 2639 |
const detail=(await res.json().catch(()=>({}))).detail||res.status;
|
| 2640 |
state.textContent=res.status===403?"ืืคืื ื ืคืชืื ืืืืจืื ืืืื.":`ื ืชืื ื ืืชืืืืืื ืื ืืืื ืื ืืจืืข (${detail}).`; return;
|
|
@@ -2648,6 +2660,18 @@ async function loadManage(){
|
|
| 2648 |
const code=mark("",x.sql,"code"); code.dir="ltr"; state.append(mark("",x.what,"b"),mark(""," ืืจืืฆื ื-SQL Editor ืฉื Supabase:"),code); }
|
| 2649 |
drawManage();
|
| 2650 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2651 |
function drawManage(){
|
| 2652 |
const classes=CLASS.classes||[];
|
| 2653 |
if(shownClass!=="*"&&!classes.some(c=>c.name===shownClass)) shownClass="*";
|
|
|
|
| 308 |
.mtitle{font:400 24px/1 var(--f-display);margin:0}
|
| 309 |
#mState.setup{padding:10px 12px;border:1px solid var(--sumac);border-radius:10px;color:var(--ink)}
|
| 310 |
#mState.setup code{display:block;margin-top:6px;font-size:12px;overflow-wrap:anywhere;user-select:all}
|
| 311 |
+
.build-info{padding:14px 16px;margin-top:4px;font-variant-numeric:tabular-nums}
|
| 312 |
+
.build-info h2{margin:0;font:400 20px/1.2 var(--f-display)}
|
| 313 |
+
.build-info>p{margin:3px 0 12px;color:var(--muted);font-size:13px}
|
| 314 |
+
.build-info dl{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin:0}
|
| 315 |
+
.build-info dl>div{min-width:0;padding:9px 10px;border-radius:9px;background:var(--bg)}
|
| 316 |
+
.build-info dt{margin-bottom:3px;color:var(--muted);font-size:12px;font-weight:600}
|
| 317 |
+
.build-info dd{margin:0;color:var(--ink);font-size:14px;overflow-wrap:anywhere}
|
| 318 |
+
.build-info code{direction:ltr;unicode-bidi:isolate;user-select:all}
|
| 319 |
+
#manage:not(.flags) #mBuild{display:none}
|
| 320 |
+
@media(max-width:560px){.build-info dl{grid-template-columns:1fr}}
|
| 321 |
.hard>.note{margin:0;padding:4px 16px 8px;color:var(--muted);font-size:14px}
|
| 322 |
/* inside a student, each part (the best sentences, the weakest, every lesson) opens and closes on its heading */
|
| 323 |
.sec>summary{cursor:pointer;list-style:none;display:grid;grid-template-columns:1fr auto 3em;gap:4px 10px;align-items:center;padding:8px 16px;border-top:1px solid var(--line);font:700 15px/1.4 var(--f-body)}
|
|
|
|
| 849 |
<div class="card hard" id="mLessons" hidden></div>
|
| 850 |
<div class="card hard roster" id="mRoster" hidden></div>
|
| 851 |
<div id="mStudent"></div>
|
| 852 |
+
<section class="build-info card" id="mBuild" aria-labelledby="mBuildTitle" hidden></section>
|
| 853 |
</section>
|
| 854 |
</div>
|
| 855 |
<dialog id="listDlg" aria-labelledby="listTitle">
|
|
|
|
| 2645 |
const state=$("mState");
|
| 2646 |
state.textContent="ืืืขืโฆ"; state.classList.remove("setup");
|
| 2647 |
try{
|
| 2648 |
+
const [res,healthRes]=await Promise.all([api("/api/class"),fetch("/api/health").catch(()=>null)]); await ready;
|
| 2649 |
+
if(healthRes?.ok) drawBuild((await healthRes.json()).build);
|
| 2650 |
if(!res.ok){
|
| 2651 |
const detail=(await res.json().catch(()=>({}))).detail||res.status;
|
| 2652 |
state.textContent=res.status===403?"ืืคืื ื ืคืชืื ืืืืจืื ืืืื.":`ื ืชืื ื ืืชืืืืืื ืื ืืืื ืื ืืจืืข (${detail}).`; return;
|
|
|
|
| 2660 |
const code=mark("",x.sql,"code"); code.dir="ltr"; state.append(mark("",x.what,"b"),mark(""," ืืจืืฆื ื-SQL Editor ืฉื Supabase:"),code); }
|
| 2661 |
drawManage();
|
| 2662 |
}
|
| 2663 |
+
function drawBuild(info){
|
| 2664 |
+
const box=$("mBuild");
|
| 2665 |
+
if(!info) return;
|
| 2666 |
+
const updated=new Date(info.updated_at), date=Number.isNaN(updated.valueOf())?info.updated_at:
|
| 2667 |
+
new Intl.DateTimeFormat("he-IL",{dateStyle:"short",timeStyle:"short"}).format(updated);
|
| 2668 |
+
const list=document.createElement("dl");
|
| 2669 |
+
const item=(label,value,code=false)=>{ const row=document.createElement("div"), dt=document.createElement("dt"), dd=document.createElement("dd"), val=document.createElement(code?"code":"span"); dt.textContent=label; val.textContent=value||"ืื ืืืื"; dd.append(val); row.append(dt,dd); return row; };
|
| 2670 |
+
list.append(item("ืืกืคืจ ืืจืกื",info.version),item("ืขืืืื ืืืจืื",date),item("Commit ID",info.commit,true),item("ืืืืขืช commit",info.commit_message));
|
| 2671 |
+
const title=mark("","ืคืจืื ืืืขืจืืช","h2"); title.id="mBuildTitle";
|
| 2672 |
+
box.replaceChildren(title,mark("","ืืืืข ืืื ื ืืืืืื ืืืจืกื ืฉืคืืขืืช ืืขืช.","p"),list);
|
| 2673 |
+
box.hidden=false;
|
| 2674 |
+
}
|
| 2675 |
function drawManage(){
|
| 2676 |
const classes=CLASS.classes||[];
|
| 2677 |
if(shownClass!=="*"&&!classes.some(c=>c.name===shownClass)) shownClass="*";
|