first commit

This commit is contained in:
Ali 2025-09-05 17:36:18 +04:00
parent 185e8dc56f
commit 8dc6f21719
6 changed files with 697 additions and 0 deletions

View File

@ -0,0 +1,297 @@
frappe.ui.form.on("Incus Container", {
refresh: function (frm) {
// Кнопка: Создать контейнер
if (frm.doc.status === "Creating") {
frm.add_custom_button("Создать", function () {
// Показываем индикатор загрузки
frappe.show_alert({
message: "Создание контейнера...",
indicator: "blue"
});
frappe.call({
method: "incus_container.incus_container.doctype.incus_container.incus_container.create_container",
args: {
name: frm.doc.container_name,
image_alias: frm.doc.image || "alpine/edge",
container_type: frm.doc.container_type || "container",
wait_completion: true
},
callback: function (r) {
if (r.message) {
const response = r.message;
if (response.success) {
frappe.msgprint({
title: "Успех",
indicator: "green",
message: response.message
});
// Показываем детали операции если есть
if (response.operation_data) {
console.log("Operation details:", response.operation_data);
}
// Обновляем статус документа
frm.set_value('status', 'Created');
frm.save();
} else {
frappe.msgprint({
title: "Ошибка создания контейнера",
indicator: "red",
message: response.message
});
console.error("Error details:", response.error);
// При ошибке ставим статус Error
frm.set_value('status', 'Error');
frm.save();
}
}
},
error: function(r) {
frappe.msgprint({
title: "Системная ошибка",
indicator: "red",
message: "Произошла ошибка при вызове API"
});
console.error("API call error:", r);
}
});
}, "Управление");
}
// Кнопка: Быстрое создание (без ожидания)
if (frm.doc.status === "Creating") {
frm.add_custom_button("Создать (быстро)", function () {
frappe.show_alert({
message: "Отправка запроса на создание контейнера...",
indicator: "orange"
});
frappe.call({
method: "incus_container.incus_container.doctype.incus_container.incus_container.create_container",
args: {
name: frm.doc.container_name,
image_alias: frm.doc.image || "alpine/edge",
container_type: frm.doc.container_type || "container",
wait_completion: false
},
callback: function (r) {
if (r.message) {
const response = r.message;
if (response.success) {
frappe.msgprint({
title: "Запрос отправлен",
indicator: "blue",
message: `${response.message}<br><small>Операция: ${response.operation_url}</small>`
});
console.log("Operation URL:", response.operation_url);
// Обновляем статус на "В процессе"
frm.set_value('status', 'Processing');
frm.save();
} else {
frappe.msgprint({
title: "Ошибка",
indicator: "red",
message: response.message
});
console.error("Error details:", response.error);
}
}
}
});
}, "Управление");
}
// Кнопка: Создать с пользовательскими параметрами
frm.add_custom_button("Создать с параметрами", function () {
// Создаем диалог для ввода параметров
let dialog = new frappe.ui.Dialog({
title: 'Создание контейнера',
fields: [
{
label: 'Имя контейнера',
fieldname: 'container_name',
fieldtype: 'Data',
reqd: 1,
default: frm.doc.container_name || ''
},
{
label: 'Образ (алиас)',
fieldname: 'image_alias',
fieldtype: 'Data',
reqd: 1,
default: frm.doc.image || 'alpine/edge'
},
{
label: 'Тип контейнера',
fieldname: 'container_type',
fieldtype: 'Select',
options: 'container\nvirtual-machine',
default: frm.doc.container_type || 'container'
},
{
label: 'Ждать завершения',
fieldname: 'wait_completion',
fieldtype: 'Check',
default: 1
}
],
primary_action_label: 'Создать',
primary_action(values) {
dialog.hide();
frappe.show_alert({
message: "Создание контейнера...",
indicator: "blue"
});
frappe.call({
method: "incus_container.incus_container.doctype.incus_container.incus_container.create_container",
args: {
name: values.container_name,
image_alias: values.image_alias,
container_type: values.container_type,
wait_completion: values.wait_completion
},
callback: function (r) {
if (r.message) {
const response = r.message;
if (response.success) {
frappe.msgprint({
title: "Успех",
indicator: "green",
message: response.message
});
// Обновляем поля документа если создавался для него
if (values.container_name === frm.doc.container_name) {
frm.set_value('image', values.image_alias);
frm.set_value('container_type', values.container_type);
frm.set_value('status', values.wait_completion ? 'Created' : 'Processing');
frm.save();
}
} else {
frappe.msgprint({
title: "Ошибка",
indicator: "red",
message: response.message
});
console.error("Error details:", response.error);
}
}
}
});
}
});
dialog.show();
}, "Управление");
// Кнопка: Показать список контейнеров
frm.add_custom_button("Список контейнеров", function () {
frappe.show_alert({
message: "Загружаем список контейнеров...",
indicator: "blue"
});
frappe.call({
method: "incus_container.incus_container.doctype.incus_container.incus_container.list_containers",
callback: function (r) {
if (r.message) {
const response = r.message;
if (response.success && response.containers) {
// Создаем HTML таблицу для отображения
let table_html = `
<div class="table-responsive">
<table class="table table-bordered">
<thead>
<tr>
<th>Название</th>
<th>Статус</th>
<th>Тип</th>
<th>Архитектура</th>
<th>Создан</th>
<th>Последнее использование</th>
</tr>
</thead>
<tbody>
`;
response.containers.forEach(container => {
// Определяем цвет статуса
let status_color = 'text-muted';
if (container.status === 'Running') status_color = 'text-success';
else if (container.status === 'Stopped') status_color = 'text-warning';
else if (container.status === 'Error') status_color = 'text-danger';
// Форматируем даты
let created_at = container.created_at !== 'Unknown' ?
new Date(container.created_at).toLocaleString() : 'Unknown';
let last_used_at = container.last_used_at !== 'Unknown' ?
new Date(container.last_used_at).toLocaleString() : 'Unknown';
table_html += `
<tr>
<td><strong>${container.name}</strong></td>
<td><span class="${status_color}">${container.status}</span></td>
<td>${container.type}</td>
<td>${container.architecture}</td>
<td>${created_at}</td>
<td>${last_used_at}</td>
</tr>
`;
});
table_html += `
</tbody>
</table>
</div>
<p><strong>Всего контейнеров: ${response.total_count}</strong></p>
`;
// Показываем диалог с таблицей
frappe.msgprint({
title: "Список контейнеров Incus",
message: table_html,
wide: true
});
// Также выводим в консоль для разработчика
console.log("Containers list:", response.containers);
} else {
frappe.msgprint({
title: "Ошибка",
indicator: "red",
message: response.message || "Не удалось получить список контейнеров"
});
console.error("Error:", response.error);
}
}
},
error: function(r) {
frappe.msgprint({
title: "Системная ошибка",
indicator: "red",
message: "Произошла ошибка при получении списка контейнеров"
});
console.error("API call error:", r);
}
});
}, "Действия");
}
});

