From f14516793ee854ee5835047a197bcb462bef2447 Mon Sep 17 00:00:00 2001 From: Muhammad Labeeb <72980976+mlabeeb03@users.noreply.github.com> Date: Mon, 24 Mar 2025 18:59:29 +0500 Subject: [PATCH] Fix UI bugs and add new features * fix ui bugs * fix warning on plugin enable * add tooltip * change install icon * stop autoscroll on user scroll * add separate js for logs scrolling * redirect to marketplace from plugins detail page * home page goes to installed plugins * add tab for local launch * add simple toast * add toast * show toast after command completed * fix toast ui * add toast after local launch * add cancel button * change naming * separate pagination handlers * refractor plugins installed * remove repitition of modal * rename local launch * fix toast on mobile * hide pagination if not required * disable auto remove toast * remove ask local launch flag * use success message instead of exit code * show enable toggle after installation is completed * update page button dynamically * fix typo local launch * remove repeating declaration * dynamic cancel button on local launch * refresh redirects to plugin store * fix toast error * remove sysmodules pop * fix alignment * add advanced tab * add advanced tab * add click dependency * move autocomplete to tutorclient * fix make command typo * ui enhancements * improve advenced tab search bar * make plugins card clickable * add enabled disabled to marketplce * add dynamic search * update comments * move warning js to proper template * only 1 modal button * fix missing modal button * config multi set * add command to logs, prevent multiple toasts on pages with logs * do not display repeated toast for anything * fix local launch cancellation * change icons * small ui fixes * my plugins now clickable * single function for execution cancellation * switch fix * reset log creation logic * single config update button * move toast logic to frontend * just values in config fields * fix checkbox value error * add htmx indicator * no toast for plugin disable * separate toast manager * update only changed config items * fix templated value updates * rename launch platform * change dropdown style * do not run commands if thread is alive * fix warning * cancelling local launch does not remove warning cookies * resolve js code comments * do not show toast when last log file is null * allow cancellation even after page reload * update shared template context * create separate utils * single view for set and unset * add plugin name to url * fix cancel button * adjust modal size --- Makefile | 6 +- setup.py | 2 + tutordash/plugin.py | 1 - tutordash/server/app.py | 280 +++++----- tutordash/server/constants.py | 3 + tutordash/server/static/img/Fading_balls.gif | Bin 0 -> 4494 bytes tutordash/server/static/img/Icon.svg | 3 + tutordash/server/static/img/X.svg | 3 + tutordash/server/static/img/advanced-mode.svg | 1 + tutordash/server/static/img/download.svg | 6 + tutordash/server/static/img/local-launch.svg | 1 + tutordash/server/static/js/dash.js | 135 ++++- tutordash/server/static/js/logs.js | 100 ++++ tutordash/server/static/scss/dash.scss | 498 +++++++++++++----- .../{base_header.html => _base_header.html} | 15 +- tutordash/server/templates/_config.html | 42 +- .../server/templates/_plugin_header.html | 36 ++ ..._list.html => _plugin_installed_list.html} | 6 +- .../server/templates/_plugin_store_list.html | 43 +- tutordash/server/templates/_switch.html | 2 +- tutordash/server/templates/advanced.html | 97 ++++ tutordash/server/templates/cli_logs.html | 35 -- tutordash/server/templates/index.html | 76 ++- .../server/templates/installed_plugins.html | 40 -- tutordash/server/templates/local_launch.html | 40 ++ tutordash/server/templates/plugin.html | 134 +++-- tutordash/server/templates/plugin_header.html | 33 -- .../server/templates/plugin_installed.html | 48 ++ tutordash/server/templates/plugin_store.html | 6 +- tutordash/server/tutorclient.py | 61 ++- tutordash/server/utils.py | 25 + 31 files changed, 1270 insertions(+), 508 deletions(-) create mode 100644 tutordash/server/static/img/Fading_balls.gif create mode 100644 tutordash/server/static/img/Icon.svg create mode 100644 tutordash/server/static/img/X.svg create mode 100644 tutordash/server/static/img/advanced-mode.svg create mode 100644 tutordash/server/static/img/download.svg create mode 100644 tutordash/server/static/img/local-launch.svg create mode 100644 tutordash/server/static/js/logs.js rename tutordash/server/templates/{base_header.html => _base_header.html} (85%) create mode 100644 tutordash/server/templates/_plugin_header.html rename tutordash/server/templates/{_installed_plugins_list.html => _plugin_installed_list.html} (66%) create mode 100644 tutordash/server/templates/advanced.html delete mode 100644 tutordash/server/templates/cli_logs.html delete mode 100644 tutordash/server/templates/installed_plugins.html create mode 100644 tutordash/server/templates/local_launch.html delete mode 100644 tutordash/server/templates/plugin_header.html create mode 100644 tutordash/server/templates/plugin_installed.html create mode 100644 tutordash/server/utils.py diff --git a/Makefile b/Makefile index 346b37c..2e196c8 100644 --- a/Makefile +++ b/Makefile @@ -6,11 +6,11 @@ BLACK_OPTS = --exclude templates ${SRC_DIRS} runserver: ## Run a development server tutor dash run --dev -css: ## Compile SCSS files to CSS +scss: ## Compile SCSS files to CSS sass ${SASS_OPTS} tutordash/server/static/scss/:tutordash/server/static/css/ -css-watch: ## Compile SCSS files to CSS and watch for changes - $(MAKE) css SASS_OPTS="--watch" +scss-watch: ## Compile SCSS files to CSS and watch for changes + $(MAKE) scss SASS_OPTS="--watch" # Warning: These checks are not necessarily run on every PR. test: test-lint test-types test-format # Run some static checks. diff --git a/setup.py b/setup.py index 92f0b3f..e9a038b 100644 --- a/setup.py +++ b/setup.py @@ -54,6 +54,8 @@ setup( "quart", "aiofiles", "types-aiofiles", + "click", + "click_repl", ] }, entry_points={"tutor.plugin.v1": ["dash = tutordash.plugin"]}, diff --git a/tutordash/plugin.py b/tutordash/plugin.py index 4415af3..eeb87d9 100644 --- a/tutordash/plugin.py +++ b/tutordash/plugin.py @@ -1,7 +1,6 @@ from __future__ import annotations import click - from tutor import hooks from tutor.commands.context import Context diff --git a/tutordash/server/app.py b/tutordash/server/app.py index 374044b..ebf7641 100644 --- a/tutordash/server/app.py +++ b/tutordash/server/app.py @@ -1,27 +1,30 @@ import asyncio -import importlib_metadata import json import logging import sys import typing as t +import importlib_metadata from markdown import markdown from quart import ( Quart, + Response, + abort, + g, + jsonify, make_response, + redirect, render_template, request, - redirect, url_for, ) from quart.helpers import WerkzeugResponse from quart.typing import ResponseTypes -from tutor.plugins import indexes from tutor.plugins.v1 import discover_package -from . import constants -from . import tutorclient +from tutordash.server.utils import current_page_plugins, pagination_context +from . import constants, tutorclient app = Quart( __name__, @@ -29,8 +32,6 @@ app = Quart( static_folder="static", ) -ONE_MONTH = 60*60*24*30 -WARNING_COOKIE_PREFIX = "warning-cookie" def run(root: str, **app_kwargs: t.Any) -> None: """ @@ -49,93 +50,76 @@ def run(root: str, **app_kwargs: t.Any) -> None: app.run(**app_kwargs) +@app.before_request +async def before_request(): + # Shared views and template context + g.installed_plugins = tutorclient.Client.installed_plugins() + g.enabled_plugins = tutorclient.Client.enabled_plugins() + + @app.get("/") async def home() -> str: - return await render_template("index.html", **shared_template_context()) + return await render_template("plugin_installed.html") -def searched_plugins(pattern: str) -> list[str]: - return [plugin._data["name"] for plugin in indexes.iter_cache_entries() if plugin.match(pattern)] @app.get("/plugin/store") async def plugin_store() -> str: - return await render_template( - "plugin_store.html", - **shared_template_context(), - ) + return await render_template("plugin_store.html") + + +@app.get("/plugin/installed") +async def plugin_installed() -> str: + return await render_template("plugin_installed.html") + @app.get("/plugin/store/list") async def plugin_store_list() -> str: - installed_plugins = tutorclient.Client.installed_plugins() + search_query = request.args.get("search", "") plugins: list[dict[str, str]] = [ { "name": p.name, "url": p.url, "index": p.index, - "author": p.author.split('<')[0].strip(), + "author": p.author.split("<")[0].strip(), "description": markdown(p.description.replace("\n", " ")), - "is_installed": p.name in installed_plugins, + "is_installed": p.name in g.installed_plugins, + "is_enabled": p.name in g.enabled_plugins, } for p in tutorclient.Client.plugins_in_store() + if p.name in tutorclient.Client.plugins_matching_pattern(search_query) ] - plugins = [plugin for plugin in plugins if plugin["name"] in searched_plugins(request.args.get("search"))] - page = request.args.get("page", default=1, type=int) - per_page = 9 - total_pages = (len(plugins) + per_page - 1) // per_page - if page < 1: - page = 1 - elif page > total_pages: - page = total_pages - start = (page - 1) * per_page - end = start + per_page - plugins = plugins[start:end] + current_page = int(request.args.get("page", "1")) + plugins = current_page_plugins(plugins, current_page) + pagination = pagination_context(plugins, current_page) return await render_template( "_plugin_store_list.html", plugins=plugins, - page_count=total_pages, - current_page=page, - **shared_template_context(), + pagination=pagination, ) -@app.get("/plugin/installed") -async def installed_plugins() -> str: - return await render_template( - "installed_plugins.html", - **shared_template_context(), - ) @app.get("/plugin/installed/list") -async def installed_plugins_list() -> str: - indexes - installed_plugins = tutorclient.Client.installed_plugins() - enabled_plugins = tutorclient.Client.enabled_plugins() - store_plugins: dict[str, dict[str, str]] = { - p.name: { - "url": p.url, - "index": p.index, - "author": p.author.split('<')[0].strip(), - "description": markdown(p.description.replace("\n", " ")), - } - for p in tutorclient.Client.plugins_in_store() - } +async def plugin_installed_list() -> str: + search_query = request.args.get("search", "") plugins: list[dict[str, str]] = [ { - "name": plugin_name, - "url": store_plugins[plugin_name]["url"] if plugin_name in store_plugins else "", - "index": store_plugins[plugin_name]["index"] if plugin_name in store_plugins else "", - "author": store_plugins[plugin_name]["author"].split('<')[0].strip() if plugin_name in store_plugins else "", - "description": markdown(store_plugins[plugin_name]["description"]) if plugin_name in store_plugins else "", - "is_enabled": plugin_name in enabled_plugins, + "name": p.name, + "url": p.url, + "index": p.index, + "author": p.author.split("<")[0].strip(), + "description": markdown(p.description.replace("\n", " ")), + "is_enabled": p.name in g.enabled_plugins, } - for plugin_name in installed_plugins + for p in tutorclient.Client.plugins_in_store() + if p.name in tutorclient.Client.plugins_matching_pattern(search_query) + and p.name in g.installed_plugins ] - plugins = [plugin for plugin in plugins if plugin["name"] in searched_plugins(request.args.get("search"))] return await render_template( - "_installed_plugins_list.html", + "_plugin_installed_list.html", plugins=plugins, - **shared_template_context(), ) @@ -143,23 +127,37 @@ async def installed_plugins_list() -> str: async def plugin(name: str) -> str: # TODO check that plugin exists show_logs = request.args.get("show_logs") - is_enabled = name in tutorclient.Client.enabled_plugins() - is_installed = name in tutorclient.Client.installed_plugins() - author = next((p.author.split('<')[0].strip() for p in tutorclient.Client.plugins_in_store() if p.name == name), "") - description = next((markdown(p.description) for p in tutorclient.Client.plugins_in_store() if p.name == name), "") - return await render_template( + author = next( + ( + p.author.split("<")[0].strip() + for p in tutorclient.Client.plugins_in_store() + if p.name == name + ), + "", + ) + description = next( + ( + markdown(p.description) + for p in tutorclient.Client.plugins_in_store() + if p.name == name + ), + "", + ) + rendered_template = await render_template( "plugin.html", plugin_name=name, - is_enabled=is_enabled, - is_installed=is_installed, + is_enabled=name in g.enabled_plugins, + is_installed=name in g.installed_plugins, author_name=author, plugin_description=description, + show_logs=show_logs, 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(), - show_logs=show_logs, - **shared_template_context(), ) + response = Response(rendered_template, status=200, content_type="text/html") + response.headers["HX-Redirect"] = url_for("plugin", name=name) + return response @app.post("/plugin//toggle") @@ -170,12 +168,23 @@ async def plugin_toggle(name: str) -> WerkzeugResponse: command = ["plugins", "enable" if enable_plugin else "disable", name] tutorclient.CliPool.run_sequential(command) # TODO error management - response = await make_response(redirect(url_for("plugin", name=name))) + + response = await make_response( + redirect( + url_for( + "plugin", + name=name, + ) + ) + ) if enable_plugin: - response.set_cookie(f"{WARNING_COOKIE_PREFIX}-{name}", "requires launch", max_age=ONE_MONTH) + response.set_cookie( + f"{constants.WARNING_COOKIE_PREFIX}-{name}", + "requires launch", + max_age=constants.ONE_MONTH, + ) else: - sys.modules.pop(importlib_metadata.entry_points().__getitem__(name).value) - response.delete_cookie(f"{WARNING_COOKIE_PREFIX}-{name}") + response.delete_cookie(f"{constants.WARNING_COOKIE_PREFIX}-{name}") return response @@ -186,63 +195,84 @@ async def plugin_install(name: str) -> WerkzeugResponse: while tutorclient.CliPool.THREAD and tutorclient.CliPool.THREAD.is_alive(): await asyncio.sleep(0.1) discover_package(importlib_metadata.entry_points().__getitem__(name)) + asyncio.create_task(bg_install_and_reload()) - return redirect(url_for("plugin", name=name, show_logs=True)) + return redirect( + url_for( + "plugin", + name=name, + show_logs=True, + ) + ) @app.post("/plugin//upgrade") async def plugin_upgrade(name: str) -> WerkzeugResponse: tutorclient.CliPool.run_parallel(app, ["plugins", "upgrade", name]) - return redirect(url_for("plugin", name=name, show_logs=True)) + return redirect( + url_for( + "plugin", + name=name, + show_logs=True, + ) + ) + @app.post("/plugins/update") async def plugins_update() -> WerkzeugResponse: tutorclient.CliPool.run_parallel(app, ["plugins", "update"]) - return redirect(url_for("cli_logs")) + return redirect(url_for("plugin_store")) -@app.post("/config//set") -async def config_set(name: str) -> WerkzeugResponse: + +@app.post("/config//update") +async def config_update(name: str) -> WerkzeugResponse: form = await request.form - value = form.get("value", "") - plugin_name = form.get("plugin_name") - tutorclient.CliPool.run_sequential(["config", "save", "--set", f"{name}={value}"]) + + unset = form.get("unset") + if unset: + tutorclient.CliPool.run_sequential(["config", "save", f"--unset={unset}"]) + else: + cmd = ["config", "save"] + for key, value in form.items(): + if value.startswith("{{"): + # Templated values that start with {{ should be explicitely converted to string + # Otherwise there will be a parsing error because it might be considered a dictionary + value = f"'{value}'" + cmd.extend(["--set", f"{key}={value}"]) + tutorclient.CliPool.run_sequential(cmd) # TODO error management - response = await make_response(redirect(request.args.get("next", "/"))) - response.set_cookie(f"{WARNING_COOKIE_PREFIX}-{plugin_name}", "requires launch", max_age=ONE_MONTH) + response = await make_response( + redirect( + url_for( + "plugin", + name=name, + ) + ) + ) + response.set_cookie( + f"{constants.WARNING_COOKIE_PREFIX}-{name}", + "requires launch", + max_age=constants.ONE_MONTH, + ) return response -@app.post("/config//unset") -async def config_unset(name: str) -> WerkzeugResponse: - tutorclient.CliPool.run_sequential(["config", "save", f"--unset={name}"]) - # TODO error management - return redirect(request.args.get("next", "/")) - - -# def tutor_cli(command: list[str]) -> WerkzeugResponse: -# # Run command asynchronously -# # if TutorCli.is_thread_alive(): -# # TODO return 400 if thread is active -# # TODO parse command from JSON request body -# tutorclient.CliPool.run_parallel(app, command) -# return redirect(url_for("cli_logs")) +@app.get("/local/launch") +async def local_launch_view() -> str: + return await render_template( + "local_launch.html", + ) @app.post("/cli/local/launch") async def cli_local_launch() -> WerkzeugResponse: tutorclient.CliPool.run_parallel(app, ["local", "launch", "--non-interactive"]) - response = await make_response(redirect(url_for("cli_logs"))) - for cookie_name in request.cookies: - if cookie_name.startswith(WARNING_COOKIE_PREFIX): - response.delete_cookie(cookie_name) - return response + return await render_template( + "local_launch.html", + show_logs=True, + ) -@app.get("/cli/logs") -async def cli_logs() -> str: - name = request.args.get("name") - return await render_template("cli_logs.html", name=name, **shared_template_context()) - @app.get("/cli/logs/stream") async def cli_logs_stream() -> ResponseTypes: @@ -287,18 +317,32 @@ async def cli_logs_stream() -> ResponseTypes: @app.post("/cli/stop") -async def cli_stop() -> WerkzeugResponse: +async def cli_stop() -> None: tutorclient.CliPool.stop() - return redirect(url_for("cli_logs")) -def shared_template_context() -> dict[str, t.Any]: - """ - Common context shared between all views that make use of the base template. +@app.get("/advanced") +async def advanced() -> str: + return await render_template( + "advanced.html", + show_logs=True, + ) - TODO isn't there a better way to achieve that? Either template variable or Quart feature. - """ - return { - "installed_plugins": tutorclient.Client.installed_plugins(), - "enabled_plugins": tutorclient.Client.enabled_plugins(), - } + +@app.post("/suggest") +async def suggest(): + data = await request.get_json() + partial_command = data.get("command", "") + suggestions = tutorclient.Client.autocomplete(partial_command) + return jsonify(suggestions) + + +@app.post("/command") +async def command() -> str: + form = await request.form + command_string = form.get("command", "") + command_args = command_string.split() + if tutorclient.CliPool.is_thread_alive(): + abort(400, description="Command execution already in progress") + tutorclient.CliPool.run_parallel(app, command_args) + return await make_response(redirect(url_for("advanced"))) diff --git a/tutordash/server/constants.py b/tutordash/server/constants.py index f710dc2..df876bb 100644 --- a/tutordash/server/constants.py +++ b/tutordash/server/constants.py @@ -1 +1,4 @@ SHORT_SLEEP_SECONDS = 0.1 +ONE_MONTH = 60 * 60 * 24 * 30 +WARNING_COOKIE_PREFIX = "warning-cookie" +ITEMS_PER_PAGE = 100 diff --git a/tutordash/server/static/img/Fading_balls.gif b/tutordash/server/static/img/Fading_balls.gif new file mode 100644 index 0000000000000000000000000000000000000000..41811f0c5b5040c28b103e028020ea9ac61c7870 GIT binary patch literal 4494 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 + + diff --git a/tutordash/server/static/img/X.svg b/tutordash/server/static/img/X.svg new file mode 100644 index 0000000..5f075f4 --- /dev/null +++ b/tutordash/server/static/img/X.svg @@ -0,0 +1,3 @@ + + + diff --git a/tutordash/server/static/img/advanced-mode.svg b/tutordash/server/static/img/advanced-mode.svg new file mode 100644 index 0000000..29742a4 --- /dev/null +++ b/tutordash/server/static/img/advanced-mode.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tutordash/server/static/img/download.svg b/tutordash/server/static/img/download.svg new file mode 100644 index 0000000..f4c8253 --- /dev/null +++ b/tutordash/server/static/img/download.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/tutordash/server/static/img/local-launch.svg b/tutordash/server/static/img/local-launch.svg new file mode 100644 index 0000000..9729c71 --- /dev/null +++ b/tutordash/server/static/img/local-launch.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tutordash/server/static/js/dash.js b/tutordash/server/static/js/dash.js index 432db3b..3ef0e52 100644 --- a/tutordash/server/static/js/dash.js +++ b/tutordash/server/static/js/dash.js @@ -1,37 +1,116 @@ -function SetWarning(){ - const warningElements = document.querySelectorAll('[id^="warning-cookie-"]'); - const warningMain = document.getElementById('warning-main'); - warningElements.forEach(function(warningElement) { - if (document.cookie.includes(warningElement.id)) { - warningElement.style.display = 'flex'; - warningMain.style.display = 'flex'; - } - }); +function setCookie(name, value, days) { + let expires = ""; + if (days) { + let date = new Date(); + date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000); + expires = "; expires=" + date.toUTCString(); + } + document.cookie = `${name}=${value || ""}${expires}; path=/`; +} +function getCookie(name) { + let nameEQ = name + "="; + return ( + document.cookie + .split(";") + .map((cookie) => cookie.trim()) + .find((cookie) => cookie.startsWith(nameEQ)) + ?.slice(nameEQ.length) || null + ); +} +function eraseCookie(name) { + document.cookie = + name + "=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;"; } -SetWarning() +// Handle modal +const modalContainer = document.getElementById("modal_container"); +const openModalButton = document.querySelector(".open-modal-button"); +const closeModalButton = document.querySelector(".close-modal-button"); -let open = document.querySelectorAll(".open-modal-button"); -const modal_container = document.getElementById("modal_container"); -let close = document.querySelectorAll(".close-modal-button"); +openModalButton?.addEventListener("click", () => { + modalContainer.classList.add("show"); +}); +closeModalButton?.addEventListener("click", () => { + modalContainer.classList.remove("show"); +}); -open.forEach((button) => { +// Handle toast +const toast = document.querySelector(".toast"); +let closeToastButtons = document.querySelectorAll(".close-toast-button"); + +closeToastButtons.forEach((button) => { button.addEventListener("click", () => { - modal_container.classList.add("show"); + hideToast(toast); }); }); +function showToast() { + if (toast) { + if (toastTitle === "Launch platform was successfully executed") { + document.cookie.split(";").forEach((cookie) => { + let name = cookie.split("=")[0].trim(); + if (name.startsWith("warning-cookie")) { + eraseCookie(name); + } + }); + } + toast.style.display = "flex"; + setTimeout(() => { + void toast.offsetHeight; + toast.classList.add("active"); + }, 1); + } +} +function hideToast() { + if (toast) { + toast.classList.remove("active"); + setTimeout(() => { + toast.style.display = "none"; + }, 500); + } +} -close.forEach((button) => { - button.addEventListener("click", () => { - modal_container.classList.remove("show"); - }); -}); +const TOAST_CONFIGS = { + "$ tutor plugins enable": { + title: "Your plugin was successfully enabled", + description: + "Running launch platform will allow all changes to plugins to take effect. This could take a few minutes to complete.", + showFooter: true, + }, + "$ tutor plugins upgrade": { + title: "Your plugin was successfully updated", + description: + "Running launch platform will allow all changes to plugins to take effect. This could take a few minutes to complete.", + showFooter: true, + }, + "$ tutor plugins install": { + title: "Plugin Installed Successfully", + description: "Enable it now to start using its features", + showFooter: false, + }, + "$ tutor config save": { + title: "You have successfully modified parameters", + description: + "Running launch platform will allow all changes to plugins to take effect. This could take a few minutes to complete.", + showFooter: true, + }, + "$ tutor local launch": { + title: "Launch platform was successfully executed", + description: "", + showFooter: false, + }, +}; - -const logsContainer = document.getElementById('tutor-logs'); - -const observer = new MutationObserver(() => { - logsContainer.scrollTop = logsContainer.scrollHeight; -}); - -observer.observe(logsContainer, { childList: true, subtree: true }); +let toastTitle = document.getElementById("toast-title"); +let toastDescription = document.getElementById("toast-description"); +let toastFooter = document.getElementById("toast-footer"); +function setToastContent(cmd) { + const matchedPrefix = Object.keys(TOAST_CONFIGS).find((prefix) => + cmd.startsWith(prefix) + ); + if (matchedPrefix) { + const config = TOAST_CONFIGS[matchedPrefix]; + toastTitle.textContent = config.title; + toastDescription.textContent = config.description; + toastFooter.style.display = config.showFooter ? "flex" : "none"; + } +} diff --git a/tutordash/server/static/js/logs.js b/tutordash/server/static/js/logs.js new file mode 100644 index 0000000..6c13dc3 --- /dev/null +++ b/tutordash/server/static/js/logs.js @@ -0,0 +1,100 @@ +// 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 +// 3) logs scrolling + +// Each page that uses logs defines its own command execution/cancellation toggle functions with the same names +// 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; + } +}); + +let executedNewCommand = true; +let logsCount = 0; +let currentLogFile = null; +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(); + + 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 + 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; + + // Reset the flag after a short delay + setTimeout(() => { + isScrollingProgrammatically = false; + }, 10); + } +}); + +// Additional handlers for scroll inputs +logsElement.addEventListener( + "wheel", + function () { + shouldAutoScroll = false; + }, + { passive: true } +); + +logsElement.addEventListener( + "touchstart", + function () { + shouldAutoScroll = false; + }, + { passive: true } +); diff --git a/tutordash/server/static/scss/dash.scss b/tutordash/server/static/scss/dash.scss index afbd229..1a41b4b 100644 --- a/tutordash/server/static/scss/dash.scss +++ b/tutordash/server/static/scss/dash.scss @@ -10,6 +10,33 @@ $black: #181d27; $blue: #1570ef; $light-blue: #2e90fa; $green: #009951; +$green-1: #edfff7; + +.htmx-indicator { + opacity: 0; + transition: opacity 500ms ease-in; +} +.htmx-request .htmx-indicator { + opacity: 1; +} +.htmx-request.htmx-indicator { + opacity: 1; +} + +@mixin command { + width: 9em; + height: 3em; + border: none; + border-radius: 0.5em; + color: $gray-1; + font-size: 1em; + cursor: pointer; + margin-left: 1em; + background-color: $light-blue; +} +@mixin cancel-command { + background-color: $red; +} @mixin a-tag { text-decoration: none; @@ -19,6 +46,40 @@ $green: #009951; } } +@mixin modal-and-toast-footer { + display: flex; + justify-content: flex-end; + margin-top: 2em; + width: 100%; + + button { + cursor: pointer; + border-radius: 0.5em; + padding: 0.5em 1em; + } + + .close-modal-button { + background: none; + border: 1px solid #888; + color: $black; + padding: 0.5em 1em; + margin-right: 1em; + font-size: 1em; + } + + .close-toast-button { + @extend .close-modal-button; + } + + .run_modal_button { + background: none; + border: none; + background-color: $light-blue; + color: $gray-1; + font-size: 1em; + } +} + h1 { font-size: 2.25em; font-weight: bold; @@ -136,6 +197,15 @@ main { header { display: flex; flex-direction: column; + .header-address { + font-size: 15px; + span { + a { + @include a-tag(); + color: $blue; + } + } + } .info { display: flex; flex-direction: row; @@ -143,8 +213,9 @@ main { align-items: center; height: 4em; - @media (max-width: 600px) { - height: 9em; + @media (max-width: 740px) { + // height: 11em; + height: fit-content; flex-direction: column; align-items: start; } @@ -153,6 +224,7 @@ main { display: flex; flex-direction: column; justify-content: space-between; + margin-top: 20px; .page-title { font-size: 30px; font-weight: 600; @@ -160,6 +232,10 @@ main { } .page-description { color: $gray-4; + margin-top: 0.5em; + } + @media (max-width: 740px) { + margin-bottom: 2em; } } @@ -167,31 +243,24 @@ main { display: flex; justify-content: center; margin-bottom: 1.25em; - @media (max-width: 600px) { + @media (max-width: 740px) { width: 100%; justify-content: end; } - .installed { - display: flex; - align-items: center; - width: 6em; - justify-content: space-around; - color: $green; - font-size: 1em; - } button { - width: 9em; - height: 2.5em; - background-color: $light-blue; - border: none; - border-radius: 0.5em; - color: $gray-1; - font-size: 1em; - cursor: pointer; + @include command(); + } + .cancel-process-button { + @include cancel-command(); } } } + #plugin-full-description { + border-top: 1px solid $gray-1; + margin-top: 1em; + padding: 1em 0em; + } .search { display: flex; justify-content: flex-end; @@ -238,7 +307,7 @@ main { margin: 0em; .installed-plugins-list { border: 1px solid $gray-2; - border-radius: 1em; + border-radius: 0.75em; display: flex; flex-direction: column; .installed-plugin { @@ -246,7 +315,15 @@ main { justify-content: space-between; padding: 1em 2em; border-bottom: 1px solid $gray-2; + cursor: pointer; + &:hover { + background-color: $blue-1; + } + &:first-child { + border-radius: 0.75em 0.75em 0em 0em; + } &:last-child { + border-radius: 0em 0em 0.75em 0.75em; border-bottom: none; } .details { @@ -255,6 +332,13 @@ main { justify-content: center; flex-grow: 1; width: 80%; + div { + margin-bottom: 0.5em; + + &:last-child { + margin-bottom: 0; + } + } .name { font-size: 1.25em; a { @@ -276,6 +360,7 @@ main { display: none; } p { + margin: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -296,9 +381,8 @@ main { flex-direction: column; .plugins-container { - display: flex; - flex-wrap: wrap; - justify-content: space-between; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(19em, 1fr)); gap: 2em; .plugin { @@ -308,8 +392,12 @@ main { flex-direction: column; padding: 1em; justify-content: space-between; - flex: 1 1 calc(30% - 14px); - min-width: 250px; + width: 100%; + min-width: 15em; + cursor: pointer; + &:hover { + border: 1px solid $blue; + } .header { display: flex; @@ -325,14 +413,12 @@ main { justify-content: space-around; .name { - a { - color: $blue; - text-decoration: none; - font-size: 1.75em; + color: $blue; + text-decoration: none; + font-size: 1.5em; - &:visited { - text-decoration: none; - } + &:visited { + text-decoration: none; } } @@ -343,8 +429,12 @@ main { } .status { + border: none; img { - width: 1.5em; + width: 2em; + filter: invert(32%) sepia(70%) + saturate(4565%) hue-rotate(143deg) + brightness(99%) contrast(102%); } } } @@ -362,16 +452,27 @@ main { } .footer { padding: 0em; - button { - width: 5em; - height: 2em; - background-color: $light-blue; - border: none; - border-radius: 0.5em; - color: white; - font-size: 1em; - cursor: pointer; - margin: 0em 0.5em; + .meta { + display: flex; + justify-content: flex-end; + div { + width: 7em; + padding: 0.25em; + display: flex; + justify-content: center; + color: $blue; + background-color: $blue-1; + border-radius: 1em; + + &.status-enabled { + color: $green; + background-color: $green-1; + } + &.status-disabled { + color: $gray-4; + background-color: $gray-1; + } + } } } } @@ -411,58 +512,81 @@ main { } .status { display: flex; - align-items: center; + flex-direction: column; padding: 1.5em 0em; border-top: 1px solid $gray-1; border-bottom: 1px solid $gray-1; - .status-text { - width: 25em; - font-weight: 600; - font-size: 1.25em; - } + .topbar { + display: flex; + align-items: center; - .switch-text { - margin-left: 1em; + .status-text { + width: 25em; + font-weight: 600; + font-size: 1.25em; + + @media (max-width: 1140px) { + width: 15em; + } + @media (max-width: 800px) { + width: 5em; + } + } + .title { + margin-left: 1em; + font-size: 1.2em; + } + } + .description { + margin-top: 0.5em; + margin-left: 36em; + color: $gray-3; + + @media (max-width: 1140px) { + margin-left: 23.5em; + } + @media (max-width: 800px) { + margin-left: 11em; + } } } - .config { - display: flex; - flex-direction: column; - .item { + form { + .config { display: flex; - margin-bottom: 1em; - align-items: center; - height: 3em; - max-width: 60em; - - @media (max-width: 1100px) { - flex-direction: column; - height: 5em; - align-items: start; - justify-content: space-between; - } - - div { - height: 100%; - font-weight: 600; - flex: 1; - - @media (max-width: 1100px) { - flex: 0; - } - } - .config-forms { + flex-direction: column; + .item { display: flex; + margin-bottom: 1em; + align-items: center; + height: 3em; + max-width: 75em; - @media (max-width: 700px) { - &:first-child { - width: 50%; - } + @media (max-width: 1445px) { + flex-direction: column; + height: 5em; + align-items: start; + justify-content: space-between; } - form { + + div { display: flex; align-items: center; + height: 100%; + flex: 1; + + @media (max-width: 1100px) { + flex: 0; + } + } + .config-forms { + display: flex; + + @media (max-width: 700px) { + &:first-child { + width: 50%; + } + } input { border: 1px solid $gray-3; @@ -472,27 +596,40 @@ main { font-size: 1em; margin-left: 1em; width: 25em; + flex-shrink: 1; @media (max-width: 700px) { width: 80%; } } - - button { - width: 4em; - height: 2em; - background-color: $light-blue; - border: none; - border-radius: 0.5em; - color: white; - font-size: 1em; - cursor: pointer; - margin: 0em 0.5em; + img { + width: 1.5em; } } } + button { + width: 4em; + height: 2em; + background-color: $light-blue; + border: none; + border-radius: 0.5em; + color: white; + font-size: 1em; + cursor: pointer; + margin: 0em 0.5em; + + &:disabled { + cursor: default; + opacity: 0; + } + } + } + button[type="submit"] { + @include command(); + margin-left: 0; } } + .tutor-logs-container { #tutor-logs { width: 100%; @@ -508,6 +645,58 @@ main { height: 40em; } } + .command-input { + margin-top: 3em; + form { + width: 100%; + display: flex; + input { + flex: 1; + font-size: 1em; + height: 3em; + border-radius: 10px; + padding-left: 1em; + border: 1px solid $gray-2; + } + button { + @include command(); + } + .cancel-command-button { + @include cancel-command(); + } + } + } + .suggestions { + display: flex; + flex-direction: column; + border: 1px solid $gray-2; + border-radius: 10px; + width: fit-content; + min-width: 20em; + position: absolute; + top: 190px; + z-index: 10; + background-color: white; + overflow-y: auto; + max-height: 30em; + &:empty { + border: none; + } + div { + padding: 1em; + border-bottom: 1px solid $gray-2; + &:last-child { + border-bottom: none; + } + cursor: pointer; + &:hover { + color: $blue; + } + } + &.hidden { + display: none; + } + } } } } @@ -535,10 +724,10 @@ main { background-color: #fff; border-radius: 2em; box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); - padding: 30px 50px; + padding: 20px 30px; width: fit-content; height: fit-content; - max-width: 30em; + max-width: 35em; text-align: center; display: flex; flex-direction: column; @@ -566,40 +755,19 @@ main { h3 { color: $black; margin-bottom: 0px; + font-size: 18px; } p { color: $gray-4; + font-size: 14px; } } .modal-footer { - display: flex; - justify-content: flex-end; + @include modal-and-toast-footer(); margin-top: 1em; - - button { - cursor: pointer; - border-radius: 0.5em; - padding: 0.5em 1em; - } - - .close-modal-button { - background: none; - border: 1px solid #888; - color: $black; - padding: 0.5em 1em; - margin-right: 1em; - font-size: 1em; - } - - .run_modal_button { - background: none; - border: none; - background-color: $light-blue; - color: $gray-1; - font-size: 1em; - } + font-size: 14px; } } } @@ -629,7 +797,7 @@ main { content: ""; height: 1.5em; width: 1.5em; - left: 0.25em; + left: 0.175em; bottom: 0.125em; background-color: white; -webkit-transition: 0.4s; @@ -666,3 +834,91 @@ main { } } } + +.toast-container { + position: fixed; + top: 25px; + right: 30px; + display: flex; + flex-direction: column; + gap: 10px; + z-index: 1000; + max-width: 90%; + + .toast { + position: relative; + width: 30em; + max-width: 100%; + border-radius: 12px; + background: #fff; + padding: 20px; + display: none; + overflow-x: hidden; + align-items: center; + transform: translateX(calc(100% + 30px)); + transition: all 0.5s cubic-bezier(0.68, -0.55, 0.265, 1.35); + box-shadow: 0em 0em 0.5em 0.1em $gray-3; + flex-direction: column; + align-items: start; + &.active { + transform: translateX(0%); + } + .toast-content { + display: flex; + flex-direction: column; + width: 100%; + + .title { + display: flex; + align-items: center; + margin-bottom: 1em; + width: 100%; + span { + flex: 1; + } + img { + &:first-child { + margin-right: 1em; + } + &:last-child { + width: 1em; + } + } + .close-toast-button { + cursor: pointer; + width: 1.5em; + } + } + .message { + display: flex; + flex-direction: column; + } + + .text { + font-size: 16px; + font-weight: 400; + color: #666666; + font-optical-sizing: auto; + font-style: normal; + &.text-1 { + font-weight: 600; + color: #333; + } + } + } + .toast-footer { + @include modal-and-toast-footer(); + } + .close { + position: absolute; + top: 10px; + right: 15px; + padding: 5px; + cursor: pointer; + opacity: 0.7; + &:hover { + opacity: 1; + } + } + } +} diff --git a/tutordash/server/templates/base_header.html b/tutordash/server/templates/_base_header.html similarity index 85% rename from tutordash/server/templates/base_header.html rename to tutordash/server/templates/_base_header.html index e5dac2b..773651f 100644 --- a/tutordash/server/templates/base_header.html +++ b/tutordash/server/templates/_base_header.html @@ -10,6 +10,7 @@ {% block page_button %}{% endblock %} +{% block searchbar %} -
- - Changes have been made to some plugins that will only take effect after a local launch. + hx-target="#plugins-list">
{% endblock %} +{% block warning %} +
+ + Changes have been made to some plugins that will only take effect after running launch platform. +
+{% endblock %} +{% endblock %} diff --git a/tutordash/server/templates/_config.html b/tutordash/server/templates/_config.html index a2e9553..dbb543e 100644 --- a/tutordash/server/templates/_config.html +++ b/tutordash/server/templates/_config.html @@ -1,28 +1,26 @@ -
+
{% for key, value in config.items() %} -
-
{{ key }}:
-
-
- +
+
{{ key }}:
+
{% if value is boolean %} - + + + {% else %} - + {% endif %} - {# TODO how to display lists? #} - - - - {% if key in user_config %} -
- -
- {% endif %} + + +
-
- {% endfor %} + {% endfor %}
diff --git a/tutordash/server/templates/_plugin_header.html b/tutordash/server/templates/_plugin_header.html new file mode 100644 index 0000000..f89ee54 --- /dev/null +++ b/tutordash/server/templates/_plugin_header.html @@ -0,0 +1,36 @@ +{% extends "index.html" %} + +{% block workspace_header %} +
+ Plugin Marketplace + / {{ plugin_name }} +
+
+
+
{{ plugin_name }}
+
By {{ author_name }}
+
+
+ {% block page_button %} +
+ +
+
+
+ +
+
+
+
+ +
+
+ {% endblock %} +
+
+ +
+ {{ plugin_description | safe }} +
+ +{% endblock %} diff --git a/tutordash/server/templates/_installed_plugins_list.html b/tutordash/server/templates/_plugin_installed_list.html similarity index 66% rename from tutordash/server/templates/_installed_plugins_list.html rename to tutordash/server/templates/_plugin_installed_list.html index f82a84a..c1d3166 100644 --- a/tutordash/server/templates/_installed_plugins_list.html +++ b/tutordash/server/templates/_plugin_installed_list.html @@ -1,14 +1,14 @@ {% from '_switch.html' import switch %} {% for plugin in plugins %} -
+
-
{{ plugin.author }}
+
By {{ plugin.author }}
{{ plugin.description|safe }}
{{ switch(plugin.name, plugin.is_enabled)}} diff --git a/tutordash/server/templates/_plugin_store_list.html b/tutordash/server/templates/_plugin_store_list.html index 0bb4114..a2292de 100644 --- a/tutordash/server/templates/_plugin_store_list.html +++ b/tutordash/server/templates/_plugin_store_list.html @@ -1,20 +1,15 @@
{% for plugin in plugins %} -
+
- {{ plugin.name }} + {{ plugin.name }}
By {{ plugin.author }}
-
- {% if plugin.is_installed %} - - {% endif %} -
@@ -22,33 +17,49 @@
{% endfor %}
+
+ {% if pagination.previous_page or pagination.next_page %} + {% endif %}
\ No newline at end of file diff --git a/tutordash/server/templates/_switch.html b/tutordash/server/templates/_switch.html index e1732f4..495feb4 100644 --- a/tutordash/server/templates/_switch.html +++ b/tutordash/server/templates/_switch.html @@ -1,7 +1,7 @@ {% macro switch(plugin_name, is_enabled) %}
-
+
+
+
+
+ + {{ toast }} + +
+
+ + {{ toast_description }} + +
+
+ +
+
+ + {% if sidebar_active_tab %} {% endif %} - + {% block scripts %}{% endblock %} diff --git a/tutordash/server/templates/installed_plugins.html b/tutordash/server/templates/installed_plugins.html deleted file mode 100644 index e4fcfa4..0000000 --- a/tutordash/server/templates/installed_plugins.html +++ /dev/null @@ -1,40 +0,0 @@ -{% extends "base_header.html" %} - -{% block page_title %} -My Plugins -{% endblock %} - -{% block page_description %} -View all your installed plugins in one place. -{% endblock %} - -{% block page_button %} - -{% endblock %} - -{% block model_icon %} - -{% endblock %} - -{% block modal_title %} -Run local launch for all plugins? -{% endblock %} - -{% block modal_description %} -Running local launch will allow all changes to plugins to take effect. This could take a few minutes to complete. -{% endblock %} - -{% block modal_footer %} - -
- -
-{% endblock %} - -{% set sidebar_active_tab = "my-plugins" %} - -{% set search_endpoint = url_for('installed_plugins_list') %} - -{% block workspace_content %} -
-{% endblock %} diff --git a/tutordash/server/templates/local_launch.html b/tutordash/server/templates/local_launch.html new file mode 100644 index 0000000..1a2948e --- /dev/null +++ b/tutordash/server/templates/local_launch.html @@ -0,0 +1,40 @@ +{% extends "_base_header.html" %} + +{% block page_title %} +Execute Launch Platform +{% endblock %} + +{% block page_description %} +Running launch platform will allow all changes to plugins to take effect. This could take a few minutes to complete. +{% endblock %} + +{% block page_button %} + + +{% endblock %} + +{% block searchbar %} +{% endblock %} + +{% block warning %} +{% endblock %} + + +{% set sidebar_active_tab = "local-launch" %} + +{% block scripts %} + + +{% endblock %} \ No newline at end of file diff --git a/tutordash/server/templates/plugin.html b/tutordash/server/templates/plugin.html index 6735495..5dd374c 100644 --- a/tutordash/server/templates/plugin.html +++ b/tutordash/server/templates/plugin.html @@ -1,78 +1,110 @@ -{% extends "plugin_header.html" %} +{% extends "_plugin_header.html" %} {% set sidebar_active_tab = "" %} {% from '_switch.html' import switch %} -{% if is_installed %} -{% block plugin_description %} -{% endblock %} -{% endif %} - {% block workspace_content %} - -{% if is_installed %} -
-
- Status -
- {{ switch(plugin_name, is_enabled) }} -
+
+
+
+ Status +
+ {{ switch(plugin_name, is_enabled) }}
{% if is_enabled %} Enabled {% else %} Disabled {% endif %}
-
- {% if not is_enabled %} Enable the plugin to edit parameters. {% endif %} -
+
+
+ {% if not is_enabled %} Enable the plugin to edit parameters. {% endif %}
-{% endif %} {% if is_enabled and not show_logs %}

Plugin Parameters

-

This plugin has default parameters. If you make any changes, save them and run a local launch to make the changes effective.

+

This plugin has default parameters. If you make any changes, save them and run launch platform to make the changes effective.

+
+

Unique settings

+ {% if plugin_config_unique %} + {% with config=plugin_config_unique %}{% include "_config.html" %}{% endwith %} + {% endif %} -

Unique settings

-{% if plugin_config_unique %} -{% with config=plugin_config_unique %}{% include "_config.html" %}{% endwith %} -{% else %} -

None defined

-{% endif %} +

Default settings

+ {% if plugin_config_defaults %} + {% with config=plugin_config_defaults %}{% include "_config.html" %}{% endwith %} + {% endif %} + +
-

Default settings

-{% if plugin_config_defaults %} -{% with config=plugin_config_defaults %}{% include "_config.html" %}{% endwith %} -{% else %} -

None defined

-{% endif %} - -{% endif %} - -{% if show_logs %} -
-

-    
{% endif %} {% endblock %} {% block scripts %} - + // Add change event to all inputs, selects + document.querySelectorAll('#config-forms-container input').forEach(function(element) { + element.addEventListener('change', function() { + this.classList.add('changed'); + // Find the associated hidden input + const hiddenInput = this.nextElementSibling; + if (hiddenInput && hiddenInput.type === 'hidden') { + hiddenInput.classList.add('changed'); + } + }); + }); + + // Handle form submission + document.querySelectorAll('form').forEach(function(form) { + form.addEventListener('submit', function(e) { + // Disable all inputs that don't have the 'changed' class + document.querySelectorAll('#config-forms-container input:not(.changed)').forEach(function(element) { + if (element.id != "plugin-name"){ + element.disabled = true; + } + }); + }); + }); + + + {% endblock %} diff --git a/tutordash/server/templates/plugin_header.html b/tutordash/server/templates/plugin_header.html deleted file mode 100644 index b8df545..0000000 --- a/tutordash/server/templates/plugin_header.html +++ /dev/null @@ -1,33 +0,0 @@ -{% extends "index.html" %} - -{% block workspace_header %} -
- Plugin Marketplace - / {{ plugin_name }} -
-
-
-
{{ plugin_name }}
-
By {{ author_name }}
-
-
- {% block page_button %} - {% if plugin_name in installed_plugins %} -
- -
- {% else %} -
- -
- {% endif %} - {% endblock %} -
-
-{% block plugin_description %} -
- {{ plugin_description | safe }} -
-{% endblock %} - -{% endblock %} diff --git a/tutordash/server/templates/plugin_installed.html b/tutordash/server/templates/plugin_installed.html new file mode 100644 index 0000000..d8bff9d --- /dev/null +++ b/tutordash/server/templates/plugin_installed.html @@ -0,0 +1,48 @@ +{% extends "_base_header.html" %} + +{% block page_title %} +My Plugins +{% endblock %} + +{% block page_description %} +View all your installed plugins in one place. +{% endblock %} + +{% block page_button %} + +{% endblock %} + +{% set sidebar_active_tab = "my-plugins" %} + +{% set search_endpoint = url_for('plugin_installed_list') %} + +{% block workspace_content %} +
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/tutordash/server/templates/plugin_store.html b/tutordash/server/templates/plugin_store.html index 8124ea3..1cd4ed6 100644 --- a/tutordash/server/templates/plugin_store.html +++ b/tutordash/server/templates/plugin_store.html @@ -1,4 +1,4 @@ -{% extends "base_header.html" %} +{% extends "_base_header.html" %} {% block page_title %} Plugin Marketplace @@ -10,7 +10,7 @@ View and install available plugins. {% block page_button %}
- +
{% endblock %} @@ -19,7 +19,7 @@ View and install available plugins. {% set search_endpoint = url_for('plugin_store_list') %} {% block workspace_content %} -
+
{% endblock %} diff --git a/tutordash/server/tutorclient.py b/tutordash/server/tutorclient.py index 04cfbca..d0c00ca 100644 --- a/tutordash/server/tutorclient.py +++ b/tutordash/server/tutorclient.py @@ -9,16 +9,18 @@ import threading import typing as t import aiofiles -from quart import Quart - -import tutor.env -from tutor.exceptions import TutorError -from tutor import fmt, hooks -from tutor.types import Config +import click +import click_repl import tutor.commands.cli import tutor.config -import tutor.utils +import tutor.env import tutor.plugins.indexes +import tutor.utils +from prompt_toolkit.document import Document +from quart import Quart +from tutor import fmt, hooks +from tutor.exceptions import TutorError +from tutor.types import Config from . import constants @@ -108,10 +110,12 @@ class Cli: # pylint: disable=no-value-for-parameter tutor.commands.cli.cli(self.args) except TutorError as e: - # This happens for incorrect commands + # This happens for incorrect commands and cancellation self.log_to_file(e.args[0]) + self.log_to_file("\nCancelled!\n") except SystemExit: - pass + # TODO Is there a better way to notify command completion??? + self.log_to_file("\nSuccess!") def stop(self) -> None: """ @@ -122,12 +126,14 @@ class Cli: async def iter_logs(self) -> t.AsyncGenerator[str, None]: """ - Async stream content from file. Output is prefixed by the running command. + Async stream content from file. + The first item is the log file path. Second item is the running command. This will handle gracefully file deletion. Note however that if the file is 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 @@ -242,6 +248,16 @@ class CliPool: if cls.CLI_INSTANCE and cls.THREAD: cls.stop_runner_thread(cls.CLI_INSTANCE, cls.THREAD) + @classmethod + def is_thread_alive(cls) -> bool: + """ + Check if the thread is running. + + """ + if cls.CLI_INSTANCE and cls.THREAD: + return cls.THREAD.is_alive() + return False + @staticmethod def stop_runner_thread(tutor_cli_runner: Cli, thread: threading.Thread) -> None: """ @@ -293,6 +309,14 @@ class Client: def enabled_plugins(cls) -> list[str]: return list(hooks.Filters.PLUGINS_LOADED.iterate()) + @classmethod + def plugins_matching_pattern(cls, pattern: str) -> list[str]: + return [ + plugin._data["name"] + for plugin in cls.plugins_in_store() + if plugin.match(pattern) + ] + @classmethod def plugin_config_unique(cls, name: str) -> Config: plugin_config = hooks.Filters.CONFIG_UNIQUE.iterate_from_context( @@ -317,3 +341,20 @@ class Client: return { key: user_config.get(key, value) for key, value in config_defaults.items() } + + @classmethod + def autocomplete(cls, partial_command: str) -> list[dict]: + cli = tutor.commands.cli.cli + ctx = click.Context(cli, info_name=cli.name, parent=None) + completer = click_repl.ClickCompleter(cli, ctx) + document = Document(partial_command, len(partial_command)) + completions = list(completer.get_completions(document, None)) + suggestions = [] + for completion in completions: + suggestions.append( + { + "text": completion.text, + "display": completion.display, + } + ) + return suggestions diff --git a/tutordash/server/utils.py b/tutordash/server/utils.py new file mode 100644 index 0000000..92a57ff --- /dev/null +++ b/tutordash/server/utils.py @@ -0,0 +1,25 @@ +import typing as t + +from tutordash.server import constants + + +def pagination_context( + plugins: list[dict[str, str]], current_page: int +) -> dict[str, t.Any]: + total_pages = ( + len(plugins) + constants.ITEMS_PER_PAGE - 1 + ) // constants.ITEMS_PER_PAGE + return { + "current_page": current_page, + "total_pages": total_pages, + "previous_page": current_page - 1 if current_page > 1 else None, + "next_page": current_page + 1 if current_page < total_pages else None, + } + + +def current_page_plugins( + plugins: list[dict[str, str]], current_page: int +) -> list[dict[str, str]]: + start_index = (current_page - 1) * constants.ITEMS_PER_PAGE + end_index = start_index + constants.ITEMS_PER_PAGE + return plugins[start_index:end_index]