Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
674 changes: 674 additions & 0 deletions kvitals/LICENSE

Large diffs are not rendered by default.

71 changes: 71 additions & 0 deletions kvitals/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# KVitals

Live CPU, RAM, GPU, temperature, fan, battery, network, and disk vitals
for the Noctalia bar, panel, and desktop. A Luau port of the KVitals
Plasma widget by yassine20011 (GPL-3.0).

## Plugin

| Field | Value |
| --- | --- |
| ID | `royalebiskut/kvitals` |
| Entries | Service: `sampler`; bar widget: `metrics`; panel: `sparkline`; desktop widget: `tile` |

## Requirements

- `cat` (coreutils) and `ip` (iproute2) on `PATH`.
- The Noctalia System Monitor service enabled for CPU, RAM, GPU, and load
data (`[system.monitor]` in the shell config).

## Usage

1. Install the plugin from the store and enable `royalebiskut/kvitals`.
2. Add the bar widget `metrics` from the Add-widget picker.
3. Add the desktop tile `tile` from the desktop widgets editor.
4. Left-click the bar widget to open the sparkline panel.
5. Right-click the bar widget to open the settings.

Open the panel with:

```sh
noctalia msg panel-toggle royalebiskut/kvitals:sparkline
```

## Settings

Settings live under Settings -> Plugins -> KVitals.

| Setting | Type | Default | Description |
| --- | --- | --- | --- |
| `refresh_ms` | `int` | `1000` | Sample interval in milliseconds. |
| `temp_unit` | `select` | `c` | Celsius or Fahrenheit. |
| `net_unit` | `select` | `auto` | Unit for network rates. |
| `metric_order` | `string_list` | `cpu, ram, temp, gpu, vram, fan, battery, net, disk` | Order of the metrics in the bar, panel, and desktop tile. |
| `enabled_cpu`, `enabled_ram`, `enabled_temp`, `enabled_gpu`, `enabled_vram`, `enabled_fan`, `enabled_battery`, `enabled_net`, `enabled_disk` | `bool` | `true` | Show or hide each metric. |
| `cpu_act` / `cpu_crit`, `ram_act` / `ram_crit`, `temp_act` / `temp_crit`, `gpu_act` / `gpu_crit`, `vram_act` / `vram_crit`, `disk_act` / `disk_crit` | `int` | per metric | Activity and critical thresholds in percent. Values at the activity level tint the metric toward the accent color; critical uses the accent color fully. |
| `net_act` / `net_crit` | `double` | `1` / `50` | Network thresholds in MB/s. |
| `icon_cpu`, `icon_ram`, `icon_temp`, `icon_gpu`, `icon_vram`, `icon_fan`, `icon_net`, `icon_disk` | `select` | per metric | Icon for each metric. |
| `accent_color`, `font_color` | `color` | theme | Colors for critical values, icons, and text. |
| `show_cpu_freq`, `show_power_draw`, `show_net_ip` | `bool` | `true` / `true` / `false` | Detail toggles for CPU clock, battery power draw, and the local IP address. |

Per-instance widget settings: `display_mode` (icons, text, or both),
`font_size`, and `hide_absent`. Panel settings: `show_graphs` and
`graph_height`. Desktop tile settings: `show_glyphs` and `font_size`.

## IPC

No extra IPC events. The bar widget opens the panel on left-click and the
settings on right-click.

## Notes

