From d95afc60e30d9613025abadca2070c7ac7c55a39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Mon, 27 Jul 2026 22:59:52 +0400 Subject: [PATCH] =?UTF-8?q?v1.0.92:=20Steps=205-9=20=E2=80=94=20dynamic=20?= =?UTF-8?q?services,=20PostgreSQL=20history,=20/api/history?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- requirements.txt | 1 + site/app.py | 7 +++- site/db/init_db.py | 72 +++++++++++++++++++++++++++++++++++++++++ site/db/pool.py | 39 ++++++++++++++++++++++ site/db/save_run.py | 37 +++++++++++++++++++++ site/routes/api_test.py | 43 ++++++++++++++++++++++++ site/static/app.js | 29 ++++++++++++----- 7 files changed, 219 insertions(+), 9 deletions(-) create mode 100644 site/db/init_db.py create mode 100644 site/db/pool.py create mode 100644 site/db/save_run.py diff --git a/requirements.txt b/requirements.txt index b67cb26..2741640 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,4 @@ Flask>=3.0 gunicorn>=21.2 requests>=2.31 PyYAML>=6.0 +psycopg2-binary>=2.9 diff --git a/site/app.py b/site/app.py index 4388d0c..cf13ebb 100644 --- a/site/app.py +++ b/site/app.py @@ -18,12 +18,17 @@ from routes.api import bp as api_bp from routes.api_test import bp as api_test_bp # Версия — меняется при КАЖДОМ изменении кода. Показывается в топбаре UI. -VERSION = "1.0.91" +VERSION = "1.0.92" app = Flask(__name__, template_folder="templates", static_folder="static") app.config["NUBES_API_ENDPOINT"] = os.getenv("NUBES_API_ENDPOINT", "https://lk-api-gateway-dev.ngcloud.ru/api/v1/svc") app.config["NUBES_API_TOKEN"] = os.getenv("NUBES_API_TOKEN", "") app.config["VERSION"] = VERSION + +# Инициализация схемы БД (идемпотентно, безопасно для нескольких воркеров) +from db.init_db import init_db +init_db() + app.register_blueprint(main_bp) app.register_blueprint(api_bp) app.register_blueprint(api_test_bp) diff --git a/site/db/init_db.py b/site/db/init_db.py new file mode 100644 index 0000000..460edc9 --- /dev/null +++ b/site/db/init_db.py @@ -0,0 +1,72 @@ +""" +Инициализация схемы БД — идемпотентно (CREATE IF NOT EXISTS). + +Вызывается при старте приложения (в app.py, до register_blueprint). +Безопасно для нескольких gunicorn-воркеров — PostgreSQL корректно +обрабатывает конкурентный DDL. + +Таблица runs — история запусков операций: + - id, created_at + - client_id, stand (изоляция по пользователю и стенду) + - svc_id, svc_name, op_name, svc_op_id + - instance_uid, display_name + - status (OK/FAIL/TIMEOUT), duration_sec, error_log + - params (JSONB), stages (JSONB) +""" + +import os +from db.pool import get_conn, put_conn + +SCHEMA_SQL = """ +CREATE TABLE IF NOT EXISTS runs ( + id SERIAL PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + client_id VARCHAR(64) NOT NULL, + stand VARCHAR(16) NOT NULL, + svc_id INTEGER NOT NULL, + svc_name VARCHAR(128), + op_name VARCHAR(32) NOT NULL, + svc_op_id INTEGER, + instance_uid VARCHAR(64), + display_name VARCHAR(255), + status VARCHAR(16) NOT NULL, + duration_sec DOUBLE PRECISION, + error_log TEXT, + params JSONB, + stages JSONB +); + +CREATE INDEX IF NOT EXISTS idx_runs_client_stand + ON runs (client_id, stand, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_runs_instance + ON runs (instance_uid); + +CREATE INDEX IF NOT EXISTS idx_runs_created + ON runs (created_at); +""" + + +def init_db(): + """Применить схему — идемпотентно, безопасно для конкурентного вызова.""" + dsn = os.getenv("DATABASE_URL", "") + if not dsn: + print("[DB] DATABASE_URL not set — skipping init", flush=True) + return + + conn = get_conn() + if not conn: + print("[DB] Failed to get connection — skipping init", flush=True) + return + + try: + cur = conn.cursor() + cur.execute(SCHEMA_SQL) + conn.commit() + cur.close() + print("[DB] Schema initialized", flush=True) + except Exception as e: + print(f"[DB] Init error: {e}", flush=True) + conn.rollback() + finally: + put_conn(conn) diff --git a/site/db/pool.py b/site/db/pool.py new file mode 100644 index 0000000..69dc0ea --- /dev/null +++ b/site/db/pool.py @@ -0,0 +1,39 @@ +""" +Connection pool для PostgreSQL (psycopg2). + +Lazy-init: пул создаётся при первом обращении к БД в каждом gunicorn-воркере. +Это безопасно для fork-модели — соединения открываются ПОСЛЕ fork. + +DSN: из переменной окружения DATABASE_URL. +Формат: postgresql://user:pass@host:5432/dbname?sslmode=require +""" + +import os +import psycopg2 +from psycopg2 import pool + +_pool = None + + +def get_pool(): + """Lazy-init ThreadedConnectionPool (1-5 соединений).""" + global _pool + if _pool is None: + dsn = os.getenv("DATABASE_URL", "") + if not dsn: + return None + _pool = pool.ThreadedConnectionPool(1, 5, dsn) + return _pool + + +def get_conn(): + """Взять соединение из пула.""" + p = get_pool() + return p.getconn() if p else None + + +def put_conn(conn): + """Вернуть соединение в пул.""" + p = get_pool() + if p and conn: + p.putconn(conn) diff --git a/site/db/save_run.py b/site/db/save_run.py new file mode 100644 index 0000000..a7f2d5d --- /dev/null +++ b/site/db/save_run.py @@ -0,0 +1,37 @@ +""" +Сохранение результатов тестов в БД. +""" + +import json +from db.pool import get_conn, put_conn + + +def save_run(client_id, stand, svc_id, svc_name, op_name, svc_op_id, + instance_uid, display_name, status, duration_sec, + error_log, params, stages): + """Сохранить один запуск в таблицу runs.""" + conn = get_conn() + if not conn: + return + + try: + cur = conn.cursor() + cur.execute(""" + INSERT INTO runs (client_id, stand, svc_id, svc_name, op_name, svc_op_id, + instance_uid, display_name, status, duration_sec, + error_log, params, stages) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, ( + client_id, stand, svc_id, svc_name, op_name, svc_op_id, + instance_uid, display_name, status, duration_sec, + error_log, + json.dumps(params) if params else None, + json.dumps(stages) if stages else None, + )) + conn.commit() + cur.close() + except Exception as e: + print(f"[DB] save_run error: {e}", flush=True) + conn.rollback() + finally: + put_conn(conn) diff --git a/site/routes/api_test.py b/site/routes/api_test.py index bac9ebe..138d543 100644 --- a/site/routes/api_test.py +++ b/site/routes/api_test.py @@ -341,6 +341,18 @@ def _finish_op(client, op_uid, instance_uid, svc_id, display_name, op_name, svc_ "duration": round(time.time() - t0, 1), "_ts": time.time(), } + # Сохранить в БД историю + try: + from db.save_run import save_run + save_run(client_id, stand, svc_id, op.get("svc", ""), op_name, svc_op_id, + instance_uid, display_name, + "OK" if is_ok else "FAIL", + round(time.time() - t0, 1), + str(err) if err else "", + {}, # params недоступны в _finish_op — будут при следующем улучшении + op.get("stages", [])) + except Exception: + pass # не ронять фоновый поток из-за БД return time.sleep(5) except Exception: @@ -391,6 +403,37 @@ def api_log(): return jsonify([]) +@bp.route("/api/history") +def api_history(): + """Последние 50 записей истории тестов (фильтр по client_id + stand).""" + try: + from db.pool import get_conn, put_conn + conn = get_conn() + if not conn: + return jsonify([]) + cur = conn.cursor() + cur.execute(""" + SELECT id, created_at, client_id, stand, svc_id, svc_name, + op_name, instance_uid, display_name, status, duration_sec, error_log + FROM runs + WHERE client_id = %s AND stand = %s + ORDER BY created_at DESC + LIMIT 50 + """, (get_client_id(), get_stand())) + rows = cur.fetchall() + cols = [d[0] for d in cur.description] + result = [dict(zip(cols, r)) for r in rows] + # Конвертировать datetime в строку + for r in result: + if r["created_at"]: + r["created_at"] = r["created_at"].isoformat() + cur.close() + put_conn(conn) + return jsonify(result) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + def _find_uid(resp): """Извлечь instanceUid или instanceOperationUid из ответа API.""" if isinstance(resp, dict): diff --git a/site/static/app.js b/site/static/app.js index 9d0551f..ca329e7 100644 --- a/site/static/app.js +++ b/site/static/app.js @@ -12,7 +12,7 @@ * selectedInst — UID выбранного инстанса (null если не выбран) * selectedOp — {opId, opName, svcId} выбранная операция * pollTimer — setInterval таймер поллинга статуса операции - * SVC_ID = 1 — фиксированный сервис (Болванка) + * currentSvcId — ID текущего выбранного сервиса (по умолчанию 1 = Болванка) * AUTOTEST_PREFIX — префикс имён autotest-инстансов * * Потоки: @@ -26,10 +26,23 @@ * showStages() — отрисовка этапов выполнения операции (⏳/✅/❌) */ let svcInstances=[], selectedInst=null, selectedOp=null, pollTimer=null; -const SVC_ID=1; +let currentSvcId=1; // динамический — загружается из /api/services const AUTOTEST_PREFIX='autotest-'; -selectService(SVC_ID); +// Загрузка списка сервисов при старте +(async function initServices(){ + try{ + const r=await fetch('/api/services'); + const svcList=await r.json(); + if(!svcList||!svcList.length) return; + // Сохраняем глобально для UI переключения + window._svcList=svcList; + currentSvcId=svcList[0].svcId; // первый сервис по умолчанию + selectService(currentSvcId); + }catch(e){ + selectService(1); // fallback — Болванка + } +})(); async function selectService(svcId){ selectedInst=null; selectedOp=null; stopPoll(); @@ -65,7 +78,7 @@ async function toggleInstance(iuid){ if(opsEl.classList.contains('open')){opsEl.classList.remove('open');return;} document.querySelectorAll('.inst-ops').forEach(e=>e.classList.remove('open')); - const r=await fetch('/api/operations/'+SVC_ID); + const r=await fetch('/api/operations/'+currentSvcId); const d=await r.json(); const ops=d.operations.filter(o=>o.operation!=='reconcile'&&o.operation!=='create'); opsEl.innerHTML=ops.map(o=> @@ -98,7 +111,7 @@ function makeCreateDisplayName(){ function runOp(opName,opId){ stopPoll(); - selectedOp={opId,opName,svcId:SVC_ID}; + selectedOp={opId,opName,svcId:currentSvcId}; if(opName==='modify'){ showParams(opId,opName); }else{ @@ -114,7 +127,7 @@ function findInstName(){ } function showParams(opId,opName){ - selectedOp={opId,opName,svcId:SVC_ID}; + selectedOp={opId,opName,svcId:currentSvcId}; document.getElementById('params-card').style.display='block'; document.getElementById('stages-box').style.display='none'; document.getElementById('test-status').textContent=''; @@ -232,7 +245,7 @@ async function executeOp(params){ try{ const r=await fetch('/api/test',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ - serviceId:SVC_ID, + serviceId:currentSvcId, operation:selectedOp.opName, svcOperationId:selectedOp.opId, params, @@ -278,7 +291,7 @@ function stopPoll(){ } async function refreshInstances(){ - const r=await fetch('/api/operations/'+SVC_ID); + const r=await fetch('/api/operations/'+currentSvcId); const d=await r.json(); const newInsts=d.instances||[]; svcInstances=newInsts;