feat: selective notification recipients

Add ability to send notifications to selected servers instead of all.
- New ActiveServersHandler at GET /api/notifications/active-servers
- BroadcastNotificationHandler accepts optional recipients array
- UI with "Send to all" checkbox and server selection list
- Select All/Deselect All buttons, dynamic button text
- Backward compatible - sends to all if recipients not specified
This commit is contained in:
stellarshenson
2026-01-14 12:46:49 +01:00
parent f5874b1e94
commit cd9c6bf7fa
6 changed files with 286 additions and 17 deletions

View File

@@ -135,3 +135,6 @@ This journal tracks substantive work on documents, diagrams, and documentation c
44. **Task - Fix volume reset encoding**: Fixed Docker volume/container name encoding for special characters<br>
**Result**: Added encode_username_for_docker() function using escapism library (same as DockerSpawner) to ensure compatibility with JupyterHub's naming scheme. Updated ManageVolumesHandler (line 152), RestartServerHandler (line 227), and BroadcastNotificationHandler (line 454) to use encoded usernames. Handles special characters like `.` -> `-2e`, `@` -> `-40` matching JupyterHub's default encoding
45. **Task - Selective notification recipients**: Enhanced notification broadcast to allow targeting specific servers<br>
**Result**: Added ActiveServersHandler (`GET /api/notifications/active-servers`) to list active servers. Modified BroadcastNotificationHandler to accept optional `recipients` array - filters to selected users if provided, sends to all if omitted (backward compatible). Updated notifications.html with "Send to all active servers" checkbox, server selection list with Select All/Deselect All buttons, dynamic button text showing recipient count. Validation prevents sending with no recipients selected

View File

