From 66270734c0f56f3a1dc1af764132ef95330699ea Mon Sep 17 00:00:00 2001 From: pedro <78969191+8bury@users.noreply.github.com> Date: Wed, 15 Jul 2026 21:04:13 -0300 Subject: [PATCH] feat(mini-docker): add Docker management plugin (#19) --- mini-docker/README.md | 32 ++ mini-docker/panel.luau | 589 +++++++++++++++++++++++++++++++ mini-docker/plugin.toml | 100 ++++++ mini-docker/service.luau | 466 ++++++++++++++++++++++++ mini-docker/thumbnail.webp | Bin 0 -> 48494 bytes mini-docker/translations/en.json | 116 ++++++ mini-docker/widget.luau | 83 +++++ 7 files changed, 1386 insertions(+) create mode 100644 mini-docker/README.md create mode 100644 mini-docker/panel.luau create mode 100644 mini-docker/plugin.toml create mode 100644 mini-docker/service.luau create mode 100644 mini-docker/thumbnail.webp create mode 100644 mini-docker/translations/en.json create mode 100644 mini-docker/widget.luau diff --git a/mini-docker/README.md b/mini-docker/README.md new file mode 100644 index 0000000..2d5b35f --- /dev/null +++ b/mini-docker/README.md @@ -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. diff --git a/mini-docker/panel.luau b/mini-docker/panel.luau new file mode 100644 index 0000000..c841986 --- /dev/null +++ b/mini-docker/panel.luau @@ -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 diff --git a/mini-docker/plugin.toml b/mini-docker/plugin.toml new file mode 100644 index 0000000..7655741 --- /dev/null +++ b/mini-docker/plugin.toml @@ -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" diff --git a/mini-docker/service.luau b/mini-docker/service.luau new file mode 100644 index 0000000..877f050 --- /dev/null +++ b/mini-docker/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 "") + 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 diff --git a/mini-docker/thumbnail.webp b/mini-docker/thumbnail.webp new file mode 100644 index 0000000000000000000000000000000000000000..29829c5af5c19bec1b920a670829d30caf00418f GIT binary patch literal 48494 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 F7y#B|=ivYV literal 0 HcmV?d00001 diff --git a/mini-docker/translations/en.json b/mini-docker/translations/en.json new file mode 100644 index 0000000..16ab6a2 --- /dev/null +++ b/mini-docker/translations/en.json @@ -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." + } +} diff --git a/mini-docker/widget.luau b/mini-docker/widget.luau new file mode 100644 index 0000000..107456d --- /dev/null +++ b/mini-docker/widget.luau @@ -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