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 = '
';
+ return;
+ }
+
+ const html = `
+
+
+
+ | Name |
+ Image |
+ Status |
+ Ports |
+ Created |
+ Actions |
+
+
+
+ ${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 `
+
+ | ${escapeHtml(name)} |
+ ${escapeHtml(image)} |
+ ${status} |
+ ${ports} |
+ ${created} |
+ ${actions} |
+
+ `;
+ }).join('')}
+
+
+ `;
+
+ 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 = '';
+ 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 = `
+
+
+
+ | Repository |
+ Tag |
+ ID |
+ Size |
+ Created |
+ Actions |
+
+
+
+ ${rows.join('')}
+
+
+ `;
+
+ 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
+
+
+
+
+
System Info
+
+
+
+
OS / Architecture
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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%;
+ }
+}