Merge remote-tracking branch 'origin/release'

This commit is contained in:
Edly 2025-05-08 07:43:29 +00:00
commit 312ab8f726
9 changed files with 181 additions and 161 deletions

View File

@ -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)

View File

@ -1,7 +1,6 @@
import asyncio import asyncio
import json import json
import logging import logging
import re
import sys import sys
import typing as t import typing as t
@ -128,6 +127,7 @@ async def plugin_installed_list() -> str:
async def plugin(name: str) -> Response: async def plugin(name: str) -> Response:
# TODO check that plugin exists # TODO check that plugin exists
show_logs = request.args.get("show_logs") show_logs = request.args.get("show_logs")
seq_command_executed = request.args.get("seq_command_executed")
author = next( author = next(
( (
p.author.split("<")[0].strip() p.author.split("<")[0].strip()
@ -152,15 +152,23 @@ async def plugin(name: str) -> Response:
author_name=author, author_name=author,
plugin_description=description, plugin_description=description,
show_logs=show_logs, show_logs=show_logs,
seq_command_executed=seq_command_executed,
plugin_config_unique=tutorclient.Client.plugin_config_unique(name), plugin_config_unique=tutorclient.Client.plugin_config_unique(name),
plugin_config_defaults=tutorclient.Client.plugin_config_defaults(name), plugin_config_defaults=tutorclient.Client.plugin_config_defaults(name),
user_config=tutorclient.Project.get_user_config(), user_config=tutorclient.Project.get_user_config(),
) )
response = Response(rendered_template, status=200, content_type="text/html") 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 return response
@app.get("/plugin/<name>/is-installed")
def plugin_installed_status(name: str) -> Response:
return jsonify({"installed": name in g.installed_plugins})
@app.post("/plugin/<name>/toggle") @app.post("/plugin/<name>/toggle")
async def plugin_toggle(name: str) -> Response: async def plugin_toggle(name: str) -> Response:
# TODO check plugin exists # TODO check plugin exists
@ -177,6 +185,7 @@ async def plugin_toggle(name: str) -> Response:
url_for( url_for(
"plugin", "plugin",
name=name, name=name,
seq_command_executed=True,
) )
) )
), ),
@ -252,6 +261,7 @@ async def config_update(name: str) -> Response:
url_for( url_for(
"plugin", "plugin",
name=name, name=name,
seq_command_executed=True,
) )
) )
), ),
@ -305,8 +315,15 @@ async def cli_logs_stream() -> ResponseTypes:
while True: while True:
# TODO this is again causing the stream to never stop... # TODO this is again causing the stream to never stop...
async for data in tutorclient.CliPool.iter_logs(): async for data in tutorclient.CliPool.iter_logs():
json_data = json.dumps(data) event = f"""data: {
event = f"data: {json_data}\nevent: logs\n\n" json.dumps(
{
"stdout": data,
"command": tutorclient.CliPool.current_command(),
"thread_alive": tutorclient.CliPool.is_thread_alive(),
}
)
}\nevent: logs\n\n"""
yield event.encode() yield event.encode()
await asyncio.sleep(constants.SHORT_SLEEP_SECONDS) await asyncio.sleep(constants.SHORT_SLEEP_SECONDS)

View File

@ -70,36 +70,35 @@ function hideToast() {
} }
const TOAST_CONFIGS = { const TOAST_CONFIGS = {
"$ tutor plugins enable": { "tutor plugins enable": {
title: "Your plugin was successfully enabled", title: "Your plugin was successfully enabled",
description: description:
"To apply the changes, run Launch Platform. This will update your platform and may take a few minutes to complete.", "To apply the changes, run Launch Platform. This will update your platform and may take a few minutes to complete.",
showFooter: true, showFooter: true,
}, },
"$ tutor plugins upgrade": { "tutor plugins upgrade": {
title: "Your plugin was successfully updated", title: "Your plugin was successfully updated",
description: description:
"To apply the changes, run Launch Platform. This will update your platform and may take a few minutes to complete.", "To apply the changes, run Launch Platform. This will update your platform and may take a few minutes to complete.",
showFooter: true, showFooter: true,
}, },
"$ tutor plugins install": { "tutor plugins install": {
title: "Plugin Installed Successfully", title: "Plugin Installed Successfully",
description: "Enable it now to start using its features", description: "Enable it now to start using its features",
showFooter: false, showFooter: false,
}, },
"$ tutor config save": { "tutor config save": {
title: "You have successfully modified parameters", title: "You have successfully modified parameters",
description: description:
"To apply the changes, run Launch Platform. This will update your platform and may take a few minutes to complete.", "To apply the changes, run Launch Platform. This will update your platform and may take a few minutes to complete.",
showFooter: true, showFooter: true,
}, },
"$ tutor local launch": { "tutor local launch": {
title: "Launch platform was successfully executed", title: "Launch platform was successfully executed",
description: "", description: "",
showFooter: false, showFooter: false,
}, },
}; };
let toastTitle = document.getElementById("toast-title"); let toastTitle = document.getElementById("toast-title");
let toastDescription = document.getElementById("toast-description"); let toastDescription = document.getElementById("toast-description");
let toastFooter = document.getElementById("toast-footer"); let toastFooter = document.getElementById("toast-footer");

