okosti commited on
Commit
42ee5bf
·
verified ·
1 Parent(s): 600c724

deploy 412dabf8

Browse files
Files changed (6) hide show
  1. Dockerfile +9 -0
  2. README.md +17 -4
  3. app/main.py +396 -0
  4. app/static/app.js +226 -0
  5. app/static/index.html +166 -0
  6. requirements.txt +6 -0
Dockerfile ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.12-slim
2
+ RUN useradd -m -u 1000 user
3
+ WORKDIR /app
4
+ COPY requirements.txt .
5
+ RUN pip install --no-cache-dir -r requirements.txt
6
+ COPY --chown=user app/ /app/
7
+ USER user
8
+ EXPOSE 7860
9
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860", "--proxy-headers", "--forwarded-allow-ips", "*"]
README.md CHANGED
@@ -1,10 +1,23 @@
1
  ---
2
  title: Data Experiments
3
- emoji: 😻
4
- colorFrom: indigo
5
- colorTo: gray
6
  sdk: docker
 
 
 
 
7
  pinned: false
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: Data Experiments
3
+ emoji: 🧪
4
+ colorFrom: blue
5
+ colorTo: indigo
6
  sdk: docker
7
+ app_port: 7860
8
+ hf_oauth: true
9
+ hf_oauth_scopes:
10
+ - email
11
  pinned: false
12
+ short_description: Extrian's catalog of data experiments (sign-in required)
13
  ---
14
 
