From 5bae8428a33939830090577d6975450e3f3d3d74 Mon Sep 17 00:00:00 2001 From: Dave Hammer Date: Sun, 9 Aug 2026 10:13:48 -0400 Subject: [PATCH] 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. --- k8s-status/README.md | 69 +++ k8s-status/launcher.luau | 791 ++++++++++++++++++++++++++ k8s-status/panel.luau | 732 ++++++++++++++++++++++++ k8s-status/plugin.toml | 120 ++++ k8s-status/service.luau | 962 ++++++++++++++++++++++++++++++++ k8s-status/thumbnail.webp | Bin 0 -> 60436 bytes k8s-status/translations/en.json | 173 ++++++ k8s-status/widget.luau | 107 ++++ 8 files changed, 2954 insertions(+) create mode 100644 k8s-status/README.md create mode 100644 k8s-status/launcher.luau create mode 100644 k8s-status/panel.luau create mode 100644 k8s-status/plugin.toml create mode 100644 k8s-status/service.luau create mode 100644 k8s-status/thumbnail.webp create mode 100644 k8s-status/translations/en.json create mode 100644 k8s-status/widget.luau diff --git a/k8s-status/README.md b/k8s-status/README.md new file mode 100644 index 0000000..d0f51c7 --- /dev/null +++ b/k8s-status/README.md @@ -0,0 +1,69 @@ +# K8s Status + +Monitor Kubernetes nodes, pods, and deployments from Noctalia, with a `/kube` launcher for common actions. + +## Plugin + +| Field | Value | +| --- | --- | +| ID | `davemhammer/k8s-status` | +| Entries | Bar widget: `status`; panel: `manager`; service: `service`; launcher: `kube` | +| Launcher Prefix | `/kube` | + +## Requirements + +Install these on `PATH` (declared in `plugin.toml` `dependencies`): + +- `kubectl` — cluster queries and actions (or set `kubectl_bin`) +- `less` — pager for describe/logs in the terminal + +Optional (not declared; used only if present): + +- `k9s` — open-k9s action + +Cluster access uses your kubeconfig (passed to kubectl as `--kubeconfig`; the plugin does not parse the file itself). + +## Usage + +Add the **status** bar widget (`davemhammer/k8s-status:status`). Click for the panel; right-click requests a refresh. + +Panel tabs: **Nodes**, **Pods**, **Deployments**, **Namespaces**. Select a row for describe / logs / shell / restart actions (where applicable). + +Launcher examples: + +- `/kube` — categories (pods, problems, nodes, …) +- `/kube pods nginx` — filter pods +- `/kube problems` — problem pods only + +```sh +noctalia msg panel-toggle davemhammer/k8s-status:manager +``` + +## Settings + +| Setting | Type | Default | Description | +| --- | --- | --- | --- | +| `kubeconfig` | `file` | `~/.kube/config` | Path passed to kubectl as `--kubeconfig`. | +| `context` | `string` | _(empty)_ | Context name; empty uses current-context. | +| `namespace` | `string` | _(empty)_ | Limit pods/deployments; empty = all namespaces. | +| `refresh_interval` | `int` | `15` | Poll interval in seconds. | +| `problems_only` | `bool` | `false` | Prefer problem pods in the panel list. | +| `notify_on_not_ready` | `bool` | `true` | Notify when a node becomes NotReady. | +| `kubectl_bin` | `string` | `kubectl` | kubectl command or absolute path. | +| `show_counts` | `bool` (widget) | `true` | Show ready nodes / problem pods on the bar. | +| `ok_color` | `select` (widget) | `tertiary` | Bar color when cluster looks healthy. | +| `warn_color` | `select` (widget) | `error` | Bar color when there are problems. | + +## IPC + +```sh +noctalia msg panel-toggle davemhammer/k8s-status:manager +noctalia msg plugin davemhammer/k8s-status:service all refresh +``` + +## Notes + +- Shells out to `kubectl` and `less` (and optionally a terminal via `runInTerminal` for logs/shell/`k9s`). +- Refresh: nodes, deployments, and namespaces use compact **jsonpath** queries; pods use `kubectl get pods` table output for ready/restart columns. +- Does not modify cluster state unless you run restart/delete-style actions from the panel or launcher. +- Network: only via `kubectl` to the API server from your kubeconfig. No cluster credentials are written into the plugin tree. diff --git a/k8s-status/launcher.luau b/k8s-status/launcher.luau new file mode 100644 index 0000000..579508f --- /dev/null +++ b/k8s-status/launcher.luau @@ -0,0 +1,791 @@ +--!nonstrict +-- /kube launcher: drill into pods/nodes/deploys with autocomplete + actions. +-- +-- /kube categories +-- /kube pods [filter] list pods +-- /kube pods ns/name actions (logs, shell, …) +-- /kube problems problem pods only +-- /kube nodes|deploys|ns same pattern +-- /kube panel|k9s|status|refresh + +local STATE_KEY = "k8s_snapshot" +local COMMAND_KEY = "k8s_command" +local PANEL_ID = "davemhammer/k8s-status:manager" +local MAX_ROWS = 40 + +local snapshot = noctalia.state.get(STATE_KEY) or { + available = false, + loading = true, + pods = {}, + nodes = {}, + deployments = {}, + namespaces = {}, + readyNodes = 0, + nodeCount = 0, + problemPods = 0, + podCount = 0, + context = "", + error = "", +} + +noctalia.state.watch(STATE_KEY, function(value) + if type(value) == "table" then + snapshot = value + end +end) + +local function trim(s) + return noctalia.string.trim(tostring(s or "")) +end + +local function lower(s) + return string.lower(tostring(s or "")) +end + +local function send(action, values) + local command = { action = action, requestId = "launcher-" .. tostring(os.time()) } + if type(values) == "table" then + for k, v in pairs(values) do + command[k] = v + end + end + noctalia.state.set(COMMAND_KEY, command) +end + +local function scoreText(filter, ...) + if filter == "" then + return 1 + end + local best = nil + for i = 1, select("#", ...) do + local text = tostring(select(i, ...) or "") + if text ~= "" then + local s = noctalia.fuzzyScore(filter, text) + if s ~= nil and (best == nil or s > best) then + best = s + end + -- also plain substring + if best == nil and lower(text):find(lower(filter), 1, true) then + best = 0.5 + end + end + end + return best +end + +local function statusRow(title, subtitle, glyph) + return { + id = "", + title = title, + subtitle = subtitle, + glyph = glyph or "hexagon", + } +end + +local function ensureSnapshot() + if snapshot.loading or (not snapshot.available and (snapshot.podCount or 0) == 0) then + send("refresh") + end +end + +local function topCategories() + local ctx = snapshot.context ~= "" and snapshot.context or "—" + local summary = `{snapshot.readyNodes or 0}/{snapshot.nodeCount or 0} nodes · {snapshot.problemPods or 0} problems · {snapshot.podCount or 0} pods` + return { + { + id = "cat:pods", + title = noctalia.tr("launcher.cat.pods"), + subtitle = noctalia.tr("launcher.cat.pods-sub"), + glyph = "box", + score = 100, + }, + { + id = "cat:problems", + title = noctalia.tr("launcher.cat.problems"), + subtitle = noctalia.tr("launcher.cat.problems-sub") .. " · " .. tostring(snapshot.problemPods or 0), + glyph = "circle-x", + score = 95, + }, + { + id = "cat:nodes", + title = noctalia.tr("launcher.cat.nodes"), + subtitle = noctalia.tr("launcher.cat.nodes-sub"), + glyph = "server", + score = 90, + }, + { + id = "cat:deploys", + title = noctalia.tr("launcher.cat.deploys"), + subtitle = noctalia.tr("launcher.cat.deploys-sub"), + glyph = "packages", + score = 85, + }, + { + id = "cat:ns", + title = noctalia.tr("launcher.cat.ns"), + subtitle = noctalia.tr("launcher.cat.ns-sub"), + glyph = "folder", + score = 80, + }, + { + id = "act:status", + title = noctalia.tr("launcher.cat.status"), + subtitle = summary .. " · " .. ctx, + glyph = "info-circle", + score = 70, + }, + { + id = "act:panel", + title = noctalia.tr("launcher.cat.panel"), + subtitle = noctalia.tr("launcher.cat.panel-sub"), + glyph = "layout-dashboard", + score = 60, + }, + { + id = "act:k9s", + title = noctalia.tr("launcher.cat.k9s"), + subtitle = noctalia.tr("launcher.cat.k9s-sub"), + glyph = "terminal-2", + score = 50, + }, + { + id = "act:refresh", + title = noctalia.tr("launcher.cat.refresh"), + subtitle = noctalia.tr("launcher.cat.refresh-sub"), + glyph = "refresh", + score = 40, + }, + } +end + +local function podActions(ns, name) + local ref = ns .. "/" .. name + return { + { + id = "podact:logs:" .. ref, + title = noctalia.tr("launcher.action.logs"), + subtitle = ref .. " · " .. noctalia.tr("launcher.action.logs-sub"), + glyph = "file-text", + score = 100, + }, + { + id = "podact:shell:" .. ref, + title = noctalia.tr("launcher.action.shell"), + subtitle = ref .. " · " .. noctalia.tr("launcher.action.shell-sub"), + glyph = "terminal", + score = 95, + }, + { + id = "podact:describe:" .. ref, + title = noctalia.tr("launcher.action.describe"), + subtitle = noctalia.tr("launcher.action.describe-sub"), + glyph = "file-description", + score = 90, + }, + { + id = "podact:yaml:" .. ref, + title = noctalia.tr("launcher.action.yaml"), + subtitle = noctalia.tr("launcher.action.yaml-sub"), + glyph = "code", + score = 85, + }, + { + id = "podact:portforward:" .. ref, + title = noctalia.tr("launcher.action.portforward"), + subtitle = noctalia.tr("launcher.action.portforward-sub"), + glyph = "arrows-exchange", + score = 80, + }, + { + id = "podact:copy:" .. ref, + title = noctalia.tr("launcher.action.copy"), + subtitle = noctalia.tr("launcher.action.copy-sub"), + glyph = "copy", + score = 75, + }, + { + id = "podact:delete:" .. ref, + title = noctalia.tr("launcher.action.delete"), + subtitle = noctalia.tr("launcher.action.delete-sub"), + glyph = "trash", + score = 10, + }, + } +end + +local function nodeActions(name) + return { + { + id = "nodeact:describe:" .. name, + title = noctalia.tr("launcher.action.describe"), + subtitle = name, + glyph = "file-description", + score = 100, + }, + { + id = "nodeact:yaml:" .. name, + title = noctalia.tr("launcher.action.yaml"), + subtitle = name, + glyph = "code", + score = 90, + }, + { + id = "nodeact:copy:" .. name, + title = noctalia.tr("launcher.action.copy"), + subtitle = name, + glyph = "copy", + score = 80, + }, + } +end + +local function deployActions(ns, name) + local ref = ns .. "/" .. name + return { + { + id = "depact:restart:" .. ref, + title = noctalia.tr("launcher.action.restart"), + subtitle = ref .. " · " .. noctalia.tr("launcher.action.restart-sub"), + glyph = "refresh", + score = 100, + }, + { + id = "depact:describe:" .. ref, + title = noctalia.tr("launcher.action.describe"), + subtitle = ref, + glyph = "file-description", + score = 90, + }, + { + id = "depact:yaml:" .. ref, + title = noctalia.tr("launcher.action.yaml"), + subtitle = ref, + glyph = "code", + score = 85, + }, + { + id = "depact:copy:" .. ref, + title = noctalia.tr("launcher.action.copy"), + subtitle = ref, + glyph = "copy", + score = 80, + }, + } +end + +local function filterActions(actions, filter) + if filter == "" then + return actions + end + local out = {} + for _, row in ipairs(actions) do + local s = scoreText(filter, row.title, row.id) + if s ~= nil then + row.score = s + table.insert(out, row) + end + end + return out +end + +local function listPods(filter, problemsOnly) + local rows = {} + for _, p in ipairs(snapshot.pods or {}) do + if problemsOnly and not p.problem then + -- skip + else + local ref = p.namespace .. "/" .. p.name + local s = scoreText(filter, p.name, p.namespace, ref, p.status, p.node) + if s ~= nil then + table.insert(rows, { + id = "pod:" .. ref, + title = ref, + subtitle = `{p.ready} · {p.status} · r{p.restarts}` .. (p.node ~= "" and (" · " .. p.node) or ""), + glyph = p.problem and "circle-x" or "box", + score = s + (p.problem and 5 or 0), + }) + end + end + end + table.sort(rows, function(a, b) + if (a.score or 0) == (b.score or 0) then + return a.title < b.title + end + return (a.score or 0) > (b.score or 0) + end) + while #rows > MAX_ROWS do + table.remove(rows) + end + if #rows == 0 then + rows[1] = statusRow(noctalia.tr("launcher.no-matches"), filter, "search") + end + return rows +end + +local function listNodes(filter) + local rows = {} + for _, n in ipairs(snapshot.nodes or {}) do + local s = scoreText(filter, n.name, n.status, n.version, n.ip) + if s ~= nil then + table.insert(rows, { + id = "node:" .. n.name, + title = n.name, + subtitle = `{n.status} · {n.version} · {n.ip}`, + glyph = "server", + score = s, + }) + end + end + table.sort(rows, function(a, b) + return (a.score or 0) > (b.score or 0) + end) + if #rows == 0 then + rows[1] = statusRow(noctalia.tr("launcher.no-matches"), filter, "search") + end + return rows +end + +local function listDeploys(filter) + local rows = {} + for _, d in ipairs(snapshot.deployments or {}) do + local ref = d.namespace .. "/" .. d.name + local s = scoreText(filter, d.name, d.namespace, ref) + if s ~= nil then + table.insert(rows, { + id = "deploy:" .. ref, + title = ref, + subtitle = `{d.ready}/{d.desired} ready`, + glyph = "packages", + score = s + (d.problem and 5 or 0), + }) + end + end + table.sort(rows, function(a, b) + return (a.score or 0) > (b.score or 0) + end) + while #rows > MAX_ROWS do + table.remove(rows) + end + if #rows == 0 then + rows[1] = statusRow(noctalia.tr("launcher.no-matches"), filter, "search") + end + return rows +end + +local function listNamespaces(filter) + local rows = {} + for _, n in ipairs(snapshot.namespaces or {}) do + local s = scoreText(filter, n.name, n.phase) + if s ~= nil then + table.insert(rows, { + id = "ns:" .. n.name, + title = n.name, + subtitle = n.phase, + glyph = "folder", + score = s, + }) + end + end + table.sort(rows, function(a, b) + return (a.score or 0) > (b.score or 0) + end) + if #rows == 0 then + rows[1] = statusRow(noctalia.tr("launcher.no-matches"), filter, "search") + end + return rows +end + +local function findPod(ref) + ref = trim(ref) + for _, p in ipairs(snapshot.pods or {}) do + local id = p.namespace .. "/" .. p.name + if id == ref or p.name == ref then + return p + end + end + -- partial unique match + local hits = {} + local q = lower(ref) + for _, p in ipairs(snapshot.pods or {}) do + local id = p.namespace .. "/" .. p.name + if lower(id):find(q, 1, true) or lower(p.name):find(q, 1, true) then + table.insert(hits, p) + end + end + if #hits == 1 then + return hits[1] + end + return nil +end + +local function findDeploy(ref) + ref = trim(ref) + for _, d in ipairs(snapshot.deployments or {}) do + local id = d.namespace .. "/" .. d.name + if id == ref or d.name == ref then + return d + end + end + local hits = {} + local q = lower(ref) + for _, d in ipairs(snapshot.deployments or {}) do + local id = d.namespace .. "/" .. d.name + if lower(id):find(q, 1, true) or lower(d.name):find(q, 1, true) then + table.insert(hits, d) + end + end + if #hits == 1 then + return hits[1] + end + return nil +end + +local function findNode(name) + name = trim(name) + for _, n in ipairs(snapshot.nodes or {}) do + if n.name == name then + return n + end + end + local hits = {} + local q = lower(name) + for _, n in ipairs(snapshot.nodes or {}) do + if lower(n.name):find(q, 1, true) then + table.insert(hits, n) + end + end + if #hits == 1 then + return hits[1] + end + return nil +end + +local POD_ACTION_WORDS = { + logs = true, + log = true, + shell = true, + sh = true, + exec = true, + describe = true, + desc = true, + yaml = true, + yml = true, + pf = true, + portforward = true, + forward = true, + copy = true, + delete = true, + del = true, + rm = true, +} + +local function normalizePodAction(word) + word = lower(word) + if word == "log" then return "logs" end + if word == "sh" or word == "exec" then return "shell" end + if word == "desc" then return "describe" end + if word == "yml" then return "yaml" end + if word == "pf" or word == "forward" then return "portforward" end + if word == "del" or word == "rm" then return "delete" end + if POD_ACTION_WORDS[word] then + return word + end + return nil +end + +local function runPodAction(action, ns, name) + if action == "logs" then + send("logs", { namespace = ns, name = name }) + elseif action == "shell" then + send("shell", { namespace = ns, name = name }) + elseif action == "describe" then + send("describe", { kind = "pod", namespace = ns, name = name }) + elseif action == "yaml" then + send("yaml", { kind = "pod", namespace = ns, name = name }) + elseif action == "portforward" then + send("port_forward", { namespace = ns, name = name, ports = "8080:8080" }) + elseif action == "copy" then + send("copy_name", { namespace = ns, name = name }) + elseif action == "delete" then + send("delete_pod", { namespace = ns, name = name }) + end +end + +-- tokens after /kube +local function parseTokens(text) + local tokens = {} + for t in trim(text):gmatch("%S+") do + table.insert(tokens, t) + end + return tokens +end + +function onQuery(query) + ensureSnapshot() + + if not snapshot.available and snapshot.loading then + launcher.setResults(query, { + statusRow(noctalia.tr("launcher.loading"), snapshot.context, "loader"), + }) + return + end + + if not snapshot.available then + launcher.setResults(query, { + statusRow(noctalia.tr("launcher.unavailable"), snapshot.error or "", "cloud-off"), + { + id = "act:refresh", + title = noctalia.tr("launcher.cat.refresh"), + subtitle = noctalia.tr("launcher.cat.refresh-sub"), + glyph = "refresh", + }, + }) + return + end + + local text = trim(query) + if text == "" then + launcher.setResults(query, topCategories()) + return + end + + local tokens = parseTokens(text) + local head = lower(tokens[1] or "") + + -- fuzzy match top categories if first token is incomplete + local function isKind(name, aliases) + if head == name then + return true + end + for _, a in ipairs(aliases) do + if head == a then + return true + end + end + return false + end + + if isKind("pods", { "pod", "po", "p" }) or isKind("problems", { "problem", "bad", "fail" }) then + local problemsOnly = isKind("problems", { "problem", "bad", "fail" }) + local rest = {} + for i = 2, #tokens do + table.insert(rest, tokens[i]) + end + + -- /kube pods ns/name action + if #rest >= 1 then + local maybeAction = normalizePodAction(rest[#rest]) + local refParts = {} + local endIdx = #rest + if maybeAction and #rest >= 2 then + endIdx = #rest - 1 + else + maybeAction = nil + end + for i = 1, endIdx do + table.insert(refParts, rest[i]) + end + local ref = table.concat(refParts, " ") + + local pod = findPod(ref) + if pod and maybeAction then + -- show only matching action (or run via activate of filtered list) + local actions = filterActions(podActions(pod.namespace, pod.name), maybeAction) + if #actions == 0 then + actions = podActions(pod.namespace, pod.name) + end + launcher.setResults(query, actions) + return + end + + if pod and not maybeAction and (#rest >= 1) then + -- exact/unique pod selected via typing full ref — show actions + -- if filter still ambiguous, list pods + local exact = false + local full = pod.namespace .. "/" .. pod.name + if lower(ref) == lower(full) or lower(ref) == lower(pod.name) then + exact = true + end + -- if only one fuzzy hit for ref, treat as drill-in when user typed a slash ref + if exact or ref:find("/", 1, true) then + launcher.setResults(query, podActions(pod.namespace, pod.name)) + return + end + end + end + + local filter = table.concat(rest, " ") + launcher.setResults(query, listPods(filter, problemsOnly)) + return + end + + if isKind("nodes", { "node", "no" }) then + local rest = table.concat(tokens, " ", 2) + local node = findNode(rest) + if node and (lower(rest) == lower(node.name) or rest:find(node.name, 1, true)) and rest ~= "" then + -- if unique and reasonably exact, show actions + if lower(rest) == lower(node.name) then + launcher.setResults(query, nodeActions(node.name)) + return + end + end + launcher.setResults(query, listNodes(rest)) + return + end + + if isKind("deploys", { "deploy", "deployment", "deployments", "dep", "d" }) then + local rest = table.concat(tokens, " ", 2) + local dep = findDeploy(rest) + if dep and rest:find("/", 1, true) then + launcher.setResults(query, deployActions(dep.namespace, dep.name)) + return + end + if dep and lower(rest) == lower(dep.name) then + launcher.setResults(query, deployActions(dep.namespace, dep.name)) + return + end + launcher.setResults(query, listDeploys(rest)) + return + end + + if isKind("ns", { "namespace", "namespaces", "n" }) then + local rest = table.concat(tokens, " ", 2) + launcher.setResults(query, listNamespaces(rest)) + return + end + + -- fallback: filter top categories + quick pod search + local rows = {} + for _, row in ipairs(topCategories()) do + local s = scoreText(text, row.title, row.id) + if s ~= nil then + row.score = s + table.insert(rows, row) + end + end + -- also inject matching pods for convenience + for _, p in ipairs(listPods(text, false)) do + if p.id ~= "" then + table.insert(rows, p) + end + end + if #rows == 0 then + rows[1] = statusRow(noctalia.tr("launcher.no-matches"), text, "search") + end + launcher.setResults(query, rows) +end + +function onActivate(id) + if id == nil or id == "" then + return + end + + if id == "cat:pods" then + launcher.setQuery("pods ") + return + end + if id == "cat:problems" then + launcher.setQuery("problems ") + return + end + if id == "cat:nodes" then + launcher.setQuery("nodes ") + return + end + if id == "cat:deploys" then + launcher.setQuery("deploys ") + return + end + if id == "cat:ns" then + launcher.setQuery("ns ") + return + end + + if id == "act:panel" then + noctalia.togglePanel(PANEL_ID) + return + end + if id == "act:k9s" then + send("k9s", {}) + return + end + if id == "act:refresh" then + send("refresh") + noctalia.notify(noctalia.tr("title"), noctalia.tr("widget.refresh_requested")) + return + end + if id == "act:status" then + local body = noctalia.tr("panel.summary", { + ready = snapshot.readyNodes or 0, + nodes = snapshot.nodeCount or 0, + problems = snapshot.problemPods or 0, + pods = snapshot.podCount or 0, + }) + local ctx = snapshot.context ~= "" and snapshot.context or "default" + noctalia.notify(noctalia.tr("title") .. " · " .. ctx, body) + return + end + + local podRef = id:match("^pod:(.+)$") + if podRef then + launcher.setQuery("pods " .. podRef .. " ") + return + end + + local nodeName = id:match("^node:(.+)$") + if nodeName then + launcher.setQuery("nodes " .. nodeName .. " ") + return + end + + local deployRef = id:match("^deploy:(.+)$") + if deployRef then + launcher.setQuery("deploys " .. deployRef .. " ") + return + end + + local nsName = id:match("^ns:(.+)$") + if nsName then + noctalia.copyToClipboard(nsName, "text/plain") + noctalia.notify(noctalia.tr("title"), noctalia.tr("result.copied", { name = nsName })) + return + end + + local podAct, pref = id:match("^podact:([%w]+):(.+)$") + if podAct and pref then + local ns, name = pref:match("^([^/]+)/(.+)$") + if ns and name then + runPodAction(podAct, ns, name) + end + return + end + + local nodeAct, nname = id:match("^nodeact:([%w]+):(.+)$") + if nodeAct and nname then + if nodeAct == "describe" then + send("describe", { kind = "node", name = nname }) + elseif nodeAct == "yaml" then + send("yaml", { kind = "node", name = nname }) + elseif nodeAct == "copy" then + send("copy_name", { name = nname }) + end + return + end + + local depAct, dref = id:match("^depact:([%w]+):(.+)$") + if depAct and dref then + local ns, name = dref:match("^([^/]+)/(.+)$") + if ns and name then + if depAct == "restart" then + send("restart_deploy", { namespace = ns, name = name }) + elseif depAct == "describe" then + send("describe", { kind = "deploy", namespace = ns, name = name }) + elseif depAct == "yaml" then + send("yaml", { kind = "deploy", namespace = ns, name = name }) + elseif depAct == "copy" then + send("copy_name", { namespace = ns, name = name }) + end + end + return + end +end diff --git a/k8s-status/panel.luau b/k8s-status/panel.luau new file mode 100644 index 0000000..8d76adf --- /dev/null +++ b/k8s-status/panel.luau @@ -0,0 +1,732 @@ +--!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 diff --git a/k8s-status/plugin.toml b/k8s-status/plugin.toml new file mode 100644 index 0000000..7b5e64a --- /dev/null +++ b/k8s-status/plugin.toml @@ -0,0 +1,120 @@ +# Kubernetes cluster status: nodes, pods, deployments. + +id = "davemhammer/k8s-status" +name = "K8s Status" +version = "1.1.5" +plugin_api = 10 +author = "davemhammer" +license = "MIT" +dependencies = ["kubectl", "less"] +tags = ["system", "development", "utility", "bar", "panel", "service", "launcher"] +icon = "hexagon" +description = "Monitor Kubernetes nodes, pods, and deployments; panel and /kube launcher." + +[[setting]] +key = "kubeconfig" +type = "file" +label_key = "settings.kubeconfig.label" +description_key = "settings.kubeconfig.description" +default = "~/.kube/config" + +[[setting]] +key = "context" +type = "string" +label_key = "settings.context.label" +description_key = "settings.context.description" +default = "" + +[[setting]] +key = "namespace" +type = "string" +label_key = "settings.namespace.label" +description_key = "settings.namespace.description" +default = "" + +[[setting]] +key = "refresh_interval" +type = "int" +label_key = "settings.refresh_interval.label" +description_key = "settings.refresh_interval.description" +default = 15 +min = 5 +max = 120 + +[[setting]] +key = "problems_only" +type = "bool" +label_key = "settings.problems_only.label" +description_key = "settings.problems_only.description" +default = false + +[[setting]] +key = "notify_on_not_ready" +type = "bool" +label_key = "settings.notify_on_not_ready.label" +description_key = "settings.notify_on_not_ready.description" +default = true + +[[setting]] +key = "kubectl_bin" +type = "string" +label_key = "settings.kubectl_bin.label" +description_key = "settings.kubectl_bin.description" +default = "kubectl" +advanced = true + +[[widget]] +id = "status" +entry = "widget.luau" + + [[widget.setting]] + key = "show_counts" + type = "bool" + label_key = "settings.show_counts.label" + description_key = "settings.show_counts.description" + default = true + + [[widget.setting]] + key = "ok_color" + type = "select" + label_key = "settings.ok_color.label" + default = "tertiary" + options = [ + { value = "tertiary", label_key = "colors.tertiary" }, + { value = "primary", label_key = "colors.primary" }, + { value = "secondary", label_key = "colors.secondary" } + ] + + [[widget.setting]] + key = "warn_color" + type = "select" + label_key = "settings.warn_color.label" + default = "error" + options = [ + { value = "error", label_key = "colors.error" }, + { value = "primary", label_key = "colors.primary" }, + { value = "on_surface_variant", label_key = "colors.muted" } + ] + +[[panel]] +id = "manager" +entry = "panel.luau" +width = 720 +height = 640 +placement = "floating" +position = "center" +open_near_click = true +keyboard_focus = "exclusive" +dismiss_on_outside_click = true + +[[service]] +id = "service" +entry = "service.luau" + +[[launcher_provider]] +id = "kube" +entry = "launcher.luau" +prefix = "kube" +glyph = "hexagon" +include_in_global_search = false +debounce_ms = 80 diff --git a/k8s-status/service.luau b/k8s-status/service.luau new file mode 100644 index 0000000..0ad7e6a --- /dev/null +++ b/k8s-status/service.luau @@ -0,0 +1,962 @@ +--!nonstrict +-- Kubernetes status backend: nodes, pods, deployments via kubectl. + +local STATE_KEY = "k8s_snapshot" +local COMMAND_KEY = "k8s_command" +local RESULT_KEY = "k8s_action_result" + +local snapshot = { + available = false, + loading = true, + busy = false, + context = "", + nodes = {}, + pods = {}, + deployments = {}, + namespaces = {}, + readyNodes = 0, + nodeCount = 0, + problemPods = 0, + podCount = 0, + deployCount = 0, + error = "", + updatedAt = 0, + revision = 0, +} + +local refreshGeneration = 0 +local refreshPending = false +local refreshAgain = false +local refreshStartedAt = 0 +local actionBusy = false +local dataSignature = "" +local prevNotReady = {} -- name -> true +local STUCK_REFRESH_SEC = 90 + +local function trim(value) + return noctalia.string.trim(tostring(value or "")) +end + +local function shellQuote(value) + return "'" .. tostring(value):gsub("'", "'\\''") .. "'" +end + +local function shellCommand(args) + local quoted = {} + for _, value in ipairs(args) do + table.insert(quoted, shellQuote(value)) + end + return table.concat(quoted, " ") +end + +local function expand(path) + return noctalia.expandPath(trim(path)) +end + +local function kubectlBin() + local bin = trim(noctalia.getConfig("kubectl_bin")) + if bin == "" then + return "kubectl" + end + return bin +end + +local function refreshIntervalMs() + local seconds = tonumber(noctalia.getConfig("refresh_interval")) or 15 + seconds = math.max(5, math.min(120, math.floor(seconds))) + return seconds * 1000 +end + +local function baseKubectlArgs() + local args = { kubectlBin() } + local kubeconfig = trim(noctalia.getConfig("kubeconfig")) + if kubeconfig ~= "" then + table.insert(args, "--kubeconfig") + table.insert(args, expand(kubeconfig)) + end + local context = trim(noctalia.getConfig("context")) + if context ~= "" then + table.insert(args, "--context") + table.insert(args, context) + end + return args +end + +local function nsArgs(args) + local ns = trim(noctalia.getConfig("namespace")) + if ns ~= "" then + table.insert(args, "-n") + table.insert(args, ns) + else + table.insert(args, "-A") + end + return args +end + +local function nowSec() + if type(noctalia.nowMs) == "function" then + local ms = noctalia.nowMs() + if type(ms) == "number" and ms > 0 then + return math.floor(ms / 1000) + end + end + return os.time() +end + +local function runKubectl(args, callback, timeoutMs) + local cmd = shellCommand(args) + local started = noctalia.runAsync(cmd, function(result) + if type(callback) == "function" then + local ok, err = pcall(callback, result) + if not ok then + noctalia.log(`k8s-status: kubectl callback failed: {tostring(err)}`) + end + end + end, timeoutMs or 45000) + if not started and type(callback) == "function" then + -- Ensure callers always get a callback so refresh cannot hang forever. + callback({ + exitCode = -1, + stdout = "", + stderr = "could not start kubectl", + timedOut = false, + }) + end + return started +end + +local function updateRevision(signature) + if signature ~= dataSignature then + dataSignature = signature + snapshot.revision += 1 + end +end + +local function publishSnapshot() + snapshot.busy = actionBusy + noctalia.state.set(STATE_KEY, snapshot) +end + +local function actionResult(command, ok, message, extra) + local result = { + requestId = command and command.requestId or "", + action = command and command.action or "", + ok = ok, + message = message or "", + } + if type(extra) == "table" then + for key, value in pairs(extra) do + result[key] = value + end + end + noctalia.state.set(RESULT_KEY, result) +end + +local function notifyOk(message) + noctalia.notify(noctalia.tr("title"), message) +end + +local function notifyErr(message) + noctalia.notifyError(noctalia.tr("title"), message) +end + +local function isProblemPhase(phase) + phase = tostring(phase or "") + if phase == "Running" or phase == "Succeeded" or phase == "Completed" then + return false + end + return true +end + +local function parseReady(ready) + -- "1/1" or "0/1" + local have, want = tostring(ready or ""):match("^(%d+)/(%d+)$") + if have and want then + return tonumber(have) or 0, tonumber(want) or 0 + end + return 0, 0 +end + +-- jsonpath with tab separators (reliable under shell quoting) +-- Use plain strings so shell quoting of jsonpath=... stays simple. +local NODE_JSONPATH = '{range .items[*]}{.metadata.name}{"|"}{range .status.conditions[?(@.type=="Ready")]}{.status}{end}{"|"}{.status.nodeInfo.kubeletVersion}{"|"}{range .status.addresses[?(@.type=="InternalIP")]}{.address}{end}{"\\n"}{end}' + +local DEPLOY_JSONPATH = '{range .items[*]}{.metadata.namespace}{"|"}{.metadata.name}{"|"}{.status.readyReplicas}{"|"}{.status.replicas}{"|"}{.status.availableReplicas}{"\\n"}{end}' + +local NS_JSONPATH = '{range .items[*]}{.metadata.name}{"|"}{.status.phase}{"\\n"}{end}' + +local function splitLines(text) + local lines = {} + for line in (tostring(text or "") .. "\n"):gmatch("(.-)\n") do + line = trim(line) + if line ~= "" then + table.insert(lines, line) + end + end + return lines +end + +local function splitFields(line) + local parts = {} + -- prefer pipe separators from jsonpath; fall back to tabs + local sep = line:find("|", 1, true) and "|" or "\t" + for part in (line .. sep):gmatch("(.-)" .. (sep == "|" and "|" or "\t")) do + table.insert(parts, part) + end + return parts +end + +local function parseNodes(stdout) + local nodes = {} + for _, line in ipairs(splitLines(stdout)) do + local parts = splitFields(line) + local name = parts[1] or "" + local readyRaw = parts[2] or "" + local version = parts[3] or "" + local ip = parts[4] or "" + local ready = readyRaw == "True" + if name ~= "" then + table.insert(nodes, { + id = name, + name = name, + ready = ready, + status = ready and "Ready" or "NotReady", + version = version, + ip = ip, + roles = "", + }) + end + end + table.sort(nodes, function(a, b) + return a.name < b.name + end) + return nodes +end + +local function parsePodsWide(stdout) + -- fallback parser for: kubectl get pods -A --no-headers + -- NS NAME READY STATUS RESTARTS AGE [NODE ...] + local pods = {} + for _, line in ipairs(splitLines(stdout)) do + local fields = {} + for f in line:gmatch("%S+") do + table.insert(fields, f) + end + if #fields >= 5 then + local ns = fields[1] + local name = fields[2] + local ready = fields[3] + local status = fields[4] + local restarts = fields[5] + -- if restarts looks like "(", merge + local idx = 6 + if restarts:match("^%d+$") and fields[6] and fields[6]:match("^%(") then + -- skip age tokens that are part of restart age? actually format is: + -- restarts can be "0" or "6" then "(20d" "ago)" then AGE + -- get pods: RESTARTS is one column "6 (20d ago)" in wide? default is single token or with paren + while fields[idx] and not fields[idx]:match("^%d+[dhms]") and fields[idx] ~= "" and idx < #fields do + if fields[idx]:match("^%(") or fields[idx]:match("ago%)") or fields[idx] == "ago)" then + idx += 1 + else + break + end + end + end + -- after restarts comes AGE then optional NODE for -o wide + local age = fields[idx] or "" + local node = fields[idx + 1] or "" + local have, want = parseReady(ready) + local restartsNum = tonumber((restarts:match("^(%d+)"))) or 0 + local problem = isProblemPhase(status) or (want > 0 and have < want) or restartsNum > 5 + table.insert(pods, { + id = ns .. "/" .. name, + namespace = ns, + name = name, + ready = ready, + readyHave = have, + readyWant = want, + status = status, + restarts = restartsNum, + age = age, + node = node, + problem = problem, + }) + end + end + return pods +end + +local function parsePodsSimple(stdout) + local pods = {} + for _, line in ipairs(splitLines(stdout)) do + local parts = splitFields(line) + local ns = parts[1] or "" + local name = parts[2] or "" + local phase = parts[3] or "" + local node = parts[4] or "" + if ns ~= "" and name ~= "" then + local problem = isProblemPhase(phase) + table.insert(pods, { + id = ns .. "/" .. name, + namespace = ns, + name = name, + ready = problem and "0/?" or "?/?", + readyHave = 0, + readyWant = 0, + status = phase, + restarts = 0, + age = "", + node = node, + problem = problem, + }) + end + end + return pods +end + +local function parseDeploys(stdout) + local items = {} + for _, line in ipairs(splitLines(stdout)) do + local parts = splitFields(line) + local ns = parts[1] or "" + local name = parts[2] or "" + -- jsonpath may emit empty fields for nil replicas + local ready = tonumber(parts[3]) or 0 + local desired = tonumber(parts[4]) or 0 + local available = tonumber(parts[5]) or 0 + if ns ~= "" and name ~= "" then + local problem = desired > 0 and ready < desired + table.insert(items, { + id = ns .. "/" .. name, + namespace = ns, + name = name, + ready = ready, + desired = desired, + available = available, + problem = problem, + }) + end + end + table.sort(items, function(a, b) + if a.problem ~= b.problem then + return a.problem + end + if a.namespace == b.namespace then + return a.name < b.name + end + return a.namespace < b.namespace + end) + return items +end + +local function parseNamespaces(stdout) + local items = {} + for _, line in ipairs(splitLines(stdout)) do + local parts = splitFields(line) + local name = parts[1] or "" + local phase = parts[2] or "" + if name ~= "" then + table.insert(items, { id = name, name = name, phase = phase }) + end + end + table.sort(items, function(a, b) + return a.name < b.name + end) + return items +end + +local function checkNotReadyNotifications(nodes) + if noctalia.getConfig("notify_on_not_ready") == false then + return + end + local current = {} + for _, node in ipairs(nodes) do + if not node.ready then + current[node.name] = true + if not prevNotReady[node.name] then + notifyErr(noctalia.tr("result.node_not_ready", { name = node.name })) + end + end + end + prevNotReady = current +end + +local refreshAll + +local function forceUnstick(reason) + noctalia.log(`k8s-status: {reason}`) + refreshPending = false + refreshStartedAt = 0 + snapshot.loading = false + if snapshot.error == "" and not snapshot.available then + snapshot.error = reason + end + publishSnapshot() +end + +refreshAll = function() + -- Recover from hung refreshes (callback never fired / stuck pending). + if refreshPending and refreshStartedAt > 0 and (nowSec() - refreshStartedAt) >= STUCK_REFRESH_SEC then + forceUnstick("refresh timed out") + end + if refreshPending then + refreshAgain = true + return + end + refreshPending = true + refreshAgain = false + refreshStartedAt = nowSec() + refreshGeneration += 1 + local generation = refreshGeneration + + if not noctalia.commandExists(kubectlBin()) then + snapshot.available = false + snapshot.loading = false + snapshot.error = noctalia.tr("result.kubectl_missing") + snapshot.nodes = {} + snapshot.pods = {} + snapshot.deployments = {} + snapshot.namespaces = {} + snapshot.readyNodes = 0 + snapshot.nodeCount = 0 + snapshot.problemPods = 0 + snapshot.podCount = 0 + snapshot.deployCount = 0 + refreshPending = false + refreshStartedAt = 0 + updateRevision("kubectl-missing") + publishSnapshot() + return + end + + -- Only show "Querying cluster…" when we have never loaded data. + if not snapshot.available then + snapshot.loading = true + publishSnapshot() + end + + local function doneRefresh() + if generation ~= refreshGeneration then + return + end + refreshPending = false + refreshStartedAt = 0 + if refreshAgain then + refreshAgain = false + refreshAll() + end + end + + local function fail(err) + if generation ~= refreshGeneration then + return + end + snapshot.available = false + snapshot.loading = false + snapshot.error = trim(err) ~= "" and trim(err) or noctalia.tr("result.unreachable") + updateRevision("err:" .. snapshot.error) + publishSnapshot() + doneRefresh() + end + + local function applySuccess(ctx, nodes, pods, deploys, namespaces) + if generation ~= refreshGeneration then + return + end + local readyCount = 0 + for _, n in ipairs(nodes) do + if n.ready then + readyCount += 1 + end + end + local problemCount = 0 + for _, p in ipairs(pods) do + if p.problem then + problemCount += 1 + end + end + pcall(checkNotReadyNotifications, nodes) + + snapshot.available = true + snapshot.loading = false + snapshot.error = "" + snapshot.context = ctx or "" + snapshot.nodes = nodes + snapshot.pods = pods + snapshot.deployments = deploys + snapshot.namespaces = namespaces + snapshot.readyNodes = readyCount + snapshot.nodeCount = #nodes + snapshot.problemPods = problemCount + snapshot.podCount = #pods + snapshot.deployCount = #deploys + snapshot.updatedAt = nowSec() + + updateRevision(table.concat({ + snapshot.context, + tostring(readyCount), + tostring(#nodes), + tostring(problemCount), + tostring(#pods), + tostring(#deploys), + }, "|")) + publishSnapshot() + doneRefresh() + end + + -- Parallel queries (pending counter) — avoids deep nesting and partial hangs. + local bag = { + context = "", + nodesOut = nil, + nodesErr = nil, + podsOut = "", + deploysOut = "", + nsOut = "", + } + local pending = 5 + local finished = false + + local function finishOne() + if generation ~= refreshGeneration or finished then + return + end + pending -= 1 + if pending > 0 then + return + end + finished = true + + if bag.nodesOut == nil then + fail(bag.nodesErr or "nodes failed") + return + end + + local okN, nodesOrErr = pcall(parseNodes, bag.nodesOut) + local nodes = (okN and nodesOrErr) or {} + if not okN then + noctalia.log(`k8s-status: parseNodes: {tostring(nodesOrErr)}`) + nodes = {} + end + + local pods = {} + local okP, podsOrErr = pcall(parsePodsWide, bag.podsOut) + if okP and type(podsOrErr) == "table" then + pods = podsOrErr + elseif not okP then + noctalia.log(`k8s-status: parsePods: {tostring(podsOrErr)}`) + end + table.sort(pods, function(a, b) + if a.problem ~= b.problem then + return a.problem + end + if a.namespace == b.namespace then + return a.name < b.name + end + return a.namespace < b.namespace + end) + + local deploys = {} + local okD, depOrErr = pcall(parseDeploys, bag.deploysOut) + if okD and type(depOrErr) == "table" then + deploys = depOrErr + end + + local namespaces = {} + local okNs, nsOrErr = pcall(parseNamespaces, bag.nsOut) + if okNs and type(nsOrErr) == "table" then + namespaces = nsOrErr + end + + applySuccess(bag.context, nodes, pods, deploys, namespaces) + end + + -- context + local ctxArgs = baseKubectlArgs() + table.insert(ctxArgs, "config") + table.insert(ctxArgs, "current-context") + runKubectl(ctxArgs, function(ctxResult) + if generation ~= refreshGeneration then + return + end + if ctxResult and ctxResult.exitCode == 0 then + bag.context = trim(ctxResult.stdout) + else + local configured = trim(noctalia.getConfig("context")) + bag.context = configured ~= "" and configured or "" + end + finishOne() + end, 15000) + + -- nodes (required) + local nodeArgs = baseKubectlArgs() + table.insert(nodeArgs, "get") + table.insert(nodeArgs, "nodes") + table.insert(nodeArgs, "-o") + table.insert(nodeArgs, "jsonpath=" .. NODE_JSONPATH) + runKubectl(nodeArgs, function(nodeResult) + if generation ~= refreshGeneration then + return + end + if nodeResult and nodeResult.exitCode == 0 and not nodeResult.timedOut then + bag.nodesOut = nodeResult.stdout or "" + else + local err = "" + if nodeResult then + err = trim(nodeResult.stderr) + if err == "" then + err = trim(nodeResult.stdout) + end + if nodeResult.timedOut then + err = err ~= "" and err or "nodes timed out" + end + end + bag.nodesErr = err ~= "" and err or "nodes failed" + bag.nodesOut = nil + end + finishOne() + end, 30000) + + -- pods + local podArgs = baseKubectlArgs() + table.insert(podArgs, "get") + table.insert(podArgs, "pods") + nsArgs(podArgs) + table.insert(podArgs, "--no-headers") + runKubectl(podArgs, function(podResult) + if generation ~= refreshGeneration then + return + end + if podResult and podResult.exitCode == 0 then + bag.podsOut = podResult.stdout or "" + end + finishOne() + end, 45000) + + -- deployments + local deployArgs = baseKubectlArgs() + table.insert(deployArgs, "get") + table.insert(deployArgs, "deploy") + nsArgs(deployArgs) + table.insert(deployArgs, "-o") + table.insert(deployArgs, "jsonpath=" .. DEPLOY_JSONPATH) + runKubectl(deployArgs, function(deployResult) + if generation ~= refreshGeneration then + return + end + if deployResult and deployResult.exitCode == 0 then + bag.deploysOut = deployResult.stdout or "" + end + finishOne() + end, 30000) + + -- namespaces + local nsArgsList = baseKubectlArgs() + table.insert(nsArgsList, "get") + table.insert(nsArgsList, "ns") + table.insert(nsArgsList, "-o") + table.insert(nsArgsList, "jsonpath=" .. NS_JSONPATH) + runKubectl(nsArgsList, function(nsResult) + if generation ~= refreshGeneration then + return + end + if nsResult and nsResult.exitCode == 0 then + bag.nsOut = nsResult.stdout or "" + end + finishOne() + end, 20000) +end + +local function finishAction(command, ok, message) + actionBusy = false + actionResult(command, ok, message) + if ok then + notifyOk(message) + else + notifyErr(message) + end + publishSnapshot() + refreshAll() +end + +local function resourceRef(command) + local ns = trim(command.namespace) + local name = trim(command.name) + return ns, name +end + +local function openInTerminal(cmd) + noctalia.runInTerminal(cmd) +end + +local function kubectlPrefixShell() + local parts = baseKubectlArgs() + return table.concat(parts, " ") -- already will be used carefully +end + +local function describeResource(command) + local kind = trim(command.kind) + local ns, name = resourceRef(command) + if name == "" then + actionResult(command, false, noctalia.tr("result.failed", { error = "missing name" })) + return + end + local args = baseKubectlArgs() + table.insert(args, "describe") + table.insert(args, kind) + table.insert(args, name) + if ns ~= "" and kind ~= "node" and kind ~= "namespace" then + table.insert(args, "-n") + table.insert(args, ns) + end + -- keep terminal open with less/pager + local cmd = shellCommand(args) .. " | less -R" + openInTerminal(cmd) + actionResult(command, true, noctalia.tr("result.describe_started", { name = name })) +end + +local function logsPod(command) + local ns, name = resourceRef(command) + if name == "" or ns == "" then + actionResult(command, false, noctalia.tr("result.failed", { error = "missing pod" })) + return + end + local args = baseKubectlArgs() + table.insert(args, "logs") + table.insert(args, "-n") + table.insert(args, ns) + table.insert(args, name) + table.insert(args, "--tail=200") + table.insert(args, "-f") + openInTerminal(shellCommand(args)) + actionResult(command, true, noctalia.tr("result.logs_started", { name = name })) +end + +local function deletePod(command) + if actionBusy then + actionResult(command, false, noctalia.tr("result.busy")) + return + end + local ns, name = resourceRef(command) + if name == "" or ns == "" then + actionResult(command, false, noctalia.tr("result.failed", { error = "missing pod" })) + return + end + local args = baseKubectlArgs() + table.insert(args, "delete") + table.insert(args, "pod") + table.insert(args, name) + table.insert(args, "-n") + table.insert(args, ns) + actionBusy = true + publishSnapshot() + runKubectl(args, function(result) + local ok = result ~= nil and result.exitCode == 0 + if ok then + finishAction(command, true, noctalia.tr("result.deleted", { name = ns .. "/" .. name })) + else + local err = trim(result and result.stderr or "") + finishAction(command, false, noctalia.tr("result.failed", { error = err ~= "" and err or "delete failed" })) + end + end, 60000) +end + +local function restartDeploy(command) + if actionBusy then + actionResult(command, false, noctalia.tr("result.busy")) + return + end + local ns, name = resourceRef(command) + if name == "" or ns == "" then + actionResult(command, false, noctalia.tr("result.failed", { error = "missing deploy" })) + return + end + local args = baseKubectlArgs() + table.insert(args, "rollout") + table.insert(args, "restart") + table.insert(args, "deploy/" .. name) + table.insert(args, "-n") + table.insert(args, ns) + actionBusy = true + publishSnapshot() + runKubectl(args, function(result) + local ok = result ~= nil and result.exitCode == 0 + if ok then + finishAction(command, true, noctalia.tr("result.restarted", { name = ns .. "/" .. name })) + else + local err = trim(result and result.stderr or "") + finishAction(command, false, noctalia.tr("result.failed", { error = err ~= "" and err or "restart failed" })) + end + end, 60000) +end + +local function openK9s(command) + if not noctalia.commandExists("k9s") then + actionResult(command, false, noctalia.tr("result.failed", { error = "k9s not found" })) + notifyErr(noctalia.tr("result.failed", { error = "k9s not found" })) + return + end + local args = { "k9s" } + local kubeconfig = trim(noctalia.getConfig("kubeconfig")) + if kubeconfig ~= "" then + table.insert(args, "--kubeconfig") + table.insert(args, expand(kubeconfig)) + end + local context = trim(noctalia.getConfig("context")) + if context ~= "" then + table.insert(args, "--context") + table.insert(args, context) + end + local ns = trim(command.namespace or noctalia.getConfig("namespace") or "") + if ns ~= "" then + table.insert(args, "-n") + table.insert(args, ns) + end + openInTerminal(shellCommand(args)) + actionResult(command, true, noctalia.tr("result.k9s_started")) +end + +local function shellPod(command) + local ns, name = resourceRef(command) + if name == "" or ns == "" then + actionResult(command, false, noctalia.tr("result.failed", { error = "missing pod" })) + return + end + local args = baseKubectlArgs() + table.insert(args, "exec") + table.insert(args, "-it") + table.insert(args, "-n") + table.insert(args, ns) + table.insert(args, name) + table.insert(args, "--") + table.insert(args, "sh") + table.insert(args, "-c") + table.insert(args, "command -v bash >/dev/null && exec bash || exec sh") + openInTerminal(shellCommand(args)) + actionResult(command, true, noctalia.tr("result.shell_started", { name = ns .. "/" .. name })) +end + +local function portForwardPod(command) + local ns, name = resourceRef(command) + local ports = trim(command.ports) + if name == "" or ns == "" then + actionResult(command, false, noctalia.tr("result.failed", { error = "missing pod" })) + return + end + if ports == "" then + ports = "8080:8080" + end + local args = baseKubectlArgs() + table.insert(args, "port-forward") + table.insert(args, "-n") + table.insert(args, ns) + table.insert(args, "pod/" .. name) + table.insert(args, ports) + openInTerminal(shellCommand(args)) + actionResult(command, true, noctalia.tr("result.portforward_started", { name = ns .. "/" .. name, ports = ports })) +end + +local function getYaml(command) + local kind = trim(command.kind) + local ns, name = resourceRef(command) + if name == "" then + actionResult(command, false, noctalia.tr("result.failed", { error = "missing name" })) + return + end + local args = baseKubectlArgs() + table.insert(args, "get") + table.insert(args, kind) + table.insert(args, name) + if ns ~= "" and kind ~= "node" and kind ~= "namespace" and kind ~= "ns" then + table.insert(args, "-n") + table.insert(args, ns) + end + table.insert(args, "-o") + table.insert(args, "yaml") + openInTerminal(shellCommand(args) .. " | less -R") + actionResult(command, true, noctalia.tr("result.yaml_started", { name = name })) +end + +local function copyResourceName(command) + local ns, name = resourceRef(command) + local text = name + if ns ~= "" then + text = ns .. "/" .. name + end + if text == "" then + actionResult(command, false, noctalia.tr("result.failed", { error = "missing name" })) + return + end + noctalia.copyToClipboard(text, "text/plain") + actionResult(command, true, noctalia.tr("result.copied", { name = text })) + notifyOk(noctalia.tr("result.copied", { name = text })) +end + +local function executeAction(command) + if type(command) ~= "table" or type(command.action) ~= "string" then + return + end + if command.action == "refresh" then + refreshAll() + return + end + if command.action == "describe" then + describeResource(command) + return + end + if command.action == "logs" then + logsPod(command) + return + end + if command.action == "shell" then + shellPod(command) + return + end + if command.action == "port_forward" then + portForwardPod(command) + return + end + if command.action == "yaml" then + getYaml(command) + return + end + if command.action == "copy_name" then + copyResourceName(command) + return + end + if command.action == "delete_pod" then + deletePod(command) + return + end + if command.action == "restart_deploy" then + restartDeploy(command) + return + end + if command.action == "k9s" then + openK9s(command) + return + end + actionResult(command, false, `Unknown action: {command.action}`) +end + +noctalia.state.watch(COMMAND_KEY, executeAction) +noctalia.setUpdateInterval(refreshIntervalMs()) +refreshAll() + +function update() + refreshAll() +end + +function onConfigChanged() + noctalia.setUpdateInterval(refreshIntervalMs()) + refreshPending = false + refreshStartedAt = 0 + refreshAll() +end + +function onIpc(event, _payload) + if event == "refresh" then + refreshPending = false + refreshStartedAt = 0 + refreshAll() + end +end diff --git a/k8s-status/thumbnail.webp b/k8s-status/thumbnail.webp new file mode 100644 index 0000000000000000000000000000000000000000..6557dc73f7e68aef72a02b3b128639ab8702ed4c GIT binary patch literal 60436 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 literal 0 HcmV?d00001 diff --git a/k8s-status/translations/en.json b/k8s-status/translations/en.json new file mode 100644 index 0000000..5b121eb --- /dev/null +++ b/k8s-status/translations/en.json @@ -0,0 +1,173 @@ +{ + "title": "K8s Status", + "settings": { + "kubeconfig": { + "label": "Kubeconfig", + "description": "Path to kubeconfig (empty uses default)." + }, + "context": { + "label": "Context", + "description": "Kubernetes context name. Leave empty for current-context." + }, + "namespace": { + "label": "Namespace filter", + "description": "Limit pods/deployments to this namespace. Empty = all namespaces." + }, + "refresh_interval": { + "label": "Refresh interval (seconds)", + "description": "How often to poll the cluster." + }, + "problems_only": { + "label": "Problems-only pod list", + "description": "In the panel pod list, hide healthy Running pods by default." + }, + "notify_on_not_ready": { + "label": "Notify on node NotReady", + "description": "Desktop notification when a node becomes NotReady." + }, + "kubectl_bin": { + "label": "kubectl binary", + "description": "kubectl command name or absolute path." + }, + "show_counts": { + "label": "Show counts on bar", + "description": "Display ready nodes and problem pods on the widget." + }, + "ok_color": { + "label": "Healthy indicator" + }, + "warn_color": { + "label": "Problem indicator" + } + }, + "colors": { + "tertiary": "Tertiary", + "primary": "Primary", + "secondary": "Secondary", + "error": "Error", + "muted": "Muted" + }, + "widget": { + "tooltip_ok": "{context} · {ready}/{nodes} nodes ready · {problems} problem pods", + "tooltip_down": "Cluster unreachable: {error}", + "tooltip_missing": "kubectl not found", + "refresh_requested": "Refreshing cluster status…" + }, + "panel": { + "subtitle": "Nodes, pods, and workloads", + "loading": "Querying cluster…", + "busy": "Working…", + "empty_nodes": "No nodes match the current filter.", + "empty_pods": "No pods match the current filter.", + "empty_deploys": "No deployments match the current filter.", + "empty_namespaces": "No namespaces match the current filter.", + "select_hint": "Select an item for actions.", + "updated": "Updated {time}", + "context": "Context: {name}", + "summary": "{ready}/{nodes} nodes ready · {problems} problem pods · {pods} pods" + }, + "tabs": { + "nodes": "Nodes", + "pods": "Pods", + "deploys": "Deployments", + "namespaces": "Namespaces" + }, + "filter": { + "all": "All", + "problems": "Problems", + "placeholder_nodes": "Filter… (!Ready excludes Ready)", + "placeholder_pods": "Filter… e.g. gitea or !Running", + "placeholder_deploys": "Filter… e.g. ai or !1/1", + "placeholder_namespaces": "Filter… e.g. !Active" + }, + "node": { + "ready": "Ready", + "not_ready": "NotReady", + "roles": "Roles: {roles}", + "version": "Version: {version}", + "ip": "IP: {ip}" + }, + "pod": { + "restarts": "{count} restarts", + "node": "Node: {node}" + }, + "deploy": { + "replicas": "{ready}/{desired} ready" + }, + "ns": { + "phase": "{phase}" + }, + "actions": { + "refresh": "Refresh", + "logs": "Logs", + "describe": "Describe", + "delete_pod": "Delete pod", + "restart": "Restart", + "open_k9s": "k9s", + "toggle_filter": "Filter" + }, + "result": { + "success": "Done", + "failed": "Failed: {error}", + "busy": "Another operation is running.", + "kubectl_missing": "kubectl is not available.", + "unreachable": "Cannot reach cluster", + "logs_started": "Opening logs for {name}…", + "shell_started": "Opening shell in {name}…", + "portforward_started": "Port-forward {name} ({ports})…", + "yaml_started": "Showing YAML for {name}…", + "copied": "Copied {name}", + "describe_started": "Describing {name}…", + "deleted": "Deleted pod {name}", + "restarted": "Restarted {name}", + "k9s_started": "Opening k9s…", + "node_not_ready": "Node {name} is NotReady" + }, + "launcher": { + "loading": "Loading cluster snapshot…", + "unavailable": "Cluster unavailable", + "no-matches": "No matches", + "cat": { + "pods": "Pods", + "pods-sub": "Browse pods — logs, shell, describe…", + "problems": "Problem pods", + "problems-sub": "Non-running / unhealthy pods only", + "nodes": "Nodes", + "nodes-sub": "Node status and describe", + "deploys": "Deployments", + "deploys-sub": "Rollout restart and describe", + "ns": "Namespaces", + "ns-sub": "Browse namespaces", + "status": "Status summary", + "status-sub": "Notify ready nodes / problem pods", + "panel": "Open panel", + "panel-sub": "Full K8s Status manager", + "k9s": "Open k9s", + "k9s-sub": "Terminal cluster UI", + "refresh": "Refresh", + "refresh-sub": "Rescan cluster now" + }, + "action": { + "logs": "Logs", + "logs-sub": "kubectl logs -f --tail=200", + "shell": "Shell", + "shell-sub": "kubectl exec -it (bash/sh)", + "describe": "Describe", + "describe-sub": "kubectl describe", + "yaml": "YAML", + "yaml-sub": "kubectl get -o yaml", + "portforward": "Port-forward", + "portforward-sub": "kubectl port-forward 8080:8080", + "copy": "Copy name", + "copy-sub": "Copy namespace/name to clipboard", + "delete": "Delete pod", + "delete-sub": "kubectl delete pod", + "restart": "Restart", + "restart-sub": "kubectl rollout restart" + }, + "pod-actions": "Choose an action", + "node-actions": "Choose an action", + "deploy-actions": "Choose an action", + "hint-type": "Type to filter, Enter to open" + } +} diff --git a/k8s-status/widget.luau b/k8s-status/widget.luau new file mode 100644 index 0000000..f74ae3c --- /dev/null +++ b/k8s-status/widget.luau @@ -0,0 +1,107 @@ +--!nonstrict + +local PANEL_ID = "davemhammer/k8s-status:manager" +local STATE_KEY = "k8s_snapshot" +local COMMAND_KEY = "k8s_command" + +local snapshot = noctalia.state.get(STATE_KEY) or { + available = false, + readyNodes = 0, + nodeCount = 0, + problemPods = 0, + context = "", + error = "", +} + +local requestId = 0 + +local function configString(key, fallback) + local value = noctalia.getConfig(key) + return type(value) == "string" and value or fallback +end + +local function render() + local available = snapshot.available == true + local ready = tonumber(snapshot.readyNodes) or 0 + local nodes = tonumber(snapshot.nodeCount) or 0 + local problems = tonumber(snapshot.problemPods) or 0 + local healthy = available and ready == nodes and nodes > 0 and problems == 0 + local showCounts = noctalia.getConfig("show_counts") ~= false + local okColor = configString("ok_color", "tertiary") + local warnColor = configString("warn_color", "error") + local color = available and (healthy and okColor or warnColor) or "on_surface_variant" + + local children = { + ui.glyph({ + name = "hexagon", + size = 16, + color = color, + }), + } + + if showCounts and available then + table.insert(children, ui.label({ + text = `{ready}/{nodes}`, + fontWeight = "bold", + color = "on_surface", + })) + if problems > 0 then + table.insert(children, ui.label({ + text = tostring(problems), + fontWeight = "bold", + color = warnColor, + })) + end + table.insert(children, ui.box({ + width = 7, + height = 7, + radius = 4, + fill = healthy and okColor or warnColor, + })) + end + + local container = barWidget.isVertical() and ui.column or ui.row + barWidget.render(container({ gap = 5, align = "center" }, children)) + + if not available then + local err = tostring(snapshot.error or "") + if err:find("kubectl", 1, true) then + barWidget.setTooltip(noctalia.tr("widget.tooltip_missing")) + else + barWidget.setTooltip(noctalia.tr("widget.tooltip_down", { + error = err ~= "" and err or "unknown", + })) + end + else + barWidget.setTooltip(noctalia.tr("widget.tooltip_ok", { + context = snapshot.context ~= "" and snapshot.context or "default", + ready = ready, + nodes = nodes, + problems = problems, + })) + end +end + +noctalia.state.watch(STATE_KEY, function(value) + if type(value) == "table" then + snapshot = value + render() + end +end) + +noctalia.setUpdateInterval(8000) +render() + +function update() + render() +end + +function onClick() + noctalia.togglePanel(PANEL_ID) +end + +function onRightClick() + requestId += 1 + noctalia.state.set(COMMAND_KEY, { action = "refresh", requestId = `widget-{requestId}` }) + noctalia.notify(noctalia.tr("title"), noctalia.tr("widget.refresh_requested")) +end