luci-app-dockerman: initial checkin
authorFlorian Eckert <fe@dev.tdt.de>
Wed, 22 Apr 2020 10:00:15 +0000 (12:00 +0200)
committerFlorian Eckert <fe@dev.tdt.de>
Wed, 10 Jun 2020 06:44:58 +0000 (08:44 +0200)
Inital commit version v0.5.13 from https://github.com/lisaac/luci-app-dockerman

Signed-off-by: Florian Eckert <fe@dev.tdt.de>
30 files changed:
applications/luci-app-dockerman/Makefile [new file with mode: 0644]
applications/luci-app-dockerman/luasrc/controller/dockerman.lua [new file with mode: 0644]
applications/luci-app-dockerman/luasrc/model/cbi/dockerman/container.lua [new file with mode: 0644]
applications/luci-app-dockerman/luasrc/model/cbi/dockerman/containers.lua [new file with mode: 0644]
applications/luci-app-dockerman/luasrc/model/cbi/dockerman/images.lua [new file with mode: 0644]
applications/luci-app-dockerman/luasrc/model/cbi/dockerman/networks.lua [new file with mode: 0644]
applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newcontainer.lua [new file with mode: 0644]
applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newnetwork.lua [new file with mode: 0644]
applications/luci-app-dockerman/luasrc/model/cbi/dockerman/overview.lua [new file with mode: 0644]
applications/luci-app-dockerman/luasrc/model/cbi/dockerman/volumes.lua [new file with mode: 0644]
applications/luci-app-dockerman/luasrc/model/docker.lua [new file with mode: 0644]
applications/luci-app-dockerman/luasrc/view/dockerman/apply_widget.htm [new file with mode: 0644]
applications/luci-app-dockerman/luasrc/view/dockerman/cbi/inlinebutton.htm [new file with mode: 0644]
applications/luci-app-dockerman/luasrc/view/dockerman/cbi/inlinevalue.htm [new file with mode: 0644]
applications/luci-app-dockerman/luasrc/view/dockerman/cbi/namedsection.htm [new file with mode: 0644]
applications/luci-app-dockerman/luasrc/view/dockerman/cbi/xfvalue.htm [new file with mode: 0644]
applications/luci-app-dockerman/luasrc/view/dockerman/container.htm [new file with mode: 0644]
applications/luci-app-dockerman/luasrc/view/dockerman/container_console.htm [new file with mode: 0644]
applications/luci-app-dockerman/luasrc/view/dockerman/container_file.htm [new file with mode: 0644]
applications/luci-app-dockerman/luasrc/view/dockerman/container_stats.htm [new file with mode: 0644]
applications/luci-app-dockerman/luasrc/view/dockerman/images_import.htm [new file with mode: 0644]
applications/luci-app-dockerman/luasrc/view/dockerman/images_load.htm [new file with mode: 0644]
applications/luci-app-dockerman/luasrc/view/dockerman/logs.htm [new file with mode: 0644]
applications/luci-app-dockerman/luasrc/view/dockerman/newcontainer_resolve.htm [new file with mode: 0644]
applications/luci-app-dockerman/luasrc/view/dockerman/overview.htm [new file with mode: 0644]
applications/luci-app-dockerman/root/etc/config/dockerman [new file with mode: 0644]
applications/luci-app-dockerman/root/etc/init.d/dockerman [new file with mode: 0755]
applications/luci-app-dockerman/root/etc/uci-defaults/luci-app-dockerman [new file with mode: 0755]
applications/luci-app-dockerman/root/usr/share/dockerman/dockerd-ac.lua [new file with mode: 0644]
applications/luci-app-dockerman/root/usr/share/dockerman/dockerd-config.lua [new file with mode: 0644]

