Skip to content

Commit 926c25e

Browse files
Fetch container uptime in timezone-independent way
1 parent 7fd151e commit 926c25e

File tree

6 files changed

+93
-48
lines changed

6 files changed

+93
-48
lines changed

stack/lab/Dockerfile

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -45,14 +45,25 @@ RUN jupyter nbextension enable --py --sys-prefix appmode && \
4545
jupyter serverextension enable --py --sys-prefix appmode
4646

4747
# Swap appmode icon for AiiDAlab gears icon, shown during app load
48-
COPY --chown=${NB_UID}:${NB_GID} gears.svg ${CONDA_DIR}/share/jupyter/nbextensions/appmode/gears.svg
48+
COPY gears.svg ${CONDA_DIR}/share/jupyter/nbextensions/appmode/gears.svg
4949

50-
# Set up opt-in countdown feature
5150
ARG PYTHON_MINOR_VERSION
52-
ENV PYTHON_MINOR_VERSION=${PYTHON_MINOR_VERSION}
53-
COPY --chown=${NB_UID}:${NB_GID} countdown/ ${CONDA_DIR}/lib/python${PYTHON_MINOR_VERSION}/site-packages/notebook/static/custom/
51+
52+
# Set up the logo of notebook interface
53+
COPY aiidalab-wide-logo.png ${CONDA_DIR}/lib/python${PYTHON_MINOR_VERSION}/site-packages/notebook/static/base/images/logo.png
54+
55+
# Copy custom CSS and JS files for AiiDAlab container countdown feature
56+
COPY countdown/ ${CONDA_DIR}/lib/python${PYTHON_MINOR_VERSION}/site-packages/notebook/static/custom/
57+
58+
# Add endpoint to fetch container uptime
59+
COPY aiidalab_uptime.py ${CONDA_DIR}/lib/python${PYTHON_MINOR_VERSION}/site-packages/
60+
COPY aiidalab_uptime.json ${CONDA_DIR}/etc/jupyter/jupyter_notebook_config.d/
5461

