v1.0.92: Steps 5-9 — dynamic services, PostgreSQL history, /api/history
This commit is contained in:
@@ -3,3 +3,4 @@ Flask>=3.0
|
||||
gunicorn>=21.2
|
||||
requests>=2.31
|
||||
PyYAML>=6.0
|
||||
psycopg2-binary>=2.9
|
||||
|
||||
+6
-1
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user