diff --git a/site/app.py b/site/app.py index f1f8325..c479fc8 100644 --- a/site/app.py +++ b/site/app.py @@ -32,7 +32,7 @@ from routes.api_scenario_defs import bp_defs as api_scenario_defs_bp # Версия — показывается в топбаре UI. Меняется при КАЖДОМ изменении кода. # Нужна для фильтрации истории (пользователь видит только записи своей версии). -VERSION = "1.2.18" +VERSION = "1.2.19" # Flask-приложение с Jinja2-шаблонами из папки templates/ app = Flask(__name__, template_folder="templates", static_folder="static") diff --git a/site/operations/tracker.py b/site/operations/tracker.py index 4ef90a9..6679dcb 100644 --- a/site/operations/tracker.py +++ b/site/operations/tracker.py @@ -113,36 +113,68 @@ def _locked_write(path, data): os.close(fd) -def add(client_id, stand, instance_uid, svc_id, display_name): - """Добавить инстанс в трекер (вызывается из executor сразу после create). +def _atomic_update(path, mutator): + """Атомарно: открыть файл → заблокировать → прочитать → мутировать → записать. - Читает текущий файл → добавляет/обновляет запись → пишет обратно. - Если инстанс уже есть — перезаписывает (идемпотентность). + Вся операция под ОДНИМ lock, в отличие от старого подхода + _locked_read() + _locked_write() где между ними lock снимался. + Это предотвращает lost-update при конкурентных add/remove. Args: - client_id: str — ClientID из JWT - stand: str — "dev"/"test" - instance_uid: str — UUID созданного инстанса - svc_id: int — ID сервиса - display_name: str — displayName инстанса""" - p = _path(client_id, stand) - data = _locked_read(p) - data[instance_uid] = { - "svcId": svc_id, - "displayName": display_name, - "instanceUid": instance_uid, - } - _locked_write(p, data) + path: str — путь к файлу + mutator: callable(data) — функция, мутирующая dict (возвращать не нужно) + + Returns: + dict — результат после мутации (или {} если не смогли)""" + try: + fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o644) + except OSError: + return {} + try: + if not _acquire_lock(fd): + return {} + # Читаем + data = {} + try: + raw = os.read(fd, 65536) + if raw: + data = json.loads(raw.decode("utf-8")) + except (json.JSONDecodeError, UnicodeDecodeError): + pass + # Мутируем + mutator(data) + # Пишем + os.lseek(fd, 0, 0) + os.ftruncate(fd, 0) + os.write(fd, json.dumps(data, indent=2).encode("utf-8")) + return data + finally: + fcntl.flock(fd, fcntl.LOCK_UN) + os.close(fd) + + +def add(client_id, stand, instance_uid, svc_id, display_name): + """Добавить инстанс в трекер (атомарно, под одним lock). + + Читает → добавляет/обновляет запись → пишет — всё под эксклюзивной блокировкой. + Если инстанс уже есть — перезаписывает (идемпотентность).""" + def _add(data): + data[instance_uid] = { + "svcId": svc_id, + "displayName": display_name, + "instanceUid": instance_uid, + } + _atomic_update(_path(client_id, stand), _add) def remove(client_id, stand, instance_uid): - """Удалить инстанс из трекера (после успешного delete). + """Удалить инстанс из трекера (атомарно, под одним lock). + + pop с default=None — не падает если инстанса уже нет.""" + def _rem(data): + data.pop(instance_uid, None) + _atomic_update(_path(client_id, stand), _rem) - pop с default=None — не падает если инстанса уже нет (уже удалён ранее).""" - p = _path(client_id, stand) - data = _locked_read(p) - data.pop(instance_uid, None) - _locked_write(p, data) def list_all(client_id, stand): diff --git a/site/routes/api_scenario_defs.py b/site/routes/api_scenario_defs.py index 61994a1..38340d3 100644 --- a/site/routes/api_scenario_defs.py +++ b/site/routes/api_scenario_defs.py @@ -55,8 +55,10 @@ def _validate_steps(steps): # Для не-create: должен быть instance_ref, instance_uid, или старый формат (fallback) if op != "create": - has_target = bool(ref or uid or (not out)) # out в не-create — ошибка выше - # ok: ref есть, uid есть, или старый формат без новых ключей (fallback) + if not ref and not uid: + # Старый формат (без instance_ref/instance_uid) — разрешаем, + # будет найден по service_id через instance_map в scenario.py + pass return None diff --git a/site/static/js/scenario-list.js b/site/static/js/scenario-list.js index cae35d3..1842c76 100644 --- a/site/static/js/scenario-list.js +++ b/site/static/js/scenario-list.js @@ -116,16 +116,21 @@ async function toggleScenarioSteps(defId) { if (s.instance_ref) html += ` → ${_esc(s.instance_ref)}`; if (s.instance_uid) html += ` → ${_esc(s.instance_uid.substring(0,8))}...`; const params = Object.entries(s.params || {}); - if (params.length) html += ' (' + params.map(([k,v]) => k+'='+v).join(', ') + ')'; + if (params.length) html += ' (' + params.map(([k,v]) => _esc(k)+'='+_esc(v)).join(', ') + ')'; html += ''; }); // Кнопки действий + // JS-escape для имени сценария в inline onclick: + // \ → \\ (backslash) + // ' → \' (terminate JS string literal) + // HTML-escape уже не нужен — внутри JS-строки в атрибуте HTML-теги не парсятся. + const escName = def.name.replace(/\\/g,'\\\\').replace(/'/g,"\\'"); html += ``; html += ``; - html += ``; + html += ``; // Удалить — только для НЕ-seed сценариев - if (!def.is_seed) html += ``; + if (!def.is_seed) html += ``; html += ''; el.innerHTML = html; @@ -182,10 +187,21 @@ async function runScenario(defId){ } // Поллинг статуса + // Поллинг статуса с защитой от бесконечного зависания + let _scenarioPollErrors = 0; scenarioPollTimer=setInterval(async()=>{ try{ const sr=await fetch('/api/scenario/run/'+runId); - if(!sr.ok){ return; } + if(!sr.ok){ + _scenarioPollErrors++; + if(_scenarioPollErrors>=5){ + stopScenarioPoll(); + busy=false; + body.innerHTML='Ошибка: сервер недоступен'; + } + return; + } + _scenarioPollErrors = 0; // сброс при успехе const last=await sr.json(); if(!last||last.error) return; @@ -203,7 +219,14 @@ async function runScenario(defId){ await refreshInstances(); // обновить список инстансов if(historyOpen) await loadHistory(); // обновить историю } - }catch(e){} + }catch(e){ + _scenarioPollErrors++; + if(_scenarioPollErrors>=5){ + stopScenarioPoll(); + busy=false; + body.innerHTML=`Ошибка поллинга: ${_esc(e.message||'')}`; + } + } },3000); }catch(e){ body.innerHTML=`Ошибка: ${_esc(e.message)}`;