feat: implement reset home volume and restart server features

- Add custom API handlers for volume reset and server restart
- Create custom home.html template with self-service buttons and modals
- Register handlers in jupyterhub_config.py with @admin_or_self permissions
- Update Dockerfile to copy templates and handlers
- Add custom templates path to JupyterHub configuration
- Update .claude/CLAUDE.md with feature documentation
- Reset Home Volume: DELETE /hub/api/users/{username}/reset-home-volume
- Restart Server: POST /hub/api/users/{username}/restart-server
- Both features use Docker API directly via /var/run/docker.sock
This commit is contained in:
stellarshenson
2025-11-03 20:24:20 +01:00
parent be8c8f2428
commit 3e52a91924
5 changed files with 387 additions and 1 deletions

View File

@@ -156,6 +156,55 @@ volumes:
User containers will access this at `/mnt/shared`.
## User Self-Service Features
The platform provides two self-service features accessible from the user control panel (`/hub/home`):
### Reset Home Volume
**Purpose**: Allows users to delete their home directory volume and start fresh with a clean environment.
**Requirements**:
- User's JupyterLab server must be stopped
- Volume `jupyterlab-{username}_home` must exist
**Implementation**:
- API Endpoint: `DELETE /hub/api/users/{username}/reset-home-volume`
- Handler: `services/jupyterhub/conf/bin/custom_handlers.py::ResetHomeVolumeHandler`
- Uses Docker API to safely remove the volume
- Only affects home volume - workspace and cache volumes are preserved
**Permissions**:
- Users can reset their own home volume
- Admins can reset any user's home volume
- Enforced via `@admin_or_self` decorator
### Restart Server
**Purpose**: Provides one-click Docker container restart without recreating the container.
**Requirements**:
- User's JupyterLab server must be running
- Container `jupyterlab-{username}` must exist
**Implementation**:
- API Endpoint: `POST /hub/api/users/{username}/restart-server`
- Handler: `services/jupyterhub/conf/bin/custom_handlers.py::RestartServerHandler`
- Uses Docker's native `container.restart(timeout=10)` method
- Preserves container identity, volumes, and configuration
- Does NOT recreate container (unlike JupyterHub's stop/spawn cycle)
**Permissions**:
- Users can restart their own server
- Admins can restart any user's server
- Enforced via `@admin_or_self` decorator
**UI Location**:
- Custom template: `services/jupyterhub/templates/home.html`
- Reset button visible when server is stopped
- Restart button visible when server is running
- Both include confirmation modals with warnings
## Troubleshooting
**GPU not detected**:

View File

@@ -146,7 +146,10 @@ c.JupyterHub.db_url = "sqlite:////data/jupyterhub.sqlite"
# authenticate users with Native Authenticator
# enable UI for native authenticator
c.JupyterHub.authenticator_class = 'native'
c.JupyterHub.template_paths = [f"{os.path.dirname(nativeauthenticator.__file__)}/templates/"]
c.JupyterHub.template_paths = [
"/srv/jupyterhub/templates/", # Custom templates
f"{os.path.dirname(nativeauthenticator.__file__)}/templates/" # NativeAuthenticator templates
]
# allow anyone to sign-up without approval
# allow all signed-up users to login
@@ -158,4 +161,16 @@ c.Authenticator.allow_all = True
c.Authenticator.admin_users = [JUPYTERHUB_ADMIN]
c.JupyterHub.admin_access = True
# Custom API handlers for volume management and server control
import sys
sys.path.insert(0, '/start-platform.d')
sys.path.insert(0, '/')
from custom_handlers import ResetHomeVolumeHandler, RestartServerHandler
c.JupyterHub.extra_handlers = [
(r'/api/users/([^/]+)/reset-home-volume', ResetHomeVolumeHandler),
(r'/api/users/([^/]+)/restart-server', RestartServerHandler),
]
# EOF

View File

@@ -34,6 +34,7 @@ EOF
COPY --chmod=755 ./conf/bin/*.sh ./conf/bin/*.py /
COPY --chmod=755 ./conf/bin/start-platform.d /start-platform.d
COPY --chmod=600 ./templates/certs /mnt/certs
COPY --chmod=644 ./templates/*.html /srv/jupyterhub/templates/
## install dockerspawner, nativeauthenticator
RUN pip install -U --no-cache-dir \

View File

@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""
Custom JupyterHub API handlers for volume management and server control
"""
from jupyterhub.handlers import BaseHandler
from jupyterhub.utils import admin_or_self
import docker
class ResetHomeVolumeHandler(BaseHandler):
"""Handler for resetting user home volumes"""
@admin_or_self
async def delete(self, username):
"""
Delete a user's home volume (only when server is stopped)
DELETE /hub/api/users/{username}/reset-home-volume
"""
# 1. Verify user exists
user = self.find_user(username)
if not user:
self.log.warning(f"Reset volume request failed: user {username} not found")
return self.send_error(404, "User not found")
# 2. Check server is stopped
spawner = user.spawner
if spawner.active:
self.log.warning(f"Reset volume request failed: {username}'s server is running")
return self.send_error(400, "Server must be stopped before resetting volume")
# 3. Connect to Docker
try:
docker_client = docker.DockerClient(base_url='unix://var/run/docker.sock')
except Exception as e:
self.log.error(f"Failed to connect to Docker: {e}")
return self.send_error(500, "Failed to connect to Docker daemon")
# 4. Verify volume exists
volume_name = f'jupyterlab-{username}_home'
try:
volume = docker_client.volumes.get(volume_name)
except docker.errors.NotFound:
self.log.warning(f"Volume {volume_name} not found")
return self.send_error(404, f"Volume {volume_name} not found")
except Exception as e:
self.log.error(f"Error checking volume {volume_name}: {e}")
return self.send_error(500, f"Failed to check volume: {str(e)}")
# 5. Remove volume
try:
volume.remove()
self.log.info(f"Successfully removed volume {volume_name} for user {username}")
self.set_status(200)
self.finish({"message": f"Volume {volume_name} successfully reset"})
except docker.errors.APIError as e:
self.log.error(f"Failed to remove volume {volume_name}: {e}")
return self.send_error(500, f"Failed to remove volume: {str(e)}")
finally:
docker_client.close()
class RestartServerHandler(BaseHandler):
"""Handler for restarting user servers"""
@admin_or_self
async def post(self, username):
"""
Restart a user's server using Docker container restart
POST /hub/api/users/{username}/restart-server
"""
# 1. Verify user exists
user = self.find_user(username)
if not user:
self.log.warning(f"Restart server request failed: user {username} not found")
return self.send_error(404, "User not found")
# 2. Check server is running
spawner = user.spawner
if not spawner.active:
self.log.warning(f"Restart server request failed: {username}'s server is not running")
return self.send_error(400, "Server is not running")
# 3. Get container name from spawner
container_name = f'jupyterlab-{username}'
# 4. Connect to Docker and restart container
try:
docker_client = docker.DockerClient(base_url='unix://var/run/docker.sock')
except Exception as e:
self.log.error(f"Failed to connect to Docker: {e}")
return self.send_error(500, "Failed to connect to Docker daemon")
try:
# Get the container
container = docker_client.containers.get(container_name)
# Restart the container (graceful restart with 10s timeout)
self.log.info(f"Restarting container {container_name} for user {username}")
container.restart(timeout=10)
self.log.info(f"Successfully restarted container {container_name}")
self.set_status(200)
self.finish({"message": f"Container {container_name} successfully restarted"})
except docker.errors.NotFound:
self.log.warning(f"Container {container_name} not found")
return self.send_error(404, f"Container {container_name} not found")
except docker.errors.APIError as e:
self.log.error(f"Failed to restart container {container_name}: {e}")
return self.send_error(500, f"Failed to restart container: {str(e)}")
finally:
docker_client.close()

View File

@@ -0,0 +1,207 @@
{% extends "page.html" %}
{% block main %}
<div class="container">
<div class="row">
<div class="col-md-8 offset-md-2">
<div class="text-center">
<h1>Welcome, {{ user.name }}!</h1>
{% if user.server %}
<div class="card mt-4">
<div class="card-body">
<h5 class="card-title">Your Server is Running</h5>
<p class="card-text">Your Jupyter server is active and ready to use.</p>
<a href="/user/{{ user.name }}/lab" class="btn btn-lg btn-primary">
<i class="fa fa-flask"></i> Go to JupyterLab
</a>
<button id="restart-server-btn"
class="btn btn-lg btn-warning"
data-username="{{ user.name }}"
data-toggle="modal"
data-target="#restart-server-modal">
<i class="fa fa-refresh"></i> Restart Server
</button>
<a href="/hub/home" class="btn btn-lg btn-danger"
data-method="delete"
data-confirm="Are you sure you want to stop your server?">
<i class="fa fa-stop"></i> Stop Server
</a>
</div>
</div>
{% else %}
<div class="card mt-4">
<div class="card-body">
<h5 class="card-title">Your Server is Stopped</h5>
<p class="card-text">Start your Jupyter server to begin working.</p>
<a href="/hub/spawn" class="btn btn-lg btn-primary">
<i class="fa fa-play"></i> Start Server
</a>
<button id="reset-home-volume-btn"
class="btn btn-lg btn-danger"
data-username="{{ user.name }}"
data-toggle="modal"
data-target="#reset-volume-modal">
<i class="fa fa-trash"></i> Reset Home Volume
</button>
</div>
</div>
{% endif %}
{% if user.admin %}
<div class="card mt-4">
<div class="card-body">
<h5 class="card-title">Admin Panel</h5>
<p class="card-text">Manage users and servers.</p>
<a href="/hub/admin" class="btn btn-lg btn-info">
<i class="fa fa-dashboard"></i> Admin Panel
</a>
</div>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Reset Home Volume Modal -->
<div class="modal fade" id="reset-volume-modal" tabindex="-1" role="dialog" aria-labelledby="resetVolumeModalLabel" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="resetVolumeModalLabel">Reset Home Volume</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<div class="alert alert-danger">
<strong><i class="fa fa-exclamation-triangle"></i> Warning:</strong> This action cannot be undone!
</div>
<p>This will permanently delete all files in your home directory:</p>
<code id="volume-name-display" class="d-block bg-light p-2 mb-3">jupyterlab-{{ user.name }}_home</code>
<p class="mt-3">Your workspace and cache volumes will <strong>NOT</strong> be affected.</p>
<p><strong>Are you sure you want to continue?</strong></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" id="confirm-reset-btn">
<i class="fa fa-check"></i> Yes, Reset Home Volume
</button>
</div>
</div>
</div>
</div>
<!-- Restart Server Modal -->
<div class="modal fade" id="restart-server-modal" tabindex="-1" role="dialog" aria-labelledby="restartServerModalLabel" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="restartServerModalLabel">Restart Server</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<div class="alert alert-warning">
<strong><i class="fa fa-info-circle"></i> Notice:</strong> Your server will be temporarily unavailable during restart.
</div>
<p>This will restart your JupyterLab container using Docker's native restart:</p>
<ul>
<li>Gracefully stops the container</li>
<li>Restarts the same container (does not recreate)</li>
<li>Preserves all volumes and configuration</li>
</ul>
<p class="mt-3"><strong>Any unsaved work in notebooks will be lost.</strong></p>
<p class="mt-2">Your files on disk are safe and will remain intact.</p>
<p><strong>Are you sure you want to restart?</strong></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-warning" id="confirm-restart-btn">
<i class="fa fa-check"></i> Yes, Restart Server
</button>
</div>
</div>
</div>
</div>
{% endblock main %}
{% block script %}
{{ super() }}
<script>
$(document).ready(function() {
const username = "{{ user.name }}";
// Reset Home Volume handler
$('#confirm-reset-btn').on('click', function() {
const apiUrl = `/hub/api/users/${username}/reset-home-volume`;
const $btn = $(this);
// Disable button and show loading state
$btn.prop('disabled', true).html('<i class="fa fa-spinner fa-spin"></i> Resetting...');
$.ajax({
url: apiUrl,
type: 'DELETE',
headers: {
'X-XSRFToken': getCookie('_xsrf')
},
success: function(response) {
$('#reset-volume-modal').modal('hide');
alert('Home volume successfully reset. Your home directory will be recreated on next server start.');
location.reload();
},
error: function(xhr) {
$('#reset-volume-modal').modal('hide');
const errorMsg = xhr.responseJSON?.message || xhr.statusText || 'Failed to reset volume';
alert(`Error: ${errorMsg}`);
$btn.prop('disabled', false).html('<i class="fa fa-check"></i> Yes, Reset Home Volume');
}
});
});
// Restart Server handler
$('#confirm-restart-btn').on('click', function() {
const apiUrl = `/hub/api/users/${username}/restart-server`;
const $btn = $(this);
// Disable button and show loading state
$btn.prop('disabled', true).html('<i class="fa fa-spinner fa-spin"></i> Restarting...');
$.ajax({
url: apiUrl,
type: 'POST',
headers: {
'X-XSRFToken': getCookie('_xsrf')
},
success: function(response) {
$('#restart-server-modal').modal('hide');
alert('Server successfully restarted. Redirecting to your server...');
// Wait a moment for restart to complete, then redirect
setTimeout(function() {
window.location.href = `/user/${username}/lab`;
}, 2000);
},
error: function(xhr) {
$('#restart-server-modal').modal('hide');
const errorMsg = xhr.responseJSON?.message || xhr.statusText || 'Failed to restart server';
alert(`Error: ${errorMsg}`);
$btn.prop('disabled', false).html('<i class="fa fa-check"></i> Yes, Restart Server');
}
});
});
// Helper function to get cookie value
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
}
});
</script>
{% endblock script %}