Skip to content

Commit 9643496

Browse files
eprifticlaude
andcommitted
Phase 3: Results visualization, jury pipeline, job management, and performance
- Add complete jury/voting visualization: concordance chart, 3-column confusion matrices with rejection class highlighted in gold, sample predictions dot plot, vote matrix heatmap, FBM stats, jury vs best individual comparison - Update all chart colors to predomicspkg defaults (deepskyblue/firebrick) with transparent fills and fine grid on heatmaps - Replace box plots with violin plots (points='all', jitter, meanline) - Add job table enhancements: search, pagination (10/page), config hash+summary column, sortable disk size column, duration, user tracking - Add duplicate job detection with config hash normalization (strip nulls) - Cache disk_size_bytes in DB (v8 migration) to avoid per-request directory walks - Population table: pagination 10/page, features sorted by group (neg/pos) - Feature prevalence sorted decreasing (most prevalent at top) - Add shared chart theme composable (useChartTheme.js) - Add MSP annotation service for feature labels - All 110 tests passing, 50% code coverage Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 280e477 commit 9643496

21 files changed

Lines changed: 3473 additions & 260 deletions

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ Thumbs.db
2828
# Data & uploads (runtime)
2929
data/uploads/
3030
data/projects/
31+
data/cache/
3132
backend/data/
3233
*.db
3334

‎Dockerfile‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ ENV PATH="/root/.cargo/bin:${PATH}"
2222
RUN pip install --no-cache-dir maturin
2323

2424
WORKDIR /build
25+
COPY gpredomics/ ./gpredomics/
2526
COPY gpredomicspy/ ./gpredomicspy/
2627
RUN cd gpredomicspy && maturin build --release --out /build/wheels
2728

‎backend/app/main.py‎

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -464,6 +464,141 @@ async def _migrate_add_admin_flag(conn):
464464
_log.info("Migration v4_admin_flag complete")
465465

466466

