diff --git a/.claude/JOURNAL.md b/.claude/JOURNAL.md
index 93f2739..1ac9286 100644
--- a/.claude/JOURNAL.md
+++ b/.claude/JOURNAL.md
@@ -144,3 +144,6 @@ This journal tracks substantive work on documents, diagrams, and documentation c
47. **Task - Standardize env vars with JUPYTERHUB_ prefix**: Renamed all environment variables to use consistent JUPYTERHUB_ prefix and added admin Settings page
**Result**: Renamed 13 environment variables (ENABLE_GPU_SUPPORT->JUPYTERHUB_GPU_ENABLED, ENABLE_JUPYTERHUB_SSL->JUPYTERHUB_SSL_ENABLED, ENABLE_SERVICE_*->JUPYTERHUB_SERVICE_*, ENABLE_SIGNUP->JUPYTERHUB_SIGNUP_ENABLED, DOCKER_NOTEBOOK_IMAGE->JUPYTERHUB_NOTEBOOK_IMAGE, DOCKER_NETWORK_NAME->JUPYTERHUB_NETWORK_NAME, NVIDIA_AUTODETECT_IMAGE->JUPYTERHUB_NVIDIA_IMAGE, IDLE_CULLER_*->JUPYTERHUB_IDLE_CULLER_*). Created SettingsPageHandler with admin-only access at /settings showing read-only banded table of all configuration values. Updated compose.yml, jupyterhub_config.py, custom_handlers.py, README.md, CLAUDE.md. Added Settings link to admin navbar. No backward compatibility for old names. Bumped version to 3.6.1
+
+48. **Task - Settings dictionary YAML**: Externalized settings metadata to config/settings_dictionary.yml
+ **Result**: Created settings_dictionary.yml with categories as top-level keys (JupyterHub Core, Docker Spawner, GPU, Services, Idle Culler, Branding), each containing list of settings with name, description, default, and optional empty_display. Updated SettingsPageHandler to load from YAML instead of hardcoded values. Added pyyaml to Dockerfile pip install. Dockerfile now copies settings_dictionary.yml to /srv/jupyterhub/
diff --git a/config/settings_dictionary.yml b/config/settings_dictionary.yml
new file mode 100644
index 0000000..aed5b2f
--- /dev/null
+++ b/config/settings_dictionary.yml
@@ -0,0 +1,79 @@
+# Settings Dictionary
+# Defines environment variables displayed on the admin Settings page
+# Categories are top-level keys, each containing a list of settings
+# Each setting has: name (env var), description (UI label), default (fallback value)
+
+JupyterHub Core:
+ - name: JUPYTERHUB_ADMIN
+ description: Admin username
+ default: admin
+
+ - name: JUPYTERHUB_BASE_URL
+ description: Base URL path
+ default: /jupyterhub
+
+ - name: JUPYTERHUB_SIGNUP_ENABLED
+ description: User self-registration (0=disabled, 1=enabled)
+ default: "1"
+
+ - name: JUPYTERHUB_SSL_ENABLED
+ description: SSL/TLS (0=disabled, 1=enabled)
+ default: "1"
+
+Docker Spawner:
+ - name: JUPYTERHUB_NOTEBOOK_IMAGE
+ description: User container image
+ default: stellars/stellars-jupyterlab-ds:latest
+
+ - name: JUPYTERHUB_NETWORK_NAME
+ description: Docker network for containers
+ default: jupyterhub_network
+
+GPU:
+ - name: JUPYTERHUB_GPU_ENABLED
+ description: GPU support (0=disabled, 1=enabled, 2=auto-detect)
+ default: "2"
+
+ - name: JUPYTERHUB_NVIDIA_IMAGE
+ description: CUDA image for GPU detection
+ default: nvidia/cuda:12.9.1-base-ubuntu24.04
+
+Services:
+ - name: JUPYTERHUB_SERVICE_MLFLOW
+ description: MLflow service (0=disabled, 1=enabled)
+ default: "1"
+
+ - name: JUPYTERHUB_SERVICE_GLANCES
+ description: Glances service (0=disabled, 1=enabled)
+ default: "1"
+
+ - name: JUPYTERHUB_SERVICE_TENSORBOARD
+ description: TensorBoard service (0=disabled, 1=enabled)
+ default: "1"
+
+Idle Culler:
+ - name: JUPYTERHUB_IDLE_CULLER_ENABLED
+ description: Idle culler (0=disabled, 1=enabled)
+ default: "0"
+
+ - name: JUPYTERHUB_IDLE_CULLER_TIMEOUT
+ description: Idle timeout in seconds
+ default: "86400"
+
+ - name: JUPYTERHUB_IDLE_CULLER_INTERVAL
+ description: Check interval in seconds
+ default: "600"
+
+ - name: JUPYTERHUB_IDLE_CULLER_MAX_AGE
+ description: Max server age (0=unlimited)
+ default: "0"
+
+Branding:
+ - name: JUPYTERHUB_CUSTOM_LOGO_URI
+ description: Custom logo URI
+ default: ""
+ empty_display: "(default)"
+
+ - name: STELLARS_JUPYTERHUB_VERSION
+ description: Platform version
+ default: unknown
diff --git a/services/jupyterhub/Dockerfile.jupyterhub b/services/jupyterhub/Dockerfile.jupyterhub
index a155003..1ae0d8b 100644
--- a/services/jupyterhub/Dockerfile.jupyterhub
+++ b/services/jupyterhub/Dockerfile.jupyterhub
@@ -42,13 +42,18 @@ COPY --chmod=600 services/jupyterhub/templates/certs /mnt/certs
COPY --chmod=644 services/jupyterhub/templates_enhanced/*.html /srv/jupyterhub/templates/
COPY --chmod=644 services/jupyterhub/templates_enhanced/static/custom.css /tmp/custom.css
COPY --chmod=644 config/jupyterhub_config.py /srv/jupyterhub/jupyterhub_config.py
+COPY --chmod=644 config/settings_dictionary.yml /srv/jupyterhub/settings_dictionary.yml
-## install dockerspawner, nativeauthenticator, idle-culler
-RUN pip install -U --no-cache-dir \
- docker \
- dockerspawner \
- jupyterhub-nativeauthenticator \
- jupyterhub-idle-culler
+## install dockerspawner, nativeauthenticator, idle-culler, pyyaml
+RUN <<-EOF
+ echo "installing core jupyterhub python packages"
+ pip install -U --no-cache-dir \
+ docker \
+ dockerspawner \
+ jupyterhub-nativeauthenticator \
+ jupyterhub-idle-culler \
+ pyyaml
+EOF
## copy custom.css to JupyterHub's static directory
RUN <<-EOF
diff --git a/services/jupyterhub/conf/bin/custom_handlers.py b/services/jupyterhub/conf/bin/custom_handlers.py
index f5de323..515c6d8 100755
--- a/services/jupyterhub/conf/bin/custom_handlers.py
+++ b/services/jupyterhub/conf/bin/custom_handlers.py
@@ -566,6 +566,9 @@ class GetUserCredentialsHandler(BaseHandler):
class SettingsPageHandler(BaseHandler):
"""Handler for rendering the settings page (admin only, read-only)"""
+ # Settings dictionary file path
+ SETTINGS_DICT_PATH = "/srv/jupyterhub/settings_dictionary.yml"
+
@web.authenticated
async def get(self):
"""
@@ -581,38 +584,48 @@ class SettingsPageHandler(BaseHandler):
self.log.info(f"[Settings Page] Admin {current_user.name} accessed settings panel")
- # Collect environment variables and settings
- settings = []
-
- # JupyterHub Core Settings
- settings.append({"category": "JupyterHub Core", "name": "JUPYTERHUB_ADMIN", "value": os.environ.get("JUPYTERHUB_ADMIN", "admin"), "description": "Admin username"})
- settings.append({"category": "JupyterHub Core", "name": "JUPYTERHUB_BASE_URL", "value": os.environ.get("JUPYTERHUB_BASE_URL", "/jupyterhub"), "description": "Base URL path"})
- settings.append({"category": "JupyterHub Core", "name": "JUPYTERHUB_SIGNUP_ENABLED", "value": os.environ.get("JUPYTERHUB_SIGNUP_ENABLED", "1"), "description": "User self-registration (0=disabled, 1=enabled)"})
- settings.append({"category": "JupyterHub Core", "name": "JUPYTERHUB_SSL_ENABLED", "value": os.environ.get("JUPYTERHUB_SSL_ENABLED", "1"), "description": "SSL/TLS (0=disabled, 1=enabled)"})
-
- # Docker/Spawner Settings
- settings.append({"category": "Docker Spawner", "name": "JUPYTERHUB_NOTEBOOK_IMAGE", "value": os.environ.get("JUPYTERHUB_NOTEBOOK_IMAGE", "stellars/stellars-jupyterlab-ds:latest"), "description": "User container image"})
- settings.append({"category": "Docker Spawner", "name": "JUPYTERHUB_NETWORK_NAME", "value": os.environ.get("JUPYTERHUB_NETWORK_NAME", "jupyterhub_network"), "description": "Docker network for containers"})
-
- # GPU Settings
- settings.append({"category": "GPU", "name": "JUPYTERHUB_GPU_ENABLED", "value": os.environ.get("JUPYTERHUB_GPU_ENABLED", "2"), "description": "GPU support (0=disabled, 1=enabled, 2=auto-detect)"})
- settings.append({"category": "GPU", "name": "JUPYTERHUB_NVIDIA_IMAGE", "value": os.environ.get("JUPYTERHUB_NVIDIA_IMAGE", "nvidia/cuda:12.9.1-base-ubuntu24.04"), "description": "CUDA image for GPU detection"})
-
- # Services
- settings.append({"category": "Services", "name": "JUPYTERHUB_SERVICE_MLFLOW", "value": os.environ.get("JUPYTERHUB_SERVICE_MLFLOW", "1"), "description": "MLflow service (0=disabled, 1=enabled)"})
- settings.append({"category": "Services", "name": "JUPYTERHUB_SERVICE_GLANCES", "value": os.environ.get("JUPYTERHUB_SERVICE_GLANCES", "1"), "description": "Glances service (0=disabled, 1=enabled)"})
- settings.append({"category": "Services", "name": "JUPYTERHUB_SERVICE_TENSORBOARD", "value": os.environ.get("JUPYTERHUB_SERVICE_TENSORBOARD", "1"), "description": "TensorBoard service (0=disabled, 1=enabled)"})
-
- # Idle Culler
- settings.append({"category": "Idle Culler", "name": "JUPYTERHUB_IDLE_CULLER_ENABLED", "value": os.environ.get("JUPYTERHUB_IDLE_CULLER_ENABLED", "0"), "description": "Idle culler (0=disabled, 1=enabled)"})
- settings.append({"category": "Idle Culler", "name": "JUPYTERHUB_IDLE_CULLER_TIMEOUT", "value": os.environ.get("JUPYTERHUB_IDLE_CULLER_TIMEOUT", "86400"), "description": "Idle timeout in seconds"})
- settings.append({"category": "Idle Culler", "name": "JUPYTERHUB_IDLE_CULLER_INTERVAL", "value": os.environ.get("JUPYTERHUB_IDLE_CULLER_INTERVAL", "600"), "description": "Check interval in seconds"})
- settings.append({"category": "Idle Culler", "name": "JUPYTERHUB_IDLE_CULLER_MAX_AGE", "value": os.environ.get("JUPYTERHUB_IDLE_CULLER_MAX_AGE", "0"), "description": "Max server age (0=unlimited)"})
-
- # Branding
- settings.append({"category": "Branding", "name": "JUPYTERHUB_CUSTOM_LOGO_URI", "value": os.environ.get("JUPYTERHUB_CUSTOM_LOGO_URI", "") or "(default)", "description": "Custom logo URI"})
- settings.append({"category": "Branding", "name": "STELLARS_JUPYTERHUB_VERSION", "value": os.environ.get("STELLARS_JUPYTERHUB_VERSION", "unknown"), "description": "Platform version"})
+ # Load settings from YAML dictionary
+ settings = self._load_settings()
# Render the template
html = self.render_template("settings.html", sync=True, user=current_user, settings=settings)
self.finish(html)
+
+ def _load_settings(self):
+ """Load settings from YAML dictionary file and populate with env values"""
+ import yaml
+
+ settings = []
+
+ try:
+ with open(self.SETTINGS_DICT_PATH, 'r') as f:
+ config = yaml.safe_load(f)
+
+ # Categories are top-level keys in the YAML
+ for category, items in config.items():
+ # Skip comment lines (strings starting with #)
+ if not isinstance(items, list):
+ continue
+
+ for item in items:
+ name = item.get('name', '')
+ default = str(item.get('default', ''))
+ value = os.environ.get(name, default)
+
+ # Handle empty_display for empty values
+ if not value and 'empty_display' in item:
+ value = item['empty_display']
+
+ settings.append({
+ "category": category,
+ "name": name,
+ "value": value,
+ "description": item.get('description', '')
+ })
+
+ except FileNotFoundError:
+ self.log.error(f"[Settings Page] Settings dictionary not found: {self.SETTINGS_DICT_PATH}")
+ except Exception as e:
+ self.log.error(f"[Settings Page] Error loading settings dictionary: {e}")
+
+ return settings