├── .gitignore
├── src
├── app
│ ├── modules
│ │ ├── user
│ │ │ ├── routes
│ │ │ │ └── __init__.py
│ │ │ ├── static
│ │ │ │ ├── js
│ │ │ │ │ ├── profile.js
│ │ │ │ │ ├── role.js
│ │ │ │ │ └── user_profile.js
│ │ │ │ └── styles
│ │ │ │ │ ├── user.css
│ │ │ │ │ └── role.css
│ │ │ ├── models
│ │ │ │ ├── __init__.py
│ │ │ │ ├── helpers.py
│ │ │ │ ├── permissions.py
│ │ │ │ └── role.py
│ │ │ ├── templates
│ │ │ │ └── user
│ │ │ │ │ ├── table_role.html
│ │ │ │ │ ├── add.html
│ │ │ │ │ ├── table.html
│ │ │ │ │ ├── role.html
│ │ │ │ │ ├── profile.html
│ │ │ │ │ └── view_profile.html
│ │ │ ├── __init__.py
│ │ │ └── forms.py
│ │ ├── main
│ │ │ ├── image
│ │ │ │ ├── api
│ │ │ │ │ ├── __init__.py
│ │ │ │ │ └── routes.py
│ │ │ │ ├── static
│ │ │ │ │ └── js
│ │ │ │ │ │ ├── image_list_actions.js
│ │ │ │ │ │ └── image_actions.js
│ │ │ │ ├── __init__.py
│ │ │ │ ├── templates
│ │ │ │ │ └── image
│ │ │ │ │ │ ├── table.html
│ │ │ │ │ │ └── info.html
│ │ │ │ └── routes.py
│ │ │ ├── network
│ │ │ │ ├── api
│ │ │ │ │ ├── __init__.py
│ │ │ │ │ └── routes.py
│ │ │ │ ├── static
│ │ │ │ │ └── js
│ │ │ │ │ │ ├── network_list_actions.js
│ │ │ │ │ │ └── network_actions.js
│ │ │ │ ├── __init__.py
│ │ │ │ ├── templates
│ │ │ │ │ └── network
│ │ │ │ │ │ ├── table.html
│ │ │ │ │ │ └── info.html
│ │ │ │ └── routes.py
│ │ │ ├── volume
│ │ │ │ ├── api
│ │ │ │ │ ├── __init__.py
│ │ │ │ │ └── routes.py
│ │ │ │ ├── static
│ │ │ │ │ └── js
│ │ │ │ │ │ ├── volume_list_actions.js
│ │ │ │ │ │ └── volume_actions.js
│ │ │ │ ├── __init__.py
│ │ │ │ ├── templates
│ │ │ │ │ └── volume
│ │ │ │ │ │ ├── table.html
│ │ │ │ │ │ └── info.html
│ │ │ │ └── routes.py
│ │ │ ├── container
│ │ │ │ ├── api
│ │ │ │ │ ├── __init__.py
│ │ │ │ │ └── routes.py
│ │ │ │ ├── static
│ │ │ │ │ ├── styles
│ │ │ │ │ │ ├── terminal.css
│ │ │ │ │ │ ├── container_list.css
│ │ │ │ │ │ └── logs.css
│ │ │ │ │ └── js
│ │ │ │ │ │ ├── logs.js
│ │ │ │ │ │ ├── container_list_actions.js
│ │ │ │ │ │ ├── container_actions.js
│ │ │ │ │ │ ├── terminal.js
│ │ │ │ │ │ └── container_list_settings.js
│ │ │ │ ├── templates
│ │ │ │ │ └── container
│ │ │ │ │ │ ├── logs.html
│ │ │ │ │ │ ├── processes.html
│ │ │ │ │ │ ├── list_settings.html
│ │ │ │ │ │ └── terminal.html
│ │ │ │ └── __init__.py
│ │ │ ├── dashboard
│ │ │ │ ├── api
│ │ │ │ │ ├── __init__.py
│ │ │ │ │ └── routes.py
│ │ │ │ ├── static
│ │ │ │ │ ├── js
│ │ │ │ │ │ ├── dashboard_info.js
│ │ │ │ │ │ └── dashboard.js
│ │ │ │ │ └── styles
│ │ │ │ │ │ └── dashboard.css
│ │ │ │ ├── __init__.py
│ │ │ │ ├── utils.py
│ │ │ │ ├── routes.py
│ │ │ │ └── templates
│ │ │ │ │ └── dashboard.html
│ │ │ └── __init__.py
│ │ ├── settings
│ │ │ ├── static
│ │ │ │ ├── styles
│ │ │ │ │ └── settings.css
│ │ │ │ └── js
│ │ │ │ │ └── settings.js
│ │ │ ├── forms.py
│ │ │ ├── __init__.py
│ │ │ ├── models.py
│ │ │ ├── routes.py
│ │ │ └── templates
│ │ │ │ └── settings
│ │ │ │ └── table.html
│ │ ├── index
│ │ │ ├── __init__.py
│ │ │ ├── static
│ │ │ │ └── styles
│ │ │ │ │ └── about.css
│ │ │ ├── routes.py
│ │ │ └── templates
│ │ │ │ └── about.html
│ │ └── auth
│ │ │ ├── __init__.py
│ │ │ ├── helpers.py
│ │ │ ├── templates
│ │ │ ├── auth.html
│ │ │ ├── login.html
│ │ │ └── install.html
│ │ │ ├── forms.py
│ │ │ ├── static
│ │ │ └── styles
│ │ │ │ └── auth.css
│ │ │ └── routes.py
│ ├── static
│ │ ├── icons
│ │ │ ├── apple-touch-icon.png
│ │ │ └── favicon.svg
│ │ ├── images
│ │ │ ├── Containery-black.png
│ │ │ └── Containery-white.png
│ │ ├── styles
│ │ │ ├── spinner.css
│ │ │ ├── animations.css
│ │ │ ├── common.css
│ │ │ ├── flash.css
│ │ │ ├── mobile.css
│ │ │ ├── modal.css
│ │ │ ├── sidebar.css
│ │ │ ├── inputs.css
│ │ │ └── colors.css
│ │ ├── js
│ │ │ ├── sidebar.js
│ │ │ ├── scrollbar.js
│ │ │ ├── modal.js
│ │ │ ├── base.js
│ │ │ └── table.js
│ │ └── lib
│ │ │ └── xterm
│ │ │ └── xterm.css
│ ├── templates
│ │ ├── icons
│ │ │ ├── start.svg
│ │ │ ├── logout.svg
│ │ │ ├── user.svg
│ │ │ ├── stop.svg
│ │ │ ├── terminal.svg
│ │ │ ├── dnd.svg
│ │ │ ├── networks.svg
│ │ │ ├── burger.svg
│ │ │ ├── clear.svg
│ │ │ ├── save.svg
│ │ │ ├── add.svg
│ │ │ ├── logs.svg
│ │ │ ├── wrench.svg
│ │ │ ├── images.svg
│ │ │ ├── spiner.svg
│ │ │ ├── containers.svg
│ │ │ ├── users.svg
│ │ │ ├── user_add.svg
│ │ │ ├── delete.svg
│ │ │ ├── error.svg
│ │ │ ├── processes.svg
│ │ │ ├── prune.svg
│ │ │ ├── volumes.svg
│ │ │ ├── restart.svg
│ │ │ ├── roles.svg
│ │ │ ├── revert.svg
│ │ │ └── settings.svg
│ │ ├── error_unauthenticated.html
│ │ ├── error.html
│ │ ├── box-top-panel-macros.html
│ │ └── main.html
│ ├── core
│ │ ├── extensions.py
│ │ ├── decorators.py
│ │ └── error_handlers.py
│ ├── config.py
│ └── lib
│ │ └── common.py
├── wsgi.py
├── gunicorn.conf.py
└── entrypoint.sh
├── docs
└── images
│ ├── dashboard.png
│ ├── role_add.png
│ ├── terminal.png
│ └── container_list.png
├── .dockerignore
├── docker-compose.yml
├── requirements.txt
├── release-notes.md
├── Dockerfile
├── .chglog
├── config.yml
└── CHANGELOG.tpl.md
├── Dockerfile.prod
├── LICENSE
└── .github
└── workflows
└── ci.yml
/.gitignore:
--------------------------------------------------------------------------------
1 | .env
2 | build
3 | dist
4 | .webassets-cache
--------------------------------------------------------------------------------
/src/app/modules/user/routes/__init__.py:
--------------------------------------------------------------------------------
1 | from .user import *
2 | from .role import *
--------------------------------------------------------------------------------
/docs/images/dashboard.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/danylo829/containery/HEAD/docs/images/dashboard.png
--------------------------------------------------------------------------------
/docs/images/role_add.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/danylo829/containery/HEAD/docs/images/role_add.png
--------------------------------------------------------------------------------
/docs/images/terminal.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/danylo829/containery/HEAD/docs/images/terminal.png
--------------------------------------------------------------------------------
/docs/images/container_list.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/danylo829/containery/HEAD/docs/images/container_list.png
--------------------------------------------------------------------------------
/src/app/static/icons/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/danylo829/containery/HEAD/src/app/static/icons/apple-touch-icon.png
--------------------------------------------------------------------------------
/src/wsgi.py:
--------------------------------------------------------------------------------
1 | from app import ApplicationFactory
2 |
3 | app_factory = ApplicationFactory()
4 |
5 | app = app_factory.create_app()
6 |
--------------------------------------------------------------------------------
/src/app/modules/main/image/api/__init__.py:
--------------------------------------------------------------------------------
1 | from flask import Blueprint
2 |
3 | api = Blueprint('image', __name__)
4 |
5 | from . import routes
--------------------------------------------------------------------------------
/src/app/static/images/Containery-black.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/danylo829/containery/HEAD/src/app/static/images/Containery-black.png
--------------------------------------------------------------------------------
/src/app/static/images/Containery-white.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/danylo829/containery/HEAD/src/app/static/images/Containery-white.png
--------------------------------------------------------------------------------
/src/app/modules/main/network/api/__init__.py:
--------------------------------------------------------------------------------
1 | from flask import Blueprint
2 |
3 | api = Blueprint('network', __name__)
4 |
5 | from . import routes
--------------------------------------------------------------------------------
/src/app/modules/main/volume/api/__init__.py:
--------------------------------------------------------------------------------
1 | from flask import Blueprint
2 |
3 | api = Blueprint('volume', __name__)
4 |
5 | from . import routes
--------------------------------------------------------------------------------
/src/app/modules/main/container/api/__init__.py:
--------------------------------------------------------------------------------
1 | from flask import Blueprint
2 |
3 | api = Blueprint('container', __name__)
4 |
5 | from . import routes
--------------------------------------------------------------------------------
/src/app/modules/main/dashboard/api/__init__.py:
--------------------------------------------------------------------------------
1 | from flask import Blueprint
2 |
3 | api = Blueprint('dashboard', __name__)
4 |
5 | from . import routes
--------------------------------------------------------------------------------
/src/app/modules/user/static/js/profile.js:
--------------------------------------------------------------------------------
1 | const slim = new SlimSelect({
2 | select: '#theme-select',
3 | settings: {
4 | showSearch: false,
5 | }
6 | });
--------------------------------------------------------------------------------
/src/app/modules/user/models/__init__.py:
--------------------------------------------------------------------------------
1 | from .permissions import Permissions
2 | from .user import User, PersonalSettings
3 | from .role import Role, RolePermission, UserRole
4 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | __pycache__/
2 | *.pyc
3 | *.pyo
4 | *.pyd
5 | .env
6 | .git
7 | .gitignore
8 | Dockerfile
9 | docker-compose.yml
10 |
11 | src/app/static/dist
12 | src/app/static/.webassets-cache
--------------------------------------------------------------------------------
/src/app/modules/main/container/static/styles/terminal.css:
--------------------------------------------------------------------------------
1 | .terminal-wrapper {
2 | background-color: black;
3 | padding: 10px;
4 | border-radius: 10px;
5 | }
6 |
7 | #terminal-container {
8 | min-height: 80vh;
9 | }
--------------------------------------------------------------------------------
/src/app/templates/icons/start.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/src/app/modules/main/image/static/js/image_list_actions.js:
--------------------------------------------------------------------------------
1 | document.querySelectorAll('.prune-btn').forEach(button => {
2 | button.addEventListener('click', function() {
3 | openModal(`/image/api/prune`, 'POST', 'Are you sure you want to delete all unused images?', '/image/list');
4 | });
5 | });
6 |
--------------------------------------------------------------------------------
/src/app/static/styles/spinner.css:
--------------------------------------------------------------------------------
1 | .loading-spinner {
2 | display: flex;
3 | align-items: center;
4 | justify-content: center;
5 | font-size: 1.5rem;
6 | color: var(--accent-color);
7 | }
8 |
9 | .loading-spinner.hidden *{
10 | opacity: 0;
11 | transition: all 0.3s ease;
12 | }
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | services:
2 | app:
3 | build: .
4 | container_name: containery
5 | restart: "unless-stopped"
6 | ports:
7 | - "5000:5000"
8 | volumes:
9 | - ./src:/containery
10 | - /var/run/docker.sock:/var/run/docker.sock
11 | env_file:
12 | - ./.env
13 |
--------------------------------------------------------------------------------
/src/app/modules/main/volume/static/js/volume_list_actions.js:
--------------------------------------------------------------------------------
1 | document.querySelectorAll('.prune-btn').forEach(button => {
2 | button.addEventListener('click', function() {
3 | openModal(`/volume/api/prune`, 'POST', 'Are you sure you want to delete all unused volumes?', '/volume/list');
4 | });
5 | });
6 |
--------------------------------------------------------------------------------
/src/app/modules/main/network/static/js/network_list_actions.js:
--------------------------------------------------------------------------------
1 | document.querySelectorAll('.prune-btn').forEach(button => {
2 | button.addEventListener('click', function() {
3 | openModal(`/network/api/prune`, 'POST', 'Are you sure you want to delete all unused networks?', '/network/list');
4 | });
5 | });
6 |
--------------------------------------------------------------------------------
/src/app/templates/icons/logout.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/src/app/templates/icons/user.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/src/gunicorn.conf.py:
--------------------------------------------------------------------------------
1 | from os import getenv
2 |
3 | DEBUG = getenv('DEBUG', 'False') == 'True'
4 |
5 | wsgi_app = 'wsgi:app'
6 | bind = '0.0.0.0:5000'
7 | workers = 1 # Only one worker is used to ensure WebSocket support with Eventlet
8 | worker_class = 'eventlet'
9 | reload = DEBUG
10 | loglevel = 'debug' if DEBUG else 'info'
--------------------------------------------------------------------------------
/src/app/templates/icons/stop.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | Flask==3.0.3
2 | Flask-WTF==1.2.1
3 | Flask-Login==0.6.3
4 | Flask-SQLAlchemy==3.1.1
5 | Flask-SocketIO==5.3.6
6 | Flask-Migrate==4.0.7
7 | Flask-Assets==2.1.0
8 |
9 | gunicorn==22.0.0
10 | eventlet==0.36.1
11 |
12 | psutil==6.0.0
13 | requests==2.32.3
14 | requests-unixsocket2==0.4.2
15 |
16 | rjsmin==1.2.4
17 | rcssmin==1.2.1
--------------------------------------------------------------------------------
/release-notes.md:
--------------------------------------------------------------------------------
1 | # Containery 1.2 is out!
2 |
3 | This release fixes a critical bug that could prevent admin account creation under certain conditions.
4 |
5 | ### Bug Fixes
6 |
7 | - **auth:** Fix admin creation logic
8 |
9 | ### Features
10 |
11 | - **core:** Add error message hint
12 |
13 | Feedback, issues, and contributions are welcome.
--------------------------------------------------------------------------------
/src/app/modules/main/image/static/js/image_actions.js:
--------------------------------------------------------------------------------
1 | document.querySelectorAll('.delete-btn').forEach(button => {
2 | button.addEventListener('click', function() {
3 | const id = this.getAttribute('data-id');
4 | openModal(`/image/api/${id}/delete`, 'DELETE', 'Are you sure you want to delete this image?', '/image/list');
5 | });
6 | });
--------------------------------------------------------------------------------
/src/app/modules/main/volume/static/js/volume_actions.js:
--------------------------------------------------------------------------------
1 | document.querySelectorAll('.delete-btn').forEach(button => {
2 | button.addEventListener('click', function() {
3 | const id = this.getAttribute('data-id');
4 | openModal(`/volume/api/${id}/delete`, 'DELETE', 'Are you sure you want to delete this volume?', '/volume/list');
5 | });
6 | });
--------------------------------------------------------------------------------
/src/app/templates/icons/terminal.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/src/app/modules/main/network/static/js/network_actions.js:
--------------------------------------------------------------------------------
1 | document.querySelectorAll('.delete-btn').forEach(button => {
2 | button.addEventListener('click', function() {
3 | const id = this.getAttribute('data-id');
4 | openModal(`/network/api/${id}/delete`, 'DELETE', 'Are you sure you want to delete this network?', '/network/list');
5 | });
6 | });
--------------------------------------------------------------------------------
/src/app/templates/icons/dnd.svg:
--------------------------------------------------------------------------------
1 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/src/app/templates/icons/networks.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/src/app/templates/icons/burger.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/src/app/templates/icons/clear.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/src/app/templates/icons/save.svg:
--------------------------------------------------------------------------------
1 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/src/app/templates/error_unauthenticated.html:
--------------------------------------------------------------------------------
1 | {% extends "auth.html" %}
2 |
3 | {% block content %}
4 |
5 |
6 | {% include 'icons/error.svg' %}
7 |
{{ code }}
8 |
9 |
{{ message }}
10 |
11 | {% endblock %}
12 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM python:3.12-slim
2 |
3 | WORKDIR /containery
4 |
5 | COPY requirements.txt ./
6 |
7 | RUN pip install --no-cache-dir -r requirements.txt && \
8 | apt update && \
9 | apt install -y sqlite3
10 |
11 | RUN mkdir -p /containery_data
12 |
13 | EXPOSE 5000
14 |
15 | ENV PYTHONDONTWRITEBYTECODE=1 \
16 | PYTHONUNBUFFERED=1
17 |
18 | ENTRYPOINT ["./entrypoint.sh"]
--------------------------------------------------------------------------------
/src/app/templates/icons/add.svg:
--------------------------------------------------------------------------------
1 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/src/app/templates/icons/logs.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/src/app/templates/icons/wrench.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/src/app/templates/icons/images.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/src/app/templates/icons/spiner.svg:
--------------------------------------------------------------------------------
1 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/src/app/modules/settings/static/styles/settings.css:
--------------------------------------------------------------------------------
1 | table {
2 | table-layout: fixed;
3 | }
4 |
5 | .cell-wrapper {
6 | display: flex;
7 | align-items: center;
8 | justify-content: space-around;
9 | gap: 1rem;
10 | }
11 |
12 | .settings-revert-icon {
13 | display: flex;
14 | justify-content: center;
15 | align-items: center;
16 | border-radius: 10px !important;
17 | height: 5vh;
18 | aspect-ratio: 1;
19 | }
--------------------------------------------------------------------------------
/src/app/templates/icons/containers.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/src/app/templates/icons/users.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/src/app/templates/icons/user_add.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/src/app/modules/main/dashboard/static/js/dashboard_info.js:
--------------------------------------------------------------------------------
1 | document.querySelectorAll('.prune-btn').forEach(button => {
2 | button.addEventListener('click', function() {
3 | url = `/dashboard/api/prune`;
4 | method = 'POST';
5 | message = 'Are you sure you want to delete all unused data and build cache? This cannot be undone.';
6 | return_url = '/dashboard/info';
7 |
8 | openModal(url, method, message, return_url);
9 | });
10 | });
--------------------------------------------------------------------------------
/src/app/templates/icons/delete.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/src/app/templates/icons/error.svg:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/src/app/core/extensions.py:
--------------------------------------------------------------------------------
1 | from flask_sqlalchemy import SQLAlchemy
2 | from flask_wtf.csrf import CSRFProtect
3 | from flask_login import LoginManager
4 | from flask_socketio import SocketIO
5 | from flask_migrate import Migrate
6 | from flask_assets import Environment
7 |
8 | from app.lib.docker import Docker
9 |
10 | db = SQLAlchemy()
11 | csrf = CSRFProtect()
12 | login_manager = LoginManager()
13 | socketio = SocketIO()
14 | migrate = Migrate()
15 | assets = Environment()
16 | docker = Docker()
--------------------------------------------------------------------------------
/src/app/templates/error.html:
--------------------------------------------------------------------------------
1 | {% extends "main.html" %}
2 |
3 | {% block custom_header %}
4 | {% endblock %}
5 |
6 | {% block content %}
7 |
8 |
9 |
10 | {% include 'icons/error.svg' %}
11 |
{{ code }}
12 |
13 |
{{ message }}
14 |
15 |
16 | {% endblock %}
--------------------------------------------------------------------------------
/src/app/modules/index/__init__.py:
--------------------------------------------------------------------------------
1 | from flask import Blueprint
2 | from flask_assets import Bundle
3 |
4 | module_name = __name__.split('.')[-1]
5 | index = Blueprint(module_name, __name__, template_folder='templates', static_folder='static')
6 |
7 | def register_assets(assets):
8 | css = Bundle(
9 | f"styles/about.css",
10 | filters="rcssmin",
11 | output="dist/css/about.%(version)s.css"
12 | )
13 |
14 | assets.register("about_css", css)
15 |
16 | from . import routes
--------------------------------------------------------------------------------
/src/app/modules/index/static/styles/about.css:
--------------------------------------------------------------------------------
1 | .about-logo {
2 | display: flex;
3 | justify-content: center;
4 | }
5 |
6 | .about-description {
7 | font-size: 1.15rem;
8 | text-align: center;
9 | }
10 |
11 | .about-meta {
12 | text-align: center;
13 | color: #888;
14 | font-size: 0.95rem;
15 | }
16 |
17 | @media (max-width: 600px) {
18 | .about-description {
19 | text-align: justify;
20 | }
21 |
22 | .about-logo img, .about-logo picture {
23 | height: 100px;
24 | }
25 | }
--------------------------------------------------------------------------------
/src/app/static/styles/animations.css:
--------------------------------------------------------------------------------
1 | @keyframes slideUp {
2 | 0% {
3 | transform: translateY(50px);
4 | opacity: 0;
5 | }
6 | 100% {
7 | transform: translateY(0);
8 | opacity: 1;
9 | }
10 | }
11 |
12 | @keyframes fadeOut {
13 | 0% {
14 | opacity: 1;
15 | }
16 | 100% {
17 | opacity: 0;
18 | }
19 | }
20 |
21 |
22 | @keyframes spin {
23 | 0% {
24 | transform: rotate(0deg);
25 | }
26 | 100% {
27 | transform: rotate(360deg);
28 | }
29 | }
--------------------------------------------------------------------------------
/src/app/templates/icons/processes.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/src/app/modules/auth/__init__.py:
--------------------------------------------------------------------------------
1 | from flask import Blueprint
2 | from flask_assets import Bundle
3 |
4 | module_name = __name__.split('.')[-1]
5 | auth = Blueprint(module_name, __name__, url_prefix=f'/{module_name}', template_folder='templates', static_folder='static')
6 |
7 | def register_assets(assets):
8 | css = Bundle(
9 | f"styles/{module_name}.css",
10 | filters="rcssmin",
11 | output=f"dist/css/{module_name}.%(version)s.css"
12 | )
13 | assets.register(f"{module_name}_css", css)
14 |
15 | from . import routes
--------------------------------------------------------------------------------
/src/app/modules/settings/static/js/settings.js:
--------------------------------------------------------------------------------
1 | function resetSetting(fieldName) {
2 | spinner.classList.remove('hidden');
3 | actions.classList.add('disabled');
4 |
5 | fetch(`/settings/reset`, {
6 | method: 'POST',
7 | headers: {
8 | 'Content-Type': 'application/json',
9 | 'X-CSRFToken': csrfToken
10 | },
11 | body: JSON.stringify({
12 | field_name: fieldName
13 | })
14 | })
15 | .then(response => handleResponse(response))
16 | .catch(error => handleError(error))
17 | }
18 |
--------------------------------------------------------------------------------
/src/app/templates/icons/prune.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/src/app/templates/box-top-panel-macros.html:
--------------------------------------------------------------------------------
1 | {# templates/box-top-panel-macros.html #}
2 | {% macro box_top_panel(extra_actions='', extra_inputs='') %}
3 |
4 |
5 |
6 | {{ extra_inputs | safe }}
7 |
8 |
14 |
15 | {% endmacro %}
16 |
--------------------------------------------------------------------------------
/.chglog/config.yml:
--------------------------------------------------------------------------------
1 | style: github
2 | template: CHANGELOG.tpl.md
3 | info:
4 | title: CHANGELOG
5 | repository_url: https://github.com/danylo829/containery
6 | options:
7 | commits:
8 | filters:
9 | Type:
10 | - feat
11 | - fix
12 | - perf
13 | - refactor
14 | commit_groups:
15 | title_maps:
16 | feat: Features
17 | fix: Bug Fixes
18 | perf: Performance Improvements
19 | refactor: Code Refactoring
20 | header:
21 | pattern: '^\[(\w+)\]\s+\[(\w+)\]\s+(.+)'
22 | pattern_maps:
23 | - Type
24 | - Scope
25 | - Subject
--------------------------------------------------------------------------------
/src/app/modules/main/container/static/js/logs.js:
--------------------------------------------------------------------------------
1 | const textarea = document.getElementById('log-textarea');
2 | const logInput = document.getElementById('log-lines');
3 |
4 | if (textarea) {
5 | textarea.scrollTop = textarea.scrollHeight;
6 | }
7 |
8 | logInput.addEventListener('blur', () => {
9 | const value = logInput.value.trim();
10 | if (value) {
11 | const url = new URL(window.location.href);
12 | url.searchParams.set('tail', value);
13 | window.location.href = url.toString();
14 | }
15 | });
16 |
17 | logInput.addEventListener('keyup', e => {
18 | if (e.key === 'Enter') logInput.blur();
19 | });
--------------------------------------------------------------------------------
/src/app/templates/icons/volumes.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/src/app/modules/user/static/styles/user.css:
--------------------------------------------------------------------------------
1 | .role-list {
2 | max-height: 20rem;
3 | overflow-y: auto;
4 | }
5 |
6 | .role-item {
7 | display: flex;
8 | justify-content: space-between;
9 | align-items: center;
10 | }
11 |
12 | .add-role {
13 | display: flex;
14 | align-items: center;
15 | gap: 1rem;
16 | padding-top: 1rem;
17 | }
18 |
19 | #role-edit-top-bar {
20 | width: 100%;
21 | display: flex;
22 | align-items: center;
23 | justify-content: space-between;
24 | }
25 |
26 | #view-role {
27 | table-layout: fixed;
28 | }
29 |
30 | input[type="submit"] {
31 | margin-top: 0;
32 | }
--------------------------------------------------------------------------------
/src/app/config.py:
--------------------------------------------------------------------------------
1 | from os import getenv
2 | import secrets
3 |
4 | class Config:
5 | SECRET_KEY = getenv('SECRET_KEY', secrets.token_hex(32))
6 | CSRF_SECRET_KEY = getenv('CSRF_SECRET_KEY', secrets.token_hex(32))
7 | SQLALCHEMY_DATABASE_URI = getenv('SQLALCHEMY_DATABASE_URI', 'sqlite:////containery_data/containery.db')
8 | SQLALCHEMY_TRACK_MODIFICATIONS = getenv('SQLALCHEMY_TRACK_MODIFICATIONS', 'False') == 'True'
9 | DEBUG = getenv('DEBUG', 'False') == 'True'
10 | DOCKER_SOCKET_PATH = getenv('DOCKER_SOCKET_PATH', '/var/run/docker.sock')
11 | VERSION = getenv('CONTAINERY_VERSION', 'unknown')
12 | # ASSETS_DEBUG = True
--------------------------------------------------------------------------------
/.chglog/CHANGELOG.tpl.md:
--------------------------------------------------------------------------------
1 | {{ range .Versions }}
2 |
3 | ## {{ if .Tag.Previous }}[{{ .Tag.Name }}]({{ $.Info.RepositoryURL }}/compare/{{ .Tag.Previous.Name }}...{{ .Tag.Name }}){{ else }}{{ .Tag.Name }}{{ end }}
4 |
5 | {{ range .CommitGroups -}}
6 | ### {{ .Title }}
7 |
8 | {{ range .Commits -}}
9 | - {{ if .Scope }}**{{ .Scope }}:** {{ end }}{{ replace (replace .Subject "<" "<" -1) ">" ">" -1 }}
10 | {{ end }}
11 | {{ end -}}
12 |
13 | {{- if .NoteGroups -}}
14 | {{ range .NoteGroups -}}
15 | ### {{ .Title }}
16 |
17 | {{ range .Notes }}
18 | {{ .Body }}
19 | {{ end }}
20 | {{ end -}}
21 | {{ end -}}
22 | {{ end -}}
--------------------------------------------------------------------------------
/src/app/static/js/sidebar.js:
--------------------------------------------------------------------------------
1 | document.getElementById('menu-toggle').addEventListener('click', function () {
2 | const sidebar = document.querySelector('.sidebar');
3 | const body = document.querySelector('body');
4 | sidebar.classList.toggle('closed');
5 |
6 | if (sidebar.classList.contains('closed')) {
7 | body.style.overflow = 'auto';
8 | } else {
9 | body.style.overflow = 'hidden';
10 | }
11 |
12 | fetch('/toggle-sidebar', {
13 | method: 'POST',
14 | headers: {
15 | 'Content-Type': 'application/json',
16 | 'X-CSRFToken': csrfToken
17 | }
18 | });
19 | });
--------------------------------------------------------------------------------
/src/entrypoint.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | MIGRATIONS_DIR="/containery_data/migrations"
4 |
5 | if [ ! -d "$MIGRATIONS_DIR" ]; then
6 | echo "Initializing Flask-Migrate at $MIGRATIONS_DIR..."
7 | flask db init -d "$MIGRATIONS_DIR"
8 | fi
9 |
10 | echo "Waiting for the database to be ready..."
11 | while ! flask db upgrade -d "$MIGRATIONS_DIR"; do
12 | echo "Migration is not ready, waiting for the database..."
13 | sleep 2
14 | done
15 |
16 | echo "Applying database migrations..."
17 | flask db migrate -d "$MIGRATIONS_DIR" || echo "No migration needed."
18 | flask db upgrade -d "$MIGRATIONS_DIR"
19 |
20 | echo "Starting Gunicorn..."
21 | gunicorn
22 |
--------------------------------------------------------------------------------
/Dockerfile.prod:
--------------------------------------------------------------------------------
1 | FROM python:3.12-slim
2 |
3 | ARG VERSION
4 |
5 | LABEL org.opencontainers.image.title="Containery"
6 | LABEL org.opencontainers.image.description="Open-source container management web UI"
7 | LABEL org.opencontainers.image.version=$VERSION
8 | LABEL org.opencontainers.image.source="https://github.com/danylo829/Containery"
9 | LABEL org.opencontainers.image.license="MIT"
10 |
11 | ENV PYTHONUNBUFFERED=1 \
12 | CONTAINERY_VERSION=$VERSION
13 |
14 | WORKDIR /containery
15 |
16 | COPY ./requirements.txt ./
17 | RUN pip install --no-cache-dir -r requirements.txt
18 |
19 | COPY ./src ./
20 |
21 | EXPOSE 5000
22 |
23 | ENTRYPOINT ["./entrypoint.sh"]
--------------------------------------------------------------------------------
/src/app/modules/auth/helpers.py:
--------------------------------------------------------------------------------
1 | from app.modules.user.models import Permissions, User, Role
2 |
3 | def create_admin_role ():
4 | """Create the admin role if it doesn't exist."""
5 | if not Role.query.filter_by(name='admin').first():
6 | admin_role = Role.create_role('admin')
7 | if not admin_role:
8 | raise Exception("Failed to create admin role.")
9 |
10 | for permission in Permissions:
11 | try:
12 | admin_role.add_permission(permission.value)
13 | except ValueError as e:
14 | raise Exception(f"Failed to add permission {permission.value} to admin role: {str(e)}")
15 | return admin_role
--------------------------------------------------------------------------------
/src/app/templates/icons/restart.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/src/app/modules/main/image/__init__.py:
--------------------------------------------------------------------------------
1 | from flask import Blueprint
2 | from flask_assets import Bundle
3 |
4 | module_name = __name__.split('.')[-1]
5 | image = Blueprint(module_name, __name__, template_folder='templates', static_folder='static')
6 |
7 | from .api import api
8 | image.register_blueprint(api, url_prefix='/api')
9 |
10 | @image.context_processor
11 | def inject_variables():
12 | return dict(active_page=module_name)
13 |
14 | def register_assets(assets):
15 | js = Bundle(
16 | "js/image_actions.js",
17 | "js/image_list_actions.js",
18 | filters='rjsmin',
19 | output=f"dist/js/{module_name}.%(version)s.js",
20 | )
21 | assets.register(f"{module_name}_js", js)
22 |
23 | from . import routes
--------------------------------------------------------------------------------
/src/app/modules/main/volume/__init__.py:
--------------------------------------------------------------------------------
1 | from flask import Blueprint
2 | from flask_assets import Bundle
3 |
4 | module_name = __name__.split('.')[-1]
5 | volume = Blueprint(module_name, __name__, template_folder='templates', static_folder='static')
6 |
7 | from .api import api
8 | volume.register_blueprint(api, url_prefix='/api')
9 |
10 | @volume.context_processor
11 | def inject_variables():
12 | return dict(active_page=module_name)
13 |
14 | from . import routes
15 |
16 | def register_assets(assets):
17 | js = Bundle(
18 | "js/volume_actions.js",
19 | "js/volume_list_actions.js",
20 | filters='rjsmin',
21 | output=f"dist/js/{module_name}.%(version)s.js",
22 | )
23 | assets.register(f"{module_name}_js", js)
--------------------------------------------------------------------------------
/src/app/templates/icons/roles.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/src/app/modules/main/network/__init__.py:
--------------------------------------------------------------------------------
1 | from flask import Blueprint
2 | from flask_assets import Bundle
3 |
4 | module_name = __name__.split('.')[-1]
5 | network = Blueprint(module_name, __name__, template_folder='templates', static_folder='static')
6 |
7 | from .api import api
8 | network.register_blueprint(api, url_prefix='/api')
9 |
10 | @network.context_processor
11 | def inject_variables():
12 | return dict(active_page=module_name)
13 |
14 | def register_assets(assets):
15 | js = Bundle(
16 | "js/network_actions.js",
17 | "js/network_list_actions.js",
18 | filters='rjsmin',
19 | output=f"dist/js/{module_name}.%(version)s.js",
20 | )
21 | assets.register(f"{module_name}_js", js)
22 |
23 | from . import routes
--------------------------------------------------------------------------------
/src/app/static/js/scrollbar.js:
--------------------------------------------------------------------------------
1 | const tableBoxes = document.querySelectorAll('.table-box');
2 |
3 | function checkScrollbar(tableBox) {
4 | if (tableBox.scrollHeight > tableBox.clientHeight) {
5 | tableBox.style.paddingRight = '1rem';
6 | } else {
7 | tableBox.style.paddingRight = '0';
8 | }
9 | }
10 |
11 | const resizeObserver = new ResizeObserver(entries => {
12 | entries.forEach(entry => {
13 | checkScrollbar(entry.target);
14 | });
15 | });
16 |
17 | tableBoxes.forEach(tableBox => {
18 | checkScrollbar(tableBox);
19 | resizeObserver.observe(tableBox);
20 | setTimeout(() => {
21 | tableBox.style.transition = 'padding-right 0.3s ease';
22 | }, 200); // small delay to prevent transition on page load (cases shaking). But enables transition on sidebar movements, etc.
23 | });
--------------------------------------------------------------------------------
/src/app/templates/icons/revert.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/src/app/modules/settings/forms.py:
--------------------------------------------------------------------------------
1 | from flask_wtf import FlaskForm
2 | from wtforms import IntegerField, SubmitField
3 | from wtforms.validators import DataRequired, NumberRange
4 |
5 | class GlobalSettingsForm(FlaskForm):
6 | # General Settings
7 | dashboard_refresh_interval = IntegerField('Dashboard refresh interval',
8 | validators=[DataRequired(), NumberRange(min=1, max=60)], default=5)
9 |
10 | # Session and Security Settings
11 | session_timeout = IntegerField('Session timeout',
12 | validators=[DataRequired(), NumberRange(min=1)], default=30)
13 | password_min_length = IntegerField('Password minimum length',
14 | validators=[DataRequired(), NumberRange(min=6, max=50)], default=8)
15 |
16 | submit = SubmitField('Save')
17 |
--------------------------------------------------------------------------------
/src/app/core/decorators.py:
--------------------------------------------------------------------------------
1 | from functools import wraps
2 | from flask import render_template, jsonify, request
3 | from flask_login import current_user
4 |
5 | def permission(permission):
6 | def decorator(f):
7 | @wraps(f)
8 | def decorated_function(*args, **kwargs):
9 | if not current_user.has_permission(permission):
10 | message = f'You do not have the necessary permission.'
11 | code = 403
12 | if request.accept_mimetypes.best == 'application/json':
13 | return jsonify({'error': 'Forbidden', 'message': message}), code
14 | else:
15 | return render_template('error.html', message=message, code=code), code
16 |
17 | # Proceed to the view if access is granted
18 | return f(*args, **kwargs)
19 | return decorated_function
20 | return decorator
21 |
--------------------------------------------------------------------------------
/src/app/modules/main/container/templates/container/logs.html:
--------------------------------------------------------------------------------
1 | {% extends "main.html" %}
2 |
3 | {% block custom_header %}
4 | {% assets "container_logs_js" %}
5 |
6 | {% endassets %}
7 | {% assets "container_logs_css" %}
8 |
9 | {% endassets %}
10 | {% endblock %}
11 |
12 | {% block content %}
13 |
27 | {% endblock %}
--------------------------------------------------------------------------------
/src/app/modules/main/__init__.py:
--------------------------------------------------------------------------------
1 | from flask import Blueprint
2 | from flask_login import login_required
3 |
4 | module_name = __name__.split('.')[-1]
5 | main = Blueprint(module_name, __name__)
6 |
7 | from . import dashboard, container, image, volume, network
8 |
9 | main.register_blueprint(dashboard.dashboard, url_prefix='/dashboard')
10 | main.register_blueprint(container.container, url_prefix='/container')
11 | main.register_blueprint(image.image, url_prefix='/image')
12 | main.register_blueprint(volume.volume, url_prefix='/volume')
13 | main.register_blueprint(network.network, url_prefix='/network')
14 |
15 | @main.before_request
16 | @login_required
17 | def before_request():
18 | pass
19 |
20 | def register_assets(assets):
21 | dashboard.register_assets(assets)
22 | container.register_assets(assets)
23 | image.register_assets(assets)
24 | volume.register_assets(assets)
25 | network.register_assets(assets)
--------------------------------------------------------------------------------
/src/app/modules/auth/templates/auth.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Containery
7 |
8 |
9 | {% assets "app_css" %}
10 |
11 | {% endassets %}
12 | {% assets "auth_css" %}
13 |
14 | {% endassets %}
15 |
16 |
17 |
18 | {% block header %}
19 | Containery
20 | {% endblock %}
21 |
22 | {% block content %}
23 |
24 | {% endblock %}
25 |
26 |
27 |
--------------------------------------------------------------------------------
/src/app/modules/auth/templates/login.html:
--------------------------------------------------------------------------------
1 | {% extends "auth.html" %}
2 |
3 | {% block content %}
4 |
5 |
6 |
Welcome
7 |
18 |
19 |
20 | {% with messages = get_flashed_messages(with_categories=true) %}
21 | {% if messages %}
22 | {% for category, message in messages %}
23 | {{ message }}
24 | {% endfor %}
25 | {% endif %}
26 | {% endwith %}
27 |
28 | {% endblock %}
29 |
--------------------------------------------------------------------------------
/src/app/core/error_handlers.py:
--------------------------------------------------------------------------------
1 | from flask import render_template
2 | from flask_login import current_user
3 |
4 | def page_not_found(e):
5 | code = 404
6 | if current_user.is_authenticated:
7 | return render_template('error.html', code=code, message=e), code
8 | else:
9 | return render_template('error_unauthenticated.html', code=code, message=e), code
10 |
11 | def internal_server_error(e):
12 | code = 500
13 | if current_user.is_authenticated:
14 | return render_template('error.html', code=code, message=e), code
15 | else:
16 | return render_template('error_unauthenticated.html', code=code, message=e), code
17 |
18 | def bad_request(e):
19 | code = 400
20 | e = f"{e} Try reloading the page."
21 | if current_user.is_authenticated:
22 | return render_template('error.html', code=code, message=e), code
23 | else:
24 | return render_template('error_unauthenticated.html', code=code, message=e), code
--------------------------------------------------------------------------------
/src/app/modules/auth/templates/install.html:
--------------------------------------------------------------------------------
1 | {% extends "auth.html" %}
2 |
3 | {% block content %}
4 |
5 |
6 |
Admin Setup
7 |
21 |
22 |
23 | {% with messages = get_flashed_messages(with_categories=true) %}
24 | {% if messages %}
25 | {% for category, message in messages %}
26 | {{ message }}
27 | {% endfor %}
28 | {% endif %}
29 | {% endwith %}
30 |
31 | {% endblock %}
32 |
--------------------------------------------------------------------------------
/src/app/modules/main/dashboard/__init__.py:
--------------------------------------------------------------------------------
1 | from flask import Blueprint
2 | from flask_assets import Bundle
3 |
4 | dashboard = Blueprint('dashboard', __name__, template_folder='templates', static_folder='static')
5 |
6 | from .api import api
7 | dashboard.register_blueprint(api, url_prefix='/api')
8 |
9 | def register_assets(assets):
10 | js = Bundle(
11 | "js/dashboard.js",
12 | filters='rjsmin',
13 | output="dist/js/dashboard.%(version)s.js",
14 | )
15 | js_info = Bundle(
16 | "js/dashboard_info.js",
17 | filters='rjsmin',
18 | output="dist/js/dashboard_info.%(version)s.js",
19 | )
20 | css = Bundle(
21 | "styles/dashboard.css",
22 | filters='rcssmin',
23 | output="dist/css/dashboard.%(version)s.css",
24 | )
25 | assets.register("dashboard_css", css)
26 | assets.register("dashboard_js", js)
27 | assets.register("dashboard_info_js", js_info)
28 |
29 | from . import routes
--------------------------------------------------------------------------------
/src/app/modules/index/routes.py:
--------------------------------------------------------------------------------
1 | from flask import redirect, url_for, session, render_template
2 | from flask_login import login_required
3 |
4 | from app.modules.user.models import User
5 | from app.config import Config
6 |
7 | from . import index
8 |
9 | @index.route('/', methods=['GET'])
10 | def root():
11 | if User.query.first():
12 | return redirect(url_for('auth.login'))
13 | return redirect(url_for('auth.install'))
14 |
15 | @index.route('/toggle-sidebar', methods=['POST'])
16 | @login_required
17 | def toggle_sidebar():
18 | if session.get('sidebar_state') == 'closed':
19 | session['sidebar_state'] = 'open'
20 | else:
21 | session['sidebar_state'] = 'closed'
22 |
23 | return {'sidebar_state': session['sidebar_state']}, 200
24 |
25 | @index.route('/about', methods=['GET'])
26 | @login_required
27 | def about():
28 | page_title = "About"
29 | return render_template('about.html', page_title=page_title, version=Config.VERSION)
--------------------------------------------------------------------------------
/src/app/static/styles/common.css:
--------------------------------------------------------------------------------
1 | *::-webkit-scrollbar {
2 | width: 10px;
3 | height: 10px;
4 | }
5 |
6 | *::-webkit-scrollbar-track {
7 | border-radius: 10px;
8 | }
9 |
10 | *::-webkit-scrollbar-track,
11 | *::-webkit-scrollbar-corner {
12 | background-color: var(--bg-color-darker);
13 | }
14 |
15 | *::-webkit-scrollbar-thumb {
16 | border-radius: 8px;
17 | background-color: var(--accent-color);
18 | }
19 |
20 | *::-webkit-scrollbar-corner {
21 | background: transparent;
22 | }
23 |
24 | body {
25 | /* Custom select global vars */
26 | --ss-border-radius: 10px !important;
27 | --ss-main-height: 5vh !important;
28 | }
29 |
30 | ul {
31 | list-style-type: none;
32 | }
33 |
34 | hr {
35 | border: 0;
36 | border-top: 1px solid var(--accent-color);
37 | }
38 |
39 | .flex {
40 | display: flex;
41 | }
42 |
43 | .justify-space-between {
44 | justify-content: space-between;
45 | }
46 |
47 | .align-center {
48 | align-items: center;
49 | }
--------------------------------------------------------------------------------
/src/app/static/styles/flash.css:
--------------------------------------------------------------------------------
1 | .flash-messages {
2 | position: fixed;
3 | bottom: 5vh;
4 | right: 100px;
5 | z-index: 1000;
6 | display: flex;
7 | flex-direction: column;
8 | gap: 10px;
9 | max-width: 20vw;
10 | }
11 |
12 | .flash-message {
13 | background-color: var(--accent-color);
14 | color: var(--bg-color);
15 | padding: 1rem;
16 | border-radius: 10px;
17 | font-size: large;
18 | box-shadow: 0 0 10px 6px rgba(0, 0, 0, 0.1);
19 | opacity: 0;
20 | animation: slideUp 0.5s cubic-bezier(0.25, 0.8, 0.25, 1) forwards, fadeOut 0.5s 3.5s ease-in-out forwards;
21 | text-align: center;
22 | }
23 |
24 | .flash-message.info {
25 | background-color: var(--info-bg);
26 | color: var(--info-text);
27 | }
28 |
29 | .flash-message.success {
30 | background-color: var(--success-bg);
31 | color: var(--success-text);
32 | }
33 |
34 | .flash-message.error {
35 | background-color: var(--error-bg);
36 | color: var(--error-text);
37 | }
--------------------------------------------------------------------------------
/src/app/modules/settings/__init__.py:
--------------------------------------------------------------------------------
1 | from flask import Blueprint, request
2 | from flask_assets import Bundle
3 | from flask_login import login_required
4 |
5 | module_name = __name__.split('.')[-1]
6 | settings = Blueprint(module_name, __name__, url_prefix=f'/{module_name}', template_folder='templates', static_folder='static')
7 |
8 | @settings.before_request
9 | @login_required
10 | def before_request():
11 | pass
12 |
13 | @settings.context_processor
14 | def inject_variables():
15 | return dict(active_page=module_name)
16 |
17 | def register_assets(assets):
18 | css = Bundle(
19 | f"styles/{module_name}.css",
20 | filters="rcssmin",
21 | output=f"dist/css/{module_name}.%(version)s.css"
22 | )
23 | js = Bundle(
24 | f"js/settings.js",
25 | filters='rjsmin',
26 | output=f"dist/js/{module_name}.%(version)s.js"
27 | )
28 |
29 | assets.register(f"{module_name}_css", css)
30 | assets.register(f"{module_name}_js", js)
31 |
32 | from . import routes
--------------------------------------------------------------------------------
/src/app/modules/user/static/js/role.js:
--------------------------------------------------------------------------------
1 | const saveBtn = document.getElementById('save-btn');
2 | const deleteBtn = document.getElementById('delete-btn');
3 |
4 | saveBtn.addEventListener('click', function (e) {
5 | e.preventDefault();
6 | document.getElementById('role-form').submit();
7 | });
8 |
9 | if (deleteBtn) {
10 | deleteBtn.addEventListener('click', function() {
11 | const id = this.getAttribute('data-id');
12 | openModal(`role/delete?id=${id}`, 'DELETE', 'Are you sure you want to delete this role?', 'role/list');
13 | });
14 | }
15 |
16 | document.querySelectorAll('.toggle-category').forEach(toggleCheckbox => {
17 | toggleCheckbox.addEventListener('change', () => {
18 | const category = toggleCheckbox.getAttribute('data-category');
19 | const checkboxes = document.querySelectorAll(`.category-checkbox[data-category="${category}"]`);
20 |
21 | checkboxes.forEach(checkbox => {
22 | checkbox.checked = toggleCheckbox.checked;
23 | });
24 | });
25 | });
--------------------------------------------------------------------------------
/src/app/modules/auth/forms.py:
--------------------------------------------------------------------------------
1 | from flask_wtf import FlaskForm
2 | from wtforms import StringField, PasswordField, SubmitField
3 | from wtforms.validators import DataRequired, EqualTo, Length
4 |
5 | class LoginForm(FlaskForm):
6 | username = StringField('Username', validators=[DataRequired()])
7 | password = PasswordField('Password', validators=[DataRequired()])
8 | submit = SubmitField('Login')
9 |
10 | class AdminSetupForm(FlaskForm):
11 | def __init__(self, min_length=8, *args, **kwargs):
12 | super().__init__(*args, **kwargs)
13 | self.password.validators.append(
14 | Length(min=min_length, max=50)
15 | )
16 |
17 | username = StringField('Username', validators=[DataRequired()])
18 | password = PasswordField('Password', validators=[DataRequired()])
19 | confirm_password = PasswordField('Confirm Password', validators=[
20 | DataRequired(),
21 | EqualTo('password', message="Passwords must match")
22 | ])
23 | submit = SubmitField('Create Admin User')
--------------------------------------------------------------------------------
/src/app/lib/common.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 | from hashlib import sha256
3 | from urllib.parse import urlparse, urljoin
4 |
5 | def format_docker_timestamp(timestamp):
6 | dt = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
7 | return dt.strftime("%H:%M %d-%m-%Y")
8 |
9 | def format_unix_timestamp(timestamp):
10 | return datetime.utcfromtimestamp(timestamp).strftime('%Y-%m-%d %H:%M:%S')
11 |
12 | def stable_hash(value):
13 | return int(sha256(value.encode()).hexdigest(), 16) % (10**8) # Limit the size of the hash
14 |
15 | def is_safe_url(target: str, host_url: str) -> bool:
16 | ref_url = urlparse(host_url)
17 | test_url = urlparse(urljoin(host_url, target))
18 | return test_url.scheme in ('http', 'https') and ref_url.netloc == test_url.netloc
19 |
20 | def bytes_to_human_readable(num_bytes):
21 | for unit in ['B', 'KB', 'MB', 'GB', 'TB', 'PB']:
22 | if num_bytes < 1024.0:
23 | return f"{num_bytes:.2f} {unit}"
24 | num_bytes /= 1024.0
25 | return f"{num_bytes:.2f} PB"
26 |
--------------------------------------------------------------------------------
/src/app/modules/main/container/templates/container/processes.html:
--------------------------------------------------------------------------------
1 | {% extends "main.html" %}
2 | {% from 'box-top-panel-macros.html' import box_top_panel %}
3 |
4 | {% block custom_header %}
5 | {% endblock %}
6 |
7 | {% block content %}
8 |
9 | {{ box_top_panel() }}
10 |
11 |
12 |
13 |
14 | {% for title in processes['Titles'] %}
15 | {{ title }}
16 | {% endfor %}
17 |
18 |
19 |
20 | {% for process in processes['Processes'] %}
21 |
22 | {% for value in process %}
23 | {{ value }}
24 | {% endfor %}
25 |
26 | {% endfor %}
27 |
28 |
29 |
30 |
31 | {% endblock %}
32 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Danylo Havryliv
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.
22 |
--------------------------------------------------------------------------------
/src/app/templates/icons/settings.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/src/app/modules/main/volume/api/routes.py:
--------------------------------------------------------------------------------
1 | from flask import jsonify
2 |
3 | from app.core.extensions import docker
4 | from app.modules.user.models import Permissions
5 | from app.core.decorators import permission
6 | from app.lib.common import bytes_to_human_readable
7 |
8 | from . import api
9 |
10 | @api.route('//delete', methods=['DELETE'])
11 | @permission(Permissions.VOLUME_INFO)
12 | def delete(id):
13 | response, status_code = docker.delete_volume(id)
14 | return str(response), status_code
15 |
16 | @api.route('/prune', methods=['POST'])
17 | @permission(Permissions.VOLUME_DELETE)
18 | def prune():
19 | response, status_code = docker.prune_volumes()
20 |
21 | volumes_deleted_list = response.json().get('VolumesDeleted')
22 | volumes_deleted = len(volumes_deleted_list) if volumes_deleted_list is not None else 0
23 |
24 | if volumes_deleted == 0:
25 | return jsonify({"message": "Nothing to prune"}), status_code
26 |
27 | space_reclaimed = bytes_to_human_readable(response.json().get('SpaceReclaimed', 0))
28 |
29 | message = f"Deleted {volumes_deleted} volumes, reclaimed {space_reclaimed}"
30 |
31 | return jsonify({"message": message}), status_code
32 |
--------------------------------------------------------------------------------
/src/app/modules/main/network/api/routes.py:
--------------------------------------------------------------------------------
1 | from flask import jsonify
2 |
3 | from app.core.extensions import docker
4 | from app.modules.user.models import Permissions
5 | from app.core.decorators import permission
6 | from app.lib.common import bytes_to_human_readable
7 |
8 | from . import api
9 |
10 | @api.route('//delete', methods=['DELETE'])
11 | @permission(Permissions.NETWORK_INFO)
12 | def delete(id):
13 | response, status_code = docker.delete_network(id)
14 | return str(response), status_code
15 |
16 | @api.route('/prune', methods=['POST'])
17 | @permission(Permissions.NETWORK_DELETE)
18 | def prune():
19 | response, status_code = docker.prune_networks()
20 |
21 | networks_deleted_list = response.json().get('NetworksDeleted')
22 | networks_deleted = len(networks_deleted_list) if networks_deleted_list is not None else 0
23 |
24 | if networks_deleted == 0:
25 | return jsonify({"message": "Nothing to prune"}), status_code
26 |
27 | space_reclaimed = bytes_to_human_readable(response.json().get('SpaceReclaimed', 0))
28 |
29 | message = f"Deleted {networks_deleted} networks, reclaimed {space_reclaimed}"
30 |
31 | return jsonify({"message": message}), status_code
32 |
--------------------------------------------------------------------------------
/src/app/modules/main/image/api/routes.py:
--------------------------------------------------------------------------------
1 | from flask import jsonify
2 |
3 | import json
4 |
5 | from app.core.extensions import docker
6 | from app.modules.user.models import Permissions
7 | from app.core.decorators import permission
8 | from app.lib.common import bytes_to_human_readable
9 |
10 | from . import api
11 |
12 | @api.route('//delete', methods=['DELETE'])
13 | @permission(Permissions.IMAGE_INFO)
14 | def delete(id):
15 | response, status_code = docker.delete_image(id)
16 | return str(response), status_code
17 |
18 | @api.route('/prune', methods=['POST'])
19 | @permission(Permissions.IMAGE_DELETE)
20 | def prune():
21 | filters = {"dangling": ["false"]}
22 | params = {"filters": json.dumps(filters)}
23 | response, status_code = docker.prune_images(params=params)
24 |
25 | images_deleted_list = response.json().get('ImagesDeleted')
26 | images_deleted = len(images_deleted_list) if images_deleted_list is not None else 0
27 |
28 | if images_deleted == 0:
29 | return jsonify({"message": "Nothing to prune"}), status_code
30 |
31 | space_reclaimed = bytes_to_human_readable(response.json().get('SpaceReclaimed', 0))
32 |
33 | message = f"Deleted {images_deleted} images, reclaimed {space_reclaimed}"
34 |
35 | return jsonify({"message": message}), status_code
36 |
--------------------------------------------------------------------------------
/src/app/modules/main/container/static/js/container_list_actions.js:
--------------------------------------------------------------------------------
1 | if (typeof SlimSelect !== 'undefined') {
2 | const slim = new SlimSelect({
3 | select: '#compose',
4 | settings: {
5 | showSearch: true,
6 | placeholderText: 'Filter by compose',
7 | },
8 | events: {
9 | afterChange: (newVal) => {
10 | const values = newVal.map(v => v.value).join(',') || '';
11 | const url = new URL(window.location);
12 | if (values) {
13 | url.searchParams.set('compose', values);
14 | } else {
15 | url.searchParams.delete('compose');
16 | }
17 | window.location = url.toString();
18 | }
19 | }
20 | });
21 | }
22 |
23 | document.querySelectorAll('.prune-btn').forEach(button => {
24 | button.addEventListener('click', function() {
25 | const url = new URL(window.location);
26 | const hasFilter = url.searchParams.has('compose');
27 | const message = hasFilter
28 | ? 'Delete all stopped containers across all composes (filters not applied)?'
29 | : 'Delete all stopped containers?';
30 | openModal('/container/api/prune', 'POST', message, '/container/list');
31 | });
32 | });
--------------------------------------------------------------------------------
/src/app/static/styles/mobile.css:
--------------------------------------------------------------------------------
1 | @media (max-width: 768px) {
2 | .content {
3 | margin-bottom: 10vh;
4 | }
5 |
6 | .main-content::-webkit-scrollbar {
7 | display: none;
8 | }
9 |
10 | .flash-messages {
11 | left: 25vw;
12 | max-width: unset;
13 | }
14 |
15 | .nav-menu li, .nav-menu li:hover {
16 | border: none;
17 | }
18 |
19 | .actions div {
20 | gap: unset;
21 | }
22 |
23 | .content-box form .table-box {
24 | width: unset;
25 | min-width: unset;
26 | }
27 |
28 | a, button {
29 | -webkit-tap-highlight-color: rgba(0, 0, 0, 0);
30 | }
31 |
32 | .table-actions {
33 | gap: unset;
34 | }
35 |
36 | .breadcrumbs span:last-child {
37 | max-width: 100px;
38 | }
39 |
40 | table {
41 | table-layout: auto;
42 | }
43 |
44 | .table-box td {
45 | white-space: nowrap;
46 | }
47 |
48 | .table-box::-webkit-scrollbar {
49 | display: none;
50 | }
51 |
52 | .search-box .inputs-box {
53 | flex-direction: column;
54 | width: 50vw;
55 | }
56 |
57 | .search-box .inputs-box:has(#search:only-child) {
58 | width: unset;
59 | }
60 |
61 | .container-list-modal-content {
62 | max-width: 70vw !important;
63 | }
64 | }
--------------------------------------------------------------------------------
/src/app/modules/main/volume/templates/volume/table.html:
--------------------------------------------------------------------------------
1 | {% extends "main.html" %}
2 | {% from 'box-top-panel-macros.html' import box_top_panel %}
3 |
4 | {% block custom_header %}
5 | {% assets "volume_js" %}
6 |
7 | {% endassets %}
8 | {% endblock %}
9 |
10 | {% block content %}
11 |
12 | {% set extra_buttons %}
13 | {% if current_user.has_permission(Permissions.VOLUME_DELETE) %}
14 |
15 | {% include 'icons/prune.svg' %}
16 |
17 | {% endif %}
18 | {% endset %}
19 | {{ box_top_panel(extra_buttons) }}
20 |
21 |
22 |
23 |
24 | Name
25 | Mountpoint
26 |
27 |
28 |
29 | {% for row in rows %}
30 |
31 | {{ row.name }}
32 | {{ row.mountpoint }}
33 |
34 | {% endfor %}
35 |
36 |
37 |
38 |
39 | {% endblock %}
--------------------------------------------------------------------------------
/src/app/modules/user/templates/user/table_role.html:
--------------------------------------------------------------------------------
1 | {% extends "main.html" %}
2 | {% from 'box-top-panel-macros.html' import box_top_panel %}
3 |
4 | {% block content %}
5 |
6 | {% set extra_buttons %}
7 | {% if current_user.has_permission(Permissions.ROLE_ADD) %}
8 |
9 | {% include 'icons/add.svg' %}
10 |
11 | {% endif %}
12 | {% endset %}
13 | {{ box_top_panel(extra_buttons) }}
14 |
15 |
16 |
17 |
18 | Role
19 | Users
20 | Created at
21 |
22 |
23 |
24 | {% for role in roles %}
25 |
26 | {{ role.name }}
27 | {{ role.get_user_count() }}
28 | {{ common.format_unix_timestamp(role.created_at) }}
29 |
30 | {% endfor %}
31 |
32 |
33 |
34 |
35 | {% endblock %}
36 |
--------------------------------------------------------------------------------
/src/app/modules/main/container/static/styles/container_list.css:
--------------------------------------------------------------------------------
1 | .container-list-modal-content {
2 | width: min-content;
3 | max-width: 95vw;
4 | }
5 |
6 | .draggable-modal-header {
7 | text-align: center;
8 | margin-bottom: 1rem;
9 | }
10 |
11 | .draggable-list {
12 | display: flex;
13 | overflow-x: auto;
14 | gap: 1rem;
15 | padding: 1rem;
16 | background: var(--bg-color-darker);
17 | border-radius: 10px;
18 | flex-wrap: nowrap;
19 | }
20 |
21 | .draggable-item {
22 | flex: 0 0 auto;
23 | width: 6rem;
24 | padding: 1rem;
25 | display: flex;
26 | align-items: center;
27 | justify-content: center;
28 | border-radius: 10px;
29 | background: var(--bg-color-box);
30 | font-size: small;
31 | font-weight: 500;
32 | user-select: none;
33 | cursor: grab;
34 | box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
35 | position: relative;
36 | }
37 |
38 | .draggable-inner {
39 | display: flex;
40 | flex-direction: column;
41 | align-items: center;
42 | gap: 6px;
43 | }
44 |
45 | .container-list-settings-buttons {
46 | margin-top: 15px;
47 | display: flex;
48 | justify-content: center;
49 | gap: 1rem;
50 | }
51 |
52 | .quick-actions-grid {
53 | display: grid;
54 | grid-template-columns: repeat(4, 1fr);
55 | }
56 |
57 | .quick-action-item {
58 | display: flex;
59 | align-items: center;
60 | gap: 0.5rem;
61 | padding: 0.5rem;
62 | }
63 |
64 | .quick-action-item span {
65 | font-size: 0.875rem;
66 | }
--------------------------------------------------------------------------------
/src/app/modules/user/__init__.py:
--------------------------------------------------------------------------------
1 | from flask import Blueprint
2 | from flask_assets import Bundle
3 | from flask_login import login_required
4 |
5 | module_name = __name__.split('.')[-1]
6 | user = Blueprint(module_name, __name__, url_prefix=f'/{module_name}', template_folder='templates', static_folder='static')
7 |
8 | @user.before_request
9 | @login_required
10 | def before_request():
11 | pass
12 |
13 | @user.context_processor
14 | def inject_variables():
15 | return dict(active_page=module_name)
16 |
17 | def register_assets(assets):
18 | css = Bundle(
19 | "styles/user.css",
20 | "styles/role.css",
21 | filters="rcssmin",
22 | output="dist/css/user.%(version)s.css"
23 | )
24 | js_role = Bundle(
25 | "js/role.js",
26 | filters='rjsmin',
27 | output=f"dist/js/role.%(version)s.js",
28 | )
29 |
30 | js_profile = Bundle(
31 | "js/profile.js",
32 | filters='rjsmin',
33 | output=f"dist/js/user_profile.%(version)s.js",
34 | )
35 |
36 | js_user_profile = Bundle(
37 | "js/user_profile.js",
38 | filters='rjsmin',
39 | output=f"dist/js/user_profile.%(version)s.js",
40 | )
41 |
42 | assets.register(f"{module_name}_css", css)
43 | assets.register(f"{module_name}_js_role", js_role)
44 | assets.register(f"{module_name}_js_profile", js_profile)
45 | assets.register(f"{module_name}_js_user_profile", js_user_profile)
46 |
47 | from . import routes
--------------------------------------------------------------------------------
/src/app/modules/main/image/templates/image/table.html:
--------------------------------------------------------------------------------
1 | {% extends "main.html" %}
2 | {% from 'box-top-panel-macros.html' import box_top_panel %}
3 |
4 | {% block custom_header %}
5 | {% assets "image_js" %}
6 |
7 | {% endassets %}
8 | {% endblock %}
9 |
10 | {% block content %}
11 |
12 | {% set extra_buttons %}
13 | {% if current_user.has_permission(Permissions.IMAGE_DELETE) %}
14 |
15 | {% include 'icons/prune.svg' %}
16 |
17 | {% endif %}
18 | {% endset %}
19 | {{ box_top_panel(extra_buttons) }}
20 |
21 |
22 |
23 |
24 | Repo
25 | Created
26 | Size (MB)
27 |
28 |
29 |
30 | {% for row in rows %}
31 |
32 | {{ row.repo_tags }}
33 | {{ row.created }}
34 | {{ row.size }}
35 |
36 | {% endfor %}
37 |
38 |
39 |
40 |
41 | {% endblock %}
--------------------------------------------------------------------------------
/src/app/modules/main/container/static/styles/logs.css:
--------------------------------------------------------------------------------
1 | .log-box {
2 | padding: 1rem;
3 | background-color: black;
4 | border-radius: 10px;
5 | }
6 |
7 | #log-textarea {
8 | width: 100%;
9 | height: 100%;
10 | min-height: 75vh;
11 | line-height: 2;
12 | background-color: black;
13 | color: white;
14 | border-radius: 10px;
15 | border: none;
16 | padding: 1rem;
17 | font-family: 'Roboto Mono', monospace;
18 | font-size: medium;
19 | resize: none;
20 | box-sizing: border-box;
21 | text-wrap: nowrap;
22 | cursor: auto;
23 | }
24 |
25 | textarea::-webkit-scrollbar-track {
26 | background-color: black;
27 | }
28 |
29 | #log-textarea:focus {
30 | outline: none !important;
31 | }
32 |
33 | .lines-input {
34 | display: flex;
35 | align-items: center;
36 | border: 1px solid var(--accent-color);
37 | border-radius: 10px;
38 | background: var(--bg-color);
39 | }
40 |
41 | .lines-input span {
42 | padding: 0 1em;
43 | border-right: 1px solid var(--accent-color);
44 | user-select: none;
45 | opacity: 0.8;
46 | }
47 |
48 | .lines-input input {
49 | border: none;
50 | text-align: center;
51 | }
52 |
53 | .lines-input input::-webkit-outer-spin-button,
54 | .lines-input input::-webkit-inner-spin-button {
55 | -webkit-appearance: none;
56 | margin: 0;
57 | }
58 |
59 | .lines-input:focus-within {
60 | border-color: var(--accent-color-strong, var(--accent-color));
61 | box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent-color) 35%, transparent);
62 | }
63 |
--------------------------------------------------------------------------------
/src/app/modules/user/models/helpers.py:
--------------------------------------------------------------------------------
1 | def merge_named_list(current_list, default_list):
2 | """Merge two lists of dicts keyed by 'name' non-destructively.
3 |
4 | Steps:
5 | 1. Filter out obsolete items (names not in defaults)
6 | 2. Map user items by name for quick lookup
7 | 3. For each default item (preserving default order):
8 | - Use user item if exists (preserve flags)
9 | - Add missing keys from default into user item
10 | - If absent, append default item
11 | 4. Ignore duplicates beyond first occurrence
12 | Returns new merged list preserving default ordering.
13 | """
14 | default_names = [d['name'] for d in default_list if isinstance(d, dict) and 'name' in d]
15 | name_to_user_item = {}
16 | for item in current_list:
17 | if not isinstance(item, dict):
18 | continue
19 | name = item.get('name')
20 | if name in default_names and name not in name_to_user_item:
21 | name_to_user_item[name] = item
22 | merged = []
23 | for d_item in default_list:
24 | name = d_item.get('name') if isinstance(d_item, dict) else None
25 | if name and name in name_to_user_item:
26 | user_item = name_to_user_item[name]
27 | # Fill missing keys (non-destructive)
28 | for k, v in d_item.items():
29 | if k not in user_item:
30 | user_item[k] = v
31 | merged.append(user_item)
32 | else:
33 | merged.append(d_item)
34 | return merged
--------------------------------------------------------------------------------
/src/app/static/icons/favicon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
--------------------------------------------------------------------------------
/src/app/modules/main/network/templates/network/table.html:
--------------------------------------------------------------------------------
1 | {% extends "main.html" %}
2 | {% from 'box-top-panel-macros.html' import box_top_panel %}
3 |
4 | {% block custom_header %}
5 | {% assets "network_js" %}
6 |
7 | {% endassets %}
8 | {% endblock %}
9 |
10 | {% block content %}
11 |
12 | {% set extra_buttons %}
13 | {% if current_user.has_permission(Permissions.NETWORK_DELETE) %}
14 |
15 | {% include 'icons/prune.svg' %}
16 |
17 | {% endif %}
18 | {% endset %}
19 | {{ box_top_panel(extra_buttons) }}
20 |
21 |
22 |
23 |
24 | Name
25 | Driver
26 | Subnet
27 | Gateway
28 |
29 |
30 |
31 | {% for row in rows %}
32 |
33 | {{ row.name }}
34 | {{ row.driver }}
35 | {{ row.subnet }}
36 | {{ row.gateway }}
37 |
38 | {% endfor %}
39 |
40 |
41 |
42 |
43 | {% endblock %}
--------------------------------------------------------------------------------
/src/app/modules/user/static/styles/role.css:
--------------------------------------------------------------------------------
1 | .controls {
2 | border-radius: 10px;
3 | border: 1px solid var(--accent-color);
4 | }
5 |
6 | .controls a {
7 | height: 100%;
8 | width: 100%;
9 | display: flex;
10 | align-items: center;
11 | justify-content: center;
12 | border-radius: 10px;
13 | }
14 |
15 | .controls a svg {
16 | padding-inline: 1rem;
17 | }
18 |
19 | .submit-btn {
20 | all: unset;
21 | }
22 |
23 | .permissions-grid {
24 | display: grid;
25 | grid-template-columns: repeat(4, 1fr);
26 | gap: 2rem;
27 | margin-top: 2rem;
28 | }
29 |
30 | .permission-category {
31 | box-shadow: 0 0 10px 6px rgba(0, 0, 0, 0.1);
32 | padding: 1rem;
33 | border-radius: 10px;
34 | background-color: var(--bg-color);
35 | }
36 |
37 | .permission-item {
38 | display: flex;
39 | align-items: center;
40 | justify-content: space-between;
41 | gap: 0.5rem;
42 | margin: 0.5rem 0;
43 | }
44 |
45 | .role-name-section {
46 | display: flex;
47 | justify-content: space-between;
48 | gap: 5rem;
49 | }
50 |
51 | .role-badge-container {
52 | display: flex;
53 | flex-wrap: wrap;
54 | gap: 0.5rem;
55 | }
56 |
57 | .role-badge {
58 | display: inline-block;
59 | background-color: var(--accent-color);
60 | color: var(--bg-color);
61 | padding: 5px 10px;
62 | border-radius: 10px;
63 | font-size: 0.8em;
64 | }
65 |
66 | .toggle-header {
67 | display: flex;
68 | justify-content: space-between;
69 | align-items: center;
70 | border-bottom: 2px solid var(--accent-color);
71 | height: 15%;
72 | margin-bottom: 3vh;
73 | }
74 |
75 | @media all and (min-width:0px) and (max-width: 650px) {
76 | .permissions-grid {
77 | grid-template-columns: repeat(1, 1fr);
78 | }
79 | }
--------------------------------------------------------------------------------
/src/app/modules/auth/static/styles/auth.css:
--------------------------------------------------------------------------------
1 | body {
2 | display: flex;
3 | align-items: center;
4 | height: 100vh;
5 | transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
6 | }
7 |
8 | header {
9 | background-color: var(--accent-color);
10 | color: white;
11 | height: 100vh;
12 | display: flex;
13 | align-items: center;
14 | justify-content: center;
15 | padding-inline: 5vw;
16 | border-top-right-radius: 10px;
17 | border-bottom-right-radius: 10px;
18 | }
19 |
20 | main {
21 | margin-left: 25%;
22 | transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
23 | }
24 |
25 | h2 {
26 | color: var(--accent-color);
27 | text-align: center;
28 | }
29 |
30 | .auth-container, .flash {
31 | background: var(--bg-color-box);
32 | border-radius: 10px;
33 | box-shadow: 2px 0 8px 4px rgba(0, 0, 0, 0.1);
34 | padding: 1rem;
35 | }
36 |
37 | .error-container {
38 | margin-left: 25%;
39 | }
40 |
41 | .flash {
42 | text-align: center;
43 | margin-top: 1rem;
44 | }
45 |
46 | .flash.error {
47 | background-color: var(--error-bg);
48 | }
49 |
50 | .auth-container > form > input {
51 | margin-bottom: 15px;
52 | }
53 |
54 | label {
55 | display: block;
56 | margin-bottom: 5px;
57 | }
58 |
59 | .validation-error {
60 | display: flex;
61 | justify-content: center;
62 | align-items: center;
63 | padding: 1vh;
64 | border-radius: 10px;
65 | background-color: var(--error-bg);
66 | }
67 |
68 | @media (max-width: 600px) {
69 | form {
70 | padding: 10px;
71 | }
72 |
73 | header {
74 | width: 100vw;
75 | position: fixed;
76 | height: unset;
77 | top: 0;
78 | left: 0;
79 | padding-inline: unset;
80 | }
81 | main {
82 | margin: auto;
83 | }
84 | }
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | on:
4 | push:
5 | tags:
6 | - 'v*'
7 |
8 | jobs:
9 | build:
10 | runs-on: ubuntu-latest
11 |
12 | permissions:
13 | packages: write
14 |
15 | steps:
16 | - name: Checkout
17 | uses: actions/checkout@v4
18 |
19 | - name: Set up QEMU
20 | uses: docker/setup-qemu-action@v3
21 |
22 | - name: Set up Docker Buildx
23 | uses: docker/setup-buildx-action@v3
24 |
25 | - name: Log in to GHCR
26 | uses: docker/login-action@v3
27 | with:
28 | registry: ghcr.io
29 | username: ${{ github.actor }}
30 | password: ${{ secrets.GITHUB_TOKEN }}
31 |
32 | - name: Build & push Docker image
33 | uses: docker/build-push-action@v6
34 | with:
35 | context: .
36 | file: ./Dockerfile.prod
37 | platforms: linux/amd64,linux/arm64
38 | push: true
39 | outputs: type=image,name=target,annotation-index.org.opencontainers.image.description=Open-source container management web UI
40 | tags: |
41 | ghcr.io/${{ github.repository }}:latest
42 | ghcr.io/${{ github.repository }}:${{ github.ref_name }}
43 | build-args: |
44 | VERSION=${{ github.ref_name }}
45 |
46 | release:
47 | runs-on: ubuntu-latest
48 | needs: build
49 |
50 | permissions:
51 | contents: write
52 |
53 | steps:
54 | - name: Checkout
55 | uses: actions/checkout@v4
56 |
57 | - name: Create GitHub Release
58 | uses: softprops/action-gh-release@v2
59 | with:
60 | tag_name: ${{ github.ref_name }}
61 | name: ${{ github.ref_name }}
62 | body_path: release-notes.md
63 | env:
64 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
--------------------------------------------------------------------------------
/src/app/modules/user/templates/user/add.html:
--------------------------------------------------------------------------------
1 | {% extends "main.html" %}
2 |
3 | {% block custom_header %}
4 |
5 |
6 |
7 |
8 |
18 | {% endblock %}
19 |
20 | {% block content %}
21 |
50 |
51 | {% endblock %}
52 |
--------------------------------------------------------------------------------
/src/app/static/styles/modal.css:
--------------------------------------------------------------------------------
1 | .modal {
2 | position: fixed;
3 | z-index: 1000;
4 | left: 0;
5 | top: 0;
6 | width: 100%;
7 | height: 100%;
8 | overflow: hidden;
9 | background-color: rgba(0, 0, 0, 0.6);
10 | animation: fade-in 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
11 | }
12 |
13 | .modal.show {
14 | display: block;
15 | }
16 |
17 | .modal-content {
18 | background-color: var(--bg-color-box);
19 | margin: 15% auto;
20 | padding: 1rem;
21 | width: 25vw;
22 | border-radius: 10px;
23 | box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
24 | animation: slide-down 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
25 | }
26 |
27 | @keyframes slide-down {
28 | 0% { transform: translateY(-50px); opacity: 0; }
29 | 100% { transform: translateY(0); opacity: 1; }
30 | }
31 |
32 | @keyframes fade-in {
33 | 0% { opacity: 0; }
34 | 100% { opacity: 1; }
35 | }
36 |
37 | #modalQuestion {
38 | font-size: large;
39 | text-align: center;
40 | }
41 |
42 | .button-group {
43 | display: flex;
44 | justify-content: space-around;
45 | align-items: center;
46 | }
47 |
48 | .btn {
49 | padding: 10px 20px;
50 | cursor: pointer;
51 | border-radius: 50em;
52 | background-color: var(--bg-color);
53 | color: var(--text-color);
54 | border: solid 1px grey;
55 | transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
56 | }
57 |
58 | .btn:hover {
59 | border: solid 1px var(--accent-color);
60 | transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
61 | }
62 |
63 | .btn.delete:hover {
64 | border: solid 1px var(--error-bg);
65 | transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
66 | }
67 |
68 | @media all and (max-width: 650px) {
69 | .modal-content {
70 | margin: 40% auto;
71 | width: 80%;
72 | padding: 1.5rem;
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/src/app/modules/index/templates/about.html:
--------------------------------------------------------------------------------
1 | {% extends "main.html" %}
2 |
3 | {% block custom_header %}
4 | {% assets 'about_css' %}
5 |
6 | {% endassets %}
7 | {% endblock %}
8 |
9 | {% block content %}
10 |
11 |
12 | {% set theme = PersonalSettings.get_setting(current_user.id, 'theme').lower() %}
13 | {% if theme == 'system' %}
14 |
15 |
16 |
17 |
18 | {% elif 'dark' in theme %}
19 |
20 | {% else %}
21 |
22 | {% endif %}
23 |
24 |
25 |
Containery is a web based container management tool that provides a fast, lightweight, and intuitive interface for managing Docker containers. Whether you're a software engineer, DevOps, QA, or anyone who needs to interact with containers, Containery makes it easy to monitor status, view logs, and access terminals for quick insights and control.
26 |
27 |
32 |
33 | {% endblock %}
34 |
--------------------------------------------------------------------------------
/src/app/modules/main/container/api/routes.py:
--------------------------------------------------------------------------------
1 | from flask import jsonify
2 |
3 | from app.core.extensions import docker
4 | from app.modules.user.models import Permissions
5 | from app.core.decorators import permission
6 | from app.lib.common import bytes_to_human_readable
7 |
8 | from . import api
9 |
10 | @api.route('//restart', methods=['POST'])
11 | @permission(Permissions.CONTAINER_RESTART)
12 | def restart(id):
13 | respone, status_code = docker.restart_container(id)
14 | return str(respone), status_code
15 |
16 | @api.route('//start', methods=['POST'])
17 | @permission(Permissions.CONTAINER_START)
18 | def start(id):
19 | response, status_code = docker.start_container(id)
20 | return str(response), status_code
21 |
22 | @api.route('//stop', methods=['POST'])
23 | @permission(Permissions.CONTAINER_STOP)
24 | def stop(id):
25 | response, status_code = docker.stop_container(id)
26 | return str(response), status_code
27 |
28 | @api.route('//delete', methods=['DELETE'])
29 | @permission(Permissions.CONTAINER_DELETE)
30 | def delete(id):
31 | response, status_code = docker.delete_container(id)
32 | return str(response), status_code
33 |
34 | @api.route('/prune', methods=['POST'])
35 | @permission(Permissions.CONTAINER_DELETE)
36 | def prune():
37 | response, status_code = docker.prune_containers()
38 |
39 | containers_deleted_list = response.json().get('ContainersDeleted')
40 | containers_deleted = len(containers_deleted_list) if containers_deleted_list is not None else 0
41 |
42 | if containers_deleted == 0:
43 | return jsonify({"message": "Nothing to prune"}), status_code
44 |
45 | space_reclaimed = bytes_to_human_readable(response.json().get('SpaceReclaimed', 0))
46 |
47 | message = f"Deleted {containers_deleted} containers, reclaimed {space_reclaimed}"
48 |
49 | return jsonify({"message": message}), status_code
50 |
--------------------------------------------------------------------------------
/src/app/modules/main/dashboard/utils.py:
--------------------------------------------------------------------------------
1 | from flask import session
2 |
3 | from app.modules.settings.models import GlobalSettings
4 | from app.config import Config
5 |
6 | from packaging import version
7 | import requests
8 | import time
9 |
10 | GITHUB_API_URL = "https://api.github.com/repos/danylo829/containery/releases/latest"
11 | CHECK_INTERVAL_MINUTES = 1
12 |
13 | def fetch_latest_version() -> str:
14 | try:
15 | response = requests.get(GITHUB_API_URL, timeout=5)
16 | if response.status_code == 200:
17 | data = response.json()
18 | tag_name = data.get("tag_name", "")
19 | return tag_name.lstrip("v")
20 | except Exception:
21 | pass
22 | return ""
23 |
24 | def check_for_update() -> tuple[str, bool]:
25 | latest_version = GlobalSettings.get_setting('latest_version')
26 | last_checked_ts = GlobalSettings.get_setting('latest_version_checked_at')
27 | last_checked = None
28 | if last_checked_ts:
29 | try:
30 | last_checked = float(last_checked_ts)
31 | except Exception:
32 | last_checked = None
33 |
34 | now_ts = time.time()
35 | should_check = not last_checked or (now_ts - last_checked) > CHECK_INTERVAL_MINUTES * 60
36 |
37 | if should_check:
38 | fetched_version = fetch_latest_version()
39 | if fetched_version:
40 | latest_version = fetched_version
41 | GlobalSettings.set_setting('latest_version', latest_version)
42 | GlobalSettings.set_setting('latest_version_checked_at', str(now_ts))
43 |
44 | show_update_notification = (
45 | latest_version
46 | and version.parse(Config.VERSION) < version.parse(latest_version)
47 | and latest_version != ''
48 | and not session.get('dismiss_update_notification', False)
49 | )
50 |
51 | return str(latest_version or ""), bool(show_update_notification)
--------------------------------------------------------------------------------
/src/app/modules/main/container/__init__.py:
--------------------------------------------------------------------------------
1 | from flask import Blueprint
2 | from flask_assets import Bundle
3 |
4 | module_name = __name__.split('.')[-1]
5 | container = Blueprint(module_name, __name__, template_folder='templates', static_folder='static')
6 |
7 | from .api import api
8 | container.register_blueprint(api, url_prefix='/api')
9 |
10 | @container.context_processor
11 | def inject_variables():
12 | return dict(active_page=module_name)
13 |
14 | def register_assets(assets):
15 | actions_js = Bundle(
16 | "js/container_actions.js",
17 | "js/container_list_actions.js",
18 | "js/container_list_settings.js",
19 | filters='rjsmin',
20 | output=f"dist/js/{module_name}_actions.%(version)s.js",
21 | )
22 | logs_js = Bundle(
23 | "js/logs.js",
24 | filters='rjsmin',
25 | output=f"dist/js/{module_name}_logs.%(version)s.js",
26 | )
27 | terminal_js = Bundle(
28 | "js/terminal.js",
29 | filters='rjsmin',
30 | output=f"dist/js/{module_name}_terminal.%(version)s.js",
31 | )
32 |
33 | terminal_css = Bundle(
34 | "styles/terminal.css",
35 | filters='rcssmin',
36 | output=f"dist/css/{module_name}_terminal.%(version)s.css",
37 | )
38 |
39 | logs_css = Bundle(
40 | "styles/logs.css",
41 | filters='rcssmin',
42 | output=f"dist/css/{module_name}_logs.%(version)s.css",
43 | )
44 |
45 | list_css = Bundle(
46 | "styles/container_list.css",
47 | filters='rcssmin',
48 | output=f"dist/css/{module_name}_list.%(version)s.css",
49 | )
50 |
51 | assets.register(f"{module_name}_actions_js", actions_js)
52 | assets.register(f"{module_name}_logs_js", logs_js)
53 | assets.register(f"{module_name}_logs_css", logs_css)
54 | assets.register(f"{module_name}_terminal_js", terminal_js)
55 | assets.register(f"{module_name}_terminal_css", terminal_css)
56 | assets.register(f"{module_name}_list_css", list_css)
57 |
58 | from . import routes
--------------------------------------------------------------------------------
/src/app/modules/main/volume/templates/volume/info.html:
--------------------------------------------------------------------------------
1 | {% extends "main.html" %}
2 |
3 | {% block custom_header %}
4 | {% assets "volume_js" %}
5 |
6 | {% endassets %}
7 | {% endblock %}
8 |
9 | {% block content %}
10 | {% if volume %}
11 |
12 |
13 |
14 |
{{ volume['Name'] }}
15 |
22 |
23 |
Driver: {{ volume['Driver'] }}
24 |
Created At: {{ format_docker_timestamp(volume['CreatedAt']) }}
25 |
Mountpoint: {{ volume['Mountpoint'] }}
26 |
Scope: {{ volume['Scope'] }}
27 |
28 |
29 |
30 |
31 |
Labels
32 | {% if volume['Labels'] and volume['Labels']|length > 0 %}
33 |
34 |
35 |
36 | {% for key, value in volume['Labels'].items() %}
37 |
38 | {{ key }}
39 | {{ value }}
40 |
41 | {% endfor %}
42 |
43 |
44 |
45 | {% else %}
46 |
No labels found.
47 | {% endif %}
48 |
49 | {% else %}
50 | No volume information available.
51 | {% endif %}
52 | {% endblock %}
--------------------------------------------------------------------------------
/src/app/modules/user/templates/user/table.html:
--------------------------------------------------------------------------------
1 | {% extends "main.html" %}
2 | {% from 'box-top-panel-macros.html' import box_top_panel %}
3 |
4 | {% block custom_header %}
5 | {% assets "user_css" %}
6 |
7 | {% endassets %}
8 | {% endblock %}
9 |
10 | {% block content %}
11 |
12 | {% set extra_buttons %}
13 | {% if current_user.has_permission(Permissions.USER_ADD) %}
14 |
15 | {% include 'icons/user_add.svg' %}
16 |
17 | {% endif %}
18 | {% if current_user.has_permission(Permissions.ROLE_VIEW_LIST) %}
19 |
20 | {% include 'icons/roles.svg' %}
21 |
22 | {% endif %}
23 | {% endset %}
24 |
25 | {{ box_top_panel(extra_buttons) }}
26 |
27 |
28 |
29 |
30 |
31 | Username
32 | Roles
33 | Created At
34 |
35 |
36 |
37 | {% for user in users %}
38 |
39 | {{ user.username }}
40 |
41 |
42 | {% for role in user.get_roles() %}
43 | {{ role.name }}
44 | {% endfor %}
45 |
46 |
47 | {{ common.format_unix_timestamp(user.created_at) }}
48 |
49 | {% endfor %}
50 |
51 |
52 |
53 |
54 | {% endblock %}
55 |
--------------------------------------------------------------------------------
/src/app/modules/main/dashboard/static/styles/dashboard.css:
--------------------------------------------------------------------------------
1 | .buttons {
2 | display: flex;
3 | flex-wrap: wrap;
4 | gap: 1rem;
5 | }
6 |
7 | .dashboard-button {
8 | background-color: var(--bg-color);
9 | color: var(--text-color);
10 | border: 1px solid grey;
11 | border-radius: 10px;
12 | padding: 2rem;
13 | font-size: 1.2rem;
14 | display: flex;
15 | flex-direction: column;
16 | align-items: center;
17 | gap: 1rem;
18 | flex: 1 1;
19 | transition: border 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
20 | }
21 |
22 | .dashboard-button:hover {
23 | border: 1px solid var(--accent-color);
24 | color: var(--text-color);
25 | text-decoration: none;
26 | }
27 |
28 | .usage-row {
29 | display: flex;
30 | }
31 |
32 | .cpu-usage, .ram-usage, .load-average, .io-operations {
33 | flex: 1;
34 | padding: 0 1rem;
35 | }
36 |
37 | .stat-table {
38 | border-collapse: collapse;
39 | table-layout: unset;
40 | }
41 |
42 | .stat-table td {
43 | width: 50%;
44 | }
45 |
46 | .progress-bar {
47 | position: relative;
48 | height: 20px;
49 | background-color: var(--bg-color);
50 | border: 1px solid var(--accent-color);
51 | border-radius: 10px;
52 | overflow: hidden;
53 | margin-bottom: 0.5rem;
54 | }
55 |
56 | .progress {
57 | height: 100%;
58 | background-color: var(--accent-color);
59 | transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
60 | }
61 |
62 | .usage-text {
63 | text-align: center;
64 | font-size: 1rem;
65 | color: var(--text-color);
66 | }
67 |
68 | .update-notification {
69 | position: relative;
70 | width: 100vw;
71 | flex: unset !important;
72 | border: 1px solid var(--accent-color);
73 | }
74 |
75 | .close-btn {
76 | position: absolute;
77 | top: 1rem;
78 | right: 1rem;
79 | background: none;
80 | border: none;
81 | font-size: 1.5em;
82 | color: var(--text-color);
83 | cursor: pointer;
84 | }
85 |
86 | tr:last-child {
87 | border-bottom: 1px solid var(--accent-color);
88 | }
89 |
90 | .prune-btn.disabled {
91 | pointer-events: none;
92 | opacity: 0.5;
93 | transition: opacity 0.3s ease;
94 | }
--------------------------------------------------------------------------------
/src/app/modules/main/dashboard/static/js/dashboard.js:
--------------------------------------------------------------------------------
1 | let updateInterval;
2 |
3 | function updateUsage() {
4 | fetch('/dashboard/api/usage')
5 | .then(response => response.json())
6 | .then(data => {
7 | // Update CPU usage
8 | document.querySelector('.cpu-usage .progress').style.width = data.cpu + '%';
9 | document.querySelector('.cpu-usage .usage-text').innerText = `${data.cpu}%`;
10 |
11 | // Update RAM usage
12 | document.querySelector('.ram-usage .progress').style.width = data.ram_percent + '%';
13 | document.querySelector('.ram-usage .usage-text').innerText = `${data.ram_absolute} GB / ${data.ram_total} GB`;
14 |
15 | // Update Load Average
16 | const loadAverageCells = document.querySelectorAll('.load-average td');
17 | loadAverageCells[0].innerText = data.load_average[0].toFixed(2);
18 | loadAverageCells[1].innerText = data.load_average[1].toFixed(2);
19 | loadAverageCells[2].innerText = data.load_average[2].toFixed(2);
20 | })
21 | .catch(error => console.error('Error fetching usage data:', error));
22 | }
23 |
24 | function startUpdating() {
25 | updateInterval = setInterval(updateUsage, intervalSeconds * 1000);
26 | updateUsage();
27 | }
28 |
29 | function stopUpdating() {
30 | clearInterval(updateInterval);
31 | }
32 |
33 | document.addEventListener('visibilitychange', function() {
34 | if (document.hidden) {
35 | stopUpdating();
36 | } else {
37 | startUpdating();
38 | }
39 | });
40 |
41 | const closeBtn = document.querySelector('.update-notification .close-btn');
42 | const notification = document.querySelector('.update-notification');
43 | if (closeBtn && notification) {
44 | closeBtn.addEventListener('click', function() {
45 | notification.style.display = 'none';
46 | fetch('/dashboard/api/dismiss-update-notification', {
47 | method: 'POST',
48 | headers: {
49 | 'X-CSRFToken': csrfToken,
50 | 'Content-Type': 'application/json',
51 | }
52 | });
53 | });
54 | }
55 |
56 | startUpdating();
--------------------------------------------------------------------------------
/src/app/modules/main/dashboard/routes.py:
--------------------------------------------------------------------------------
1 | from flask import render_template
2 |
3 | import json
4 |
5 | from app.core.extensions import docker
6 |
7 | from app.config import Config
8 |
9 | import app.modules.main.dashboard.utils as utils
10 | from . import dashboard
11 |
12 | @dashboard.route('/', methods=['GET'])
13 | def index():
14 | response, status_code = docker.info()
15 | info = []
16 | if status_code not in range(200, 300):
17 | message = response.text if hasattr(response, 'text') else str(response)
18 | try:
19 | message = json.loads(message).get('message', message)
20 | except json.JSONDecodeError:
21 | pass
22 | return render_template('error.html', message=message, code=status_code), status_code
23 | else:
24 | info = response.json()
25 |
26 | latest_version, show_update_notification = utils.check_for_update()
27 |
28 | page_title = "Dashboard"
29 | return render_template(
30 | 'dashboard.html',
31 | info=info,
32 | page_title=page_title,
33 | show_update_notification=show_update_notification,
34 | latest_version=latest_version,
35 | installed_version=Config.VERSION
36 | )
37 |
38 | @dashboard.route('/info', methods=['GET'])
39 | def info():
40 | response, status_code = docker.info()
41 | response_df, status_code_df = docker.df()
42 | info = []
43 | df = []
44 | if status_code not in range(200, 300):
45 | message = response.text if hasattr(response, 'text') else str(response)
46 | try:
47 | message = json.loads(message).get('message', message)
48 | except json.JSONDecodeError:
49 | pass
50 | return render_template('error.html', message=message, code=status_code), status_code
51 | else:
52 | info = response.json()
53 |
54 | if status_code_df in range(200, 300):
55 | df = response_df.json()
56 |
57 | page_title = "Dashboard"
58 | breadcrumbs = [
59 | {'name': 'Dashboard', 'url': '/'},
60 | {'name': 'Info', 'url': ''}
61 | ]
62 | return render_template('info.html', page_title=page_title, info=info, df=df, breadcrumbs=breadcrumbs)
--------------------------------------------------------------------------------
/src/app/modules/user/static/js/user_profile.js:
--------------------------------------------------------------------------------
1 | const deleteButton = document.getElementById('delete-btn');
2 |
3 | const roleList = document.querySelector('.role-list');
4 | const userId = roleList.getAttribute('data-user-id');
5 |
6 | const slim = new SlimSelect({
7 | select: '#role-select',
8 | settings: {
9 | showSearch: false,
10 | }
11 | });
12 |
13 | deleteButton.addEventListener('click', function () {
14 | const id = this.getAttribute('data-id');
15 | openModal(`user/delete?id=${id}`, 'DELETE', 'Are you sure you want to delete this user?', 'user/list');
16 | });
17 |
18 | document.querySelectorAll('.delete-role').forEach(button => {
19 | button.addEventListener('click', function() {
20 | const roleId = this.getAttribute('data-role-id');
21 |
22 | const formData = new FormData();
23 | formData.append('user_id', userId);
24 | formData.append('role_id', roleId);
25 |
26 | fetch('/user/role/remove', {
27 | method: 'DELETE',
28 | headers: {
29 | 'X-CSRFToken': csrfToken,
30 | 'Accept': 'application/json'
31 | },
32 | body: formData
33 | })
34 | .then(response => {
35 | if (response.ok) {
36 | localStorage.setItem('flash_message', 'Role deleted successfully!');
37 | localStorage.setItem('flash_type', 'success');
38 | window.location.reload();
39 | } else if (response.status === 403) {
40 | localStorage.setItem('flash_message', 'You do not have permission to perform this action.');
41 | localStorage.setItem('flash_type', 'error');
42 | } else {
43 | localStorage.setItem('flash_message', 'Failed to delete the role.');
44 | localStorage.setItem('flash_type', 'error');
45 | }
46 | })
47 | .catch(error => {
48 | console.error('Error:', error);
49 | localStorage.setItem('flash_message', 'An error occurred.');
50 | localStorage.setItem('flash_type', 'error');
51 | })
52 | .finally(() => {
53 | window.location.reload();
54 | });
55 | });
56 | });
--------------------------------------------------------------------------------
/src/app/modules/user/models/permissions.py:
--------------------------------------------------------------------------------
1 | from app.lib.common import stable_hash
2 |
3 | # List of all permission names used in the system.
4 | # These will be hashed into stable values and added as class attributes.
5 | permission_names = [
6 | 'USER_ADD',
7 | 'USER_DELETE',
8 | 'USER_EDIT',
9 | 'USER_VIEW_LIST',
10 | 'USER_VIEW_PROFILE',
11 |
12 | 'ROLE_ADD',
13 | 'ROLE_VIEW',
14 | 'ROLE_VIEW_LIST',
15 | 'ROLE_EDIT',
16 |
17 | 'CONTAINER_INFO',
18 | 'CONTAINER_START',
19 | 'CONTAINER_STOP',
20 | 'CONTAINER_RESTART',
21 | 'CONTAINER_DELETE',
22 | 'CONTAINER_VIEW_LIST',
23 | 'CONTAINER_EXEC',
24 |
25 | 'IMAGE_INFO',
26 | 'IMAGE_DELETE',
27 | 'IMAGE_VIEW_LIST',
28 |
29 | 'VOLUME_INFO',
30 | 'VOLUME_DELETE',
31 | 'VOLUME_VIEW_LIST',
32 |
33 | 'NETWORK_INFO',
34 | 'NETWORK_DELETE',
35 | 'NETWORK_VIEW_LIST',
36 |
37 | 'GLOBAL_SETTINGS_VIEW',
38 | 'GLOBAL_SETTINGS_EDIT'
39 | ]
40 |
41 | class PermissionsMeta(type):
42 | def __iter__(cls):
43 | """
44 | Makes the class itself iterable.
45 |
46 | Enables `for perm in Permissions:` to yield individual permission objects.
47 | Each yielded object has `.name` and `.value` attributes.
48 |
49 | Note: This only works *after* the class is fully defined.
50 | You CANNOT iterate over the class inside its own body,
51 | because the metaclass __iter__ is not active during class construction.
52 | """
53 | for name in permission_names:
54 | value = getattr(cls, name)
55 | yield type('Permission', (), {'name': name, 'value': value})()
56 |
57 | class Permissions(metaclass=PermissionsMeta):
58 | # Dynamically assign each permission name to a stable hash value.
59 | # These become class-level constants, e.g. Permissions.USER_ADD
60 | for name in permission_names:
61 | locals()[name] = stable_hash(name)
62 |
63 | @classmethod
64 | def all(cls):
65 | """
66 | Returns all permission values (i.e., stable hashes) as a list.
67 |
68 | Equivalent to: [Permissions.USER_ADD, Permissions.USER_DELETE, ...]
69 | """
70 | return list(cls) # Leverages the metaclass __iter__
--------------------------------------------------------------------------------
/src/app/modules/settings/models.py:
--------------------------------------------------------------------------------
1 | from app.core.extensions import db
2 |
3 | class GlobalSettings(db.Model):
4 | __tablename__ = 'stg_global_settings'
5 | id = db.Column(db.Integer, primary_key=True)
6 | key = db.Column(db.String(150), unique=True, nullable=False)
7 | value = db.Column(db.String(150), nullable=False)
8 |
9 | defaults = {
10 | 'dashboard_refresh_interval': 5,
11 | 'session_timeout': 1800,
12 | 'password_min_length': 8,
13 | 'latest_version': '',
14 | 'latest_version_checked_at': '',
15 | }
16 |
17 | @classmethod
18 | def get_setting(cls, key):
19 | if key not in cls.defaults:
20 | raise KeyError(f"The setting '{key}' is not defined in defaults.")
21 |
22 | try:
23 | setting = cls.query.filter_by(key=key).first()
24 | return setting.value if setting else cls.defaults[key]
25 | except Exception as e:
26 | raise RuntimeError(f"Database error while retrieving setting '{key}': {str(e)}")
27 |
28 | @classmethod
29 | def set_setting(cls, key, value):
30 | if key not in cls.defaults:
31 | raise KeyError(f"The setting '{key}' is not defined in defaults.")
32 |
33 | try:
34 | setting = cls.query.filter_by(key=key).first()
35 | if setting:
36 | setting.value = str(value)
37 | else:
38 | setting = cls(key=key, value=str(value))
39 | db.session.add(setting)
40 |
41 | db.session.commit()
42 | except Exception as e:
43 | db.session.rollback()
44 | raise RuntimeError(f"Database error while setting '{key}': {str(e)}")
45 |
46 | @classmethod
47 | def delete_setting(cls, key):
48 | if key not in cls.defaults:
49 | raise KeyError(f"The setting '{key}' is not defined in defaults.")
50 |
51 | try:
52 | setting = cls.query.filter_by(key=key).first()
53 | if setting:
54 | db.session.delete(setting)
55 | db.session.commit()
56 | except Exception as e:
57 | db.session.rollback()
58 | raise RuntimeError(f"Database error while deleting setting '{key}': {str(e)}")
--------------------------------------------------------------------------------
/src/app/modules/main/container/templates/container/list_settings.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | {% for item in container_list_columns %}
10 |
11 |
12 |
19 |
20 | {{ item.name }}
21 |
22 |
23 | {% endfor %}
24 |
25 |
26 |
27 |
28 |
29 |
30 | {% for action in container_list_quick_actions %}
31 |
32 |
40 |
41 | {{ action.name }}
42 |
43 | {% endfor %}
44 |
45 |
46 |
47 |
48 | Cancel
49 | Save
50 |
51 |
52 |
--------------------------------------------------------------------------------
/src/app/modules/main/container/static/js/container_actions.js:
--------------------------------------------------------------------------------
1 | document.querySelectorAll('.delete-btn').forEach(button => {
2 | button.addEventListener('click', function() {
3 | const id = this.getAttribute('data-id');
4 | openModal(`/container/api/${id}/delete`, 'DELETE', 'Are you sure you want to delete this container?', '/container/list');
5 | });
6 | });
7 |
8 | document.querySelectorAll('.start-btn').forEach(button => {
9 | button.addEventListener('click', function() {
10 | const containerId = this.getAttribute('data-id');
11 |
12 | spinner.classList.remove('hidden');
13 |
14 | disableAllActions();
15 |
16 | fetch(`/container/api/${containerId}/start`, {
17 | method: 'POST',
18 | headers: {
19 | 'Content-Type': 'application/json',
20 | 'X-CSRFToken': csrfToken
21 | },
22 | })
23 | .then(response => handleResponse(response))
24 | .catch(error => handleError(error));
25 | });
26 | });
27 |
28 | document.querySelectorAll('.restart-btn').forEach(button => {
29 | button.addEventListener('click', function() {
30 | const containerId = this.getAttribute('data-id');
31 |
32 | spinner.classList.remove('hidden');
33 |
34 | disableAllActions();
35 |
36 | fetch(`/container/api/${containerId}/restart`, {
37 | method: 'POST',
38 | headers: {
39 | 'Content-Type': 'application/json',
40 | 'X-CSRFToken': csrfToken
41 | },
42 | })
43 | .then(response => handleResponse(response))
44 | .catch(error => handleError(error));
45 | });
46 | });
47 |
48 | document.querySelectorAll('.stop-btn').forEach(button => {
49 | button.addEventListener('click', function() {
50 | const containerId = this.getAttribute('data-id');
51 |
52 | spinner.classList.remove('hidden');
53 |
54 | disableAllActions();
55 |
56 | fetch(`/container/api/${containerId}/stop`, {
57 | method: 'POST',
58 | headers: {
59 | 'Content-Type': 'application/json',
60 | 'X-CSRFToken': csrfToken
61 | },
62 | })
63 | .then(response => handleResponse(response))
64 | .catch(error => handleError(error));
65 | });
66 | });
--------------------------------------------------------------------------------
/src/app/modules/main/container/templates/container/terminal.html:
--------------------------------------------------------------------------------
1 | {% extends "main.html" %}
2 |
3 | {% block custom_header %}
4 |
5 |
6 |
7 |
8 |
9 | {% assets "container_terminal_js" %}
10 |
11 | {% endassets %}
12 | {% assets "container_terminal_css" %}
13 |
14 | {% endassets %}
15 | {% endblock %}
16 |
17 | {% block content %}
18 |
52 | {% endblock %}
53 |
--------------------------------------------------------------------------------
/src/app/modules/user/templates/user/role.html:
--------------------------------------------------------------------------------
1 | {% extends "main.html" %}
2 |
3 | {% block custom_header %}
4 | {% assets "user_css" %}
5 |
6 | {% endassets %}
7 | {% assets "user_js_role" %}
8 |
9 | {% endassets %}
10 | {% endblock %}
11 |
12 | {% block content %}
13 |
65 | {% endblock %}
--------------------------------------------------------------------------------
/src/app/modules/main/volume/routes.py:
--------------------------------------------------------------------------------
1 | from flask import render_template, url_for
2 |
3 | import json
4 |
5 | from app.core.extensions import docker
6 | from app.lib.common import format_docker_timestamp
7 |
8 | from app.core.decorators import permission
9 | from app.modules.user.models import Permissions
10 |
11 | from . import volume
12 |
13 | @volume.route('/list', methods=['GET'])
14 | @permission(Permissions.VOLUME_VIEW_LIST)
15 | def get_list():
16 | response, status_code = docker.get_volumes()
17 | volumes = []
18 | if status_code not in range(200, 300):
19 | message = response.text if hasattr(response, 'text') else str(response)
20 | try:
21 | message = json.loads(message).get('message', message)
22 | except json.JSONDecodeError:
23 | pass
24 | return render_template('error.html', message=message, code=status_code), status_code
25 | else:
26 | volumes = response.json().get('Volumes', [])
27 |
28 | rows = []
29 | for volume in volumes:
30 | row = {
31 | 'name': volume['Name'],
32 | 'mountpoint': volume['Mountpoint'],
33 | }
34 | rows.append(row)
35 |
36 | rows = sorted(rows, key=lambda x: x['name'], reverse=True)
37 |
38 | breadcrumbs = [
39 | {"name": "Dashboard", "url": url_for('main.dashboard.index')},
40 | {"name": "Volumes", "url": None},
41 | ]
42 | page_title = "Volumes List"
43 | return render_template('volume/table.html', rows=rows, breadcrumbs=breadcrumbs, page_title=page_title)
44 |
45 | @volume.route('/', methods=['GET'])
46 | @permission(Permissions.VOLUME_INFO)
47 | def info(name):
48 | response, status_code = docker.inspect_volume(name)
49 | volume = []
50 | if status_code not in range(200, 300):
51 | message = response.text if hasattr(response, 'text') else str(response)
52 | try:
53 | message = json.loads(message).get('message', message)
54 | except json.JSONDecodeError:
55 | pass
56 | return render_template('error.html', message=message, code=status_code), status_code
57 | else:
58 | volume = response.json()
59 |
60 | breadcrumbs = [
61 | {"name": "Dashboard", "url": url_for('main.dashboard.index')},
62 | {"name": "Volumes", "url": url_for('main.volume.get_list')},
63 | {"name": volume['Name'], "url": None},
64 | ]
65 | page_title = 'Volume Details'
66 |
67 | return render_template('volume/info.html', volume=volume, breadcrumbs=breadcrumbs, page_title=page_title, format_docker_timestamp=format_docker_timestamp)
--------------------------------------------------------------------------------
/src/app/modules/main/dashboard/api/routes.py:
--------------------------------------------------------------------------------
1 | from flask import jsonify, session
2 |
3 | from app.core.extensions import docker
4 | from app.lib.common import bytes_to_human_readable
5 |
6 | import psutil
7 | import json
8 |
9 | from . import api
10 |
11 | @api.route('/usage', methods=['GET'])
12 | def get_usage():
13 | cpu_usage = psutil.cpu_percent(interval=1)
14 |
15 | ram_usage_percent = psutil.virtual_memory().percent
16 | ram_usage_absolute = round((psutil.virtual_memory().used / 1024 / 1024 / 1024), 2)
17 | ram_total = round((psutil.virtual_memory().total / 1024 / 1024 / 1024), 2)
18 |
19 | # Load average
20 | load_average = psutil.getloadavg() # Returns a tuple (1min, 5min, 15min)
21 |
22 | return jsonify(
23 | cpu=cpu_usage,
24 | ram_percent=ram_usage_percent,
25 | ram_absolute=ram_usage_absolute,
26 | ram_total=ram_total,
27 | load_average=load_average
28 | )
29 |
30 | @api.route('/dismiss-update-notification', methods=['POST'])
31 | def dismiss_update_notification():
32 | session['dismiss_update_notification'] = True
33 | return jsonify({'success': True}), 200
34 |
35 | @api.route('/prune', methods=['POST'])
36 | def prune():
37 | reclaimed_space = 0
38 |
39 | response, status_code = docker.prune_containers()
40 | if status_code not in range(200, 300):
41 | return jsonify({'message': 'Failed to prune containers'}), status_code
42 | reclaimed_space += response.json().get('SpaceReclaimed', 0)
43 |
44 | filters = {"dangling": ["false"]}
45 | params = {"filters": json.dumps(filters)}
46 | response, status_code = docker.prune_images(params=params)
47 | if status_code not in range(200, 300):
48 | return jsonify({'message': 'Failed to prune images'}), status_code
49 | reclaimed_space += response.json().get('SpaceReclaimed', 0)
50 |
51 | response, status_code = docker.prune_volumes()
52 | if status_code not in range(200, 300):
53 | return jsonify({'message': 'Failed to prune volumes'}), status_code
54 | reclaimed_space += response.json().get('SpaceReclaimed', 0)
55 |
56 | response, status_code = docker.prune_networks()
57 | if status_code not in range(200, 300):
58 | return jsonify({'message': 'Failed to prune networks'}), status_code
59 | reclaimed_space += response.json().get('SpaceReclaimed', 0)
60 |
61 | response, status_code = docker.prune_build_cache()
62 | if status_code not in range(200, 300):
63 | return jsonify({'message': 'Failed to prune build cache'}), status_code
64 | reclaimed_space += response.json().get('SpaceReclaimed', 0)
65 |
66 | message = f"Reclaimed {bytes_to_human_readable(reclaimed_space)} of disk space."
67 |
68 | return jsonify({'message': message}), 200
--------------------------------------------------------------------------------
/src/app/static/js/modal.js:
--------------------------------------------------------------------------------
1 | function createModal() {
2 | const modal = document.createElement('div');
3 | modal.id = 'confirmationModal';
4 | modal.className = 'modal';
5 |
6 | const modalContent = document.createElement('div');
7 | modalContent.className = 'modal-content';
8 |
9 | const header = document.createElement('h2');
10 | header.textContent = 'Confirmation';
11 | header.style.textAlign = 'center';
12 |
13 | const question = document.createElement('p');
14 | question.id = 'modalQuestion';
15 | question.textContent = 'Are you sure you want to perform this action?';
16 |
17 | const buttonGroup = document.createElement('div');
18 | buttonGroup.className = 'button-group';
19 |
20 | const cancelBtn = document.createElement('button');
21 | cancelBtn.id = 'cancelBtn';
22 | cancelBtn.className = 'btn cancel';
23 | cancelBtn.textContent = 'Cancel';
24 |
25 | const confirmBtn = document.createElement('button');
26 | confirmBtn.id = 'confirmBtn';
27 | confirmBtn.className = 'btn delete';
28 | confirmBtn.textContent = 'Confirm';
29 |
30 | // Build hierarchy
31 | modalContent.appendChild(header);
32 | modalContent.appendChild(question);
33 |
34 | buttonGroup.appendChild(cancelBtn);
35 | buttonGroup.appendChild(confirmBtn);
36 | modalContent.appendChild(buttonGroup);
37 |
38 | modal.appendChild(modalContent);
39 |
40 | return modal;
41 | }
42 |
43 | function closeModal() {
44 | const modal = document.getElementById('confirmationModal');
45 | if (modal) {
46 | modal.remove();
47 | }
48 | }
49 |
50 | function openModal(url, method, question, returnUrl) {
51 | const confirmationModal = createModal();
52 | const confirmBtn = confirmationModal.querySelector('#confirmBtn');
53 | const cancelBtn = confirmationModal.querySelector('#cancelBtn');
54 | const modalQuestion = confirmationModal.querySelector('#modalQuestion');
55 |
56 | modalQuestion.textContent = question;
57 |
58 | confirmBtn.addEventListener('click', function () {
59 | const spinner = document.querySelector('.loading-spinner');
60 | spinner.classList.remove('hidden');
61 |
62 | disableAllActions();
63 |
64 | closeModal();
65 |
66 | fetch(url, {
67 | method: method,
68 | headers: {
69 | 'Content-Type': 'application/json',
70 | 'X-CSRFToken': csrfToken
71 | },
72 | })
73 | .then(response => handleResponse(response, returnUrl))
74 | .catch(error => handleError(error));
75 | });
76 |
77 | cancelBtn.addEventListener('click', closeModal);
78 |
79 | window.addEventListener('click', function (event) {
80 | if (event.target === confirmationModal) {
81 | closeModal();
82 | }
83 | });
84 |
85 | document.body.appendChild(confirmationModal);
86 | }
--------------------------------------------------------------------------------
/src/app/modules/settings/routes.py:
--------------------------------------------------------------------------------
1 | from flask import render_template, flash, redirect, url_for, request, jsonify
2 | from flask_login import current_user
3 |
4 | from .models import GlobalSettings
5 | from .forms import GlobalSettingsForm
6 |
7 | from app.modules.user.models import Permissions
8 | from app.core.decorators import permission
9 |
10 | from . import settings
11 |
12 | @settings.route('', methods=['GET', 'POST'])
13 | @permission(Permissions.GLOBAL_SETTINGS_VIEW)
14 | def get_list():
15 | form = GlobalSettingsForm()
16 |
17 | if request.method == 'POST' and not current_user.has_permission(Permissions.GLOBAL_SETTINGS_EDIT):
18 | message = f'You do not have the necessary permission.'
19 | code = 403
20 | return render_template('error.html', message=message, code=code), code
21 |
22 | if request.method == 'GET':
23 | for field_name, field in form._fields.items():
24 | if field_name in GlobalSettings.defaults:
25 | field_value = GlobalSettings.get_setting(field_name)
26 | field.data = field_value
27 |
28 | if form.validate_on_submit():
29 | for field_name, field in form._fields.items():
30 | if field_name in GlobalSettings.defaults:
31 | try:
32 | GlobalSettings.set_setting(field_name, field.data)
33 | except (ValueError, KeyError) as e:
34 | flash(f"Error updating {field_name}: {str(e)}", "error")
35 | except RuntimeError as e:
36 | flash(f"Database error when updating {field_name}: {str(e)}", "error")
37 |
38 | flash("Settings have been saved successfully!", "success")
39 | return redirect(url_for('settings.get_list'))
40 |
41 | breadcrumbs = [
42 | {"name": "Dashboard", "url": url_for('main.dashboard.index')},
43 | {"name": "Settings", "url": None},
44 | ]
45 | page_title = 'Global settings'
46 |
47 | return render_template('settings/table.html',
48 | breadcrumbs=breadcrumbs,
49 | page_title=page_title,
50 | form=form)
51 |
52 | @settings.route('/reset', methods=['POST'])
53 | @permission(Permissions.GLOBAL_SETTINGS_EDIT)
54 | def reset_setting():
55 | field_name = request.json.get('field_name')
56 |
57 | if not field_name:
58 | return jsonify({'error': "Field name is required."}), 400
59 |
60 | try:
61 | GlobalSettings.delete_setting(field_name)
62 | return jsonify({'message': f"Setting '{field_name}' reset to default."}), 204
63 |
64 | except KeyError:
65 | return jsonify({'error': f"Setting '{field_name}' is not a valid setting key."}), 400
66 | except ValueError:
67 | return jsonify({'error': f"Setting '{field_name}' does not exist in the database."}), 404
68 | except Exception as e:
69 | return jsonify({'error': str(e)}), 500
--------------------------------------------------------------------------------
/src/app/modules/main/container/static/js/terminal.js:
--------------------------------------------------------------------------------
1 | const form = document.getElementById('start-form');
2 | const terminalWrapper = document.getElementById('terminal-wrapper');
3 | const container = document.getElementById('terminal-container');
4 | const xterm = new Terminal();
5 | const commandSelect = document.getElementById('command-select');
6 | const commandInput = document.getElementById('command-input');
7 | const submitBtn = document.getElementById('submit-btn');
8 |
9 | const slim = new SlimSelect({
10 | select: '#command-select',
11 | settings: {
12 | showSearch: false,
13 | }
14 | });
15 |
16 | const charWidth = 9 + 0.2; //a small offset so vertical scroll slider does not overlap text
17 | const charHeight = 17;
18 |
19 | let socket;
20 | let execId;
21 |
22 | function getContainerSize() {
23 | const { width, height } = container.getBoundingClientRect();
24 |
25 | const cols = Math.floor(width / charWidth);
26 |
27 | const rows = Math.floor(height / charHeight);
28 |
29 | return { cols, rows };
30 | }
31 |
32 | const resizeTerminalObserver = new ResizeObserver(entries => {
33 | for (let entry of entries) {
34 | if (!execId) {
35 | return;
36 | }
37 | const { cols, rows } = getContainerSize();
38 |
39 | socket.emit('resize_session', {
40 | exec_id: execId,
41 | cols: cols,
42 | rows: rows
43 | });
44 |
45 | xterm.resize(cols, rows);
46 | }
47 | });
48 |
49 | // Disable select when there is any input in custom command
50 | commandInput.addEventListener('input', function () {
51 | if (commandInput.value.length > 0) {
52 | commandSelect.disabled = true;
53 | } else {
54 | commandSelect.disabled = false;
55 | }
56 | });
57 |
58 | form.addEventListener('submit', (event) => {
59 | event.preventDefault();
60 |
61 | resizeTerminalObserver.observe(container);
62 |
63 | const user = document.getElementById('user-field').value;
64 | const containerId = submitBtn.getAttribute('data-container-id');
65 |
66 | const command = commandInput.value.length > 0 ? commandInput.value : commandSelect.value;
67 |
68 | form.style.display = 'none';
69 | terminalWrapper.style.display = 'block';
70 |
71 | xterm.open(container);
72 |
73 | socket = io();
74 |
75 | const { cols, rows } = getContainerSize();
76 |
77 | socket.emit('start_session', {
78 | container_id: containerId,
79 | user: user,
80 | command: command,
81 | consoleSize: [rows, cols]
82 | });
83 |
84 | xterm.resize(cols, rows);
85 |
86 | xterm.onData(e => {
87 | socket.emit('input', {
88 | command: e
89 | });
90 | });
91 |
92 | socket.on('output', function(data) {
93 | const output = data.data;
94 | xterm.write(output);
95 | });
96 |
97 | socket.on('exec_id', function(data) {
98 | execId = data.execId;
99 | });
100 | });
101 |
--------------------------------------------------------------------------------
/src/app/modules/auth/routes.py:
--------------------------------------------------------------------------------
1 | from flask import render_template, flash, redirect, url_for, session, request
2 | from flask_login import login_user, logout_user, login_required, current_user
3 |
4 | from app.modules.user.models import User, Role
5 | from app.modules.settings.models import GlobalSettings
6 | from app.lib.common import is_safe_url
7 |
8 | from .helpers import create_admin_role
9 | from .forms import LoginForm, AdminSetupForm
10 |
11 | from . import auth
12 |
13 | @auth.route('/login', methods=['GET', 'POST'])
14 | def login():
15 | if current_user.is_authenticated:
16 | return redirect(url_for('main.dashboard.index'))
17 | if not User.query.first():
18 | return redirect(url_for('auth.install'))
19 |
20 | form = LoginForm()
21 |
22 | next_page = request.args.get('next')
23 |
24 | if form.validate_on_submit():
25 | username = str(form.username.data).strip()
26 | password = form.password.data
27 |
28 | user = User.query.filter_by(username=username).first()
29 |
30 | if user and user.check_password(password):
31 | login_user(user)
32 | session.permanent = True
33 |
34 | if next_page and is_safe_url(next_page, request.host_url):
35 | return redirect(next_page)
36 |
37 | return redirect(url_for('index.root'))
38 | else:
39 | flash('Invalid username or password', 'error')
40 |
41 | return render_template('login.html', form=form, next=next_page)
42 |
43 | @auth.route('/install', methods=['GET', 'POST'])
44 | def install():
45 | if User.query.first():
46 | return redirect(url_for('index.root'))
47 |
48 | password_min_length = int(GlobalSettings.get_setting('password_min_length'))
49 |
50 | form = AdminSetupForm(min_length=password_min_length)
51 |
52 | if form.validate_on_submit():
53 | username = str(form.username.data).strip()
54 | password = str(form.password.data)
55 |
56 | if len(password) < password_min_length:
57 | return redirect(url_for('auth.install'))
58 |
59 | try:
60 | user = User.create_user(username=username, password=password)
61 | if not user or not isinstance(user, User):
62 | flash("User creation failed. Please try again.", 'error')
63 | return redirect(url_for('auth.install'))
64 |
65 | admin_role = create_admin_role()
66 | if not admin_role or not isinstance(admin_role, Role):
67 | flash("Failed to create admin role. Please try again.", 'error')
68 | return redirect(url_for('auth.install'))
69 |
70 | user.assign_role(admin_role)
71 |
72 | flash("Admin user created successfully.", 'success')
73 | return redirect(url_for('index.root'))
74 |
75 | except Exception as e:
76 | flash(str(e), 'error')
77 |
78 | return render_template('install.html', form=form)
79 |
80 | @auth.route('/logout')
81 | @login_required
82 | def logout():
83 | logout_user()
84 | return redirect(url_for('index.root'))
--------------------------------------------------------------------------------
/src/app/static/js/base.js:
--------------------------------------------------------------------------------
1 | const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
2 | const spinner = document.querySelector('.loading-spinner');
3 | const actions = document.querySelector('.actions');
4 |
5 | function handleResponse(response, returnUrl) {
6 | if (response.ok) {
7 | const contentType = response.headers.get('content-type');
8 | if (contentType && contentType.includes('application/json')) {
9 | response.json().then(data => {
10 | if (data && data.message) {
11 | localStorage.setItem('flash_message', data.message);
12 | }
13 | });
14 | } else {
15 | localStorage.setItem('flash_message', 'Success!');
16 | }
17 | localStorage.setItem('flash_type', 'success');
18 | if (returnUrl) {
19 | window.location.href = returnUrl;
20 | return;
21 | }
22 | } else if (response.status === 403) {
23 | localStorage.setItem('flash_message', 'You do not have permission to perform this action.');
24 | localStorage.setItem('flash_type', 'error');
25 | } else {
26 | localStorage.setItem('flash_message', 'Failed to perform action.');
27 | localStorage.setItem('flash_type', 'error');
28 | }
29 |
30 | window.location.reload();
31 | }
32 |
33 | function handleError(error) {
34 | localStorage.setItem('flash_message', `An error occurred: ${error}`);
35 | localStorage.setItem('flash_type', 'error');
36 | window.location.reload();
37 | }
38 |
39 | document.querySelector('#user-icon').addEventListener('click', function() {
40 | document.querySelector('.user-panel').classList.toggle('open');
41 | });
42 |
43 | const flashMessage = localStorage.getItem('flash_message');
44 | const flashType = localStorage.getItem('flash_type');
45 |
46 | if (flashMessage) {
47 | let flashContainer = document.querySelector('.flash-messages');
48 | if (!flashContainer) {
49 | flashContainer = document.createElement('div');
50 | flashContainer.className = 'flash-messages';
51 | document.body.appendChild(flashContainer);
52 | }
53 |
54 | const flashElement = document.createElement('div');
55 | flashElement.className = `flash-message ${flashType}`;
56 | flashElement.textContent = flashMessage;
57 |
58 | flashContainer.appendChild(flashElement);
59 |
60 | localStorage.removeItem('flash_message');
61 | localStorage.removeItem('flash_type');
62 | }
63 |
64 | const refresh_btn = document.getElementById('refresh-page-btn');
65 | if (refresh_btn != null) {
66 | refresh_btn.addEventListener('click', function() {
67 | location.reload();
68 | });
69 | }
70 |
71 | function disableAllActions() {
72 | const disable_on_load = document.querySelectorAll('.disable-on-load');
73 | disable_on_load.forEach(element => {
74 | element.classList.add('disabled');
75 | });
76 | }
77 |
78 | function enableAllActions() {
79 | const disable_on_load = document.querySelectorAll('.disable-on-load');
80 | disable_on_load.forEach(element => {
81 | element.classList.remove('disabled');
82 | });
83 | }
--------------------------------------------------------------------------------
/src/app/modules/settings/templates/settings/table.html:
--------------------------------------------------------------------------------
1 | {% extends "main.html" %}
2 |
3 | {% block custom_header %}
4 | {% assets "settings_css" %}
5 |
6 | {% endassets %}
7 | {% assets "settings_js" %}
8 |
9 | {% endassets %}
10 | {% endblock %}
11 |
12 | {% block content %}
13 |
14 |
Global Settings
15 |
79 |
80 | {% endblock %}
81 |
--------------------------------------------------------------------------------
/src/app/modules/user/templates/user/profile.html:
--------------------------------------------------------------------------------
1 | {% extends "main.html" %}
2 |
3 | {% block custom_header %}
4 |
5 |
6 | {% assets "user_js_profile" %}
7 |
8 | {% endassets %}
9 | {% assets "user_css" %}
10 |
11 | {% endassets %}
12 | {% endblock %}
13 |
14 | {% block content %}
15 |
16 |
26 |
27 |
Created At: {{ common.format_unix_timestamp(current_user.created_at) }}
28 |
29 |
30 |
31 |
32 |
Security
33 |
Change password
34 |
68 |
69 |
70 |
71 |
Personal settings
72 |
88 |
89 |
90 | {% endblock %}
91 |
--------------------------------------------------------------------------------
/src/app/modules/main/image/templates/image/info.html:
--------------------------------------------------------------------------------
1 | {% extends "main.html" %}
2 |
3 | {% block custom_header %}
4 | {% assets "image_js" %}
5 |
6 | {% endassets %}
7 | {% endblock %}
8 |
9 | {% block content %}
10 | {% if image %}
11 |
12 |
13 |
14 |
{{ image.repo_tags[0] }}
15 |
22 |
23 |
24 |
Architecture: {{ image.general_info.architecture }}
25 |
Docker Version: {{ image.general_info.docker_version }}
26 |
OS: {{ image.general_info.os }}
27 |
Created At: {{ image.general_info.created_at }}
28 |
Size: {{ image.general_info.size }} MB
29 |
Command: {{ image.cmd }}
30 |
Entrypoint: {{ image.entrypoint }}
31 | {% if image.general_info.author %}
32 |
Author: {{ image.general_info.author }}
33 | {% else %}
34 |
Author: -
35 | {% endif %}
36 |
37 | {% if image.general_info.comment %}
38 |
Comment: {{ image.general_info.comment }}
39 | {% else %}
40 |
Comment: -
41 | {% endif %}
42 |
43 |
44 |
45 |
46 |
Environment Variables
47 | {% if image.env_vars %}
48 |
49 |
50 |
51 | {% for env in image.env_vars %}
52 |
53 | {{ env.split('=', 1)[0] }}
54 | {{ env.split('=', 1)[1] }}
55 |
56 | {% endfor %}
57 |
58 |
59 |
60 | {% else %}
61 |
No environment variables found.
62 | {% endif %}
63 |
64 |
65 |
66 |
67 |
Labels
68 | {% if image.labels %}
69 |
70 |
71 |
72 | {% for key, value in image.labels.items() %}
73 |
74 | {{ key }}
75 | {{ value }}
76 |
77 | {% endfor %}
78 |
79 |
80 |
81 | {% else %}
82 |
No labels found.
83 | {% endif %}
84 |
85 | {% endif %}
86 | {% endblock %}
87 |
--------------------------------------------------------------------------------
/src/app/static/styles/sidebar.css:
--------------------------------------------------------------------------------
1 | .sidebar {
2 | width: 15rem;
3 | min-width: 15rem;
4 | background-color: var(--bg-color-box);
5 | box-shadow: 0 0 10px 6px rgba(0, 0, 0, 0.1);
6 | display: flex;
7 | flex-direction: column;
8 | transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
9 | overflow-x: hidden;
10 | border-top-right-radius: 10px;
11 | border-bottom-right-radius: 10px;
12 | }
13 |
14 | .sidebar-header {
15 | display: flex;
16 | align-items: center;
17 | padding: 1rem;
18 | border-bottom: 1px solid var(--accent-color);
19 | color: #ffffff;
20 | }
21 |
22 | .sidebar-header h2 {
23 | margin: 0;
24 | color: var(--text-color);
25 | }
26 |
27 | .sidebar-header a:hover {
28 | text-decoration: none;
29 | }
30 |
31 | .sidebar .bottom {
32 | display: flex;
33 | flex-direction: column;
34 | align-items: center;
35 | padding: 1rem;
36 | }
37 |
38 | .menu-toggle {
39 | font-size: 0;
40 | background: none;
41 | border: none;
42 | cursor: pointer;
43 | color: var(--text-color);
44 | transition: transform 0.3s ease;
45 | }
46 |
47 | .sidebar.closed {
48 | width: 4.125rem;
49 | min-width: 4.125rem;
50 | }
51 |
52 | .sidebar.closed .bottom{
53 | visibility: hidden;
54 | }
55 |
56 | .sidebar-title {
57 | margin-left: 1rem;
58 | }
59 |
60 | .sidebar.closed .menu-toggle svg rect:nth-child(1) {
61 | width: 8px;
62 | }
63 | .sidebar.closed .menu-toggle svg rect:nth-child(2) {
64 | width: 12px;
65 | }
66 |
67 | .nav-menu {
68 | flex-grow: 1;
69 | }
70 |
71 | .nav-menu ul {
72 | padding: 0;
73 | margin: 0;
74 | }
75 |
76 | .nav-menu li a:hover,
77 | .nav-menu li.active a {
78 | background-color: var(--bg-color-darker);
79 | }
80 |
81 | .nav-menu a {
82 | text-decoration: none;
83 | color: var(--text-color);
84 | display: flex;
85 | align-items: center;
86 | padding: 0.5rem;
87 | margin: 0.5rem;
88 | border-radius: 10px;
89 | transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
90 | }
91 |
92 | .nav-text {
93 | margin-left: 1rem;
94 | }
95 |
96 | /* Mobile Bottom Navigation */
97 | @media (max-width: 768px) {
98 | .sidebar {
99 | position: fixed;
100 | bottom: 0;
101 | left: 0;
102 | right: 0;
103 | width: 100%;
104 | min-width: unset;
105 | border-radius: 0;
106 | border-top-left-radius: 20px;
107 | border-top-right-radius: 20px;
108 | box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.15);
109 | z-index: 1000;
110 |
111 | }
112 |
113 | .sidebar.closed {
114 | width: 100%;
115 | min-width: unset;
116 | }
117 |
118 | /* Hide desktop-only elements */
119 | .sidebar-header,
120 | .sidebar .bottom,
121 | .menu-toggle {
122 | display: none;
123 | }
124 |
125 | .nav-menu ul {
126 | display: flex;
127 | justify-content: space-around;
128 | align-items: center;
129 | width: 100%;
130 | }
131 |
132 | .nav-menu li {
133 | flex: 1;
134 | }
135 |
136 | .nav-menu li a {
137 | flex-direction: column;
138 | align-items: center;
139 | justify-content: center;
140 | padding: 0.5rem;
141 | margin: 0;
142 | gap: 0.25rem;
143 | min-width: min-content;
144 | border-radius: 12px;
145 | }
146 |
147 | .nav-menu li.active a {
148 | border-bottom-left-radius: 0;
149 | border-bottom-right-radius: 0;
150 | }
151 |
152 | .nav-text {
153 | display: none;
154 | }
155 | }
--------------------------------------------------------------------------------
/src/app/modules/main/dashboard/templates/dashboard.html:
--------------------------------------------------------------------------------
1 | {% extends "main.html" %}
2 |
3 | {% block custom_header %}
4 | {% assets "dashboard_css" %}
5 |
6 | {% endassets %}
7 | {% assets "dashboard_js" %}
8 |
9 | {% endassets %}
10 |
13 | {% endblock %}
14 |
15 | {% block content %}
16 |
17 |
System Info
18 |
OSType: {{ info['OSType'] }}
19 |
Operating System: {{ info['OperatingSystem'] }}
20 |
Hostname: {{ info['Name'] }}
21 |
Containers: {{ info['Containers'] }}
22 |
Images: {{ info['Images'] }}
23 |
Docker root dir: {{ info['DockerRootDir'] }}
24 |
More
25 |
26 |
27 |
28 |
System Load
29 |
30 |
31 |
32 |
CPU
33 |
36 |
0%
37 |
38 |
39 |
RAM
40 |
43 |
-
44 |
45 |
46 |
47 |
48 |
Load Average
49 |
50 |
51 |
52 | 0.00
53 | 0.00
54 | 0.00
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
83 |
84 | {% if show_update_notification %}
85 |
86 |
×
87 |
New Version Available!
88 |
89 | A new version of Containery is available: {{ latest_version }}
90 | You are running: {{ installed_version }}
91 |
92 |
93 | View Release Notes
94 |
95 |
96 | {% endif %}
97 | {% endblock %}
--------------------------------------------------------------------------------
/src/app/static/styles/inputs.css:
--------------------------------------------------------------------------------
1 | /* Base input styles */
2 | input,
3 | button.full {
4 | width: 100%;
5 | padding: 10px;
6 | height: 5vh;
7 | border: 1px solid var(--accent-color);
8 | border-radius: 10px;
9 | box-sizing: border-box;
10 | background: var(--bg-color);
11 | color: var(--text-color);
12 | font-family: inherit;
13 | font-size: 1rem;
14 | outline: none;
15 | transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
16 | }
17 |
18 | input[type=number]::-webkit-inner-spin-button,
19 | input[type=number]::-webkit-outer-spin-button {
20 | -webkit-appearance: none;
21 | }
22 |
23 | /* Submit buttons and full-width buttons */
24 | input[type="submit"],
25 | button.full {
26 | background: var(--accent-color);
27 | color: var(--bg-color);
28 | border: none;
29 | font-size: medium;
30 | cursor: pointer;
31 | }
32 |
33 | input[type="submit"]:hover,
34 | button.full:hover {
35 | background: var(--accent-color-darken);
36 | }
37 |
38 | /* Disabled states */
39 | input[type="text"][disabled],
40 | input[type="submit"][disabled],
41 | input[type="submit"][disabled]:hover,
42 | button[disabled] {
43 | border: 1px solid grey;
44 | background-color: grey;
45 | cursor: not-allowed;
46 | }
47 |
48 | input[type="search"]::-webkit-search-cancel-button {
49 | -webkit-appearance: none;
50 | height: 16px;
51 | width: 16px;
52 | background: url('data:image/svg+xml;utf8, ') no-repeat center;
53 | cursor: pointer;
54 | opacity: 0.7;
55 | }
56 |
57 | input[type="search"]::-webkit-search-cancel-button:hover {
58 | opacity: 1;
59 | }
60 |
61 | /* Custom checkbox toggle */
62 | input[type="checkbox"] {
63 | display: none;
64 | }
65 |
66 | input[type="checkbox"] + label {
67 | position: relative;
68 | display: inline-block;
69 | width: 40px;
70 | height: 20px;
71 | background-color: var(--bg-color);
72 | border: 1px solid var(--accent-color);
73 | border-radius: 20px;
74 | vertical-align: middle;
75 | cursor: pointer;
76 | transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
77 | }
78 |
79 | input[type="checkbox"] + label:before {
80 | content: "";
81 | position: absolute;
82 | width: 16px;
83 | height: 16px;
84 | left: 2px;
85 | bottom: 2px;
86 | background-color: var(--text-color);
87 | border-radius: 50%;
88 | transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
89 | }
90 |
91 | input[type="checkbox"]:checked + label {
92 | background-color: var(--accent-color);
93 | }
94 |
95 | input[type="checkbox"]:checked + label:before {
96 | transform: translateX(20px);
97 | background-color: var(--bg-color);
98 | }
99 |
100 | input[type="checkbox"][disabled] + label {
101 | background-color: #ccc;
102 | border: none;
103 | cursor: not-allowed;
104 | }
105 |
106 | input[type="checkbox"][disabled] + label:before {
107 | background-color: #ddd;
108 | }
109 |
110 | input[type="checkbox"][disabled]:checked + label {
111 | background-color: #bbb;
112 | }
113 |
114 | input[type="checkbox"][disabled]:checked + label:before {
115 | background-color: #999;
116 | transform: translateX(20px);
117 | }
118 |
119 | /* Custom select library overrides */
120 | .ss-option:hover {
121 | transition: background-color var(--ss-animation-timing) cubic-bezier(0.25, 0.8, 0.25, 1) !important;
122 | border-left: none !important;
123 | }
124 |
125 | .ss-content {
126 | transition: transform var(--ss-animation-timing) cubic-bezier(0.25, 0.8, 0.25, 1),
127 | opacity var(--ss-animation-timing) cubic-bezier(0.25, 0.8, 0.25, 1) !important;
128 | }
--------------------------------------------------------------------------------
/src/app/modules/user/templates/user/view_profile.html:
--------------------------------------------------------------------------------
1 | {% extends "main.html" %}
2 |
3 | {% block custom_header %}
4 |
5 |
6 | {% assets "user_css" %}
7 |
8 | {% endassets %}
9 | {% assets "user_js_user_profile" %}
10 |
11 | {% endassets %}
12 | {% endblock %}
13 |
14 | {% block content %}
15 |
16 |
17 |
18 |
{{ user.username }}
19 |
20 |
25 |
26 |
Roles
27 |
28 | {% for role in user.get_roles() %}
29 |
30 |
{{ role.name }}
31 |
38 |
39 | {% endfor %}
40 |
41 |
48 | {% if current_user.id == user.id %}
49 |
Edit Your Profile
50 | {% endif %}
51 |
52 |
53 | {% if current_user.id != user.id %}
54 |
55 |
Security
56 |
Change user's password
57 |
92 |
93 | {% endif %}
94 |
95 | {% endblock %}
96 |
--------------------------------------------------------------------------------
/src/app/modules/main/network/templates/network/info.html:
--------------------------------------------------------------------------------
1 | {% extends "main.html" %}
2 |
3 | {% block custom_header %}
4 | {% assets "network_js" %}
5 |
6 | {% endassets %}
7 | {% endblock %}
8 |
9 | {% block content %}
10 | {% if network %}
11 |
12 |
13 |
14 |
{{ network.Name }}
15 |
22 |
23 |
24 |
Created At: {{ network.Created }}
25 |
Scope: {{ network.Scope }}
26 |
Driver: {{ network.Driver }}
27 |
Enable IPv6: {{ network.EnableIPv6 }}
28 |
Internal: {{ network.Internal }}
29 |
Attachable: {{ network.Attachable }}
30 |
Ingress: {{ network.Ingress }}
31 |
32 |
33 |
Network Configuration
34 | {% if network.IPAM %}
35 | {% for config in network.IPAM %}
36 |
Subnet: {{ config[0] }}
37 |
Gateway: {{ config[1] }}
38 | {% endfor %}
39 | {% else %}
40 |
No network configuration found.
41 | {% endif %}
42 |
43 |
44 |
45 |
46 |
47 |
Containers
48 | {% if network.Containers %}
49 |
50 |
51 |
52 |
53 | Container
54 | IPv4 Address
55 | MAC Address
56 |
57 |
58 |
59 | {% for container_id, container_info in network.Containers.items() %}
60 |
61 | {{ container_info.Name }}
62 | {{ container_info.IPv4Address }}
63 | {{ container_info.MacAddress }}
64 |
65 | {% endfor %}
66 |
67 |
68 |
69 | {% else %}
70 |
No containers found in this network.
71 | {% endif %}
72 |
73 |
74 |
75 |
76 |
77 |
Labels
78 | {% if network.Labels %}
79 |
80 |
81 |
82 | {% for key, value in network.Labels.items() %}
83 |
84 | {{ key }}
85 | {{ value }}
86 |
87 | {% endfor %}
88 |
89 |
90 |
91 | {% else %}
92 |
No labels found.
93 | {% endif %}
94 |
95 | {% endif %}
96 | {% endblock %}
--------------------------------------------------------------------------------
/src/app/modules/user/forms.py:
--------------------------------------------------------------------------------
1 | from flask_wtf import FlaskForm
2 | from wtforms import BooleanField, SubmitField, SelectField, PasswordField, StringField, FieldList, FormField, HiddenField
3 | from wtforms.validators import DataRequired, EqualTo, Length
4 |
5 | class PersonalSettingsForm(FlaskForm):
6 | theme = SelectField(
7 | 'Theme',
8 | choices=[('light', 'Light'), ('dark', 'Dark'), ('dark_mixed', 'Dark Mixed'), ('system', 'System')],
9 | default='system'
10 | )
11 | submit = SubmitField('Save Changes', name='submit_settings')
12 |
13 | class ChangeOwnPasswordForm(FlaskForm):
14 | def __init__(self, password_min_length=8, *args, **kwargs):
15 | super().__init__(*args, **kwargs)
16 | # Adjust the minimum password length dynamically
17 | self.new_password.validators.append(
18 | Length(min=password_min_length, message=f"Password must be at least {password_min_length} characters long.")
19 | )
20 |
21 | current_password = PasswordField('Current Password', validators=[DataRequired()])
22 | new_password = PasswordField('New Password', validators=[DataRequired()])
23 | confirm_new_password = PasswordField(
24 | 'Confirm New Password',
25 | validators=[DataRequired(), EqualTo('new_password', message='Passwords must match.')]
26 | )
27 | submit = SubmitField('Change Password', name='submit_password')
28 |
29 | class ChangeUserPasswordForm(FlaskForm):
30 | def __init__(self, password_min_length=8, *args, **kwargs):
31 | super().__init__(*args, **kwargs)
32 | self.new_password.validators.append(
33 | Length(min=password_min_length, message=f"Password must be at least {password_min_length} characters long.")
34 | )
35 |
36 | new_password = PasswordField('New Password', validators=[DataRequired()])
37 | confirm_new_password = PasswordField(
38 | 'Confirm New Password',
39 | validators=[DataRequired(), EqualTo('new_password', message='Passwords must match.')]
40 | )
41 | submit = SubmitField('Change Password', name='submit_user_password')
42 |
43 | class AddUserRoleForm(FlaskForm):
44 | role = SelectField(
45 | 'Role',
46 | choices=[],
47 | validators=[DataRequired()]
48 | )
49 | submit = SubmitField('Add', name='Add_role')
50 |
51 | def set_role_choices(self, roles):
52 | if roles:
53 | self.role.choices = [(role.id, role.name) for role in roles]
54 | else:
55 | self.role.choices = [('', 'No available roles')]
56 | self.submit.render_kw = {'disabled': 'disabled'}
57 |
58 | class AddUserForm(FlaskForm):
59 | def __init__(self, password_min_length=8, *args, **kwargs):
60 | super().__init__(*args, **kwargs)
61 | self.password.validators.append(
62 | Length(min=password_min_length, message=f"Password must be at least {password_min_length} characters long.")
63 | )
64 |
65 | username = StringField(
66 | 'Username',
67 | validators=[DataRequired(), Length(max=24, min=1)],
68 | render_kw={'autocomplete': 'off'}
69 | )
70 | password = PasswordField(
71 | 'Password',
72 | validators=[DataRequired()],
73 | render_kw={'autocomplete': 'new-password'}
74 | )
75 | role = SelectField(
76 | 'Role',
77 | choices=[],
78 | validators=[DataRequired()]
79 | )
80 | submit = SubmitField('Create User', name='create_user')
81 |
82 | def set_role_choices(self, roles):
83 | self.role.choices = [(role.id, role.name) for role in roles]
84 |
85 | class PermissionForm(FlaskForm):
86 | enabled = BooleanField('Enabled')
87 | permission_value = HiddenField()
88 |
89 | class RoleForm(FlaskForm):
90 | name = StringField(
91 | 'Role Name',
92 | validators=[DataRequired()],
93 | render_kw={
94 | 'placeholder': 'Role name',
95 | 'autocomplete': 'off'
96 | }
97 | )
98 | permissions = FieldList(FormField(PermissionForm), label='Permissions')
99 | submit = SubmitField('Save')
--------------------------------------------------------------------------------
/src/app/modules/main/image/routes.py:
--------------------------------------------------------------------------------
1 | from flask import render_template, url_for
2 |
3 | import json
4 |
5 | from app.modules.user.models import Permissions
6 | from app.core.decorators import permission
7 | from app.lib.common import format_docker_timestamp, format_unix_timestamp
8 |
9 | from app.core.extensions import docker
10 |
11 | from . import image
12 |
13 | def image_info(id):
14 | response, status_code = docker.inspect_image(id)
15 | image_details = []
16 | if status_code not in range(200, 300):
17 | return response, status_code
18 | else:
19 | image_details = response.json()
20 |
21 | general_info = {
22 | "id": id,
23 | "architecture": image_details["Architecture"],
24 | "docker_version": image_details["DockerVersion"],
25 | "os": image_details["Os"],
26 | "created_at": format_docker_timestamp(image_details["Created"]),
27 | "size": round(image_details["Size"] / 1024 / 1024, 2),
28 | "author": image_details.get("Author", ""),
29 | "comment": image_details.get("Comment", "")
30 | }
31 |
32 | env_vars = image_details["Config"].get("Env", [])
33 |
34 | labels = image_details["Config"].get("Labels", {})
35 |
36 | repo_tags = image_details.get("RepoTags", [])
37 |
38 | entrypoint = image_details["Config"].get("Entrypoint", [])
39 |
40 | cmd = image_details["Config"].get("Cmd", [])
41 |
42 | image = {
43 | 'general_info': general_info,
44 | 'env_vars': env_vars,
45 | 'labels': labels,
46 | 'repo_tags': repo_tags,
47 | 'entrypoint': entrypoint,
48 | 'cmd': cmd
49 | }
50 |
51 | return image, 200
52 |
53 | def image_name(id):
54 | response, status_code = image_info(id)
55 | return response['repo_tags'][0] if 'repo_tags' in response and response['repo_tags'] and status_code in range(200, 300) else "Unamed Image"
56 |
57 | @image.route('/list', methods=['GET'])
58 | @permission(Permissions.IMAGE_VIEW_LIST)
59 | def get_list():
60 | response, status_code = docker.get_images()
61 | images = []
62 | if status_code not in range(200, 300):
63 | message = response.text if hasattr(response, 'text') else str(response)
64 | try:
65 | message = json.loads(message).get('message', message)
66 | except json.JSONDecodeError:
67 | pass
68 | return render_template('error.html', message=message, code=status_code), status_code
69 | else:
70 | images = response.json()
71 |
72 | rows = []
73 | for image in images:
74 | row = {
75 | 'id': image['Id'],
76 | 'created': format_unix_timestamp(image['Created']),
77 | 'repo_tags': ', '.join(image['RepoTags']) if image.get('RepoTags') else 'N/A',
78 | 'size': round(image['Size'] / 1024 / 1024, 2)
79 | }
80 | rows.append(row)
81 |
82 | rows = sorted(rows, key=lambda x: x['repo_tags'], reverse=True)
83 |
84 | breadcrumbs = [
85 | {"name": "Dashboard", "url": url_for('main.dashboard.index')},
86 | {"name": "Images", "url": None},
87 | ]
88 | page_title = "Images List"
89 | endpoint = "image"
90 | return render_template('image/table.html', rows=rows, breadcrumbs=breadcrumbs, page_title=page_title)
91 |
92 | @image.route('/', methods=['GET'])
93 | @permission(Permissions.IMAGE_INFO)
94 | def info(id):
95 | response, status_code = image_info(id)
96 | image = []
97 | if status_code not in range(200, 300):
98 | message = response.text if hasattr(response, 'text') else str(response)
99 | try:
100 | message = json.loads(message).get('message', message)
101 | except json.JSONDecodeError:
102 | pass
103 | return render_template('error.html', message=message, code=status_code), status_code
104 | else:
105 | image = response
106 |
107 | breadcrumbs = [
108 | {"name": "Dashboard", "url": url_for('main.dashboard.index')},
109 | {"name": "Images", "url": url_for('main.image.get_list')},
110 | {"name": image_name(id), "url": None},
111 | ]
112 | page_title = 'Image Details'
113 |
114 | return render_template('image/info.html', image=image, breadcrumbs=breadcrumbs, page_title=page_title)
--------------------------------------------------------------------------------
/src/app/modules/main/network/routes.py:
--------------------------------------------------------------------------------
1 | from flask import render_template, url_for
2 |
3 | import json
4 |
5 | from app.core.extensions import docker
6 | from app.lib.common import format_docker_timestamp
7 |
8 | from app.core.decorators import permission
9 | from app.modules.user.models import Permissions
10 |
11 | from . import network
12 |
13 | def network_info(id):
14 | response, status_code = docker.inspect_network(id)
15 | network_details = []
16 | if status_code not in range(200, 300):
17 | return response, status_code
18 | else:
19 | network_details = response.json()
20 |
21 | # Extracting general network information
22 | network = {
23 | 'Name': network_details["Name"],
24 | 'Id': network_details["Id"],
25 | 'Created': format_docker_timestamp(network_details["Created"]),
26 | 'Scope': network_details["Scope"],
27 | 'Driver': network_details["Driver"],
28 | 'EnableIPv6': network_details["EnableIPv6"],
29 | 'Internal': network_details["Internal"],
30 | 'Attachable': network_details["Attachable"],
31 | 'Ingress': network_details["Ingress"],
32 | 'Containers': network_details.get("Containers", {}),
33 | 'Labels': network_details.get("Labels", {}),
34 | 'IPAM': [],
35 | }
36 |
37 | subnets_gateways = []
38 | if 'IPAM' in network_details and network_details['IPAM']['Config']:
39 | for config in network_details['IPAM']['Config']:
40 | subnet = config.get('Subnet')
41 | gateway = config.get('Gateway')
42 | subnets_gateways.append((subnet, gateway))
43 | network['IPAM'] = subnets_gateways
44 |
45 | return network, 200
46 |
47 | @network.route('/list', methods=['GET'])
48 | @permission(Permissions.NETWORK_VIEW_LIST)
49 | def get_list():
50 | response, status_code = docker.get_networks()
51 | networks = []
52 | if status_code not in range(200, 300):
53 | message = response.text if hasattr(response, 'text') else str(response)
54 | try:
55 | message = json.loads(message).get('message', message)
56 | except json.JSONDecodeError:
57 | pass
58 | return render_template('error.html', message=message, code=status_code), status_code
59 | else:
60 | networks = response.json()
61 |
62 | rows = []
63 | for network in networks:
64 | row = {
65 | 'id': network['Id'],
66 | 'name': network['Name'],
67 | 'driver': network['Driver'],
68 | 'subnet': network['IPAM']['Config'][0]['Subnet'] if network['IPAM']['Config'] else 'N/A',
69 | 'gateway': network['IPAM']['Config'][0]['Gateway'] if network['IPAM']['Config'] else 'N/A',
70 | }
71 | rows.append(row)
72 |
73 | rows = sorted(rows, key=lambda x: x['name'], reverse=True)
74 |
75 | breadcrumbs = [
76 | {"name": "Dashboard", "url": url_for('main.dashboard.index')},
77 | {"name": "Networks", "url": None},
78 | ]
79 | page_title = "Networks List"
80 | endpoint = "network"
81 | return render_template('network/table.html', rows=rows, breadcrumbs=breadcrumbs, page_title=page_title)
82 |
83 | @network.route('/', methods=['GET'])
84 | @permission(Permissions.NETWORK_INFO)
85 | def info(id):
86 | response, status_code = network_info(id)
87 | network = []
88 | if status_code not in range(200, 300):
89 | message = response.text if hasattr(response, 'text') else str(response)
90 | try:
91 | message = json.loads(message).get('message', message)
92 | except json.JSONDecodeError:
93 | pass
94 | return render_template('error.html', message=message, code=status_code), status_code
95 | else:
96 | network = response
97 |
98 | breadcrumbs = [
99 | {"name": "Dashboard", "url": url_for('main.dashboard.index')},
100 | {"name": "Networks", "url": url_for('main.network.get_list')},
101 | {"name": network['Name'], "url": None},
102 | ]
103 | page_title = 'Network Details'
104 |
105 | return render_template('network/info.html', network=network, breadcrumbs=breadcrumbs, page_title=page_title)
106 |
107 | @network.route('//delete', methods=['DELETE'])
108 | @permission(Permissions.NETWORK_DELETE)
109 | def delete(id):
110 | response, status_code = docker.delete_network(id)
111 | return str(response), status_code
--------------------------------------------------------------------------------
/src/app/static/styles/colors.css:
--------------------------------------------------------------------------------
1 | :root {
2 | /* Base colors */
3 | --accent-dark: #66bfff;
4 | --accent-dark-darken: #33aaff;
5 | --accent-light: #0077ff;
6 | --accent-light-darken: #3392FF;
7 |
8 | --success: #28a745;
9 | --error: #f44336;
10 | --status-text: #ffffff;
11 |
12 | /* Dark theme */
13 | --bg-dark: #121212;
14 | --bg-box-dark: #282828;
15 | --bg-transparent-dark: rgba(0, 0, 0, 0.4);
16 | --text-dark: #f5f5f5;
17 |
18 | /* Light theme */
19 | --bg-light: #f5f5f5;
20 | --bg-darker-light: #d3d3d3;
21 | --bg-transparent-light: rgba(255, 255, 255, 0.4);
22 | --text-light: #333;
23 |
24 | /* Mixed dark theme */
25 | --bg-mixed: #1b1f26;
26 | --bg-box-mixed: #30343a;
27 | }
28 |
29 | /* System theme - respects OS preference */
30 | @media (prefers-color-scheme: dark) {
31 | body.system {
32 | --bg-color: var(--bg-dark);
33 | --bg-color-darker: var(--bg-dark);
34 | --bg-color-box: var(--bg-box-dark);
35 | --bg-color-transparent: var(--bg-transparent-dark);
36 | --text-color: var(--text-dark);
37 | --accent-color: var(--accent-dark);
38 | --accent-color-darken: var(--accent-dark-darken);
39 | --info-bg: var(--accent-dark);
40 |
41 | /* Custom select colors */
42 | --ss-primary-color: var(--accent-color) !important;
43 | --ss-border-color: var(--accent-color) !important;
44 | --ss-bg-color: var(--bg-color) !important;
45 | --ss-font-color: var(--text-color) !important;
46 | }
47 | }
48 |
49 | @media (prefers-color-scheme: light) {
50 | body.system {
51 | --bg-color: var(--bg-light);
52 | --bg-color-darker: var(--bg-darker-light);
53 | --bg-color-box: var(--bg-light);
54 | --bg-color-transparent: var(--bg-transparent-light);
55 | --text-color: var(--text-light);
56 | --accent-color: var(--accent-light);
57 | --accent-color-darken: var(--accent-light-darken);
58 | --info-bg: var(--accent-light);
59 |
60 | /* Custom select colors */
61 | --ss-primary-color: var(--accent-color) !important;
62 | --ss-border-color: var(--accent-color) !important;
63 | --ss-bg-color: var(--bg-color) !important;
64 | --ss-font-color: var(--text-color) !important;
65 | }
66 | }
67 |
68 | /* Manual theme overrides */
69 | body.light {
70 | --bg-color: var(--bg-light);
71 | --bg-color-darker: var(--bg-darker-light);
72 | --bg-color-box: var(--bg-light);
73 | --bg-color-transparent: var(--bg-transparent-light);
74 | --text-color: var(--text-light);
75 | --accent-color: var(--accent-light);
76 | --accent-color-darken: var(--accent-light-darken);
77 | --info-bg: var(--accent-light);
78 |
79 | /* Custom select colors */
80 | --ss-primary-color: var(--accent-color) !important;
81 | --ss-border-color: var(--accent-color) !important;
82 | --ss-bg-color: var(--bg-color) !important;
83 | --ss-font-color: var(--text-color) !important;
84 | }
85 |
86 | body.dark {
87 | --bg-color: var(--bg-dark);
88 | --bg-color-darker: var(--bg-dark);
89 | --bg-color-box: var(--bg-box-dark);
90 | --bg-color-transparent: var(--bg-transparent-dark);
91 | --text-color: var(--text-dark);
92 | --accent-color: var(--accent-dark);
93 | --accent-color-darken: var(--accent-dark-darken);
94 | --info-bg: var(--accent-dark);
95 |
96 | /* Custom select colors */
97 | --ss-primary-color: var(--accent-color) !important;
98 | --ss-border-color: var(--accent-color) !important;
99 | --ss-bg-color: var(--bg-color) !important;
100 | --ss-font-color: var(--text-color) !important;
101 | }
102 |
103 | body.dark_mixed {
104 | --bg-color: var(--bg-mixed);
105 | --bg-color-darker: var(--bg-dark);
106 | --bg-color-box: var(--bg-box-mixed);
107 | --bg-color-transparent: var(--bg-transparent-dark);
108 | --text-color: var(--text-dark);
109 | --accent-color: var(--accent-dark);
110 | --accent-color-darken: var(--accent-dark-darken);
111 | --info-bg: var(--accent-dark);
112 |
113 | /* Custom select colors */
114 | --ss-primary-color: var(--accent-color) !important;
115 | --ss-border-color: var(--accent-color) !important;
116 | --ss-bg-color: var(--bg-color) !important;
117 | --ss-font-color: var(--text-color) !important;
118 | }
119 |
120 | /* Status colors - consistent across all themes */
121 | body {
122 | --success-bg: var(--success);
123 | --error-bg: var(--error);
124 | --success-text: var(--status-text);
125 | --error-text: var(--status-text);
126 | --info-text: var(--status-text);
127 | }
--------------------------------------------------------------------------------
/src/app/modules/user/models/role.py:
--------------------------------------------------------------------------------
1 | from app.core.extensions import db
2 |
3 | class Role(db.Model):
4 | __tablename__ = 'usr_role'
5 | id = db.Column(db.Integer, primary_key=True)
6 | name = db.Column(db.String(50), unique=True, nullable=False)
7 | created_at = db.Column(db.Integer, default=lambda: int(__import__('time').time()), nullable=False)
8 |
9 | user_roles = db.relationship('UserRole', back_populates='role', cascade='all, delete-orphan')
10 | permissions = db.relationship('RolePermission', back_populates='role', cascade='all, delete-orphan')
11 |
12 | @classmethod
13 | def create_role(cls, name):
14 | if not name or not name.strip():
15 | raise ValueError("Role name cannot be empty.")
16 | if len(name) > 20:
17 | raise ValueError("Role name must be 20 characters or less.")
18 | existing_role = cls.query.filter_by(name=name).first()
19 | if existing_role:
20 | raise ValueError(f"Role '{name}' already exists.")
21 | try:
22 | role = cls(name=name)
23 | db.session.add(role)
24 | db.session.commit()
25 | except Exception as e:
26 | db.session.rollback()
27 | raise RuntimeError(f"Failed to create role: {str(e)}")
28 | return role
29 |
30 | @classmethod
31 | def delete_role(cls, id):
32 | if not isinstance(id, int) or id <= 0:
33 | raise ValueError("Invalid role ID provided.")
34 | if id == 1:
35 | raise PermissionError("The admin role cannot be deleted.")
36 | role = cls.query.filter_by(id=id).first()
37 | if not role:
38 | raise LookupError(f"Role with ID '{id}' not found.")
39 | try:
40 | db.session.delete(role)
41 | db.session.commit()
42 | except Exception as e:
43 | db.session.rollback()
44 | raise RuntimeError(f"Failed to delete role: {str(e)}")
45 |
46 | @classmethod
47 | def get_roles(cls):
48 | return cls.query.all()
49 |
50 | @classmethod
51 | def get_role(cls, id):
52 | if not isinstance(id, int) or id <= 0:
53 | raise ValueError("Invalid role ID provided.")
54 |
55 | role = cls.query.filter_by(id=id).first()
56 |
57 | if not role:
58 | raise LookupError(f"Role with ID '{id}' not found.")
59 |
60 | return role
61 |
62 | def rename(self, name):
63 | if not name or not isinstance(name, str):
64 | raise ValueError("Invalid role name")
65 | if len(name) > 20:
66 | raise ValueError("Role name must be 20 characters or less.")
67 | self.name = name
68 | db.session.commit()
69 |
70 | def get_user_count(self):
71 | if not self.id:
72 | raise ValueError("The role must have a valid ID.")
73 | user_count = UserRole.query.filter_by(role_id=self.id).count()
74 | return user_count
75 |
76 | def get_permissions(self):
77 | return [p.permission for p in self.permissions]
78 |
79 | def add_permission(self, permission):
80 | if not isinstance(permission, int):
81 | raise ValueError("Permission must be an integer value from Permissions.")
82 | if any(rp.permission == permission for rp in self.permissions):
83 | return
84 | new_permission = RolePermission(role_id=self.id, permission=permission)
85 | db.session.add(new_permission)
86 | db.session.commit()
87 |
88 | def get_permissions_values(self):
89 | return [rp.permission for rp in RolePermission.query.filter_by(role_id=self.id).all()]
90 |
91 | def remove_permission(self, permission):
92 | if not isinstance(permission, int):
93 | raise ValueError("Permission must be an integer value from Permissions.")
94 | role_permission = RolePermission.query.filter_by(role_id=self.id, permission=permission).first()
95 | if not role_permission:
96 | return
97 | db.session.delete(role_permission)
98 | db.session.commit()
99 |
100 | class RolePermission(db.Model):
101 | __tablename__ = 'usr_role_permission'
102 | id = db.Column(db.Integer, primary_key=True)
103 | role_id = db.Column(db.Integer, db.ForeignKey('usr_role.id'), nullable=False)
104 | permission = db.Column(db.Integer, nullable=False)
105 | role = db.relationship('Role', back_populates='permissions')
106 |
107 | class UserRole(db.Model):
108 | __tablename__ = 'usr_user_role'
109 | id = db.Column(db.Integer, primary_key=True)
110 | user_id = db.Column(db.Integer, db.ForeignKey('usr_user.id'), nullable=False)
111 | role_id = db.Column(db.Integer, db.ForeignKey('usr_role.id'), nullable=False)
112 | user = db.relationship('User', back_populates='user_roles')
113 | role = db.relationship('Role', back_populates='user_roles')
114 |
--------------------------------------------------------------------------------
/src/app/modules/main/container/static/js/container_list_settings.js:
--------------------------------------------------------------------------------
1 | let modalOpened = false;
2 |
3 | document.querySelectorAll('.settings-btn').forEach(button => {
4 | button.addEventListener('click', function() {
5 | openDraggableListModal();
6 | });
7 | });
8 |
9 | async function openDraggableListModal() {
10 | const url = '/container/list/settings';
11 | const method = 'GET';
12 |
13 | const spinner = document.querySelector('.loading-spinner');
14 | const disable_on_load = document.querySelector('.disable-on-load');
15 |
16 | spinner.classList.remove('hidden');
17 |
18 | if (disable_on_load) {
19 | disable_on_load.classList.add('disabled');
20 | }
21 |
22 | const response = await fetch(url, {
23 | method: method,
24 | headers: {
25 | 'Content-Type': 'application/json',
26 | 'X-CSRFToken': csrfToken
27 | },
28 | })
29 | .catch(error => handleError(error));
30 |
31 | if (response.ok) {
32 | const html = await response.text();
33 | const tempDiv = document.createElement('div');
34 | tempDiv.innerHTML = html;
35 |
36 | const modalContent = tempDiv.querySelector('#containerListSettingsModal');
37 | document.body.appendChild(modalContent);
38 |
39 | const draggableList = modalContent.querySelector('.draggable-list');
40 | enableHorizontalDrag(draggableList);
41 |
42 | const saveBtn = modalContent.querySelector('.btn.confirm');
43 | saveBtn.addEventListener('click', () => {
44 | const colomns = Array.from(draggableList.querySelectorAll('.item')).map(item => ({
45 | name: item.querySelector('span').textContent,
46 | enabled: item.querySelector('input').checked
47 | }));
48 | const quick_actions = Array.from(modalContent.querySelectorAll('.quick-action-item input')).map(input => ({
49 | name: input.dataset.action,
50 | enabled: input.checked,
51 | url: input.dataset.url || false
52 | }));
53 |
54 |
55 | const data = {
56 | columns: colomns,
57 | quick_actions: quick_actions
58 | };
59 |
60 | fetch('/container/list/settings', {
61 | method: 'POST',
62 | headers: {
63 | 'X-CSRFToken': csrfToken,
64 | 'Content-Type': 'application/json',
65 | },
66 | body: JSON.stringify(data)
67 | })
68 | .then(response => handleResponse(response))
69 | .catch(error => handleError(error));
70 | });
71 |
72 | const cancelBtn = modalContent.querySelector('.btn.cancel');
73 | cancelBtn.addEventListener('click', closeDraggableListModal);
74 |
75 | window.addEventListener('click', function (event) {
76 | if (event.target === modalContent) closeDraggableListModal();
77 | });
78 |
79 | spinner.classList.add('hidden');
80 |
81 | if (disable_on_load) {
82 | disable_on_load.classList.remove('disabled');
83 | }
84 | } else {
85 | handleError(new Error('Failed to fetch modal content'));
86 | }
87 |
88 | modalOpened = true;
89 | }
90 |
91 | function closeDraggableListModal() {
92 | const modal = document.getElementById('containerListSettingsModal');
93 | if (modal) modal.remove();
94 | modalOpened = false;
95 | }
96 |
97 | // === drag logic ===
98 | function enableHorizontalDrag(container) {
99 | let draggingEl = null;
100 |
101 | container.addEventListener('dragstart', e => {
102 | if (!e.target.classList.contains('item')) return;
103 | draggingEl = e.target;
104 | draggingEl.style.opacity = '0.5';
105 | });
106 |
107 | container.addEventListener('dragend', () => {
108 | if (draggingEl) draggingEl.style.opacity = '1';
109 | draggingEl = null;
110 | });
111 |
112 | container.addEventListener('dragover', e => {
113 | e.preventDefault();
114 | const afterElement = getDragAfterElement(container, e.clientX);
115 | if (!draggingEl) return;
116 | if (afterElement == null) container.appendChild(draggingEl);
117 | else container.insertBefore(draggingEl, afterElement);
118 | });
119 |
120 | function getDragAfterElement(container, x) {
121 | const draggableElements = [...container.querySelectorAll('.item:not([style*="opacity: 0.5"])')];
122 | return draggableElements.reduce((closest, child) => {
123 | const box = child.getBoundingClientRect();
124 | const offset = x - box.left - box.width / 2;
125 | if (offset < 0 && offset > closest.offset) return { offset, element: child };
126 | else return closest;
127 | }, { offset: Number.NEGATIVE_INFINITY }).element;
128 | }
129 | }
130 |
131 | document.addEventListener('keydown', e => {
132 | if (e.key === 'm' && !modalOpened) openDraggableListModal();
133 | });
134 |
135 | document.addEventListener('keydown', e => {
136 | if (e.key === 'Escape' && modalOpened) closeDraggableListModal();
137 | });
138 |
--------------------------------------------------------------------------------
/src/app/static/js/table.js:
--------------------------------------------------------------------------------
1 | // Mapping of table IDs to the columns used for searching
2 | const tables = {
3 | 'container-table': [0], // name
4 | 'image-table': [0], // name
5 | 'volume-table': [0], // name
6 | 'user-table': [0], // username
7 | 'roles-table': [0], // name
8 | 'network-table': [0, 2], // name, subnet
9 | 'process-table': [0, 1, 7] // UID, PID, CMD
10 | };
11 |
12 | function search(searchValue, currentTable) {
13 | if (currentTable !== null) {
14 | const rows = document.querySelectorAll(`#${currentTable} tbody tr`);
15 | let visibleRowCount = 0;
16 |
17 | rows.forEach(row => {
18 | const columnsToSearch = tables[currentTable];
19 | let match = false;
20 |
21 | columnsToSearch.forEach(colIndex => {
22 | const cellValue = row.cells[colIndex].textContent.toLowerCase();
23 | if (cellValue.includes(searchValue)) {
24 | match = true;
25 | }
26 | });
27 |
28 | if (match) {
29 | row.style.display = '';
30 | visibleRowCount++;
31 | } else {
32 | row.style.display = 'none';
33 | }
34 | });
35 |
36 | // Handle the no rows found case
37 | let noRowsMessage = document.getElementById('no-rows-message');
38 | if (visibleRowCount === 0) {
39 | if (!noRowsMessage) {
40 | noRowsMessage = document.createElement('tr');
41 | noRowsMessage.id = 'no-rows-message';
42 | noRowsMessage.innerHTML = `No matching records found
`;
43 | document.querySelector(`.content-box`).appendChild(noRowsMessage);
44 | }
45 | noRowsMessage.style.display = '';
46 | } else if (noRowsMessage) {
47 | noRowsMessage.style.display = 'none';
48 | }
49 | }
50 | }
51 |
52 | function sortTable(tableId) {
53 | const table = document.getElementById(tableId);
54 | const headers = table.querySelectorAll('th[data-sort]');
55 |
56 | headers.forEach(th => {
57 | th.addEventListener('click', function() {
58 | const tbody = table.querySelector('tbody');
59 | const rows = Array.from(tbody.querySelectorAll('tr'));
60 | const index = Array.from(th.parentNode.children).indexOf(th);
61 | const isAscending = th.classList.contains('ascending');
62 |
63 | // Remove sorting indicators from all columns
64 | headers.forEach(otherTh => {
65 | otherTh.classList.remove('ascending', 'descending');
66 | });
67 |
68 | // Toggle ascending/descending classes on the clicked column
69 | if (isAscending) {
70 | th.classList.remove('ascending');
71 | th.classList.add('descending');
72 | } else {
73 | th.classList.remove('descending');
74 | th.classList.add('ascending');
75 | }
76 |
77 | // Determine the new sort direction
78 | const sortAscending = th.classList.contains('ascending');
79 |
80 | // Sort rows based on the selected column
81 | rows.sort((a, b) => {
82 | const cellA = a.children[index].textContent.trim();
83 | const cellB = b.children[index].textContent.trim();
84 |
85 | if (!isNaN(cellA) && !isNaN(cellB)) {
86 | return sortAscending ? cellA - cellB : cellB - cellA;
87 | }
88 |
89 | return sortAscending ? cellA.localeCompare(cellB) : cellB.localeCompare(cellA);
90 | });
91 |
92 | // Re-append rows to the tbody in the new order
93 | rows.forEach(row => tbody.appendChild(row));
94 | });
95 | });
96 | }
97 |
98 | const searchField = document.getElementById('search');
99 | const searchReset = document.getElementById('search-reset');
100 |
101 | if (searchField) {
102 | let currentTable = null;
103 |
104 | for (const tableId in tables) {
105 | const tableElement = document.getElementById(tableId);
106 | if (tableElement !== null) {
107 | currentTable = tableId;
108 | break;
109 | }
110 | }
111 |
112 | if (searchReset) {
113 | searchReset.addEventListener('click', function() {
114 | searchField.value = '';
115 | localStorage.removeItem(`lastSearchValue_${currentTable}`);
116 | search(searchField.value, currentTable);
117 | });
118 | }
119 |
120 | if (currentTable !== null) {
121 | const lastSearchValue = localStorage.getItem(`lastSearchValue_${currentTable}`);
122 | if (lastSearchValue) {
123 | searchField.value = lastSearchValue;
124 | setTimeout(() => {
125 | search(lastSearchValue, currentTable);
126 | }, 300);
127 | }
128 | }
129 |
130 | searchField.addEventListener('input', function() {
131 | const searchValue = this.value.toLowerCase();
132 |
133 | search(searchValue, currentTable);
134 |
135 | localStorage.setItem(`lastSearchValue_${currentTable}`, searchValue);
136 | });
137 | }
138 |
139 | for (const tableId in tables) {
140 | const tableElement = document.getElementById(tableId);
141 | if (tableElement !== null) {
142 | sortTable(tableId);
143 | }
144 | }
--------------------------------------------------------------------------------
/src/app/templates/main.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Containery
8 |
9 |
10 | {% assets "app_css" %}
11 |
12 | {% endassets %}
13 | {% assets "app_js" %}
14 |
15 | {% endassets %}
16 | {% block custom_header %}
17 | {% endblock %}
18 |
19 |
20 |
21 |
78 |
79 |
80 |
81 |
82 | {% for crumb in breadcrumbs %}
83 | {% if crumb.url %}
84 | {{ crumb.name }} >
85 | {% else %}
86 | {{ crumb.name }}
87 | {% endif %}
88 | {% endfor %}
89 |
90 |
91 |
{{ page_title }}
92 | {% include 'icons/spiner.svg' %}
93 |
94 |
95 |
96 | {% include 'icons/user.svg' %}
97 |
103 |
104 |
105 |
106 | {% block content %}
107 | {% endblock %}
108 |
109 |
110 |
111 | {% with messages = get_flashed_messages(with_categories=true) %}
112 | {% if messages %}
113 |
114 | {% for category, message in messages %}
115 |
{{ message }}
116 | {% endfor %}
117 |
118 | {% endif %}
119 | {% endwith %}
120 |
121 |
122 |
123 |
--------------------------------------------------------------------------------
/src/app/static/lib/xterm/xterm.css:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) 2014 The xterm.js authors. All rights reserved.
3 | * Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
4 | * https://github.com/chjj/term.js
5 | * @license MIT
6 | *
7 | * Permission is hereby granted, free of charge, to any person obtaining a copy
8 | * of this software and associated documentation files (the "Software"), to deal
9 | * in the Software without restriction, including without limitation the rights
10 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11 | * copies of the Software, and to permit persons to whom the Software is
12 | * furnished to do so, subject to the following conditions:
13 | *
14 | * The above copyright notice and this permission notice shall be included in
15 | * all copies or substantial portions of the Software.
16 | *
17 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
23 | * THE SOFTWARE.
24 | *
25 | * Originally forked from (with the author's permission):
26 | * Fabrice Bellard's javascript vt100 for jslinux:
27 | * http://bellard.org/jslinux/
28 | * Copyright (c) 2011 Fabrice Bellard
29 | * The original design remains. The terminal itself
30 | * has been extended to include xterm CSI codes, among
31 | * other features.
32 | */
33 |
34 | /**
35 | * Default styles for xterm.js
36 | */
37 |
38 | .xterm {
39 | cursor: text;
40 | position: relative;
41 | user-select: none;
42 | -ms-user-select: none;
43 | -webkit-user-select: none;
44 | }
45 |
46 | .xterm.focus,
47 | .xterm:focus {
48 | outline: none;
49 | }
50 |
51 | .xterm .xterm-helpers {
52 | position: absolute;
53 | top: 0;
54 | /**
55 | * The z-index of the helpers must be higher than the canvases in order for
56 | * IMEs to appear on top.
57 | */
58 | z-index: 5;
59 | }
60 |
61 | .xterm .xterm-helper-textarea {
62 | padding: 0;
63 | border: 0;
64 | margin: 0;
65 | /* Move textarea out of the screen to the far left, so that the cursor is not visible */
66 | position: absolute;
67 | opacity: 0;
68 | left: -9999em;
69 | top: 0;
70 | width: 0;
71 | height: 0;
72 | z-index: -5;
73 | /** Prevent wrapping so the IME appears against the textarea at the correct position */
74 | white-space: nowrap;
75 | overflow: hidden;
76 | resize: none;
77 | }
78 |
79 | .xterm .composition-view {
80 | /* TODO: Composition position got messed up somewhere */
81 | background: #000;
82 | color: #FFF;
83 | display: none;
84 | position: absolute;
85 | white-space: nowrap;
86 | z-index: 1;
87 | }
88 |
89 | .xterm .composition-view.active {
90 | display: block;
91 | }
92 |
93 | .xterm .xterm-viewport {
94 | /* On OS X this is required in order for the scroll bar to appear fully opaque */
95 | background-color: #000;
96 | overflow-y: scroll;
97 | cursor: default;
98 | position: absolute;
99 | right: 0;
100 | left: 0;
101 | top: 0;
102 | bottom: 0;
103 | }
104 |
105 | .xterm .xterm-screen {
106 | position: relative;
107 | }
108 |
109 | .xterm .xterm-screen canvas {
110 | position: absolute;
111 | left: 0;
112 | top: 0;
113 | }
114 |
115 | .xterm .xterm-scroll-area {
116 | visibility: hidden;
117 | }
118 |
119 | .xterm-char-measure-element {
120 | display: inline-block;
121 | visibility: hidden;
122 | position: absolute;
123 | top: 0;
124 | left: -9999em;
125 | line-height: normal;
126 | }
127 |
128 | .xterm.enable-mouse-events {
129 | /* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
130 | cursor: default;
131 | }
132 |
133 | .xterm.xterm-cursor-pointer,
134 | .xterm .xterm-cursor-pointer {
135 | cursor: pointer;
136 | }
137 |
138 | .xterm.column-select.focus {
139 | /* Column selection mode */
140 | cursor: crosshair;
141 | }
142 |
143 | .xterm .xterm-accessibility,
144 | .xterm .xterm-message {
145 | position: absolute;
146 | left: 0;
147 | top: 0;
148 | bottom: 0;
149 | right: 0;
150 | z-index: 10;
151 | color: transparent;
152 | pointer-events: none;
153 | }
154 |
155 | .xterm .live-region {
156 | position: absolute;
157 | left: -9999px;
158 | width: 1px;
159 | height: 1px;
160 | overflow: hidden;
161 | }
162 |
163 | .xterm-dim {
164 | /* Dim should not apply to background, so the opacity of the foreground color is applied
165 | * explicitly in the generated class and reset to 1 here */
166 | opacity: 1 !important;
167 | }
168 |
169 | .xterm-underline-1 { text-decoration: underline; }
170 | .xterm-underline-2 { text-decoration: double underline; }
171 | .xterm-underline-3 { text-decoration: wavy underline; }
172 | .xterm-underline-4 { text-decoration: dotted underline; }
173 | .xterm-underline-5 { text-decoration: dashed underline; }
174 |
175 | .xterm-overline {
176 | text-decoration: overline;
177 | }
178 |
179 | .xterm-overline.xterm-underline-1 { text-decoration: overline underline; }
180 | .xterm-overline.xterm-underline-2 { text-decoration: overline double underline; }
181 | .xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; }
182 | .xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; }
183 | .xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; }
184 |
185 | .xterm-strikethrough {
186 | text-decoration: line-through;
187 | }
188 |
189 | .xterm-screen .xterm-decoration-container .xterm-decoration {
190 | z-index: 6;
191 | position: absolute;
192 | }
193 |
194 | .xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer {
195 | z-index: 7;
196 | }
197 |
198 | .xterm-decoration-overview-ruler {
199 | z-index: 8;
200 | position: absolute;
201 | top: 0;
202 | right: 0;
203 | pointer-events: none;
204 | }
205 |
206 | .xterm-decoration-top {
207 | z-index: 2;
208 | position: relative;
209 | }
210 |
--------------------------------------------------------------------------------