feat(mini-docker): add Docker management plugin (#19)
This commit is contained in:
@@ -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.
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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 |
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user