467+
async def _migrate_add_job_name(conn):
468+
"""v5: Add name column to jobs table. Idempotent."""
469+
try:
470+
r = await conn.execute(
471+
text("SELECT 1 FROM schema_versions WHERE version = 'v5_job_name'")
472+
)
473+
if r.scalar():
474+
return
475+
except Exception:
476+
pass
477+
478+
_log.info("Migration v5: job name — starting")
479+
480+
try:
481+
r = await conn.execute(text(
482+
"SELECT column_name FROM information_schema.columns "
483+
"WHERE table_name = 'jobs' AND column_name = 'name'"
484+
))
485+
if not r.scalar():
486+
await conn.execute(text(
487+
"ALTER TABLE jobs ADD COLUMN name VARCHAR(255)"
488+
))
489+
_log.info(" Added jobs.name column")
490+
except Exception:
491+
pass
492+
493+
await conn.execute(text(
494+
"INSERT INTO schema_versions (version, applied_at) "
495+
"VALUES ('v5_job_name', CURRENT_TIMESTAMP) ON CONFLICT DO NOTHING"
496+
))
497+
_log.info("Migration v5_job_name complete")
498+
499+
500+
async def _migrate_add_job_user_id(conn):
501+
"""v6: Add user_id FK to jobs table for tracking who launched each job."""
502+
try:
503+
r = await conn.execute(
504+
text("SELECT 1 FROM schema_versions WHERE version = 'v6_job_user_id'")
505+
)
506+
if r.scalar():
507+
return
508+
except Exception:
509+
pass
510+
511+
_log.info("Migration v6: job user_id — starting")
512+
513+
try:
514+
r = await conn.execute(text(
515+
"SELECT column_name FROM information_schema.columns "
516+
"WHERE table_name = 'jobs' AND column_name = 'user_id'"
517+
))
518+
if not r.scalar():
519+
await conn.execute(text(
520+
"ALTER TABLE jobs ADD COLUMN user_id VARCHAR(12) REFERENCES users(id) ON DELETE SET NULL"
521+
))
522+
_log.info(" Added jobs.user_id column")
523+
except Exception:
524+
pass
525+
526+
await conn.execute(text(
527+
"INSERT INTO schema_versions (version, applied_at) "
528+
"VALUES ('v6_job_user_id', CURRENT_TIMESTAMP) ON CONFLICT DO NOTHING"
529+
))
530+
_log.info("Migration v6_job_user_id complete")
531+
532+
533+
async def _migrate_add_job_config_hash(conn):
534+
"""v7: Add config_hash column to jobs for duplicate detection."""
535+
try:
536+
r = await conn.execute(
537+
text("SELECT 1 FROM schema_versions WHERE version = 'v7_job_config_hash'")
538+
)
539+
if r.scalar():
540+
return
541+
except Exception:
542+
pass
543+
544+
_log.info("Migration v7: job config_hash — starting")
545+
546+
try:
547+
r = await conn.execute(text(
548+
"SELECT column_name FROM information_schema.columns "
549+
"WHERE table_name = 'jobs' AND column_name = 'config_hash'"
550+
))
551+
if not r.scalar():
552+
await conn.execute(text(
553+
"ALTER TABLE jobs ADD COLUMN config_hash VARCHAR(32)"
554+
))
555+
await conn.execute(text(
556+
"CREATE INDEX IF NOT EXISTS ix_jobs_config_hash ON jobs (config_hash)"
557+
))
558+
_log.info(" Added jobs.config_hash column with index")
559+
except Exception:
560+
pass
561+
562+
await conn.execute(text(
563+
"INSERT INTO schema_versions (version, applied_at) "
564+
"VALUES ('v7_job_config_hash', CURRENT_TIMESTAMP) ON CONFLICT DO NOTHING"
565+
))
566+
_log.info("Migration v7_job_config_hash complete")
567+
568+
569+
async def _migrate_add_job_disk_size(conn):
570+
"""v8: Add disk_size_bytes column to jobs for cached disk usage."""
571+
try:
572+
r = await conn.execute(
573+
text("SELECT 1 FROM schema_versions WHERE version = 'v8_job_disk_size'")
574+
)
575+
if r.scalar():
576+
return
577+
except Exception:
578+
pass
579+
580+
_log.info("Migration v8: job disk_size_bytes — starting")
581+
582+
try:
583+
r = await conn.execute(text(
584+
"SELECT column_name FROM information_schema.columns "
585+
"WHERE table_name = 'jobs' AND column_name = 'disk_size_bytes'"
586+
))
587+
if not r.scalar():
588+
await conn.execute(text(
589+
"ALTER TABLE jobs ADD COLUMN disk_size_bytes BIGINT"
590+
))
591+
_log.info(" Added jobs.disk_size_bytes column")
592+
except Exception:
593+
pass
594+
595+
await conn.execute(text(
596+
"INSERT INTO schema_versions (version, applied_at) "
597+
"VALUES ('v8_job_disk_size', CURRENT_TIMESTAMP) ON CONFLICT DO NOTHING"
598+
))
599+
_log.info("Migration v8_job_disk_size complete")
600+
601+
467602
@asynccontextmanager
468603
async def lifespan(app: FastAPI):
469604
"""Startup/shutdown events."""
@@ -478,6 +613,14 @@ async def lifespan(app: FastAPI):
478613
await _migrate_to_composite_datasets(conn)
479614
async with engine.begin() as conn:
480615
await _migrate_add_admin_flag(conn)
616+
async with engine.begin() as conn:
617+
await _migrate_add_job_name(conn)
618+
async with engine.begin() as conn:
619+
await _migrate_add_job_user_id(conn)
620+
async with engine.begin() as conn:
621+
await _migrate_add_job_config_hash(conn)
622+
async with engine.begin() as conn:
623+
await _migrate_add_job_disk_size(conn)
481624
_log.info("PredomicsApp started — data_dir=%s", settings.data_dir)
482625
yield
483626

‎backend/app/models/db_models.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,14 +96,19 @@ class Job(Base):
9696

