├── .dockerignore ├── .gitignore ├── DPanel.drawio.svg ├── LICENSE ├── README.md ├── compose.dev.yaml ├── compose.prod.yaml ├── fastapi ├── Dockerfile ├── Dockerfile.prod ├── composefiles │ ├── kali-ford-stack.yaml │ ├── minecraft-stack.yaml │ └── wordpress-stack.yaml ├── docker_utils.py ├── helpers.py ├── logger.py ├── main.py ├── requirements.txt └── static │ ├── css │ ├── bootstrap.min.css │ ├── custom.css │ └── styles.css │ ├── index.html │ ├── js │ ├── actions.js │ └── lib.js │ └── media │ ├── docker.png │ └── favicon.png ├── pubsub-go ├── Dockerfile ├── go.mod ├── go.sum └── main.go └── resources ├── advanced-container.png ├── create-container.png ├── cropped ├── advanced-container.png ├── create-container.png ├── main-page.png ├── main-small.png ├── second-page.png └── upload-compose.png ├── main-page.png ├── second-page.png ├── styled ├── advanced-container.png ├── architecture.png ├── create-container.png ├── goroutes.png ├── main-page.png ├── secondary-page.png └── upload-compose.png └── upload-compose.png /.dockerignore: -------------------------------------------------------------------------------- 1 | **/__pycache__ 2 | **/.venv 3 | **/.classpath 4 | **/.dockerignore 5 | **/.env 6 | **/.git 7 | **/.gitignore 8 | **/.project 9 | **/.settings 10 | **/.toolstarget 11 | **/.vs 12 | **/.vscode 13 | **/*.*proj.user 14 | **/*.dbmdl 15 | **/*.jfm 16 | **/bin 17 | **/charts 18 | **/docker-compose* 19 | **/compose* 20 | **/Dockerfile* 21 | **/node_modules 22 | **/npm-debug.log 23 | **/obj 24 | **/secrets.dev.yaml 25 | **/values.dev.yaml 26 | LICENSE 27 | README.md 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Brey Rivera 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DPanel 2 | 3 | DPanel is a web interface leveraging FastAPI, Redis, Go PubSub, and Nginx to manage Docker processes, including containers, images, and volumes, with real-time statistics. 4 | 5 | 6 | ![Main Page](resources/cropped/main-small.png) 7 | 8 | ## Features 9 | 10 | ### Key Uses 11 | 12 | - **Container Management**: Start, stop, kill, restart, pause, resume, and remove containers. 13 | - **Prune Selectively**: Easily prune containers, images, and volumes, depending on your needs. 14 | - **Customize New Containers**: Create and run containers with custom configurations with networks, env variables, and volumes. 15 | - **Image Management**: Pull and remove images all in one window, without commands. 16 | - **Live Statistics**: View real-time statistics for containers, including CPU, memory, and network usage. 17 | - **Upload and Compose**: Upload Docker Compose files and run them with a single click, and save for later use. 18 | 19 | ### Flexibility 20 | 21 | - **Localhost**: Run DPanel on your local machine. 22 | - **Domain Name**: Connect to a LAN accessible server and access it from DNS. 23 | - **Reverse TCP**: Use Cloudflared to tunnel DPanel to a public domain. 24 | 25 | ### Web Interface 26 | 27 |
28 | 29 | 30 |
31 | 32 |
33 | 34 | 35 | 36 |
37 | 38 | 39 | ### Architecture 40 | 41 | - **Solid line**: connections and requests 42 | - **Dashed line**: publish-subscribe paths* 43 | 44 |
45 | 46 | 47 |
48 | 49 | ## Usage 50 | 51 | ### Running Locally 52 | 53 | 1. Navigate to http://localhost:5002 on a browser. 54 | 55 | ### Running over LAN 56 | 57 | 1. Navigate to https://0.0.0.0 on a browser. 58 | 59 | ### Running over Cloudflared 60 | 61 | 1. Navigate to https://dpanel.domain.com on a browser. 62 | 63 | ## Installation 64 | 65 | Copy and run the following compose file: 66 | 67 | ```yaml 68 | version: "3.9" 69 | 70 | services: 71 | pubsub: 72 | image: breyr/dpanel-pubsub-go 73 | volumes: 74 | - /var/run/docker.sock:/var/run/docker.sock 75 | restart: on-failure 76 | depends_on: 77 | - redis 78 | extra_hosts: 79 | - "host.docker.internal:host-gateway" 80 | fastapi: 81 | image: breyr/dpanel-fastapi 82 | ports: 83 | - 5002:5002 84 | volumes: 85 | - /var/run/docker.sock:/var/run/docker.sock 86 | - composefiles:/app/composefiles 87 | restart: on-failure 88 | depends_on: 89 | - redis 90 | extra_hosts: 91 | - "host.docker.internal:host-gateway" 92 | redis: 93 | image: redis:latest 94 | ports: 95 | - 6379:6379 96 | extra_hosts: 97 | - "host.docker.internal:host-gateway" 98 | 99 | volumes: 100 | composefiles: 101 | ``` 102 | -------------------------------------------------------------------------------- /compose.dev.yaml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | 3 | services: 4 | pubsub: 5 | build: 6 | context: ./pubsub-go 7 | volumes: 8 | - /var/run/docker.sock:/var/run/docker.sock 9 | restart: on-failure 10 | depends_on: 11 | - redis 12 | develop: 13 | watch: 14 | - action: rebuild 15 | path: ./pubsub-go/main.go 16 | target: /app 17 | fastapi: 18 | build: 19 | context: ./fastapi 20 | ports: 21 | - 5002:5002 22 | volumes: 23 | - /var/run/docker.sock:/var/run/docker.sock 24 | - ./composefiles:/code/composefiles # where compose files are stored on host machine 25 | restart: on-failure 26 | depends_on: 27 | - redis 28 | develop: 29 | watch: 30 | - action: sync+restart 31 | path: ./fastapi 32 | target: /code 33 | redis: 34 | image: redis:latest 35 | ports: 36 | - 6379:6379 37 | -------------------------------------------------------------------------------- /compose.prod.yaml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | 3 | services: 4 | pubsub: 5 | image: breyr/dpanel-pubsub-go 6 | volumes: 7 | - /var/run/docker.sock:/var/run/docker.sock 8 | restart: on-failure 9 | depends_on: 10 | - redis 11 | extra_hosts: 12 | - "host.docker.internal:host-gateway" # needed for linux 13 | fastapi: 14 | image: breyr/dpanel-fastapi 15 | ports: 16 | - 5002:5002 17 | volumes: 18 | - /var/run/docker.sock:/var/run/docker.sock 19 | - composefiles:/app/composefiles # where compose files are stored on fastapi container 20 | restart: on-failure 21 | depends_on: 22 | - redis 23 | extra_hosts: 24 | - "host.docker.internal:host-gateway" # needed for linux 25 | redis: 26 | image: redis:latest 27 | ports: 28 | - 6379:6379 29 | extra_hosts: 30 | - "host.docker.internal:host-gateway" # needed for linux 31 | 32 | # name volume to hold uploaded compose files 33 | volumes: 34 | composefiles: 35 | -------------------------------------------------------------------------------- /fastapi/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.9 2 | 3 | WORKDIR /app 4 | 5 | COPY ./requirements.txt /code/requirements.txt 6 | 7 | RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt 8 | 9 | RUN python-on-whales download-cli 10 | 11 | # install docker buildx, this step is optional 12 | RUN mkdir -p ~/.docker/cli-plugins/ 13 | RUN wget https://github.com/docker/buildx/releases/download/v0.6.3/buildx-v0.6.3.linux-amd64 -O ~/.docker/cli-plugins/docker-buildx 14 | RUN chmod a+x ~/.docker/cli-plugins/docker-buildx 15 | 16 | # install docker compose, this step is optional 17 | RUN mkdir -p ~/.docker/cli-plugins/ 18 | RUN wget https://github.com/docker/compose/releases/download/v2.0.1/docker-compose-linux-x86_64 -O ~/.docker/cli-plugins/docker-compose 19 | RUN chmod a+x ~/.docker/cli-plugins/docker-compose 20 | 21 | COPY ./main.py /app/ 22 | COPY ./helpers.py /app/ 23 | COPY ./docker_utils.py /app/ 24 | COPY ./logger.py /app/ 25 | COPY ./static /app/static 26 | 27 | CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "5002", "--reload"] -------------------------------------------------------------------------------- /fastapi/Dockerfile.prod: -------------------------------------------------------------------------------- 1 | FROM python:3.9 2 | 3 | WORKDIR /app 4 | 5 | COPY ./requirements.txt /code/requirements.txt 6 | 7 | RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt 8 | 9 | RUN python-on-whales download-cli 10 | 11 | # install docker buildx, this step is optional 12 | RUN mkdir -p ~/.docker/cli-plugins/ 13 | RUN wget https://github.com/docker/buildx/releases/download/v0.6.3/buildx-v0.6.3.linux-amd64 -O ~/.docker/cli-plugins/docker-buildx 14 | RUN chmod a+x ~/.docker/cli-plugins/docker-buildx 15 | 16 | # install docker compose, this step is optional 17 | RUN mkdir -p ~/.docker/cli-plugins/ 18 | RUN wget https://github.com/docker/compose/releases/download/v2.0.1/docker-compose-linux-x86_64 -O ~/.docker/cli-plugins/docker-compose 19 | RUN chmod a+x ~/.docker/cli-plugins/docker-compose 20 | 21 | COPY main.py /app/ 22 | COPY helpers.py /app/ 23 | COPY docker_utils.py /app/ 24 | COPY logger.py /app/ 25 | COPY static /app/static 26 | COPY composefiles /app/composefiles 27 | 28 | CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "5002"] -------------------------------------------------------------------------------- /fastapi/composefiles/kali-ford-stack.yaml: -------------------------------------------------------------------------------- 1 | version: '3.9' 2 | services: 3 | kali-fordv: 4 | image: vitalyford/kali 5 | ports: 6 | - "5908:5908" 7 | volumes: 8 | - kali_data_fordv:/home/kali 9 | container_name: kali-fordv 10 | networks: 11 | - cs262network 12 | privileged: true 13 | deploy: 14 | resources: 15 | limits: 16 | memory: 2048M 17 | volumes: 18 | kali_data_fordv: 19 | networks: 20 | cs262network: -------------------------------------------------------------------------------- /fastapi/composefiles/minecraft-stack.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | minecraft: 3 | image: itzg/minecraft-server 4 | ports: 5 | - "25565:25565" 6 | environment: 7 | EULA: "TRUE" 8 | deploy: 9 | resources: 10 | limits: 11 | memory: 1.5G 12 | volumes: 13 | - "minecraft_data:/data" 14 | volumes: 15 | minecraft_data: -------------------------------------------------------------------------------- /fastapi/composefiles/wordpress-stack.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | db: 3 | # We use a mariadb image which supports both amd64 & arm64 architecture 4 | image: mariadb:10.6.4-focal 5 | # If you really want to use MySQL, uncomment the following line 6 | #image: mysql:8.0.27 7 | command: '--default-authentication-plugin=mysql_native_password' 8 | volumes: 9 | - db_data:/var/lib/mysql 10 | restart: always 11 | environment: 12 | - MYSQL_ROOT_PASSWORD=somewordpress 13 | - MYSQL_DATABASE=wordpress 14 | - MYSQL_USER=wordpress 15 | - MYSQL_PASSWORD=wordpress 16 | expose: 17 | - 3306 18 | - 33060 19 | wordpress: 20 | image: wordpress:latest 21 | ports: 22 | - 80:80 23 | restart: always 24 | environment: 25 | - WORDPRESS_DB_HOST=db 26 | - WORDPRESS_DB_USER=wordpress 27 | - WORDPRESS_DB_PASSWORD=wordpress 28 | - WORDPRESS_DB_NAME=wordpress 29 | volumes: 30 | db_data: 31 | 32 | 33 | -------------------------------------------------------------------------------- /fastapi/docker_utils.py: -------------------------------------------------------------------------------- 1 | # docker_utils holds async and sync clients as well as all actions being preformed 2 | # TODO: add python on whales 3 | 4 | import aiodocker 5 | from aiodocker.exceptions import DockerError 6 | import docker 7 | from aiodocker.docker import DockerContainer 8 | from logger import Logger 9 | 10 | 11 | class DockerManager: 12 | def __init__(self): 13 | self.async_client = aiodocker.Docker() 14 | self.sync_client = docker.from_env() 15 | self.images_interface = aiodocker.docker.DockerImages(self.async_client) 16 | self.logger = Logger(__name__) 17 | 18 | async def get_container_details(self, container: DockerContainer): 19 | return await container.show() 20 | 21 | async def run_container(self, config): 22 | # attempt to create and run a container based on the config 23 | try: 24 | # self.logger.info(f"Attempting to create and run new container") 25 | container = self.sync_client.containers.run( 26 | image=config["image"], 27 | name=config.get("containerName"), 28 | volumes=config.get("volumes"), 29 | ports=config.get("ports"), 30 | environment=config.get("environment"), 31 | detach=True, 32 | ) 33 | # self.logger.info(f"Created and ran new container: {container}") 34 | except DockerError as e: 35 | return {"type": "error", "statusCode": e.status, "message": e.message} 36 | return {"type": "success", "objectId": container.id} 37 | 38 | async def pause_container(self, container: DockerContainer): 39 | container_details = await self.get_container_details(container) 40 | if container_details["State"]["Running"]: 41 | await container.pause() 42 | return {"type": "success", "objectId": container_details["Id"]} 43 | # already paused 44 | return {"type": "error", "objectId": container_details["Id"]} 45 | 46 | async def resume_container(self, container: DockerContainer): 47 | container_details = await self.get_container_details(container) 48 | if container_details["State"]["Paused"]: 49 | await container.unpause() 50 | return {"type": "success", "objectId": container_details["Id"]} 51 | # already in a running state 52 | return {"type": "error", "objectId": container_details["Id"]} 53 | 54 | async def start_container(self, container: DockerContainer): 55 | container_details = await self.get_container_details(container) 56 | if container_details["State"]["Status"] == "exited": 57 | await container.start() 58 | return {"type": "success", "objectId": container.id} 59 | # already in a running state 60 | return {"type": "error", "objectId": container.id} 61 | 62 | async def stop_container(self, container: DockerContainer): 63 | container_details = await self.get_container_details(container) 64 | if ( 65 | container_details["State"]["Running"] 66 | or container_details["State"]["Paused"] 67 | ): 68 | await container.stop() 69 | return {"type": "success", "objectId": container_details["Id"]} 70 | # already exited 71 | return {"type": "error", "objectId": container_details["Id"]} 72 | 73 | async def restart_container(self, container: DockerContainer): 74 | # The container must be in started state. This means that the container must be up and running for at least 10 seconds. This shall prevent docker from restarting the container unnecessarily in case it has not even started successfully. 75 | container_details = await self.get_container_details(container) 76 | if ( 77 | container_details["State"]["Running"] 78 | or container_details["State"]["Paused"] 79 | ): 80 | await container.restart() 81 | return {"type": "success", "objectId": container_details["Id"]} 82 | # not running or paused to restart 83 | return {"type": "error", "objectId": container_details["Id"]} 84 | 85 | async def kill_container(self, container: DockerContainer): 86 | # container must be paused or running to be killed 87 | container_details = await self.get_container_details(container) 88 | if ( 89 | container_details["State"]["Running"] 90 | or container_details["State"]["Paused"] 91 | ): 92 | await container.kill() 93 | return {"type": "success", "objectId": container_details["Id"]} 94 | # not running or paused to be killed 95 | return {"type": "error", "objectId": container_details["Id"]} 96 | 97 | async def delete_container(self, container: DockerContainer): 98 | # delete shouldn't go off of status because it wouldn't exist! 99 | try: 100 | # self.logger.info(f"Attempting to delete container: {container}") 101 | # stop running container before deletion 102 | container_details = await self.get_container_details(container) 103 | if ( 104 | container_details["State"]["Running"] 105 | or container_details["State"]["Paused"] 106 | ): 107 | if container_details["State"]["Status"] != "exited": 108 | self.logger.info( 109 | f"Attempting to stop running container: {container}" 110 | ) 111 | await container.stop() 112 | self.logger.info(f"Stopped running container: {container}") 113 | await container.delete() 114 | # self.logger.info(f"Deleted container: {container}") 115 | except DockerError as e: 116 | # already deleted 117 | return {"type": "error", "objectId": container_details["Id"]} 118 | return {"type": "success", "objectId": container_details["Id"]} 119 | 120 | async def delete_image(self, id: str): 121 | # delete any images forcefully 122 | try: 123 | await self.images_interface.delete(name=id) 124 | except DockerError as e: 125 | # already deleted 126 | return {"type": "error", "objectId": id} 127 | return {"type": "success", "objectId": id} 128 | 129 | async def pull_image(self, from_image: str, tag: str): 130 | # pull the image 131 | try: 132 | # if tag is an empty string, use latest 133 | tag = tag if tag else "latest" 134 | res = await self.images_interface.pull(from_image=from_image, tag=tag) 135 | # self.logger.info(res) 136 | except DockerError as e: 137 | return {"type": "error", "statusCode": e.status, "message": e.message} 138 | return {"type": "success", "message": res[-1]} 139 | -------------------------------------------------------------------------------- /fastapi/helpers.py: -------------------------------------------------------------------------------- 1 | import json 2 | from aioredis import Redis 3 | from fastapi import Request 4 | import time 5 | import enum 6 | 7 | 8 | class ObjectType(enum.Enum): 9 | CONTAINER = "container" 10 | IMAGE = "image" 11 | VOLUME = "volume" 12 | 13 | 14 | def convert_from_bytes(bytes) -> str: 15 | size_in_mb = bytes / 1048576 16 | size_in_gb = size_in_mb / 1024 17 | return ( 18 | f"{round(size_in_mb, 2)} MB" if size_in_gb < 1 else f"{round(size_in_mb, 2)} GB" 19 | ) 20 | 21 | 22 | async def subscribe_to_channel(req: Request, chan: str, redis: Redis): 23 | pubsub = redis.pubsub() 24 | # will fail if you pass a channel that doesn't exist 25 | await pubsub.subscribe(chan) 26 | async for message in pubsub.listen(): 27 | if await req.is_disconnected(): 28 | await pubsub.close() 29 | break 30 | if message["type"] == "message" and message["data"] != 1: 31 | yield f"{message['data'].decode('utf-8')}\n\n" 32 | 33 | 34 | async def publish_message_data(message: str, category: str, redis: Redis): 35 | # gets consumed by frontend via toasts 36 | # need to pass alert text, category (error, success), and time when the message is posted 37 | await redis.publish( 38 | "server_messages", 39 | json.dumps({"text": message, "category": category, "timeSent": time.time()}), 40 | ) 41 | -------------------------------------------------------------------------------- /fastapi/logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | 4 | 5 | class Logger: 6 | def __init__(self, name): 7 | self.logger = logging.getLogger(name) 8 | self.logger.setLevel(logging.INFO) 9 | handler = logging.StreamHandler(sys.stdout) # Output to console 10 | formatter = logging.Formatter( 11 | "%(asctime)s - %(name)s - %(levelname)s - %(message)s" 12 | ) 13 | handler.setFormatter(formatter) 14 | if not self.logger.handlers: 15 | self.logger.addHandler(handler) 16 | 17 | def info(self, message): 18 | self.logger.info(message) 19 | 20 | def warning(self, message): 21 | self.logger.warning(message) 22 | 23 | def error(self, message): 24 | self.logger.error(message) 25 | 26 | def debug(self, message): 27 | self.logger.debug(message) 28 | -------------------------------------------------------------------------------- /fastapi/main.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI, Request 2 | from sse_starlette import EventSourceResponse 3 | from fastapi.requests import Request 4 | from fastapi.staticfiles import StaticFiles 5 | from fastapi.responses import JSONResponse, FileResponse 6 | from fastapi.middleware.cors import CORSMiddleware 7 | import aioredis 8 | from aioredis import Redis 9 | from aiodocker.exceptions import DockerError 10 | from typing import Callable, List 11 | from python_on_whales import DockerClient, DockerException 12 | import asyncio 13 | import aiofiles 14 | import os 15 | import json 16 | from helpers import ( 17 | convert_from_bytes, 18 | subscribe_to_channel, 19 | publish_message_data, 20 | ObjectType, 21 | ) 22 | from logger import Logger 23 | from docker_utils import DockerManager 24 | 25 | # Define global variables 26 | redis: Redis = None 27 | logger = Logger(__name__) 28 | docker_manager = DockerManager() 29 | 30 | app = FastAPI() 31 | app.mount("/static", StaticFiles(directory="static"), name="static") 32 | 33 | app.add_middleware( 34 | CORSMiddleware, 35 | allow_origins=["*"], # Allows all origins 36 | allow_credentials=True, 37 | allow_methods=["*"], # Allows all methods 38 | allow_headers=["*"], # Allows all headers 39 | ) 40 | 41 | 42 | # Function to setup aioredis 43 | async def setup_redis(): 44 | global redis 45 | redis = await aioredis.from_url("redis://redis") 46 | 47 | 48 | # Function to close the Redis connection pool 49 | async def close_redis(): 50 | global redis 51 | if redis: 52 | await redis.close() 53 | await redis.wait_closed() 54 | 55 | 56 | # Register startup event handler 57 | app.add_event_handler("startup", setup_redis) 58 | # Register shutdown event handler 59 | app.add_event_handler("shutdown", close_redis) 60 | 61 | 62 | # ======== HELPERS ========= 63 | 64 | 65 | async def perform_action( 66 | req: Request, 67 | action: Callable, 68 | object_type: ObjectType, 69 | success_msg: str, 70 | error_msg: str, 71 | ): 72 | try: 73 | data = await req.json() 74 | ids = data.get("ids", []) 75 | from_image = data.get("image", "") 76 | tag = data.get("tag", "") 77 | config = data.get("config", "") 78 | error_ids_msgs = [] 79 | success_ids_msgs = [] 80 | logger.info( 81 | f"\nRequest Data: {data}\nIds: {ids}\nImage to pull: {from_image}\nImage tag: {tag}\n" 82 | ) 83 | 84 | async def perform_action_and_handle_error( 85 | id: str, publish_image: str = None, tag: str = None 86 | ): 87 | if object_type == ObjectType.CONTAINER: 88 | # if create a new container, have to pass config from request 89 | if config: 90 | res = await action(config) 91 | # logger.info(f"Result from creating new container") 92 | else: 93 | container = await docker_manager.async_client.containers.get(id) 94 | res = await action(container) 95 | elif object_type == ObjectType.IMAGE: 96 | # for pulling an image 97 | if publish_image: 98 | res = await action(publish_image, tag) 99 | else: 100 | # for deleting an image 101 | res = await action(id) 102 | return res 103 | 104 | tasks = [] 105 | # create list of tasks and use asyncio.gather to run them concurrently 106 | if ids: 107 | tasks.extend([perform_action_and_handle_error(id) for id in ids]) 108 | if config: 109 | tasks.append(perform_action_and_handle_error(None)) 110 | if from_image: 111 | tasks.append(perform_action_and_handle_error(None, from_image, tag)) 112 | 113 | # the * is list unpacking 114 | results = await asyncio.gather(*tasks) 115 | 116 | for res in results: 117 | if res["type"] == "error": 118 | if "statusCode" in res: 119 | # get message from DockerError 120 | error_ids_msgs.append(res["message"]) 121 | else: 122 | # get short id of object's full id 123 | error_ids_msgs.append(res["objectId"][:12]) 124 | elif res["type"] == "success": 125 | if "message" in res and "status" in res["message"]: 126 | # append result from pulling an image 127 | success_ids_msgs.append(res["message"]["status"]) 128 | else: 129 | success_ids_msgs.append(res["objectId"][:12]) 130 | 131 | publish_tasks = [] 132 | if success_ids_msgs: 133 | publish_tasks.append( 134 | publish_message_data( 135 | f"{success_msg} ({len(success_ids_msgs)}): {success_ids_msgs}", 136 | "Success", 137 | redis=redis, 138 | ) 139 | ) 140 | 141 | if error_ids_msgs: 142 | publish_tasks.append( 143 | publish_message_data( 144 | f"{error_msg}: {error_ids_msgs}", "Error", redis=redis 145 | ) 146 | ) 147 | 148 | await asyncio.gather(*publish_tasks) 149 | 150 | if error_ids_msgs: 151 | return JSONResponse( 152 | content={"message": f"{error_msg}: {error_ids_msgs}"}, status_code=400 153 | ) 154 | else: 155 | return JSONResponse(content={"message": f"{success_msg}"}, status_code=200) 156 | except Exception as e: 157 | await publish_message_data( 158 | f"API error, please try again: {e}", "Error", redis=redis 159 | ) 160 | return JSONResponse(content={"message": "API error"}, status_code=400) 161 | 162 | 163 | # ======== ENDPOINTS ========= 164 | 165 | 166 | @app.get("/") 167 | async def read_root(): 168 | return FileResponse("static/index.html") 169 | 170 | 171 | @app.get("/api/streams/composefiles") 172 | async def list_files(): 173 | async def event_stream(): 174 | while True: 175 | files = os.listdir("./composefiles") 176 | files = [os.path.splitext(file)[0] for file in files] 177 | yield f"{json.dumps({'files': files})}\n\n" 178 | await asyncio.sleep(1) 179 | 180 | return EventSourceResponse(event_stream()) 181 | 182 | 183 | @app.get("/api/streams/containerlist") 184 | async def container_list(req: Request): 185 | # passes subscribe_to_channel async generator to consume the messages it yields 186 | return EventSourceResponse(subscribe_to_channel(req, "containers_list", redis)) 187 | 188 | 189 | @app.get("/api/streams/servermessages") 190 | async def server_messages(req: Request): 191 | # passes subscribe_to_channel async generator to consume the messages it yields 192 | return EventSourceResponse(subscribe_to_channel(req, "server_messages", redis)) 193 | 194 | 195 | @app.get("/api/streams/imagelist") 196 | async def image_list(req: Request): 197 | return EventSourceResponse(subscribe_to_channel(req, "images_list", redis)) 198 | 199 | 200 | @app.get("/api/streams/containermetrics") 201 | async def container_stat(req: Request): 202 | return EventSourceResponse(subscribe_to_channel(req, "container_metrics", redis)) 203 | 204 | 205 | @app.get("/api/containers/info/{container_id}") 206 | def info(container_id: str): 207 | # get container information 208 | # this function does not need to be async because get() is not asynchronous 209 | return docker_manager.sync_client.containers.get(container_id=container_id).attrs 210 | 211 | 212 | @app.post("/api/system/prune") 213 | async def prune_system(req: Request): 214 | data = await req.json() 215 | objects_to_prune: List[str] = data["objectsToPrune"] 216 | # logger.info(f"Objects: {objects_to_prune}") 217 | try: 218 | res = {} 219 | for obj in objects_to_prune: 220 | if obj == "containers": 221 | pruned_containers = docker_manager.sync_client.containers.prune() 222 | res["Containers"] = pruned_containers 223 | elif obj == "images": 224 | pruned_images = docker_manager.sync_client.images.prune() 225 | res["Images"] = pruned_images 226 | elif obj == "volumes": 227 | pruned_volumes = docker_manager.sync_client.volumes.prune() 228 | res["Volumes"] = pruned_volumes 229 | elif obj == "networks": 230 | pruned_networks = docker_manager.sync_clientT.networks.prune() 231 | res["Networks"] = pruned_networks 232 | break 233 | 234 | # res['containers'] -> list or None ['ContainersDeleted], ['SpaceReclaimed'] 235 | # res['images'] -> list or None ['ImagesDeleted'], ['SpaceReclaimed'] 236 | # res['volumes'] -> list or None ['VolumesDelete'], ['SpaceReclaimed'] 237 | # res['networks'] -> list or None ['NetworksDeleted'] 238 | async def schedule_messages(object_type: str, res: dict, redis): 239 | # get number of objects deleted 240 | # logger.info(f"Scheduling message for object: {object_type} after pruning") 241 | num_deleted = ( 242 | 0 243 | if res[object_type][f"{object_type}Deleted"] is None 244 | else len(res[object_type][f"{object_type}Deleted"]) 245 | ) 246 | space_reclaimed = ( 247 | 0 248 | if object_type == "Networks" 249 | else convert_from_bytes(res[object_type]["SpaceReclaimed"]) 250 | ) 251 | await publish_message_data( 252 | f"{object_type} pruned successfully: {num_deleted} deleted, {space_reclaimed} space reclaimed", 253 | "Success", 254 | redis=redis, 255 | ) 256 | 257 | # schedules message sending based on keys in result dict from pruning 258 | tasks = [schedule_messages(key, res, redis) for key in res.keys()] 259 | await asyncio.gather(*tasks) 260 | 261 | return JSONResponse( 262 | content={"message": "System prune successfull"}, status_code=200 263 | ) 264 | except DockerError as e: 265 | await publish_message_data("API error, please try again", "Error", redis=redis) 266 | return JSONResponse(content={"message": "API error"}, status_code=400) 267 | 268 | 269 | @app.post("/api/containers/run") 270 | async def run_container(req: Request): 271 | return await perform_action( 272 | req, 273 | docker_manager.run_container, 274 | ObjectType.CONTAINER, 275 | "Container created and running", 276 | "Container failed to create and run", 277 | ) 278 | 279 | 280 | @app.post("/api/containers/start") 281 | async def start_containers(req: Request): 282 | return await perform_action( 283 | req, 284 | docker_manager.start_container, 285 | ObjectType.CONTAINER, 286 | "Containers started", 287 | "Containers already started", 288 | ) 289 | 290 | 291 | @app.post("/api/containers/stop") 292 | async def stop_containers(req: Request): 293 | return await perform_action( 294 | req, 295 | docker_manager.stop_container, 296 | ObjectType.CONTAINER, 297 | "Containers stopped", 298 | "Containers already stopped", 299 | ) 300 | 301 | 302 | @app.post("/api/containers/kill") 303 | async def kill_containers(req: Request): 304 | return await perform_action( 305 | req, 306 | docker_manager.kill_container, 307 | ObjectType.CONTAINER, 308 | "Containers killed", 309 | "Containers already killed", 310 | ) 311 | 312 | 313 | @app.post("/api/containers/restart") 314 | async def restart_containers(req: Request): 315 | return await perform_action( 316 | req, 317 | docker_manager.restart_container, 318 | ObjectType.CONTAINER, 319 | "Containers restarted", 320 | "Containers already restarted", 321 | ) 322 | 323 | 324 | @app.post("/api/containers/pause") 325 | async def pause_containers(req: Request): 326 | return await perform_action( 327 | req, 328 | docker_manager.pause_container, 329 | ObjectType.CONTAINER, 330 | "Containers paused", 331 | "Containers already paused", 332 | ) 333 | 334 | 335 | @app.post("/api/containers/resume") 336 | async def resume_containers(req: Request): 337 | return await perform_action( 338 | req, 339 | docker_manager.resume_container, 340 | ObjectType.CONTAINER, 341 | "Containers resumed", 342 | "Containers already resumed", 343 | ) 344 | 345 | 346 | @app.post("/api/containers/delete") 347 | async def delete_containers(req: Request): 348 | return await perform_action( 349 | req, 350 | docker_manager.delete_container, 351 | ObjectType.CONTAINER, 352 | "Containers deleted", 353 | "Containers already deleted", 354 | ) 355 | 356 | 357 | @app.post("/api/images/delete") 358 | async def delete_images(req: Request): 359 | return await perform_action( 360 | req, 361 | docker_manager.delete_image, 362 | ObjectType.IMAGE, 363 | success_msg="Images deleted", 364 | error_msg="Error, check if containers are running", 365 | ) 366 | 367 | 368 | @app.post("/api/images/pull") 369 | async def pull_images(req: Request): 370 | return await perform_action( 371 | req, 372 | docker_manager.pull_image, 373 | ObjectType.IMAGE, 374 | success_msg="Image pulled", 375 | error_msg="Error", 376 | ) 377 | 378 | 379 | @app.post("/api/compose/upload") 380 | async def upload_file(req: Request): 381 | try: 382 | data = await req.json() 383 | project_name = data.get("projectName") 384 | yaml_contents = data.get("yamlContents") 385 | 386 | # Create a new file with the project name as the filename and .yml as the extension 387 | async with aiofiles.open( 388 | f"./composefiles/{project_name}.yaml", "w" 389 | ) as out_file: 390 | await out_file.write(yaml_contents) 391 | 392 | await publish_message_data( 393 | f"Uploaded: {project_name}.yaml", "Success", redis=redis 394 | ) 395 | return JSONResponse( 396 | content={"message": f"Successfully uploaded {project_name}.yaml"}, 397 | status_code=200, 398 | ) 399 | except Exception as e: 400 | await publish_message_data( 401 | f"API error, please try again: {e}", "Error", redis=redis 402 | ) 403 | return JSONResponse( 404 | content={"message": "Error processing uploaded file"}, status_code=400 405 | ) 406 | 407 | 408 | @app.post("/api/compose/delete") 409 | async def delete_compose_file(req: Request): 410 | data = await req.json() 411 | # logger.info(f"File to delete: {data}") 412 | file_to_delete = data.get("projectName") 413 | try: 414 | os.remove(f"./composefiles/{file_to_delete}.yaml") 415 | await publish_message_data( 416 | f"Successfully deleted {file_to_delete}", "Success", redis=redis 417 | ) 418 | return JSONResponse( 419 | content={"message": f"Successfully deleted {file_to_delete}"}, 420 | status_code=200, 421 | ) 422 | except Exception as e: 423 | await publish_message_data( 424 | f"API error, please try again: {e}", "Error", redis=redis 425 | ) 426 | return JSONResponse( 427 | content={"message": f"Error deleting file: {e}"}, status_code=400 428 | ) 429 | 430 | 431 | @app.post("/api/compose/up") 432 | async def run_compose_file(req: Request): 433 | data = await req.json() 434 | # get path of file to run 435 | file_to_run = data.get("projectName") 436 | file_path = f"./composefiles/{file_to_run}.yaml" 437 | # project name is the name of the file 438 | project_name = file_to_run.split(".")[0] 439 | try: 440 | # create docker client 441 | # logger.info("Attempting to compose up") 442 | docker = DockerClient( 443 | compose_files=[file_path], compose_project_name=project_name 444 | ) 445 | docker.compose.up(detach=True) 446 | await publish_message_data( 447 | f"Compose up successful: {file_to_run}", "Success", redis=redis 448 | ) 449 | return JSONResponse( 450 | content={"message": f"Compose up successful: {file_to_run}"}, 451 | status_code=200, 452 | ) 453 | except DockerException as e: 454 | await publish_message_data( 455 | f"API error, please try again: {e}", "Error", redis=redis 456 | ) 457 | return JSONResponse( 458 | content={"message": f"Docker compose error: {e}"}, status_code=400 459 | ) 460 | 461 | 462 | @app.post("/api/compose/down") 463 | async def compose_down(req: Request): 464 | data = await req.json() 465 | # get path of file to run 466 | file_to_run = data.get("projectName") 467 | file_path = f"./composefiles/{file_to_run}.yaml" 468 | # project name is the name of the file 469 | project_name = file_to_run.split(".")[0] 470 | # read the project name from the Docker Compose file 471 | try: 472 | # create docker client 473 | # logger.info("Attempting to compose up") 474 | docker = DockerClient( 475 | compose_files=[file_path], compose_project_name=project_name 476 | ) 477 | docker.compose.down() 478 | await publish_message_data( 479 | f"Compose down successful: {file_to_run}", "Success", redis=redis 480 | ) 481 | return JSONResponse( 482 | content={"message": f"Compose down successful: {file_to_run}"}, 483 | status_code=200, 484 | ) 485 | except DockerException as e: 486 | await publish_message_data( 487 | f"API error, please try again: {e}", "Error", redis=redis 488 | ) 489 | return JSONResponse( 490 | content={"message": f"Docker compose error: {e}"}, status_code=400 491 | ) 492 | -------------------------------------------------------------------------------- /fastapi/requirements.txt: -------------------------------------------------------------------------------- 1 | fastapi>=0.68.0,<0.69.0 2 | pydantic>=1.8.0,<2.0.0 3 | uvicorn>=0.15.0,<0.16.0 4 | aiodocker 5 | aioredis 6 | sse_starlette 7 | docker 8 | python-multipart 9 | aiofiles 10 | python-on-whales -------------------------------------------------------------------------------- /fastapi/static/css/custom.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | overflow-x: hidden; 4 | font-family: "Open Sans",sans-serif; 5 | } 6 | 7 | a { 8 | text-decoration: none; 9 | } 10 | 11 | .opacity-full { 12 | opacity: 1; 13 | } 14 | 15 | .transparent-btn { 16 | background-color: transparent; 17 | border: none; 18 | } 19 | 20 | input:hover { 21 | cursor: pointer; 22 | } 23 | 24 | .btn-primary { 25 | background-color: rgb(11, 111, 253, 0.85); 26 | } 27 | 28 | .btn-danger { 29 | background-color: rgb(219, 53, 70, 0.85); 30 | } 31 | 32 | .btn-success { 33 | background-color: rgb(26, 135, 85, 0.85); 34 | } 35 | 36 | .w-20 { 37 | width: 20% !important; 38 | } -------------------------------------------------------------------------------- /fastapi/static/css/styles.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: rgb(245, 245, 245); 3 | } 4 | 5 | #app { 6 | padding: 28px !important; 7 | } 8 | 9 | #app #page-links > a { 10 | color: #365aa2; 11 | text-decoration: none; 12 | } 13 | 14 | th { 15 | background-color: transparent !important; 16 | color: rgb(50, 50, 50) !important; 17 | } 18 | td { 19 | background-color: transparent !important; 20 | color: rgb(70, 70, 70) !important; 21 | } 22 | 23 | .button-box { 24 | margin-bottom: 10px !important; 25 | } 26 | 27 | .compose-file { 28 | min-width: 200px; 29 | padding-left: 10px; 30 | padding-right: 10px; 31 | position: relative; 32 | background-color: #dcdcdc; 33 | border-radius: 10px; 34 | box-shadow: 0 4px 8px 0 rgba(0,0,0,0.2); 35 | padding: 20px; 36 | margin: 10px; 37 | text-align: center; 38 | } 39 | 40 | .hover-div { 41 | position: absolute; 42 | top: 0; 43 | left: 0; 44 | width: 100%; 45 | height: 100%; 46 | visibility: hidden; 47 | background-color: rgba(0, 0, 0, 0.7); 48 | color: white; 49 | padding: 10px; 50 | border-radius: 10px; 51 | } 52 | 53 | .compose-file:hover .hover-div { 54 | visibility: visible; 55 | } 56 | 57 | .compose-button { 58 | margin-bottom: 0px !important; 59 | } -------------------------------------------------------------------------------- /fastapi/static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | DPanel - Dashboard 9 | 10 | 11 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 |
25 |

