soumi guria commited on
Commit
f9a2deb
Β·
1 Parent(s): 28d5087

fix: serve React dashboard from FastAPI; add /training-log endpoint

Browse files
Dockerfile CHANGED
@@ -1,16 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
  FROM python:3.11-slim
2
 
3
  WORKDIR /app
4
 
 
5
  COPY backend/requirements.txt .
6
  RUN pip install uv && uv pip install --system --no-cache -r requirements.txt
7
 
8
- COPY backend/ /app/backend/
9
- COPY server/ /app/server/
10
- COPY grader/ /app/grader/
11
- COPY models.py /app/models.py
12
- COPY inference.py /app/inference.py
13
- COPY openenv.yaml /app/openenv.yaml
 
 
 
 
14
 
15
  EXPOSE 7860
16
 
 
1
+ # ── Stage 1: Build React frontend ────────────────────────────────────────────
2
+ FROM node:20-slim AS frontend-builder
3
+
4
+ WORKDIR /frontend
5
+
6
+ COPY frontend/package.json frontend/package-lock.json ./
7
+ RUN npm ci
8
+
9
+ COPY frontend/ ./
10
+ RUN npm run build
11
+
12
+ # ── Stage 2: Python / FastAPI backend ─────────────────────────────────────────
13
  FROM python:3.11-slim
14
 
15
  WORKDIR /app
16
 
17
+ # Python dependencies
18
  COPY backend/requirements.txt .
19
  RUN pip install uv && uv pip install --system --no-cache -r requirements.txt
20
 
21
+ # Application code
22
+ COPY backend/ /app/backend/
23
+ COPY server/ /app/server/
24
+ COPY grader/ /app/grader/
25
+ COPY models.py /app/models.py
26
+ COPY inference.py /app/inference.py
27
+ COPY openenv.yaml /app/openenv.yaml
28
+
29
+ # Built React SPA – served by FastAPI at / (assets at /assets/*)
30
+ COPY --from=frontend-builder /frontend/dist /app/frontend/dist
31
 
32
  EXPOSE 7860
33
 
backend/main.py CHANGED
@@ -11,6 +11,7 @@ Endpoints (matching openenv.yaml contract):
11
 
12
  No openenv-core dependency β€” works on Python 3.9+.
13
  """
 
14
  import os
15
  import sys
16
  import uuid
@@ -18,6 +19,8 @@ from typing import Dict, Any, Optional, List
18
 
19
  from fastapi import FastAPI, HTTPException
20
  from fastapi.middleware.cors import CORSMiddleware
 
 
21
  from pydantic import BaseModel, Field
22
 
23
  sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
@@ -240,6 +243,43 @@ def build_app() -> FastAPI:
240
  @app.get("/grade/expert", tags=["Grader"])
241
  async def grade_expert(): return _run_grader_episode("expert")
242
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
243
  return app
244
 
245
 
 
11
 
12
  No openenv-core dependency β€” works on Python 3.9+.
13
  """
14
+ import json
15
  import os
16
  import sys
17
  import uuid
 
19
 
20
  from fastapi import FastAPI, HTTPException
21
  from fastapi.middleware.cors import CORSMiddleware
22
+ from fastapi.responses import FileResponse
23
+ from fastapi.staticfiles import StaticFiles
24
  from pydantic import BaseModel, Field
25
 
26
  sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
 
243
  @app.get("/grade/expert", tags=["Grader"])
244
  async def grade_expert(): return _run_grader_episode("expert")
245
 
246
+ # ------------------------------------------------------------------
247
+ # Training log β€” serves reward_curve.json if it exists
248
+ # ------------------------------------------------------------------
249
+ _ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
250
+ _REWARD_CURVE = os.path.join(_ROOT, "reward_curve.json")
251
+
252
+ @app.get("/training-log", tags=["Training"])
253
+ async def training_log():
254
+ if os.path.exists(_REWARD_CURVE):
255
+ with open(_REWARD_CURVE) as f:
256
+ return json.load(f)
257
+ return []
258
+
259
+ # ------------------------------------------------------------------
260
+ # React SPA β€” serve built frontend at / and /assets/*
261
+ # Only active when frontend/dist is present (i.e. inside Docker)
262
+ # ------------------------------------------------------------------
263
+ _DIST = os.path.join(_ROOT, "frontend", "dist")
264
+ _ASSETS = os.path.join(_DIST, "assets")
265
+
266
+ if os.path.isdir(_ASSETS):
267
+ app.mount("/assets", StaticFiles(directory=_ASSETS), name="assets")
268
+
269
+ if os.path.isdir(_DIST):
270
+ @app.get("/", include_in_schema=False)
271
+ async def serve_spa():
272
+ return FileResponse(os.path.join(_DIST, "index.html"))
273
+ else:
274
+ @app.get("/", tags=["System"])
275
+ async def api_root():
276
+ return {
277
+ "status": "ok",
278
+ "service": "Cognitive Load Manager β€” OpenEnv API",
279
+ "docs": "/docs",
280
+ "health": "/health",
281
+ }
282
+
283
  return app
284
 
285
 
backend/requirements.txt CHANGED
@@ -1,7 +1,8 @@
1
  fastapi
2
- uvicorn
3
  pydantic
4
  openai
5
  requests
6
  python-dotenv
7
  openenv-core>=0.2.0
 
 
1
  fastapi
2
+ uvicorn[standard]
3
  pydantic
4
  openai
5
  requests
6
  python-dotenv
7
  openenv-core>=0.2.0
8
+ aiofiles
frontend/dist/assets/index-C3o0olYq.js DELETED
The diff for this file is too large to render. See raw diff
 
