Browse Source

Merge pull request #4073 from TDT-AG/pr/20200427-luci-app-dockerman

luci-app-dockerman: add package
Florian Eckert 3 years ago
parent
commit
490596a623
33 changed files with 4459 additions and 0 deletions
  1. 19 0
      applications/luci-app-dockerman/Makefile
  2. 387 0
      applications/luci-app-dockerman/luasrc/controller/dockerman.lua
  3. 588 0
      applications/luci-app-dockerman/luasrc/model/cbi/dockerman/container.lua
  4. 195 0
      applications/luci-app-dockerman/luasrc/model/cbi/dockerman/containers.lua
  5. 223 0
      applications/luci-app-dockerman/luasrc/model/cbi/dockerman/images.lua
  6. 130 0
      applications/luci-app-dockerman/luasrc/model/cbi/dockerman/networks.lua
  7. 653 0
      applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newcontainer.lua
  8. 221 0
      applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newnetwork.lua
  9. 154 0
      applications/luci-app-dockerman/luasrc/model/cbi/dockerman/overview.lua
  10. 116 0
      applications/luci-app-dockerman/luasrc/model/cbi/dockerman/volumes.lua
  11. 397 0
      applications/luci-app-dockerman/luasrc/model/docker.lua
  12. 140 0
      applications/luci-app-dockerman/luasrc/view/dockerman/apply_widget.htm
  13. 7 0
      applications/luci-app-dockerman/luasrc/view/dockerman/cbi/inlinebutton.htm
  14. 33 0
      applications/luci-app-dockerman/luasrc/view/dockerman/cbi/inlinevalue.htm
  15. 9 0
      applications/luci-app-dockerman/luasrc/view/dockerman/cbi/namedsection.htm
  16. 10 0
      applications/luci-app-dockerman/luasrc/view/dockerman/cbi/xfvalue.htm
  17. 27 0
      applications/luci-app-dockerman/luasrc/view/dockerman/container.htm
  18. 6 0
      applications/luci-app-dockerman/luasrc/view/dockerman/container_console.htm
  19. 63 0
      applications/luci-app-dockerman/luasrc/view/dockerman/container_file.htm
  20. 80 0
      applications/luci-app-dockerman/luasrc/view/dockerman/container_stats.htm
  21. 88 0
      applications/luci-app-dockerman/luasrc/view/dockerman/images_import.htm
  22. 29 0
      applications/luci-app-dockerman/luasrc/view/dockerman/images_load.htm
  23. 13 0
      applications/luci-app-dockerman/luasrc/view/dockerman/logs.htm
  24. 95 0
      applications/luci-app-dockerman/luasrc/view/dockerman/newcontainer_resolve.htm
  25. 152 0
      applications/luci-app-dockerman/luasrc/view/dockerman/overview.htm
  26. 10 0
      applications/luci-app-dockerman/root/etc/config/dockerman
  27. 46 0
      applications/luci-app-dockerman/root/etc/init.d/dockerman
  28. 15 0
      applications/luci-app-dockerman/root/etc/uci-defaults/luci-app-dockerman
  29. 20 0
      applications/luci-app-dockerman/root/usr/share/dockerman/dockerd-ac.lua
  30. 52 0
      applications/luci-app-dockerman/root/usr/share/dockerman/dockerd-config.lua
  31. 11 0
      applications/luci-app-dockerman/root/usr/share/rpcd/acl.d/luci-app-dockerman.json
  32. 18 0
      collections/luci-lib-docker/Makefile
  33. 452 0
      collections/luci-lib-docker/luasrc/docker.lua

+ 19 - 0
applications/luci-app-dockerman/Makefile

@@ -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

+ 387 - 0
applications/luci-app-dockerman/luasrc/controller/dockerman.lua

