Files
community-plugins/mini-docker/panel.luau
T

540 lines
16 KiB
Luau

--!nonstrict
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 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)
local key = keyForItem(item)
local selected = key == selectedKey
return ui.button({
key = key,
text = itemSummary(item),
glyph = itemGlyph(),
contentAlign = "start",
variant = selected and "primary" or "outline",
selected = selected,
onClick = function()
selectedKey = key
feedback = ""
render()
end,
})
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 = {}
for index = 1, #items do
table.insert(rows, itemCard(items[index]))
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
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 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