From f618d107edb8c396c6b0d3e98f134e10f9365714 Mon Sep 17 00:00:00 2001 From: luskbyte Date: Fri, 24 Apr 2026 15:36:53 -0300 Subject: [PATCH] Add initial project files --- .gitignore | 22 ++ Dockerfile | 20 ++ build.sh | 3 + docker-compose.yml | 12 + docker/client.go | 130 ++++++++ go.mod | 3 + handlers/containers.go | 121 +++++++ handlers/images.go | 71 ++++ handlers/info.go | 18 + main.go | 137 ++++++++ static/app.js | 514 ++++++++++++++++++++++++++++ static/index.html | 144 ++++++++ static/style.css | 742 +++++++++++++++++++++++++++++++++++++++++ 13 files changed, 1937 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 build.sh create mode 100644 docker-compose.yml create mode 100644 docker/client.go create mode 100644 go.mod create mode 100644 handlers/containers.go create mode 100644 handlers/images.go create mode 100644 handlers/info.go create mode 100644 main.go create mode 100644 static/app.js create mode 100644 static/index.html create mode 100644 static/style.css diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7c6bffd --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# Go +*.o +*.a +*.so +.DS_Store +docker-manager +docker-manager-bin + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Build +bin/ +dist/ + +# Environment +.env +.env.local diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4a8c46a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM golang:1.22-alpine AS builder + +WORKDIR /app + +COPY . . + +RUN go build -o docker-manager . + +FROM alpine:latest + +RUN apk --no-cache add ca-certificates + +WORKDIR /app + +COPY --from=builder /app/docker-manager . +COPY --from=builder /app/static ./static + +EXPOSE 3000 + +CMD ["./docker-manager"] diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..fd33f31 --- /dev/null +++ b/build.sh @@ -0,0 +1,3 @@ +#!/bin/bash +cd /home/lucas/docker-manager +go build -o docker-manager . diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e1e77e0 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,12 @@ +version: '3.8' + +services: + docker-manager: + build: . + ports: + - "3000:3000" + volumes: + - /var/run/docker.sock:/var/run/docker.sock + environment: + - PORT=3000 + restart: unless-stopped diff --git a/docker/client.go b/docker/client.go new file mode 100644 index 0000000..1f17333 --- /dev/null +++ b/docker/client.go @@ -0,0 +1,130 @@ +package docker + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" +) + +type Client struct { + http *http.Client + socket string +} + +func DetectSocket() string { + // 1. DOCKER_HOST env var + if host := os.Getenv("DOCKER_HOST"); host != "" { + if strings.HasPrefix(host, "unix://") { + return strings.TrimPrefix(host, "unix://") + } + } + + // 2. docker context inspect (active context) + if sockPath := getContextSocket(); sockPath != "" { + return sockPath + } + + // 3. Known paths + knownPaths := []string{ + "/var/run/docker.sock", + filepath.Join(os.Getenv("HOME"), ".docker/run/docker.sock"), + filepath.Join(os.Getenv("HOME"), "Library/containers/com.docker.docker/Data/docker.raw.sock"), + "/run/docker.sock", + } + for _, path := range knownPaths { + if _, err := os.Stat(path); err == nil { + return path + } + } + + // 4. Fallback + return "/var/run/docker.sock" +} + +func getContextSocket() string { + cmd := exec.Command("docker", "context", "inspect") + out, err := cmd.Output() + if err != nil { + return "" + } + + var contexts []map[string]interface{} + if err := json.Unmarshal(out, &contexts); err != nil { + return "" + } + + if len(contexts) > 0 { + if meta, ok := contexts[0]["Metadata"].(map[string]interface{}); ok { + if host, ok := meta["host"].(string); ok && strings.HasPrefix(host, "unix://") { + return strings.TrimPrefix(host, "unix://") + } + } + } + return "" +} + +func NewClient(socketPath string) *Client { + httpClient := &http.Client{ + Transport: &http.Transport{ + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + return net.Dial("unix", socketPath) + }, + }, + } + return &Client{ + http: httpClient, + socket: socketPath, + } +} + +func (c *Client) Do(method, path string, body io.Reader) (*http.Response, error) { + url := fmt.Sprintf("http://localhost%s", path) + req, err := http.NewRequest(method, url, body) + if err != nil { + return nil, err + } + return c.http.Do(req) +} + +func (c *Client) DoWithJSONBody(method, path string, bodyData interface{}) (*http.Response, error) { + jsonBody, err := json.Marshal(bodyData) + if err != nil { + return nil, err + } + body := bytes.NewReader(jsonBody) + return c.Do(method, path, body) +} + +func (c *Client) GetJSON(path string, result interface{}) error { + resp, err := c.Do("GET", path, nil) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("docker api error: %d", resp.StatusCode) + } + + return json.NewDecoder(resp.Body).Decode(result) +} + +func (c *Client) DoStream(method, path string, body io.Reader) (io.ReadCloser, error) { + resp, err := c.Do(method, path, body) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + resp.Body.Close() + return nil, fmt.Errorf("docker api error: %d", resp.StatusCode) + } + return resp.Body, nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a8d1bb8 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module docker-manager + +go 1.22 diff --git a/handlers/containers.go b/handlers/containers.go new file mode 100644 index 0000000..c1b6e94 --- /dev/null +++ b/handlers/containers.go @@ -0,0 +1,121 @@ +package handlers + +import ( + "docker-manager/docker" + "encoding/json" + "fmt" + "io" + "net/http" +) + +func ListContainersHandler(w http.ResponseWriter, r *http.Request, client *docker.Client) { + var containers []map[string]interface{} + if err := client.GetJSON("/containers/json?all=true", &containers); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + if containers == nil { + w.Write([]byte("[]")) + } else { + json.NewEncoder(w).Encode(containers) + } +} + +func StartContainerHandler(w http.ResponseWriter, r *http.Request, client *docker.Client, id string) { + if resp, err := client.Do("POST", fmt.Sprintf("/containers/%s/start", id), nil); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } else { + defer resp.Body.Close() + w.WriteHeader(resp.StatusCode) + } +} + +func StopContainerHandler(w http.ResponseWriter, r *http.Request, client *docker.Client, id string) { + if resp, err := client.Do("POST", fmt.Sprintf("/containers/%s/stop", id), nil); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } else { + defer resp.Body.Close() + w.WriteHeader(resp.StatusCode) + } +} + +func RestartContainerHandler(w http.ResponseWriter, r *http.Request, client *docker.Client, id string) { + if resp, err := client.Do("POST", fmt.Sprintf("/containers/%s/restart", id), nil); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } else { + defer resp.Body.Close() + w.WriteHeader(resp.StatusCode) + } +} + +func RemoveContainerHandler(w http.ResponseWriter, r *http.Request, client *docker.Client, id string) { + if resp, err := client.Do("DELETE", fmt.Sprintf("/containers/%s?force=true", id), nil); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } else { + defer resp.Body.Close() + w.WriteHeader(resp.StatusCode) + } +} + +func GetLogsHandler(w http.ResponseWriter, r *http.Request, client *docker.Client, id string) { + body, err := client.DoStream("GET", fmt.Sprintf("/containers/%s/logs?stdout=true&stderr=true&tail=200", id), nil) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer body.Close() + + // Docker logs returns docker stream format, need to parse it + logs := parseLogs(body) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"logs": logs}) +} + +// Docker log stream format: [8 bytes header][payload] +// Header: [1 byte stream type][3 bytes padding][4 bytes payload length] +func parseLogs(body io.Reader) string { + var result string + buffer := make([]byte, 4096) + + for { + n, err := body.Read(buffer) + if err != nil && err != io.EOF { + break + } + if n == 0 { + break + } + + // Parse docker stream format + data := buffer[:n] + for i := 0; i < len(data); { + if i+8 > len(data) { + break + } + // Skip 8-byte header + i += 8 + // Read until next header or end + start := i + for i < len(data) && i+8 <= len(data) { + i += 8 + if i > len(data) { + i = len(data) + break + } + } + if start < len(data) { + result += string(data[start : len(data)]) + i = len(data) + } + } + } + + // Fallback: if stream format parsing fails, just return the raw data as text + if result == "" { + return string(buffer) + } + return result +} diff --git a/handlers/images.go b/handlers/images.go new file mode 100644 index 0000000..e51ba25 --- /dev/null +++ b/handlers/images.go @@ -0,0 +1,71 @@ +package handlers + +import ( + "docker-manager/docker" + "encoding/json" + "fmt" + "net/http" + "strings" +) + +func ListImagesHandler(w http.ResponseWriter, r *http.Request, client *docker.Client) { + var images []map[string]interface{} + if err := client.GetJSON("/images/json", &images); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + if images == nil { + w.Write([]byte("[]")) + } else { + json.NewEncoder(w).Encode(images) + } +} + +func RemoveImageHandler(w http.ResponseWriter, r *http.Request, client *docker.Client, id string) { + if resp, err := client.Do("DELETE", fmt.Sprintf("/images/%s?force=true", id), nil); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } else { + defer resp.Body.Close() + w.WriteHeader(resp.StatusCode) + } +} + +func PullImageHandler(w http.ResponseWriter, r *http.Request, client *docker.Client) { + name := r.URL.Query().Get("name") + if name == "" { + http.Error(w, "name parameter required", http.StatusBadRequest) + return + } + + // Strip tag if contains ":" + ref := name + if strings.Contains(name, ":") { + parts := strings.Split(name, ":") + ref = parts[0] + } + + body, err := client.DoStream("POST", fmt.Sprintf("/images/create?fromImage=%s", ref), nil) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer body.Close() + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Transfer-Encoding", "chunked") + + // Stream pull progress as JSON lines + decoder := json.NewDecoder(body) + for { + var line map[string]interface{} + if err := decoder.Decode(&line); err != nil { + break + } + json.NewEncoder(w).Encode(line) + if flusher, ok := w.(http.Flusher); ok { + flusher.Flush() + } + } +} diff --git a/handlers/info.go b/handlers/info.go new file mode 100644 index 0000000..f298bcb --- /dev/null +++ b/handlers/info.go @@ -0,0 +1,18 @@ +package handlers + +import ( + "docker-manager/docker" + "encoding/json" + "net/http" +) + +func InfoHandler(w http.ResponseWriter, r *http.Request, client *docker.Client) { + var info map[string]interface{} + if err := client.GetJSON("/info", &info); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(info) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..a4c809a --- /dev/null +++ b/main.go @@ -0,0 +1,137 @@ +package main + +import ( + "docker-manager/docker" + "docker-manager/handlers" + "fmt" + "net/http" + "os" + "strings" +) + +func main() { + // Detect Docker socket + socketPath := docker.DetectSocket() + fmt.Printf("Using Docker socket: %s\n", socketPath) + + client := docker.NewClient(socketPath) + + // Create router + mux := http.NewServeMux() + + // API routes + mux.HandleFunc("/api/info", corsMiddleware(func(w http.ResponseWriter, r *http.Request) { + handlers.InfoHandler(w, r, client) + })) + + mux.HandleFunc("/api/containers", corsMiddleware(func(w http.ResponseWriter, r *http.Request) { + handlers.ListContainersHandler(w, r, client) + })) + + mux.HandleFunc("/api/images", corsMiddleware(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" { + handlers.ListImagesHandler(w, r, client) + } + })) + + mux.HandleFunc("/api/", corsMiddleware(func(w http.ResponseWriter, r *http.Request) { + handleDynamicRoutes(w, r, client) + })) + + // Serve static files + mux.Handle("/", http.FileServer(http.Dir("static"))) + + // Get port from env or default to 3000 + port := os.Getenv("PORT") + if port == "" { + port = "3000" + } + + addr := fmt.Sprintf(":%s", port) + fmt.Printf("Starting server on %s\n", addr) + if err := http.ListenAndServe(addr, mux); err != nil { + fmt.Fprintf(os.Stderr, "Server error: %v\n", err) + os.Exit(1) + } +} + +func corsMiddleware(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + + next(w, r) + } +} + +func handleDynamicRoutes(w http.ResponseWriter, r *http.Request, client *docker.Client) { + path := r.URL.Path + + // /api/containers/{id}/start + if strings.HasSuffix(path, "/start") && strings.HasPrefix(path, "/api/containers/") { + id := extractID(path, "/api/containers/", "/start") + handlers.StartContainerHandler(w, r, client, id) + return + } + + // /api/containers/{id}/stop + if strings.HasSuffix(path, "/stop") && strings.HasPrefix(path, "/api/containers/") { + id := extractID(path, "/api/containers/", "/stop") + handlers.StopContainerHandler(w, r, client, id) + return + } + + // /api/containers/{id}/restart + if strings.HasSuffix(path, "/restart") && strings.HasPrefix(path, "/api/containers/") { + id := extractID(path, "/api/containers/", "/restart") + handlers.RestartContainerHandler(w, r, client, id) + return + } + + // /api/containers/{id} DELETE + if strings.HasPrefix(path, "/api/containers/") && r.Method == "DELETE" { + id := strings.TrimPrefix(path, "/api/containers/") + id = strings.TrimSuffix(id, "/") + if id != "" && !strings.Contains(id, "/") { + handlers.RemoveContainerHandler(w, r, client, id) + return + } + } + + // /api/containers/{id}/logs + if strings.HasSuffix(path, "/logs") && strings.HasPrefix(path, "/api/containers/") { + id := extractID(path, "/api/containers/", "/logs") + handlers.GetLogsHandler(w, r, client, id) + return + } + + // /api/images/{id} DELETE + if strings.HasPrefix(path, "/api/images/") && r.Method == "DELETE" { + id := strings.TrimPrefix(path, "/api/images/") + id = strings.TrimSuffix(id, "/") + if id != "" && !strings.Contains(id, "/") { + handlers.RemoveImageHandler(w, r, client, id) + return + } + } + + // /api/images/pull POST + if r.Method == "GET" && strings.HasPrefix(path, "/api/images/pull") { + handlers.PullImageHandler(w, r, client) + return + } + + http.Error(w, "Not Found", http.StatusNotFound) +} + +func extractID(path, prefix, suffix string) string { + id := strings.TrimPrefix(path, prefix) + id = strings.TrimSuffix(id, suffix) + return id +} diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..11426a3 --- /dev/null +++ b/static/app.js @@ -0,0 +1,514 @@ +const API_BASE = '/api'; + +const state = { + tab: 'dashboard', + containers: [], + images: [], + info: null, + connected: false, + logsContainerId: null, +}; + +function init() { + setupEventListeners(); + fetchInitialData(); + setInterval(fetchInitialData, 30000); +} + +function setupEventListeners() { + document.querySelectorAll('.tab-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active')); + e.target.classList.add('active'); + state.tab = e.target.dataset.tab; + renderTabs(); + }); + }); + + document.querySelector('.modal-close').addEventListener('click', closeLogsModal); + document.getElementById('logs-modal').addEventListener('click', (e) => { + if (e.target.id === 'logs-modal') closeLogsModal(); + }); + + document.getElementById('logs-refresh').addEventListener('click', refreshLogs); + document.getElementById('logs-tail').addEventListener('change', refreshLogs); + + document.getElementById('confirm-cancel').addEventListener('click', closeConfirmModal); + document.getElementById('confirm-ok').addEventListener('click', confirmAction); + document.getElementById('confirm-modal').addEventListener('click', (e) => { + if (e.target.id === 'confirm-modal') closeConfirmModal(); + }); + + document.getElementById('pull-btn').addEventListener('click', pullImage); + document.getElementById('pull-input').addEventListener('keypress', (e) => { + if (e.key === 'Enter') pullImage(); + }); +} + +async function fetchInitialData() { + try { + await Promise.all([ + fetchInfo(), + fetchContainers(), + fetchImages(), + ]); + state.connected = true; + updateStatusIndicator(); + } catch (err) { + state.connected = false; + updateStatusIndicator(); + } + renderTabs(); +} + +async function fetchInfo() { + const res = await fetch(`${API_BASE}/info`); + if (!res.ok) throw new Error('Failed to fetch info'); + state.info = await res.json(); +} + +async function fetchContainers() { + const res = await fetch(`${API_BASE}/containers`); + if (!res.ok) throw new Error('Failed to fetch containers'); + state.containers = (await res.json()) || []; +} + +async function fetchImages() { + const res = await fetch(`${API_BASE}/images`); + if (!res.ok) throw new Error('Failed to fetch images'); + state.images = (await res.json()) || []; +} + +function updateStatusIndicator() { + const indicator = document.querySelector('.status-indicator'); + const statusText = document.querySelector('.status-text'); + if (state.connected) { + indicator.classList.add('connected'); + statusText.textContent = 'Connected'; + statusText.classList.add('connected'); + statusText.classList.remove('disconnected'); + } else { + indicator.classList.remove('connected'); + statusText.textContent = 'Disconnected'; + statusText.classList.add('disconnected'); + statusText.classList.remove('connected'); + } +} + +function renderTabs() { + document.querySelectorAll('.tab-content').forEach(tab => { + tab.classList.remove('active'); + }); + document.getElementById(state.tab).classList.add('active'); + + if (state.tab === 'dashboard') { + renderDashboard(); + } else if (state.tab === 'containers') { + renderContainers(); + } else if (state.tab === 'images') { + renderImages(); + } +} + +function renderDashboard() { + const running = state.containers.filter(c => c.State === 'running').length; + const stopped = state.containers.filter(c => c.State === 'exited').length; + const total = state.containers.length; + + document.getElementById('containers-total-count').textContent = total; + document.getElementById('containers-running-count').textContent = running; + document.getElementById('containers-stopped-count').textContent = stopped; + document.getElementById('images-count').textContent = state.images.length; + + document.getElementById('docker-version').textContent = state.info?.ServerVersion || '-'; + document.getElementById('docker-os').textContent = + `${state.info?.Os || '-'} / ${state.info?.Architecture || '-'}`; + document.getElementById('docker-cpus').textContent = + state.info?.NCPU ? `${state.info.NCPU}` : '-'; + document.getElementById('docker-memory').textContent = + state.info?.MemTotal ? formatBytes(state.info.MemTotal) : '-'; + document.getElementById('docker-kernel').textContent = + state.info?.KernelVersion || '-'; + document.getElementById('docker-server').textContent = + state.info?.ServerVersion || '-'; +} + +function renderContainers() { + const container = document.getElementById('containers-list'); + + if (!state.containers.length) { + container.innerHTML = '
📦