diff --git a/applications/luci-app-dockerman/Makefile b/applications/luci-app-dockerman/Makefile
new file mode 100644 (file)
index 0000000..9838c70
--- /dev/null
@@ -0,0 +1,19 @@
+include $(TOPDIR)/rules.mk
+
+LUCI_TITLE:=LuCI Support for docker
+LUCI_DEPENDS:=@(aarch64||arm||x86_64) \
+       +luci-compat \
+       +luci-lib-docker \
+       +docker-ce \
+       +ttyd
+LUCI_PKGARCH:=all
+
+PKG_LICENSE:=AGPL-3.0
+PKG_MAINTAINER:=lisaac <lisaac.cn@gmail.com> \
+               Florian Eckert <fe@dev.tdt.de>
+
+PKG_VERSION:=v0.5.13
+
+include ../../luci.mk
+
+# call BuildPackage - OpenWrt buildroot signature
diff --git a/applications/luci-app-dockerman/luasrc/controller/dockerman.lua b/applications/luci-app-dockerman/luasrc/controller/dockerman.lua
new file mode 100644 (file)
index 0000000..939edd9
--- /dev/null
@@ -0,0 +1,384 @@
+--[[
+LuCI - Lua Configuration Interface
+Copyright 2019 lisaac <https://github.com/lisaac/luci-app-dockerman>
+]]--
+require "luci.util"
+local docker = require "luci.model.docker"
+-- local uci = require "luci.model.uci"
+
+module("luci.controller.dockerman",package.seeall)
+
+function index()
+
+  entry({"admin", "docker"}, firstchild(), "Docker", 40).dependent = false
+  entry({"admin","docker","overview"},cbi("dockerman/overview"),_("Overview"),0).leaf=true
+
+  local remote = luci.model.uci.cursor():get("dockerman", "local", "remote_endpoint")
+  if remote ==  nil then
+    local socket = luci.model.uci.cursor():get("dockerman", "local", "socket_path")
+    if socket and not nixio.fs.access(socket) then return end
+  elseif remote == "true" then
+    local host = luci.model.uci.cursor():get("dockerman", "local", "remote_host")
+    local port = luci.model.uci.cursor():get("dockerman", "local", "remote_port")
+    if not host or not port then return end
+  end
+
+  if (require "luci.model.docker").new():_ping().code ~= 200 then return end
+  entry({"admin","docker","containers"},form("dockerman/containers"),_("Containers"),1).leaf=true
+  entry({"admin","docker","images"},form("dockerman/images"),_("Images"),2).leaf=true
+  entry({"admin","docker","networks"},form("dockerman/networks"),_("Networks"),3).leaf=true
+  entry({"admin","docker","volumes"},form("dockerman/volumes"),_("Volumes"),4).leaf=true
+  entry({"admin","docker","events"},call("action_events"),_("Events"),5)
+  entry({"admin","docker","newcontainer"},form("dockerman/newcontainer")).leaf=true
+  entry({"admin","docker","newnetwork"},form("dockerman/newnetwork")).leaf=true
+  entry({"admin","docker","container"},form("dockerman/container")).leaf=true
+  entry({"admin","docker","container_stats"},call("action_get_container_stats")).leaf=true
+  entry({"admin","docker","container_get_archive"},call("download_archive")).leaf=true
+  entry({"admin","docker","container_put_archive"},call("upload_archive")).leaf=true
+  entry({"admin","docker","images_save"},call("save_images")).leaf=true
+  entry({"admin","docker","images_load"},call("load_images")).leaf=true
+  entry({"admin","docker","images_import"},call("import_images")).leaf=true
+  entry({"admin","docker","images_get_tags"},call("get_image_tags")).leaf=true
+  entry({"admin","docker","images_tag"},call("tag_image")).leaf=true
+  entry({"admin","docker","images_untag"},call("untag_image")).leaf=true
+  entry({"admin","docker","confirm"},call("action_confirm")).leaf=true
+end
+
+function action_events()
+  local logs = ""
+  local dk = docker.new()
+  local query ={}
+  query["until"] = os.time()
+  local events = dk:events({query = query})
+  if events.code == 200 then
+    for _, v in ipairs(events.body) do
+      if v and v.Type == "container" then
+        logs = (logs ~= "" and (logs .. "\n") or logs) .. "[" .. os.date("%Y-%m-%d %H:%M:%S", v.time) .."] "..v.Type.. " " .. (v.Action or "null") .. " Container ID:"..  (v.Actor.ID or "null") .. " Container Name:" .. (v.Actor.Attributes.name or "null")
+      elseif v.Type == "network" then
+        logs = (logs ~= "" and (logs .. "\n") or logs) .. "[" .. os.date("%Y-%m-%d %H:%M:%S", v.time) .."] "..v.Type.. " " .. v.Action .. " Container ID:"..( v.Actor.Attributes.container or "null" ) .. " Network Name:" .. (v.Actor.Attributes.name or "null") .. " Network type:".. v.Actor.Attributes.type or ""
+      elseif v.Type == "image" then
+        logs = (logs ~= "" and (logs .. "\n") or logs) .. "[" .. os.date("%Y-%m-%d %H:%M:%S", v.time) .."] "..v.Type.. " " .. v.Action .. " Image:".. (v.Actor.ID or "null").. " Image Name:" .. (v.Actor.Attributes.name or "null")
+      end
+    end
+  end
+  luci.template.render("dockerman/logs", {self={syslog = logs, title="Events"}})
+end
+
+local calculate_cpu_percent = function(d)
+  if type(d) ~= "table" then return end
+   cpu_count = tonumber(d["cpu_stats"]["online_cpus"])
+   cpu_percent = 0.0
+   cpu_delta = tonumber(d["cpu_stats"]["cpu_usage"]["total_usage"]) - tonumber(d["precpu_stats"]["cpu_usage"]["total_usage"])
+   system_delta = tonumber(d["cpu_stats"]["system_cpu_usage"]) - tonumber(d["precpu_stats"]["system_cpu_usage"])
+  if system_delta > 0.0 then
+    cpu_percent = string.format("%.2f", cpu_delta / system_delta * 100.0 * cpu_count)
+  end
+  -- return cpu_percent .. "%"
+  return cpu_percent
+end
+
+local get_memory = function(d)
+  if type(d) ~= "table" then return end
+  -- local limit = string.format("%.2f", tonumber(d["memory_stats"]["limit"]) / 1024 / 1024)
+  -- local usage = string.format("%.2f", (tonumber(d["memory_stats"]["usage"]) - tonumber(d["memory_stats"]["stats"]["total_cache"])) / 1024 / 1024)
+  -- return usage .. "MB / " .. limit.. "MB" 
+  local limit =tonumber(d["memory_stats"]["limit"])
+  local usage = tonumber(d["memory_stats"]["usage"]) - tonumber(d["memory_stats"]["stats"]["total_cache"])
+  return usage, limit
+end
+
+local get_rx_tx = function(d)
+  if type(d) ~="table" then return end
+  -- local data
+  -- if type(d["networks"]) == "table" then
+  --   for e, v in pairs(d["networks"]) do
+  --     data = (data and (data .. "<br>") or "") .. e .. "  Total Tx:" .. string.format("%.2f",(tonumber(v.tx_bytes)/1024/1024)) .. "MB  Total Rx: ".. string.format("%.2f",(tonumber(v.rx_bytes)/1024/1024)) .. "MB"
+  --   end
+  -- end
+  local data = {}
+  if type(d["networks"]) == "table" then
+    for e, v in pairs(d["networks"]) do
+      data[e] = {
+        bw_tx = tonumber(v.tx_bytes),
+        bw_rx = tonumber(v.rx_bytes)
+      }
+    end
+  end
+  return data
+end
+
+function action_get_container_stats(container_id)
+  if container_id then
+    local dk = docker.new()
+    local response = dk.containers:inspect({id = container_id})
+    if response.code == 200 and response.body.State.Running then
+      response = dk.containers:stats({id = container_id, query = {stream = false}})
+      if response.code == 200 then
+        local container_stats = response.body
+        local cpu_percent = calculate_cpu_percent(container_stats)
+        local mem_useage, mem_limit = get_memory(container_stats)
+        local bw_rxtx = get_rx_tx(container_stats)
+        luci.http.status(response.code, response.body.message)
+        luci.http.prepare_content("application/json")
+        luci.http.write_json({ 
+          cpu_percent = cpu_percent,
+          memory = {
+            mem_useage = mem_useage,
+            mem_limit = mem_limit
+          },
+          bw_rxtx = bw_rxtx
+        })
+      else
+        luci.http.status(response.code, response.body.message)
+        luci.http.prepare_content("text/plain")
+        luci.http.write(response.body.message)
+      end
+    else
+      if response.code == 200 then
+        luci.http.status(500, "container "..container_id.." not running")
+        luci.http.prepare_content("text/plain")
+        luci.http.write("Container "..container_id.." not running")
+      else
+        luci.http.status(response.code, response.body.message)
+        luci.http.prepare_content("text/plain")
+        luci.http.write(response.body.message)
+      end
+    end
+  else
+    luci.http.status(404, "No container name or id")
+    luci.http.prepare_content("text/plain")
+    luci.http.write("No container name or id")
+  end
+end
+
+function action_confirm()
+  local data = docker:read_status()
+  if data then
+    data = data:gsub("\n","<br>"):gsub(" ","&nbsp;")
+    code = 202
+    msg = data
+  else
+    code = 200
+    msg = "finish"
+    data = "finish"
+  end
+  luci.http.status(code, msg)
+  luci.http.prepare_content("application/json")
+  luci.http.write_json({info = data})
+end
+
+function download_archive()
+  local id = luci.http.formvalue("id")
+  local path = luci.http.formvalue("path")
+  local dk = docker.new()
+  local first
+
+  local cb = function(res, chunk)
+    if res.code == 200 then
+      if not first then
+        first = true
+        luci.http.header('Content-Disposition', 'inline; filename="archive.tar"')
+        luci.http.header('Content-Type', 'application\/x-tar')
+      end
+      luci.ltn12.pump.all(chunk, luci.http.write)
+    else
+      if not first then
+        first = true
+        luci.http.prepare_content("text/plain")
+      end
+      luci.ltn12.pump.all(chunk, luci.http.write)
+    end
+  end
+
+  local res = dk.containers:get_archive({id = id, query = {path = path}}, cb)
+end
+
+function upload_archive(container_id)
+  local path = luci.http.formvalue("upload-path")
+  local dk = docker.new()
+  local ltn12 = require "luci.ltn12"
+
+  local rec_send = function(sinkout)
+    luci.http.setfilehandler(function (meta, chunk, eof)
+      if chunk then
+        ltn12.pump.step(ltn12.source.string(chunk), sinkout)
+      end
+    end)
+  end
+
+  local res = dk.containers:put_archive({id = container_id, query = {path = path}, body = rec_send})
+  local msg = res and res.body and res.body.message or nil
+  luci.http.status(res.code, msg)
+  luci.http.prepare_content("application/json")
+  luci.http.write_json({message = msg})
+end
+
+function save_images(container_id)
+  local names = luci.http.formvalue("names")
+  local dk = docker.new()
+  local first
+
+  local cb = function(res, chunk)
+    if res.code == 200 then
+      if not first then
+        first = true
+        luci.http.status(res.code, res.message)
+        luci.http.header('Content-Disposition', 'inline; filename="images.tar"')
+        luci.http.header('Content-Type', 'application\/x-tar')
+      end
+      luci.ltn12.pump.all(chunk, luci.http.write)
+    else
+      if not first then
+        first = true
+        luci.http.prepare_content("text/plain")
+      end
+      luci.ltn12.pump.all(chunk, luci.http.write)
+    end
+  end
+  docker:write_status("Images: saving" .. " " .. container_id .. "...")
+  local res = dk.images:get({id = container_id, query = {names = names}}, cb)
+  docker:clear_status()
+  local msg = res and res.body and res.body.message or nil
+  luci.http.status(res.code, msg)
+  luci.http.prepare_content("application/json")
+  luci.http.write_json({message = msg})
+end
+
+function load_images()
+  local path = luci.http.formvalue("upload-path")
+  local dk = docker.new()
+  local ltn12 = require "luci.ltn12"
+
+  local rec_send = function(sinkout)
+    luci.http.setfilehandler(function (meta, chunk, eof)
+      if chunk then
+        ltn12.pump.step(ltn12.source.string(chunk), sinkout)
+      end
+    end)
+  end
+
+  docker:write_status("Images: loading...")
+  local res = dk.images:load({body = rec_send})
+  -- res.body = {"stream":"Loaded image ID: sha256:1399d3d81f80d68832e85ed6ba5f94436ca17966539ba715f661bd36f3caf08f\n"}
+  local msg = res and res.body and ( res.body.message or res.body.stream or res.body.error)or nil
+  if res.code == 200 and msg and msg:match("Loaded image ID") then
+    docker:clear_status()
+    luci.http.status(res.code, msg)
+  else
+    docker:append_status("code:" .. res.code.." ".. msg)
+    luci.http.status(300, msg)
+  end
+  luci.http.prepare_content("application/json")
+  luci.http.write_json({message = msg})
+end
+
+function import_images()
+  local src = luci.http.formvalue("src")
+  local itag = luci.http.formvalue("tag")
+  local dk = docker.new()
+  local ltn12 = require "luci.ltn12"
+  local rec_send = function(sinkout)
+    luci.http.setfilehandler(function (meta, chunk, eof)
+      if chunk then
+        ltn12.pump.step(ltn12.source.string(chunk), sinkout)
+      end
+    end)
+  end
+  docker:write_status("Images: importing".. " ".. itag .."...\n")
+  local repo = itag and itag:match("^([^:]+)")
+  local tag = itag and itag:match("^[^:]-:([^:]+)")
+  local res = dk.images:create({query = {fromSrc = src or "-", repo = repo or nil, tag = tag or nil }, body = not src and rec_send or nil}, docker.import_image_show_status_cb)
+  local msg = res and res.body and ( res.body.message )or nil
+  if not msg and #res.body == 0 then
+    -- res.body = {"status":"sha256:d5304b58e2d8cc0a2fd640c05cec1bd4d1229a604ac0dd2909f13b2b47a29285"}
+    msg = res.body.status or res.body.error
+  elseif not msg and #res.body >= 1 then
+    -- res.body = [...{"status":"sha256:d5304b58e2d8cc0a2fd640c05cec1bd4d1229a604ac0dd2909f13b2b47a29285"}]
+    msg = res.body[#res.body].status or res.body[#res.body].error
+  end
+  if res.code == 200 and msg and msg:match("sha256:") then
+    docker:clear_status()
+  else
+    docker:append_status("code:" .. res.code.." ".. msg)
+  end
+  luci.http.status(res.code, msg)
+  luci.http.prepare_content("application/json")
+  luci.http.write_json({message = msg})
+end
+
+function get_image_tags(image_id)
+  if not image_id then 
+    luci.http.status(400, "no image id")
+    luci.http.prepare_content("application/json")
+    luci.http.write_json({message = "no image id"})
+    return
+  end
+  local dk = docker.new()
+  local res = dk.images:inspect({id = image_id})
+  local msg = res and res.body and res.body.message or nil
+  luci.http.status(res.code, msg)
+  luci.http.prepare_content("application/json")
+  if res.code == 200 then
+    local tags = res.body.RepoTags
+    luci.http.write_json({tags = tags})
+  else
+    local msg = res and res.body and res.body.message or nil
+    luci.http.write_json({message = msg})
+  end
+end
+
+function tag_image(image_id)
+  local src = luci.http.formvalue("tag")
+  local image_id = image_id or luci.http.formvalue("id")
+  if type(src) ~= "string" or not image_id then
+    luci.http.status(400, "no image id or tag")
+    luci.http.prepare_content("application/json")
+    luci.http.write_json({message = "no image id or tag"})
+    return
+  end
+  local repo = src:match("^([^:]+)")
+  local tag = src:match("^[^:]-:([^:]+)")
+  local dk = docker.new()
+  local res = dk.images:tag({id = image_id, query={repo=repo, tag=tag}})
+  local msg = res and res.body and res.body.message or nil
+  luci.http.status(res.code, msg)
+  luci.http.prepare_content("application/json")
+  if res.code == 201 then
+    local tags = res.body.RepoTags
+    luci.http.write_json({tags = tags})
+  else
+    local msg = res and res.body and res.body.message or nil
+    luci.http.write_json({message = msg})
+  end
+end
+
+function untag_image(tag)
+  local tag = tag or luci.http.formvalue("tag")
+  if not tag then 
+    luci.http.status(400, "no tag name")
+    luci.http.prepare_content("application/json")
+    luci.http.write_json({message = "no tag name"})
+    return
+  end
+  local dk = docker.new()
+  local res = dk.images:inspect({name = tag})
+  if res.code == 200 then
+    local tags = res.body.RepoTags
+    if #tags > 1 then
+      local r = dk.images:remove({name = tag})
+      local msg = r and r.body and r.body.message or nil
+      luci.http.status(r.code, msg)
+      luci.http.prepare_content("application/json")
+      luci.http.write_json({message = msg})
+    else
+      luci.http.status(500, "Cannot remove the last tag")
+      luci.http.prepare_content("application/json")
+      luci.http.write_json({message = "Cannot remove the last tag"})
+    end
+  else
+    local msg = res and res.body and res.body.message or nil
+    luci.http.status(res.code, msg)
+    luci.http.prepare_content("application/json")
+    luci.http.write_json({message = msg})
+  end
+end
diff --git a/applications/luci-app-dockerman/luasrc/model/cbi/dockerman/container.lua b/applications/luci-app-dockerman/luasrc/model/cbi/dockerman/container.lua
new file mode 100644 (file)
index 0000000..62f00e7
--- /dev/null
@@ -0,0 +1,588 @@
+--[[
+LuCI - Lua Configuration Interface
+Copyright 2019 lisaac <https://github.com/lisaac/luci-app-dockerman>
+]]--
+
+require "luci.util"
+local docker = require "luci.model.docker"
+local dk = docker.new()
+container_id = arg[1]
+local action = arg[2] or "info"
+
+local images, networks, container_info
+if not container_id then return end
+local res = dk.containers:inspect({id = container_id})
+if res.code < 300 then container_info = res.body else return end
+res = dk.networks:list()
+if res.code < 300 then networks = res.body else return end
+
+local get_ports = function(d)
+  local data
+  if d.HostConfig and d.HostConfig.PortBindings then
+    for inter, out in pairs(d.HostConfig.PortBindings) do
+      data = (data and (data .. "<br>") or "") .. out[1]["HostPort"] .. ":" .. inter
+    end
+  end
+  return data
+end
+
+local get_env = function(d)
+  local data
+  if d.Config and d.Config.Env then
+    for _,v in ipairs(d.Config.Env) do
+      data = (data and (data .. "<br>") or "") .. v
+    end
+  end
+  return data
+end
+
+local get_command = function(d)
+  local data
+  if d.Config and d.Config.Cmd then
+    for _,v in ipairs(d.Config.Cmd) do
+      data = (data and (data .. " ") or "") .. v
+    end
+  end
+  return data
+end
+
+local get_mounts = function(d)
+  local data
+  if d.Mounts then
+    for _,v in ipairs(d.Mounts) do
+      local v_sorce_d, v_dest_d
+      local v_sorce = ""
+      local v_dest = ""
+      for v_sorce_d in v["Source"]:gmatch('[^/]+') do
+        if v_sorce_d and #v_sorce_d > 12 then
+          v_sorce = v_sorce .. "/" .. v_sorce_d:sub(1,12) .. "..."
+        else
+          v_sorce = v_sorce .."/".. v_sorce_d
+        end
+      end
+      for v_dest_d in v["Destination"]:gmatch('[^/]+') do
+        if v_dest_d and #v_dest_d > 12 then
+          v_dest = v_dest .. "/" .. v_dest_d:sub(1,12) .. "..."
+        else
+          v_dest = v_dest .."/".. v_dest_d
+        end
+      end
+      data = (data and (data .. "<br>") or "") .. v_sorce .. ":" .. v["Destination"] .. (v["Mode"] ~= "" and (":" .. v["Mode"]) or "")
+    end
+  end
+  return data
+end
+
+local get_device = function(d)
+  local data
+  if d.HostConfig and d.HostConfig.Devices then
+    for _,v in ipairs(d.HostConfig.Devices) do
+      data = (data and (data .. "<br>") or "") .. v["PathOnHost"] .. ":" .. v["PathInContainer"] .. (v["CgroupPermissions"] ~= "" and (":" .. v["CgroupPermissions"]) or "")
+    end
+  end
+  return data
+end
+
+local get_links = function(d)
+  local data
+  if d.HostConfig and d.HostConfig.Links then
+    for _,v in ipairs(d.HostConfig.Links) do
+      data = (data and (data .. "<br>") or "") .. v
+    end
+  end
+  return data
+end
+
+local get_tmpfs = function(d)
+  local data
+  if d.HostConfig and d.HostConfig.Tmpfs then
+    for k, v in pairs(d.HostConfig.Tmpfs) do
+      data = (data and (data .. "<br>") or "") .. k .. (v~="" and ":" or "")..v
+    end
+  end
+  return data
+end
+
+local get_dns = function(d)
+  local data
+  if d.HostConfig and d.HostConfig.Dns then
+    for _, v in ipairs(d.HostConfig.Dns) do
+      data = (data and (data .. "<br>") or "") .. v
+    end
+  end
+  return data
+end
+
+local get_sysctl = function(d)
+  local data
+  if d.HostConfig and d.HostConfig.Sysctls then
+    for k, v in pairs(d.HostConfig.Sysctls) do
+      data = (data and (data .. "<br>") or "") .. k..":"..v
+    end
+  end
+  return data
+end
+
+local get_networks = function(d)
+  local data={}
+  if d.NetworkSettings and d.NetworkSettings.Networks and type(d.NetworkSettings.Networks) == "table" then
+    for k,v in pairs(d.NetworkSettings.Networks) do
+      data[k] = v.IPAddress or ""
+    end
+  end
+  return data
+end
+
+
+local start_stop_remove = function(m, cmd)
+  docker:clear_status()
+  docker:append_status("Containers: " .. cmd .. " " .. container_id .. "...")
+  local res
+  if cmd ~= "upgrade" then
+    res = dk.containers[cmd](dk, {id = container_id})
+  else
+    res = dk.containers_upgrade(dk, {id = container_id})
+  end
+  if res and res.code >= 300 then
+    docker:append_status("code:" .. res.code.." ".. (res.body.message and res.body.message or res.message))
+    luci.http.redirect(luci.dispatcher.build_url("admin/docker/container/"..container_id))
+  else
+    docker:clear_status()
+    if cmd ~= "remove" and cmd ~= "upgrade" then
+      luci.http.redirect(luci.dispatcher.build_url("admin/docker/container/"..container_id))
+    else
+      luci.http.redirect(luci.dispatcher.build_url("admin/docker/containers"))
+    end
+  end
+end
+
+m=SimpleForm("docker", container_info.Name:sub(2), translate("Docker Container") )
+m.redirect = luci.dispatcher.build_url("admin/docker/containers")
+-- m:append(Template("dockerman/container"))
+docker_status = m:section(SimpleSection)
+docker_status.template = "dockerman/apply_widget"
+docker_status.err=docker:read_status()
+docker_status.err=docker_status.err and docker_status.err:gsub("\n","<br>"):gsub(" ","&nbsp;")
+if docker_status.err then docker:clear_status() end
+
+
+action_section = m:section(Table,{{}})
+action_section.notitle=true
+action_section.rowcolors=false
+action_section.template = "cbi/nullsection"
+
+btnstart=action_section:option(Button, "_start")
+btnstart.template = "dockerman/cbi/inlinebutton"
+btnstart.inputtitle=translate("Start")
+btnstart.inputstyle = "apply"
+btnstart.forcewrite = true
+btnrestart=action_section:option(Button, "_restart")
+btnrestart.template = "dockerman/cbi/inlinebutton"
+btnrestart.inputtitle=translate("Restart")
+btnrestart.inputstyle = "reload"
+btnrestart.forcewrite = true
+btnstop=action_section:option(Button, "_stop")
+btnstop.template = "dockerman/cbi/inlinebutton"
+btnstop.inputtitle=translate("Stop")
+btnstop.inputstyle = "reset"
+btnstop.forcewrite = true
+btnkill=action_section:option(Button, "_kill")
+btnkill.template = "dockerman/cbi/inlinebutton"
+btnkill.inputtitle=translate("Kill")
+btnkill.inputstyle = "reset"
+btnkill.forcewrite = true
+btnupgrade=action_section:option(Button, "_upgrade")
+btnupgrade.template = "dockerman/cbi/inlinebutton"
+btnupgrade.inputtitle=translate("Upgrade")
+btnupgrade.inputstyle = "reload"
+btnstop.forcewrite = true
+btnduplicate=action_section:option(Button, "_duplicate")
+btnduplicate.template = "dockerman/cbi/inlinebutton"
+btnduplicate.inputtitle=translate("Duplicate/Edit")
+btnduplicate.inputstyle = "add"
+btnstop.forcewrite = true
+btnremove=action_section:option(Button, "_remove")
+btnremove.template = "dockerman/cbi/inlinebutton"
+btnremove.inputtitle=translate("Remove")
+btnremove.inputstyle = "remove"
+btnremove.forcewrite = true
+
+btnstart.write = function(self, section)
+  start_stop_remove(m,"start")
+end
+btnrestart.write = function(self, section)
+  start_stop_remove(m,"restart")
+end
+btnupgrade.write = function(self, section)
+  start_stop_remove(m,"upgrade")
+end
+btnremove.write = function(self, section)
+  start_stop_remove(m,"remove")
+end
+btnstop.write = function(self, section)
+  start_stop_remove(m,"stop")
+end
+btnkill.write = function(self, section)
+  start_stop_remove(m,"kill")
+end
+btnduplicate.write = function(self, section)
+  luci.http.redirect(luci.dispatcher.build_url("admin/docker/newcontainer/duplicate/"..container_id))
+end
+
+tab_section = m:section(SimpleSection)
+tab_section.template = "dockerman/container"
+
+if action == "info" then 
+  m.submit = false
+  m.reset  = false
+  table_info = {
+    ["01name"] = {_key = translate("Name"),  _value = container_info.Name:sub(2)  or "-", _button=translate("Update")},
+    ["02id"] = {_key = translate("ID"),  _value = container_info.Id  or "-"},
+    ["03image"] = {_key = translate("Image"),  _value = container_info.Config.Image .. "<br>" .. container_info.Image},
+    ["04status"] = {_key = translate("Status"),  _value = container_info.State and container_info.State.Status  or "-"},
+    ["05created"] = {_key = translate("Created"),  _value = container_info.Created  or "-"},
+  }
+  table_info["06start"] = container_info.State.Status == "running" and {_key = translate("Start Time"),  _value = container_info.State and container_info.State.StartedAt or "-"} or {_key = translate("Finish Time"),  _value = container_info.State and container_info.State.FinishedAt or "-"}
+  table_info["07healthy"] = {_key = translate("Healthy"),  _value = container_info.State and container_info.State.Health and container_info.State.Health.Status or "-"}
+  table_info["08restart"] = {_key = translate("Restart Policy"),  _value = container_info.HostConfig and container_info.HostConfig.RestartPolicy and container_info.HostConfig.RestartPolicy.Name or "-", _button=translate("Update")}
+  table_info["081user"] = {_key = translate("User"),  _value = container_info.Config and (container_info.Config.User ~="" and container_info.Config.User or "-") or "-"}
+  table_info["09mount"] = {_key = translate("Mount/Volume"),  _value = get_mounts(container_info)  or "-"}
+  table_info["10cmd"] = {_key = translate("Command"),  _value = get_command(container_info) or "-"}
+  table_info["11env"] = {_key = translate("Env"),  _value = get_env(container_info)  or "-"}
+  table_info["12ports"] = {_key = translate("Ports"),  _value = get_ports(container_info) or "-"}
+  table_info["13links"] = {_key = translate("Links"),  _value = get_links(container_info)  or "-"}
+  table_info["14device"] = {_key = translate("Device"),  _value = get_device(container_info)  or "-"}
+  table_info["15tmpfs"] = {_key = translate("Tmpfs"),  _value = get_tmpfs(container_info)  or "-"}
+  table_info["16dns"] = {_key = translate("DNS"),  _value = get_dns(container_info)  or "-"}
+  table_info["17sysctl"] = {_key = translate("Sysctl"),  _value = get_sysctl(container_info)  or "-"}
+  info_networks = get_networks(container_info)
+  list_networks = {}
+  for _, v in ipairs (networks) do
+    if v.Name then
+      local parent = v.Options and v.Options.parent or nil
+      local ip = v.IPAM and v.IPAM.Config and v.IPAM.Config[1] and v.IPAM.Config[1].Subnet or nil
+      ipv6 =  v.IPAM and v.IPAM.Config and v.IPAM.Config[2] and v.IPAM.Config[2].Subnet or nil
+      local network_name = v.Name .. " | " .. v.Driver  .. (parent and (" | " .. parent) or "") .. (ip and (" | " .. ip) or "").. (ipv6 and (" | " .. ipv6) or "")
+      list_networks[v.Name] = network_name
+    end
+  end
+
+  if type(info_networks)== "table" then
+    for k,v in pairs(info_networks) do
+      table_info["14network"..k] = {
+        _key = translate("Network"),  _value = k.. (v~="" and (" | ".. v) or ""), _button=translate("Disconnect")
+      }
+      list_networks[k]=nil
+    end
+  end
+
+  table_info["15connect"] = {_key = translate("Connect Network"),  _value = list_networks ,_opts = "", _button=translate("Connect")}
+
+
+  d_info = m:section(Table,table_info)
+  d_info.nodescr=true
+  d_info.formvalue=function(self, section)
+    return table_info
+  end
+  dv_key = d_info:option(DummyValue, "_key", translate("Info"))
+  dv_key.width = "20%"
+  dv_value = d_info:option(ListValue, "_value")
+  dv_value.render = function(self, section, scope)
+    if table_info[section]._key == translate("Name") then
+      self:reset_values()
+      self.template = "cbi/value"
+      self.size = 30
+      self.keylist = {}
+      self.vallist = {}
+      self.default=table_info[section]._value
+      Value.render(self, section, scope)
+    elseif table_info[section]._key == translate("Restart Policy") then
+      self.template = "cbi/lvalue"
+      self:reset_values()
+      self.size = nil
+      self:value("no", "No")
+      self:value("unless-stopped", "Unless stopped")
+      self:value("always", "Always")
+      self:value("on-failure", "On failure")
+      self.default=table_info[section]._value
+      ListValue.render(self, section, scope)
+    elseif table_info[section]._key == translate("Connect Network") then
+      self.template = "cbi/lvalue"
+      self:reset_values()
+      self.size = nil
+      for k,v in pairs(list_networks) do
+        if k ~= "host" then
+          self:value(k,v)
+        end
+      end
+      self.default=table_info[section]._value
+      ListValue.render(self, section, scope)
+    else
+      self:reset_values()
+      self.rawhtml=true
+      self.template = "cbi/dvalue"
+      self.default=table_info[section]._value
+      DummyValue.render(self, section, scope)
+    end
+  end
+  dv_value.forcewrite = true -- for write function using simpleform 
+  dv_value.write = function(self, section, value)
+    table_info[section]._value=value
+  end
+  dv_value.validate = function(self, value)
+    return value
+  end
+  dv_opts = d_info:option(Value, "_opts")
+  dv_opts.forcewrite = true -- for write function using simpleform 
+  dv_opts.write = function(self, section, value)
+
+    table_info[section]._opts=value
+  end
+  dv_opts.validate = function(self, value)
+    return value
+  end
+  dv_opts.render = function(self, section, scope)
+    if table_info[section]._key==translate("Connect Network") then
+      self.template = "cbi/value"
+      self.keylist = {}
+      self.vallist = {}
+      self.placeholder = "10.1.1.254"
+      self.datatype = "ip4addr"
+      self.default=table_info[section]._opts
+      Value.render(self, section, scope)
+    else
+      self.rawhtml=true
+      self.template = "cbi/dvalue"
+      self.default=table_info[section]._opts
+      DummyValue.render(self, section, scope)
+    end
+  end
+  btn_update = d_info:option(Button, "_button")
+  btn_update.forcewrite = true
+  btn_update.render = function(self, section, scope)
+    if table_info[section]._button and table_info[section]._value ~= nil then
+      btn_update.inputtitle=table_info[section]._button
+      self.template = "cbi/button"
+      self.inputstyle = "edit"
+      Button.render(self, section, scope)
+    else 
+      self.template = "cbi/dvalue"
+      self.default=""
+      DummyValue.render(self, section, scope)
+    end
+  end
+  btn_update.write = function(self, section, value)
+    local res
+    docker:clear_status()
+    if section == "01name" then
+      docker:append_status("Containers: rename " .. container_id .. "...")
+      local new_name = table_info[section]._value
+      res = dk.containers:rename({id = container_id, query = {name=new_name}})
+    elseif section == "08restart" then
+      docker:append_status("Containers: update " .. container_id .. "...")
+      local new_restart = table_info[section]._value
+      res = dk.containers:update({id = container_id, body = {RestartPolicy = {Name = new_restart}}})
+    elseif table_info[section]._key == translate("Network") then
+      local _,_,leave_network = table_info[section]._value:find("(.-) | .+")
+      leave_network = leave_network or table_info[section]._value
+      docker:append_status("Network: disconnect " .. leave_network .. container_id .. "...")
+      res = dk.networks:disconnect({name = leave_network, body = {Container = container_id}})
+    elseif section == "15connect" then
+      local connect_network = table_info[section]._value
+      local network_opiton
+      if connect_network ~= "none" and connect_network ~= "bridge" and connect_network ~= "host" then
+        network_opiton = table_info[section]._opts ~= "" and {
+            IPAMConfig={
+              IPv4Address=table_info[section]._opts
+            }
+        } or nil
+      end
+      docker:append_status("Network: connect " .. connect_network .. container_id .. "...")
+      res = dk.networks:connect({name = connect_network, body = {Container = container_id, EndpointConfig= network_opiton}})
+    end
+    if res and res.code > 300 then
+      docker:append_status("code:" .. res.code.." ".. (res.body.message and res.body.message or res.message))
+    else
+      docker:clear_status()
+    end
+    luci.http.redirect(luci.dispatcher.build_url("admin/docker/container/"..container_id.."/info"))
+  end
+  
+-- info end
+elseif action == "resources" then
+  local resources_section= m:section(SimpleSection)
+  d = resources_section:option( Value, "cpus", translate("CPUs"), translate("Number of CPUs. Number is a fractional number. 0.000 means no limit."))
+  d.placeholder = "1.5"
+  d.rmempty = true
+  d.datatype="ufloat"
+  d.default = container_info.HostConfig.NanoCpus / (10^9)
+
+  d = resources_section:option(Value, "cpushares", translate("CPU Shares Weight"), translate("CPU shares relative weight, if 0 is set, the system will ignore the value and use the default of 1024."))
+  d.placeholder = "1024"
+  d.rmempty = true
+  d.datatype="uinteger"
+  d.default = container_info.HostConfig.CpuShares
+
+  d = resources_section:option(Value, "memory", translate("Memory"), translate("Memory limit (format: <number>[<unit>]). Number is a positive integer. Unit can be one of b, k, m, or g. Minimum is 4M."))
+  d.placeholder = "128m"
+  d.rmempty = true
+  d.default = container_info.HostConfig.Memory ~=0 and ((container_info.HostConfig.Memory / 1024 /1024) .. "M") or 0
+
+  d = resources_section:option(Value, "blkioweight", translate("Block IO Weight"), translate("Block IO weight (relative weight) accepts a weight value between 10 and 1000."))
+  d.placeholder = "500"
+  d.rmempty = true
+  d.datatype="uinteger"
+  d.default = container_info.HostConfig.BlkioWeight
+
+  m.handle = function(self, state, data)
+    if state == FORM_VALID then
+      local memory = data.memory
+      if memory and memory ~= 0 then
+        _,_,n,unit = memory:find("([%d%.]+)([%l%u]+)")
+        if n then
+          unit = unit and unit:sub(1,1):upper() or "B"
+          if  unit == "M" then
+            memory = tonumber(n) * 1024 * 1024
+          elseif unit == "G" then
+            memory = tonumber(n) * 1024 * 1024 * 1024
+          elseif unit == "K" then
+            memory = tonumber(n) * 1024
+          else
+            memory = tonumber(n)
+          end
+        end
+      end
+      request_body = {
+        BlkioWeight = tonumber(data.blkioweight),
+        NanoCPUs = tonumber(data.cpus)*10^9,
+        Memory = tonumber(memory),
+        CpuShares = tonumber(data.cpushares)
+        }
+      docker:write_status("Containers: update " .. container_id .. "...")
+      local res = dk.containers:update({id = container_id, body = request_body})
+      if res and res.code >= 300 then
+        docker:append_status("code:" .. res.code.." ".. (res.body.message and res.body.message or res.message))
+      else
+        docker:clear_status()
+      end
+      luci.http.redirect(luci.dispatcher.build_url("admin/docker/container/"..container_id.."/resources"))
+    end
+  end
+elseif action == "file" then
+  local filesection= m:section(SimpleSection)
+  m.submit = false
+  m.reset  = false
+  filesection.template = "dockerman/container_file"
+  filesection.container = container_id
+elseif action == "inspect" then
+  local inspectsection= m:section(SimpleSection)
+  inspectsection.syslog = luci.jsonc.stringify(container_info, true)
+  inspectsection.title = translate("Container Inspect")
+  inspectsection.template = "dockerman/logs"
+  m.submit = false
+  m.reset  = false
+elseif action == "logs" then
+  local logsection= m:section(SimpleSection)
+  local logs = ""
+  local query ={
+    stdout = 1,
+    stderr = 1,
+    tail = 1000
+  }
+  local logs = dk.containers:logs({id = container_id, query = query})
+  if logs.code == 200 then
+    logsection.syslog=logs.body
+  else
+    logsection.syslog="Get Logs ERROR\n"..logs.code..": "..logs.body
+  end
+  logsection.title=translate("Container Logs")
+  logsection.template = "dockerman/logs"
+  m.submit = false
+  m.reset  = false
+elseif action == "console" then
+  m.submit = false
+  m.reset  = false
+  local cmd_docker = luci.util.exec("which docker"):match("^.+docker") or nil
+  local cmd_ttyd = luci.util.exec("which ttyd"):match("^.+ttyd") or nil
+  if cmd_docker and cmd_ttyd and container_info.State.Status == "running" then
+    local consolesection= m:section(SimpleSection)
+    local cmd = "/bin/sh"
+    local uid
+    local vcommand = consolesection:option(Value, "command", translate("Command"))
+    vcommand:value("/bin/sh", "/bin/sh")
+    vcommand:value("/bin/ash", "/bin/ash")
+    vcommand:value("/bin/bash", "/bin/bash")
+    vcommand.default = "/bin/sh"
+    vcommand.forcewrite = true
+    vcommand.write = function(self, section, value)
+      cmd = value
+    end
+    local vuid = consolesection:option(Value, "uid", translate("UID"))
+    vuid.forcewrite = true
+    vuid.write = function(self, section, value)
+      uid = value
+    end
+    local btn_connect = consolesection:option(Button, "connect")
+    btn_connect.render = function(self, section, scope)
+      self.inputstyle = "add"
+      self.title = " "
+      self.inputtitle = translate("Connect")
+      Button.render(self, section, scope)
+    end
+    btn_connect.write = function(self, section)
+      local cmd_docker = luci.util.exec("which docker"):match("^.+docker") or nil
+      local cmd_ttyd = luci.util.exec("which ttyd"):match("^.+ttyd") or nil
+      if not cmd_docker or not cmd_ttyd or cmd_docker:match("^%s+$") or cmd_ttyd:match("^%s+$") then return end
+      local kill_ttyd = 'netstat -lnpt | grep ":7682[ \t].*ttyd$" | awk \'{print $NF}\' | awk -F\'/\' \'{print "kill -9 " $1}\' | sh > /dev/null'
+      luci.util.exec(kill_ttyd)
+      local hosts
+      local uci = (require "luci.model.uci").cursor()
+      local remote = uci:get("dockerman", "local", "remote_endpoint")
+      local socket_path = (remote == "false" or not remote) and  uci:get("dockerman", "local", "socket_path") or nil
+      local host = (remote == "true") and uci:get("dockerman", "local", "remote_host") or nil
+      local port = (remote == "true") and uci:get("dockerman", "local", "remote_port") or nil
+      if remote and host and port then
+        hosts = host .. ':'.. port
+      elseif socket_path then
+        hosts = "unix://" .. socket_path
+      else
+        return
+      end
+      local start_cmd = cmd_ttyd .. ' -d 2 --once -p 7682 '.. cmd_docker .. ' -H "'.. hosts ..'" exec -it ' .. (uid and uid ~= "" and (" -u ".. uid  .. ' ') or "").. container_id .. ' ' .. cmd .. ' &'
+      os.execute(start_cmd)
+      local console = consolesection:option(DummyValue, "console")
+      console.container_id = container_id
+      console.template = "dockerman/container_console"
+    end
+  end
+elseif action == "stats" then
+  local response = dk.containers:top({id = container_id, query = {ps_args="-aux"}})
+  local container_top
+  if response.code == 200 then
+    container_top=response.body
+  else
+    response = dk.containers:top({id = container_id})
+    if response.code == 200 then
+      container_top=response.body
+    end
+  end
+
+  if type(container_top) == "table" then
+    container_top=response.body
+    stat_section = m:section(SimpleSection)
+    stat_section.container_id = container_id
+    stat_section.template = "dockerman/container_stats"
+    table_stats = {cpu={key=translate("CPU Useage"),value='-'},memory={key=translate("Memory Useage"),value='-'}}
+    stat_section = m:section(Table, table_stats, translate("Stats"))
+    stat_section:option(DummyValue, "key", translate("Stats")).width="33%"
+    stat_section:option(DummyValue, "value")
+    top_section= m:section(Table, container_top.Processes, translate("TOP"))
+    for i, v in ipairs(container_top.Titles) do
+      top_section:option(DummyValue, i, translate(v))
+  end
+end
+m.submit = false
+m.reset  = false
+end
+
+return m
\ No newline at end of file
diff --git a/applications/luci-app-dockerman/luasrc/model/cbi/dockerman/containers.lua b/applications/luci-app-dockerman/luasrc/model/cbi/dockerman/containers.lua
new file mode 100644 (file)
index 0000000..a4f925d
--- /dev/null
@@ -0,0 +1,195 @@
+--[[
+LuCI - Lua Configuration Interface
+Copyright 2019 lisaac <https://github.com/lisaac/luci-app-dockerman>
+]]--
+
+require "luci.util"
+local http = require "luci.http"
+local uci = luci.model.uci.cursor()
+local docker = require "luci.model.docker"
+local dk = docker.new()
+
+local images, networks, containers
+local res = dk.images:list()
+if res.code <300 then images = res.body else return end
+res = dk.networks:list()
+if res.code <300 then networks = res.body else return end
+res = dk.containers:list({query = {all=true}})
+if res.code <300 then containers = res.body else return end
+
+local urlencode = luci.http.protocol and luci.http.protocol.urlencode or luci.util.urlencode
+
+function get_containers()
+  local data = {}
+  if type(containers) ~= "table" then return nil end
+  for i, v in ipairs(containers) do
+    local index = v.Created .. v.Id
+    data[index]={}
+    data[index]["_selected"] = 0
+    data[index]["_id"] = v.Id:sub(1,12)
+    data[index]["name"] = v.Names[1]:sub(2)
+    data[index]["_name"] = '<a href='..luci.dispatcher.build_url("admin/docker/container/"..v.Id)..'  class="dockerman_link" title="'..translate("Container detail")..'">'.. v.Names[1]:sub(2).."</a>"
+    data[index]["_status"] = v.Status
+    if v.Status:find("^Up") then
+      data[index]["_status"] = '<font color="green">'.. data[index]["_status"] .. "</font>"
+    else
+      data[index]["_status"] = '<font color="red">'.. data[index]["_status"] .. "</font>"
+    end
+    if (type(v.NetworkSettings) == "table" and type(v.NetworkSettings.Networks) == "table") then
+      for networkname, netconfig in pairs(v.NetworkSettings.Networks) do
+        data[index]["_network"] = (data[index]["_network"] ~= nil and (data[index]["_network"] .." | ") or "").. networkname .. (netconfig.IPAddress ~= "" and (": " .. netconfig.IPAddress) or "")
+      end
+    end
+    -- networkmode = v.HostConfig.NetworkMode ~= "default" and v.HostConfig.NetworkMode or "bridge"
+    -- data[index]["_network"] = v.NetworkSettings.Networks[networkmode].IPAddress or nil
+    -- local _, _, image = v.Image:find("^sha256:(.+)")
+    -- if image ~= nil then
+    --   image=image:sub(1,12)
+    -- end
+    if v.Ports and next(v.Ports) ~= nil then
+      data[index]["_ports"] = nil
+      for _,v2 in ipairs(v.Ports) do
+        data[index]["_ports"] = (data[index]["_ports"] and (data[index]["_ports"] .. ", ") or "")
+        .. ((v2.PublicPort and v2.Type and v2.Type == "tcp") and ('<a href="javascript:void(0);" onclick="window.open((window.location.origin.match(/^(.+):\\d+$/) && window.location.origin.match(/^(.+):\\d+$/)[1] || window.location.origin) + \':\' + '.. v2.PublicPort ..', \'_blank\');">') or "")
+        .. (v2.PublicPort and (v2.PublicPort .. ":") or "")  .. (v2.PrivatePort and (v2.PrivatePort .."/") or "") .. (v2.Type and v2.Type or "")
+        .. ((v2.PublicPort and v2.Type and v2.Type == "tcp")and "</a>" or "")
+      end
+    end
+    for ii,iv in ipairs(images) do
+      if iv.Id == v.ImageID then
+        data[index]["_image"] = iv.RepoTags and iv.RepoTags[1] or (iv.RepoDigests[1]:gsub("(.-)@.+", "%1") .. ":<none>")
+      end
+    end
+    
+    data[index]["_image_id"] = v.ImageID:sub(8,20)
+    data[index]["_command"] = v.Command
+  end
+  return data
+end
+
+local c_lists = get_containers()
+-- list Containers
+-- m = Map("docker", translate("Docker"))
+m = SimpleForm("docker", translate("Docker"))
+m.submit=false
+m.reset=false
+
+docker_status = m:section(SimpleSection)
+docker_status.template = "dockerman/apply_widget"
+docker_status.err=docker:read_status()
+docker_status.err=docker_status.err and docker_status.err:gsub("\n","<br>"):gsub(" ","&nbsp;")
+if docker_status.err then docker:clear_status() end
+
+c_table = m:section(Table, c_lists, translate("Containers"))
+c_table.nodescr=true
+-- v.template = "cbi/tblsection"
+-- v.sortable = true
+container_selecter = c_table:option(Flag, "_selected","")
+container_selecter.disabled = 0
+container_selecter.enabled = 1
+container_selecter.default = 0
+
+container_id = c_table:option(DummyValue, "_id", translate("ID"))
+container_id.width="10%"
+container_name = c_table:option(DummyValue, "_name", translate("Container Name"))
+container_name.rawhtml = true
+container_status = c_table:option(DummyValue, "_status", translate("Status"))
+container_status.width="15%"
+container_status.rawhtml=true
+container_ip = c_table:option(DummyValue, "_network", translate("Network"))
+container_ip.width="15%"
+container_ports = c_table:option(DummyValue, "_ports", translate("Ports"))
+container_ports.width="10%"
+container_ports.rawhtml = true
+container_image = c_table:option(DummyValue, "_image", translate("Image"))
+container_image.width="10%"
+container_command = c_table:option(DummyValue, "_command", translate("Command"))
+container_command.width="20%"
+
+container_selecter.write=function(self, section, value)
+  c_lists[section]._selected = value
+end
+
+local start_stop_remove = function(m,cmd)
+  local c_selected = {}
+  -- 遍历table中sectionid
+  local c_table_sids = c_table:cfgsections()
+  for _, c_table_sid in ipairs(c_table_sids) do
+    -- 得到选中项的名字
+    if c_lists[c_table_sid]._selected == 1 then
+      c_selected[#c_selected+1] = c_lists[c_table_sid].name --container_name:cfgvalue(c_table_sid)
+    end
+  end
+  if #c_selected >0 then
+    docker:clear_status()
+    local success = true
+    for _,cont in ipairs(c_selected) do
+      docker:append_status("Containers: " .. cmd .. " " .. cont .. "...")
+      local res = dk.containers[cmd](dk, {id = cont})
+      if res and res.code >= 300 then
+        success = false
+        docker:append_status("code:" .. res.code.." ".. (res.body.message and res.body.message or res.message).. "\n")
+      else
+        docker:append_status("done\n")
+      end
+    end
+    if success then docker:clear_status() end
+    luci.http.redirect(luci.dispatcher.build_url("admin/docker/containers"))
+  end
+end
+
+action_section = m:section(Table,{{}})
+action_section.notitle=true
+action_section.rowcolors=false
+action_section.template="cbi/nullsection"
+
+btnnew=action_section:option(Button, "_new")
+btnnew.inputtitle= translate("New")
+btnnew.template = "dockerman/cbi/inlinebutton"
+btnnew.inputstyle = "add"
+btnnew.forcewrite = true
+btnstart=action_section:option(Button, "_start")
+btnstart.template = "dockerman/cbi/inlinebutton"
+btnstart.inputtitle=translate("Start")
+btnstart.inputstyle = "apply"
+btnstart.forcewrite = true
+btnrestart=action_section:option(Button, "_restart")
+btnrestart.template = "dockerman/cbi/inlinebutton"
+btnrestart.inputtitle=translate("Restart")
+btnrestart.inputstyle = "reload"
+btnrestart.forcewrite = true
+btnstop=action_section:option(Button, "_stop")
+btnstop.template = "dockerman/cbi/inlinebutton"
+btnstop.inputtitle=translate("Stop")
+btnstop.inputstyle = "reset"
+btnstop.forcewrite = true
+btnkill=action_section:option(Button, "_kill")
+btnkill.template = "dockerman/cbi/inlinebutton"
+btnkill.inputtitle=translate("Kill")
+btnkill.inputstyle = "reset"
+btnkill.forcewrite = true
+btnremove=action_section:option(Button, "_remove")
+btnremove.template = "dockerman/cbi/inlinebutton"
+btnremove.inputtitle=translate("Remove")
+btnremove.inputstyle = "remove"
+btnremove.forcewrite = true
+btnnew.write = function(self, section)
+  luci.http.redirect(luci.dispatcher.build_url("admin/docker/newcontainer"))
+end
+btnstart.write = function(self, section)
+  start_stop_remove(m,"start")
+end
+btnrestart.write = function(self, section)
+  start_stop_remove(m,"restart")
+end
+btnremove.write = function(self, section)
+  start_stop_remove(m,"remove")
+end
+btnstop.write = function(self, section)
+  start_stop_remove(m,"stop")
+end
+btnkill.write = function(self, section)
+  start_stop_remove(m,"kill")
+end
+
+return m
\ No newline at end of file
diff --git a/applications/luci-app-dockerman/luasrc/model/cbi/dockerman/images.lua b/applications/luci-app-dockerman/luasrc/model/cbi/dockerman/images.lua
new file mode 100644 (file)
index 0000000..d16e73b
--- /dev/null
@@ -0,0 +1,223 @@
+--[[
+LuCI - Lua Configuration Interface
+Copyright 2019 lisaac <https://github.com/lisaac/luci-app-dockerman>
+]]--
+
+require "luci.util"
+local uci = luci.model.uci.cursor()
+local docker = require "luci.model.docker"
+local dk = docker.new()
+
+local containers, images
+local res = dk.images:list()
+if res.code <300 then images = res.body else return end
+res = dk.containers:list({query = {all=true}})
+if res.code <300 then containers = res.body else return end
+
+function get_images()
+  local data = {}
+  for i, v in ipairs(images) do
+    local index = v.Created .. v.Id
+    data[index]={}
+    data[index]["_selected"] = 0
+    data[index]["id"] = v.Id:sub(8)
+    data[index]["_id"] = '<a href="javascript:new_tag(\''..v.Id:sub(8,20)..'\')" class="dockerman-link" title="'..translate("New tag")..'">' .. v.Id:sub(8,20) .. '</a>'
+    if v.RepoTags and next(v.RepoTags)~=nil then
+      for i, v1 in ipairs(v.RepoTags) do
+        data[index]["_tags"] =(data[index]["_tags"] and ( data[index]["_tags"] .. "<br>" )or "") .. ((v1:match("<none>") or (#v.RepoTags == 1)) and v1 or ('<a href="javascript:un_tag(\''..v1..'\')" class="dockerman_link" title="'..translate("Remove tag")..'" >' .. v1 .. '</a>'))
+        if not data[index]["tag"] then
+          data[index]["tag"] = v1--:match("<none>") and nil or v1
+        end
+      end
+    else
+      data[index]["_tags"] = v.RepoDigests[1] and v.RepoDigests[1]:match("^(.-)@.+")
+      data[index]["_tags"] = (data[index]["_tags"] and data[index]["_tags"] or  "<none>" ).. ":<none>"
+    end
+    data[index]["_tags"] = data[index]["_tags"]:gsub("<none>","&lt;none&gt;")
+    -- data[index]["_tags"] = '<a href="javascript:handle_tag(\''..data[index]["_id"]..'\')">' .. data[index]["_tags"] .. '</a>'
+    for ci,cv in ipairs(containers) do
+      if v.Id == cv.ImageID then
+        data[index]["_containers"] = (data[index]["_containers"] and (data[index]["_containers"] .. " | ") or "")..
+        '<a href='..luci.dispatcher.build_url("admin/docker/container/"..cv.Id)..' class="dockerman_link" title="'..translate("Container detail")..'">'.. cv.Names[1]:sub(2).."</a>"
+      end
+    end
+    data[index]["_size"] = string.format("%.2f", tostring(v.Size/1024/1024)).."MB"
+    data[index]["_created"] = os.date("%Y/%m/%d %H:%M:%S",v.Created)
+  end
+  return data
+end
+
+local image_list = get_images()
+
+-- m = Map("docker", translate("Docker"))
+m = SimpleForm("docker", translate("Docker"))
+m.submit=false
+m.reset=false
+
+local pull_value={_image_tag_name="", _registry="index.docker.io"}
+local pull_section = m:section(SimpleSection, translate("Pull Image"))
+pull_section.template="cbi/nullsection"
+local tag_name = pull_section:option(Value, "_image_tag_name")
+tag_name.template = "dockerman/cbi/inlinevalue"
+tag_name.placeholder="lisaac/luci:latest"
+local action_pull = pull_section:option(Button, "_pull")
+action_pull.inputtitle= translate("Pull")
+action_pull.template = "dockerman/cbi/inlinebutton"
+action_pull.inputstyle = "add"
+tag_name.write = function(self, section, value)
+  local hastag = value:find(":")
+  if not hastag then
+    value = value .. ":latest"
+  end
+  pull_value["_image_tag_name"] = value
+end
+action_pull.write = function(self, section)
+  local tag = pull_value["_image_tag_name"]
+  local json_stringify = luci.jsonc and luci.jsonc.stringify
+  if tag and tag ~= "" then
+    docker:write_status("Images: " .. "pulling" .. " " .. tag .. "...\n")
+    -- local x_auth = nixio.bin.b64encode(json_stringify({serveraddress= server})) , header={["X-Registry-Auth"] = x_auth}
+    local res = dk.images:create({query = {fromImage=tag}}, docker.pull_image_show_status_cb)
+    -- {"errorDetail": {"message": "failed to register layer: ApplyLayer exit status 1 stdout:  stderr: write \/docker: no space left on device" }, "error": "failed to register layer: ApplyLayer exit status 1 stdout:  stderr: write \/docker: no space left on device" }
+    if res and res.code == 200 and (res.body[#res.body] and not res.body[#res.body].error and res.body[#res.body].status and (res.body[#res.body].status == "Status: Downloaded newer image for ".. tag)) then
+      docker:clear_status()
+    else
+      docker:append_status("code:" .. res.code.." ".. (res.body[#res.body] and res.body[#res.body].error or (res.body.message or res.message)).. "\n")
+    end
+  else
+    docker:append_status("code: 400 please input the name of image name!")
+  end
+  luci.http.redirect(luci.dispatcher.build_url("admin/docker/images"))
+end
+
+local import_section = m:section(SimpleSection, translate("Import Images"))
+local im = import_section:option(DummyValue, "_image_import")
+im.template = "dockerman/images_import"
+
+local image_table = m:section(Table, image_list, translate("Images"))
+
+local image_selecter = image_table:option(Flag, "_selected","")
+image_selecter.disabled = 0
+image_selecter.enabled = 1
+image_selecter.default = 0
+
+local image_id = image_table:option(DummyValue, "_id", translate("ID"))
+image_id.rawhtml = true
+image_table:option(DummyValue, "_tags", translate("RepoTags")).rawhtml = true
+image_table:option(DummyValue, "_containers", translate("Containers")).rawhtml = true
+image_table:option(DummyValue, "_size", translate("Size"))
+image_table:option(DummyValue, "_created", translate("Created"))
+image_selecter.write = function(self, section, value)
+  image_list[section]._selected = value
+end
+
+local remove_action = function(force)
+  local image_selected = {}
+  -- 遍历table中sectionid
+  local image_table_sids = image_table:cfgsections()
+  for _, image_table_sid in ipairs(image_table_sids) do
+    -- 得到选中项的名字
+    if image_list[image_table_sid]._selected == 1 then
+      image_selected[#image_selected+1] = (image_list[image_table_sid]["_tags"]:match("<br>") or image_list[image_table_sid]["_tags"]:match("&lt;none&gt;")) and image_list[image_table_sid].id or image_list[image_table_sid].tag
+    end
+  end
+  if next(image_selected) ~= nil then
+    local success = true
+    docker:clear_status()
+    for _,img in ipairs(image_selected) do
+      docker:append_status("Images: " .. "remove" .. " " .. img .. "...")
+      local query
+      if force then query = {force = true} end
+      local msg = dk.images:remove({id = img, query = query})
+      if msg.code ~= 200 then
+        docker:append_status("code:" .. msg.code.." ".. (msg.body.message and msg.body.message or msg.message).. "\n")
+        success = false
+      else
+        docker:append_status("done\n")
+      end
+    end
+    if success then docker:clear_status() end
+    luci.http.redirect(luci.dispatcher.build_url("admin/docker/images"))
+  end
+end
+
+local docker_status = m:section(SimpleSection)
+docker_status.template = "dockerman/apply_widget"
+docker_status.err = docker:read_status()
+docker_status.err = docker_status.err and docker_status.err:gsub("\n","<br>"):gsub(" ","&nbsp;")
+if docker_status.err then docker:clear_status() end
+
+local action = m:section(Table,{{}})
+action.notitle=true
+action.rowcolors=false
+action.template="cbi/nullsection"
+
+local btnremove = action:option(Button, "remove")
+btnremove.inputtitle= translate("Remove")
+btnremove.template = "dockerman/cbi/inlinebutton"
+btnremove.inputstyle = "remove"
+btnremove.forcewrite = true
+btnremove.write = function(self, section)
+  remove_action()
+end
+
+local btnforceremove = action:option(Button, "forceremove")
+btnforceremove.inputtitle= translate("Force Remove")
+btnforceremove.template = "dockerman/cbi/inlinebutton"
+btnforceremove.inputstyle = "remove"
+btnforceremove.forcewrite = true
+btnforceremove.write = function(self, section)
+  remove_action(true)
+end
+
+local btnsave = action:option(Button, "save")
+btnsave.inputtitle= translate("Save")
+btnsave.template = "dockerman/cbi/inlinebutton"
+btnsave.inputstyle = "edit"
+btnsave.forcewrite = true
+btnsave.write = function (self, section)
+  local image_selected = {}
+  local image_table_sids = image_table:cfgsections()
+  for _, image_table_sid in ipairs(image_table_sids) do
+    if image_list[image_table_sid]._selected == 1 then
+      image_selected[#image_selected+1] = image_list[image_table_sid].id --image_id:cfgvalue(image_table_sid)
+    end
+  end
+  if next(image_selected) ~= nil then
+    local names
+    for _,img in ipairs(image_selected) do
+      names = names and (names .. "&names=".. img) or img
+    end
+    local first
+    local cb = function(res, chunk)
+      if res.code == 200 then
+        if not first then
+          first = true
+          luci.http.header('Content-Disposition', 'inline; filename="images.tar"')
+          luci.http.header('Content-Type', 'application\/x-tar')
+        end
+        luci.ltn12.pump.all(chunk, luci.http.write)
+      else
+        if not first then
+          first = true
+          luci.http.prepare_content("text/plain")
+        end
+        luci.ltn12.pump.all(chunk, luci.http.write)
+      end
+    end
+    docker:write_status("Images: " .. "save" .. " " .. table.concat(image_selected, "\n") .. "...")
+    local msg = dk.images:get({query = {names = names}}, cb)
+    if msg.code ~= 200 then
+      docker:append_status("code:" .. msg.code.." ".. (msg.body.message and msg.body.message or msg.message).. "\n")
+      success = false
+    else
+      docker:clear_status()
+    end
+  end
+end
+
+local btnload = action:option(Button, "load")
+btnload.inputtitle= translate("Load")
+btnload.template = "dockerman/images_load"
+btnload.inputstyle = "add"
+return m
\ No newline at end of file
diff --git a/applications/luci-app-dockerman/luasrc/model/cbi/dockerman/networks.lua b/applications/luci-app-dockerman/luasrc/model/cbi/dockerman/networks.lua
new file mode 100644 (file)
index 0000000..1659596
--- /dev/null
@@ -0,0 +1,130 @@
+--[[
+LuCI - Lua Configuration Interface
+Copyright 2019 lisaac <https://github.com/lisaac/luci-app-dockerman>
+]]--
+
+require "luci.util"
+local uci = luci.model.uci.cursor()
+local docker = require "luci.model.docker"
+local dk = docker.new()
+local networks
+local res = dk.networks:list()
+if res.code < 300 then networks = res.body else return end
+
+local get_networks = function ()
+  local data = {}
+
+  if type(networks) ~= "table" then return nil end
+  for i, v in ipairs(networks) do
+    local index = v.Created .. v.Id
+    data[index]={}
+    data[index]["_selected"] = 0
+    data[index]["_id"] = v.Id:sub(1,12)
+    data[index]["_name"] = v.Name
+    data[index]["_driver"] = v.Driver
+    if v.Driver == "bridge" then
+      data[index]["_interface"] = v.Options["com.docker.network.bridge.name"]
+    elseif v.Driver == "macvlan" then
+      data[index]["_interface"] = v.Options.parent
+    end
+    data[index]["_subnet"] = v.IPAM and v.IPAM.Config[1] and v.IPAM.Config[1].Subnet or nil
+    data[index]["_gateway"] = v.IPAM and v.IPAM.Config[1] and v.IPAM.Config[1].Gateway or nil
+  end
+  return data
+end
+
+local network_list = get_networks()
+-- m = Map("docker", translate("Docker"))
+m = SimpleForm("docker", translate("Docker"))
+m.submit=false
+m.reset=false
+
+network_table = m:section(Table, network_list, translate("Networks"))
+network_table.nodescr=true
+
+network_selecter = network_table:option(Flag, "_selected","")
+network_selecter.template = "dockerman/cbi/xfvalue"
+network_id = network_table:option(DummyValue, "_id", translate("ID"))
+network_selecter.disabled = 0
+network_selecter.enabled = 1
+network_selecter.default = 0
+network_selecter.render = function(self, section, scope)
+  self.disable = 0
+  if network_list[section]["_name"] == "bridge" or network_list[section]["_name"] == "none" or network_list[section]["_name"] == "host" then
+    self.disable = 1
+  end
+  Flag.render(self, section, scope)
+end
+
+network_name = network_table:option(DummyValue, "_name", translate("Network Name"))
+network_driver = network_table:option(DummyValue, "_driver", translate("Driver"))
+network_interface = network_table:option(DummyValue, "_interface", translate("Parent Interface"))
+network_subnet = network_table:option(DummyValue, "_subnet", translate("Subnet"))
+network_gateway = network_table:option(DummyValue, "_gateway", translate("Gateway"))
+
+network_selecter.write = function(self, section, value)
+  network_list[section]._selected = value
+end
+
+docker_status = m:section(SimpleSection)
+docker_status.template = "dockerman/apply_widget"
+docker_status.err=docker:read_status()
+docker_status.err=docker_status.err and docker_status.err:gsub("\n","<br>"):gsub(" ","&nbsp;")
+if docker_status.err then docker:clear_status() end
+
+action = m:section(Table,{{}})
+action.notitle=true
+action.rowcolors=false
+action.template="cbi/nullsection"
+btnnew=action:option(Button, "_new")
+btnnew.inputtitle= translate("New")
+btnnew.template = "dockerman/cbi/inlinebutton"
+btnnew.notitle=true
+btnnew.inputstyle = "add"
+btnnew.forcewrite = true
+btnnew.write = function(self, section)
+  luci.http.redirect(luci.dispatcher.build_url("admin/docker/newnetwork"))
+end
+btnremove = action:option(Button, "_remove")
+btnremove.inputtitle= translate("Remove")
+btnremove.template = "dockerman/cbi/inlinebutton"
+btnremove.inputstyle = "remove"
+btnremove.forcewrite = true
+btnremove.write = function(self, section)
+  local network_selected = {}
+  local network_name_selected = {}
+  local network_driver_selected = {}
+  -- 遍历table中sectionid
+  local network_table_sids = network_table:cfgsections()
+  for _, network_table_sid in ipairs(network_table_sids) do
+    -- 得到选中项的名字
+    if network_list[network_table_sid]._selected == 1 then
+      network_selected[#network_selected+1] = network_list[network_table_sid]._id --network_name:cfgvalue(network_table_sid)
+      network_name_selected[#network_name_selected+1] = network_list[network_table_sid]._name
+      network_driver_selected[#network_driver_selected+1] = network_list[network_table_sid]._driver
+    end
+  end
+  if next(network_selected) ~= nil then
+    local success = true
+    docker:clear_status()
+    for ii, net in ipairs(network_selected) do
+      docker:append_status("Networks: " .. "remove" .. " " .. net .. "...")
+      local res = dk.networks["remove"](dk, {id = net})
+      if res and res.code >= 300 then
+        docker:append_status("code:" .. res.code.." ".. (res.body.message and res.body.message or res.message).. "\n")
+        success = false
+      else
+        docker:append_status("done\n")
+        if network_driver_selected[ii] == "macvlan" then
+          docker.remove_macvlan_interface(network_name_selected[ii])
+        end
+      end
+    end
+    if success then
+      docker:clear_status()
+    end
+    luci.http.redirect(luci.dispatcher.build_url("admin/docker/networks"))
+  end
+end
+
+return m
\ No newline at end of file
diff --git a/applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newcontainer.lua b/applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newcontainer.lua
new file mode 100644 (file)
index 0000000..324fc6d
--- /dev/null
@@ -0,0 +1,653 @@
+--[[
+LuCI - Lua Configuration Interface
+Copyright 2019 lisaac <https://github.com/lisaac/luci-app-dockerman>
+]]--
+
+require "luci.util"
+local uci = luci.model.uci.cursor()
+local docker = require "luci.model.docker"
+local dk = docker.new()
+local cmd_line = table.concat(arg, '/')
+local create_body = {}
+
+local images = dk.images:list().body
+local networks = dk.networks:list().body
+local containers = dk.containers:list({query = {all=true}}).body
+
+local is_quot_complete = function(str)
+  require "math"
+  if not str then return true end
+  local num = 0, w
+  for w in str:gmatch("\"") do
+    num = num + 1
+  end
+  if math.fmod(num, 2) ~= 0 then return false end
+  num = 0
+  for w in str:gmatch("\'") do
+    num = num + 1
+  end
+  if math.fmod(num, 2) ~= 0 then return false end
+  return true
+end
+
+local resolve_cli = function(cmd_line)
+  local config = {advance = 1}
+  local key_no_val = '|t|d|i|tty|rm|read_only|interactive|init|help|detach|privileged|P|publish_all|'
+  local key_with_val = '|sysctl|add_host|a|attach|blkio_weight_device|cap_add|cap_drop|device|device_cgroup_rule|device_read_bps|device_read_iops|device_write_bps|device_write_iops|dns|dns_option|dns_search|e|env|env_file|expose|group_add|l|label|label_file|link|link_local_ip|log_driver|log_opt|network_alias|p|publish|security_opt|storage_opt|tmpfs|v|volume|volumes_from|blkio_weight|cgroup_parent|cidfile|cpu_period|cpu_quota|cpu_rt_period|cpu_rt_runtime|c|cpu_shares|cpus|cpuset_cpus|cpuset_mems|detach_keys|disable_content_trust|domainname|entrypoint|gpus|health_cmd|health_interval|health_retries|health_start_period|health_timeout|h|hostname|ip|ip6|ipc|isolation|kernel_memory|log_driver|mac_address|m|memory|memory_reservation|memory_swap|memory_swappiness|mount|name|network|no_healthcheck|oom_kill_disable|oom_score_adj|pid|pids_limit|restart|runtime|shm_size|sig_proxy|stop_signal|stop_timeout|ulimit|u|user|userns|uts|volume_driver|w|workdir|'
+  local key_abb = {net='network',a='attach',c='cpu-shares',d='detach',e='env',h='hostname',i='interactive',l='label',m='memory',p='publish',P='publish_all',t='tty',u='user',v='volume',w='workdir'}
+  local key_with_list = '|sysctl|add_host|a|attach|blkio_weight_device|cap_add|cap_drop|device|device_cgroup_rule|device_read_bps|device_read_iops|device_write_bps|device_write_iops|dns|dns_option|dns_search|e|env|env_file|expose|group_add|l|label|label_file|link|link_local_ip|log_driver|log_opt|network_alias|p|publish|security_opt|storage_opt|tmpfs|v|volume|volumes_from|'
+  local key = nil
+  local _key = nil
+  local val = nil
+  local is_cmd = false
+
+  cmd_line = cmd_line:match("^DOCKERCLI%s+(.+)")
+  for w in cmd_line:gmatch("[^%s]+") do
+    if w =='\\' then
+    elseif not key and not _key and not is_cmd then
+      --key=val
+      key, val = w:match("^%-%-([%lP%-]-)=(.+)")
+      if not key then
+        --key val
+        key = w:match("^%-%-([%lP%-]+)")
+        if not key then
+          -- -v val
+          key = w:match("^%-([%lP%-]+)")
+          if key then
+            -- for -dit
+            if key:match("i") or key:match("t") or key:match("d") then
+              if key:match("i") then
+                config[key_abb["i"]] = true
+                key:gsub("i", "")
+              end
+              if key:match("t") then
+                config[key_abb["t"]] = true
+                key:gsub("t", "")
+              end
+              if key:match("d") then
+                config[key_abb["d"]] = true
+                key:gsub("d", "")
+              end
+              if key:match("P") then
+                config[key_abb["P"]] = true
+                key:gsub("P", "")
+              end
+              if key == "" then key = nil end
+            end
+          end
+        end
+      end
+      if key then
+        key = key:gsub("-","_")
+        key = key_abb[key] or key
+        if key_no_val:match("|"..key.."|") then
+          config[key] = true
+          val = nil
+          key = nil
+        elseif key_with_val:match("|"..key.."|") then
+          -- if key == "cap_add" then config.privileged = true end
+        else
+          key = nil
+          val = nil
+        end
+      else
+        config.image = w
+        key = nil
+        val = nil
+        is_cmd = true
+      end
+    elseif (key or _key) and not is_cmd then
+      if key == "mount" then
+        -- we need resolve mount options here
+        -- type=bind,source=/source,target=/app
+        local _type = w:match("^type=([^,]+),") or "bind"
+        local source =  (_type ~= "tmpfs") and (w:match("source=([^,]+),") or  w:match("src=([^,]+),")) or ""
+        local target =  w:match(",target=([^,]+)") or  w:match(",dst=([^,]+)") or w:match(",destination=([^,]+)") or ""
+        local ro = w:match(",readonly") and "ro" or nil
+        if source and target then
+          if _type ~= "tmpfs" then
+            -- bind or volume
+            local bind_propagation = (_type == "bind") and w:match(",bind%-propagation=([^,]+)") or nil
+            val = source..":"..target .. ((ro or bind_propagation) and (":" .. (ro and ro or "") .. (((ro and bind_propagation) and "," or "") .. (bind_propagation and bind_propagation or ""))or ""))
+          else
+            -- tmpfs
+            local tmpfs_mode = w:match(",tmpfs%-mode=([^,]+)") or nil
+            local tmpfs_size = w:match(",tmpfs%-size=([^,]+)") or nil
+            key = "tmpfs"
+            val = target .. ((tmpfs_mode or tmpfs_size) and (":" .. (tmpfs_mode and ("mode=" .. tmpfs_mode) or "") .. ((tmpfs_mode and tmpfs_size) and "," or "") .. (tmpfs_size and ("size=".. tmpfs_size) or "")) or "")
+            if not config[key] then config[key] = {} end
+            table.insert( config[key], val )
+            key = nil
+            val = nil
+          end
+        end
+      else
+        val = w
+      end
+    elseif is_cmd then
+      config["command"] = (config["command"] and (config["command"] .. " " )or "")  .. w
+    end
+    if (key or _key) and val then
+      key = _key or key
+      if key_with_list:match("|"..key.."|") then
+        if not config[key] then config[key] = {} end
+        if _key then
+          config[key][#config[key]] = config[key][#config[key]] .. " " .. w
+        else
+          table.insert( config[key], val )
+        end
+        if is_quot_complete(config[key][#config[key]]) then
+          -- clear quotation marks
+          config[key][#config[key]] = config[key][#config[key]]:gsub("[\"\']", "")
+          _key = nil
+        else
+          _key = key
+        end
+      else
+        config[key] = (config[key] and (config[key] .. " ") or "") .. val
+        if is_quot_complete(config[key]) then
+          -- clear quotation marks
+          config[key] = config[key]:gsub("[\"\']", "")
+          _key = nil
+        else
+          _key = key
+        end
+      end
+      key = nil
+      val = nil
+    end
+  end
+  return config
+end
+-- reslvo default config
+local default_config = {}
+if cmd_line and cmd_line:match("^DOCKERCLI.+") then
+  default_config = resolve_cli(cmd_line)
+elseif cmd_line and cmd_line:match("^duplicate/[^/]+$") then
+  local container_id = cmd_line:match("^duplicate/(.+)")
+  create_body = dk:containers_duplicate_config({id = container_id}) or {}
+  if not create_body.HostConfig then create_body.HostConfig = {} end
+  if next(create_body) ~= nil then
+    default_config.name = nil
+    default_config.image = create_body.Image
+    default_config.hostname = create_body.Hostname
+    default_config.tty = create_body.Tty and true or false
+    default_config.interactive = create_body.OpenStdin and true or false
+    default_config.privileged = create_body.HostConfig.Privileged and true or false
+    default_config.restart =  create_body.HostConfig.RestartPolicy and create_body.HostConfig.RestartPolicy.name or nil
+    -- default_config.network = create_body.HostConfig.NetworkMode == "default" and "bridge" or create_body.HostConfig.NetworkMode
+    -- if container has leave original network, and add new network, .HostConfig.NetworkMode is INcorrect, so using first child of .NetworkingConfig.EndpointsConfig
+    default_config.network = create_body.NetworkingConfig and create_body.NetworkingConfig.EndpointsConfig and next(create_body.NetworkingConfig.EndpointsConfig) or nil
+    default_config.ip = default_config.network and default_config.network ~= "bridge" and default_config.network ~= "host" and default_config.network ~= "null" and create_body.NetworkingConfig.EndpointsConfig[default_config.network].IPAMConfig and create_body.NetworkingConfig.EndpointsConfig[default_config.network].IPAMConfig.IPv4Address or nil
+    default_config.link = create_body.HostConfig.Links
+    default_config.env = create_body.Env
+    default_config.dns = create_body.HostConfig.Dns
+    default_config.volume = create_body.HostConfig.Binds
+    default_config.cap_add = create_body.HostConfig.CapAdd
+    default_config.publish_all = create_body.HostConfig.PublishAllPorts
+
+    if create_body.HostConfig.Sysctls and type(create_body.HostConfig.Sysctls) == "table" then
+      default_config.sysctl = {}
+      for k, v in pairs(create_body.HostConfig.Sysctls) do
+        table.insert( default_config.sysctl, k.."="..v )
+      end
+    end
+
+    if create_body.HostConfig.LogConfig and create_body.HostConfig.LogConfig.Config and type(create_body.HostConfig.LogConfig.Config) == "table" then
+      default_config.log_opt = {}
+      for k, v in pairs(create_body.HostConfig.LogConfig.Config) do
+        table.insert( default_config.log_opt, k.."="..v )
+      end
+    end
+
+    if create_body.HostConfig.PortBindings and type(create_body.HostConfig.PortBindings) == "table" then
+      default_config.publish = {}
+      for k, v in pairs(create_body.HostConfig.PortBindings) do
+        table.insert( default_config.publish, v[1].HostPort..":"..k:match("^(%d+)/.+").."/"..k:match("^%d+/(.+)") )
+      end
+    end
+
+    default_config.user = create_body.User or nil
+    default_config.command = create_body.Cmd and type(create_body.Cmd) == "table" and table.concat(create_body.Cmd, " ") or nil
+    default_config.advance = 1
+    default_config.cpus = create_body.HostConfig.NanoCPUs
+    default_config.cpu_shares =  create_body.HostConfig.CpuShares
+    default_config.memory = create_body.HostConfig.Memory
+    default_config.blkio_weight = create_body.HostConfig.BlkioWeight
+
+    if create_body.HostConfig.Devices and type(create_body.HostConfig.Devices) == "table" then
+      default_config.device = {}
+      for _, v in ipairs(create_body.HostConfig.Devices) do
+        table.insert( default_config.device, v.PathOnHost..":"..v.PathInContainer..(v.CgroupPermissions ~= "" and (":" .. v.CgroupPermissions) or "") )
+      end
+    end
+    if create_body.HostConfig.Tmpfs and type(create_body.HostConfig.Tmpfs) == "table" then
+      default_config.tmpfs = {}
+      for k, v in pairs(create_body.HostConfig.Tmpfs) do
+        table.insert( default_config.tmpfs, k .. (v~="" and ":" or "")..v )
+      end
+    end
+  end
+end
+
+local m = SimpleForm("docker", translate("Docker"))
+m.redirect = luci.dispatcher.build_url("admin", "docker", "containers")
+-- m.reset = false
+-- m.submit = false
+-- new Container
+
+docker_status = m:section(SimpleSection)
+docker_status.template = "dockerman/apply_widget"
+docker_status.err=docker:read_status()
+docker_status.err=docker_status.err and docker_status.err:gsub("\n","<br>"):gsub(" ","&nbsp;")
+if docker_status.err then docker:clear_status() end
+
+local s = m:section(SimpleSection, translate("New Container"))
+s.addremove = true
+s.anonymous = true
+
+local d = s:option(DummyValue,"cmd_line", translate("Resolve CLI"))
+d.rawhtml  = true
+d.template = "dockerman/newcontainer_resolve"
+
+d = s:option(Value, "name", translate("Container Name"))
+d.rmempty = true
+d.default = default_config.name or nil
+
+d = s:option(Flag, "interactive", translate("Interactive (-i)"))
+d.rmempty = true
+d.disabled = 0
+d.enabled = 1
+d.default = default_config.interactive and 1 or 0
+
+d = s:option(Flag, "tty", translate("TTY (-t)"))
+d.rmempty = true
+d.disabled = 0
+d.enabled = 1
+d.default = default_config.tty and 1 or 0
+
+d = s:option(Value, "image", translate("Docker Image"))
+d.rmempty = true
+d.default = default_config.image or nil
+for _, v in ipairs (images) do
+  if v.RepoTags then
+    d:value(v.RepoTags[1], v.RepoTags[1])
+  end
+end
+
+d = s:option(Flag, "_force_pull", translate("Always pull image first"))
+d.rmempty = true
+d.disabled = 0
+d.enabled = 1
+d.default = 0
+
+d = s:option(Flag, "privileged", translate("Privileged"))
+d.rmempty = true
+d.disabled = 0
+d.enabled = 1
+d.default = default_config.privileged and 1 or 0
+
+d = s:option(ListValue, "restart", translate("Restart Policy"))
+d.rmempty = true
+
+d:value("no", "No")
+d:value("unless-stopped", "Unless stopped")
+d:value("always", "Always")
+d:value("on-failure", "On failure")
+d.default = default_config.restart or "unless-stopped"
+
+local d_network = s:option(ListValue, "network", translate("Networks"))
+d_network.rmempty = true
+d_network.default = default_config.network or "bridge"
+
+local d_ip = s:option(Value, "ip", translate("IPv4 Address"))
+d_ip.datatype="ip4addr"
+d_ip:depends("network", "nil")
+d_ip.default = default_config.ip or nil
+
+d = s:option(DynamicList, "link", translate("Links with other containers"))
+d.placeholder = "container_name:alias"
+d.rmempty = true
+d:depends("network", "bridge")
+d.default = default_config.link or nil
+
+d = s:option(DynamicList, "dns", translate("Set custom DNS servers"))
+d.placeholder = "8.8.8.8"
+d.rmempty = true
+d.default = default_config.dns or nil
+
+d = s:option(Value, "user", translate("User(-u)"), translate("The user that commands are run as inside the container.(format: name|uid[:group|gid])"))
+d.placeholder = "1000:1000"
+d.rmempty = true
+d.default = default_config.user or nil
+
+d = s:option(DynamicList, "env", translate("Environmental Variable(-e)"), translate("Set environment variables to inside the container"))
+d.placeholder = "TZ=Asia/Shanghai"
+d.rmempty = true
+d.default = default_config.env or nil
+
+d = s:option(DynamicList, "volume", translate("Bind Mount(-v)"), translate("Bind mount a volume"))
+d.placeholder = "/media:/media:slave"
+d.rmempty = true
+d.default = default_config.volume or nil
+
+local d_publish = s:option(DynamicList, "publish", translate("Exposed Ports(-p)"), translate("Publish container's port(s) to the host"))
+d_publish.placeholder = "2200:22/tcp"
+d_publish.rmempty = true
+d_publish.default = default_config.publish or nil
+
+d = s:option(Value, "command", translate("Run command"))
+d.placeholder = "/bin/sh init.sh"
+d.rmempty = true
+d.default = default_config.command or nil
+
+d = s:option(Flag, "advance", translate("Advance"))
+d.rmempty = true
+d.disabled = 0
+d.enabled = 1
+d.default = default_config.advance or 0
+
+d = s:option(Value, "hostname", translate("Host Name"), translate("The hostname to use for the container"))
+d.rmempty = true
+d.default = default_config.hostname or nil
+d:depends("advance", 1)
+
+d = s:option(Flag, "publish_all", translate("Exposed All Ports(-P)"), translate("Allocates an ephemeral host port for all of a container's exposed ports"))
+d.rmempty = true
+d.disabled = 0
+d.enabled = 1
+d.default = default_config.publish_all and 1 or 0
+d:depends("advance", 1)
+
+d = s:option(DynamicList, "device", translate("Device(--device)"), translate("Add host device to the container"))
+d.placeholder = "/dev/sda:/dev/xvdc:rwm"
+d.rmempty = true
+d:depends("advance", 1)
+d.default = default_config.device or nil
+
+d = s:option(DynamicList, "tmpfs", translate("Tmpfs(--tmpfs)"), translate("Mount tmpfs directory"))
+d.placeholder = "/run:rw,noexec,nosuid,size=65536k"
+d.rmempty = true
+d:depends("advance", 1)
+d.default = default_config.tmpfs or nil
+
+d = s:option(DynamicList, "sysctl", translate("Sysctl(--sysctl)"), translate("Sysctls (kernel parameters) options"))
+d.placeholder = "net.ipv4.ip_forward=1"
+d.rmempty = true
+d:depends("advance", 1)
+d.default = default_config.sysctl or nil
+
+d = s:option(DynamicList, "cap_add", translate("CAP-ADD(--cap-add)"), translate("A list of kernel capabilities to add to the container"))
+d.placeholder = "NET_ADMIN"
+d.rmempty = true
+d:depends("advance", 1)
+d.default = default_config.cap_add or nil
+
+d = s:option(Value, "cpus", translate("CPUs"), translate("Number of CPUs. Number is a fractional number. 0.000 means no limit"))
+d.placeholder = "1.5"
+d.rmempty = true
+d:depends("advance", 1)
+d.datatype="ufloat"
+d.default = default_config.cpus or nil
+
+d = s:option(Value, "cpu_shares", translate("CPU Shares Weight"), translate("CPU shares relative weight, if 0 is set, the system will ignore the value and use the default of 1024"))
+d.placeholder = "1024"
+d.rmempty = true
+d:depends("advance", 1)
+d.datatype="uinteger"
+d.default = default_config.cpu_shares or nil
+
+d = s:option(Value, "memory", translate("Memory"), translate("Memory limit (format: <number>[<unit>]). Number is a positive integer. Unit can be one of b, k, m, or g. Minimum is 4M"))
+d.placeholder = "128m"
+d.rmempty = true
+d:depends("advance", 1)
+d.default = default_config.memory or nil
+
+d = s:option(Value, "blkio_weight", translate("Block IO Weight"), translate("Block IO weight (relative weight) accepts a weight value between 10 and 1000"))
+d.placeholder = "500"
+d.rmempty = true
+d:depends("advance", 1)
+d.datatype="uinteger"
+d.default = default_config.blkio_weight or nil
+
+d = s:option(DynamicList, "log_opt", translate("Log driver options"), translate("The logging configuration for this container"))
+d.placeholder = "max-size=1m"
+d.rmempty = true
+d:depends("advance", 1)
+d.default = default_config.log_opt or nil
+
+for _, v in ipairs (networks) do
+  if v.Name then
+    local parent = v.Options and v.Options.parent or nil
+    local ip = v.IPAM and v.IPAM.Config and v.IPAM.Config[1] and v.IPAM.Config[1].Subnet or nil
+    ipv6 =  v.IPAM and v.IPAM.Config and v.IPAM.Config[2] and v.IPAM.Config[2].Subnet or nil
+    local network_name = v.Name .. " | " .. v.Driver  .. (parent and (" | " .. parent) or "") .. (ip and (" | " .. ip) or "").. (ipv6 and (" | " .. ipv6) or "")
+    d_network:value(v.Name, network_name)
+
+    if v.Name ~= "none" and v.Name ~= "bridge" and v.Name ~= "host" then
+      d_ip:depends("network", v.Name)
+    end
+
+    if v.Driver == "bridge" then
+      d_publish:depends("network", v.Name)
+    end
+  end
+end
+
+m.handle = function(self, state, data)
+  if state ~= FORM_VALID then return end
+  local tmp
+  local name = data.name or ("luci_" .. os.date("%Y%m%d%H%M%S"))
+  local hostname = data.hostname
+  local tty = type(data.tty) == "number" and (data.tty == 1 and true or false) or default_config.tty or false
+  local publish_all = type(data.publish_all) == "number" and (data.publish_all == 1 and true or false) or default_config.publish_all or false
+  local interactive = type(data.interactive) == "number" and (data.interactive == 1 and true or false) or default_config.interactive or false
+  local image = data.image
+  local user = data.user
+  if image and not image:match(".-:.+") then
+    image = image .. ":latest"
+  end
+  local privileged = type(data.privileged) == "number" and (data.privileged == 1 and true or false) or default_config.privileged or false
+  local restart = data.restart
+  local env = data.env
+  local dns = data.dns
+  local cap_add = data.cap_add
+  local sysctl = {}
+  tmp = data.sysctl
+  if type(tmp) == "table" then
+    for i, v in ipairs(tmp) do
+      local k,v1 = v:match("(.-)=(.+)")
+      if k and v1 then
+        sysctl[k]=v1
+      end
+    end
+  end
+  local log_opt = {}
+  tmp = data.log_opt
+  if type(tmp) == "table" then
+    for i, v in ipairs(tmp) do
+      local k,v1 = v:match("(.-)=(.+)")
+      if k and v1 then
+        log_opt[k]=v1
+      end
+    end
+  end
+  local network = data.network
+  local ip = (network ~= "bridge" and network ~= "host" and network ~= "none") and data.ip or nil
+  local volume = data.volume
+  local memory = data.memory or 0
+  local cpu_shares = data.cpu_shares or 0
+  local cpus = data.cpus or 0
+  local blkio_weight = data.blkio_weight or 500
+
+  local portbindings = {}
+  local exposedports = {}
+  local tmpfs = {}
+  tmp = data.tmpfs
+  if type(tmp) == "table" then
+    for i, v in ipairs(tmp)do
+      local k= v:match("([^:]+)")
+      local v1 = v:match(".-:([^:]+)") or ""
+      if k then
+        tmpfs[k]=v1
+      end
+    end
+  end
+
+  local device = {}
+  tmp = data.device
+  if type(tmp) == "table" then
+    for i, v in ipairs(tmp) do
+      local t = {}
+      local _,_, h, c, p = v:find("(.-):(.-):(.+)")
+      if h and c then
+        t['PathOnHost'] = h
+        t['PathInContainer'] = c
+        t['CgroupPermissions'] = p or "rwm"
+      else
+        local _,_, h, c = v:find("(.-):(.+)")
+        if h and c then
+          t['PathOnHost'] = h
+          t['PathInContainer'] = c
+          t['CgroupPermissions'] = "rwm"
+        else
+          t['PathOnHost'] = v
+          t['PathInContainer'] = v
+          t['CgroupPermissions'] = "rwm"
+        end
+      end
+      if next(t) ~= nil then
+        table.insert( device, t )
+      end
+    end
+  end
+
+  tmp = data.publish or {}
+  for i, v in ipairs(tmp) do
+    for v1 ,v2 in string.gmatch(v, "(%d+):([^%s]+)") do
+      local _,_,p= v2:find("^%d+/(%w+)")
+      if p == nil then
+        v2=v2..'/tcp'
+      end
+      portbindings[v2] = {{HostPort=v1}}
+      exposedports[v2] = {HostPort=v1}
+    end
+  end
+
+  local link = data.link
+  tmp = data.command
+  local command = {}
+  if tmp ~= nil then
+    for v in string.gmatch(tmp, "[^%s]+") do
+      command[#command+1] = v
+    end 
+  end
+  if memory ~= 0 then
+    _,_,n,unit = memory:find("([%d%.]+)([%l%u]+)")
+    if n then
+      unit = unit and unit:sub(1,1):upper() or "B"
+      if  unit == "M" then
+        memory = tonumber(n) * 1024 * 1024
+      elseif unit == "G" then
+        memory = tonumber(n) * 1024 * 1024 * 1024
+      elseif unit == "K" then
+        memory = tonumber(n) * 1024
+      else
+        memory = tonumber(n)
+      end
+    end
+  end
+
+  create_body.Hostname = network ~= "host" and (hostname or name) or nil
+  create_body.Tty = tty and true or false
+  create_body.OpenStdin = interactive and true or false
+  create_body.User = user
+  create_body.Cmd = command
+  create_body.Env = env
+  create_body.Image = image
+  create_body.ExposedPorts = exposedports
+  create_body.HostConfig = create_body.HostConfig or {}
+  create_body.HostConfig.Dns = dns
+  create_body.HostConfig.Binds = volume
+  create_body.HostConfig.RestartPolicy = { Name = restart, MaximumRetryCount = 0 }
+  create_body.HostConfig.Privileged = privileged and true or false
+  create_body.HostConfig.PortBindings = portbindings
+  create_body.HostConfig.Memory = tonumber(memory)
+  create_body.HostConfig.CpuShares = tonumber(cpu_shares)
+  create_body.HostConfig.NanoCPUs = tonumber(cpus) * 10 ^ 9
+  create_body.HostConfig.BlkioWeight = tonumber(blkio_weight)
+  create_body.HostConfig.PublishAllPorts = publish_all
+  if create_body.HostConfig.NetworkMode ~= network then
+    -- network mode changed, need to clear duplicate config
+    create_body.NetworkingConfig = nil
+  end
+  create_body.HostConfig.NetworkMode = network
+  if ip then
+    if create_body.NetworkingConfig and create_body.NetworkingConfig.EndpointsConfig and type(create_body.NetworkingConfig.EndpointsConfig) == "table" then
+      -- ip + duplicate config
+      for k, v in pairs (create_body.NetworkingConfig.EndpointsConfig) do
+        if k == network and v.IPAMConfig and v.IPAMConfig.IPv4Address then
+          v.IPAMConfig.IPv4Address = ip
+        else
+          create_body.NetworkingConfig.EndpointsConfig = { [network] = { IPAMConfig = { IPv4Address = ip } } }
+        end
+        break
+      end
+    else
+      -- ip + no duplicate config
+      create_body.NetworkingConfig = { EndpointsConfig = { [network] = { IPAMConfig = { IPv4Address = ip } } } }
+    end
+  elseif not create_body.NetworkingConfig then
+    -- no ip + no duplicate config
+    create_body.NetworkingConfig = nil
+  end
+  create_body["HostConfig"]["Tmpfs"] = tmpfs
+  create_body["HostConfig"]["Devices"] = device
+  create_body["HostConfig"]["Sysctls"] = sysctl
+  create_body["HostConfig"]["CapAdd"] = cap_add
+  create_body["HostConfig"]["LogConfig"] = next(log_opt) ~= nil and { Config = log_opt } or nil
+
+  if network == "bridge" then
+    create_body["HostConfig"]["Links"] = link
+  end
+  local pull_image = function(image)
+    local json_stringify = luci.jsonc and luci.jsonc.stringify
+    docker:append_status("Images: " .. "pulling" .. " " .. image .. "...\n")
+    local res = dk.images:create({query = {fromImage=image}}, docker.pull_image_show_status_cb)
+    if res and res.code == 200 and (res.body[#res.body] and not res.body[#res.body].error and res.body[#res.body].status and (res.body[#res.body].status == "Status: Downloaded newer image for ".. image or res.body[#res.body].status == "Status: Image is up to date for ".. image)) then
+      docker:append_status("done\n")
+    else
+      res.code = (res.code == 200) and 500 or res.code
+      docker:append_status("code:" .. res.code.." ".. (res.body[#res.body] and res.body[#res.body].error or (res.body.message or res.message)).. "\n")
+      luci.http.redirect(luci.dispatcher.build_url("admin/docker/newcontainer"))
+    end
+  end
+  docker:clear_status()
+  local exist_image = false
+  if image then
+    for _, v in ipairs (images) do
+      if v.RepoTags and v.RepoTags[1] == image then
+        exist_image = true
+        break
+      end
+    end
+    if not exist_image then
+      pull_image(image)
+    elseif data._force_pull == 1 then
+      pull_image(image)
+    end
+  end
+
+  create_body = docker.clear_empty_tables(create_body)
+  docker:append_status("Container: " .. "create" .. " " .. name .. "...")
+  local res = dk.containers:create({name = name, body = create_body})
+  if res and res.code == 201 then
+    docker:clear_status()
+    luci.http.redirect(luci.dispatcher.build_url("admin/docker/containers"))
+  else
+    docker:append_status("code:" .. res.code.." ".. (res.body.message and res.body.message or res.message))
+    luci.http.redirect(luci.dispatcher.build_url("admin/docker/newcontainer"))
+  end
+end
+
+return m
diff --git a/applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newnetwork.lua b/applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newnetwork.lua
new file mode 100644 (file)
index 0000000..4dc8a0c
--- /dev/null
@@ -0,0 +1,221 @@
+--[[
+LuCI - Lua Configuration Interface
+Copyright 2019 lisaac <https://github.com/lisaac/luci-app-dockerman>
+]]--
+
+require "luci.util"
+local docker = require "luci.model.docker"
+local dk = docker.new()
+
+m = SimpleForm("docker", translate("Docker"))
+m.redirect = luci.dispatcher.build_url("admin", "docker", "networks")
+
+docker_status = m:section(SimpleSection)
+docker_status.template = "dockerman/apply_widget"
+docker_status.err=docker:read_status()
+docker_status.err=docker_status.err and docker_status.err:gsub("\n","<br>"):gsub(" ","&nbsp;")
+if docker_status.err then docker:clear_status() end
+
+s = m:section(SimpleSection, translate("New Network"))
+s.addremove = true
+s.anonymous = true
+
+d = s:option(Value, "name", translate("Network Name"))
+d.rmempty = true
+
+d = s:option(ListValue, "dirver", translate("Driver"))
+d.rmempty = true
+d:value("bridge", "bridge")
+d:value("macvlan", "macvlan")
+d:value("ipvlan", "ipvlan")
+d:value("overlay", "overlay")
+
+d = s:option(Value, "parent", translate("Parent Interface"))
+d.rmempty = true
+d:depends("dirver", "macvlan")
+local interfaces = luci.sys and luci.sys.net and luci.sys.net.devices() or {}
+for _, v in ipairs(interfaces) do
+  d:value(v, v)
+end
+d.default="br-lan"
+d.placeholder="br-lan"
+
+d = s:option(Value, "macvlan_mode", translate("Macvlan Mode"))
+d.rmempty = true
+d:depends("dirver", "macvlan")
+d.default="bridge"
+d:value("bridge", "bridge")
+d:value("private", "private")
+d:value("vepa", "vepa")
+d:value("passthru", "passthru")
+
+d = s:option(Value, "ipvlan_mode", translate("Ipvlan Mode"))
+d.rmempty = true
+d:depends("dirver", "ipvlan")
+d.default="l3"
+d:value("l2", "l2")
+d:value("l3", "l3")
+
+d = s:option(Flag, "ingress", translate("Ingress"), translate("Ingress network is the network which provides the routing-mesh in swarm mode"))
+d.rmempty = true
+d.disabled = 0
+d.enabled = 1
+d.default = 0
+d:depends("dirver", "overlay")
+
+d = s:option(DynamicList, "options", translate("Options"))
+d.rmempty = true
+d.placeholder="com.docker.network.driver.mtu=1500"
+
+d = s:option(Flag, "internal", translate("Internal"), translate("Restrict external access to the network"))
+d.rmempty = true
+d:depends("dirver", "overlay")
+d.disabled = 0
+d.enabled = 1
+d.default = 0
+
+if  nixio.fs.access("/etc/config/network") and nixio.fs.access("/etc/config/firewall")then
+  d = s:option(Flag, "op_macvlan", translate("Create macvlan interface"), translate("Auto create macvlan interface in Openwrt"))
+  d:depends("dirver", "macvlan")
+  d.disabled = 0
+  d.enabled = 1
+  d.default = 1
+end
+
+d = s:option(Value, "subnet", translate("Subnet"))
+d.rmempty = true
+d.placeholder="10.1.0.0/16"
+d.datatype="ip4addr"
+
+d = s:option(Value, "gateway", translate("Gateway"))
+d.rmempty = true
+d.placeholder="10.1.1.1"
+d.datatype="ip4addr"
+
+d = s:option(Value, "ip_range", translate("IP range"))
+d.rmempty = true
+d.placeholder="10.1.1.0/24"
+d.datatype="ip4addr"
+
+d = s:option(DynamicList, "aux_address", translate("Exclude IPs"))
+d.rmempty = true
+d.placeholder="my-route=10.1.1.1"
+
+d = s:option(Flag, "ipv6", translate("Enable IPv6"))
+d.rmempty = true
+d.disabled = 0
+d.enabled = 1
+d.default = 0
+
+d = s:option(Value, "subnet6", translate("IPv6 Subnet"))
+d.rmempty = true
+d.placeholder="fe80::/10"
+d.datatype="ip6addr"
+d:depends("ipv6", 1)
+
+d = s:option(Value, "gateway6", translate("IPv6 Gateway"))
+d.rmempty = true
+d.placeholder="fe80::1"
+d.datatype="ip6addr"
+d:depends("ipv6", 1)
+
+m.handle = function(self, state, data)
+  if state == FORM_VALID then
+    local name = data.name
+    local driver = data.dirver
+
+    local internal = data.internal == 1 and true or false
+
+    local subnet = data.subnet
+    local gateway = data.gateway
+    local ip_range = data.ip_range
+
+    local aux_address = {}
+    local tmp = data.aux_address or {}
+    for i,v in ipairs(tmp) do
+      _,_,k1,v1 = v:find("(.-)=(.+)")
+      aux_address[k1] = v1
+    end
+
+    local options = {}
+    tmp = data.options or {}
+    for i,v in ipairs(tmp) do
+      _,_,k1,v1 = v:find("(.-)=(.+)")
+      options[k1] = v1
+    end
+
+    local ipv6 = data.ipv6 == 1 and true or false
+
+    local create_body={
+      Name = name,
+      Driver = driver,
+      EnableIPv6 = ipv6,
+      IPAM = {
+        Driver= "default"
+      },
+      Internal = internal
+    }
+  
+    if subnet or gateway or ip_range then
+      create_body["IPAM"]["Config"] = {
+        {
+          Subnet = subnet,
+          Gateway = gateway,
+          IPRange = ip_range,
+          AuxAddress = aux_address,
+          AuxiliaryAddresses = aux_address
+        }
+      }
+    end
+    if driver == "macvlan" then
+      create_body["Options"] = {
+        macvlan_mode = data.macvlan_mode,
+        parent = data.parent
+      }
+    elseif driver == "ipvlan" then
+      create_body["Options"] = {
+        ipvlan_mode = data.ipvlan_mode
+      }
+    elseif driver == "overlay" then
+      create_body["Ingress"] = data.ingerss == 1 and true or false
+    end
+
+    if ipv6 and data.subnet6 and data.subnet6 then
+      if type(create_body["IPAM"]["Config"]) ~= "table" then 
+        create_body["IPAM"]["Config"] = {}
+      end
+      local index = #create_body["IPAM"]["Config"]
+      create_body["IPAM"]["Config"][index+1] = {
+        Subnet = data.subnet6,
+        Gateway = data.gateway6
+      }
+    end
+
+    if next(options) ~= nil then
+      create_body["Options"] = create_body["Options"] or {}
+      for k, v in pairs(options) do
+        create_body["Options"][k] = v
+      end
+    end
+
+    create_body = docker.clear_empty_tables(create_body)
+    docker:write_status("Network: " .. "create" .. " " .. create_body.Name .. "...")
+    local res = dk.networks:create({body = create_body})
+    if res and res.code == 201 then
+      docker:write_status("Network: " .. "create macvlan interface...")
+      res = dk.networks:inspect({ name = create_body.Name })
+      if driver == "macvlan" and data.op_macvlan ~= 0 and res.code == 200 
+        and res.body and res.body.IPAM and res.body.IPAM.Config and res.body.IPAM.Config[1] 
+        and res.body.IPAM.Config[1].Gateway and res.body.IPAM.Config[1].Subnet then
+        docker.create_macvlan_interface(data.name, data.parent, res.body.IPAM.Config[1].Gateway, res.body.IPAM.Config[1].Subnet)
+      end
+      docker:clear_status()
+      luci.http.redirect(luci.dispatcher.build_url("admin/docker/networks"))
+    else
+      docker:append_status("code:" .. res.code.." ".. (res.body.message and res.body.message or res.message).. "\n")
+      luci.http.redirect(luci.dispatcher.build_url("admin/docker/newnetwork"))
+    end
+  end
+end
+
+return m
\ No newline at end of file
diff --git a/applications/luci-app-dockerman/luasrc/model/cbi/dockerman/overview.lua b/applications/luci-app-dockerman/luasrc/model/cbi/dockerman/overview.lua
new file mode 100644 (file)
index 0000000..e810c7d
--- /dev/null
@@ -0,0 +1,154 @@
+--[[
+LuCI - Lua Configuration Interface
+Copyright 2019 lisaac <https://github.com/lisaac/luci-app-dockerman>
+]]--
+
+require "luci.util"
+local docker = require "luci.model.docker"
+local uci = require "luci.model.uci"
+
+function byte_format(byte)
+  local suff = {"B", "KB", "MB", "GB", "TB"}
+  for i=1, 5 do
+    if byte > 1024 and i < 5 then
+      byte = byte / 1024
+    else
+      return string.format("%.2f %s", byte, suff[i])
+    end
+  end
+end
+
+local map_dockerman = Map("dockerman", translate("Docker"), translate("DockerMan is a Simple Docker manager client for LuCI, If you have any issue please visit:") .. " ".. [[<a href="https://github.com/lisaac/luci-app-dockerman" target="_blank">]] ..translate("Github") .. [[</a>]])
+local docker_info_table = {}
+-- docker_info_table['0OperatingSystem'] = {_key=translate("Operating System"),_value='-'}
+-- docker_info_table['1Architecture'] = {_key=translate("Architecture"),_value='-'}
+-- docker_info_table['2KernelVersion'] = {_key=translate("Kernel Version"),_value='-'}
+docker_info_table['3ServerVersion'] = {_key=translate("Docker Version"),_value='-'}
+docker_info_table['4ApiVersion'] = {_key=translate("Api Version"),_value='-'}
+docker_info_table['5NCPU'] = {_key=translate("CPUs"),_value='-'}
+docker_info_table['6MemTotal'] = {_key=translate("Total Memory"),_value='-'}
+docker_info_table['7DockerRootDir'] = {_key=translate("Docker Root Dir"),_value='-'}
+docker_info_table['8IndexServerAddress'] = {_key=translate("Index Server Address"),_value='-'}
+docker_info_table['9RegistryMirrors'] = {_key=translate("Registry Mirrors"),_value='-'}
+
+local s = map_dockerman:section(Table, docker_info_table)
+s:option(DummyValue, "_key", translate("Info"))
+s:option(DummyValue, "_value")
+s = map_dockerman:section(SimpleSection)
+s.containers_running = '-'
+s.images_used = '-'
+s.containers_total = '-'
+s.images_total = '-'
+s.networks_total = '-'
+s.volumes_total = '-'
+local containers_list
+-- local socket = luci.model.uci.cursor():get("dockerman", "local", "socket_path")
+if (require "luci.model.docker").new():_ping().code == 200 then
+  local dk = docker.new()
+  containers_list = dk.containers:list({query = {all=true}}).body
+  local images_list = dk.images:list().body
+  local vol = dk.volumes:list()
+  local volumes_list = vol and vol.body and vol.body.Volumes or {}
+  local networks_list = dk.networks:list().body or {}
+  local docker_info = dk:info()
+  -- docker_info_table['0OperatingSystem']._value = docker_info.body.OperatingSystem
+  -- docker_info_table['1Architecture']._value = docker_info.body.Architecture
+  -- docker_info_table['2KernelVersion']._value = docker_info.body.KernelVersion
+  docker_info_table['3ServerVersion']._value = docker_info.body.ServerVersion
+  docker_info_table['4ApiVersion']._value = docker_info.headers["Api-Version"]
+  docker_info_table['5NCPU']._value = tostring(docker_info.body.NCPU)
+  docker_info_table['6MemTotal']._value = byte_format(docker_info.body.MemTotal)
+  if docker_info.body.DockerRootDir then
+    local statvfs = nixio.fs.statvfs(docker_info.body.DockerRootDir)
+    local size = statvfs and (statvfs.bavail * statvfs.bsize) or 0
+    docker_info_table['7DockerRootDir']._value = docker_info.body.DockerRootDir .. " (" .. tostring(byte_format(size)) .. " " .. translate("Available") .. ")"
+  end
+  docker_info_table['8IndexServerAddress']._value = docker_info.body.IndexServerAddress
+  for i, v in ipairs(docker_info.body.RegistryConfig.Mirrors) do
+    docker_info_table['9RegistryMirrors']._value = docker_info_table['9RegistryMirrors']._value == "-" and v or (docker_info_table['9RegistryMirrors']._value .. ", " .. v)
+  end
+
+  s.images_used = 0
+  for i, v in ipairs(images_list) do
+    for ci,cv in ipairs(containers_list) do
+      if v.Id == cv.ImageID then
+        s.images_used = s.images_used + 1
+        break
+      end
+    end
+  end
+  s.containers_running = tostring(docker_info.body.ContainersRunning)
+  s.images_used = tostring(s.images_used)
+  s.containers_total = tostring(docker_info.body.Containers)
+  s.images_total = tostring(#images_list)
+  s.networks_total = tostring(#networks_list)
+  s.volumes_total = tostring(#volumes_list)
+end
+s.template = "dockerman/overview"
+
+local section_dockerman = map_dockerman:section(NamedSection, "local", "section", translate("Setting"))
+section_dockerman:tab("daemon", translate("Docker Daemon"))
+section_dockerman:tab("ac", translate("Access Control"))
+section_dockerman:tab("dockerman",  translate("DockerMan"))
+
+local socket_path = section_dockerman:taboption("dockerman", Value, "socket_path", translate("Docker Socket Path"))
+socket_path.default = "/var/run/docker.sock"
+socket_path.placeholder = "/var/run/docker.sock"
+socket_path.rmempty = false
+
+local remote_endpoint = section_dockerman:taboption("dockerman", Flag, "remote_endpoint", translate("Remote Endpoint"), translate("Dockerman connect to remote endpoint"))
+remote_endpoint.rmempty = false
+remote_endpoint.enabled = "true"
+remote_endpoint.disabled = "false"
+
+local remote_host = section_dockerman:taboption("dockerman", Value, "remote_host", translate("Remote Host"))
+remote_host.placeholder = "10.1.1.2"
+-- remote_host:depends("remote_endpoint", "true")
+
+local remote_port = section_dockerman:taboption("dockerman", Value, "remote_port", translate("Remote Port"))
+remote_port.placeholder = "2375"
+remote_port.default = "2375"
+-- remote_port:depends("remote_endpoint", "true")
+
+-- local status_path = section_dockerman:taboption("dockerman", Value, "status_path", translate("Action Status Tempfile Path"), translate("Where you want to save the docker status file"))
+-- local debug = section_dockerman:taboption("dockerman", Flag, "debug", translate("Enable Debug"), translate("For debug, It shows all docker API actions of luci-app-dockerman in Debug Tempfile Path"))
+-- debug.enabled="true"
+-- debug.disabled="false"
+-- local debug_path = section_dockerman:taboption("dockerman", Value, "debug_path", translate("Debug Tempfile Path"), translate("Where you want to save the debug tempfile"))
+
+if nixio.fs.access("/usr/bin/dockerd") then
+  local allowed_interface = section_dockerman:taboption("ac", DynamicList, "ac_allowed_interface", translate("Allowed access interfaces"), translate("Which interface(s) can access containers under the bridge network, fill-in Interface Name"))
+  local interfaces = luci.sys and luci.sys.net and luci.sys.net.devices() or {}
+  for i, v in ipairs(interfaces) do
+    allowed_interface:value(v, v)
+  end
+  local allowed_container = section_dockerman:taboption("ac", DynamicList, "ac_allowed_container", translate("Containers allowed to be accessed"), translate("Which container(s) under bridge network can be accessed, even from interfaces that are not allowed, fill-in Container Id or Name"))
+  -- allowed_container.placeholder = "container name_or_id"
+  if containers_list then
+    for i, v in ipairs(containers_list) do
+      if  v.State == "running" and v.NetworkSettings and v.NetworkSettings.Networks and v.NetworkSettings.Networks.bridge and v.NetworkSettings.Networks.bridge.IPAddress then
+        allowed_container:value(v.Id:sub(1,12), v.Names[1]:sub(2) .. " | " .. v.NetworkSettings.Networks.bridge.IPAddress)
+      end
+    end
+  end
+
+  local dockerd_enable = section_dockerman:taboption("daemon", Flag, "daemon_ea", translate("Enable"))
+  dockerd_enable.enabled = "true"
+  dockerd_enable.rmempty = true
+  local data_root = section_dockerman:taboption("daemon", Value, "daemon_data_root", translate("Docker Root Dir"))
+  data_root.placeholder = "/opt/docker/"
+  local registry_mirrors = section_dockerman:taboption("daemon", DynamicList, "daemon_registry_mirrors", translate("Registry Mirrors"))
+  registry_mirrors:value("https://hub-mirror.c.163.com", "https://hub-mirror.c.163.com")
+
+  local log_level = section_dockerman:taboption("daemon", ListValue, "daemon_log_level", translate("Log Level"), translate('Set the logging level'))
+  log_level:value("debug", "debug")
+  log_level:value("info", "info")
+  log_level:value("warn", "warn")
+  log_level:value("error", "error")
+  log_level:value("fatal", "fatal")
+  local hosts = section_dockerman:taboption("daemon", DynamicList, "daemon_hosts", translate("Server Host"), translate('Daemon unix socket (unix:///var/run/docker.sock) or TCP Remote Hosts (tcp://0.0.0.0:2375), default: unix:///var/run/docker.sock'))
+  hosts:value("unix:///var/run/docker.sock", "unix:///var/run/docker.sock")
+  hosts:value("tcp://0.0.0.0:2375", "tcp://0.0.0.0:2375")
+  hosts.rmempty = true
+end
+return map_dockerman
\ No newline at end of file
diff --git a/applications/luci-app-dockerman/luasrc/model/cbi/dockerman/volumes.lua b/applications/luci-app-dockerman/luasrc/model/cbi/dockerman/volumes.lua
new file mode 100644 (file)
index 0000000..2374969
--- /dev/null
@@ -0,0 +1,116 @@
+--[[
+LuCI - Lua Configuration Interface
+Copyright 2019 lisaac <https://github.com/lisaac/luci-app-dockerman>
+]]--
+
+require "luci.util"
+local uci = luci.model.uci.cursor()
+local docker = require "luci.model.docker"
+local dk = docker.new()
+
+local containers, volumes
+local res = dk.volumes:list()
+if res.code <300 then volumes = res.body.Volumes else return end
+res = dk.containers:list({query = {all=true}})
+if res.code <300 then containers = res.body else return end
+
+function get_volumes()
+  local data = {}
+  for i, v in ipairs(volumes) do
+    -- local index = v.CreatedAt .. v.Name
+    local index = v.Name
+    data[index]={}
+    data[index]["_selected"] = 0
+    data[index]["_nameraw"] = v.Name
+    data[index]["_name"] = v.Name:sub(1,12)
+    for ci,cv in ipairs(containers) do
+      if cv.Mounts and type(cv.Mounts) ~= "table" then break end
+      for vi, vv in ipairs(cv.Mounts) do
+        if v.Name == vv.Name then
+          data[index]["_containers"] = (data[index]["_containers"] and (data[index]["_containers"] .. " | ") or "")..
+          '<a href='..luci.dispatcher.build_url("admin/docker/container/"..cv.Id)..' class="dockerman_link" title="'..translate("Container detail")..'">'.. cv.Names[1]:sub(2)..'</a>'
+        end
+      end
+    end
+    data[index]["_driver"] = v.Driver
+    data[index]["_mountpoint"] = nil
+    for v1 in v.Mountpoint:gmatch('[^/]+') do
+      if v1 == index then 
+        data[index]["_mountpoint"] = data[index]["_mountpoint"] .."/" .. v1:sub(1,12) .. "..."
+      else
+        data[index]["_mountpoint"] = (data[index]["_mountpoint"] and data[index]["_mountpoint"] or "").."/".. v1
+      end
+    end
+    data[index]["_created"] = v.CreatedAt
+  end
+  return data
+end
+
+local volume_list = get_volumes()
+
+-- m = Map("docker", translate("Docker"))
+m = SimpleForm("docker", translate("Docker"))
+m.submit=false
+m.reset=false
+
+
+volume_table = m:section(Table, volume_list, translate("Volumes"))
+
+volume_selecter = volume_table:option(Flag, "_selected","")
+volume_selecter.disabled = 0
+volume_selecter.enabled = 1
+volume_selecter.default = 0
+
+volume_id = volume_table:option(DummyValue, "_name", translate("Name"))
+volume_table:option(DummyValue, "_driver", translate("Driver"))
+volume_table:option(DummyValue, "_containers", translate("Containers")).rawhtml = true
+volume_table:option(DummyValue, "_mountpoint", translate("Mount Point"))
+volume_table:option(DummyValue, "_created", translate("Created"))
+volume_selecter.write = function(self, section, value)
+  volume_list[section]._selected = value
+end
+
+docker_status = m:section(SimpleSection)
+docker_status.template = "dockerman/apply_widget"
+docker_status.err=docker:read_status()
+docker_status.err=docker_status.err and docker_status.err:gsub("\n","<br>"):gsub(" ","&nbsp;")
+if docker_status.err then docker:clear_status() end
+
+action = m:section(Table,{{}})
+action.notitle=true
+action.rowcolors=false
+action.template="cbi/nullsection"
+btnremove = action:option(Button, "remove")
+btnremove.inputtitle= translate("Remove")
+btnremove.template = "dockerman/cbi/inlinebutton"
+btnremove.inputstyle = "remove"
+btnremove.forcewrite = true
+btnremove.write = function(self, section)
+  local volume_selected = {}
+  -- 遍历table中sectionid
+  local volume_table_sids = volume_table:cfgsections()
+  for _, volume_table_sid in ipairs(volume_table_sids) do
+    -- 得到选中项的名字
+    if volume_list[volume_table_sid]._selected == 1 then
+      -- volume_selected[#volume_selected+1] = volume_id:cfgvalue(volume_table_sid)
+      volume_selected[#volume_selected+1] = volume_table_sid
+    end
+  end
+  if next(volume_selected) ~= nil then
+    local success = true
+    docker:clear_status()
+    for _,vol in ipairs(volume_selected) do
+      docker:append_status("Volumes: " .. "remove" .. " " .. vol .. "...")
+      local msg = dk.volumes["remove"](dk, {id = vol})
+      if msg.code ~= 204 then
+        docker:append_status("code:" .. msg.code.." ".. (msg.body.message and msg.body.message or msg.message).. "\n")
+        success = false
+      else
+        docker:append_status("done\n")
+      end
+    end
+    if success then docker:clear_status() end
+    luci.http.redirect(luci.dispatcher.build_url("admin/docker/volumes"))
+  end
+end
+return m
\ No newline at end of file
diff --git a/applications/luci-app-dockerman/luasrc/model/docker.lua b/applications/luci-app-dockerman/luasrc/model/docker.lua
new file mode 100644 (file)
index 0000000..65628c3
--- /dev/null
@@ -0,0 +1,397 @@
+--[[
+LuCI - Lua Configuration Interface
+Copyright 2019 lisaac <https://github.com/lisaac/luci-app-dockerman>
+]]--
+
+require "luci.util"
+local docker = require "luci.docker"
+local uci = (require "luci.model.uci").cursor()
+
+local _docker = {}
+
+--pull image and return iamge id
+local update_image = function(self, image_name)
+  local json_stringify = luci.jsonc and luci.jsonc.stringify
+  _docker:append_status("Images: " .. "pulling" .. " " .. image_name .. "...\n")
+  local res = self.images:create({query = {fromImage=image_name}}, _docker.pull_image_show_status_cb)
+  if res and res.code == 200 and (#res.body > 0 and not res.body[#res.body].error and res.body[#res.body].status and (res.body[#res.body].status == "Status: Downloaded newer image for ".. image_name)) then
+    _docker:append_status("done\n")
+  else
+    res.body.message = res.body[#res.body] and res.body[#res.body].error or (res.body.message or res.message)
+  end
+  new_image_id = self.images:inspect({name = image_name}).body.Id
+  return new_image_id, res
+end
+
+local table_equal = function(t1, t2)
+  if not t1 then return true end
+  if not t2 then return false end
+  if #t1 ~= #t2 then return false end
+  for i, v in ipairs(t1) do
+    if t1[i] ~= t2[i] then return false end
+  end
+  return true
+end
+
+local table_subtract = function(t1, t2)
+  if not t1 or next(t1) == nil then return nil end
+  if not t2 or next(t2) == nil then return t1 end
+  local res = {}
+  for _, v1 in ipairs(t1) do
+    local found = false
+    for _, v2 in ipairs(t2) do
+      if v1 == v2 then
+        found= true
+        break
+      end
+    end
+    if not found then
+      table.insert(res, v1)
+    end
+  end
+  return next(res) == nil and nil or res
+end
+
+local map_subtract = function(t1, t2)
+  if not t1 or next(t1) == nil then return nil end
+  if not t2 or next(t2) == nil then return t1 end
+  local res = {}
+  for k1, v1 in pairs(t1) do
+    local found = false
+    for k2, v2 in ipairs(t2) do
+      if k1 == k2 and luci.util.serialize_data(v1) == luci.util.serialize_data(v2) then
+        found= true
+        break
+      end
+    end
+    if not found then
+      res[k1] = v1
+      -- if v1 and type(v1) == "table" then
+      --   if next(v1) == nil then 
+      --     res[k1] = { k = 'v' }
+      --   else
+      --     res[k1] = v1
+      --   end
+      -- end
+    end
+  end
+
+  return next(res) ~= nil and res or nil
+end
+
+_docker.clear_empty_tables = function ( t )
+  local k, v
+  if next(t) == nil then
+    t = nil
+  else
+    for k, v in pairs(t) do
+      if type(v) == 'table' then
+        t[k] = _docker.clear_empty_tables(v)
+      end
+    end
+  end
+  return t
+end
+
+-- return create_body, extra_network
+local get_config = function(container_config, image_config)
+  local config = container_config.Config
+  local old_host_config = container_config.HostConfig
+  local old_network_setting = container_config.NetworkSettings.Networks or {}
+  if config.WorkingDir == image_config.WorkingDir then config.WorkingDir = "" end
+  if config.User == image_config.User then config.User = "" end
+  if table_equal(config.Cmd, image_config.Cmd) then config.Cmd = nil end
+  if table_equal(config.Entrypoint, image_config.Entrypoint) then config.Entrypoint = nil end
+  if table_equal(config.ExposedPorts, image_config.ExposedPorts) then config.ExposedPorts = nil end
+  config.Env = table_subtract(config.Env, image_config.Env)
+  config.Labels = table_subtract(config.Labels, image_config.Labels)
+  config.Volumes = map_subtract(config.Volumes, image_config.Volumes)
+  -- subtract ports exposed in image from container
+  if old_host_config.PortBindings and next(old_host_config.PortBindings) ~= nil then
+    config.ExposedPorts = {}
+    for p, v in pairs(old_host_config.PortBindings) do
+      config.ExposedPorts[p] = { HostPort=v[1] and v[1].HostPort }
+    end
+  end
+
+  -- handle network config, we need only one network, extras need to network connect action
+  local network_setting = {}
+  local multi_network = false
+  local extra_network = {}
+  for k, v in pairs(old_network_setting) do
+    if multi_network then
+      extra_network[k] = v
+    else
+      network_setting[k] = v
+    end
+    multi_network = true
+  end
+
+  -- handle hostconfig
+  local host_config = old_host_config
+  -- if host_config.PortBindings and next(host_config.PortBindings) == nil then host_config.PortBindings = nil end
+  -- host_config.LogConfig = nil
+  host_config.Mounts = {}
+  -- for volumes
+  for i, v in ipairs(container_config.Mounts) do
+    if v.Type == "volume" then
+      table.insert(host_config.Mounts, {
+        Type = v.Type,
+        Target = v.Destination,
+        Source = v.Source:match("([^/]+)\/_data"),
+        BindOptions = (v.Type == "bind") and {Propagation = v.Propagation} or nil,
+        ReadOnly = not v.RW
+      })
+    end
+  end
+  
+
+  -- merge configs
+  local create_body = config
+  create_body["HostConfig"] = host_config
+  create_body["NetworkingConfig"] = {EndpointsConfig = network_setting}
+  create_body = _docker.clear_empty_tables(create_body) or {}
+  extra_network = _docker.clear_empty_tables(extra_network) or {}
+  return create_body, extra_network
+end
+
+local upgrade = function(self, request)
+  _docker:clear_status()
+  -- get image name, image id, container name, configuration information
+  local container_info = self.containers:inspect({id = request.id})
+  if container_info.code > 300 and type(container_info.body) == "table" then
+    return container_info
+  end
+  local image_name = container_info.body.Config.Image
+  if not image_name:match(".-:.+") then image_name = image_name .. ":latest" end
+  local old_image_id = container_info.body.Image
+  local container_name = container_info.body.Name:sub(2)
+
+  local image_id, res = update_image(self, image_name)
+  if res and res.code ~= 200 then return res end
+  if image_id == old_image_id then
+    return {code = 305, body = {message = "Already up to date"}}
+  end
+
+  _docker:append_status("Container: " .. "Stop" .. " " .. container_name .. "...")
+  res = self.containers:stop({name = container_name})
+  if res and res.code < 305 then
+    _docker:append_status("done\n")
+  else
+    return res
+  end
+
+  _docker:append_status("Container: rename" .. " " .. container_name .. " to ".. container_name .. "_old ...")
+  res = self.containers:rename({name = container_name, query = { name = container_name .. "_old" }})
+  if res and res.code < 300 then
+    _docker:append_status("done\n")
+  else
+    return res
+  end
+
+  -- handle config
+  local image_config = self.images:inspect({id = old_image_id}).body.Config
+  local create_body, extra_network = get_config(container_info.body, image_config)
+
+  -- create new container
+  _docker:append_status("Container: Create" .. " " .. container_name .. "...")
+  create_body = _docker.clear_empty_tables(create_body)
+  res = self.containers:create({name = container_name, body = create_body})
+  if res and res.code > 300 then return res end
+  _docker:append_status("done\n")
+
+  -- extra networks need to network connect action
+  for k, v in pairs(extra_network) do
+    _docker:append_status("Networks: Connect" .. " " .. container_name .. "...")
+    res = self.networks:connect({id = k, body = {Container = container_name, EndpointConfig = v}})
+    if res.code > 300 then return res end
+
+    _docker:append_status("done\n")
+  end
+  _docker:clear_status()
+  return res
+end
+
+local duplicate_config = function (self, request)
+  local container_info = self.containers:inspect({id = request.id})
+  if container_info.code > 300 and type(container_info.body) == "table" then return nil end
+  local old_image_id = container_info.body.Image
+  local image_config = self.images:inspect({id = old_image_id}).body.Config
+  return get_config(container_info.body, image_config)
+end
+
+_docker.new = function(option)
+  local option = option or {}
+  local remote = uci:get("dockerman", "local", "remote_endpoint")
+  options = {
+    host = (remote == "true") and (option.host or uci:get("dockerman", "local", "remote_host")) or nil,
+    port = (remote == "true") and (option.port or uci:get("dockerman", "local", "remote_port")) or nil,
+    debug = option.debug or uci:get("dockerman", "local", "debug") == 'true' and true or false,
+    debug_path = option.debug_path or uci:get("dockerman", "local", "debug_path")
+  }
+  options.socket_path = (remote ~= "true" or not options.host or not options.port) and (option.socket_path or uci:get("dockerman", "local", "socket_path") or "/var/run/docker.sock") or nil
+  local _new = docker.new(options)
+  _new.options.status_path = uci:get("dockerman", "local", "status_path")
+  _new.containers_upgrade = upgrade
+  _new.containers_duplicate_config = duplicate_config
+  return _new
+end
+_docker.options={}
+_docker.options.status_path = uci:get("dockerman", "local", "status_path")
+
+_docker.append_status=function(self,val)
+  if not val then return end
+  local file_docker_action_status=io.open(self.options.status_path, "a+")
+  file_docker_action_status:write(val)
+  file_docker_action_status:close()
+end
+
+_docker.write_status=function(self,val)
+  if not val then return end
+  local file_docker_action_status=io.open(self.options.status_path, "w+")
+  file_docker_action_status:write(val)
+  file_docker_action_status:close()
+end
+
+_docker.read_status=function(self)
+  return nixio.fs.readfile(self.options.status_path)
+end
+
+_docker.clear_status=function(self)
+  nixio.fs.remove(self.options.status_path)
+end
+
+local status_cb = function(res, source, handler)
+  res.body = res.body or {}
+  while true do
+    local chunk = source()
+    if chunk then
+      --standard output to res.body
+      table.insert(res.body, chunk)
+      handler(chunk)
+    else
+      return
+    end
+  end
+end
+
+--{"status":"Pulling from library\/debian","id":"latest"}
+--{"status":"Pulling fs layer","progressDetail":[],"id":"50e431f79093"}
+--{"status":"Downloading","progressDetail":{"total":50381971,"current":2029978},"id":"50e431f79093","progress":"[==>                                                ]   2.03MB\/50.38MB"}
+--{"status":"Download complete","progressDetail":[],"id":"50e431f79093"}
+--{"status":"Extracting","progressDetail":{"total":50381971,"current":17301504},"id":"50e431f79093","progress":"[=================>                                 ]   17.3MB\/50.38MB"}
+--{"status":"Pull complete","progressDetail":[],"id":"50e431f79093"}
+--{"status":"Digest: sha256:a63d0b2ecbd723da612abf0a8bdb594ee78f18f691d7dc652ac305a490c9b71a"}
+--{"status":"Status: Downloaded newer image for debian:latest"}
+_docker.pull_image_show_status_cb = function(res, source)
+  return status_cb(res, source, function(chunk)
+    local json_parse = luci.jsonc.parse
+    local step = json_parse(chunk)
+    if type(step) == "table" then
+      local buf = _docker:read_status()
+      local num = 0
+      local str = '\t' .. (step.id and (step.id .. ": ") or "") .. (step.status and step.status or "")  .. (step.progress and (" " .. step.progress) or "").."\n"
+      if step.id then buf, num = buf:gsub("\t"..step.id .. ": .-\n", str) end
+      if num == 0 then
+        buf = buf .. str
+      end
+      _docker:write_status(buf)
+    end
+  end)
+end
+
+--{"status":"Downloading from https://downloads.openwrt.org/releases/19.07.0/targets/x86/64/openwrt-19.07.0-x86-64-generic-rootfs.tar.gz"}
+--{"status":"Importing","progressDetail":{"current":1572391,"total":3821714},"progress":"[====================\u003e                              ]  1.572MB/3.822MB"}
+--{"status":"sha256:d5304b58e2d8cc0a2fd640c05cec1bd4d1229a604ac0dd2909f13b2b47a29285"}
+_docker.import_image_show_status_cb = function(res, source)
+  return status_cb(res, source, function(chunk)
+    local json_parse = luci.jsonc.parse
+    local step = json_parse(chunk)
+    if type(step) == "table" then
+      local buf = _docker:read_status()
+      local num = 0
+      local str = '\t' .. (step.status and step.status or "") .. (step.progress and (" " .. step.progress) or "").."\n"
+      if step.status then buf, num = buf:gsub("\t"..step.status .. " .-\n", str) end
+      if num == 0 then
+        buf = buf .. str
+      end
+      _docker:write_status(buf)
+    end
+  end
+  )
+end
+
+-- _docker.print_status_cb = function(res, source)
+--   return status_cb(res, source, function(step)
+--     luci.util.perror(step)
+--   end
+--   )
+-- end
+
+_docker.create_macvlan_interface = function(name, device, gateway, subnet)
+  if not nixio.fs.access("/etc/config/network") or not nixio.fs.access("/etc/config/firewall") then return end
+  if uci:get("dockerman", "local", "remote_endpoint") == "true" then return end
+  local ip = require "luci.ip"
+  local if_name = "docker_"..name
+  local dev_name = "macvlan_"..name
+  local net_mask = tostring(ip.new(subnet):mask())
+  local lan_interfaces
+  -- add macvlan device
+  uci:delete("network", dev_name)
+  uci:set("network", dev_name, "device")
+  uci:set("network", dev_name, "name", dev_name)
+  uci:set("network", dev_name, "ifname", device)
+  uci:set("network", dev_name, "type", "macvlan")
+  uci:set("network", dev_name, "mode", "bridge")
+  -- add macvlan interface
+  uci:delete("network", if_name)
+  uci:set("network", if_name, "interface")
+  uci:set("network", if_name, "proto", "static")
+  uci:set("network", if_name, "ifname", dev_name)
+  uci:set("network", if_name, "ipaddr", gateway)
+  uci:set("network", if_name, "netmask", net_mask)
+  uci:foreach("firewall", "zone", function(s)
+    if s.name == "lan" then
+      local interfaces
+      if type(s.network) == "table" then
+        interfaces = table.concat(s.network, " ")
+        uci:delete("firewall", s[".name"], "network")
+      else
+        interfaces = s.network and s.network or ""
+      end
+      interfaces = interfaces .. " " .. if_name
+      interfaces = interfaces:gsub("%s+", " ")
+      uci:set("firewall", s[".name"], "network", interfaces)
+    end
+  end)
+  uci:commit("firewall")
+  uci:commit("network")
+  os.execute("ifup " .. if_name)
+end
+
+_docker.remove_macvlan_interface = function(name)
+  if not nixio.fs.access("/etc/config/network") or not nixio.fs.access("/etc/config/firewall") then return end
+  if uci:get("dockerman", "local", "remote_endpoint") == "true" then return end
+  local if_name = "docker_"..name
+  local dev_name = "macvlan_"..name
+  uci:foreach("firewall", "zone", function(s)
+    if s.name == "lan" then
+      local interfaces
+      if type(s.network) == "table" then
+        interfaces = table.concat(s.network, " ")
+      else
+        interfaces = s.network and s.network or ""
+      end
+      interfaces = interfaces and interfaces:gsub(if_name, "")
+      interfaces = interfaces and interfaces:gsub("%s+", " ")
+      uci:set("firewall", s[".name"], "network", interfaces)
+    end
+  end)
+  uci:commit("firewall")
+  uci:delete("network", dev_name)
+  uci:delete("network", if_name)
+  uci:commit("network")
+  os.execute("ip link del " .. if_name)
+end
+
+return _docker
\ No newline at end of file
diff --git a/applications/luci-app-dockerman/luasrc/view/dockerman/apply_widget.htm b/applications/luci-app-dockerman/luasrc/view/dockerman/apply_widget.htm
new file mode 100644 (file)
index 0000000..334c76a
--- /dev/null
@@ -0,0 +1,140 @@
+<style type="text/css">
+  #docker_apply_overlay {
+    position: absolute;
+    top: 0;
+    left: 0;
+    bottom: 0;
+    right: 0;
+    background: rgba(0, 0, 0, 0.7);
+    display: none;
+    z-index: 20000;
+  }
+
+  #docker_apply_overlay .alert-message {
+    position: relative;
+    top: 10%;
+    width: 60%;
+    margin: auto;
+    display: flex;
+    flex-wrap: wrap;
+    min-height: 32px;
+    align-items: center;
+  }
+
+  #docker_apply_overlay .alert-message > h4,
+  #docker_apply_overlay .alert-message > p,
+  #docker_apply_overlay .alert-message > div {
+    flex-basis: 100%;
+  }
+
+  #docker_apply_overlay .alert-message > img {
+    margin-right: 1em;
+    flex-basis: 32px;
+  }
+
+  body.apply-overlay-active {
+    overflow: hidden;
+    height: 100vh;
+  }
+
+  body.apply-overlay-active #docker_apply_overlay {
+    display: block;
+  }
+</style>
+<script type="text/javascript">//<![CDATA[
+    var xhr = new XHR(),
+      uci_apply_rollback = <%=math.max(luci.config and luci.config.apply and luci.config.apply.rollback or 30, 30)%>,
+      uci_apply_holdoff = <%=math.max(luci.config and luci.config.apply and luci.config.apply.holdoff or 4, 1)%>,
+      uci_apply_timeout = <%=math.max(luci.config and luci.config.apply and luci.config.apply.timeout or 5, 1)%>,
+      uci_apply_display = <%=math.max(luci.config and luci.config.apply and luci.config.apply.display or 1.5, 1)%>,
+      was_xhr_poll_running = false;
+
+function docker_status_message(type, content) {
+  document.getElementById('docker_apply_overlay') || document.body.insertAdjacentHTML("beforeend",'<div id="docker_apply_overlay"><div class="alert-message"></div></div>')
+  var overlay = document.getElementById('docker_apply_overlay')
+      message = overlay.querySelector('.alert-message');
+
+  if (message && type) {
+    if (!message.classList.contains(type)) {
+      message.classList.remove('notice');
+      message.classList.remove('warning');
+      message.classList.add(type);
+    }
+
+    if (content)
+      message.innerHTML = content;
+
+    document.body.classList.add('apply-overlay-active');
+    document.body.scrollTop = document.documentElement.scrollTop = 0;
+    if (!was_xhr_poll_running) {
+      was_xhr_poll_running = XHR.running();
+      XHR.halt();
+    }
+  }
+  else {
+    document.body.classList.remove('apply-overlay-active');
+
+    if (was_xhr_poll_running)
+      XHR.run();
+  }
+}
+var loading_msg="Loading.."
+function uci_confirm_docker() {
+    var tt;
+    docker_status_message('notice');
+    var call = function(r, resjson, duration) {
+      if (r && r.status === 200 ) {
+        var indicator = document.querySelector('.uci_change_indicator');
+        if (indicator) indicator.style.display = 'none';
+        docker_status_message('notice', '<%:Docker actions done.%>');
+        document.body.classList.remove('apply-overlay-active');
+        window.clearTimeout(tt);
+        return;
+      }
+      loading_msg = resjson?resjson.info:loading_msg
+      // var delay = isNaN(duration) ? 0 : Math.max(1000 - duration, 0);
+      var delay =1000
+      window.setTimeout(function() {
+        xhr.get('<%=url("admin/docker/confirm")%>', null, call, uci_apply_timeout * 1000);
+      }, delay);
+    };
+
+    var tick = function() {
+      var now = Date.now();
+
+      docker_status_message('notice',
+        '<img src="<%=resource%>/icons/loading.gif" alt="" style="vertical-align:middle" /> <span style="white-space:pre-line; word-break:break-all; font-family: \'Courier New\', Courier, monospace;">' +
+        loading_msg + '</span>');
+
+      tt = window.setTimeout(tick, 200);
+      ts = now;
+    };
+    tick();
+    /* wait a few seconds for the settings to become effective */
+    window.setTimeout(call, Math.max(uci_apply_holdoff * 1000 , 1));
+  }
+  // document.getElementsByTagName("form")[0].addEventListener("submit", (e)=>{
+  //   uci_confirm_docker()
+  // })
+
+function fnSubmitForm(el){
+  if (el.id != "cbid.table.1._new") {
+    uci_confirm_docker()
+  }
+}
+
+<% if self.err then -%>
+  docker_status_message('warning', '<span style="white-space:pre-line; word-break:break-all; font-family: \'Courier New\', Courier, monospace;">'+`<%=self.err%>`+'</span>');
+  document.getElementById('docker_apply_overlay').addEventListener("click", (e)=>{
+    docker_status_message()
+  })
+<%- end %>
+
+window.onload= function (){
+var buttons = document.querySelectorAll('input[type="submit"]');
+[].slice.call(buttons).forEach(function (el) {
+  el.onclick = fnSubmitForm.bind(this, el);
+});
+}
+
+//]]></script>
\ No newline at end of file
diff --git a/applications/luci-app-dockerman/luasrc/view/dockerman/cbi/inlinebutton.htm b/applications/luci-app-dockerman/luasrc/view/dockerman/cbi/inlinebutton.htm
new file mode 100644 (file)
index 0000000..b1b1932
--- /dev/null
@@ -0,0 +1,7 @@
+<div style="display: inline-block;">
+       <% if self:cfgvalue(section) ~= false then %>
+               <input class="cbi-button cbi-button-<%=self.inputstyle or "button" %>" type="submit"" <% if self.disable then %>disabled <% end %><%= attr("name", cbid) .. attr("id", cbid) .. attr("value", self.inputtitle or self.title)%> />
+       <% else %>
+               -
+       <% end %>
+</div>
diff --git a/applications/luci-app-dockerman/luasrc/view/dockerman/cbi/inlinevalue.htm b/applications/luci-app-dockerman/luasrc/view/dockerman/cbi/inlinevalue.htm
new file mode 100644 (file)
index 0000000..51c97f5
--- /dev/null
@@ -0,0 +1,33 @@
+<div style="display: inline-block;">
+       <!-- <%- if self.title then -%>
+       <label class="cbi-value-title"<%= attr("for", cbid) %>>
+               <%- if self.titleref then -%><a title="<%=self.titledesc or translate('Go to relevant configuration page')%>" class="cbi-title-ref" href="<%=self.titleref%>"><%- end -%>
+                       <%-=self.title-%>
+               <%- if self.titleref then -%></a><%- end -%>
+               </label>
+       <%- end -%> -->
+       <%- if self.password then -%>
+               <input type="password" style="position:absolute; left:-100000px" aria-hidden="true"<%=
+                       attr("name", "password." .. cbid)
+               %> />
+       <%- end -%>
+       <input data-update="change"<%=
+               attr("id", cbid) ..
+               attr("name", cbid) ..
+               attr("type", self.password and "password" or "text") ..
+               attr("class", self.password and "cbi-input-password" or "cbi-input-text") ..
+               attr("value", self:cfgvalue(section) or self.default) ..
+               ifattr(self.password, "autocomplete", "new-password") ..
+               ifattr(self.size, "size") ..
+               ifattr(self.placeholder, "placeholder") ..
+               ifattr(self.readonly, "readonly") ..
+               ifattr(self.maxlength, "maxlength") ..
+               ifattr(self.datatype, "data-type", self.datatype) ..
+               ifattr(self.datatype, "data-optional", self.optional or self.rmempty) ..
+               ifattr(self.combobox_manual, "data-manual", self.combobox_manual) ..
+               ifattr(#self.keylist > 0, "data-choices", { self.keylist, self.vallist })
+       %> />
+       <%- if self.password then -%>
+               <div class="cbi-button cbi-button-neutral" title="<%:Reveal/hide password%>" onclick="var e = this.previousElementSibling; e.type = (e.type === 'password') ? 'text' : 'password'">∗</div>
+       <% end %>
+</div>
diff --git a/applications/luci-app-dockerman/luasrc/view/dockerman/cbi/namedsection.htm b/applications/luci-app-dockerman/luasrc/view/dockerman/cbi/namedsection.htm
new file mode 100644 (file)
index 0000000..244d2c1
--- /dev/null
@@ -0,0 +1,9 @@
+<% if self:cfgvalue(self.section) then section = self.section %>
+       <div class="cbi-section" id="cbi-<%=self.config%>-<%=section%>">
+               <%+cbi/tabmenu%>
+               <div class="cbi-section-node<% if self.tabs then %> cbi-section-node-tabbed<% end %>" id="cbi-<%=self.config%>-<%=section%>">
+                       <%+cbi/ucisection%>
+               </div>
+       </div>
+<% end %>
+<!-- /nsection -->
diff --git a/applications/luci-app-dockerman/luasrc/view/dockerman/cbi/xfvalue.htm b/applications/luci-app-dockerman/luasrc/view/dockerman/cbi/xfvalue.htm
new file mode 100644 (file)
index 0000000..04f7bc2
--- /dev/null
@@ -0,0 +1,10 @@
+<%+cbi/valueheader%>
+       <input type="hidden" value="1"<%=
+               attr("name", "cbi.cbe." .. self.config .. "." .. section .. "." .. self.option)
+       %> />
+       <input class="cbi-input-checkbox" data-update="click change" type="checkbox" <% if self.disable == 1 then %>disabled <% end %><%=
+               attr("id", cbid) .. attr("name", cbid) .. attr("value", self.enabled or 1) ..
+               ifattr((self:cfgvalue(section) or self.default) == self.enabled, "checked", "checked")
+       %> />
+       <label<%= attr("for", cbid)%>></label>
+<%+cbi/valuefooter%>
diff --git a/applications/luci-app-dockerman/luasrc/view/dockerman/container.htm b/applications/luci-app-dockerman/luasrc/view/dockerman/container.htm
new file mode 100644 (file)
index 0000000..82d8e00
--- /dev/null
@@ -0,0 +1,27 @@
+<br>
+<ul class="cbi-tabmenu">
+       <li  id="cbi-tab-container_info"><a id="a-cbi-tab-container_info" href=""><%:Info%></a></li>
+       <li id="cbi-tab-container_resources"><a id="a-cbi-tab-container_resources" href=""><%:Resources%></a></li>
+       <li  id="cbi-tab-container_stats"><a id="a-cbi-tab-container_stats" href=""><%:Stats%></a></li>
+       <li  id="cbi-tab-container_file"><a id="a-cbi-tab-container_file" href=""><%:File%></a></li>
+       <li  id="cbi-tab-container_console"><a id="a-cbi-tab-container_console" href=""><%:Console%></a></li>
+       <li  id="cbi-tab-container_inspect"><a id="a-cbi-tab-container_inspect" href=""><%:Inspect%></a></li>
+       <li  id="cbi-tab-container_logs"><a id="a-cbi-tab-container_logs" href=""><%:Logs%></a></li>
+</ul>
+
+<script type="text/javascript">
+       let re = /\/admin\/docker\/container\//
+       let p = window.location.href
+       let path = p.split(re)
+       let container_id = path[1].split('/')[0] || path[1]
+       let action = path[1].split('/')[1] || "info"
+       let actions=["info","resources","stats","file","console","logs","inspect"]
+       actions.forEach(function(item) {
+               document.getElementById("a-cbi-tab-container_" + item).href= path[0]+"/admin/docker/container/"+container_id+'/'+item
+               if (action === item) {
+                       document.getElementById("cbi-tab-container_" + item).className="cbi-tab"
+               }       else {
+                       document.getElementById("cbi-tab-container_" + item).className="cbi-tab-disabled"
+               }
+       })
+</script>
\ No newline at end of file
diff --git a/applications/luci-app-dockerman/luasrc/view/dockerman/container_console.htm b/applications/luci-app-dockerman/luasrc/view/dockerman/container_console.htm
new file mode 100644 (file)
index 0000000..0b9fc4c
--- /dev/null
@@ -0,0 +1,6 @@
+<div class="cbi-map">
+  <iframe id="terminal" style="width: 100%; min-height: 500px; border: none; border-radius: 3px;"></iframe>
+</div>
+<script type="text/javascript">
+  document.getElementById("terminal").src = "http://" + window.location.hostname + ":7682";
+</script>
\ No newline at end of file
diff --git a/applications/luci-app-dockerman/luasrc/view/dockerman/container_file.htm b/applications/luci-app-dockerman/luasrc/view/dockerman/container_file.htm
new file mode 100644 (file)
index 0000000..7dd7237
--- /dev/null
@@ -0,0 +1,63 @@
+
+<div id="upload-container" class="cbi-value cbi-value-last">
+  <label class="cbi-value-title" for="archive"><%:Upload%></label>
+  <div class="cbi-value-field">
+    <input type="file" name="upload_archive" accept="application/x-tar" id="upload_archive" />
+  </div>
+  <br>
+  <label class="cbi-value-title" for="path"><%:Path%></label>
+  <div class="cbi-value-field">
+    <input type="text" class="cbi-input-text" name="path" value="/tmp/" id="path" />
+  </div>
+  <br>
+  <div class="cbi-value-field">
+    <input type="button"" class="cbi-button cbi-button-action important" id="upload" name="upload" value="<%:Upload%>" />
+    <input type="button"" class="cbi-button cbi-button-action important" id="download" name="download" value="<%:Download%>" />
+  </div>
+</div>
+<script type="text/javascript">
+  let btnUpload = document.getElementById('upload')
+  btnUpload.onclick = function (e) {
+    let uploadArchive = document.getElementById('upload_archive')
+    let uploadPath = document.getElementById('path').value
+    if (!uploadArchive.value || !uploadPath) {
+      docker_status_message('warning', "<%:Please input the PATH and select the file !%>")
+      document.getElementById('docker_apply_overlay').addEventListener("click", (e)=>{
+          docker_status_message()
+        })
+      return
+    }
+    let fileName = uploadArchive.files[0].name
+    let formData = new FormData()
+    formData.append('upload-filename', fileName)
+    formData.append('upload-path', uploadPath)
+    formData.append('upload-archive', uploadArchive.files[0])
+    let xhr = new XMLHttpRequest()
+    xhr.open("POST", '<%=luci.dispatcher.build_url("admin/docker/container_put_archive")%>/<%=self.container%>', true)
+    xhr.onload = function() {
+      if (xhr.status == 200) {
+        uploadArchive.value = ''
+        docker_status_message('notice', "<%:Upload Success%>")
+      }
+      else {
+        docker_status_message('warning', "<%:Upload Error%>:" + xhr.statusText)
+      }
+      document.getElementById('docker_apply_overlay').addEventListener("click", (e)=>{
+          docker_status_message()
+        })
+    }
+    xhr.send(formData)
+  }
+  let btnDownload = document.getElementById('download')
+  btnDownload.onclick = function (e) {
+    let downloadPath = document.getElementById('path').value
+    if (!downloadPath) {
+      docker_status_message('warning', "<%:Please input the PATH !%>")
+      document.getElementById('docker_apply_overlay').addEventListener("click", (e)=>{
+          docker_status_message()
+        })
+      return
+    }
+    window.open('<%=luci.dispatcher.build_url("admin/docker/container_get_archive")%>?id=<%=self.container%>&path=' + encodeURIComponent(downloadPath))
+  }
+</script>
\ No newline at end of file
diff --git a/applications/luci-app-dockerman/luasrc/view/dockerman/container_stats.htm b/applications/luci-app-dockerman/luasrc/view/dockerman/container_stats.htm
new file mode 100644 (file)
index 0000000..37bf01b
--- /dev/null
@@ -0,0 +1,80 @@
+<script type="text/javascript">//<![CDATA[
+       let last_bw_tx
+       let last_bw_rx
+       let interval = 3
+       function progressbar(v, m, pc, np, f) {
+               m = m || 100
+
+               return String.format(
+                       '<div style="width:100%%; max-width:500px; position:relative; border:1px solid #999999">' +
+                       '<div style="background-color:#CCCCCC; width:%d%%; height:15px">' +
+                       '<div style="position:absolute; left:0; top:0; text-align:center; width:100%%; color:#000000">' +
+                       '<small>%s '+(f?f:'/')+' %s ' + (np ? "" : '(%d%%)') + '</small>' +
+                       '</div>' +
+                       '</div>' +
+                       '</div>', pc, v, m, pc, f
+               );
+       }
+
+       function niceBytes(bytes, decimals) {
+               if (bytes == 0) return '0 Bytes';
+               var k = 1000,
+                       dm = decimals + 1 || 3,
+                       sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'],
+                       i = Math.floor(Math.log(bytes) / Math.log(k));
+               return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
+       }
+
+       XHR.poll(interval, '<%=luci.dispatcher.build_url("admin/docker/container_stats")%>/<%=self.container_id%>', { status: 1 },
+               function (x, info) {
+                       var e;
+
+                       if (e = document.getElementById('cbi-table-cpu-value'))
+                               e.innerHTML = progressbar(
+                                       (info.cpu_percent), 100, (info.cpu_percent ? info.cpu_percent : 0));
+                       if (e = document.getElementById('cbi-table-memory-value'))
+                               e.innerHTML = progressbar(
+                                       niceBytes(info.memory.mem_useage),
+                                       niceBytes(info.memory.mem_limit),
+                                       ((100 / (info.memory.mem_limit ? info.memory.mem_limit : 100)) * (info.memory.mem_useage ? info.memory.mem_useage : 0))
+                               );
+
+                       for (var eth in info.bw_rxtx) {
+                               if (!document.getElementById("cbi-table-speed_" + eth + "-value")) {
+                                       let tab = document.getElementById("cbi-table-cpu").parentNode
+                                       let div = document.getElementById('cbi-table-cpu').cloneNode(true);
+                                       div.id = "cbi-table-speed_" + eth;
+                                       div.children[0].innerHTML = "<%:Upload/Download%>: " + eth
+                                       div.children[1].id = "cbi-table-speed_" + eth + "-value"
+                                       tab.appendChild(div)
+                               }
+                               if (!document.getElementById("cbi-table-network_" + eth + "-value")) {
+                                       let tab = document.getElementById("cbi-table-cpu").parentNode
+                                       let div = document.getElementById('cbi-table-cpu').cloneNode(true);
+                                       div.id = "cbi-table-network_" + eth;
+                                       div.children[0].innerHTML = "<%:TX/RX%>: " + eth
+                                       div.children[1].id = "cbi-table-network_" + eth + "-value"
+                                       tab.appendChild(div)
+                               }
+                               e = document.getElementById("cbi-table-network_" + eth + "-value")
+                               e.innerHTML = progressbar(
+                                       '↑'+niceBytes(info.bw_rxtx[eth].bw_tx),
+                                       '↓'+niceBytes(info.bw_rxtx[eth].bw_rx),
+                                       null,
+                                       true, " "
+                               );
+                               e = document.getElementById("cbi-table-speed_" + eth + "-value")
+                               if (! last_bw_tx) last_bw_tx = info.bw_rxtx[eth].bw_tx
+                               if (! last_bw_rx) last_bw_rx = info.bw_rxtx[eth].bw_rx
+                               e.innerHTML = progressbar(
+                                       '↑'+niceBytes((info.bw_rxtx[eth].bw_tx - last_bw_tx)/interval)+'/s',
+                                       '↓'+niceBytes((info.bw_rxtx[eth].bw_rx - last_bw_rx)/interval)+'/s',
+                                       null,
+                                       true, " "
+                               );
+                               last_bw_tx = info.bw_rxtx[eth].bw_tx
+                               last_bw_rx = info.bw_rxtx[eth].bw_rx
+                       }
+
+               });
+//]]></script>
\ No newline at end of file
diff --git a/applications/luci-app-dockerman/luasrc/view/dockerman/images_import.htm b/applications/luci-app-dockerman/luasrc/view/dockerman/images_import.htm
new file mode 100644 (file)
index 0000000..cae386c
--- /dev/null
@@ -0,0 +1,88 @@
+<input type="text" class="cbi-input-text" name="isrc" placeholder="http://host/image.tar" id="isrc" />
+<input type="text" class="cbi-input-text" name="itag" placeholder="repository:tag" id="itag" />
+<div style="display: inline-block;">
+  <input type="button"" class=" cbi-button cbi-button-add" id="btnimport" name="import" value="<%:Import%>" />
+  <input type="file" id="file_import" style="visibility:hidden; position: absolute;top: 0px; left: 0px;" />
+</div>
+
+<script type="text/javascript">
+  let btnImport = document.getElementById('btnimport')
+  let valISrc = document.getElementById('isrc')
+  let valITag = document.getElementById('itag')
+  btnImport.onclick = function (e) {
+    if (valISrc.value == "") {
+      document.getElementById("file_import").click()
+      return
+    } else {
+      let formData = new FormData()
+      formData.append('src', valISrc.value)
+      formData.append('tag', valITag.value)
+      let xhr = new XMLHttpRequest()
+      uci_confirm_docker()
+      xhr.open("POST", "<%=luci.dispatcher.build_url('admin/docker/images_import')%>", true)
+      xhr.onload = function () {
+        location.reload()
+      }
+      xhr.send(formData)
+    }
+  }
+  let fileimport = document.getElementById('file_import')
+  fileimport.onchange = function (e) {
+    let fileimport = document.getElementById('file_import')
+    if (!fileimport.value) {
+      return
+    }
+    let valITag = document.getElementById('itag')
+    let fileName = fileimport.files[0].name
+    let formData = new FormData()
+    formData.append('upload-filename', fileName)
+    formData.append('tag', valITag.value)
+    formData.append('upload-archive', fileimport.files[0])
+    let xhr = new XMLHttpRequest()
+    uci_confirm_docker()
+    xhr.open("POST", "<%=luci.dispatcher.build_url('admin/docker/images_import')%>", true)
+    xhr.onload = function () {
+      fileimport.value = ''
+      location.reload()
+    }
+    xhr.send(formData)
+  }
+
+  let new_tag = function (image_id) {
+    let new_tag = prompt("<%:New tag%>\n<%:Image%>" + "ID: " + image_id + "\n<%:Please input new tag%>:", "")
+    if (new_tag) {
+      (new XHR()).post("<%=luci.dispatcher.build_url('admin/docker/images_tag')%>",
+        { id: image_id, tag: new_tag },
+        function (r) {
+          if (r.status == 201) {
+            location.reload()
+          }
+          else {
+            docker_status_message('warning', 'Image: untagging ' + tag + '...fail code:' + r.status + r.statusText);
+            document.getElementById('docker_apply_overlay').addEventListener("click", (e)=>{
+              docker_status_message()
+            })
+          }
+        })
+    }
+  }
+
+  let un_tag = function (tag) {
+    if (tag.match("<none>")) return
+    if (confirm("<%:Remove tag%>: " + tag + " ?")) {
+      (new XHR()).post("<%=luci.dispatcher.build_url('admin/docker/images_untag')%>",
+        { tag: tag },
+        function (r) {
+          if (r.status == 200) {
+            location.reload()
+          }
+          else {
+            docker_status_message('warning', 'Image: untagging ' + tag + '...fail code:' + r.status + r.statusText);
+            document.getElementById('docker_apply_overlay').addEventListener("click", (e)=>{
+              docker_status_message()
+            })
+          }
+        })
+    }
+  }
+</script>
\ No newline at end of file
diff --git a/applications/luci-app-dockerman/luasrc/view/dockerman/images_load.htm b/applications/luci-app-dockerman/luasrc/view/dockerman/images_load.htm
new file mode 100644 (file)
index 0000000..4fe34e1
--- /dev/null
@@ -0,0 +1,29 @@
+<div style="display: inline-block;">
+  <input type="button"" class="cbi-button cbi-button-add" id="btnload" name="load" value="<%:Load%>" />
+  <input type="file" id="file_load" style="visibility:hidden; position: absolute;top: 0px; left: 0px;" accept="application/x-tar" />
+</div>
+<script type="text/javascript">
+  let btnLoad = document.getElementById('btnload')
+  btnLoad.onclick = function (e) {
+    document.getElementById("file_load").click()
+    e.preventDefault()
+  }
+  let fileLoad = document.getElementById('file_load')
+  fileLoad.onchange = function(e){
+    let fileLoad = document.getElementById('file_load')
+      if (!fileLoad.value) {
+      return
+    }
+    let fileName = fileLoad.files[0].name
+    let formData = new FormData()
+    formData.append('upload-filename', fileName)
+    formData.append('upload-archive', fileLoad.files[0])
+    let xhr = new XMLHttpRequest()
+    uci_confirm_docker()
+    xhr.open("POST", '<%=luci.dispatcher.build_url("admin/docker/images_load")%>', true)
+    xhr.onload = function() {
+      location.reload()
+    }
+    xhr.send(formData)
+  }
+</script>
diff --git a/applications/luci-app-dockerman/luasrc/view/dockerman/logs.htm b/applications/luci-app-dockerman/luasrc/view/dockerman/logs.htm
new file mode 100644 (file)
index 0000000..1fc2b2d
--- /dev/null
@@ -0,0 +1,13 @@
+<% if self.title == "Events" then %>
+<%+header%>
+<h2 name="content"><%:Docker%></h2>
+<div class="cbi-section">
+<h3><%:Events%></h3>
+<% end %>
+<div id="content_syslog">
+<textarea readonly="readonly" wrap="off" rows="<%=self.syslog:cmatch('\n')+2%>" id="syslog"><%=self.syslog:pcdata()%></textarea>
+</div>
+<% if self.title == "Events" then %>
+</div>
+<%+footer%>
+<% end %>
\ No newline at end of file
diff --git a/applications/luci-app-dockerman/luasrc/view/dockerman/newcontainer_resolve.htm b/applications/luci-app-dockerman/luasrc/view/dockerman/newcontainer_resolve.htm
new file mode 100644 (file)
index 0000000..dacd5e1
--- /dev/null
@@ -0,0 +1,95 @@
+<style type="text/css">
+  #dialog_reslov {
+    position: absolute;
+    top: 0;
+    left: 0;
+    bottom: 0;
+    right: 0;
+    background: rgba(0, 0, 0, 0.7);
+    display: none;
+    z-index: 20000;
+  }
+
+  #dialog_reslov .dialog_box {
+    position: relative;
+    background: rgba(255, 255, 255);
+    top: 10%;
+    width: 50%;
+    margin: auto;
+    display: flex;
+    flex-wrap: wrap;
+    height:auto;
+    align-items: center;
+  }
+
+  #dialog_reslov .dialog_line {
+    margin-top: .5em;
+    margin-bottom: .5em;
+    margin-left: 2em;
+    margin-right: 2em;
+  }
+
+  #dialog_reslov .dialog_box>h4,
+  #dialog_reslov .dialog_box>p,
+  #dialog_reslov .dialog_box>div {
+    flex-basis: 100%;
+  }
+
+  #dialog_reslov .dialog_box>img {
+    margin-right: 1em;
+    flex-basis: 32px;
+  }
+
+  body.dialog-reslov-active {
+    overflow: hidden;
+    height: 100vh;
+  }
+
+  body.dialog-reslov-active #dialog_reslov {
+    display: block;
+  }
+</style>
+<script type="text/javascript">
+  function close_reslov_dialog() {
+    document.body.classList.remove('dialog-reslov-active')
+    document.documentElement.style.overflowY = 'scroll'
+  }
+
+  function reslov_container() {
+    let s = document.getElementById('cmd-line-status')
+    if (!s) return
+    let cmd_line = document.getElementById("dialog_reslov_text").value;
+    if (cmd_line == null || cmd_line == "") {
+      return
+    }
+    cmd_line = cmd_line.replace(/(^\s*)/g,"")
+    if (!cmd_line.match(/^docker\s+(run|create)/)) {
+      s.innerHTML = "<font color='red'><%:Command line Error%></font>"
+      return
+    }
+    let reg_space = /\s+/g
+    let reg_muti_line= /\\\s*\n/g
+    //   reg_rem =/(?<!\\)`#.+(?<!\\)`/g  // the command has `# `
+    let reg_rem =/`#.+`/g// the command has `# `
+    cmd_line = cmd_line.replace(/^docker\s+(run|create)/,"DOCKERCLI").replace(reg_rem, " ").replace(reg_muti_line, " ").replace(reg_space, " ")
+    console.log(cmd_line)
+    window.location.href = '<%=luci.dispatcher.build_url("admin/docker/newcontainer")%>/' + encodeURI(cmd_line)
+  }
+
+  function clear_text(){
+    let s = document.getElementById('cmd-line-status')
+    s.innerHTML = ""
+  }
+
+  function show_reslov_dialog() {
+    document.getElementById('dialog_reslov') || document.body.insertAdjacentHTML("beforeend", '<div id="dialog_reslov"><div class="dialog_box"><div class="dialog_line"></div><div class="dialog_line"><span><%:Plese input <docker create/run> command line:%></span><br><span id="cmd-line-status"></span></div><div class="dialog_line"><textarea class="cbi-input-textarea" id="dialog_reslov_text" style="width: 100%; height:100%;" rows="15" onkeyup="clear_text()"></textarea></div><div class="dialog_line" style="text-align: right;"><input type="button" class="cbi-button cbi-button-apply" type="submit" value="<%:Submit%>" onclick="reslov_container()" /> <input type="button" class="cbi-button cbi-button-reset" type="reset" value="<%:Cancel%>" onclick="close_reslov_dialog()" /></div><div class="dialog_line"></div></div></div>')
+    document.body.classList.add('dialog-reslov-active')
+    let s = document.getElementById('cmd-line-status')
+    s.innerHTML = ""
+    document.documentElement.style.overflowY = 'hidden'
+  }
+</script>
+<%+cbi/valueheader%>
+<input type="button" class="cbi-button cbi-button-apply" value="<%:Command line%>" onclick="show_reslov_dialog()" />
+
+<%+cbi/valuefooter%>
\ No newline at end of file
diff --git a/applications/luci-app-dockerman/luasrc/view/dockerman/overview.htm b/applications/luci-app-dockerman/luasrc/view/dockerman/overview.htm
new file mode 100644 (file)
index 0000000..a0c2f6f
--- /dev/null
@@ -0,0 +1,280 @@
+<style>
+  /*!
+Pure v1.0.1
+Copyright 2013 Yahoo!
+Licensed under the BSD License.
+https://github.com/pure-css/pure/blob/master/LICENSE.md
+*/
+  .pure-g {
+    letter-spacing: -.31em;
+    text-rendering: optimizespeed;
+    font-family: FreeSans, Arimo, "Droid Sans", Helvetica, Arial, sans-serif;
+    display: -webkit-box;
+    display: -webkit-flex;
+    display: -ms-flexbox;
+    display: flex;
+    -webkit-box-orient: horizontal;
+    -webkit-box-direction: normal;
+    -webkit-flex-flow: row wrap;
+    -ms-flex-flow: row wrap;
+    flex-flow: row wrap;
+    -webkit-align-content: flex-start;
+    -ms-flex-line-pack: start;
+    align-content: flex-start
+  }
+
+  .pure-u {
+    display: inline-block;
+    zoom: 1;
+    letter-spacing: normal;
+    word-spacing: normal;
+    vertical-align: top;
+    text-rendering: auto
+  }
+
+  .pure-g [class*=pure-u] {
+    font-family: sans-serif
+  }
+
+  .pure-u-1-4,
+  .pure-u-2-5,
+  .pure-u-3-5 {
+    display: inline-block;
+    zoom: 1;
+    letter-spacing: normal;
+    word-spacing: normal;
+    vertical-align: top;
+    text-rendering: auto
+  }
+
+  .pure-u-1-4 {
+    width: 25%
+  }
+
+  .pure-u-2-5 {
+    width: 40%
+  }
+
+  .pure-u-3-5 {
+    width: 60%
+  }
+
+  .status {
+    margin: 1rem -0.5rem 1rem -0.5rem;
+  }
+
+  .block {
+    margin: 0.5rem 0.5rem;
+    padding: 0;
+    font-weight: normal;
+    font-style: normal;
+    line-height: 1;
+    font-family: inherit;
+    min-width: inherit;
+    overflow-x: auto;
+    overflow-y: hidden;
+    border: 1px solid rgba(0, 0, 0, .05);
+    border-radius: .375rem;
+    box-shadow: 0 0 2rem 0 rgba(136, 152, 170, .15);
+  }
+
+  .img-con {
+    margin: 1rem;
+    min-width: 4rem;
+    max-width: 4rem;
+    min-height: 4rem;
+    max-height: 4rem;
+  }
+
+  .block h4 {
+    font-size: .8125rem;
+    font-weight: 600;
+    margin: 1rem;
+    color: #8898aa !important;
+    line-height: 1.8em;
+  }
+
+  .cbi-section-table-cell {
+    position: relative;
+  }
+
+  @media screen and (max-width: 700px) {
+    .pure-u-1-4 {
+      width: 50%;
+    }
+
+    .cbi-button-add {
+      position: fixed;
+      padding: 0.3rem 0.5rem;
+      z-index: 1000;
+      width: 50px !important;
+      height: 50px;
+      bottom: 90px;
+      right: 5px;
+      font-size: 16px;
+      border-radius: 50%;
+      display: block;
+      background-color: #fb6340 !important;
+      border-color: #fb6340 !important;
+      box-shadow: 0 0 1rem 0 rgba(136, 152, 170, .75);
+    }
+  }
+</style>
+
+<div class="pure-g status">
+  <div class="pure-u-1-4">
+    <div class="block pure-g">
+      <div class="pure-u-2-5">
+        <div class="img-con">
+          <svg role="img" viewBox="0 0 24 24">
+            <title>Docker icon</title>
+            <path
+              d="M4.82 17.275c-.684 0-1.304-.56-1.304-1.24s.56-1.243 1.305-1.243c.748 0 1.31.56 1.31 1.242s-.622 1.24-1.305 1.24zm16.012-6.763c-.135-.992-.75-1.8-1.56-2.42l-.315-.25-.254.31c-.494.56-.69 1.553-.63 2.295.06.562.24 1.12.554 1.554-.254.13-.568.25-.81.377-.57.187-1.124.25-1.68.25H.097l-.06.37c-.12 1.182.06 2.42.562 3.54l.244.435v.06c1.5 2.483 4.17 3.6 7.078 3.6 5.594 0 10.182-2.42 12.357-7.633 1.425.062 2.864-.31 3.54-1.676l.18-.31-.3-.187c-.81-.494-1.92-.56-2.85-.31l-.018.002zm-8.008-.992h-2.428v2.42h2.43V9.518l-.002.003zm0-3.043h-2.428v2.42h2.43V6.48l-.002-.003zm0-3.104h-2.428v2.42h2.43v-2.42h-.002zm2.97 6.147H13.38v2.42h2.42V9.518l-.007.003zm-8.998 0H4.383v2.42h2.422V9.518l-.01.003zm3.03 0h-2.4v2.42H9.84V9.518l-.015.003zm-6.03 0H1.4v2.42h2.428V9.518l-.03.003zm6.03-3.043h-2.4v2.42H9.84V6.48l-.015-.003zm-3.045 0H4.387v2.42H6.8V6.48l-.016-.003z" />
+          </svg>
+        </div>
+      </div>
+      <div class="pure-u-3-5">
+        <h4 style="text-align: right; font-size: 1rem"><%:Containers%></h4>
+        <h4 style="text-align: right;">
+            <%- if self.containers_total ~= "-" then -%><a href='<%=luci.dispatcher.build_url("admin/docker/containers")%>'><%- end -%>
+            <span style="font-size: 2rem; color: #2dce89;"><%=self.containers_running%></span>
+            <span style="font-size: 1rem; color: #8898aa !important;">/<%=self.containers_total%></span>
+          <%- if self.containers_total ~= "-" then -%></a><%- end -%>
+        </h4>
+      </div>
+    </div>
+  </div>
+  <div class="pure-u-1-4">
+    <div class="block pure-g">
+      <div class="pure-u-2-5">
+        <div class="img-con">
+          <svg id="icon-hub" viewBox="0 -4 42 50" stroke-width="2" fill-rule="nonzero" width="100%" height="100%">
+            <path
+              d="M37.176371,36.2324812 C37.1920117,36.8041095 36.7372743,37.270685 36.1684891,37.270685 L3.74335204,37.2703476 C3.17827583,37.2703476 2.72400056,36.8091818 2.72400056,36.2397767 L2.72400056,19.6131383 C1.4312007,18.4881431 0.662551336,16.8884326 0.662551336,15.1618249 L0.664207893,14.69503 C0.63774183,14.4532127 0.650524255,14.2942438 0.711604827,14.1238231 L5.10793246,1.20935468 C5.24853286,0.797020623 5.63848594,0.511627907 6.06681069,0.511627907 L34.0728364,0.511627907 C34.5091607,0.511627907 34.889927,0.793578201 35.0316653,1.20921034 L39.4428567,14.1234095 C39.4871296,14.273204 39.5020782,14.4249444 39.4884726,14.5493649 L39.4884726,15.1505835 C39.4884726,16.9959517 38.6190601,18.6883031 37.1764746,19.7563084 L37.176371,36.2324812 Z M35.1376208,35.209311 L35.1376208,20.7057152 C34.7023924,20.8097593 34.271333,20.8633641 33.8336069,20.8633641 C32.0046019,20.8633641 30.3013756,19.9547008 29.2437221,18.4771538 C28.1860473,19.954695 26.4828515,20.8633641 24.6538444,20.8633641 C22.824803,20.8633641 21.1216155,19.9547157 20.0639591,18.4771544 C19.0062842,19.9546953 17.3030887,20.8633641 15.4740818,20.8633641 C13.6450404,20.8633641 11.9418529,19.9547157 10.8841965,18.4771544 C9.82652161,19.9546953 8.12332608,20.8633641 6.29431919,20.8633641 C5.76735555,20.8633641 5.24095778,20.7883418 4.73973398,20.644674 L4.73973398,35.209311 L35.1376208,35.209311 Z M30.2720226,15.6557626 C30.5154632,17.4501192 32.0503909,18.8018554 33.845083,18.8018554 C35.7286794,18.8018554 37.285413,17.3395134 37.4474599,15.4751932 L30.2280765,15.4751932 C30.2470638,15.532987 30.2617919,15.5932958 30.2720226,15.6557626 Z M21.0484306,15.4751932 C21.0674179,15.532987 21.0821459,15.5932958 21.0923767,15.6557626 C21.3358173,17.4501192 22.8707449,18.8018554 24.665437,18.8018554 C26.4601001,18.8018554 27.9950169,17.4501481 28.2378191,15.6611556 C28.2451225,15.5981318 28.2590045,15.5358056 28.2787375,15.4751932 L21.0484306,15.4751932 Z M11.9238102,15.6557626 C12.1672508,17.4501192 13.7021785,18.8018554 15.4968705,18.8018554 C17.2915336,18.8018554 18.8264505,17.4501481 19.0692526,15.6611556 C19.0765561,15.5981318 19.0904381,15.5358056 19.110171,15.4751932 L11.8798641,15.4751932 C11.8988514,15.532987 11.9135795,15.5932958 11.9238102,15.6557626 Z M6.31682805,18.8018317 C8.11149114,18.8018317 9.64640798,17.4501244 9.88921012,15.6611319 C9.89651357,15.5981081 9.91039559,15.5357819 9.93012856,15.4751696 L2.70318796,15.4751696 C2.86612006,17.3346852 4.42809696,18.8018317 6.31682805,18.8018317 Z M3.09670082,13.4139924 L37.04257,13.4139924 L33.3489482,2.57204736 L6.80119239,2.57204736 L3.09670082,13.4139924 Z"
+              id="Fill-1"></path>
+            <rect id="Rectangle-3" x="14" y="26" width="6" height="10"></rect>
+            <path d="M20,26 L20,36 L26,36 L26,26 L20,26 Z" id="Rectangle-3"></path>
+          </svg>
+        </div>
+      </div>
+      <div class="pure-u-3-5">
+        <h4 style="text-align: right; font-size: 1rem"><%:Images%></h4>
+        <h4 style="text-align: right;">
+          <%- if self.images_total ~= "-" then -%><a href='<%=luci.dispatcher.build_url("admin/docker/images")%>'><%- end -%>
+            <span style="font-size: 2rem; color: #2dce89;"><%=self.images_used%></span>
+            <span style="font-size: 1rem; color: #8898aa !important;">/<%=self.images_total%></span>
+          <%- if self.images_total ~= "-" then -%></a><%- end -%>
+        </h4>
+      </div>
+    </div>
+  </div>
+  <div class="pure-u-1-4">
+    <div class="block pure-g">
+      <div class="pure-u-2-5">
+        <div class="img-con">
+          <svg version="1.1" x="0px" y="0px" width="100%" height="100%" viewBox="0 0 48.723 48.723" xml:space="preserve">
+                  <path d="M7.452,24.152h3.435v5.701h0.633c0.001,0,0.001,0,0.002,0h0.636v-5.701h3.51v-1.059h17.124v1.104h3.178v5.656h0.619
+                            c0,0,0,0,0.002,0h0.619v-5.656h3.736v-0.856c0-0.012,0.006-0.021,0.006-0.032c0-0.072,0-0.143,0-0.215h5.721v-1.316h-5.721
+                            c0-0.054,0-0.108,0-0.164c0-0.011-0.006-0.021-0.006-0.032v-0.832h-8.154v1.028h-7.911v-2.652h-0.689c-0.001,0-0.001,0-0.002,0
+                            h-0.678v2.652h-7.846v-1.104H7.452v1.104H1.114v1.316h6.338V24.152z" />
+                  <path
+                    d="M21.484,16.849h5.204v-2.611h7.133V1.555H14.588v12.683h6.896V16.849z M16.537,12.288V3.505h15.335v8.783H16.537z" />
+                  <rect x="18.682" y="16.898" width="10.809" height="0.537" />
+                  <path
+                    d="M0,43.971h6.896v2.611H12.1v-2.611h7.134V31.287H0V43.971z M1.95,33.236h15.334v8.785H1.95V33.236z" />
+                  <rect x="4.095" y="46.631" width="10.808" height="0.537" />
+                  <path
+                    d="M29.491,30.994v12.684h6.895v2.611h5.205v-2.611h7.133V30.994H29.491z M46.774,41.729H31.44v-8.783h15.334V41.729z" />
+                  <rect x="33.584" y="46.338" width="10.809" height="0.537" />
+          </svg>
+        </div>
+      </div>
+      <div class="pure-u-3-5">
+        <h4 style="text-align: right; font-size: 1rem"><%:Networks%></h4>
+        <h4 style="text-align: right;">
+          <%- if self.networks_total ~= "-" then -%><a href='<%=luci.dispatcher.build_url("admin/docker/networks")%>'><%- end -%>
+          <span style="font-size: 2rem; color: #2dce89;"><%=self.networks_total%></span>
+          <!-- <span style="font-size: 1rem; color: #8898aa !important;">/20</span> -->
+          <%- if self.networks_total ~= "-" then -%></a><%- end -%>
+        </h4>
+      </div>
+    </div>
+  </div>
+  <div class="pure-u-1-4">
+    <div class="block pure-g">
+      <div class="pure-u-2-5">
+        <div class="img-con">
+          <svg x="0px" y="0px" viewBox="0 0 55 55" style="enable-background:new 0 0 55 55;" xml:space="preserve">
+            <path
+              d="M52.354,8.51C51.196,4.22,42.577,0,27.5,0C12.423,0,3.803,4.22,2.646,8.51C2.562,8.657,2.5,8.818,2.5,9v0.5V21v0.5V22v11
+           v0.5V34v12c0,0.162,0.043,0.315,0.117,0.451C3.798,51.346,14.364,55,27.5,55c13.106,0,23.655-3.639,24.875-8.516
+           C52.455,46.341,52.5,46.176,52.5,46V34v-0.5V33V22v-0.5V21V9.5V9C52.5,8.818,52.438,8.657,52.354,8.51z M50.421,33.985
+           c-0.028,0.121-0.067,0.241-0.116,0.363c-0.04,0.099-0.089,0.198-0.143,0.297c-0.067,0.123-0.142,0.246-0.231,0.369
+           c-0.066,0.093-0.141,0.185-0.219,0.277c-0.111,0.131-0.229,0.262-0.363,0.392c-0.081,0.079-0.17,0.157-0.26,0.236
+           c-0.164,0.143-0.335,0.285-0.526,0.426c-0.082,0.061-0.17,0.12-0.257,0.18c-0.226,0.156-0.462,0.311-0.721,0.463
+           c-0.068,0.041-0.141,0.08-0.212,0.12c-0.298,0.168-0.609,0.335-0.945,0.497c-0.043,0.021-0.088,0.041-0.132,0.061
+           c-0.375,0.177-0.767,0.351-1.186,0.519c-0.012,0.005-0.024,0.009-0.036,0.014c-2.271,0.907-5.176,1.67-8.561,2.17
+           c-0.017,0.002-0.034,0.004-0.051,0.007c-0.658,0.097-1.333,0.183-2.026,0.259c-0.113,0.012-0.232,0.02-0.346,0.032
+           c-0.605,0.063-1.217,0.121-1.847,0.167c-0.288,0.021-0.59,0.031-0.883,0.049c-0.474,0.028-0.943,0.059-1.429,0.076
+           C29.137,40.984,28.327,41,27.5,41s-1.637-0.016-2.432-0.044c-0.486-0.017-0.955-0.049-1.429-0.076
+           c-0.293-0.017-0.595-0.028-0.883-0.049c-0.63-0.046-1.242-0.104-1.847-0.167c-0.114-0.012-0.233-0.02-0.346-0.032
+           c-0.693-0.076-1.368-0.163-2.026-0.259c-0.017-0.002-0.034-0.004-0.051-0.007c-3.385-0.5-6.29-1.263-8.561-2.17
+           c-0.012-0.004-0.024-0.009-0.036-0.014c-0.419-0.168-0.812-0.342-1.186-0.519c-0.043-0.021-0.089-0.041-0.132-0.061
+           c-0.336-0.162-0.647-0.328-0.945-0.497c-0.07-0.04-0.144-0.079-0.212-0.12c-0.259-0.152-0.495-0.307-0.721-0.463
+           c-0.086-0.06-0.175-0.119-0.257-0.18c-0.191-0.141-0.362-0.283-0.526-0.426c-0.089-0.078-0.179-0.156-0.26-0.236
+           c-0.134-0.13-0.252-0.26-0.363-0.392c-0.078-0.092-0.153-0.184-0.219-0.277c-0.088-0.123-0.163-0.246-0.231-0.369
+           c-0.054-0.099-0.102-0.198-0.143-0.297c-0.049-0.121-0.088-0.242-0.116-0.363C4.541,33.823,4.5,33.661,4.5,33.5
+           c0-0.113,0.013-0.226,0.031-0.338c0.025-0.151,0.011-0.302-0.031-0.445v-7.424c0.028,0.026,0.063,0.051,0.092,0.077
+           c0.218,0.192,0.44,0.383,0.69,0.567C9.049,28.786,16.582,31,27.5,31c10.872,0,18.386-2.196,22.169-5.028
+           c0.302-0.22,0.574-0.447,0.83-0.678l0.001-0.001v7.424c-0.042,0.143-0.056,0.294-0.031,0.445c0.019,0.112,0.031,0.225,0.031,0.338
+           C50.5,33.661,50.459,33.823,50.421,33.985z M50.5,13.293v7.424c-0.042,0.143-0.056,0.294-0.031,0.445
+           c0.019,0.112,0.031,0.225,0.031,0.338c0,0.161-0.041,0.323-0.079,0.485c-0.028,0.121-0.067,0.241-0.116,0.363
+           c-0.04,0.099-0.089,0.198-0.143,0.297c-0.067,0.123-0.142,0.246-0.231,0.369c-0.066,0.093-0.141,0.185-0.219,0.277
+           c-0.111,0.131-0.229,0.262-0.363,0.392c-0.081,0.079-0.17,0.157-0.26,0.236c-0.164,0.143-0.335,0.285-0.526,0.426
+           c-0.082,0.061-0.17,0.12-0.257,0.18c-0.226,0.156-0.462,0.311-0.721,0.463c-0.068,0.041-0.141,0.08-0.212,0.12
+           c-0.298,0.168-0.609,0.335-0.945,0.497c-0.043,0.021-0.088,0.041-0.132,0.061c-0.375,0.177-0.767,0.351-1.186,0.519
+           c-0.012,0.005-0.024,0.009-0.036,0.014c-2.271,0.907-5.176,1.67-8.561,2.17c-0.017,0.002-0.034,0.004-0.051,0.007
+           c-0.658,0.097-1.333,0.183-2.026,0.259c-0.113,0.012-0.232,0.02-0.346,0.032c-0.605,0.063-1.217,0.121-1.847,0.167
+           c-0.288,0.021-0.59,0.031-0.883,0.049c-0.474,0.028-0.943,0.059-1.429,0.076C29.137,28.984,28.327,29,27.5,29
+           s-1.637-0.016-2.432-0.044c-0.486-0.017-0.955-0.049-1.429-0.076c-0.293-0.017-0.595-0.028-0.883-0.049
+           c-0.63-0.046-1.242-0.104-1.847-0.167c-0.114-0.012-0.233-0.02-0.346-0.032c-0.693-0.076-1.368-0.163-2.026-0.259
+           c-0.017-0.002-0.034-0.004-0.051-0.007c-3.385-0.5-6.29-1.263-8.561-2.17c-0.012-0.004-0.024-0.009-0.036-0.014
+           c-0.419-0.168-0.812-0.342-1.186-0.519c-0.043-0.021-0.089-0.041-0.132-0.061c-0.336-0.162-0.647-0.328-0.945-0.497
+           c-0.07-0.04-0.144-0.079-0.212-0.12c-0.259-0.152-0.495-0.307-0.721-0.463c-0.086-0.06-0.175-0.119-0.257-0.18
+           c-0.191-0.141-0.362-0.283-0.526-0.426c-0.089-0.078-0.179-0.156-0.26-0.236c-0.134-0.13-0.252-0.26-0.363-0.392
+           c-0.078-0.092-0.153-0.184-0.219-0.277c-0.088-0.123-0.163-0.246-0.231-0.369c-0.054-0.099-0.102-0.198-0.143-0.297
+           c-0.049-0.121-0.088-0.242-0.116-0.363C4.541,21.823,4.5,21.661,4.5,21.5c0-0.113,0.013-0.226,0.031-0.338
+           c0.025-0.151,0.011-0.302-0.031-0.445v-7.424c0.12,0.109,0.257,0.216,0.387,0.324c0.072,0.06,0.139,0.12,0.215,0.18
+           c0.3,0.236,0.624,0.469,0.975,0.696c0.073,0.047,0.155,0.093,0.231,0.14c0.294,0.183,0.605,0.362,0.932,0.538
+           c0.121,0.065,0.242,0.129,0.367,0.193c0.365,0.186,0.748,0.367,1.151,0.542c0.066,0.029,0.126,0.059,0.193,0.087
+           c0.469,0.199,0.967,0.389,1.485,0.573c0.143,0.051,0.293,0.099,0.44,0.149c0.412,0.139,0.838,0.272,1.279,0.401
+           c0.159,0.046,0.315,0.094,0.478,0.138c0.585,0.162,1.189,0.316,1.823,0.458c0.087,0.02,0.181,0.036,0.269,0.055
+           c0.559,0.122,1.139,0.235,1.735,0.341c0.202,0.036,0.407,0.07,0.613,0.104c0.567,0.093,1.151,0.178,1.75,0.256
+           c0.154,0.02,0.301,0.043,0.457,0.062c0.744,0.09,1.514,0.167,2.305,0.233c0.195,0.016,0.398,0.028,0.596,0.042
+           c0.633,0.046,1.28,0.084,1.942,0.114c0.241,0.011,0.481,0.022,0.727,0.031C25.712,18.979,26.59,19,27.5,19s1.788-0.021,2.65-0.05
+           c0.245-0.009,0.485-0.02,0.727-0.031c0.662-0.03,1.309-0.068,1.942-0.114c0.198-0.015,0.4-0.026,0.596-0.042
+           c0.791-0.065,1.561-0.143,2.305-0.233c0.156-0.019,0.303-0.042,0.457-0.062c0.599-0.078,1.182-0.163,1.75-0.256
+           c0.206-0.034,0.411-0.068,0.613-0.104c0.596-0.106,1.176-0.219,1.735-0.341c0.088-0.019,0.182-0.036,0.269-0.055
+           c0.634-0.142,1.238-0.297,1.823-0.458c0.163-0.045,0.319-0.092,0.478-0.138c0.441-0.129,0.867-0.262,1.279-0.401
+           c0.147-0.05,0.297-0.098,0.44-0.149c0.518-0.184,1.017-0.374,1.485-0.573c0.067-0.028,0.127-0.058,0.193-0.087
+           c0.403-0.176,0.786-0.356,1.151-0.542c0.125-0.064,0.247-0.128,0.367-0.193c0.327-0.175,0.638-0.354,0.932-0.538
+           c0.076-0.047,0.158-0.093,0.231-0.14c0.351-0.227,0.675-0.459,0.975-0.696c0.075-0.06,0.142-0.12,0.215-0.18
+           C50.243,13.509,50.38,13.402,50.5,13.293z M27.5,2c13.555,0,23,3.952,23,7.5s-9.445,7.5-23,7.5s-23-3.952-23-7.5S13.945,2,27.5,2z
+            M50.5,45.703c-0.014,0.044-0.024,0.089-0.032,0.135C49.901,49.297,40.536,53,27.5,53S5.099,49.297,4.532,45.838
+           c-0.008-0.045-0.019-0.089-0.032-0.131v-8.414c0.028,0.026,0.063,0.051,0.092,0.077c0.218,0.192,0.44,0.383,0.69,0.567
+           C9.049,40.786,16.582,43,27.5,43c10.872,0,18.386-2.196,22.169-5.028c0.302-0.22,0.574-0.447,0.83-0.678l0.001-0.001V45.703z" />
+          </svg>
+        </div>
+      </div>
+      <div class="pure-u-3-5">
+        <h4 style="text-align: right; font-size: 1rem"><%:Volumes%></h4>
+        <h4 style="text-align: right;">
+          <%- if self.volumes_total ~= "-" then -%><a href='<%=luci.dispatcher.build_url("admin/docker/volumes")%>'><%- end -%>
+            <span style="font-size: 2rem; color: #2dce89;"><%=self.volumes_total%></span>
+            <!-- <span style="font-size: 1rem; color: #8898aa !important;">/20</span> -->
+            <%- if self.volumes_total ~= "-" then -%></a><%- end -%>
+        </h4>
+      </div>
+    </div>
+  </div>
+</div>
\ No newline at end of file
diff --git a/applications/luci-app-dockerman/root/etc/config/dockerman b/applications/luci-app-dockerman/root/etc/config/dockerman
new file mode 100644 (file)
index 0000000..63e30bf
--- /dev/null
@@ -0,0 +1,10 @@
+config section 'local'
+        option socket_path '/var/run/docker.sock'
+        option status_path '/tmp/.docker_action_status'
+        option debug 'false'
+        option debug_path '/tmp/.docker_debug'
+        option remote_endpoint 'false'
+        option daemon_ea 'true'
+        option daemon_data_root '/opt/docker'
+        option daemon_log_level 'warn'
+        list ac_allowed_interface 'br-lan'
diff --git a/applications/luci-app-dockerman/root/etc/init.d/dockerman b/applications/luci-app-dockerman/root/etc/init.d/dockerman
new file mode 100755 (executable)
index 0000000..22629c1
--- /dev/null
@@ -0,0 +1,46 @@
+#!/bin/sh /etc/rc.common
+
+START=99
+DOCKERD_CONF="/etc/docker/daemon.json"
+
+config_load dockerman
+config_get daemon_ea "local" daemon_ea
+
+init_dockerman_chain(){
+       iptables -N DOCKER-MAN >/dev/null 2>&1
+       iptables -F DOCKER-MAN >/dev/null 2>&1
+       iptables -D DOCKER-USER -j DOCKER-MAN >/dev/null 2>&1
+       iptables -I DOCKER-USER -j DOCKER-MAN >/dev/null 2>&1
+}
+
+add_allowed_interface(){
+       iptables -A DOCKER-MAN -i $1 -o docker0 -j RETURN
+}
+
+add_allowed_ip(){
+       iptables -A DOCKER-MAN -d $1 -o docker0 -j RETURN
+}
+
+handle_allowed_interface(){
+       #config_list_foreach "local" allowed_ip add_allowed_ip
+       config_list_foreach "local" ac_allowed_interface add_allowed_interface
+       iptables -A DOCKER-MAN -m conntrack --ctstate ESTABLISHED,RELATED -o docker0 -j RETURN >/dev/null 2>&1
+       iptables -A DOCKER-MAN -m conntrack --ctstate NEW,INVALID -o docker0 -j DROP >/dev/null 2>&1
+       iptables -A DOCKER-MAN -j RETURN >/dev/null 2>&1
+}
+
+start(){
+       [ ! -x "/etc/init.d/dockerd" ] && return 0
+       init_dockerman_chain
+       if [ -n "$daemon_ea" ]; then
+               handle_allowed_interface
+               lua /usr/share/dockerman/dockerd-config.lua "$DOCKERD_CONF" && /etc/init.d/dockerd restart && sleep 5 || {
+                       # 1 running, 0 stopped
+                       STATE=$([ -n "$(ps |grep /usr/bin/dockerd | grep -v grep)" ] && echo 1 || echo 0)
+                       [ "$STATE" == "0" ] && /etc/init.d/dockerd start && sleep 5
+               }
+               lua /usr/share/dockerman/dockerd-ac.lua
+       else
+               /etc/init.d/dockerd stop
+       fi
+}
diff --git a/applications/luci-app-dockerman/root/etc/uci-defaults/luci-app-dockerman b/applications/luci-app-dockerman/root/etc/uci-defaults/luci-app-dockerman
new file mode 100755 (executable)
index 0000000..eab5d73
--- /dev/null
@@ -0,0 +1,15 @@
+#!/bin/sh
+
+uci -q batch <<-EOF >/dev/null
+       set uhttpd.main.script_timeout="360"
+       commit uhttpd
+       delete ucitrack.@dockerman[-1]
+       add ucitrack dockerman
+       set ucitrack.@dockerman[-1].exec='/etc/init.d/dockerman start'
+       commit ucitrack
+EOF
+[ -x "$(which dockerd)" ] && chmod +x /etc/init.d/dockerman && /etc/init.d/dockerd disable && /etc/init.d/dockerman enable >/dev/null 2>&1
+sed -i 's/self:cfgvalue(section) or {}/self:cfgvalue(section) or self.default or {}/' /usr/lib/lua/luci/view/cbi/dynlist.htm
+/etc/init.d/uhttpd restart >/dev/null 2>&1
+rm -fr /tmp/luci-indexcache /tmp/luci-modulecache >/dev/null 2>&1
+exit 0
\ No newline at end of file
diff --git a/applications/luci-app-dockerman/root/usr/share/dockerman/dockerd-ac.lua b/applications/luci-app-dockerman/root/usr/share/dockerman/dockerd-ac.lua
new file mode 100644 (file)
index 0000000..e8a2c0b
--- /dev/null
@@ -0,0 +1,20 @@
+require "luci.util"
+docker = require "luci.docker"
+uci = (require "luci.model.uci").cursor()
+dk = docker.new({socket_path = "/var/run/docker.sock"})
+
+if dk:_ping().code ~= 200 then return end
+containers_list = dk.containers:list({query = {all=true}}).body
+allowed_container = uci:get("dockerman", "local", "ac_allowed_container")
+
+if not allowed_container or next(allowed_container)==nil then return end
+allowed_ip = {}
+for i, v in ipairs(containers_list) do
+  for ii, vv in ipairs(allowed_container) do
+    if v.Id:sub(1,12) == vv and v.NetworkSettings and v.NetworkSettings.Networks and v.NetworkSettings.Networks.bridge and v.NetworkSettings.Networks.bridge.IPAddress then
+      print(v.NetworkSettings.Networks.bridge.IPAddress)
+      luci.util.exec("iptables -I DOCKER-MAN -d "..v.NetworkSettings.Networks.bridge.IPAddress.." -o docker0 -j RETURN")
+      table.remove(allowed_container, ii)
+    end
+  end
+end
diff --git a/applications/luci-app-dockerman/root/usr/share/dockerman/dockerd-config.lua b/applications/luci-app-dockerman/root/usr/share/dockerman/dockerd-config.lua
new file mode 100644 (file)
index 0000000..1798688
--- /dev/null
@@ -0,0 +1,52 @@
+require "luci.util"
+fs = require "nixio.fs"
+uci = (require "luci.model.uci").cursor()
+
+raw_file_dir = arg[1]
+
+raw_json_str = fs.readfile(raw_file_dir) or "[]"
+raw_json = luci.jsonc.parse(raw_json_str) or {}
+
+new_json = {}
+new_json["data-root"] = uci:get("dockerman", "local", "daemon_data_root")
+new_json["hosts"] = uci:get("dockerman", "local", "daemon_hosts") or {}
+new_json["registry-mirrors"] = uci:get("dockerman", "local", "daemon_registry_mirrors") or {}
+new_json["log-level"] = uci:get("dockerman", "local", "daemon_log_level")
+
+function comp(raw, new)
+  for k, v in pairs(new) do
+    if type(v) == "table" and raw[k] then
+      if #v == #raw[k] then
+        comp(raw[k], v)
+      else
+        changed = true
+      raw[k] = v
+      end
+    elseif raw[k] ~= v then
+      changed = true
+      raw[k] = v
+    end
+  end
+  for k, v in ipairs(new) do
+    if type(v) == "table" and raw[k] then
+      if #v == #raw[k] then
+        comp(raw[k], v)
+      else
+        changed = true
+      raw[k] = v
+      end
+    elseif raw[k] ~= v then
+      changed = true
+      raw[k] = v
+    end
+  end
+end
+comp(raw_json, new_json)
+if changed then
+  if next(raw_json["registry-mirrors"]) == nil then raw_json["registry-mirrors"] = nil end
+  if next(raw_json["hosts"]) == nil then raw_json["hosts"] = nil end
+  fs.writefile(raw_file_dir, luci.jsonc.stringify(raw_json, true):gsub("\\", ""))
+  os.exit(0)
+else
+  os.exit(1)
+end