diff --git a/changelog.d/20250505_153835_muhammad.labeeb_improve_cancellation_flows.md b/changelog.d/20250505_153835_muhammad.labeeb_improve_cancellation_flows.md new file mode 100644 index 0000000..3c44124 --- /dev/null +++ b/changelog.d/20250505_153835_muhammad.labeeb_improve_cancellation_flows.md @@ -0,0 +1 @@ +- [Improvement] Remove the `last-log-file` cookie and use `is_thread_alive` function to check the status of running commands. (by @mlabeeb03) \ No newline at end of file diff --git a/tutordeck/server/app.py b/tutordeck/server/app.py index 85c08cf..aa73200 100644 --- a/tutordeck/server/app.py +++ b/tutordeck/server/app.py @@ -1,7 +1,6 @@ import asyncio import json import logging -import re import sys import typing as t @@ -128,6 +127,7 @@ async def plugin_installed_list() -> str: async def plugin(name: str) -> Response: # TODO check that plugin exists show_logs = request.args.get("show_logs") + seq_command_executed = request.args.get("seq_command_executed") author = next( ( p.author.split("<")[0].strip() @@ -152,15 +152,23 @@ async def plugin(name: str) -> Response: author_name=author, plugin_description=description, show_logs=show_logs, + seq_command_executed=seq_command_executed, plugin_config_unique=tutorclient.Client.plugin_config_unique(name), plugin_config_defaults=tutorclient.Client.plugin_config_defaults(name), user_config=tutorclient.Project.get_user_config(), ) response = Response(rendered_template, status=200, content_type="text/html") - response.headers["HX-Redirect"] = url_for("plugin", name=name) + response.headers["HX-Redirect"] = url_for( + "plugin", name=name, seq_command_executed=seq_command_executed + ) return response +@app.get("/plugin//is-installed") +def plugin_installed_status(name: str) -> Response: + return jsonify({"installed": name in g.installed_plugins}) + + @app.post("/plugin//toggle") async def plugin_toggle(name: str) -> Response: # TODO check plugin exists @@ -177,6 +185,7 @@ async def plugin_toggle(name: str) -> Response: url_for( "plugin", name=name, + seq_command_executed=True, ) ) ), @@ -252,6 +261,7 @@ async def config_update(name: str) -> Response: url_for( "plugin", name=name, + seq_command_executed=True, ) ) ), @@ -305,8 +315,15 @@ async def cli_logs_stream() -> ResponseTypes: while True: # TODO this is again causing the stream to never stop... async for data in tutorclient.CliPool.iter_logs(): - json_data = json.dumps(data) - event = f"data: {json_data}\nevent: logs\n\n" + event = f"""data: { + json.dumps( + { + "stdout": data, + "command": tutorclient.CliPool.current_command(), + "thread_alive": tutorclient.CliPool.is_thread_alive(), + } + ) + }\nevent: logs\n\n""" yield event.encode() await asyncio.sleep(constants.SHORT_SLEEP_SECONDS) diff --git a/tutordeck/server/static/js/deck.js b/tutordeck/server/static/js/deck.js index dc9e503..a8ba56a 100644 --- a/tutordeck/server/static/js/deck.js +++ b/tutordeck/server/static/js/deck.js @@ -70,36 +70,35 @@ function hideToast() { } const TOAST_CONFIGS = { - "$ tutor plugins enable": { + "tutor plugins enable": { title: "Your plugin was successfully enabled", description: "To apply the changes, run Launch Platform. This will update your platform and may take a few minutes to complete.", showFooter: true, }, - "$ tutor plugins upgrade": { + "tutor plugins upgrade": { title: "Your plugin was successfully updated", description: "To apply the changes, run Launch Platform. This will update your platform and may take a few minutes to complete.", showFooter: true, }, - "$ tutor plugins install": { + "tutor plugins install": { title: "Plugin Installed Successfully", description: "Enable it now to start using its features", showFooter: false, }, - "$ tutor config save": { + "tutor config save": { title: "You have successfully modified parameters", description: "To apply the changes, run Launch Platform. This will update your platform and may take a few minutes to complete.", showFooter: true, }, - "$ tutor local launch": { + "tutor local launch": { title: "Launch platform was successfully executed", description: "", showFooter: false, }, }; - let toastTitle = document.getElementById("toast-title"); let toastDescription = document.getElementById("toast-description"); let toastFooter = document.getElementById("toast-footer"); diff --git a/tutordeck/server/static/js/logs.js b/tutordeck/server/static/js/logs.js index 6c13dc3..cdf7c99 100644 --- a/tutordeck/server/static/js/logs.js +++ b/tutordeck/server/static/js/logs.js @@ -1,100 +1,91 @@ // Most of the websites dynamic functionality depends on the content of the logs // This file is responsible for: -// 1) setting and displaying toast messages -// 2) toggling command execution/cancellation buttons +// 1) calling functions to set and display toast messages +// 2) calling functions to toggle command execution/cancellation buttons // 3) logs scrolling -// Each page that uses logs defines its own command execution/cancellation toggle functions with the same names +// Each page that uses logs defines its own command execution/cancellation toggle functions with the same signature // We can safely call these functions and their functionality will be handeled by the page specific js let shouldAutoScroll = true; let isScrollingProgrammatically = false; // When user manually scrolls, update behaviour logsElement.addEventListener("scroll", function () { - if (!isScrollingProgrammatically) { - shouldAutoScroll = false; - } + if (!isScrollingProgrammatically) { + shouldAutoScroll = false; + } }); - -let executedNewCommand = true; -let logsCount = 0; -let currentLogFile = null; +let executedNewCommand = false; htmx.on("htmx:sseBeforeMessage", function (evt) { - logsCount += 1; - // Don't swap content, we want to append - evt.preventDefault(); + // Don't swap content, we want to append + evt.preventDefault(); - const stdout = JSON.parse(evt.detail.data); - const text = document.createTextNode(stdout); - // First log element contains the name of logging file - if (logsCount === 1) { - currentLogFile = text.nodeValue.trim(); + const data = JSON.parse(evt.detail.data); + const command = data.command; - let lastLogFile = getCookie("last-log-file"); + // This means a parallel command just started its execution + if (!executedNewCommand && data.thread_alive) { + ShowCancelCommandButton(); + executedNewCommand = true; + } - // If the new log file name is same as the previous log file name that means - // we have not executed a new command, they are logs of the last executed command - if (lastLogFile === currentLogFile) { - executedNewCommand = false; - } else { - // We are indeed executing a new command so show cancel button and update log file name - ShowCancelCommandButton(); - } - } else if (logsCount === 2) { - // Second log element is the running command, make toast here - cmd = text.nodeValue.trim(); - setToastContent(cmd); - evt.detail.elt.appendChild(text); - } else { - // Only show toast if it was a new command - if (executedNewCommand === true) { - // If command has run successfully update UI - if (stdout.includes("Success!")) { - setCookie("last-log-file", currentLogFile, 365); - // Do not show the toast if it is empty - if (toastTitle.textContent.trim() != "") { - showToast("info"); - } - // Check if we are on the plugin page - if (typeof pluginName !== "undefined") { - // Successfull command means plugin is either successfully installed or upgraded - // In either case we can safely display the enable/disable bar - isPluginInstalled = true; - showPluginEnableDisableBar(); - } - ShowRunCommandButton(); - } - if (stdout.includes("Cancelled!")) { - ShowRunCommandButton(); - } - } - evt.detail.elt.appendChild(text); - } - if (shouldAutoScroll) { - // Set flag so event listner knows we are scrolling programatically - isScrollingProgrammatically = true; - evt.detail.elt.scrollTop = evt.detail.elt.scrollHeight; + const parallelCommandCompleted = + executedNewCommand && !data.thread_alive; - // Reset the flag after a short delay - setTimeout(() => { - isScrollingProgrammatically = false; - }, 10); - } + const onPluginPage = typeof pluginName !== "undefined"; + // Note that sequential commands are only executed on the plugins page + // Refreshing the page will run this block again + // Because there is no way to determine if its a newly executed sequential command or an old one + if ( + parallelCommandCompleted || + (onPluginPage && sequentialCommandExecuted) + ) { + // There are certain commands for which we do not show the toast message + // Only show the toast if it was set in the `setToastContent` function and if the command ran successfully + if (data.stdout.includes("Success!")) { + setToastContent(command); + if (toastTitle.textContent.trim()) { + showToast("info"); + } + } + if (onPluginPage) { + checkIfPluginInstalled(pluginName).then((isInstalled) => { + if (isInstalled) { + isPluginInstalled = true; + } + showPluginEnableDisableBar(); + ShowRunCommandButton(); + }); + } else { + ShowRunCommandButton(); + } + } + evt.detail.elt.appendChild(document.createTextNode(data.stdout)); + if (shouldAutoScroll) { + // Set flag so event listner knows we are scrolling programatically + isScrollingProgrammatically = true; + evt.detail.elt.scrollTop = evt.detail.elt.scrollHeight; + + // Reset the flag after a short delay + setTimeout(() => { + isScrollingProgrammatically = false; + }, 10); + } }); // Additional handlers for scroll inputs logsElement.addEventListener( - "wheel", - function () { - shouldAutoScroll = false; - }, - { passive: true } + "wheel", + function () { + shouldAutoScroll = false; + }, + { passive: true } ); logsElement.addEventListener( - "touchstart", - function () { - shouldAutoScroll = false; - }, - { passive: true } + "touchstart", + function () { + shouldAutoScroll = false; + }, + { passive: true } ); diff --git a/tutordeck/server/static/scss/deck.scss b/tutordeck/server/static/scss/deck.scss index 8f3dafa..2c8be6d 100644 --- a/tutordeck/server/static/scss/deck.scss +++ b/tutordeck/server/static/scss/deck.scss @@ -198,8 +198,8 @@ main { width: 1.25em; } .sidebar-tab-logo-selected { - filter: invert(39%) sepia(98%) saturate(3884%) hue-rotate(205deg) - brightness(95%) contrast(96%); + filter: invert(39%) sepia(98%) saturate(3884%) + hue-rotate(205deg) brightness(95%) contrast(96%); } h4 { margin-left: 1em; @@ -489,8 +489,9 @@ main { border: none; img { width: 2em; - filter: invert(32%) sepia(70%) saturate(4565%) - hue-rotate(143deg) brightness(99%) contrast(102%); + filter: invert(32%) sepia(70%) + saturate(4565%) hue-rotate(143deg) + brightness(99%) contrast(102%); } } } @@ -731,7 +732,7 @@ main { width: fit-content; min-width: 20em; position: absolute; - top: 218px; + top: 200px; z-index: 10; background-color: white; overflow-y: auto; diff --git a/tutordeck/server/templates/advanced.html b/tutordeck/server/templates/advanced.html index 78e6b1b..4e9b437 100644 --- a/tutordeck/server/templates/advanced.html +++ b/tutordeck/server/templates/advanced.html @@ -36,13 +36,15 @@ Search for any tutor command and execute it with a single click. diff --git a/tutordeck/server/templates/plugin.html b/tutordeck/server/templates/plugin.html index 00ac49c..147a065 100644 --- a/tutordeck/server/templates/plugin.html +++ b/tutordeck/server/templates/plugin.html @@ -23,9 +23,10 @@ {% if is_enabled and not show_logs %}

