Browse Source

Add docker service management

Toby Chui 4 days ago
parent
commit
b31e9ea7a4

+ 22 - 1
src/docker.go

@@ -40,7 +40,16 @@ func DockerServiceInit() {
 	}
 	}
 	dockerManager = dm
 	dockerManager = dm
 
 
-	//Register the "Docker Engine" tab under the new "Containers" settings group.
+	//Register the "Containers" settings group tabs in display order:
+	//Docker Daemon -> Docker Engine -> Docker List.
+	registerSetting(settingModule{
+		Name:         "Docker Daemon",
+		Desc:         "Start, stop and configure the Docker daemon service",
+		IconPath:     "SystemAO/docker/img/small_icon.svg",
+		Group:        "Container",
+		StartDir:     "SystemAO/docker/daemon.html",
+		RequireAdmin: true,
+	})
 	registerSetting(settingModule{
 	registerSetting(settingModule{
 		Name:         "Docker Engine",
 		Name:         "Docker Engine",
 		Desc:         "Docker version, daemon status, disk usage and cleanup",
 		Desc:         "Docker version, daemon status, disk usage and cleanup",
@@ -49,6 +58,14 @@ func DockerServiceInit() {
 		StartDir:     "SystemAO/docker/index.html",
 		StartDir:     "SystemAO/docker/index.html",
 		RequireAdmin: true,
 		RequireAdmin: true,
 	})
 	})
+	registerSetting(settingModule{
+		Name:         "Docker List",
+		Desc:         "Running containers overview and quick access to Docker Manager",
+		IconPath:     "SystemAO/docker/img/small_icon.svg",
+		Group:        "Container",
+		StartDir:     "SystemAO/docker/list.html",
+		RequireAdmin: true,
+	})
 
 
 	//Admin-only router. Docker access is effectively root on the host, so every
 	//Admin-only router. Docker access is effectively root on the host, so every
 	//endpoint is restricted to administrators.
 	//endpoint is restricted to administrators.
@@ -111,6 +128,10 @@ func DockerServiceInit() {
 	adminRouter.HandleFunc("/system/docker/daemon/get", dm.HandleDaemonGet)
 	adminRouter.HandleFunc("/system/docker/daemon/get", dm.HandleDaemonGet)
 	adminRouter.HandleFunc("/system/docker/daemon/save", dm.HandleDaemonSave)
 	adminRouter.HandleFunc("/system/docker/daemon/save", dm.HandleDaemonSave)
 
 
+	//Daemon service lifecycle control (systemd on Linux).
+	adminRouter.HandleFunc("/system/docker/service/status", dm.HandleServiceStatus)
+	adminRouter.HandleFunc("/system/docker/service/action", dm.HandleServiceAction)
+
 	//Register the desktop app. Deliberately NOT group "Utilities"/"System Tools"
 	//Register the desktop app. Deliberately NOT group "Utilities"/"System Tools"
 	//(those would make it visible to every user via UniversalModules); group
 	//(those would make it visible to every user via UniversalModules); group
 	//"Development" falls through to the normal IsAdmin permission check, so only
 	//"Development" falls through to the normal IsAdmin permission check, so only

+ 62 - 0
src/mod/docker/service.go

@@ -0,0 +1,62 @@
+package docker
+
+/*
+	service.go
+
+	Docker daemon lifecycle control via the host init system. The actual
+	implementation is platform-specific (service_linux.go uses systemctl;
+	service_other.go returns "unsupported"). These actions require ArozOS to run
+	with sufficient privilege (root) to manage the docker unit.
+
+	Stopping or restarting the daemon stops every running container, so the
+	settings UI gates those actions behind the standard re-auth confirmation.
+*/
+
+import (
+	"encoding/json"
+	"net/http"
+
+	"imuslab.com/arozos/mod/utils"
+)
+
+// ServiceStatus describes the docker daemon's init-system service state.
+type ServiceStatus struct {
+	Available bool   `json:"available"`         // service control usable on this host
+	Active    bool   `json:"active"`            // daemon currently running
+	Enabled   bool   `json:"enabled"`           // starts automatically on boot
+	State     string `json:"state"`             // raw is-active value (active/inactive/failed...)
+	Message   string `json:"message,omitempty"` // explanation when not available
+}
+
+// validServiceActions is the whitelist of permitted service operations.
+var validServiceActions = map[string]bool{
+	"start":   true,
+	"stop":    true,
+	"restart": true,
+	"enable":  true,
+	"disable": true,
+}
+
+// HandleServiceStatus serves the docker service status as JSON.
+func (d *DockerManager) HandleServiceStatus(w http.ResponseWriter, r *http.Request) {
+	js, _ := json.Marshal(dockerServiceStatus())
+	utils.SendJSONResponse(w, string(js))
+}
+
+// HandleServiceAction performs a whitelisted service action (POST: action).
+func (d *DockerManager) HandleServiceAction(w http.ResponseWriter, r *http.Request) {
+	if r.Method != http.MethodPost {
+		utils.SendErrorResponse(w, "method not allowed")
+		return
+	}
+	action, err := utils.PostPara(r, "action")
+	if err != nil || !validServiceActions[action] {
+		utils.SendErrorResponse(w, "invalid or missing action")
+		return
+	}
+	if err := dockerServiceAction(action); err != nil {
+		utils.SendErrorResponse(w, err.Error())
+		return
+	}
+	utils.SendOK(w)
+}

+ 61 - 0
src/mod/docker/service_linux.go

@@ -0,0 +1,61 @@
+//go:build linux
+
+package docker
+
+/*
+	service_linux.go
+
+	Docker daemon service control on Linux via systemctl. Isolated behind a
+	build tag so non-systemd platforms compile against service_other.go.
+
+	These operations require root; when ArozOS is not privileged enough,
+	systemctl's own error message is surfaced back to the caller.
+*/
+
+import (
+	"errors"
+	"os/exec"
+	"strings"
+)
+
+func systemctlAvailable() bool {
+	_, err := exec.LookPath("systemctl")
+	return err == nil
+}
+
+// dockerServiceStatus reports the docker unit's active/enabled state.
+func dockerServiceStatus() ServiceStatus {
+	st := ServiceStatus{}
+	if !systemctlAvailable() {
+		st.Message = "systemctl is not available on this host"
+		return st
+	}
+	st.Available = true
+
+	// `is-active` exits non-zero when inactive, but still prints the state on
+	// stdout — parse the output rather than relying on the exit code.
+	out, _ := exec.Command("systemctl", "is-active", "docker").CombinedOutput()
+	st.State = strings.TrimSpace(string(out))
+	st.Active = st.State == "active"
+
+	outEnabled, _ := exec.Command("systemctl", "is-enabled", "docker").CombinedOutput()
+	st.Enabled = strings.TrimSpace(string(outEnabled)) == "enabled"
+
+	return st
+}
+
+// dockerServiceAction runs `systemctl <action> docker` for a whitelisted action.
+func dockerServiceAction(action string) error {
+	if !systemctlAvailable() {
+		return errors.New("systemctl is not available on this host")
+	}
+	out, err := exec.Command("systemctl", action, "docker").CombinedOutput()
+	if err != nil {
+		msg := strings.TrimSpace(string(out))
+		if msg == "" {
+			msg = err.Error()
+		}
+		return errors.New(msg)
+	}
+	return nil
+}

+ 24 - 0
src/mod/docker/service_other.go

@@ -0,0 +1,24 @@
+//go:build !linux
+
+package docker
+
+/*
+	service_other.go
+
+	Docker daemon service control is only implemented for Linux/systemd hosts.
+	On other platforms (Windows / macOS, typically Docker Desktop) the daemon is
+	managed by that platform's own tooling, so these return a clear unsupported
+	state rather than attempting anything.
+*/
+
+import "errors"
+
+const serviceUnsupportedMsg = "Docker daemon service control is only available on Linux (systemd) hosts"
+
+func dockerServiceStatus() ServiceStatus {
+	return ServiceStatus{Message: serviceUnsupportedMsg}
+}
+
+func dockerServiceAction(action string) error {
+	return errors.New(serviceUnsupportedMsg)
+}

+ 34 - 0
src/mod/docker/service_test.go

@@ -0,0 +1,34 @@
+package docker
+
+import "testing"
+
+func TestValidServiceActions(t *testing.T) {
+	valid := []string{"start", "stop", "restart", "enable", "disable"}
+	for _, a := range valid {
+		if !validServiceActions[a] {
+			t.Errorf("action %q should be valid", a)
+		}
+	}
+	invalid := []string{"", "status", "kill", "reload", "rm", "start docker"}
+	for _, a := range invalid {
+		if validServiceActions[a] {
+			t.Errorf("action %q should be invalid", a)
+		}
+	}
+}
+
+// TestDockerServiceStatusConsistent asserts the status object is internally
+// consistent on whatever platform the test runs on: when service control is
+// unavailable there must be an explanatory message and the daemon must not be
+// reported active.
+func TestDockerServiceStatusConsistent(t *testing.T) {
+	st := dockerServiceStatus()
+	if !st.Available {
+		if st.Message == "" {
+			t.Error("unavailable service status must carry an explanatory message")
+		}
+		if st.Active {
+			t.Error("service reported active while unavailable")
+		}
+	}
+}

+ 187 - 0
src/web/SystemAO/docker/daemon.html

@@ -0,0 +1,187 @@
+<style>
+#dkd-root {
+    --dk-bg: #ffffff; --dk-border: #e5e5e5; --dk-text: #1f1f1f; --dk-dim: #676767;
+    --dk-soft: #f5f5f5; --dk-soft2: #efefef; --dk-accent: #2b6cb0; --dk-accent-text:#fff;
+    --dk-ok: #107c10; --dk-warn: #b7791f; --dk-danger: #c42b1c;
+    --dk-shadow: 0 4px 20px rgba(0,0,0,0.08);
+    background: var(--dk-bg); border: 1px solid var(--dk-border); border-radius: 12px;
+    box-shadow: var(--dk-shadow); color: var(--dk-text); padding: 16px; margin-bottom: 4px;
+    font-family: 'Segoe UI', system-ui, -apple-system, BlinkMacSystemFont, sans-serif; font-size: 13px;
+}
+body.dark #dkd-root {
+    --dk-bg:#2b2b2b; --dk-border:#3b3b3b; --dk-text:#ececec; --dk-dim:#aaaaaa;
+    --dk-soft:#343434; --dk-soft2:#3c3c3c; --dk-accent:#4a90d9; --dk-ok:#2ecc71;
+    --dk-warn:#e0a838; --dk-danger:#ff6b5e; --dk-shadow:0 8px 22px rgba(0,0,0,0.3);
+}
+#dkd-root * { box-sizing: border-box; }
+.dkd-head { display:flex; align-items:center; gap:10px; margin-bottom:14px; }
+.dkd-title { font-size:17px; font-weight:600; }
+.dkd-sub { font-size:12px; color:var(--dk-dim); margin-top:2px; }
+.dkd-card { border:1px solid var(--dk-border); border-radius:10px; background:var(--dk-soft); padding:14px; margin-bottom:12px; }
+.dkd-card-title { font-size:13.5px; font-weight:600; margin-bottom:4px; display:flex; align-items:center; gap:8px; }
+.dkd-card-desc { font-size:11.5px; color:var(--dk-dim); margin-bottom:12px; line-height:1.5; }
+.dkd-grid { display:grid; grid-template-columns: repeat(2, minmax(120px,1fr)); gap:8px; margin-bottom:14px; }
+.dkd-metric { border:1px solid var(--dk-border); border-radius:10px; background:var(--dk-bg); padding:10px; }
+.dkd-metric .lab { font-size:11px; color:var(--dk-dim); margin-bottom:4px; }
+.dkd-metric .val { font-size:16px; font-weight:600; }
+.dkd-metric .val.ok { color:var(--dk-ok); } .dkd-metric .val.off { color:var(--dk-danger); }
+#dkd-root code { background:var(--dk-soft2); padding:1px 5px; border-radius:4px; font-size:11.5px; }
+.dkd-btn { border:1px solid var(--dk-border); background:var(--dk-bg); color:var(--dk-text); border-radius:8px;
+    font-size:12.5px; font-weight:600; padding:8px 14px; cursor:pointer; transition:.12s; }
+.dkd-btn:hover { background:var(--dk-soft2); }
+.dkd-btn.primary { background:var(--dk-accent); color:var(--dk-accent-text); border-color:var(--dk-accent); }
+.dkd-btn.danger { border-color:var(--dk-danger); color:var(--dk-danger); }
+.dkd-actions { display:flex; gap:8px; flex-wrap:wrap; }
+.dkd-result { font-size:12px; color:var(--dk-dim); margin-left:4px; align-self:center; }
+.dkd-notice { border:1px solid var(--dk-warn); border-left-width:4px; border-radius:8px;
+    background:rgba(183,121,31,0.08); padding:10px 12px; margin-bottom:14px; font-size:12px; line-height:1.5;
+    display:flex; gap:10px; align-items:flex-start; }
+.dkd-notice i.icon { color:var(--dk-warn); margin:0; }
+</style>
+
+<div id="dkd-root">
+    <div class="dkd-head">
+        <div>
+            <div class="dkd-title">Docker Daemon</div>
+            <div class="dkd-sub">Control the Docker daemon service and edit its configuration.</div>
+        </div>
+    </div>
+
+    <div class="dkd-notice">
+        <i class="exclamation triangle icon"></i>
+        <div>Service control uses the host init system (systemd) and requires ArozOS to run with sufficient privilege (root). Stopping the daemon stops every running container.</div>
+    </div>
+
+    <div class="dkd-card">
+        <div class="dkd-card-title">Service</div>
+        <div class="dkd-card-desc">Current state of the <code>docker</code> system service.</div>
+        <div class="dkd-grid">
+            <div class="dkd-metric"><div class="lab">Service</div><div class="val" id="dkd-active">...</div></div>
+            <div class="dkd-metric"><div class="lab">Start on boot</div><div class="val" id="dkd-enabled">...</div></div>
+        </div>
+        <div class="dkd-actions">
+            <button class="dkd-btn primary" id="dkd-start"><i class="play icon"></i> Start</button>
+            <button class="dkd-btn danger" id="dkd-stop"><i class="stop icon"></i> Stop</button>
+            <button class="dkd-btn danger" id="dkd-restart"><i class="redo icon"></i> Restart</button>
+            <button class="dkd-btn" id="dkd-enable"><i class="check icon"></i> Enable autostart</button>
+            <button class="dkd-btn" id="dkd-disable"><i class="times icon"></i> Disable autostart</button>
+            <span class="dkd-result" id="dkd-svc-result"></span>
+        </div>
+    </div>
+
+    <div class="dkd-card">
+        <div class="dkd-card-title">Daemon Configuration</div>
+        <div class="dkd-card-desc">
+            <code>daemon.json</code> at <code id="dkd-path">...</code>. <span id="dkd-editbadge"></span>
+            Changes apply only after the daemon is restarted (use the Restart button above), which briefly stops all containers.
+        </div>
+        <textarea id="dkd-content" spellcheck="false" style="width:100%;height:150px;background:var(--dk-bg);border:1px solid var(--dk-border);color:var(--dk-text);border-radius:8px;padding:10px;font-family:ui-monospace,Menlo,Consolas,monospace;font-size:12px;" placeholder='{ "registry-mirrors": [] }'></textarea>
+        <div class="dkd-actions" style="margin-top:10px;">
+            <button class="dkd-btn" id="dkd-save" style="display:none;">Save daemon.json</button>
+            <span class="dkd-result" id="dkd-cfg-result"></span>
+        </div>
+    </div>
+</div>
+
+<script>
+(function () {
+    var API = "../../system/docker/";
+
+    (function applyTheme(){
+        try { if (typeof preferredTheme !== 'undefined') { document.body.classList.toggle('dark', (preferredTheme==='dark'||preferredTheme==='darkTheme')); return; } } catch(e){}
+        if (typeof ao_module_getSystemThemeColor === 'function') { ao_module_getSystemThemeColor(function(c){ document.body.classList.toggle('dark', c!=='whiteTheme'); }); }
+    })();
+
+    function el(id){ return document.getElementById(id); }
+
+    function loadStatus(){
+        $.get(API + "service/status", function(d){
+            if (d && d.error){ el("dkd-svc-result").textContent = d.error; return; }
+            if (!d.available){
+                el("dkd-active").textContent = "Unsupported";
+                el("dkd-active").className = "val off";
+                el("dkd-enabled").textContent = "-";
+                el("dkd-svc-result").textContent = d.message || "Service control not available on this host.";
+                ["dkd-start","dkd-stop","dkd-restart","dkd-enable","dkd-disable"].forEach(function(i){ el(i).disabled = true; });
+                return;
+            }
+            el("dkd-active").textContent = d.active ? "Running" : "Stopped";
+            el("dkd-active").className = "val " + (d.active ? "ok" : "off");
+            el("dkd-enabled").textContent = d.enabled ? "Enabled" : "Disabled";
+            el("dkd-enabled").className = "val " + (d.enabled ? "ok" : "off");
+        }).fail(function(){ el("dkd-svc-result").textContent = "Failed to load (admin only)."; });
+    }
+
+    // Non-destructive actions run directly; stop/restart re-prompt for the password.
+    function doAction(action){
+        el("dkd-svc-result").textContent = "Working...";
+        $.post(API + "service/action", { action: action }, function(r){
+            if (r && r.error){ el("dkd-svc-result").textContent = "Error: " + r.error; return; }
+            el("dkd-svc-result").textContent = action + " ok.";
+            setTimeout(loadStatus, 600);
+        }).fail(function(){ el("dkd-svc-result").textContent = action + " request failed."; });
+    }
+
+    function confirmAction(action){
+        var apiObject = {
+            api: "../../system/docker/service/action",
+            data: { "action": action },
+            title: "<i class='yellow exclamation triangle icon'></i> DOCKER DAEMON " + action.toUpperCase() + " <i class='yellow exclamation triangle icon'></i>",
+            desc: action === "stop"
+                ? "Stop the Docker daemon? This stops all running containers."
+                : "Restart the Docker daemon? This briefly stops all running containers.",
+            thisuser: true, method: "POST", success: undefined
+        };
+        apiObject = encodeURIComponent(JSON.stringify(apiObject));
+        parent.newFloatWindow({
+            url: "SystemAO/security/authreq.html#" + apiObject,
+            width: 480, height: 300,
+            appicon: "SystemAO/docker/img/small_icon.svg",
+            title: "Confirm Docker Daemon " + action,
+            parent: ao_module_windowID,
+            callback: "handleDaemonServiceCallback"
+        });
+    }
+    window.handleDaemonServiceCallback = function(data){
+        if (data && data.error){ el("dkd-svc-result").textContent = "Error: " + data.error; }
+        else { el("dkd-svc-result").textContent = "Done."; setTimeout(loadStatus, 800); }
+    };
+
+    /* daemon.json config */
+    function loadConfig(){
+        $.get(API + "daemon/get", function(d){
+            if (d && d.error){ el("dkd-cfg-result").textContent = d.error; return; }
+            el("dkd-path").textContent = d.path || "?";
+            el("dkd-content").value = d.content || "";
+            if (d.editable){
+                el("dkd-editbadge").innerHTML = '<b style="color:var(--dk-ok);">Editable.</b>';
+                el("dkd-content").removeAttribute("readonly");
+                el("dkd-save").style.display = "inline-block";
+            } else {
+                el("dkd-editbadge").innerHTML = '<b style="color:var(--dk-warn);">Read-only on this host.</b>';
+                el("dkd-content").setAttribute("readonly","readonly");
+                el("dkd-save").style.display = "none";
+            }
+        }).fail(function(){ el("dkd-cfg-result").textContent = "Failed to load (admin only)."; });
+    }
+    function saveConfig(){
+        el("dkd-cfg-result").textContent = "Saving...";
+        $.ajax({ url: API + "daemon/save", method: "POST", data: JSON.stringify({ content: el("dkd-content").value }), contentType: "application/json" })
+            .done(function(r){
+                if (r && r.error){ el("dkd-cfg-result").textContent = "Error: " + r.error; return; }
+                el("dkd-cfg-result").textContent = "Saved. Restart the daemon to apply.";
+            })
+            .fail(function(){ el("dkd-cfg-result").textContent = "Save request failed."; });
+    }
+
+    el("dkd-start").addEventListener("click", function(){ doAction("start"); });
+    el("dkd-stop").addEventListener("click", function(){ confirmAction("stop"); });
+    el("dkd-restart").addEventListener("click", function(){ confirmAction("restart"); });
+    el("dkd-enable").addEventListener("click", function(){ doAction("enable"); });
+    el("dkd-disable").addEventListener("click", function(){ doAction("disable"); });
+    el("dkd-save").addEventListener("click", saveConfig);
+
+    loadStatus();
+    loadConfig();
+})();
+</script>

