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
|
gunicorn>=21.2
|
||||||
requests>=2.31
|
requests>=2.31
|
||||||
PyYAML>=6.0
|
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
|
from routes.api_test import bp as api_test_bp
|
||||||
|
|
||||||
# Версия — меняется при КАЖДОМ изменении кода. Показывается в топбаре UI.
|
# Версия — меняется при КАЖДОМ изменении кода. Показывается в топбаре UI.
|
||||||
VERSION = "1.0.91"
|
VERSION = "1.0.92"
|
||||||
|
|
||||||
app = Flask(__name__, template_folder="templates", static_folder="static")
|
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_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["NUBES_API_TOKEN"] = os.getenv("NUBES_API_TOKEN", "")
|
||||||
app.config["VERSION"] = VERSION
|
app.config["VERSION"] = VERSION
|
||||||
|
|
||||||
|
# Инициализация схемы БД (идемпотентно, безопасно для нескольких воркеров)
|
||||||
|
from db.init_db import init_db
|
||||||
|
init_db()
|
||||||
|
|
||||||
app.register_blueprint(main_bp)
|
app.register_blueprint(main_bp)
|
||||||
app.register_blueprint(api_bp)
|
app.register_blueprint(api_bp)
|
||||||
app.register_blueprint(api_test_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),
|
"duration": round(time.time() - t0, 1),
|
||||||
"_ts": time.time(),
|
"_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
|
return
|
||||||
time.sleep(5)
|
time.sleep(5)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -391,6 +403,37 @@ def api_log():
|
|||||||
return jsonify([])
|
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):
|
def _find_uid(resp):
|
||||||
"""Извлечь instanceUid или instanceOperationUid из ответа API."""
|
"""Извлечь instanceUid или instanceOperationUid из ответа API."""
|
||||||
if isinstance(resp, dict):
|
if isinstance(resp, dict):
|
||||||
|
|||||||
+21
-8
@@ -12,7 +12,7 @@
|
|||||||
* selectedInst — UID выбранного инстанса (null если не выбран)
|
* selectedInst — UID выбранного инстанса (null если не выбран)
|
||||||
* selectedOp — {opId, opName, svcId} выбранная операция
|
* selectedOp — {opId, opName, svcId} выбранная операция
|
||||||
* pollTimer — setInterval таймер поллинга статуса операции
|
* pollTimer — setInterval таймер поллинга статуса операции
|
||||||
* SVC_ID = 1 — фиксированный сервис (Болванка)
|
* currentSvcId — ID текущего выбранного сервиса (по умолчанию 1 = Болванка)
|
||||||
* AUTOTEST_PREFIX — префикс имён autotest-инстансов
|
* AUTOTEST_PREFIX — префикс имён autotest-инстансов
|
||||||
*
|
*
|
||||||
* Потоки:
|
* Потоки:
|
||||||
@@ -26,10 +26,23 @@
|
|||||||
* showStages() — отрисовка этапов выполнения операции (⏳/✅/❌)
|
* showStages() — отрисовка этапов выполнения операции (⏳/✅/❌)
|
||||||
*/
|
*/
|
||||||
let svcInstances=[], selectedInst=null, selectedOp=null, pollTimer=null;
|
let svcInstances=[], selectedInst=null, selectedOp=null, pollTimer=null;
|
||||||
const SVC_ID=1;
|
let currentSvcId=1; // динамический — загружается из /api/services
|
||||||
const AUTOTEST_PREFIX='autotest-';
|
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){
|
async function selectService(svcId){
|
||||||
selectedInst=null; selectedOp=null; stopPoll();
|
selectedInst=null; selectedOp=null; stopPoll();
|
||||||
@@ -65,7 +78,7 @@ async function toggleInstance(iuid){
|
|||||||
if(opsEl.classList.contains('open')){opsEl.classList.remove('open');return;}
|
if(opsEl.classList.contains('open')){opsEl.classList.remove('open');return;}
|
||||||
document.querySelectorAll('.inst-ops').forEach(e=>e.classList.remove('open'));
|
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 d=await r.json();
|
||||||
const ops=d.operations.filter(o=>o.operation!=='reconcile'&&o.operation!=='create');
|
const ops=d.operations.filter(o=>o.operation!=='reconcile'&&o.operation!=='create');
|
||||||
opsEl.innerHTML=ops.map(o=>
|
opsEl.innerHTML=ops.map(o=>
|
||||||
@@ -98,7 +111,7 @@ function makeCreateDisplayName(){
|
|||||||
|
|
||||||
function runOp(opName,opId){
|
function runOp(opName,opId){
|
||||||
stopPoll();
|
stopPoll();
|
||||||
selectedOp={opId,opName,svcId:SVC_ID};
|
selectedOp={opId,opName,svcId:currentSvcId};
|
||||||
if(opName==='modify'){
|
if(opName==='modify'){
|
||||||
showParams(opId,opName);
|
showParams(opId,opName);
|
||||||
}else{
|
}else{
|
||||||
@@ -114,7 +127,7 @@ function findInstName(){
|
|||||||
}
|
}
|
||||||
|
|
||||||
function showParams(opId,opName){
|
function showParams(opId,opName){
|
||||||
selectedOp={opId,opName,svcId:SVC_ID};
|
selectedOp={opId,opName,svcId:currentSvcId};
|
||||||
document.getElementById('params-card').style.display='block';
|
document.getElementById('params-card').style.display='block';
|
||||||
document.getElementById('stages-box').style.display='none';
|
document.getElementById('stages-box').style.display='none';
|
||||||
document.getElementById('test-status').textContent='';
|
document.getElementById('test-status').textContent='';
|
||||||
@@ -232,7 +245,7 @@ async function executeOp(params){
|
|||||||
|
|
||||||
try{
|
try{
|
||||||
const r=await fetch('/api/test',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({
|
const r=await fetch('/api/test',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({
|
||||||
serviceId:SVC_ID,
|
serviceId:currentSvcId,
|
||||||
operation:selectedOp.opName,
|
operation:selectedOp.opName,
|
||||||
svcOperationId:selectedOp.opId,
|
svcOperationId:selectedOp.opId,
|
||||||
params,
|
params,
|
||||||
@@ -278,7 +291,7 @@ function stopPoll(){
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function refreshInstances(){
|
async function refreshInstances(){
|
||||||
const r=await fetch('/api/operations/'+SVC_ID);
|
const r=await fetch('/api/operations/'+currentSvcId);
|
||||||
const d=await r.json();
|
const d=await r.json();
|
||||||
const newInsts=d.instances||[];
|
const newInsts=d.instances||[];
|
||||||
svcInstances=newInsts;
|
svcInstances=newInsts;
|
||||||
|
|||||||
Reference in New Issue
Block a user