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 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/<name>/is-installed")
def plugin_installed_status(name: str) -> Response:
return jsonify({"installed": name in g.installed_plugins})
@app.post("/plugin/<name>/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)

View File

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

View File

@ -1,10 +1,10 @@
// 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;
@ -15,61 +15,52 @@ logsElement.addEventListener("scroll", function () {
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();
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");
// 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
// This means a parallel command just started its execution
if (!executedNewCommand && data.thread_alive) {
ShowCancelCommandButton();
executedNewCommand = true;
}
} 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() != "") {
const parallelCommandCompleted =
executedNewCommand && !data.thread_alive;
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");
}
// 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
}
if (onPluginPage) {
checkIfPluginInstalled(pluginName).then((isInstalled) => {
if (isInstalled) {
isPluginInstalled = true;
}
showPluginEnableDisableBar();
}
ShowRunCommandButton();
}
if (stdout.includes("Cancelled!")) {
});
} else {
ShowRunCommandButton();
}
}
evt.detail.elt.appendChild(text);
}
evt.detail.elt.appendChild(document.createTextNode(data.stdout));
if (shouldAutoScroll) {
// Set flag so event listner knows we are scrolling programatically
isScrollingProgrammatically = true;

View File

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

View File

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

View File

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

View File

@ -23,7 +23,8 @@
{% if is_enabled and not show_logs %}
<div>
<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>
<form id="config-forms-container" action="{{ url_for('config_update', name=plugin_name) }}" method="POST">
<h3>Unique settings</h3>
@ -47,57 +48,56 @@
pluginName = '{{ plugin_name }}';
isPluginInstalled = '{{ is_installed }}' === 'True';
isPluginEnabled = '{{ is_enabled }}' === 'True';
sequentialCommandExecuted = '{{ seq_command_executed }}' === 'True';
pluginUpgradeButton = document.getElementById('plugin-upgrade-button');
pluginInstallButton = document.getElementById('plugin-install-button');
cancelCommandButton = document.getElementById('cancel-command-button');
function showPluginInstallButton(){
pluginInstallButton.style.display = 'block';
pluginUpgradeButton.style.display = 'none';
cancelCommandButton.style.display = 'none';
}
function showPluginUpgradeButton(){
pluginInstallButton.style.display = 'none';
pluginUpgradeButton.style.display = 'block';
cancelCommandButton.style.display = 'none';
}
function ShowCancelCommandButton(){
pluginInstallButton.style.display = 'none';
pluginUpgradeButton.style.display = 'none';
cancelCommandButton.style.display = 'block';
const toggleButtons = ({install = false, upgrade = false, cancel = false} = {}) => {
pluginInstallButton.style.display = install ? 'block' : 'none';
pluginUpgradeButton.style.display = upgrade ? 'block' : 'none';
cancelCommandButton.style.display = cancel ? 'block' : 'none';
}
function ShowRunCommandButton() {
if (isPluginInstalled) {
showPluginUpgradeButton();
toggleButtons({upgrade: true});
} else {
showPluginInstallButton();
toggleButtons({install: true});
}
}
function ShowCancelCommandButton() {
toggleButtons({cancel: true});
}
function showPluginEnableDisableBar() {
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();
ShowRunCommandButton();
// Add change event to all inputs, selects
document.querySelectorAll('#config-forms-container input').forEach(function(element) {
element.addEventListener('change', function() {
this.classList.add('changed');
document.querySelectorAll('#config-forms-container input').forEach((element) => {
element.addEventListener('change', () => {
element.classList.add('changed');
// Find the associated hidden input
const hiddenInput = this.nextElementSibling;
const hiddenInput = element.nextElementSibling;
if (hiddenInput && hiddenInput.type === 'hidden') {
hiddenInput.classList.add('changed');
}
});
})
});
// Handle form submission
document.querySelectorAll('form').forEach(function(form) {
form.addEventListener('submit', function(e) {
document.querySelectorAll('form').forEach((form) => {
form.addEventListener('submit', (e) => {
// 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") {
element.disabled = true;
}

View File

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