New keybindings, height/width vars

This commit is contained in:
Digital Studium 2024-04-26 07:40:54 +03:00
parent 56eb336ac9
commit 67ce50dbb8
2 changed files with 43 additions and 30 deletions

View File

@ -1,11 +1,13 @@
# KLS # KLS
## Description ## Description
`kls` is a cli tool for managing kubernetes cluster resources. Inspired by `lf` and `ranger` file managers. `kls` is a cli tool based on `kubectl` for managing kubernetes cluster resources.
Inspired by `lf` and `ranger` file managers.
It is lightweight and easy to customize. Supports mouse navigation as well as keyboard navigation. It is lightweight and easy to customize. Supports mouse navigation as well as keyboard navigation.
## Key bindings for kubectl ## Key bindings
- `1` - get yaml of resource For kubectl (You can customize these bindings or add extra bindings in `KEY_BINDINGS` variable of `kls` in a row #4):
- `1` or `Enter` - get yaml of resource
- `2` - describe resource - `2` - describe resource
- `3` - edit resource - `3` - edit resource
- `4` - logs of pod - `4` - logs of pod
@ -13,7 +15,9 @@ It is lightweight and easy to customize. Supports mouse navigation as well as ke
- `6` - network debug of pod (with nicolaka/netshoot container attached) - `6` - network debug of pod (with nicolaka/netshoot container attached)
- `delete` - delete resource - `delete` - delete resource
You can customize these bindings or add extra bindings in `KEY_BINDINGS` variable of `kls` (in a row #4). Other:
- `Escape` - exit filter mode or `kls` itself
- `TAB`, arrow keys - navigation
![kls in action](./images/kls.gif) ![kls in action](./images/kls.gif)

View File

@ -3,6 +3,7 @@ import subprocess, curses, time
KEY_BINDINGS = { # can be extended KEY_BINDINGS = { # can be extended
"1": 'kubectl -n {namespace} get {api_resource} {resource} -o yaml | batcat -l yaml --paging always --style numbers', "1": 'kubectl -n {namespace} get {api_resource} {resource} -o yaml | batcat -l yaml --paging always --style numbers',
"\n": 'kubectl -n {namespace} get {api_resource} {resource} -o yaml | batcat -l yaml --paging always --style numbers', # Enter key
"2": 'kubectl -n {namespace} describe {api_resource} {resource} | batcat -l yaml --paging always --style numbers', "2": 'kubectl -n {namespace} describe {api_resource} {resource} | batcat -l yaml --paging always --style numbers',
"3": 'kubectl -n {namespace} edit {api_resource} {resource}', "3": 'kubectl -n {namespace} edit {api_resource} {resource}',
"4": 'kubectl -n {namespace} logs {resource} | batcat -l log --paging always --style numbers', "4": 'kubectl -n {namespace} logs {resource} | batcat -l log --paging always --style numbers',
@ -12,11 +13,17 @@ KEY_BINDINGS = { # can be extended
} }
# which api resources are on the top of menu? # which api resources are on the top of menu?
TOP_API_RESOURCES = ["pods", "services", "configmaps", "secrets", "persistentvolumeclaims", "ingresses", "nodes", TOP_API_RESOURCES = ["pods", "services", "configmaps", "secrets", "persistentvolumeclaims", "ingresses", "nodes",
"deployments", "statefulsets", "daemonsets", "storageclasses"] "deployments", "statefulsets", "daemonsets", "storageclasses", "all"]
HELP_TEXT = "Esc: exit filter mode or exit kls, 1: get yaml, 2: describe, 3: edit, 4: pod logs, arrows/TAB: navigation" HELP_TEXT = "Esc: exit filter mode or exit kls, 1/Enter: get yaml, 2: describe, 3: edit, 4: logs, 5: exec, 6: debug, arrows/TAB: navigation"
MOUSE = True MOUSE_ENABLED = True
SCREEN = curses.initscr() # screen initialization, needed for ROWS_HEIGHT working
HEADER_HEIGHT = 4 # in rows
FOOTER_HEIGHT = 3
ROWS_HEIGHT = curses.LINES - HEADER_HEIGHT - FOOTER_HEIGHT - 3 # maximum number of visible rows indices
WIDTH = curses.COLS
class Menu: class Menu:
@ -29,14 +36,14 @@ class Menu:
# __start_index - starting from which row we will select rows from filtered_rows()? Usually from the first row, # __start_index - starting from which row we will select rows from filtered_rows()? Usually from the first row,
# but if the size of filtered_rows is greater than HEIGHT and filtered_row_index exceeds the menu HEIGHT, # but if the size of filtered_rows is greater than HEIGHT and filtered_row_index exceeds the menu HEIGHT,
# we shift __start_index to the right by filtered_row_index - HEIGHT. This way we implement menu scrolling # we shift __start_index to the right by filtered_row_index - HEIGHT. This way we implement menu scrolling
self.__start_index = lambda: 0 if self.filtered_row_index < HEIGHT else self.filtered_row_index - HEIGHT + 1 self.__start_index = lambda: 0 if self.filtered_row_index < ROWS_HEIGHT else self.filtered_row_index - ROWS_HEIGHT + 1
self.visible_rows = lambda: self.filtered_rows()[self.__start_index():][:HEIGHT] # visible rows self.visible_rows = lambda: self.filtered_rows()[self.__start_index():][:ROWS_HEIGHT] # visible rows
self.__visible_row_index = lambda: self.filtered_row_index - self.__start_index() # index of the selected visible row self.__visible_row_index = lambda: self.filtered_row_index - self.__start_index() # index of the selected visible row
# selected row from visible rows # selected row from visible rows
self.selected_row = lambda: self.visible_rows()[self.__visible_row_index()] if self.visible_rows() else None self.selected_row = lambda: self.visible_rows()[self.__visible_row_index()] if self.visible_rows() else None
self.width = width self.width = width
self.begin_x = begin_x self.begin_x = begin_x
self.win = curses.newwin(curses.LINES - 3, width, 0, begin_x) self.win = curses.newwin(curses.LINES - FOOTER_HEIGHT, width, 0, begin_x)
def draw_row(window: curses.window, text: str, y: int, x: int, selected: bool = False): def draw_row(window: curses.window, text: str, y: int, x: int, selected: bool = False):
@ -47,14 +54,14 @@ def draw_row(window: curses.window, text: str, y: int, x: int, selected: bool =
def draw_rows(menu: Menu): def draw_rows(menu: Menu):
for index, row in enumerate(menu.visible_rows()): for index, row in enumerate(menu.visible_rows()):
draw_row(menu.win, row, index + 3, 2, selected=True if row == menu.selected_row() else False) draw_row(menu.win, row, index + HEADER_HEIGHT, 2, selected=True if row == menu.selected_row() else False)
def draw_menu(menu: Menu): def draw_menu(menu: Menu):
menu.win.clear() # clear menu window menu.win.clear() # clear menu window
draw_row(menu.win, menu.title, 1, 2, selected=True if menu == SELECTED_MENU else False) # draw title draw_row(menu.win, menu.title, 1, 2, selected=True if menu == SELECTED_MENU else False) # draw title
draw_rows(menu) # draw menu rows draw_rows(menu) # draw menu rows
draw_row(menu.win, f"/{menu.filter}" if menu.filter else "", curses.LINES - 5, 2) # draw filter row draw_row(menu.win, f"/{menu.filter}" if menu.filter else "", curses.LINES - FOOTER_HEIGHT - 2, 2) # draw filter row
def refresh_third_menu(): def refresh_third_menu():
@ -64,11 +71,15 @@ def refresh_third_menu():
draw_menu(MENUS[2]) draw_menu(MENUS[2])
def run_command(key: str): def run_command(key: str, api_resource: str, resource: str):
if not (key in ("4","5","6") and api_resource() != "pods"): if key in ("4", "5", "6"):
if api_resource not in ["pods", "all"] or (api_resource == "all" and not resource.startswith("pod/")):
return
curses.def_prog_mode() # save the previous terminal state curses.def_prog_mode() # save the previous terminal state
curses.endwin() # without this, there are problems after exiting vim curses.endwin() # without this, there are problems after exiting vim
command = KEY_BINDINGS[key].format(namespace=namespace(), api_resource=api_resource(), resource=resource()) command = KEY_BINDINGS[key].format(namespace=namespace(), api_resource=api_resource, resource=resource)
if api_resource == "all":
command = command.replace(" all", "")
subprocess.call(command, shell=True) subprocess.call(command, shell=True)
curses.reset_prog_mode() # restore the previous terminal state curses.reset_prog_mode() # restore the previous terminal state
SCREEN.refresh() SCREEN.refresh()
@ -92,7 +103,7 @@ def handle_filter_state(key: str, menu: Menu):
def handle_mouse(mouse_info: tuple, menu: Menu): def handle_mouse(mouse_info: tuple, menu: Menu):
row_number = mouse_info[2] - 3 row_number = mouse_info[2] - HEADER_HEIGHT
column_number = mouse_info[1] column_number = mouse_info[1]
next_menu = None next_menu = None
if column_number > (menu.begin_x + menu.width): if column_number > (menu.begin_x + menu.width):
@ -140,11 +151,11 @@ def catch_input(menu: Menu):
draw_rows(menu) # this will change selected row in menu draw_rows(menu) # this will change selected row in menu
if menu != MENUS[2]: if menu != MENUS[2]:
MENUS[2].filtered_row_index = 0 # reset the selected row index of third menu before redrawing MENUS[2].filtered_row_index = 0 # reset the selected row index of third menu before redrawing
elif key == "KEY_MOUSE" and MOUSE: elif key == "KEY_MOUSE" and MOUSE_ENABLED:
mouse_info = curses.getmouse() mouse_info = curses.getmouse()
handle_mouse(mouse_info, menu) handle_mouse(mouse_info, menu)
elif key in KEY_BINDINGS.keys() and MENUS[2].selected_row(): elif key in KEY_BINDINGS.keys() and MENUS[2].selected_row():
run_command(key) run_command(key, api_resource(), resource())
elif key == "\x1b" and not menu.filter: elif key == "\x1b" and not menu.filter:
globals().update(SELECTED_MENU=None) # exit globals().update(SELECTED_MENU=None) # exit
else: else:
@ -155,15 +166,13 @@ def kubectl(command: str) -> list:
return subprocess.check_output(f"kubectl {command}", shell=True).decode().strip().split("\n") return subprocess.check_output(f"kubectl {command}", shell=True).decode().strip().split("\n")
SCREEN = curses.initscr() # screen initialization
api_resources_kubectl = [x.split()[0] for x in kubectl("api-resources --no-headers --verbs=get")] api_resources_kubectl = [x.split()[0] for x in kubectl("api-resources --no-headers --verbs=get")]
api_resources = list(dict.fromkeys(TOP_API_RESOURCES + api_resources_kubectl)) # so top api resources are at the top api_resources = list(dict.fromkeys(TOP_API_RESOURCES + api_resources_kubectl)) # so top api resources are at the top
width_unit = curses.COLS // 8 width_unit = WIDTH // 8
MENUS = [Menu("Namespaces", kubectl("get ns --no-headers -o custom-columns=NAME:.metadata.name"), 0, width_unit), MENUS = [Menu("Namespaces", kubectl("get ns --no-headers -o custom-columns=NAME:.metadata.name"), 0, width_unit),
Menu("API resources", api_resources, width_unit, width_unit * 2), Menu("API resources", api_resources, width_unit, width_unit * 2),
Menu("Resources", [], width_unit * 3, curses.COLS - width_unit * 3)] Menu("Resources", [], width_unit * 3, WIDTH - width_unit * 3)]
SELECTED_MENU = MENUS[0] SELECTED_MENU = MENUS[0]
HEIGHT = curses.LINES - 9 # maximum number of visible row indices
namespace = MENUS[0].selected_row # method alias namespace = MENUS[0].selected_row # method alias
api_resource = MENUS[1].selected_row api_resource = MENUS[1].selected_row
resource = lambda: MENUS[2].selected_row().split()[0] resource = lambda: MENUS[2].selected_row().split()[0]
@ -181,7 +190,7 @@ def main(screen):
print('\033[?1003h') # enable mouse tracking with the XTERM API. That's the magic print('\033[?1003h') # enable mouse tracking with the XTERM API. That's the magic
for menu in MENUS: # draw the main windows for menu in MENUS: # draw the main windows
draw_menu(menu) draw_menu(menu)
draw_row(curses.newwin(3, curses.COLS, curses.LINES - 3, 0), HELP_TEXT, 1, 2) # and the help window draw_row(curses.newwin(3, curses.COLS, curses.LINES - FOOTER_HEIGHT, 0), HELP_TEXT, 1, 2) # and the help window
while SELECTED_MENU: while SELECTED_MENU:
catch_input(SELECTED_MENU) # if a menu is selected, catch user input catch_input(SELECTED_MENU) # if a menu is selected, catch user input