-
Notifications
You must be signed in to change notification settings - Fork 15
Implement opt-in countdown feature and add to image recipe #525
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| { | ||
| "NotebookApp": { | ||
| "nbserver_extensions": { | ||
| "aiidalab_uptime": true | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| import re | ||
| import subprocess | ||
|
|
||
| from notebook.base.handlers import IPythonHandler | ||
| from notebook.utils import url_path_join | ||
|
|
||
|
|
||
| class UptimeHandler(IPythonHandler): | ||
| def get(self): | ||
| try: | ||
| output = subprocess.check_output(["ps", "-p", "1", "-o", "etime="]) | ||
| etime = output.decode("utf-8").strip() | ||
| seconds = self._parse_etime_to_seconds(etime) | ||
| self.finish({"uptime": seconds}) | ||
| except Exception as e: | ||
| self.set_status(500) | ||
| self.finish({"error": str(e)}) | ||
|
|
||
| def _parse_etime_to_seconds(self, etime): | ||
| # Supports MM:SS, HH:MM:SS, or D-HH:MM:SS formats | ||
| match = re.match(r"(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)", etime) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we use some native parsing from stdlib, perhaps datetime or time modules?
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same comment as above |
||
| if not match: | ||
| raise ValueError(f"Unrecognized etime format: {etime}") | ||
|
|
||
| days, hours, minutes, seconds = match.groups(default="0") | ||
| return int(days) * 86400 + int(hours) * 3600 + int(minutes) * 60 + int(seconds) | ||
|
|
||
|
|
||
| def load_jupyter_server_extension(nb_server_app): | ||
| web_app = nb_server_app.web_app | ||
| route = url_path_join(web_app.settings["base_url"], "/uptime") | ||
| web_app.add_handlers(".*", [(route, UptimeHandler)]) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| #!/bin/bash | ||
| set -e | ||
|
|
||
| CUSTOM_DIR="${CONDA_DIR}/lib/python${PYTHON_MINOR_VERSION}/site-packages/notebook/static/custom" | ||
|
|
||
| cat <<EOF >"${CUSTOM_DIR}/config.json" | ||
| { | ||
| "ephemeral": $([ -n "$LIFETIME" ] && echo 1 || echo 0), | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. LIFETIME is quite generic. Perhaps
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hmm, I think I'll keep this as is for this version. Happy to discuss this with the team at the next meeting. |
||
| "lifetime": "$LIFETIME" | ||
| } | ||
| EOF | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,116 @@ | ||
| require(["base/js/namespace", "base/js/events"], (Jupyter, events) => { | ||
| const parseLifetimeToMs = (str) => { | ||
| // Supports MM:SS, HH:MM:SS, or D-HH:MM:SS formats | ||
| const regex = /^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not stoked about this regex and the custom parsing. Can we instead use some native API, perhaps the Date object? https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date#date_time_string_format
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These APIs tend to work best with standard time formats, of which the one I'm working with here is not. Regex gives me more precise control of the format, as well as a clean way to provide defaults. This is true in both languages. |
||
| const match = regex.exec(str.trim()); | ||
|
|
||
| if (!match) { | ||
| console.warn("Invalid lifetime format:", str); | ||
| return null; | ||
| } | ||
|
|
||
| const [_, days, hours, minutes, seconds] = match.map((v) => Number(v) || 0); | ||
|
|
||
| const totalSeconds = days * 86400 + hours * 3600 + minutes * 60 + seconds; | ||
| return totalSeconds * 1000; | ||
| }; | ||
|
|
||
| const insertCountdown = (remainingMs) => { | ||
| if (document.getElementById("culling-countdown")) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Does
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hmm, don't I inject the element in the DOM before this is called?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hmm, it looks like you create the side note: Maybe using 4 spaces for indentation would help readability here, it's hard to keep up with all the nested functions / callbacks :D
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Right. Sorry, I didn't have the code in front of me when I replied.
This was done very quickly. I'll have another look and clean it up where appropriate. |
||
| return; | ||
edan-bainglass marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| 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 <b>File Manager</b> or the <b>Terminal</b> | ||
| `; | ||
| banner.appendChild(saveInfo); | ||
|
|
||
| const startTime = Date.now(); | ||
| const endTime = startTime + 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"); | ||
| // Format as HH:MM:SS, even if > 1 day (for now - rare?) | ||
| return `${hrs}:${mins}:${secs}`; | ||
| }; | ||
|
|
||
| const updateTimer = () => { | ||
| const timeLeft = (endTime - Date.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); | ||
| } | ||
| }; | ||
|
|
||
| const loadCountdown = async () => { | ||
| try { | ||
| const [configResponse, uptimeResponse] = await Promise.all([ | ||
| fetch("/static/custom/config.json"), | ||
| fetch("/uptime"), | ||
edan-bainglass marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| ]); | ||
|
|
||
| const config = await configResponse.json(); | ||
| const uptimeData = await uptimeResponse.json(); | ||
|
|
||
| if (!config.ephemeral) { | ||
| return; | ||
| } | ||
|
|
||
| if (!config.lifetime) { | ||
| console.warn("Missing `lifetime` in config file"); | ||
| return; | ||
| } | ||
|
|
||
| const lifetimeMs = parseLifetimeToMs(config.lifetime); | ||
| const uptimeMs = uptimeData.uptime * 1000; | ||
| const remaining = lifetimeMs - uptimeMs; | ||
|
|
||
| insertCountdown(Math.max(0, remaining)); | ||
| } catch (err) { | ||
| console.error("Countdown init failed:", err); | ||
| } | ||
| }; | ||
|
|
||
| events.on("app_initialized.NotebookApp", loadCountdown); | ||
| loadCountdown(); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ugh, directly copying a python script to site-packages is naughty 😅
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It doesn't have to live there