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 0000000..29829c5 Binary files /dev/null and b/mini-docker/thumbnail.webp differ 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