26 | DPanel 27 | DPanel logo 28 |

29 | 35 | 36 | 37 |
39 |
41 |
42 | 43 | Containers 44 |
45 | 46 |
47 |
48 |
49 | 50 |
51 |
52 |
53 | 56 | 59 | 62 | 65 | 68 | 71 | 74 |
75 |
76 | 84 | 87 |
88 | 89 | 90 | 257 | 258 | 259 | 321 | 322 |
323 |
324 | 325 | 326 | 328 | 329 | 330 | 331 | 332 | 333 | 334 | 335 | 336 | 337 | 338 |
327 | NameShort IDStatusUptimeImagePort BindingActions
339 | 345 |
346 |
347 |
348 |
349 |
350 | 351 | 352 |
353 | 361 |
362 | 363 |
364 | 365 | 366 | 367 | 368 | 369 | 370 | 371 | 372 | 373 | 374 |
NameCPU %Memory UsageMemory LimitMemory %
375 | 380 |
381 |
382 |
383 | 384 | 385 |
386 |
387 | 388 | 389 |
390 |
391 | 399 |
400 |
401 |
402 | 407 | 409 |
410 |
411 | 412 | 413 | 438 | 439 | 440 |
441 | 442 | 443 | 446 | 447 | 448 | 449 | 450 | 451 | 452 | 453 | 454 |
445 | NameTagDateSizeRunning
455 | 461 |
462 |
463 |
464 |
465 | 466 | 467 |
468 |
469 | 477 |
478 | 479 |
480 | 485 |
486 |
487 |
488 |
489 |
490 |
491 | 492 | 493 | 530 | 531 |
532 |
533 | 534 | 535 |
536 |
537 | 538 | 539 | 542 | 543 | 545 | 546 | 547 | 548 | 549 | 550 | -------------------------------------------------------------------------------- /fastapi/static/js/actions.js: -------------------------------------------------------------------------------- 1 | function getUrl() { 2 | let url; 3 | if (window.location.hostname === 'localhost') { 4 | url = `http://${window.location.hostname}:${window.location.port}` 5 | } else { 6 | url = `https://${window.location.hostname}` 7 | } 8 | return url; 9 | } 10 | 11 | function getInfo(containerId) { 12 | $.ajax({ 13 | url: `${getUrl()}/api/containers/info/${containerId}`, 14 | type: 'GET', 15 | success: function (data) { 16 | // Create a new Blob from the JSON string 17 | const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); 18 | // Create an object URL from the Blob 19 | const url = URL.createObjectURL(blob); 20 | // Open the URL in a new tab 21 | window.open(url, '_blank'); 22 | } 23 | }); 24 | } 25 | 26 | function getStatusClass(status) { 27 | switch (status) { 28 | case "running": return "success"; 29 | case "paused": return "warning"; 30 | case "exited": return "danger"; 31 | default: return "secondary"; 32 | } 33 | } 34 | 35 | function getPortBindings(portBindings) { 36 | var result = ""; 37 | $.each(portBindings, function (i, port) { 38 | if (port.PublicPort && port.IP === "0.0.0.0") { 39 | if (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1") { 40 | var link = "http://" + window.location.hostname + ":" + port.PublicPort; 41 | result += `${port.PublicPort}:${port.PrivatePort} `; 42 | } else { 43 | result += port.PublicPort + ':' + port.PrivatePort + "
"; 44 | } 45 | } 46 | }); 47 | return result; 48 | } 49 | 50 | function toggleSpinnerAndButtonRow(objectType, actionBtnId, showCheckbox = true) { 51 | // objectType is container, image, or volume 52 | const checkboxes = $(`.tr-${objectType}-checkbox:checked`); 53 | checkboxes.each(function () { 54 | const row = $(this).closest('tr'); 55 | row.find('.spinner-border').toggleClass('d-none'); 56 | if (showCheckbox) { 57 | $(this).show(); 58 | $(this).prop('checked', false); 59 | } 60 | }); 61 | $('#' + actionBtnId).prop('disabled', false); 62 | } 63 | 64 | function performActionContainer(action, actionBtnId) { 65 | const checkedIds = $('.tr-container-checkbox:checked').map(function () { 66 | return this.value; 67 | }).get(); 68 | // hide checkboxes 69 | $('.tr-container-checkbox:checked').css('display', 'none'); 70 | // disable clicked action button 71 | $('#' + actionBtnId).prop('disabled', true); 72 | 73 | toggleSpinnerAndButtonRow('container', actionBtnId, false); 74 | 75 | $.ajax({ 76 | url: `${getUrl()}/api/containers/${action}`, 77 | type: 'POST', 78 | contentType: 'application/json', 79 | data: JSON.stringify({ 'ids': checkedIds }), 80 | success: function (result) { 81 | toggleSpinnerAndButtonRow('container', actionBtnId); 82 | }, 83 | error: function (result) { 84 | toggleSpinnerAndButtonRow('container', actionBtnId); 85 | } 86 | }); 87 | } 88 | 89 | function performActionImage(action, actionBtnId) { 90 | 91 | let checkedIds, image, tag; 92 | switch (action) { 93 | case 'delete': 94 | // get checked rows for images 95 | checkedIds = $('.tr-image-checkbox:checked').map(function () { 96 | return this.value; 97 | }).get(); 98 | // hide checkboxes for image rows 99 | $('.tr-image-checkbox:checked').css('display', 'none'); 100 | // disable clicked action button 101 | $('#' + actionBtnId).prop('disabled', true); 102 | // shows spinners for image rows 103 | toggleSpinnerAndButtonRow('image', actionBtnId, false); 104 | $.ajax({ 105 | url: `${getUrl()}/api/images/${action}`, 106 | type: 'POST', 107 | contentType: 'application/json', 108 | data: JSON.stringify({ 'ids': checkedIds }), 109 | success: function (result) { 110 | toggleSpinnerAndButtonRow('image', actionBtnId); 111 | }, 112 | error: function (result) { 113 | toggleSpinnerAndButtonRow('image', actionBtnId); 114 | } 115 | }); 116 | break; 117 | case 'pull': 118 | // get image input 119 | image = $('#image-name').val(); 120 | // show error if image is '' 121 | if (image === '') { 122 | $('#pull-validation').append($(" Pulling ${image}:${tag}

`; 131 | $('#pull-validation').append($alert); 132 | $.ajax({ 133 | url: `${getUrl()}/api/images/${action}`, 134 | type: 'POST', 135 | contentType: 'application/json', 136 | data: JSON.stringify({ 'image': image, 'tag': tag }), 137 | success: function (result) { 138 | // clear inputs after success only 139 | $('#image-name').val(''); 140 | $('#tag').val(''); 141 | $(`#${uniqueId}`).removeClass('text-warning').addClass('text-success'); 142 | $(`#${uniqueId}`).html(` Successfully pulled ${image}:${tag}`); 143 | setTimeout(function () { 144 | $(`#${uniqueId}`).remove(); 145 | }, 10000); // 5000 milliseconds = 5 seconds 146 | }, 147 | error: function (result) { 148 | $(`#${uniqueId}`).removeClass('text-warning').addClass('text-danger'); 149 | $(`#${uniqueId}`).html(` Failed to pull ${image}:${tag} (double check the image name)`); 150 | setTimeout(function () { 151 | $(`#${uniqueId}`).remove(); 152 | }, 10000); // 5000 milliseconds = 5 seconds 153 | } 154 | }); 155 | } 156 | break; 157 | } 158 | } 159 | 160 | function performActionCompose(action, projectName, event) { 161 | // function to handle compose up, compose down, delete (file) 162 | // show the spinner and diabled the button 163 | const clickedButton = event.target; 164 | $(clickedButton).find(".spinner-border").toggleClass('d-none'); 165 | $(clickedButton).addClass('disabled'); 166 | $.ajax({ 167 | url: `${getUrl()}/api/compose/${action}`, 168 | method: "post", 169 | data: JSON.stringify({ 170 | projectName 171 | }), 172 | success: function (res) { 173 | $(clickedButton).find(".spinner-border").toggleClass('d-none'); 174 | $(clickedButton).removeClass('disabled'); 175 | } 176 | }); 177 | } 178 | -------------------------------------------------------------------------------- /fastapi/static/js/lib.js: -------------------------------------------------------------------------------- 1 | // File: Lib.js 2 | 3 | $(document).ready(function () { 4 | 5 | const websiteUrl = getUrl() 6 | 7 | // remove pull validation errors div 8 | $('#pull-validation-errors').empty(); 9 | 10 | // Adds actions to all teh container buttons 11 | ['delete', 'start', 'stop', 'kill', 'restart', 'pause', 'resume'].forEach(action => { 12 | $(`#btn-${action}`).click(function () { 13 | performActionContainer(action, `btn-${action}`); 14 | }); 15 | }); 16 | // add actions to all the image section buttons 17 | ['delete', 'pull'].forEach(action => { 18 | $(`#${action}-img-btn`).click(function () { 19 | performActionImage(action, `${action}-img-btn`); 20 | }); 21 | }); 22 | // add actions to all the compose file buttons 23 | ['down', 'up', 'delete'].forEach(action => { 24 | // pass down the click event, to modify the button 25 | $(document).on('click', `[id^="${action}-compose-btn-"]`, function (event) { 26 | const projectName = this.id.replace(`${action}-compose-btn-`, ''); 27 | performActionCompose(action, projectName, event); 28 | }); 29 | }); 30 | 31 | // table spinners 32 | $("#containers-loading").show(); 33 | $("#images-loading").show(); 34 | $("#stats-loading").show(); 35 | 36 | // new container 37 | // Add a delete button to each row 38 | $("#env-container .row").each(function () { 39 | $(this).append(`
`); 41 | }); 42 | 43 | // Event handler for the delete button 44 | $("#env-container").on('click', '.delete-row', function () { 45 | $(this).closest('.row').remove(); 46 | }); 47 | 48 | // Event handler for the add row button 49 | let rowCounter = 0; 50 | 51 | $("#env-container").on('click', '.add-row', function () { 52 | rowCounter++; 53 | $("#env-container").append(`
`); 55 | }); 56 | 57 | // Get event sources 58 | var containerListSource = null; 59 | function initContainerListES() { 60 | if (containerListSource == null || containerListSource.readyState == 2) { 61 | containerListSource = new EventSource(`${websiteUrl}/api/streams/containerlist`); 62 | containerListSource.onerror = function (event) { 63 | if (containerListSource.readyState == 2) { 64 | // retry connection to ES 65 | setTimeout(initContainerListES, 5000); 66 | } 67 | } 68 | var containersTbody = $("#containers-tbody"); 69 | var previousStateContainers = {}; 70 | var firstLoadContainerList = true; 71 | containerListSource.onmessage = function (event) { 72 | var data = JSON.parse(event.data); 73 | // keep track of containerIds in the incoming data stream 74 | var containerIds = new Set(data.map(container => container.ID)); 75 | // remove any rows with IDs not in the set 76 | containersTbody.find('tr').each(function () { 77 | var tr = $(this); 78 | var id = tr.attr('id').substring(4); // remove 'row-' prefix 79 | if (!containerIds.has(id)) { 80 | tr.remove(); 81 | } 82 | }); 83 | $.each(data, function (i, container) { 84 | var tr = containersTbody.find('#row-' + container.ID); 85 | if (!tr.length) { 86 | // If the row does not exist, create it 87 | tr = $("").attr('id', 'row-' + container.ID); 88 | tr.append($("").html(' ')); 89 | tr.append($("").attr('id', 'name-' + container.ID)); 90 | tr.append($("").attr('id', 'id-' + container.ID)); 91 | tr.append($("").attr('id', 'state-' + container.ID)); 92 | tr.append($("").attr('id', 'status-' + container.ID)); 93 | tr.append($("").attr('id', 'image-' + container.ID)); 94 | tr.append($("").attr('id', 'port-' + container.ID)); 95 | tr.append($("").html('')); 96 | // first load, all rows are new so append in order the data was sent 97 | // if its the first load, append everything 98 | // if its a newly created container, will have to prepend 99 | if (firstLoadContainerList) { 100 | containersTbody.append(tr); 101 | } else { 102 | containersTbody.prepend(tr); 103 | } 104 | } 105 | 106 | // Define the attributes to be updated 107 | const attributes = ['Names', 'ID', 'State', 'Status', 'Image', 'Ports']; 108 | attributes.forEach(attr => { 109 | // If the attribute has changed 110 | if (previousStateContainers[container.ID]?.[attr] !== container[attr]) { 111 | switch (attr) { 112 | case 'Names': 113 | $(`#name-${container.ID}`).text(container.Names[0].substring(1)); 114 | break; 115 | case 'ID': 116 | $(`#id-${container.ID}`).text(container.ID.substring(0, 12)); 117 | break; 118 | case 'State': 119 | $(`#state-${container.ID}`).html(`${container.State}`); 120 | break; 121 | case 'Status': 122 | $(`#status-${container.ID}`).html(`${container.Status}`); 123 | break; 124 | case 'Image': 125 | $(`#image-${container.ID}`).text(container.Image); 126 | break; 127 | case 'Ports': 128 | $(`#port-${container.ID}`).html(getPortBindings(container.Ports)); 129 | break; 130 | } 131 | } 132 | }); 133 | 134 | // Store the current state of the container for the next update 135 | previousStateContainers[container.ID] = container; 136 | }); 137 | if (firstLoadContainerList) { 138 | firstLoadContainerList = false; 139 | } 140 | 141 | $("#containers-loading").hide(); 142 | }; 143 | } 144 | } 145 | initContainerListES(); 146 | 147 | var messagesSource = null; 148 | function initMessageES() { 149 | if (messagesSource == null || messagesSource.readyState == 2) { 150 | messagesSource = new EventSource(`${websiteUrl}/api/streams/servermessages`); 151 | messagesSource.onerror = function (event) { 152 | if (messagesSource.readyState == 2) { 153 | // retry connection to ES 154 | setTimeout(initMessageES, 5000); 155 | } 156 | } 157 | } 158 | messagesSource.onmessage = function (event) { 159 | var data = JSON.parse(event.data); 160 | let icon; 161 | let toastHeaderBgColor; 162 | const uniqueId = 'toast' + Date.now(); 163 | const timeSent = new Date(data.timeSent * 1000); 164 | const now = new Date(); 165 | const diffInMilliseconds = now - timeSent; 166 | const diffInMinutes = Math.floor(diffInMilliseconds / 1000 / 60); 167 | // if data.category is success use success, error uses danger 168 | switch (data.category.toLowerCase()) { 169 | case 'success': 170 | toastHeaderBgColor = 'success'; 171 | icon = ``; 172 | break; 173 | case 'error': 174 | toastHeaderBgColor = 'danger'; 175 | icon = ``; 176 | break; 177 | } 178 | $('#toast-container').append(` 179 | 192 | 193 | `); 194 | // Initialize the toast 195 | $('#' + uniqueId).toast('show'); 196 | }; 197 | } 198 | initMessageES(); 199 | 200 | var imageListSource = null; 201 | function initImageListES() { 202 | if (imageListSource == null || imageListSource.readyState == 2) { 203 | imageListSource = new EventSource(`${websiteUrl}/api/streams/imagelist`); 204 | imageListSource.onerror = function (event) { 205 | if (imageListSource.readyState == 2) { 206 | // retry connection to ES 207 | setTimeout(initImageListES, 5000); 208 | } 209 | } 210 | } 211 | // handle new messages from image list stream 212 | let previousStateImages = {}; 213 | let firstLoadImageList = true; 214 | const imagesTbody = $("#images-tbody"); 215 | imageListSource.onmessage = function (event) { 216 | const data = JSON.parse(event.data); 217 | // Created - timestamp 218 | // Id.split(":")[1].substring(12) - gets short id, otherwise complete hash 219 | // RepoTags[0] - name of image 220 | // Size (bytes) - convert to mb 221 | // RepoTags[0].split(":")[1] gets tag of image 222 | // Labels{} - holds compose information 223 | // keep track of containerIds in the incoming data stream 224 | // getting short id 225 | const imageIds = new Set(data.map(image => image.ID)); 226 | // remove any rows with IDs not in the set 227 | imagesTbody.find('tr').each(function () { 228 | const tr = $(this); 229 | const id = tr.attr('id').substring(4); // remove 'row-' prefix 230 | if (!imageIds.has(id)) { 231 | tr.remove(); 232 | } 233 | }); 234 | $.each(data, function (i, image) { 235 | let tr = imagesTbody.find('#row-' + image.ID); 236 | if (!tr.length) { 237 | // If the row does not exist, create it 238 | tr = $("").attr('id', 'row-' + image.ID); 239 | tr.append($("").html(' ')); 240 | tr.append($("").attr('id', 'name-' + image.ID)); 241 | tr.append($("").attr('id', 'tag-' + image.ID)); 242 | tr.append($("").attr('id', 'created-date-' + image.ID)); 243 | tr.append($("").attr('id', 'size-' + image.ID)); 244 | tr.append($("").attr('id', 'used-by-' + image.ID)); 245 | // first load, all rows are new so append in order the data was sent 246 | // if its the first load, append everything 247 | // if its a newly created container, will have to prepend 248 | if (firstLoadImageList) { 249 | imagesTbody.append(tr); 250 | } else { 251 | imagesTbody.prepend(tr); 252 | } 253 | } 254 | 255 | // Define the attributes to be updated 256 | const attributes = ['Name', 'Tag', 'Created', 'NumContainers', 'Size']; 257 | attributes.forEach(attr => { 258 | // If the attribute has changed 259 | if (previousStateImages[image.ID]?.[attr] !== image[attr]) { 260 | switch (attr) { 261 | case 'Name': 262 | let formattedName = image.Name.split(':')[0]; 263 | $(`#name-${image.ID}`).text(formattedName); 264 | break; 265 | case 'Tag': 266 | $(`#tag-${image.ID}`).html(`${image.Tag}`); 267 | break; 268 | case 'Created': 269 | const createdTimeStamp = new Date(image.Created * 1000); 270 | $(`#created-date-${image.ID}`).html(`${createdTimeStamp.toLocaleDateString()}`); 271 | break; 272 | case 'NumContainers': 273 | $(`#used-by-${image.ID}`).html(`${image.NumContainers}`); 274 | break; 275 | case 'Size': 276 | // convert bytes to mb and gb if necessary 277 | const sizeInBytes = image.Size; 278 | const sizeInMb = sizeInBytes / 1048576 279 | const sizeInGb = sizeInMb / 1024 280 | let displaySize; 281 | if (sizeInGb < 1) { 282 | displaySize = `${sizeInMb.toFixed(2)} MB`; 283 | } else { 284 | displaySize = `${sizeInGb.toFixed(2)} GB`; 285 | } 286 | $(`#size-${image.ID}`).text(displaySize); 287 | break; 288 | } 289 | } 290 | }); 291 | 292 | // Store the current state of the container for the next update 293 | previousStateImages[image.ID] = image; 294 | }); 295 | if (firstLoadImageList) { 296 | firstLoadImageList = false; 297 | } 298 | 299 | $("#images-loading").hide(); 300 | }; 301 | } 302 | initImageListES(); 303 | 304 | var containerStatsSource = null; 305 | function initContainerStatsES() { 306 | if (containerStatsSource == null || containerStatsSource.readyState == 2) { 307 | // container metrics are being populated individually instead of all at once 308 | // Go is publishing all individual container stats as messages to container_metrics channel 309 | // so that means each message HAS an id for a container and stats for ONLY that container 310 | // this is why containermetrics doesnt need to use id 311 | containerStatsSource = new EventSource(`${websiteUrl}/api/streams/containermetrics`);//how come /containermetrics doesnt have the id 312 | containerStatsSource.onerror = function (event) { 313 | if (containerStatsSource.readyState == 2) { 314 | // retry connection to ES 315 | setTimeout(initContainerStatsES, 5000); 316 | } 317 | } 318 | } 319 | 320 | let previousStateStats = {}; 321 | let firstLoadStatsList = true; 322 | const statsTbody = $("#stats-tbody"); 323 | 324 | containerStatsSource.onmessage = function (event) { 325 | const container = JSON.parse(event.data); 326 | if (container['Message']) { 327 | // delete row and return 328 | $("#stats-row-" + container.ID).remove(); 329 | return; 330 | } 331 | let tr = statsTbody.find('#stats-row-' + container.ID); 332 | if (!tr.length) { 333 | // If the row does not exist, create it 334 | tr = $("").attr('id', 'stats-row-' + container.ID); 335 | tr.append($("").attr('id', 'stats-name-' + container.ID)); 336 | tr.append($("").attr('id', 'stats-cpu-percent-' + container.ID)); 337 | tr.append($("").attr('id', 'stats-memory-usage-' + container.ID)); 338 | tr.append($("").attr('id', 'stats-memory-limit-' + container.ID)); 339 | tr.append($("").attr('id', 'stats-memory-percent-' + container.ID)); 340 | // first load, all rows are new so append in order the data was sent 341 | // if its the first load, append everything 342 | // if its a newly created container, will have to prepend 343 | if (firstLoadStatsList) { 344 | statsTbody.append(tr); 345 | } else { 346 | statsTbody.prepend(tr); 347 | } 348 | } 349 | // Define the attributes to be updated 350 | const attributes = ['Name', 'CpuPercent', 'MemoryUsage', 'MemoryLimit', 'MemoryPercent']; 351 | attributes.forEach(attr => { 352 | // If the attribute has changed 353 | if (previousStateStats[container.ID]?.[attr] !== container[attr]) { 354 | switch (attr) { 355 | case 'Name': 356 | $(`#stats-name-${container.ID}`).text(container[attr]); 357 | break; 358 | case 'CpuPercent': 359 | let fixedCpuPercent = container[attr].toFixed(3); 360 | $(`#stats-cpu-percent-${container.ID}`).html(`${fixedCpuPercent} %`); 361 | break; 362 | case 'MemoryUsage': 363 | // const sizeBytes = container[attr]; 364 | let displaySize = convertBytes(container[attr]); 365 | $(`#stats-memory-usage-${container.ID}`).text(displaySize); 366 | break; 367 | case 'MemoryLimit': 368 | let memLimit = convertBytes(container[attr]); 369 | $(`#stats-memory-limit-${container.ID}`).text(memLimit); 370 | break; 371 | case 'MemoryPercent': 372 | let fixedMemPercent = container[attr].toFixed(3); 373 | $(`#stats-memory-percent-${container.ID}`).html(`${fixedMemPercent} %`); 374 | break; 375 | } 376 | } 377 | }); 378 | // Store the current state of the container for the next update 379 | previousStateStats[container.ID] = container; 380 | 381 | if (firstLoadStatsList) { 382 | firstLoadStatsList = false; 383 | } 384 | $("#stats-loading").hide(); 385 | }; 386 | } 387 | initContainerStatsES() 388 | 389 | 390 | 391 | 392 | // handle file uploading 393 | $('#upload-compose-btn').click(function (e) { 394 | // get projectName 395 | const projectName = $('#projectName').val(); 396 | // get yaml contents 397 | const yamlContents = $('#yamlContents').val(); 398 | 399 | // alert if fields aren't filled in 400 | if (!projectName || !yamlContents) { 401 | alert('Please fill out required fields.'); 402 | return; 403 | } 404 | 405 | // disable button and show spinner 406 | $(this).addClass('disabled'); 407 | $(this).find('.spinner-border').toggleClass('d-none'); 408 | 409 | $.ajax({ 410 | url: `${websiteUrl}/api/compose/upload`, 411 | type: 'POST', 412 | data: JSON.stringify({ 413 | "projectName": projectName, 414 | "yamlContents": yamlContents, 415 | }), 416 | processData: false, // tell jQuery not to process the data 417 | contentType: false, // tell jQuery not to set contentType 418 | success: function (data) { 419 | $('#upload-compose-btn').removeClass('disabled'); 420 | $('#upload-compose-btn').find('.spinner-border').toggleClass('d-none'); 421 | // clear projectName input and textarea 422 | $('#projectName').val(''); 423 | $('#yamlContents').val(''); 424 | // hide modal 425 | $('#composeModal').modal('hide'); 426 | } 427 | }); 428 | }); 429 | 430 | // retrieve composefiles eventsource 431 | var composeFilesSource = null; 432 | let composeFilesState = new Set(); 433 | function initComposeFilesSource() { 434 | if (composeFilesSource == null || composeFilesSource.readyState == 2) { 435 | composeFilesSource = new EventSource(`${websiteUrl}/api/streams/composefiles`); 436 | composeFilesSource.onerror = function (event) { 437 | if (composeFilesSource.readyState == 2) { 438 | // retry connection to ES 439 | setTimeout(composeFilesSource, 5000); 440 | } 441 | } 442 | } 443 | composeFilesSource.onmessage = function (event) { 444 | // event.data.files -> list of file names within /composefiles directory 445 | if (event.data.trim() === "") { 446 | // data hasnt changed, recieved heartbeat from server so return 447 | return; 448 | } 449 | // if event.data['files'] doesnt have what is on the screen, remove it 450 | const data = JSON.parse(event.data); 451 | data.files.forEach(fileName => { 452 | if (!composeFilesState.has(fileName)) { 453 | composeFilesState.add(fileName); 454 | const newCard = ` 455 |
456 |
457 |

${fileName}

458 |
459 |
460 | 465 | 470 | 475 |
476 |
`; 477 | $('#compose-files-list').append(newCard); 478 | } 479 | }); 480 | 481 | // Remove cards that are not in the current files list 482 | $('.compose-file').each(function () { 483 | const fileName = this.id; 484 | if (!data.files.includes(fileName)) { 485 | $(this).remove(); 486 | composeFilesState.delete(fileName); 487 | } 488 | }); 489 | } 490 | } 491 | initComposeFilesSource(); 492 | 493 | // Function to close EventSource connections 494 | // close() calls the call_on_close in server and unsubscribes from topic 495 | function closeEventSources() { 496 | containerListSource.close(); 497 | messagesSource.close(); 498 | imageListSource.close(); 499 | } 500 | 501 | function convertBytes(bytes) { 502 | const sizeMb = bytes / 1048576 503 | const sizeGb = sizeMb / 1024 504 | let displaySize; 505 | if (sizeGb < 1) { 506 | displaySize = `${sizeMb.toFixed(2)} MB`; 507 | } else { 508 | displaySize = `${sizeGb.toFixed(2)} GB`; 509 | } 510 | return displaySize; 511 | } 512 | 513 | // Close connections when the page is refreshed or closed 514 | $(window).on('beforeunload', function () { 515 | closeEventSources(); 516 | }); 517 | 518 | // handle prune selecting check boxes 519 | $('#all-prune-check').change(function () { 520 | if (this.checked) { 521 | // Disable individual checkboxes if "All" is checked 522 | $('#individual-prune-selects input[type="checkbox"]').each(function () { 523 | $(this).prop('disabled', true); 524 | $(this).prop('checked', true); 525 | }); 526 | } else { 527 | // Enable individual checkboxes if "All" is unchecked 528 | $('#individual-prune-selects input[type="checkbox"]').each(function () { 529 | $(this).prop('disabled', false); 530 | $(this).prop('checked', false); 531 | }); 532 | } 533 | }); 534 | 535 | // handle creating a container 536 | $('#create-container-btn').on('click', function () { 537 | // image to use/pull 538 | const image = $('#run-image').val(); 539 | const tag = $('#run-tag').val() === '' ? 'latest' : $('#run-tag').val(); 540 | // container name 541 | const containerName = $('#container-name').val(); 542 | // ports 543 | const containerPort = $('#container-port').val(); 544 | const hostPort = $('#host-port').val(); 545 | const selectedProtocol = $('#protocol').val(); 546 | // volumes - NOT BIND MOUNTS 547 | const volumeName = $('#volume-name').val(); 548 | const volumeTarget = $('#volume-bind').val(); 549 | const volumeMode = $('#volume-mode').val(); 550 | // env variables 551 | let envArray = []; 552 | $("#env-container .row").each(function () { 553 | let key = $(this).find(".form-control").first().val(); 554 | let value = $(this).find(".form-control").last().val(); 555 | envArray.push(key + "=" + value); 556 | }); 557 | 558 | let createContainerReq = { 559 | 'image': image + ':' + tag 560 | }; 561 | 562 | if (containerName) { 563 | createContainerReq.containerName = containerName; 564 | } 565 | 566 | if (envArray) { 567 | createContainerReq.environment = envArray; 568 | } 569 | 570 | if (containerPort && hostPort && selectedProtocol) { 571 | const formatContainerPort = containerPort + '/' + selectedProtocol; 572 | createContainerReq.ports = { 573 | [formatContainerPort]: hostPort 574 | }; 575 | } 576 | 577 | if (volumeName && volumeTarget && volumeMode) { 578 | createContainerReq.volumes = { 579 | [volumeName]: { 580 | 'bind': volumeTarget, 581 | 'mode': volumeMode 582 | } 583 | }; 584 | } 585 | 586 | // show spinner and disable button 587 | $('#run-container-spinner').toggleClass('d-none'); 588 | $('#create-container-btn').addClass('disabled'); 589 | // ajax request 590 | $.ajax({ 591 | url: `${websiteUrl}/api/containers/run`, 592 | type: 'post', 593 | contentType: 'application/json', 594 | data: JSON.stringify({ 595 | 'config': createContainerReq 596 | }), 597 | success: function (res) { 598 | $('#run-container-spinner').toggleClass('d-none'); 599 | $('#create-container-btn').removeClass('disabled'); 600 | }, 601 | error: function (res) { 602 | $('#run-container-spinner').toggleClass('d-none'); 603 | $('#create-container-btn').removeClass('disabled'); 604 | } 605 | }) 606 | }); 607 | 608 | // handle prune check box 609 | $('#btncheck1').change(function () { 610 | $('#confirm-prune').toggleClass('disabled'); 611 | }); 612 | 613 | // handle prune button click 614 | $('#confirm-prune').on('click', function () { 615 | // hide prune modal 616 | $("#pruneModal").modal('hide'); 617 | // uncheck box for next opening of modal 618 | $("#btncheck1").prop('checked', false); 619 | $("#confirm-prune").toggleClass('disabled'); 620 | // get data 621 | let checkedToPrune = [] 622 | $('#individual-prune-selects input[type="checkbox"]').each(function () { 623 | if ($(this).prop('checked') == true) { 624 | checkedToPrune.push($(this).val()); 625 | } 626 | }); 627 | // close modal 628 | $('#pruneModal').modal('hide'); 629 | // disable prune button 630 | $('#prune-btn').addClass('disabled'); 631 | // hide icon 632 | $('#prune-icon').addClass('d-none'); 633 | // show spinner 634 | $('#prune-spinner').toggleClass('d-none'); 635 | $.ajax({ 636 | url: `${websiteUrl}/api/system/prune`, 637 | type: 'POST', 638 | contentType: 'application/json', 639 | data: JSON.stringify({ 640 | 'objectsToPrune': checkedToPrune 641 | }), 642 | success: function (result) { 643 | // enable prune button 644 | $('#prune-btn').removeClass('disabled'); 645 | // show icon 646 | $('#prune-icon').removeClass('d-none'); 647 | // hide spinner 648 | $('#prune-spinner').toggleClass('d-none'); 649 | // clear checkboxes 650 | $('#individual-prune-selects input[type="checkbox"]').each(function () { 651 | $(this).prop('checked', false); 652 | $(this).prop('disabled', false); 653 | }); 654 | $('#all-prune-check').prop('checked', false); 655 | }, 656 | error: function (result) { 657 | console.error(result); 658 | } 659 | }); 660 | }) 661 | 662 | // handle select all checkbox change 663 | $('#select-all').change(function () { 664 | // select all checkboxes with class of tr-checkbox and make them selected 665 | $('.tr-container-checkbox').prop('checked', this.checked); 666 | // enable/disable buttons 667 | if (this.checked) { 668 | $('#btn-stop, #btn-kill, #btn-restart, #btn-pause, #btn-delete, #btn-resume, #btn-start').removeClass('disabled'); 669 | } else { 670 | // If no checkboxes are checked, disable all buttons 671 | $('#btn-stop, #btn-kill, #btn-restart, #btn-pause, #btn-delete, #btn-resume, #btn-start').addClass('disabled'); 672 | } 673 | }); 674 | $('#select-all-image').change(function () { 675 | // select all checkboxes with class of tr-checkbox and make them selected 676 | $('.tr-image-checkbox').prop('checked', this.checked); 677 | // enable/disable buttons 678 | if (this.checked) { 679 | $('#delete-img-btn').removeClass('disabled'); 680 | } else { 681 | // If no checkboxes are checked, disable all buttons 682 | $('#delete-img-btn').addClass('disabled'); 683 | } 684 | }); 685 | }); 686 | 687 | // Enables container action buttons after checkbox input 688 | $(document).on('change', '.tr-container-checkbox', function () { 689 | const checkedCount = $('.tr-container-checkbox:checked').length; 690 | const buttonSelector = '#btn-stop, #btn-kill, #btn-restart, #btn-pause, #btn-delete, #btn-resume, #btn-start'; 691 | 692 | $(buttonSelector).toggleClass('disabled', checkedCount === 0); 693 | }); 694 | 695 | // Enables image action buttons after checkbox input 696 | $(document).on('change', '.tr-image-checkbox', function () { 697 | const checkedCount = $('.tr-image-checkbox:checked').length; 698 | const buttonSelector = '#delete-img-btn'; 699 | 700 | $(buttonSelector).toggleClass('disabled', checkedCount === 0); 701 | }); -------------------------------------------------------------------------------- /fastapi/static/media/docker.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breyr/dpanel/be9b35279973962db41f31a14c5b308d55640fd2/fastapi/static/media/docker.png -------------------------------------------------------------------------------- /fastapi/static/media/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breyr/dpanel/be9b35279973962db41f31a14c5b308d55640fd2/fastapi/static/media/favicon.png -------------------------------------------------------------------------------- /pubsub-go/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM --platform=$BUILDPLATFORM golang:latest as builder 2 | 3 | ARG TARGETARCH 4 | 5 | WORKDIR /app 6 | COPY * /app/ 7 | RUN CGO_ENABLED=0 GOOS=linux GOARCH=$TARGETARCH go build -a -o output/main main.go 8 | 9 | FROM alpine:latest 10 | WORKDIR /root 11 | COPY --from=builder /app/output/main . 12 | CMD ["./main"] 13 | -------------------------------------------------------------------------------- /pubsub-go/go.mod: -------------------------------------------------------------------------------- 1 | module pubsub 2 | 3 | go 1.21.3 4 | 5 | require ( 6 | github.com/Microsoft/go-winio v0.4.14 // indirect 7 | github.com/cespare/xxhash/v2 v2.1.2 // indirect 8 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 9 | github.com/distribution/reference v0.5.0 // indirect 10 | github.com/docker/docker v26.0.0+incompatible // indirect 11 | github.com/docker/go-connections v0.5.0 // indirect 12 | github.com/docker/go-units v0.5.0 // indirect 13 | github.com/felixge/httpsnoop v1.0.4 // indirect 14 | github.com/go-logr/logr v1.4.1 // indirect 15 | github.com/go-logr/stdr v1.2.2 // indirect 16 | github.com/go-redis/redis v6.15.9+incompatible // indirect 17 | github.com/go-redis/redis/v8 v8.11.5 // indirect 18 | github.com/gogo/protobuf v1.3.2 // indirect 19 | github.com/moby/docker-image-spec v1.3.1 // indirect 20 | github.com/opencontainers/go-digest v1.0.0 // indirect 21 | github.com/opencontainers/image-spec v1.1.0 // indirect 22 | github.com/pkg/errors v0.9.1 // indirect 23 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect 24 | go.opentelemetry.io/otel v1.24.0 // indirect 25 | go.opentelemetry.io/otel/metric v1.24.0 // indirect 26 | go.opentelemetry.io/otel/trace v1.24.0 // indirect 27 | golang.org/x/sys v0.1.0 // indirect 28 | ) 29 | -------------------------------------------------------------------------------- /pubsub-go/go.sum: -------------------------------------------------------------------------------- 1 | github.com/Microsoft/go-winio v0.4.14 h1:+hMXMk01us9KgxGb7ftKQt2Xpf5hH/yky+TDA+qxleU= 2 | github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA= 3 | github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE= 4 | github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 5 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= 7 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= 8 | github.com/distribution/reference v0.5.0 h1:/FUIFXtfc/x2gpa5/VGfiGLuOIdYa1t65IKK2OFGvA0= 9 | github.com/distribution/reference v0.5.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= 10 | github.com/docker/docker v26.0.0+incompatible h1:Ng2qi+gdKADUa/VM+6b6YaY2nlZhk/lVJiKR/2bMudU= 11 | github.com/docker/docker v26.0.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= 12 | github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= 13 | github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= 14 | github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= 15 | github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= 16 | github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= 17 | github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 18 | github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 19 | github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= 20 | github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 21 | github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 22 | github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 23 | github.com/go-redis/redis v6.15.9+incompatible h1:K0pv1D7EQUjfyoMql+r/jZqCLizCGKFlFgcHWWmHQjg= 24 | github.com/go-redis/redis v6.15.9+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA= 25 | github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI= 26 | github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= 27 | github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 28 | github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 29 | github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= 30 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 31 | github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 32 | github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= 33 | github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= 34 | github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= 35 | github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= 36 | github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= 37 | github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= 38 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 39 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 40 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 41 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 42 | github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= 43 | github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 44 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 45 | github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 46 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 47 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= 48 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= 49 | go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo= 50 | go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo= 51 | go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI= 52 | go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco= 53 | go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI= 54 | go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU= 55 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 56 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 57 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 58 | golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 59 | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 60 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 61 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 62 | golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 63 | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 64 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 65 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 66 | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 67 | golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 68 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 69 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 70 | golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 71 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 72 | golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U= 73 | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 74 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 75 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 76 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 77 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 78 | golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 79 | golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 80 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 81 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 82 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 83 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 84 | -------------------------------------------------------------------------------- /pubsub-go/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "io" 7 | "log" 8 | "strings" 9 | "time" 10 | 11 | "github.com/docker/docker/api/types" 12 | "github.com/docker/docker/api/types/container" 13 | "github.com/docker/docker/api/types/image" 14 | "github.com/docker/docker/client" 15 | "github.com/go-redis/redis" 16 | ) 17 | 18 | type ContainerInfo struct { 19 | ID string 20 | Image string 21 | Status string 22 | State string 23 | Names []string 24 | Ports []types.Port 25 | } 26 | 27 | type ImageInfo struct { 28 | ID string 29 | Name string 30 | Tag string 31 | Created int64 32 | Size int64 33 | NumContainers uint8 34 | } 35 | 36 | type ContainerStats struct { 37 | ID string 38 | Name string 39 | CpuPercent float64 40 | MemoryUsage uint64 41 | MemoryLimit uint64 42 | MemoryPercent float64 43 | } 44 | 45 | type DeletionMessage struct { 46 | ID string 47 | Message string 48 | } 49 | 50 | // Package level variables accessible by all functions 51 | // Good to keep up here if the variable doesnt need to be modified 52 | var ctx = context.Background() 53 | var redisClient = redis.NewClient(&redis.Options{ 54 | Addr: "host.docker.internal:6379", 55 | Password: "", // no password set 56 | DB: 0, // use default DB 57 | }) 58 | 59 | func getRunningContainersPerImage(dockerClient *client.Client) (map[string]int, error) { 60 | containers, err := dockerClient.ContainerList(ctx, container.ListOptions{All: true}) 61 | if err != nil { 62 | return nil, err 63 | } 64 | 65 | containerCountPerImage := make(map[string]int) 66 | for _, container := range containers { 67 | containerCountPerImage[container.ImageID]++ 68 | } 69 | 70 | return containerCountPerImage, nil 71 | } 72 | 73 | func publishHomepageData(dockerClient *client.Client) { 74 | for { 75 | containers, err := dockerClient.ContainerList(ctx, container.ListOptions{All: true}) 76 | if err != nil { 77 | log.Printf("Failed to list containers: %v\n", err) 78 | continue 79 | } 80 | 81 | var containerInfos []ContainerInfo 82 | for _, container := range containers { 83 | containerInfo := ContainerInfo{ 84 | ID: container.ID, 85 | Image: container.Image, 86 | Status: container.Status, 87 | State: container.State, 88 | Names: container.Names, 89 | Ports: container.Ports, 90 | } 91 | containerInfos = append(containerInfos, containerInfo) 92 | } 93 | 94 | containersJSON, err := json.Marshal(containerInfos) 95 | if err != nil { 96 | log.Printf("Failed to marshal containers to JSON: %v\n", err) 97 | continue 98 | } 99 | 100 | err = redisClient.Publish("containers_list", containersJSON).Err() 101 | if err != nil { 102 | log.Printf("Failed to publish containers list to Redis: %v\n", err) 103 | continue 104 | } 105 | 106 | time.Sleep(time.Second) 107 | } 108 | } 109 | 110 | func publishImagesList(dockerClient *client.Client) { 111 | for { 112 | images, err := dockerClient.ImageList(ctx, image.ListOptions{All: true}) 113 | if err != nil { 114 | log.Printf("Failed to list images: %v\n", err) 115 | } 116 | containerCountPerImage, err := getRunningContainersPerImage(dockerClient) 117 | if err != nil { 118 | log.Printf("Failed to get running containers per image %v\n", err) 119 | } 120 | var imageInfos []ImageInfo 121 | for _, image := range images { 122 | // get number of containers running per image 123 | tagValue := "none" 124 | imageName := "none" 125 | if len(image.RepoTags) > 0 { 126 | // set image Name 127 | imageName = image.RepoTags[0] 128 | // get image tag 129 | split := strings.Split(imageName, ":") 130 | if len(split) >= 2 { 131 | tagValue = split[1] 132 | } 133 | } 134 | // get number of containers running for this image 135 | val, ok := containerCountPerImage[image.ID] 136 | var containerCount uint8 137 | if ok { 138 | containerCount = uint8(val) 139 | } else { 140 | containerCount = 0 141 | } 142 | imageInfo := ImageInfo{ 143 | ID: strings.Split(image.ID, ":")[1], // remove sha: from id 144 | Name: imageName, 145 | Tag: tagValue, 146 | Created: image.Created, 147 | Size: image.Size, 148 | NumContainers: containerCount, 149 | } 150 | imageInfos = append(imageInfos, imageInfo) 151 | } 152 | imagesJSON, err := json.Marshal(imageInfos) 153 | if err != nil { 154 | log.Printf("Failed to marshal images to JSON: %v\n", err) 155 | } 156 | err = redisClient.Publish("images_list", imagesJSON).Err() 157 | if err != nil { 158 | log.Printf("Failed to publish image list to Redis: %v\n", err) 159 | } 160 | time.Sleep(time.Second) 161 | } 162 | } 163 | 164 | // chan<- means sending 165 | func collectContainerStats(ctx context.Context, dockerClient *client.Client, container types.Container, statsCh chan<- ContainerStats) { 166 | stream := true 167 | stats, err := dockerClient.ContainerStats(ctx, container.ID, stream) 168 | if err != nil { 169 | log.Printf("Error getting stats for container %s: %v", container.ID, err) 170 | return 171 | } 172 | defer stats.Body.Close() 173 | 174 | for { 175 | select { 176 | case <-ctx.Done(): 177 | // Context has been cancelled, stop collecting stats 178 | return 179 | default: 180 | var containerStats types.StatsJSON 181 | if err := json.NewDecoder(stats.Body).Decode(&containerStats); err != nil { 182 | if err == io.EOF { 183 | // Stream is closed, stop collecting stats 184 | log.Printf("Stats stream for container %s closed, stopping stats collection", container.ID) 185 | return 186 | } 187 | log.Printf("Error decoding stats for container %s: %v", container.ID, err) 188 | return 189 | } 190 | var customStats ContainerStats 191 | customStats.ID = containerStats.ID 192 | customStats.Name = containerStats.Name 193 | if containerStats.CPUStats.CPUUsage.TotalUsage == 0 || containerStats.CPUStats.SystemUsage == 0 { 194 | customStats.CpuPercent = 0 195 | } else { 196 | customStats.CpuPercent = float64(containerStats.CPUStats.CPUUsage.TotalUsage) / float64(containerStats.CPUStats.SystemUsage) * 100 197 | } 198 | if containerStats.MemoryStats.Usage == 0 { 199 | customStats.MemoryPercent = 0 200 | } else { 201 | customStats.MemoryPercent = float64(containerStats.MemoryStats.Usage) / float64(containerStats.MemoryStats.Limit) * 100 202 | } 203 | customStats.MemoryUsage = containerStats.MemoryStats.Usage 204 | customStats.MemoryLimit = containerStats.MemoryStats.Limit 205 | 206 | statsCh <- customStats 207 | } 208 | } 209 | } 210 | 211 | func monitorContainers(dockerClient *client.Client, statsCh chan<- ContainerStats) { 212 | containerContexts := make(map[string]context.CancelFunc) 213 | 214 | for { 215 | // get containers 216 | containers, err := dockerClient.ContainerList(context.Background(), container.ListOptions{All: true}) 217 | if err != nil { 218 | log.Printf("Failed getting list of containers %v\n", err) 219 | return 220 | } 221 | 222 | // Create a set of container IDs 223 | containerIDSet := make(map[string]struct{}) 224 | for _, container := range containers { 225 | containerIDSet[container.ID] = struct{}{} 226 | } 227 | 228 | for _, container := range containers { 229 | if _, exists := containerContexts[container.ID]; !exists { 230 | // New container, start a goroutine to collect its stats 231 | ctx, cancel := context.WithCancel(context.Background()) 232 | containerContexts[container.ID] = cancel 233 | go collectContainerStats(ctx, dockerClient, container, statsCh) 234 | } 235 | } 236 | 237 | // Check for deleted containers 238 | for id, cancel := range containerContexts { 239 | if _, found := containerIDSet[id]; !found { 240 | // Container has been deleted, cancel its context 241 | log.Printf("Canceling context for %s", id) 242 | cancel() 243 | delete(containerContexts, id) 244 | // publish message with id and cancelled, used to delete rows of container stats for those deleted 245 | msg := DeletionMessage{ 246 | ID: id, 247 | Message: "deleted", 248 | } 249 | msgJSON, err := json.Marshal(msg) 250 | if err != nil { 251 | log.Printf("Failed to marshal container stats to JSON: %v", err) 252 | return 253 | } 254 | err = redisClient.Publish("container_metrics", msgJSON).Err() 255 | if err != nil { 256 | log.Printf("Error publishing container stats to reids: %v", err) 257 | } 258 | } 259 | } 260 | 261 | time.Sleep(time.Second) 262 | } 263 | } 264 | 265 | // <-chan means recieving 266 | func publishContainerStats(redisClient *redis.Client, statsCh <-chan ContainerStats) { 267 | for stats := range statsCh { 268 | statsJSON, err := json.Marshal(stats) 269 | if err != nil { 270 | log.Printf("Failed to marshal container stats to JSON: %v", err) 271 | return 272 | } 273 | 274 | err = redisClient.Publish("container_metrics", statsJSON).Err() 275 | if err != nil { 276 | log.Printf("Error publishing container stats to reids: %v", err) 277 | } 278 | } 279 | } 280 | 281 | func main() { 282 | dockerClient, err := client.NewClientWithOpts(client.WithVersion("1.44")) 283 | if err != nil { 284 | log.Fatalf("Failed to create Docker client: %v\n", err) 285 | } 286 | defer dockerClient.Close() 287 | 288 | statsChan := make(chan ContainerStats) 289 | go publishContainerStats(redisClient, statsChan) 290 | go monitorContainers(dockerClient, statsChan) 291 | go publishHomepageData(dockerClient) 292 | go publishImagesList(dockerClient) 293 | 294 | // blocking operation, puts main() goroutine in an idle state waiting for all other goroutines to finish 295 | select {} 296 | 297 | } 298 | -------------------------------------------------------------------------------- /resources/advanced-container.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breyr/dpanel/be9b35279973962db41f31a14c5b308d55640fd2/resources/advanced-container.png -------------------------------------------------------------------------------- /resources/create-container.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breyr/dpanel/be9b35279973962db41f31a14c5b308d55640fd2/resources/create-container.png -------------------------------------------------------------------------------- /resources/cropped/advanced-container.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breyr/dpanel/be9b35279973962db41f31a14c5b308d55640fd2/resources/cropped/advanced-container.png -------------------------------------------------------------------------------- /resources/cropped/create-container.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breyr/dpanel/be9b35279973962db41f31a14c5b308d55640fd2/resources/cropped/create-container.png -------------------------------------------------------------------------------- /resources/cropped/main-page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breyr/dpanel/be9b35279973962db41f31a14c5b308d55640fd2/resources/cropped/main-page.png -------------------------------------------------------------------------------- /resources/cropped/main-small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breyr/dpanel/be9b35279973962db41f31a14c5b308d55640fd2/resources/cropped/main-small.png -------------------------------------------------------------------------------- /resources/cropped/second-page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breyr/dpanel/be9b35279973962db41f31a14c5b308d55640fd2/resources/cropped/second-page.png -------------------------------------------------------------------------------- /resources/cropped/upload-compose.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breyr/dpanel/be9b35279973962db41f31a14c5b308d55640fd2/resources/cropped/upload-compose.png -------------------------------------------------------------------------------- /resources/main-page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breyr/dpanel/be9b35279973962db41f31a14c5b308d55640fd2/resources/main-page.png -------------------------------------------------------------------------------- /resources/second-page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breyr/dpanel/be9b35279973962db41f31a14c5b308d55640fd2/resources/second-page.png -------------------------------------------------------------------------------- /resources/styled/advanced-container.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breyr/dpanel/be9b35279973962db41f31a14c5b308d55640fd2/resources/styled/advanced-container.png -------------------------------------------------------------------------------- /resources/styled/architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breyr/dpanel/be9b35279973962db41f31a14c5b308d55640fd2/resources/styled/architecture.png -------------------------------------------------------------------------------- /resources/styled/create-container.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breyr/dpanel/be9b35279973962db41f31a14c5b308d55640fd2/resources/styled/create-container.png -------------------------------------------------------------------------------- /resources/styled/goroutes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breyr/dpanel/be9b35279973962db41f31a14c5b308d55640fd2/resources/styled/goroutes.png -------------------------------------------------------------------------------- /resources/styled/main-page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breyr/dpanel/be9b35279973962db41f31a14c5b308d55640fd2/resources/styled/main-page.png -------------------------------------------------------------------------------- /resources/styled/secondary-page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breyr/dpanel/be9b35279973962db41f31a14c5b308d55640fd2/resources/styled/secondary-page.png -------------------------------------------------------------------------------- /resources/styled/upload-compose.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breyr/dpanel/be9b35279973962db41f31a14c5b308d55640fd2/resources/styled/upload-compose.png -------------------------------------------------------------------------------- /resources/upload-compose.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breyr/dpanel/be9b35279973962db41f31a14c5b308d55640fd2/resources/upload-compose.png --------------------------------------------------------------------------------