frontend/dist/assets/index-CV2RR57m.css DELETED
@@ -1 +0,0 @@
1
- *,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.collapse{visibility:collapse}.sticky{position:sticky}.top-0{top:0}.top-6{top:1.5rem}.z-10{z-index:10}.mx-auto{margin-left:auto;margin-right:auto}.mb-1{margin-bottom:.25rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.ml-4{margin-left:1rem}.ml-auto{margin-left:auto}.mr-2{margin-right:.5rem}.mt-1{margin-top:.25rem}.mt-10{margin-top:2.5rem}.mt-3{margin-top:.75rem}.block{display:block}.inline-block{display:inline-block}.flex{display:flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-2{height:.5rem}.h-3{height:.75rem}.h-\[calc\(100vh-6rem\)\]{height:calc(100vh - 6rem)}.min-h-screen{min-height:100vh}.w-full{width:100%}.max-w-7xl{max-width:80rem}.flex-1{flex:1 1 0%}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}@keyframes spin{to{transform:rotate(360deg)}}.animate-spin{animation:spin 1s linear infinite}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.items-start{align-items:flex-start}.items-center{align-items:center}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-6{gap:1.5rem}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.75rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem * var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem * var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem * var(--tw-space-y-reverse))}.overflow-hidden{overflow:hidden}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-xl{border-radius:.75rem}.rounded-t-xl{border-top-left-radius:.75rem;border-top-right-radius:.75rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-emerald-500\/20{border-color:#10b98133}.border-emerald-500\/30{border-color:#10b9814d}.border-indigo-500\/30{border-color:#6366f14d}.border-indigo-500\/50{border-color:#6366f180}.border-red-500\/20{border-color:#ef444433}.border-slate-700{--tw-border-opacity: 1;border-color:rgb(51 65 85 / var(--tw-border-opacity, 1))}.border-slate-700\/50{border-color:#33415580}.border-slate-800{--tw-border-opacity: 1;border-color:rgb(30 41 59 / var(--tw-border-opacity, 1))}.bg-amber-500{--tw-bg-opacity: 1;background-color:rgb(245 158 11 / var(--tw-bg-opacity, 1))}.bg-amber-500\/20{background-color:#f59e0b33}.bg-emerald-500{--tw-bg-opacity: 1;background-color:rgb(16 185 129 / var(--tw-bg-opacity, 1))}.bg-emerald-500\/10{background-color:#10b9811a}.bg-emerald-500\/20{background-color:#10b98133}.bg-indigo-500{--tw-bg-opacity: 1;background-color:rgb(99 102 241 / var(--tw-bg-opacity, 1))}.bg-indigo-500\/10{background-color:#6366f11a}.bg-indigo-600{--tw-bg-opacity: 1;background-color:rgb(79 70 229 / var(--tw-bg-opacity, 1))}.bg-indigo-900\/40{background-color:#312e8166}.bg-red-500{--tw-bg-opacity: 1;background-color:rgb(239 68 68 / var(--tw-bg-opacity, 1))}.bg-red-500\/10{background-color:#ef44441a}.bg-red-500\/20{background-color:#ef444433}.bg-slate-700{--tw-bg-opacity: 1;background-color:rgb(51 65 85 / var(--tw-bg-opacity, 1))}.bg-slate-800{--tw-bg-opacity: 1;background-color:rgb(30 41 59 / var(--tw-bg-opacity, 1))}.bg-slate-800\/50{background-color:#1e293b80}.bg-slate-800\/80{background-color:#1e293bcc}.bg-slate-900{--tw-bg-opacity: 1;background-color:rgb(15 23 42 / var(--tw-bg-opacity, 1))}.bg-slate-900\/50{background-color:#0f172a80}.bg-gradient-to-r{background-image:linear-gradient(to right,var(--tw-gradient-stops))}.from-indigo-400{--tw-gradient-from: #818cf8 var(--tw-gradient-from-position);--tw-gradient-to: rgb(129 140 248 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.to-cyan-400{--tw-gradient-to: #22d3ee var(--tw-gradient-to-position)}.bg-clip-text{-webkit-background-clip:text;background-clip:text}.p-2\.5{padding:.625rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-4{padding-top:1rem;padding-bottom:1rem}.text-center{text-align:center}.text-right{text-align:right}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.font-sans{font-family:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji"}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.capitalize{text-transform:capitalize}.text-amber-400{--tw-text-opacity: 1;color:rgb(251 191 36 / var(--tw-text-opacity, 1))}.text-emerald-400{--tw-text-opacity: 1;color:rgb(52 211 153 / var(--tw-text-opacity, 1))}.text-indigo-400{--tw-text-opacity: 1;color:rgb(129 140 248 / var(--tw-text-opacity, 1))}.text-red-400{--tw-text-opacity: 1;color:rgb(248 113 113 / var(--tw-text-opacity, 1))}.text-slate-100{--tw-text-opacity: 1;color:rgb(241 245 249 / var(--tw-text-opacity, 1))}.text-slate-200{--tw-text-opacity: 1;color:rgb(226 232 240 / var(--tw-text-opacity, 1))}.text-slate-300{--tw-text-opacity: 1;color:rgb(203 213 225 / var(--tw-text-opacity, 1))}.text-slate-400{--tw-text-opacity: 1;color:rgb(148 163 184 / var(--tw-text-opacity, 1))}.text-slate-500{--tw-text-opacity: 1;color:rgb(100 116 139 / var(--tw-text-opacity, 1))}.text-transparent{color:transparent}.text-white{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.opacity-40{opacity:.4}.opacity-50{opacity:.5}.shadow-\[0_0_15px_rgba\(99\,102\,241\,0\.15\)\]{--tw-shadow: 0 0 15px rgba(99,102,241,.15);--tw-shadow-colored: 0 0 15px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-inner{--tw-shadow: inset 0 2px 4px 0 rgb(0 0 0 / .05);--tw-shadow-colored: inset 0 2px 4px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow: 0 1px 2px 0 rgb(0 0 0 / .05);--tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-xl{--tw-shadow: 0 20px 25px -5px rgb(0 0 0 / .1), 0 8px 10px -6px rgb(0 0 0 / .1);--tw-shadow-colored: 0 20px 25px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.outline-none{outline:2px solid transparent;outline-offset:2px}.outline{outline-style:solid}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur{--tw-backdrop-blur: blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-300{transition-duration:.3s}.duration-500{transition-duration:.5s}.ease-out{transition-timing-function:cubic-bezier(0,0,.2,1)}body{margin:0;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen,Ubuntu,Cantarell,Fira Sans,Droid Sans,Helvetica Neue,sans-serif;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.selection\:bg-indigo-500\/30 *::-moz-selection{background-color:#6366f14d}.selection\:bg-indigo-500\/30 *::selection{background-color:#6366f14d}.selection\:bg-indigo-500\/30::-moz-selection{background-color:#6366f14d}.selection\:bg-indigo-500\/30::selection{background-color:#6366f14d}.hover\:scale-105:hover{--tw-scale-x: 1.05;--tw-scale-y: 1.05;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:border-slate-500:hover{--tw-border-opacity: 1;border-color:rgb(100 116 139 / var(--tw-border-opacity, 1))}.hover\:border-slate-600:hover{--tw-border-opacity: 1;border-color:rgb(71 85 105 / var(--tw-border-opacity, 1))}.hover\:bg-emerald-500\/20:hover{background-color:#10b98133}.hover\:bg-indigo-500:hover{--tw-bg-opacity: 1;background-color:rgb(99 102 241 / var(--tw-bg-opacity, 1))}.hover\:bg-indigo-500\/20:hover{background-color:#6366f133}.hover\:bg-slate-600:hover{--tw-bg-opacity: 1;background-color:rgb(71 85 105 / var(--tw-bg-opacity, 1))}.focus\:ring-2:focus{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.focus\:ring-indigo-500:focus{--tw-ring-opacity: 1;--tw-ring-color: rgb(99 102 241 / var(--tw-ring-opacity, 1))}.active\:scale-95:active{--tw-scale-x: .95;--tw-scale-y: .95;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.disabled\:opacity-50:disabled{opacity:.5}.disabled\:hover\:bg-indigo-600:hover:disabled{--tw-bg-opacity: 1;background-color:rgb(79 70 229 / var(--tw-bg-opacity, 1))}.disabled\:hover\:bg-slate-700:hover:disabled{--tw-bg-opacity: 1;background-color:rgb(51 65 85 / var(--tw-bg-opacity, 1))}@media (min-width: 1024px){.lg\:col-span-2{grid-column:span 2 / span 2}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}
 
 
frontend/dist/index.html CHANGED
@@ -4,8 +4,8 @@
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
  <title>CLM Dashboard</title>
7
- <script type="module" crossorigin src="/assets/index-C3o0olYq.js"></script>
8
- <link rel="stylesheet" crossorigin href="/assets/index-CV2RR57m.css">
9
  </head>
10
  <body class="bg-slate-900 text-slate-100 font-sans">
11
  <div id="root"></div>
 
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
  <title>CLM Dashboard</title>
7
+ <script type="module" crossorigin src="/assets/index-txBpMzf1.js"></script>
8
+ <link rel="stylesheet" crossorigin href="/assets/index-BvMzkwTv.css">
9
  </head>
10
  <body class="bg-slate-900 text-slate-100 font-sans">
11
  <div id="root"></div>
frontend/src/components/Dashboard.jsx CHANGED
@@ -1,12 +1,167 @@
1
  import React, { useState, useEffect, useRef } from 'react';
2
 
3
- const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:7860';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
 
5
  export default function Dashboard() {
6
  const [level, setLevel] = useState('medium');
7
  const [sessionId, setSessionId] = useState(null);
8
  const [obs, setObs] = useState(null);
9
- const [stateData, setStateData] = useState(null);
10
  const [logs, setLogs] = useState([]);
11
  const [loading, setLoading] = useState(false);
12
  const [error, setError] = useState(null);
@@ -16,8 +171,8 @@ export default function Dashboard() {
16
  const fetchState = async (sid) => {
17
  try {
18
  const res = await fetch(`${API_BASE}/state?session_id=${sid}`);
19
- if (res.ok) setStateData(await res.json());
20
- } catch(e) { console.error(e); }
21
  };
22
 
23
  const handleReset = async () => {
@@ -28,15 +183,16 @@ export default function Dashboard() {
28
  const res = await fetch(`${API_BASE}/reset`, {
29
  method: 'POST',
30
  headers: { 'Content-Type': 'application/json' },
31
- body: JSON.stringify({ task_id: level })
32
  });
 
33
  const data = await res.json();
34
  setSessionId(data.session_id);
35
  setObs(data.observation);
36
  setLogs([{ type: 'system', msg: `Episode started: ${level}` }]);
37
  await fetchState(data.session_id);
38
  } catch (err) {
39
- setError('Cannot reach backend at ' + API_BASE);
40
  } finally { setLoading(false); }
41
  };
42
 
@@ -49,28 +205,26 @@ export default function Dashboard() {
49
  const res = await fetch(`${API_BASE}/step`, {
50
  method: 'POST',
51
  headers: { 'Content-Type': 'application/json' },
52
- body: JSON.stringify({ session_id: sessionId, action })
53
  });
 
54
  const data = await res.json();
55
  setObs(data.observation);
56
- setRewardHistory(prev => [...prev, {
57
- step: prev.length + 1,
58
- reward: data.reward
59
- }]);
60
  setLogs(prev => [...prev, {
61
  type: data.reward >= 0 ? 'positive' : 'negative',
62
- msg: `${actionType}${taskId ? ' '+taskId : ''} β†’ reward: ${data.reward?.toFixed(3)}`,
63
- reward: data.reward
64
  }]);
65
  if (data.done) {
66
  setLogs(prev => [...prev, {
67
  type: 'system',
68
- msg: `DONE. Final score: ${data.info?.final_score?.toFixed(3) || 'N/A'}`
69
  }]);
 
70
  }
71
  await fetchState(sessionId);
72
  setTimeout(() => {
73
- if(scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
74
  }, 50);
75
  } catch (err) {
76
  setError(err.message);
@@ -78,14 +232,16 @@ export default function Dashboard() {
78
  };
79
 
80
  const workers = obs?.visible_state?.workers || [];
81
- const tasks = obs?.tasks || [];
82
- const firstWorker = workers[0] || {};
83
 
84
  return (
85
- <div style={{ minHeight: '100vh', background: '#f8fafc', fontFamily: 'system-ui, sans-serif', padding: 24 }}>
86
-
87
- {/* HEADER */}
88
- <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
 
 
89
  <div>
90
  <h1 style={{ fontSize: 22, fontWeight: 700, color: '#0f172a', margin: 0 }}>
91
  🧠 StressTest β€” Cognitive Load Manager
@@ -96,20 +252,23 @@ export default function Dashboard() {
96
  </div>
97
  <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
98
  <select value={level} onChange={e => setLevel(e.target.value)}
99
- style={{ border: '1px solid #e2e8f0', borderRadius: 8, padding: '6px 10px', fontSize: 13 }}>
 
100
  <option value="easy">Easy</option>
101
  <option value="medium">Medium</option>
102
  <option value="hard">Hard</option>
103
  <option value="expert">Expert</option>
104
  </select>
105
  <button onClick={handleReset} disabled={loading}
106
- style={{ background: '#6366f1', color: '#fff', border: 'none', borderRadius: 8,
107
- padding: '8px 18px', fontSize: 13, fontWeight: 600, cursor: 'pointer' }}>
108
- {loading ? 'Loading...' : sessionId ? 'β†Ί Reset' : 'β–Ά Start'}
 
109
  </button>
110
  </div>
111
  </div>
112
 
 
113
  {error && (
114
  <div style={{ background: '#fef2f2', border: '1px solid #fca5a5', borderRadius: 8,
115
  padding: '10px 14px', marginBottom: 16, fontSize: 13, color: '#dc2626' }}>
@@ -117,33 +276,30 @@ export default function Dashboard() {
117
  </div>
118
  )}
119
 
120
- {/* WORKER METRICS */}
121
  {workers.length > 0 && (
122
- <div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 12, marginBottom: 16 }}>
123
- {[
124
- { label: 'Fatigue', value: firstWorker.fatigue_level || 'β€”',
125
- color: firstWorker.fatigue_level === 'high' ? '#ef4444' : firstWorker.fatigue_level === 'medium' ? '#f59e0b' : '#22c55e' },
126
- { label: 'Stress', value: firstWorker.stress_level || 'β€”',
127
- color: firstWorker.stress_level === 'critical' ? '#ef4444' : firstWorker.stress_level === 'elevated' ? '#f59e0b' : '#22c55e' },
128
- { label: 'Step', value: obs?.time_step ?? 'β€”', color: '#6366f1' },
129
- { label: 'Tasks Done', value: tasks.filter(t => t.progress >= 1.0).length + '/' + tasks.length, color: '#0ea5e9' },
130
- ].map(m => (
131
- <div key={m.label} style={{ background: '#fff', border: '1px solid #e2e8f0',
132
- borderRadius: 12, padding: '14px 16px', textAlign: 'center' }}>
133
- <div style={{ fontSize: 10, color: '#94a3b8', textTransform: 'uppercase',
134
- letterSpacing: '0.08em', marginBottom: 6 }}>{m.label}</div>
135
- <div style={{ fontSize: 22, fontWeight: 700, color: m.color }}>{m.value}</div>
136
- </div>
137
- ))}
138
  </div>
139
  )}
140
 
 
141
  <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginBottom: 16 }}>
142
-
143
- {/* TASK LIST */}
144
- <div style={{ background: '#fff', border: '1px solid #e2e8f0', borderRadius: 12, padding: 16 }}>
145
- <div style={{ fontSize: 11, fontWeight: 700, color: '#94a3b8', textTransform: 'uppercase',
146
- letterSpacing: '0.08em', marginBottom: 12 }}>Task Queue</div>
147
  {tasks.length === 0 && (
148
  <div style={{ color: '#cbd5e1', fontSize: 13, textAlign: 'center', padding: 20 }}>
149
  Press Start to begin episode
@@ -154,87 +310,75 @@ export default function Dashboard() {
154
  padding: '8px 0', borderBottom: '1px solid #f1f5f9' }}>
155
  <div style={{ flex: 1 }}>
156
  <div style={{ fontSize: 13, fontWeight: 500, color: '#0f172a' }}>
157
- {task.task_type} <span style={{ fontSize: 10, color: '#94a3b8' }}>#{task.id}</span>
 
158
  </div>
159
  <div style={{ fontSize: 11, color: '#94a3b8' }}>
160
- deadline: {task.deadline ?? 'β€”'} Β· {task.depends_on ? `depends: ${task.depends_on}` : 'no dep'}
 
161
  </div>
162
- <div style={{ height: 3, background: '#f1f5f9', borderRadius: 99, marginTop: 4, overflow: 'hidden' }}>
 
163
  <div style={{ width: `${task.progress * 100}%`, height: '100%',
164
- background: task.progress >= 1 ? '#22c55e' : '#6366f1', borderRadius: 99 }} />
 
165
  </div>
166
  </div>
167
- <span style={{ fontSize: 10, padding: '2px 7px', borderRadius: 99, fontWeight: 600,
168
- background: task.priority === 'critical' ? '#fef2f2' : task.priority === 'high' ? '#fffbeb' : '#f0fdf4',
169
- color: task.priority === 'critical' ? '#dc2626' : task.priority === 'high' ? '#d97706' : '#16a34a' }}>
 
 
 
170
  {task.priority}
171
  </span>
172
- <div style={{ display: 'flex', gap: 4 }}>
173
- {sessionId && task.progress < 1.0 && (
174
- <>
175
- <button onClick={() => handleAction('work', task.id)}
176
- style={{ fontSize: 10, padding: '3px 8px', borderRadius: 6, border: '1px solid #e2e8f0',
177
- background: '#f8fafc', cursor: 'pointer' }}>work</button>
178
- <button onClick={() => handleAction('focus', task.id)}
179
- style={{ fontSize: 10, padding: '3px 8px', borderRadius: 6, border: '1px solid #6366f1',
180
- background: '#eef2ff', color: '#6366f1', cursor: 'pointer' }}>focus</button>
181
- </>
182
- )}
183
- </div>
184
  </div>
185
  ))}
186
  {sessionId && (
187
  <div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
188
  <button onClick={() => handleAction('break')}
189
- style={{ flex: 1, padding: '8px', borderRadius: 8, border: '1px solid #e2e8f0',
190
- background: '#f0fdf4', color: '#16a34a', fontWeight: 600, cursor: 'pointer', fontSize: 13 }}>
191
- β˜• Break
192
- </button>
193
  <button onClick={() => handleAction('delay')}
194
- style={{ flex: 1, padding: '8px', borderRadius: 8, border: '1px solid #e2e8f0',
195
- background: '#f8fafc', color: '#64748b', fontWeight: 600, cursor: 'pointer', fontSize: 13 }}>
196
- ⏸ Delay
197
- </button>
198
  </div>
199
  )}
200
  </div>
201
 
202
- {/* REWARD CURVE */}
203
- <div style={{ background: '#fff', border: '1px solid #e2e8f0', borderRadius: 12, padding: 16 }}>
204
- <div style={{ fontSize: 11, fontWeight: 700, color: '#94a3b8', textTransform: 'uppercase',
205
- letterSpacing: '0.08em', marginBottom: 12 }}>Reward Per Step</div>
206
  {rewardHistory.length === 0 ? (
207
  <div style={{ color: '#cbd5e1', fontSize: 13, textAlign: 'center', padding: 40 }}>
208
  Rewards will appear here as the agent acts
209
  </div>
210
  ) : (
211
- <div style={{ position: 'relative', height: 180 }}>
212
- <svg width="100%" height="180" viewBox={`0 0 ${Math.max(rewardHistory.length * 20, 300)} 180`}
213
- preserveAspectRatio="none">
214
- <line x1="0" y1="90" x2="10000" y2="90" stroke="#f1f5f9" strokeWidth="1" />
215
- {rewardHistory.map((d, i) => {
216
- const x = i * 20 + 10;
217
- const y = 90 - (d.reward * 70);
218
- const prev = rewardHistory[i - 1];
219
- return (
220
- <g key={i}>
221
- {prev && (
222
- <line x1={(i-1)*20+10} y1={90-(prev.reward*70)} x2={x} y2={y}
223
- stroke={d.reward >= 0 ? '#6366f1' : '#ef4444'} strokeWidth="2" />
224
- )}
225
- <circle cx={x} cy={y} r="3"
226
- fill={d.reward >= 0 ? '#6366f1' : '#ef4444'} />
227
- </g>
228
- );
229
- })}
230
- </svg>
231
  </div>
232
  )}
233
  {rewardHistory.length > 0 && (
234
  <div style={{ display: 'flex', gap: 16, marginTop: 8 }}>
235
  {[
236
- { label: 'Total', val: rewardHistory.reduce((s,d) => s+d.reward, 0).toFixed(3) },
237
- { label: 'Mean', val: (rewardHistory.reduce((s,d) => s+d.reward, 0)/rewardHistory.length).toFixed(3) },
238
  { label: 'Steps', val: rewardHistory.length },
239
  ].map(s => (
240
  <div key={s.label} style={{ textAlign: 'center' }}>
@@ -247,23 +391,28 @@ export default function Dashboard() {
247
  </div>
248
  </div>
249
 
250
- {/* ACTION LOG */}
251
- <div style={{ background: '#fff', border: '1px solid #e2e8f0', borderRadius: 12, padding: 16 }}>
252
- <div style={{ fontSize: 11, fontWeight: 700, color: '#94a3b8', textTransform: 'uppercase',
253
- letterSpacing: '0.08em', marginBottom: 10 }}>Action Log</div>
254
- <div ref={scrollRef} style={{ height: 120, overflowY: 'auto', fontFamily: 'monospace', fontSize: 12 }}>
 
255
  {logs.length === 0 && (
256
- <span style={{ color: '#cbd5e1' }}>No actions yet...</span>
257
  )}
258
  {logs.map((log, i) => (
259
  <div key={i} style={{ padding: '2px 0',
260
- color: log.type === 'positive' ? '#16a34a' : log.type === 'negative' ? '#dc2626' : '#64748b' }}>
 
261
  [{i}] {log.msg}
262
  </div>
263
  ))}
264
  </div>
265
  </div>
266
 
 
 
 
267
  </div>
268
  );
269
  }
 
1
  import React, { useState, useEffect, useRef } from 'react';
2
 
3
+ // Empty string = relative URLs β†’ works on HuggingFace Spaces and local dev behind a proxy.
4
+ // Set VITE_API_URL in .env.local only when the frontend is served separately from the backend.
5
+ const API_BASE = import.meta.env.VITE_API_URL || '';
6
+
7
+ // ─── tiny helpers ─────────────────────────────────────────────────────────────
8
+
9
+ function StatCard({ label, value, color }) {
10
+ return (
11
+ <div style={{
12
+ background: '#fff', border: '1px solid #e2e8f0', borderRadius: 12,
13
+ padding: '14px 16px', textAlign: 'center',
14
+ }}>
15
+ <div style={{ fontSize: 10, color: '#94a3b8', textTransform: 'uppercase',
16
+ letterSpacing: '0.08em', marginBottom: 6 }}>{label}</div>
17
+ <div style={{ fontSize: 22, fontWeight: 700, color }}>{value}</div>
18
+ </div>
19
+ );
20
+ }
21
+
22
+ function SectionHeader({ children }) {
23
+ return (
24
+ <div style={{ fontSize: 11, fontWeight: 700, color: '#94a3b8',
25
+ textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: 12 }}>
26
+ {children}
27
+ </div>
28
+ );
29
+ }
30
+
31
+ function RewardChart({ data, width = 300 }) {
32
+ if (!data.length) return null;
33
+ const H = 160;
34
+ const W = Math.max(data.length * 24, width);
35
+ const vals = data.map(d => d.mean ?? d.reward ?? 0);
36
+ const lo = Math.min(...vals);
37
+ const hi = Math.max(...vals);
38
+ const span = hi === lo ? 1 : hi - lo;
39
+ const toY = v => H - 16 - ((v - lo) / span) * (H - 32);
40
+
41
+ return (
42
+ <svg width="100%" height={H} viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none"
43
+ style={{ display: 'block' }}>
44
+ <line x1="0" y1={H / 2} x2={W} y2={H / 2} stroke="#f1f5f9" strokeWidth="1" />
45
+ {vals.map((v, i) => {
46
+ const x = i * 24 + 12;
47
+ const y = toY(v);
48
+ const prev = i > 0 ? toY(vals[i - 1]) : null;
49
+ return (
50
+ <g key={i}>
51
+ {prev !== null && (
52
+ <line x1={(i - 1) * 24 + 12} y1={prev} x2={x} y2={y}
53
+ stroke={v >= 0 ? '#6366f1' : '#ef4444'} strokeWidth="2" />
54
+ )}
55
+ <circle cx={x} cy={y} r="3" fill={v >= 0 ? '#6366f1' : '#ef4444'} />
56
+ </g>
57
+ );
58
+ })}
59
+ </svg>
60
+ );
61
+ }
62
+
63
+ // ─── Training Log panel ───────────────────────────────────────────────────────
64
+
65
+ function TrainingLog() {
66
+ const [data, setData] = useState(null);
67
+
68
+ useEffect(() => {
69
+ fetch(`${API_BASE}/training-log`)
70
+ .then(r => r.ok ? r.json() : [])
71
+ .then(setData)
72
+ .catch(() => setData([]));
73
+ }, []);
74
+
75
+ const cardStyle = {
76
+ background: '#fff', border: '1px solid #e2e8f0', borderRadius: 12, padding: 16,
77
+ marginBottom: 16,
78
+ };
79
+
80
+ if (data === null) {
81
+ return (
82
+ <div style={cardStyle}>
83
+ <SectionHeader>Training Log β€” Last Run</SectionHeader>
84
+ <div style={{ color: '#94a3b8', fontSize: 13, textAlign: 'center', padding: 24 }}>
85
+ Loading…
86
+ </div>
87
+ </div>
88
+ );
89
+ }
90
+
91
+ if (!data.length) {
92
+ return (
93
+ <div style={cardStyle}>
94
+ <SectionHeader>Training Log β€” Last Run</SectionHeader>
95
+ <div style={{ color: '#94a3b8', fontSize: 13, textAlign: 'center', padding: 24 }}>
96
+ No training data yet. Run&nbsp;
97
+ <code style={{ background: '#f1f5f9', padding: '1px 6px', borderRadius: 4, fontSize: 12 }}>
98
+ python training_loop.py --train
99
+ </code>
100
+ &nbsp;to generate reward curves.
101
+ </div>
102
+ </div>
103
+ );
104
+ }
105
+
106
+ const means = data.map(d => d.mean);
107
+ const total = data.length;
108
+ const finalMean = means[means.length - 1];
109
+ const peakMean = Math.max(...means);
110
+ const loMean = Math.min(...means);
111
+
112
+ return (
113
+ <div style={cardStyle}>
114
+ <SectionHeader>Training Log β€” Last Run ({total} steps)</SectionHeader>
115
+
116
+ {/* Summary stats */}
117
+ <div style={{ display: 'flex', gap: 24, marginBottom: 12 }}>
118
+ {[
119
+ { label: 'Final Mean', val: finalMean.toFixed(4), color: '#6366f1' },
120
+ { label: 'Peak Mean', val: peakMean.toFixed(4), color: '#22c55e' },
121
+ { label: 'Min Mean', val: loMean.toFixed(4), color: '#ef4444' },
122
+ { label: 'Steps', val: total, color: '#0ea5e9' },
123
+ ].map(s => (
124
+ <div key={s.label} style={{ textAlign: 'center', minWidth: 64 }}>
125
+ <div style={{ fontSize: 10, color: '#94a3b8', textTransform: 'uppercase' }}>{s.label}</div>
126
+ <div style={{ fontSize: 16, fontWeight: 700, color: s.color }}>{s.val}</div>
127
+ </div>
128
+ ))}
129
+ </div>
130
+
131
+ {/* Reward curve chart */}
132
+ <div style={{ position: 'relative', overflow: 'hidden', borderRadius: 8,
133
+ border: '1px solid #f1f5f9', background: '#fafafa' }}>
134
+ <RewardChart data={data} width={600} />
135
+ </div>
136
+
137
+ {/* Step table β€” last 10 rows */}
138
+ <div style={{ marginTop: 12, fontFamily: 'monospace', fontSize: 11, color: '#475569' }}>
139
+ <div style={{ display: 'flex', gap: 0, borderBottom: '1px solid #f1f5f9',
140
+ paddingBottom: 4, marginBottom: 4, fontWeight: 700, color: '#94a3b8' }}>
141
+ {['Step', 'Mean', 'Max', 'Min'].map(h => (
142
+ <div key={h} style={{ flex: 1 }}>{h}</div>
143
+ ))}
144
+ </div>
145
+ {data.slice(-10).map(d => (
146
+ <div key={d.step} style={{ display: 'flex', gap: 0, padding: '2px 0',
147
+ color: d.mean >= 0 ? '#16a34a' : '#dc2626' }}>
148
+ <div style={{ flex: 1 }}>{d.step}</div>
149
+ <div style={{ flex: 1 }}>{d.mean.toFixed(4)}</div>
150
+ <div style={{ flex: 1 }}>{d.max?.toFixed(4) ?? 'β€”'}</div>
151
+ <div style={{ flex: 1 }}>{d.min?.toFixed(4) ?? 'β€”'}</div>
152
+ </div>
153
+ ))}
154
+ </div>
155
+ </div>
156
+ );
157
+ }
158
+
159
+ // ─── Main Dashboard ───────────────────────────────────────────────────────────
160
 
161
  export default function Dashboard() {
162
  const [level, setLevel] = useState('medium');
163
  const [sessionId, setSessionId] = useState(null);
164
  const [obs, setObs] = useState(null);
 
165
  const [logs, setLogs] = useState([]);
166
  const [loading, setLoading] = useState(false);
167
  const [error, setError] = useState(null);
 
171
  const fetchState = async (sid) => {
172
  try {
173
  const res = await fetch(`${API_BASE}/state?session_id=${sid}`);
174
+ if (res.ok) await res.json();
175
+ } catch (e) { console.error(e); }
176
  };
177
 
178
  const handleReset = async () => {
 
183
  const res = await fetch(`${API_BASE}/reset`, {
184
  method: 'POST',
185
  headers: { 'Content-Type': 'application/json' },
186
+ body: JSON.stringify({ task_id: level }),
187
  });
188
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
189
  const data = await res.json();
190
  setSessionId(data.session_id);
191
  setObs(data.observation);
192
  setLogs([{ type: 'system', msg: `Episode started: ${level}` }]);
193
  await fetchState(data.session_id);
194
  } catch (err) {
195
+ setError(`Cannot reach backend at "${API_BASE || window.location.origin}" β€” ${err.message}`);
196
  } finally { setLoading(false); }
197
  };
198
 
 
205
  const res = await fetch(`${API_BASE}/step`, {
206
  method: 'POST',
207
  headers: { 'Content-Type': 'application/json' },
208
+ body: JSON.stringify({ session_id: sessionId, action }),
209
  });
210
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
211
  const data = await res.json();
212
  setObs(data.observation);
213
+ setRewardHistory(prev => [...prev, { step: prev.length + 1, reward: data.reward }]);
 
 
 
214
  setLogs(prev => [...prev, {
215
  type: data.reward >= 0 ? 'positive' : 'negative',
216
+ msg: `${actionType}${taskId ? ' ' + taskId : ''} β†’ reward: ${data.reward?.toFixed(3)}`,
 
217
  }]);
218
  if (data.done) {
219
  setLogs(prev => [...prev, {
220
  type: 'system',
221
+ msg: `DONE. Final score: ${data.info?.final_score?.toFixed(3) ?? 'N/A'}`,
222
  }]);
223
+ setSessionId(null);
224
  }
225
  await fetchState(sessionId);
226
  setTimeout(() => {
227
+ if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
228
  }, 50);
229
  } catch (err) {
230
  setError(err.message);
 
232
  };
233
 
234
  const workers = obs?.visible_state?.workers || [];
235
+ const tasks = obs?.tasks || [];
236
+ const firstW = workers[0] || {};
237
 
238
  return (
239
+ <div style={{ minHeight: '100vh', background: '#f8fafc',
240
+ fontFamily: 'system-ui, sans-serif', padding: 24 }}>
241
+
242
+ {/* ── Header ── */}
243
+ <div style={{ display: 'flex', justifyContent: 'space-between',
244
+ alignItems: 'center', marginBottom: 20 }}>
245
  <div>
246
  <h1 style={{ fontSize: 22, fontWeight: 700, color: '#0f172a', margin: 0 }}>
247
  🧠 StressTest β€” Cognitive Load Manager
 
252
  </div>
253
  <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
254
  <select value={level} onChange={e => setLevel(e.target.value)}
255
+ style={{ border: '1px solid #e2e8f0', borderRadius: 8,
256
+ padding: '6px 10px', fontSize: 13 }}>
257
  <option value="easy">Easy</option>
258
  <option value="medium">Medium</option>
259
  <option value="hard">Hard</option>
260
  <option value="expert">Expert</option>
261
  </select>
262
  <button onClick={handleReset} disabled={loading}
263
+ style={{ background: '#6366f1', color: '#fff', border: 'none',
264
+ borderRadius: 8, padding: '8px 18px', fontSize: 13, fontWeight: 600,
265
+ cursor: loading ? 'not-allowed' : 'pointer', opacity: loading ? 0.7 : 1 }}>
266
+ {loading ? 'Loading…' : sessionId ? 'β†Ί Reset' : 'β–Ά Start'}
267
  </button>
268
  </div>
269
  </div>
270
 
271
+ {/* ── Error banner ── */}
272
  {error && (
273
  <div style={{ background: '#fef2f2', border: '1px solid #fca5a5', borderRadius: 8,
274
  padding: '10px 14px', marginBottom: 16, fontSize: 13, color: '#dc2626' }}>
 
276
  </div>
277
  )}
278
 
279
+ {/* ── Worker metric cards ── */}
280
  {workers.length > 0 && (
281
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)',
282
+ gap: 12, marginBottom: 16 }}>
283
+ <StatCard label="Fatigue" value={firstW.fatigue_level || 'β€”'}
284
+ color={firstW.fatigue_level === 'high' ? '#ef4444'
285
+ : firstW.fatigue_level === 'medium' ? '#f59e0b' : '#22c55e'} />
286
+ <StatCard label="Stress" value={firstW.stress_level || 'β€”'}
287
+ color={firstW.stress_level === 'critical' ? '#ef4444'
288
+ : firstW.stress_level === 'elevated' ? '#f59e0b' : '#22c55e'} />
289
+ <StatCard label="Step" value={obs?.time_step ?? 'β€”'} color="#6366f1" />
290
+ <StatCard label="Tasks Done"
291
+ value={`${tasks.filter(t => t.progress >= 1.0).length}/${tasks.length}`}
292
+ color="#0ea5e9" />
 
 
 
 
293
  </div>
294
  )}
295
 
296
+ {/* ── Task list + Step reward chart ── */}
297
  <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginBottom: 16 }}>
298
+
299
+ {/* Task queue */}
300
+ <div style={{ background: '#fff', border: '1px solid #e2e8f0',
301
+ borderRadius: 12, padding: 16 }}>
302
+ <SectionHeader>Task Queue</SectionHeader>
303
  {tasks.length === 0 && (
304
  <div style={{ color: '#cbd5e1', fontSize: 13, textAlign: 'center', padding: 20 }}>
305
  Press Start to begin episode
 
310
  padding: '8px 0', borderBottom: '1px solid #f1f5f9' }}>
311
  <div style={{ flex: 1 }}>
312
  <div style={{ fontSize: 13, fontWeight: 500, color: '#0f172a' }}>
313
+ {task.task_type}&nbsp;
314
+ <span style={{ fontSize: 10, color: '#94a3b8' }}>#{task.id}</span>
315
  </div>
316
  <div style={{ fontSize: 11, color: '#94a3b8' }}>
317
+ deadline: {task.deadline ?? 'β€”'} Β· {task.depends_on
318
+ ? `depends: ${task.depends_on}` : 'no dep'}
319
  </div>
320
+ <div style={{ height: 3, background: '#f1f5f9', borderRadius: 99,
321
+ marginTop: 4, overflow: 'hidden' }}>
322
  <div style={{ width: `${task.progress * 100}%`, height: '100%',
323
+ background: task.progress >= 1 ? '#22c55e' : '#6366f1',
324
+ borderRadius: 99 }} />
325
  </div>
326
  </div>
327
+ <span style={{ fontSize: 10, padding: '2px 7px', borderRadius: 99,
328
+ fontWeight: 600,
329
+ background: task.priority === 'critical' ? '#fef2f2'
330
+ : task.priority === 'high' ? '#fffbeb' : '#f0fdf4',
331
+ color: task.priority === 'critical' ? '#dc2626'
332
+ : task.priority === 'high' ? '#d97706' : '#16a34a' }}>
333
  {task.priority}
334
  </span>
335
+ {sessionId && task.progress < 1.0 && (
336
+ <div style={{ display: 'flex', gap: 4 }}>
337
+ <button onClick={() => handleAction('work', task.id)}
338
+ style={{ fontSize: 10, padding: '3px 8px', borderRadius: 6,
339
+ border: '1px solid #e2e8f0', background: '#f8fafc',
340
+ cursor: 'pointer' }}>work</button>
341
+ <button onClick={() => handleAction('focus', task.id)}
342
+ style={{ fontSize: 10, padding: '3px 8px', borderRadius: 6,
343
+ border: '1px solid #6366f1', background: '#eef2ff',
344
+ color: '#6366f1', cursor: 'pointer' }}>focus</button>
345
+ </div>
346
+ )}
347
  </div>
348
  ))}