- Read-only: sysfs (`/sys/class/hwmon`, `/sys/class/power_supply`, CPU
cpufreq), `/proc/diskstats`, and the host System Monitor service.
The plugin writes no files and makes no network calls.
- Spawns `cat` for tiny sysfs reads and `ip -j -brief addr` every
30 seconds for the local IPv4 address (only when `show_net_ip` is
enabled and `ip` exists).
- GPU VRAM comes from the host System Monitor (amdgpu sysfs on AMD,
NVML on NVIDIA).
- License: GPL-3.0. Port of KVitals by yassine20011
(https://github.com/yassine20011/kvitals).
76 changes: 76 additions & 0 deletions kvitals/desktop.luau
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
--!nonstrict
-- KVitals desktop tile: the same metric chips as the bar widget, stacked
-- vertically and pinned to the desktop. Reads the plugin state like every
-- other surface.

local format = require("./lib/format.luau")
local chips = require("./lib/chips.luau")

local sample = noctalia.state.get("kvitals.sample")
local config = noctalia.state.get("kvitals.config")
local ready = noctalia.state.get("kvitals.ready") == true

local font_size = noctalia.getConfig("font_size") or 12
local show_glyphs = noctalia.getConfig("show_glyphs")
if show_glyphs == nil then show_glyphs = true end

local function chipColor(chip)
if chip.tint == "critical" then return config.accent end
if chip.tint == "active" then return format.withAlpha(config.accent, 0.65) end
return config.font
end

local function render()
if not ready or sample == nil or config == nil then
desktopWidget.render(ui.label({
text = "…",
fontSize = font_size,
color = "on_surface_variant",
}))
return
end

local children = {}
for _, id in ipairs(config.metricOrder) do
if config.enabled[id] then
local chip = chips.chip(id, sample, config)
if chip then
local node
if show_glyphs then
node = ui.row({ gap = 6, align = "center" }, {
ui.glyph({ name = chip.glyph, size = math.floor(font_size * 1.25) }),
ui.label({ text = chip.text, fontSize = font_size, fontWeight = "medium", color = chipColor(chip) }),
})
else
node = ui.label({ text = chip.text, fontSize = font_size, fontWeight = "medium", color = chipColor(chip) })
end
table.insert(children, node)
end
end
end

if #children == 0 then
children = { ui.label({ text = "-", fontSize = font_size, color = "on_surface_variant" }) }
end

desktopWidget.render(ui.column({ gap = 8, align = "start" }, children))
end

noctalia.state.watch("kvitals.sample", function(value)
sample = value
render()
end)

noctalia.state.watch("kvitals.config", function(value)
config = value
render()
end)

noctalia.state.watch("kvitals.ready", function(value)
ready = value == true
render()
end)

function update()
render()
end
236 changes: 236 additions & 0 deletions kvitals/lib/chips.luau
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
--!nonstrict
-- Shared metric descriptors for the bar widget, panel, and desktop tile.
--
-- A chip is a plain table: { id, glyph, text, tint, charging }.
-- tint is nil (normal), "active" (at or above the activity threshold),
-- or "critical" (at or above the critical threshold). Pure functions over
-- the sample and the resolved config from the plugin state.

local format = require("./format.luau")

local M = {}

local function tintLevel(thresholds, value)
if value == nil or thresholds == nil then return nil end
local act = thresholds.act or 50
local crit = thresholds.crit or 90
if value >= crit then return "critical" end
if value >= act then return "active" end
return nil
end

local builders = {
cpu = function(s, c)
if s.cpu == nil or s.cpu.usage == nil then return nil end
local text = format.pct(s.cpu.usage)
if c.showCpuFreq and s.cpuFreqHz then
text = text .. " " .. format.freqCompact(s.cpuFreqHz)
end
return {
glyph = c.icons.cpu or "cpu",
text = text,
tint = tintLevel(c.thresholds.cpu, s.cpu.usage),
}
end,

ram = function(s, c)
if s.ram == nil or s.ram.usage == nil then return nil end
return {
glyph = c.icons.ram or "database",
text = format.pct(s.ram.usage),
tint = tintLevel(c.thresholds.ram, s.ram.usage),
}
end,

temp = function(s, c)
if s.cpu == nil or s.cpu.temp == nil then return nil end
return {
glyph = c.icons.temp or "temperature",
text = format.temp(s.cpu.temp, c.tempUnit, false),
tint = tintLevel(c.thresholds.temp, s.cpu.temp),
}
end,

gpu = function(s, c)
if s.gpu == nil then return nil end
if s.gpu.usage ~= nil then
return {
glyph = c.icons.gpu or "device-desktop-analytics",
text = format.pct(s.gpu.usage),
tint = tintLevel(c.thresholds.gpu, s.gpu.usage),
}
end
if s.gpu.temp ~= nil then
return {
glyph = c.icons.gpu or "device-desktop-analytics",
text = format.temp(s.gpu.temp, c.tempUnit, false),
tint = tintLevel(c.thresholds.gpu, s.gpu.temp),
}
end
return nil
end,

vram = function(s, c)
if s.gpu == nil or s.gpu.vramUsed == nil or s.gpu.vramTotal == nil then return nil end
if s.gpu.vramTotal <= 0 then return nil end
local pct = s.gpu.vramUsed / s.gpu.vramTotal * 100
return {
glyph = c.icons.vram or "memory",
text = format.ramBytes(s.gpu.vramUsed) .. " / " .. format.ramBytes(s.gpu.vramTotal),
tint = tintLevel(c.thresholds.vram, pct),
}
end,

fan = function(s, _c)
if s.fanRpm == nil then return nil end
return { glyph = c.icons.fan or "wind", text = format.rpm(s.fanRpm), tint = nil }
end,

battery = function(s, c)
if s.battery == nil or s.battery.capacity == nil then return nil end
local text = format.pct(s.battery.capacity)
if c.showPowerDraw and s.battery.powerW then
text = text .. " " .. format.watts(s.battery.powerW)
end
local charging = false
if s.battery.status ~= nil and string.find(s.battery.status, "[Cc]harging") then
charging = true
end
return {
glyph = charging and "battery-charging" or "battery",
text = text,
tint = nil,
charging = charging,
}
end,

net = function(s, c)
if s.net == nil or s.net.rx == nil then return nil end
local text = "↓" .. format.netBytes(s.net.rx, c.netUnit) .. " ↑" .. format.netBytes(s.net.tx, c.netUnit)
-- Thresholds are in MB/s.
return {
glyph = c.icons.net or "network",
text = text,
tint = tintLevel(c.thresholds.net, s.net.rx / 1000000),
}
end,

disk = function(s, c)
if s.diskIo == nil then return nil end
local text = "↓" .. format.netBytes(s.diskIo.rx, c.netUnit) .. " ↑" .. format.netBytes(s.diskIo.tx, c.netUnit)
if s.diskTempC then
text = text .. " " .. format.temp(s.diskTempC, c.tempUnit, false)
end
return {
glyph = c.icons.disk or "device-floppy",
text = text,
tint = tintLevel(c.thresholds.disk, s.diskTempC),
}
end,
}

-- One chip for a metric id, or nil when the metric is disabled or its
-- sensor is absent.
function M.chip(id, sample, config)
if sample == nil or config == nil then return nil end
if not config.enabled[id] then return nil end
local builder = builders[id]
if builder == nil then return nil end
local chip = builder(sample, config)
if chip == nil then return nil end
chip.id = id
return chip
end

-- Ordered, enabled chips for a sample. Absent sensors are skipped.
function M.chips(sample, config)
local out = {}
if sample == nil or config == nil then return out end
for _, id in ipairs(config.metricOrder) do
local chip = M.chip(id, sample, config)
if chip then
table.insert(out, chip)
end
end
return out
end

-- Detailed tooltip rows: { key = ..., value = ... }.
function M.tooltipRows(sample, config)
local rows = {}
if sample == nil or config == nil then return rows end
local function add(key, value)
if value ~= nil and value ~= "" then
table.insert(rows, { key = key, value = value })
end
end

if sample.cpu and sample.cpu.usage ~= nil then
local parts = { format.pct(sample.cpu.usage) }
if sample.cpuFreqHz then table.insert(parts, format.freqHz(sample.cpuFreqHz)) end
if sample.cpu.temp then table.insert(parts, format.temp(sample.cpu.temp, config.tempUnit, true)) end
add("CPU", table.concat(parts, " · "))
end

if sample.ram then
add("RAM", string.format("%s / %s (%s)",
format.ramBytes(sample.ram.used), format.ramBytes(sample.ram.total), format.pct(sample.ram.usage)))
if sample.swap and sample.swap.total and sample.swap.total > 0 then
add("Swap", string.format("%s / %s",
format.ramBytes(sample.swap.used), format.ramBytes(sample.swap.total)))
end
end

if sample.gpu then
local parts = {}
if sample.gpu.usage ~= nil then table.insert(parts, format.pct(sample.gpu.usage)) end
if sample.gpu.temp ~= nil then table.insert(parts, format.temp(sample.gpu.temp, config.tempUnit, true)) end
if #parts > 0 then add("GPU", table.concat(parts, " · ")) end
end

if sample.gpu and sample.gpu.vramUsed ~= nil and sample.gpu.vramTotal ~= nil then
add("VRAM", string.format("%s / %s (%s)",
format.ramBytes(sample.gpu.vramUsed), format.ramBytes(sample.gpu.vramTotal),
format.pct(sample.gpu.vramUsed / sample.gpu.vramTotal * 100)))
end

if sample.fanRpm then
add("Fan", format.rpm(sample.fanRpm) .. " RPM")
end

if sample.battery and sample.battery.capacity ~= nil then
local parts = { format.pct(sample.battery.capacity) }
if sample.battery.status and sample.battery.status ~= "" then
table.insert(parts, sample.battery.status)
end
if sample.battery.powerW then table.insert(parts, format.watts(sample.battery.powerW)) end
add("Battery", table.concat(parts, " · "))
end

if sample.net and sample.net.rx ~= nil then
local parts = { "↓ " .. format.netBytes(sample.net.rx, config.netUnit) .. "/s",
"↑ " .. format.netBytes(sample.net.tx, config.netUnit) .. "/s" }
if config.showNetIp and sample.netIp then table.insert(parts, sample.netIp) end
add("Network", table.concat(parts, " · "))
end

if sample.diskIo then
local parts = { "↓ " .. format.netBytes(sample.diskIo.rx, config.netUnit) .. "/s",
"↑ " .. format.netBytes(sample.diskIo.tx, config.netUnit) .. "/s" }
if sample.diskTempC then table.insert(parts, format.temp(sample.diskTempC, config.tempUnit, true)) end
if sample.diskUsage and sample.diskUsage.usagePercent ~= nil then
table.insert(parts, format.pct(sample.diskUsage.usagePercent) .. " used")
end
add("Disk", table.concat(parts, " · "))
end

if sample.load and #sample.load > 0 then
local parts = {}
for _, v in ipairs(sample.load) do table.insert(parts, format.load(v)) end
add("Load", table.concat(parts, " · "))
end

return rows
end

return M
Loading
Loading