+ 102 - 0
src/web/SystemAO/docker/list.html

@@ -0,0 +1,102 @@
+<style>
+#dkl-root {
+    --dk-bg:#ffffff; --dk-border:#e5e5e5; --dk-text:#1f1f1f; --dk-dim:#676767;
+    --dk-soft:#f5f5f5; --dk-soft2:#efefef; --dk-accent:#2b6cb0; --dk-accent-text:#fff;
+    --dk-ok:#107c10; --dk-danger:#c42b1c; --dk-shadow:0 4px 20px rgba(0,0,0,0.08);
+    background:var(--dk-bg); border:1px solid var(--dk-border); border-radius:12px;
+    box-shadow:var(--dk-shadow); color:var(--dk-text); padding:16px; margin-bottom:4px;
+    font-family:'Segoe UI', system-ui, -apple-system, BlinkMacSystemFont, sans-serif; font-size:13px;
+}
+body.dark #dkl-root {
+    --dk-bg:#2b2b2b; --dk-border:#3b3b3b; --dk-text:#ececec; --dk-dim:#aaaaaa;
+    --dk-soft:#343434; --dk-soft2:#3c3c3c; --dk-accent:#4a90d9; --dk-ok:#2ecc71;
+    --dk-danger:#ff6b5e; --dk-shadow:0 8px 22px rgba(0,0,0,0.3);
+}
+#dkl-root * { box-sizing:border-box; }
+.dkl-head { display:flex; align-items:center; gap:10px; margin-bottom:14px; }
+.dkl-title { font-size:17px; font-weight:600; }
+.dkl-sub { font-size:12px; color:var(--dk-dim); margin-top:2px; }
+.dkl-head .right { margin-left:auto; }
+.dkl-table { width:100%; border-collapse:collapse; font-size:12.5px; }
+.dkl-table th { text-align:left; font-size:11px; color:var(--dk-dim); font-weight:600; padding:7px 8px; border-bottom:1px solid var(--dk-border); }
+.dkl-table td { padding:7px 8px; border-bottom:1px solid var(--dk-border); }
+.dkl-mono { font-family:ui-monospace, Menlo, Consolas, monospace; font-size:11.5px; }
+.dkl-badge { font-size:11px; padding:2px 8px; border-radius:10px; font-weight:600; background:var(--dk-ok); color:#fff; }
+.dkl-empty { text-align:center; color:var(--dk-dim); padding:22px; }
+.dkl-btn { border:1px solid var(--dk-border); background:var(--dk-bg); color:var(--dk-text); border-radius:8px;
+    font-size:12.5px; font-weight:600; padding:8px 14px; cursor:pointer; transition:.12s; }
+.dkl-btn:hover { background:var(--dk-soft2); }
+.dkl-btn.primary { background:var(--dk-accent); color:var(--dk-accent-text); border-color:var(--dk-accent); }
+.dkl-footer { margin-top:16px; display:flex; align-items:center; gap:10px; }
+.dkl-footer .note { font-size:11.5px; color:var(--dk-dim); }
+</style>
+
+<div id="dkl-root">
+    <div class="dkl-head">
+        <div>
+            <div class="dkl-title">Docker List</div>
+            <div class="dkl-sub">Containers currently running on this host.</div>
+        </div>
+        <div class="right"><button class="dkl-btn" id="dkl-refresh"><i class="refresh icon"></i> Refresh</button></div>
+    </div>
+
+    <table class="dkl-table">
+        <thead><tr><th>Name</th><th>Image</th><th>Status</th><th>Ports</th></tr></thead>
+        <tbody id="dkl-body"><tr><td colspan="4" class="dkl-empty">Loading...</td></tr></tbody>
+    </table>
+
+    <div class="dkl-footer">
+        <button class="dkl-btn primary" id="dkl-manage"><i class="cube icon"></i> Manage Containers</button>
+        <span class="note">Opens the full Docker Manager app to start/stop, edit, view logs, open a console and manage images, compose stacks and registries.</span>
+    </div>
+</div>
+
+<script>
+(function () {
+    var API = "../../system/docker/";
+
+    (function applyTheme(){
+        try { if (typeof preferredTheme !== 'undefined') { document.body.classList.toggle('dark', (preferredTheme==='dark'||preferredTheme==='darkTheme')); return; } } catch(e){}
+        if (typeof ao_module_getSystemThemeColor === 'function') { ao_module_getSystemThemeColor(function(c){ document.body.classList.toggle('dark', c!=='whiteTheme'); }); }
+    })();
+
+    function esc(s){ return String(s==null?"":s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;"); }
+
+    function loadRunning(){
+        var $b = $("#dkl-body");
+        $b.html('<tr><td colspan="4" class="dkl-empty">Loading...</td></tr>');
+        $.get(API + "containers/list", function(list){
+            if (list && list.error){ $b.html('<tr><td colspan="4" class="dkl-empty">' + esc(list.error) + '</td></tr>'); return; }
+            var running = (list || []).filter(function(c){ return (c.State || "").toLowerCase() === "running"; });
+            if (running.length === 0){ $b.html('<tr><td colspan="4" class="dkl-empty">No running containers.</td></tr>'); return; }
+            $b.html(running.map(function(c){
+                return '<tr>' +
+                    '<td class="dkl-mono">' + esc(c.Names) + '</td>' +
+                    '<td class="dkl-mono">' + esc(c.Image) + '</td>' +
+                    '<td><span class="dkl-badge">' + esc(c.Status || "running") + '</span></td>' +
+                    '<td class="dkl-mono">' + esc(c.Ports || "") + '</td>' +
+                    '</tr>';
+            }).join(""));
+        }).fail(function(){ $b.html('<tr><td colspan="4" class="dkl-empty">Failed to load (admin only).</td></tr>'); });
+    }
+
+    function launchDockerManager(){
+        try {
+            parent.newFloatWindow({
+                url: "DockerManager/index.html",
+                width: 1100, height: 680,
+                appicon: "DockerManager/img/icon.svg",
+                title: "Docker Manager"
+            });
+        } catch (e) {
+            // Fallback: open in a new browser tab if the desktop launcher is unavailable.
+            window.open("../../DockerManager/index.html", "_blank");
+        }
+    }
+
+    $("#dkl-refresh").on("click", loadRunning);
+    $("#dkl-manage").on("click", launchDockerManager);
+
+    loadRunning();
+})();
+</script>