349
  {sessionId && (
350
  <div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
351
  <button onClick={() => handleAction('break')}
352
+ style={{ flex: 1, padding: 8, borderRadius: 8, border: '1px solid #e2e8f0',
353
+ background: '#f0fdf4', color: '#16a34a', fontWeight: 600,
354
+ cursor: 'pointer', fontSize: 13 }}>β˜• Break</button>
 
355
  <button onClick={() => handleAction('delay')}
356
+ style={{ flex: 1, padding: 8, borderRadius: 8, border: '1px solid #e2e8f0',
357
+ background: '#f8fafc', color: '#64748b', fontWeight: 600,
358
+ cursor: 'pointer', fontSize: 13 }}>⏸ Delay</button>
 
359
  </div>
360
  )}
361
  </div>
362
 
363
+ {/* Step reward chart */}
364
+ <div style={{ background: '#fff', border: '1px solid #e2e8f0',
365
+ borderRadius: 12, padding: 16 }}>
366
+ <SectionHeader>Reward Per Step</SectionHeader>
367
  {rewardHistory.length === 0 ? (
368
  <div style={{ color: '#cbd5e1', fontSize: 13, textAlign: 'center', padding: 40 }}>
369
  Rewards will appear here as the agent acts
370
  </div>
371
  ) : (
372
+ <div style={{ overflow: 'hidden', borderRadius: 8,
373
+ border: '1px solid #f1f5f9', background: '#fafafa' }}>
374
+ <RewardChart data={rewardHistory} width={300} />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
375
  </div>
376
  )}
