first commit
This commit is contained in:
parent
0835a4d63c
commit
675ac0ca36
0
config/__init__.py
Normal file
0
config/__init__.py
Normal file
59
config/settings.py
Normal file
59
config/settings.py
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
import os
|
||||||
|
from xdg.BaseDirectory import xdg_config_home
|
||||||
|
from configparser import ConfigParser
|
||||||
|
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
def __init__(self):
|
||||||
|
self.config = ConfigParser()
|
||||||
|
self.config_dir = os.path.join(xdg_config_home, "natalie")
|
||||||
|
|
||||||
|
def check_dir_exists(self):
|
||||||
|
if not os.path.exists(self.config_dir):
|
||||||
|
os.makedirs(self.config_dir)
|
||||||
|
|
||||||
|
def check_file_exists(self, filename):
|
||||||
|
config_file = os.path.join(self.config_dir, filename)
|
||||||
|
return os.path.exists(config_file)
|
||||||
|
|
||||||
|
|
||||||
|
class PortainerConfig(Config):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.config_file = os.path.join(self.config_dir, "portainer.ini")
|
||||||
|
self.config.read(self.config_file)
|
||||||
|
|
||||||
|
def get_portainer_instances(self):
|
||||||
|
return self.config.sections()
|
||||||
|
|
||||||
|
def set_new_portainer(self, name, base_url, token):
|
||||||
|
self.config[name] = {"url": base_url, "token": token}
|
||||||
|
with open(self.config_file, 'w') as configfile:
|
||||||
|
self.config.write(configfile)
|
||||||
|
|
||||||
|
def get_portainer_info(self, name):
|
||||||
|
base_url = self.config[name]['url']
|
||||||
|
token = self.config[name]['token']
|
||||||
|
if base_url and token:
|
||||||
|
return base_url, token
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class DockerConfig(Config):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.config_file = os.path.join(self.config_dir, 'docker.ini')
|
||||||
|
self.config.read(self.config_file)
|
||||||
|
|
||||||
|
def get_docker_hosts_list(self):
|
||||||
|
docker_hosts_list = self.config['hosts'].keys()
|
||||||
|
return list(docker_hosts_list)
|
||||||
|
|
||||||
|
def get_docker_host(self, hostname):
|
||||||
|
return self.config['hosts'][hostname]
|
||||||
|
|
||||||
|
def set_new_docker_host(self, hostname, url, port):
|
||||||
|
self.config['hosts'][hostname] = f"{url}:{port}"
|
||||||
|
with open(self.config_file, 'w') as configfile:
|
||||||
|
self.config.write(configfile)
|
0
docker_api/__init__.py
Normal file
0
docker_api/__init__.py
Normal file
9
docker_api/api/__init__.py
Normal file
9
docker_api/api/__init__.py
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
from .containers import Containers
|
||||||
|
from .images import Images
|
||||||
|
|
||||||
|
|
||||||
|
class DockerManager:
|
||||||
|
def __init__(self, docker_host):
|
||||||
|
self.docker_host = docker_host
|
||||||
|
self.containers = Containers(docker_host)
|
||||||
|
self.images = Images(docker_host)
|
6
docker_api/api/base.py
Normal file
6
docker_api/api/base.py
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
import docker
|
||||||
|
|
||||||
|
|
||||||
|
class Docker:
|
||||||
|
def __init__(self, endpoint):
|
||||||
|
self.client = docker.DockerClient(endpoint)
|
7
docker_api/api/containers.py
Normal file
7
docker_api/api/containers.py
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
from .base import Docker
|
||||||
|
|
||||||
|
|
||||||
|
class Containers(Docker):
|
||||||
|
def list_containers(self):
|
||||||
|
containers = self.client.containers.list()
|
||||||
|
return containers
|
11
docker_api/api/images.py
Normal file
11
docker_api/api/images.py
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
from .base import Docker
|
||||||
|
|
||||||
|
|
||||||
|
class Images(Docker):
|
||||||
|
def list_images(self):
|
||||||
|
images = self.client.images.list()
|
||||||
|
return images
|
||||||
|
|
||||||
|
def pull_image(self, repository):
|
||||||
|
image = self.client.images.pull(repository)
|
||||||
|
return image
|
43
docker_api/docker_manager.py
Normal file
43
docker_api/docker_manager.py
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
from .api import DockerManager
|
||||||
|
from config.settings import DockerConfig
|
||||||
|
from tui.terminal_menu import terminal_menu
|
||||||
|
|
||||||
|
|
||||||
|
def docker_manager_menu():
|
||||||
|
config = DockerConfig()
|
||||||
|
docker_hosts = config.get_docker_hosts_list()
|
||||||
|
docker_hosts.append('Add Docker host')
|
||||||
|
docker_hosts.append('Back to main menu')
|
||||||
|
selected_docker_host = terminal_menu(docker_hosts, "Docker hosts")
|
||||||
|
while selected_docker_host != "Back to main menu":
|
||||||
|
if selected_docker_host == "Add Docker host":
|
||||||
|
host_name = input("Enter Docker host name: ")
|
||||||
|
url = input("Enter Docker host URL: ")
|
||||||
|
port = input("Enter Docker host port: ")
|
||||||
|
config.set_new_docker_host(host_name, url, port)
|
||||||
|
else:
|
||||||
|
docker_host = config.get_docker_host(selected_docker_host)
|
||||||
|
docker_manager = DockerManager(docker_host)
|
||||||
|
docker_menu = [
|
||||||
|
"List containers",
|
||||||
|
"List images",
|
||||||
|
"Update images",
|
||||||
|
"Back to Docker hosts"
|
||||||
|
]
|
||||||
|
selected_docker_menu_option = terminal_menu(docker_menu, docker_host)
|
||||||
|
while selected_docker_menu_option != "Back to Docker hosts":
|
||||||
|
if selected_docker_menu_option == "List containers":
|
||||||
|
containers_list = docker_manager.containers.list_containers()
|
||||||
|
for c in containers_list:
|
||||||
|
image = c.attrs['Config']['Image']
|
||||||
|
print(f"Container ID: {c.id[:12]} - Image: {image}")
|
||||||
|
update_confirm = input("Do you want to update images (y/n): ")
|
||||||
|
if update_confirm.lower() == 'y':
|
||||||
|
for c in containers_list:
|
||||||
|
print(f"Updating container: {c.id[:12]}")
|
||||||
|
image = docker_manager.images.pull_image(c.attrs['Config']['Image'])
|
||||||
|
print(f"Pulled image {image.tags[0]}")
|
||||||
|
print(f"Finished updating {len(containers_list)} images")
|
||||||
|
|
||||||
|
selected_docker_menu_option = terminal_menu(docker_menu, docker_host)
|
||||||
|
selected_docker_host = terminal_menu(docker_hosts, "Docker hosts")
|
0
gui/__init__.py
Normal file
0
gui/__init__.py
Normal file
67
gui/portainer_ui.py
Normal file
67
gui/portainer_ui.py
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
import sys
|
||||||
|
from PyQt6.QtWidgets import (QApplication, QMainWindow, QWidget,
|
||||||
|
QVBoxLayout, QHBoxLayout,
|
||||||
|
QLabel, QLineEdit, QListWidget,
|
||||||
|
QTableWidget, QHeaderView, QPushButton, QComboBox)
|
||||||
|
|
||||||
|
|
||||||
|
class PortainerUi:
|
||||||
|
def __init__(self):
|
||||||
|
self.main_window = QMainWindow()
|
||||||
|
|
||||||
|
self.main_window.show()
|
||||||
|
self.main_window.setWindowTitle("Natalie")
|
||||||
|
|
||||||
|
self.search_box = QLineEdit()
|
||||||
|
self.combo_box = QComboBox()
|
||||||
|
|
||||||
|
search_layout = QHBoxLayout()
|
||||||
|
search_layout.addWidget(self.search_box)
|
||||||
|
search_layout.addWidget(self.combo_box)
|
||||||
|
|
||||||
|
self.stacks_list = QListWidget()
|
||||||
|
|
||||||
|
self.stack_title = QLabel()
|
||||||
|
self.containers_table = QTableWidget()
|
||||||
|
self.containers_table.setColumnCount(3)
|
||||||
|
self.containers_table.setHorizontalHeaderLabels(["ID", "Name", "Status"])
|
||||||
|
self.containers_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)
|
||||||
|
self.containers_table.horizontalHeader().setStretchLastSection(True)
|
||||||
|
|
||||||
|
self.start = QPushButton("Start")
|
||||||
|
self.stop = QPushButton("Stop")
|
||||||
|
self.update = QPushButton("Update")
|
||||||
|
|
||||||
|
buttons_layout = QHBoxLayout()
|
||||||
|
buttons_layout.addWidget(self.start)
|
||||||
|
buttons_layout.addWidget(self.stop)
|
||||||
|
buttons_layout.addWidget(self.update)
|
||||||
|
|
||||||
|
info_layout = QVBoxLayout()
|
||||||
|
info_layout.addWidget(self.stack_title)
|
||||||
|
info_layout.addLayout(buttons_layout)
|
||||||
|
info_layout.addWidget(self.containers_table)
|
||||||
|
|
||||||
|
main_layout = QHBoxLayout()
|
||||||
|
main_layout.addWidget(self.stacks_list)
|
||||||
|
main_layout.addLayout(info_layout)
|
||||||
|
main_layout.setStretch(0, 1)
|
||||||
|
main_layout.setStretch(1, 2)
|
||||||
|
|
||||||
|
layout = QVBoxLayout()
|
||||||
|
layout.addLayout(search_layout)
|
||||||
|
layout.addLayout(main_layout)
|
||||||
|
|
||||||
|
widget = QWidget()
|
||||||
|
widget.setLayout(layout)
|
||||||
|
self.main_window.setCentralWidget(widget)
|
||||||
|
|
||||||
|
def show(self):
|
||||||
|
self.main_window.show()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
app = QApplication(sys.argv)
|
||||||
|
win = PortainerUi()
|
||||||
|
win.show()
|
||||||
|
sys.exit(app.exec())
|
0
gui/workers/__init__.py
Normal file
0
gui/workers/__init__.py
Normal file
17
gui/workers/load_endpoints_worker.py
Normal file
17
gui/workers/load_endpoints_worker.py
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
from PyQt6.QtCore import QThread, pyqtSignal
|
||||||
|
|
||||||
|
|
||||||
|
class LoadEndpointsWorker(QThread):
|
||||||
|
endpoints_retrieved = pyqtSignal(list)
|
||||||
|
error_occurred = pyqtSignal(str)
|
||||||
|
|
||||||
|
def __init__(self, portainer):
|
||||||
|
super().__init__()
|
||||||
|
self.portainer = portainer
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
try:
|
||||||
|
endpoints = self.portainer.endpoints.list_endpoints()
|
||||||
|
self.endpoints_retrieved.emit(endpoints)
|
||||||
|
except Exception as e:
|
||||||
|
self.error_occurred.emit(str(e))
|
18
gui/workers/load_stacks_worker.py
Normal file
18
gui/workers/load_stacks_worker.py
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
from PyQt6.QtCore import QThread, pyqtSignal
|
||||||
|
from portainer_api import PortainerService
|
||||||
|
|
||||||
|
|
||||||
|
class LoadStacksWorker(QThread):
|
||||||
|
stacks_retrieved = pyqtSignal(list)
|
||||||
|
error_occurred = pyqtSignal(str)
|
||||||
|
|
||||||
|
def __init__(self, portainer):
|
||||||
|
super().__init__()
|
||||||
|
self.portainer = portainer
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
try:
|
||||||
|
stacks = self.portainer.stacks.list_stacks()
|
||||||
|
self.stacks_retrieved.emit(stacks)
|
||||||
|
except Exception as e:
|
||||||
|
self.error_occurred.emit(str(e))
|
51
natalie.py
Normal file
51
natalie.py
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
import sys
|
||||||
|
from PyQt6.QtWidgets import (QApplication, QListWidgetItem)
|
||||||
|
from PyQt6.QtCore import Qt
|
||||||
|
from portainer_api import PortainerService
|
||||||
|
from gui.portainer_ui import PortainerUi
|
||||||
|
from config.settings import PortainerConfig
|
||||||
|
|
||||||
|
from gui.workers.load_stacks_worker import LoadStacksWorker
|
||||||
|
from gui.workers.load_endpoints_worker import LoadEndpointsWorker
|
||||||
|
|
||||||
|
|
||||||
|
class Natalie(PortainerUi):
|
||||||
|
def __init__(self, portainer_url, portainer_token):
|
||||||
|
super().__init__()
|
||||||
|
self.portainer = PortainerService(portainer_url, portainer_token)
|
||||||
|
self.endpoints_worker = LoadEndpointsWorker(self.portainer)
|
||||||
|
self.endpoints_worker.endpoints_retrieved.connect(self.load_endpoints)
|
||||||
|
self.endpoints_worker.error_occurred.connect(self.error_occurred)
|
||||||
|
self.endpoints_worker.start()
|
||||||
|
self.stacks_worker = LoadStacksWorker(self.portainer)
|
||||||
|
self.stacks_worker.stacks_retrieved.connect(self.load_stacks)
|
||||||
|
self.stacks_worker.error_occurred.connect(self.error_occurred)
|
||||||
|
self.stacks_worker.start()
|
||||||
|
|
||||||
|
def load_endpoints(self, endpoints):
|
||||||
|
self.endpoints_list = endpoints
|
||||||
|
self.combo_box.clear()
|
||||||
|
self.combo_box.addItem("All")
|
||||||
|
for endpoint in endpoints:
|
||||||
|
self.combo_box.addItem(endpoint['name'])
|
||||||
|
|
||||||
|
def load_stacks(self, stacks):
|
||||||
|
current_group = self.combo_box.currentText()
|
||||||
|
self.stacks_list.clear()
|
||||||
|
for stack in stacks:
|
||||||
|
item = QListWidgetItem(stack['name'])
|
||||||
|
item.setData(Qt.ItemDataRole.UserRole, stack)
|
||||||
|
self.stacks_list.addItem(item)
|
||||||
|
|
||||||
|
def error_occurred(self, error_message):
|
||||||
|
print(f"Error occurred: {error_message}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
portainer_config = PortainerConfig()
|
||||||
|
instances = portainer_config.get_portainer_instances()
|
||||||
|
base_url, token = portainer_config.get_portainer_info(instances[0])
|
||||||
|
app = QApplication(sys.argv)
|
||||||
|
win = Natalie(base_url, token)
|
||||||
|
win.show()
|
||||||
|
sys.exit(app.exec())
|
16
natalie_cli.py
Normal file
16
natalie_cli.py
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import argparse
|
||||||
|
from portainer_api import PortainerService
|
||||||
|
from config.settings import PortainerConfig
|
||||||
|
from docker_api.docker_manager import docker_manager_menu
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
portainer_config = PortainerConfig()
|
||||||
|
instances = portainer_config.get_portainer_instances()
|
||||||
|
base_url, token = portainer_config.get_portainer_info(instances[0])
|
||||||
|
print(base_url, token)
|
||||||
|
portainer = PortainerService(base_url, token)
|
||||||
|
stacks = portainer.stacks.list_stacks()
|
||||||
|
for stack in stacks:
|
||||||
|
print(stack)
|
||||||
|
|
||||||
|
docker_manager_menu()
|
8
portainer_api/__init__.py
Normal file
8
portainer_api/__init__.py
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
from .stacks import Stacks
|
||||||
|
|
||||||
|
|
||||||
|
class PortainerService:
|
||||||
|
def __init__(self, base_url, token):
|
||||||
|
self.base_url = base_url
|
||||||
|
self.token = token
|
||||||
|
self.stacks = Stacks(base_url, token)
|
6
portainer_api/base.py
Normal file
6
portainer_api/base.py
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
from portainer import PortainerApi
|
||||||
|
|
||||||
|
|
||||||
|
class Portainer:
|
||||||
|
def __init__(self, base_url, token):
|
||||||
|
self.portainer = PortainerApi(base_url, token)
|
18
portainer_api/endpoints.py
Normal file
18
portainer_api/endpoints.py
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
from .base import Portainer
|
||||||
|
|
||||||
|
|
||||||
|
class Endpoints(Portainer):
|
||||||
|
def list_endpoints(self):
|
||||||
|
endpoints = self.portainer.endpoints.list_endpoints()
|
||||||
|
endpoints_list = []
|
||||||
|
for endpoint in endpoints:
|
||||||
|
endpoint_data = {
|
||||||
|
"id": endpoint['Id'],
|
||||||
|
"name": endpoint['Name'],
|
||||||
|
"url": endpoint['URL'],
|
||||||
|
"status": endpoint['Status'],
|
||||||
|
}
|
||||||
|
endpoints_list.append(endpoint_data)
|
||||||
|
|
||||||
|
return endpoints_list
|
||||||
|
|
44
portainer_api/stacks.py
Normal file
44
portainer_api/stacks.py
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
from .base import Portainer
|
||||||
|
|
||||||
|
|
||||||
|
class Stacks(Portainer):
|
||||||
|
def list_stacks(self):
|
||||||
|
stacks = self.portainer.stacks.list_stacks()
|
||||||
|
stacks_list = []
|
||||||
|
for stack in stacks:
|
||||||
|
stack_data = {
|
||||||
|
"id": stack['Id'],
|
||||||
|
"name": stack['Name'],
|
||||||
|
"type": stack['Type'],
|
||||||
|
"endpoint_id": stack['EndpointId'],
|
||||||
|
"resource_id": stack['ResourceControl']['ResourceId'],
|
||||||
|
"status": stack['Status'],
|
||||||
|
"creation_date": stack['CreationDate'],
|
||||||
|
"created_by": stack['CreatedBy'],
|
||||||
|
"update_date": stack['UpdateDate'],
|
||||||
|
"updated_by": stack['UpdatedBy'],
|
||||||
|
"webhook": stack['Webhook']
|
||||||
|
}
|
||||||
|
stacks_list.append(stack_data)
|
||||||
|
return stacks_list
|
||||||
|
|
||||||
|
def get_stack_by_id(self, stack_id):
|
||||||
|
stack = self.portainer.stacks.get_stack_by_id(stack_id)
|
||||||
|
stack_data = {
|
||||||
|
"id": stack['Id'],
|
||||||
|
"name": stack['Name'],
|
||||||
|
"type": stack['Type'],
|
||||||
|
"endpoint_id": stack['EndpointId'],
|
||||||
|
"resource_id": stack['ResourceControl']['ResourceId'],
|
||||||
|
"status": stack['Status'],
|
||||||
|
"creation_date": stack['CreationDate'],
|
||||||
|
"created_by": stack['CreatedBy'],
|
||||||
|
"update_date": stack['UpdateDate'],
|
||||||
|
"updated_by": stack['UpdatedBy'],
|
||||||
|
"webhook": stack['Webhook']
|
||||||
|
}
|
||||||
|
return stack_data
|
||||||
|
|
||||||
|
def update_stack(self, webhook):
|
||||||
|
response = self.portainer.stacks.update_stack(webhook)
|
||||||
|
return response
|
0
tui/__init__.py
Normal file
0
tui/__init__.py
Normal file
21
tui/terminal_menu.py
Normal file
21
tui/terminal_menu.py
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
from simple_term_menu import TerminalMenu
|
||||||
|
|
||||||
|
|
||||||
|
def terminal_menu(menu_options, menu_title, clear_screen=False):
|
||||||
|
if isinstance(menu_options, list):
|
||||||
|
menu = TerminalMenu(menu_options, clear_screen=clear_screen, title=menu_title)
|
||||||
|
entry_index = menu.show()
|
||||||
|
if entry_index is not None:
|
||||||
|
return menu_options[entry_index]
|
||||||
|
|
||||||
|
elif isinstance(menu_options, dict):
|
||||||
|
menu = TerminalMenu(list(menu_options.keys()), clear_screen=True, title=menu_title)
|
||||||
|
entry_index = menu.show()
|
||||||
|
if entry_index is not None:
|
||||||
|
selected_key = list(menu_options.keys())[entry_index]
|
||||||
|
return selected_key, menu_options[selected_key]
|
||||||
|
return None
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise ValueError("Input must be list or dict.")
|
||||||
|
|
Loading…
Reference in a new issue