Spaces:
Running
Running
deploy 412dabf8
Browse files- Dockerfile +9 -0
- README.md +17 -4
- app/main.py +396 -0
- app/static/app.js +226 -0
- app/static/index.html +166 -0
- 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:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
pinned: false
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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/<slug></code>, with access guides under <code>docs/<id></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
|