No containers found

'; + return; + } + + const html = ` + + + + + + + + + + + + + ${state.containers.map(c => { + const name = (c.Names || []).map(n => n.replace(/^\//,'')).join(', ') || c.Id.slice(0, 12); + const image = c.Image || '-'; + const status = getStatusBadge(c.State); + const ports = getPortsHtml(c.Ports); + const created = formatRelativeTime(c.Created); + const actions = getContainerActions(c); + + return ` + + + + + + + + + `; + }).join('')} + +
NameImageStatusPortsCreatedActions
${escapeHtml(name)}${escapeHtml(image)}${status}${ports}${created}${actions}
+ `; + + container.innerHTML = html; + attachContainerEventListeners(); +} + +function getPortsHtml(ports) { + if (!ports || !ports.length) return 'No ports'; + const unique = new Map(); + ports.forEach(p => { + const key = `${p.PrivatePort}/${p.Type}`; + if (!unique.has(key)) { + unique.set(key, p.PublicPort || '-'); + } + }); + return Array.from(unique.entries()) + .map(([priv, pub]) => `${pub}→${priv}`) + .join(''); +} + +function getContainerActions(container) { + const isRunning = container.State === 'running'; + return ` +
+ ${isRunning ? ` + + + ` : ` + + `} + + +
+ `; +} + +function attachContainerEventListeners() { + document.querySelectorAll('[data-action]').forEach(btn => { + btn.addEventListener('click', (e) => { + const action = e.target.closest('[data-action]').dataset.action; + const id = e.target.closest('[data-action]').dataset.id; + handleContainerAction(action, id); + }); + }); +} + +function getStatusBadge(state) { + const badges = { + 'running': { class: 'badge-running', label: 'running' }, + 'paused': { class: 'badge-paused', label: 'paused' }, + 'exited': { class: 'badge-exited', label: 'exited' }, + 'created': { class: 'badge-created', label: 'created' }, + }; + const badge = badges[state] || { class: 'badge-exited', label: state }; + return `${badge.label}`; +} + +async function handleContainerAction(action, id) { + try { + if (action === 'logs') { + state.logsContainerId = id; + await fetchAndShowLogs(); + return; + } + + if (action === 'remove') { + showConfirmModal( + 'Remove Container', + 'Are you sure you want to remove this container?', + async () => { + await executeContainerAction(action, id); + } + ); + return; + } + + await executeContainerAction(action, id); + } catch (err) { + showToast(`Error: ${err.message}`, 'error'); + } +} + +async function executeContainerAction(action, id) { + let url = ''; + let method = 'POST'; + + switch (action) { + case 'start': + url = `${API_BASE}/containers/${id}/start`; + break; + case 'stop': + url = `${API_BASE}/containers/${id}/stop`; + break; + case 'restart': + url = `${API_BASE}/containers/${id}/restart`; + break; + case 'remove': + url = `${API_BASE}/containers/${id}`; + method = 'DELETE'; + break; + default: + return; + } + + const res = await fetch(url, { method }); + if (!res.ok) throw new Error(`Failed to ${action} container`); + + showToast(`Container ${action}ed successfully`, 'success'); + await Promise.all([fetchContainers(), fetchInitialData()]); + renderTabs(); +} + +async function fetchAndShowLogs() { + try { + const tail = document.getElementById('logs-tail').value; + const res = await fetch(`${API_BASE}/containers/${state.logsContainerId}/logs?tail=${tail}`); + if (!res.ok) throw new Error('Failed to fetch logs'); + const data = await res.json(); + + let logs = data.logs || 'No logs available'; + logs = stripAnsi(logs); + + document.getElementById('logs-content').textContent = logs; + document.getElementById('logs-modal').style.display = 'flex'; + } catch (err) { + showToast(`Error: ${err.message}`, 'error'); + } +} + +function stripAnsi(text) { + return text.replace(/\x1b\[[0-9;]*m/g, ''); +} + +function refreshLogs() { + if (state.logsContainerId) { + fetchAndShowLogs(); + } +} + +function closeLogsModal() { + document.getElementById('logs-modal').style.display = 'none'; +} + +function renderImages() { + const container = document.getElementById('images-list'); + + if (!state.images.length) { + container.innerHTML = '
🖼

No images found

'; + return; + } + + const rows = []; + state.images.forEach((img) => { + const tags = img.RepoTags || ['untagged']; + const size = formatBytes(img.Size || 0); + const created = formatRelativeTime(img.Created); + + tags.forEach((tag, tagIdx) => { + const isFirstTag = tagIdx === 0; + const actions = isFirstTag ? `` : ''; + + rows.push(` + + ${escapeHtml(tag.split(':')[0] || '-')} + ${escapeHtml(tag.split(':')[1] || '-')} + ${escapeHtml(img.Id.slice(0, 12))} + ${isFirstTag ? size : ''} + ${isFirstTag ? created : ''} + ${actions} + + `); + }); + }); + + const html = ` + + + + + + + + + + + + + ${rows.join('')} + +
RepositoryTagIDSizeCreatedActions
+ `; + + container.innerHTML = html; + attachImageEventListeners(); +} + +function attachImageEventListeners() { + document.querySelectorAll('[data-action="remove-image"]').forEach(btn => { + btn.addEventListener('click', (e) => { + const id = e.target.dataset.id; + showConfirmModal( + 'Remove Image', + 'Are you sure you want to remove this image?', + async () => { + try { + const res = await fetch(`${API_BASE}/images/${id}`, { method: 'DELETE' }); + if (!res.ok) throw new Error('Failed to remove image'); + showToast('Image removed successfully', 'success'); + await fetchImages(); + renderTabs(); + } catch (err) { + showToast(`Error: ${err.message}`, 'error'); + } + } + ); + }); + }); +} + +async function pullImage() { + const input = document.getElementById('pull-input'); + const name = input.value.trim(); + + if (!name) { + showToast('Please enter an image name', 'error'); + return; + } + + const progressDiv = document.getElementById('pull-progress'); + progressDiv.style.display = 'block'; + progressDiv.innerHTML = ''; + const layerStates = {}; + + try { + const res = await fetch(`${API_BASE}/images/pull?name=${encodeURIComponent(name)}`); + if (!res.ok) throw new Error('Failed to pull image'); + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const lines = decoder.decode(value).split('\n').filter(l => l); + for (const line of lines) { + try { + const data = JSON.parse(line); + if (data.id && data.status) { + const layerId = data.id; + if (!layerStates[layerId]) { + layerStates[layerId] = document.createElement('div'); + layerStates[layerId].className = 'pull-progress-item'; + layerStates[layerId].dataset.layerId = layerId; + progressDiv.appendChild(layerStates[layerId]); + } + layerStates[layerId].textContent = `${layerId.slice(0, 12)}: ${data.status}`; + progressDiv.scrollTop = progressDiv.scrollHeight; + } + } catch (e) { + // Not JSON, skip + } + } + } + + showToast('Image pulled successfully', 'success'); + await fetchImages(); + renderTabs(); + input.value = ''; + progressDiv.style.display = 'none'; + } catch (err) { + showToast(`Error: ${err.message}`, 'error'); + progressDiv.style.display = 'none'; + } +} + +function showConfirmModal(title, message, onConfirm) { + window.pendingConfirmAction = onConfirm; + document.getElementById('confirm-title').textContent = title; + document.getElementById('confirm-message').textContent = message; + document.getElementById('confirm-modal').style.display = 'flex'; +} + +function closeConfirmModal() { + window.pendingConfirmAction = null; + document.getElementById('confirm-modal').style.display = 'none'; +} + +function confirmAction() { + if (window.pendingConfirmAction) { + window.pendingConfirmAction(); + } + closeConfirmModal(); +} + +function formatBytes(bytes) { + if (!bytes) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]; +} + +function formatRelativeTime(timestamp) { + if (!timestamp) return '-'; + const date = new Date(timestamp); + const now = new Date(); + const diff = now - date; + + const seconds = Math.floor(diff / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (seconds < 60) return `${seconds}s ago`; + if (minutes < 60) return `${minutes}m ago`; + if (hours < 24) return `${hours}h ago`; + return `${days}d ago`; +} + +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +function showToast(message, type = 'info') { + const toast = document.getElementById('toast'); + toast.textContent = message; + toast.className = `toast show ${type}`; + + setTimeout(() => { + toast.classList.remove('show'); + }, 3000); +} + +init(); diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..9ad3710 --- /dev/null +++ b/static/index.html @@ -0,0 +1,144 @@ + + + + + + Docker Manager + + + + + +
+
+

Dashboard

+ +
+
+
0
+
Total Containers
+
+
+
0
+
Running
+
+
+
0
+
Stopped
+
+
+
0
+
Total Images
+
+
+ +
+

System Info

+
+
+
Docker Version
+
-
+
+
+
OS / Architecture
+
-
+
+
+
CPUs
+
-
+
+
+
Memory
+
-
+
+
+
Kernel Version
+
-
+
+
+
Server Version
+
-
+
+
+
+
+ +
+

Containers

+
+
+ +
+

Images

+
+ + + +
+
+
+
+ + + + + + + + +
+ + + + diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..6f652c3 --- /dev/null +++ b/static/style.css @@ -0,0 +1,742 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +:root { + --bg: #0d1117; + --surface: #161b22; + --card: #21262d; + --border: #30363d; + --text: #c9d1d9; + --text-muted: #8b949e; + --accent: #58a6ff; + --success: #3fb950; + --danger: #f85149; + --warning: #d29922; +} + +body { + background-color: var(--bg); + color: var(--text); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; + line-height: 1.5; +} + +/* Navbar */ +.navbar { + position: fixed; + top: 0; + left: 0; + right: 0; + height: 60px; + background-color: var(--surface); + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 20px; + z-index: 1000; + gap: 20px; +} + +.navbar-left { + display: flex; + align-items: center; + gap: 12px; + flex-shrink: 0; +} + +.docker-logo { + width: 32px; + height: 32px; + color: #2496ed; + flex-shrink: 0; +} + +.navbar-left h1 { + font-size: 18px; + font-weight: 600; + color: var(--text); +} + +.navbar-center { + display: flex; + gap: 0; + flex: 1; + justify-content: flex-start; + margin-left: 20px; +} + +.tab-btn { + background: none; + border: none; + color: var(--text-muted); + padding: 20px 16px; + cursor: pointer; + font-size: 14px; + font-weight: 500; + border-bottom: 2px solid transparent; + transition: all 0.2s; +} + +.tab-btn:hover { + color: var(--text); +} + +.tab-btn.active { + color: var(--accent); + border-bottom-color: var(--accent); +} + +.navbar-right { + display: flex; + align-items: center; + gap: 12px; + flex-shrink: 0; +} + +.status-container { + display: flex; + align-items: center; + gap: 8px; +} + +.status-indicator { + width: 12px; + height: 12px; + border-radius: 50%; + background-color: var(--danger); + animation: pulse 2s infinite; + flex-shrink: 0; +} + +.status-indicator.connected { + background-color: var(--success); +} + +.status-text { + font-size: 13px; + color: var(--text-muted); + font-weight: 500; +} + +.status-text.connected { + color: var(--success); +} + +.status-text.disconnected { + color: var(--danger); +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +/* App Container */ +.app-container { + margin-top: 60px; + padding: 30px 20px; + max-width: 1440px; + margin-left: auto; + margin-right: auto; +} + +.app-container h2 { + font-size: 24px; + font-weight: 600; + margin-bottom: 20px; + color: var(--text); +} + +.app-container h3 { + font-size: 18px; + font-weight: 600; + margin-bottom: 16px; + margin-top: 24px; + color: var(--text); +} + +.app-container h3:first-child { + margin-top: 0; +} + +.system-info-card { + background-color: var(--card); + border: 1px solid var(--border); + border-radius: 8px; + padding: 20px; + margin-top: 24px; +} + +.system-info-card h3 { + margin-top: 0; + margin-bottom: 16px; +} + + +/* Tab Content */ +.tab-content { + display: none; +} + +.tab-content.active { + display: block; +} + +/* Dashboard */ +.dashboard-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 16px; + margin-bottom: 30px; +} + +.status-grid { + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); +} + +.system-info-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 20px; +} + +.info-item { + display: flex; + flex-direction: column; +} + +.info-label { + font-size: 12px; + color: var(--text-muted); + margin-bottom: 6px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.info-value { + font-size: 16px; + font-weight: 600; + color: var(--text); + word-break: break-word; +} + +.status-card { + background: linear-gradient(135deg, var(--card) 0%, #262c36 100%); + border: 1px solid var(--border); + border-radius: 8px; + padding: 20px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + cursor: pointer; + transition: all 0.2s; +} + +.status-card:hover { + transform: translateY(-2px); + border-color: var(--accent); + box-shadow: 0 4px 12px rgba(88, 166, 255, 0.1); +} + +.status-count { + font-size: 32px; + font-weight: 700; + color: var(--text); + line-height: 1; +} + +.status-label { + font-size: 12px; + color: var(--text-muted); + margin-top: 8px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.status-running .status-count { + color: var(--success); +} + +.status-stopped .status-count { + color: var(--danger); +} + +.status-total .status-count { + color: var(--accent); +} + +.status-images .status-count { + color: var(--accent); +} + +/* Tables */ +.table-container { + overflow-x: auto; +} + +.data-table { + width: 100%; + border-collapse: collapse; + border: 1px solid var(--border); + border-radius: 8px; + background-color: var(--card); + overflow: hidden; +} + +.data-table thead { + background-color: var(--surface); + border-bottom: 1px solid var(--border); +} + +.data-table th { + padding: 12px 16px; + text-align: left; + font-weight: 600; + font-size: 12px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.data-table td { + padding: 12px 16px; + border-bottom: 1px solid var(--border); + font-size: 14px; +} + +.data-table tbody tr:hover { + background-color: #262c36; +} + +.data-table tbody tr:last-child td { + border-bottom: none; +} + +/* Badges */ +.badge { + display: inline-block; + padding: 4px 8px; + border-radius: 4px; + font-size: 11px; + font-weight: 600; + white-space: nowrap; +} + +.badge-running { + background-color: rgba(63, 185, 80, 0.2); + color: var(--success); +} + +.badge-exited { + background-color: rgba(248, 81, 73, 0.2); + color: var(--danger); +} + +.badge-paused { + background-color: rgba(210, 153, 34, 0.2); + color: var(--warning); +} + +.badge-created { + background-color: rgba(88, 166, 255, 0.2); + color: var(--accent); +} + +/* Port Chips */ +.port-chip { + display: inline-block; + background-color: rgba(88, 166, 255, 0.2); + color: var(--accent); + padding: 4px 8px; + border-radius: 4px; + font-size: 12px; + margin-right: 4px; + font-family: 'Monaco', 'Courier New', monospace; +} + +/* Action Buttons */ +.action-buttons { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.btn { + padding: 6px 12px; + border: 1px solid var(--border); + background-color: var(--surface); + color: var(--text); + border-radius: 6px; + cursor: pointer; + font-size: 12px; + font-weight: 500; + transition: all 0.2s; + display: inline-flex; + align-items: center; + gap: 4px; +} + +.btn:hover { + background-color: var(--card); + border-color: var(--accent); + color: var(--accent); +} + +.btn-sm { + padding: 4px 8px; + font-size: 11px; +} + +.btn-small { + padding: 4px 8px; + font-size: 11px; +} + +.btn-primary { + background-color: var(--accent); + color: var(--bg); + border-color: var(--accent); +} + +.btn-primary:hover { + background-color: #79c0ff; + border-color: #79c0ff; +} + +.btn-danger { + color: var(--danger); + border-color: var(--danger); +} + +.btn-danger:hover { + background-color: rgba(248, 81, 73, 0.1); +} + +.btn-success { + color: var(--success); + border-color: var(--success); +} + +.btn-success:hover { + background-color: rgba(63, 185, 80, 0.1); +} + +.btn-warning { + color: var(--warning); + border-color: var(--warning); +} + +.btn-warning:hover { + background-color: rgba(210, 153, 34, 0.1); +} + +/* Empty State */ +.empty-state { + text-align: center; + padding: 60px 20px; + color: var(--text-muted); +} + +.empty-icon { + font-size: 48px; + margin-bottom: 16px; + opacity: 0.5; +} + +.empty-state p { + font-size: 16px; + margin: 0; +} + +.text-muted { + color: var(--text-muted); +} + +/* Pull Section */ +.pull-section { + margin-bottom: 24px; + display: flex; + gap: 8px; +} + +.pull-input { + flex: 1; + padding: 8px 12px; + background-color: var(--surface); + border: 1px solid var(--border); + color: var(--text); + border-radius: 6px; + font-size: 14px; +} + +.pull-input::placeholder { + color: var(--text-muted); +} + +.pull-input:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 2px rgba(88, 166, 255, 0.2); +} + +.pull-progress { + padding: 12px; + background-color: var(--card); + border: 1px solid var(--border); + border-radius: 6px; + font-size: 12px; + color: var(--text-muted); + max-height: 200px; + overflow-y: auto; +} + +.pull-progress-item { + padding: 4px 0; + border-bottom: 1px solid var(--border); + font-family: 'Monaco', 'Courier New', monospace; +} + +.pull-progress-item:last-child { + border-bottom: none; +} + +/* Modal */ +.modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.8); + display: flex; + align-items: center; + justify-content: center; + z-index: 2000; +} + +.modal-content { + background-color: var(--card); + border: 1px solid var(--border); + border-radius: 8px; + display: flex; + flex-direction: column; + max-height: 80vh; + overflow: hidden; +} + +.logs-modal-content { + width: 90%; + max-width: 960px; +} + +.confirm-modal-content { + width: 90%; + max-width: 360px; +} + +.modal-header { + padding: 16px; + border-bottom: 1px solid var(--border); + display: flex; + justify-content: space-between; + align-items: center; +} + +.modal-header h3 { + font-size: 16px; + color: var(--text); + margin: 0; + flex: 1; +} + +.logs-controls { + display: flex; + gap: 8px; + align-items: center; +} + +.logs-tail-select { + padding: 6px 8px; + background-color: var(--surface); + border: 1px solid var(--border); + color: var(--text); + border-radius: 4px; + font-size: 12px; + cursor: pointer; +} + +.logs-tail-select:focus { + outline: none; + border-color: var(--accent); +} + +.modal-close { + background: none; + border: none; + color: var(--text-muted); + font-size: 24px; + cursor: pointer; + padding: 0; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.modal-close:hover { + color: var(--text); +} + +.modal-body { + flex: 1; + overflow-y: auto; + padding: 16px; +} + +.logs-modal-body { + background-color: #010409; + padding: 0; +} + +.logs-content { + background-color: #010409; + color: var(--text); + font-family: 'Monaco', 'Courier New', monospace; + font-size: 12px; + line-height: 1.4; + white-space: pre-wrap; + word-wrap: break-word; + margin: 0; + padding: 16px; +} + +.confirm-modal-body { + padding: 20px; + min-height: 100px; +} + +.confirm-modal-body p { + margin: 0; + color: var(--text); + font-size: 14px; + line-height: 1.6; +} + +.modal-footer { + padding: 16px; + border-top: 1px solid var(--border); + display: flex; + justify-content: flex-end; + gap: 8px; +} + +/* Toast */ +.toast { + position: fixed; + bottom: 20px; + right: 20px; + background-color: var(--card); + color: var(--text); + padding: 12px 16px; + border-radius: 6px; + border: 1px solid var(--border); + max-width: 300px; + word-wrap: break-word; + opacity: 0; + pointer-events: none; + transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); + z-index: 1500; + transform: translateY(120px); +} + +.toast.show { + opacity: 1; + pointer-events: auto; + transform: translateY(0); +} + +.toast.success { + border-color: var(--success); + color: var(--success); +} + +.toast.error { + border-color: var(--danger); + color: var(--danger); +} + +.toast.info { + border-color: var(--accent); + color: var(--accent); +} + +/* Responsive */ +@media (max-width: 768px) { + .navbar { + flex-wrap: wrap; + height: auto; + padding: 10px; + } + + .navbar-left h1 { + display: none; + } + + .navbar-center { + width: 100%; + order: 3; + justify-content: flex-start; + margin-top: 10px; + } + + .tab-btn { + padding: 12px 12px; + font-size: 12px; + } + + .app-container { + margin-top: 120px; + padding: 20px 10px; + } + + .dashboard-grid, + .status-grid, + .system-info-grid { + grid-template-columns: repeat(2, 1fr); + } + + .data-table { + font-size: 12px; + } + + .data-table th, + .data-table td { + padding: 8px 12px; + } + + .action-buttons { + flex-direction: column; + } + + .action-buttons .btn { + width: 100%; + justify-content: center; + } + + .pull-section { + flex-direction: column; + } + + .logs-modal-content, + .confirm-modal-content { + width: 95%; + } +}