View File

@ -0,0 +1,122 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2025-09-03 19:23:42.889432",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"container_name",
"image",
"container_type",
"server",
"status",
"ip_address",
"section_break_details",
"created_at",
"last_used_at",
"architecture",
"section_break_pmeb"
],
"fields": [
{
"fieldname": "container_name",
"fieldtype": "Data",
"label": "Container Name",
"reqd": 1,
"description": "Уникальное имя контейнера в Incus"
},
{
"fieldname": "image",
"fieldtype": "Data",
"label": "Image Alias",
"reqd": 1,
"default": "alpine/edge",
"description": "Алиас образа Incus (например: alpine/edge, ubuntu/22.04)"
},
{
"fieldname": "container_type",
"fieldtype": "Select",
"label": "Container Type",
"options": "container\nvirtual-machine",
"default": "container",
"description": "Тип экземпляра: контейнер или виртуальная машина"
},
{
"fieldname": "server",
"fieldtype": "Data",
"label": "Server",
"default": "router1",
"description": "Адрес Incus API (пример: router1 или 10.0.0.1)"
},
{
"fieldname": "status",
"fieldtype": "Select",
"label": "Status",
"options": "Creating\nProcessing\nCreated\nRunning\nStopped\nError",
"default": "Creating",
"read_only": 1,
"description": "Текущий статус контейнера"
},
{
"fieldname": "ip_address",
"fieldtype": "Data",
"label": "IP Address",
"read_only": 1,
"description": "IP адрес контейнера (если назначен)"
},
{
"fieldname": "section_break_details",
"fieldtype": "Section Break",
"label": "Детали контейнера"
},
{
"fieldname": "created_at",
"fieldtype": "Datetime",
"label": "Created At",
"read_only": 1,
"description": "Дата и время создания контейнера в Incus"
},
{
"fieldname": "last_used_at",
"fieldtype": "Datetime",
"label": "Last Used At",
"read_only": 1,
"description": "Дата и время последнего использования"
},
{
"fieldname": "architecture",
"fieldtype": "Data",
"label": "Architecture",
"read_only": 1,
"description": "Архитектура контейнера (x86_64, aarch64, etc.)"
},
{
"fieldname": "section_break_pmeb",
"fieldtype": "Section Break"
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-09-03 19:23:42.889432",
"modified_by": "Administrator",
"module": "Incus Manager",
"name": "Incus Container",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}

View File

@ -0,0 +1,248 @@
import frappe
import requests
import json
import time
from frappe.model.document import Document
# === Настройки Incus API ===
INCUS_HOST = "https://10.220.187.117:8443"
CLIENT_CERT = ("/home/frappe/frappe-bench/apps/jey_erp/client.crt", "/home/frappe/frappe-bench/apps/jey_erp/client.key")
VERIFY = False
class IncusContainer(Document):
"""DocType для управления контейнерами Incus"""
pass
def wait_for_operation(operation_url, timeout=60):
"""
Ждёт завершения асинхронной операции в Incus
"""
start_time = time.time()
while time.time() - start_time < timeout:
try:
r = requests.get(
operation_url,
headers={"Content-Type": "application/json"},
cert=CLIENT_CERT,
verify=VERIFY
)
r.raise_for_status()
op_data = r.json()
metadata = op_data.get("metadata", {})
status = metadata.get("status")
if status == "Success":
return {
"success": True,
"operation_data": op_data,
"message": "Операция успешно завершена"
}
elif status == "Failure":
return {
"success": False,
"operation_data": op_data,
"error": metadata.get("err", "Unknown error"),
"message": "Операция завершена с ошибкой"
}
elif status in ["Running", "Pending"]:
time.sleep(2)
continue
except requests.exceptions.RequestException as e:
return {
"success": False,
"error": str(e),
"message": "Ошибка при проверке статуса операции"
}
return {
"success": False,
"error": "Timeout",
"message": f"Операция не завершилась за {timeout} секунд"
}
@frappe.whitelist()
def list_containers():
"""
Получает список контейнеров
"""
url = f"{INCUS_HOST}/1.0/instances"
try:
r = requests.get(
url,
headers={"Content-Type": "application/json"},
cert=CLIENT_CERT,
verify=VERIFY
)
r.raise_for_status()
containers_data = r.json()
# Получаем детальную информацию о каждом контейнере
detailed_containers = []
if containers_data.get("metadata"):
for container_url in containers_data["metadata"]:
# container_url выглядит как "/1.0/instances/container_name"
container_name = container_url.split("/")[-1]
# Получаем подробную информацию о контейнере
detail_url = f"{INCUS_HOST}{container_url}"
try:
detail_r = requests.get(
detail_url,
headers={"Content-Type": "application/json"},
cert=CLIENT_CERT,
verify=VERIFY
)
detail_r.raise_for_status()
container_detail = detail_r.json()
if container_detail.get("metadata"):
metadata = container_detail["metadata"]
detailed_containers.append({
"name": container_name,
"status": metadata.get("status", "Unknown"),
"type": metadata.get("type", "Unknown"),
"architecture": metadata.get("architecture", "Unknown"),
"created_at": metadata.get("created_at", "Unknown"),
"last_used_at": metadata.get("last_used_at", "Unknown")
})
except:
# Если не удалось получить детали, добавляем базовую информацию
detailed_containers.append({
"name": container_name,
"status": "Unknown",
"type": "Unknown",
"architecture": "Unknown",
"created_at": "Unknown",
"last_used_at": "Unknown"
})
return {
"success": True,
"containers": detailed_containers,
"total_count": len(detailed_containers),
"message": f"Найдено контейнеров: {len(detailed_containers)}"
}
except requests.exceptions.RequestException as e:
return {
"success": False,
"error": str(e),
"message": "Ошибка получения списка контейнеров"
}
@frappe.whitelist()
def create_container(name, image_alias="alpine/edge", container_type="container", wait_completion=True):
"""
Создаёт новый контейнер
Args:
name (str): Имя контейнера
image_alias (str): Алиас образа (по умолчанию alpine/edge)
container_type (str): Тип контейнера (по умолчанию container)
wait_completion (bool): Ждать ли завершения операции (по умолчанию True)
Returns:
dict: Результат операции создания контейнера
"""
url = f"{INCUS_HOST}/1.0/instances"
# Подготавливаем данные для создания контейнера
container_data = {
"name": name,
"type": container_type,
"source": {
"type": "image",
"alias": image_alias
}
}
try:
# Отправляем запрос на создание контейнера
r = requests.post(
url,
headers={"Content-Type": "application/json"},
cert=CLIENT_CERT,
verify=VERIFY,
data=json.dumps(container_data)
)
r.raise_for_status()
response_data = r.json()
# Если wait_completion=False, возвращаем сразу
if not wait_completion:
return {
"success": True,
"container_name": name,
"operation_url": response_data.get("operation"),
"message": f"Запрос на создание контейнера '{name}' отправлен"
}
# Ждём завершения операции
operation_url = response_data.get("operation")
if operation_url:
full_operation_url = f"{INCUS_HOST}{operation_url}"
operation_result = wait_for_operation(full_operation_url, timeout=120)
if operation_result["success"]:
return {
"success": True,
"container_name": name,
"image_alias": image_alias,
"container_type": container_type,
"operation_data": operation_result["operation_data"],
"message": f"Контейнер '{name}' успешно создан"
}
else:
return {
"success": False,
"container_name": name,
"error": operation_result.get("error"),
"operation_data": operation_result.get("operation_data"),
"message": f"Ошибка при создании контейнера '{name}': {operation_result.get('error')}"
}
else:
return {
"success": False,
"container_name": name,
"error": "No operation URL returned",
"message": f"Не удалось получить URL операции для контейнера '{name}'"
}
except requests.exceptions.HTTPError as e:
# Обрабатываем HTTP ошибки более детально
error_detail = ""
try:
error_response = e.response.json()
error_detail = error_response.get("error", str(e))
except:
error_detail = str(e)
return {
"success": False,
"container_name": name,
"error": error_detail,
"status_code": e.response.status_code if e.response else None,
"message": f"HTTP ошибка при создании контейнера '{name}': {error_detail}"
}
except requests.exceptions.RequestException as e:
return {
"success": False,
"container_name": name,
"error": str(e),
"message": f"Ошибка сети при создании контейнера '{name}': {str(e)}"
}
except Exception as e:
return {
"success": False,
"container_name": name,
"error": str(e),
"message": f"Неожиданная ошибка при создании контейнера '{name}': {str(e)}"
}

View File

@ -0,0 +1,30 @@
# Copyright (c) 2025, Jey ERP and Contributors
# See license.txt
# import frappe
from frappe.tests import IntegrationTestCase, UnitTestCase
# On IntegrationTestCase, the doctype test records and all
# link-field test record depdendencies are recursively loaded
# Use these module variables to add/remove to/from that list
EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
class UnitTestIncusContainer(UnitTestCase):
"""
Unit tests for IncusContainer.
Use this class for testing individual functions and methods.
"""
pass
class IntegrationTestIncusContainer(IntegrationTestCase):
"""
Integration tests for IncusContainer.
Use this class for testing interactions between multiple components.
"""
pass