diff --git a/nvtop/README.md b/nvtop/README.md new file mode 100644 index 00000000..88051888 --- /dev/null +++ b/nvtop/README.md @@ -0,0 +1,83 @@ +# NVTOP + +Monitor real-time information about your GPU status and running processes with nvtop. + +# Features + +* Uses `nvtop` to monitor real-time information about your GPU status. +* Support for multiple GPUs +* View processes using the GPU +* Process columns: **PID**, **GPU Usage**, **Video Encoder Usage**, **Video Decoder Usage**, **GPU Memory Usage**, and **Command Line** +* Sort processes by any column by clicking its header +* Customizable polling interval +* Customizable colors for the sorted column +* The plugin runs `nvtop` when the panel is opened and closes it when the panel is closed. This helps you save resources. + +![NVTOP panel](screenshots/panel.png) + +## Plugin + +| Field | Value | +| --- | --- | +| ID | `tordex/nvtop` | +| Entries | Panel: `panel` | + +## Requirements + +The plugin requires `nvtop`, `jq`, and `pkill` to be installed and available in `$PATH`. + +## Usage + +You can open the panel by binding it in your compositor or by setting the action for `sysmon` widgets: + +![Actions](screenshots/actions.png) + +```toml +[widget.GPU_Usage] +stat = "gpu_usage" +type = "sysmon" + + [widget.GPU_Usage.actions] + left = "panel-toggle tordex/nvtop:panel gpu" + +[widget.GPU_VRAM] +stat = "gpu_vram_used" +type = "sysmon" + + [widget.GPU_VRAM.actions] + left = "panel-toggle tordex/nvtop:panel mem" +``` + +To open the panel from the command line, use: + +```sh +noctalia msg panel-toggle tordex/nvtop:panel [order_by] +``` + +Possible values for `order_by`: + +| `order_by` value | Effect | +|--------------------|-------------------------------| +| `pid` | Order by Process ID (PID) | +| `gpu` | Order by GPU Usage | +| `enc` | Order by Video Encoder Usage | +| `dec` | Order by Video Decoder Usage | +| `mem` | GPU Memory Usage | +| `cmd` | Order by Process Command Line | + +Without `order_by`, the panel opens with the previous sort mode. + +Add `-` before `order_by` to reverse the sort order. For example: + +```sh +noctalia msg panel-toggle tordex/nvtop:panel -mem +``` + +## Settings + +| Setting | Type | Default | Description | +| --- | --- | --- | --- | +| `delay` | `int` | `5` | Refresh rate. 1 == 0.1s. Valid values 1-20 | +| `sort_column_background` | `color` | `secondary` | The background color for the sort column. | +| `sort_column_color` | `color` | `on_secondary` | The text color for the sort column. | + diff --git a/nvtop/panel.luau b/nvtop/panel.luau new file mode 100644 index 00000000..365548a6 --- /dev/null +++ b/nvtop/panel.luau @@ -0,0 +1,522 @@ +--!nonstrict + +-- Data from the nvtop command, which is updated in real-time. +local nvtop_data = {} +-- The PID of the nvtop process, which is used to terminate the process when the panel is closed. +local nvtop_pid = -1 +-- The index of the currently selected GPU, which is used to display the correct GPU info. +local selected_gpu = 1 + +local g_order_by = "gpu_mem_bytes_alloc" + +-- Forward declaration of the render function to allow it to be called from other functions. +local render + +-- Render a single row of GPU info with a label, value, and glyph. +local function gpu_info_row(label, value, glyph) + return ui.row({ + gap = 8, + },{ + ui.glyph({ + name = glyph, + color = "on_surface", + }), + ui.label({ + text = label .. ":", + color = "on_surface", + }) + }), ui.label({ + text = value, + flexGrow = 1, + fontWeight = "bold", + color = "on_surface", + }) +end + +-- Format memory size in bytes to a human-readable string in M or G. +local function format_memory_size(size_in_bytes) + local size_in_mb = size_in_bytes / (1024 * 1024) + if size_in_mb >= 1024 then + local size_in_gb = size_in_mb / 1024 + return string.format("%.2f G", size_in_gb) + else + return string.format("%.1f M", size_in_mb) + end +end + +-- Render a message when there is no data available. +local function render_no_data_message() + if #nvtop_data == 0 then + panel.render(ui.row({ + key = "no-data-row", + fill = "surface_variant", + border = "outline", + padding = 16, + radius = 12, + gap = 8, + },{ + ui.glyph({ + key = "no-data-glyph", + name = "info-triangle" + }), + ui.label({ + key = "no-data-label", + text = noctalia.tr("nvtop.nodata"), + }) + })) + return true + end + return false +end + +-- Render the list of GPUs as buttons, and highlight the selected GPU. +local function render_gpu_list(selected_idx) + local children = {} + for idx, gpu in ipairs(nvtop_data) do + table.insert(children, + ui.button({ + selected = idx == selected_idx, + glyph = "cpu", + text = gpu.device_name, + variant = "outline", + onClick = function() + selected_gpu = idx + render() + end + }) + ) + end + return ui.row({ + gap = 4, + }, children) +end + +-- Define the GPU info rows to display in the panel. +local gpu_info = { + { + label = noctalia.tr("nvtop.gpu_load"), + glyph = "brand-speedtest", + data_key = "gpu_util", + func = function(value) return value end, + column = 1 + }, + { + label = noctalia.tr("nvtop.gpu_clock"), + glyph = "bolt", + data_key = "gpu_clock", + func = function(value) return value end, + column = 1 + }, + { + label = noctalia.tr("nvtop.temperature"), + glyph = "temperature", + data_key = "temp", + func = function(value) return value end, + column = 1 + }, + { + label = noctalia.tr("nvtop.fan_speed"), + glyph = "car-fan", + data_key = "fan_speed", + func = function(value) return value end, + column = 1 + }, + { + label = noctalia.tr("nvtop.power"), + glyph = "bolt", + data_key = "power_draw", + func = function(value) return value end, + column = 1 + }, + { + label = noctalia.tr("nvtop.memory_clock"), + glyph = "bolt", + data_key = "mem_clock", + func = function(value) return value end, + column = 2 + }, + { + label = noctalia.tr("nvtop.memory_total"), + glyph = "cpu-2", + data_key = "mem_total", + func = format_memory_size, + column = 2 + }, + { + label = noctalia.tr("nvtop.memory_used"), + glyph = "cpu-2", + data_key = "mem_used", + func = format_memory_size, + column = 2 + }, + { + label = noctalia.tr("nvtop.memory_free"), + glyph = "cpu-2", + data_key = "mem_free", + func = format_memory_size, + column = 2 + }, +} + +-- Render the GPU info for the selected GPU. +local function render_gpu_info(selected_idx) + local columns = { + { + labels = {}, + values = {}, + }, + { + labels = {}, + values = {}, + } + } + + for _, info in ipairs(gpu_info) do + if nvtop_data[selected_idx][info.data_key] ~= nil then + local label, value = gpu_info_row(info.label, info.func(nvtop_data[selected_idx][info.data_key]), info.glyph) + table.insert(columns[info.column].labels, label) + table.insert(columns[info.column].values, value) + end + end + + local children = {} + for _, column in ipairs(columns) do + table.insert(children, ui.column({ + gap = 1, + }, column.labels)) + table.insert(children, ui.column({ + gap = 1, + flexGrow = 1, + }, column.values)) + end + + return ui.row({ + gap = 8, + padding = 8, + radius = 12, + border = "outline", + fill = "surface_variant", + borderWidth = 1, + }, children) +end + +local function persent_to_number(value) + if value == nil then + return 0 + end + local number = tonumber(value:match("(%d+)%%")) + return number or 0 +end + +-- Define the process data columns to display in the panel. +local processes_data = { + { + data_key = "pid", + context_key = "pid", + label = noctalia.tr("nvtop.process.pid.label"), + tip = noctalia.tr("nvtop.process.pid.tip"), + flexGrow = 2, + textAlign = "end", + process_value = function(value) return value end, + sort_value = function(value) return tonumber(value) end + }, + { + data_key = "gpu_usage", + context_key = "gpu", + label = noctalia.tr("nvtop.process.gpu_usage.label"), + tip = noctalia.tr("nvtop.process.gpu_usage.tip"), + flexGrow = 1.5, + textAlign = "end", + process_value = function(value) return value or "n/a" end, + sort_value = function(value) return persent_to_number(value) end + }, + { + data_key = "encode", + context_key = "enc", + label = noctalia.tr("nvtop.process.encode.label"), + tip = noctalia.tr("nvtop.process.encode.tip"), + flexGrow = 1.5, + textAlign = "end", + process_value = function(value) return value or "n/a" end, + sort_value = function(value) return persent_to_number(value) end + }, + { + data_key = "decode", + context_key = "dec", + label = noctalia.tr("nvtop.process.decode.label"), + tip = noctalia.tr("nvtop.process.decode.tip"), + flexGrow = 1.5, + textAlign = "end", + process_value = function(value) return value or "n/a" end, + sort_value = function(value) return persent_to_number(value) end + }, + { + data_key = "gpu_mem_bytes_alloc", + context_key = "mem", + label = noctalia.tr("nvtop.process.gpu_mem.label"), + tip = noctalia.tr("nvtop.process.gpu_mem.tip"), + flexGrow = 2, + textAlign = "end", + process_value = function(value) return format_memory_size(value) end, + sort_value = function(value) return tonumber(value) end, + }, + { + data_key = "cmdline", + context_key = "cmd", + label = noctalia.tr("nvtop.process.cmdline.label"), + tip = noctalia.tr("nvtop.process.cmdline.tip"), + flexGrow = 10, + textAlign = "start", + process_value = function(value) return value end, + sort_value = function(value) return tostring(value) end, + }, +} + +local processes_data_transfer = {} +for _, column in ipairs(processes_data) do + processes_data_transfer[column.data_key] = column.sort_value +end + +local function values_are_equal(a, b) + if a == nil and b == nil then + return true + elseif a == nil or b == nil then + return false + end + + if type(a) == "number" and type(b) == "number" then + return a == b + else + return tostring(a) == tostring(b) + end +end + +local function compare_values(a, b, reverse) + if a == nil and b == nil then + return false + elseif a == nil then + return not reverse + elseif b == nil then + return reverse + end + + if type(a) == "number" and type(b) == "number" then + if reverse then + return a > b + else + return a < b + end + else + if reverse then + return tostring(a) > tostring(b) + else + return tostring(a) < tostring(b) + end + end +end + +-- Sort the processes based on the selected column and order (ascending or descending). +local function sort_processes(processes) + local order_by = g_order_by + local reverse = true + if order_by:sub(1, 1) == "-" then + order_by = order_by:sub(2) + reverse = false + end + + table.sort(processes, function(a, b) + local a_value = processes_data_transfer[order_by](a[order_by]) + local b_value = processes_data_transfer[order_by](b[order_by]) + local a_value2 = processes_data_transfer["gpu_mem_bytes_alloc"](a["gpu_mem_bytes_alloc"]) + local b_value2 = processes_data_transfer["gpu_mem_bytes_alloc"](b["gpu_mem_bytes_alloc"]) + + if a_value == nil and b_value == nil then + return false + elseif a_value == nil then + return not reverse + elseif b_value == nil then + return reverse + end + + if values_are_equal(a_value, b_value) then + return compare_values(a_value2, b_value2, reverse) + end + + return compare_values(a_value, b_value, reverse) + end) +end + +local function sort_all_processes() + for _, gpu in ipairs(nvtop_data) do + if gpu.processes then + sort_processes(gpu.processes) + end + end +end + +-- Render the header row for the process list, with sortable columns. +local function render_processes_header() + local children = {} + for _, column in ipairs(processes_data) do + local sort_indicator = "" + if g_order_by == column.data_key then + sort_indicator = "▼ " + elseif g_order_by == "-" .. column.data_key then + sort_indicator = "▲ " + end + table.insert(children, ui.button({ + text = sort_indicator .. column.label, + tooltip = column.tip, + flexGrow = column.flexGrow, + variant = "ghost", + controlSize = "sm", + fontSize = 10, + selected = g_order_by == column.data_key or g_order_by == "-" .. column.data_key, + onClick = function() + if g_order_by == column.data_key then + g_order_by = "-" .. column.data_key + else + g_order_by = column.data_key + end + sort_all_processes() + render() + end + })) + end + return ui.row({ + gap = 8, + }, children) +end + +-- Render the list of processes for the selected GPU, with sortable columns. +local function render_processes(selected_idx) + local children = {} + for _, process in ipairs(nvtop_data[selected_idx].processes) do + local process_children = {} + for _, column in ipairs(processes_data) do + local fill = "surface_variant" + local color = "on_surface" + local fontWeight = "medium" + if g_order_by == column.data_key or g_order_by == "-" .. column.data_key then + fill = noctalia.getConfig("sort_column_background") or "secondary" + color = noctalia.getConfig("sort_column_color") or "on_secondary" + fontWeight = "bold" + end + table.insert(process_children, ui.column({ + flexGrow = column.flexGrow, + paddingH = 2, + fill = fill, + }, { + ui.label({ + text = column.process_value(process[column.data_key]), + maxLines = 1, + flexGrow = 1, + color = process[column.data_key] and color or color .. "/0.4", + textAlign = column.textAlign, + fontWeight = fontWeight, + }) + })) + end + table.insert(children, ui.row({ + key = "proc-row-" .. process.pid, + gap = 8, + }, process_children)) + end + + return ui.column({ + fill = "surface_variant", + border = "outline", + borderWidth = 1, + flexGrow = 1, + radius = 12, + }, { + render_processes_header(), + ui.scroll({ + flexGrow = 1, + gap = 1 + }, children) + }) +end + +-- Render the entire panel, including the GPU list, GPU info, and process list. +render = function() + if render_no_data_message() then + return + end + + local children = {} + + -- Fix the selected index if it is out of bounds, and render the GPU list. + local selected_idx = selected_gpu + if selected_idx > #nvtop_data or selected_idx < 1 then + selected_idx = 1 + end + + -- Add the GPU list to the children + table.insert(children, render_gpu_list(selected_idx)) + -- Add the GPU info for the selected GPU to the children + table.insert(children, render_gpu_info(selected_idx)) + -- Add the process rows for the selected GPU to the children + table.insert(children, render_processes(selected_idx)) + + panel.render(ui.column({ + flexGrow = 1, + gap = 6, + }, children)) +end + +-- Wrap the update command to capture the PID and exit code, so that we can track the progress of the update. +local function wrap_command(cmd) + return "echo tordex/nvtop:pid:$$;" .. cmd .. " 2>&1;echo tordex/nvtop:done:$?" +end + +local function run_nvtop() + local nvtop_cmd = wrap_command("nvtop -s | jq -c . ; nvtop -l -d " .. noctalia.getConfig("delay") .. " | jq -c .") + noctalia.runStream(nvtop_cmd, function(line) + if line:find("tordex/nvtop:pid:") then + nvtop_pid = tonumber(line:match("tordex/nvtop:pid:(%d+)")) + elseif not line:find("tordex/nvtop:done:") then + local data = noctalia.json.decode(line) or nil + if data then + nvtop_data = data + sort_all_processes() + render() + end + end + end) +end + +-- Stop the nvtop process if it is running, and reset the PID to -1. +local function stop_nvtop() + if nvtop_pid ~= -1 then + noctalia.runAsync("pkill -P " .. tostring(nvtop_pid)) + nvtop_pid = -1 + end +end + +-- Handle the panel opening event, and check for a context key to sort the process list by. +function onOpen(context) + if context ~= nil and context ~= "" then + context = context:lower() + for _, column in ipairs(processes_data) do + if column.context_key == context or "-" .. column.context_key == context then + g_order_by = column.data_key + if context:sub(1, 1) == "-" then + g_order_by = "-" .. column.data_key + end + sort_all_processes() + render() + break + end + end + end + stop_nvtop() + run_nvtop() +end + +-- Handle the panel closing event, and stop the nvtop process. +function onClose() + stop_nvtop() +end + +render() \ No newline at end of file diff --git a/nvtop/plugin.toml b/nvtop/plugin.toml new file mode 100644 index 00000000..db89124e --- /dev/null +++ b/nvtop/plugin.toml @@ -0,0 +1,41 @@ +id = "tordex/nvtop" +name = "NVTOP" +version = "1.0.0" +plugin_api = 20 +author = "tordex" +license = "MIT" +dependencies = ["nvtop", "jq", "pkill"] +tags = ["panel", "system"] +icon = "cpu" +description = "Monitor real-time information about your GPU status and running processes with nvtop." + +[[setting]] +key = "delay" +type = "int" +label_key = "settings.delay.label" +description_key = "settings.delay.description" +default = 5 +min = 1 +max = 20 + +[[setting]] +key = "sort_column_background" +type = "color" +label_key = "settings.sort_column_background.label" +description_key = "settings.sort_column_background.description" +default = "secondary" + +[[setting]] +key = "sort_column_color" +type = "color" +label_key = "settings.sort_column_color.label" +description_key = "settings.sort_column_color.description" +default = "on_secondary" + +[[panel]] +id = "panel" +entry = "panel.luau" +width = 750 +height = 600 +placement = "attached" +open_near_click = true diff --git a/nvtop/screenshots/actions.png b/nvtop/screenshots/actions.png new file mode 100644 index 00000000..455fe254 Binary files /dev/null and b/nvtop/screenshots/actions.png differ diff --git a/nvtop/screenshots/panel.png b/nvtop/screenshots/panel.png new file mode 100644 index 00000000..36c9a24c Binary files /dev/null and b/nvtop/screenshots/panel.png differ diff --git a/nvtop/thumbnail.webp b/nvtop/thumbnail.webp new file mode 100644 index 00000000..9d300cfb Binary files /dev/null and b/nvtop/thumbnail.webp differ diff --git a/nvtop/translations/en.json b/nvtop/translations/en.json new file mode 100644 index 00000000..69f37a5b --- /dev/null +++ b/nvtop/translations/en.json @@ -0,0 +1,54 @@ +{ + "settings": { + "delay": { + "description": "Select the refresh rate (1 == 0.1s)", + "label": "Refresh rate" + }, + "sort_column_background": { + "description": "Select the background color for the sort column", + "label": "Sort column background" + }, + "sort_column_color": { + "description": "Select the text color for the sort column", + "label": "Sort column text color" + } + }, + "nvtop": { + "nodata": "No data available", + "gpu_clock": "GPU Clock", + "memory_clock": "Memory Clock", + "gpu_load": "GPU Load", + "fan_speed": "Fan Speed", + "power": "Power", + "temperature": "Temperature", + "memory_used": "Memory Used", + "memory_free": "Memory Free", + "memory_total": "Memory Total", + "process": { + "pid": { + "label": "PID", + "tip": "Process ID" + }, + "gpu_usage": { + "label": "GPU", + "tip": "GPU Usage" + }, + "encode": { + "label": "ENC", + "tip": "Video Encoder Usage" + }, + "decode": { + "label": "DEC", + "tip": "Video Decoder Usage" + }, + "gpu_mem": { + "label": "VRAM", + "tip": "GPU Memory Usage" + }, + "cmdline": { + "label": "Command Line", + "tip": "Process Command Line" + } + } + } +}