377
  {rewardHistory.length > 0 && (
378
  <div style={{ display: 'flex', gap: 16, marginTop: 8 }}>
379
  {[
380
+ { label: 'Total', val: rewardHistory.reduce((s, d) => s + d.reward, 0).toFixed(3) },
381
+ { label: 'Mean', val: (rewardHistory.reduce((s, d) => s + d.reward, 0) / rewardHistory.length).toFixed(3) },
382
  { label: 'Steps', val: rewardHistory.length },
383
  ].map(s => (
384
  <div key={s.label} style={{ textAlign: 'center' }}>
 
391
  </div>
392
  </div>
393
 
394
+ {/* ── Action log ── */}
395
+ <div style={{ background: '#fff', border: '1px solid #e2e8f0',
396
+ borderRadius: 12, padding: 16, marginBottom: 16 }}>
397
+ <SectionHeader>Action Log</SectionHeader>
398
+ <div ref={scrollRef} style={{ height: 120, overflowY: 'auto',
399
+ fontFamily: 'monospace', fontSize: 12 }}>
400
  {logs.length === 0 && (
401
+ <span style={{ color: '#cbd5e1' }}>No actions yet…</span>
402
  )}
403
  {logs.map((log, i) => (
404
  <div key={i} style={{ padding: '2px 0',
405
+ color: log.type === 'positive' ? '#16a34a'
406
+ : log.type === 'negative' ? '#dc2626' : '#64748b' }}>
407
  [{i}] {log.msg}
408
  </div>
409
  ))}
410
  </div>
411
  </div>
412
 
413
+ {/* ── Training log (reward_curve.json from last GRPO run) ── */}
414
+ <TrainingLog />
415
+
416
  </div>
417
  );
418
  }