diff --git a/stack/lab/Dockerfile b/stack/lab/Dockerfile index 22335e05..f0ac77b3 100644 --- a/stack/lab/Dockerfile +++ b/stack/lab/Dockerfile @@ -47,6 +47,11 @@ RUN jupyter nbextension enable --py --sys-prefix appmode && \ # Swap appmode icon for AiiDAlab gears icon, shown during app load COPY --chown=${NB_UID}:${NB_GID} gears.svg ${CONDA_DIR}/share/jupyter/nbextensions/appmode/gears.svg +# Set up opt-in countdown feature +ARG PYTHON_MINOR_VERSION +ENV PYTHON_MINOR_VERSION=${PYTHON_MINOR_VERSION} +COPY --chown=${NB_UID}:${NB_GID} countdown/ ${CONDA_DIR}/lib/python${PYTHON_MINOR_VERSION}/site-packages/notebook/static/custom/ + # Copy start-up scripts for AiiDAlab. COPY before-notebook.d/* /usr/local/bin/before-notebook.d/ @@ -99,5 +104,4 @@ ENV NOTEBOOK_ARGS=\ "--TerminalManager.cull_interval=300" # Set up the logo of notebook interface -ARG PYTHON_MINOR_VERSION COPY --chown=${NB_UID}:${NB_GID} aiidalab-wide-logo.png ${CONDA_DIR}/lib/python${PYTHON_MINOR_VERSION}/site-packages/notebook/static/base/images/logo.png diff --git a/stack/lab/before-notebook.d/70_prepare_countdown_config.sh b/stack/lab/before-notebook.d/70_prepare_countdown_config.sh new file mode 100644 index 00000000..98065ccb --- /dev/null +++ b/stack/lab/before-notebook.d/70_prepare_countdown_config.sh @@ -0,0 +1,22 @@ +#!/bin/bash +set -e + +CUSTOM_DIR="${CONDA_DIR}/lib/python${PYTHON_MINOR_VERSION}/site-packages/notebook/static/custom" + +if [ "$LIFETIME" ]; then + EPHEMERAL=true + + # Convert LIFETIME from HH:MM:SS to seconds + IFS=: read -r H M S <<<"$LIFETIME" + LIFETIME_SEC=$((10#$H * 3600 + 10#$M * 60 + 10#$S)) + + # Calculate expiry timestamp in UTC + EXPIRY=$(date -u -d "+${LIFETIME_SEC} seconds" +"%Y-%m-%dT%H:%M:%SZ") + export EXPIRY +else + EPHEMERAL=false +fi + +export EPHEMERAL +envsubst <"${CUSTOM_DIR}/config.json.template" >"$CUSTOM_DIR/config.json" +rm "${CUSTOM_DIR}/config.json.template" diff --git a/stack/lab/countdown/config.json.template b/stack/lab/countdown/config.json.template new file mode 100644 index 00000000..cf9a674b --- /dev/null +++ b/stack/lab/countdown/config.json.template @@ -0,0 +1,4 @@ +{ + "ephemeral": ${EPHEMERAL}, + "expiry": "${EXPIRY}" +} diff --git a/stack/lab/countdown/custom.css b/stack/lab/countdown/custom.css new file mode 100644 index 00000000..cd8e2a02 --- /dev/null +++ b/stack/lab/countdown/custom.css @@ -0,0 +1,33 @@ +#culling-countdown { + position: sticky; + top: 0; + left: 0; + width: 100%; + background-color: #0078d4; + color: white; + text-align: center; + padding: 8px; + font-size: 18px; + font-weight: bold; + z-index: 9999; +} + +#shutdown-warning, +#save-info { + display: none; +} + +#shutdown-warning { + font-size: 20px; +} + +#save-info { + font-size: 16px; + text-align: center; + font-weight: normal; + font-size: 18px; +} + +#culling-timer { + margin-left: 5px; +} diff --git a/stack/lab/countdown/custom.js b/stack/lab/countdown/custom.js new file mode 100644 index 00000000..46af9277 --- /dev/null +++ b/stack/lab/countdown/custom.js @@ -0,0 +1,103 @@ +require(["base/js/namespace", "base/js/events"], (Jupyter, events) => { + const parseLifetimeToMs = (str) => { + const parts = str.split(":").map(Number); + if (parts.length !== 3 || parts.some(isNaN)) { + return null; + } + const [h, m, s] = parts; + return ((h * 60 + m) * 60 + s) * 1000; + }; + + const insertCountdown = (remainingMs) => { + if (document.getElementById("culling-countdown")) { + return; + } + + const banner = document.createElement("div"); + banner.id = "culling-countdown"; + + const shutdownWarning = document.createElement("div"); + shutdownWarning.id = "shutdown-warning"; + shutdownWarning.innerHTML = "⚠️ Shutdown imminent! ⚠️"; + banner.appendChild(shutdownWarning); + + const countdown = document.createElement("div"); + countdown.id = "countdown"; + countdown.innerHTML = `Session time remaining: `; + const timer = document.createElement("span"); + timer.id = "culling-timer"; + timer.innerHTML = "Calculating..."; + countdown.appendChild(timer); + banner.appendChild(countdown); + + const saveInfo = document.createElement("div"); + saveInfo.id = "save-info"; + saveInfo.innerHTML = ` + Consider saving your work using the File Manager or the Terminal + `; + banner.appendChild(saveInfo); + + const endTime = new Date(Date.now() + remainingMs); + + const formatTime = (seconds) => { + const hrs = `${Math.floor(seconds / 3600)}`.padStart(2, "0"); + const mins = `${Math.floor((seconds % 3600) / 60)}`.padStart(2, "0"); + const secs = `${Math.floor(seconds % 60)}`.padStart(2, "0"); + return `${hrs}:${mins}:${secs}`; + }; + + const updateTimer = () => { + const now = new Date(); + const timeLeft = (endTime - now) / 1000; + if (timeLeft < 0) { + clearInterval(interval); + return; + } + if (timeLeft < 1800) { + banner.style.backgroundColor = "#DAA801"; + saveInfo.style.display = "block"; + } + if (timeLeft < 300) { + banner.style.backgroundColor = "red"; + shutdownWarning.style.display = "block"; + shutdownWarning.innerHTML = "⚠️ Shutdown imminent ⚠️"; + } + timer.innerHTML = formatTime(timeLeft); + }; + + updateTimer(); + const interval = setInterval(updateTimer, 1000); + + const container = document.getElementById("header"); + if (container) { + container.parentNode.insertBefore(banner, container); + } + }; + + loadCountdown = async () => { + try { + const response = await fetch("/static/custom/config.json"); + const config = await response.json(); + + // Opt-in point for deployments + if (!config.ephemeral) { + return; + } + + if (!config.expiry) { + console.warn("Missing `expiry` in config file"); + return; + } + + const expiry = new Date(config.expiry).getTime(); + const remaining = expiry - Date.now(); + + insertCountdown(Math.max(0, remaining)); + } catch (err) { + console.error("Countdown init failed:", err); + } + }; + + events.on("app_initialized.NotebookApp", loadCountdown); + loadCountdown(); +});