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.
This commit is contained in:
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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] ~= "<none>" 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
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user