Plugin Settings

-

You can adjust the plugin's behavior by changing these settings. Changes will only go live after you apply them.

+

You can adjust the plugin's behavior by changing these settings. Changes will only go live after you apply them. +

-
+

Unique settings

{% if plugin_config_unique %} {% with config=plugin_config_unique %}{% include "_config.html" %}{% endwith %} @@ -43,66 +44,65 @@ {% endblock %} {% block scripts %} - -{% endblock %} + }); + +{% endblock %} \ No newline at end of file diff --git a/tutordeck/server/tutorclient.py b/tutordeck/server/tutorclient.py index 3a2ea4c..16c0210 100644 --- a/tutordeck/server/tutorclient.py +++ b/tutordeck/server/tutorclient.py @@ -133,7 +133,6 @@ class Cli: truncated, all contents added to the beginning until the current position will be missed. """ - yield f"{self.log_path}\n" yield f"$ {self.command}\n" async with aiofiles.open(self.log_path, "rb") as f: # Note that file reading needs to happen from the file path, because it maye @@ -248,6 +247,15 @@ class CliPool: if cls.CLI_INSTANCE and cls.THREAD: cls.stop_runner_thread(cls.CLI_INSTANCE, cls.THREAD) + @classmethod + def current_command(cls) -> str: + """ + Return the current or last command that was executed. + """ + if cls.CLI_INSTANCE is None: + raise RuntimeError("CLI_INSTANCE is not initialized.") + return cls.CLI_INSTANCE.command + @classmethod def is_thread_alive(cls) -> bool: """ @@ -294,7 +302,6 @@ class CliPool: class Client: - @classmethod def plugins_in_store(cls) -> list[tutor.plugins.indexes.IndexEntry]: if not os.path.exists(tutor.plugins.indexes.Indexes.CACHE_PATH):