From 7f6284bb4690d3bfe215eb15f42f1c6d0dcb9118 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Mon, 27 Jul 2026 09:26:39 +0400 Subject: [PATCH] v1.0.49: in-memory tracker (no file IO); traceback on tracker error; refreshInstances adds new items; no selectService on OK --- DOCS/sonnet-full-code-review.md | 507 ++++++++++++++++++++++++++++++++ site/app.py | 2 +- site/operations/tracker.py | 32 +- site/routes/api_test.py | 6 +- site/templates/index.html | 25 +- 5 files changed, 538 insertions(+), 34 deletions(-) create mode 100644 DOCS/sonnet-full-code-review.md diff --git a/DOCS/sonnet-full-code-review.md b/DOCS/sonnet-full-code-review.md new file mode 100644 index 0000000..ee3912f --- /dev/null +++ b/DOCS/sonnet-full-code-review.md @@ -0,0 +1,507 @@ +# Полный обзор кода app-autotest — запрос к Sonnet + +Отправлено: 27.07.2026 + +--- + +## Контекст + +Flask-приложение на Nubes pythonk8s (gunicorn, 1 worker, нет persistent volume). +Репозиторий: https://gitea.services.ngcloud.ru/forcloud/app-autotest.git +Деплой: git push → managed service редеплоит. +Текущая версия: v1.0.48. + +## ПРОБЛЕМА + +CREATE инстанса через UI: инстанс создаётся в Nubes (виден в UI платформы), НО в нашем списке инстансов НЕ появляется. Происходит СТАБИЛЬНО, 3 раза подряд (v1.0.46, v1.0.47, v1.0.48). Пробовали: +- v1.0.46: tracker_add в фоновом daemon-потоке _finish_op +- v1.0.47: tracker_add в _finish_op, но до установки _op_results[OK] (попытка победить гонку) +- v1.0.48: tracker_add синхронно в api_test() до запуска потока, с try/except: pass + +НИ ОДИН вариант не сработал. Инстанс в трекере не появляется. + +Текущий код на проде показывает 4 инстанса из _INITIAL: +``` +curl-final-test (suspended) +curl-test-144834 (suspended) +autotest-1 (suspended) +autotest-1-first (running) +``` + +Новый инстанс (f192b10d-beb0-4a25-b79f-5dbd7de4712e, создан в 09:16) — отсутствует. + +--- + +## ВЕСЬ КОД + +### 1. site/app.py — точка входа +```python +import os +from flask import Flask +from routes.main import bp as main_bp +from routes.api import bp as api_bp +from routes.api_test import bp as api_test_bp + +VERSION = "1.0.48" + +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 +app.register_blueprint(main_bp) +app.register_blueprint(api_bp) +app.register_blueprint(api_test_bp) + +@app.route("/health") +def health(): + return "OK" + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5000) +``` + +### 2. site/api/http_client.py — HTTP клиент +```python +import requests + +class HttpClient: + def __init__(self, endpoint, token): + self._endpoint = endpoint.rstrip("/") + self._session = requests.Session() + self._session.headers.update({ + "Authorization": f"Bearer {token}", + "User-Agent": "Mozilla/5.0", + }) + + def get(self, path, **kwargs): + kwargs.setdefault("timeout", 10) + r = self._session.get(f"{self._endpoint}{path}", **kwargs) + r.raise_for_status() + return r.json() + + def post(self, path, data=None, **kwargs): + kwargs.setdefault("timeout", 30) + url = f"{self._endpoint}{path}" + r = self._session.post(url, json=(data if data is not None else {}), **kwargs) + if not r.ok: + raise Exception(f"POST {path}: {r.status_code} {r.reason}: {r.text[:200]}") + result = {} + try: + parsed = r.json() + if isinstance(parsed, dict): + result = parsed + except Exception: + pass + loc = r.headers.get("Location", "") + if loc: + result["_location"] = loc + return result +``` + +### 3. site/operations/tracker.py — трекер инстансов +```python +"""Трекер созданных инстансов — только те что породило приложение.""" +import json +import os +import threading + +_LOCK = threading.Lock() +_PATH = "/tmp/instances.json" + +_INITIAL = { + "408b7f96-6ed7-4985-be1b-f5bdcb6ab44d": {"svcId": 1, "displayName": "autotest-1", "instanceUid": "408b7f96-6ed7-4985-be1b-f5bdcb6ab44d"}, + "884356cf-5212-4395-b56b-27c58a5fc1fa": {"svcId": 1, "displayName": "autotest-1-first", "instanceUid": "884356cf-5212-4395-b56b-27c58a5fc1fa"}, + "6528854d-a4ea-428c-9fa4-68e85fa9b3ac": {"svcId": 1, "displayName": "curl-test-144834", "instanceUid": "6528854d-a4ea-428c-9fa4-68e85fa9b3ac"}, + "fdcb5887-39f7-4ef8-a9c0-d55e434a55ff": {"svcId": 1, "displayName": "curl-final-test", "instanceUid": "fdcb5887-39f7-4ef8-a9c0-d55e434a55ff"}, +} + +def _load(): + try: + with open(_PATH) as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + data = dict(_INITIAL) + _save(data) + return data + +def _save(data): + with open(_PATH, "w") as f: + json.dump(data, f, indent=2) + +def add(instance_uid, svc_id, display_name): + with _LOCK: + data = _load() + data[instance_uid] = { + "svcId": svc_id, + "displayName": display_name, + "instanceUid": instance_uid, + } + _save(data) + +def remove(instance_uid): + with _LOCK: + data = _load() + data.pop(instance_uid, None) + _save(data) + +def list_all(): + with _LOCK: + return list(_load().values()) +``` + +### 4. site/routes/api_test.py — основной файл с проблемой +```python +from flask import Blueprint, current_app, jsonify, request + +from api.http_client import HttpClient +from operations.get_services import get_services, get_service_detail +from operations.get_instances import get_instances +from operations.tracker import add as tracker_add, list_all as tracker_list +from operations.tracker import remove as tracker_remove + +bp = Blueprint("api_test", __name__) + + +def _client(): + token = request.cookies.get("token") or current_app.config["NUBES_API_TOKEN"] + return HttpClient(current_app.config["NUBES_API_ENDPOINT"], token) + + +@bp.route("/api/services") +def api_services(): + try: + raw = get_services(_client()) + svc_list = [{"svcId": s["svcId"], "svc": s["svc"], "svcExtendedName": s.get("svcExtendedName", "")} for s in raw] + svc_list.sort(key=lambda s: s["svcId"]) + return jsonify(svc_list) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +@bp.route("/api/instances/list") +def api_instances_list(): + try: + raw = get_instances(_client()) + inst = [{"instanceUid": i["instanceUid"], "displayName": i["displayName"], "serviceId": i["serviceId"], "svc": i["svc"], "explainedStatus": i.get("explainedStatus", "?")} for i in raw] + return jsonify(inst) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +@bp.route("/api/operations/") +def api_operations(svc_id): + try: + detail = get_service_detail(_client(), svc_id) + ops = detail.get("operations", []) + # только отслеживаемые инстансы этого сервиса + tracked = tracker_list() + tracked_uids = {t["instanceUid"] for t in tracked if t["svcId"] == svc_id} + instances = get_instances(_client()) + svc_instances = [i for i in instances + if i.get("instanceUid") in tracked_uids + and i.get("explainedStatus") not in ("deleted", "not created")] + return jsonify({ + "svc": detail.get("svc", ""), + "operations": [{"svcOperationId": o["svcOperationId"], "operation": o["operation"]} for o in ops], + "instances": svc_instances, + }) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +@bp.route("/api/params/") +def api_params(op_id): + try: + data = _client().get(f"/instanceOperations/default/{op_id}") + params = data["svcOperation"]["cfsParams"] + result = [] + for p in params: + result.append({ + "svcOperationCfsParamId": p["svcOperationCfsParamId"], + "name": p.get("svcOperationCfsParam", ""), + "dataType": p.get("dataType", ""), + "isRequired": p.get("isRequired", False), + "defaultValue": p.get("defaultValue"), + "valueList": p.get("valueList"), + "dataDescriptor": {k: {"dataType": v.get("dataType",""), "valueList": v.get("valueList",""), "isRequired": v.get("isRequired", False)} for k, v in p.get("dataDescriptor", {}).items()} if p.get("dataDescriptor") else None, + }) + return jsonify(result) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +@bp.route("/api/test", methods=["POST"]) +def api_test(): + """Запустить операцию — возвращает opUid сразу, выполнение в фоне.""" + import threading, time + + data = request.get_json() + svc_id = data["serviceId"] + op_name = data["operation"] + svc_op_id = data["svcOperationId"] + params = data.get("params", {}) + instance_uid = data.get("instanceUid") + display_name = data.get("displayName", f"autotest-{svc_id}") + + client = _client() + + try: + if op_name == "create": + payload = {"serviceId": svc_id, "displayName": display_name, "descr": ""} + resp = client.post("/instances", payload) + instance_uid = resp.get("instanceUid") or _find_uid(resp) or _uid_from_location(resp.get("_location", "")) + if not instance_uid: + return jsonify({"status": "FAIL", "error": "Не удалось получить instanceUid"}), 500 + + op_payload = {"instanceUid": instance_uid, "operation": "create"} + op_resp = client.post("/instanceOperations", op_payload) + op_uid = _find_uid(op_resp) or _uid_from_location(op_resp.get("_location", "")) + if not op_uid: + return jsonify({"status": "FAIL", "error": "Не удалось получить opUid"}), 500 + + for pid, pval in params.items(): + client.post("/instanceOperationCfsParams", + {"instanceOperationUid": op_uid, "svcOperationCfsParamId": int(pid), "paramValue": str(pval)}) + client.post(f"/instanceOperations/{op_uid}/run") + + # Записать в трекер СРАЗУ, до фонового потока + try: + tracker_add(instance_uid, svc_id, display_name) + except Exception: + pass # ← МОЛЧА ПРОГЛАТЫВАЕТ ОШИБКУ + + # фоном ждать завершения + threading.Thread(target=_finish_op, args=(client, op_uid, instance_uid, svc_id, display_name, op_name, svc_op_id, True), daemon=True).start() + return jsonify({"status": "RUNNING", "opUid": op_uid, "instanceUid": instance_uid}) + + else: + if not instance_uid: + return jsonify({"status": "FAIL", "error": "Нет instanceUid"}), 400 + + if op_name == "redeploy": + op_payload = {"instanceUid": instance_uid, "svcOperationId": svc_op_id, "operation": op_name} + op_resp = client.post("/instanceOperations", op_payload) + op_uid = _find_uid(op_resp) or _uid_from_location(op_resp.get("_location", "")) + if not op_uid: + return jsonify({"status": "FAIL", "error": "Не удалось получить opUid"}), 500 + client.post(f"/instanceOperations/{op_uid}/run") + else: + op_payload = {"instanceUid": instance_uid, "svcOperationId": svc_op_id, "operation": op_name} + op_resp = client.post("/instanceOperations", op_payload) + op_uid = _find_uid(op_resp) or _uid_from_location(op_resp.get("_location", "")) + if not op_uid: + return jsonify({"status": "FAIL", "error": "Не удалось получить opUid"}), 500 + for pid, pval in params.items(): + client.post("/instanceOperationCfsParams", + {"instanceOperationUid": op_uid, "svcOperationCfsParamId": int(pid), "paramValue": str(pval)}) + client.post(f"/instanceOperations/{op_uid}/run") + + is_delete = (op_name == "delete") + threading.Thread(target=_finish_op, args=(client, op_uid, instance_uid, svc_id, display_name, op_name, svc_op_id, False, is_delete), daemon=True).start() + return jsonify({"status": "RUNNING", "opUid": op_uid, "instanceUid": instance_uid}) + + except Exception as e: + import traceback + return jsonify({"status": "FAIL", "error": str(e) + " | " + traceback.format_exc()[-200:]}) + + +# Результаты фоновых операций: opUid → {status, error, stages, duration} +_op_results = {} + + +def _finish_op(client, op_uid, instance_uid, svc_id, display_name, op_name, svc_op_id, is_create, is_delete=False): + """Фоном ждать dtFinish и сохранить результат.""" + import time + t0 = time.time() + deadline = t0 + 300 + while time.time() < deadline: + try: + data = client.get(f"/instanceOperations/{op_uid}?fields=dtFinish,isSuccessful,errorLog,isInProgress,duration,stages") + except Exception: + time.sleep(5) + continue + op = data.get("instanceOperation", {}) + dt_finish = op.get("dtFinish") + _op_results[op_uid] = { + "status": "RUNNING", + "stages": op.get("stages", []), + "duration": round(time.time() - t0, 1), + } + if dt_finish and str(dt_finish).strip(): + is_ok = op.get("isSuccessful") + err = op.get("errorLog") or "" + print(f"[DEBUG] _finish_op op_uid={op_uid} isSuccessful={is_ok!r}", flush=True) + if is_ok and is_delete: + tracker_remove(instance_uid) + _op_results[op_uid] = { + "status": "OK" if is_ok else "FAIL", + "error": str(err) if err else "", + "stages": op.get("stages", []), + "duration": round(time.time() - t0, 1), + } + return + time.sleep(5) + _op_results[op_uid] = {"status": "TIMEOUT", "duration": round(time.time() - t0, 1)} + + +@bp.route("/api/test/status/") +def api_test_status(op_uid): + """Получить текущий статус операции (поллинг с UI).""" + if op_uid in _op_results: + return jsonify(_op_results[op_uid]) + try: + data = _client().get(f"/instanceOperations/{op_uid}?fields=dtFinish,isSuccessful,errorLog,isInProgress,duration,stages") + op = data.get("instanceOperation", {}) + dt_finish = op.get("dtFinish") + done = bool(dt_finish and str(dt_finish).strip()) + return jsonify({ + "status": "OK" if (done and op.get("isSuccessful")) else ("FAIL" if done else "RUNNING"), + "done": done, + "isSuccessful": op.get("isSuccessful"), + "isInProgress": op.get("isInProgress"), + "duration": op.get("duration"), + "stages": op.get("stages", []), + "errorLog": op.get("errorLog"), + }) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +def _find_uid(d): + if isinstance(d, dict): + for k in ("instanceUid", "instanceOperationUid", "uid", "Uid"): + if k in d: + return d[k] + return None + + +def _uid_from_location(loc): + parts = loc.rstrip("/").split("/") + return parts[-1] if parts else None +``` + +### 5. site/routes/main.py — главная страница (фрагмент с /api/operations) +```python +@bp.route("/api/operations/") +def api_operations(svc_id): + try: + detail = get_service_detail(_client(), svc_id) + ops = detail.get("operations", []) + tracked = tracker_list() + tracked_uids = {t["instanceUid"] for t in tracked if t["svcId"] == svc_id} + instances = get_instances(_client()) + svc_instances = [i for i in instances + if i.get("instanceUid") in tracked_uids + and i.get("explainedStatus") not in ("deleted", "not created")] + return jsonify({ + "svc": detail.get("svc", ""), + "operations": [{"svcOperationId": o["svcOperationId"], "operation": o["operation"]} for o in ops], + "instances": svc_instances, + }) + except Exception as e: + return jsonify({"error": str(e)}), 500 +``` + +### 6. site/templates/index.html — UI (ключевые функции) +```javascript +// Инстансы + кнопки операций +async function selectService(svcId){ + selectedInst=null; selectedOp=null; stopPoll(); + document.getElementById('params-card').style.display='none'; + document.getElementById('stages-box').style.display='none'; + const r=await fetch('/api/operations/'+svcId); + const d=await r.json(); + svcInstances=d.instances||[]; + // ... рендерит список +} + +async function executeOp(params){ + stopPoll(); + const displayName=document.getElementById('param-displayname')?.value||'autotest-1'; + // ... очищает форму + const r=await fetch('/api/test',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ + serviceId:SVC_ID, + operation:selectedOp.opName, + svcOperationId:selectedOp.opId, + params, + instanceUid:selectedInst||'', + displayName + })}); + const d=await r.json(); + if(d.status==='FAIL'){/* ошибка */ return;} + // start polling + const opUid=d.opUid; + pollTimer=setInterval(async()=>{ + const sr=await fetch('/api/test/status/'+opUid); + const sd=await sr.json(); + showStages(sd.stages||[]); + if(sd.status!=='RUNNING'){ + stopPoll(); + // ... показать результат + if(sd.status==='OK'){ + if(selectedOp.opName==='create'){await selectService(SVC_ID);} // ← ПЕРЕЗАГРУЖАЕТ ВСЁ + else{await refreshInstances();} + } + } + },2000); +} + +async function refreshInstances(){ + const r=await fetch('/api/operations/'+SVC_ID); + const d=await r.json(); + svcInstances=d.instances||[]; + // обновить только бейджи статусов + svcInstances.forEach(i=>{ + const el=document.querySelector(`[data-iuid="${i.instanceUid}"] .badge`); + if(el){ + el.textContent=i.explainedStatus||'?'; + el.className='badge '+(i.explainedStatus==='running'?'badge-success':''); + } + }); +} +``` + +--- + +## ЧТО ПРОИСХОДИТ ПРИ CREATE (трассировка) + +1. UI: `executeOp()` → POST `/api/test` `{serviceId:1, operation:"create", svcOperationId:18, params:{...}, instanceUid:"", displayName:"autotest-1-lq5x3a"}` +2. `api_test()`: `op_name="create"`, `svc_id=1` (int), `display_name="autotest-1-lq5x3a"` (str) +3. `client.post("/instances", ...)` → ответ от Nubes → `instance_uid = "f192b10d-beb0-4a25-b79f-5dbd7de4712e"` +4. `client.post("/instanceOperations", ...)` → `op_uid = "d489348e-5d92-47cd-97f7-25f1c4d65ffc"` +5. Параметры → `client.post("/instanceOperationCfsParams", ...)` для каждого +6. `client.post("/instanceOperations/{op_uid}/run")` → запуск +7. **`tracker_add("f192b10d-...", 1, "autotest-1-lq5x3a")`** ← ЗДЕСЬ ПРОБЛЕМА +8. `threading.Thread(target=_finish_op, ...)` → фон +9. Возврат `{status:"RUNNING", opUid:"d489348e-...", instanceUid:"f192b10d-..."}` +10. UI поллит `/api/test/status/d489348e-...` +11. `_finish_op` получает `isSuccessful=true` → `_op_results[opUid] = {status:"OK", ...}` +12. UI получает OK → вызывает `selectService(1)` → `/api/operations/1` → `tracker_list()` → 4 инстанса → **нового НЕТ** + +--- + +## ВОПРОСЫ + +### Критический: почему tracker_add не работает? + +1. Может ли `json.dump` в `/tmp/instances.json` падать молча на pythonk8s? (диск full, fs ro, quota) +2. Может ли `_save` записать, но `_load` прочитать старую версию из-за кеша ФС? +3. Может ли gunicorn preload создавать несколько копий модуля tracker.py с разными `_LOCK`? +4. Может ли `_INITIAL` перезаписывать файл при КАЖДОМ `_load`, если файл повреждён? +5. **ГЛАВНЫЙ ВОПРОС: как надёжно сохранять состояние на pythonk8s БЕЗ persistent volume?** + +### Архитектурный +6. Не перейти ли на sqlite3 в `/tmp/`? Даст ли это атомарность? +7. Не заменить ли `/tmp/instances.json` на in-memory dict + seed из `_INITIAL` при старте? (без файла вообще) +8. Как правильно логировать ошибки на pythonk8s чтобы их было видно? + +### UI +9. После успешного CREATE `selectService()` скрывает progress card — это бесит. Как обновить только список инстансов не скрывая stages? + +--- + +## Что уже пробовали (НЕ ПОМОГЛО) + +- v1.0.46: tracker_add в daemon-потоке _finish_op → поток умирает под gunicorn +- v1.0.47: tracker_add в _finish_op, но до _op_results[OK] → не помогло +- v1.0.48: tracker_add синхронно в api_test() ДО потока, try/except: pass → ОШИБКА СКРЫТА diff --git a/site/app.py b/site/app.py index b04e661..5b4f9bd 100644 --- a/site/app.py +++ b/site/app.py @@ -6,7 +6,7 @@ from routes.main import bp as main_bp from routes.api import bp as api_bp from routes.api_test import bp as api_test_bp -VERSION = "1.0.48" +VERSION = "1.0.49" 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") diff --git a/site/operations/tracker.py b/site/operations/tracker.py index 6b131fe..fe43424 100644 --- a/site/operations/tracker.py +++ b/site/operations/tracker.py @@ -1,12 +1,9 @@ -"""Трекер созданных инстансов — только те что породило приложение.""" -import json -import os +"""Трекер созданных инстансов — in-memory dict (один gunicorn worker).""" import threading _LOCK = threading.Lock() -_PATH = "/tmp/instances.json" -_INITIAL = { +_data = { "408b7f96-6ed7-4985-be1b-f5bdcb6ab44d": {"svcId": 1, "displayName": "autotest-1", "instanceUid": "408b7f96-6ed7-4985-be1b-f5bdcb6ab44d"}, "884356cf-5212-4395-b56b-27c58a5fc1fa": {"svcId": 1, "displayName": "autotest-1-first", "instanceUid": "884356cf-5212-4395-b56b-27c58a5fc1fa"}, "6528854d-a4ea-428c-9fa4-68e85fa9b3ac": {"svcId": 1, "displayName": "curl-test-144834", "instanceUid": "6528854d-a4ea-428c-9fa4-68e85fa9b3ac"}, @@ -14,39 +11,20 @@ _INITIAL = { } -def _load(): - try: - with open(_PATH) as f: - return json.load(f) - except (FileNotFoundError, json.JSONDecodeError): - data = dict(_INITIAL) - _save(data) - return data - - -def _save(data): - with open(_PATH, "w") as f: - json.dump(data, f, indent=2) - - def add(instance_uid, svc_id, display_name): with _LOCK: - data = _load() - data[instance_uid] = { + _data[instance_uid] = { "svcId": svc_id, "displayName": display_name, "instanceUid": instance_uid, } - _save(data) def remove(instance_uid): with _LOCK: - data = _load() - data.pop(instance_uid, None) - _save(data) + _data.pop(instance_uid, None) def list_all(): with _LOCK: - return list(_load().values()) + return list(_data.values()) diff --git a/site/routes/api_test.py b/site/routes/api_test.py index 57864a5..bc6a0b2 100644 --- a/site/routes/api_test.py +++ b/site/routes/api_test.py @@ -114,8 +114,10 @@ def api_test(): # Записать в трекер СРАЗУ, до фонового потока (поток может умереть под gunicorn) try: tracker_add(instance_uid, svc_id, display_name) - except Exception: - pass + except Exception as e: + import traceback + print(f"[TRACKER ERROR] add failed: {e}", flush=True) + traceback.print_exc() # фоном ждать завершения threading.Thread(target=_finish_op, args=(client, op_uid, instance_uid, svc_id, display_name, op_name, svc_op_id, True), daemon=True).start() diff --git a/site/templates/index.html b/site/templates/index.html index 332e340..51b0247 100644 --- a/site/templates/index.html +++ b/site/templates/index.html @@ -249,8 +249,7 @@ async function executeOp(params){ btn.disabled=false; btn.textContent='Готово'; if(sd.status==='OK'){ - if(selectedOp.opName==='create'){await selectService(SVC_ID);} - else{await refreshInstances();} + await refreshInstances(); } } },2000); @@ -278,8 +277,11 @@ function stopPoll(){ async function refreshInstances(){ const r=await fetch('/api/operations/'+SVC_ID); const d=await r.json(); - svcInstances=d.instances||[]; - // обновить только бейджи статусов + const newInsts=d.instances||[]; + const oldUids=new Set(svcInstances.map(i=>i.instanceUid)); + const added=newInsts.filter(i=>!oldUids.has(i.instanceUid)); + svcInstances=newInsts; + // обновить бейджи существующих svcInstances.forEach(i=>{ const el=document.querySelector(`[data-iuid="${i.instanceUid}"] .badge`); if(el){ @@ -287,6 +289,21 @@ async function refreshInstances(){ el.className='badge '+(i.explainedStatus==='running'?'badge-success':''); } }); + // добавить новые инстансы в список (перед кнопкой "+ Создать") + if(added.length){ + const list=document.getElementById('inst-list'); + const addBtn=list.querySelector('[onclick="startCreate()"]'); + let html=''; + added.forEach(i=>{ + const sc=i.explainedStatus==='running'?'badge-success':''; + html+=`
+ ${i.displayName} + ${i.explainedStatus||'?'} +
`; + html+=`
`; + }); + if(addBtn){addBtn.insertAdjacentHTML('beforebegin',html);} + } }