chore updating hyprland to lua

This commit is contained in:
2026-05-12 01:50:20 +02:00
parent 52344c6287
commit 26303f56d8
17 changed files with 2128 additions and 4 deletions
+10
View File
@@ -0,0 +1,10 @@
{
"hooksConfig": {
"disabled": [
"security-gate",
"slm-memory-sync",
"dynamic-context",
"slm-observe"
]
}
}
@@ -7,7 +7,7 @@ hl.config({
numlock_by_default = true,
repeat_rate = 25,
repeat_delay = 600,
follow_mouse = 1,
follow_mouse = 0,
focus_on_close = 2,
mouse_refocus = true,
@@ -0,0 +1,366 @@
-- This is an example Hyprland Lua config file.
-- Refer to the wiki for more information.
-- https://wiki.hypr.land/Configuring/Start/
-- Please note not all available settings / options are set here.
-- For a full list, see the wiki
-- You can (and should!!) split this configuration into multiple files
-- Create your files separately and then require them like this:
-- require("myColors")
------------------
---- MONITORS ----
------------------
-- See https://wiki.hypr.land/Configuring/Basics/Monitors/
hl.monitor({
output = "",
mode = "preferred",
position = "auto",
scale = "auto",
})
---------------------
---- MY PROGRAMS ----
---------------------
-- Set programs that you use
local terminal = "kitty"
local fileManager = "dolphin"
local menu = "hyprlauncher"
-------------------
---- AUTOSTART ----
-------------------
-- See https://wiki.hypr.land/Configuring/Basics/Autostart/
-- Autostart necessary processes (like notifications daemons, status bars, etc.)
-- Or execute your favorite apps at launch like this:
--
-- hl.on("hyprland.start", function ()
-- hl.exec_cmd(terminal)
-- hl.exec_cmd("nm-applet")
-- hl.exec_cmd("waybar & hyprpaper & firefox")
-- end)
-------------------------------
---- ENVIRONMENT VARIABLES ----
-------------------------------
-- See https://wiki.hypr.land/Configuring/Advanced-and-Cool/Environment-variables/
hl.env("XCURSOR_SIZE", "24")
hl.env("HYPRCURSOR_SIZE", "24")
-----------------------
----- PERMISSIONS -----
-----------------------
-- See https://wiki.hypr.land/Configuring/Advanced-and-Cool/Permissions/
-- Please note permission changes here require a Hyprland restart and are not applied on-the-fly
-- for security reasons
-- hl.config({
-- ecosystem = {
-- enforce_permissions = true,
-- },
-- })
-- hl.permission("/usr/(bin|local/bin)/grim", "screencopy", "allow")
-- hl.permission("/usr/(lib|libexec|lib64)/xdg-desktop-portal-hyprland", "screencopy", "allow")
-- hl.permission("/usr/(bin|local/bin)/hyprpm", "plugin", "allow")
-----------------------
---- LOOK AND FEEL ----
-----------------------
-- Refer to https://wiki.hypr.land/Configuring/Basics/Variables/
hl.config({
general = {
gaps_in = 5,
gaps_out = 20,
border_size = 2,
col = {
active_border = { colors = { "rgba(33ccffee)", "rgba(00ff99ee)" }, angle = 45 },
inactive_border = "rgba(595959aa)",
},
-- Set to true to enable resizing windows by clicking and dragging on borders and gaps
resize_on_border = false,
-- Please see https://wiki.hypr.land/Configuring/Advanced-and-Cool/Tearing/ before you turn this on
allow_tearing = false,
layout = "dwindle",
},
decoration = {
rounding = 10,
rounding_power = 2,
-- Change transparency of focused and unfocused windows
active_opacity = 1.0,
inactive_opacity = 1.0,
shadow = {
enabled = true,
range = 4,
render_power = 3,
color = 0xee1a1a1a,
},
blur = {
enabled = true,
size = 3,
passes = 1,
vibrancy = 0.1696,
},
},
animations = {
enabled = true,
},
})
-- Default curves and animations, see https://wiki.hypr.land/Configuring/Advanced-and-Cool/Animations/
hl.curve("easeOutQuint", { type = "bezier", points = { { 0.23, 1 }, { 0.32, 1 } } })
hl.curve("easeInOutCubic", { type = "bezier", points = { { 0.65, 0.05 }, { 0.36, 1 } } })
hl.curve("linear", { type = "bezier", points = { { 0, 0 }, { 1, 1 } } })
hl.curve("almostLinear", { type = "bezier", points = { { 0.5, 0.5 }, { 0.75, 1 } } })
hl.curve("quick", { type = "bezier", points = { { 0.15, 0 }, { 0.1, 1 } } })
-- Default springs
hl.curve("easy", { type = "spring", mass = 1, stiffness = 71.2633, dampening = 15.8273644 })
hl.animation({ leaf = "global", enabled = true, speed = 10, bezier = "default" })
hl.animation({ leaf = "border", enabled = true, speed = 5.39, bezier = "easeOutQuint" })
hl.animation({ leaf = "windows", enabled = true, speed = 4.79, spring = "easy" })
hl.animation({ leaf = "windowsIn", enabled = true, speed = 4.1, spring = "easy", style = "popin 87%" })
hl.animation({ leaf = "windowsOut", enabled = true, speed = 1.49, bezier = "linear", style = "popin 87%" })
hl.animation({ leaf = "fadeIn", enabled = true, speed = 1.73, bezier = "almostLinear" })
hl.animation({ leaf = "fadeOut", enabled = true, speed = 1.46, bezier = "almostLinear" })
hl.animation({ leaf = "fade", enabled = true, speed = 3.03, bezier = "quick" })
hl.animation({ leaf = "layers", enabled = true, speed = 3.81, bezier = "easeOutQuint" })
hl.animation({ leaf = "layersIn", enabled = true, speed = 4, bezier = "easeOutQuint", style = "fade" })
hl.animation({ leaf = "layersOut", enabled = true, speed = 1.5, bezier = "linear", style = "fade" })
hl.animation({ leaf = "fadeLayersIn", enabled = true, speed = 1.79, bezier = "almostLinear" })
hl.animation({ leaf = "fadeLayersOut", enabled = true, speed = 1.39, bezier = "almostLinear" })
hl.animation({ leaf = "workspaces", enabled = true, speed = 1.94, bezier = "almostLinear", style = "fade" })
hl.animation({ leaf = "workspacesIn", enabled = true, speed = 1.21, bezier = "almostLinear", style = "fade" })
hl.animation({ leaf = "workspacesOut", enabled = true, speed = 1.94, bezier = "almostLinear", style = "fade" })
hl.animation({ leaf = "zoomFactor", enabled = true, speed = 7, bezier = "quick" })
-- Ref https://wiki.hypr.land/Configuring/Basics/Workspace-Rules/
-- "Smart gaps" / "No gaps when only"
-- uncomment all if you wish to use that.
-- hl.workspace_rule({ workspace = "w[tv1]", gaps_out = 0, gaps_in = 0 })
-- hl.workspace_rule({ workspace = "f[1]", gaps_out = 0, gaps_in = 0 })
-- hl.window_rule({
-- name = "no-gaps-wtv1",
-- match = { float = false, workspace = "w[tv1]" },
-- border_size = 0,
-- rounding = 0,
-- })
-- hl.window_rule({
-- name = "no-gaps-f1",
-- match = { float = false, workspace = "f[1]" },
-- border_size = 0,
-- rounding = 0,
-- })
-- See https://wiki.hypr.land/Configuring/Layouts/Dwindle-Layout/ for more
hl.config({
dwindle = {
preserve_split = true, -- You probably want this
},
})
-- See https://wiki.hypr.land/Configuring/Layouts/Master-Layout/ for more
hl.config({
master = {
new_status = "master",
},
})
-- See https://wiki.hypr.land/Configuring/Layouts/Scrolling-Layout/ for more
hl.config({
scrolling = {
fullscreen_on_one_column = true,
},
})
----------------
---- MISC ----
----------------
hl.config({
misc = {
force_default_wallpaper = -1, -- Set to 0 or 1 to disable the anime mascot wallpapers
disable_hyprland_logo = false, -- If true disables the random hyprland logo / anime girl background. :(
},
})
---------------
---- INPUT ----
---------------
hl.config({
input = {
kb_layout = "us",
kb_variant = "",
kb_model = "",
kb_options = "",
kb_rules = "",
follow_mouse = 1,
sensitivity = 0, -- -1.0 - 1.0, 0 means no modification.
touchpad = {
natural_scroll = false,
},
},
})
hl.gesture({
fingers = 3,
direction = "horizontal",
action = "workspace",
})
-- Example per-device config
-- See https://wiki.hypr.land/Configuring/Advanced-and-Cool/Devices/ for more
hl.device({
name = "epic-mouse-v1",
sensitivity = -0.5,
})
---------------------
---- KEYBINDINGS ----
---------------------
local mainMod = "SUPER" -- Sets "Windows" key as main modifier
-- Example binds, see https://wiki.hypr.land/Configuring/Basics/Binds/ for more
hl.bind(mainMod .. " + Q", hl.dsp.exec_cmd(terminal))
local closeWindowBind = hl.bind(mainMod .. " + C", hl.dsp.window.close())
-- closeWindowBind:set_enabled(false)
hl.bind(
mainMod .. " + M",
hl.dsp.exec_cmd("command -v hyprshutdown >/dev/null 2>&1 && hyprshutdown || hyprctl dispatch 'hl.dsp.exit()'")
)
hl.bind(mainMod .. " + E", hl.dsp.exec_cmd(fileManager))
hl.bind(mainMod .. " + V", hl.dsp.window.float({ action = "toggle" }))
hl.bind(mainMod .. " + R", hl.dsp.exec_cmd(menu))
hl.bind(mainMod .. " + P", hl.dsp.window.pseudo())
hl.bind(mainMod .. " + J", hl.dsp.layout("togglesplit")) -- dwindle only
-- Move focus with mainMod + arrow keys
hl.bind(mainMod .. " + left", hl.dsp.focus({ direction = "left" }))
hl.bind(mainMod .. " + right", hl.dsp.focus({ direction = "right" }))
hl.bind(mainMod .. " + up", hl.dsp.focus({ direction = "up" }))
hl.bind(mainMod .. " + down", hl.dsp.focus({ direction = "down" }))
-- Switch workspaces with mainMod + [0-9]
-- Move active window to a workspace with mainMod + SHIFT + [0-9]
for i = 1, 10 do
local key = i % 10 -- 10 maps to key 0
hl.bind(mainMod .. " + " .. key, hl.dsp.focus({ workspace = i }))
hl.bind(mainMod .. " + SHIFT + " .. key, hl.dsp.window.move({ workspace = i }))
end
-- Example special workspace (scratchpad)
hl.bind(mainMod .. " + S", hl.dsp.workspace.toggle_special("magic"))
hl.bind(mainMod .. " + SHIFT + S", hl.dsp.window.move({ workspace = "special:magic" }))
-- Scroll through existing workspaces with mainMod + scroll
hl.bind(mainMod .. " + mouse_down", hl.dsp.focus({ workspace = "e+1" }))
hl.bind(mainMod .. " + mouse_up", hl.dsp.focus({ workspace = "e-1" }))
-- Move/resize windows with mainMod + LMB/RMB and dragging
hl.bind(mainMod .. " + mouse:272", hl.dsp.window.drag(), { mouse = true })
hl.bind(mainMod .. " + mouse:273", hl.dsp.window.resize(), { mouse = true })
-- Laptop multimedia keys for volume and LCD brightness
hl.bind(
"XF86AudioRaiseVolume",
hl.dsp.exec_cmd("wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 5%+"),
{ locked = true, repeating = true }
)
hl.bind(
"XF86AudioLowerVolume",
hl.dsp.exec_cmd("wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-"),
{ locked = true, repeating = true }
)
hl.bind(
"XF86AudioMute",
hl.dsp.exec_cmd("wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle"),
{ locked = true, repeating = true }
)
hl.bind(
"XF86AudioMicMute",
hl.dsp.exec_cmd("wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle"),
{ locked = true, repeating = true }
)
hl.bind("XF86MonBrightnessUp", hl.dsp.exec_cmd("brightnessctl -e4 -n2 set 5%+"), { locked = true, repeating = true })
hl.bind("XF86MonBrightnessDown", hl.dsp.exec_cmd("brightnessctl -e4 -n2 set 5%-"), { locked = true, repeating = true })
-- Requires playerctl
hl.bind("XF86AudioNext", hl.dsp.exec_cmd("playerctl next"), { locked = true })
hl.bind("XF86AudioPause", hl.dsp.exec_cmd("playerctl play-pause"), { locked = true })
hl.bind("XF86AudioPlay", hl.dsp.exec_cmd("playerctl play-pause"), { locked = true })
hl.bind("XF86AudioPrev", hl.dsp.exec_cmd("playerctl previous"), { locked = true })
--------------------------------
---- WINDOWS AND WORKSPACES ----
--------------------------------
-- See https://wiki.hypr.land/Configuring/Basics/Window-Rules/
-- and https://wiki.hypr.land/Configuring/Basics/Workspace-Rules/
-- Example window rules that are useful
local suppressMaximizeRule = hl.window_rule({
-- Ignore maximize requests from all apps. You'll probably like this.
name = "suppress-maximize-events",
match = { class = ".*" },
suppress_event = "maximize",
})
-- suppressMaximizeRule:set_enabled(false)
hl.window_rule({
-- Fix some dragging issues with XWayland
name = "fix-xwayland-drags",
match = {
class = "^$",
title = "^$",
xwayland = true,
float = true,
fullscreen = false,
pin = false,
},
no_focus = true,
})
-- Layer rules also return a handle.
-- local overlayLayerRule = hl.layer_rule({
-- name = "no-anim-overlay",
-- match = { namespace = "^my-overlay$" },
-- no_anim = true,
-- })
-- overlayLayerRule:set_enabled(false)
-- Hyprland-run windowrule
hl.window_rule({
name = "move-hyprland-run",
match = { class = "hyprland-run" },
move = "20 monitor_h-120",
float = true,
})
@@ -0,0 +1 @@
{"reason":"idle timeout","timestamp":1776812893183}
@@ -0,0 +1,16 @@
{"type":"server-started","port":57943,"host":"127.0.0.1","url_host":"localhost","url":"http://localhost:57943","screen_dir":"/home/mpuchstein/.local/share/chezmoi/dot_config/quickshell/.superpowers/brainstorm/128159-1776810192"}
{"type":"screen-added","file":"/home/mpuchstein/.local/share/chezmoi/dot_config/quickshell/.superpowers/brainstorm/128159-1776810192/pill-options.html"}
{"source":"user-event","type":"click","text":"󱃾\n 3/3\n \n \n \n B — Icon + node ratio\n Shows ready nodes vs total (e.g. 3/3). Immediately tells you if a node is down.","choice":"b","id":null,"timestamp":1776810288228}
{"source":"user-event","type":"click","text":"󱃾\n 3/3\n \n \n \n B — Icon + node ratio\n Shows ready nodes vs total (e.g. 3/3). Immediately tells you if a node is down.","choice":"b","id":null,"timestamp":1776810289433}
{"source":"user-event","type":"click","text":"󱃾\n 3/3\n \n \n \n B — Icon + node ratio\n Shows ready nodes vs total (e.g. 3/3). Immediately tells you if a node is down.","choice":"b","id":null,"timestamp":1776810289627}
{"type":"screen-added","file":"/home/mpuchstein/.local/share/chezmoi/dot_config/quickshell/.superpowers/brainstorm/128159-1776810192/pill-combined.html"}
{"source":"user-event","type":"click","text":"󱃾\n 3/3\n \n \n \n B — Icon + ratio (ratio IS the status)\n Two rows. Ratio color encodes health: green = all ready, yellow = degraded, red = critical. No redundant \"OK\" label.","choice":"b","id":null,"timestamp":1776810328915}
{"type":"screen-added","file":"/home/mpuchstein/.local/share/chezmoi/dot_config/quickshell/.superpowers/brainstorm/128159-1776810192/popout-layout.html"}
{"type":"screen-added","file":"/home/mpuchstein/.local/share/chezmoi/dot_config/quickshell/.superpowers/brainstorm/128159-1776810192/popout-layout-v2.html"}
{"type":"screen-added","file":"/home/mpuchstein/.local/share/chezmoi/dot_config/quickshell/.superpowers/brainstorm/128159-1776810192/popout-layout-v3.html"}
{"source":"user-event","type":"click","text":"A\n Bars with actual + % labelsVisual bars, more scannable","choice":"a","id":null,"timestamp":1776810810800}
{"type":"screen-added","file":"/home/mpuchstein/.local/share/chezmoi/dot_config/quickshell/.superpowers/brainstorm/128159-1776810192/waiting.html"}
{"type":"screen-added","file":"/home/mpuchstein/.local/share/chezmoi/dot_config/quickshell/.superpowers/brainstorm/128159-1776810192/popout-layout-v4.html"}
{"source":"user-event","type":"click","text":"✓\n \n This works\n Per-pod table + quota bars with real numbers","choice":"looks-good","id":null,"timestamp":1776811068409}
{"type":"screen-added","file":"/home/mpuchstein/.local/share/chezmoi/dot_config/quickshell/.superpowers/brainstorm/128159-1776810192/waiting-2.html"}
{"type":"server-stopped","reason":"idle timeout"}
@@ -0,0 +1,33 @@
<h2>Combined: icon + node ratio + status color</h2>
<p class="subtitle">Two variations on how to stack all three elements in the 52px pill.</p>
<div class="cards">
<div class="card" data-choice="a" onclick="toggleSelect(this)">
<div class="card-image" style="display:flex;align-items:center;justify-content:center;padding:28px 0;background:#1e1e2e;border-radius:8px">
<div style="display:flex;flex-direction:column;align-items:center;gap:5px;background:#313244;border-radius:14px;padding:10px 14px;width:42px">
<span style="font-size:18px;color:#a6e3a1">󱃾</span>
<span style="font-size:10px;color:#cdd6f4;font-family:monospace;font-weight:bold">3/3</span>
<span style="font-size:8px;color:#a6e3a1;font-family:monospace;letter-spacing:0.5px">OK</span>
</div>
</div>
<div class="card-body">
<h3>A — Icon / ratio / status stacked</h3>
<p>Three rows. Icon accent color matches status. Ratio stays neutral text color.</p>
</div>
</div>
<div class="card" data-choice="b" onclick="toggleSelect(this)">
<div class="card-image" style="display:flex;align-items:center;justify-content:center;padding:28px 0;background:#1e1e2e;border-radius:8px">
<div style="display:flex;flex-direction:column;align-items:center;gap:5px;background:#313244;border-radius:14px;padding:10px 14px;width:42px">
<span style="font-size:18px;color:#a6e3a1">󱃾</span>
<span style="font-size:9px;color:#a6e3a1;font-family:monospace;font-weight:bold">3/3</span>
</div>
</div>
<div class="card-body">
<h3>B — Icon + ratio (ratio IS the status)</h3>
<p>Two rows. Ratio color encodes health: green = all ready, yellow = degraded, red = critical. No redundant "OK" label.</p>
</div>
</div>
</div>
@@ -0,0 +1,44 @@
<h2>What should the bar pill show?</h2>
<p class="subtitle">The pill sits in the left sidebar (52px wide). Pick the compact display style you prefer.</p>
<div class="cards">
<div class="card" data-choice="a" onclick="toggleSelect(this)">
<div class="card-image" style="display:flex;align-items:center;justify-content:center;padding:24px 0;background:#1e1e2e;border-radius:8px">
<div style="display:flex;flex-direction:column;align-items:center;gap:6px;background:#313244;border-radius:14px;padding:10px 14px;width:42px">
<span style="font-size:18px;color:#89b4fa">󱃾</span>
</div>
</div>
<div class="card-body">
<h3>A — Icon only</h3>
<p>Pill color shifts: green = healthy, yellow = degraded, red = critical. Minimal, like the system pill.</p>
</div>
</div>
<div class="card" data-choice="b" onclick="toggleSelect(this)">
<div class="card-image" style="display:flex;align-items:center;justify-content:center;padding:24px 0;background:#1e1e2e;border-radius:8px">
<div style="display:flex;flex-direction:column;align-items:center;gap:6px;background:#313244;border-radius:14px;padding:10px 14px;width:42px">
<span style="font-size:18px;color:#89b4fa">󱃾</span>
<span style="font-size:10px;color:#cdd6f4;font-family:monospace;font-weight:bold">3/3</span>
</div>
</div>
<div class="card-body">
<h3>B — Icon + node ratio</h3>
<p>Shows ready nodes vs total (e.g. 3/3). Immediately tells you if a node is down.</p>
</div>
</div>
<div class="card" data-choice="c" onclick="toggleSelect(this)">
<div class="card-image" style="display:flex;align-items:center;justify-content:center;padding:24px 0;background:#1e1e2e;border-radius:8px">
<div style="display:flex;flex-direction:column;align-items:center;gap:6px;background:#313244;border-radius:14px;padding:10px 14px;width:42px">
<span style="font-size:18px;color:#a6e3a1">󱃾</span>
<span style="font-size:9px;color:#a6e3a1;font-family:monospace;font-weight:bold">OK</span>
</div>
</div>
<div class="card-body">
<h3>C — Icon + status text</h3>
<p>Shows OK / WARN / ERR in the pill accent color. Clear at a glance, no counting required.</p>
</div>
</div>
</div>
@@ -0,0 +1,94 @@
<h2>Updated layout — namespace quota instead of cluster metrics</h2>
<p class="subtitle">Nodes show status only (no metrics-server needed). Resources show quota consumption from kubectl.</p>
<div class="mockup">
<div class="mockup-header">K8s — tenant-5</div>
<div class="mockup-body" style="background:#1e1e2e;padding:16px;display:flex;flex-direction:column;gap:14px;font-family:monospace">
<!-- Nodes section -->
<div>
<div style="font-size:10px;color:#6c7086;letter-spacing:1px;text-transform:uppercase;margin-bottom:8px">Nodes</div>
<div style="display:flex;flex-direction:column;gap:6px">
<div style="display:flex;align-items:center;justify-content:space-between;background:#313244;border-radius:8px;padding:7px 10px">
<span style="color:#cdd6f4;font-size:12px">node-1</span>
<span style="font-size:11px;color:#a6e3a1">● Ready</span>
</div>
<div style="display:flex;align-items:center;justify-content:space-between;background:#313244;border-radius:8px;padding:7px 10px">
<span style="color:#cdd6f4;font-size:12px">node-2</span>
<span style="font-size:11px;color:#a6e3a1">● Ready</span>
</div>
<div style="display:flex;align-items:center;justify-content:space-between;background:#313244;border-radius:8px;padding:7px 10px">
<span style="color:#cdd6f4;font-size:12px">node-3</span>
<span style="font-size:11px;color:#f38ba8">● NotReady</span>
</div>
</div>
</div>
<!-- Pods summary -->
<div>
<div style="font-size:10px;color:#6c7086;letter-spacing:1px;text-transform:uppercase;margin-bottom:8px">Pods — tenant-5</div>
<div style="display:flex;gap:8px">
<div style="flex:1;background:#313244;border-radius:8px;padding:10px;text-align:center">
<div style="font-size:22px;font-weight:bold;color:#a6e3a1">12</div>
<div style="font-size:9px;color:#6c7086;margin-top:2px">Running</div>
</div>
<div style="flex:1;background:#313244;border-radius:8px;padding:10px;text-align:center">
<div style="font-size:22px;font-weight:bold;color:#f9e2af">1</div>
<div style="font-size:9px;color:#6c7086;margin-top:2px">Pending</div>
</div>
<div style="flex:1;background:#313244;border-radius:8px;padding:10px;text-align:center">
<div style="font-size:22px;font-weight:bold;color:#f38ba8">0</div>
<div style="font-size:9px;color:#6c7086;margin-top:2px">Failed</div>
</div>
</div>
</div>
<!-- Namespace quota -->
<div>
<div style="font-size:10px;color:#6c7086;letter-spacing:1px;text-transform:uppercase;margin-bottom:8px">Namespace Quota — tenant-5</div>
<div style="display:flex;flex-direction:column;gap:8px">
<div>
<div style="display:flex;justify-content:space-between;margin-bottom:4px">
<span style="font-size:10px;color:#89dceb">CPU requests</span>
<span style="font-size:10px;color:#cdd6f4">1.2 / 4 cores</span>
</div>
<div style="height:6px;background:#313244;border-radius:3px">
<div style="height:100%;width:30%;background:#89dceb;border-radius:3px"></div>
</div>
</div>
<div>
<div style="display:flex;justify-content:space-between;margin-bottom:4px">
<span style="font-size:10px;color:#cba6f7">Memory requests</span>
<span style="font-size:10px;color:#cdd6f4">3.4 / 8 GiB</span>
</div>
<div style="height:6px;background:#313244;border-radius:3px">
<div style="height:100%;width:42%;background:#cba6f7;border-radius:3px"></div>
</div>
</div>
<div>
<div style="display:flex;justify-content:space-between;margin-bottom:4px">
<span style="font-size:10px;color:#f9e2af">Pods</span>
<span style="font-size:10px;color:#cdd6f4">13 / 20</span>
</div>
<div style="height:6px;background:#313244;border-radius:3px">
<div style="height:100%;width:65%;background:#f9e2af;border-radius:3px"></div>
</div>
</div>
</div>
</div>
<!-- Last updated -->
<div style="text-align:right;font-size:9px;color:#45475a">Updated 8s ago</div>
</div>
</div>
<div class="options" style="margin-top:16px">
<div class="option" data-choice="looks-good" onclick="toggleSelect(this)">
<div class="letter"></div>
<div class="content">
<h3>This works</h3>
<p>Node status, pod counts, namespace quota bars</p>
</div>
</div>
</div>
@@ -0,0 +1,113 @@
<h2>Namespace resources — k9s-style columns</h2>
<p class="subtitle">Aggregated across all pods in tenant-5. Two options for how to display the data.</p>
<div class="split">
<div class="mockup">
<div class="mockup-header">A — Bar with actual + % labels</div>
<div class="mockup-body" style="background:#1e1e2e;padding:14px;display:flex;flex-direction:column;gap:10px;font-family:monospace">
<div style="font-size:10px;color:#6c7086;letter-spacing:1px;text-transform:uppercase;margin-bottom:2px">Namespace Resources</div>
<!-- CPU -->
<div style="background:#313244;border-radius:8px;padding:10px 12px;display:flex;flex-direction:column;gap:6px">
<div style="display:flex;justify-content:space-between;align-items:baseline">
<span style="font-size:10px;color:#89dceb;font-weight:bold">CPU</span>
<span style="font-size:11px;color:#cdd6f4">420m</span>
</div>
<div>
<div style="display:flex;justify-content:space-between;margin-bottom:3px">
<span style="font-size:9px;color:#6c7086">%REQ</span>
<span style="font-size:9px;color:#a6e3a1">35%</span>
</div>
<div style="height:4px;background:#1e1e2e;border-radius:2px">
<div style="height:100%;width:35%;background:#a6e3a1;border-radius:2px"></div>
</div>
</div>
<div>
<div style="display:flex;justify-content:space-between;margin-bottom:3px">
<span style="font-size:9px;color:#6c7086">%LIM</span>
<span style="font-size:9px;color:#f9e2af">21%</span>
</div>
<div style="height:4px;background:#1e1e2e;border-radius:2px">
<div style="height:100%;width:21%;background:#f9e2af;border-radius:2px"></div>
</div>
</div>
</div>
<!-- MEM -->
<div style="background:#313244;border-radius:8px;padding:10px 12px;display:flex;flex-direction:column;gap:6px">
<div style="display:flex;justify-content:space-between;align-items:baseline">
<span style="font-size:10px;color:#cba6f7;font-weight:bold">MEM</span>
<span style="font-size:11px;color:#cdd6f4">2.8 GiB</span>
</div>
<div>
<div style="display:flex;justify-content:space-between;margin-bottom:3px">
<span style="font-size:9px;color:#6c7086">%REQ</span>
<span style="font-size:9px;color:#a6e3a1">58%</span>
</div>
<div style="height:4px;background:#1e1e2e;border-radius:2px">
<div style="height:100%;width:58%;background:#cba6f7;border-radius:2px"></div>
</div>
</div>
<div>
<div style="display:flex;justify-content:space-between;margin-bottom:3px">
<span style="font-size:9px;color:#6c7086">%LIM</span>
<span style="font-size:9px;color:#f9e2af">34%</span>
</div>
<div style="height:4px;background:#1e1e2e;border-radius:2px">
<div style="height:100%;width:34%;background:#cba6f7;border-radius:2px;opacity:0.6"></div>
</div>
</div>
</div>
</div>
</div>
<div class="mockup">
<div class="mockup-header">B — Compact table row</div>
<div class="mockup-body" style="background:#1e1e2e;padding:14px;display:flex;flex-direction:column;gap:10px;font-family:monospace">
<div style="font-size:10px;color:#6c7086;letter-spacing:1px;text-transform:uppercase;margin-bottom:2px">Namespace Resources</div>
<!-- header row -->
<div style="display:grid;grid-template-columns:2fr 1.5fr 1fr 1fr;gap:4px;padding:0 4px">
<span style="font-size:8px;color:#45475a"></span>
<span style="font-size:8px;color:#45475a;text-align:right">ACTUAL</span>
<span style="font-size:8px;color:#45475a;text-align:right">%R</span>
<span style="font-size:8px;color:#45475a;text-align:right">%L</span>
</div>
<!-- CPU row -->
<div style="display:grid;grid-template-columns:2fr 1.5fr 1fr 1fr;gap:4px;background:#313244;border-radius:8px;padding:8px 10px;align-items:center">
<span style="font-size:11px;color:#89dceb;font-weight:bold">CPU</span>
<span style="font-size:11px;color:#cdd6f4;text-align:right">420m</span>
<span style="font-size:11px;color:#a6e3a1;text-align:right">35%</span>
<span style="font-size:11px;color:#f9e2af;text-align:right">21%</span>
</div>
<!-- MEM row -->
<div style="display:grid;grid-template-columns:2fr 1.5fr 1fr 1fr;gap:4px;background:#313244;border-radius:8px;padding:8px 10px;align-items:center">
<span style="font-size:11px;color:#cba6f7;font-weight:bold">MEM</span>
<span style="font-size:11px;color:#cdd6f4;text-align:right">2.8G</span>
<span style="font-size:11px;color:#a6e3a1;text-align:right">58%</span>
<span style="font-size:11px;color:#f9e2af;text-align:right">34%</span>
</div>
<div style="font-size:9px;color:#45475a;margin-top:2px">%R = % of requests quota &nbsp;·&nbsp; %L = % of limits quota</div>
</div>
</div>
</div>
<div class="options" style="margin-top:16px">
<div class="option" data-choice="a" onclick="toggleSelect(this)">
<div class="letter">A</div>
<div class="content"><h3>Bars with actual + % labels</h3><p>Visual bars, more scannable</p></div>
</div>
<div class="option" data-choice="b" onclick="toggleSelect(this)">
<div class="letter">B</div>
<div class="content"><h3>Compact table row</h3><p>Denser, mirrors k9s columns exactly</p></div>
</div>
</div>
@@ -0,0 +1,117 @@
<h2>Updated layout — pods replace nodes, per-pod metrics</h2>
<p class="subtitle">Based on your real kubectl data. dnddrugs absent from kubectl top → shown as —</p>
<div class="mockup">
<div class="mockup-header">K8s — tenant-5</div>
<div class="mockup-body" style="background:#1e1e2e;padding:16px;display:flex;flex-direction:column;gap:14px;font-family:monospace">
<!-- Pods section -->
<div>
<div style="font-size:10px;color:#6c7086;letter-spacing:1px;text-transform:uppercase;margin-bottom:8px">Pods</div>
<!-- header -->
<div style="display:grid;grid-template-columns:1fr 1.2fr 1.2fr 1fr;gap:4px;padding:0 10px 4px;font-size:8px;color:#45475a">
<span></span><span style="text-align:right">CPU</span><span style="text-align:right">MEM</span><span style="text-align:right">STATUS</span>
</div>
<div style="display:flex;flex-direction:column;gap:5px">
<div style="display:grid;grid-template-columns:1fr 1.2fr 1.2fr 1fr;gap:4px;background:#313244;border-radius:8px;padding:7px 10px;align-items:center">
<span style="font-size:11px;color:#cdd6f4">joplin</span>
<span style="font-size:11px;color:#89dceb;text-align:right">13m</span>
<span style="font-size:11px;color:#cba6f7;text-align:right">343Mi</span>
<span style="font-size:10px;color:#a6e3a1;text-align:right">● Ready</span>
</div>
<div style="display:grid;grid-template-columns:1fr 1.2fr 1.2fr 1fr;gap:4px;background:#313244;border-radius:8px;padding:7px 10px;align-items:center">
<span style="font-size:11px;color:#cdd6f4">n8n</span>
<span style="font-size:11px;color:#89dceb;text-align:right">6m</span>
<span style="font-size:11px;color:#cba6f7;text-align:right">427Mi</span>
<span style="font-size:10px;color:#a6e3a1;text-align:right">● Ready</span>
</div>
<div style="display:grid;grid-template-columns:1fr 1.2fr 1.2fr 1fr;gap:4px;background:#313244;border-radius:8px;padding:7px 10px;align-items:center">
<span style="font-size:11px;color:#cdd6f4">shared-pg</span>
<span style="font-size:11px;color:#89dceb;text-align:right">10m</span>
<span style="font-size:11px;color:#cba6f7;text-align:right">151Mi</span>
<span style="font-size:10px;color:#a6e3a1;text-align:right">● Ready</span>
</div>
<div style="display:grid;grid-template-columns:1fr 1.2fr 1.2fr 1fr;gap:4px;background:#313244;border-radius:8px;padding:7px 10px;align-items:center">
<span style="font-size:11px;color:#cdd6f4">dnddrugs</span>
<span style="font-size:11px;color:#45475a;text-align:right"></span>
<span style="font-size:11px;color:#45475a;text-align:right"></span>
<span style="font-size:10px;color:#a6e3a1;text-align:right">● Ready</span>
</div>
</div>
</div>
<!-- Quota section -->
<div>
<div style="font-size:10px;color:#6c7086;letter-spacing:1px;text-transform:uppercase;margin-bottom:8px">Quota — tenant-5</div>
<div style="display:flex;flex-direction:column;gap:10px">
<!-- CPU -->
<div style="background:#313244;border-radius:8px;padding:10px 12px;display:flex;flex-direction:column;gap:6px">
<div style="display:flex;justify-content:space-between">
<span style="font-size:10px;color:#89dceb;font-weight:bold">CPU</span>
<span style="font-size:10px;color:#cdd6f4">29m actual</span>
</div>
<div>
<div style="display:flex;justify-content:space-between;margin-bottom:3px">
<span style="font-size:9px;color:#6c7086">%REQ &nbsp;78m / 2000m</span>
<span style="font-size:9px;color:#a6e3a1">4%</span>
</div>
<div style="height:4px;background:#1e1e2e;border-radius:2px">
<div style="height:100%;width:4%;background:#a6e3a1;border-radius:2px"></div>
</div>
</div>
<div>
<div style="display:flex;justify-content:space-between;margin-bottom:3px">
<span style="font-size:9px;color:#6c7086">%LIM &nbsp;1450m / 2000m</span>
<span style="font-size:9px;color:#f9e2af">73%</span>
</div>
<div style="height:4px;background:#1e1e2e;border-radius:2px">
<div style="height:100%;width:73%;background:#f9e2af;border-radius:2px"></div>
</div>
</div>
</div>
<!-- MEM -->
<div style="background:#313244;border-radius:8px;padding:10px 12px;display:flex;flex-direction:column;gap:6px">
<div style="display:flex;justify-content:space-between">
<span style="font-size:10px;color:#cba6f7;font-weight:bold">MEM</span>
<span style="font-size:10px;color:#cdd6f4">921Mi actual</span>
</div>
<div>
<div style="display:flex;justify-content:space-between;margin-bottom:3px">
<span style="font-size:9px;color:#6c7086">%REQ &nbsp;1.01Gi / 4Gi</span>
<span style="font-size:9px;color:#a6e3a1">25%</span>
</div>
<div style="height:4px;background:#1e1e2e;border-radius:2px">
<div style="height:100%;width:25%;background:#cba6f7;border-radius:2px"></div>
</div>
</div>
<div>
<div style="display:flex;justify-content:space-between;margin-bottom:3px">
<span style="font-size:9px;color:#6c7086">%LIM &nbsp;2.14Gi / 4Gi</span>
<span style="font-size:9px;color:#f9e2af">54%</span>
</div>
<div style="height:4px;background:#1e1e2e;border-radius:2px">
<div style="height:100%;width:54%;background:#cba6f7;border-radius:2px;opacity:0.6"></div>
</div>
</div>
</div>
</div>
</div>
<div style="text-align:right;font-size:9px;color:#45475a">Updated 8s ago</div>
</div>
</div>
<p class="subtitle" style="margin-top:16px">Pill now shows ready/total pods, e.g. <strong style="color:#a6e3a1">4/4</strong> green → <strong style="color:#f9e2af">3/4</strong> yellow if one goes unready.</p>
<div class="options" style="margin-top:8px">
<div class="option" data-choice="looks-good" onclick="toggleSelect(this)">
<div class="letter"></div>
<div class="content">
<h3>This works</h3>
<p>Per-pod table + quota bars with real numbers</p>
</div>
</div>
</div>
@@ -0,0 +1,97 @@
<h2>Popout layout — does this cover what you need?</h2>
<p class="subtitle">320px wide panel. Click to select if this looks right, or describe what's missing below.</p>
<div class="mockup">
<div class="mockup-header">K8s — tenant-5</div>
<div class="mockup-body" style="background:#1e1e2e;padding:16px;display:flex;flex-direction:column;gap:14px;font-family:monospace">
<!-- Nodes section -->
<div>
<div style="font-size:10px;color:#6c7086;letter-spacing:1px;text-transform:uppercase;margin-bottom:8px">Nodes</div>
<div style="display:flex;flex-direction:column;gap:6px">
<div style="display:flex;align-items:center;justify-content:space-between;background:#313244;border-radius:8px;padding:7px 10px">
<span style="color:#cdd6f4;font-size:12px">node-1</span>
<div style="display:flex;gap:10px;align-items:center">
<span style="font-size:10px;color:#89dceb">CPU <b style="color:#cdd6f4">38%</b></span>
<span style="font-size:10px;color:#cba6f7">MEM <b style="color:#cdd6f4">61%</b></span>
<span style="font-size:11px;color:#a6e3a1">● Ready</span>
</div>
</div>
<div style="display:flex;align-items:center;justify-content:space-between;background:#313244;border-radius:8px;padding:7px 10px">
<span style="color:#cdd6f4;font-size:12px">node-2</span>
<div style="display:flex;gap:10px;align-items:center">
<span style="font-size:10px;color:#89dceb">CPU <b style="color:#cdd6f4">12%</b></span>
<span style="font-size:10px;color:#cba6f7">MEM <b style="color:#cdd6f4">44%</b></span>
<span style="font-size:11px;color:#a6e3a1">● Ready</span>
</div>
</div>
<div style="display:flex;align-items:center;justify-content:space-between;background:#313244;border-radius:8px;padding:7px 10px">
<span style="color:#cdd6f4;font-size:12px">node-3</span>
<div style="display:flex;gap:10px;align-items:center">
<span style="font-size:10px;color:#89dceb">CPU <b style="color:#cdd6f4"></b></span>
<span style="font-size:10px;color:#cba6f7">MEM <b style="color:#cdd6f4"></b></span>
<span style="font-size:11px;color:#f38ba8">● NotReady</span>
</div>
</div>
</div>
</div>
<!-- Pods summary -->
<div>
<div style="font-size:10px;color:#6c7086;letter-spacing:1px;text-transform:uppercase;margin-bottom:8px">Pods — tenant-5</div>
<div style="display:flex;gap:8px">
<div style="flex:1;background:#313244;border-radius:8px;padding:10px;text-align:center">
<div style="font-size:22px;font-weight:bold;color:#a6e3a1">12</div>
<div style="font-size:9px;color:#6c7086;margin-top:2px">Running</div>
</div>
<div style="flex:1;background:#313244;border-radius:8px;padding:10px;text-align:center">
<div style="font-size:22px;font-weight:bold;color:#f9e2af">1</div>
<div style="font-size:9px;color:#6c7086;margin-top:2px">Pending</div>
</div>
<div style="flex:1;background:#313244;border-radius:8px;padding:10px;text-align:center">
<div style="font-size:22px;font-weight:bold;color:#f38ba8">0</div>
<div style="font-size:9px;color:#6c7086;margin-top:2px">Failed</div>
</div>
</div>
</div>
<!-- Cluster resources -->
<div>
<div style="font-size:10px;color:#6c7086;letter-spacing:1px;text-transform:uppercase;margin-bottom:8px">Cluster Resources</div>
<div style="display:flex;flex-direction:column;gap:8px">
<div>
<div style="display:flex;justify-content:space-between;margin-bottom:4px">
<span style="font-size:10px;color:#89dceb">CPU</span>
<span style="font-size:10px;color:#cdd6f4">1.8 / 8 cores</span>
</div>
<div style="height:6px;background:#313244;border-radius:3px">
<div style="height:100%;width:22%;background:#89dceb;border-radius:3px"></div>
</div>
</div>
<div>
<div style="display:flex;justify-content:space-between;margin-bottom:4px">
<span style="font-size:10px;color:#cba6f7">Memory</span>
<span style="font-size:10px;color:#cdd6f4">5.2 / 16 GiB</span>
</div>
<div style="height:6px;background:#313244;border-radius:3px">
<div style="height:100%;width:52%;background:#cba6f7;border-radius:3px"></div>
</div>
</div>
</div>
</div>
<!-- Last updated -->
<div style="text-align:right;font-size:9px;color:#45475a">Updated 14s ago</div>
</div>
</div>
<div class="options" style="margin-top:16px">
<div class="option" data-choice="looks-good" onclick="toggleSelect(this)">
<div class="letter"></div>
<div class="content">
<h3>This covers it</h3>
<p>Nodes with per-node CPU/mem/status, pod counts, cluster resource bars</p>
</div>
</div>
</div>
@@ -0,0 +1,3 @@
<div style="display:flex;align-items:center;justify-content:center;min-height:60vh">
<p class="subtitle">Continuing in terminal...</p>
</div>
@@ -0,0 +1,3 @@
<div style="display:flex;align-items:center;justify-content:center;min-height:60vh">
<p class="subtitle">Continuing in terminal...</p>
</div>
+3 -3
View File
@@ -66,9 +66,9 @@ Item {
acceptedButtons: Qt.NoButton
onWheel: event => {
if (event.angleDelta.y > 0)
Hyprland.dispatch("workspace m-1");
Hyprland.dispatch("hl.dsp.focus({ workspace = 'm-1' })");
else
Hyprland.dispatch("workspace m+1");
Hyprland.dispatch("hl.dsp.focus({ workspace = 'm+1' })");
}
}
@@ -155,7 +155,7 @@ Item {
MouseArea {
anchors.fill: parent
onClicked: Hyprland.dispatch("workspace name:" + wsItem.wsName)
onClicked: Hyprland.dispatch("hl.dsp.focus({ workspace = 'name:" + wsItem.wsName + "' })")
}
}
}
@@ -0,0 +1,947 @@
# K8s Monitoring Widget Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a Quickshell bar pill + popout that shows pod health and namespace resource quota for the `tenant-5` Kubernetes cluster.
**Architecture:** A `Kubernetes.qml` Singleton runs two shell scripts on independent timers — `k8s-status.sh` (pod readiness, 30s) and `k8s-metrics.sh` (kubectl top + resourcequota, 15s). A `KubernetesPill` in the bar shows ready/total pods colored by health; clicking opens `KubernetesPopout` with a per-pod table and quota bars.
**Tech Stack:** Quickshell QML (Singleton, Process, StdioCollector, BarPill), bash + jq, kubectl
> **Working directory for all git commands:** `~/.local/share/chezmoi/dot_config/quickshell/`
> `Kubernetes.qml` needs no `qmldir` registration — Quickshell auto-discovers Singletons in the same directory (same mechanism as `Weather.qml`, `Config.qml`, etc.).
---
## File Map
| Action | Path | Responsibility |
|---|---|---|
| Create | `scripts/executable_k8s-status.sh` | Pod list + ready status → JSON |
| Create | `scripts/executable_k8s-metrics.sh` | kubectl top + resourcequota → JSON |
| Create | `shared/Kubernetes.qml` | Singleton data layer |
| Create | `bar/KubernetesPill.qml` | Bar pill UI |
| Create | `bar/popouts/KubernetesPopout.qml` | Popout UI |
| Modify | `shared/Config.qml.tmpl` | Add kube config properties |
| Modify | `Bar.qml` | Wire pill + popout |
---
## Task 1: Config properties
**Files:**
- Modify: `shared/Config.qml.tmpl`
- [ ] **Step 1: Add kube properties to Config**
Open `shared/Config.qml.tmpl`. After the `gpuScript` line (line 36), add:
```qml
// Kubernetes
readonly property string kubeNamespace: "tenant-5"
readonly property int kubeStatusRefreshMs: 30000
readonly property int kubeMetricsRefreshMs: 15000
```
- [ ] **Step 2: Apply and verify Config is valid**
```bash
chezmoi apply ~/.config/quickshell/shared/Config.qml
# No output = success. If quickshell is running, it hot-reloads — check for errors in:
quickshell --version # just confirm tool works
```
- [ ] **Step 3: Commit**
```bash
git add shared/Config.qml.tmpl
git commit -m "k8s-widget: add kube config properties"
```
---
## Task 2: k8s-status.sh
**Files:**
- Create: `scripts/executable_k8s-status.sh`
- [ ] **Step 1: Write the script**
Create `scripts/executable_k8s-status.sh`:
```bash
#!/usr/bin/env bash
# Outputs pod ready status for a namespace as JSON.
# Usage: k8s-status.sh <namespace>
set -euo pipefail
NS="${1:-tenant-5}"
pods_json=$(kubectl get pods -n "$NS" -o json 2>/dev/null) || exit 0
echo "$pods_json" | jq -c '
[.items[] | {
app: (.metadata.labels["app.kubernetes.io/instance"] // .metadata.name),
ready: ((.status.conditions // [])
| map(select(.type == "Ready"))
| if length > 0 then .[0].status == "True" else false end)
}] as $pods |
{
pods: $pods,
readyCount: ($pods | map(select(.ready)) | length),
totalCount: ($pods | length)
}
'
```
- [ ] **Step 2: Test standalone**
```bash
chezmoi apply ~/.config/quickshell/scripts/k8s-status.sh
~/.config/quickshell/scripts/k8s-status.sh tenant-5 | jq .
```
Expected output:
```json
{
"pods": [
{"app": "dnddrugs", "ready": true},
{"app": "joplin", "ready": true},
{"app": "n8n", "ready": true},
{"app": "shared-postgres", "ready": true}
],
"readyCount": 4,
"totalCount": 4
}
```
- [ ] **Step 3: Commit**
```bash
git add scripts/executable_k8s-status.sh
git commit -m "k8s-widget: add k8s-status.sh"
```
---
## Task 3: k8s-metrics.sh
**Files:**
- Create: `scripts/executable_k8s-metrics.sh`
- [ ] **Step 1: Write the script**
Create `scripts/executable_k8s-metrics.sh`:
```bash
#!/usr/bin/env bash
# Outputs per-pod CPU/MEM metrics and namespace quota as JSON.
# Usage: k8s-metrics.sh <namespace>
set -euo pipefail
NS="${1:-tenant-5}"
# ── Unit normalization ───────────────────────────────────────────────────────
# Normalize CPU string to millicores integer
normalize_cpu() {
local val="$1"
if [[ "$val" =~ ^([0-9]+)m$ ]]; then
echo "${BASH_REMATCH[1]}"
elif [[ "$val" =~ ^([0-9]+(\.[0-9]+)?)$ ]]; then
awk "BEGIN { printf \"%d\", ${BASH_REMATCH[1]} * 1000 }"
else
echo 0
fi
}
# Normalize memory string to MiB integer
normalize_mem() {
local val="$1"
if [[ "$val" =~ ^([0-9]+)Gi$ ]]; then
echo $(( ${BASH_REMATCH[1]} * 1024 ))
elif [[ "$val" =~ ^([0-9]+)Mi$ ]]; then
echo "${BASH_REMATCH[1]}"
elif [[ "$val" =~ ^([0-9]+)Ki$ ]]; then
echo $(( ${BASH_REMATCH[1]} / 1024 ))
elif [[ "$val" =~ ^([0-9]+)$ ]]; then
echo $(( ${BASH_REMATCH[1]} / 1048576 ))
else
echo 0
fi
}
# Format millicores for display ("420m" or "1.5c")
fmt_cpu() {
local m="$1"
if [[ $m -lt 1000 ]]; then
echo "${m}m"
else
awk "BEGIN { printf \"%.1fc\", $m / 1000 }"
fi
}
# Format MiB for display ("343Mi" or "1.50Gi")
fmt_mem() {
local mib="$1"
if [[ $mib -lt 1024 ]]; then
echo "${mib}Mi"
else
awk "BEGIN { printf \"%.2fGi\", $mib / 1024 }"
fi
}
# ── Fetch data ───────────────────────────────────────────────────────────────
pods_json=$(kubectl get pods -n "$NS" -o json 2>/dev/null) || exit 0
top_output=$(kubectl top pods -n "$NS" --no-headers 2>/dev/null) || top_output=""
quota_json=$(kubectl get resourcequota -n "$NS" -o json 2>/dev/null) || exit 0
# ── Build podName → app map ──────────────────────────────────────────────────
declare -A name_to_app
while IFS= read -r line; do
pod_name=$(echo "$line" | jq -r '.name')
app=$(echo "$line" | jq -r '.app')
name_to_app["$pod_name"]="$app"
done < <(echo "$pods_json" | jq -c '.items[] | {name: .metadata.name, app: (.metadata.labels["app.kubernetes.io/instance"] // .metadata.name)}')
# ── Parse kubectl top ────────────────────────────────────────────────────────
declare -A top_cpu # app → cpuM
declare -A top_mem # app → memMi
cpu_actual=0
mem_actual=0
while IFS= read -r line; do
[[ -z "$line" ]] && continue
read -r pod_name cpu_str mem_str <<< "$line"
app="${name_to_app[$pod_name]:-}"
[[ -z "$app" ]] && continue
cpu_m=$(normalize_cpu "$cpu_str")
mem_mib=$(normalize_mem "$mem_str")
top_cpu["$app"]=$cpu_m
top_mem["$app"]=$mem_mib
cpu_actual=$(( cpu_actual + cpu_m ))
mem_actual=$(( mem_actual + mem_mib ))
done <<< "$top_output"
# ── Build podMetrics JSON array ──────────────────────────────────────────────
pod_metrics="["
first=1
while IFS= read -r line; do
app=$(echo "$line" | jq -r '.app')
cpu_m="${top_cpu[$app]:-"-1"}"
mem_mib="${top_mem[$app]:-"-1"}"
[[ $first -eq 0 ]] && pod_metrics+=","
pod_metrics+=$(jq -nc --arg app "$app" --argjson cpu "$cpu_m" --argjson mem "$mem_mib" \
'{app: $app, cpuM: $cpu, memMi: $mem}')
first=0
done < <(echo "$pods_json" | jq -c '.items[] | {app: (.metadata.labels["app.kubernetes.io/instance"] // .metadata.name)}')
pod_metrics+="]"
# ── Parse resourcequota ──────────────────────────────────────────────────────
q_cpu_req_used=$(echo "$quota_json" | jq -r '.items[0].status.used["requests.cpu"] // "0"')
q_cpu_req_hard=$(echo "$quota_json" | jq -r '.items[0].status.hard["requests.cpu"] // "0"')
q_cpu_lim_used=$(echo "$quota_json" | jq -r '.items[0].status.used["limits.cpu"] // "0"')
q_cpu_lim_hard=$(echo "$quota_json" | jq -r '.items[0].status.hard["limits.cpu"] // "0"')
q_mem_req_used=$(echo "$quota_json" | jq -r '.items[0].status.used["requests.memory"] // "0"')
q_mem_req_hard=$(echo "$quota_json" | jq -r '.items[0].status.hard["requests.memory"] // "0"')
q_mem_lim_used=$(echo "$quota_json" | jq -r '.items[0].status.used["limits.memory"] // "0"')
q_mem_lim_hard=$(echo "$quota_json" | jq -r '.items[0].status.hard["limits.memory"] // "0"')
cpu_req_used_m=$(normalize_cpu "$q_cpu_req_used")
cpu_req_hard_m=$(normalize_cpu "$q_cpu_req_hard")
cpu_lim_used_m=$(normalize_cpu "$q_cpu_lim_used")
cpu_lim_hard_m=$(normalize_cpu "$q_cpu_lim_hard")
mem_req_used_mib=$(normalize_mem "$q_mem_req_used")
mem_req_hard_mib=$(normalize_mem "$q_mem_req_hard")
mem_lim_used_mib=$(normalize_mem "$q_mem_lim_used")
mem_lim_hard_mib=$(normalize_mem "$q_mem_lim_hard")
cpu_req_pct=$(awk "BEGIN { printf \"%.4f\", $cpu_req_used_m / ($cpu_req_hard_m > 0 ? $cpu_req_hard_m : 1) }")
cpu_lim_pct=$(awk "BEGIN { printf \"%.4f\", $cpu_lim_used_m / ($cpu_lim_hard_m > 0 ? $cpu_lim_hard_m : 1) }")
mem_req_pct=$(awk "BEGIN { printf \"%.4f\", $mem_req_used_mib / ($mem_req_hard_mib > 0 ? $mem_req_hard_mib : 1) }")
mem_lim_pct=$(awk "BEGIN { printf \"%.4f\", $mem_lim_used_mib / ($mem_lim_hard_mib > 0 ? $mem_lim_hard_mib : 1) }")
cpu_req_label="$(fmt_cpu $cpu_req_used_m) / $(fmt_cpu $cpu_req_hard_m)"
cpu_lim_label="$(fmt_cpu $cpu_lim_used_m) / $(fmt_cpu $cpu_lim_hard_m)"
mem_req_label="$(fmt_mem $mem_req_used_mib) / $(fmt_mem $mem_req_hard_mib)"
mem_lim_label="$(fmt_mem $mem_lim_used_mib) / $(fmt_mem $mem_lim_hard_mib)"
# ── Output ───────────────────────────────────────────────────────────────────
jq -nc \
--argjson podMetrics "$pod_metrics" \
--argjson cpuActualM "$cpu_actual" \
--argjson memActualMi "$mem_actual" \
--argjson cpuReqPct "$cpu_req_pct" \
--argjson cpuLimPct "$cpu_lim_pct" \
--argjson memReqPct "$mem_req_pct" \
--argjson memLimPct "$mem_lim_pct" \
--arg cpuReqLabel "$cpu_req_label" \
--arg cpuLimLabel "$cpu_lim_label" \
--arg memReqLabel "$mem_req_label" \
--arg memLimLabel "$mem_lim_label" \
'{
podMetrics: $podMetrics,
quota: {
cpuActualM: $cpuActualM,
memActualMi: $memActualMi,
cpuReqPct: $cpuReqPct,
cpuLimPct: $cpuLimPct,
memReqPct: $memReqPct,
memLimPct: $memLimPct,
cpuReqLabel: $cpuReqLabel,
cpuLimLabel: $cpuLimLabel,
memReqLabel: $memReqLabel,
memLimLabel: $memLimLabel
}
}'
```
- [ ] **Step 2: Test standalone**
```bash
chezmoi apply ~/.config/quickshell/scripts/k8s-metrics.sh
~/.config/quickshell/scripts/k8s-metrics.sh tenant-5 | jq .
```
Expected: JSON with `podMetrics` array (joplin/n8n/shared-postgres have real cpuM/memMi; dnddrugs has -1/-1) and `quota` object with percentages matching `kubectl get resourcequota -n tenant-5 -o json`.
Spot-check manually:
```bash
# cpuLimPct should be ~0.73 (1450m used / 2000m hard)
kubectl get resourcequota -n tenant-5 -o jsonpath='{.items[0].status}'
```
- [ ] **Step 3: Commit**
```bash
git add scripts/executable_k8s-metrics.sh
git commit -m "k8s-widget: add k8s-metrics.sh"
```
---
## Task 4: Kubernetes.qml Singleton
**Files:**
- Create: `shared/Kubernetes.qml`
- [ ] **Step 1: Write the Singleton**
Create `shared/Kubernetes.qml`:
```qml
pragma Singleton
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
property string status: "loading"
property var pods: []
property int readyCount: 0
property int totalCount: 0
property var quota: null
property int lastUpdatedSecs: 0
// Increment seconds since last successful status fetch
Timer {
interval: 1000
running: root.status !== "loading"
repeat: true
onTriggered: root.lastUpdatedSecs++
}
// Staleness check — flip to "stale" if no update for >60s
Timer {
interval: 10000
running: true
repeat: true
onTriggered: {
if (root.lastUpdatedSecs > 60
&& (root.status === "ok" || root.status === "degraded")) {
root.status = "stale";
}
}
}
// ─── Status script ────────────────────────────────────────────────────────
Process {
id: statusProc
command: ["bash", Config.scriptsDir + "/k8s-status.sh", Config.kubeNamespace]
stdout: StdioCollector {
onStreamFinished: {
if (this.text.trim() === "") {
root.status = (root.status === "ok" || root.status === "degraded")
? "stale" : "error";
return;
}
try {
let data = JSON.parse(this.text);
// Preserve existing cpuM/memMi from last metrics fetch
let byApp = {};
for (let p of root.pods) byApp[p.app] = p;
root.pods = data.pods.map(p => ({
app: p.app,
ready: p.ready,
cpuM: byApp[p.app]?.cpuM ?? -1,
memMi: byApp[p.app]?.memMi ?? -1
}));
root.readyCount = data.readyCount;
root.totalCount = data.totalCount;
root.status = data.pods.every(p => p.ready) ? "ok" : "degraded";
root.lastUpdatedSecs = 0;
} catch(e) {
console.warn("Kubernetes: status parse error:", e);
root.status = (root.status === "ok" || root.status === "degraded")
? "stale" : "error";
}
}
}
}
Timer {
interval: Config.kubeStatusRefreshMs
running: true
repeat: true
onTriggered: statusProc.running = true
}
// ─── Metrics script ───────────────────────────────────────────────────────
Process {
id: metricsProc
command: ["bash", Config.scriptsDir + "/k8s-metrics.sh", Config.kubeNamespace]
stdout: StdioCollector {
onStreamFinished: {
if (this.text.trim() === "") return;
try {
let data = JSON.parse(this.text);
let byApp = {};
for (let m of data.podMetrics) byApp[m.app] = m;
root.pods = root.pods.map(p => ({
app: p.app,
ready: p.ready,
cpuM: byApp[p.app]?.cpuM ?? -1,
memMi: byApp[p.app]?.memMi ?? -1
}));
root.quota = data.quota;
} catch(e) {
console.warn("Kubernetes: metrics parse error:", e);
}
}
}
}
Timer {
interval: Config.kubeMetricsRefreshMs
running: true
repeat: true
onTriggered: metricsProc.running = true
}
// Stagger metrics 500ms after status to avoid a CPU spike at startup
Timer {
interval: 500
repeat: false
onTriggered: metricsProc.running = true
}
Component.onCompleted: statusProc.running = true
}
```
- [ ] **Step 2: Apply and verify quickshell loads without errors**
```bash
chezmoi apply ~/.config/quickshell/shared/Kubernetes.qml
# Restart quickshell. Check for QML errors:
# (kill and relaunch, or use your normal quickshell reload mechanism)
# The bar should appear as before — Kubernetes.qml is loaded but nothing displays yet.
```
- [ ] **Step 3: Commit**
```bash
git add shared/Kubernetes.qml
git commit -m "k8s-widget: add Kubernetes.qml singleton"
```
---
## Task 5: KubernetesPill + Bar wiring (pill only)
**Files:**
- Create: `bar/KubernetesPill.qml`
- Modify: `Bar.qml` (pill only — popout comes in Task 6)
- [ ] **Step 1: Write KubernetesPill.qml**
Create `bar/KubernetesPill.qml`:
```qml
import Quickshell
import QtQuick
import QtQuick.Layouts
import "../shared" as Shared
BarPill {
groupName: "kubernetes"
accentColor: {
let s = Shared.Kubernetes.status;
if (s === "ok") return Shared.Theme.green;
if (s === "degraded") return Shared.Theme.yellow;
if (s === "error") return Shared.Theme.red;
if (s === "stale") return Shared.Theme.overlay0;
return Shared.Theme.blue; // "loading"
}
content: [
Text {
Layout.alignment: Qt.AlignHCenter
text: "\u{f03be}" // 󱃾 Nerd Font kubernetes wheel
color: {
let s = Shared.Kubernetes.status;
if (s === "ok") return Shared.Theme.green;
if (s === "degraded") return Shared.Theme.yellow;
if (s === "error") return Shared.Theme.red;
if (s === "stale") return Shared.Theme.overlay0;
return Shared.Theme.blue;
}
font.pixelSize: Shared.Theme.fontLarge
font.family: Shared.Theme.iconFont
},
Text {
Layout.alignment: Qt.AlignHCenter
text: Shared.Kubernetes.status === "loading"
? "…"
: Shared.Kubernetes.readyCount + "/" + Shared.Kubernetes.totalCount
color: {
let s = Shared.Kubernetes.status;
if (s === "ok") return Shared.Theme.green;
if (s === "degraded") return Shared.Theme.yellow;
if (s === "error") return Shared.Theme.red;
if (s === "stale") return Shared.Theme.overlay0;
return Shared.Theme.blue;
}
font.pixelSize: Shared.Theme.fontSmall
font.family: Shared.Theme.fontFamily
font.bold: true
}
]
}
```
- [ ] **Step 2: Add pill to Bar.qml**
In `Bar.qml`, find the line `BarComponents.SystemPill { id: systemBtn }` (currently the last item in the bottom section, line ~127). Add `KubernetesPill` **directly above it**:
```qml
BarComponents.KubernetesPill { id: kubernetesBtn }
BarComponents.SystemPill { id: systemBtn }
```
The surrounding context for orientation:
```qml
BarComponents.BarPill {
id: notifBtn
// ... (notification pill, unchanged)
}
BarComponents.KubernetesPill { id: kubernetesBtn } // ← insert here
BarComponents.SystemPill { id: systemBtn }
```
- [ ] **Step 3: Apply and verify pill appears**
```bash
chezmoi apply ~/.config/quickshell/bar/KubernetesPill.qml \
~/.config/quickshell/Bar.qml
# Restart quickshell
# Expected: k8s wheel icon + "4/4" appears in the bar in green
# Clicking it does nothing yet (no popout wired)
```
- [ ] **Step 4: Commit**
```bash
git add bar/KubernetesPill.qml Bar.qml
git commit -m "k8s-widget: add KubernetesPill to bar"
```
---
## Task 6: KubernetesPopout + full Bar.qml wiring
**Files:**
- Create: `bar/popouts/KubernetesPopout.qml`
- Modify: `Bar.qml` (add PopoutSlot + update visible guard)
- [ ] **Step 1: Write KubernetesPopout.qml**
Create `bar/popouts/KubernetesPopout.qml`:
```qml
import Quickshell
import QtQuick
import QtQuick.Layouts
import "../../shared" as Shared
Item {
id: root
implicitWidth: Shared.Theme.popoutWidth
implicitHeight: col.implicitHeight + Shared.Theme.popoutPadding * 2
PopoutBackground { anchors.fill: parent }
MouseArea { anchors.fill: parent }
// Reusable quota bar component
component QuotaBar: Column {
property string rowLabel: ""
property string valueLabel: ""
property real fill: 0
property color barColor: Shared.Theme.green
property real barOpacity: 1.0
Layout.fillWidth: true
width: parent.width
spacing: 3
RowLayout {
width: parent.width
Text {
text: rowLabel
color: Shared.Theme.overlay0
font.pixelSize: 9
font.family: Shared.Theme.fontFamily
}
Item { Layout.fillWidth: true }
Text {
text: valueLabel
color: Shared.Theme.subtext0
font.pixelSize: 9
font.family: Shared.Theme.fontFamily
}
Text {
text: Math.round(fill * 100) + "%"
color: barColor
font.pixelSize: 9
font.family: Shared.Theme.fontFamily
}
}
Rectangle {
width: parent.width
height: 4
color: Shared.Theme.surface1
radius: 2
Rectangle {
width: Math.max(0, Math.min(1, parent.parent.fill)) * parent.width
height: parent.height
color: parent.parent.barColor
opacity: parent.parent.barOpacity
radius: 2
}
}
}
ColumnLayout {
id: col
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.margins: Shared.Theme.popoutPadding
spacing: 8
// ── Header ────────────────────────────────────────────
RowLayout {
Layout.fillWidth: true
Text {
text: "K8s — " + Shared.Config.kubeNamespace
color: Shared.Theme.text
font.pixelSize: 16
font.family: Shared.Theme.fontFamily
font.bold: true
Layout.fillWidth: true
}
Text {
text: "\u{f03be}"
color: {
let s = Shared.Kubernetes.status;
if (s === "ok") return Shared.Theme.green;
if (s === "degraded") return Shared.Theme.yellow;
if (s === "error") return Shared.Theme.red;
if (s === "stale") return Shared.Theme.overlay0;
return Shared.Theme.blue;
}
font.pixelSize: 20
font.family: Shared.Theme.iconFont
}
}
// ── Pods section ──────────────────────────────────────
Text {
text: "PODS"
color: Shared.Theme.overlay0
font.pixelSize: 9
font.family: Shared.Theme.fontFamily
font.letterSpacing: 1
}
// Column header row
RowLayout {
Layout.fillWidth: true
spacing: 4
Item { Layout.fillWidth: true }
Text { text: "CPU"; color: Shared.Theme.overlay0; font.pixelSize: 8; font.family: Shared.Theme.fontFamily; Layout.preferredWidth: 44; horizontalAlignment: Text.AlignRight }
Text { text: "MEM"; color: Shared.Theme.overlay0; font.pixelSize: 8; font.family: Shared.Theme.fontFamily; Layout.preferredWidth: 52; horizontalAlignment: Text.AlignRight }
Text { text: "STATUS"; color: Shared.Theme.overlay0; font.pixelSize: 8; font.family: Shared.Theme.fontFamily; Layout.preferredWidth: 62; horizontalAlignment: Text.AlignRight }
}
// Pod rows
Repeater {
model: Shared.Kubernetes.pods
delegate: Rectangle {
required property var modelData
Layout.fillWidth: true
implicitHeight: 28
color: Shared.Theme.surface0
radius: Shared.Theme.radiusSmall
RowLayout {
anchors.fill: parent
anchors.leftMargin: 10
anchors.rightMargin: 10
spacing: 4
Text {
text: modelData.app
color: Shared.Theme.text
font.pixelSize: 11
font.family: Shared.Theme.fontFamily
Layout.fillWidth: true
elide: Text.ElideRight
}
Text {
text: modelData.cpuM === -1 ? "—" : modelData.cpuM + "m"
color: Shared.Theme.sky
font.pixelSize: 11
font.family: Shared.Theme.fontFamily
Layout.preferredWidth: 44
horizontalAlignment: Text.AlignRight
}
Text {
text: modelData.memMi === -1 ? "—" : modelData.memMi + "Mi"
color: Shared.Theme.mauve
font.pixelSize: 11
font.family: Shared.Theme.fontFamily
Layout.preferredWidth: 52
horizontalAlignment: Text.AlignRight
}
RowLayout {
spacing: 3
Layout.preferredWidth: 62
layoutDirection: Qt.RightToLeft
Text {
text: modelData.ready ? "Ready" : "NotReady"
color: modelData.ready ? Shared.Theme.green : Shared.Theme.red
font.pixelSize: 10
font.family: Shared.Theme.fontFamily
}
Text {
text: "●"
color: modelData.ready ? Shared.Theme.green : Shared.Theme.red
font.pixelSize: 9
}
}
}
}
}
// ── Quota section ─────────────────────────────────────
Text {
text: "QUOTA"
color: Shared.Theme.overlay0
font.pixelSize: 9
font.family: Shared.Theme.fontFamily
font.letterSpacing: 1
topPadding: 4
}
// CPU card
Rectangle {
Layout.fillWidth: true
color: Shared.Theme.surface0
radius: Shared.Theme.radiusSmall
implicitHeight: cpuCardCol.implicitHeight + 20
Column {
id: cpuCardCol
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: 12
anchors.verticalCenter: parent.verticalCenter
spacing: 6
RowLayout {
width: parent.width
Text { text: "CPU"; color: Shared.Theme.sky; font.pixelSize: 10; font.family: Shared.Theme.fontFamily; font.bold: true }
Item { Layout.fillWidth: true }
Text {
text: Shared.Kubernetes.quota ? Shared.Kubernetes.quota.cpuActualM + "m actual" : "—"
color: Shared.Theme.text
font.pixelSize: 10
font.family: Shared.Theme.fontFamily
}
}
QuotaBar {
rowLabel: "%REQ"
valueLabel: Shared.Kubernetes.quota?.cpuReqLabel ?? ""
fill: Shared.Kubernetes.quota?.cpuReqPct ?? 0
barColor: Shared.Theme.green
}
QuotaBar {
rowLabel: "%LIM"
valueLabel: Shared.Kubernetes.quota?.cpuLimLabel ?? ""
fill: Shared.Kubernetes.quota?.cpuLimPct ?? 0
barColor: Shared.Theme.yellow
barOpacity: 0.6
}
}
}
// MEM card
Rectangle {
Layout.fillWidth: true
color: Shared.Theme.surface0
radius: Shared.Theme.radiusSmall
implicitHeight: memCardCol.implicitHeight + 20
Column {
id: memCardCol
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: 12
anchors.verticalCenter: parent.verticalCenter
spacing: 6
RowLayout {
width: parent.width
Text { text: "MEM"; color: Shared.Theme.mauve; font.pixelSize: 10; font.family: Shared.Theme.fontFamily; font.bold: true }
Item { Layout.fillWidth: true }
Text {
text: Shared.Kubernetes.quota ? Shared.Kubernetes.quota.memActualMi + "Mi actual" : "—"
color: Shared.Theme.text
font.pixelSize: 10
font.family: Shared.Theme.fontFamily
}
}
QuotaBar {
rowLabel: "%REQ"
valueLabel: Shared.Kubernetes.quota?.memReqLabel ?? ""
fill: Shared.Kubernetes.quota?.memReqPct ?? 0
barColor: Shared.Theme.mauve
}
QuotaBar {
rowLabel: "%LIM"
valueLabel: Shared.Kubernetes.quota?.memLimLabel ?? ""
fill: Shared.Kubernetes.quota?.memLimPct ?? 0
barColor: Shared.Theme.mauve
barOpacity: 0.6
}
}
}
// ── Footer ────────────────────────────────────────────
Text {
text: "Updated " + Shared.Kubernetes.lastUpdatedSecs + "s ago"
color: Shared.Theme.overlay0
font.pixelSize: 9
font.family: Shared.Theme.fontFamily
Layout.alignment: Qt.AlignRight
}
}
}
```
- [ ] **Step 2: Add PopoutSlot to Bar.qml and update visible guard**
In `Bar.qml`, inside the `Item { id: popoutArea }` block (around line 175), add after the `systemSlot` PopoutSlot:
```qml
PopoutSlot {
id: kubernetesSlot
name: "kubernetes"
verticalAnchor: "bottom"
sourceComponent: Popouts.KubernetesPopout {}
}
```
Then update the `visible:` line on `popoutWindow` (line 145) to include `kubernetesSlot.animating`:
```qml
visible: modelData.name === Shared.Config.monitor && (popoutOpen || notifSlot.animating || mediaSlot.animating || weatherSlot.animating || datetimeSlot.animating || systemSlot.animating || kubernetesSlot.animating)
```
- [ ] **Step 3: Apply and verify full widget**
```bash
chezmoi apply ~/.config/quickshell/bar/popouts/KubernetesPopout.qml \
~/.config/quickshell/Bar.qml
# Restart quickshell
```
Verify:
1. Pill shows `4/4` in green
2. Click pill → popout opens (smooth animation matching other popouts)
3. Pod table shows all 4 pods; joplin/n8n/shared-postgres have CPU/MEM values; dnddrugs shows `—`
4. Quota bars for CPU and MEM match values from:
```bash
kubectl top pods -n tenant-5
kubectl get resourcequota -n tenant-5 -o json
```
5. Footer shows "Updated Xs ago" incrementing each second
6. Click outside popout → closes cleanly
- [ ] **Step 4: Test degraded state**
```bash
kubectl rollout restart deployment/joplin -n tenant-5
# Watch pill: within ~30s it should turn yellow and show 3/4
# Once joplin pod is Ready again: pill returns to green 4/4
```
- [ ] **Step 5: Commit**
```bash
git add bar/popouts/KubernetesPopout.qml Bar.qml
git commit -m "k8s-widget: add KubernetesPopout and complete bar wiring"
```
---
## Verification Checklist
```bash
# Scripts work standalone
~/.config/quickshell/scripts/k8s-status.sh tenant-5 | jq .
~/.config/quickshell/scripts/k8s-metrics.sh tenant-5 | jq .
# Full widget smoke test (after chezmoi apply + quickshell restart)
# ✓ Pill visible with correct count + green color
# ✓ Popout opens on click, closes on outside click or Escape
# ✓ Pod table accurate (compare to: kubectl get pods -n tenant-5)
# ✓ Quota bars accurate (compare to: kubectl get resourcequota -n tenant-5 -o json)
# ✓ dnddrugs row shows — for CPU/MEM
# ✓ No animation glitches (popout fades/scales like other popouts)
# ✓ Degraded state: kubectl rollout restart deployment/joplin → yellow pill, NotReady row
```
@@ -0,0 +1,279 @@
# K8s Monitoring Widget — Design Spec
**Date:** 2026-04-22
**Status:** Approved
---
## Context
The user hosts several applications (joplin, n8n, grocy, dnddrugs, shared-postgres) on a Kubernetes cluster (ITSH Cloud, namespace `tenant-5`). There is no always-open terminal or dashboard for at-a-glance cluster health. The goal is a Quickshell bar widget that surfaces pod health and resource pressure without leaving the desktop.
**Constraints discovered during design:**
- `kubectl get nodes` is forbidden — the service account only has namespace scope
- `kubectl top pods` works — metrics-server is available
- `kubectl get resourcequota` works — quota has both requests and limits tracked
- Pods absent from `kubectl top` (e.g. dnddrugs) should show `—`, not an error
**Dependencies:** `kubectl` and `jq` must be in `$PATH` on the host.
---
## Files
### Create
| File | Purpose |
|---|---|
| `scripts/executable_k8s-status.sh` | Fetches pod list + ready status → JSON (30s poll) |
| `scripts/executable_k8s-metrics.sh` | Fetches kubectl top + resourcequota → JSON (15s poll) |
| `shared/Kubernetes.qml` | Singleton: owns all k8s state, runs both scripts, exposes properties |
| `bar/KubernetesPill.qml` | Bar pill: k8s icon + ready/total pod ratio, color-coded by health |
| `bar/popouts/KubernetesPopout.qml` | Popout: per-pod table + quota bars |
> The `executable_` prefix is the chezmoi convention (see `scripts/executable_gpu.sh`). Chezmoi maps these to plain filenames at apply time with the executable bit set.
### Modify
| File | Change |
|---|---|
| `Bar.qml` | Import `KubernetesPill`, add `PopoutSlot`, add `kubernetesSlot.animating` to popout `visible` guard |
| `shared/Config.qml.tmpl` | Add `kubeNamespace`, `kubeStatusRefreshMs`, `kubeMetricsRefreshMs` |
---
## Scripts
Both scripts accept the namespace as `$1` and are invoked from QML as:
```qml
command: ["bash", Config.scriptsDir + "/k8s-status.sh", Config.kubeNamespace]
```
### `scripts/executable_k8s-status.sh`
Runs: `kubectl get pods -n "$1" -o json`
Extracts per pod:
- `app`: from `metadata.labels["app.kubernetes.io/instance"]`, fallback to `metadata.name`
- `ready`: `true` if `status.conditions` contains `{type: "Ready", status: "True"}`
Outputs JSON:
```json
{
"pods": [{"app": "joplin", "ready": true}, ...],
"readyCount": 4,
"totalCount": 4
}
```
On any error: exits non-zero, outputs nothing. The QML handler checks `this.text.trim() === ""` to detect this.
### `scripts/executable_k8s-metrics.sh`
Runs two commands sequentially:
1. `kubectl get pods -n "$1" -o json` — builds a `podName → app` label map
2. `kubectl top pods -n "$1" --no-headers` — parses into `{podName → {cpuM, memMi}}`
3. `kubectl get resourcequota -n "$1" -o json`
**Pod name → app mapping:** `kubectl top` returns raw pod names (e.g. `joplin-7bfd5bc954-jq47w`). The metrics script resolves each top pod name to its `app.kubernetes.io/instance` label using the pod list from step 1, producing `podMetrics` keyed by `app` label. Pods in the top output that have no matching pod object are skipped.
**Unit normalization for quota values:** All CPU values are normalized to millicores (e.g. `"1"``1000`, `"500m"``500`). All memory values are normalized to MiB (e.g. `"4Gi"``4096`, `"512Mi"``512`, raw bytes → `/ 1048576`).
From resourcequota `status.used` and `status.hard` (after normalization), computes:
- `cpuActualM`: sum of CPU millicores from `kubectl top` output (actual live usage, not from resourcequota)
- `memActualMi`: sum of MEM MiB from `kubectl top` output (same — actual, not requests)
- `cpuReqPct`, `cpuLimPct`: `used / hard` as 0.01.0 fractions
- `memReqPct`, `memLimPct`: same for memory
- Human-readable labels (e.g. `"78m / 2000m"`, `"1.01Gi / 4Gi"`) for display
Outputs JSON:
```json
{
"podMetrics": [{"app": "joplin", "cpuM": 13, "memMi": 343}, ...],
"quota": {
"cpuActualM": 29,
"cpuReqPct": 0.04, "cpuLimPct": 0.73,
"memActualMi": 921,
"memReqPct": 0.25, "memLimPct": 0.54,
"cpuReqLabel": "78m / 2000m",
"cpuLimLabel": "1450m / 2000m",
"memReqLabel": "1.01Gi / 4Gi",
"memLimLabel": "2.14Gi / 4Gi"
}
}
```
Pods not present in `kubectl top` output get `cpuM: -1, memMi: -1` (displayed as `—`).
On any error: exits non-zero, outputs nothing.
---
## Singleton: `shared/Kubernetes.qml`
Root element must be `Singleton` (the Quickshell type), matching every other file in `shared/`:
```qml
pragma Singleton
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
// properties, Processes, Timers here
}
```
### Properties
| Property | Type | Description |
|---|---|---|
| `status` | string | `"loading"` \| `"ok"` \| `"degraded"` \| `"error"` \| `"stale"` |
| `pods` | var | Array of `{app, ready, cpuM, memMi}` — merged from both scripts |
| `readyCount` | int | Number of ready pods |
| `totalCount` | int | Total pod count |
| `quota` | var | Quota object from metrics script |
| `lastUpdatedSecs` | int | Seconds since last successful **status** fetch |
### Health logic
- `"loading"` (blue/accent): no data yet — initial state before first fetch completes
- `"ok"` (green): all pods ready, last status fetch < 60s ago
- `"degraded"` (yellow): any pod not ready
- `"error"` (red): status script failed and no prior data
- `"stale"` (overlay0): last successful status fetch > 60s ago (connectivity lost). Transitions back to `"ok"` or `"degraded"` on the next successful parse.
Metrics staleness (15s cadence) is not tracked separately — if metrics are stale, pods show `cpuM: -1` (displayed as `—`), which is sufficient signal.
### Polling
Two independent `Process` + `StdioCollector` pairs:
| Process | Script | Interval | Timer start |
|---|---|---|---|
| `statusProc` | `k8s-status.sh` | 30 000ms | `Component.onCompleted` |
| `metricsProc` | `k8s-metrics.sh` | 15 000ms | Staggered 500ms after status (via single-shot Timer) |
**Merge:** After each metrics fetch, the Singleton iterates `podMetrics` and patches `cpuM`/`memMi` into the existing `pods` array by matching on `app`. Pods in `pods` with no match in `podMetrics` retain `cpuM: -1, memMi: -1`.
**Error handling in `onStreamFinished`:**
```js
if (this.text.trim() === "") {
// script failed
root.status = (root.status === "ok" || root.status === "degraded") ? "stale" : "error";
return;
}
try {
let data = JSON.parse(this.text);
// update properties
root.status = "ok"; // or "degraded" if any pod not ready
} catch(e) {
root.status = (root.status === "ok" || root.status === "degraded") ? "stale" : "error";
}
```
---
## UI Components
### `bar/KubernetesPill.qml`
Follows the `BarPill` pattern exactly (see `WeatherPill.qml`):
```
groupName: "kubernetes"
accentColor: status → green(ok) / yellow(degraded) / red(error) / overlay0(stale) / blue(loading)
content: [
Text { icon glyph 󱃾 (U+F03BE, Nerd Font k8s wheel), font.family: iconFont },
Text {
text: status === "loading" ? "…" : readyCount + "/" + totalCount
color: accentColor
}
]
```
### `bar/popouts/KubernetesPopout.qml`
Two sections:
**Pods table** — one row per pod:
- Columns: `app name | CPU | MEM | ● Ready/NotReady`
- CPU/MEM shown as `13m` / `343Mi`; shown as `—` if `cpuM === -1`
- Ready: green dot + "Ready"; red dot + "NotReady"
**Quota section** — two cards (CPU, MEM), each card contains:
- Header: label + actual value (`29m actual` / `921Mi actual`)
- `%REQ` bar: `cpuReqLabel` + percentage + colored bar (green / `Theme.green`)
- `%LIM` bar: `cpuLimLabel` + percentage + colored bar (yellow dimmed / `Theme.yellow` at 60% opacity)
Footer: `Updated Xs ago` from `lastUpdatedSecs`.
### `Bar.qml` wiring
```qml
// In pill column:
BarComponents.KubernetesPill {}
// PopoutSlot (add alongside existing slots):
PopoutSlot {
id: kubernetesSlot
name: "kubernetes"
verticalAnchor: "bottom"
sourceComponent: Popouts.KubernetesPopout {}
}
// Update the popout PanelWindow visible guard (existing line, add the new condition):
visible: notifSlot.animating || mediaSlot.animating || weatherSlot.animating
|| datetimeSlot.animating || systemSlot.animating || kubernetesSlot.animating
```
---
## Config additions (`shared/Config.qml.tmpl`)
```qml
readonly property string kubeNamespace: "tenant-5"
readonly property int kubeStatusRefreshMs: 30000
readonly property int kubeMetricsRefreshMs: 15000
```
---
## Error Handling Summary
| Scenario | Pill | Popout |
|---|---|---|
| First load | blue `…` | empty / loading indicator |
| All pods ready | green `4/4` | normal |
| Any pod NotReady | yellow `3/4` | affected row shows red dot |
| Status script fails (first time) | red `0/0` | error message |
| Status script fails (had data) | overlay `4/4` | stale indicator + last known data |
| Metrics script fails | unchanged | CPU/MEM show `—` |
---
## Verification
```bash
# 1. Test scripts standalone (after chezmoi apply)
~/.config/quickshell/scripts/k8s-status.sh tenant-5 | jq .
~/.config/quickshell/scripts/k8s-metrics.sh tenant-5 | jq .
# 2. Apply and restart quickshell
chezmoi apply ~/.config/quickshell
# kill and relaunch quickshell
# 3. Visual checks:
# - Pill appears with correct ready/total count and green color
# - Click pill → popout opens with pod table (joplin/n8n/shared-postgres show CPU/MEM, dnddrugs shows —)
# - Quota bars match: kubectl top pods -n tenant-5 and kubectl get resourcequota -n tenant-5 -o json
# 4. Degraded state (transient, brief outage):
kubectl rollout restart deployment/joplin -n tenant-5
# → pill turns yellow, joplin row shows NotReady during rollout
# → returns green once pod is Ready again
# 5. Stale state (simulate connectivity loss):
# Temporarily rename kubeconfig or revoke access, wait >60s → pill turns overlay color
```