5562
# Copy start-up scripts for AiiDAlab.
63+
# We expose PYTHON_MINOR_VERSION in the container, as it is used by the
64+
# before-notebook.d/70_prepare_countdown_config.sh script to write the
65+
# environment-variable-dependent (opt-in) configuration file.
66+
ENV PYTHON_MINOR_VERSION=${PYTHON_MINOR_VERSION}
5667
COPY before-notebook.d/* /usr/local/bin/before-notebook.d/
5768

5869
# Configure AiiDAlab environment.
@@ -76,12 +87,6 @@ ENV AIIDALAB_DEFAULT_APPS=""
7687
# Specify default factory reset (not set):
7788
ENV AIIDALAB_FACTORY_RESET=""
7889

79-
USER ${NB_USER}
80-
81-
WORKDIR "/home/${NB_USER}"
82-
83-
RUN mkdir -p /home/${NB_USER}/apps
84-
8590
# When a Jupyter notebook server looses a connection to the frontend,
8691
# it keeps the messages in a buffer. If there is a background thread running
8792
# and trying to update the frontend, the buffer grows indefinitely,
@@ -103,5 +108,8 @@ ENV NOTEBOOK_ARGS=\
103108
"--TerminalManager.cull_inactive_timeout=3600 "\
104109
"--TerminalManager.cull_interval=300"
105110

106-
# Set up the logo of notebook interface
107-
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
111+
USER ${NB_USER}
112+
113+
WORKDIR "/home/${NB_USER}"
114+
115+
RUN mkdir -p /home/${NB_USER}/apps

stack/lab/aiidalab_uptime.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"NotebookApp": {
3+
"nbserver_extensions": {
4+
"aiidalab_uptime": true
5+
}
6+
}
7+
}

stack/lab/aiidalab_uptime.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import re
2+
import subprocess
3+
4+
from notebook.base.handlers import IPythonHandler
5+
from notebook.utils import url_path_join
6+
7+
8+
class UptimeHandler(IPythonHandler):
9+
def get(self):
10+
try:
11+
output = subprocess.check_output(["ps", "-p", "1", "-o", "etime="])
12+
etime = output.decode("utf-8").strip()
13+
seconds = self._parse_etime_to_seconds(etime)
14+
self.finish({"uptime": seconds})
15+
except Exception as e:
16+
self.set_status(500)
17+
self.finish({"error": str(e)})
18+
19+
def _parse_etime_to_seconds(self, etime):
20+
# Supports MM:SS, HH:MM:SS, or D-HH:MM:SS formats
21+
match = re.match(r"(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)", etime)
22+
if not match:
23+
raise ValueError(f"Unrecognized etime format: {etime}")
24+
25+
days, hours, minutes, seconds = match.groups(default="0")
26+
return int(days) * 86400 + int(hours) * 3600 + int(minutes) * 60 + int(seconds)
27+
28+
29+
def load_jupyter_server_extension(nb_server_app):
30+
web_app = nb_server_app.web_app
31+
route = url_path_join(web_app.settings["base_url"], "/uptime")
32+
web_app.add_handlers(".*", [(route, UptimeHandler)])

stack/lab/before-notebook.d/70_prepare_countdown_config.sh

Lines changed: 6 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,9 @@ set -e
33

44
CUSTOM_DIR="${CONDA_DIR}/lib/python${PYTHON_MINOR_VERSION}/site-packages/notebook/static/custom"
55

6-
if [ "$LIFETIME" ]; then
7-
EPHEMERAL=true
8-
9-
# Convert LIFETIME from HH:MM:SS to seconds
10-
IFS=: read -r H M S <<<"$LIFETIME"
11-
LIFETIME_SEC=$((10#$H * 3600 + 10#$M * 60 + 10#$S))
12-
13-
# Calculate expiry timestamp in UTC
14-
EXPIRY=$(date -u -d "+${LIFETIME_SEC} seconds" +"%Y-%m-%dT%H:%M:%SZ")
15-
export EXPIRY
16-
else
17-
EPHEMERAL=false
18-
fi
19-
20-
export EPHEMERAL
21-
envsubst <"${CUSTOM_DIR}/config.json.template" >"$CUSTOM_DIR/config.json"
22-
rm "${CUSTOM_DIR}/config.json.template"
6+
cat <<EOF >"${CUSTOM_DIR}/config.json"
7+
{
8+
"ephemeral": $([ -n "$LIFETIME" ] && echo 1 || echo 0),
9+
"lifetime": "$LIFETIME"
10+
}
11+
EOF

stack/lab/countdown/config.json.template

Lines changed: 0 additions & 4 deletions
This file was deleted.

stack/lab/countdown/custom.js

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
11
require(["base/js/namespace", "base/js/events"], (Jupyter, events) => {
22
const parseLifetimeToMs = (str) => {
3-
const parts = str.split(":").map(Number);
4-
if (parts.length !== 3 || parts.some(isNaN)) {
3+
// Supports MM:SS, HH:MM:SS, or D-HH:MM:SS formats
4+
const regex = /^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/;
5+
const match = regex.exec(str.trim());
6+
7+
if (!match) {
8+
console.warn("Invalid lifetime format:", str);
59
return null;
610
}
7-
const [h, m, s] = parts;
8-
return ((h * 60 + m) * 60 + s) * 1000;
11+
12+
const [_, days, hours, minutes, seconds] = match.map((v) => Number(v) || 0);
13+
14+
const totalSeconds = days * 86400 + hours * 3600 + minutes * 60 + seconds;
15+
return totalSeconds * 1000;
916
};
1017

1118
const insertCountdown = (remainingMs) => {
@@ -37,18 +44,19 @@ require(["base/js/namespace", "base/js/events"], (Jupyter, events) => {
3744
`;
3845
banner.appendChild(saveInfo);
3946

40-
const endTime = new Date(Date.now() + remainingMs);
47+
const startTime = Date.now();
48+
const endTime = startTime + remainingMs;
4149

4250
const formatTime = (seconds) => {
4351
const hrs = `${Math.floor(seconds / 3600)}`.padStart(2, "0");
4452
const mins = `${Math.floor((seconds % 3600) / 60)}`.padStart(2, "0");
4553
const secs = `${Math.floor(seconds % 60)}`.padStart(2, "0");
54+
// Format as HH:MM:SS, even if > 1 day (for now - rare?)
4655
return `${hrs}:${mins}:${secs}`;
4756
};
4857

4958
const updateTimer = () => {
50-
const now = new Date();
51-
const timeLeft = (endTime - now) / 1000;
59+
const timeLeft = (endTime - Date.now()) / 1000;
5260
if (timeLeft < 0) {
5361
clearInterval(interval);
5462
return;
@@ -74,23 +82,28 @@ require(["base/js/namespace", "base/js/events"], (Jupyter, events) => {
7482
}
7583
};
7684

77-
loadCountdown = async () => {
85+
const loadCountdown = async () => {
7886
try {
79-
const response = await fetch("/static/custom/config.json");
80-
const config = await response.json();
87+
const [configResponse, uptimeResponse] = await Promise.all([
88+
fetch("/static/custom/config.json"),
89+
fetch("/uptime"),
90+
]);
91+
92+
const config = await configResponse.json();
93+
const uptimeData = await uptimeResponse.json();
8194

82-
// Opt-in point for deployments
8395
if (!config.ephemeral) {
8496
return;
8597
}
8698

87-
if (!config.expiry) {
88-
console.warn("Missing `expiry` in config file");
99+
if (!config.lifetime) {
100+
console.warn("Missing `lifetime` in config file");
89101
return;
90102
}
91103

92-
const expiry = new Date(config.expiry).getTime();
93-
const remaining = expiry - Date.now();
104+
const lifetimeMs = parseLifetimeToMs(config.lifetime);
105+
const uptimeMs = uptimeData.uptime * 1000;
106+
const remaining = lifetimeMs - uptimeMs;
94107

95108
insertCountdown(Math.max(0, remaining));
96109
} catch (err) {

0 commit comments

Comments
 (0)