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 0000000..6557dc7 Binary files /dev/null and b/k8s-status/thumbnail.webp differ 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