diff --git a/dolphie/App.py b/dolphie/App.py index 7c0d9c0..c22b844 100755 --- a/dolphie/App.py +++ b/dolphie/App.py @@ -568,7 +568,7 @@ def process_mysql_data(self, tab: Tab): if dolphie.panels.processlist.visible or dolphie.record_for_replay: dolphie.processlist_threads = ProcesslistPanel.fetch_data(tab) - if dolphie.innodb_cluster or dolphie.innodb_cluster_read_replica: + if dolphie.panels.replication.visible and (dolphie.innodb_cluster or dolphie.innodb_cluster_read_replica): dolphie.main_db_connection.execute(MySQLQueries.get_clustersets) dolphie.innodb_cluster_clustersets = dolphie.main_db_connection.fetchall() @@ -1417,27 +1417,13 @@ def command_get_input(filter_data): elif key == "k": - def command_get_input(data): - self.run_command_in_worker(key=key, dolphie=dolphie, additional_data=data) - - self.app.push_screen( - CommandModal( - command=HotkeyCommands.thread_kill_by_id, - message="Kill Thread", - processlist_data=dolphie.processlist_threads_snapshot, - ), - command_get_input, - ) - - elif key == "K": - def command_get_input(data): self.run_command_in_worker(key=key, dolphie=dolphie, additional_data=data) self.app.push_screen( CommandModal( command=HotkeyCommands.thread_kill_by_parameter, - message="Kill threads by parameter(s)", + message="Kill thread(s)", processlist_data=dolphie.processlist_threads_snapshot, ), command_get_input, @@ -1876,20 +1862,11 @@ def show_thread_screen(): ) self.call_from_thread(show_command_screen) - elif key == "k": - thread_id = additional_data - try: - query = dolphie.build_kill_query(thread_id) - dolphie.secondary_db_connection.execute(query) - - self.notify("Killed Thread ID [b highlight]%s[/b highlight]" % thread_id, severity="success") - except ManualException as e: - self.notify(e.reason, title="Error killing Thread ID", severity="error") - - elif key == "K": + elif key == "k": # Unpack the data from the modal ( + kill_by_id, kill_by_username, kill_by_host, kill_by_age_range, @@ -1899,37 +1876,49 @@ def show_thread_screen(): include_sleeping_queries, ) = additional_data - threads_killed = 0 - commands_to_kill = ["Query", "Execute"] + if kill_by_id: + try: + query = dolphie.build_kill_query(kill_by_id) + dolphie.secondary_db_connection.execute(query) - if include_sleeping_queries: - commands_to_kill.append("Sleep") + self.notify(f"Killed Thread ID [b highlight]{kill_by_id}[/b highlight]", severity="success") + except ManualException as e: + self.notify(e.reason, title="Error killing Thread ID", severity="error") + else: + threads_killed = 0 + commands_to_kill = ["Query", "Execute"] - # Make a copy of the threads snapshot to avoid modification during iteration - threads = dolphie.processlist_threads_snapshot.copy() + if include_sleeping_queries: + commands_to_kill.append("Sleep") - for thread_id, thread in threads.items(): - thread: ProcesslistThread - try: - # Check if the thread matches all conditions - if ( - thread.command in commands_to_kill - and (not kill_by_username or kill_by_username == thread.user) - and (not kill_by_host or kill_by_host == thread.host) - and (not kill_by_age_range or age_range_lower_limit <= thread.time <= age_range_upper_limit) - and (not kill_by_query_text or kill_by_query_text in thread.formatted_query.code) - ): - query = dolphie.build_kill_query(thread_id) - dolphie.secondary_db_connection.execute(query) - - threads_killed += 1 - except ManualException as e: - self.notify(e.reason, title=f"Error Killing Thread ID {thread_id}", severity="error") + # Make a copy of the threads snapshot to avoid modification during next refresh polling + threads = dolphie.processlist_threads_snapshot.copy() - if threads_killed: - self.notify(f"Killed [highlight]{threads_killed}[/highlight] thread(s)") - else: - self.notify("No threads were killed") + for thread_id, thread in threads.items(): + thread: ProcesslistThread + try: + # Check if the thread matches all conditions + if ( + thread.command in commands_to_kill + and (not kill_by_username or kill_by_username == thread.user) + and (not kill_by_host or kill_by_host == thread.host) + and ( + not kill_by_age_range + or age_range_lower_limit <= thread.time <= age_range_upper_limit + ) + and (not kill_by_query_text or kill_by_query_text in thread.formatted_query.code) + ): + query = dolphie.build_kill_query(thread_id) + dolphie.secondary_db_connection.execute(query) + + threads_killed += 1 + except ManualException as e: + self.notify(e.reason, title=f"Error Killing Thread ID {thread_id}", severity="error") + + if threads_killed: + self.notify(f"Killed [highlight]{threads_killed}[/highlight] thread(s)") + else: + self.notify("No threads were killed") elif key == "l": deadlock = "" diff --git a/dolphie/DataTypes.py b/dolphie/DataTypes.py index def0f50..45b76d8 100644 --- a/dolphie/DataTypes.py +++ b/dolphie/DataTypes.py @@ -234,7 +234,6 @@ def _get_formatted_number(self, number): class HotkeyCommands: show_thread = "show_thread" thread_filter = "thread_filter" - thread_kill_by_id = "thread_kill_by_id" thread_kill_by_parameter = "thread_kill_by_parameter" variable_search = "variable_search" rename_tab = "rename_tab" diff --git a/dolphie/Dolphie.py b/dolphie/Dolphie.py index da67f66..24d3c06 100644 --- a/dolphie/Dolphie.py +++ b/dolphie/Dolphie.py @@ -239,6 +239,7 @@ def determine_distro_and_connection_source_alt( version_comment = global_variables.get("version_comment", "").casefold() basedir = global_variables.get("basedir", "").casefold() aad_auth_only = global_variables.get("aad_auth_only") + aurora_version = global_variables.get("aurora_version") # Identify MariaDB and its variants aria_in_global_variables = any(variable.startswith("aria_") for variable in global_variables.keys()) @@ -262,7 +263,7 @@ def determine_distro_and_connection_source_alt( return distro, conn_source # Identify MySQL and its variants - if global_variables.get("aurora_version"): + if aurora_version: return "Amazon Aurora", ConnectionSource.mysql if "rdsdb" in basedir: return "Amazon RDS (MySQL)", ConnectionSource.mysql @@ -274,15 +275,16 @@ def determine_distro_and_connection_source_alt( def build_kill_query(self, thread_id: int) -> str: basedir = self.global_variables.get("basedir", "").casefold() aad_auth_only = self.global_variables.get("aad_auth_only") + aurora_version = self.global_variables.get("aurora_version") - if "rdsdb" in basedir or self.global_variables.get("aurora_version"): + if "rdsdb" in basedir or aurora_version: return f"CALL mysql.rds_kill({thread_id})" - elif aad_auth_only: + if aad_auth_only: return f"CALL mysql.az_kill({thread_id})" - elif self.connection_source == ConnectionSource.proxysql: + if self.connection_source == ConnectionSource.proxysql: return f"KILL CONNECTION {thread_id}" - else: - return f"KILL {thread_id}" + + return f"KILL {thread_id}" def collect_system_utilization(self): if not self.enable_system_utilization: diff --git a/dolphie/Dolphie.tcss b/dolphie/Dolphie.tcss index 0e0e173..78f157a 100644 --- a/dolphie/Dolphie.tcss +++ b/dolphie/Dolphie.tcss @@ -75,10 +75,13 @@ CommandList { padding: 0; } & > .option-list--option-highlighted { - background: #171d2d; + color: #b7c7ee; + background: #171e2f; + text-style: bold; } & > .option-list--option-hover { - background: #171d2d; + color: #b7c7ee; + background: #171e2f; text-style: bold; } } @@ -361,11 +364,12 @@ Dropdown { background: #151926; & > .autocomplete--highlight-match { - background: #777500; + background: #384673; } & > .autocomplete--selection-cursor { background: #283048; + color: #b7c7ee; } } diff --git a/dolphie/Modules/CommandManager.py b/dolphie/Modules/CommandManager.py index 060d536..a168a49 100644 --- a/dolphie/Modules/CommandManager.py +++ b/dolphie/Modules/CommandManager.py @@ -66,8 +66,7 @@ def __init__(self): "c": {"human_key": "c", "description": "Clear all filters set"}, "f": {"human_key": "f", "description": "Filter threads by field(s)"}, "E": {"human_key": "E", "description": "Export the processlist to a CSV file"}, - "k": {"human_key": "k", "description": "Kill thread by its ID"}, - "K": {"human_key": "K", "description": "Kill threads by parameter(s)"}, + "k": {"human_key": "k", "description": "Kill thread(s)"}, "M": {"human_key": "M", "description": "Maximize a panel"}, "q": {"human_key": "q", "description": "Quit"}, "r": {"human_key": "r", "description": "Set the refresh interval"}, @@ -113,8 +112,7 @@ def __init__(self): "c": {"human_key": "c", "description": "Clear all filters set"}, "f": {"human_key": "f", "description": "Filter threads by field(s)"}, "E": {"human_key": "E", "description": "Export the processlist to a CSV file"}, - "k": {"human_key": "k", "description": "Kill thread by its ID"}, - "K": {"human_key": "K", "description": "Kill threads by parameter(s)"}, + "k": {"human_key": "k", "description": "Kill thread(s)"}, "M": {"human_key": "M", "description": "Maximize a panel"}, "q": {"human_key": "q", "description": "Quit"}, "r": {"human_key": "r", "description": "Set the refresh interval"}, diff --git a/dolphie/Modules/MySQL.py b/dolphie/Modules/MySQL.py index 72dd1f4..62920d0 100644 --- a/dolphie/Modules/MySQL.py +++ b/dolphie/Modules/MySQL.py @@ -191,9 +191,9 @@ def execute(self, query, values=None, ignore_error=False): ) return None - # Prefix all queries with dolphie so they can be identified in the processlist from other people + # Prefix all queries with Dolphie so they can be easily identified in the processlist from other people if self.source != ConnectionSource.proxysql: - query = "/* dolphie */ " + query + query = "/* Dolphie */ " + query for attempt_number in range(self.max_reconnect_attempts): self.is_running_query = True diff --git a/dolphie/Widgets/AutoComplete.py b/dolphie/Widgets/AutoComplete.py index 725792f..f9efd1e 100644 --- a/dolphie/Widgets/AutoComplete.py +++ b/dolphie/Widgets/AutoComplete.py @@ -253,15 +253,16 @@ class Dropdown(Widget): max-height: 12; max-width: 1fr; scrollbar-size-vertical: 1; + border-left: wide #384673; } Dropdown .autocomplete--highlight-match { - color: $accent-lighten-2; text-style: bold; } Dropdown .autocomplete--selection-cursor { background: $boost; + text-style: bold; } """ @@ -325,11 +326,11 @@ def on_mount(self, event: events.Mount) -> None: callback=self._input_value_changed, ) - self.watch( - self.input_widget, - attribute_name="cursor_position", - callback=self._input_cursor_position_changed, - ) + # self.watch( + # self.input_widget, + # attribute_name="cursor_position", + # callback=self._input_cursor_position_changed, + # ) # TODO: Having to use scroll_target here because scroll_y doesn't fire. # Will also probably need separate callbacks for x and y. @@ -427,7 +428,7 @@ def reposition( x, y, width, height = self.input_widget.content_region line_below_cursor = y + 1 + scroll_target_adjust_y - cursor_screen_position = x + (input_cursor_position - self.input_widget.view_position) + cursor_screen_position = self.app.cursor_position.x self.styles.margin = ( line_below_cursor, right, diff --git a/dolphie/Widgets/CommandModal.py b/dolphie/Widgets/CommandModal.py index 2bd1f1d..3cc0480 100644 --- a/dolphie/Widgets/CommandModal.py +++ b/dolphie/Widgets/CommandModal.py @@ -1,10 +1,11 @@ import re +from textual import on from textual.app import ComposeResult from textual.binding import Binding from textual.containers import Horizontal, Vertical from textual.screen import ModalScreen -from textual.widgets import Button, Checkbox, Input, Label, Select, Static +from textual.widgets import Button, Checkbox, Input, Label, Rule, Select, Static from dolphie.DataTypes import ConnectionSource, HotkeyCommands from dolphie.Widgets.AutoComplete import AutoComplete, Dropdown, DropdownItem @@ -42,6 +43,11 @@ class CommandModal(ModalScreen): padding-bottom: 1; } + & Rule { + width: 100%; + margin-bottom: 1; + } + & #error_response { color: #fe5c5c; width: 100%; @@ -119,6 +125,8 @@ def compose(self) -> ComposeResult: yield Input(id="filter_by_query_time_input") yield Input(id="filter_by_query_text_input") with Vertical(id="kill_container", classes="command_container"): + yield AutoComplete(Input(id="kill_by_id_input"), Dropdown(id="kill_by_id_dropdown_items", items=[])) + yield Rule(line_style="heavy") yield AutoComplete( Input(id="kill_by_username_input"), Dropdown(id="kill_by_username_dropdown_items", items=[]) ) @@ -128,7 +136,10 @@ def compose(self) -> ComposeResult: yield Input(id="kill_by_age_range_input", placeholder="Example: 5-8") yield Input(id="kill_by_query_text_input") yield Checkbox("Include sleeping queries", id="sleeping_queries") - yield Label("[dark_gray][b]Note[/b]: This feature uses threads visible in the processlist") + yield Label( + "[dark_gray][b]Note:[/b] Only threads visible and executing (or sleeping)\n" + "in the Processlist panel can be killed in this section" + ) yield AutoComplete( Input(id="modal_input"), Dropdown(id="dropdown_items", items=self.dropdown_items), @@ -161,8 +172,12 @@ def on_mount(self): self.query_one("#filter_by_host_dropdown_items", Dropdown).items = self.create_dropdown_items("host") self.query_one("#filter_by_db_input", Input).border_title = "Database" self.query_one("#filter_by_db_dropdown_items", Dropdown).items = self.create_dropdown_items("db") - self.query_one("#filter_by_query_time_input", Input).border_title = "Minimum Query Time (seconds)" - self.query_one("#filter_by_query_text_input", Input).border_title = "Partial Query Text" + self.query_one("#filter_by_query_time_input", Input).border_title = ( + "Minimum Query Time [dark_gray](seconds)" + ) + self.query_one("#filter_by_query_text_input", Input).border_title = ( + "Partial Query Text [dark_gray](case-sensitive)" + ) if self.connection_source != ConnectionSource.proxysql: self.query_one("#filter_by_hostgroup_input", Input).display = False @@ -176,13 +191,17 @@ def on_mount(self): input.display = False kill_container.display = True - self.query_one("#kill_by_username_input", Input).focus() + self.query_one("#kill_by_id_input", Input).focus() + self.query_one("#kill_by_id_dropdown_items", Dropdown).items = self.dropdown_items + self.query_one("#kill_by_id_input", Input).border_title = "Thread ID [dark_gray](enter submits)" self.query_one("#kill_by_username_input", Input).border_title = "Username" self.query_one("#kill_by_username_dropdown_items", Dropdown).items = self.create_dropdown_items("user") self.query_one("#kill_by_host_input", Input).border_title = "Host/IP" self.query_one("#kill_by_host_dropdown_items", Dropdown).items = self.create_dropdown_items("host") - self.query_one("#kill_by_age_range_input", Input).border_title = "Age Range (seconds)" - self.query_one("#kill_by_query_text_input", Input).border_title = "Partial Query Text" + self.query_one("#kill_by_age_range_input", Input).border_title = "Age Range [dark_gray](seconds)" + self.query_one("#kill_by_query_text_input", Input).border_title = ( + "Partial Query Text [dark_gray](case-sensitive)" + ) sleeping_queries_checkbox = self.query_one("#sleeping_queries", Checkbox) sleeping_queries_checkbox.toggle() @@ -198,7 +217,7 @@ def on_mount(self): elif self.command == HotkeyCommands.variable_search: input.placeholder = "Input 'all' to show everything" input.focus() - elif self.command in [HotkeyCommands.show_thread, HotkeyCommands.thread_kill_by_id]: + elif self.command in [HotkeyCommands.show_thread]: input.placeholder = "Input a Thread ID" input.focus() elif self.command == HotkeyCommands.refresh_interval: @@ -275,6 +294,7 @@ def on_button_pressed(self, event: Button.Pressed) -> None: self.dismiss(list(filters.values())) elif self.command == HotkeyCommands.thread_kill_by_parameter: # Get input values + kill_by_id = self.query_one("#kill_by_id_input", Input).value kill_by_username = self.query_one("#kill_by_username_input", Input).value kill_by_host = self.query_one("#kill_by_host_input", Input).value kill_by_age_range = self.query_one("#kill_by_age_range_input", Input).value @@ -283,6 +303,10 @@ def on_button_pressed(self, event: Button.Pressed) -> None: age_range_lower_limit, age_range_upper_limit = None, None + if kill_by_id and not kill_by_id.isdigit(): + self.update_error_response("Thread ID must be a number") + return + # Process and validate age range input if kill_by_age_range: match = re.match(r"(\d+)-(\d+)", kill_by_age_range) @@ -296,13 +320,14 @@ def on_button_pressed(self, event: Button.Pressed) -> None: return # Ensure at least one parameter is provided - if not any([kill_by_username, kill_by_host, kill_by_age_range, kill_by_query_text]): - self.update_error_response("At least one parameter must be provided") + if not any([kill_by_id, kill_by_username, kill_by_host, kill_by_age_range, kill_by_query_text]): + self.update_error_response("At least Thread ID or one parameter must be provided") return # Dismiss with the filter values self.dismiss( [ + kill_by_id, kill_by_username, kill_by_host, kill_by_age_range, @@ -313,11 +338,10 @@ def on_button_pressed(self, event: Button.Pressed) -> None: ] ) - elif self.command in {HotkeyCommands.thread_kill_by_id, HotkeyCommands.show_thread}: - if self.command == HotkeyCommands.show_thread: - if modal_input not in self.processlist_data: - self.update_error_response(f"Thread ID [bold red]{modal_input}[/bold red] does not exist") - return + elif self.command in {HotkeyCommands.show_thread}: + if modal_input not in self.processlist_data: + self.update_error_response(f"Thread ID [bold red]{modal_input}[/bold red] does not exist") + return if not modal_input.isdigit(): self.update_error_response("Thread ID must be a number") @@ -359,6 +383,11 @@ def update_error_response(self, message): error_response.display = True error_response.update(message) - def on_input_submitted(self): + @on(Input.Submitted, "Input") + def on_input_submitted(self, event: Input.Submitted): if self.command not in [HotkeyCommands.thread_filter, HotkeyCommands.thread_kill_by_parameter]: self.query_one("#submit", Button).press() + + @on(Input.Submitted, "#kill_by_id_input") + def on_kill_by_id_input_submitted(self, event: Input.Submitted): + self.query_one("#submit", Button).press() diff --git a/poetry.lock b/poetry.lock index bd22a9c..71c3e75 100644 --- a/poetry.lock +++ b/poetry.lock @@ -640,13 +640,13 @@ doc = ["sphinx"] [[package]] name = "textual" -version = "0.89.1" +version = "1.0.0" description = "Modern Text User Interface framework" optional = false python-versions = "<4.0.0,>=3.8.1" files = [ - {file = "textual-0.89.1-py3-none-any.whl", hash = "sha256:0a5d214df6e951b4a2c421e13d0b608482882471c1e34ea74a3631adede8054f"}, - {file = "textual-0.89.1.tar.gz", hash = "sha256:66befe80e2bca5a8c876cd8ceeaf01752267b6b1dc1d0f73071f1f1e15d90cc8"}, + {file = "textual-1.0.0-py3-none-any.whl", hash = "sha256:2d4a701781c05104925e463ae370c630567c70c2880e92ab838052e3e23c986f"}, + {file = "textual-1.0.0.tar.gz", hash = "sha256:bec9fe63547c1c552569d1b75d309038b7d456c03f86dfa3706ddb099b151399"}, ] [package.dependencies] @@ -1200,4 +1200,4 @@ cffi = ["cffi (>=1.11)"] [metadata] lock-version = "2.0" python-versions = "^3.8.1" -content-hash = "e92c0ae99efbb60291861a6ca3dbfc9a73940e4f9215bf55d6063dfb6cfd1ac6" +content-hash = "4d42f0123dc2569225f3f43b465d0599f16f7f9ab602127050a31c38ddba03d5" diff --git a/pyproject.toml b/pyproject.toml index b9ef5bf..228f778 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "dolphie" -version = "6.7.2" +version = "6.7.3" license = "GPL-3.0-or-later" description = "Your single pane of glass for real-time analytics into MySQL/MariaDB & ProxySQL" authors = ["Charles Thompson <01charles.t@gmail.com>"] @@ -13,11 +13,11 @@ pymysql = "^1.1.1" myloginpath = "^0.0.4" packaging = "^24.2" requests = "^2.32.3" -sqlparse = "^0.5.2" -textual = {extras = ["syntax"], version = "^0.89.1"} +sqlparse = "^0.5.3" +textual = {extras = ["syntax"], version = "^1.0.0"} plotext = "^5.3.2" zstandard = "^0.23.0" -loguru = "^0.7.2" +loguru = "^0.7.3" orjson = "^3.10.12" psutil = "^6.1.0"