feat(mini-docker): add Docker management plugin (#19)

This commit is contained in:
pedro
2026-07-15 20:04:13 -04:00
committed by GitHub
parent 6b1d4baf4a
commit 66270734c0
7 changed files with 1386 additions and 0 deletions
+32
View File
@@ -0,0 +1,32 @@
# Mini Docker
Mini Docker is a Noctalia v5 plugin for managing Docker from the shell. The bar widget shows Docker availability and the running-container count. Its management panel can start, stop, restart, and remove containers; run and remove images; and inspect or remove volumes and networks.
This Luau implementation migrates the Noctalia v4 Mini Docker plugin originally written by [MannuVilasara](https://github.com/MannuVilasara).
## Requirements
- Noctalia 5.0.0 or newer
- Docker CLI and a reachable Docker daemon
- Permission for the current user to access Docker
## Usage
Enable `8bury/mini-docker`, then add its `mini-docker` widget to a bar. Click the widget to open the management panel. Right-click it to refresh Docker state immediately.
The panel has four tabs:
- Containers: start, stop, restart, or remove a selected container
- Images: run an image with optional name, network, port, and environment variables, or remove an unused image
- Volumes: inspect and remove volumes
- Networks: inspect and remove non-default networks
Settings control the refresh interval, default run network, running count, icon color, and status indicator.
## Security
Mini Docker invokes only the local `docker` CLI. Subprocess arguments are shell-quoted, and user-entered container names, ports, and environment-variable keys are validated before execution.
## License
MIT. Attribution to the original v4 author is retained above.
+589
View File
@@ -0,0 +1,589 @@
--!nonstrict
local MAX_VISIBLE_ITEMS = 32
local snapshot = noctalia.state.get("docker_snapshot") or {
available = false,
loading = true,
busy = false,
containers = {},
images = {},
volumes = {},
networks = {},
error = "",
}
local currentTab = "containers"
local selectedKey = ""
local selectSlots = {}
local requestCounter = 0
local pendingRunRequest = nil
local pendingInspectRequest = nil
local feedback = ""
local feedbackError = false
local showRunForm = false
local runFormGeneration = 0
local runImage = ""
local runName = ""
local runPort = "8080"
local runPublishPort = true
local runEnvironment = ""
local runNetworkIndex = 0
local runFormError = ""
local dirty = true
local render
local function tr(key, subst)
return noctalia.tr(key, subst)
end
local function itemsForTab()
local value = snapshot[currentTab]
return type(value) == "table" and value or {}
end
local function keyForItem(item)
if currentTab == "containers" or currentTab == "images" or currentTab == "networks" then
return tostring(item.id or "")
end
return tostring(item.name or "")
end
local function selectedItem()
if selectedKey == "" then
return nil
end
for _, item in ipairs(itemsForTab()) do
if keyForItem(item) == selectedKey then
return item
end
end
return nil
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("docker_command", command)
return command.requestId
end
local function guessPort(image)
local lower = tostring(image):lower()
if lower:find("mongo", 1, true) then return "27017" end
if lower:find("postgres", 1, true) then return "5432" end
if lower:find("redis", 1, true) then return "6379" end
if lower:find("mysql", 1, true) or lower:find("mariadb", 1, true) then return "3306" end
if lower:find("nginx", 1, true) or lower:find("apache", 1, true) or lower:find("httpd", 1, true) then return "80" end
if lower:find("node", 1, true) or lower:find("react", 1, true) then return "3000" end
return "8080"
end
local function networkNames()
local names = {}
for _, network in ipairs(snapshot.networks or {}) do
table.insert(names, tostring(network.name or ""))
end
if #names == 0 then
names[1] = "bridge"
end
return names
end
local function chooseDefaultNetwork()
local names = networkNames()
local configured = noctalia.getConfig("default_network")
if type(configured) ~= "string" or configured == "" then
configured = "bridge"
end
runNetworkIndex = 0
for index, name in ipairs(names) do
if name == configured then
runNetworkIndex = index - 1
break
end
end
end
local function detailLabel(key, value)
local text = tostring(value or "")
if text == "" then text = "—" end
return ui.label({
text = tr(key, { value = text }),
color = "on_surface_variant",
fontSize = 12,
maxLines = 1,
})
end
local function itemTitle(item)
if currentTab == "containers" then return item.name end
if currentTab == "images" then return item.name end
return item.name
end
local function itemGlyph()
if currentTab == "containers" then return "brand-docker" end
if currentTab == "images" then return "photo" end
if currentTab == "volumes" then return "database" end
return "network"
end
local function itemDetails(item)
if currentTab == "containers" then
return {
detailLabel("details.image", item.image),
detailLabel("details.status", item.status),
detailLabel("details.ports", item.ports),
}
elseif currentTab == "images" then
return {
detailLabel("details.id", item.id),
detailLabel("details.size", item.size),
detailLabel("details.created", item.created),
}
elseif currentTab == "volumes" then
return {
detailLabel("details.driver", item.driver),
detailLabel("details.mountpoint", item.mountpoint),
}
end
return {
detailLabel("details.id", item.id),
detailLabel("details.driver", item.driver),
detailLabel("details.scope", item.scope),
}
end
local function itemSummary(item)
if currentTab == "containers" then
return `{item.name} · {item.image} · {item.status}`
elseif currentTab == "images" then
return `{item.name} · {item.size} · {item.created}`
elseif currentTab == "volumes" then
return `{item.name} · {item.driver} · {item.mountpoint}`
end
return `{item.name} · {item.driver} · {item.scope}`
end
local function itemCard(item, slotIndex)
local key = keyForItem(item)
local selected = key == selectedKey
selectSlots[slotIndex] = key
return ui.button({
key = key,
text = itemSummary(item),
glyph = itemGlyph(),
contentAlign = "start",
variant = selected and "primary" or "outline",
selected = selected,
onClick = `onSelect{slotIndex}`,
})
end
local function actionButton(text, glyph, callback, destructive, enabled)
return ui.button({
text = text,
glyph = glyph,
variant = destructive and "destructive" or "outline",
enabled = enabled ~= false and snapshot.busy ~= true,
onClick = callback,
})
end
local function selectionToolbar()
local item = selectedItem()
if item == nil then
return ui.label({ text = tr("panel.select_hint"), color = "on_surface_variant" })
end
local buttons = {}
if currentTab == "containers" then
local running = item.state == "running"
table.insert(buttons, actionButton(tr("actions.start"), "player-play", "onStart", false, not running))
table.insert(buttons, actionButton(tr("actions.stop"), "player-stop", "onStop", false, running))
table.insert(buttons, actionButton(tr("actions.restart"), "refresh", "onRestart", false, running))
table.insert(buttons, actionButton(tr("actions.remove"), "trash", "onRemove", true, not running))
elseif currentTab == "images" then
table.insert(buttons, actionButton(tr("actions.run"), "player-play", "onRunImage", false, true))
table.insert(buttons, actionButton(tr("actions.remove"), "trash", "onRemove", true, item.isRunning ~= true))
elseif currentTab == "volumes" then
table.insert(buttons, actionButton(tr("actions.remove"), "trash", "onRemove", true, true))
else
table.insert(buttons, actionButton(tr("actions.remove"), "trash", "onRemove", true, item.isDefault ~= true))
end
local details = itemDetails(item)
table.insert(details, 1, ui.row({ gap = 8, align = "center" }, {
ui.label({ text = tostring(itemTitle(item)), fontWeight = "bold", flexGrow = 1, maxLines = 1 }),
ui.row({ gap = 6, align = "center" }, buttons),
}))
return ui.column({ gap = 3, padding = 8, fill = "surface_variant/0.45", radius = 10 }, details)
end
local function emptyMessage()
if currentTab == "containers" then return tr("panel.no_containers") end
if currentTab == "images" then return tr("panel.no_images") end
if currentTab == "volumes" then return tr("panel.no_volumes") end
return tr("panel.no_networks")
end
local function itemList()
local items = itemsForTab()
if #items == 0 then
return ui.column({ align = "center", padding = 24 }, {
ui.glyph({ name = itemGlyph(), size = 42, color = "on_surface_variant" }),
ui.label({ text = emptyMessage(), color = "on_surface_variant", textAlign = "center" }),
})
end
local rows = {}
local count = math.min(#items, MAX_VISIBLE_ITEMS)
for index = 1, count do
table.insert(rows, itemCard(items[index], index - 1))
end
if #items > MAX_VISIBLE_ITEMS then
table.insert(rows, ui.label({
text = tr("panel.showing_limit", { count = MAX_VISIBLE_ITEMS }),
color = "on_surface_variant",
textAlign = "center",
}))
end
return ui.column({ gap = 8 }, rows)
end
local function runForm()
local names = networkNames()
return ui.scroll({ flexGrow = 1, gap = 12 }, {
ui.row({ gap = 8, align = "center" }, {
ui.label({ text = tr("run_form.title", { image = runImage }), fontSize = 16, fontWeight = "bold", flexGrow = 1 }),
ui.button({ glyph = "close", onClick = "onCancelRun" }),
}),
ui.label({ text = tr("run_form.container_name"), color = "on_surface_variant" }),
ui.input({
key = `run-name-{runFormGeneration}`,
value = runName,
placeholder = tr("run_form.container_name_placeholder"),
onChange = "onRunNameChange",
}),
ui.label({ text = tr("run_form.network"), color = "on_surface_variant" }),
ui.select({
key = `run-network-{runFormGeneration}`,
options = names,
selectedIndex = runNetworkIndex,
onChange = "onRunNetworkChange",
}),
ui.row({ gap = 10, align = "center" }, {
ui.toggle({ checked = runPublishPort, onChange = "onRunPublishPortChange" }),
ui.label({ text = tr("run_form.publish_port"), flexGrow = 1 }),
}),
ui.label({ text = tr("run_form.port"), color = "on_surface_variant", visible = runPublishPort }),
ui.input({
key = `run-port-{runFormGeneration}`,
value = runPort,
placeholder = tr("run_form.port_placeholder"),
onChange = "onRunPortChange",
visible = runPublishPort,
}),
ui.label({ text = tr("run_form.environment"), color = "on_surface_variant" }),
ui.input({
key = `run-environment-{runFormGeneration}`,
value = runEnvironment,
placeholder = tr("run_form.environment_placeholder"),
multiline = true,
height = 130,
onChange = "onRunEnvironmentChange",
}),
ui.label({ text = tr("run_form.environment_help"), color = "on_surface_variant", fontSize = 12 }),
ui.label({ text = runFormError, color = "error", visible = runFormError ~= "" }),
ui.row({ justify = "end", gap = 8 }, {
ui.button({ text = tr("actions.cancel"), variant = "outline", onClick = "onCancelRun" }),
ui.button({ text = tr("actions.run"), glyph = "player-play", variant = "primary", enabled = snapshot.busy ~= true, onClick = "onConfirmRun" }),
}),
})
end
local function tabButton(label, tab, callback)
return ui.button({
text = label,
selected = currentTab == tab,
variant = currentTab == tab and "primary" or "ghost",
onClick = callback,
})
end
render = function()
dirty = false
selectSlots = {}
local statusRows = {}
if snapshot.loading == true 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 = 2 }))
end
if feedback ~= "" then
table.insert(statusRows, ui.label({ text = feedback, color = feedbackError and "error" or "tertiary", maxLines = 2 }))
end
local content = showRunForm and runForm() or ui.column({ flexGrow = 1, gap = 10 }, {
selectionToolbar(),
ui.scroll({ flexGrow = 1, gap = 8 }, { itemList() }),
})
panel.render(ui.column({ flexGrow = 1, gap = 10 }, {
ui.row({ align = "center", gap = 8 }, {
ui.glyph({ name = "brand-docker", size = 24, color = snapshot.available and "primary" or "on_surface_variant" }),
ui.label({ text = tr("title"), fontSize = 18, fontWeight = "bold", flexGrow = 1 }),
ui.button({ text = tr("actions.refresh"), glyph = "refresh", variant = "outline", onClick = "onRefresh" }),
ui.button({ glyph = "close", onClick = "onCloseClicked" }),
}),
ui.row({ gap = 4, align = "center" }, {
tabButton(tr("tabs.containers"), "containers", "onTabContainers"),
tabButton(tr("tabs.images"), "images", "onTabImages"),
tabButton(tr("tabs.volumes"), "volumes", "onTabVolumes"),
tabButton(tr("tabs.networks"), "networks", "onTabNetworks"),
ui.spacer({ flexGrow = 1 }),
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,
}),
}),
ui.column({ gap = 3 }, statusRows),
content,
}))
end
local function selectAt(index)
local key = selectSlots[index]
if key ~= nil then
selectedKey = key
feedback = ""
render()
end
end
local function switchTab(tab)
currentTab = tab
selectedKey = ""
showRunForm = false
feedback = ""
render()
end
local function environmentValues()
local values = {}
for line in (runEnvironment .. "\n"):gmatch("(.-)\n") do
line = noctalia.string.trim(line)
if line ~= "" then
local key = line:match("^([^=]+)=") or ""
if key:match("^[A-Za-z_][A-Za-z0-9_]*$") == nil then
return nil, tr("run_form.invalid_environment", { line = line })
end
table.insert(values, line)
end
end
return values, nil
end
noctalia.state.watch("docker_snapshot", function(value)
if type(value) == "table" then
local changed = value.revision ~= snapshot.revision
or value.available ~= snapshot.available
or value.busy ~= snapshot.busy
or value.error ~= snapshot.error
snapshot = value
if selectedKey ~= "" and selectedItem() == nil then
selectedKey = ""
end
if changed then
dirty = true
end
end
end)
noctalia.state.watch("docker_action_result", function(result)
if type(result) ~= "table" then return end
if result.requestId == pendingInspectRequest then
pendingInspectRequest = nil
if type(result.exposedPort) == "string" and result.exposedPort ~= "" then
runPort = result.exposedPort
runFormGeneration += 1
dirty = true
end
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
if result.requestId == pendingRunRequest and result.ok == true then
showRunForm = false
pendingRunRequest = nil
end
dirty = true
end)
panel.setWantsSecondTicks(true)
function onOpen(_context)
sendCommand("refresh")
render()
end
function update()
if dirty then
render()
end
end
function onCloseClicked() panel.close() end
function onRefresh() sendCommand("refresh") end
function onTabContainers() switchTab("containers") end
function onTabImages() switchTab("images") end
function onTabVolumes() switchTab("volumes") end
function onTabNetworks() switchTab("networks") end
function onStart()
local item = selectedItem()
if item then sendCommand("start_container", { id = item.id }) end
end
function onStop()
local item = selectedItem()
if item then sendCommand("stop_container", { id = item.id }) end
end
function onRestart()
local item = selectedItem()
if item then sendCommand("restart_container", { id = item.id }) end
end
function onRemove()
local item = selectedItem()
if not item then return end
if currentTab == "containers" then
sendCommand("remove_container", { id = item.id })
elseif currentTab == "images" then
sendCommand("remove_image", { id = item.id })
elseif currentTab == "volumes" then
sendCommand("remove_volume", { name = item.name })
else
sendCommand("remove_network", { id = item.id, name = item.name })
end
end
function onRunImage()
local item = selectedItem()
if not item then return end
showRunForm = true
runImage = tostring(item.name or "")
runName = ""
runPort = guessPort(runImage)
runPublishPort = true
runEnvironment = ""
runFormError = ""
runFormGeneration += 1
chooseDefaultNetwork()
pendingInspectRequest = sendCommand("inspect_image", { image = runImage })
render()
end
function onCancelRun()
showRunForm = false
runFormError = ""
render()
end
function onRunNameChange(value) runName = value end
function onRunPortChange(value) runPort = value end
function onRunEnvironmentChange(value) runEnvironment = value end
function onRunNetworkChange(index, _text)
runNetworkIndex = tonumber(index) or 0
end
function onRunPublishPortChange(value)
runPublishPort = value == "true"
render()
end
function onConfirmRun()
local name = noctalia.string.trim(runName)
if name ~= "" and name:match("^[%w][%w_.-]*$") == nil then
runFormError = tr("run_form.invalid_name")
render()
return
end
local port = runPublishPort and noctalia.string.trim(runPort) or ""
local portNumber = tonumber(port)
if port ~= "" and (portNumber == nil or portNumber < 1 or portNumber > 65535 or math.floor(portNumber) ~= portNumber) then
runFormError = tr("run_form.invalid_port")
render()
return
end
local environment, err = environmentValues()
if environment == nil then
runFormError = err
render()
return
end
local names = networkNames()
local network = names[runNetworkIndex + 1] or "bridge"
runFormError = ""
pendingRunRequest = sendCommand("run_image", {
image = runImage,
name = name,
network = network,
port = port,
environment = environment,
})
render()
end
function onSelect0() selectAt(0) end
function onSelect1() selectAt(1) end
function onSelect2() selectAt(2) end
function onSelect3() selectAt(3) end
function onSelect4() selectAt(4) end
function onSelect5() selectAt(5) end
function onSelect6() selectAt(6) end
function onSelect7() selectAt(7) end
function onSelect8() selectAt(8) end
function onSelect9() selectAt(9) end
function onSelect10() selectAt(10) end
function onSelect11() selectAt(11) end
function onSelect12() selectAt(12) end
function onSelect13() selectAt(13) end
function onSelect14() selectAt(14) end
function onSelect15() selectAt(15) end
function onSelect16() selectAt(16) end
function onSelect17() selectAt(17) end
function onSelect18() selectAt(18) end
function onSelect19() selectAt(19) end
function onSelect20() selectAt(20) end
function onSelect21() selectAt(21) end
function onSelect22() selectAt(22) end
function onSelect23() selectAt(23) end
function onSelect24() selectAt(24) end
function onSelect25() selectAt(25) end
function onSelect26() selectAt(26) end
function onSelect27() selectAt(27) end
function onSelect28() selectAt(28) end
function onSelect29() selectAt(29) end
function onSelect30() selectAt(30) end
function onSelect31() selectAt(31) end
+100
View File
@@ -0,0 +1,100 @@
id = "8bury/mini-docker"
name = "Mini Docker"
version = "1.0.3"
min_noctalia = "5.0.0"
author = "8bury"
license = "MIT"
icon = "brand-docker"
description = "Manage Docker containers, images, volumes, and networks from Noctalia."
tags = ["bar", "panel", "service", "development", "system", "utility"]
dependencies = ["docker"]
[[setting]]
key = "refresh_interval"
type = "int"
label_key = "settings.refresh_interval.label"
description_key = "settings.refresh_interval.description"
default = 5
min = 1
max = 30
[[setting]]
key = "default_network"
type = "string"
label_key = "settings.default_network.label"
description_key = "settings.default_network.description"
default = "bridge"
[[widget]]
id = "mini-docker"
entry = "widget.luau"
[[widget.setting]]
key = "show_count"
type = "bool"
label_key = "settings.show_count.label"
description_key = "settings.show_count.description"
default = true
[[widget.setting]]
key = "glyph_color"
type = "select"
label_key = "settings.glyph_color.label"
description_key = "settings.glyph_color.description"
default = "on_surface"
options = [
{ value = "on_surface", label_key = "colors.default" },
{ value = "primary", label_key = "colors.primary" },
{ value = "secondary", label_key = "colors.secondary" },
{ value = "tertiary", label_key = "colors.tertiary" }
]
[[widget.setting]]
key = "status_mode"
type = "select"
label_key = "settings.status_mode.label"
description_key = "settings.status_mode.description"
default = "always"
options = [
{ value = "always", label_key = "settings.status_mode.options.always" },
{ value = "running_only", label_key = "settings.status_mode.options.running_only" },
{ value = "hidden", label_key = "settings.status_mode.options.hidden" }
]
[[widget.setting]]
key = "active_color"
type = "select"
label_key = "settings.active_color.label"
description_key = "settings.active_color.description"
default = "tertiary"
options = [
{ value = "primary", label_key = "colors.primary" },
{ value = "secondary", label_key = "colors.secondary" },
{ value = "tertiary", label_key = "colors.tertiary" }
]
[[widget.setting]]
key = "inactive_color"
type = "select"
label_key = "settings.inactive_color.label"
description_key = "settings.inactive_color.description"
default = "error"
options = [
{ value = "error", label_key = "colors.error" },
{ value = "on_surface_variant", label_key = "colors.muted" },
{ value = "primary", label_key = "colors.primary" },
{ value = "tertiary", label_key = "colors.tertiary" }
]
[[panel]]
id = "manager"
entry = "panel.luau"
width = 860
height = 620
placement = "floating"
position = "center"
open_near_click = true
[[service]]
id = "docker-service"
entry = "service.luau"
+466
View File
@@ -0,0 +1,466 @@
--!nonstrict
-- Docker backend for Mini Docker. All other entries communicate with this
-- singleton through noctalia.state, keeping subprocess ownership in one place.
local snapshot = {
available = false,
loading = true,
busy = false,
containers = {},
images = {},
volumes = {},
networks = {},
runningCount = 0,
error = "",
updatedAt = 0,
revision = 0,
}
local refreshGeneration = 0
local refreshPending = false
local refreshAgain = false
local actionBusy = false
local dataSignature = ""
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 runDocker(args, callback, timeoutMs)
local command = { "docker" }
for _, value in ipairs(args) do
table.insert(command, value)
end
return noctalia.runAsync(shellCommand(command), callback, timeoutMs or 30000)
end
local function decodeLines(output, mapper)
local rows = {}
for line in (tostring(output or "") .. "\n"):gmatch("(.-)\n") do
line = trim(line)
if line ~= "" then
local decoded, err = noctalia.json.decode(line)
if type(decoded) == "table" then
table.insert(rows, mapper(decoded))
else
noctalia.log(`mini-docker: ignored malformed Docker JSON: {err or "unknown error"}`)
end
end
end
return rows
end
local function parseContainers(output)
return decodeLines(output, function(item)
return {
id = tostring(item.ID or ""),
name = tostring(item.Names or ""),
image = tostring(item.Image or ""),
state = tostring(item.State or "unknown"),
status = tostring(item.Status or ""),
ports = tostring(item.Ports or ""),
created = tostring(item.CreatedAt or ""),
}
end)
end
local function parseImages(output)
return decodeLines(output, function(item)
local repository = tostring(item.Repository or "<none>")
local tag = tostring(item.Tag or "latest")
return {
repository = repository,
tag = tag,
name = repository .. ":" .. tag,
id = tostring(item.ID or ""),
size = tostring(item.Size or ""),
created = tostring(item.CreatedAt or ""),
isRunning = false,
}
end)
end
local function parseVolumes(output)
return decodeLines(output, function(item)
return {
name = tostring(item.Name or ""),
driver = tostring(item.Driver or ""),
mountpoint = tostring(item.Mountpoint or ""),
}
end)
end
local function parseNetworks(output)
return decodeLines(output, function(item)
local name = tostring(item.Name or "")
return {
name = name,
id = tostring(item.ID or ""),
driver = tostring(item.Driver or ""),
scope = tostring(item.Scope or ""),
isDefault = name == "bridge" or name == "host" or name == "none",
}
end)
end
local function markRunningImages(images, containers)
for _, image in ipairs(images) do
for _, container in ipairs(containers) do
if container.image == image.name
or container.image == image.repository
or container.image == image.id
or image.id:sub(1, 12) == container.image then
-- Docker refuses to remove an image referenced by stopped containers too.
image.isRunning = true
break
end
end
end
end
local function publishSnapshot()
snapshot.busy = actionBusy
noctalia.state.set("docker_snapshot", snapshot)
end
local function updateRevision(signature)
if signature ~= dataSignature then
dataSignature = signature
snapshot.revision += 1
end
end
local function refreshIntervalMs()
local seconds = tonumber(noctalia.getConfig("refresh_interval")) or 5
seconds = math.max(1, math.min(30, math.floor(seconds)))
return seconds * 1000
end
local refreshAll
refreshAll = function()
if refreshPending then
refreshAgain = true
return
end
refreshPending = true
refreshAgain = false
refreshGeneration += 1
local generation = refreshGeneration
if not noctalia.commandExists("docker") then
snapshot.available = false
snapshot.loading = false
snapshot.error = noctalia.tr("result.docker_missing")
snapshot.containers = {}
snapshot.images = {}
snapshot.volumes = {}
snapshot.networks = {}
snapshot.runningCount = 0
refreshPending = false
updateRevision("docker-missing:" .. snapshot.error)
publishSnapshot()
return
end
snapshot.loading = true
publishSnapshot()
local signatureParts = {}
local containers = {}
local images = {}
local volumes = {}
local networks = {}
local function recordResult(kind, result)
signatureParts[kind] = table.concat({
tostring(result and result.exitCode or -1),
tostring(result and result.stdout or ""),
tostring(result and result.stderr or ""),
}, "\0")
end
local function finish(containerResult)
if generation ~= refreshGeneration then
return
end
if containerResult == nil or containerResult.exitCode ~= 0 then
snapshot.available = false
snapshot.error = trim(containerResult and containerResult.stderr)
if snapshot.error == "" then
snapshot.error = noctalia.tr("result.docker_unreachable")
end
snapshot.containers = {}
snapshot.images = {}
snapshot.volumes = {}
snapshot.networks = {}
snapshot.runningCount = 0
else
local runningCount = 0
for _, container in ipairs(containers) do
if container.state == "running" then
runningCount += 1
end
end
markRunningImages(images, containers)
snapshot.available = true
snapshot.error = ""
snapshot.containers = containers
snapshot.images = images
snapshot.volumes = volumes
snapshot.networks = networks
snapshot.runningCount = runningCount
snapshot.updatedAt = os.time()
end
snapshot.loading = false
refreshPending = false
updateRevision(table.concat({
signatureParts.containers or "",
signatureParts.images or "",
signatureParts.volumes or "",
signatureParts.networks or "",
}, "\1"))
publishSnapshot()
if refreshAgain then
refreshAll()
end
end
local function launch(kind, args, callback)
local started = runDocker(args, function(result)
if generation ~= refreshGeneration then return end
recordResult(kind, result)
callback(result)
end)
if not started then
local result = { exitCode = -1, stdout = "", stderr = "could not start Docker", timedOut = false }
recordResult(kind, result)
callback(result)
end
end
launch("containers", { "ps", "-a", "--format", "{{json .}}" }, function(containerResult)
if containerResult.exitCode ~= 0 then
finish(containerResult)
return
end
containers = parseContainers(containerResult.stdout)
launch("images", { "images", "--format", "{{json .}}" }, function(imageResult)
if imageResult.exitCode == 0 then
images = parseImages(imageResult.stdout)
end
launch("volumes", { "volume", "ls", "--format", "{{json .}}" }, function(volumeResult)
if volumeResult.exitCode == 0 then
volumes = parseVolumes(volumeResult.stdout)
end
launch("networks", { "network", "ls", "--format", "{{json .}}" }, function(networkResult)
if networkResult.exitCode == 0 then
networks = parseNetworks(networkResult.stdout)
end
finish(containerResult)
end)
end)
end)
end)
end
local function actionResult(command, ok, message, extra)
local result = {
requestId = command.requestId,
action = command.action,
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("docker_action_result", result)
end
local function finishAction(command, result)
actionBusy = false
local ok = result ~= nil and result.exitCode == 0 and not result.timedOut
local message = trim(ok and result.stdout or (result and result.stderr))
if message == "" then
message = ok and noctalia.tr("result.success") or noctalia.tr("result.failed", { error = "unknown error" })
end
actionResult(command, ok, message)
if ok then
noctalia.notify(noctalia.tr("title"), message)
else
noctalia.notifyError(noctalia.tr("title"), message)
end
publishSnapshot()
refreshAll()
end
local function validContainerName(name)
return name == "" or name:match("^[%w][%w_.-]*$") ~= nil
end
local function validPort(port)
local number = tonumber(port)
return number ~= nil and number >= 1 and number <= 65535 and math.floor(number) == number
end
local function inspectImage(command)
if actionBusy then
actionResult(command, false, noctalia.tr("result.command_busy"))
return
end
actionBusy = true
publishSnapshot()
local started = runDocker({ "image", "inspect", "--format", "{{json .Config.ExposedPorts}}", tostring(command.image or "") }, function(result)
actionBusy = false
local port = nil
if result and result.exitCode == 0 then
local decoded = noctalia.json.decode(trim(result.stdout))
if type(decoded) == "table" then
for key in pairs(decoded) do
port = tostring(key):match("^(%d+)/")
if port ~= nil then
break
end
end
end
end
actionResult(command, result ~= nil and result.exitCode == 0, trim(result and result.stderr), { exposedPort = port })
publishSnapshot()
end)
if not started then
actionBusy = false
actionResult(command, false, "could not start Docker")
publishSnapshot()
end
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 == "inspect_image" then
inspectImage(command)
return
end
if actionBusy then
actionResult(command, false, noctalia.tr("result.command_busy"))
return
end
local args = nil
if command.action == "start_container" then
args = { "start", tostring(command.id or "") }
elseif command.action == "stop_container" then
args = { "stop", tostring(command.id or "") }
elseif command.action == "restart_container" then
args = { "restart", tostring(command.id or "") }
elseif command.action == "remove_container" then
args = { "rm", tostring(command.id or "") }
elseif command.action == "remove_image" then
args = { "rmi", tostring(command.id or "") }
elseif command.action == "remove_volume" then
args = { "volume", "rm", tostring(command.name or "") }
elseif command.action == "remove_network" then
local name = tostring(command.name or "")
if name == "bridge" or name == "host" or name == "none" then
actionResult(command, false, noctalia.tr("panel.default_network"))
return
end
args = { "network", "rm", tostring(command.id or "") }
elseif command.action == "run_image" then
local image = trim(command.image)
local name = trim(command.name)
local network = trim(command.network)
local port = trim(command.port)
if image == "" then
actionResult(command, false, noctalia.tr("result.failed", { error = "missing image" }))
return
end
if not validContainerName(name) then
actionResult(command, false, noctalia.tr("run_form.invalid_name"))
return
end
if port ~= "" and not validPort(port) then
actionResult(command, false, noctalia.tr("run_form.invalid_port"))
return
end
args = { "run", "-d" }
if name ~= "" then
table.insert(args, "--name")
table.insert(args, name)
end
if type(command.environment) == "table" then
for _, env in ipairs(command.environment) do
local key = tostring(env):match("^([^=]+)=") or ""
if key:match("^[A-Za-z_][A-Za-z0-9_]*$") == nil then
actionResult(command, false, noctalia.tr("run_form.invalid_environment", { line = tostring(env) }))
return
end
table.insert(args, "-e")
table.insert(args, tostring(env))
end
end
if port ~= "" then
table.insert(args, "-p")
table.insert(args, port .. ":" .. port)
end
if network ~= "" and network ~= "bridge" then
table.insert(args, "--network")
table.insert(args, network)
end
table.insert(args, image)
end
if args == nil then
actionResult(command, false, `Unknown Docker action: {command.action}`)
return
end
actionBusy = true
publishSnapshot()
local launched = runDocker(args, function(result) finishAction(command, result) end, 60000)
if not launched then
actionBusy = false
actionResult(command, false, noctalia.tr("result.failed", { error = "could not start Docker" }))
publishSnapshot()
end
end
noctalia.state.watch("docker_command", executeAction)
noctalia.setUpdateInterval(refreshIntervalMs())
refreshAll()
function update()
refreshAll()
end
function onConfigChanged()
noctalia.setUpdateInterval(refreshIntervalMs())
refreshAll()
end
function onIpc(event, _payload)
if event == "refresh" then
refreshAll()
end
end
Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

+116
View File
@@ -0,0 +1,116 @@
{
"title": "Mini Docker",
"widget": {
"running": "Running containers: {count}",
"unavailable": "Docker is not available",
"refresh_requested": "Docker refresh requested"
},
"settings": {
"refresh_interval": {
"label": "Refresh interval",
"description": "How often Mini Docker refreshes Docker state, in seconds."
},
"default_network": {
"label": "Default network",
"description": "Network selected when running an image."
},
"show_count": {
"label": "Show running count",
"description": "Show the number of running containers beside the Docker icon."
},
"glyph_color": {
"label": "Icon color",
"description": "Theme color used by the Docker icon."
},
"status_mode": {
"label": "Status indicator",
"description": "Choose when the status dot is visible.",
"options": {
"always": "Always",
"running_only": "Only while containers are running",
"hidden": "Hidden"
}
},
"active_color": {
"label": "Active indicator color",
"description": "Color used when at least one container is running."
},
"inactive_color": {
"label": "Inactive indicator color",
"description": "Color used when no containers are running."
}
},
"colors": {
"default": "Default",
"primary": "Primary",
"secondary": "Secondary",
"tertiary": "Tertiary",
"success": "Success",
"error": "Error",
"muted": "Muted"
},
"tabs": {
"containers": "Containers",
"images": "Images",
"volumes": "Volumes",
"networks": "Networks"
},
"actions": {
"refresh": "Refresh",
"select": "Select",
"start": "Start",
"stop": "Stop",
"restart": "Restart",
"remove": "Remove",
"run": "Run",
"cancel": "Cancel",
"close": "Close"
},
"panel": {
"loading": "Loading Docker state…",
"busy": "Docker command in progress…",
"updated": "Last updated: {time}",
"select_hint": "Select an item to see available actions.",
"no_containers": "No containers found.",
"no_images": "No images found.",
"no_volumes": "No volumes found.",
"no_networks": "No networks found.",
"showing_limit": "Showing the first {count} items.",
"default_network": "Built-in network",
"image_in_use": "Used by a container",
"image_unused": "Not used by a container"
},
"details": {
"image": "Image: {value}",
"status": "Status: {value}",
"ports": "Ports: {value}",
"id": "ID: {value}",
"created": "Created: {value}",
"size": "Size: {value}",
"driver": "Driver: {value}",
"mountpoint": "Mountpoint: {value}",
"scope": "Scope: {value}"
},
"run_form": {
"title": "Run {image}",
"container_name": "Container name (optional)",
"container_name_placeholder": "my-container",
"network": "Network",
"publish_port": "Publish a port",
"port": "Host and container port",
"port_placeholder": "8080",
"environment": "Environment variables",
"environment_placeholder": "KEY=value\nANOTHER_KEY=value",
"environment_help": "Enter one KEY=value pair per line.",
"invalid_name": "Container names may contain letters, numbers, dots, underscores, and hyphens.",
"invalid_port": "Port must be a number from 1 to 65535.",
"invalid_environment": "Invalid environment-variable line: {line}"
},
"result": {
"success": "Docker command completed.",
"failed": "Docker command failed: {error}",
"docker_missing": "The Docker CLI is not installed.",
"docker_unreachable": "Could not reach the Docker daemon.",
"command_busy": "Another Docker command is still running."
}
}
+83
View File
@@ -0,0 +1,83 @@
--!nonstrict
local PANEL_ID = "8bury/mini-docker:manager"
local snapshot = noctalia.state.get("docker_snapshot") or {
available = false,
loading = true,
runningCount = 0,
}
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 runningCount = tonumber(snapshot.runningCount) or 0
local available = snapshot.available == true
local showCount = noctalia.getConfig("show_count") ~= false
local statusMode = configString("status_mode", "always")
local activeColor = configString("active_color", "tertiary")
local inactiveColor = configString("inactive_color", "error")
local showStatus = available
and statusMode ~= "hidden"
and (statusMode ~= "running_only" or runningCount > 0)
local children = {
ui.glyph({
name = "brand-docker",
size = 16,
color = available and configString("glyph_color", "on_surface") or "on_surface_variant",
}),
}
if showCount and available then
table.insert(children, ui.label({
text = tostring(runningCount),
fontWeight = "bold",
color = "on_surface",
}))
end
if showStatus then
table.insert(children, ui.box({
width = 7,
height = 7,
radius = 4,
fill = runningCount > 0 and activeColor or inactiveColor,
}))
end
local container = barWidget.isVertical() and ui.column or ui.row
barWidget.render(container({ gap = 5, align = "center" }, children))
barWidget.setTooltip(if available
then noctalia.tr("widget.running", { count = runningCount })
else noctalia.tr("widget.unavailable"))
end
noctalia.state.watch("docker_snapshot", function(value)
if type(value) == "table" then
snapshot = value
render()
end
end)
noctalia.setUpdateInterval(5000)
render()
function update()
render()
end
function onClick()
if snapshot.available == true then
noctalia.togglePanel(PANEL_ID)
else
noctalia.notifyError(noctalia.tr("title"), snapshot.error or noctalia.tr("widget.unavailable"))
end
end
function onRightClick()
requestId += 1
noctalia.state.set("docker_command", { action = "refresh", requestId = `widget-{requestId}` })
noctalia.notify(noctalia.tr("title"), noctalia.tr("widget.refresh_requested"))
end