Files
community-plugins/k8s-status/panel.luau
T
Dave HammerandGitHub 5bae8428a3 Add davemhammer/k8s-status (#314)
* Add davemhammer/k8s-status

Kubernetes nodes/pods/deployments monitor with panel and /kube launcher.

* k8s-status v1.1.5: fix stuck Querying cluster

Parallel kubectl refresh, hang recovery, safer loading UI.
2026-08-09 10:13:48 -04:00

733 lines
20 KiB
Luau

--!nonstrict
-- K8s Status panel: nodes, pods, deployments, namespaces.
local STATE_KEY = "k8s_snapshot"
local COMMAND_KEY = "k8s_command"
local RESULT_KEY = "k8s_action_result"
local snapshot = noctalia.state.get(STATE_KEY) or {
available = false,
loading = true,
busy = false,
context = "",
nodes = {},
pods = {},
deployments = {},
namespaces = {},
readyNodes = 0,
nodeCount = 0,
problemPods = 0,
podCount = 0,
error = "",
updatedAt = 0,
revision = 0,
}
local tab = "nodes" -- nodes | pods | deploys | namespaces
local selectedId = ""
local problemsOnly = noctalia.getConfig("problems_only") == true
local filterText = ""
local filterKey = 0
local requestCounter = 0
local feedback = ""
local feedbackError = false
local dirty = true
-- Prefer neutral cluster icons (avoid missing glyphs / grim fallbacks)
local ICON_MAIN = "hexagon"
local ICON_NODE = "server"
local ICON_POD = "box"
local ICON_POD_BAD = "circle-x"
local ICON_DEPLOY = "packages"
local ICON_NS = "folder"
local render
local function tr(key, subst)
return noctalia.tr(key, subst)
end
local function nextRequestId()
requestCounter += 1
return `panel-{requestCounter}`
end
local function sendCommand(action, values)
local command = {
action = action,
requestId = nextRequestId(),
}
if type(values) == "table" then
for key, value in pairs(values) do
command[key] = value
end
end
noctalia.state.set(COMMAND_KEY, command)
return command.requestId
end
local function lower(s)
return string.lower(tostring(s or ""))
end
local function haystackContains(needle, ...)
if needle == "" then
return true
end
for i = 1, select("#", ...) do
local part = lower(select(i, ...))
if part ~= "" and part:find(needle, 1, true) then
return true
end
end
return false
end
-- Space-separated terms; prefix a term with ! to exclude matches.
-- Examples: !Running gitea !Completed !Ready
local function matchesFilter(...)
local q = noctalia.string.trim(filterText)
if q == "" then
return true
end
for raw in q:gmatch("%S+") do
local neg = false
local term = raw
if term:sub(1, 1) == "!" then
neg = true
term = term:sub(2)
end
term = lower(term)
if term ~= "" then
local hit = haystackContains(term, ...)
if neg then
if hit then
return false
end
else
if not hit then
return false
end
end
end
end
return true
end
local function selectedNode()
if tab ~= "nodes" then return nil end
for _, n in ipairs(snapshot.nodes or {}) do
if n.id == selectedId then return n end
end
return nil
end
local function selectedPod()
if tab ~= "pods" then return nil end
for _, p in ipairs(snapshot.pods or {}) do
if p.id == selectedId then return p end
end
return nil
end
local function selectedDeploy()
if tab ~= "deploys" then return nil end
for _, d in ipairs(snapshot.deployments or {}) do
if d.id == selectedId then return d end
end
return nil
end
local function selectedNs()
if tab ~= "namespaces" then return nil end
for _, n in ipairs(snapshot.namespaces or {}) do
if n.id == selectedId then return n end
end
return nil
end
local function statusColor(ok)
return ok and "tertiary" or "error"
end
local function listButton(props)
-- Full-width list rows, left-aligned content (never centered chips).
props.contentAlign = "start"
props.controlSize = props.controlSize or "md"
return ui.button(props)
end
local function nodeCard(node)
local selected = node.id == selectedId
local text = `{node.name} · {node.status} · {node.version} · {node.ip}`
return listButton({
key = "node-" .. node.id,
text = text,
glyph = ICON_NODE,
variant = selected and "primary" or "outline",
selected = selected,
onClick = function()
selectedId = node.id
feedback = ""
render()
end,
})
end
local function podCard(pod)
local selected = pod.id == selectedId
local text = `{pod.namespace}/{pod.name} · {pod.ready} · {pod.status} · r{pod.restarts}`
return listButton({
key = "pod-" .. pod.id,
text = text,
glyph = pod.problem and ICON_POD_BAD or ICON_POD,
variant = selected and "primary" or "outline",
selected = selected,
onClick = function()
selectedId = pod.id
feedback = ""
render()
end,
})
end
local function deployCard(dep)
local selected = dep.id == selectedId
local text = `{dep.namespace}/{dep.name} · {dep.ready}/{dep.desired}`
return listButton({
key = "deploy-" .. dep.id,
text = text,
glyph = ICON_DEPLOY,
variant = selected and "primary" or "outline",
selected = selected,
onClick = function()
selectedId = dep.id
feedback = ""
render()
end,
})
end
local function nsCard(ns)
local selected = ns.id == selectedId
return listButton({
key = "ns-" .. ns.id,
text = `{ns.name} · {ns.phase}`,
glyph = ICON_NS,
variant = selected and "primary" or "outline",
selected = selected,
onClick = function()
selectedId = ns.id
feedback = ""
render()
end,
})
end
local function toolbar()
local busy = snapshot.busy == true
if tab == "nodes" then
local node = selectedNode()
if not node then
return ui.label({ text = tr("panel.select_hint"), color = "on_surface_variant" })
end
return ui.column({ gap = 4, padding = 10, fill = "surface_variant/0.45", radius = 10 }, {
ui.row({ gap = 8, align = "center" }, {
ui.glyph({ name = ICON_NODE, size = 18, color = statusColor(node.ready) }),
ui.label({ text = node.name, fontWeight = "bold", flexGrow = 1, maxLines = 1 }),
ui.label({ text = node.status, color = statusColor(node.ready), fontSize = 12 }),
}),
ui.label({ text = tr("node.version", { version = node.version }), color = "on_surface_variant", fontSize = 12 }),
ui.label({ text = tr("node.ip", { ip = node.ip }), color = "on_surface_variant", fontSize = 12 }),
ui.row({ gap = 6 }, {
ui.button({
text = tr("actions.describe"),
glyph = "file-description",
variant = "outline",
enabled = not busy,
onClick = "onDescribeNode",
}),
}),
})
end
if tab == "pods" then
local pod = selectedPod()
if not pod then
return ui.label({ text = tr("panel.select_hint"), color = "on_surface_variant" })
end
return ui.column({ gap = 4, padding = 10, fill = "surface_variant/0.45", radius = 10 }, {
ui.row({ gap = 8, align = "center" }, {
ui.glyph({ name = ICON_POD, size = 18, color = statusColor(not pod.problem) }),
ui.label({ text = `{pod.namespace}/{pod.name}`, fontWeight = "bold", flexGrow = 1, maxLines = 1 }),
ui.label({ text = pod.status, color = statusColor(not pod.problem), fontSize = 12 }),
}),
ui.label({
text = `{pod.ready} · {tr("pod.restarts", { count = pod.restarts })}`,
color = "on_surface_variant",
fontSize = 12,
}),
ui.label({
text = pod.node ~= "" and tr("pod.node", { node = pod.node }) or "",
color = "on_surface_variant",
fontSize = 12,
visible = pod.node ~= "",
}),
ui.row({ gap = 6 }, {
ui.button({ text = tr("actions.logs"), glyph = "file-text", variant = "primary", enabled = not busy, onClick = "onLogs" }),
ui.button({ text = tr("actions.describe"), glyph = "file-description", variant = "outline", enabled = not busy, onClick = "onDescribePod" }),
ui.button({ text = tr("actions.delete_pod"), glyph = "trash", variant = "destructive", enabled = not busy, onClick = "onDeletePod" }),
}),
})
end
if tab == "deploys" then
local dep = selectedDeploy()
if not dep then
return ui.label({ text = tr("panel.select_hint"), color = "on_surface_variant" })
end
return ui.column({ gap = 4, padding = 10, fill = "surface_variant/0.45", radius = 10 }, {
ui.row({ gap = 8, align = "center" }, {
ui.glyph({ name = ICON_DEPLOY, size = 18, color = statusColor(not dep.problem) }),
ui.label({ text = `{dep.namespace}/{dep.name}`, fontWeight = "bold", flexGrow = 1, maxLines = 1 }),
ui.label({
text = tr("deploy.replicas", { ready = dep.ready, desired = dep.desired }),
color = statusColor(not dep.problem),
fontSize = 12,
}),
}),
ui.row({ gap = 6 }, {
ui.button({ text = tr("actions.restart"), glyph = "refresh", variant = "primary", enabled = not busy, onClick = "onRestart" }),
ui.button({ text = tr("actions.describe"), glyph = "file-description", variant = "outline", enabled = not busy, onClick = "onDescribeDeploy" }),
}),
})
end
local ns = selectedNs()
if not ns then
return ui.label({ text = tr("panel.select_hint"), color = "on_surface_variant" })
end
return ui.column({ gap = 4, padding = 10, fill = "surface_variant/0.45", radius = 10 }, {
ui.row({ gap = 8, align = "center" }, {
ui.glyph({ name = ICON_NS, size = 18, color = "primary" }),
ui.label({ text = ns.name, fontWeight = "bold", flexGrow = 1, maxLines = 1 }),
ui.label({ text = tr("ns.phase", { phase = ns.phase }), color = "on_surface_variant", fontSize = 12 }),
}),
ui.row({ gap = 6 }, {
ui.button({ text = tr("actions.describe"), glyph = "file-description", variant = "outline", enabled = not busy, onClick = "onDescribeNs" }),
}),
})
end
local function filteredNodes()
local out = {}
for _, n in ipairs(snapshot.nodes or {}) do
if matchesFilter(n.name, n.status, n.version, n.ip, n.roles) then
table.insert(out, n)
end
end
return out
end
local function filteredPods()
local out = {}
for _, p in ipairs(snapshot.pods or {}) do
if (not problemsOnly or p.problem)
and matchesFilter(p.namespace, p.name, p.status, p.node, p.ready, p.id)
then
table.insert(out, p)
end
end
return out
end
local function filteredDeploys()
local out = {}
for _, d in ipairs(snapshot.deployments or {}) do
if matchesFilter(d.namespace, d.name, d.id, tostring(d.ready), tostring(d.desired)) then
table.insert(out, d)
end
end
return out
end
local function filteredNamespaces()
local out = {}
for _, n in ipairs(snapshot.namespaces or {}) do
if matchesFilter(n.name, n.phase) then
table.insert(out, n)
end
end
return out
end
local function emptyList(message)
-- Distinct key from the item list so align="center" is never retained on results.
return ui.column({
key = "empty-" .. tab,
align = "center",
justify = "center",
padding = 24,
gap = 8,
flexGrow = 1,
}, {
ui.glyph({ name = "search", size = 36, color = "on_surface_variant" }),
ui.label({ text = message, color = "on_surface_variant", textAlign = "center" }),
})
end
local function itemColumn(rows)
-- Always stretch full width; unique key per tab+mode so filter toggles
-- do not reuse empty-state layout props.
return ui.column({
key = "items-" .. tab,
align = "stretch",
justify = "start",
gap = 8,
flexGrow = 1,
}, rows)
end
local function itemList()
if tab == "nodes" then
local nodes = filteredNodes()
if #nodes == 0 then
return emptyList(tr("panel.empty_nodes"))
end
local rows = {}
for _, n in ipairs(nodes) do
table.insert(rows, nodeCard(n))
end
return itemColumn(rows)
end
if tab == "pods" then
local pods = filteredPods()
if #pods == 0 then
return emptyList(tr("panel.empty_pods"))
end
local rows = {}
for _, p in ipairs(pods) do
table.insert(rows, podCard(p))
end
return itemColumn(rows)
end
if tab == "deploys" then
local deps = filteredDeploys()
if #deps == 0 then
return emptyList(tr("panel.empty_deploys"))
end
local rows = {}
for _, d in ipairs(deps) do
table.insert(rows, deployCard(d))
end
return itemColumn(rows)
end
local nss = filteredNamespaces()
if #nss == 0 then
return emptyList(tr("panel.empty_namespaces"))
end
local rows = {}
for _, n in ipairs(nss) do
table.insert(rows, nsCard(n))
end
return itemColumn(rows)
end
local function tabButton(label, id, cb)
return ui.button({
text = label,
selected = tab == id,
variant = tab == id and "primary" or "ghost",
onClick = cb,
})
end
local function filterPlaceholder()
if tab == "nodes" then
return tr("filter.placeholder_nodes")
end
if tab == "pods" then
return tr("filter.placeholder_pods")
end
if tab == "deploys" then
return tr("filter.placeholder_deploys")
end
return tr("filter.placeholder_namespaces")
end
render = function()
dirty = false
local statusRows = {}
if snapshot.loading == true and not snapshot.available then
table.insert(statusRows, ui.label({ text = tr("panel.loading"), color = "on_surface_variant" }))
end
if snapshot.busy == true then
table.insert(statusRows, ui.label({ text = tr("panel.busy"), color = "primary" }))
end
if type(snapshot.error) == "string" and snapshot.error ~= "" then
table.insert(statusRows, ui.label({ text = snapshot.error, color = "error", maxLines = 3 }))
end
if feedback ~= "" then
table.insert(statusRows, ui.label({
text = feedback,
color = feedbackError and "error" or "tertiary",
maxLines = 2,
}))
end
local summary = tr("panel.summary", {
ready = snapshot.readyNodes or 0,
nodes = snapshot.nodeCount or 0,
problems = snapshot.problemPods or 0,
pods = snapshot.podCount or 0,
})
panel.render(ui.column({ flexGrow = 1, gap = 10 }, {
ui.row({ align = "center", gap = 8 }, {
ui.glyph({
name = ICON_MAIN,
size = 24,
color = snapshot.available and "primary" or "on_surface_variant",
}),
ui.column({ flexGrow = 1, gap = 0 }, {
ui.label({ text = tr("title"), fontSize = 18, fontWeight = "bold" }),
ui.label({
text = tr("panel.context", { name = snapshot.context ~= "" and snapshot.context or "—" }),
fontSize = 11,
color = "on_surface_variant",
}),
}),
ui.button({
text = tr("actions.open_k9s"),
glyph = "terminal-2",
variant = "outline",
onClick = "onK9s",
}),
ui.button({
glyph = "refresh",
variant = "ghost",
onClick = "onRefresh",
}),
ui.button({ glyph = "close", onClick = "onClose" }),
}),
-- Tabs only (summary lives on its own row so it cannot overflow)
ui.row({ gap = 4, align = "center" }, {
tabButton(tr("tabs.nodes"), "nodes", "onTabNodes"),
tabButton(tr("tabs.pods"), "pods", "onTabPods"),
tabButton(tr("tabs.deploys"), "deploys", "onTabDeploys"),
tabButton(tr("tabs.namespaces"), "namespaces", "onTabNamespaces"),
}),
ui.label({
text = summary,
color = "on_surface_variant",
fontSize = 11,
maxLines = 1,
}),
-- Search filter for every tab
ui.row({ gap = 8, align = "center" }, {
ui.input({
key = `filter-{tab}-{filterKey}`,
value = filterText,
placeholder = filterPlaceholder(),
flexGrow = 1,
controlSize = "sm",
onChange = "onFilterChange",
}),
ui.button({
text = problemsOnly and tr("filter.problems") or tr("filter.all"),
glyph = "filter",
variant = problemsOnly and "primary" or "outline",
visible = tab == "pods",
onClick = "onToggleFilter",
}),
ui.button({
glyph = "x",
variant = "ghost",
visible = filterText ~= "",
onClick = "onClearFilter",
}),
}),
toolbar(),
ui.column({ gap = 3, align = "stretch" }, statusRows),
-- Scroll owns the list; stretch so rows span the panel width while filtering.
ui.scroll({
key = "scroll-" .. tab,
flexGrow = 1,
gap = 8,
align = "stretch",
}, {
itemList(),
}),
ui.label({
text = (snapshot.updatedAt or 0) > 0
and tr("panel.updated", { time = noctalia.formatTime("%H:%M:%S", snapshot.updatedAt) })
or "",
color = "on_surface_variant",
fontSize = 11,
}),
}))
end
noctalia.state.watch(STATE_KEY, function(value)
if type(value) ~= "table" then
return
end
local changed = value.revision ~= snapshot.revision
or value.busy ~= snapshot.busy
or value.error ~= snapshot.error
or value.problemPods ~= snapshot.problemPods
or value.loading ~= snapshot.loading
or value.available ~= snapshot.available
or value.nodeCount ~= snapshot.nodeCount
or value.podCount ~= snapshot.podCount
snapshot = value
if selectedId ~= "" then
local still = selectedNode() or selectedPod() or selectedDeploy() or selectedNs()
if not still then
selectedId = ""
end
end
if changed then
dirty = true
end
end)
noctalia.state.watch(RESULT_KEY, function(result)
if type(result) ~= "table" then
return
end
if type(result.requestId) ~= "string" or not result.requestId:match("^panel%-") then
return
end
feedback = tostring(result.message or "")
feedbackError = result.ok ~= true
dirty = true
end)
panel.setWantsSecondTicks(true)
function onOpen(_context)
feedback = ""
sendCommand("refresh")
render()
end
function update()
if dirty then
render()
end
end
function onClose() panel.close() end
function onRefresh() sendCommand("refresh") end
local function switchTab(next)
tab = next
selectedId = ""
-- keep filter text across tabs; re-key input so placeholder updates cleanly
filterKey += 1
render()
end
function onTabNodes() switchTab("nodes") end
function onTabPods() switchTab("pods") end
function onTabDeploys() switchTab("deploys") end
function onTabNamespaces() switchTab("namespaces") end
function onFilterChange(value)
filterText = if type(value) == "string" then value else ""
-- drop selection if it no longer matches
if selectedId ~= "" then
local still = selectedNode() or selectedPod() or selectedDeploy() or selectedNs()
if still then
if tab == "nodes" and not matchesFilter(still.name, still.status, still.version, still.ip) then
selectedId = ""
elseif tab == "pods" and not matchesFilter(still.namespace, still.name, still.status, still.node) then
selectedId = ""
elseif tab == "deploys" and not matchesFilter(still.namespace, still.name) then
selectedId = ""
elseif tab == "namespaces" and not matchesFilter(still.name, still.phase) then
selectedId = ""
end
end
end
-- re-render list without re-keying the input (uncontrolled field keeps typed text)
render()
end
function onClearFilter()
filterText = ""
filterKey += 1
render()
end
function onToggleFilter()
problemsOnly = not problemsOnly
render()
end
function onK9s()
local ns = ""
local pod = selectedPod()
local dep = selectedDeploy()
if pod then
ns = pod.namespace
elseif dep then
ns = dep.namespace
end
sendCommand("k9s", { namespace = ns })
end
function onDescribeNode()
local n = selectedNode()
if n then
sendCommand("describe", { kind = "node", name = n.name })
end
end
function onDescribePod()
local p = selectedPod()
if p then
sendCommand("describe", { kind = "pod", name = p.name, namespace = p.namespace })
end
end
function onDescribeDeploy()
local d = selectedDeploy()
if d then
sendCommand("describe", { kind = "deploy", name = d.name, namespace = d.namespace })
end
end
function onDescribeNs()
local n = selectedNs()
if n then
sendCommand("describe", { kind = "namespace", name = n.name })
end
end
function onLogs()
local p = selectedPod()
if p then
sendCommand("logs", { name = p.name, namespace = p.namespace })
end
end
function onDeletePod()
local p = selectedPod()
if p then
sendCommand("delete_pod", { name = p.name, namespace = p.namespace })
end
end
function onRestart()
local d = selectedDeploy()
if d then
sendCommand("restart_deploy", { name = d.name, namespace = d.namespace })
end
end