9797
id: Mapped[str] = mapped_column(String(12), primary_key=True, default=_new_id)
9898
project_id: Mapped[str] = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"), nullable=False)
99+
user_id: Mapped[Optional[str]] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
100+
name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
99101
status: Mapped[str] = mapped_column(String(20), default="pending")
100102
config: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
101103
results_path: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
102104
error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
103105
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
104106
completed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
107+
config_hash: Mapped[Optional[str]] = mapped_column(String(32), nullable=True, index=True)
108+
disk_size_bytes: Mapped[Optional[int]] = mapped_column(nullable=True)
105109

106110
project: Mapped["Project"] = relationship(back_populates="jobs")
111+
owner: Mapped[Optional["User"]] = relationship()
107112

108113

109114
class ProjectShare(Base):

‎backend/app/models/schemas.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,7 @@ class PopulationResponse(BaseModel):
182182

183183
class ExperimentSummary(BaseModel):
184184
job_id: str
185+
name: Optional[str] = None
185186
status: JobStatus
186187
fold_count: int = 0
187188
generation_count: int = 0
@@ -190,6 +191,16 @@ class ExperimentSummary(BaseModel):
190191
sample_count: int = 0
191192
best_auc: Optional[float] = None
192193
best_k: Optional[int] = None
194+
created_at: Optional[str] = None
195+
completed_at: Optional[str] = None
196+
duration_seconds: Optional[float] = None
197+
config_summary: Optional[str] = None
198+
user_name: Optional[str] = None
199+
language: Optional[str] = None
200+
data_type: Optional[str] = None
201+
population_size: Optional[int] = None
202+
config_hash: Optional[str] = None
203+
disk_size_bytes: Optional[int] = None
193204

194205

195206
class ExperimentDetail(ExperimentSummary):

‎backend/app/routers/admin.py‎

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,20 @@
1-
"""Admin endpoints: user management."""
1+
"""Admin endpoints: user management and default configuration."""
2+
3+
import json
4+
from pathlib import Path
25

36
from fastapi import APIRouter, Depends, HTTPException
47
from sqlalchemy import select, func
58
from sqlalchemy.ext.asyncio import AsyncSession
69

10+
from ..core.config import settings
711
from ..core.database import get_db
812
from ..core.deps import get_admin_user
913
from ..models.db_models import User, Project, Dataset
1014
from ..models.auth_schemas import AdminUserResponse, AdminUserUpdate
1115

16+
DEFAULTS_PATH = Path(settings.data_dir) / "admin_defaults.json"
17+
1218
router = APIRouter(prefix="/admin", tags=["admin"])
1319

1420

@@ -114,3 +120,42 @@ async def delete_user(
114120

115121
await db.delete(target)
116122
return {"status": "deleted", "email": target.email}
123+
124+
125+
def _load_defaults() -> dict:
126+
"""Load admin defaults from disk."""
127+
if DEFAULTS_PATH.exists():
128+
return json.loads(DEFAULTS_PATH.read_text())
129+
return {}
130+
131+
132+
def _save_defaults(data: dict) -> None:
133+
"""Save admin defaults to disk."""
134+
DEFAULTS_PATH.parent.mkdir(parents=True, exist_ok=True)
135+
DEFAULTS_PATH.write_text(json.dumps(data, indent=2))
136+
137+
138+
@router.get("/defaults")
139+
async def get_defaults(admin: User = Depends(get_admin_user)):
140+
"""Get the admin default parameter overrides (admin only)."""
141+
return _load_defaults()
142+
143+
144+
@router.get("/defaults/public")
145+
async def get_defaults_public():
146+
"""Get the admin default parameter overrides (public, no auth required).
147+
148+
Used by the Parameters form to show current server defaults.
149+
"""
150+
return _load_defaults()
151+
152+
153+
@router.put("/defaults")
154+
async def set_defaults(body: dict, admin: User = Depends(get_admin_user)):
155+
"""Set admin default parameter overrides (admin only).
156+
157+
Accepts a flat dict of section.key overrides, e.g.:
158+
{"general.language": "bin,ter", "ga.population_size": 3000, "voting.vote": true}
159+
"""
160+
_save_defaults(body)
161+
return body

0 commit comments

Comments
 (0)