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)}`;