15
+ # Data Experiments
16
+
17
+ The Extrian team's catalog of data projects: what each one is, where it stands, the pilot to listen to, and how to get
18
+ the data. Open it at **https://extrian-data-experiments.hf.space** (signing in doesn't work inside the embedded view on
19
+ huggingface.co).
20
+
21
+ Access needs "Sign in with Hugging Face" and a place on the allow list. This repository holds only the page and the
22
+ server code: no data and no secrets. The project records live in private storage and are served only to signed-in,
23
+ allow-listed viewers.
app/main.py ADDED
@@ -0,0 +1,396 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Data Experiments dashboard, served from a Hugging Face Space.
2
+
3
+ The page is a static shell (static/index.html + static/app.js) that holds no data. Everything it shows comes from
4
+ /api/*, and every /api route needs a signed-in, allow-listed viewer:
5
+
6
+ sign-in "Sign in with Hugging Face" (the Space's OAuth app: hf_oauth in README.md). Server-side authorization-code
7
+ flow with PKCE and a signed, 10-minute state cookie; the callback trades the code for a token and reads
8
+ /oauth/userinfo server to server. The viewer then gets a signed HttpOnly session cookie (24 h).
9
+ allow experiments/_access.json in R2 (edited with `python dataops/experiments_db.py access ...`), re-read at most
10
+ every ACCESS_TTL seconds, checked on EVERY request, so a removal takes effect within a minute:
11
+ an allowed HF username, membership of an allowed HF org, an allowed email, or an email in an allowed
12
+ domain. Emails only count when Hugging Face reports them verified.
13
+ data JSON records under experiments/ (projects/, docs/, sft/, _access.json), the same ones sessions write with
14
+ dataops/experiments_db.py. SFT marks are written here, recording who marked them. Two interchangeable
15
+ stores, picked by DATA_BACKEND:
16
+ r2 (default) bucket ops-dash; R2_ACCOUNT_ID / R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY
17
+ hf a private Hugging Face dataset repo, same paths; DATA_REPO (default Extrian/data-experiments-db)
18
+ and HF_DATA_TOKEN (read + write on that one repo)
19
+
20
+ Settings come from the environment: OAUTH_CLIENT_ID / OAUTH_CLIENT_SECRET / OAUTH_SCOPES / SPACE_HOST are set by the
21
+ Space; the store's credentials are Space secrets. SESSION_SECRET (signs the cookies) is optional: without it a random one
22
+ is made at startup, so everyone is signed out whenever the Space restarts. The R2 key only ever touches
23
+ ops-dash/experiments/ (every key is checked), so a key limited to bucket ops-dash is enough.
24
+ """
25
+ import base64, hashlib, html, json, os, re, secrets, threading, time
26
+ from concurrent.futures import ThreadPoolExecutor
27
+ from datetime import datetime, timezone
28
+ from pathlib import Path
29
+ from urllib.parse import urlencode
30
+
31
+ import boto3, botocore, httpx
32
+ from fastapi import FastAPI, Request
33
+ from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse
34
+ from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
35
+
36
+ HF = "https://huggingface.co"
37
+ BUCKET, PREFIX = "ops-dash", "experiments/"
38
+ ACCESS_KEY = PREFIX + "_access.json"
39
+ ID_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{0,99}$")
40
+ SESSION_COOKIE, STATE_COOKIE = "dx_session", "dx_oauth"
41
+ SESSION_TTL, STATE_TTL, ACCESS_TTL, ACCESS_STALE_MAX = 24 * 3600, 600, 60, 600
42
+ STATIC = Path(__file__).resolve().parent / "static"
43
+
44
+ CSP = ("default-src 'self'; script-src 'self' https://cdnjs.cloudflare.com; "
45
+ "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; "
46
+ "img-src 'self' data:; connect-src 'self'; frame-ancestors https://huggingface.co; base-uri 'none'; "
47
+ "form-action 'self' https://huggingface.co")
48
+
49
+
50
+ def env(name, default=None):
51
+ v = os.environ.get(name, default)
52
+ if not v:
53
+ raise RuntimeError(f"{name} is not set")
54
+ return v
55
+
56
+
57
+ def public_origin():
58
+ return "https://" + env("SPACE_HOST").strip().rstrip("/")
59
+
60
+
61
+ _SESSION_SECRET = os.environ.get("SESSION_SECRET") or ""
62
+ if not _SESSION_SECRET:
63
+ _SESSION_SECRET = secrets.token_urlsafe(48)
64
+ print("SESSION_SECRET is not set: using a random one for this run, so sign-ins reset whenever the Space restarts",
65
+ flush=True)
66
+
67
+
68
+ def signer(purpose):
69
+ return URLSafeTimedSerializer(_SESSION_SECRET, salt="dx-" + purpose)
70
+
71
+
72
+ def inside(key):
73
+ """Every store access stays under experiments/ (a narrow R2 key may not reach anything else)."""
74
+ if not key.startswith(PREFIX) or ".." in key:
75
+ raise ValueError(f"refusing to touch {key!r}: only {PREFIX} is used")
76
+ return key
77
+
78
+
79
+ def now():
80
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
81
+
82
+
83
+ # ------------------------------------------------------------------------------------------------ stores
84
+ class R2Store:
85
+ def __init__(self):
86
+ self.c = boto3.client("s3", endpoint_url=f"https://{env('R2_ACCOUNT_ID')}.r2.cloudflarestorage.com",
87
+ aws_access_key_id=env("R2_ACCESS_KEY_ID"), aws_secret_access_key=env("R2_SECRET_ACCESS_KEY"),
88
+ region_name="auto", config=botocore.config.Config(retries={"max_attempts": 3},
89
+ connect_timeout=5, read_timeout=15))
90
+
91
+ def get(self, key):
92
+ try:
93
+ return json.loads(self.c.get_object(Bucket=BUCKET, Key=inside(key))["Body"].read())
94
+ except botocore.exceptions.ClientError as e:
95
+ if e.response["Error"]["Code"] in ("NoSuchKey", "404"):
96
+ return None
97
+ raise
98
+
99
+ def keys(self, prefix):
100
+ out, kw = [], {"Bucket": BUCKET, "Prefix": inside(prefix)}
101
+ while True:
102
+ r = self.c.list_objects_v2(**kw)
103
+ out += [o["Key"] for o in r.get("Contents", [])]
104
+ if not r.get("IsTruncated"):
105
+ return out
106
+ kw["ContinuationToken"] = r["NextContinuationToken"]
107
+
108
+ def put(self, key, obj, note):
109
+ self.c.put_object(Bucket=BUCKET, Key=inside(key), Body=json.dumps(obj).encode(), ContentType="application/json; charset=utf-8")
110
+
111
+
112
+ class HFStore:
113
+ """A private Hugging Face dataset repo holding the same paths. Reads go to resolve/main with the token (fresh, no
114
+ local cache); every write is one commit."""
115
+
116
+ def __init__(self):
117
+ from huggingface_hub import HfApi
118
+ self.repo, self.token = env("DATA_REPO", "Extrian/data-experiments-db"), env("HF_DATA_TOKEN")
119
+ self.api = HfApi(token=self.token)
120
+ self.http = httpx.Client(timeout=15, headers={"Authorization": "Bearer " + self.token}, follow_redirects=True)
121
+
122
+ def get(self, key):
123
+ r = self.http.get(f"{HF}/datasets/{self.repo}/resolve/main/{inside(key)}")
124
+ if r.status_code == 404:
125
+ return None
126
+ r.raise_for_status()
127
+ return r.json()
128
+
129
+ def keys(self, prefix):
130
+ from huggingface_hub.errors import EntryNotFoundError
131
+ try:
132
+ items = self.api.list_repo_tree(self.repo, path_in_repo=inside(prefix).rstrip("/"), repo_type="dataset")
133
+ return [i.path for i in items if getattr(i, "size", None) is not None]
134
+ except EntryNotFoundError:
135
+ return []
136
+
137
+ def put(self, key, obj, note):
138
+ self.api.upload_file(path_or_fileobj=json.dumps(obj).encode(), path_in_repo=inside(key), repo_id=self.repo,
139
+ repo_type="dataset", commit_message=note)
140
+
141
+
142
+ _store = None
143
+
144
+
145
+ def store():
146
+ global _store
147
+ if _store is None:
148
+ kind = os.environ.get("DATA_BACKEND", "r2").strip().lower()
149
+ if kind not in ("r2", "hf"):
150
+ raise RuntimeError(f"DATA_BACKEND must be r2 or hf, got {kind!r}")
151
+ _store = R2Store() if kind == "r2" else HFStore()
152
+ return _store
153
+
154
+
155
+ def r2_get(key): # kept under this name: the tests patch it
156
+ return store().get(key)
157
+
158
+
159
+ def r2_read_all(coll):
160
+ prefix = f"{PREFIX}{coll}/"
161
+ keys = [k for k in store().keys(prefix) if k.endswith(".json") and "/" not in k[len(prefix):]]
162
+ with ThreadPoolExecutor(16) as ex:
163
+ vals = list(ex.map(r2_get, keys))
164
+ return {k[len(prefix):-5]: v for k, v in zip(keys, vals) if v is not None}
165
+
166
+
167
+ # ------------------------------------------------------------------------------------------------ access list
168
+ _acl = {"data": None, "at": 0.0}
169
+ _acl_lock = threading.Lock()
170
+
171
+
172
+ def access_list():
173
+ """The allow list, re-read at most every ACCESS_TTL s. If R2 is unreachable the last good copy is used for up to
174
+ ACCESS_STALE_MAX s; past that, or with no copy at all, nobody gets in (fail closed)."""
175
+ with _acl_lock:
176
+ if _acl["data"] is not None and time.time() - _acl["at"] < ACCESS_TTL:
177
+ return _acl["data"]
178
+ try:
179
+ _acl["data"], _acl["at"] = r2_get(ACCESS_KEY) or {}, time.time()
180
+ except Exception: # noqa: BLE001
181
+ if _acl["data"] is None or time.time() - _acl["at"] > ACCESS_STALE_MAX:
182
+ raise
183
+ return _acl["data"]
184
+
185
+
186
+ def allowed(ident, acl):
187
+ """Why this viewer may see the page ("hf_user", "hf_org", "email", "domain"), or None."""
188
+ low = lambda xs: {str(x).strip().lower().lstrip("@") for x in xs or []}
189
+ user = str(ident.get("u") or "").lower()
190
+ if user and user in low(acl.get("hf_users")):
191
+ return "hf_user"
192
+ if low(ident.get("orgs")) & low(acl.get("hf_orgs")):
193
+ return "hf_org"
194
+ email = str(ident.get("e") or "").strip().lower()
195
+ if email and ident.get("ev") is True and email.count("@") == 1:
196
+ if email in low(acl.get("emails")):
197
+ return "email"
198
+ if email.split("@")[1] in low(acl.get("domains")):
199
+ return "domain"
200
+ return None
201
+
202
+
203
+ def identity_from_userinfo(info):
204
+ orgs = [o.get("preferred_username") or o.get("name") for o in info.get("orgs") or [] if isinstance(o, dict)]
205
+ return {"u": info.get("preferred_username") or "", "sub": str(info.get("sub") or ""),
206
+ "e": (info.get("email") or "").strip().lower(), "ev": info.get("email_verified") is True,
207
+ "orgs": [o for o in orgs if o], "iat": int(time.time())}
208
+
209
+
210
+ def viewer(request):
211
+ """(identity, None) for an allowed viewer, else (None, response)."""
212
+ raw = request.cookies.get(SESSION_COOKIE)
213
+ if not raw:
214
+ return None, JSONResponse({"error": "sign in", "login": "/login"}, 401)
215
+ try:
216
+ ident = signer("session").loads(raw, max_age=SESSION_TTL)
217
+ except SignatureExpired:
218
+ return None, JSONResponse({"error": "your sign-in expired", "login": "/login"}, 401)
219
+ except BadSignature:
220
+ return None, JSONResponse({"error": "sign in", "login": "/login"}, 401)
221
+ try:
222
+ why = allowed(ident, access_list())
223
+ except Exception: # noqa: BLE001
224
+ return None, JSONResponse({"error": "could not check the access list; try again shortly"}, 503)
225
+ if not why:
226
+ return None, JSONResponse({"error": "not on the access list", "who": label(ident)}, 403)
227
+ return ident, None
228
+
229
+
230
+ def label(ident):
231
+ return ident.get("e") if ident.get("ev") and ident.get("e") else "@" + (ident.get("u") or "?")
232
+
233
+
234
+ # ------------------------------------------------------------------------------------------------ app
235
+ app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
236
+
237
+
238
+ @app.middleware("http")
239
+ async def headers(request, call_next):
240
+ resp = await call_next(request)
241
+ resp.headers.setdefault("Cache-Control", "no-store")
242
+ resp.headers["X-Content-Type-Options"] = "nosniff"
243
+ resp.headers["Referrer-Policy"] = "no-referrer"
244
+ resp.headers["Content-Security-Policy"] = CSP
245
+ return resp
246
+
247
+
248
+ def cookie(resp, name, value, max_age, path="/"):
249
+ resp.set_cookie(name, value, max_age=max_age, path=path, secure=True, httponly=True, samesite="lax")
250
+
251
+
252
+ @app.get("/")
253
+ def index():
254
+ return FileResponse(STATIC / "index.html", media_type="text/html; charset=utf-8")
255
+
256
+
257
+ @app.get("/app.js")
258
+ def app_js():
259
+ return FileResponse(STATIC / "app.js", media_type="text/javascript; charset=utf-8")
260
+
261
+
262
+ @app.get("/login")
263
+ def login():
264
+ state, verifier = secrets.token_urlsafe(24), secrets.token_urlsafe(48)
265
+ challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
266
+ scopes = " ".join(dict.fromkeys(["openid", "profile", "email"] + os.environ.get("OAUTH_SCOPES", "").split()))
267
+ q = {"client_id": env("OAUTH_CLIENT_ID"), "redirect_uri": public_origin() + "/login/callback",
268
+ "response_type": "code", "scope": scopes, "state": state,
269
+ "code_challenge": challenge, "code_challenge_method": "S256"}
270
+ resp = RedirectResponse(f"{HF}/oauth/authorize?{urlencode(q)}", 302)
271
+ cookie(resp, STATE_COOKIE, signer("state").dumps({"s": state, "v": verifier}), STATE_TTL, path="/login")
272
+ return resp
273
+
274
+
275
+ def page(title, body, status=200):
276
+ return HTMLResponse(f"""<!doctype html><html><head><meta charset="utf-8"><meta name="viewport"
277
+ content="width=device-width,initial-scale=1"><title>{html.escape(title)}</title><style>body{{font:15px/1.55 system-ui,
278
+ sans-serif;margin:0;padding:48px 16px;background:#f3f5f8;color:#16202c}}main{{max-width:560px;margin:0 auto;
279
+ background:#fff;border:1px solid #dde2ea;border-radius:12px;padding:24px}}a{{color:#2a5bd7}}code{{background:#eef1f5;
280
+ padding:1px 5px;border-radius:4px}}@media (prefers-color-scheme:dark){{body{{background:#0f141b;color:#e4e9f1}}
281
+ main{{background:#161d27;border-color:#283241}}a{{color:#7ea6ff}}code{{background:#1d2633}}}}</style></head>
282
+ <body><main>{body}</main></body></html>""", status)
283
+
284
+
285
+ @app.get("/login/callback")
286
+ def callback(request: Request, code: str = "", state: str = "", error: str = ""):
287
+ raw = request.cookies.get(STATE_COOKIE)
288
+ try:
289
+ st = signer("state").loads(raw or "", max_age=STATE_TTL)
290
+ except (BadSignature, SignatureExpired):
291
+ st = None
292
+ if error or not st or not code or not state or not secrets.compare_digest(str(st.get("s")), state):
293
+ resp = page("Sign-in failed", "<h1>Sign-in didn't complete</h1><p>The sign-in link expired or came from "
294
+ "somewhere else. <a href='/login'>Try again</a>.</p>", 400)
295
+ resp.delete_cookie(STATE_COOKIE, path="/login")
296
+ return resp
297
+ try:
298
+ with httpx.Client(timeout=15) as h:
299
+ tok = h.post(f"{HF}/oauth/token", auth=(env("OAUTH_CLIENT_ID"), env("OAUTH_CLIENT_SECRET")),
300
+ data={"grant_type": "authorization_code", "code": code, "code_verifier": st["v"],
301
+ "redirect_uri": public_origin() + "/login/callback"})
302
+ tok.raise_for_status()
303
+ info = h.get(f"{HF}/oauth/userinfo", headers={"Authorization": "Bearer " + tok.json()["access_token"]})
304
+ info.raise_for_status()
305
+ info = info.json()
306
+ except Exception: # noqa: BLE001
307
+ return page("Sign-in failed", "<h1>Couldn't reach Hugging Face</h1><p><a href='/login'>Try again</a>.</p>", 502)
308
+ ident = identity_from_userinfo(info)
309
+ try:
310
+ why = allowed(ident, access_list())
311
+ except Exception: # noqa: BLE001
312
+ return page("Try again", "<h1>Couldn't check the access list</h1><p><a href='/login'>Try again</a> in a minute.</p>", 503)
313
+ if not why:
314
+ who = html.escape(label(ident))
315
+ mail = "" if ident.get("ev") or not ident.get("e") else (
316
+ "<p>Your Hugging Face email isn't verified, so it can't be used to let you in. Verify it in your Hugging Face "
317
+ "settings, or ask for your username to be added.</p>")
318
+ resp = page("No access yet", f"<h1>You're signed in, but not on the access list</h1><p>Signed in as "
319
+ f"<code>{who}</code> (Hugging Face user <code>{html.escape(ident.get('u') or '?')}</code>).</p>{mail}"
320
+ "<p>Ask the dashboard owner to add your email or Hugging Face username, then "
321
+ "<a href='/login'>sign in again</a>.</p>", 403)
322
+ resp.delete_cookie(STATE_COOKIE, path="/login")
323
+ return resp
324
+ resp = RedirectResponse("/", 302)
325
+ resp.delete_cookie(STATE_COOKIE, path="/login")
326
+ cookie(resp, SESSION_COOKIE, signer("session").dumps(ident), SESSION_TTL)
327
+ return resp
328
+
329
+
330
+ @app.get("/logout")
331
+ def logout():
332
+ resp = RedirectResponse("/", 302)
333
+ resp.delete_cookie(SESSION_COOKIE, path="/")
334
+ return resp
335
+
336
+
337
+ @app.get("/api/me")
338
+ def me(request: Request):
339
+ ident, err = viewer(request)
340
+ return err or {"me": label(ident), "user": ident.get("u")}
341
+
342
+
343
+ @app.get("/api/data")
344
+ def data(request: Request):
345
+ ident, err = viewer(request)
346
+ if err:
347
+ return err
348
+ with ThreadPoolExecutor(3) as ex:
349
+ projects, docs, sft = ex.map(r2_read_all, ("projects", "docs", "sft"))
350
+ index = {i: {"title": d.get("title"), "path": d.get("path"), "has_markdown": bool(d.get("markdown")),
351
+ "updated_at": d.get("updated_at")} for i, d in docs.items()}
352
+ return {"projects": projects, "docs": index, "sft": sft, "me": label(ident)}
353
+
354
+
355
+ @app.get("/api/doc/{doc_id}")
356
+ def doc(doc_id: str, request: Request):
357
+ ident, err = viewer(request)
358
+ if err:
359
+ return err
360
+ if not ID_RE.match(doc_id):
361
+ return JSONResponse({"error": "bad doc id"}, 400)
362
+ d = r2_get(f"{PREFIX}docs/{doc_id}.json")
363
+ return d if d is not None else JSONResponse({"error": "no such guide"}, 404)
364
+
365
+
366
+ async def sft_write(slug, request, done):
367
+ ident, err = viewer(request)
368
+ if err:
369
+ return err
370
+ # Writes must come from this page: same Origin and a custom header a cross-site form can't send.
371
+ if request.headers.get("origin") != public_origin() or request.headers.get("x-dashboard") != "1":
372
+ return JSONResponse({"error": "writes must come from the dashboard page"}, 403)
373
+ if not ID_RE.match(slug):
374
+ return JSONResponse({"error": "bad project id"}, 400)
375
+ if r2_get(f"{PREFIX}projects/{slug}.json") is None:
376
+ return JSONResponse({"error": "no such project"}, 404)
377
+ note = ""
378
+ if done:
379
+ try:
380
+ body = await request.json()
381
+ except Exception: # noqa: BLE001
382
+ return JSONResponse({"error": "send a JSON body"}, 400)
383
+ note = str((body or {}).get("note") or "")[:300]
384
+ mark = {"done": done, "by": label(ident), "by_hf": ident.get("u"), "at": now(), "note": note}
385
+ store().put(f"{PREFIX}sft/{slug}.json", mark, f"SFT {'mark' if done else 'undo'} {slug} by {mark['by']}")
386
+ return mark
387
+
388
+
389
+ @app.post("/api/sft/{slug}")
390
+ async def sft_mark(slug: str, request: Request):
391
+ return await sft_write(slug, request, True)
392
+
393
+
394
+ @app.delete("/api/sft/{slug}")
395
+ async def sft_undo(slug: str, request: Request):
396
+ return await sft_write(slug, request, False)
app/static/app.js ADDED
@@ -0,0 +1,226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Data Experiments page: rows, guides and SFT marks come from /api/* (the Space's server reading R2; every route needs
2
+ // a signed-in, allow-listed viewer). The page polls every 60 s and on focus, so session writes show up within a minute.
3
+ "use strict";
4
+ const STATUSES = [
5
+ ["prod_running", "Prod running"], ["prod_paused", "Prod paused"], ["pilot_ready", "Pilot ready"], ["building", "Building"],
6
+ ["prod_done", "Prod done"]];
7
+ const LABEL = Object.fromEntries(STATUSES);
8
+ const HINT = {
9
+ prod_running: "Full-size generation is running now.",
10
+ prod_paused: "Stopped before reaching its target; what it made so far is usable.",
11
+ pilot_ready: "A small sample is ready to listen to and judge before scaling up.",
12
+ building: "Code or scripts are being written; nothing to review yet.",
13
+ prod_done: "Finished; the final data is ready to use.",
14
+ sft: "Someone has marked it as already used in a training run." };
15
+ const $ = (id) => document.getElementById(id);
16
+ const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; };
17
+ let projects = [], docs = {}, sft = {}, me = null, filter = null, query = "";
18
+ let sftEditing = null, sftDraft = "", sftFocus = false, sftError = {}, loadedOnce = false;
19
+ const docCache = {};
20
+ try { filter = localStorage.getItem("dx-filter") || null; } catch (e) {}
21
+
22
+ function ago(iso) {
23
+ const t = Date.parse(iso); if (!t) return "";
24
+ const m = Math.round((Date.now() - t) / 60000);
25
+ if (m < 60) return m <= 1 ? "just now" : m + " min ago";
26
+ const h = Math.round(m / 60); if (h < 48) return h + " h ago";
27
+ return Math.round(h / 24) + " days ago";
28
+ }
29
+ const num = (x) => typeof x === "number" ? x.toLocaleString("en-US", { maximumFractionDigits: 1 }) : String(x ?? "");
30
+
31
+ function copyBtn(text) {
32
+ const b = el("button", null, "Copy"); b.type = "button";
33
+ b.addEventListener("click", async () => {
34
+ try { await navigator.clipboard.writeText(text); b.textContent = "Copied"; }
35
+ catch (e) { const r = document.createRange(); r.selectNodeContents(b.previousSibling); const s = getSelection(); s.removeAllRanges(); s.addRange(r); b.textContent = "Selected"; }
36
+ setTimeout(() => (b.textContent = "Copy"), 1400);
37
+ });
38
+ return b;
39
+ }
40
+
41
+ // Guides are written by sessions, so their markdown is untrusted: raw HTML in it is shown as text (never
42
+ // parsed), and the rendered result still goes through DOMPurify.
43
+ const escapeHtml = (s) => String(s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
44
+ if (window.marked) marked.use({ renderer: { html: (html) => escapeHtml(typeof html === "string" ? html : (html && html.text) || "") } });
45
+
46
+ function renderMarkdown(body, md) {
47
+ if (window.marked && window.DOMPurify) {
48
+ body.innerHTML = DOMPurify.sanitize(marked.parse(String(md)), { FORBID_TAGS: ["style", "form", "input", "iframe"] });
49
+ body.querySelectorAll("a[href]").forEach((a) => {
50
+ if (!/^https?:\/\//i.test(a.getAttribute("href") || "")) { a.removeAttribute("href"); return; }
51
+ a.target = "_blank"; a.rel = "noopener noreferrer";
52
+ });
53
+ } else { const pre = el("pre"); pre.textContent = String(md); body.append(pre); }
54
+ }
55
+
56
+ async function openDoc(id) {
57
+ const meta = docs[id];
58
+ $("rtitle").textContent = meta ? (meta.title || id) : id;
59
+ $("rpath").textContent = meta && meta.path ? meta.path : "";
60
+ const body = $("rbody"); body.textContent = "";
61
+ $("reader").hidden = false; body.scrollTop = 0; $("rclose").focus();
62
+ if (!meta) { body.append(el("p", null, "This guide hasn't been added to the page yet.")); return; }
63
+ if (!meta.has_markdown) { body.append(el("p", null, "Only the file path is recorded for this guide. Open it in the repo: " + (meta.path || ""))); return; }
64
+ body.append(el("p", "lede", "Loading…"));
65
+ try {
66
+ let d = docCache[id];
67
+ if (!d || d.updated_at !== meta.updated_at) {
68
+ const r = await fetch("/api/doc/" + encodeURIComponent(id), { credentials: "same-origin", cache: "no-store" });
69
+ if (!r.ok) throw new Error(r.status);
70
+ d = docCache[id] = await r.json();
71
+ }
72
+ body.textContent = ""; renderMarkdown(body, d.markdown || "");
73
+ } catch (e) { body.textContent = ""; body.append(el("p", null, "Couldn't load this guide. Reload the page (your sign-in may have expired).")); }
74
+ }
75
+ $("rclose").addEventListener("click", () => ($("reader").hidden = true));
76
+ $("reader").addEventListener("click", (e) => { if (e.target === $("reader")) $("reader").hidden = true; });
77
+ document.addEventListener("keydown", (e) => { if (e.key === "Escape") $("reader").hidden = true; });
78
+
79
+ async function saveSft(id, done, note) {
80
+ sftError[id] = "";
81
+ try {
82
+ const r = await fetch("/api/sft/" + encodeURIComponent(id), {
83
+ method: done ? "POST" : "DELETE", credentials: "same-origin", cache: "no-store",
84
+ headers: { "Content-Type": "application/json", "X-Dashboard": "1" },
85
+ body: done ? JSON.stringify({ note: note.slice(0, 300) }) : undefined });
86
+ if (!r.ok) throw new Error(r.status);
87
+ sft[id] = await r.json();
88
+ sftEditing = null; sftDraft = "";
89
+ } catch (e) { sftError[id] = "Couldn't save the mark. Reload the page (your sign-in may have expired) and try again."; }
90
+ render();
91
+ }
92
+ function sftRow(p) {
93
+ const m = sft[p.id]; const row = el("div", "sftrow");
94
+ const editable = !!me;
95
+ if (m && m.done) {
96
+ row.append(el("span", "sftbadge", "SFT done"));
97
+ const who = [m.by || "", m.at ? ago(m.at) : ""].filter(Boolean).join(" · ");
98
+ if (who) row.append(el("span", "who", who));
99
+ if (editable) { const u = el("button", null, "Undo"); u.type = "button"; u.setAttribute("aria-label", "Undo SFT done for " + (p.title || p.id)); u.addEventListener("click", () => saveSft(p.id, false, "")); row.append(u); }
100
+ if (m.note) row.append(el("span", "sftnote", m.note));
101
+ } else if (editable && sftEditing === p.id) {
102
+ const inp = el("input"); inp.id = "sft-note-" + p.id; inp.placeholder = "Note (optional): run name or result"; inp.maxLength = 300; inp.value = sftDraft;
103
+ inp.setAttribute("aria-label", "SFT note for " + (p.title || p.id));
104
+ inp.addEventListener("input", (e) => { sftDraft = e.target.value; });
105
+ const ok = el("button", null, "Save"); ok.type = "button"; ok.addEventListener("click", () => saveSft(p.id, true, inp.value.trim()));
106
+ const no = el("button", null, "Cancel"); no.type = "button"; no.addEventListener("click", () => { sftEditing = null; sftDraft = ""; render(); });
107
+ inp.addEventListener("keydown", (e) => { if (e.key === "Enter") ok.click(); if (e.key === "Escape") no.click(); });
108
+ row.append(inp, ok, no);
109
+ if (sftFocus) { sftFocus = false; setTimeout(() => inp.focus(), 0); }
110
+ } else if (editable) {
111
+ const b = el("button", null, "Mark SFT done"); b.type = "button"; b.setAttribute("aria-label", "Mark SFT done for " + (p.title || p.id));
112
+ b.addEventListener("click", () => { sftEditing = p.id; sftDraft = ""; sftFocus = true; render(); }); row.append(b);
113
+ }
114
+ if (sftError[p.id]) row.append(el("span", "err", sftError[p.id]));
115
+ return row.childNodes.length ? row : null;
116
+ }
117
+
118
+ function card(p) {
119
+ const c = el("article", "card"); c.id = "p-" + p.id;
120
+ const top = el("div", "top");
121
+ top.append(el("h3", null, p.title || p.id));
122
+ const pill = el("span", "pill " + (p.status || "")); pill.append(el("span", "dot"), document.createTextNode(LABEL[p.status] || p.status || "Unknown"));
123
+ top.append(pill); c.append(top);
124
+ if (p.description) c.append(el("p", "desc", p.description));
125
+ if (p.data_type) c.append(el("p", "type", p.data_type));
126
+ if (p.progress && p.progress.target) {
127
+ const pr = el("div", "prog"); const tr = el("div", "track"); const f = el("div", "fill");
128
+ f.style.width = Math.max(0, Math.min(100, 100 * (p.progress.done || 0) / p.progress.target)) + "%"; tr.append(f);
129
+ pr.append(tr, el("span", "lbl", `${num(p.progress.done)} / ${num(p.progress.target)} ${p.progress.unit || ""}`.trim()));
130
+ c.append(pr);
131
+ }
132
+ if (p.size) c.append(el("div", "size", p.size));
133
+ if (p.summary) c.append(el("p", null, p.summary));
134
+ const links = el("div", "links");
135
+ for (const l of p.links || []) {
136
+ if (!l || !l.url || !/^https:\/\//.test(l.url)) continue;
137
+ const a = el("a", null, l.label || "Open"); a.href = l.url; a.target = "_blank"; a.rel = "noopener noreferrer"; links.append(a);
138
+ }
139
+ for (const id of p.docs || []) {
140
+ const b = el("button", null, (docs[id] && docs[id].title) ? docs[id].title : id); b.type = "button";
141
+ b.addEventListener("click", () => openDoc(id)); links.append(b);
142
+ }
143
+ if (links.childNodes.length) c.append(links);
144
+ if (p.data_location) { const loc = el("div", "loc"); loc.append(el("code", null, p.data_location), copyBtn(p.data_location)); c.append(loc); }
145
+ const kv = el("dl", "kv");
146
+ for (const [k, v] of [["Next", p.next_step], ["Code", p.code]]) { if (v) { kv.append(el("dt", null, k), el("dd", null, v)); } }
147
+ if (kv.childNodes.length) c.append(kv);
148
+ const sr = sftRow(p); if (sr) c.append(sr);
149
+ const foot = [p.updated_at ? "Updated " + ago(p.updated_at) : "", p.updated_by ? "by " + p.updated_by : ""].filter(Boolean).join(" ");
150
+ if (foot) c.append(el("div", "foot", foot));
151
+ return c;
152
+ }
153
+
154
+ function render() {
155
+ const counts = Object.fromEntries(STATUSES.map(([k]) => [k, 0]));
156
+ projects.forEach((p) => { if (p.status in counts) counts[p.status]++; });
157
+ const strip = $("strip"); strip.textContent = "";
158
+ for (const [k, label] of STATUSES) {
159
+ const b = el("button", "stat " + k); b.type = "button"; b.setAttribute("aria-pressed", String(filter === k));
160
+ b.append(el("b", null, String(counts[k])), el("span", null, label), el("small", null, HINT[k]));
161
+ b.addEventListener("click", () => { filter = filter === k ? null : k; try { filter ? localStorage.setItem("dx-filter", filter) : localStorage.removeItem("dx-filter"); } catch (e) {} render(); });
162
+ strip.append(b);
163
+ }
164
+ const nSft = projects.filter((p) => sft[p.id] && sft[p.id].done).length;
165
+ const sb = el("button", "stat sft"); sb.type = "button"; sb.setAttribute("aria-pressed", String(filter === "sft"));
166
+ sb.append(el("b", null, String(nSft)), el("span", null, "SFT done"), el("small", null, HINT.sft));
167
+ sb.addEventListener("click", () => { filter = filter === "sft" ? null : "sft"; try { filter ? localStorage.setItem("dx-filter", filter) : localStorage.removeItem("dx-filter"); } catch (e) {} render(); });
168
+ strip.append(sb);
169
+ const q = query.trim().toLowerCase();
170
+ const inFilter = (p) => !filter || (filter === "sft" ? !!(sft[p.id] && sft[p.id].done) : p.status === filter);
171
+ const match = (p) => inFilter(p) && (!q || [p.title, p.description, p.data_type, p.summary, p.data_location, p.size, p.group]
172
+ .filter(Boolean).join(" ").toLowerCase().includes(q));
173
+ const host = $("sections"); host.textContent = "";
174
+ let shown = 0;
175
+ for (const [k, label] of STATUSES) {
176
+ const rows = projects.filter((p) => p.status === k && match(p)).sort((a, b) => (b.updated_at || "").localeCompare(a.updated_at || ""));
177
+ if (!rows.length) continue;
178
+ shown += rows.length;
179
+ const s = el("section"); const h = el("h2"); const pill = el("span", "pill " + k); pill.append(el("span", "dot"), document.createTextNode(label));
180
+ h.append(pill, el("span", "count", rows.length + (rows.length === 1 ? " project" : " projects"))); s.append(h);
181
+ const g = el("div", "grid"); rows.forEach((p) => g.append(card(p))); s.append(g); host.append(s);
182
+ }
183
+ const other = projects.filter((p) => !(p.status in counts) && match(p));
184
+ if (other.length) { const s = el("section"); s.append(el("h2", null, "Other")); const g = el("div", "grid"); other.forEach((p) => g.append(card(p))); s.append(g); host.append(s); shown += other.length; }
185
+ if (!shown) host.append(el("div", "empty", projects.length ? (filter === "sft" ? "No project is marked SFT done yet." : "No project matches this filter.") : (loadedOnce ? "No projects yet. Each session adds its own row; see the instructions below." : "Loading…")));
186
+ }
187
+
188
+ $("q").addEventListener("input", (e) => { query = e.target.value; render(); });
189
+
190
+ // Signed out (401) or not on the access list (403): show the sign-in card instead of the projects. Inside the
191
+ // huggingface.co iframe the browser blocks the sign-in cookie, so the button opens the dashboard in its own tab.
192
+ const framed = (() => { try { return window.top !== window.self; } catch (e) { return true; } })();
193
+ function showGate(status, body) {
194
+ $("gate").hidden = false; $("strip").hidden = true; $("sections").hidden = true;
195
+ document.querySelector(".bar").hidden = true;
196
+ const a = $("signin");
197
+ if (framed) { a.href = location.origin + "/"; a.target = "_blank"; a.rel = "noopener"; a.textContent = "Open the dashboard in its own tab";
198
+ $("gtext").textContent = "Signing in doesn't work inside this embedded view. Open the dashboard in its own tab, then sign in with Hugging Face."; }
199
+ if (status === 403) {
200
+ $("gtitle").textContent = "You're signed in, but not on the access list";
201
+ $("gtext").textContent = "Signed in as " + ((body && body.who) || "an unlisted account") + ". Ask the dashboard owner to add your email or Hugging Face username, then sign in again.";
202
+ $("signout").hidden = false;
203
+ }
204
+ }
205
+
206
+ async function load() {
207
+ try {
208
+ const r = await fetch("/api/data", { credentials: "same-origin", cache: "no-store" });
209
+ if (r.status === 401 || r.status === 403) { showGate(r.status, await r.json().catch(() => null)); return; }
210
+ if (!r.ok) throw new Error(r.status);
211
+ const d = await r.json();
212
+ projects = Object.entries(d.projects || {}).map(([id, p]) => Object.assign({ id }, p));
213
+ docs = d.docs || {}; sft = d.sft || {}; me = d.me || null; loadedOnce = true;
214
+ $("me").textContent = me ? "Signed in as " + me : ""; $("signout").hidden = !me;
215
+ $("gate").hidden = true; $("strip").hidden = false; $("sections").hidden = false; document.querySelector(".bar").hidden = false;
216
+ $("status").textContent = projects.length + " projects · refreshed " + new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
217
+ } catch (e) {
218
+ $("status").textContent = loadedOnce ? "Couldn't refresh. Reload the page if this persists (your sign-in may have expired)."
219
+ : "Couldn't load projects. Reload the page to sign in again.";
220
+ }
221
+ if (!sftEditing) render();
222
+ }
223
+ load();
224
+ setInterval(() => { if (!document.hidden) load(); }, 60000);
225
+ document.addEventListener("visibilitychange", () => { if (!document.hidden) load(); });
226
+ render();
app/static/index.html ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6
+ <title>Data Experiments</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap">
9
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/marked/12.0.2/marked.min.js" integrity="sha384-/TQbtLCAerC3jgaim+N78RZSDYV7ryeoBCVqTuzRrFec2akfBkHS7ACQ3PQhvMVi" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
10
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.1.6/purify.min.js" integrity="sha384-+VfUPEb0PdtChMwmBcBmykRMDd+v6D/oFmB3rZM/puCMDYcIvF968OimRh4KQY9a" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
11
+ <style>
12
+ /* Layout: a status strip, a filter bar, then one section per status with a grid of project cards; access guides open in a side reader. */
13
+ :root {
14
+ --bg: #f3f5f8; --panel: #ffffff; --ink: #16202c; --muted: #5d6a7b; --rule: #dde2ea; --accent: #2a5bd7;
15
+ --bld: #8a5a00; --bld-bg: #fff2d1; --plt: #6a3fc4; --plt-bg: #f0e9fd; --run: #1d64d8; --run-bg: #e3edff;
16
+ --don: #18794b; --don-bg: #e2f5ea; --code-bg: #eef1f5; --pau: #8b4a3c; --pau-bg: #fbe9e4;
17
+ --sft: #0b6e74; --sft-bg: #ddf3f2;
18
+ --sans: "IBM Plex Sans", system-ui, -apple-system, "Segoe UI", sans-serif;
19
+ --mono: "IBM Plex Mono", ui-monospace, Menlo, Consolas, monospace;
20
+ }
21
+ @media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) {
22
+ --bg: #0f141b; --panel: #161d27; --ink: #e4e9f1; --muted: #98a4b6; --rule: #283241; --accent: #7ea6ff;
23
+ --bld: #f0c35a; --bld-bg: #2d2412; --plt: #b99cf7; --plt-bg: #241b38; --run: #7fb0ff; --run-bg: #14233d;
24
+ --don: #6fd39d; --don-bg: #11281d; --code-bg: #1d2633; --pau: #f0a08c; --pau-bg: #2e1a15;
25
+ --sft: #5fd4d0; --sft-bg: #0f2a2a; color-scheme: dark } }
26
+ :root[data-theme="dark"] {
27
+ --bg: #0f141b; --panel: #161d27; --ink: #e4e9f1; --muted: #98a4b6; --rule: #283241; --accent: #7ea6ff;
28
+ --bld: #f0c35a; --bld-bg: #2d2412; --plt: #b99cf7; --plt-bg: #241b38; --run: #7fb0ff; --run-bg: #14233d;
29
+ --don: #6fd39d; --don-bg: #11281d; --code-bg: #1d2633; --pau: #f0a08c; --pau-bg: #2e1a15;
30
+ --sft: #5fd4d0; --sft-bg: #0f2a2a; color-scheme: dark }
31
+ body { background: var(--bg); color: var(--ink); font: 15px/1.5 var(--sans);
32
+ padding-left: max(16px, env(safe-area-inset-left, 0px)); padding-right: max(16px, env(safe-area-inset-right, 0px)); padding-block: 26px 56px; }
33
+ main { max-width: 1180px; margin: 0 auto; display: flex; flex-direction: column; gap: 26px; }
34
+ h1, h2, h3 { margin: 0; text-wrap: balance; font-weight: 600; }
35
+ h1 { font-size: 1.85rem; line-height: 1.15; letter-spacing: -0.01em; }
36
+ h2 { font-size: 1.05rem; display: flex; align-items: center; gap: 10px; }
37
+ h3 { font-size: 1.02rem; line-height: 1.3; }
38
+ p { margin: 0; max-width: 72ch; }
39
+ .lede { color: var(--muted); }
40
+ .strip { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 170px), 1fr)); gap: 10px; }
41
+ .stat { background: var(--panel); border: 1px solid var(--rule); border-radius: 10px; padding: 12px 14px; display: flex; flex-direction: column; gap: 2px;
42
+ cursor: pointer; text-align: left; font: inherit; color: inherit; }
43
+ .stat b { font: 600 1.6rem/1.1 var(--mono); font-variant-numeric: tabular-nums; }
44
+ .stat span { font-size: .78rem; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); }
45
+ .stat small { font-size: .78rem; line-height: 1.35; color: var(--muted); margin-top: 4px; }
46
+ .stat[aria-pressed="true"] { outline: 2px solid var(--accent); outline-offset: -1px; }
47
+ .stat.building b { color: var(--bld); } .stat.pilot_ready b { color: var(--plt); } .stat.prod_running b { color: var(--run); } .stat.prod_done b { color: var(--don); }
48
+ .stat.prod_paused b { color: var(--pau); } .stat.sft b { color: var(--sft); }
49
+ .bar { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
50
+ .bar input { font: inherit; color: var(--ink); background: var(--panel); border: 1px solid var(--rule); border-radius: 8px; padding: 7px 10px; min-width: 0; flex: 1 1 220px; max-width: 360px; }
51
+ .bar .note { color: var(--muted); font-size: .85rem; }
52
+ button:focus-visible, input:focus-visible, a:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
53
+ section { display: flex; flex-direction: column; gap: 12px; }
54
+ .count { font: 500 .8rem var(--mono); color: var(--muted); }
55
+ .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 340px), 1fr)); gap: 14px; }
56
+ .card { background: var(--panel); border: 1px solid var(--rule); border-radius: 12px; padding: 16px; display: flex; flex-direction: column; gap: 10px; min-width: 0; }
57
+ .top { display: flex; justify-content: space-between; align-items: flex-start; gap: 10px; }
58
+ .pill { flex: none; font: 600 .72rem var(--sans); text-transform: uppercase; letter-spacing: .06em; padding: 3px 9px; border-radius: 999px; white-space: nowrap; display: inline-flex; align-items: center; gap: 6px; }
59
+ .pill.building { background: var(--bld-bg); color: var(--bld); } .pill.pilot_ready { background: var(--plt-bg); color: var(--plt); }
60
+ .pill.prod_running { background: var(--run-bg); color: var(--run); } .pill.prod_done { background: var(--don-bg); color: var(--don); }
61
+ .pill.prod_paused { background: var(--pau-bg); color: var(--pau); }
62
+ .sftrow { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; font-size: .84rem; }
63
+ .sftbadge { font: 600 .74rem var(--sans); text-transform: uppercase; letter-spacing: .06em; padding: 3px 9px; border-radius: 999px; background: var(--sft-bg); color: var(--sft); }
64
+ .sftrow .who { color: var(--muted); }
65
+ .sftrow .sftnote { flex-basis: 100%; color: var(--ink); overflow-wrap: anywhere; }
66
+ .sftrow button { font: 500 .8rem var(--sans); color: var(--sft); background: transparent; border: 1px solid var(--rule); border-radius: 999px; padding: 3px 10px; cursor: pointer; }
67
+ .sftrow button:hover { border-color: var(--sft); }
68
+ .sftrow input { font: inherit; font-size: .85rem; color: var(--ink); background: var(--bg); border: 1px solid var(--rule); border-radius: 8px; padding: 5px 8px; flex: 1 1 200px; min-width: 0; }
69
+ .sftrow .err { flex-basis: 100%; color: var(--pau); }
70
+ .dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
71
+ .pill.prod_running .dot { animation: pulse 1.6s ease-in-out infinite; }
72
+ @keyframes pulse { 50% { opacity: .25; } }
73
+ @media (prefers-reduced-motion: reduce) { .pill.prod_running .dot { animation: none; } }
74
+ .desc { font-size: .95rem; line-height: 1.45; }
75
+ .type { color: var(--muted); font-size: .86rem; }
76
+ .size { font: 500 .9rem var(--mono); font-variant-numeric: tabular-nums; }
77
+ .prog { display: flex; flex-direction: column; gap: 4px; }
78
+ .track { height: 8px; background: var(--code-bg); border-radius: 999px; overflow: hidden; }
79
+ .fill { height: 100%; background: var(--run); border-radius: 999px; }
80
+ .prog .lbl { font: .8rem var(--mono); color: var(--muted); font-variant-numeric: tabular-nums; }
81
+ .links { display: flex; flex-wrap: wrap; gap: 8px; }
82
+ .links a, .links button { font: 500 .82rem var(--sans); color: var(--accent); background: transparent; border: 1px solid var(--rule); border-radius: 999px; padding: 4px 11px; text-decoration: none; cursor: pointer; }
83
+ .links a:hover, .links button:hover { border-color: var(--accent); }
84
+ .loc { display: flex; gap: 8px; align-items: center; min-width: 0; }
85
+ .loc code { font: .78rem var(--mono); background: var(--code-bg); padding: 3px 7px; border-radius: 6px; overflow-wrap: anywhere; min-width: 0; }
86
+ .loc button { flex: none; font: .75rem var(--sans); color: var(--muted); background: transparent; border: 1px solid var(--rule); border-radius: 6px; padding: 2px 8px; cursor: pointer; }
87
+ .kv { display: grid; grid-template-columns: auto 1fr; gap: 3px 10px; font-size: .86rem; }
88
+ .kv dt { color: var(--muted); } .kv dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
89
+ .foot { font-size: .78rem; color: var(--muted); border-top: 1px solid var(--rule); padding-top: 8px; }
90
+ .empty { background: var(--panel); border: 1px dashed var(--rule); border-radius: 12px; padding: 22px; color: var(--muted); }
91
+ .howto { background: var(--panel); border: 1px solid var(--rule); border-radius: 12px; padding: 16px; display: flex; flex-direction: column; gap: 8px; }
92
+ .howto code { font: .82rem var(--mono); background: var(--code-bg); padding: 1px 5px; border-radius: 4px; }
93
+ .reader { position: fixed; inset: 0; background: color-mix(in srgb, var(--ink) 35%, transparent); display: flex; justify-content: flex-end; z-index: 20; }
94
+ .reader[hidden] { display: none !important; }
95
+ .sheet { background: var(--panel); width: min(860px, 100%); height: 100%; display: flex; flex-direction: column; border-left: 1px solid var(--rule);
96
+ padding-top: env(safe-area-inset-top, 0px); padding-bottom: env(safe-area-inset-bottom, 0px); padding-right: env(safe-area-inset-right, 0px); }
97
+ .sheet header { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 14px 18px; border-bottom: 1px solid var(--rule); }
98
+ .sheet header .path { font: .78rem var(--mono); color: var(--muted); overflow-wrap: anywhere; }
99
+ .sheet header button { font: 500 .85rem var(--sans); color: var(--ink); background: var(--bg); border: 1px solid var(--rule); border-radius: 8px; padding: 6px 12px; cursor: pointer; flex: none; }
100
+ .md { overflow: auto; padding: 18px 22px 40px; line-height: 1.6; }
101
+ .md h1 { font-size: 1.4rem; margin: .2em 0 .6em; } .md h2 { font-size: 1.15rem; margin: 1.3em 0 .5em; display: block; } .md h3 { font-size: 1rem; margin: 1.1em 0 .4em; }
102
+ .md p, .md ul, .md ol { margin: .5em 0; max-width: 78ch; }
103
+ .md table { border-collapse: collapse; font-size: .88rem; display: block; overflow-x: auto; max-width: 100%; margin: .7em 0; }
104
+ .md th, .md td { border: 1px solid var(--rule); padding: 5px 9px; text-align: left; vertical-align: top; }
105
+ .md th { background: var(--code-bg); font-weight: 600; }
106
+ .md code { font: .85em var(--mono); background: var(--code-bg); padding: 1px 5px; border-radius: 4px; }
107
+ .md pre { background: var(--code-bg); padding: 12px 14px; border-radius: 8px; overflow-x: auto; }
108
+ .md pre code { background: none; padding: 0; }
109
+ .md a { color: var(--accent); }
110
+ .md blockquote { border-left: 3px solid var(--rule); margin: .6em 0; padding: .1em 0 .1em 12px; color: var(--muted); }
111
+
112
+ .who-am-i { color: var(--muted); font-size: .82rem; }
113
+ .who-am-i a { color: var(--accent); margin-left: 6px; }
114
+ .gate { border: 1px solid var(--rule); border-radius: 12px; background: var(--panel); padding: 20px 22px; display: flex; flex-direction: column; gap: 8px; max-width: 640px; }
115
+ .gate h2 { margin: 0; font-size: 1.15rem; }
116
+ .gate p { margin: 0; }
117
+ .gate a.signin { display: inline-block; font-weight: 600; color: var(--panel); background: var(--accent); text-decoration: none; padding: 8px 14px; border-radius: 9px; }
118
+ .build { font: .72rem var(--mono); color: var(--muted); }
119
+ </style>
120
+ </head>
121
+ <body>
122
+ <main>
123
+ <header style="display:flex;flex-direction:column;gap:8px">
124
+ <h1>Data Experiments</h1>
125
+ <p class="lede">Every data type we are building, piloting or producing: what it is, where it stands, the pilot to listen to, and how to get the production data. Sessions keep their own rows up to date.</p>
126
+ <span class="who-am-i"><span id="me"></span> <a id="signout" href="/logout" hidden>Sign out</a></span>
127
+ </header>
128
+
129
+ <div class="gate" id="gate" hidden>
130
+ <h2 id="gtitle">Sign in to see the projects</h2>
131
+ <p id="gtext">This dashboard is for the Extrian team and invited collaborators. Sign in with your Hugging Face account; a free one works.</p>
132
+ <p><a class="signin" id="signin" href="/login">Sign in with Hugging Face</a></p>
133
+ <p class="note" id="gnote">No access after signing in? Ask the dashboard owner to add your email or Hugging Face username.</p>
134
+ </div>
135
+
136
+ <div class="strip" id="strip"></div>
137
+
138
+ <div class="bar">
139
+ <input id="q" type="search" placeholder="Search titles, data types, locations" aria-label="Search projects">
140
+ <span class="note" id="status">Loading projects…</span>
141
+ </div>
142
+
143
+ <div id="sections" style="display:flex;flex-direction:column;gap:26px">
144
+ <div class="empty" id="empty">Projects will appear here as each session adds its row. Nothing has loaded yet.</div>
145
+ </div>
146
+
147
+ <div class="howto">
148
+ <h2>Add or update a project</h2>
149
+ <p>Each project is one JSON record under <code>projects/&lt;slug&gt;</code>, with access guides under <code>docs/&lt;id&gt;</code>. A session updates its own record with <code>python dataops/experiments_db.py</code>, and this page picks it up within a minute. Full instructions, fields and examples are in <code>DATA_EXPERIMENTS_DASHBOARD.md</code> at the root of the data-processing-tts repo.</p>
150
+ <p>Trained on a dataset? Press <b>Mark SFT done</b> on its card and add a note (the run name or result). Anyone signed in can mark or undo it; everyone sees who marked it and when.</p>
151
+ <span class="build">build 412dabf8</span>
152
+ </div>
153
+ </main>
154
+
155
+ <div class="reader" id="reader" hidden>
156
+ <div class="sheet" role="dialog" aria-modal="true" aria-labelledby="rtitle">
157
+ <header>
158
+ <div style="display:flex;flex-direction:column;gap:2px;min-width:0"><b id="rtitle"></b><span class="path" id="rpath"></span></div>
159
+ <button type="button" id="rclose">Close</button>
160
+ </header>
161
+ <div class="md" id="rbody"></div>
162
+ </div>
163
+ </div>
164
+ <script src="/app.js"></script>
165
+ </body>
166
+ </html>
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ fastapi==0.115.6
2
+ uvicorn==0.32.1
3
+ boto3==1.35.90
4
+ itsdangerous==2.2.0
5
+ httpx==0.28.1
6
+ huggingface_hub==0.34.4