v1.0.92: Steps 5-9 — dynamic services, PostgreSQL history, /api/history

This commit is contained in:
2026-07-27 22:59:52 +04:00
parent 4952f8b096
commit d95afc60e3
7 changed files with 219 additions and 9 deletions
+1
View File
@@ -3,3 +3,4 @@ Flask>=3.0
gunicorn>=21.2
requests>=2.31
PyYAML>=6.0
psycopg2-binary>=2.9
+6 -1
View File
@@ -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)
+72
View File
@@ -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)
+39
View File
@@ -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)
+37
View File
@@ -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)
+43
View File
@@ -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):
+21 -8
View File
@@ -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;