View File

@ -1,10 +1,10 @@
// Most of the websites dynamic functionality depends on the content of the logs // Most of the websites dynamic functionality depends on the content of the logs
// This file is responsible for: // This file is responsible for:
// 1) setting and displaying toast messages // 1) calling functions to set and display toast messages
// 2) toggling command execution/cancellation buttons // 2) calling functions to toggle command execution/cancellation buttons
// 3) logs scrolling // 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 // We can safely call these functions and their functionality will be handeled by the page specific js
let shouldAutoScroll = true; let shouldAutoScroll = true;
@ -15,61 +15,52 @@ logsElement.addEventListener("scroll", function () {
shouldAutoScroll = false; shouldAutoScroll = false;
} }
}); });
let executedNewCommand = false;
let executedNewCommand = true;
let logsCount = 0;
let currentLogFile = null;
htmx.on("htmx:sseBeforeMessage", function (evt) { htmx.on("htmx:sseBeforeMessage", function (evt) {
logsCount += 1;
// Don't swap content, we want to append // Don't swap content, we want to append
evt.preventDefault(); evt.preventDefault();
const stdout = JSON.parse(evt.detail.data); const data = JSON.parse(evt.detail.data);
const text = document.createTextNode(stdout); const command = data.command;
// First log element contains the name of logging file
if (logsCount === 1) {
currentLogFile = text.nodeValue.trim();
let lastLogFile = getCookie("last-log-file"); // This means a parallel command just started its execution
if (!executedNewCommand && data.thread_alive) {
// 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(); ShowCancelCommandButton();
executedNewCommand = true;
} }
} else if (logsCount === 2) {
// Second log element is the running command, make toast here const parallelCommandCompleted =
cmd = text.nodeValue.trim(); executedNewCommand && !data.thread_alive;
setToastContent(cmd);
evt.detail.elt.appendChild(text); const onPluginPage = typeof pluginName !== "undefined";
} else { // Note that sequential commands are only executed on the plugins page
// Only show toast if it was a new command // Refreshing the page will run this block again
if (executedNewCommand === true) { // Because there is no way to determine if its a newly executed sequential command or an old one
// If command has run successfully update UI if (
if (stdout.includes("Success!")) { parallelCommandCompleted ||
setCookie("last-log-file", currentLogFile, 365); (onPluginPage && sequentialCommandExecuted)
// Do not show the toast if it is empty ) {
if (toastTitle.textContent.trim() != "") { // 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"); showToast("info");
} }
// Check if we are on the plugin page }
if (typeof pluginName !== "undefined") { if (onPluginPage) {
// Successfull command means plugin is either successfully installed or upgraded checkIfPluginInstalled(pluginName).then((isInstalled) => {
// In either case we can safely display the enable/disable bar if (isInstalled) {
isPluginInstalled = true; isPluginInstalled = true;
}
showPluginEnableDisableBar(); showPluginEnableDisableBar();
}
ShowRunCommandButton(); ShowRunCommandButton();
} });
if (stdout.includes("Cancelled!")) { } else {
ShowRunCommandButton(); ShowRunCommandButton();
} }
} }
evt.detail.elt.appendChild(text); evt.detail.elt.appendChild(document.createTextNode(data.stdout));
}
if (shouldAutoScroll) { if (shouldAutoScroll) {
// Set flag so event listner knows we are scrolling programatically // Set flag so event listner knows we are scrolling programatically
isScrollingProgrammatically = true; isScrollingProgrammatically = true;

View File

@ -198,8 +198,8 @@ main {
width: 1.25em; width: 1.25em;
} }
.sidebar-tab-logo-selected { .sidebar-tab-logo-selected {
filter: invert(39%) sepia(98%) saturate(3884%) hue-rotate(205deg) filter: invert(39%) sepia(98%) saturate(3884%)
brightness(95%) contrast(96%); hue-rotate(205deg) brightness(95%) contrast(96%);
} }
h4 { h4 {
margin-left: 1em; margin-left: 1em;
@ -489,8 +489,9 @@ main {
border: none; border: none;
img { img {
width: 2em; width: 2em;
filter: invert(32%) sepia(70%) saturate(4565%) filter: invert(32%) sepia(70%)
hue-rotate(143deg) brightness(99%) contrast(102%); saturate(4565%) hue-rotate(143deg)
brightness(99%) contrast(102%);
} }
} }
} }
@ -731,7 +732,7 @@ main {
width: fit-content; width: fit-content;
min-width: 20em; min-width: 20em;
position: absolute; position: absolute;
top: 218px; top: 200px;
z-index: 10; z-index: 10;
background-color: white; background-color: white;
overflow-y: auto; overflow-y: auto;

View File

@ -36,13 +36,15 @@ Search for any tutor command and execute it with a single click.
<script> <script>
runCommandButton = document.querySelector('.run-command-button') runCommandButton = document.querySelector('.run-command-button')
cancelCommandButton = document.querySelector('.cancel-command-button') cancelCommandButton = document.querySelector('.cancel-command-button')
const toggleButtons = ({run = false, cancel = false} = {}) => {
runCommandButton.style.display = run ? 'block' : 'none';
cancelCommandButton.style.display = cancel ? 'block' : 'none';
}
function ShowRunCommandButton(){ function ShowRunCommandButton(){
runCommandButton.style.display = 'block'; toggleButtons({run: true});
cancelCommandButton.style.display = 'none';
} }
function ShowCancelCommandButton(){ function ShowCancelCommandButton(){
runCommandButton.style.display = 'none'; toggleButtons({cancel: true});
cancelCommandButton.style.display = 'block';
} }
ShowRunCommandButton(); ShowRunCommandButton();

View File

@ -26,13 +26,15 @@ This will run Launch Platform to apply all plugin changes. This may take a few m
<script> <script>
localLaunchButton = document.getElementById('local-launch-button') localLaunchButton = document.getElementById('local-launch-button')
cancelLocalLaunchButton = document.getElementById('cancel-local-launch-button') cancelLocalLaunchButton = document.getElementById('cancel-local-launch-button')
const toggleButtons = ({run = false, cancel = false} = {}) => {
localLaunchButton.style.display = run ? 'block' : 'none';
cancelLocalLaunchButton.style.display = cancel ? 'block' : 'none';
}
function ShowRunCommandButton(){ function ShowRunCommandButton(){
localLaunchButton.style.display = 'block'; toggleButtons({run: true});
cancelLocalLaunchButton.style.display = 'none';
} }
function ShowCancelCommandButton(){ function ShowCancelCommandButton(){
localLaunchButton.style.display = 'none'; toggleButtons({cancel: true})
cancelLocalLaunchButton.style.display = 'block';
} }
ShowRunCommandButton(); ShowRunCommandButton();
</script> </script>

View File

@ -23,7 +23,8 @@
{% if is_enabled and not show_logs %} {% if is_enabled and not show_logs %}
<div> <div>
<h2>Plugin Settings</h2> <h2>Plugin Settings</h2>
<p>You can adjust the plugin's behavior by changing these settings. Changes will only go live after you apply them.</p> <p>You can adjust the plugin's behavior by changing these settings. Changes will only go live after you apply them.
</p>
</div> </div>
<form id="config-forms-container" action="{{ url_for('config_update', name=plugin_name) }}" method="POST"> <form id="config-forms-container" action="{{ url_for('config_update', name=plugin_name) }}" method="POST">
<h3>Unique settings</h3> <h3>Unique settings</h3>
@ -43,66 +44,65 @@
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script> <script>
pluginName = '{{ plugin_name }}'; pluginName = '{{ plugin_name }}';
isPluginInstalled = '{{ is_installed }}' === 'True'; isPluginInstalled = '{{ is_installed }}' === 'True';
isPluginEnabled = '{{ is_enabled }}' === 'True'; isPluginEnabled = '{{ is_enabled }}' === 'True';
sequentialCommandExecuted = '{{ seq_command_executed }}' === 'True';
pluginUpgradeButton = document.getElementById('plugin-upgrade-button'); pluginUpgradeButton = document.getElementById('plugin-upgrade-button');
pluginInstallButton = document.getElementById('plugin-install-button'); pluginInstallButton = document.getElementById('plugin-install-button');
cancelCommandButton = document.getElementById('cancel-command-button'); cancelCommandButton = document.getElementById('cancel-command-button');
function showPluginInstallButton(){ const toggleButtons = ({install = false, upgrade = false, cancel = false} = {}) => {
pluginInstallButton.style.display = 'block'; pluginInstallButton.style.display = install ? 'block' : 'none';
pluginUpgradeButton.style.display = 'none'; pluginUpgradeButton.style.display = upgrade ? 'block' : 'none';
cancelCommandButton.style.display = 'none'; cancelCommandButton.style.display = cancel ? 'block' : 'none';
} }
function showPluginUpgradeButton(){ function ShowRunCommandButton() {
pluginInstallButton.style.display = 'none'; if (isPluginInstalled) {
pluginUpgradeButton.style.display = 'block'; toggleButtons({upgrade: true});
cancelCommandButton.style.display = 'none';
}
function ShowCancelCommandButton(){
pluginInstallButton.style.display = 'none';
pluginUpgradeButton.style.display = 'none';
cancelCommandButton.style.display = 'block';
}
function ShowRunCommandButton(){
if (isPluginInstalled){
showPluginUpgradeButton();
} else { } else {
showPluginInstallButton(); toggleButtons({install: true});
} }
} }
function ShowCancelCommandButton() {
toggleButtons({cancel: true});
}
function showPluginEnableDisableBar() { function showPluginEnableDisableBar() {
const bar = document.getElementById('plugin-enable-disable-bar'); const bar = document.getElementById('plugin-enable-disable-bar');
bar.style.display = isPluginInstalled === true ? 'flex' : 'none'; bar.style.display = isPluginInstalled ? 'flex' : 'none';
}
async function checkIfPluginInstalled(pluginName) {
const response = await fetch(`/plugin/${pluginName}/is-installed`);
const data = await response.json();
return data.installed;
} }
showPluginEnableDisableBar(); showPluginEnableDisableBar();
ShowRunCommandButton(); ShowRunCommandButton();
// Add change event to all inputs, selects // Add change event to all inputs, selects
document.querySelectorAll('#config-forms-container input').forEach(function(element) { document.querySelectorAll('#config-forms-container input').forEach((element) => {
element.addEventListener('change', function() { element.addEventListener('change', () => {
this.classList.add('changed'); element.classList.add('changed');
// Find the associated hidden input // Find the associated hidden input
const hiddenInput = this.nextElementSibling; const hiddenInput = element.nextElementSibling;
if (hiddenInput && hiddenInput.type === 'hidden') { if (hiddenInput && hiddenInput.type === 'hidden') {
hiddenInput.classList.add('changed'); hiddenInput.classList.add('changed');
} }
}); })
}); });
// Handle form submission // Handle form submission
document.querySelectorAll('form').forEach(function(form) { document.querySelectorAll('form').forEach((form) => {
form.addEventListener('submit', function(e) { form.addEventListener('submit', (e) => {
// Disable all inputs that don't have the 'changed' class // Disable all inputs that don't have the 'changed' class
document.querySelectorAll('#config-forms-container input:not(.changed)').forEach(function(element) { document.querySelectorAll('#config-forms-container input:not(.changed)').forEach((element) => {
if (element.id != "plugin-name"){ if (element.id != "plugin-name") {
element.disabled = true; element.disabled = true;
} }
}); });
}); });
}); });
</script> </script>
{% endblock %} {% endblock %}

View File

@ -133,7 +133,6 @@ class Cli:
truncated, all contents added to the beginning until the current position will be truncated, all contents added to the beginning until the current position will be
missed. missed.
""" """
yield f"{self.log_path}\n"
yield f"$ {self.command}\n" yield f"$ {self.command}\n"
async with aiofiles.open(self.log_path, "rb") as f: async with aiofiles.open(self.log_path, "rb") as f:
# Note that file reading needs to happen from the file path, because it maye # 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: if cls.CLI_INSTANCE and cls.THREAD:
cls.stop_runner_thread(cls.CLI_INSTANCE, 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 @classmethod
def is_thread_alive(cls) -> bool: def is_thread_alive(cls) -> bool:
""" """
@ -294,7 +302,6 @@ class CliPool:
class Client: class Client:
@classmethod @classmethod
def plugins_in_store(cls) -> list[tutor.plugins.indexes.IndexEntry]: def plugins_in_store(cls) -> list[tutor.plugins.indexes.IndexEntry]:
if not os.path.exists(tutor.plugins.indexes.Indexes.CACHE_PATH): if not os.path.exists(tutor.plugins.indexes.Indexes.CACHE_PATH):