- pale muted colors: #446897 (blue), #bfa348 (yellow), #af4e56 (red)
- CSS custom properties now use hex format
- JS parseColor() handles both hex and RGB triplet formats
- progress track border changed to #6f757c
- narrowed bar from col-md-6 to col-md-4
Replace the large session status card on the home page with a compact
inline progress bar. Extract all session timer JS into standalone
session-timer.js module loaded via page.html.
- progress bar with bordered track showing remaining time as percentage
- smooth RGB color interpolation: blue (100%) -> yellow (30%) -> red (10%)
- colors defined as CSS custom properties for easy theming
- "Extend" button opens Bootstrap modal instead of inline controls
- 60s local countdown with 5-minute server refresh
- DOM observer calls SessionTimer.hide() on server stop
- Dockerfile copies session-timer.js to JupyterHub static/js/
- bump version to 3.8.1
- rewrite builder stage to use uv with venv for isolated builds and tests
- disable COMPOSE_BAKE (created manifest lists instead of plain images,
causing latest tag to point to stale cached image despite build logs
showing successful installation)
- pass "$@" from build scripts to docker compose build so --no-cache
and other CLI flags actually reach Docker (previously silently ignored)
- fix shared volume comment: read-write not read-only
docker tag was inside the git tag else-branch, so when git tag already
existed (e.g., rebuild without version bump), Docker tag was never
created and make push failed with "tag does not exist"
check_routes() runs every ~5min and deletes any CHP route not in its
good_routes set. Favicon routes added only via add_route() were treated
as stale and removed after ~5 minutes. Now also registered in
app.proxy.extra_routes so check_routes() recognizes them as legitimate.
Applied to both pre_spawn_hook and startup callback for surviving servers.
file:// icons were resolved to bare paths at config load time, but
JupyterLab extensions interpret bare paths as filesystem locations.
Moved URI resolution to pre_spawn_hook where app.hub.url provides the
hub origin at runtime, constructing fully qualified http:// URLs
(e.g., http://jupyterhub:8080/hub/static/lab-main-icon.svg).
- add JUPYTERLAB_AUX_SCRIPTS_PATH env var for admin-managed startup
scripts in user containers, default /mnt/shared/start-platform.d
- pass JUPYTERLAB_AUX_SCRIPTS_PATH to DockerSpawner.environment
- expand README Custom Branding from logo-only to full table covering
logo, favicon, lab main icon, and lab splash icon
- add README Admin Startup Scripts section
- add User Environment category in settings_dictionary.yml
- add JUPYTERLAB_AUX_SCRIPTS_PATH to Dockerfile defaults
- add JUPYTERHUB_LAB_MAIN_ICON_URI and JUPYTERHUB_LAB_SPLASH_ICON_URI
for custom JupyterLab main toolbar logo and splash screen icon
- file:// URIs copy to hub static dir and resolve to hub static URL,
external URLs passed through as-is
- resolved URIs injected conditionally into DockerSpawner.environment
as JUPYTERLAB_MAIN_ICON_URI and JUPYTERLAB_SPLASH_ICON_URI
- fix spawner env var names to match lab image expectations:
JUPYTERHUB_SERVICE_* -> ENABLE_SERVICE_* (MLflow, Resources Monitor,
TensorBoard), remove duplicate JUPYTERHUB_GPU_ENABLED
- update custom-branding.md with JupyterLab Icons section
pre_spawn_hook only fires on new spawns. Servers already running when
JupyterHub restarts never trigger it, leaving their favicon CHP routes
missing. Add IOLoop.current().add_callback() one-shot startup callback
that iterates all active servers and registers their favicon routes
immediately after the event loop starts.
JupyterLab sessions show their own default favicon because requests to
/user/{username}/static/favicons/favicon.ico route through CHP directly
to user containers, bypassing the hub.
Exploit CHP's trie-based longest-prefix-match: pre_spawn_hook registers
a per-user CHP route (/user/{username}/static/favicons/) pointing back
to the hub. CHP prefers this over the generic /user/{username}/ route.
FaviconRedirectHandler changed from BaseHandler to RequestHandler and
injected into Tornado's wildcard_router (not extra_handlers which
auto-prefixes /hub/). Handler 302-redirects to hub's static favicon.
- CHP target uses host:port only (no path) to avoid CHP path rewriting
- Tornado handler inserted at position 0 in wildcard_router rules
- Conditional on JUPYTERHUB_FAVICON_URI being non-empty
- CHP routes added idempotently per spawn, no cleanup needed
Static files are served from {sys.prefix}/share/jupyterhub/static/,
not from the Python module directory. os.path.dirname(jupyterhub.__file__)
resolves to site-packages/jupyterhub/ which has no static/ subdirectory.
JUPYTERHUB_LOGO_URI previously defaulted to file:///srv/jupyterhub/logo.svg
which doesn't exist in the image - the os.path.exists() check silently
fell through to stock JupyterHub logo anyway. Changed default to empty
across Dockerfile, config, compose.yml, and settings_dictionary.yml.
Both JUPYTERHUB_LOGO_URI and JUPYTERHUB_FAVICON_URI now consistently
default to empty, meaning stock JupyterHub assets are used unless
explicitly configured.
Mirrors the existing JUPYTERHUB_LOGO_URI pattern:
- file:// URIs copy the file to JupyterHub static dir at startup
- http(s):// URLs passed directly to template as link href
- empty/unset falls back to default static_url('favicon.ico')
Enables visual differentiation between dev/staging/prod deployments.
- show spinner immediately on "Add Users" click before POST completes,
then transition message to "Generating credentials..." after response
- add spinner for user rename (PATCH) with "Renaming user..." message
- add error path cleanup hiding spinner on failed creation, empty
response, or parse errors
- Volume management button now only appears for users with stopped servers
- Button dynamically removed when server starts running
- Button positioned as last element in action cell (after Edit User)
- MutationObserver handles React re-renders
- Add feature bullet for admin volume management button
- Add note about admin capability in User Self-Service Workflow section
- Admins can manage any user's volumes via database icon in admin panel
- Document final implementation using tr.user-row selector
- Username extracted from span[data-testid="user-name-div-{username}"]
- Button matches Edit User button styling (btn btn-light btn-xs)
- Positioned before Edit User button in actions cell
- Find user rows via tr.user-row selector
- Get username from span[data-testid="user-name-div-{username}"]
- Find Edit User button by text content (no title attribute)
- Insert volume button before Edit User button
- Copies exact classes from Edit User button (btn btn-light btn-xs)
- Find Edit buttons directly instead of looking for td.actions
- Inject button before each Edit User button found
- Works with JupyterHub React admin panel structure
- Walks up DOM to find user container and extract username
- Find username via multiple methods (admin link, server link, text)
- Copy exact class from Edit button for consistent styling
- Insert button before Edit button (between server actions and edit)
- Works for all users, not just those with running servers
- Admin panel shows spinner with "Deleting user {username}..." during deletion
- Refactored loading modal to accept dynamic message text
- New users get initial activity sample recorded automatically
- New users show 0% activity bar instead of '--' in Activity Monitor
- Increased .col-auth width from 4em to 5em to prevent truncation
- Changed Volumes tooltip to generic "hover for breakdown" since
volume names are autodiscovered
- Added explanatory tooltips to all 9 column headers in Activity Monitor
- Added sortable Auth column showing NativeAuthenticator authorization status
- Green checkmark for authorized users, red X for not authorized
- Backend queries users_info table with graceful fallback
- Updated documentation with new column and API schema
- Activity tooltip shows "Not enough data (Nh of 24h collected)" until
sufficient samples collected (144 samples at 10-min intervals)
- Progress bar still renders to show emerging trend, tooltip clarifies
percentage not yet reliable
- Added activitymon_sample_interval to template_vars for frontend access
- Rewrote docs/activity-tracking-methodology.md as comprehensive
implementation specification covering data collection, scoring formulas,
UI components, API endpoints, and design rationale
Added JUPYTERHUB_ACTIVITYMON_TARGET_HOURS env var (default 8) to
normalize activity scores based on expected daily work hours.
- Raw score (% of sampled time active) normalized to target
- 8h/day worker with 33% raw score -> 100% normalized
- Progress bar capped at 5 segments (100%)
- Tooltip shows real % with "(>8h/day)" indicator if over 100%
Files: Dockerfile, settings_dictionary.yml, jupyterhub_config.py,
activity.html
Rewrote simulation section explaining why effective half-life differs
from configured half-life:
1. Decay is CONTINUOUS (24/7 in calendar time)
2. Work is SPARSE (only during work hours)
3. Decay during BREAKS (overnight with no new work)
Added single table showing effective half-life for work patterns
(12h, 10h, 8h, 6h, 4h, 2h) vs configured half-lives (24h, 48h, 72h).
Key insight: 72h configured = 18h effective for 8h/day worker.
Added detailed simulation results showing how calendar half-life
translates to effective working-time decay:
- 10h/day (intensive): 72h -> 28.5 work hours at 50%
- 8h/day (typical): 72h -> 22.8 work hours at 50%
- 4h/day (part-time): 72h -> 11.5 work hours at 50%
Key finding: 72h calendar half-life consistently yields ~2.9 work
days at the 50% point, regardless of daily work hours. Activity
scores correctly reflect work fraction (8h/24h = 33.3%).
Explains why 72h calendar half-life was chosen:
- Decay applies to wall-clock time, not working time
- Users work ~8h/day (1/3 of 24h period)
- 72h calendar = ~24h of working time = one full workday
- Prevents overnight breaks from penalizing scores
This calibrates decay to actual engagement patterns rather
than raw calendar time.
Changed JUPYTERHUB_ACTIVITYMON_HALF_LIFE default from 48h to 72h
for more stable activity scores. Activity from 3 days ago now has
50% weight, better suited for users with irregular schedules.
Updated: Dockerfile, custom_handlers.py, activity_sampler.py,
settings_dictionary.yml, README.md, docs/activity-tracking-methodology.md