@@ -407,6 +407,7 @@ if c is not None:
ManageVolumesHandler,
RestartServerHandler,
NotificationsPageHandler,
ActiveServersHandler,
BroadcastNotificationHandler,
GetUserCredentialsHandler
)
@@ -414,6 +415,7 @@ if c is not None:
c.JupyterHub.extra_handlers = [
(r'/api/users/([^/]+)/manage-volumes', ManageVolumesHandler),
(r'/api/users/([^/]+)/restart-server', RestartServerHandler),
(r'/api/notifications/active-servers', ActiveServersHandler),
(r'/api/notifications/broadcast', BroadcastNotificationHandler),
(r'/api/admin/credentials', GetUserCredentialsHandler),
(r'/notifications', NotificationsPageHandler),

View File

@@ -3,7 +3,7 @@ PROJECT_NAME="stellars-jupyterhub-ds"
PROJECT_DESCRIPTION="Multi-user JupyterHub 4 deployment platform with data science stack, GPU auto-detection, NativeAuthenticator, and isolated per-user environments spawned via DockerSpawner"
# Version
VERSION="3.5.44_cuda-12.9.1_jh-5.4.2"
VERSION="3.5.46_cuda-12.9.1_jh-5.4.2"
VERSION_COMMENT="Admin user creation with auto-generated passwords, NativeAuth sync, custom templates"
RELEASE_TAG="RELEASE_3.2.11"
RELEASE_DATE="2025-11-09"

View File

@@ -270,18 +270,47 @@ class NotificationsPageHandler(BaseHandler):
self.finish(html)
class ActiveServersHandler(BaseHandler):
"""Handler for listing active servers for notification targeting"""
@web.authenticated
async def get(self):
"""
List all active JupyterLab servers (admin only)
GET /hub/api/notifications/active-servers
Returns: {"servers": [{"username": "user1"}, {"username": "user2"}, ...]}
"""
current_user = self.current_user
if not current_user.admin:
raise web.HTTPError(403, "Only administrators can list active servers")
self.log.info(f"[Active Servers] Request from admin: {current_user.name}")
active_servers = []
from jupyterhub import orm
for orm_user in self.db.query(orm.User).all():
user = self.find_user(orm_user.name)
if user and user.spawner and user.spawner.active:
active_servers.append({"username": user.name})
self.log.info(f"[Active Servers] Found {len(active_servers)} active server(s)")
self.finish({"servers": active_servers})
class BroadcastNotificationHandler(BaseHandler):
"""Handler for broadcasting notifications to all active JupyterLab servers"""
"""Handler for broadcasting notifications to active JupyterLab servers"""
async def post(self):
"""
Broadcast a notification to all active JupyterLab servers
Broadcast a notification to active JupyterLab servers
POST /hub/api/notifications/broadcast
Body: {
"message": "string",
"variant": "info|success|warning|error",
"autoClose": false
"autoClose": false,
"recipients": ["user1", "user2"] # optional - if omitted, sends to all
}
"""
self.log.info(f"[Broadcast Notification] API endpoint called")
@@ -305,8 +334,9 @@ class BroadcastNotificationHandler(BaseHandler):
message = data.get('message', '').strip()
variant = data.get('variant', 'info')
auto_close = data.get('autoClose', False)
recipients = data.get('recipients', None) # Optional: list of usernames
self.log.info(f"[Broadcast Notification] Message: {message[:50]}..., Variant: {variant}, AutoClose: {auto_close}")
self.log.info(f"[Broadcast Notification] Message: {message[:50]}..., Variant: {variant}, AutoClose: {auto_close}, Recipients: {recipients or 'all'}")
except Exception as e:
self.log.error(f"[Broadcast Notification] Failed to parse request body: {e}")
return self.send_error(400, "Invalid request body")
@@ -338,6 +368,12 @@ class BroadcastNotificationHandler(BaseHandler):
self.log.info(f"[Broadcast Notification] Found {len(active_spawners)} active server(s)")
# 4. Filter by recipients if specified
if recipients and isinstance(recipients, list) and len(recipients) > 0:
recipients_set = set(recipients)
active_spawners = [(u, s) for u, s in active_spawners if u.name in recipients_set]
self.log.info(f"[Broadcast Notification] Filtered to {len(active_spawners)} selected recipient(s)")
if not active_spawners:
self.log.info(f"[Broadcast Notification] No active servers found")
return self.finish({

View File

@@ -42,9 +42,29 @@
</label>
</div>
<div class="mb-3">
<label class="form-label">Recipients</label>
<div class="form-check">
<input type="checkbox" class="form-check-input" id="send-to-all" checked>
<label class="form-check-label" for="send-to-all">
<strong>Send to all active servers</strong>
</label>
</div>
<div id="server-selection" class="mt-2" style="display: none;">
<div class="d-flex gap-2 mb-2">
<button type="button" class="btn btn-outline-secondary btn-sm" id="select-all">Select All</button>
<button type="button" class="btn btn-outline-secondary btn-sm" id="deselect-all">Deselect All</button>
</div>
<div id="server-list" class="border rounded p-2" style="max-height: 200px; overflow-y: auto;">
<span class="text-muted">Loading active servers...</span>
</div>
</div>
</div>
<button type="submit" class="btn btn-primary btn-lg" id="send-btn">
<i class="fa fa-paper-plane" aria-hidden="true"></i>
Send to All Active Servers
<span id="send-btn-text">Send to All Active Servers</span>
</button>
</form>
</div>
@@ -89,6 +109,79 @@
require(["jquery"], function($) {
"use strict";
var activeServers = [];
// Fetch active servers on page load
$.ajax({
url: '{{ base_url }}api/notifications/active-servers',
method: 'GET',
headers: {
'X-XSRFToken': getCookie('_xsrf')
},
success: function(data) {
activeServers = data.servers || [];
renderServerList();
},
error: function() {
$('#server-list').html('<span class="text-danger">Failed to load servers</span>');
}
});
function renderServerList() {
var $list = $('#server-list');
if (activeServers.length === 0) {
$list.html('<span class="text-muted">No active servers</span>');
return;
}
var html = '';
activeServers.forEach(function(server) {
html += '<div class="form-check">' +
'<input type="checkbox" class="form-check-input server-checkbox" ' +
'id="server-' + escapeHtml(server.username) + '" ' +
'value="' + escapeHtml(server.username) + '" checked>' +
'<label class="form-check-label" for="server-' + escapeHtml(server.username) + '">' +
escapeHtml(server.username) + '</label></div>';
});
$list.html(html);
// Bind change event for button text update
$('.server-checkbox').on('change', updateButtonText);
}
// Toggle server selection visibility
$('#send-to-all').on('change', function() {
$('#server-selection').toggle(!this.checked);
updateButtonText();
});
// Select All / Deselect All
$('#select-all').on('click', function() {
$('.server-checkbox').prop('checked', true);
updateButtonText();
});
$('#deselect-all').on('click', function() {
$('.server-checkbox').prop('checked', false);
updateButtonText();
});
function updateButtonText() {
var sendToAll = $('#send-to-all').is(':checked');
var selectedCount = $('.server-checkbox:checked').length;
var totalCount = activeServers.length;
if (sendToAll) {
$('#send-btn-text').text('Send to All Active Servers');
} else if (selectedCount === 0) {
$('#send-btn-text').text('Select Recipients');
} else if (selectedCount === totalCount) {
$('#send-btn-text').text('Send to All ' + totalCount + ' Server(s)');
} else {
$('#send-btn-text').text('Send to ' + selectedCount + ' Server(s)');
}
}
// Character counter
$('#message').on('input', function() {
var length = $(this).val().length;
@@ -103,6 +196,21 @@ require(["jquery"], function($) {
var message = $('#message').val().trim();
var variant = $('#variant').val();
var autoClose = $('#autoClose').is(':checked');
var sendToAll = $('#send-to-all').is(':checked');
// Build recipients list
var recipients = null;
if (!sendToAll) {
recipients = [];
$('.server-checkbox:checked').each(function() {
recipients.push($(this).val());
});
if (recipients.length === 0) {
alert('Please select at least one recipient');
return;
}
}
// Validate
if (!message) {
@@ -124,6 +232,16 @@ require(["jquery"], function($) {
// Hide previous results
$('#results-section').hide();
// Build request payload
var payload = {
message: message,
variant: variant,
autoClose: autoClose
};
if (recipients !== null) {
payload.recipients = recipients;
}
// Send request
$.ajax({
url: '{{ base_url }}api/notifications/broadcast',
@@ -132,11 +250,7 @@ require(["jquery"], function($) {
headers: {
'X-XSRFToken': getCookie('_xsrf')
},
data: JSON.stringify({
message: message,
variant: variant,
autoClose: autoClose
}),
data: JSON.stringify(payload),
success: function(data) {
displayResults(data);
// Clear form

View File

@@ -42,9 +42,29 @@
</label>
</div>
<div class="mb-3">
<label class="form-label">Recipients</label>
<div class="form-check">
<input type="checkbox" class="form-check-input" id="send-to-all" checked>
<label class="form-check-label" for="send-to-all">
<strong>Send to all active servers</strong>
</label>
</div>
<div id="server-selection" class="mt-2" style="display: none;">
<div class="d-flex gap-2 mb-2">
<button type="button" class="btn btn-outline-secondary btn-sm" id="select-all">Select All</button>
<button type="button" class="btn btn-outline-secondary btn-sm" id="deselect-all">Deselect All</button>
</div>
<div id="server-list" class="border rounded p-2" style="max-height: 200px; overflow-y: auto;">
<span class="text-muted">Loading active servers...</span>
</div>
</div>
</div>
<button type="submit" class="btn btn-primary btn-lg" id="send-btn">
<i class="fa fa-paper-plane" aria-hidden="true"></i>
Send to All Active Servers
<span id="send-btn-text">Send to All Active Servers</span>
</button>
</form>
</div>
@@ -89,6 +109,79 @@
require(["jquery"], function($) {
"use strict";
var activeServers = [];
// Fetch active servers on page load
$.ajax({
url: '{{ base_url }}api/notifications/active-servers',
method: 'GET',
headers: {
'X-XSRFToken': getCookie('_xsrf')
},
success: function(data) {
activeServers = data.servers || [];
renderServerList();
},
error: function() {
$('#server-list').html('<span class="text-danger">Failed to load servers</span>');
}
});
function renderServerList() {
var $list = $('#server-list');
if (activeServers.length === 0) {
$list.html('<span class="text-muted">No active servers</span>');
return;
}
var html = '';
activeServers.forEach(function(server) {
html += '<div class="form-check">' +
'<input type="checkbox" class="form-check-input server-checkbox" ' +
'id="server-' + escapeHtml(server.username) + '" ' +
'value="' + escapeHtml(server.username) + '" checked>' +
'<label class="form-check-label" for="server-' + escapeHtml(server.username) + '">' +
escapeHtml(server.username) + '</label></div>';
});
$list.html(html);
// Bind change event for button text update
$('.server-checkbox').on('change', updateButtonText);
}
// Toggle server selection visibility
$('#send-to-all').on('change', function() {
$('#server-selection').toggle(!this.checked);
updateButtonText();
});
// Select All / Deselect All
$('#select-all').on('click', function() {
$('.server-checkbox').prop('checked', true);
updateButtonText();
});
$('#deselect-all').on('click', function() {
$('.server-checkbox').prop('checked', false);
updateButtonText();
});
function updateButtonText() {
var sendToAll = $('#send-to-all').is(':checked');
var selectedCount = $('.server-checkbox:checked').length;
var totalCount = activeServers.length;
if (sendToAll) {
$('#send-btn-text').text('Send to All Active Servers');
} else if (selectedCount === 0) {
$('#send-btn-text').text('Select Recipients');
} else if (selectedCount === totalCount) {
$('#send-btn-text').text('Send to All ' + totalCount + ' Server(s)');
} else {
$('#send-btn-text').text('Send to ' + selectedCount + ' Server(s)');
}
}
// Character counter
$('#message').on('input', function() {
var length = $(this).val().length;
@@ -103,6 +196,21 @@ require(["jquery"], function($) {
var message = $('#message').val().trim();
var variant = $('#variant').val();
var autoClose = $('#autoClose').is(':checked');
var sendToAll = $('#send-to-all').is(':checked');
// Build recipients list
var recipients = null;
if (!sendToAll) {
recipients = [];
$('.server-checkbox:checked').each(function() {
recipients.push($(this).val());
});
if (recipients.length === 0) {
alert('Please select at least one recipient');
return;
}
}
// Validate
if (!message) {
@@ -124,6 +232,16 @@ require(["jquery"], function($) {
// Hide previous results
$('#results-section').hide();
// Build request payload
var payload = {
message: message,
variant: variant,
autoClose: autoClose
};
if (recipients !== null) {
payload.recipients = recipients;
}
// Send request
$.ajax({
url: '{{ base_url }}api/notifications/broadcast',
@@ -132,11 +250,7 @@ require(["jquery"], function($) {
headers: {
'X-XSRFToken': getCookie('_xsrf')
},
data: JSON.stringify({
message: message,
variant: variant,
autoClose: autoClose
}),
data: JSON.stringify(payload),
success: function(data) {
displayResults(data);
// Clear form