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
Changed JUPYTERHUB_ACTIVITYMON_HALF_LIFE default from 24h to 48h
for smoother decay in activity scoring. A sample from 48 hours ago
now has 50% weight (was 50% at 24h).
Research document covering industry approaches for activity tracking:
- Exponential Moving Average (EMA) with half-life decay
- Hubstaff's hour-based approach
- Daily target method (8h=100%)
- GitHub contribution graph methodology
Validates current EMA implementation aligns with industry standards.
Activity tracking now runs as a JupyterHub managed service that starts
on boot, independent of page views. Uses REST API to fetch user data.
Changes:
- Add activity_sampler.py standalone service script
- Configure as JupyterHub service with roles/scopes in config
- Add aiohttp dependency for async HTTP requests
- Remove lazy-start from ActivityDataHandler (now independent)
- Rename env var to JUPYTERHUB_ACTIVITYMON_SAMPLE_INTERVAL
- Update settings_dictionary.yml
Volume size refresher still lazy-starts on Activity page view.
- VolumeSizeRefresher class using Tornado PeriodicCallback
- Refreshes volume sizes every hour (JUPYTERHUB_ACTIVITYMON_VOLUMES_UPDATE_INTERVAL)
- Lazy start on first Activity page access (like ActivitySampler)
- Runs first refresh immediately, then at configured interval