@@ -0,0 +1,387 @@
+--[[
+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()
+
+  local e = entry({"admin", "docker"}, firstchild(), "Docker", 40)
+  e.dependent = false
+  e.acl_depends = { "luci-app-dockerman" }
+
+  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

+ 588 - 0
applications/luci-app-dockerman/luasrc/model/cbi/dockerman/container.lua

@@ -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

+ 195 - 0
applications/luci-app-dockerman/luasrc/model/cbi/dockerman/containers.lua

@@ -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

+ 223 - 0
applications/luci-app-dockerman/luasrc/model/cbi/dockerman/images.lua

@@ -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

+ 130 - 0
applications/luci-app-dockerman/luasrc/model/cbi/dockerman/networks.lua

@@ -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

+ 653 - 0
applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newcontainer.lua

@@ -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

+ 221 - 0
applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newnetwork.lua

@@ -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

+ 154 - 0
applications/luci-app-dockerman/luasrc/model/cbi/dockerman/overview.lua

@@ -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

+ 116 - 0
applications/luci-app-dockerman/luasrc/model/cbi/dockerman/volumes.lua

@@ -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

+ 397 - 0
applications/luci-app-dockerman/luasrc/model/docker.lua

@@ -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

+ 140 - 0
applications/luci-app-dockerman/luasrc/view/dockerman/apply_widget.htm

@@ -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>

+ 7 - 0
applications/luci-app-dockerman/luasrc/view/dockerman/cbi/inlinebutton.htm

@@ -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>

+ 33 - 0
applications/luci-app-dockerman/luasrc/view/dockerman/cbi/inlinevalue.htm

@@ -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>

+ 9 - 0
applications/luci-app-dockerman/luasrc/view/dockerman/cbi/namedsection.htm

@@ -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 -->

+ 10 - 0
applications/luci-app-dockerman/luasrc/view/dockerman/cbi/xfvalue.htm

@@ -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%>

+ 27 - 0
applications/luci-app-dockerman/luasrc/view/dockerman/container.htm

@@ -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>

+ 6 - 0
applications/luci-app-dockerman/luasrc/view/dockerman/container_console.htm

@@ -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>

+ 63 - 0
applications/luci-app-dockerman/luasrc/view/dockerman/container_file.htm

@@ -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>

+ 80 - 0
applications/luci-app-dockerman/luasrc/view/dockerman/container_stats.htm

@@ -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>

+ 88 - 0
applications/luci-app-dockerman/luasrc/view/dockerman/images_import.htm

@@ -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>

+ 29 - 0
applications/luci-app-dockerman/luasrc/view/dockerman/images_load.htm

@@ -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>

+ 13 - 0
applications/luci-app-dockerman/luasrc/view/dockerman/logs.htm

@@ -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 %>

+ 95 - 0
applications/luci-app-dockerman/luasrc/view/dockerman/newcontainer_resolve.htm

@@ -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%>

File diff suppressed because it is too large
+ 152 - 0
applications/luci-app-dockerman/luasrc/view/dockerman/overview.htm


+ 10 - 0
applications/luci-app-dockerman/root/etc/config/dockerman

@@ -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'

+ 46 - 0
applications/luci-app-dockerman/root/etc/init.d/dockerman

@@ -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
+}

+ 15 - 0
applications/luci-app-dockerman/root/etc/uci-defaults/luci-app-dockerman

@@ -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

+ 20 - 0
applications/luci-app-dockerman/root/usr/share/dockerman/dockerd-ac.lua

@@ -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

+ 52 - 0
applications/luci-app-dockerman/root/usr/share/dockerman/dockerd-config.lua

@@ -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

+ 11 - 0
applications/luci-app-dockerman/root/usr/share/rpcd/acl.d/luci-app-dockerman.json

@@ -0,0 +1,11 @@
+{
+	"luci-app-dockerman": {
+		"description": "Grant UCI access for luci-app-dockerman",
+		"read": {
+			"uci": [ "dockerman" ]
+			},
+		"write": {
+			"uci": [ "dockerman" ]
+		}
+	}
+}

+ 18 - 0
collections/luci-lib-docker/Makefile

@@ -0,0 +1,18 @@
+include $(TOPDIR)/rules.mk
+
+PKG_NAME:=luci-lib-docker
+PKG_LICENSE:=AGPL-3.0
+PKG_MAINTAINER:=lisaac <lisaac.cn@gmail.com> \
+	Florian Eckert <fe@dev.tdt.de>
+
+LUCI_TYPE:=col
+
+LUCI_TITLE:=LuCI library for docker
+LUCI_DESCRIPTION:=Docker Engine API for LuCI
+
+LUCI_DEPENDS:=@(aarch64||arm||x86_64) +luci-lib-jsonc
+LUCI_PKGARCH:=all
+
+include ../../luci.mk
+
+# call BuildPackage - OpenWrt buildroot signature

+ 452 - 0
collections/luci-lib-docker/luasrc/docker.lua

@@ -0,0 +1,452 @@
+--[[
+LuCI - Lua Configuration Interface
+Copyright 2019 lisaac <https://github.com/lisaac/luci-lib-docker>
+]]--
+require "nixio.util"
+require "luci.util"
+local jsonc = require "luci.jsonc"
+local nixio = require "nixio"
+local ltn12 = require "luci.ltn12"
+local fs = require "nixio.fs"
+
+local urlencode = luci.util.urlencode or luci.http and luci.http.protocol and luci.http.protocol.urlencode
+local json_stringify = jsonc.stringify
+local json_parse = jsonc.parse
+
+local chunksource = function(sock, buffer)
+  buffer = buffer or ""
+  return function()
+    local output
+    local _, endp, count = buffer:find("^([0-9a-fA-F]+)\r\n")
+    if not count then
+      local newblock, code = sock:recv(1024)
+      if not newblock then return nil, code end
+      buffer = buffer .. newblock
+      _, endp, count = buffer:find("^([0-9a-fA-F]+)\r\n")
+    end
+    count = tonumber(count, 16)
+    if not count then
+      return nil, -1, "invalid encoding"
+    elseif count == 0 then -- finial
+      return nil
+    elseif count <= #buffer - endp then
+      --data >= count
+      output = buffer:sub(endp + 1, endp + count)
+      if count == #buffer - endp then          -- [data]
+        buffer = buffer:sub(endp + count + 1)
+        count, code = sock:recvall(2) --read \r\n
+        if not count then return nil, code end
+      elseif count + 1 == #buffer - endp then  -- [data]\r
+        buffer = buffer:sub(endp + count + 2)
+        count, code = sock:recvall(1) --read \n
+        if not count then return nil, code end
+      else                                     -- [data]\r\n[count]\r\n[data]...
+        buffer = buffer:sub(endp + count + 3) -- cut buffer
+      end
+      return output
+    else
+      -- data < count
+      output = buffer:sub(endp + 1, endp + count)
+      buffer = buffer:sub(endp + count + 1)
+      local remain, code = sock:recvall(count - #output) --need read remaining
+      if not remain then return nil, code end
+      output = output .. remain
+      count, code = sock:recvall(2) --read \r\n
+      if not count then return nil, code end
+      return output
+    end
+  end
+end
+
+local chunksink = function (sock)
+  return function(chunk, err)
+    if not chunk then
+      return sock:writeall("0\r\n\r\n")
+    else
+      return sock:writeall(("%X\r\n%s\r\n"):format(#chunk, tostring(chunk)))
+    end
+  end
+end
+
+local docker_stream_filter = function(buffer)
+  buffer = buffer or ""
+  if #buffer < 8 then
+    return ""
+  end
+  local stream_type = ((string.byte(buffer, 1) == 1) and "stdout") or ((string.byte(buffer, 1) == 2) and "stderr") or ((string.byte(buffer, 1) == 0) and "stdin") or "stream_err"
+  local valid_length =
+    tonumber(string.byte(buffer, 5)) * 256 * 256 * 256 + tonumber(string.byte(buffer, 6)) * 256 * 256 + tonumber(string.byte(buffer, 7)) * 256 + tonumber(string.byte(buffer, 8))
+  if valid_length > #buffer + 8 then
+    return ""
+  end
+  return stream_type .. ": " .. string.sub(buffer, 9, valid_length + 8)
+  -- return string.sub(buffer, 9, valid_length + 8)
+end
+
+local open_socket = function(req_options)
+  local socket
+  if type(req_options) ~= "table" then return socket end
+  if req_options.socket_path then
+    socket = nixio.socket("unix", "stream")
+    if socket:connect(req_options.socket_path) ~= true then return nil end
+  elseif req_options.host and req_options.port then
+    socket = nixio.connect(req_options.host, req_options.port)
+  end
+  if socket then
+    return socket
+  else
+    return nil
+  end
+end
+
+local send_http_socket = function(docker_socket, req_header, req_body, callback)
+  if docker_socket:send(req_header) == 0 then
+    return {
+      headers={code=498,message="bad path", protocol="HTTP/1.1"},
+      body={message="can\'t send data to socket"}
+    }
+  end
+
+  if req_body and type(req_body) == "function" and req_header and req_header:match("chunked") then
+    -- chunked send
+    req_body(chunksink(docker_socket))
+  elseif req_body and type(req_body) == "function" then
+    -- normal send by req_body function
+    req_body(docker_socket)
+  elseif req_body and type(req_body) == "table" then
+    -- json
+    docker_socket:send(json_stringify(req_body))
+    if options.debug then io.popen("echo '".. json_stringify(req_body) .. "' >> " .. options.debug_path) end
+  elseif req_body then
+    docker_socket:send(req_body)
+    if options.debug then io.popen("echo '".. req_body .. "' >> " .. options.debug_path) end
+  end
+
+  local linesrc = docker_socket:linesource()
+  -- read socket using source http://w3.impa.br/~diego/software/luasocket/ltn12.html
+  --http://lua-users.org/wiki/FiltersSourcesAndSinks
+  -- handle response header
+  local line = linesrc()
+  if not line then
+    docker_socket:close()
+    return {
+      headers = {code=499, message="bad socket path", protocol="HTTP/1.1"},
+      body = {message="no data receive from socket"}
+    }
+  end
+  local response = {code = 0, headers = {}, body = {}}
+
+  local p, code, msg = line:match("^([%w./]+) ([0-9]+) (.*)")
+  response.protocol = p
+  response.code = tonumber(code)
+  response.message = msg
+  line = linesrc()
+  while line and line ~= "" do
+    local key, val = line:match("^([%w-]+)%s?:%s?(.*)")
+    if key and key ~= "Status" then
+      if type(response.headers[key]) == "string" then
+        response.headers[key] = {response.headers[key], val}
+      elseif type(response.headers[key]) == "table" then
+        response.headers[key][#response.headers[key] + 1] = val
+      else
+        response.headers[key] = val
+      end
+    end
+    line = linesrc()
+  end
+  -- handle response body
+  local body_buffer = linesrc(true)
+  response.body = {}
+  if type(callback) ~= "function" then
+    if response.headers["Transfer-Encoding"] == "chunked" then
+      local source = chunksource(docker_socket, body_buffer)
+      code = ltn12.pump.all(source, (ltn12.sink.table(response.body))) and response.code or 555
+      response.code = code
+    else
+      local body_source = ltn12.source.cat(ltn12.source.string(body_buffer), docker_socket:blocksource())
+      code = ltn12.pump.all(body_source, (ltn12.sink.table(response.body))) and response.code or 555
+      response.code = code
+    end
+  else
+    if response.headers["Transfer-Encoding"] == "chunked" then
+      local source = chunksource(docker_socket, body_buffer)
+      callback(response, source)
+    else
+      local body_source = ltn12.source.cat(ltn12.source.string(body_buffer), docker_socket:blocksource())
+      callback(response, body_source)
+    end
+  end
+  docker_socket:close()
+  return response
+end
+
+local gen_header = function(options, http_method, api_group, api_action, name_or_id, request)
+  local header, query, path
+  name_or_id = (name_or_id ~= "") and name_or_id or nil
+
+  if request and type(request.query) == "table" then
+    local k, v
+    for k, v in pairs(request.query) do
+      if type(v) == "table" then
+        query = (query and query .. "&" or "?") .. k .. "=" .. urlencode(json_stringify(v))
+      elseif type(v) == "boolean" then
+        query = (query and query .. "&" or "?") .. k .. "=" .. (v and "true" or "false")
+      elseif type(v) == "number" or type(v) == "string" then
+        query = (query and query .. "&" or "?") .. k .. "=" .. v
+      end
+    end
+  end
+  path = (api_group and ("/" .. api_group) or "") .. (name_or_id and ("/" .. name_or_id) or "") .. (api_action and ("/" .. api_action) or "") .. (query or "")
+  header = (http_method or "GET") .. " " .. path .. " " .. options.protocol .. "\r\n"
+  header = header .. "Host: " .. options.host .. "\r\n"
+  header = header .. "User-Agent: " .. options.user_agent .. "\r\n"
+  header = header .. "Connection: close\r\n"
+
+  if request and type(request.header) == "table" then
+    local k, v
+    for k, v in pairs(request.header) do
+      header = header .. k .. ": " .. v .. "\r\n"
+    end
+  end
+
+  -- when requst_body is function, we need to custom header using custom header
+  if request and request.body and type(request.body) == "function" then
+    if not header:match("Content-Length:") then
+      header = header .. "Transfer-Encoding: chunked\r\n"
+    end
+  elseif http_method == "POST" and request and request.body and type(request.body) == "table" then
+    local conetnt_json = json_stringify(request.body)
+    header = header .. "Content-Type: application/json\r\n"
+    header = header .. "Content-Length: " .. #conetnt_json .. "\r\n"
+  elseif request and request.body and type(request.body) == "string" then
+    header = header .. "Content-Length: " .. #request.body .. "\r\n"
+  end
+  header = header .. "\r\n"
+  if options.debug then io.popen("echo '".. header .. "' >> " .. options.debug_path) end
+  return header
+end
+
+local call_docker = function(options, http_method, api_group, api_action, name_or_id, request, callback)
+  local req_options = setmetatable({}, {__index = options})
+
+  local req_header = gen_header(req_options, http_method, api_group, api_action, name_or_id, request)
+  local req_body = request and request.body or nil
+  local docker_socket = open_socket(req_options)
+
+  if docker_socket then
+    return send_http_socket(docker_socket, req_header, req_body, callback)
+  else
+    return {
+      headers = {code=497, message="bad socket path or host", protocol="HTTP/1.1"},
+      body = {message="can\'t connect to socket"}
+    }
+  end
+end
+
+local gen_api = function(_table, http_method, api_group, api_action)
+  local _api_action
+  if api_action == "get_archive" or api_action == "put_archive" then
+    _api_action = "archive"
+  elseif api_action == "df" then
+    _api_action = "system/df"
+  elseif api_action ~= "list" and api_action ~= "inspect" and api_action ~= "remove" then
+    _api_action = api_action
+  elseif (api_group == "containers" or api_group == "images" or api_group == "exec") and (api_action == "list" or api_action == "inspect") then
+    _api_action = "json"
+  end
+
+  local fp = function(self, request, callback)
+    local name_or_id = request and (request.name or request.id or request.name_or_id) or nil
+    if api_action == "list" then
+      if (name_or_id ~= "" and name_or_id ~= nil) then
+        if api_group == "images" then
+          name_or_id = nil
+        else
+          request.query = request and request.query or {}
+          request.query.filters = request.query.filters or {}
+          request.query.filters.name = request.query.filters.name or {}
+          request.query.filters.name[#request.query.filters.name + 1] = name_or_id
+          name_or_id = nil
+        end
+      end
+    elseif api_action == "create" then
+      if (name_or_id ~= "" and name_or_id ~= nil) then
+        request.query = request and request.query or {}
+        request.query.name = request.query.name or name_or_id
+        name_or_id = nil
+      end
+    elseif api_action == "logs" then
+      local body_buffer = ""
+      local response = call_docker(self.options, http_method, api_group, _api_action, name_or_id, request, callback)
+      if response.code >= 200 and response.code < 300 then
+        for i, v in ipairs(response.body) do
+          body_buffer = body_buffer .. docker_stream_filter(response.body[i])
+        end
+        response.body = body_buffer
+      end
+      return response
+    end
+    local response = call_docker(self.options, http_method, api_group, _api_action, name_or_id, request, callback)
+    if response.headers and response.headers["Content-Type"] == "application/json" then
+      if #response.body == 1 then
+        response.body = json_parse(response.body[1])
+      else
+        local tmp = {}
+        for _, v in ipairs(response.body) do
+          tmp[#tmp+1] = json_parse(v)
+        end
+        response.body = tmp
+      end
+    end
+    return response
+  end
+
+  if api_group then
+    _table[api_group][api_action] = fp
+  else
+    _table[api_action] = fp
+  end
+end
+
+local _docker = {containers = {}, exec = {}, images = {}, networks = {}, volumes = {}}
+
+gen_api(_docker, "GET", "containers", "list")
+gen_api(_docker, "POST", "containers", "create")
+gen_api(_docker, "GET", "containers", "inspect")
+gen_api(_docker, "GET", "containers", "top")
+gen_api(_docker, "GET", "containers", "logs")
+gen_api(_docker, "GET", "containers", "changes")
+gen_api(_docker, "GET", "containers", "stats")
+gen_api(_docker, "POST", "containers", "resize")
+gen_api(_docker, "POST", "containers", "start")
+gen_api(_docker, "POST", "containers", "stop")
+gen_api(_docker, "POST", "containers", "restart")
+gen_api(_docker, "POST", "containers", "kill")
+gen_api(_docker, "POST", "containers", "update")
+gen_api(_docker, "POST", "containers", "rename")
+gen_api(_docker, "POST", "containers", "pause")
+gen_api(_docker, "POST", "containers", "unpause")
+gen_api(_docker, "POST", "containers", "update")
+gen_api(_docker, "DELETE", "containers", "remove")
+gen_api(_docker, "POST", "containers", "prune")
+gen_api(_docker, "POST", "containers", "exec")
+gen_api(_docker, "POST", "exec", "start")
+gen_api(_docker, "POST", "exec", "resize")
+gen_api(_docker, "GET", "exec", "inspect")
+gen_api(_docker, "GET", "containers", "get_archive")
+gen_api(_docker, "PUT", "containers", "put_archive")
+-- TODO: export,attch
+
+gen_api(_docker, "GET", "images", "list")
+gen_api(_docker, "POST", "images", "create")
+gen_api(_docker, "GET", "images", "inspect")
+gen_api(_docker, "GET", "images", "history")
+gen_api(_docker, "POST", "images", "tag")
+gen_api(_docker, "DELETE", "images", "remove")
+gen_api(_docker, "GET", "images", "search")
+gen_api(_docker, "POST", "images", "prune")
+gen_api(_docker, "GET", "images", "get")
+gen_api(_docker, "POST", "images", "load")
+
+gen_api(_docker, "GET", "networks", "list")
+gen_api(_docker, "GET", "networks", "inspect")
+gen_api(_docker, "DELETE", "networks", "remove")
+gen_api(_docker, "POST", "networks", "create")
+gen_api(_docker, "POST", "networks", "connect")
+gen_api(_docker, "POST", "networks", "disconnect")
+gen_api(_docker, "POST", "networks", "prune")
+
+gen_api(_docker, "GET", "volumes", "list")
+gen_api(_docker, "GET", "volumes", "inspect")
+gen_api(_docker, "DELETE", "volumes", "remove")
+gen_api(_docker, "POST", "volumes", "create")
+
+gen_api(_docker, "GET", nil, "events")
+gen_api(_docker, "GET", nil, "version")
+gen_api(_docker, "GET", nil, "info")
+gen_api(_docker, "GET", nil, "_ping")
+gen_api(_docker, "GET", nil, "df")
+
+function _docker.new(options)
+  local docker = {}
+  local _options = options or {}
+  docker.options = {
+    socket_path = _options.socket_path or nil,
+    host = _options.socket_path and "localhost" or _options.host,
+    port = not _options.socket_path and _options.port or nil,
+    tls = _options.tls or nil,
+    tls_cacert = _options.tls and _options.tls_cacert or nil,
+    tls_cert = _options.tls and _options.tls_cert or nil,
+    tls_key = _options.tls and _options.tls_key or nil,
+    version = _options.version or "v1.40",
+    user_agent = _options.user_agent or "LuCI",
+    protocol = _options.protocol or "HTTP/1.1",
+    debug = _options.debug or false,
+    debug_path = _options.debug and _options.debug_path or nil
+  }
+  setmetatable(
+    docker,
+    {
+      __index = function(t, key)
+        if _docker[key] ~= nil then
+          return _docker[key]
+        else
+          return _docker.containers[key]
+        end
+      end
+    }
+  )
+  setmetatable(
+    docker.containers,
+    {
+      __index = function(t, key)
+        if key == "options" then
+          return docker.options
+        end
+      end
+    }
+  )
+  setmetatable(
+    docker.networks,
+    {
+      __index = function(t, key)
+        if key == "options" then
+          return docker.options
+        end
+      end
+    }
+  )
+  setmetatable(
+    docker.images,
+    {
+      __index = function(t, key)
+        if key == "options" then
+          return docker.options
+        end
+      end
+    }
+  )
+  setmetatable(
+    docker.volumes,
+    {
+      __index = function(t, key)
+        if key == "options" then
+          return docker.options
+        end
+      end
+    }
+  )
+  setmetatable(
+    docker.exec,
+    {
+      __index = function(t, key)
+        if key == "options" then
+          return docker.options
+        end
+      end
+    }
+  )
+  return docker
+end
+
+return _docker

Some files were not shown because too many files changed in this diff