Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0e16b91a8 | ||
|
|
b8e7292667 | ||
|
|
c50aaf59d8 | ||
|
|
0783319fd1 | ||
|
|
82a3be2067 | ||
|
|
0180f3cb3f | ||
|
|
bc09b69aa4 | ||
|
|
3c97fea4bc | ||
|
|
beaff3f1cf | ||
|
|
bfa21accf5 | ||
|
|
e618d6d011 | ||
|
|
2133a1b2fe | ||
|
|
10377e9cdb | ||
|
|
78b5063259 | ||
|
|
cc60f9401a | ||
|
|
74c22d2612 | ||
|
|
eb517594bc | ||
|
|
622a7c7023 | ||
|
|
15f5ebe647 | ||
|
|
353e07bfa8 | ||
|
|
91e97f6f75 | ||
|
|
38f58ddbd7 | ||
|
|
7ae172e863 | ||
|
|
a96658b9b5 | ||
|
|
189a2779d6 | ||
|
|
e5e96593f1 | ||
|
|
7354d9c125 | ||
|
|
6f051aa348 | ||
|
|
983b387708 | ||
|
|
67f6390509 | ||
|
|
7f69c916ec | ||
|
|
566a6da8d9 | ||
|
|
7eeb1131b3 | ||
|
|
0ab6b3a89e | ||
|
|
045ca438c6 | ||
|
|
acc28351c5 | ||
|
|
b6808423da | ||
|
|
b26332fbe7 | ||
|
|
3f653a7205 |
@@ -1,417 +0,0 @@
|
|||||||
# ⚠️ LEGACY — НЕАКТУАЛЬНО. См. ARCHITECTURE.md для текущего состояния.
|
|
||||||
|
|
||||||
# Архитектура app-autotest — полный документ (v1.0.76)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Обзор
|
|
||||||
|
|
||||||
Flask-приложение для автотестов операций сервисов облачной платформы Nubes.
|
|
||||||
Деплой: Nubes pythonk8s (gunicorn), тестовый стенд `atest.pythonk8s.dev.nubes.ru`.
|
|
||||||
Репозиторий: `https://gitea.services.ngcloud.ru/forcloud/app-autotest.git`.
|
|
||||||
|
|
||||||
Текущий срез потока операций:
|
|
||||||
- CREATE и обычные операции разделены в UI и backend.
|
|
||||||
- CREATE использует фиксированный префикс `autotest-`, non-create работает по выбранному инстансу.
|
|
||||||
- Финальный статус показывает статус, операцию, длительность и имя инстанса.
|
|
||||||
- Список autotest-инстансов строится cloud-first; tracker нужен только как короткий fallback после CREATE.
|
|
||||||
- **Modify** (и другие операции с параметрами) показывает **текущие** значения параметров инстанса: создаётся pending-операция, читаются `cfsParams.paramValue`, форма заполняется реальными данными. При submit операция переиспользуется через `previewOpUid`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Структура файлов
|
|
||||||
|
|
||||||
```
|
|
||||||
app-autotest/
|
|
||||||
├── site/ ← НЕ пакет (без __init__.py, конфликт с stdlib)
|
|
||||||
│ ├── app.py ← Flask(__name__), VERSION, blueprints, /health
|
|
||||||
│ ├── api/
|
|
||||||
│ │ └── http_client.py ← HttpClient + STANDS + detect_endpoint()
|
|
||||||
│ ├── operations/
|
|
||||||
│ │ ├── tracker.py ← Трекер: /tmp/instances.json + fcntl.flock
|
|
||||||
│ │ ├── get_services.py ← get_services(), get_service_detail()
|
|
||||||
│ │ └── get_instances.py ← get_instances(), get_organization()
|
|
||||||
│ ├── routes/
|
|
||||||
│ │ ├── main.py ← GET/POST / (главная), _tmpl()
|
|
||||||
│ │ ├── api.py ← /api/run, /api/status, /api/config
|
|
||||||
│ │ └── api_test.py ← /api/test, /api/test/status, _finish_op
|
|
||||||
│ ├── templates/
|
|
||||||
│ │ └── index.html ← UI: Jinja2 + JS (поллинг)
|
|
||||||
│ └── static/
|
|
||||||
│ └── style.css
|
|
||||||
└── secrets/
|
|
||||||
├── dev.token ← Токен для DEV стенда
|
|
||||||
├── test.token ← Токен для TEST стенда
|
|
||||||
└── test.token.new
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Поток CREATE — полная трассировка (14 шагов)
|
|
||||||
|
|
||||||
### Шаг 1: UI — пользователь начинает CREATE
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// index.html: startCreate()
|
|
||||||
function startCreate(){
|
|
||||||
selectedInst=null; // нет выбранного инстанса
|
|
||||||
stopPoll(); // остановить предыдущий поллинг
|
|
||||||
document.querySelectorAll('[data-iuid]').forEach(e=>e.classList.remove('active'));
|
|
||||||
document.querySelectorAll('.inst-ops').forEach(e=>e.classList.remove('open'));
|
|
||||||
showParams(18,'create'); // 18 = svcOperationId для create у сервиса "Болванка"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`selectedOp = {opId: 18, opName: "create", svcId: 1}`
|
|
||||||
|
|
||||||
### Шаг 2: UI — загрузка параметров и показ формы
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// index.html: showParams(18, 'create')
|
|
||||||
fetch('/api/params/18') // GET /api/params/18
|
|
||||||
.then(r=>r.json())
|
|
||||||
.then(params=>{
|
|
||||||
// Генерация формы: displayName + поля из params
|
|
||||||
const displayName = 'autotest-1-' + Date.now().toString(36);
|
|
||||||
form.innerHTML = '<input id="param-displayname" value="' + displayName + '">'
|
|
||||||
+ params.map(p => '<input name="p_' + p.svcOperationCfsParamId + '">').join('');
|
|
||||||
|
|
||||||
btn.onclick = () => {
|
|
||||||
// Сбор params из формы
|
|
||||||
const pp = {};
|
|
||||||
document.querySelectorAll('#params-form [name^="p_"]').forEach(el => {
|
|
||||||
pp[el.name.replace('p_','')] = el.value;
|
|
||||||
});
|
|
||||||
executeOp(pp); // ← запуск
|
|
||||||
};
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
Бэкенд: `GET /instanceOperations/default/18` → возвращает список `cfsParams`.
|
|
||||||
|
|
||||||
### Шаг 3: UI — executeOp (отправка)
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// index.html: executeOp(params)
|
|
||||||
const displayName = document.getElementById('param-displayname')?.value || 'autotest-1';
|
|
||||||
// Захват ДО очистки формы! (v1.0.48)
|
|
||||||
|
|
||||||
document.getElementById('params-form').innerHTML = ''; // очистка
|
|
||||||
|
|
||||||
fetch('/api/test', {
|
|
||||||
method:'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
serviceId: 1, // SVC_ID
|
|
||||||
operation: "create",
|
|
||||||
svcOperationId: 18,
|
|
||||||
params: {...}, // собранные параметры
|
|
||||||
instanceUid: "", // пусто для create
|
|
||||||
displayName: displayName
|
|
||||||
})
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
JSON: `{"serviceId":1, "operation":"create", "svcOperationId":18, "params":{...}, "instanceUid":"", "displayName":"autotest-1-lq5x3a"}`
|
|
||||||
|
|
||||||
### Шаг 4: Бэкенд — api_test() разбор запроса
|
|
||||||
|
|
||||||
```python
|
|
||||||
# api_test.py: POST /api/test
|
|
||||||
data = request.get_json()
|
|
||||||
svc_id = data["serviceId"] # 1 (int)
|
|
||||||
op_name = data["operation"] # "create" (str)
|
|
||||||
svc_op_id = data["svcOperationId"] # 18 (int)
|
|
||||||
params = data.get("params", {}) # {...} (dict)
|
|
||||||
instance_uid = data.get("instanceUid") # "" (falsy)
|
|
||||||
display_name = data.get("displayName", f"autotest-{svc_id}") # "autotest-1-lq5x3a"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Шаг 5: Бэкенд — _client() с автоопределением стенда
|
|
||||||
|
|
||||||
```python
|
|
||||||
def _client():
|
|
||||||
token = request.cookies.get("token") or current_app.config["NUBES_API_TOKEN"]
|
|
||||||
endpoint = detect_endpoint(token) or current_app.config["NUBES_API_ENDPOINT"]
|
|
||||||
return HttpClient(endpoint, token)
|
|
||||||
```
|
|
||||||
|
|
||||||
`detect_endpoint(token)` пробует dev→test стенды, возвращает рабочий URL или None.
|
|
||||||
|
|
||||||
### Шаг 6: Бэкенд — создание инстанса в Nubes
|
|
||||||
|
|
||||||
```python
|
|
||||||
payload = {"serviceId": 1, "displayName": "autotest-1-lq5x3a", "descr": ""}
|
|
||||||
resp = client.post("/instances", payload)
|
|
||||||
# Ответ: {"instanceUid": "93b0b65d-..."}
|
|
||||||
instance_uid = resp.get("instanceUid") or _find_uid(resp) or _uid_from_location(...)
|
|
||||||
# → "93b0b65d-e080-416d-bbb8-463f7adbda80"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Шаг 7: Бэкенд — создание операции
|
|
||||||
|
|
||||||
```python
|
|
||||||
op_payload = {"instanceUid": "93b0b65d-...", "operation": "create"}
|
|
||||||
op_resp = client.post("/instanceOperations", op_payload)
|
|
||||||
# Ответ: {"instanceOperationUid": "0b0596d8-..."}
|
|
||||||
op_uid = _find_uid(op_resp) or _uid_from_location(...)
|
|
||||||
# _find_uid ищет: instanceOperationUid → instanceUid → uid → Uid
|
|
||||||
# → "0b0596d8-b48d-4694-98c3-65f2f8eeb27d"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Шаг 8: Бэкенд — tracker_add (v1.0.54: ДО params!)
|
|
||||||
|
|
||||||
```python
|
|
||||||
try:
|
|
||||||
tracker_add("93b0b65d-...", 1, "autotest-1-lq5x3a")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[TRACKER ERROR] ...")
|
|
||||||
```
|
|
||||||
|
|
||||||
`tracker.py`: `_locked_read()` → `data["93b0b65d-..."] = {...}` → `_locked_write(data)`.
|
|
||||||
Файл `/tmp/instances.json` обновлён под `fcntl.LOCK_EX | LOCK_NB`.
|
|
||||||
|
|
||||||
### Шаг 9: Бэкенд — установка параметров и запуск
|
|
||||||
|
|
||||||
```python
|
|
||||||
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")
|
|
||||||
```
|
|
||||||
|
|
||||||
Если params пустые (например, повторный клик на «Готово») → `/run` → 422 "required CFS parameter ... is missing".
|
|
||||||
|
|
||||||
### Шаг 10: Бэкенд — фоновый поток
|
|
||||||
|
|
||||||
```python
|
|
||||||
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})
|
|
||||||
```
|
|
||||||
|
|
||||||
### Шаг 11: UI — поллинг статуса
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// index.html: executeOp() — после получения RUNNING
|
|
||||||
pollTimer = setInterval(async () => {
|
|
||||||
const sr = await fetch('/api/test/status/' + opUid);
|
|
||||||
const sd = await sr.json();
|
|
||||||
showStages(sd.stages || []);
|
|
||||||
if (sd.status !== 'RUNNING') {
|
|
||||||
stopPoll();
|
|
||||||
// показать OK/FAIL
|
|
||||||
btn.textContent = 'Готово';
|
|
||||||
btn.disabled = false;
|
|
||||||
// ⚠️ БАГ: btn.onclick всё ещё активен!
|
|
||||||
if (sd.status === 'OK') {
|
|
||||||
await refreshInstances();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 2000);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Шаг 12: Бэкенд — _finish_op (фоновый)
|
|
||||||
|
|
||||||
```python
|
|
||||||
while time.time() < deadline: # 300 секунд
|
|
||||||
data = client.get(f"/instanceOperations/{op_uid}?fields=...")
|
|
||||||
op = data.get("instanceOperation", {})
|
|
||||||
dt_finish = op.get("dtFinish")
|
|
||||||
|
|
||||||
_op_results[op_uid] = {"status": "RUNNING", "stages": [...], "duration": ...}
|
|
||||||
|
|
||||||
if dt_finish and str(dt_finish).strip():
|
|
||||||
is_ok = op.get("isSuccessful")
|
|
||||||
# tracker_add уже вызван синхронно!
|
|
||||||
if is_ok and is_delete:
|
|
||||||
tracker_remove(instance_uid)
|
|
||||||
_op_results[op_uid] = {"status": "OK" if is_ok else "FAIL", ...}
|
|
||||||
return
|
|
||||||
time.sleep(5)
|
|
||||||
```
|
|
||||||
|
|
||||||
Весь цикл обёрнут в `try/except: print(traceback)` (v1.0.51).
|
|
||||||
|
|
||||||
### Шаг 13: UI — refreshInstances
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
async function refreshInstances(){
|
|
||||||
const r = await fetch('/api/operations/1');
|
|
||||||
const d = await r.json();
|
|
||||||
const newInsts = d.instances || [];
|
|
||||||
// Найти новые (отсутствуют в svcInstances)
|
|
||||||
const added = newInsts.filter(i => !oldUids.has(i.instanceUid));
|
|
||||||
// Обновить бейджи существующих
|
|
||||||
// Добавить новые в DOM перед кнопкой "+ Создать"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Шаг 14: Бэкенд — api_operations (GET /api/operations/1)
|
|
||||||
|
|
||||||
```python
|
|
||||||
tracked = tracker_list() # читает /tmp/instances.json
|
|
||||||
tracked_by_uid = {uid: item for uid in tracked if svcId == 1}
|
|
||||||
instances = get_instances(_client()) # запрос в Nubes API
|
|
||||||
nubes_uids = {i["instanceUid"] for i in instances}
|
|
||||||
|
|
||||||
# Инстансы из Nubes которые есть в трекере (кроме deleted)
|
|
||||||
svc_instances = [i for i in instances
|
|
||||||
if i.get("instanceUid") in tracked_uids
|
|
||||||
and i.get("explainedStatus") not in ("deleted",)]
|
|
||||||
|
|
||||||
# Инстансы из трекера которых Nubes ещё не отдаёт → status "creating"
|
|
||||||
for uid, t in tracked_by_uid.items():
|
|
||||||
if uid not in nubes_uids:
|
|
||||||
svc_instances.append({
|
|
||||||
"instanceUid": uid,
|
|
||||||
"displayName": t["displayName"],
|
|
||||||
"explainedStatus": "creating",
|
|
||||||
...
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Трекер инстансов
|
|
||||||
|
|
||||||
### 4.1 Назначение
|
|
||||||
|
|
||||||
Хранит UID инстансов, созданных приложением. Нужен чтобы показывать в UI только «наши» инстансы, а не все в организации.
|
|
||||||
|
|
||||||
### 4.2 Реализация (v1.0.53+)
|
|
||||||
|
|
||||||
Файл: `site/operations/tracker.py`
|
|
||||||
|
|
||||||
- Хранилище: `/tmp/instances.json` (JSON-файл)
|
|
||||||
- Блокировка: `fcntl.flock(fd, LOCK_EX | LOCK_NB)` с retry до 2 сек
|
|
||||||
- Seed: `_INITIAL` (4 инстанса) при пустом/битом файле
|
|
||||||
- Функции: `add(uid, svc_id, name)`, `remove(uid)`, `list_all()`
|
|
||||||
|
|
||||||
### 4.3 Эволюция
|
|
||||||
|
|
||||||
| Версия | Реализация | Проблема |
|
|
||||||
|--------|-----------|----------|
|
|
||||||
| v1.0.45 | `/tmp/instances.json` + `threading.Lock` | `except: pass` скрывал ошибки |
|
|
||||||
| v1.0.49 | In-memory dict | Multi-worker gunicorn: у каждого свой dict |
|
|
||||||
| v1.0.53 | `/tmp/instances.json` + `fcntl.flock(LOCK_EX)` | Без таймаута: потенциальный зависон |
|
|
||||||
| v1.0.54 | `/tmp/instances.json` + `fcntl.flock(LOCK_EX\|LOCK_NB)` + retry 2s | ✅ Текущий |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Автоопределение стенда
|
|
||||||
|
|
||||||
Файл: `site/api/http_client.py`
|
|
||||||
|
|
||||||
```python
|
|
||||||
STANDS = [
|
|
||||||
"https://lk-api-gateway-dev.ngcloud.ru/api/v1/svc",
|
|
||||||
"https://lk-api-gateway-test.ngcloud.ru/api/v1/svc",
|
|
||||||
]
|
|
||||||
|
|
||||||
def detect_endpoint(token):
|
|
||||||
for ep in STANDS:
|
|
||||||
try:
|
|
||||||
c = HttpClient(ep, token)
|
|
||||||
data = c.get("/instances", params={"pageSize": 1, "page": 1})
|
|
||||||
if data.get("results") is not None:
|
|
||||||
return ep
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
return None
|
|
||||||
```
|
|
||||||
|
|
||||||
Используется в:
|
|
||||||
- `main.py`: при загрузке главной страницы
|
|
||||||
- `api_test.py: _client()`: при каждом API-запросе
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Все известные баги (найдено/исправлено)
|
|
||||||
|
|
||||||
### Исправленные
|
|
||||||
|
|
||||||
| # | Версия | Баг | Причина | Исправление |
|
|
||||||
|---|--------|-----|---------|-------------|
|
|
||||||
| 1 | v1.0.46 | `_finish_op()` недостающие параметры | Не передавались `op_name`, `svc_op_id` | Добавлены в сигнатуру |
|
|
||||||
| 2 | v1.0.48 | displayName терялся | `params-form.innerHTML=''` до чтения `param-displayname` | Захват до очистки |
|
|
||||||
| 3 | v1.0.48 | tracker_add в daemon-потоке | Поток умирает под gunicorn | Перенос в синхронный код |
|
|
||||||
| 4 | v1.0.50 | ❌ для этапов в процессе | `isSuccessful===false` для in-progress | Проверка `dtFinish` |
|
|
||||||
| 5 | v1.0.50 | Инстанс не в списке | `explainedStatus:"not created"` фильтровался | Убран фильтр + tracked-сироты |
|
|
||||||
| 6 | v1.0.50 | `selectService()` скрывал stages | Полный перерендер после OK | `refreshInstances()` с диффом |
|
|
||||||
| 7 | v1.0.51 | `instance_groups` UnboundLocalError | Не иниц. до `if active_token:` | `instance_groups = {}` |
|
|
||||||
| 8 | v1.0.51 | `_finish_op` молча умирал | `data.get()` вне try/except | Весь цикл в try/except |
|
|
||||||
| 9 | v1.0.51 | Автостенд только в main.py | `_client()` в api_test.py не использовал | `detect_endpoint()` в http_client.py |
|
|
||||||
| 10 | v1.0.52 | `token_info` UndefinedError | Не передан в шаблон при action=clear | `_tmpl()` helper со всеми переменными |
|
|
||||||
| 11 | v1.0.53 | In-memory dict + multi-worker | У каждого воркера свой `_data` | `/tmp/instances.json` + `fcntl.flock` |
|
|
||||||
| 12 | v1.0.54 | `_find_uid()` возвращал не тот UUID | Итерация по всем значениям dict | Поиск по ключам: instanceOperationUid→instanceUid |
|
|
||||||
| 13 | v1.0.54 | Сироты при ошибке params | `tracker_add` ПОСЛЕ params loop | `tracker_add` ДО params loop |
|
|
||||||
| 14 | v1.0.54 | `flock` без таймаута | `LOCK_EX` блокируется навсегда | `LOCK_EX \| LOCK_NB` + retry 2s |
|
|
||||||
| 15 | v1.0.55 | F5 = предупреждение браузера | clear/stray POST → HTML без редиректа | `redirect("/")` для всех POST |
|
|
||||||
| 16 | v1.0.56 | Кнопка «Готово» запускала повторный CREATE | `btn.onclick` оставался привязанным после завершения операции | `btn.onclick = null` после финального статуса |
|
|
||||||
| 17 | v1.0.57 | Хрупкость финального состояния кнопки | Повторяемый код финализации статуса | `setFinishedState()` централизует финальный UI-состояние |
|
|
||||||
| 18 | v1.0.59 | После F5 список терял tracked-инстанс | `api_operations()` не добирал tracked-сирот обратно из трекера | `tracked_by_uid` + добавление отсутствующих tracked-инстансов |
|
|
||||||
| 19 | v1.0.60 | CREATE не появлялся сразу в списке после OK | UI делал diff и мог не перерисовать список целиком | Полная перерисовка `inst-list` в `refreshInstances()` |
|
|
||||||
| 20 | v1.0.63 | Дубли и чужие инстансы | Принадлежность определялась не по namespace | `autotest-` prefix + уникализация `displayName` при CREATE |
|
|
||||||
| 21 | v1.0.64 | Версия не показывалась в шапке | `load_config()` затирал VERSION | `config["VERSION"] = current_app.config["VERSION"]` |
|
|
||||||
| 22 | v1.0.64 | displayName был editable value, а не placeholder | CREATE подставлял имя в value | Placeholder `autotest-...`, значение вводит пользователь |
|
|
||||||
| 23 | v1.0.65 | `creating` из-за недочитанной страницы | `get_instances()` доверял `total` и мог не перейти на следующую страницу | Остановка по `len(batch) < pageSize` |
|
|
||||||
|
|
||||||
### ✅ Закрыто в v1.0.56
|
|
||||||
|
|
||||||
| Баг | Статус | Что поменяли |
|
|
||||||
|-----|--------|--------------|
|
|
||||||
| Кнопка «Готово» запускала повторный CREATE | закрыто | После финального статуса обработчик снимается через `btn.onclick = null` |
|
|
||||||
| displayName "autotest-1" при повторном клике | закрыто | Повторный клик больше не вызывает `executeOp()` на очищенной форме |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. HTTP-клиент
|
|
||||||
|
|
||||||
Файл: `site/api/http_client.py`
|
|
||||||
|
|
||||||
```python
|
|
||||||
class HttpClient:
|
|
||||||
def __init__(self, endpoint, token):
|
|
||||||
# Bearer auth + User-Agent: Mozilla/5.0 (DDoS-Guard)
|
|
||||||
|
|
||||||
def get(self, path, timeout=10):
|
|
||||||
# GET → r.raise_for_status() → r.json()
|
|
||||||
|
|
||||||
def post(self, path, data, timeout=30):
|
|
||||||
# POST → проверка r.ok → парсинг JSON → Location header
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Шаблон Jinja2 — используемые переменные
|
|
||||||
|
|
||||||
| Переменная | Где | Назначение |
|
|
||||||
|-----------|-----|-----------|
|
|
||||||
| `config.VERSION` | Строка 32 | Версия в топбаре |
|
|
||||||
| `token_info.email` | Строка 33 | Email пользователя |
|
|
||||||
| `token_info.company` | Строка 33 | Компания |
|
|
||||||
| `env_token_masked` | Строка 36 | Маскированный токен в placeholder |
|
|
||||||
| `has_user_token` | Строка 36 | Показать токен или маску |
|
|
||||||
| `error` | Строка 40 | Ошибка |
|
|
||||||
| `organization.displayName` | Строка 50 | Название организации |
|
|
||||||
| `organization.explainedStatus` | Строка 51 | Статус организации |
|
|
||||||
| `client_id` | Строка 50 | ID клиента |
|
|
||||||
| `instance_groups` | Строки 55-62 | Группы инфраструктурных инстансов |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Ограничения платформы Nubes pythonk8s
|
|
||||||
|
|
||||||
- `site/` — НЕ пакет (без `__init__.py`, конфликт с stdlib `site.py`)
|
|
||||||
- Импорты: `from api.http_client import ...` (без префикса `site.`)
|
|
||||||
- `app.run(host="0.0.0.0", port=5000)` — обязательно
|
|
||||||
- Gunicorn: запускается платформой, количество воркеров неизвестно
|
|
||||||
- Нет persistent volume — `/tmp/` теряется при редеплое
|
|
||||||
- User-Agent: `Mozilla/5.0` обязателен (DDoS-Guard)
|
|
||||||
- Деплой: `git push` → managed service редеплоит
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
# Архитектура app-autotest — текущее состояние
|
|
||||||
|
|
||||||
v1.0.93, 28.07.2026
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Обзор
|
|
||||||
|
|
||||||
Flask-приложение (один HTML-файл, без SPA-фреймворка) для ручного тестирования операций
|
|
||||||
сервисов облачной платформы Nubes. Работает через REST API Nubes, автоопределяет стенд
|
|
||||||
(dev/test) по JWT-токену.
|
|
||||||
|
|
||||||
**Деплой**: Nubes pythonk8s (gunicorn, несколько воркеров).
|
|
||||||
**Кластер**: `iot-naeel` (K8s resourceRealm, DEV-стенд)
|
|
||||||
**URL**: `https://atest.pythonk8s.dev.nubes.ru`
|
|
||||||
**Репозиторий**: `https://gitea.services.ngcloud.ru/forcloud/app-autotest.git`
|
|
||||||
|
|
||||||
### Ключевые принципы
|
|
||||||
|
|
||||||
1. **Источник правды — Nubes API**. Все данные (инстансы, параметры, статусы) получаются
|
|
||||||
только из API. Никаких локальных копий или кеша (кроме краткосрочного трекера).
|
|
||||||
2. **Разделение CREATE и non-CREATE**. Это два принципиально разных потока.
|
|
||||||
3. **Multi-worker safety**. Все общие ресурсы (/tmp файлы) защищены `fcntl.flock`.
|
|
||||||
4. **Мелкие функции**. Каждое действие — отдельная функция.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Структура файлов
|
|
||||||
|
|
||||||
```
|
|
||||||
app-autotest/
|
|
||||||
├── site/ ← НЕ пакет (без __init__.py)
|
|
||||||
│ ├── app.py ← Flask(__name__), VERSION, blueprints, /health
|
|
||||||
│ ├── runner.py ← [LEGACY] Раннер тестов
|
|
||||||
│ ├── config.yaml ← [LEGACY] Конфиг раннера
|
|
||||||
│ ├── api/
|
|
||||||
│ │ └── http_client.py ← HttpClient, автостенд
|
|
||||||
│ ├── operations/
|
|
||||||
│ │ ├── get_services.py ← get_services(), get_service_detail()
|
|
||||||
│ │ ├── get_instances.py ← get_instances() (пагинация), get_organization()
|
|
||||||
│ │ ├── get_params.py ← get_params_with_current_values() — слияние state.params + шаблон
|
|
||||||
│ │ └── tracker.py ← /tmp/instances-{clientId}-{stand}.json + flock
|
|
||||||
│ ├── routes/
|
|
||||||
│ │ ├── main.py ← GET/POST /, /api/operations/<svc_id>
|
|
||||||
│ │ ├── api.py ← [LEGACY] /api/run, /api/status, /api/config
|
|
||||||
│ │ └── api_test.py ← /api/test, /api/params, /api/log
|
|
||||||
│ ├── templates/
|
|
||||||
│ │ └── index.html ← Весь UI (Jinja2 + ванильный JS)
|
|
||||||
│ └── static/
|
|
||||||
│ ├── style.css
|
|
||||||
│ ├── logo.svg
|
|
||||||
│ └── favicon.svg
|
|
||||||
└── secrets/
|
|
||||||
├── dev.token
|
|
||||||
└── test.token
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Бэкенд: HTTP-клиент и автостенд
|
|
||||||
|
|
||||||
### `http_client.py`
|
|
||||||
|
|
||||||
- `HttpClient(endpoint, token)` — обёртка над `requests.Session`:
|
|
||||||
- Заголовки: `Authorization: Bearer`, `User-Agent: Mozilla/5.0` (DDoS-Guard)
|
|
||||||
- `get(path)` → `raise_for_status()` → `.json()`
|
|
||||||
- `post(path, data)` → проверка `r.ok`, парсинг JSON, извлечение `Location`
|
|
||||||
- `detect_endpoint(token)` — пробует dev→test стенды, возвращает рабочий URL
|
|
||||||
- `stand_name(endpoint)` — "dev"/"test" по URL
|
|
||||||
- `create_client(token, fallback)` — HttpClient + endpoint
|
|
||||||
|
|
||||||
### `get_instances.py`
|
|
||||||
|
|
||||||
- `get_instances(client)` — `GET /instances` с пагинацией (pageSize=200). Остановка по `len(batch) < pageSize`.
|
|
||||||
- `get_organization(client)` — первый инстанс с `serviceId == 19`
|
|
||||||
|
|
||||||
### `get_services.py`
|
|
||||||
|
|
||||||
- `get_services(client)` → `GET /services` → список
|
|
||||||
- `get_service_detail(client, svc_id)` → `GET /services/{id}` → детали + операции
|
|
||||||
|
|
||||||
### `get_params.py` — КЛЮЧЕВОЙ модуль
|
|
||||||
|
|
||||||
`get_params_with_current_values(client, op_id, instance_uid)`:
|
|
||||||
1. `GET /instances/{uid}` → `state.params` (текущие значения: `{"whereFail":"1",...}`)
|
|
||||||
2. `GET /instanceOperations/default/{opId}` → шаблон (коды, типы, valueList, dataDescriptor)
|
|
||||||
3. Слияние: `defaultValue = state.params["код"] ?? template.defaultValue`
|
|
||||||
4. Приведение типов: `bool` → `"true"/"false"`, `dict` → `json.dumps()`
|
|
||||||
|
|
||||||
### `tracker.py`
|
|
||||||
|
|
||||||
- Файл: `/tmp/instances-{clientId}-{stand}.json` (изолирован по пользователю и стенду)
|
|
||||||
- Блокировка: `fcntl.flock(LOCK_EX | LOCK_NB)` + retry 2s
|
|
||||||
- `add(client_id, stand, uid, svc_id, name)`, `remove(...)`, `list_all(...)`
|
|
||||||
- Нужен ТОЛЬКО как fallback — когда инстанс только создан и ещё не в `/instances`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Бэкенд: Роуты
|
|
||||||
|
|
||||||
### `main.py`
|
|
||||||
|
|
||||||
- **`GET/POST /`** — главная: организация, инфраструктура (serviceId 2,12,19,21,22,25,26,29,110,150), сервисы
|
|
||||||
- `action=save` → сохранить токен в cookie
|
|
||||||
- `action=clear` → удалить cookie
|
|
||||||
- **`GET /api/operations/<svc_id>`** — операции + autotest-инстансы:
|
|
||||||
- Cloud-first: фильтр по префиксу `autotest-`
|
|
||||||
- Tracker-fallback: статус "creating" для ещё невидимых
|
|
||||||
|
|
||||||
### `api_test.py` — основной модуль операций
|
|
||||||
|
|
||||||
- **`GET /api/params/<op_id>[?instanceUid=xxx]`**:
|
|
||||||
- Без `instanceUid` → шаблонные `defaultValue`
|
|
||||||
- С `instanceUid` → `get_params_with_current_values()`
|
|
||||||
- **`POST /api/test`** — запуск операции:
|
|
||||||
- **CREATE**: `POST /instances` → `instanceUid` → `POST /instanceOperations` → `opUid` → `tracker_add` → params → `/run`
|
|
||||||
- **non-CREATE**: `POST /instanceOperations` → params → `/run` (redeploy: сразу `/run`)
|
|
||||||
- `displayName` = `_get_instance_display_name()` (из API, не UUID!)
|
|
||||||
- Фоновый поток: `_finish_op()` — поллинг до `dtFinish` (300s таймаут)
|
|
||||||
- **`GET /api/test/status/<op_uid>`** — поллинг: in-memory `_op_results` → прямой API
|
|
||||||
- **`GET /api/log`** — последние 200 строк из `/tmp/app-autotest.log`
|
|
||||||
|
|
||||||
### Логирование
|
|
||||||
|
|
||||||
`_log(msg)` в `api_test.py`:
|
|
||||||
- stdout (gunicorn) + `/tmp/app-autotest.log` (flock, общий для воркеров)
|
|
||||||
- Ротация при 512 КБ
|
|
||||||
- UI: скрытая панель, кнопка `log` (правый нижний угол)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Фронтенд
|
|
||||||
|
|
||||||
### Структура
|
|
||||||
|
|
||||||
Один HTML-файл, три колонки:
|
|
||||||
- Левая (280px): инфраструктура
|
|
||||||
- Средняя (240px): сервисы
|
|
||||||
- Правая (flex): инстансы + параметры + кнопка + этапы
|
|
||||||
|
|
||||||
### Глобальное состояние (JS)
|
|
||||||
|
|
||||||
```
|
|
||||||
svcInstances — кеш инстансов (из /api/operations)
|
|
||||||
selectedInst — UID выбранного инстанса
|
|
||||||
selectedOp — {opId, opName, svcId}
|
|
||||||
pollTimer — таймер поллинга
|
|
||||||
SVC_ID = 1 — фиксированный сервис (Болванка)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Потоки операций
|
|
||||||
|
|
||||||
**CREATE**: `startCreate()` → `showParams(18,'create')` → форма → `executeOp(pp)` → POST /api/test → поллинг → refreshInstances
|
|
||||||
|
|
||||||
**MODIFY**: `toggleInstance()` → кнопки → `runOp('modify',opId)` → `showParams(opId,'modify')` → GET /api/params?instanceUid= → форма с ТЕКУЩИМИ значениями → валидация map → `executeOp(pp)` → POST /api/test → поллинг
|
|
||||||
|
|
||||||
**Без параметров** (suspend/delete/resume/redeploy): `runOp()` → confirm → `executeOp({})` → POST /api/test → поллинг
|
|
||||||
|
|
||||||
### Валидация JSON
|
|
||||||
|
|
||||||
- `_esc(s)` — HTML-экранирование (`"` → `"`, `&` → `&`, `<` → `<`)
|
|
||||||
- `validateJson(el, quiet)` — `JSON.parse()` на `onblur` (красная рамка + текст)
|
|
||||||
- Batch-проверка перед отправкой — ошибка → запрос не уходит
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Nubes API — используемые endpoint'ы
|
|
||||||
|
|
||||||
| Метод | Путь | Назначение |
|
|
||||||
|-------|------|-----------|
|
|
||||||
| GET | `/instances?pageSize=200&page=N` | Все инстансы (пагинация) |
|
|
||||||
| GET | `/instances/{uid}` | Детали + state.params |
|
|
||||||
| GET | `/services` | Список сервисов |
|
|
||||||
| GET | `/services/{id}` | Детали + операции |
|
|
||||||
| GET | `/instanceOperations/default/{opId}` | Шаблон параметров |
|
|
||||||
| POST | `/instances` | Создать инстанс |
|
|
||||||
| POST | `/instanceOperations` | Создать операцию |
|
|
||||||
| POST | `/instanceOperationCfsParams` | Установить параметр |
|
|
||||||
| POST | `/instanceOperations/{opUid}/run` | Запустить операцию |
|
|
||||||
| GET | `/instanceOperations/{opUid}?fields=...` | Поллинг статуса |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Ограничения платформы
|
|
||||||
|
|
||||||
- `site/` — НЕ пакет (без `__init__.py`), импорты без префикса `site.`
|
|
||||||
- `app.run(host="0.0.0.0", port=5000)`
|
|
||||||
- Gunicorn multi-worker → общие данные через файлы + flock
|
|
||||||
- `/tmp/` теряется при редеплое
|
|
||||||
- User-Agent: `Mozilla/5.0` обязателен (DDoS-Guard)
|
|
||||||
-478
@@ -1,478 +0,0 @@
|
|||||||
# История разработки app-autotest
|
|
||||||
|
|
||||||
## v1.1.2 (28.07.2026) — refSvcId: выпадающие списки для cross-service параметров
|
|
||||||
|
|
||||||
- `get_params.py` + `api_test.py`: параметры включают `refSvcId` из API
|
|
||||||
- Фронт: `refSvcId` → `fetch('/api/instances/list')` → фильтр по `serviceId` → `<select>` с `displayName`
|
|
||||||
- Универсально: S3, K8s-кластер, любой сервис — data-driven
|
|
||||||
|
|
||||||
## Инфраструктура
|
|
||||||
- **Кластер**: `iot-naeel` (resourceRealm K8s)
|
|
||||||
- **URL**: `https://atest.pythonk8s.dev.nubes.ru`
|
|
||||||
- **Деплой**: git push → managed service pythonk8s → gunicorn
|
|
||||||
- **PostgreSQL**: `foriot` (509145c3-...), PG 17, `iot-naeel`
|
|
||||||
|
|
||||||
## v1.0.93 (28.07.2026) — отдельные DB_* переменные
|
|
||||||
|
|
||||||
### Что изменилось
|
|
||||||
- `db/pool.py`: `_dsn()` собирает DSN из отдельных переменных: DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASSWORD, DB_SSLMODE
|
|
||||||
- `db/init_db.py`: проверка `DB_USER` вместо `DATABASE_URL`
|
|
||||||
- `secrets/pg-foriot.md`: полный jsonEnv для pythonk8s (6 DB-переменных + 2 существующие)
|
|
||||||
- `app.py`: NUBES_API_ENDPOINT по умолчанию TEST
|
|
||||||
|
|
||||||
## v1.0.92 (27.07.2026) — Steps 5-9: динамические сервисы + PostgreSQL история
|
|
||||||
|
|
||||||
### Шаг 5: JS → /static/app.js
|
|
||||||
- JS вынесен из index.html в отдельный файл
|
|
||||||
- `window.APP = {version, stand, hasUserToken}` — передача Jinja2-переменных
|
|
||||||
|
|
||||||
### Шаг 6: Динамические сервисы
|
|
||||||
- `currentSvcId` вместо хардкода `SVC_ID=1`
|
|
||||||
- `initServices()` — загрузка из `/api/services` при старте
|
|
||||||
|
|
||||||
### Шаг 7-8: PostgreSQL
|
|
||||||
- `db/pool.py` — ThreadedConnectionPool (lazy-init, 1-5 соединений)
|
|
||||||
- `db/init_db.py` — таблица `runs` с индексами (идемпотентно)
|
|
||||||
- `db/save_run.py` — запись результатов в БД
|
|
||||||
- `requirements.txt` — добавлен `psycopg2-binary`
|
|
||||||
|
|
||||||
### Шаг 9: /api/history
|
|
||||||
- GET `/api/history` — последние 50 записей (фильтр по client_id + stand)
|
|
||||||
|
|
||||||
## v1.0.90 (27.07.2026) — Steps 1-4: auth.py + TTL + cookie
|
|
||||||
|
|
||||||
### Шаг 1-2: api/auth.py
|
|
||||||
- Новый модуль: `get_token()`, `get_client()`, `get_client_id()`, `get_stand()`, `get_token_info()`, `get_token_masked()`
|
|
||||||
- Убраны дубликаты из main.py и api_test.py
|
|
||||||
|
|
||||||
### Шаг 3: Cookie httponly + samesite
|
|
||||||
- `set_cookie(..., httponly=True, samesite="Strict")`
|
|
||||||
|
|
||||||
### Шаг 4: _op_results TTL
|
|
||||||
- Максимум 500 записей, удаление старше 1 часа
|
|
||||||
- `_ts` timestamp в каждой записи
|
|
||||||
|
|
||||||
## v1.0.99 (28.07.2026) — фикс: Flask-прокси в фоновом потоке
|
|
||||||
|
|
||||||
### Найденные ошибки
|
|
||||||
1. `get_token_info()` → `request.cookies` → в потоке нет request-контекста → RuntimeError
|
|
||||||
2. `current_app.config.get("VERSION")` → Flask-proxy → та же проблема
|
|
||||||
3. `except: pass` глушил ошибки → записи молча не сохранялись
|
|
||||||
|
|
||||||
### Исправление
|
|
||||||
- `user_email` и `app_version` получаются в `api_test()` (где есть request)
|
|
||||||
и передаются параметрами в `_finish_op` → `save_run`
|
|
||||||
- Добавлено логирование ошибок в `save_run` (больше не глухое `pass`)
|
|
||||||
|
|
||||||
### Урок
|
|
||||||
`py_compile` проверяет только синтаксис. Flask-прокси (`request`, `current_app`, `g`, `session`)
|
|
||||||
**не работают** в фоновых потоках. Все нужные значения — до `threading.Thread` параметрами.
|
|
||||||
|
|
||||||
## v1.0.89 (27.07.2026) — Sonnet Rounds 1-3 + документирование
|
|
||||||
|
|
||||||
### 1. PostgreSQL connection string
|
|
||||||
- `os.getenv("DATABASE_URL")` — никакого service discovery
|
|
||||||
- Инжектится через `jsonEnv` параметры pythonk8s-сервиса (как NUBES_API_TOKEN)
|
|
||||||
- Порядок: создать PostgreSQL-инстанс в Nubes → взять host/port/user/pass из state_out → собрать DSN → jsonEnv → деплой
|
|
||||||
|
|
||||||
### 2. Миграции при деплое
|
|
||||||
- `init_db()` на уровне модуля в app.py, до register_blueprint
|
|
||||||
- `CREATE TABLE/INDEX IF NOT EXISTS` — идемпотентно, каждый воркер выполнит независимо
|
|
||||||
- PostgreSQL обрабатывает конкурентный DDL корректно
|
|
||||||
- Никаких ручных шагов при деплое
|
|
||||||
- `DROP COLUMN` — не идемпотентно, осторожно
|
|
||||||
|
|
||||||
### 3. Multi-service UI (map-fixed)
|
|
||||||
- `dataDescriptor` уже в API-ответе — инфраструктура готова
|
|
||||||
- `map` (свободный): textarea + JSON-валидация (как сейчас)
|
|
||||||
- `map-fixed`: таблица подполей с отдельными input/select из dataDescriptor
|
|
||||||
- Бэкенд не меняется — paramValue всегда строка
|
|
||||||
- Изменения только во фронте: showParams() + сбор подполей в JSON перед отправкой
|
|
||||||
|
|
||||||
### 4. Тесты
|
|
||||||
- pytest для чистых функций без моков: `_find_uid`, `_uid_from_location`, `_with_prefix`, слияние params, tracker
|
|
||||||
- Структура: `tests/conftest.py`, `test_utils.py`, `test_tracker.py`, `test_get_params.py`
|
|
||||||
- Роуты с Flask test client + mock HttpClient — отложить (высокая стоимость мокирования)
|
|
||||||
|
|
||||||
## v1.0.89 (27.07.2026) — Sonnet Round 2: PostgreSQL, auth.py, детальный план
|
|
||||||
|
|
||||||
### Результаты анализа
|
|
||||||
Sonnet дал конкретные ответы на все 7 вопросов Round 2:
|
|
||||||
|
|
||||||
**1. PostgreSQL схема:**
|
|
||||||
- Таблица `runs` с JSONB-полями `params` и `stages`
|
|
||||||
- 3 индекса: `(client_id, stand, created_at DESC)`, `(instance_uid)`, `(created_at)`
|
|
||||||
- Stages в JSONB (не отдельная таблица), instances не нужны (API — source of truth)
|
|
||||||
- Чувствительные params маскировать при сохранении
|
|
||||||
|
|
||||||
**2. Connection pool:**
|
|
||||||
- `psycopg2.pool.ThreadedConnectionPool` — минимум зависимостей
|
|
||||||
- Lazy-init в `db/pool.py` — безопасно для fork-модели gunicorn
|
|
||||||
- `flask.g` + `@app.teardown_appcontext` для возврата соединений
|
|
||||||
|
|
||||||
**3. Миграции:**
|
|
||||||
- `db/init_db.py` с `IF NOT EXISTS` — идемпотентно, авто-применение при старте
|
|
||||||
- Alembic — когда 3+ таблицы или нужен rollback (сейчас не нужно)
|
|
||||||
|
|
||||||
**4. Трекер vs PostgreSQL:**
|
|
||||||
- **НЕ заменять** трекер на БД — это разные слои
|
|
||||||
- Трекер = кеш (секунды), PostgreSQL = история (недели)
|
|
||||||
- Сетевое обращение к БД на критическом пути CREATE недопустимо
|
|
||||||
|
|
||||||
**5. auth.py:**
|
|
||||||
- Явные функции вместо `before_request` — проще, нет скрытых зависимостей
|
|
||||||
- Одна реализация `get_client_id()` вместо двух копий
|
|
||||||
- Кеширование `token → endpoint` (опционально, TTL 5 мин)
|
|
||||||
|
|
||||||
**6. Динамические сервисы:**
|
|
||||||
- `/api/services` из API → фильтр по `enabled: true` из config.yaml
|
|
||||||
- UI: заменить хардкод `SVC_ID=1` на динамическую загрузку в `selectService()`
|
|
||||||
|
|
||||||
**7. JS в файл:**
|
|
||||||
- Классический скрипт, один файл `app.js` (300 строк — не нужно ES modules)
|
|
||||||
- `window.APP = {...}` через `tojson` фильтр Jinja2 для передачи переменных
|
|
||||||
|
|
||||||
### Итоговый порядок реализации (9 шагов)
|
|
||||||
1. `api/auth.py` — вынести get_token/get_client/get_client_id/get_stand
|
|
||||||
2. Заменить дублирующиеся функции в main.py и api_test.py
|
|
||||||
3. Cookie `httponly=True, samesite='Strict'`
|
|
||||||
4. `_op_results` TTL/очистка
|
|
||||||
5. JS вынести в `/static/app.js` + `window.APP`
|
|
||||||
6. Динамические сервисы из `/api/services`
|
|
||||||
7. `db/pool.py` + `db/init_db.py` (PostgreSQL)
|
|
||||||
8. Запись истории в runs при завершении операции
|
|
||||||
9. `/api/history` эндпоинт + UI
|
|
||||||
|
|
||||||
## v1.0.89 (27.07.2026) — Sonnet Round 1: архитектурный аудит
|
|
||||||
- Cloud-first подход с tracker-fallback
|
|
||||||
- HTML-экранирование в JS
|
|
||||||
|
|
||||||
**🔴 Критические находки:**
|
|
||||||
1. `_op_results` dict растёт бесконечно — утечка памяти, нужен TTL/очистка
|
|
||||||
2. Cookie токена без `httponly` и `samesite`
|
|
||||||
3. `/api/log` без проверки авторизации
|
|
||||||
|
|
||||||
**🟡 Дублирование:**
|
|
||||||
1. `_client_id()` — идентичный код в main.py и api_test.py
|
|
||||||
2. `_client()` — разное поведение в main.py и api_test.py
|
|
||||||
3. Инлайн `<style>` дублирует `style.css`
|
|
||||||
|
|
||||||
**🟡 Захардкодено:**
|
|
||||||
1. `SVC_ID=1` и список сервисов в HTML — хотя `/api/services` существует
|
|
||||||
2. `create_client()` в main.py vs `_client()` в api_test.py — разные подходы
|
|
||||||
|
|
||||||
**Приоритетный план исправлений:**
|
|
||||||
1. 🔴 `_op_results` — добавить очистку/TTL
|
|
||||||
2. 🔴 Cookie `httponly=True, samesite='Strict'`
|
|
||||||
3. 🟡 Вынести `_client_id()` / `get_token()` в общий модуль `api/auth.py`
|
|
||||||
4. 🟡 Загрузка сервисов из `/api/services` вместо хардкода
|
|
||||||
5. 🟡 Вынести JS в `/static/app.js`
|
|
||||||
6. 🟢 SQLite история запусков
|
|
||||||
7. 🟢 `/api/log` проверка токена
|
|
||||||
|
|
||||||
### Создан запрос Round 2
|
|
||||||
Файл: `DOCS/sonnet-architecture-review-v1.0.89-r2.md` — уточняющие вопросы по реализации.
|
|
||||||
|
|
||||||
## v1.0.89 (27.07.2026) — документация и комментарии кода
|
|
||||||
|
|
||||||
## v1.0.87 (27.07.2026) — документация: ARCHITECTURE.md переписан, legacy-доки помечены
|
|
||||||
|
|
||||||
## v1.0.84 (27.07.2026) — лог-панель скрыта по умолчанию
|
|
||||||
|
|
||||||
### Что изменилось
|
|
||||||
- Лог-панель теперь `display:none`, показывается кнопкой `log` (правый нижний угол).
|
|
||||||
- При скрытой панели `/api/log` не поллится.
|
|
||||||
|
|
||||||
## v1.0.83 (27.07.2026) — HTML-escape + JSON-валидация map-полей
|
|
||||||
|
|
||||||
### Что изменилось
|
|
||||||
- `_esc(s)` — HTML-экранирование значений (`"` → `"`, `&` → `&`, `<` → `<`).
|
|
||||||
- `validateJson(el, quiet)` — проверка `JSON.parse()` при `onblur` (красная рамка + текст ошибки).
|
|
||||||
- Batch-проверка всех map-полей перед отправкой — ошибка → запрос не уходит.
|
|
||||||
|
|
||||||
### Что это чинит
|
|
||||||
- Поломку HTML при значениях с кавычками (напр. `{"f":1}`).
|
|
||||||
- Отправку битого JSON в API.
|
|
||||||
|
|
||||||
## v1.0.82 (27.07.2026) — displayName из API для non-create операций
|
|
||||||
|
|
||||||
### Что изменилось
|
|
||||||
- `_get_instance_display_name(client, uid)` — `GET /instances/{uid}` → `displayName`.
|
|
||||||
- Используется в не-create ветке `POST /api/test` вместо `instance_uid` как fallback.
|
|
||||||
|
|
||||||
### Что это чинит
|
|
||||||
- UUID вместо имени инстанса в финальном статусе.
|
|
||||||
|
|
||||||
## v1.0.81 (27.07.2026) — get_params.py: текущие значения из state.params
|
|
||||||
|
|
||||||
### Что изменилось
|
|
||||||
- Новый файл `site/operations/get_params.py` — независимый модуль.
|
|
||||||
- `get_params_with_current_values(client, op_id, instance_uid)`:
|
|
||||||
- `GET /instances/{uid}` → `state.params` (текущие значения)
|
|
||||||
- `GET /instanceOperations/default/{opId}` → шаблон
|
|
||||||
- Слияние: `defaultValue = state.params["код"] ?? template.defaultValue`
|
|
||||||
- Убран `previewOpUid` полностью (и из бэкенда, и из фронтенда).
|
|
||||||
|
|
||||||
## v1.0.80 (27.07.2026) — попытка fix через svcOperationId (НЕ СРАБОТАЛО)
|
|
||||||
|
|
||||||
## v1.0.79 (27.07.2026) — файловый лог для multi-worker
|
|
||||||
|
|
||||||
### Что изменилось
|
|
||||||
- `_log()` пишет в `/tmp/app-autotest.log` под `fcntl.flock` (вместо in-memory deque).
|
|
||||||
- `/api/log` читает из файла последние 200 строк.
|
|
||||||
- Ротация при 512 КБ.
|
|
||||||
- UI-панель логов (180px, автоскролл, поллинг 2s).
|
|
||||||
|
|
||||||
## v1.0.77 (27.07.2026) — debug-логирование в api_params
|
|
||||||
|
|
||||||
## v1.0.76 (27.07.2026) — modify: preview-операция для получения paramValue (НЕ СРАБОТАЛО)
|
|
||||||
|
|
||||||
# v1.0.74 (27.07.2026) — version bump for push
|
|
||||||
|
|
||||||
### Что изменилось
|
|
||||||
- В `site/templates/index.html` create-операция отделена от обычных операций: CREATE использует фиксированный префикс `autotest-`, а non-create берёт имя выбранного инстанса.
|
|
||||||
- Из non-create ветки убран случайный fallback `tut` и общий create-style `displayName`.
|
|
||||||
- В `site/routes/api_test.py` backend больше не подставляет create-имя по умолчанию для обычных операций и возвращает `displayName` для финального статуса.
|
|
||||||
- В `site/routes/main.py` список autotest-инстансов теперь строится cloud-first без старой склейки через map по имени; tracker остался только как временный fallback, если cloud ещё не вернул новый инстанс.
|
|
||||||
|
|
||||||
### Что это чинит
|
|
||||||
- Путаницу между CREATE и обычными операциями.
|
|
||||||
- Ситуацию, когда в UI появлялся лишний `tut`.
|
|
||||||
- Дубли `running/creating` на одном autotest-инстансе.
|
|
||||||
- Неясный финальный статус, где было видно операцию, но не было понятно, над каким инстансом она выполнялась.
|
|
||||||
|
|
||||||
# v1.0.72 (27.07.2026) — service list keeps one autotest row per displayName
|
|
||||||
|
|
||||||
### Что изменилось
|
|
||||||
- В `site/routes/main.py` сервисный список теперь cloud-first и не склеивает разные строки в одну map.
|
|
||||||
- Tracker остаётся только как fallback, если cloud ещё не вернул конкретный autotest-инстанс.
|
|
||||||
|
|
||||||
### Что это чинит
|
|
||||||
- Дубли `running/creating` на одном и том же autotest-инстансе.
|
|
||||||
|
|
||||||
# v1.0.71 (27.07.2026) — finished status shows instance name
|
|
||||||
|
|
||||||
### Что изменилось
|
|
||||||
- В `site/templates/index.html` финальная строка после `OK/FAIL` теперь показывает имя инстанса.
|
|
||||||
- Имя операции и длительность остаются рядом, отдельно от имени инстанса.
|
|
||||||
|
|
||||||
### Что это чинит
|
|
||||||
- Ситуацию, когда после завершения delete/modify было видно только название операции, но не ясно, над каким инстансом она выполнялась.
|
|
||||||
|
|
||||||
# v1.0.70 (27.07.2026) — dedupe autotest instances by displayName
|
|
||||||
|
|
||||||
### Что изменилось
|
|
||||||
- В `site/routes/main.py` сервисный список теперь склеивает записи по `displayName`.
|
|
||||||
- Если cloud уже отдал `running`, он выигрывает у tracker-fallback `creating`.
|
|
||||||
|
|
||||||
### Что это чинит
|
|
||||||
- Дубли одной и той же autotest-записи после CREATE, когда в списке появлялись и `running`, и `creating`.
|
|
||||||
|
|
||||||
## v1.0.69 (27.07.2026) — autotest prefix fixed in CREATE field
|
|
||||||
|
|
||||||
### Что изменилось
|
|
||||||
- В `site/templates/index.html` префикс `autotest-` отображается отдельным фиксированным текстом.
|
|
||||||
- Рядом с ним остаётся только редактируемый суффикс имени.
|
|
||||||
- При отправке CREATE имя собирается как `autotest-` + введённый хвост.
|
|
||||||
|
|
||||||
### Что это чинит
|
|
||||||
- Ситуацию, когда весь `displayName` показывался в одном редактируемом поле.
|
|
||||||
- Риск случайно стереть обязательный префикс `autotest-`.
|
|
||||||
|
|
||||||
## v1.0.68 (27.07.2026) — operation name shown after OK + muted operation buttons
|
|
||||||
|
|
||||||
### Что изменилось
|
|
||||||
- В `site/templates/index.html` финальный статус теперь показывает имя операции рядом со временем.
|
|
||||||
- Кнопки операций получили спокойные неброские оттенки по типу операции.
|
|
||||||
|
|
||||||
### Что это чинит
|
|
||||||
- Неясность после завершения операции, когда было видно только `OK` и время без указания, что именно выполнялось.
|
|
||||||
- Слишком кислотный вид кнопок операций.
|
|
||||||
|
|
||||||
## v1.0.67 (27.07.2026) — autotest instances are shown by cloud prefix
|
|
||||||
|
|
||||||
### Что изменилось
|
|
||||||
- В `site/routes/main.py` список инстансов сервиса больше не отфильтровывается через `/tmp/instances.json`.
|
|
||||||
- Теперь в таблицу попадают все облачные инстансы с префиксом `autotest-`.
|
|
||||||
- Трекер оставлен только как fallback, если новый autotest-инстанс ещё не успел появиться в ответе облака.
|
|
||||||
|
|
||||||
### Что это чинит
|
|
||||||
- Ситуацию, когда autotest-инстанс уже есть в облаке, но не показывается в таблице из-за отсутствия записи в json.
|
|
||||||
|
|
||||||
## v1.0.66 (27.07.2026) — backend status resolver for operations
|
|
||||||
|
|
||||||
### Что изменилось
|
|
||||||
- В `site/routes/main.py` добавлен канонический резолвер статуса инстанса для `/api/operations/<svc_id>`.
|
|
||||||
- Backend теперь отдаёт `status` для инстансов, а UI читает именно его вместо угадывания по `explainedStatus`.
|
|
||||||
- В `site/templates/index.html` убрано раннее отображение «Готово» во время выполнения операции.
|
|
||||||
|
|
||||||
### Что это чинит
|
|
||||||
- Путаницу между `creating`, `running` и фактическим статусом в UI.
|
|
||||||
- Ситуацию, когда фронтенд сам интерпретировал статусы и показывал не то состояние.
|
|
||||||
|
|
||||||
## v1.0.65 (27.07.2026) — cloud list pagination fix
|
|
||||||
|
|
||||||
### Что изменилось
|
|
||||||
- В `site/operations/get_instances.py` пагинация списка инстансов теперь останавливается по размеру страницы, а не по `total`.
|
|
||||||
- Это убирает ситуацию, когда инстанс уже создан в облаке, но приложение не дочитало следующую страницу и показывает `creating`.
|
|
||||||
- В `DOCS/ARCHITECTURE.md` и `site/app.py` синхронизирована версия `1.0.65`.
|
|
||||||
|
|
||||||
### Что это чинит
|
|
||||||
- Ложный `creating` для уже существующего инстанса, если он попал не на первую страницу `/instances`.
|
|
||||||
|
|
||||||
## v1.0.64 (27.07.2026) — header version + editable displayName placeholder
|
|
||||||
|
|
||||||
### Что изменилось
|
|
||||||
- В `site/routes/main.py` версия теперь гарантированно попадает в `config.VERSION` даже после `load_config()`.
|
|
||||||
- В `site/templates/index.html` поле `displayName` остаётся редактируемым, но автотест-имя показывается как placeholder.
|
|
||||||
- При CREATE пользователь может ввести своё имя, а backend всё равно добавит `autotest-` и проверит уникальность.
|
|
||||||
- В `DOCS/ARCHITECTURE.md` зафиксированы оба изменения.
|
|
||||||
|
|
||||||
### Что это чинит
|
|
||||||
- Пропадающую версию в шапке.
|
|
||||||
- Неправильное поведение поля `displayName`, когда оно выглядело как готовое значение вместо подсказки.
|
|
||||||
|
|
||||||
## v1.0.63 (27.07.2026) — autotest namespace for instances
|
|
||||||
|
|
||||||
### Что изменилось
|
|
||||||
- В `site/templates/index.html` CREATE теперь генерирует `displayName` с префиксом `autotest-` и проверяет его на уникальность среди текущих инстансов.
|
|
||||||
- В `site/routes/api_test.py` backend тоже нормализует `displayName` с этим префиксом и, если нужно, добавляет суффикс для уникальности перед созданием в облаке.
|
|
||||||
- В `site/routes/main.py` список инстансов фильтруется по namespace `autotest-`, чтобы в UI попадали только тестовые инстансы приложения.
|
|
||||||
- В `DOCS/ARCHITECTURE.md` зафиксирован переход на namespace-based фильтрацию.
|
|
||||||
|
|
||||||
### Что это чинит
|
|
||||||
- Дубли `displayName` при CREATE.
|
|
||||||
- Чужие инстансы в списке.
|
|
||||||
- Зависимость от локального трекера как от основного признака принадлежности.
|
|
||||||
|
|
||||||
## v1.0.62 (27.07.2026) — immediate visual refresh after CREATE
|
|
||||||
|
|
||||||
### Что изменилось
|
|
||||||
- В `site/templates/index.html` после завершения CREATE список инстансов перерисовывается полностью.
|
|
||||||
- `refreshInstances()` больше не зависит от DOM-diff и сразу ставит новый инстанс в список.
|
|
||||||
- `DOCS/ARCHITECTURE.md` и `site/app.py` синхронизированы на `1.0.62`.
|
|
||||||
|
|
||||||
### Что это чинит
|
|
||||||
- После появления кнопки «Готово» созданный инстанс должен сразу быть виден в списке.
|
|
||||||
|
|
||||||
## v1.0.61 (27.07.2026) — immediate list rebuild after CREATE
|
|
||||||
|
|
||||||
### Что изменилось
|
|
||||||
- В `site/templates/index.html` `refreshInstances()` теперь всегда полностью перерисовывает список инстансов после завершения операции.
|
|
||||||
- После `OK` новый инстанс должен появляться в списке сразу, без зависимости от DOM-diff и без перезагрузки страницы.
|
|
||||||
- В `DOCS/ARCHITECTURE.md` и `site/app.py` синхронизирована версия `1.0.61`.
|
|
||||||
|
|
||||||
### Что это чинит
|
|
||||||
- Убрана ситуация, когда после CREATE статус уже `OK`, но в UI инстанс ещё не виден.
|
|
||||||
|
|
||||||
## v1.0.60 (27.07.2026) — full list rerender after CREATE
|
|
||||||
|
|
||||||
### Что изменилось
|
|
||||||
- В `site/templates/index.html` `refreshInstances()` теперь полностью перерисовывает список инстансов, а не пытается вставить только diff.
|
|
||||||
- После завершения CREATE новый инстанс должен появляться в списке сразу после `OK`.
|
|
||||||
- В `DOCS/ARCHITECTURE.md` добавлен отдельный шаг про полную перерисовку списка.
|
|
||||||
|
|
||||||
### Что это чинит
|
|
||||||
- Снята зависимость от частичного DOM-diff-а, из-за которого созданный инстанс мог не появляться сразу.
|
|
||||||
|
|
||||||
## v1.0.59 (27.07.2026) — restore tracked instance after reload
|
|
||||||
|
|
||||||
### Что изменилось
|
|
||||||
- В `site/routes/main.py` tracked-инстансы, которых Nubes уже не отдает в ответе после reload, снова добавляются в список из `/tmp/instances.json`.
|
|
||||||
- В `site/templates/index.html` статус CREATE теперь показывает имя инстанса, а не только `create`.
|
|
||||||
- В `DOCS/ARCHITECTURE.md` зафиксирован новый шаг восстановления списка после F5.
|
|
||||||
|
|
||||||
### Что это чинит
|
|
||||||
- После F5 инстанс не пропадает из UI, если он есть в локальном трекере.
|
|
||||||
- В статусе выполнения видно, по какому displayName идет CREATE.
|
|
||||||
|
|
||||||
## v1.0.58 (27.07.2026) — version bump
|
|
||||||
|
|
||||||
- Поднята версия проекта до `1.0.58`.
|
|
||||||
- Синхронизированы заголовки в `site/app.py` и `DOCS/ARCHITECTURE.md`.
|
|
||||||
|
|
||||||
## v1.0.57 (27.07.2026) — UI hardening + history sync
|
|
||||||
|
|
||||||
### Что изменилось
|
|
||||||
- В `site/templates/index.html` финальное состояние кнопки вынесено в `setFinishedState()`, чтобы не держать логику завершения операции в двух местах.
|
|
||||||
- После завершения операции кнопка «Готово» теперь гарантированно сбрасывает обработчик через `btn.onclick = null`.
|
|
||||||
- Повторный CREATE через старый обработчик больше не воспроизводится.
|
|
||||||
- `displayName` больше не теряется в нормальном сценарии завершения CREATE.
|
|
||||||
- В `site/app.py` версия поднята до `1.0.57`.
|
|
||||||
- В `DOCS/ARCHITECTURE.md` отмечено закрытие бага с повторным CREATE и синхронизирован статус по `displayName`.
|
|
||||||
|
|
||||||
### Зачем это было сделано
|
|
||||||
- Убрать хрупкость вокруг состояния кнопки после завершения операции.
|
|
||||||
- Привести документацию и версию к текущему состоянию кода.
|
|
||||||
|
|
||||||
## v1.0.54 (27.07.2026) — Аудит #3: 4 бага исправлено
|
|
||||||
|
|
||||||
### Баг #1: `_client()` не автоопределял стенд (КРИТИЧЕСКИЙ)
|
|
||||||
- **Где:** `api_test.py:_client()` — использовал `current_app.config["NUBES_API_ENDPOINT"]` (raw env var)
|
|
||||||
- **Симптом:** main.py автоопределял стенд для главной, но POST /api/test падал с 401 если токен от другого стенда
|
|
||||||
- **Исправление:** `detect_endpoint()` вынесен в `api/http_client.py`, используется в `_client()`
|
|
||||||
- **Как пропустили:** анализировал main.py и api_test.py в изоляции, не сравнил
|
|
||||||
|
|
||||||
### Баг #2: `_find_uid()` возвращал не тот UUID (КРИТИЧЕСКИЙ)
|
|
||||||
- **Где:** `api_test.py:_find_uid()` — итерировал по ВСЕМ значениям dict, искал 36-символьную строку
|
|
||||||
- **Симптом:** `/instanceOperations` возвращает оба `instanceUid` + `instanceOperationUid`, функция брала первый попавшийся → `run` шёл на неправильный URL
|
|
||||||
- **Исправление:** ищет по конкретным ключам: `instanceOperationUid` → `instanceUid` → `uid` → `Uid`
|
|
||||||
- **Как пропустили:** не проверил реальный формат ответа API
|
|
||||||
|
|
||||||
### Баг #3: Сироты при ошибке params (СРЕДНИЙ)
|
|
||||||
- **Где:** `api_test.py`: порядок — `POST /instances` → `POST /instanceOperations` → params loop → `tracker_add`
|
|
||||||
- **Симптом:** если params падает — инстанс и операция созданы в Nubes, трекер пуст → сироты
|
|
||||||
- **Исправление:** `tracker_add` вызывается ДО params loop, сразу после получения `instanceUid`
|
|
||||||
- **Как пропустили:** не трассировал линейно порядок вызовов
|
|
||||||
|
|
||||||
### Баг #4: `flock` без таймаута (НИЗКИЙ)
|
|
||||||
- **Где:** `tracker.py:_locked_read/_write` — `fcntl.flock(fd, LOCK_EX)` без LOCK_NB
|
|
||||||
- **Симптом:** при зависшем процессе с локом все воркеры блокируются навсегда
|
|
||||||
- **Исправление:** `_acquire_lock()` с `LOCK_EX | LOCK_NB` + retry до 2 секунд
|
|
||||||
- **Как пропустили:** новый код flock не перечитал с нуля после добавления
|
|
||||||
|
|
||||||
### Архитектурное: `detect_endpoint` вынесен в http_client.py
|
|
||||||
- Раньше был в main.py → недоступен для api_test.py
|
|
||||||
- Теперь в `api/http_client.py` → оба модуля используют
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v1.0.53 (27.07.2026) — Файловый трекер с fcntl.flock
|
|
||||||
- In-memory dict заменён на `/tmp/instances.json` с `fcntl.LOCK_EX`
|
|
||||||
- Multi-worker gunicorn: все воркеры читают/пишут один файл под локом
|
|
||||||
|
|
||||||
## v1.0.52 (27.07.2026) — `_tmpl()` helper
|
|
||||||
- Все переменные шаблона передаются во всех трёх return-путях index()
|
|
||||||
- Исправлен UndefinedError при «Выйти»
|
|
||||||
|
|
||||||
## v1.0.51 (27.07.2026) — Автоопределение стенда + UnboundLocalError
|
|
||||||
- `detect_endpoint()` в main.py: пробует токен против dev и test API
|
|
||||||
- `instance_groups = {}` инициализирован до if
|
|
||||||
- `_finish_op` обёрнут в полный try/except
|
|
||||||
|
|
||||||
## v1.0.50 (27.07.2026) — showStages + api_operations fix
|
|
||||||
- `showStages()`: dtFinish вместо isSuccessful для ⏳/✅/❌
|
|
||||||
- `api_operations()`: не фильтровать "not created", добавлять tracked-сирот
|
|
||||||
|
|
||||||
## v1.0.49 (27.07.2026) — In-memory tracker + refreshInstances
|
|
||||||
- Трекер: in-memory dict вместо файла `/tmp/instances.json`
|
|
||||||
- `refreshInstances()` добавляет новые инстансы в DOM (не только бейджи)
|
|
||||||
- `selectService()` заменён на `refreshInstances()` после OK
|
|
||||||
|
|
||||||
## v1.0.48 (27.07.2026) — tracker_add синхронно
|
|
||||||
- `tracker_add` в `api_test()` до `threading.Thread`
|
|
||||||
- Лог `is_ok` в `_finish_op`
|
|
||||||
- Flexbox-кнопки горизонтально
|
|
||||||
|
|
||||||
## v1.0.47 (27.07.2026) — displayName fix
|
|
||||||
- displayName захватывается до очистки формы
|
|
||||||
- tracker_add до _op_results[OK]
|
|
||||||
- autotest-1 в _INITIAL
|
|
||||||
|
|
||||||
## v1.0.46 (27.07.2026) — _finish_op signature
|
|
||||||
- Добавлены op_name, svc_op_id в _finish_op()
|
|
||||||
|
|
||||||
## v1.0.45 и ранее
|
|
||||||
- Базовый CREATE/MODIFY/SUSPEND/DELETE/RESUME/REDEPLOY
|
|
||||||
- UI с этапами, поллинг, params форма
|
|
||||||
- Множественные баги CREATE flow (документированы в DOCS/api-create-flow.md)
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
# GPT-5.6 Sol — Идеи развития + поиск багов
|
|
||||||
|
|
||||||
## Что такое autotest
|
|
||||||
|
|
||||||
Flask 3.0 + vanilla JS + PostgreSQL. Развёрнут на Nubes pythonk8s (managed).
|
|
||||||
Ручной инструмент для тестирования Nubes Cloud API — создание/изменение/удаление инстансов любых сервисов (PostgreSQL, Redis, S3, Kafka, ~35 сервисов).
|
|
||||||
|
|
||||||
## Что умеет сейчас (v1.1.44)
|
|
||||||
|
|
||||||
- Ручное создание/изменение/удаление инстансов через UI (как в облаке)
|
|
||||||
- Многопользовательский доступ (свой токен = свой стенд)
|
|
||||||
- История всех операций в PostgreSQL (who/what/when/status)
|
|
||||||
- Полный паритет с Nubes API (все 9 шагов create, валидация, normalize, refSvc)
|
|
||||||
|
|
||||||
## Что хочет заказчик (переписка)
|
|
||||||
|
|
||||||
Файл: `/home/naeel/nubes/autotest/TASKS/3007.md` — прочитай весь.
|
|
||||||
|
|
||||||
Коротко:
|
|
||||||
1. Автоматизированные тесты по конфигурируемым сценариям (не код, а конфиг)
|
|
||||||
2. Последовательности: create → modify → delete для выбранных сервисов
|
|
||||||
3. Общая история операций для всех пользователей
|
|
||||||
4. Учёт карантина (на проде удаление с задержкой 2 недели)
|
|
||||||
5. Тестовая организация (нельзя создавать org на проде)
|
|
||||||
|
|
||||||
## Файлы для анализа
|
|
||||||
|
|
||||||
- `/home/naeel/nubes/autotest/TASKS/3007.md` — требования заказчика
|
|
||||||
- `/home/naeel/nubes/autotest/app-autotest/site/static/app.js` — весь фронтенд (460 строк)
|
|
||||||
- `/home/naeel/nubes/autotest/app-autotest/site/routes/api_test.py` — бэкенд (550 строк)
|
|
||||||
- `/home/naeel/nubes/autotest/app-autotest/site/app.py` — точка входа
|
|
||||||
- `/home/naeel/nubes/autotest/app-autotest/site/db/save_run.py` — сохранение в БД
|
|
||||||
- `/home/naeel/nubes/autotest/HISTORY/2026-07-29-session.md` — что сделано за 2 дня
|
|
||||||
|
|
||||||
## Что нужно
|
|
||||||
|
|
||||||
### 1. Идеи по развитию (главное)
|
|
||||||
|
|
||||||
Исходя из требований заказчика и текущего состояния:
|
|
||||||
- Как должна выглядеть архитектура автотестов? YAML-конфиги → runner → БД?
|
|
||||||
- Как совместить ручной UI и автоматические сценарии?
|
|
||||||
- Как масштабировать на прод (карантин, тестовая организация)?
|
|
||||||
- Что делать с историей — дашборд, алерты при падениях?
|
|
||||||
- Несколько конкретных предложений (от минимального MVP до полной картины)
|
|
||||||
|
|
||||||
### 2. Баги (вторично)
|
|
||||||
|
|
||||||
Просмотри код. Найди что мы пропустили за 44 версии.
|
|
||||||
|
|
||||||
### Требования к ответу
|
|
||||||
|
|
||||||
- Конкретно. Без воды. Без "можно рассмотреть" и "в долгосрочной перспективе".
|
|
||||||
- Каждое предложение — что делать, какой файл менять, какой результат.
|
|
||||||
- Если несколько вариантов — перечислить с плюсами/минусами.
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
# Сравнение: Sonnet 4.6 vs Opus 4.8 — полный код-ревью v1.1.20
|
|
||||||
|
|
||||||
## Совпадения (оба нашли)
|
|
||||||
|
|
||||||
| Баг | Sonnet | Opus |
|
|
||||||
|-----|--------|------|
|
|
||||||
| `_finish_op` args CREATE | 🔴 | 🔴 |
|
|
||||||
| `api_history()` connection leak | 🔴 | 🟠 |
|
|
||||||
| XSS displayName/svc | 🟡 | 🟡 |
|
|
||||||
| Мёртвый код/docstring'и | 🟠 | 🟢 |
|
|
||||||
|
|
||||||
## Только Opus (дополнительно)
|
|
||||||
|
|
||||||
| Баг | |
|
|
||||||
|-----|---|
|
|
||||||
| `detect_endpoint()` — 2-3 лишних HTTP на каждый запрос | 🟠 |
|
|
||||||
| `_op_results` in-memory dict — не переживает multi-worker | 🟠 |
|
|
||||||
| CSRF на POST (частично закрыто SameSite) | 🟡 |
|
|
||||||
| `currentSvcShort` без `let` — неявная глобальная | 🟢 |
|
|
||||||
| Дублирование рендера в selectService/refreshInstances | 🟢 |
|
|
||||||
| CREATE шаг 6: шаблон vs реальная операция | 🟢 |
|
|
||||||
|
|
||||||
## Только Sonnet
|
|
||||||
|
|
||||||
| Баг | |
|
|
||||||
|-----|---|
|
|
||||||
| pool.py `_initialized=True` до `init_db()` | 🔴 |
|
|
||||||
| Cookie без `secure=True` | 🟡 |
|
|
||||||
| MODIFY без required+default | 🟡 |
|
|
||||||
| pool.py race condition без мьютекса | 🟠 |
|
|
||||||
|
|
||||||
## Мой анализ
|
|
||||||
|
|
||||||
Opus 4.8 глубже — проверил эталонный Terraform flow, подтвердил что CREATE-флоу соответствует, нашёл проблемы с `detect_endpoint` и multi-worker. Sonnet нашёл pool.py баги, которые Opus пропустил.
|
|
||||||
|
|
||||||
**План исправлений (по приоритету):**
|
|
||||||
|
|
||||||
1. 🔴 `_finish_op` args — добавить `False` для `is_delete` в CREATE-ветку
|
|
||||||
2. 🔴 `api_history()` — `try/finally: put_conn(conn)`
|
|
||||||
3. 🔴 `pool.py` — `_initialized=True` после успешного `init_db()`
|
|
||||||
4. 🟡 XSS — `_esc()` для displayName/svc в рендере инстансов + истории + stages
|
|
||||||
5. 🟡 Cookie `secure=True`
|
|
||||||
6. 🟠 `currentSvcShort` → `let currentSvcShort`
|
|
||||||
|
|
||||||
Правки 1-3 критические — их делаю первыми. 4-6 следом.
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
# Запрос к Claude (Sonnet): архитектурный аудит — Round 2
|
|
||||||
|
|
||||||
> **Контекст**: Round 1 анализ получен, исправления по приоритетам приняты.
|
|
||||||
> **Новое**: проект будет подключать **PostgreSQL** (отдельный сервис Nubes).
|
|
||||||
> **Дата**: 27.07.2026
|
|
||||||
> **Версия**: 1.0.89
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Контекст Round 1
|
|
||||||
|
|
||||||
Sonnet в первом раунде выявил:
|
|
||||||
- 🔴 Утечка `_op_results` — нужно TTL/очистка
|
|
||||||
- 🔴 Cookie без `httponly`/`samesite`
|
|
||||||
- 🟡 Дублирование `_client_id()` в main.py и api_test.py
|
|
||||||
- 🟡 Захардкожен `SVC_ID=1` — хотя `/api/services` существует
|
|
||||||
- 🟢 Рекомендация: SQLite для истории → **теперь PostgreSQL**
|
|
||||||
|
|
||||||
Все исправления из Round 1 приняты к реализации. Начинаем со среднего приоритета (общий модуль `auth.py`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Новый контекст: PostgreSQL
|
|
||||||
|
|
||||||
Проект получает отдельный PostgreSQL-сервис в Nubes. Нужно спроектировать:
|
|
||||||
1. **Схему БД** для хранения истории тестов
|
|
||||||
2. **Подключение** (connection pool для multi-worker gunicorn)
|
|
||||||
3. **Миграции** (как управлять схемой при обновлениях)
|
|
||||||
4. **API** для чтения/записи результатов
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Вопросы Round 2
|
|
||||||
|
|
||||||
### 1. PostgreSQL — схема данных
|
|
||||||
|
|
||||||
Нужна схема для истории запусков. Минимальный набор полей:
|
|
||||||
- id, created_at
|
|
||||||
- client_id, stand (изоляция по пользователю и стенду)
|
|
||||||
- svc_id, svc_name
|
|
||||||
- op_name (create/modify/delete/...), svc_op_id
|
|
||||||
- instance_uid, display_name
|
|
||||||
- status (OK/FAIL/TIMEOUT), duration_sec
|
|
||||||
- error_log (текст ошибки)
|
|
||||||
- params (JSON — все параметры запуска)
|
|
||||||
- stages (JSON — этапы выполнения с длительностью)
|
|
||||||
|
|
||||||
Вопросы:
|
|
||||||
- Какие индексы нужны? (по client_id+stand, по created_at, по instance_uid?)
|
|
||||||
- Нужна ли отдельная таблица для stages или JSON в основном поле?
|
|
||||||
- Нужна ли таблица `instances` (связь instance_uid → последний статус)?
|
|
||||||
- Как хранить чувствительные params (пароли, токены в map-параметрах)?
|
|
||||||
|
|
||||||
### 2. PostgreSQL — connection pool
|
|
||||||
|
|
||||||
Сейчас gunicorn с несколькими воркерами. Каждый воркер — отдельный процесс.
|
|
||||||
|
|
||||||
Вопросы:
|
|
||||||
- Какой connection pooler использовать? (psycopg2.pool? SQLAlchemy? asyncpg?)
|
|
||||||
- Где создавать пул? (в app.py при старте? lazy в каждом воркере?)
|
|
||||||
- Как передавать соединение в роуты? (flask.g? внедрение через blueprint?)
|
|
||||||
- Нужен ли `@app.teardown_appcontext` для возврата соединения в пул?
|
|
||||||
|
|
||||||
### 3. PostgreSQL — миграции
|
|
||||||
|
|
||||||
Вопросы:
|
|
||||||
- Нужна ли полноценная миграция (Alembic) или простая схема в `init_db.py`?
|
|
||||||
- Где хранить SQL-файлы миграций? (отдельная папка `migrations/`?)
|
|
||||||
- Как применять при деплое? (автоматически при старте приложения?)
|
|
||||||
|
|
||||||
### 4. Объединение с текущим трекером
|
|
||||||
|
|
||||||
Сейчас трекер (`tracker.py`) хранит UID'ы в JSON-файлах `/tmp/`. После появления PostgreSQL:
|
|
||||||
|
|
||||||
Вопросы:
|
|
||||||
- Заменяет ли PostgreSQL трекер полностью? (хранить UID'ы в БД вместо /tmp/)
|
|
||||||
- Или оставить трекер для краткосрочного кеша, а БД — для истории?
|
|
||||||
- Если заменить — нужна ли миграция существующих данных из /tmp/ в БД?
|
|
||||||
|
|
||||||
### 5. Унификация `auth.py`
|
|
||||||
|
|
||||||
Из Round 1: вынести `_client_id()`, `get_client()`, `get_stand()`, `get_token_info()` в общий модуль `api/auth.py`.
|
|
||||||
|
|
||||||
Вопросы:
|
|
||||||
- Использовать `flask.g` + `@bp.before_request` или явные вызовы функций?
|
|
||||||
- Как обрабатывать роуты без обязательного токена (GET / без авторизации)?
|
|
||||||
- Где создавать `HttpClient` — в `before_request` (на каждый запрос) или lazy?
|
|
||||||
|
|
||||||
### 6. Фронтенд — динамические сервисы
|
|
||||||
|
|
||||||
Сейчас `SVC_ID=1` захардкожен. Нужно загружать список из `/api/services`.
|
|
||||||
|
|
||||||
Вопросы:
|
|
||||||
- Показывать ВСЕ сервисы или только из whitelist в config.yaml?
|
|
||||||
- Какой UI для переключения между сервисами? (выпадающий список? вкладки?)
|
|
||||||
- При смене сервиса — сбрасывать `selectedInst` и форму параметров?
|
|
||||||
|
|
||||||
### 7. JS в отдельный файл
|
|
||||||
|
|
||||||
Вынести весь JS из `<script>` в `/static/app.js`.
|
|
||||||
|
|
||||||
Вопросы:
|
|
||||||
- Нужно ли использовать ES modules (`type="module"`) или классический скрипт?
|
|
||||||
- Если модули — как разбить на файлы (instances.js, params.js, log.js)?
|
|
||||||
- Как передавать Jinja2-переменные (config.VERSION, token_info) в JS-файл?
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Файлы для изучения (если нужно)
|
|
||||||
|
|
||||||
Те же что в Round 1, плюс:
|
|
||||||
- `DOCS/HISTORY.md` — обновлён результатами Round 1
|
|
||||||
- `DOCS/sonnet-architecture-review-v1.0.89.md` — запрос Round 1
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
# Запрос к Claude (Sonnet): архитектурный аудит app-autotest v1.0.89
|
|
||||||
|
|
||||||
> **Цель**: получить анализ текущей архитектуры и рекомендации по развитию проекта.
|
|
||||||
> **Дата**: 27.07.2026
|
|
||||||
> **Версия**: 1.0.89
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. О проекте
|
|
||||||
|
|
||||||
### Что делает
|
|
||||||
Flask-приложение для ручного тестирования операций облачной платформы Nubes:
|
|
||||||
- CREATE — создать инстанс сервиса с параметрами
|
|
||||||
- MODIFY — изменить параметры существующего инстанса
|
|
||||||
- DELETE / SUSPEND / RESUME / REDEPLOY — управление инстансом
|
|
||||||
|
|
||||||
### Как работает сейчас
|
|
||||||
- Один HTML-файл (index.html) с ванильным JS — без фреймворков, без SPA
|
|
||||||
- Бэкенд: Flask + gunicorn (несколько воркеров)
|
|
||||||
- Все данные — из REST API Nubes (никакого локального кеша, кроме краткосрочного трекера)
|
|
||||||
- Трекер инстансов: JSON-файлы в /tmp/ под fcntl.flock
|
|
||||||
- Автоопределение стенда (dev/test) по JWT-токену
|
|
||||||
|
|
||||||
### Текущая файловая структура
|
|
||||||
```
|
|
||||||
app-autotest/
|
|
||||||
├── site/
|
|
||||||
│ ├── app.py ← Flask(__name__), blueprints, /health
|
|
||||||
│ ├── api/
|
|
||||||
│ │ └── http_client.py ← HttpClient, автостенд
|
|
||||||
│ ├── operations/
|
|
||||||
│ │ ├── get_params.py ← слияние state.params + шаблон
|
|
||||||
│ │ ├── get_services.py ← API-обёртки
|
|
||||||
│ │ ├── get_instances.py ← пагинация
|
|
||||||
│ │ └── tracker.py ← /tmp/instances-{clientId}-{stand}.json
|
|
||||||
│ ├── routes/
|
|
||||||
│ │ ├── main.py ← GET/POST /, /api/operations/<svc_id>
|
|
||||||
│ │ ├── api.py ← [LEGACY] раннер тестов
|
|
||||||
│ │ └── api_test.py ← /api/test, /api/params, /api/log
|
|
||||||
│ ├── templates/
|
|
||||||
│ │ └── index.html ← весь UI (~400 строк)
|
|
||||||
│ └── static/
|
|
||||||
│ └── style.css
|
|
||||||
├── DOCS/
|
|
||||||
│ ├── ARCHITECTURE.md ← актуальная архитектура
|
|
||||||
│ ├── ARCHITECTURE.legacy.md ← старая версия
|
|
||||||
│ ├── HISTORY.md ← история версий
|
|
||||||
│ └── ... ← 12+ legacy-документов
|
|
||||||
└── secrets/ ← токены (не в git)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Ключевые детали реализации
|
|
||||||
- **Multi-worker safety**: все общие данные в /tmp/ под fcntl.flock
|
|
||||||
- **CREATE vs non-CREATE**: раздельные потоки в бэкенде и фронтенде
|
|
||||||
- **Текущие значения параметров**: GET /instances/{uid} → state.params + GET /instanceOperations/default/{opId} → слияние
|
|
||||||
- **Логирование**: /tmp/app-autotest.log + /api/log + скрытая UI-панель
|
|
||||||
- **Валидация**: HTML-escape + JSON-валидация map-полей (onblur + pre-submit)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Что нужно проанализировать
|
|
||||||
|
|
||||||
### A. Decoupling и модульность
|
|
||||||
Сейчас всё в одном HTML и одном api_test.py (300+ строк). Нужны рекомендации:
|
|
||||||
- Как разбить index.html на компоненты/модули?
|
|
||||||
- Как разбить api_test.py на независимые модули по операциям?
|
|
||||||
- Стоит ли переходить на микро-фронтенд (HTMX, Alpine.js, что-то ещё) или оставить ванильный JS?
|
|
||||||
- Как организовать роутинг если добавится больше страниц?
|
|
||||||
|
|
||||||
### B. Хранение данных тестов
|
|
||||||
Сейчас никакие результаты тестов не сохраняются. Нужно:
|
|
||||||
- Где и как хранить историю запусков (каждый запуск = запись)?
|
|
||||||
- Какие данные сохранять: параметры, длительность, статус, этапы, ошибки?
|
|
||||||
- Формат хранения: SQLite? JSON-файлы? Внешняя БД?
|
|
||||||
- Как привязать к конкретному инстансу и пользователю?
|
|
||||||
- Нужна ли страница истории/отчётов?
|
|
||||||
|
|
||||||
### C. Оптимизация для развития
|
|
||||||
- Что мешает добавить второй сервис (сейчас только Болванка, svcId=1)?
|
|
||||||
- Как сделать конфигурацию сервисов (какие сервисы показывать, какие операции)?
|
|
||||||
- Нужен ли config.yaml или всё из API?
|
|
||||||
- Как масштабировать на множество пользователей (изоляция данных)?
|
|
||||||
|
|
||||||
### D. Улучшения архитектуры
|
|
||||||
- Сейчас _client() определяется в двух местах (main.py и api_test.py) — как унифицировать?
|
|
||||||
- Стоит ли вынести общие хелперы (_client_id, _stand, _token_info) в отдельный модуль?
|
|
||||||
- Нужен ли middleware для токена (вместо ручного извлечения в каждом роуте)?
|
|
||||||
- Как улучшить обработку ошибок (сейчас много try/except с голым str(e))?
|
|
||||||
|
|
||||||
### E. Безопасность
|
|
||||||
- Токен передаётся в cookie и форме — достаточно ли этого?
|
|
||||||
- Нужна ли CSRF-защита для POST-запросов?
|
|
||||||
- Логи могут содержать чувствительные данные?
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Ожидаемый формат ответа
|
|
||||||
|
|
||||||
### 1. Общая оценка
|
|
||||||
- Что сделано хорошо
|
|
||||||
- Что требует внимания
|
|
||||||
- Критические проблемы (если есть)
|
|
||||||
|
|
||||||
### 2. Decoupling — план
|
|
||||||
- Пошаговый план разбиения на модули
|
|
||||||
- Рекомендации по фронтенду (ванильный JS / HTMX / Alpine / SPA)
|
|
||||||
- Рекомендации по бэкенду (структура модулей)
|
|
||||||
|
|
||||||
### 3. Хранение данных тестов — план
|
|
||||||
- Схема данных (какие поля, какие таблицы/файлы)
|
|
||||||
- Технология (SQLite / PostgreSQL / JSON-файлы)
|
|
||||||
- API для чтения/записи
|
|
||||||
- UI для просмотра истории
|
|
||||||
|
|
||||||
### 4. Оптимизация — конкретные шаги
|
|
||||||
- Приоритизированный список улучшений
|
|
||||||
- Что можно сделать быстро, что требует переписывания
|
|
||||||
|
|
||||||
### 5. Риски и ограничения
|
|
||||||
- Что может пойти не так при предлагаемых изменениях
|
|
||||||
- Ограничения платформы (gunicorn, /tmp/, DDoS-Guard)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Файлы для изучения
|
|
||||||
|
|
||||||
**Код (в порядке важности):**
|
|
||||||
1. `site/routes/api_test.py` — основной модуль операций
|
|
||||||
2. `site/templates/index.html` — весь фронтенд
|
|
||||||
3. `site/routes/main.py` — главная страница и API операций
|
|
||||||
4. `site/api/http_client.py` — HTTP-клиент и автостенд
|
|
||||||
5. `site/operations/get_params.py` — слияние параметров
|
|
||||||
6. `site/operations/tracker.py` — файловый трекер
|
|
||||||
7. `site/app.py` — точка входа
|
|
||||||
8. `site/operations/get_services.py` + `get_instances.py` — API-обёртки
|
|
||||||
|
|
||||||
**Документация:**
|
|
||||||
1. `DOCS/ARCHITECTURE.md` — актуальная архитектура
|
|
||||||
2. `DOCS/HISTORY.md` — история версий
|
|
||||||
3. `DOCS/terraform-operations-full-logic.md` — логика API из Terraform-провайдера
|
|
||||||
|
|
||||||
**Данные (HAR-файлы):**
|
|
||||||
1. `development/dummycreate.har` — полный flow CREATE
|
|
||||||
2. `development/dummymodify.har` — полный flow MODIFY
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
# Sonnet 4.6 — Финальный аудит: баги и действия пользователя
|
|
||||||
|
|
||||||
Дата: 2026-07-30
|
|
||||||
Версия: v1.1.43
|
|
||||||
|
|
||||||
## Контекст
|
|
||||||
|
|
||||||
За 2 дня: 43 версии. Множество правок. Только что нашли баг — в `toggleInstance` удалили `const opsEl = ...` при рефакторинге, упало с ReferenceError. Нужно найти ВСЕ подобные баги.
|
|
||||||
|
|
||||||
## Файлы для проверки (ВСЕ)
|
|
||||||
|
|
||||||
- `/home/naeel/nubes/autotest/app-autotest/site/static/app.js` — 460 строк, весь фронтенд
|
|
||||||
- `/home/naeel/nubes/autotest/app-autotest/site/routes/api_test.py` — бэкенд
|
|
||||||
- `/home/naeel/nubes/autotest/app-autotest/site/templates/index.html` — HTML/CSS
|
|
||||||
- `/home/naeel/nubes/autotest/app-autotest/site/api/http_client.py` — HTTP-клиент
|
|
||||||
- `/home/naeel/nubes/autotest/app-autotest/site/operations/get_params.py` — параметры
|
|
||||||
|
|
||||||
## Что искать
|
|
||||||
|
|
||||||
### 1. ReferenceError / undefined variables
|
|
||||||
- Все ли переменные объявлены перед использованием?
|
|
||||||
- `opsEl`, `svcEl`, `btnSpan`, `inst`, `afterEl` — все ли определены в своей области видимости?
|
|
||||||
|
|
||||||
### 2. Все действия пользователя при busy=true
|
|
||||||
Проверь КАЖДУЮ функцию — не вызывает ли она `stopPoll()` до проверки `busy`? Не меняет ли DOM (stages-box, params-area) до проверки?
|
|
||||||
|
|
||||||
Функции для проверки:
|
|
||||||
- `toggleInstance(iuid)` — строка 76
|
|
||||||
- `selectService(svcId)` — строка 39
|
|
||||||
- `startCreate()` — строка 106
|
|
||||||
- `runOp(opName,opId)` — строка 128
|
|
||||||
- `showParams(opId,opName)` — строка 146 (вызывается из runOp, startCreate)
|
|
||||||
- `executeOp(params)` — строка 307
|
|
||||||
- `setFinishedState(...)` — строка 286
|
|
||||||
- `refreshInstances()` — строка 389
|
|
||||||
- `toggleHistory()` — строка 410
|
|
||||||
- `toggleLog()` — строка 456
|
|
||||||
|
|
||||||
### 3. Пустые catch / потерянные ошибки
|
|
||||||
- `showParams` — есть `.catch()` ✅
|
|
||||||
- `executeOp` — есть `catch(e)` + busy ✅
|
|
||||||
- `loadHistory` — есть `catch(e)` ✅
|
|
||||||
- `startCreate` — есть `catch(e)` с fallback ✅
|
|
||||||
- Поллинг в executeOp — `catch(e)` со счётчиком ✅
|
|
||||||
- Есть ли другие `try/catch` или `fetch().then()` без обработки ошибок?
|
|
||||||
|
|
||||||
### 4. busy не сбрасывается
|
|
||||||
Все ли пути выхода из `executeOp` сбрасывают `busy=false`?
|
|
||||||
- FAIL: `busy=false` + return ✅
|
|
||||||
- CMDB OK: `busy=false` + setFinishedState ✅
|
|
||||||
- Поллинг завершился: `setFinishedState` → `busy=false` ✅
|
|
||||||
- catch: `busy=false` ✅
|
|
||||||
- Сетевая ошибка в поллинге: счётчик → `setFinishedState` ✅
|
|
||||||
|
|
||||||
### 5. DOM-элементы
|
|
||||||
- Все ли `getElementById` ссылаются на существующие ID в index.html?
|
|
||||||
- `ops-${iuid}` — генерируется динамически в `selectService` и `refreshInstances`
|
|
||||||
- `param-displayname` — генерируется в `showParams`
|
|
||||||
- Все ли статические ID присутствуют?
|
|
||||||
|
|
||||||
### 6. Параметры операций
|
|
||||||
- `runOp` → `showParams` — правильно ли для ВСЕХ типов операций?
|
|
||||||
- `showParams` → `opHeader` для не-create — показывается всегда ✅
|
|
||||||
- `showParams` → `createHeader` для create — показывается ✅
|
|
||||||
- Пустые params → `opHeader` + кнопка «Запустить» ✅
|
|
||||||
|
|
||||||
### 7. Любые другие баги
|
|
||||||
Что ещё могло сломаться за 43 версии?
|
|
||||||
@@ -1,509 +0,0 @@
|
|||||||
# ⚠️ LEGACY — НЕАКТУАЛЬНО. Исторический документ.
|
|
||||||
|
|
||||||
# Полный обзор кода 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/<int:svc_id>")
|
|
||||||
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/<int:op_id>")
|
|
||||||
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/<op_uid>")
|
|
||||||
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/<int:svc_id>")
|
|
||||||
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 → ОШИБКА СКРЫТА
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
# Sonnet 4.6 — Полный код-ревью v1.1.20
|
|
||||||
|
|
||||||
Дата: 2026-07-29
|
|
||||||
|
|
||||||
## 🔴 КРИТИЧЕСКИЙ БАГ #1 — `_finish_op`: неправильный порядок аргументов для CREATE
|
|
||||||
|
|
||||||
Файл: /home/naeel/nubes/autotest/app-autotest/site/routes/api_test.py
|
|
||||||
|
|
||||||
Сигнатура: `def _finish_op(client, op_uid, instance_uid, svc_id, display_name, op_name, svc_op_id, is_create, client_id, stand, is_delete=False, params=None, user_email="", app_version=""):`
|
|
||||||
|
|
||||||
Вызов CREATE передаёт 13 аргументов. `params` попадает на позицию `is_delete` → dict truthy → после успешного CREATE вызывается `tracker_remove` — инстанс НЕМЕДЛЕННО удаляется из трекера.
|
|
||||||
|
|
||||||
## 🔴 ВАЖНЫЙ БАГ #2 — `api_history()`: утечка соединения
|
|
||||||
|
|
||||||
Нет `try/finally` — при исключении соединение не возвращается в пул. 5 утечек = пул исчерпан.
|
|
||||||
|
|
||||||
## 🔴 ВАЖНЫЙ БАГ #3 — pool.py: `_initialized = True` до `init_db()`
|
|
||||||
|
|
||||||
При сетевой ошибке `init_db()` не выполняется, но флаг уже True — схема не создастся до перезапуска воркера.
|
|
||||||
|
|
||||||
## 🟡 XSS — `displayName` и `svc` не экранируются в app.js
|
|
||||||
|
|
||||||
`_esc()` не применяется при рендере списка инстансов. Вектор через поле displayName.
|
|
||||||
|
|
||||||
## 🟡 Cookie без `secure=True`
|
|
||||||
|
|
||||||
## 🟡 MODIFY — не досылаются required+default параметры (в отличие от CREATE)
|
|
||||||
|
|
||||||
## 🟠 Мёртвый код: runner.py (LEGACY), дубликат redeploy/else, устаревшие docstring'и
|
|
||||||
|
|
||||||
## 🟠 pool.py — race condition при создании пула без мьютекса
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
# Полный код-ревью ВСЕГО проекта autotest
|
|
||||||
|
|
||||||
## Контекст
|
|
||||||
|
|
||||||
Flask 3.0 + gunicorn (multi-worker) + vanilla JS + psycopg2 PostgreSQL.
|
|
||||||
Тестирует Nubes REST API — создаёт/удаляет инстансы облачных сервисов.
|
|
||||||
Версия: v1.1.20 (сегодня, после 20 правок за день).
|
|
||||||
За день сделано ~20 правок, накопился технический долг — нужен свежий взгляд.
|
|
||||||
|
|
||||||
## ВСЕ файлы (прочитай КАЖДЫЙ)
|
|
||||||
|
|
||||||
```
|
|
||||||
/home/naeel/nubes/autotest/app-autotest/site/app.py — точка входа, регистрация blueprint'ов
|
|
||||||
/home/naeel/nubes/autotest/app-autotest/site/runner.py — загрузка config.yaml
|
|
||||||
/home/naeel/nubes/autotest/app-autotest/site/api/auth.py — get_token, get_client, get_client_id, get_stand
|
|
||||||
/home/naeel/nubes/autotest/app-autotest/site/api/http_client.py — HttpClient, detect_endpoint, stand_name
|
|
||||||
/home/naeel/nubes/autotest/app-autotest/site/db/pool.py — psycopg2 ThreadedConnectionPool (lazy)
|
|
||||||
/home/naeel/nubes/autotest/app-autotest/site/db/init_db.py — CREATE TABLE runs + миграции
|
|
||||||
/home/naeel/nubes/autotest/app-autotest/site/db/save_run.py — INSERT в runs (16 колонок)
|
|
||||||
/home/naeel/nubes/autotest/app-autotest/site/operations/get_instances.py — GET /instances (пагинация)
|
|
||||||
/home/naeel/nubes/autotest/app-autotest/site/operations/get_params.py — get_params_with_current_values, _normalize_value_list
|
|
||||||
/home/naeel/nubes/autotest/app-autotest/site/operations/get_services.py — GET /services, /services/{id}
|
|
||||||
/home/naeel/nubes/autotest/app-autotest/site/operations/service_list.py — load_service_ids из services_{stand}.txt
|
|
||||||
/home/naeel/nubes/autotest/app-autotest/site/operations/tracker.py — fcntl.flock file tracker
|
|
||||||
/home/naeel/nubes/autotest/app-autotest/site/routes/api.py — LEGACY эндпоинты
|
|
||||||
/home/naeel/nubes/autotest/app-autotest/site/routes/api_test.py — ОСНОВНОЙ: /api/test, /api/params, /api/log, поллинг, _finish_op
|
|
||||||
/home/naeel/nubes/autotest/app-autotest/site/routes/main.py — / (главная), /api/operations/{svc_id}
|
|
||||||
/home/naeel/nubes/autotest/app-autotest/site/static/app.js — ВЕСЬ фронтенд (420 строк)
|
|
||||||
/home/naeel/nubes/autotest/app-autotest/site/templates/index.html — Jinja2 шаблон
|
|
||||||
```
|
|
||||||
|
|
||||||
## Что нужно от тебя
|
|
||||||
|
|
||||||
1. **Найти ВСЕ баги** — синтаксические, логические, race conditions, необработанные исключения
|
|
||||||
2. **Проверить универсальность** — работает ли код для ВСЕХ сервисов (Redis 3 параметра, PostgreSQL 8 map-fixed параметров, Болванка)
|
|
||||||
3. **Проверить CREATE flow** — сверь с эталонным Terraform flow из /home/naeel/nubes/autotest/DOCS/terraform-operations-full-logic.md
|
|
||||||
4. **Проверить MODIFY/SUSPEND/DELETE/RESUME** — нет ли там таких же проблем
|
|
||||||
5. **Проверить фронтенд** — все ли DOM ID совпадают, нет ли необработанных Promise, правильный ли порядок элементов
|
|
||||||
6. **Проверить на дубликаты** — нет ли мёртвого кода, дублирующихся эндпоинтов
|
|
||||||
7. **Проверить безопасность** — httponly cookie, токены, CORS
|
|
||||||
|
|
||||||
## Известные недавние баги (уже исправлены, но проверь что фиксы корректны)
|
|
||||||
|
|
||||||
- `_client()` → NameError (не было такой функции) → заменили на get_client()
|
|
||||||
- `-строка` → TypeError в сортировке → заменили на два sort()
|
|
||||||
- `_normalize_value_list` не импортирован → добавили импорт
|
|
||||||
- `</div>\`;` осиротевший HTML в JS → удалили
|
|
||||||
- `currentSvcName` вместо реального `i.svc` → исправили
|
|
||||||
- serviceId фильтр (убран, заменён на сортировку)
|
|
||||||
- dataDescriptor defaultValue не прокидывался → добавили
|
|
||||||
- required+default params не досылались (шаг 6 Terraform) → добавили
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
# ⚠️ LEGACY — НЕАКТУАЛЬНО. Исторический документ.
|
|
||||||
|
|
||||||
# Sonnet — почему ты пропустил эти баги?
|
|
||||||
|
|
||||||
Отправлено: 27.07.2026, после v1.0.49 (твой анализ выполнен, НЕ ПОМОГЛО)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Ты предложил 4 правки — мы сделали. Инстанс ВСЁ РАВНО не появляется в списке. Плюс новый баг: ❌ вместо ⏳ для этапов в процессе.
|
|
||||||
|
|
||||||
## Ты НЕ заметил баг №1: showStages() — ❌ для выполняющихся этапов
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
function showStages(stages){
|
|
||||||
stages.forEach(s=>{
|
|
||||||
const ok=s.isSuccessful;
|
|
||||||
const icon=ok===true?'✅':ok===false?'❌':'⏳'; // ← БАГ
|
|
||||||
```
|
|
||||||
|
|
||||||
API Nubes для этапа в процессе возвращает `isSuccessful: false` (или `null`).
|
|
||||||
Код интерпретирует `false === false` → ❌ (авария).
|
|
||||||
Но этап просто **ещё не завершился** — должен быть ⏳.
|
|
||||||
|
|
||||||
**Правильная логика:** проверять `dtFinish` этапа. Если `dtFinish` нет → ⏳.
|
|
||||||
Если есть и `isSuccessful === true` → ✅. Если есть и `false` → ❌.
|
|
||||||
|
|
||||||
Почему ты это пропустил? Код `showStages()` был в твоём обзоре.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Ты НЕ заметил баг №2: api_operations() теряет инстансы
|
|
||||||
|
|
||||||
```python
|
|
||||||
def api_operations(svc_id):
|
|
||||||
tracked = tracker_list() # ← 5 UID (включая новый)
|
|
||||||
tracked_uids = {t["instanceUid"] for t in tracked if t["svcId"] == svc_id}
|
|
||||||
instances = get_instances(_client()) # ← Nubes API: только 4 старых
|
|
||||||
svc_instances = [i for i in instances
|
|
||||||
if i.get("instanceUid") in tracked_uids # ← новый UID НЕ НАЙДЕН
|
|
||||||
and i.get("explainedStatus") not in ("deleted", "not created")]
|
|
||||||
```
|
|
||||||
|
|
||||||
`tracker_add` работает (in-memory, мгновенно). Но `get_instances()` из Nubes API **не сразу** возвращает только что созданный инстанс.
|
|
||||||
Фильтр требует чтобы инстанс был В ОБОИХ источниках. Новый UID есть в `tracked_uids` но отсутствует в `instances` → выпадает.
|
|
||||||
|
|
||||||
Результат: список всегда показывает только старые инстансы, новый — никогда.
|
|
||||||
|
|
||||||
**Правильная логика:** инстансы из трекера, отсутствующие в `get_instances()`, добавлять напрямую со статусом `"creating"`.
|
|
||||||
|
|
||||||
Почему ты это пропустил? Код `api_operations()` был в твоём обзоре, полностью.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Что мы имеем после твоих рекомендаций
|
|
||||||
|
|
||||||
| Версия | Что делали | Результат |
|
|
||||||
|--------|-----------|-----------|
|
|
||||||
| v1.0.48 | tracker_add синхронно, except:pass | ❌ инстанса нет |
|
|
||||||
| v1.0.49 | in-memory dict (твоя рекомендация) | ❌ инстанса нет |
|
|
||||||
| v1.0.49 | refreshInstances вместо selectService | ❌ stages ушли, но инстанса нет |
|
|
||||||
|
|
||||||
Трекер РАБОТАЕТ — in-memory dict, добавление мгновенное.
|
|
||||||
НО фильтр `api_operations()` ВЫБРАСЫВАЕТ новый инстанс потому что Nubes API его ещё не проиндексировал.
|
|
||||||
|
|
||||||
## Вопрос
|
|
||||||
|
|
||||||
Ты проанализировал ВЕСЬ код, включая `api_operations()` и `showStages()`.
|
|
||||||
Как ты мог пропустить оба этих бага, которые видны при простой трассировке CREATE-потока?
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
# Полный код-ревью и вопрос: CREATE пропускает required-параметры
|
|
||||||
|
|
||||||
## Что такое autotest
|
|
||||||
|
|
||||||
Flask 3.0 + gunicorn + vanilla JS. Тестирует Nubes REST API — создаёт/удаляет инстансы облачных сервисов (Redis, PostgreSQL, S3, etc.). Всё через API: POST /instances, POST /instanceOperations, POST /instanceOperationCfsParams, POST /run.
|
|
||||||
|
|
||||||
## Текущий CREATE-флоу (код)
|
|
||||||
|
|
||||||
Файл: `/home/naeel/nubes/autotest/app-autotest/site/routes/api_test.py`, функция `api_test()`, строки 209-225:
|
|
||||||
|
|
||||||
```python
|
|
||||||
display_name = _unique_display_name(client, display_name)
|
|
||||||
descr = f"created by autotest v{current_app.config.get('VERSION', '')}"
|
|
||||||
payload = {"serviceId": svc_id, "displayName": display_name, "descr": descr}
|
|
||||||
resp = client.post("/instances", payload) # шаг 1
|
|
||||||
instance_uid = resp.get("instanceUid") or _find_uid(resp) ...
|
|
||||||
|
|
||||||
op_payload = {"instanceUid": instance_uid, "operation": "create"}
|
|
||||||
op_resp = client.post("/instanceOperations", op_payload) # шаг 2
|
|
||||||
op_uid = _find_uid(op_resp) ...
|
|
||||||
|
|
||||||
tracker_add(...)
|
|
||||||
|
|
||||||
for pid, pval in params.items(): # шаг 3 — ТОЛЬКО пользовательские
|
|
||||||
client.post("/instanceOperationCfsParams",
|
|
||||||
{"instanceOperationUid": op_uid, "svcOperationCfsParamId": int(pid), "paramValue": str(pval)})
|
|
||||||
client.post(f"/instanceOperations/{op_uid}/run") # шаг 4
|
|
||||||
```
|
|
||||||
|
|
||||||
## Эталонный флоу (Terraform-провайдер)
|
|
||||||
|
|
||||||
Файл: `/home/naeel/nubes/autotest/DOCS/terraform-operations-full-logic.md` (скопирован из репы tf_provider)
|
|
||||||
|
|
||||||
```
|
|
||||||
Шаг 1: POST /instances {serviceId, displayName, descr}
|
|
||||||
Шаг 2: POST /instanceOperations {instanceUid, operation:"create"}
|
|
||||||
Шаг 3: GET /instanceOperations/{opUid}?fields=cfsParams ← получить ВСЕ params с defaults
|
|
||||||
Шаг 4: resolveRefSvcParamValues ← резолв refSvcId
|
|
||||||
Шаг 5: POST /instanceOperationCfsParams ← пользовательские params (×N)
|
|
||||||
Шаг 6: POST /instanceOperationCfsParams ← required params с defaultValue, не переданные в шаге 5 (×M) ⬅ НЕТ В AUTOTEST
|
|
||||||
Шаг 7: GET /instanceOperations/{opUid}/validate-cfs
|
|
||||||
Шаг 8: POST /instanceOperations/{opUid}/run {}
|
|
||||||
Шаг 9: поллинг dtFinish
|
|
||||||
```
|
|
||||||
|
|
||||||
## Баг
|
|
||||||
|
|
||||||
PostgreSQL (сервис 90, create opId=19) имеет **8 map-fixed параметров**, все required:
|
|
||||||
- startupConfiguration (id=789)
|
|
||||||
- clusterConfiguration (id=788)
|
|
||||||
- accessConfiguration (id=790)
|
|
||||||
- postgresConfiguration (id=791)
|
|
||||||
- postgresConf (id=792) — array-map-fixed
|
|
||||||
- backupConfiguration (id=793)
|
|
||||||
- autoscaleConfiguration (id=794)
|
|
||||||
- mtlsConfiguration (id=1094)
|
|
||||||
|
|
||||||
Пользователь в UI заполняет первые 3-4. Остальные 4-5 **не отправляются** → Nubes возвращает: «Не передан параметр: serviceInstanceUid».
|
|
||||||
|
|
||||||
Redis (3 параметра), Болванка (все заполняются) — работают.
|
|
||||||
|
|
||||||
## Нужное решение
|
|
||||||
|
|
||||||
Добавить **шаг 6** в CREATE-ветку: после отправки пользовательских `params`, дослать required-параметры с их `defaultValue`, которые не были в пользовательском вводе.
|
|
||||||
|
|
||||||
## Вопросы
|
|
||||||
|
|
||||||
1. **Где брать список required-параметров с defaultValue?**
|
|
||||||
- Вариант А: сделать `GET /instanceOperations/default/{svcOperationId}` (тот же что для /api/params) — там есть все params с `isRequired` и `defaultValue`. Взять те, где `isRequired=True` и `svcOperationCfsParamId` нет в `params.keys()`.
|
|
||||||
- Вариант Б: сделать `GET /instanceOperations/{opUid}?fields=cfsParams` как Terraform (шаг 3) — после создания операции, получить cfsParams от сервера. Но это доп. запрос.
|
|
||||||
- Какой правильнее?
|
|
||||||
|
|
||||||
2. **Как отличить «пользователь заполнил» от «не заполнил»?** Сейчас params приходят из фронтенда как `{"788": "{\"cpu\":\"500\",...}"}`. Если поле не в params.keys() — значит юзер не заполнил. Но что если юзер оставил пустую строку? Достаточно проверки `pid not in params` или нужно проверять значение?
|
|
||||||
|
|
||||||
3. **Нужны ли шаги 3 (GET cfsParams) и 7 (validate)?** Terraform их делает. Без validate-cfs мы пропускаем серверную валидацию до /run. Это критично или нет?
|
|
||||||
|
|
||||||
4. **Не сломает ли это существующие сервисы?** Если добавить досылку required+default для ВСЕХ сервисов — не навредит ли это Redis/Болванке где и так всё работает?
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
# Sonnet 4.6 — Полный анализ: параметризованные операции инстанса + поиск багов
|
|
||||||
|
|
||||||
Дата: 2026-07-30
|
|
||||||
Версия: v1.1.36
|
|
||||||
|
|
||||||
## Контекст
|
|
||||||
|
|
||||||
Flask 3.0 + gunicorn + vanilla JS. Тестирует Nubes REST API.
|
|
||||||
|
|
||||||
PostgreSQL создался успешно. Но при попытке `create_user` — ошибка: параметры не были запрошены у пользователя, отправлен пустой `{}`.
|
|
||||||
|
|
||||||
## 🔴 Проблема 1: runOp() не показывает форму параметров для не-modify операций
|
|
||||||
|
|
||||||
**Файл:** `/home/naeel/nubes/autotest/app-autotest/site/static/app.js`, функция `runOp()`, строка 127:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
function runOp(opName,opId){
|
|
||||||
if(busy) return;
|
|
||||||
stopPoll();
|
|
||||||
selectedOp={opId,opName,svcId:currentSvcId};
|
|
||||||
if(opName==='modify'){
|
|
||||||
showParams(opId,opName); // ← только modify показывает форму
|
|
||||||
}else{
|
|
||||||
// confirm and execute immediately
|
|
||||||
if(!confirm(`Запустить ${opName} для ${findInstName()}?`)) return;
|
|
||||||
executeOp({}); // ← пустые params → API ругается "Missing required parameter"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Проблема:** PostgreSQL имеет много операций с параметрами:
|
|
||||||
- `create_user` — нужен username
|
|
||||||
- `delete_user` — нужен username
|
|
||||||
- `create_database` — нужны параметры БД
|
|
||||||
- `delete_database` — нужны параметры
|
|
||||||
- `restart` — может иметь параметры
|
|
||||||
- `recovery` — параметры восстановления
|
|
||||||
- `create_backup` — параметры бэкапа
|
|
||||||
|
|
||||||
Все они сейчас идут с `executeOp({})` — пустые params.
|
|
||||||
|
|
||||||
**Операции БЕЗ параметров** (можно confirm → execute):
|
|
||||||
- `delete`
|
|
||||||
- `suspend`
|
|
||||||
- `resume`
|
|
||||||
- `redeploy`
|
|
||||||
- `reconcile`
|
|
||||||
|
|
||||||
**Вопрос:** Как универсально определить, нужны ли параметры для операции?
|
|
||||||
- Вариант А: проверять `cfsParams` в `/api/operations/{svcId}` — если есть params → показать форму
|
|
||||||
- Вариант Б: список известных "безпараметровых" операций (delete/suspend/resume/redeploy/reconcile) — для них confirm, для остальных — showParams
|
|
||||||
- Вариант В: всегда показывать форму (showParams сам разберётся если params пустые)
|
|
||||||
|
|
||||||
## 🔴 Проблема 2: refreshInstances() ДОЛЖЕН обновлять список инстансов после create_user и подобных
|
|
||||||
|
|
||||||
После `create_user` инстанс не меняется, но список инстансов всё равно надо обновить (в UI могут быть связанные изменения). Сейчас `refreshInstances()` вызывается только при `sd.status === 'OK'`.
|
|
||||||
|
|
||||||
**Файл:** `/home/naeel/nubes/autotest/app-autotest/site/static/app.js`, executeOp polling (строка ~357):
|
|
||||||
```javascript
|
|
||||||
if(sd.status==='OK'){
|
|
||||||
await refreshInstances(); // только при OK
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Вопрос:** Нужно ли вызывать refreshInstances при любом завершении (OK или FAIL)?
|
|
||||||
|
|
||||||
## Полный аудит всех оставшихся проблем
|
|
||||||
|
|
||||||
Пожалуйста, прочитай ВСЕ файлы и найди ЛЮБЫЕ оставшиеся баги или неустойчивости:
|
|
||||||
|
|
||||||
**Файлы для проверки:**
|
|
||||||
- `/home/naeel/nubes/autotest/app-autotest/site/static/app.js` — весь фронтенд
|
|
||||||
- `/home/naeel/nubes/autotest/app-autotest/site/routes/api_test.py` — бэкенд
|
|
||||||
- `/home/naeel/nubes/autotest/app-autotest/site/routes/main.py` — главная + /api/operations
|
|
||||||
- `/home/naeel/nubes/autotest/app-autotest/site/api/http_client.py` — HTTP-клиент
|
|
||||||
- `/home/naeel/nubes/autotest/app-autotest/site/operations/get_params.py` — параметры
|
|
||||||
- `/home/naeel/nubes/autotest/app-autotest/site/templates/index.html` — HTML шаблон
|
|
||||||
|
|
||||||
**Что искать:**
|
|
||||||
1. Все места где параметры операций не запрашиваются у пользователя
|
|
||||||
2. Все места где busy lock может застрять
|
|
||||||
3. Необработанные ошибки (пустые catch, пропущенные исключения)
|
|
||||||
4. Несоответствия между JS и бэкендом (разные имена полей, форматы)
|
|
||||||
5. Проблемы с `currentSvcShort` — где объявлена, где используется
|
|
||||||
6. Любые другие баги которые мы пропустили за 35+ версий
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
# ⚠️ LEGACY — НЕАКТУАЛЬНО. Исторический документ.
|
|
||||||
|
|
||||||
# Вопрос к Sonnet — архитектура трекера инстансов + UI кнопок
|
|
||||||
|
|
||||||
Отправлено: 27.07.2026
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Ты — senior архитектор. Разбери проблему в Flask-приложении на Nubes pythonk8s (gunicorn, 1 worker).
|
|
||||||
|
|
||||||
## Контекст
|
|
||||||
|
|
||||||
Приложение app-autotest — веб-интерфейс для автотестов операций облачных сервисов Nubes.
|
|
||||||
Репозиторий: https://gitea.services.ngcloud.ru/forcloud/app-autotest.git
|
|
||||||
Версия: v1.0.47
|
|
||||||
|
|
||||||
## Файлы для изучения
|
|
||||||
|
|
||||||
| Файл | Назначение |
|
|
||||||
|------|-----------|
|
|
||||||
| `site/app.py` | Точка входа Flask, VERSION, blueprints |
|
|
||||||
| `site/routes/api_test.py` | POST /api/test (запуск), GET /api/test/status (поллинг), _finish_op (фоновый поток) |
|
|
||||||
| `site/routes/main.py` | Главная страница, /api/operations/<svcId> (список инстансов из трекера) |
|
|
||||||
| `site/operations/tracker.py` | Запись/чтение /tmp/instances.json (_INITIAL, add, remove, list_all) |
|
|
||||||
| `site/templates/index.html` | UI: executeOp(), showStages(), поллинг, refreshInstances() |
|
|
||||||
| `site/api/http_client.py` | HTTP-клиент (Bearer auth, User-Agent) |
|
|
||||||
|
|
||||||
Полные пути в репозитории:
|
|
||||||
```
|
|
||||||
app-autotest/site/app.py
|
|
||||||
app-autotest/site/routes/api_test.py
|
|
||||||
app-autotest/site/routes/main.py
|
|
||||||
app-autotest/site/operations/tracker.py
|
|
||||||
app-autotest/site/templates/index.html
|
|
||||||
app-autotest/site/api/http_client.py
|
|
||||||
```
|
|
||||||
|
|
||||||
## Поток CREATE (как работает сейчас)
|
|
||||||
|
|
||||||
1. UI: пользователь жмёт «Создать» → showParams(18, 'create') → заполняет форму → executeOp(params)
|
|
||||||
2. UI отправляет POST /api/test `{serviceId:1, operation:"create", svcOperationId:18, displayName, params, instanceUid:""}`
|
|
||||||
3. Бэкенд api_test():
|
|
||||||
- `client.post("/instances", {serviceId, displayName})` → получает instanceUid
|
|
||||||
- `client.post("/instanceOperations", {instanceUid, operation:"create"})` → получает opUid
|
|
||||||
- `client.post("/instanceOperationCfsParams", ...)` — для каждого параметра
|
|
||||||
- `client.post("/instanceOperations/{opUid}/run")` — запуск
|
|
||||||
- `threading.Thread(target=_finish_op, args=(client, opUid, instanceUid, ...), daemon=True).start()`
|
|
||||||
- возвращает `{status:"RUNNING", opUid, instanceUid}`
|
|
||||||
4. UI начинает поллинг: `GET /api/test/status/<opUid>` каждые 2 секунды
|
|
||||||
5. _finish_op (фоновый daemon-поток):
|
|
||||||
- поллит `GET /instanceOperations/{opUid}?fields=dtFinish,isSuccessful,errorLog,isInProgress,duration,stages`
|
|
||||||
- обновляет `_op_results[opUid]` (in-memory dict)
|
|
||||||
- когда `dtFinish != null` и `isSuccessful == true`:
|
|
||||||
- **вызывает tracker_add(instanceUid, svcId, displayName)** ← ПРОБЛЕМА ЗДЕСЬ
|
|
||||||
- устанавливает `_op_results[opUid] = {status:"OK", stages, duration}`
|
|
||||||
- таймаут 300 секунд
|
|
||||||
6. UI при status=="OK": вызывает `selectService(SVC_ID)` → `GET /api/operations/1` → читает tracker → показывает список
|
|
||||||
|
|
||||||
## ПРОБЛЕМА
|
|
||||||
|
|
||||||
Инстанс создаётся в Nubes (виден в UI платформы), НО в списке инстансов приложения НЕ появляется.
|
|
||||||
`tracker_add` не вызывается → `/tmp/instances.json` не обновляется → инстанс не в списке.
|
|
||||||
|
|
||||||
**Происходило 2 раза подряд** (v1.0.46 и v1.0.47). Оба раза инстанс в Nubes есть, в трекере — нет.
|
|
||||||
|
|
||||||
## Моя гипотеза
|
|
||||||
|
|
||||||
tracker_add вызывается внутри daemon-потока _finish_op. В gunicorn:
|
|
||||||
- Воркер может быть перезапущен платформой в любой момент
|
|
||||||
- Daemon-потоки умирают вместе с воркером — молча, без логов
|
|
||||||
- Python не пишет traceback при смерти daemon-потока
|
|
||||||
|
|
||||||
## Текущий код _finish_op (api_test.py, строки 153-195)
|
|
||||||
|
|
||||||
```python
|
|
||||||
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 ""
|
|
||||||
# Сначала трекер — чтобы UI при poll уже видел инстанс
|
|
||||||
if is_ok:
|
|
||||||
if is_create:
|
|
||||||
tracker_add(instance_uid, svc_id, display_name)
|
|
||||||
elif 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)}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Вопросы
|
|
||||||
|
|
||||||
### 1. Где вызывать tracker_add?
|
|
||||||
|
|
||||||
Вариант А: синхронно в api_test(), сразу после получения instanceUid от API (шаг 3), до запуска потока.
|
|
||||||
- Плюс: гарантированная запись, инстанс в трекере мгновенно
|
|
||||||
- Минус: если операция потом упадёт — в трекере «мусорный» инстанс (но это лучше чем отсутствие)
|
|
||||||
|
|
||||||
Вариант Б: subprocess.Popen вместо threading.Thread.
|
|
||||||
- Плюс: процесс живёт независимо от gunicorn worker
|
|
||||||
- Минус: сложнее, нужен IPC для _op_results
|
|
||||||
|
|
||||||
Вариант В: sqlite3 с WAL-режимом.
|
|
||||||
- Плюс: атомарная запись, конкурентный доступ
|
|
||||||
- Минус: без persistent volume данные теряются при редеплое (как и /tmp/)
|
|
||||||
|
|
||||||
### 2. Что делать с _op_results?
|
|
||||||
|
|
||||||
Сейчас это module-level dict. При нескольких gunicorn workers — каждый worker имеет свой dict. Нужен ли переход на sqlite/file-based storage для _op_results?
|
|
||||||
|
|
||||||
### 3. Надёжность под gunicorn
|
|
||||||
|
|
||||||
Как правильно организовать фоновую работу под gunicorn на pythonk8s (1 worker, нет PV, нет Redis/RabbitMQ)?
|
|
||||||
|
|
||||||
## Дополнительно: UI кнопок
|
|
||||||
|
|
||||||
Нужен CSS чтобы кнопки операций шли горизонтально в 1-2 ряда, как в Nubes UI:
|
|
||||||
|
|
||||||
```
|
|
||||||
delete | modify | suspend | resume | redeploy | reconcile
|
|
||||||
```
|
|
||||||
|
|
||||||
Сейчас они в vertical списке внутри `div.inst-ops`. Предложи стиль.
|
|
||||||
|
|
||||||
## Ограничения платформы
|
|
||||||
|
|
||||||
- `site/` — НЕ пакет (без __init__.py, конфликт с stdlib site.py)
|
|
||||||
- Импорты без префикса site.: `from api.http_client import ...`
|
|
||||||
- `app.run(host="0.0.0.0", port=5000)` — обязательно
|
|
||||||
- Gunicorn запускается платформой, предположительно 1 worker
|
|
||||||
- Нет persistent volume — данные в /tmp/ теряются при редеплое
|
|
||||||
- Деплой: git push → managed service подхватывает → редеплой
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
# Sonnet 4.6 — Финальный аудит
|
|
||||||
|
|
||||||
Дата: 2026-07-30
|
|
||||||
|
|
||||||
## 5 багов в app.js
|
|
||||||
|
|
||||||
| # | Баг | Строка |
|
|
||||||
|---|-----|--------|
|
|
||||||
| 🔴1 | `d.operations.filter` без null-guard | toggleInstance |
|
|
||||||
| 🔴2 | toggleInstance без try/catch | toggleInstance |
|
|
||||||
| 🔴3 | selectService без try/catch | selectService |
|
|
||||||
| 🔴4 | refreshInstances без try/catch | refreshInstances |
|
|
||||||
| 🟡5 | CMDB OK + refreshInstances → ошибка перезаписывает успех | executeOp |
|
|
||||||
|
|
||||||
## redeploy без _send_params_terraform
|
|
||||||
Намеренно — redeploy переиспользует текущие params из state.
|
|
||||||
|
|
||||||
## Бэкенд чист
|
|
||||||
api_test.py, main.py, get_params.py, http_client.py — багов нет.
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
# Sonnet 4.6 — Параметры операций + аудит багов
|
|
||||||
|
|
||||||
Дата: 2026-07-30
|
|
||||||
|
|
||||||
## 🔴 Баг 1: runOp() — форма только для modify
|
|
||||||
app.js:127 — все кроме modify идут с executeOp({}). create_user, delete_user и др. получают пустые params.
|
|
||||||
|
|
||||||
**Решение (Вариант C):** Всегда showParams. Если params пустые — показать заголовок + кнопку без полей.
|
|
||||||
|
|
||||||
## 🔴 Баг 2: CMDB-delete не чистит трекер
|
|
||||||
api_test.py — после успешного CMDB инстанс в трекере → показывается как "creating" вечно.
|
|
||||||
|
|
||||||
## 🟠 Баг 3: Сетевая ошибка в поллинге → busy навсегда
|
|
||||||
app.js — fetch в setInterval без try/catch → silent reject → busy stuck.
|
|
||||||
|
|
||||||
## 🟡 Баг 4: refreshInstances только при OK
|
|
||||||
app.js:357 — после FAIL список не обновляется.
|
|
||||||
|
|
||||||
## 🟢 Баг 5: currentSvcId=1 хардкод
|
|
||||||
Первый сервис ≠ 1 → рассинхрон UI и данных.
|
|
||||||
|
|
||||||
## 🟢 Баг 6: detect_endpoint ×3-4 на запрос
|
|
||||||
auth.py — кэшировать через flask.g.
|
|
||||||
|
|
||||||
## Приоритет
|
|
||||||
1. Баг 2 — 1 строка
|
|
||||||
2. Баг 1 — основная задача (форма для всех операций)
|
|
||||||
3. Баг 3 — busy stuck
|
|
||||||
4. Баг 4 — одна строка
|
|
||||||
5. Баги 5-6 — отдельно
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
# Sonnet 4.6 — Round 2 Response
|
|
||||||
|
|
||||||
Дата: 2026-07-29
|
|
||||||
|
|
||||||
## Ответы
|
|
||||||
|
|
||||||
### 1. Фильтр serviceId ✅
|
|
||||||
Добавить `if i.get("serviceId") != svc_id: continue` — безопасно.
|
|
||||||
Tracker-fallback уже отфильтрован по svc_id, не сломается.
|
|
||||||
Даже улучшит дедупликацию cloud_names.
|
|
||||||
|
|
||||||
### 2. Генерация 3 символов
|
|
||||||
Надёжнее: `(Math.random() * 46656 | 0).toString(36).padStart(3, '0')`
|
|
||||||
Ровно 3 символа, равномерно по 46 656 комбинациям.
|
|
||||||
|
|
||||||
### 3. descr
|
|
||||||
Не нужно тащить через JS! `app_version` уже есть в той же функции.
|
|
||||||
Одна строка: `"descr": f"created by autotest v{app_version}"`
|
|
||||||
|
|
||||||
### 4. Пропущенный баг: stand-ключ трекера
|
|
||||||
main.py → `stand_name(endpoint)`
|
|
||||||
api_test.py → `get_stand()`
|
|
||||||
Могут разойтись → tracker_add и tracker_list под разными ключами → creating инстансы не видны.
|
|
||||||
|
|
||||||
### 5. Дубликат эндпоинта
|
|
||||||
Удалить `/api/operations/{svc_id}` из api_test.py (строки 108-148).
|
|
||||||
Недостижим, старый код, техдолг.
|
|
||||||
|
|
||||||
## Итоговый план исправлений
|
|
||||||
|
|
||||||
| # | Файл | Что |
|
|
||||||
|---|------|-----|
|
|
||||||
| 1 | main.py:194 | `if i.get("serviceId") != svc_id: continue` |
|
|
||||||
| 2 | main.py:227 | `"svcShort": detail.get("svcShort", "")` |
|
|
||||||
| 3 | app.js:53 | `currentSvcShort = d.svcShort\|\|''` |
|
|
||||||
| 4 | app.js:113-116 | Переписать `makeCreateDisplayName()` |
|
|
||||||
| 5 | api_test.py:212 | `f"created by autotest v{app_version}"` |
|
|
||||||
| 6 | api_test.py:108-148 | Удалить дубликат эндпоинта |
|
|
||||||
| 7 | main.py + api_test.py | Унифицировать stand |
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
# Sonnet 4.6 — Root Cause: serviceInstanceUid
|
|
||||||
|
|
||||||
Дата: 2026-07-29
|
|
||||||
|
|
||||||
## Найдена корневая причина
|
|
||||||
|
|
||||||
**Разница в источнике данных для шага 6 (required+default params):**
|
|
||||||
|
|
||||||
| | Terraform | Autotest |
|
|
||||||
|---|---|---|
|
|
||||||
| Источник | `GET /instanceOperations/{opUid}?fields=cfsParams` — **реальная операция** | `GET /instanceOperations/default/{svc_op_id}` — **шаблон** |
|
|
||||||
| Данные | `paramValue` (автоматически заполнен Nubes) + `defaultValue` | Только `defaultValue` |
|
|
||||||
|
|
||||||
`serviceInstanceUid` — параметр, который Nubes **автоматически добавляет** в реальную операцию (шаг 3 Terraform). В шаблоне его НЕТ. Поэтому наш код его не видит → не отправляет → validate-cfs падает.
|
|
||||||
|
|
||||||
## Исправление
|
|
||||||
|
|
||||||
В `api_test.py` CREATE-ветка (строка ~210): заменить
|
|
||||||
```python
|
|
||||||
tmpl = client.get(f"/instanceOperations/default/{svc_op_id}")
|
|
||||||
```
|
|
||||||
на
|
|
||||||
```python
|
|
||||||
op_details = client.get(f"/instanceOperations/{op_uid}?fields=cfsParams")
|
|
||||||
```
|
|
||||||
И брать `paramValue` первым, `defaultValue` как fallback — как Terraform.
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
# Sonnet 4.6 — state.out valueList fix
|
|
||||||
|
|
||||||
Дата: 2026-07-30
|
|
||||||
|
|
||||||
## Ответ
|
|
||||||
|
|
||||||
### Решение верное, но с ошибкой
|
|
||||||
- `"user" in code` → `state_out["users"]` ✅
|
|
||||||
- `"owner" in code` → `state_out["users"]` ✅ (НЕ databases!)
|
|
||||||
- `"db" in code` → `state_out["databases"]` ✅
|
|
||||||
|
|
||||||
### Попутный баг
|
|
||||||
MERGED-путь (get_params.py) не нормализует `valueList` через `_normalize_value_list`. DEFAULT-путь (api_test.py) — нормализует.
|
|
||||||
|
|
||||||
## План (2 изменения в get_params.py)
|
|
||||||
|
|
||||||
1. Читать `state_out`, заполнять пустые `valueList`: user/owner→users, db→databases
|
|
||||||
2. Нормализовать `value_list` через `_normalize_value_list()`
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
# Sonnet 4.6 — Code Review v1.1.11
|
|
||||||
|
|
||||||
Дата: 2026-07-29
|
|
||||||
Модель: Claude Sonnet 4.6
|
|
||||||
|
|
||||||
## Ответ
|
|
||||||
|
|
||||||
### 🔴 Критично: NameError в api_test.py
|
|
||||||
|
|
||||||
Строки 159, 178, 180 вызывают:
|
|
||||||
- `get_params_with_current_values(...)`
|
|
||||||
- `_normalize_value_list(...)`
|
|
||||||
|
|
||||||
Оба символа определены в `operations/get_params.py`, но **не импортированы** в `routes/api_test.py`.
|
|
||||||
|
|
||||||
Цепочка падения:
|
|
||||||
1. `startCreate()` → `fetch('/api/params/{opId}')`
|
|
||||||
2. Сервер: `_normalize_value_list(p.get("valueList"))` → `NameError`
|
|
||||||
3. `except` → `{"error": "name '_normalize_value_list' is not defined"}`, HTTP 500
|
|
||||||
4. JS: `data.params` = undefined → fallback к `data` = `{"error": "..."}`
|
|
||||||
5. `params.map(...)` → `TypeError: params.map is not a function`
|
|
||||||
6. Нет `.catch()` → промис падает молча
|
|
||||||
|
|
||||||
Также сломаны MODIFY-запросы (строка 159 — `get_params_with_current_values`).
|
|
||||||
|
|
||||||
**Фикс:** одна строка импорта.
|
|
||||||
|
|
||||||
### 🟡 Вторичные баги в JS
|
|
||||||
|
|
||||||
1. `showParams()` — нет `.catch()` на fetch-цепочке → ошибки скрыты
|
|
||||||
2. `data.params||data` без `Array.isArray()` → TypeError при ошибке API
|
|
||||||
|
|
||||||
### ⚪ Косметика
|
|
||||||
|
|
||||||
`create-btn-area` после `params-area` в DOM — неудобно, но не ломает.
|
|
||||||
|
|
||||||
## Действия
|
|
||||||
|
|
||||||
- [x] Добавить импорт в api_test.py
|
|
||||||
- [x] Исправить клиентский код
|
|
||||||
- [x] Добавить `.catch()` в showParams
|
|
||||||
- [x] Проверить Array.isArray перед .map()
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
# Sonnet 4.6 — Round 2: оставшиеся баги и план исправлений
|
|
||||||
|
|
||||||
Дата: 2026-07-29
|
|
||||||
Контекст: v1.1.12 уже починил NameError и JS error handling. Сейчас v1.1.13 (мелкие UI-фиксы).
|
|
||||||
|
|
||||||
## Баги к исправлению
|
|
||||||
|
|
||||||
### 🔴 Баг 1: Инстансы всех сервисов показываются под любым выбранным
|
|
||||||
|
|
||||||
**Файл:** `/home/naeel/nubes/autotest/app-autotest/site/routes/main.py`, строка 193-194, функция `api_operations(svc_id)`
|
|
||||||
|
|
||||||
```python
|
|
||||||
for i in instances:
|
|
||||||
display_name = str(i.get("displayName", "") or "")
|
|
||||||
if not display_name.startswith(AUTOTEST_PREFIX):
|
|
||||||
continue # не наш инстанс
|
|
||||||
# ← НЕТ проверки i.get("serviceId") != svc_id
|
|
||||||
```
|
|
||||||
|
|
||||||
Фильтр только по префиксу `autotest-`, без проверки что `instance.serviceId == svc_id`.
|
|
||||||
Результат: под сервисом «Container Registry» показываются S3 бакеты, под «Redis» — Flask инстансы.
|
|
||||||
|
|
||||||
**Предлагаемый фикс:** добавить `if i.get("serviceId") != svc_id: continue`
|
|
||||||
|
|
||||||
### 🟡 Задача 2: displayName → autotest-<svcShort>-<3rand>
|
|
||||||
|
|
||||||
Сейчас: `autotest-ms4gzydq` (timestamp base36)
|
|
||||||
Нужно: `autotest-redis-x7k`
|
|
||||||
|
|
||||||
**Данные:** API `/services/{id}` возвращает `svcShort=redis`
|
|
||||||
|
|
||||||
**План:**
|
|
||||||
1. `/home/naeel/nubes/autotest/app-autotest/site/routes/main.py` `/api/operations/{svc_id}` — добавить `svcShort` в JSON-ответ
|
|
||||||
2. `/home/naeel/nubes/autotest/app-autotest/site/static/app.js` `selectService()` — сохранить `currentSvcShort = d.svcShort`
|
|
||||||
3. `/home/naeel/nubes/autotest/app-autotest/site/static/app.js` `makeCreateDisplayName()` — `autotest-${currentSvcShort}-${3rand}`
|
|
||||||
4. Коллизии обрабатываются в `_unique_display_name()` на бэкенде
|
|
||||||
|
|
||||||
### 🟡 Задача 3: description (descr) при создании
|
|
||||||
|
|
||||||
HAR-файл (`development/dummycreate.har`) показывает что Nubes API принимает `descr`:
|
|
||||||
```json
|
|
||||||
POST /instances
|
|
||||||
{"serviceId":1, "displayName":"dummy-11255555", "descr":""}
|
|
||||||
```
|
|
||||||
|
|
||||||
В autotest `/home/naeel/nubes/autotest/app-autotest/site/routes/api_test.py` строка 212 уже шлёт `"descr": ""` — пустое. Нужно заполнить:
|
|
||||||
```
|
|
||||||
"descr": "created by autotest v1.1.13"
|
|
||||||
```
|
|
||||||
|
|
||||||
**План:**
|
|
||||||
1. `/home/naeel/nubes/autotest/app-autotest/site/static/app.js` `executeOp()` — передать `descr` в тело запроса
|
|
||||||
2. `/home/naeel/nubes/autotest/app-autotest/site/routes/api_test.py` `/api/test` — принять `descr` из JSON, подставить в payload
|
|
||||||
|
|
||||||
## Вопросы к Sonnet
|
|
||||||
|
|
||||||
1. Верен ли план для бага 1? Не сломает ли фильтр по serviceId tracker-fallback логику?
|
|
||||||
2. Для задачи 2: как лучше генерировать 3 случайных символа? `Math.random().toString(36).slice(2,5)`? Не будет ли коллизий?
|
|
||||||
3. Для задачи 3: `descr` уже есть в payload. Достаточно ли просто подставить строку, или нужно что-то ещё?
|
|
||||||
4. Есть ли ещё какие-то баги, которые мы упустили?
|
|
||||||
5. `/home/naeel/nubes/autotest/app-autotest/site/routes/api_test.py` содержит ДУБЛИКАТ эндпоинта `/api/operations/{svc_id}` (строка 108-148), который никогда не вызывается из-за порядка регистрации blueprint'ов. Удалить ли его, или он нужен для чего-то?
|
|
||||||
|
|
||||||
**Файлы для проверки (только эти):**
|
|
||||||
- `/home/naeel/nubes/autotest/app-autotest/site/routes/main.py` (строки 155-230)
|
|
||||||
- `/home/naeel/nubes/autotest/app-autotest/site/routes/api_test.py` (строки 108-148, 195-280)
|
|
||||||
- `/home/naeel/nubes/autotest/app-autotest/site/static/app.js` (строки 85-130, 240-270)
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
# Sonnet 4.6 — Round 3: missing required params in CREATE
|
|
||||||
|
|
||||||
Дата: 2026-07-29
|
|
||||||
Версия: v1.1.18
|
|
||||||
|
|
||||||
## Проблема
|
|
||||||
PostgreSQL create падает с ошибкой Nubes: «Не передан параметр: serviceInstanceUid».
|
|
||||||
Redis работает, Болванка работает, PG — нет.
|
|
||||||
|
|
||||||
## Что нашли
|
|
||||||
Terraform-провайдер делает дополнительный шаг после отправки пользовательских параметров:
|
|
||||||
отправляет ВСЕ required-параметры с их defaultValue, даже если юзер их не заполнял.
|
|
||||||
|
|
||||||
Наш код (api_test.py /api/test, CREATE-ветка, примерно строка 230):
|
|
||||||
```python
|
|
||||||
for pid, pval in params.items():
|
|
||||||
client.post("/instanceOperationCfsParams",
|
|
||||||
{"instanceOperationUid": op_uid, "svcOperationCfsParamId": int(pid), "paramValue": str(pval)})
|
|
||||||
```
|
|
||||||
Шлёт ТОЛЬКО то, что пользователь заполнил в форме.
|
|
||||||
|
|
||||||
## PG имеет 8 map-fixed параметров, все required:
|
|
||||||
- startupConfiguration (id=789)
|
|
||||||
- clusterConfiguration (id=788)
|
|
||||||
- accessConfiguration (id=790)
|
|
||||||
- postgresConfiguration (id=791)
|
|
||||||
- postgresConf (id=792) — array-map-fixed
|
|
||||||
- backupConfiguration (id=793)
|
|
||||||
- autoscaleConfiguration (id=794)
|
|
||||||
- mtlsConfiguration (id=1094)
|
|
||||||
|
|
||||||
Если пользователь заполнил только первые 3 — остальные 5 НЕ отправляются → Nubes ругается.
|
|
||||||
|
|
||||||
## Вопросы
|
|
||||||
1. Как лучше реализовать досылку required-параметров с defaultValue?
|
|
||||||
- Вариант А: до отправки params сделать GET /instanceOperations/default/{svcOpId}, взять все required с их defaultValue, смержить с пользовательскими
|
|
||||||
- Вариант Б: после отправки пользовательских params, дослать оставшиеся required с defaultValue
|
|
||||||
2. Terraform делает GET /instanceOperations/{opUid}?fields=cfsParams ПОСЛЕ создания операции. Нужно ли нам тоже?
|
|
||||||
3. Не сломает ли это существующие сервисы (Redis, Болванка) где параметров мало и все заполняются?
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
# Sonnet Code Review — app-autotest v1.1.11
|
|
||||||
|
|
||||||
## Контекст
|
|
||||||
Flask 3.0 + gunicorn (multi-worker) + vanilla JS фронтенд. Тестирует Nubes API (создание/удаление инстансов).
|
|
||||||
|
|
||||||
## Файлы для проверки (только эти, не надо лазить по всей репе)
|
|
||||||
|
|
||||||
1. **Фронтенд (основной баг!)**
|
|
||||||
- `/home/naeel/nubes/autotest/app-autotest/site/static/app.js` — ВСЁ
|
|
||||||
- `/home/naeel/nubes/autotest/app-autotest/site/templates/index.html` — структура DOM
|
|
||||||
|
|
||||||
2. **Бэкенд**
|
|
||||||
- `/home/naeel/nubes/autotest/app-autotest/site/routes/api_test.py` — эндпоинты /api/params, /api/test
|
|
||||||
- `/home/naeel/nubes/autotest/app-autotest/site/routes/main.py` — эндпоинт /api/operations/<svc_id>
|
|
||||||
- `/home/naeel/nubes/autotest/app-autotest/site/operations/get_params.py` — _normalize_value_list
|
|
||||||
|
|
||||||
3. **API-документация (для понимания что отдаёт Nubes)**
|
|
||||||
- `/home/naeel/nubes/autotest/DOCS/api-access.md`
|
|
||||||
- `/home/naeel/nubes/autotest/DOCS/api-create-flow.md`
|
|
||||||
|
|
||||||
## Конкретный баг
|
|
||||||
Кнопка "+ Создать новый инстанс" (функция `startCreate()` в app.js) не работает:
|
|
||||||
- При клике — `params-area` открывается пустой (display:block)
|
|
||||||
- Загрузка параметров молча не происходит
|
|
||||||
- Кнопка остаётся на месте, юзер думает что ничего не случилось
|
|
||||||
|
|
||||||
## Что нужно от Sonnet
|
|
||||||
1. Найти КОНКРЕТНУЮ причину почему `showParams` не рендерит форму. Проверить ВСЕ пути выполнения:
|
|
||||||
- `startCreate()` → fetch /api/operations/{svcId} → find create op → showParams(opId,'create')
|
|
||||||
- `showParams()` → fetch /api/params/{opId} → рендер createHeader + form
|
|
||||||
2. Нет ли race condition: `currentSvcName` может быть не установлен?
|
|
||||||
3. Нет ли ошибки в `params.map()` если API возвращает ошибку вместо массива?
|
|
||||||
4. `create-btn-area` сейчас ПОСЛЕ `params-area` в DOM — это нормально?
|
|
||||||
|
|
||||||
## Версия для справки
|
|
||||||
v1.1.11 — сегодняшние изменения: нормализация valueList CSV→array для вложенных dataDescriptor параметров.
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
# Sonnet 4.6 — valueList из state.out для внутренних операций инстанса
|
|
||||||
|
|
||||||
Дата: 2026-07-30
|
|
||||||
Версия: v1.1.38
|
|
||||||
|
|
||||||
## Проблема
|
|
||||||
|
|
||||||
PostgreSQL создан. `create_user` отработал успешно (HTTP 201). Но при вызове `delete_user` — поле `username` показывает пустой выпадающий список (`valueList=[]`). Созданный юзер `uuu8` не виден.
|
|
||||||
|
|
||||||
## Что нашли
|
|
||||||
|
|
||||||
**GET /instances/{uid}** возвращает `state.out` с актуальными данными:
|
|
||||||
|
|
||||||
```json
|
|
||||||
"state": {
|
|
||||||
"out": {
|
|
||||||
"users": {"uuu8": {"username": "uuu8", "role": "ddl_user", "rights": ["createdb", "createrole"]}},
|
|
||||||
"databases": {},
|
|
||||||
"backups": [],
|
|
||||||
...
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Шаблон** (`/instanceOperations/default/244` — delete_user) возвращает `valueList=[]`.
|
|
||||||
|
|
||||||
**Текущий код** — файл `/home/naeel/nubes/autotest/app-autotest/site/operations/get_params.py`
|
|
||||||
|
|
||||||
```python
|
|
||||||
def get_params_with_current_values(client, op_id, instance_uid):
|
|
||||||
inst_data = client.get(f"/instances/{instance_uid}")
|
|
||||||
state_params = inst_data.get("instance", {}).get("state", {}).get("params", {}) or {}
|
|
||||||
|
|
||||||
tmpl_data = client.get(f"/instanceOperations/default/{op_id}")
|
|
||||||
tmpl_params = tmpl_data.get("svcOperation", {}).get("cfsParams", []) or []
|
|
||||||
|
|
||||||
for p in tmpl_params:
|
|
||||||
...
|
|
||||||
result.append({
|
|
||||||
...
|
|
||||||
"valueList": value_list, # ← из шаблона, всегда []
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
Читает `state.params` (конфигурация кластера), но НЕ читает `state.out` (пользователи, базы).
|
|
||||||
|
|
||||||
## Предлагаемое решение
|
|
||||||
|
|
||||||
После получения `inst_data`, извлечь `state.out`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
state_out = inst_data.get("instance", {}).get("state", {}).get("out", {}) or {}
|
|
||||||
```
|
|
||||||
|
|
||||||
В цикле слияния, если `valueList` пустой — попробовать заполнить из `state.out`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
value_list = p.get("valueList")
|
|
||||||
# Заполнить пустые valueList из state.out
|
|
||||||
if value_list is not None and not value_list:
|
|
||||||
code = p.get("svcOperationCfsParam", "").lower()
|
|
||||||
if "user" in code and isinstance(state_out.get("users"), dict):
|
|
||||||
value_list = sorted(state_out["users"].keys())
|
|
||||||
elif ("db" in code or "owner" in code) and isinstance(state_out.get("databases"), dict):
|
|
||||||
value_list = sorted(state_out["databases"].keys())
|
|
||||||
```
|
|
||||||
|
|
||||||
## Вопросы
|
|
||||||
|
|
||||||
1. **Правильный ли подход?** Смотреть в `state.out.users` / `state.out.databases` для заполнения пустых valueList? Или есть другой источник?
|
|
||||||
|
|
||||||
2. **Какие ещё поля из state.out могут понадобиться?** Для других сервисов (MongoDB, Redis, Kafka) — есть ли там аналогичные подресурсы?
|
|
||||||
|
|
||||||
3. **Универсальность:** Достаточно ли проверки по имени параметра (`"user" in code`, `"db" in code`)? Или нужен более общий механизм (например, проверять все ключи state.out и матчить по имени)?
|
|
||||||
|
|
||||||
4. **Кеширование:** `get_params_with_current_values` вызывается при каждом открытии формы. GET /instances/{uid} — дорогой запрос. Стоит ли кешировать state.out?
|
|
||||||
|
|
||||||
5. **Есть ли другие баги** в этой же области, которые мы упустили?
|
|
||||||
|
|
||||||
## Файлы для анализа
|
|
||||||
|
|
||||||
- `/home/naeel/nubes/autotest/app-autotest/site/operations/get_params.py` — основной файл
|
|
||||||
- `/home/naeel/nubes/autotest/app-autotest/site/routes/api_test.py` — api_params (строка 120-155)
|
|
||||||
- `/home/naeel/nubes/autotest/app-autotest/site/static/app.js` — showParams (строка 145-260)
|
|
||||||
- Пример API-ответа: `curl -H "Authorization: Bearer $TOKEN" /instances/0a42bef3-7ce1-42b7-aa80-4274c4d9568f`
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
# Sonnet 4.6 — СВЕРКА С ТЕРРАФОРМОМ: почему autotest не работает а Terraform работает
|
|
||||||
|
|
||||||
## СУТЬ ПРОБЛЕМЫ
|
|
||||||
|
|
||||||
Terraform-провайдер создаёт PostgreSQL БЕЗ ошибок. Autotest (наш код) — падает с «Не передан параметр: serviceInstanceUid». Мы скопировали «похожую» логику, но где-то расхождение. Нужно найти ВСЕ расхождения, пошагово.
|
|
||||||
|
|
||||||
## ЭТАЛОН — Terraform CREATE flow
|
|
||||||
|
|
||||||
Файл: /home/naeel/nubes/autotest/DOCS/terraform-operations-full-logic.md (читай ВЕСЬ, особенно строки 30-90)
|
|
||||||
|
|
||||||
```
|
|
||||||
Шаг 1: POST /instances {serviceId, displayName, descr}
|
|
||||||
Шаг 2: POST /instanceOperations {instanceUid, operation:"create"}
|
|
||||||
Шаг 3: GET /instanceOperations/{opUid}?fields=cfsParams ← получить ВСЕ params с defaults
|
|
||||||
Шаг 4: resolveRefSvcParamValues ← резолв refSvcId параметров
|
|
||||||
Шаг 5: POST /instanceOperationCfsParams ← пользовательские params (×N)
|
|
||||||
Шаг 6: POST /instanceOperationCfsParams ← required params с defaultValue, НЕ переданные в шаге 5 (×M)
|
|
||||||
Шаг 7: GET /instanceOperations/{opUid}/validate-cfs
|
|
||||||
Шаг 8: POST /instanceOperations/{opUid}/run {}
|
|
||||||
Шаг 9: поллинг dtFinish
|
|
||||||
```
|
|
||||||
|
|
||||||
## НАШ КОД — CREATE flow
|
|
||||||
|
|
||||||
Файл: /home/naeel/nubes/autotest/app-autotest/site/routes/api_test.py, функция api_test(), строки 170-245
|
|
||||||
|
|
||||||
Прочитай ВЕСЬ файл, особенно:
|
|
||||||
- Строки 170-195 — создание instance + operation
|
|
||||||
- Строки 207-245 — отправка params + досылка required+default + validate + run
|
|
||||||
|
|
||||||
## HAR-файл — реальные API-вызовы Terraform
|
|
||||||
|
|
||||||
Файл: /home/naeel/nubes/autotest/development/dummycreate.har
|
|
||||||
Это HAR с реальными запросами Terraform при создании Болванки (сервис 1).
|
|
||||||
Найди ВСЕ POST /instanceOperationCfsParams — посмотри КАКИЕ именно params отправляются, в КАКОМ порядке, с КАКИМИ svcOperationCfsParamId.
|
|
||||||
|
|
||||||
## PostgreSQL YAML — структура параметров
|
|
||||||
|
|
||||||
Файл: /home/naeel/nubes/autotest/STANDS/dev/resources_yaml/90_postgres.yaml
|
|
||||||
Читай строки с operations.create.params — там 8 map-fixed параметров с sub_params.
|
|
||||||
|
|
||||||
Также проверь через API что реально возвращается:
|
|
||||||
```
|
|
||||||
curl -H "Authorization: Bearer <TOKEN>" /instanceOperations/default/19
|
|
||||||
```
|
|
||||||
(create opId для PostgreSQL = 19)
|
|
||||||
|
|
||||||
## ЧТО НУЖНО НАЙТИ
|
|
||||||
|
|
||||||
1. **ШАГ 3 Terraform vs наш код**: Terraform делает GET /instanceOperations/{opUid}?fields=cfsParams ПОСЛЕ создания операции чтобы получить параметры С РЕЗОЛВОМ refSvcId. Мы берём из /instanceOperations/default/{svc_op_id} (шаблон). В чём разница? Может ли шаблон не содержать нужных значений которые появляются только в реальной операции?
|
|
||||||
|
|
||||||
2. **ШАГ 4 Terraform vs наш код**: resolveRefSvcParamValues — что это делает? Как Terraform резолвит refSvcId (например backupConfiguration.s3Uid ссылается на serviceId=12)? Есть ли у нас такой резолв?
|
|
||||||
|
|
||||||
3. **ШАГ 6 Terraform vs наш код**: Terraform досылает required+default. Мы тоже (v1.1.19). НО: Terraform берёт defaults из cfsParams РЕАЛЬНОЙ операции (шаг 3), а не из шаблона. Может ли быть расхождение?
|
|
||||||
|
|
||||||
4. **ПОРЯДОК отправки params**: В HAR, в каком порядке идут POST /instanceOperationCfsParams? Важен ли порядок?
|
|
||||||
|
|
||||||
5. **serviceInstanceUid**: Этого параметра НЕТ ни в YAML, ни в шаблоне API. Откуда он берётся? Может это внутренний параметр Nubes который появляется при резолве refSvcId?
|
|
||||||
|
|
||||||
6. **ПРОВЕРЬ ВЕСЬ НАШ КОД** на предмет любых других расхождений с Terraform flow. Не только CREATE — MODIFY, DELETE, SUSPEND, RESUME тоже.
|
|
||||||
|
|
||||||
## ИСХОДНИКИ TERRAFORM-ПРОВАЙДЕРА (читать только эти файлы!)
|
|
||||||
|
|
||||||
Это Go-код Terraform-провайдера Nubes. В нём ЭТАЛОННАЯ логика которая РАБОТАЕТ.
|
|
||||||
|
|
||||||
- /home/naeel/tf_provider/provider/internal/core/client.go — **главный**: HTTP-клиент, CreateGenericInstanceUniversalV6 (весь CREATE flow), RunInstanceOperationUniversal (MODIFY/DELETE/SUSPEND), doRequest (retry, заголовки)
|
|
||||||
- /home/naeel/tf_provider/provider/internal/core/refsvc_resolve.go — **resolveRefSvcParamValues**: как резолвятся refSvcId (s3Uid → UUID S3-инстанса)
|
|
||||||
- /home/naeel/tf_provider/provider/internal/core/instance_params.go — обработка параметров
|
|
||||||
|
|
||||||
## Файлы autotest (наш код — сравнивать с Terraform)
|
|
||||||
|
|
||||||
- /home/naeel/nubes/autotest/app-autotest/site/routes/api_test.py — ВЕСЬ: /api/test, /api/params, _finish_op
|
|
||||||
- /home/naeel/nubes/autotest/app-autotest/site/operations/get_params.py — get_params_with_current_values, _normalize_value_list
|
|
||||||
- /home/naeel/nubes/autotest/DOCS/terraform-operations-full-logic.md — эталонный flow (уже скопирован из client.go)
|
|
||||||
- /home/naeel/nubes/autotest/development/dummycreate.har — реальные запросы Terraform
|
|
||||||
- /home/naeel/nubes/autotest/STANDS/dev/resources_yaml/90_postgres.yaml — PG параметры
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
# ⚠️ LEGACY — НЕАКТУАЛЬНО. Исторический документ.
|
|
||||||
|
|
||||||
# Пошаговый аудит кода — 27.07.2026 (v1.0.50)
|
|
||||||
|
|
||||||
## Методология
|
|
||||||
|
|
||||||
Полная трассировка CREATE-потока: UI → api_test() → _finish_op() → api_test_status() → refreshInstances() → api_operations(). Каждый if, try, except, присваивание.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Результат: найдено 3 бага
|
|
||||||
|
|
||||||
### 🔴 Баг #1 (КРИТИЧЕСКИЙ): Multi-worker gunicorn — in-memory dict не работает
|
|
||||||
|
|
||||||
**Файл:** `site/operations/tracker.py`
|
|
||||||
|
|
||||||
```python
|
|
||||||
_data = {
|
|
||||||
"408b7f96-...": {...}, # 4 initial items
|
|
||||||
}
|
|
||||||
|
|
||||||
def add(instance_uid, svc_id, display_name):
|
|
||||||
with _LOCK:
|
|
||||||
_data[instance_uid] = {...}
|
|
||||||
```
|
|
||||||
|
|
||||||
`_data` — module-level dict. У каждого gunicorn worker своя копия модуля → свой `_data`.
|
|
||||||
|
|
||||||
**Трассировка:**
|
|
||||||
1. POST /api/test → попадает на **воркер A**
|
|
||||||
2. `tracker_add(...)` → `_data` воркера A = 5 элементов ✅
|
|
||||||
3. GET /api/operations/1 → попадает на **воркер B**
|
|
||||||
4. `tracker_list()` → `_data` воркера B = 4 элемента ❌
|
|
||||||
|
|
||||||
**Почему это объясняет ВСЕ симптомы:**
|
|
||||||
- После F5 — рандомный воркер → опять 4
|
|
||||||
- v1.0.46-1.0.49 — ни одно решение не помогало (in-memory принципиально не跨-process)
|
|
||||||
- Инстанс в Nubes есть, в трекере нет — разные воркеры
|
|
||||||
|
|
||||||
**Решение:** вернуть файловый трекер (`/tmp/instances.json`) с межпроцессной блокировкой (`fcntl.flock` вместо `threading.Lock`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 🔴 Баг #2 (КРИТИЧЕСКИЙ): _finish_op() умирает молча на не-словаре
|
|
||||||
|
|
||||||
**Файл:** `site/routes/api_test.py`, строки 176-179
|
|
||||||
|
|
||||||
```python
|
|
||||||
def _finish_op(...):
|
|
||||||
while time.time() < deadline:
|
|
||||||
try:
|
|
||||||
data = client.get(...) # ← except ловит ТОЛЬКО это
|
|
||||||
except Exception:
|
|
||||||
time.sleep(5)
|
|
||||||
continue
|
|
||||||
op = data.get("instanceOperation", {}) # ← если data не dict → AttributeError!
|
|
||||||
```
|
|
||||||
|
|
||||||
Если Nubes API возвращает `list`, `str`, `None` или любой не-словарь — `data.get()` → **AttributeError**. Этот except НЕ покрывает строку 179. Поток умирает молча. Python daemon-потоки не пишут traceback.
|
|
||||||
|
|
||||||
**Решение:** обернуть ВСЁ тело цикла (строки 166-195) в `try/except Exception: print(traceback)`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 🟡 Баг #3 (НЕКРИТИЧНЫЙ): _op_results — та же multi-worker проблема
|
|
||||||
|
|
||||||
**Файл:** `site/routes/api_test.py`, строка 152
|
|
||||||
|
|
||||||
```python
|
|
||||||
_op_results = {} # module-level
|
|
||||||
|
|
||||||
# В _finish_op (воркер A):
|
|
||||||
_op_results[op_uid] = {"status": "OK", ...}
|
|
||||||
|
|
||||||
# В api_test_status (воркер B):
|
|
||||||
if op_uid in _op_results: # ← False! (другой воркер)
|
|
||||||
return jsonify(_op_results[op_uid])
|
|
||||||
# fallback → прямой запрос в Nubes API
|
|
||||||
```
|
|
||||||
|
|
||||||
**Некритично** потому что есть fallback: если `_op_results` не содержит op_uid, `api_test_status()` делает прямой GET в Nubes API и возвращает статус. UI получает stages напрямую из Nubes, не из `_op_results`.
|
|
||||||
|
|
||||||
**НО:** fallback не обновляет `tracker_remove` для delete. При multi-worker delete не удалит инстанс из трекера.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Полная трассировка CREATE (все шаги)
|
|
||||||
|
|
||||||
| Шаг | Код | Результат | Статус |
|
|
||||||
|-----|-----|-----------|--------|
|
|
||||||
| 1 | `data = request.get_json()` | `svc_id=1, op_name="create", display_name="autotest-1-xxx"` | ✅ |
|
|
||||||
| 2 | `client.post("/instances", ...)` | `instance_uid = "f192b10d-..."` | ✅ |
|
|
||||||
| 3 | `client.post("/instanceOperations", ...)` | `op_uid = "d489348e-..."` | ✅ |
|
|
||||||
| 4 | `for pid,pval: client.post("/instanceOperationCfsParams", ...)` | Параметры установлены | ✅ |
|
|
||||||
| 5 | `client.post("/instanceOperations/{op_uid}/run")` | Операция запущена | ✅ |
|
|
||||||
| 6 | `tracker_add(instance_uid, svc_id, display_name)` | `_data[uid] = {...}` (in-memory) | ✅ |
|
|
||||||
| 7 | `threading.Thread(target=_finish_op, ...)` | Поток запущен | ✅ |
|
|
||||||
| 8 | `return {status:"RUNNING", opUid, instanceUid}` | Ответ UI | ✅ |
|
|
||||||
| 9 | UI poll `/api/test/status/<opUid>` | `_op_results[opUid]` или fallback API | ⚠️ разн. воркеры |
|
|
||||||
| 10 | `_finish_op` детектит `dtFinish` | `_op_results[opUid] = {status:"OK"}` | ⚠️ воркер A |
|
|
||||||
| 11 | UI: `refreshInstances()` → `/api/operations/1` | `tracker_list()` → 4 элемента | ❌ воркер B |
|
|
||||||
| 12 | `api_operations()` возвращает 4 инстанса | Нового нет | ❌ |
|
|
||||||
| 13 | F5 → `selectService(1)` → `/api/operations/1` | Опять 4 | ❌ |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Дополнительные находки (не критические)
|
|
||||||
|
|
||||||
### ⚠️ params loop — orphaned resources при ошибке
|
|
||||||
Если `client.post("/instanceOperationCfsParams", ...)` падает на mid-param:
|
|
||||||
- Инстанс УЖЕ создан в Nubes (сирота)
|
|
||||||
- Операция УЖЕ создана (сирота)
|
|
||||||
- `tracker_add` НЕ вызван (он после цикла)
|
|
||||||
- Ответ: FAIL
|
|
||||||
|
|
||||||
### ⚠️ `data["svcOperationId"]` — KeyError если поле отсутствует
|
|
||||||
Вызов API без `svcOperationId` в JSON → KeyError → FAIL. Обработано внешним try/except.
|
|
||||||
|
|
||||||
### ⚠️ `int(pid)` — ValueError если param ID не число
|
|
||||||
`int("abc")` → ValueError → FAIL. Обработано внешним try/except.
|
|
||||||
|
|
||||||
### ⚠️ `_find_uid()` итерация по ВСЕМ значениям
|
|
||||||
Может случайно найти uid во вложенном объекте. Низкий риск, но нечисто.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## План исправлений для v1.0.51
|
|
||||||
|
|
||||||
| # | Что | Как |
|
|
||||||
|---|-----|-----|
|
|
||||||
| 1 | Файловый трекер с fcntl.flock | Вернуть `/tmp/instances.json`, заменить `threading.Lock` на `fcntl.flock` |
|
|
||||||
| 2 | _finish_op: try/except на всё тело | Обернуть строки 166-195 в `try/except: print(traceback)` |
|
|
||||||
| 3 | (опционально) `svc_id = int(data["serviceId"])` | Защита от строкового "1" в JSON |
|
|
||||||
@@ -6,6 +6,10 @@ Flask-приложение для автотестов сервисов Nubes.
|
|||||||
|
|
||||||
См. основной репо: [DOCS/](../DOCS/) — [стадии операций](../DOCS/api-operation-stages.md), [поток CREATE](../DOCS/api-create-flow.md), [доступ к API](../DOCS/api-access.md).
|
См. основной репо: [DOCS/](../DOCS/) — [стадии операций](../DOCS/api-operation-stages.md), [поток CREATE](../DOCS/api-create-flow.md), [доступ к API](../DOCS/api-access.md).
|
||||||
|
|
||||||
|
## Архитектура
|
||||||
|
|
||||||
|
**Полный обзор:** [../DOCS/ARCHITECTURE-FULL.md](../DOCS/ARCHITECTURE-FULL.md) — все файлы, CREATE flow, глобальное состояние, план развития.
|
||||||
|
|
||||||
## Сервис
|
## Сервис
|
||||||
|
|
||||||
`https://autotest.pythonk8s.dev.nubes.ru/` (test)
|
`https://autotest.pythonk8s.dev.nubes.ru/` (test)
|
||||||
|
|||||||
+43
-15
@@ -3,15 +3,15 @@
|
|||||||
|
|
||||||
Раньше _client(), _client_id(), _stand(), _token_info() были продублированы
|
Раньше _client(), _client_id(), _stand(), _token_info() были продублированы
|
||||||
в main.py и api_test.py с идентичным или почти идентичным кодом.
|
в main.py и api_test.py с идентичным или почти идентичным кодом.
|
||||||
Теперь всё здесь — один источник правды.
|
Теперь всё здесь — один источник правды для всех роутов.
|
||||||
|
|
||||||
Функции:
|
Функции (все без аргументов — берут данные из Flask request/current_app):
|
||||||
get_token() — токен из cookie или env
|
get_token() — токен: cookie → env-переменная
|
||||||
get_client() — HttpClient с автоопределением стенда
|
get_client() — HttpClient с автоопределением стенда
|
||||||
get_client_id() — ClientID из JWT (base64, без проверки подписи)
|
get_client_id() — ClientID из JWT (base64url, без проверки подписи)
|
||||||
get_stand() — "dev" / "test" по токену
|
get_stand() — "dev" / "test" по токену
|
||||||
get_token_info() — {email, company, client_id} из JWT
|
get_token_info() — {email, company, client_id} из JWT для UI
|
||||||
get_token_masked() — маскированный токен (abc...xyz)
|
get_token_masked() — маскированный токен (abc...xyz) для placeholder
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
@@ -22,12 +22,18 @@ from api.http_client import HttpClient, detect_endpoint, stand_name
|
|||||||
|
|
||||||
|
|
||||||
def get_token():
|
def get_token():
|
||||||
"""Токен: сначала из cookie, потом из env-переменной."""
|
"""Получить активный токен: сначала из cookie пользователя, потом из env.
|
||||||
|
|
||||||
|
Приоритет:
|
||||||
|
1. cookie "token" — пользователь ввёл свой токен в форме
|
||||||
|
2. NUBES_API_TOKEN из env — сервисный токен (для автоматических тестов)
|
||||||
|
|
||||||
|
Пользовательский токен приоритетнее — он переопределяет сервисный."""
|
||||||
return request.cookies.get("token") or current_app.config["NUBES_API_TOKEN"]
|
return request.cookies.get("token") or current_app.config["NUBES_API_TOKEN"]
|
||||||
|
|
||||||
|
|
||||||
def get_client():
|
def get_client():
|
||||||
"""HttpClient с автоопределением стенда по токену.
|
"""HttpClient с автоопределением стенда по активному токену.
|
||||||
|
|
||||||
Использует detect_endpoint() — пробует dev→test стенды.
|
Использует detect_endpoint() — пробует dev→test стенды.
|
||||||
Если автоопределение не сработало — fallback на NUBES_API_ENDPOINT из конфига."""
|
Если автоопределение не сработало — fallback на NUBES_API_ENDPOINT из конфига."""
|
||||||
@@ -37,27 +43,45 @@ def get_client():
|
|||||||
|
|
||||||
|
|
||||||
def get_client_id():
|
def get_client_id():
|
||||||
"""Извлечение ClientID из payload JWT-токена (base64url, без проверки подписи)."""
|
"""Извлечь ClientID из payload JWT-токена (base64url, без проверки подписи).
|
||||||
|
|
||||||
|
JWT состоит из трёх частей: header.payload.signature, разделённых точкой.
|
||||||
|
Нам нужен ТОЛЬКО payload — он в base64url (не base64!).
|
||||||
|
|
||||||
|
ClientID используется для:
|
||||||
|
- Изоляции данных в БД (runs.client_id)
|
||||||
|
- Изоляции трекера инстансов (/tmp/instances-{clientId}-{stand}.json)
|
||||||
|
- Отображения в UI
|
||||||
|
|
||||||
|
Безопасность: мы НЕ проверяем подпись — это не нужно.
|
||||||
|
Токен уже проверен Nubes API (detect_endpoint делает реальный запрос)."""
|
||||||
token = get_token()
|
token = get_token()
|
||||||
try:
|
try:
|
||||||
parts = token.split(".") # header.payload.signature
|
parts = token.split(".") # [header, payload, signature]
|
||||||
if len(parts) >= 2:
|
if len(parts) >= 2:
|
||||||
payload = base64.urlsafe_b64decode(parts[1] + "==") # padding
|
# base64url → добавляем padding ("==") на случай если длина не кратна 4
|
||||||
|
payload = base64.urlsafe_b64decode(parts[1] + "==")
|
||||||
return json.loads(payload).get("ClientID", "")
|
return json.loads(payload).get("ClientID", "")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass # битый токен — не критично, вернём пустую строку
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def get_stand():
|
def get_stand():
|
||||||
"""dev/test — по токену (detect_endpoint → stand_name)."""
|
"""Определить стенд (dev/test) по активному токену.
|
||||||
|
|
||||||
|
detect_endpoint → stand_name. Если автоопределение не сработало —
|
||||||
|
fallback на NUBES_API_ENDPOINT из конфига."""
|
||||||
token = get_token()
|
token = get_token()
|
||||||
endpoint = detect_endpoint(token) or current_app.config["NUBES_API_ENDPOINT"]
|
endpoint = detect_endpoint(token) or current_app.config["NUBES_API_ENDPOINT"]
|
||||||
return stand_name(endpoint)
|
return stand_name(endpoint)
|
||||||
|
|
||||||
|
|
||||||
def get_token_info():
|
def get_token_info():
|
||||||
"""{email, company, client_id} из JWT — для отображения в топбаре UI."""
|
"""Извлечь {email, company, client_id} из JWT — для отображения в топбаре UI.
|
||||||
|
|
||||||
|
Возвращает dict с ключами: email, company, client_id.
|
||||||
|
Если JWT невалиден — возвращает пустой {}."""
|
||||||
token = get_token()
|
token = get_token()
|
||||||
try:
|
try:
|
||||||
parts = token.split(".")
|
parts = token.split(".")
|
||||||
@@ -75,8 +99,12 @@ def get_token_info():
|
|||||||
|
|
||||||
|
|
||||||
def get_token_masked():
|
def get_token_masked():
|
||||||
"""Маскированный токен для placeholder: abc...xyz."""
|
"""Маскированный env-токен для placeholder в форме: abc...xyz.
|
||||||
|
|
||||||
|
Используется ТОЛЬКО env-токен (не пользовательский!).
|
||||||
|
Если токен короче 8 символов — возвращает пустую строку."""
|
||||||
token = current_app.config["NUBES_API_TOKEN"]
|
token = current_app.config["NUBES_API_TOKEN"]
|
||||||
if not token or len(token) < 8:
|
if not token or len(token) < 8:
|
||||||
return ""
|
return ""
|
||||||
|
# Первые 4 символа + звёздочки + последние 4 символа
|
||||||
return token[:4] + "*" * (len(token) - 8) + token[-4:]
|
return token[:4] + "*" * (len(token) - 8) + token[-4:]
|
||||||
|
|||||||
+97
-41
@@ -1,20 +1,27 @@
|
|||||||
"""
|
"""
|
||||||
HTTP-клиент для Nubes API + автоопределение стенда.
|
HTTP-клиент для Nubes API — тонкая обёртка над requests.Session.
|
||||||
|
|
||||||
Класс HttpClient — тонкая обёртка над requests.Session:
|
HttpClient:
|
||||||
- Добавляет заголовки: Authorization Bearer, User-Agent (DDoS-Guard)
|
- Добавляет обязательные заголовки:
|
||||||
- GET: raise_for_status → .json()
|
Authorization: Bearer <token> — аутентификация
|
||||||
- POST: проверка r.ok, извлечение Location-заголовка
|
User-Agent: Mozilla/5.0 — DDoS-Guard блокирует python-requests по умолчанию
|
||||||
|
- GET: raise_for_status → .json() — автоматически проверяет HTTP-статус
|
||||||
|
- POST: проверка r.ok, извлечение Location-заголовка + UUID
|
||||||
|
- raw_delete: DELETE без авторизации (для CMDB)
|
||||||
|
|
||||||
Функции автостенда:
|
Функции автоопределения стенда:
|
||||||
- detect_endpoint(token) — пробует dev→test стенды, возвращает URL
|
- detect_endpoint(token) — пробует dev→test стенды по токену
|
||||||
- stand_name(endpoint) — "dev" / "test" по URL
|
- stand_name(endpoint) — "dev" / "test" по URL
|
||||||
- create_client(token, fallback) — HttpClient + endpoint (одним вызовом)
|
- create_client(token) — HttpClient + endpoint одним вызовом
|
||||||
|
|
||||||
|
Зачем автоопределение: пользователь вводит токен, мы не знаем dev это или test.
|
||||||
|
Пробуем оба стенда — какой ответит с results != None, тот и рабочий.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
# Список стендов для автоопределения. Порядок важен: dev первый (быстрее).
|
# Список стендов для автоопределения.
|
||||||
|
# Порядок ВАЖЕН: dev первый — он быстрее (меньше нагрузка), test — резервный.
|
||||||
STANDS = [
|
STANDS = [
|
||||||
"https://lk-api-gateway-dev.ngcloud.ru/api/v1/svc",
|
"https://lk-api-gateway-dev.ngcloud.ru/api/v1/svc",
|
||||||
"https://lk-api-gateway-test.ngcloud.ru/api/v1/svc",
|
"https://lk-api-gateway-test.ngcloud.ru/api/v1/svc",
|
||||||
@@ -24,9 +31,15 @@ STANDS = [
|
|||||||
def detect_endpoint(token):
|
def detect_endpoint(token):
|
||||||
"""Пробуем токен против dev и test стендов, возвращаем рабочий URL.
|
"""Пробуем токен против dev и test стендов, возвращаем рабочий URL.
|
||||||
|
|
||||||
Делает GET /instances?pageSize=1 на каждый стенд.
|
Алгоритм:
|
||||||
Если results != None — стенд рабочий, возвращаем его URL.
|
1. Для каждого стенда создаём HttpClient + делаем GET /instances?pageSize=1.
|
||||||
Если ни один не подошёл — возвращаем None."""
|
2. Если results != None — стенд рабочий, токен валиден → возвращаем URL.
|
||||||
|
3. Если Exception (401/403/таймаут) — пробуем следующий.
|
||||||
|
4. Ни один не подошёл → None.
|
||||||
|
|
||||||
|
Почему results != None а не просто HTTP 200:
|
||||||
|
API может вернуть 200 с пустым списком (нет инстансов) — это норм.
|
||||||
|
results=None — признак что ответ не соответствует ожидаемой структуре."""
|
||||||
for ep in STANDS:
|
for ep in STANDS:
|
||||||
try:
|
try:
|
||||||
c = HttpClient(ep, token)
|
c = HttpClient(ep, token)
|
||||||
@@ -39,10 +52,10 @@ def detect_endpoint(token):
|
|||||||
|
|
||||||
|
|
||||||
def stand_name(endpoint):
|
def stand_name(endpoint):
|
||||||
"""dev/test по URL стенда.
|
"""Определить имя стенда по URL.
|
||||||
|
|
||||||
Ищет подстроку 'dev' или 'test' в URL.
|
Ищет подстроку 'dev' или 'test' в URL.
|
||||||
Если не найдено — возвращает '?'."""
|
Если не найдено — '?' (неизвестный стенд)."""
|
||||||
for name in ("dev", "test"):
|
for name in ("dev", "test"):
|
||||||
if name in (endpoint or ""):
|
if name in (endpoint or ""):
|
||||||
return name
|
return name
|
||||||
@@ -50,10 +63,11 @@ def stand_name(endpoint):
|
|||||||
|
|
||||||
|
|
||||||
def create_client(token, fallback_endpoint=None):
|
def create_client(token, fallback_endpoint=None):
|
||||||
"""HttpClient с автоопределением стенда по токену.
|
"""HttpClient с автоопределением стенда.
|
||||||
|
|
||||||
Сначала detect_endpoint(token), если не сработало — fallback_endpoint.
|
Сначала detect_endpoint(token) — dev→test.
|
||||||
Возвращает кортеж (HttpClient, endpoint_url) или None если стенд не определён."""
|
Если не сработало — fallback_endpoint (из конфига).
|
||||||
|
Возвращает (HttpClient, endpoint_url) или None."""
|
||||||
ep = detect_endpoint(token) or fallback_endpoint
|
ep = detect_endpoint(token) or fallback_endpoint
|
||||||
if not ep:
|
if not ep:
|
||||||
return None
|
return None
|
||||||
@@ -63,65 +77,107 @@ def create_client(token, fallback_endpoint=None):
|
|||||||
class HttpClient:
|
class HttpClient:
|
||||||
"""HTTP-клиент для Nubes REST API.
|
"""HTTP-клиент для Nubes REST API.
|
||||||
|
|
||||||
Использует requests.Session для keep-alive соединений.
|
Использует requests.Session для:
|
||||||
Добавляет обязательные заголовки:
|
- Keep-alive (переиспользование TCP+TLS между запросами)
|
||||||
- Authorization: Bearer <token> (аутентификация)
|
- Единые заголовки для всех запросов
|
||||||
- User-Agent: Mozilla/5.0 (DDoS-Guard требует НЕ python-requests)"""
|
|
||||||
|
Обязательные заголовки:
|
||||||
|
- Authorization: Bearer <token> — без этого API вернёт 401
|
||||||
|
- User-Agent: Mozilla/5.0 — DDoS-Guard блокирует "python-requests/2.x"
|
||||||
|
|
||||||
|
Таймауты: GET=10с (лёгкие), POST=30с (создание ресурсов дольше)."""
|
||||||
|
|
||||||
def __init__(self, endpoint, token):
|
def __init__(self, endpoint, token):
|
||||||
# Убираем trailing slash чтобы потом добавлять "/path"
|
# Убираем trailing slash (".../svc/" → ".../svc")
|
||||||
|
# чтобы path добавлялся как "/path", а не "path"
|
||||||
self._endpoint = endpoint.rstrip("/")
|
self._endpoint = endpoint.rstrip("/")
|
||||||
# Session — переиспользует TCP-соединения между запросами
|
# Session переиспользует TCP + TLS handshake между запросами.
|
||||||
|
# Без Session каждый запрос делал бы новый connect → медленно.
|
||||||
self._session = requests.Session()
|
self._session = requests.Session()
|
||||||
self._session.headers.update({
|
self._session.headers.update({
|
||||||
"Authorization": f"Bearer {token}",
|
"Authorization": f"Bearer {token}",
|
||||||
"User-Agent": "Mozilla/5.0",
|
"User-Agent": "Mozilla/5.0", # DDoS-Guard: не python-requests
|
||||||
})
|
})
|
||||||
|
|
||||||
def get(self, path, **kwargs):
|
def get(self, path, **kwargs):
|
||||||
"""GET-запрос. Таймаут по умолчанию 10 секунд.
|
"""GET-запрос к Nubes API.
|
||||||
|
|
||||||
Вызывает raise_for_status() — при 4xx/5xx выбрасывает HTTPError.
|
Особенности:
|
||||||
Возвращает распарсенный JSON (dict/list)."""
|
- Таймаут 10 секунд по умолчанию.
|
||||||
|
- raise_for_status() — 4xx/5xx → HTTPError (ловим выше).
|
||||||
|
- Пустой ответ (нет body) → {} (норма для validate-cfs).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: str — путь относительно endpoint (напр. "/instances").
|
||||||
|
**kwargs — params, timeout, headers и т.д.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict/list — распарсенный JSON, или {} если ответ пустой."""
|
||||||
kwargs.setdefault("timeout", 10)
|
kwargs.setdefault("timeout", 10)
|
||||||
r = self._session.get(f"{self._endpoint}{path}", **kwargs)
|
r = self._session.get(f"{self._endpoint}{path}", **kwargs)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
if not r.text or not r.text.strip():
|
if not r.text or not r.text.strip():
|
||||||
return {} # пустой ответ (напр. validate-cfs успех)
|
return {} # пустой ответ — норма для некоторых эндпоинтов
|
||||||
return r.json()
|
return r.json()
|
||||||
|
|
||||||
def post(self, path, data=None, **kwargs):
|
def post(self, path, data=None, **kwargs):
|
||||||
"""POST-запрос. Таймаут по умолчанию 30 секунд.
|
"""POST-запрос к Nubes API.
|
||||||
|
|
||||||
Отправляет data как JSON (json=...).
|
Особенности:
|
||||||
При HTTP-ошибке выбрасывает Exception с кодом и телом ответа.
|
- Таймаут 30 секунд по умолчанию.
|
||||||
Возвращает dict:
|
- data → json=... — requests сам ставит Content-Type: application/json.
|
||||||
- Распарсенный JSON (если ответ — валидный JSON-объект)
|
- Извлекает Location-заголовок → UUID → кладёт в instanceUid/instanceOperationUid.
|
||||||
- + ключ "_location" со значением заголовка Location (если есть)
|
Это нужно чтобы не полагаться только на тело ответа (которое может быть пустым).
|
||||||
Location нужен для получения UID созданного ресурса (instanceUid, opUid)."""
|
- При HTTP-ошибке: Exception с кодом и телом ответа.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: str — путь (напр. "/instances").
|
||||||
|
data: dict|None — тело запроса (сериализуется в JSON).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict с полями ответа + _status + _location (+ UUID если найден)."""
|
||||||
kwargs.setdefault("timeout", 30)
|
kwargs.setdefault("timeout", 30)
|
||||||
url = f"{self._endpoint}{path}"
|
url = f"{self._endpoint}{path}"
|
||||||
# json=... — requests сам сериализует и ставит Content-Type: application/json
|
# json=... — requests сам сериализует и ставит Content-Type: application/json
|
||||||
r = self._session.post(url, json=(data if data is not None else {}), **kwargs)
|
r = self._session.post(url, json=(data if data is not None else {}), **kwargs)
|
||||||
if not r.ok:
|
if not r.ok:
|
||||||
raise Exception(f"POST {path}: {r.status_code} {r.reason}: {r.text[:200]}")
|
raise Exception(f"POST {path}: {r.status_code} {r.reason}: {r.text[:200]}")
|
||||||
result = {}
|
result = {"_status": r.status_code}
|
||||||
try:
|
try:
|
||||||
parsed = r.json()
|
parsed = r.json()
|
||||||
if isinstance(parsed, dict):
|
if isinstance(parsed, dict):
|
||||||
result = parsed
|
result = parsed
|
||||||
|
result["_status"] = r.status_code
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # тело не JSON или не dict — ок, Location всё равно извлечём
|
pass # тело не JSON — ок, UUID извлечём из Location
|
||||||
|
# Location-заголовок: "./UUID" или "/api/v1/svc/.../UUID"
|
||||||
loc = r.headers.get("Location", "")
|
loc = r.headers.get("Location", "")
|
||||||
if loc:
|
if loc:
|
||||||
# Location бывает вида "./UUID" или "/api/v1/svc/instanceOperations/UUID"
|
|
||||||
result["_location"] = loc
|
result["_location"] = loc
|
||||||
|
# Извлекаем UUID — последний сегмент после split("/")
|
||||||
|
parts = loc.rstrip("/").split("/")
|
||||||
|
uid = parts[-1]
|
||||||
|
# Проверка: не ".", длина >= 32 (UUID = 36 символов с дефисами)
|
||||||
|
if uid and uid != "." and len(uid) >= 32:
|
||||||
|
# Кладём UUID в правильное поле в зависимости от эндпоинта
|
||||||
|
if "/instanceOperations" in path:
|
||||||
|
result["instanceOperationUid"] = uid
|
||||||
|
elif "/instances" in path:
|
||||||
|
result["instanceUid"] = uid
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def raw_delete(self, url):
|
def raw_delete(self, url):
|
||||||
"""DELETE-запрос к произвольному URL (CMDB API).
|
"""DELETE-запрос к произвольному URL — БЕЗ авторизации.
|
||||||
Использует отдельную сессию БЕЗ auth-заголовков.
|
|
||||||
Возвращает кортеж (ok: bool, status_code: int)."""
|
Используется для CMDB API (cmdb-api.deck.nubes.ru) —
|
||||||
|
жёсткое удаление недосозданных/зависших инстансов.
|
||||||
|
CMDB не требует Bearer-токена.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: str — полный URL (не path, другой хост!)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(ok: bool, status_code: int|str)."""
|
||||||
try:
|
try:
|
||||||
r = requests.delete(url, timeout=10)
|
r = requests.delete(url, timeout=10)
|
||||||
return r.ok, r.status_code
|
return r.ok, r.status_code
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""
|
||||||
|
Общие утилиты: извлечение UUID из ответов API Nubes.
|
||||||
|
|
||||||
|
Проблема: Nubes API возвращает UUID инстанса/операции в разных форматах
|
||||||
|
в зависимости от эндпоинта и фазы жизненного цикла:
|
||||||
|
- POST /instances → {"instanceUid": "..."} (прямой ключ)
|
||||||
|
- POST /instances → Location: "./uuid" (заголовок)
|
||||||
|
- POST /instanceOperations → {"instanceOperationUid": "..."} (другой ключ)
|
||||||
|
- GET /instanceOperations → {"instanceOperation": {"instanceOperationUid": "..."}} (вложенный)
|
||||||
|
|
||||||
|
Эти две функции — единый способ достать UUID из ЛЮБОГО ответа.
|
||||||
|
Раньше каждая точка вызова имела свою реализацию _find_uid / _uid_from_location.
|
||||||
|
Теперь всё здесь — один источник правды.
|
||||||
|
|
||||||
|
Используется: executor.py, api_test.py, scenario.py.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def find_uid(resp):
|
||||||
|
"""Извлечь instanceUid или instanceOperationUid из dict-ответа API.
|
||||||
|
|
||||||
|
Стратегия поиска (в порядке приоритета):
|
||||||
|
1. Верхний уровень — прямые ключи (стиль api_test.py):
|
||||||
|
instanceOperationUid → instanceUid → uid → Uid
|
||||||
|
2. Вложенные dict-значения (стиль scenario.py):
|
||||||
|
Для каждого значения-словаря проверяем instanceOperationUid → instanceUid
|
||||||
|
|
||||||
|
Почему такой порядок:
|
||||||
|
- instanceOperationUid приоритетнее — это ответ на POST /instanceOperations
|
||||||
|
- instanceUid — ответ на POST /instances или GET /instances
|
||||||
|
- uid / Uid — legacy-форматы, почти не встречаются
|
||||||
|
|
||||||
|
Args:
|
||||||
|
resp: dict — распарсенный JSON-ответ API (или что угодно).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str или None — UUID (36 символов) или None если не нашли.
|
||||||
|
"""
|
||||||
|
# Защита: если передали не dict (например, list или str) — сразу None
|
||||||
|
if not isinstance(resp, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ── Уровень 1: прямые ключи на верхнем уровне ──
|
||||||
|
# Пример: {"instanceUid": "abc-123", ...}
|
||||||
|
# Проверяем по порядку: сначала специфичные, потом общие
|
||||||
|
for key in ("instanceOperationUid", "instanceUid", "uid", "Uid"):
|
||||||
|
v = resp.get(key)
|
||||||
|
# isinstance(str) — отсекаем числа, None, пустые строки
|
||||||
|
if isinstance(v, str) and v:
|
||||||
|
return v
|
||||||
|
|
||||||
|
# ── Уровень 2: вложенные dict-значения ──
|
||||||
|
# Пример: {"instanceOperation": {"instanceOperationUid": "abc-123"}}
|
||||||
|
# Перебираем ВСЕ значения resp, ищем вложенные словари
|
||||||
|
for v in resp.values():
|
||||||
|
if isinstance(v, dict):
|
||||||
|
# Внутри вложенного dict — те же ключи что на верхнем уровне
|
||||||
|
uid = v.get("instanceOperationUid") or v.get("instanceUid")
|
||||||
|
if uid:
|
||||||
|
return uid
|
||||||
|
|
||||||
|
# Ничего не нашли
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def uid_from_location(loc):
|
||||||
|
"""Извлечь UUID из Location-заголовка HTTP-ответа.
|
||||||
|
|
||||||
|
Location — это HTTP-заголовок, который Nubes API возвращает при создании
|
||||||
|
ресурса (201 Created). Форматы:
|
||||||
|
- "./uuid" (относительный)
|
||||||
|
- "/api/v1/svc/instances/uuid" (абсолютный путь)
|
||||||
|
- "/api/v1/svc/instanceOperations/uuid" (полный путь)
|
||||||
|
|
||||||
|
Алгоритм:
|
||||||
|
1. Убираем trailing slash.
|
||||||
|
2. Split по "/" — берём ПОСЛЕДНИЙ сегмент.
|
||||||
|
3. Проверяем что это похоже на UUID (>= 32 символа) и не ".".
|
||||||
|
|
||||||
|
Args:
|
||||||
|
loc: str или None — значение заголовка Location.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str или None — UUID или None если не нашли.
|
||||||
|
"""
|
||||||
|
# Пустой Location → нечего извлекать
|
||||||
|
if not loc:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Убираем trailing slash (напр. "./uuid/" → "./uuid")
|
||||||
|
# Split по "/" → [".", "uuid"] или ["", "api", "v1", "svc", "instances", "uuid"]
|
||||||
|
parts = str(loc).rstrip("/").split("/")
|
||||||
|
|
||||||
|
# Последний сегмент — это UUID
|
||||||
|
uid = parts[-1]
|
||||||
|
|
||||||
|
# Проверки:
|
||||||
|
# uid != "." — Location "./" даст пустой последний сегмент
|
||||||
|
# len >= 32 — UUID всегда 36 символов (8-4-4-4-12), но на всякий случай >= 32
|
||||||
|
if uid and uid != "." and len(uid) >= 32:
|
||||||
|
return uid
|
||||||
|
|
||||||
|
return None
|
||||||
+40
-12
@@ -1,12 +1,24 @@
|
|||||||
"""
|
"""
|
||||||
app-autotest — точка входа Flask-приложения для автотестов Nubes.
|
app-autotest — точка входа Flask-приложения для автотестов Nubes.
|
||||||
|
|
||||||
Регистрирует три blueprint'а:
|
Это приложение позволяет:
|
||||||
- main_bp → / (главная, токен, инфраструктура)
|
1. Тестировать операции с сервисами Nubes (create/modify/delete/...) через UI
|
||||||
- api_bp → /api/run, /api/status, /api/config [LEGACY]
|
2. Создавать и запускать сценарии (последовательности операций)
|
||||||
- api_test_bp → /api/test, /api/params, /api/log (основная логика)
|
3. Просматривать историю запусков и логи
|
||||||
|
|
||||||
Деплой: Nubes pythonk8s (gunicorn, несколько воркеров).
|
Blueprint'ы (каждый — отдельный файл в routes/):
|
||||||
|
- main_bp → / (главная: токен, инфраструктура, сервисы)
|
||||||
|
- api_test_bp → /api/test, /api/params, /api/log (ручной режим)
|
||||||
|
- api_scenario_run_bp → /api/scenario/run, /api/scenario/status (запуск сценариев)
|
||||||
|
- api_scenario_defs_bp → /api/scenario/definitions (CRUD сценариев)
|
||||||
|
|
||||||
|
Деплой: Nubes pythonk8s (managed service) с gunicorn:
|
||||||
|
gunicorn app:app --workers 2 --bind 0.0.0.0:8000
|
||||||
|
|
||||||
|
Конфигурация:
|
||||||
|
NUBES_API_ENDPOINT — URL API (по умолчанию test)
|
||||||
|
NUBES_API_TOKEN — сервисный токен (из переменных окружения k8s)
|
||||||
|
VERSION — версия приложения (меняется при КАЖДОМ изменении кода)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@@ -14,25 +26,41 @@ import os
|
|||||||
from flask import Flask
|
from flask import Flask
|
||||||
|
|
||||||
from routes.main import bp as main_bp
|
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
|
from routes.api_test import bp as api_test_bp
|
||||||
|
from routes.api_scenario_run import bp_run as api_scenario_run_bp
|
||||||
|
from routes.api_scenario_defs import bp_defs as api_scenario_defs_bp
|
||||||
|
|
||||||
# Версия — меняется при КАЖДОМ изменении кода. Показывается в топбаре UI.
|
# Версия — показывается в топбаре UI. Меняется при КАЖДОМ изменении кода.
|
||||||
VERSION = "1.1.44"
|
# Нужна для фильтрации истории (пользователь видит только записи своей версии).
|
||||||
|
VERSION = "1.2.18"
|
||||||
|
|
||||||
|
# Flask-приложение с Jinja2-шаблонами из папки templates/
|
||||||
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-test.ngcloud.ru/api/v1/svc")
|
|
||||||
|
# Конфигурация из переменных окружения (задаются в Nubes UI при создании сервиса)
|
||||||
|
app.config["NUBES_API_ENDPOINT"] = os.getenv(
|
||||||
|
"NUBES_API_ENDPOINT",
|
||||||
|
"https://lk-api-gateway-test.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
|
||||||
app.register_blueprint(main_bp)
|
|
||||||
app.register_blueprint(api_bp)
|
# Регистрируем blueprint'ы — каждый отвечает за свою группу маршрутов
|
||||||
app.register_blueprint(api_test_bp)
|
app.register_blueprint(main_bp) # /
|
||||||
|
app.register_blueprint(api_test_bp) # /api/test, /api/params, ...
|
||||||
|
app.register_blueprint(api_scenario_run_bp) # /api/scenario/run, ...
|
||||||
|
app.register_blueprint(api_scenario_defs_bp) # /api/scenario/definitions, ...
|
||||||
|
|
||||||
|
|
||||||
@app.route("/health")
|
@app.route("/health")
|
||||||
def health():
|
def health():
|
||||||
|
"""Healthcheck — всегда возвращает 200 OK.
|
||||||
|
|
||||||
|
Используется Nubes для проверки живости сервиса.
|
||||||
|
Если не ответит — сервис будет перезапущен."""
|
||||||
return "OK"
|
return "OK"
|
||||||
|
|
||||||
|
|
||||||
|
# Локальный запуск (без gunicorn) — только для разработки
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
app.run(debug=True, host="0.0.0.0", port=5000)
|
app.run(debug=True, host="0.0.0.0", port=5000)
|
||||||
|
|||||||
+169
-15
@@ -1,24 +1,45 @@
|
|||||||
"""
|
"""
|
||||||
Инициализация схемы БД — идемпотентно (CREATE IF NOT EXISTS).
|
Инициализация схемы БД — идемпотентно (CREATE IF NOT EXISTS) + startup cleanup.
|
||||||
|
|
||||||
Вызывается при старте приложения (в app.py, до register_blueprint).
|
Вызывается при ПЕРВОМ обращении к БД в каждом gunicorn-воркере.
|
||||||
Безопасно для нескольких gunicorn-воркеров — PostgreSQL корректно
|
Безопасно для конкурентного вызова из нескольких воркеров — PostgreSQL
|
||||||
обрабатывает конкурентный DDL.
|
корректно обрабатывает одновременный CREATE IF NOT EXISTS.
|
||||||
|
|
||||||
Таблица runs — история запусков операций:
|
ТАБЛИЦЫ:
|
||||||
- id, created_at
|
|
||||||
- client_id, stand (изоляция по пользователю и стенду)
|
runs — история ВСЕХ запусков операций:
|
||||||
- user_email (кто запустил)
|
- Ручной режим: каждая операция (create/modify/delete/...) — одна строка
|
||||||
- svc_id, svc_name, op_name, svc_op_id
|
- Сценарный режим: каждый шаг сценария — одна строка
|
||||||
- instance_uid, display_name, op_uid
|
- Колонки: id, client_id, stand, user_email, svc_id, svc_name, op_name,
|
||||||
- status (OK/FAIL/TIMEOUT), duration_sec, error_log
|
svc_op_id, op_uid, instance_uid, display_name, status, duration_sec,
|
||||||
- params (JSONB), stages (JSONB)
|
error_log, params (JSONB), stages (JSONB), app_version,
|
||||||
- app_version (версия приложения)
|
scenario_run_id, step_number, instance_meta (JSONB)
|
||||||
|
|
||||||
|
scenario_runs — история запусков сценариев:
|
||||||
|
- Одна строка = один запуск сценария
|
||||||
|
- Колонки: id, client_id, stand, scenario_name, status (RUNNING/OK/FAIL/TIMEOUT),
|
||||||
|
current_step, total_steps, instance_bindings (JSONB), duration_sec, error_log,
|
||||||
|
definition_id, definition_version
|
||||||
|
|
||||||
|
scenario_definitions — определения сценариев (редактируются через UI):
|
||||||
|
- Колонки: id, client_id, stand, name, steps (JSONB), version,
|
||||||
|
is_active, created_at, updated_at, updated_by
|
||||||
|
- Уникальность: (client_id, stand, LOWER(name)) — нельзя два сценария с одинаковым именем
|
||||||
|
|
||||||
|
STARTUP CLEANUP:
|
||||||
|
При старте приложения все зависшие RUNNING-сценарии (старше 1 часа)
|
||||||
|
переводятся в TIMEOUT. Это чистит последствия падения gunicorn-воркера.
|
||||||
|
|
||||||
|
SEED:
|
||||||
|
При первом старте импортируются дефолтные сценарии из scenario_seed.yaml.
|
||||||
|
INSERT ON CONFLICT DO NOTHING — не перезаписывает существующие.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import json
|
||||||
from db.pool import get_conn, put_conn
|
from db.pool import get_conn, put_conn
|
||||||
|
|
||||||
|
# ── DDL для таблицы runs (история операций) ──
|
||||||
SCHEMA_SQL = """
|
SCHEMA_SQL = """
|
||||||
CREATE TABLE IF NOT EXISTS runs (
|
CREATE TABLE IF NOT EXISTS runs (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
@@ -41,26 +62,134 @@ CREATE TABLE IF NOT EXISTS runs (
|
|||||||
app_version VARCHAR(16)
|
app_version VARCHAR(16)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Индекс для фильтрации по пользователю и стенду (основной запрос истории)
|
||||||
CREATE INDEX IF NOT EXISTS idx_runs_client_stand
|
CREATE INDEX IF NOT EXISTS idx_runs_client_stand
|
||||||
ON runs (client_id, stand, created_at DESC);
|
ON runs (client_id, stand, created_at DESC);
|
||||||
|
|
||||||
|
-- Индекс для поиска по instance_uid (связь с scenario_runs)
|
||||||
CREATE INDEX IF NOT EXISTS idx_runs_instance
|
CREATE INDEX IF NOT EXISTS idx_runs_instance
|
||||||
ON runs (instance_uid);
|
ON runs (instance_uid);
|
||||||
|
|
||||||
|
-- Индекс по дате создания (для очистки старых записей)
|
||||||
CREATE INDEX IF NOT EXISTS idx_runs_created
|
CREATE INDEX IF NOT EXISTS idx_runs_created
|
||||||
ON runs (created_at);
|
ON runs (created_at);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Миграция для существующих таблиц (добавляем колонки если их нет)
|
# ── Миграции: добавляем колонки которых может не быть в старых БД ──
|
||||||
|
# ALTER TABLE ADD COLUMN IF NOT EXISTS — безопасно, не ломает существующие данные
|
||||||
MIGRATION_SQL = """
|
MIGRATION_SQL = """
|
||||||
ALTER TABLE runs ADD COLUMN IF NOT EXISTS op_uid VARCHAR(64);
|
ALTER TABLE runs ADD COLUMN IF NOT EXISTS op_uid VARCHAR(64);
|
||||||
ALTER TABLE runs ADD COLUMN IF NOT EXISTS user_email VARCHAR(128);
|
ALTER TABLE runs ADD COLUMN IF NOT EXISTS user_email VARCHAR(128);
|
||||||
ALTER TABLE runs ADD COLUMN IF NOT EXISTS app_version VARCHAR(16);
|
ALTER TABLE runs ADD COLUMN IF NOT EXISTS app_version VARCHAR(16);
|
||||||
|
ALTER TABLE runs ADD COLUMN IF NOT EXISTS scenario_run_id INTEGER;
|
||||||
|
ALTER TABLE runs ADD COLUMN IF NOT EXISTS step_number INTEGER;
|
||||||
|
ALTER TABLE runs ADD COLUMN IF NOT EXISTS instance_meta JSONB;
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ── DDL для scenario_runs (запуски сценариев) ──
|
||||||
|
SCENARIO_RUNS_SQL = """
|
||||||
|
CREATE TABLE IF NOT EXISTS scenario_runs (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
client_id VARCHAR(64) NOT NULL,
|
||||||
|
stand VARCHAR(16) NOT NULL,
|
||||||
|
user_email VARCHAR(128),
|
||||||
|
scenario_name VARCHAR(200) NOT NULL,
|
||||||
|
status VARCHAR(16) NOT NULL DEFAULT 'RUNNING',
|
||||||
|
current_step INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_steps INTEGER NOT NULL,
|
||||||
|
instance_bindings JSONB DEFAULT '{}',
|
||||||
|
duration_sec DOUBLE PRECISION,
|
||||||
|
error_log TEXT,
|
||||||
|
app_version VARCHAR(16)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Индекс для списка последних запусков (основной запрос)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_scenario_runs_client_stand
|
||||||
|
ON scenario_runs (client_id, stand, created_at DESC);
|
||||||
|
|
||||||
|
-- Индекс для проверки lock (есть ли RUNNING)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_scenario_runs_status
|
||||||
|
ON scenario_runs (client_id, stand, status);
|
||||||
|
|
||||||
|
-- Миграции для scenario_runs
|
||||||
|
ALTER TABLE scenario_runs ADD COLUMN IF NOT EXISTS definition_id INTEGER;
|
||||||
|
ALTER TABLE scenario_runs ADD COLUMN IF NOT EXISTS definition_version INTEGER;
|
||||||
|
|
||||||
|
-- ── DDL для scenario_definitions (определения, редактируются через UI) ──
|
||||||
|
CREATE TABLE IF NOT EXISTS scenario_definitions (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
client_id VARCHAR(64) NOT NULL,
|
||||||
|
stand VARCHAR(16) NOT NULL,
|
||||||
|
name VARCHAR(200) NOT NULL,
|
||||||
|
steps JSONB NOT NULL DEFAULT '[]',
|
||||||
|
version INTEGER NOT NULL DEFAULT 1,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_by VARCHAR(128)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Уникальность имени в рамках пользователя и стенда
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_scenario_defs_unique
|
||||||
|
ON scenario_definitions (client_id, stand, LOWER(name));
|
||||||
|
|
||||||
|
-- Индекс для фильтрации активных
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_scenario_defs_active
|
||||||
|
ON scenario_definitions (client_id, stand, is_active);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_scenarios():
|
||||||
|
"""Импорт дефолтных сценариев из scenario_seed.yaml при первом старте.
|
||||||
|
|
||||||
|
Формат YAML:
|
||||||
|
scenarios:
|
||||||
|
- name: "dummy_test"
|
||||||
|
client_id: "" # пустой = seed (доступен всем)
|
||||||
|
stand: "" # пустой = seed
|
||||||
|
steps: [...]
|
||||||
|
|
||||||
|
INSERT ON CONFLICT DO NOTHING — если пользователь уже создал сценарий
|
||||||
|
с таким именем, seed не перезапишет его."""
|
||||||
|
try:
|
||||||
|
import yaml, os
|
||||||
|
path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scenario_seed.yaml")
|
||||||
|
if not os.path.exists(path):
|
||||||
|
return
|
||||||
|
with open(path) as f:
|
||||||
|
data = yaml.safe_load(f) or {}
|
||||||
|
seed_list = data.get("scenarios", [])
|
||||||
|
if not seed_list:
|
||||||
|
return
|
||||||
|
conn = get_conn()
|
||||||
|
if not conn:
|
||||||
|
return
|
||||||
|
cur = conn.cursor()
|
||||||
|
for s in seed_list:
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO scenario_definitions (client_id, stand, name, steps)
|
||||||
|
VALUES (%s, %s, %s, %s)
|
||||||
|
ON CONFLICT (client_id, stand, LOWER(name)) DO NOTHING
|
||||||
|
""", (s.get("client_id", ""), s.get("stand", ""), s["name"], json.dumps(s.get("steps", []))))
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
put_conn(conn)
|
||||||
|
print("[DB] Seed scenarios OK", flush=True)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DB] Seed scenarios error: {e}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
def init_db():
|
def init_db():
|
||||||
"""Применить схему + миграции — идемпотентно, безопасно для конкурентного вызова."""
|
"""Применить схему + миграции — идемпотентно, безопасно для конкуретного вызова.
|
||||||
|
|
||||||
|
Порядок:
|
||||||
|
1. Создать таблицы (CREATE IF NOT EXISTS)
|
||||||
|
2. Применить миграции (ALTER TABLE ADD COLUMN IF NOT EXISTS)
|
||||||
|
3. Startup cleanup: зависшие RUNNING → TIMEOUT
|
||||||
|
4. Seed дефолтных сценариев
|
||||||
|
"""
|
||||||
|
# Без DB_USER приложение работает без БД (без истории)
|
||||||
if not os.getenv("DB_USER"):
|
if not os.getenv("DB_USER"):
|
||||||
print("[DB] DB_USER not set — skipping init", flush=True)
|
print("[DB] DB_USER not set — skipping init", flush=True)
|
||||||
return
|
return
|
||||||
@@ -72,13 +201,38 @@ def init_db():
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
# Шаг 1-2: схема + миграции
|
||||||
cur.execute(SCHEMA_SQL)
|
cur.execute(SCHEMA_SQL)
|
||||||
cur.execute(MIGRATION_SQL)
|
cur.execute(MIGRATION_SQL)
|
||||||
|
cur.execute(SCENARIO_RUNS_SQL)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
cur.close()
|
cur.close()
|
||||||
print("[DB] Schema initialized", flush=True)
|
print("[DB] Schema initialized", flush=True)
|
||||||
|
|
||||||
|
# Шаг 3: Startup cleanup — перевести зависшие RUNNING в TIMEOUT
|
||||||
|
# Если gunicorn упал во время выполнения сценария, статус остался RUNNING.
|
||||||
|
# Чистим всё что старше 1 часа — операция точно не могла длиться дольше.
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE scenario_runs
|
||||||
|
SET status = 'TIMEOUT', error_log = 'worker restart'
|
||||||
|
WHERE status = 'RUNNING'
|
||||||
|
AND created_at < NOW() - INTERVAL '1 hour'
|
||||||
|
""")
|
||||||
|
if cur.rowcount:
|
||||||
|
print(f"[DB] Cleaned up {cur.rowcount} stuck scenario_runs", flush=True)
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DB] Cleanup error: {e}", flush=True)
|
||||||
|
conn.rollback()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[DB] Init error: {e}", flush=True)
|
print(f"[DB] Init error: {e}", flush=True)
|
||||||
conn.rollback()
|
conn.rollback()
|
||||||
finally:
|
finally:
|
||||||
put_conn(conn)
|
put_conn(conn)
|
||||||
|
|
||||||
|
# Шаг 4: Seed сценариев (вне основной транзакции)
|
||||||
|
_seed_scenarios()
|
||||||
|
|||||||
+46
-11
@@ -1,26 +1,41 @@
|
|||||||
"""
|
"""
|
||||||
Connection pool для PostgreSQL (psycopg2).
|
Connection pool для PostgreSQL (psycopg2) — lazy-init в каждом gunicorn-воркере.
|
||||||
|
|
||||||
Lazy-init: пул создаётся при первом обращении к БД в каждом gunicorn-воркере.
|
Почему lazy-init: gunicorn форкает воркеры, и если создать пул до fork,
|
||||||
|
все воркеры будут делить ОДНО соединение → гонки и падения.
|
||||||
|
Поэтому _pool создаётся при ПЕРВОМ обращении к БД в каждом воркере отдельно.
|
||||||
|
|
||||||
Переменные окружения (КАЖДАЯ отдельно):
|
Переменные окружения (КАЖДАЯ отдельно, не DSN-строкой):
|
||||||
DB_HOST — хост
|
DB_HOST — хост PostgreSQL
|
||||||
DB_PORT — порт (5432)
|
DB_PORT — порт (по умолчанию 5432)
|
||||||
DB_NAME — имя БД
|
DB_NAME — имя базы данных
|
||||||
DB_USER — пользователь
|
DB_USER — пользователь
|
||||||
DB_PASSWORD — пароль
|
DB_PASSWORD — пароль
|
||||||
DB_SSLMODE — sslmode (require)
|
DB_SSLMODE — sslmode (по умолчанию "require")
|
||||||
|
|
||||||
|
ThreadedConnectionPool(1, 5):
|
||||||
|
- Минимум 1 соединение (всегда готово)
|
||||||
|
- Максимум 5 одновременных соединений на воркер
|
||||||
|
- При 2 воркерах gunicorn: макс. 10 соединений к БД всего
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import psycopg2
|
import psycopg2
|
||||||
from psycopg2 import pool
|
from psycopg2 import pool
|
||||||
|
|
||||||
|
# _pool — по одному на gunicorn-воркер (создаётся при первом get_conn)
|
||||||
_pool = None
|
_pool = None
|
||||||
|
# _initialized — схема уже применена в этом воркере
|
||||||
_initialized = False
|
_initialized = False
|
||||||
|
|
||||||
|
|
||||||
def _dsn():
|
def _dsn():
|
||||||
|
"""Построить DSN-строку из отдельных переменных окружения.
|
||||||
|
|
||||||
|
DSN (Data Source Name) — это строка подключения для psycopg2:
|
||||||
|
"host=... port=... dbname=... user=... password=... sslmode=..."
|
||||||
|
|
||||||
|
Каждое поле отдельной переменной — безопаснее чем один DATABASE_URL."""
|
||||||
return (
|
return (
|
||||||
f"host={os.getenv('DB_HOST')} "
|
f"host={os.getenv('DB_HOST')} "
|
||||||
f"port={os.getenv('DB_PORT', '5432')} "
|
f"port={os.getenv('DB_PORT', '5432')} "
|
||||||
@@ -32,7 +47,10 @@ def _dsn():
|
|||||||
|
|
||||||
|
|
||||||
def _ensure_schema():
|
def _ensure_schema():
|
||||||
"""Инициализировать схему при первом обращении к БД."""
|
"""Инициализировать схему БД при первом обращении в этом воркере.
|
||||||
|
|
||||||
|
Вызывает init_db() который делает CREATE IF NOT EXISTS — идемпотентно.
|
||||||
|
_initialized — глобальный флаг чтобы не дёргать init_db при каждом запросе."""
|
||||||
global _initialized
|
global _initialized
|
||||||
if _initialized:
|
if _initialized:
|
||||||
return
|
return
|
||||||
@@ -41,25 +59,42 @@ def _ensure_schema():
|
|||||||
init_db()
|
init_db()
|
||||||
_initialized = True
|
_initialized = True
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass # без БД приложение работает (без истории)
|
||||||
|
|
||||||
|
|
||||||
def get_pool():
|
def get_pool():
|
||||||
|
"""Получить connection pool (создать при первом вызове).
|
||||||
|
|
||||||
|
ThreadedConnectionPool — каждый поток получает своё соединение.
|
||||||
|
Для gunicorn с sync-воркерами (не threads) это эквивалентно SimpleConnectionPool.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ThreadedConnectionPool или None если DB_USER не задан."""
|
||||||
global _pool
|
global _pool
|
||||||
if _pool is None:
|
if _pool is None:
|
||||||
if not os.getenv("DB_USER"):
|
if not os.getenv("DB_USER"):
|
||||||
return None
|
return None # нет переменных БД — работаем без истории
|
||||||
_pool = pool.ThreadedConnectionPool(1, 5, _dsn())
|
_pool = pool.ThreadedConnectionPool(1, 5, _dsn())
|
||||||
_ensure_schema()
|
_ensure_schema() # при первом соединении применяем схему
|
||||||
return _pool
|
return _pool
|
||||||
|
|
||||||
|
|
||||||
def get_conn():
|
def get_conn():
|
||||||
|
"""Взять соединение из пула.
|
||||||
|
|
||||||
|
Вызывается перед КАЖДЫМ запросом к БД.
|
||||||
|
Возвращает None если БД не настроена.
|
||||||
|
|
||||||
|
ВАЖНО: после использования ОБЯЗАТЕЛЬНО вернуть через put_conn()."""
|
||||||
p = get_pool()
|
p = get_pool()
|
||||||
return p.getconn() if p else None
|
return p.getconn() if p else None
|
||||||
|
|
||||||
|
|
||||||
def put_conn(conn):
|
def put_conn(conn):
|
||||||
|
"""Вернуть соединение в пул.
|
||||||
|
|
||||||
|
ВСЕГДА вызывается в finally-блоке после get_conn.
|
||||||
|
Если не вернуть — пул исчерпается и приложение встанет."""
|
||||||
p = get_pool()
|
p = get_pool()
|
||||||
if p and conn:
|
if p and conn:
|
||||||
p.putconn(conn)
|
p.putconn(conn)
|
||||||
|
|||||||
+45
-6
@@ -1,5 +1,13 @@
|
|||||||
"""
|
"""
|
||||||
Сохранение результатов тестов в БД.
|
Сохранение результатов тестов в БД (таблица runs).
|
||||||
|
|
||||||
|
Вызывается после КАЖДОЙ операции:
|
||||||
|
- Ручной режим: _finish_op() в api_test.py
|
||||||
|
- Сценарный режим: run_scenario() в scenario.py (после каждого шага)
|
||||||
|
|
||||||
|
Сохраняет ВСЕ параметры запроса и ответа — для истории и анализа ошибок.
|
||||||
|
Секретные поля (password, token, ...) маскируются через redact_params().
|
||||||
|
instance_meta — полный JSON ответа GET /instances/{uid} (для post-mortem анализа).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
@@ -8,8 +16,34 @@ from db.pool import get_conn, put_conn
|
|||||||
|
|
||||||
def save_run(client_id, stand, user_email, svc_id, svc_name, op_name, svc_op_id,
|
def save_run(client_id, stand, user_email, svc_id, svc_name, op_name, svc_op_id,
|
||||||
op_uid, instance_uid, display_name, status, duration_sec,
|
op_uid, instance_uid, display_name, status, duration_sec,
|
||||||
error_log, params, stages, app_version):
|
error_log, params, stages, app_version,
|
||||||
"""Сохранить один запуск в таблицу runs."""
|
scenario_run_id=None, step_number=None, instance_meta=None):
|
||||||
|
"""Сохранить один запуск в таблицу runs.
|
||||||
|
|
||||||
|
Эта функция — единственная точка записи в runs.
|
||||||
|
Вызывается и из ручного режима, и из сценариев.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client_id: str — ClientID пользователя
|
||||||
|
stand: str — "dev"/"test"
|
||||||
|
user_email: str — email из JWT
|
||||||
|
svc_id: int — ID сервиса
|
||||||
|
svc_name: str — имя сервиса (из ответа poll)
|
||||||
|
op_name: str — "create"/"modify"/"delete"/"suspend"/"resume"/"redeploy"
|
||||||
|
svc_op_id: int — числовой ID операции
|
||||||
|
op_uid: str — UUID операции
|
||||||
|
instance_uid: str — UUID инстанса
|
||||||
|
display_name: str — displayName инстанса
|
||||||
|
status: str — "OK"/"FAIL"/"TIMEOUT"/"RUNNING"
|
||||||
|
duration_sec: float — длительность операции
|
||||||
|
error_log: str — текст ошибки (если FAIL)
|
||||||
|
params: dict — параметры операции (уже маскированные)
|
||||||
|
stages: list — этапы выполнения
|
||||||
|
app_version: str — версия приложения
|
||||||
|
scenario_run_id: int|None — ID запуска сценария
|
||||||
|
step_number: int|None — номер шага в сценарии
|
||||||
|
instance_meta: dict|None — полная информация об инстансе (JSONB)
|
||||||
|
"""
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
if not conn:
|
if not conn:
|
||||||
print("[DB] save_run SKIP: no connection", flush=True)
|
print("[DB] save_run SKIP: no connection", flush=True)
|
||||||
@@ -20,15 +54,20 @@ def save_run(client_id, stand, user_email, svc_id, svc_name, op_name, svc_op_id,
|
|||||||
cur.execute("""
|
cur.execute("""
|
||||||
INSERT INTO runs (client_id, stand, user_email, svc_id, svc_name,
|
INSERT INTO runs (client_id, stand, user_email, svc_id, svc_name,
|
||||||
op_name, svc_op_id, op_uid, instance_uid, display_name,
|
op_name, svc_op_id, op_uid, instance_uid, display_name,
|
||||||
status, duration_sec, error_log, params, stages, app_version)
|
status, duration_sec, error_log, params, stages, app_version,
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
scenario_run_id, step_number, instance_meta)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
""", (
|
""", (
|
||||||
client_id, stand, user_email, svc_id, svc_name, op_name, svc_op_id,
|
client_id, stand, user_email, svc_id, svc_name, op_name, svc_op_id,
|
||||||
op_uid, instance_uid, display_name, status, duration_sec,
|
op_uid, instance_uid, display_name, status, duration_sec,
|
||||||
error_log,
|
error_log,
|
||||||
|
# params и stages — JSONB: сериализуем в JSON-строку
|
||||||
json.dumps(params) if params else None,
|
json.dumps(params) if params else None,
|
||||||
json.dumps(stages) if stages else None,
|
json.dumps(stages) if stages else None,
|
||||||
app_version,
|
app_version,
|
||||||
|
scenario_run_id, step_number,
|
||||||
|
# instance_meta — тоже JSONB
|
||||||
|
json.dumps(instance_meta) if instance_meta else None,
|
||||||
))
|
))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
cur.close()
|
cur.close()
|
||||||
@@ -37,4 +76,4 @@ def save_run(client_id, stand, user_email, svc_id, svc_name, op_name, svc_op_id,
|
|||||||
print(f"[DB] save_run error: {e}", flush=True)
|
print(f"[DB] save_run error: {e}", flush=True)
|
||||||
conn.rollback()
|
conn.rollback()
|
||||||
finally:
|
finally:
|
||||||
put_conn(conn)
|
put_conn(conn) # ВСЕГДА возвращаем соединение в пул
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
"""
|
||||||
|
CRUD для таблицы scenario_definitions — определения сценариев.
|
||||||
|
|
||||||
|
Сценарии редактируются через UI (модальный редактор), хранятся в БД.
|
||||||
|
Это позволяет менять сценарии БЕЗ редеплоя приложения.
|
||||||
|
|
||||||
|
ОПТИМИСТИЧНАЯ БЛОКИРОВКА (version):
|
||||||
|
update_definition проверяет version — если другой пользователь уже изменил
|
||||||
|
сценарий, возвращает conflict=true → UI показывает "Конфликт версий".
|
||||||
|
|
||||||
|
МЯГКОЕ УДАЛЕНИЕ:
|
||||||
|
delete_definition ставит is_active=FALSE — не удаляет физически.
|
||||||
|
Это позволяет восстановить случайно удалённый сценарий.
|
||||||
|
|
||||||
|
SEED-СЦЕНАРИИ:
|
||||||
|
Сценарии с client_id="" и stand="" — seed (дефолтные, из YAML).
|
||||||
|
Они доступны всем пользователям но не могут быть удалены через UI.
|
||||||
|
|
||||||
|
Функции:
|
||||||
|
list_definitions(client_id, stand) — список активных (+seed)
|
||||||
|
get_definition(def_id, cid, stand) — один (по id)
|
||||||
|
create_definition(...) → {id, version}
|
||||||
|
update_definition(...) → {ok, version} | {conflict: true}
|
||||||
|
delete_definition(...) → bool
|
||||||
|
lock_check(cid, stand) — есть ли RUNNING → False
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from db.pool import get_conn, put_conn
|
||||||
|
|
||||||
|
|
||||||
|
def list_definitions(client_id, stand):
|
||||||
|
"""Список ВСЕХ активных определений: пользовательские + seed.
|
||||||
|
|
||||||
|
Seed-сценарии (client_id="" AND stand="") видны всем.
|
||||||
|
Пользовательские — только для текущего пользователя и стенда.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list[dict] с ключами: id, name, steps, version, is_active,
|
||||||
|
created_at, updated_at, client_id, stand, is_seed (bool)."""
|
||||||
|
conn = get_conn()
|
||||||
|
if not conn:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
# Два условия через OR: пользовательские + seed
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, name, steps, version, is_active, created_at, updated_at,
|
||||||
|
client_id, stand
|
||||||
|
FROM scenario_definitions
|
||||||
|
WHERE is_active = TRUE
|
||||||
|
AND ((client_id = %s AND stand = %s) OR (client_id = '' AND stand = ''))
|
||||||
|
ORDER BY name
|
||||||
|
""", (client_id, stand))
|
||||||
|
rows = cur.fetchall()
|
||||||
|
cols = [d[0] for d in cur.description]
|
||||||
|
result = []
|
||||||
|
for r in rows:
|
||||||
|
d = dict(zip(cols, r))
|
||||||
|
# Конвертируем datetime → ISO-строка (JSON-совместимо)
|
||||||
|
d["created_at"] = d["created_at"].isoformat() if d["created_at"] else None
|
||||||
|
d["updated_at"] = d["updated_at"].isoformat() if d["updated_at"] else None
|
||||||
|
# is_seed: client_id пустой → seed (из YAML)
|
||||||
|
d["is_seed"] = (d.get("client_id") == "" and d.get("stand") == "")
|
||||||
|
result.append(d)
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DEFS] list error: {e}", flush=True)
|
||||||
|
return []
|
||||||
|
finally:
|
||||||
|
put_conn(conn)
|
||||||
|
|
||||||
|
|
||||||
|
def get_definition(def_id, client_id, stand):
|
||||||
|
"""Получить ОДНО определение по id.
|
||||||
|
|
||||||
|
Доступ: пользовательские (client_id+stand) ИЛИ seed (пустые).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict или None если не найдено."""
|
||||||
|
conn = get_conn()
|
||||||
|
if not conn:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, name, steps, version, is_active, created_at, updated_at, updated_by
|
||||||
|
FROM scenario_definitions
|
||||||
|
WHERE id = %s AND ((client_id = %s AND stand = %s) OR (client_id = '' AND stand = ''))
|
||||||
|
""", (def_id, client_id, stand))
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
cols = [d[0] for d in cur.description]
|
||||||
|
d = dict(zip(cols, row))
|
||||||
|
d["created_at"] = d["created_at"].isoformat() if d["created_at"] else None
|
||||||
|
d["updated_at"] = d["updated_at"].isoformat() if d["updated_at"] else None
|
||||||
|
return d
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DEFS] get error: {e}", flush=True)
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
put_conn(conn)
|
||||||
|
|
||||||
|
|
||||||
|
def create_definition(client_id, stand, name, steps, updated_by=""):
|
||||||
|
"""Создать ИЛИ перезаписать определение (upsert).
|
||||||
|
|
||||||
|
ON CONFLICT (client_id, stand, LOWER(name)):
|
||||||
|
Если сценарий с таким именем уже существует → UPDATE (перезапись steps, version+1).
|
||||||
|
Если нет → INSERT.
|
||||||
|
|
||||||
|
Это позволяет «сохранить» без проверки существования.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{id: int, version: int} или None при ошибке."""
|
||||||
|
conn = get_conn()
|
||||||
|
if not conn:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO scenario_definitions (client_id, stand, name, steps, updated_by)
|
||||||
|
VALUES (%s, %s, %s, %s, %s)
|
||||||
|
ON CONFLICT (client_id, stand, LOWER(name))
|
||||||
|
DO UPDATE SET steps = EXCLUDED.steps, version = scenario_definitions.version + 1,
|
||||||
|
is_active = TRUE, updated_at = NOW(), updated_by = EXCLUDED.updated_by
|
||||||
|
RETURNING id, version
|
||||||
|
""", (client_id, stand, name, json.dumps(steps), updated_by))
|
||||||
|
row = cur.fetchone()
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
return {"id": row[0], "version": row[1]}
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DEFS] create error: {e}", flush=True)
|
||||||
|
conn.rollback()
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
put_conn(conn)
|
||||||
|
|
||||||
|
|
||||||
|
def update_definition(def_id, client_id, stand, name, steps, version, updated_by=""):
|
||||||
|
"""Обновить определение с ОПТИМИСТИЧНОЙ БЛОКИРОВКОЙ.
|
||||||
|
|
||||||
|
WHERE version = %s — если версия изменилась (другой пользователь/вкладка),
|
||||||
|
UPDATE затронет 0 строк → возвращаем {conflict: true}.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{ok: True, version: int} — успех
|
||||||
|
{conflict: True} — версия устарела (409 Conflict)
|
||||||
|
{error: str} — ошибка БД"""
|
||||||
|
conn = get_conn()
|
||||||
|
if not conn:
|
||||||
|
return {"error": "no db"}
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE scenario_definitions
|
||||||
|
SET name = %s, steps = %s, version = version + 1,
|
||||||
|
updated_at = NOW(), updated_by = %s
|
||||||
|
WHERE id = %s AND client_id = %s AND stand = %s AND version = %s
|
||||||
|
RETURNING version
|
||||||
|
""", (name, json.dumps(steps), updated_by, def_id, client_id, stand, version))
|
||||||
|
row = cur.fetchone()
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
if row:
|
||||||
|
return {"ok": True, "version": row[0]}
|
||||||
|
# 0 строк — версия не совпала (кто-то уже изменил)
|
||||||
|
return {"conflict": True}
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DEFS] update error: {e}", flush=True)
|
||||||
|
conn.rollback()
|
||||||
|
return {"error": str(e)}
|
||||||
|
finally:
|
||||||
|
put_conn(conn)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_definition(def_id, client_id, stand):
|
||||||
|
"""Мягкое удаление: is_active = FALSE.
|
||||||
|
|
||||||
|
Только для пользовательских сценариев (client_id не пустой).
|
||||||
|
Seed-сценарии не могут быть удалены через этот метод.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool — True если удалён, False если не найден."""
|
||||||
|
conn = get_conn()
|
||||||
|
if not conn:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE scenario_definitions
|
||||||
|
SET is_active = FALSE, updated_at = NOW()
|
||||||
|
WHERE id = %s AND client_id = %s AND stand = %s
|
||||||
|
""", (def_id, client_id, stand))
|
||||||
|
ok = cur.rowcount > 0
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
return ok
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DEFS] delete error: {e}", flush=True)
|
||||||
|
conn.rollback()
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
put_conn(conn)
|
||||||
|
|
||||||
|
|
||||||
|
def lock_check(client_id, stand):
|
||||||
|
"""Проверить что нет активного RUNNING-запуска сценария.
|
||||||
|
|
||||||
|
Используется перед запуском нового сценария — предотвращает
|
||||||
|
одновременный запуск двух сценариев одним пользователем.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True — можно запускать (нет RUNNING)
|
||||||
|
False — нельзя (уже есть RUNNING) → 409 Conflict"""
|
||||||
|
conn = get_conn()
|
||||||
|
if not conn:
|
||||||
|
return True # без БД — разрешаем (fallback)
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id FROM scenario_runs
|
||||||
|
WHERE client_id = %s AND stand = %s AND status = 'RUNNING'
|
||||||
|
LIMIT 1
|
||||||
|
""", (client_id, stand))
|
||||||
|
row = cur.fetchone()
|
||||||
|
cur.close()
|
||||||
|
return row is None # None = нет RUNNING = можно запускать
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DEFS] lock_check error: {e}", flush=True)
|
||||||
|
return True
|
||||||
|
finally:
|
||||||
|
put_conn(conn)
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
"""
|
||||||
|
Единый executor операции Nubes — ЕДИНСТВЕННАЯ точка запуска операций.
|
||||||
|
|
||||||
|
Выполняет ВСЕ шаги от начала до /run (включительно):
|
||||||
|
1. CREATE: POST /instances → получает instanceUid
|
||||||
|
2. CREATE + NON-CREATE: POST /instanceOperations → получает opUid
|
||||||
|
3. Параметры: send_params_terraform → нормализация + refSvc-резолв + отправка
|
||||||
|
4. POST /instanceOperations/{opUid}/run → запуск
|
||||||
|
|
||||||
|
НЕ делает поллинг — поллинг вынесен в poll.py (используется отдельно).
|
||||||
|
|
||||||
|
Критический инвариант: tracker_add вызывается СРАЗУ после получения instanceUid
|
||||||
|
(до params и run), чтобы даже при падении gunicorn инстанс не стал сиротой.
|
||||||
|
|
||||||
|
Используется:
|
||||||
|
- api_test.py → ручной режим (фронтенд → POST /api/test → execute_operation)
|
||||||
|
- scenario.py → сценарный режим (каждый шаг → execute_operation)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from api.utils import find_uid, uid_from_location
|
||||||
|
from operations.terraform import send_params_terraform
|
||||||
|
from operations.tracker import add as tracker_add
|
||||||
|
|
||||||
|
|
||||||
|
def execute_operation(client, service_id, operation, instance_uid, params,
|
||||||
|
svc_op_id=None, display_name=None, descr=None,
|
||||||
|
client_id="", stand=""):
|
||||||
|
"""Запустить операцию и вернуть результат (без поллинга).
|
||||||
|
|
||||||
|
Эта функция — «единый шлюз»: и ручной тест, и сценарий идут через неё.
|
||||||
|
Это гарантирует что поведение create/modify/delete идентично в обоих режимах.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: HttpClient (уже с токеном и endpoint)
|
||||||
|
service_id: int — ID сервиса Nubes (напр. 1 для Болванки)
|
||||||
|
operation: str — "create" | "modify" | "delete" | "suspend" | "resume" | "redeploy"
|
||||||
|
instance_uid: str|None — UUID существующего инстанса.
|
||||||
|
None для create (инстанса ещё нет).
|
||||||
|
params: dict — {numeric_param_id: string_value}
|
||||||
|
Пример: {"198": "0", "199": "test-value"}
|
||||||
|
svc_op_id: int|None — svcOperationId (числовой ID операции в Nubes).
|
||||||
|
Для create игнорируется (там свой ID из шаблона сервиса).
|
||||||
|
display_name: str|None — displayName для create.
|
||||||
|
Если None → генерируется "autotest-{service_id}".
|
||||||
|
descr: str|None — описание инстанса (только для create).
|
||||||
|
Если None → "created by autotest".
|
||||||
|
client_id: str — ID пользователя (для tracker_add).
|
||||||
|
stand: str — "dev" / "test" (для tracker_add).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict с ВСЕГДА одинаковыми ключами:
|
||||||
|
{ok, error, failed_step, instance_uid, op_uid, display_name}
|
||||||
|
|
||||||
|
ok=True → успех, можно поллить
|
||||||
|
ok=False → ошибка на шаге failed_step:
|
||||||
|
"instances" — не смогли создать инстанс (POST /instances)
|
||||||
|
"instanceOperations"— не смогли создать операцию (POST /instanceOperations)
|
||||||
|
"params" — ошибка в send_params_terraform
|
||||||
|
"run" — не смогли запустить (/run)
|
||||||
|
"""
|
||||||
|
# ── Определяем режим: create или нет ──
|
||||||
|
# Все не-create операции требуют существующий instance_uid
|
||||||
|
is_create = (operation == "create")
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════
|
||||||
|
# Шаг 1: POST /instances — только для create
|
||||||
|
# ═══════════════════════════════════════════════════════
|
||||||
|
# Создаём инстанс в Nubes, получаем его UUID.
|
||||||
|
# Это аналог нажатия «Создать» в личном кабинете.
|
||||||
|
if is_create:
|
||||||
|
# display_name — обязателен для create.
|
||||||
|
# Если не передан — генерируем из service_id.
|
||||||
|
if not display_name:
|
||||||
|
display_name = f"autotest-{service_id}"
|
||||||
|
|
||||||
|
# descr — описание, видно в личном кабинете.
|
||||||
|
if not descr:
|
||||||
|
descr = "created by autotest"
|
||||||
|
|
||||||
|
# payload для POST /instances:
|
||||||
|
# serviceId — какой сервис создаём
|
||||||
|
# displayName — имя инстанса (уникально в рамках пользователя)
|
||||||
|
# descr — текстовое описание
|
||||||
|
payload = {"serviceId": service_id, "displayName": display_name, "descr": descr}
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = client.post("/instances", payload)
|
||||||
|
except Exception as e:
|
||||||
|
# Сетевая ошибка или HTTP-ошибка → сразу FAIL
|
||||||
|
return {"ok": False, "error": str(e), "failed_step": "instances",
|
||||||
|
"instance_uid": None, "op_uid": None, "display_name": display_name}
|
||||||
|
|
||||||
|
# Извлекаем instanceUid из ответа API.
|
||||||
|
# Nubes API может вернуть UUID в разных местах ответа:
|
||||||
|
# 1. resp["instanceUid"] — прямой ключ
|
||||||
|
# 2. resp["instance"]["instanceUid"] — вложенный (через find_uid)
|
||||||
|
# 3. Location-заголовок — "./uuid" (через uid_from_location)
|
||||||
|
instance_uid = resp.get("instanceUid") or find_uid(resp) or uid_from_location(resp.get("_location", ""))
|
||||||
|
|
||||||
|
# Если ни один метод не дал UUID — это баг API, не можем продолжать
|
||||||
|
if not instance_uid:
|
||||||
|
return {"ok": False, "error": "No instanceUid in response", "failed_step": "instances",
|
||||||
|
"instance_uid": None, "op_uid": None, "display_name": display_name}
|
||||||
|
|
||||||
|
# ⚠️ КРИТИЧЕСКИ: tracker_add СРАЗУ после получения instanceUid.
|
||||||
|
# Зачем: если gunicorn упадёт между create и run, инстанс останется в Nubes
|
||||||
|
# но не будет виден в UI (GET /instances иногда задерживает новый инстанс).
|
||||||
|
# Трекер — краткосрочный fallback, пока облако не подхватит.
|
||||||
|
# try/except: не ронять операцию если /tmp/ переполнен или нет прав.
|
||||||
|
try:
|
||||||
|
tracker_add(client_id, stand, instance_uid, service_id, display_name)
|
||||||
|
except Exception:
|
||||||
|
pass # silently ignore — трекер не критичен для операции
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════
|
||||||
|
# Шаг 2: POST /instanceOperations — создаём операцию
|
||||||
|
# ═══════════════════════════════════════════════════════
|
||||||
|
# Для create: {instanceUid, operation} — svcOperationId не нужен
|
||||||
|
# Для не-create: {instanceUid, svcOperationId, operation} — нужен ID операции
|
||||||
|
#
|
||||||
|
# Разница: для create система сама знает какой svcOperationId использовать
|
||||||
|
# (он единственный для create у каждого сервиса).
|
||||||
|
# Для modify/delete/suspend/resume — их много, нужно указать конкретный.
|
||||||
|
if is_create:
|
||||||
|
op_payload = {"instanceUid": instance_uid, "operation": operation}
|
||||||
|
else:
|
||||||
|
op_payload = {"instanceUid": instance_uid, "svcOperationId": svc_op_id, "operation": operation}
|
||||||
|
|
||||||
|
try:
|
||||||
|
op_resp = client.post("/instanceOperations", op_payload)
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "error": str(e), "failed_step": "instanceOperations",
|
||||||
|
"instance_uid": instance_uid, "op_uid": None, "display_name": display_name}
|
||||||
|
|
||||||
|
# Извлекаем opUid — UUID операции, нужен для поллинга и /run
|
||||||
|
op_uid = op_resp.get("instanceOperationUid") or find_uid(op_resp) or uid_from_location(op_resp.get("_location", ""))
|
||||||
|
|
||||||
|
if not op_uid:
|
||||||
|
return {"ok": False, "error": "No opUid in response", "failed_step": "instanceOperations",
|
||||||
|
"instance_uid": instance_uid, "op_uid": None, "display_name": display_name}
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════
|
||||||
|
# Шаг 3: Параметры — отправка значений CFS-параметров
|
||||||
|
# ═══════════════════════════════════════════════════════
|
||||||
|
# send_params_terraform делает 4 вещи:
|
||||||
|
# 1. Получает cfsParams шаблон операции
|
||||||
|
# 2. resolveRefSvc — подставляет UUID инстансов для refSvcId-параметров
|
||||||
|
# 3. Отправляет пользовательские значения
|
||||||
|
# 4. Отправляет defaults для незаполненных параметров + validate-cfs
|
||||||
|
try:
|
||||||
|
send_params_terraform(client, op_uid, params)
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "error": str(e), "failed_step": "params",
|
||||||
|
"instance_uid": instance_uid, "op_uid": op_uid, "display_name": display_name}
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════
|
||||||
|
# Шаг 4: POST /instanceOperations/{opUid}/run — запуск
|
||||||
|
# ═══════════════════════════════════════════════════════
|
||||||
|
# После этого операция начинает выполняться в Nubes.
|
||||||
|
# Дальше — поллинг (poll_until_done), который делает вызывающий код.
|
||||||
|
try:
|
||||||
|
client.post(f"/instanceOperations/{op_uid}/run")
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "error": str(e), "failed_step": "run",
|
||||||
|
"instance_uid": instance_uid, "op_uid": op_uid, "display_name": display_name}
|
||||||
|
|
||||||
|
# Успех — все 4 шага пройдены
|
||||||
|
return {"ok": True, "error": None, "failed_step": None,
|
||||||
|
"instance_uid": instance_uid, "op_uid": op_uid, "display_name": display_name}
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
"""
|
"""
|
||||||
Операции с инстансами Nubes — получение списка и организации через API.
|
Операции с инстансами Nubes — получение списка и организации через API.
|
||||||
|
|
||||||
|
Используется:
|
||||||
|
- main.py → отображение инфраструктуры на главной
|
||||||
|
- api_test.py → список инстансов для выбора
|
||||||
|
- scenario.py → поиск refSvcId-инстансов
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from api.http_client import HttpClient
|
from api.http_client import HttpClient
|
||||||
@@ -8,9 +13,16 @@ from api.http_client import HttpClient
|
|||||||
def get_instances(client):
|
def get_instances(client):
|
||||||
"""GET /instances с пагинацией → ВСЕ инстансы пользователя.
|
"""GET /instances с пагинацией → ВСЕ инстансы пользователя.
|
||||||
|
|
||||||
pageSize=200 — максимум за один запрос.
|
pageSize=200 — максимум за один запрос (API лимит).
|
||||||
Остановка по len(batch) < pageSize (НЕ доверяет total — бывали баги).
|
Остановка по len(batch) < pageSize — НЕ доверяет total из ответа.
|
||||||
Возвращает полный list[dict]."""
|
|
||||||
|
Почему не доверяем total:
|
||||||
|
Бывали баги где API возвращал total=500, а реально инстансов 3.
|
||||||
|
Проверка по размеру страницы надёжнее.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list[dict] — каждый dict содержит instanceUid, displayName, serviceId,
|
||||||
|
svc, explainedStatus, instanceConfigDtCreated и др."""
|
||||||
results = []
|
results = []
|
||||||
page = 1
|
page = 1
|
||||||
page_size = 200
|
page_size = 200
|
||||||
@@ -18,8 +30,8 @@ def get_instances(client):
|
|||||||
data = client.get("/instances", params={"pageSize": page_size, "page": page})
|
data = client.get("/instances", params={"pageSize": page_size, "page": page})
|
||||||
batch = data.get("results", []) or []
|
batch = data.get("results", []) or []
|
||||||
results.extend(batch)
|
results.extend(batch)
|
||||||
# Если страница неполная — данные закончились.
|
# Если страница неполная — данных больше нет.
|
||||||
# Не проверяем total, потому что API иногда врёт про total.
|
# Это надёжнее чем проверка page * pageSize >= total.
|
||||||
if len(batch) < page_size:
|
if len(batch) < page_size:
|
||||||
break
|
break
|
||||||
page += 1
|
page += 1
|
||||||
@@ -29,7 +41,11 @@ def get_instances(client):
|
|||||||
def get_organization(client):
|
def get_organization(client):
|
||||||
"""Первый инстанс с serviceId=19 (Организация).
|
"""Первый инстанс с serviceId=19 (Организация).
|
||||||
|
|
||||||
Организация всегда одна на пользователя. Используется в UI: название, статус, ClientID."""
|
Организация всегда одна на пользователя — это его «корневой» инстанс.
|
||||||
|
Используется в UI: название организации, статус, ClientID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict или None — инстанс организации или None если не найден."""
|
||||||
for inst in get_instances(client):
|
for inst in get_instances(client):
|
||||||
if inst.get("serviceId") == 19:
|
if inst.get("serviceId") == 19:
|
||||||
return inst
|
return inst
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
"""
|
"""
|
||||||
Операция: получить параметры операции с ТЕКУЩИМИ значениями инстанса.
|
Операция: получить параметры операции с ТЕКУЩИМИ значениями инстанса.
|
||||||
|
|
||||||
|
Зачем: когда пользователь выбирает modify/suspend/resume, мы показываем
|
||||||
|
ТЕКУЩИЕ значения параметров (из state.params инстанса), а не шаблонные defaults.
|
||||||
|
Это позволяет видеть что реально настроено, а не то что было при создании.
|
||||||
|
|
||||||
Источник правды — Nubes API:
|
Источник правды — Nubes API:
|
||||||
1. GET /instances/{instanceUid} → state.params (текущие значения, ключ = код параметра)
|
1. GET /instances/{instanceUid} → state.params (текущие значения, ключ = код параметра)
|
||||||
2. GET /instanceOperations/default/{opId} (шаблон: код, тип, valueList, dataDescriptor)
|
2. GET /instanceOperations/default/{opId} (шаблон: код, тип, valueList, dataDescriptor)
|
||||||
3. Смержить: defaultValue = state.params["код"] ?? template.defaultValue
|
3. Смержить: defaultValue = state.params["код"] ?? template.defaultValue
|
||||||
|
|
||||||
|
Также заполняет пустые valueList из state.out (users/databases для PG и др.).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
@@ -12,50 +18,78 @@ def get_params_with_current_values(client, op_id, instance_uid):
|
|||||||
"""
|
"""
|
||||||
Возвращает список параметров (dict) для заполнения формы операции.
|
Возвращает список параметров (dict) для заполнения формы операции.
|
||||||
|
|
||||||
Каждый элемент:
|
Каждый элемент результирующего списка:
|
||||||
svcOperationCfsParamId — числовой ID параметра
|
svcOperationCfsParamId — числовой ID параметра (напр. 198)
|
||||||
name — код параметра (напр. "durationMs")
|
name — код параметра (напр. "durationMs")
|
||||||
dataType — тип (напр. "integer >= 0", "boolean", "string")
|
dataType — тип (напр. "integer >= 0", "boolean", "string")
|
||||||
isRequired — обязательный?
|
isRequired — обязательный?
|
||||||
defaultValue — ТЕКУЩЕЕ значение из state.params или шаблонный default
|
defaultValue — ТЕКУЩЕЕ значение из state.params или шаблонный default
|
||||||
valueList — список допустимых значений (если есть)
|
valueList — список допустимых значений (если есть)
|
||||||
|
refSvcId — ID сервиса для refSvc-параметров
|
||||||
dataDescriptor — вложенная структура для map-параметров (если есть)
|
dataDescriptor — вложенная структура для map-параметров (если есть)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: HttpClient
|
||||||
|
op_id: int — svcOperationId (напр. 18 для modify Болванки)
|
||||||
|
instance_uid: str — UUID инстанса (для получения state.params)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list[dict] — параметры со смерженными текущими значениями
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# --- Шаг 1: текущие значения из инстанса ---
|
# --- Шаг 1: текущие значения из инстанса ---
|
||||||
|
# GET /instances/{uid} возвращает полную информацию об инстансе
|
||||||
|
# Нас интересует instance.state.params — dict вида:
|
||||||
|
# {"whereFail": "1", "durationMs": "0", "diskSizeGb": "10"}
|
||||||
|
# Ключи — СИМВОЛИЧЕСКИЕ имена параметров (не числовые ID!)
|
||||||
inst_data = client.get(f"/instances/{instance_uid}")
|
inst_data = client.get(f"/instances/{instance_uid}")
|
||||||
state_params = inst_data.get("instance", {}).get("state", {}).get("params", {}) or {}
|
state_params = inst_data.get("instance", {}).get("state", {}).get("params", {}) or {}
|
||||||
# state_params — dict вида {"whereFail": "1", "durationMs": "0", ...}
|
|
||||||
|
# state.out — дополнительные данные (users, databases для PG-сервисов)
|
||||||
|
# Используется для заполнения пустых valueList (см. ниже)
|
||||||
state_out = inst_data.get("instance", {}).get("state", {}).get("out", {}) or {}
|
state_out = inst_data.get("instance", {}).get("state", {}).get("out", {}) or {}
|
||||||
|
|
||||||
# --- Шаг 2: шаблон параметров операции ---
|
# --- Шаг 2: шаблон параметров операции ---
|
||||||
|
# GET /instanceOperations/default/{opId} → svcOperation.cfsParams[]
|
||||||
|
# Это полный список параметров с типами, default-значениями, valueList и т.д.
|
||||||
tmpl_data = client.get(f"/instanceOperations/default/{op_id}")
|
tmpl_data = client.get(f"/instanceOperations/default/{op_id}")
|
||||||
tmpl_params = tmpl_data.get("svcOperation", {}).get("cfsParams", []) or []
|
tmpl_params = tmpl_data.get("svcOperation", {}).get("cfsParams", []) or []
|
||||||
|
|
||||||
# --- Шаг 3: слияние ---
|
# --- Шаг 3: слияние шаблона с текущими значениями ---
|
||||||
result = []
|
result = []
|
||||||
for p in tmpl_params:
|
for p in tmpl_params:
|
||||||
pid = p["svcOperationCfsParamId"] # числовой ID (напр. 198)
|
pid = p["svcOperationCfsParamId"] # числовой ID (напр. 198)
|
||||||
code = p.get("svcOperationCfsParam", "") # код/имя (напр. "durationMs")
|
code = p.get("svcOperationCfsParam", "") # символическое имя (напр. "durationMs")
|
||||||
data_type = p.get("dataType", "")
|
data_type = p.get("dataType", "")
|
||||||
required = p.get("isRequired", False)
|
required = p.get("isRequired", False)
|
||||||
tmpl_default = p.get("defaultValue") # шаблонный default
|
tmpl_default = p.get("defaultValue") # шаблонный default
|
||||||
value_list = p.get("valueList")
|
value_list = p.get("valueList")
|
||||||
# Заполнить пустые valueList из state.out (пользователи/базы PG и др.)
|
|
||||||
|
# Заполнить ПУСТЫЕ valueList из state.out.
|
||||||
|
# Некоторые сервисы (PG) возвращают valueList=[] в шаблоне,
|
||||||
|
# но реальный список значений (пользователи/базы) лежит в state.out.
|
||||||
|
# Пример: state.out.users = {"pgadmin": {...}, "appuser": {...}}
|
||||||
|
# → valueList = ["pgadmin", "appuser"]
|
||||||
if value_list is not None and not value_list:
|
if value_list is not None and not value_list:
|
||||||
code_lower = code.lower()
|
code_lower = code.lower()
|
||||||
|
# Параметр типа "пользователь" / "владелец" → state.out.users
|
||||||
if ("user" in code_lower or "owner" in code_lower) and isinstance(state_out.get("users"), dict):
|
if ("user" in code_lower or "owner" in code_lower) and isinstance(state_out.get("users"), dict):
|
||||||
value_list = sorted(state_out["users"].keys())
|
value_list = sorted(state_out["users"].keys())
|
||||||
|
# Параметр типа "база данных" → state.out.databases
|
||||||
elif "db" in code_lower and isinstance(state_out.get("databases"), dict):
|
elif "db" in code_lower and isinstance(state_out.get("databases"), dict):
|
||||||
value_list = sorted(state_out["databases"].keys())
|
value_list = sorted(state_out["databases"].keys())
|
||||||
dd = p.get("dataDescriptor") # вложенная структура (map-параметры)
|
|
||||||
|
|
||||||
# Берём текущее значение из state.params по коду,
|
dd = p.get("dataDescriptor") # вложенная структура (для map-параметров)
|
||||||
# если нет — используем шаблонный defaultValue.
|
|
||||||
|
# Выбираем defaultValue:
|
||||||
|
# Если в state.params есть значение для этого кода → используем его
|
||||||
|
# Иначе → шаблонный defaultValue
|
||||||
current = state_params.get(code)
|
current = state_params.get(code)
|
||||||
if current is not None:
|
if current is not None:
|
||||||
# Приводим к строке — фронт ожидает строки в полях ввода.
|
# Приводим к строке — фронтенд ожидает строки в полях ввода.
|
||||||
# bool → "true"/"false", dict → JSON, остальное → str()
|
# bool → "true"/"false" (не "True"/"False" — JS не поймёт)
|
||||||
|
# dict → JSON-строка
|
||||||
|
# остальное → str()
|
||||||
if isinstance(current, bool):
|
if isinstance(current, bool):
|
||||||
current = "true" if current else "false"
|
current = "true" if current else "false"
|
||||||
elif isinstance(current, dict):
|
elif isinstance(current, dict):
|
||||||
@@ -76,6 +110,7 @@ def get_params_with_current_values(client, op_id, instance_uid):
|
|||||||
"valueList": _normalize_value_list(value_list),
|
"valueList": _normalize_value_list(value_list),
|
||||||
"refSvcId": p.get("refSvcId"), # если параметр ссылается на другой сервис
|
"refSvcId": p.get("refSvcId"), # если параметр ссылается на другой сервис
|
||||||
"dataDescriptor": (
|
"dataDescriptor": (
|
||||||
|
# Для map-параметров: рекурсивно нормализуем вложенные поля
|
||||||
{
|
{
|
||||||
k: {
|
k: {
|
||||||
"dataType": v.get("dataType", ""),
|
"dataType": v.get("dataType", ""),
|
||||||
@@ -93,12 +128,21 @@ def get_params_with_current_values(client, op_id, instance_uid):
|
|||||||
|
|
||||||
|
|
||||||
def _normalize_value_list(vl):
|
def _normalize_value_list(vl):
|
||||||
"""Nubes API возвращает valueList как CSV-строку или массив.
|
"""Нормализовать valueList: API может вернуть CSV-строку или массив.
|
||||||
Приводим к list для фронтенда."""
|
|
||||||
|
Приводим к list для фронтенда (всегда массив строк).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
vl: None | str | list — valueList из API.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list или None — нормализованный список или None если пусто."""
|
||||||
if vl is None:
|
if vl is None:
|
||||||
return None
|
return None
|
||||||
|
# Уже массив — возвращаем как есть
|
||||||
if isinstance(vl, list):
|
if isinstance(vl, list):
|
||||||
return vl
|
return vl
|
||||||
|
# CSV-строка: "val1,val2,val3" → ["val1", "val2", "val3"]
|
||||||
if isinstance(vl, str):
|
if isinstance(vl, str):
|
||||||
return [x.strip() for x in vl.split(',') if x.strip()] or None
|
return [x.strip() for x in vl.split(',') if x.strip()] or None
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
"""
|
"""
|
||||||
Операции с сервисами Nubes — получение списка и деталей через API.
|
Операции с сервисами Nubes — получение списка и деталей через API.
|
||||||
|
|
||||||
|
Используется:
|
||||||
|
- main.py → список сервисов для UI
|
||||||
|
- api_test.py → операции сервиса (modify/delete/...)
|
||||||
|
- scenario.py → поиск svcOperationId для операции
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from api.http_client import HttpClient
|
from api.http_client import HttpClient
|
||||||
@@ -8,7 +13,10 @@ from api.http_client import HttpClient
|
|||||||
def get_services(client):
|
def get_services(client):
|
||||||
"""GET /services → список ВСЕХ сервисов пользователя.
|
"""GET /services → список ВСЕХ сервисов пользователя.
|
||||||
|
|
||||||
Возвращает list[dict], каждый с ключами: svcId, svc, svcExtendedName, operations, ..."""
|
Возвращает list[dict], каждый с ключами:
|
||||||
|
svcId, svc (короткое имя), svcExtendedName (полное имя), operations[]
|
||||||
|
|
||||||
|
operations — это ДОСТУПНЫЕ операции, не путать с запущенными."""
|
||||||
data = client.get("/services")
|
data = client.get("/services")
|
||||||
return data.get("results", [])
|
return data.get("results", [])
|
||||||
|
|
||||||
@@ -16,7 +24,14 @@ def get_services(client):
|
|||||||
def get_service_detail(client, svc_id):
|
def get_service_detail(client, svc_id):
|
||||||
"""GET /services/{svcId} → детали одного сервиса.
|
"""GET /services/{svcId} → детали одного сервиса.
|
||||||
|
|
||||||
Возвращает dict с ключами: svc, operations[], ...
|
Возвращает dict с ключами:
|
||||||
operations — список доступных операций (create, modify, delete, ...)."""
|
svc, svcShort, operations[]
|
||||||
|
|
||||||
|
operations — список доступных операций:
|
||||||
|
[{svcOperationId, operation, isCreate, ...}, ...]
|
||||||
|
|
||||||
|
svcOperationId — ЧИСЛОВОЙ ID операции, нужен для:
|
||||||
|
- POST /instanceOperations (поле svcOperationId)
|
||||||
|
- GET /instanceOperations/default/{svcOperationId} (шаблон параметров)"""
|
||||||
data = client.get(f"/services/{svc_id}")
|
data = client.get(f"/services/{svc_id}")
|
||||||
return data.get("svc", {})
|
return data.get("svc", {})
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
"""
|
||||||
|
Общий цикл поллинга операции Nubes — ждём завершения или таймаута.
|
||||||
|
|
||||||
|
Поллинг — это периодический опрос API:
|
||||||
|
GET /instanceOperations/{opUid}?fields=dtFinish,isSuccessful,errorLog,duration,stages,svc
|
||||||
|
|
||||||
|
Каждые 5 секунд проверяем не появился ли dtFinish (дата завершения).
|
||||||
|
Как только dtFinish есть → операция завершена → возвращаем результат.
|
||||||
|
|
||||||
|
Используется в двух режимах:
|
||||||
|
- _finish_op (api_test.py) — асинхронно, в threading.Thread.
|
||||||
|
Запускается после возврата HTTP-ответа пользователю.
|
||||||
|
Пользователь видит "RUNNING" и поллит через /api/test/status/<opUid>.
|
||||||
|
- run_scenario (scenario.py) — синхронно, в основном потоке сценария.
|
||||||
|
Сценарий ждёт завершения шага перед переходом к следующему.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
def poll_until_done(client, op_uid, timeout=1800):
|
||||||
|
"""Поллинг операции до завершения или таймаута.
|
||||||
|
|
||||||
|
Алгоритм:
|
||||||
|
1. Запоминаем время старта (t0).
|
||||||
|
2. В цикле: GET /instanceOperations/{opUid} с нужными полями.
|
||||||
|
3. Если dtFinish есть и не пустой → операция завершена.
|
||||||
|
- isSuccessful=True → status="OK"
|
||||||
|
- isSuccessful=False → status="FAIL"
|
||||||
|
4. Если dtFinish нет → sleep(5) и повторяем.
|
||||||
|
5. Если вышли за deadline → status="TIMEOUT".
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: HttpClient
|
||||||
|
op_uid: str — UUID операции (instanceOperationUid)
|
||||||
|
timeout: int — максимальное время ожидания в секундах.
|
||||||
|
По умолчанию 1800 (30 минут).
|
||||||
|
Столько может длиться create сложного сервиса.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict с ВСЕГДА одинаковыми ключами:
|
||||||
|
{status, is_successful, error_log, stages, duration, svc}
|
||||||
|
|
||||||
|
status ∈ {"OK", "FAIL", "TIMEOUT"}
|
||||||
|
OK — операция завершилась успешно
|
||||||
|
FAIL — операция завершилась с ошибкой
|
||||||
|
TIMEOUT — не дождались за timeout секунд
|
||||||
|
|
||||||
|
is_successful: bool — True только для OK
|
||||||
|
error_log: str — текст ошибки из API (или "TIMEOUT after ...")
|
||||||
|
stages: list — этапы выполнения [{stage, dtStart, dtFinish, isSuccessful, duration}, ...]
|
||||||
|
duration: float — реальное время поллинга в секундах (округлено до 0.1)
|
||||||
|
svc: str — имя сервиса (из ответа API)
|
||||||
|
"""
|
||||||
|
# Фиксируем время начала поллинга
|
||||||
|
t0 = time.time()
|
||||||
|
deadline = t0 + timeout # крайний срок
|
||||||
|
|
||||||
|
# Основной цикл поллинга
|
||||||
|
while time.time() < deadline:
|
||||||
|
try:
|
||||||
|
# GET с fields — запрашиваем только нужные поля (экономия трафика)
|
||||||
|
# dtFinish — ключевое: если есть → операция завершена
|
||||||
|
# isSuccessful — true/false
|
||||||
|
# errorLog — текст ошибки если FAIL
|
||||||
|
# duration — сколько заняла операция по версии API
|
||||||
|
# stages — этапы (Terraform: plan → apply → ...)
|
||||||
|
# svc — имя сервиса (может отличаться от того что знаем)
|
||||||
|
data = client.get(
|
||||||
|
f"/instanceOperations/{op_uid}"
|
||||||
|
"?fields=dtFinish,isSuccessful,errorLog,duration,stages,svc"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
# Сетевая ошибка или API временно недоступен — не падаем.
|
||||||
|
# Ждём 5 секунд и пробуем снова.
|
||||||
|
# Это нормально: Nubes API иногда отдаёт 502 при высокой нагрузке.
|
||||||
|
time.sleep(5)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Ответ API: {"instanceOperation": {...}}
|
||||||
|
op = data.get("instanceOperation", {})
|
||||||
|
dt_finish = op.get("dtFinish")
|
||||||
|
|
||||||
|
# dtFinish может быть None, пустой строкой, или датой.
|
||||||
|
# Проверяем что dtFinish не пустой:
|
||||||
|
# str(dt_finish).strip() — отсекаем None→"None" и пустые строки
|
||||||
|
if dt_finish and str(dt_finish).strip():
|
||||||
|
is_ok = op.get("isSuccessful")
|
||||||
|
return {
|
||||||
|
"status": "OK" if is_ok else "FAIL",
|
||||||
|
"is_successful": is_ok,
|
||||||
|
"error_log": op.get("errorLog") or "",
|
||||||
|
"stages": op.get("stages", []),
|
||||||
|
"duration": round(time.time() - t0, 1),
|
||||||
|
"svc": op.get("svc", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Операция ещё не завершена — ждём 5 секунд
|
||||||
|
time.sleep(5)
|
||||||
|
|
||||||
|
# Вышли из while по deadline — таймаут.
|
||||||
|
# 30 минут истекли, операция всё ещё выполняется.
|
||||||
|
# Это редкий случай (обычно create занимает 2-10 минут).
|
||||||
|
return {
|
||||||
|
"status": "TIMEOUT",
|
||||||
|
"is_successful": False,
|
||||||
|
"error_log": f"TIMEOUT after {timeout}s",
|
||||||
|
"stages": [],
|
||||||
|
"duration": round(time.time() - t0, 1),
|
||||||
|
"svc": "",
|
||||||
|
}
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
"""
|
||||||
|
Scenario runner — запуск сценария: шаги из БД → API Nubes → результат.
|
||||||
|
|
||||||
|
Сценарий — это последовательность операций над сервисами.
|
||||||
|
Пример: create Болванку → modify параметры → delete Болванку.
|
||||||
|
|
||||||
|
ШАГИ (новый формат с именованными ссылками):
|
||||||
|
{"service_id": 1, "operation": "create", "params": {...}, "output": "d1"}
|
||||||
|
— создать инстанс сервиса 1, дать ему имя "d1" для ссылок из следующих шагов
|
||||||
|
{"service_id": 1, "operation": "modify", "instance_ref": "d1", "params": {...}}
|
||||||
|
— модифицировать инстанс, созданный в шаге с output="d1"
|
||||||
|
{"service_id": 1, "operation": "delete", "instance_uid": "UUID", "params": {}}
|
||||||
|
— удалить конкретный инстанс по UUID
|
||||||
|
|
||||||
|
Старый формат (без output/instance_ref/instance_uid):
|
||||||
|
{"service_id": 1, "operation": "create", "params": {...}}
|
||||||
|
{"service_id": 1, "operation": "modify", "params": {...}}
|
||||||
|
— fallback на service_id (instance_map[service_id] → последний созданный)
|
||||||
|
|
||||||
|
РЕЗОЛВИНГ instance_uid (приоритет):
|
||||||
|
1. Явный instance_uid в шаге
|
||||||
|
2. instance_ref → поиск в bindings (output предыдущего create-шага)
|
||||||
|
3. instance_map[service_id] → последний инстанс этого сервиса (fallback)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from operations.get_services import get_service_detail
|
||||||
|
from operations.executor import execute_operation
|
||||||
|
from operations.poll import poll_until_done
|
||||||
|
from db.pool import get_conn, put_conn
|
||||||
|
from db.save_run import save_run
|
||||||
|
|
||||||
|
# Все инстансы созданные сценарием имеют такой префикс в displayName
|
||||||
|
AUTOTEST_PREFIX = "autotest-scenario-"
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_params(cfs_params, symbolic_params):
|
||||||
|
"""Конвертировать символические имена параметров → {numeric_id: value}.
|
||||||
|
|
||||||
|
В БД сценарии хранят параметры с СИМВОЛИЧЕСКИМИ именами (напр. "durationMs": "0").
|
||||||
|
Но executor.execute_operation требует ЧИСЛОВЫЕ ID (напр. "198": "0").
|
||||||
|
|
||||||
|
Эта функция делает обратный маппинг: code → svcOperationCfsParamId.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cfs_params: list — cfsParams из шаблона операции (содержит svcOperationCfsParam)
|
||||||
|
symbolic_params: dict — {code: value} из БД
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict — {numeric_id: value}
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError если код параметра не найден в шаблоне (опечатка в сценарии)."""
|
||||||
|
# Строим словарь: code → numeric_id
|
||||||
|
code_to_id = {p.get("svcOperationCfsParam", ""): p["svcOperationCfsParamId"] for p in cfs_params}
|
||||||
|
result = {}
|
||||||
|
for code, val in symbolic_params.items():
|
||||||
|
pid = code_to_id.get(code)
|
||||||
|
if pid is None:
|
||||||
|
raise ValueError(f"Param code '{code}' not found in operation template")
|
||||||
|
result[str(pid)] = str(val)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _save_scenario_run(scenario_run_id, status, current_step=0, duration_sec=None, error_log=None):
|
||||||
|
"""Обновить запись scenario_runs в БД.
|
||||||
|
|
||||||
|
Вызывается после КАЖДОГО шага чтобы UI видел прогресс.
|
||||||
|
При ошибке — пишем в stderr и продолжаем (не роняем сценарий из-за БД)."""
|
||||||
|
conn = get_conn()
|
||||||
|
if not conn:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE scenario_runs
|
||||||
|
SET status = %s, current_step = %s, duration_sec = %s, error_log = %s
|
||||||
|
WHERE id = %s
|
||||||
|
""", (status, current_step, duration_sec, error_log, scenario_run_id))
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[SCENARIO] save_scenario_run error: {e}", flush=True)
|
||||||
|
conn.rollback()
|
||||||
|
finally:
|
||||||
|
put_conn(conn)
|
||||||
|
|
||||||
|
|
||||||
|
def _update_bindings(scenario_run_id, bindings):
|
||||||
|
"""Сохранить output→instance_uid в scenario_runs.instance_bindings (JSONB).
|
||||||
|
|
||||||
|
bindings — это словарь: {"d1": "uuid-1", "d2": "uuid-2"}
|
||||||
|
Нужен для отладки: видеть какие инстансы были созданы на каждом шаге."""
|
||||||
|
conn = get_conn()
|
||||||
|
if not conn:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE scenario_runs SET instance_bindings = %s WHERE id = %s
|
||||||
|
""", (json.dumps(bindings), scenario_run_id))
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[SCENARIO] update_bindings error: {e}", flush=True)
|
||||||
|
conn.rollback()
|
||||||
|
finally:
|
||||||
|
put_conn(conn)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_instance_uid(step, bindings, instance_map):
|
||||||
|
"""Резолвинг instance_uid по приоритету: явный uid > instance_ref > service_id.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
step: dict — шаг сценария
|
||||||
|
bindings: dict — {output_name: instance_uid} (накопленные за сценарий)
|
||||||
|
instance_map: dict — {service_id: instance_uid} (fallback, последний инстанс сервиса)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str — instance_uid
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError если instance_ref не найден в bindings (шаги не в том порядке)."""
|
||||||
|
# Приоритет 1: явный instance_uid в шаге
|
||||||
|
uid = (step.get("instance_uid") or "").strip()
|
||||||
|
if uid:
|
||||||
|
return uid
|
||||||
|
|
||||||
|
# Приоритет 2: instance_ref → поиск в bindings
|
||||||
|
ref = (step.get("instance_ref") or "").strip()
|
||||||
|
if ref:
|
||||||
|
if ref in bindings:
|
||||||
|
return bindings[ref]
|
||||||
|
raise ValueError(f"instance_ref '{ref}' not found in bindings (step order issue?)")
|
||||||
|
|
||||||
|
# Приоритет 3: fallback — последний инстанс этого сервиса
|
||||||
|
svc_id = step.get("service_id")
|
||||||
|
return instance_map.get(svc_id)
|
||||||
|
|
||||||
|
|
||||||
|
def run_scenario(client, steps, client_id, stand, user_email, app_version, scenario_run_id, scenario_name):
|
||||||
|
"""Главный исполнитель сценария — последовательно выполняет шаги.
|
||||||
|
|
||||||
|
Алгоритм для КАЖДОГО шага:
|
||||||
|
1. Получить детали сервиса → найти svcOperationId для операции
|
||||||
|
2. Получить шаблон параметров → резолвить символические имена в числовые ID
|
||||||
|
3. Резолвить instance_uid (create → None, не-create → из bindings/instance_map)
|
||||||
|
4. Вызвать execute_operation (единый executor)
|
||||||
|
5. Сохранить RUNNING в БД
|
||||||
|
6. Поллить через poll_until_done
|
||||||
|
7. Сохранить финальный результат (OK/FAIL/TIMEOUT) + instance_meta
|
||||||
|
8. Обновить scenario_runs (прогресс для UI)
|
||||||
|
|
||||||
|
Если любой шаг FAIL — сценарий останавливается.
|
||||||
|
Если все шаги OK — сценарий завершается успешно.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: HttpClient
|
||||||
|
steps: list[dict] — шаги из БД (scenario_definitions.steps)
|
||||||
|
client_id: str — ClientID
|
||||||
|
stand: str — "dev"/"test"
|
||||||
|
user_email: str — email пользователя
|
||||||
|
app_version: str — версия приложения
|
||||||
|
scenario_run_id: int — ID записи в scenario_runs
|
||||||
|
scenario_name: str — имя сценария
|
||||||
|
"""
|
||||||
|
total = len(steps)
|
||||||
|
t0 = time.time()
|
||||||
|
|
||||||
|
# Две карты для резолвинга instance_uid:
|
||||||
|
bindings = {} # output_name → instance_uid (новый формат, именованные ссылки)
|
||||||
|
instance_map = {} # service_id → instance_uid (fallback, старый формат)
|
||||||
|
|
||||||
|
for i, step in enumerate(steps):
|
||||||
|
step_num = i + 1 # шаги нумеруются с 1 (для пользователя)
|
||||||
|
svc_id = step.get("service_id")
|
||||||
|
op_name = (step.get("operation") or "").strip()
|
||||||
|
symbolic_params = step.get("params", {})
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Валидация базовых полей шага
|
||||||
|
if not isinstance(svc_id, int) or svc_id <= 0:
|
||||||
|
raise ValueError(f"Invalid service_id: {svc_id!r}")
|
||||||
|
if not op_name:
|
||||||
|
raise ValueError("Empty operation name")
|
||||||
|
|
||||||
|
# ═══ Шаг 1: получить svcOperationId для операции ═══
|
||||||
|
detail = get_service_detail(client, svc_id)
|
||||||
|
ops = detail.get("operations", [])
|
||||||
|
op = next((o for o in ops if o.get("operation") == op_name), None)
|
||||||
|
if not op:
|
||||||
|
raise ValueError(f"Operation '{op_name}' not found for service {svc_id}")
|
||||||
|
svc_op_id = op["svcOperationId"]
|
||||||
|
|
||||||
|
# ═══ Шаг 2: резолвить параметры (символ. имена → числовые ID) ═══
|
||||||
|
tmpl_data = client.get(f"/instanceOperations/default/{svc_op_id}")
|
||||||
|
cfs_params = tmpl_data.get("svcOperation", {}).get("cfsParams", [])
|
||||||
|
resolved_params = _resolve_params(cfs_params, symbolic_params)
|
||||||
|
|
||||||
|
# ═══ Шаг 3: резолвинг instance_uid ═══
|
||||||
|
is_create = (op_name == "create")
|
||||||
|
if is_create:
|
||||||
|
# Для create: instance_uid=None (создаём новый)
|
||||||
|
# displayName генерируем уникальный: autotest-scenario-{name}-{random6}
|
||||||
|
instance_uid = None
|
||||||
|
display_name = f"{AUTOTEST_PREFIX}{scenario_name}-{uuid.uuid4().hex[:6]}"
|
||||||
|
descr = f"scenario {scenario_name} step {step_num}"
|
||||||
|
else:
|
||||||
|
# Для не-create: резолвим instance_uid
|
||||||
|
instance_uid = _resolve_instance_uid(step, bindings, instance_map)
|
||||||
|
if not instance_uid:
|
||||||
|
raise ValueError(
|
||||||
|
f"No instance for step {step_num} — need CREATE before non-create operations")
|
||||||
|
display_name = None
|
||||||
|
descr = None
|
||||||
|
|
||||||
|
# ═══ Шаг 4: запуск через ЕДИНЫЙ executor ═══
|
||||||
|
# Это гарантирует идентичное поведение с ручным режимом
|
||||||
|
result = execute_operation(
|
||||||
|
client, svc_id, op_name, instance_uid, resolved_params,
|
||||||
|
svc_op_id=svc_op_id, display_name=display_name, descr=descr,
|
||||||
|
client_id=client_id, stand=stand
|
||||||
|
)
|
||||||
|
if not result["ok"]:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"{op_name}: {result['error']} (step: {result['failed_step']})")
|
||||||
|
|
||||||
|
instance_uid = result["instance_uid"]
|
||||||
|
op_uid = result["op_uid"]
|
||||||
|
step_display = result["display_name"]
|
||||||
|
|
||||||
|
# Обновить карты для резолвинга следующих шагов
|
||||||
|
instance_map[svc_id] = instance_uid # fallback: последний инстанс сервиса
|
||||||
|
output_name = (step.get("output") or "").strip()
|
||||||
|
if is_create and output_name:
|
||||||
|
bindings[output_name] = instance_uid # именованная ссылка
|
||||||
|
_update_bindings(scenario_run_id, bindings)
|
||||||
|
|
||||||
|
# ═══ Шаг 5: сохранить RUNNING в БД (до поллинга) ═══
|
||||||
|
try:
|
||||||
|
save_run(client_id, stand, user_email,
|
||||||
|
svc_id, "", op_name, svc_op_id,
|
||||||
|
op_uid, instance_uid, step_display,
|
||||||
|
"RUNNING", 0, "",
|
||||||
|
resolved_params, [], app_version,
|
||||||
|
scenario_run_id, step_num)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[SCENARIO] save_run(RUNNING) failed for step {step_num}: {e}", flush=True)
|
||||||
|
|
||||||
|
# ═══ Шаг 6: поллинг до завершения ═══
|
||||||
|
poll_result = poll_until_done(client, op_uid)
|
||||||
|
step_status = poll_result["status"] # OK / FAIL / TIMEOUT
|
||||||
|
step_error = poll_result["error_log"]
|
||||||
|
step_duration = poll_result["duration"]
|
||||||
|
step_stages = poll_result["stages"]
|
||||||
|
svc_final = poll_result["svc"]
|
||||||
|
|
||||||
|
# ═══ Шаг 7: сохранить финальный результат + instance_meta ═══
|
||||||
|
try:
|
||||||
|
# Получить полную информацию об инстансе для анализа
|
||||||
|
instance_meta = None
|
||||||
|
try:
|
||||||
|
inst_data = client.get(f"/instances/{instance_uid}")
|
||||||
|
inst = inst_data.get("instance", {}) if isinstance(inst_data, dict) else {}
|
||||||
|
if inst:
|
||||||
|
# Сохраняем ВСЁ кроме очевидных/избыточных полей
|
||||||
|
instance_meta = {k: v for k, v in inst.items()
|
||||||
|
if k not in ('instanceUid', 'displayName', 'svc', 'serviceId', 'explainedStatus')}
|
||||||
|
except Exception:
|
||||||
|
pass # не смогли получить meta — не критично
|
||||||
|
save_run(client_id, stand, user_email,
|
||||||
|
svc_id, svc_final, op_name, svc_op_id,
|
||||||
|
op_uid, instance_uid, step_display,
|
||||||
|
step_status, step_duration, step_error,
|
||||||
|
resolved_params, step_stages, app_version,
|
||||||
|
scenario_run_id, step_num,
|
||||||
|
instance_meta=instance_meta)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[SCENARIO] save_run failed for step {step_num}: {e}", flush=True)
|
||||||
|
|
||||||
|
# ═══ Шаг 8: обновить scenario_runs (прогресс для UI) ═══
|
||||||
|
# Пока не последний шаг — статус RUNNING
|
||||||
|
_save_scenario_run(scenario_run_id,
|
||||||
|
"RUNNING" if step_num < total else step_status,
|
||||||
|
step_num, round(time.time() - t0, 1),
|
||||||
|
step_error if step_status != "OK" else None)
|
||||||
|
|
||||||
|
# Если шаг FAIL — останавливаем сценарий
|
||||||
|
if step_status != "OK":
|
||||||
|
_save_scenario_run(scenario_run_id, step_status, step_num,
|
||||||
|
round(time.time() - t0, 1), step_error)
|
||||||
|
return
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Любая ошибка → FAIL, сценарий остановлен
|
||||||
|
import traceback
|
||||||
|
err = f"Step {step_num}: {e}"
|
||||||
|
print(f"[SCENARIO] {err}", flush=True)
|
||||||
|
traceback.print_exc()
|
||||||
|
_save_scenario_run(scenario_run_id, "FAIL", step_num,
|
||||||
|
round(time.time() - t0, 1), err)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Все шаги пройдены успешно
|
||||||
|
_save_scenario_run(scenario_run_id, "OK", total, round(time.time() - t0, 1))
|
||||||
@@ -1,9 +1,18 @@
|
|||||||
"""
|
"""
|
||||||
Чтение списка разрешённых сервисов из services_{stand}.txt.
|
Чтение списка разрешённых сервисов из services_{stand}.txt.
|
||||||
|
|
||||||
Формат файла: service_id service_name # описание
|
Зачем: не все сервисы должны быть доступны для тестирования.
|
||||||
Закомментированные (#) строки — исключены.
|
Файл services_test.txt (или services_dev.txt) содержит только те сервисы,
|
||||||
Пустые строки — игнорируются.
|
которые настроены и готовы к автотестам.
|
||||||
|
|
||||||
|
Формат файла:
|
||||||
|
# Комментарий
|
||||||
|
1 dummy # Болванка
|
||||||
|
21 vdc # Virtual Data Center
|
||||||
|
|
||||||
|
Закомментированные (#) строки исключаются.
|
||||||
|
Пустые строки игнорируются.
|
||||||
|
Первое слово в строке — service_id (число).
|
||||||
|
|
||||||
Файл лежит в site/config/, пушится с кодом, деплоится вместе с приложением.
|
Файл лежит в site/config/, пушится с кодом, деплоится вместе с приложением.
|
||||||
При изменении списка: edit → commit → push → redeploy.
|
При изменении списка: edit → commit → push → redeploy.
|
||||||
@@ -12,23 +21,36 @@
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
|
||||||
|
# Путь к папке config относительно этого файла:
|
||||||
|
# service_list.py → site/operations/
|
||||||
|
# dirname → site/operations
|
||||||
|
# dirname(dirname) → site
|
||||||
|
# + "config" → site/config
|
||||||
_CONFIG_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "config")
|
_CONFIG_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "config")
|
||||||
|
|
||||||
|
|
||||||
def load_service_ids(stand="test"):
|
def load_service_ids(stand="test"):
|
||||||
"""Вернуть set разрешённых service_id для указанного стенда."""
|
"""Вернуть set разрешённых service_id для указанного стенда.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
stand: str — "dev" или "test".
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
set[int] — множество service_id. Пустой set если файла нет → показываем все."""
|
||||||
path = os.path.join(_CONFIG_DIR, f"services_{stand}.txt")
|
path = os.path.join(_CONFIG_DIR, f"services_{stand}.txt")
|
||||||
ids = set()
|
ids = set()
|
||||||
try:
|
try:
|
||||||
with open(path) as f:
|
with open(path) as f:
|
||||||
for line in f:
|
for line in f:
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
|
# Пропускаем пустые строки и комментарии
|
||||||
if not line or line.startswith("#"):
|
if not line or line.startswith("#"):
|
||||||
continue
|
continue
|
||||||
# Формат: "1 dummy # Болванка"
|
# Формат: "1 dummy # Болванка"
|
||||||
|
# Берём первое слово, проверяем что это число
|
||||||
parts = line.split()
|
parts = line.split()
|
||||||
if parts and parts[0].isdigit():
|
if parts and parts[0].isdigit():
|
||||||
ids.add(int(parts[0]))
|
ids.add(int(parts[0]))
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
pass # файла нет — показываем все сервисы
|
pass # файла нет — возвращаем пустой set → показываем все сервисы
|
||||||
return ids
|
return ids
|
||||||
|
|||||||
@@ -0,0 +1,257 @@
|
|||||||
|
"""
|
||||||
|
Terraform-совместимые операции с параметрами: нормализация, refSvc-резолв, отправка.
|
||||||
|
|
||||||
|
Эти шаги воспроизводят логику Terraform-провайдера Nubes при создании/изменении
|
||||||
|
инстанса через личный кабинет:
|
||||||
|
|
||||||
|
Шаг 3: Получить cfsParams шаблон операции
|
||||||
|
Шаг 4: resolveRefSvc — подставить UUID инстансов для refSvcId-параметров
|
||||||
|
Шаг 5: Отправить пользовательские значения параметров (те что ввёл пользователь)
|
||||||
|
Шаг 6: Отправить default-значения для незаполненных параметров
|
||||||
|
Шаг 7: validate-cfs — проверить что все обязательные параметры заполнены
|
||||||
|
|
||||||
|
Используется из executor.py (через send_params_terraform) — и ручной режим, и сценарий.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from operations.get_instances import get_instances
|
||||||
|
|
||||||
|
# Параметры, значения которых НЕЛЬЗЯ сохранять в БД в открытом виде.
|
||||||
|
# При сохранении в runs.params их значения заменяются на "***".
|
||||||
|
REDACT_KEYS = {"password", "secret", "token", "key", "privatekey", "private_key", "passwd", "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def redact_params(params):
|
||||||
|
"""Заменить значения секретных полей на *** перед сохранением в БД.
|
||||||
|
|
||||||
|
Проверяет КЛЮЧИ параметров (не значения!) на вхождение секретных слов.
|
||||||
|
Пример: params={"adminPassword": "secret123"} → {"adminPassword": "***"}
|
||||||
|
|
||||||
|
Args:
|
||||||
|
params: dict — {param_id: value}
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict с замаскированными секретами."""
|
||||||
|
if not params:
|
||||||
|
return params
|
||||||
|
result = {}
|
||||||
|
for k, v in params.items():
|
||||||
|
# Проверяем ключ (имя параметра) на敏感ные слова
|
||||||
|
if any(r in str(k).lower() for r in REDACT_KEYS):
|
||||||
|
result[k] = "***"
|
||||||
|
else:
|
||||||
|
result[k] = v
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_value(val, data_type, data_descriptor):
|
||||||
|
"""normalizeUniversalValueV6 — Terraform-эквивалент нормализации значения.
|
||||||
|
|
||||||
|
Что делает: приводит пустые значения к «нулю» соответствующего типа.
|
||||||
|
Terraform не принимает пустую строку для integer-параметра — нужно "0".
|
||||||
|
Для map-параметров собирает JSON из sub-param defaults.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
val: str — значение параметра (может быть "", "null", ...)
|
||||||
|
data_type: str — тип параметра ("integer >= 0", "boolean", "map(...)", ...)
|
||||||
|
data_descriptor: dict|None — вложенная структура для map-параметров
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str — нормализованное значение."""
|
||||||
|
# Очистка: strip + специальные значения null / ""
|
||||||
|
val = (val or "").strip()
|
||||||
|
if val.lower() == "null" or val == '""':
|
||||||
|
val = ""
|
||||||
|
|
||||||
|
dt = (data_type or "").lower()
|
||||||
|
|
||||||
|
# Map-параметры с dataDescriptor:
|
||||||
|
# Если значение пустое — собираем JSON из sub-param defaults.
|
||||||
|
# Пример: dataDescriptor={"host": {defaultValue: "localhost"}, "port": {defaultValue: "5432"}}
|
||||||
|
# → "{\"host\":\"localhost\",\"port\":\"5432\"}"
|
||||||
|
if ("map" in dt) and isinstance(data_descriptor, dict) and data_descriptor:
|
||||||
|
if not val or val == "{}":
|
||||||
|
sub_obj = {}
|
||||||
|
for sk, sv in data_descriptor.items():
|
||||||
|
sd = sv.get("defaultValue")
|
||||||
|
# default может быть любым типом — приводим к строке для JSON
|
||||||
|
sub_obj[sk] = str(sd) if sd is not None else ""
|
||||||
|
return json.dumps(sub_obj)
|
||||||
|
return val # значение уже заполнено — не трогаем
|
||||||
|
|
||||||
|
# Значение не пустое — возвращаем как есть
|
||||||
|
if val:
|
||||||
|
return val
|
||||||
|
|
||||||
|
# Пустое значение — подставляем «ноль» типа:
|
||||||
|
if "array" in dt:
|
||||||
|
return "[]"
|
||||||
|
if "map" in dt or "json" in dt:
|
||||||
|
return "{}"
|
||||||
|
if "integer" in dt or "int" in dt:
|
||||||
|
return "0"
|
||||||
|
if "boolean" in dt or "bool" in dt:
|
||||||
|
return "false"
|
||||||
|
|
||||||
|
# string или неизвестный тип — пустая строка
|
||||||
|
return val
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_ref_svc(client, cfs_params, params):
|
||||||
|
"""resolveRefSvcParamValues — Terraform step 4.
|
||||||
|
|
||||||
|
Некоторые параметры имеют тип «ссылка на другой сервис» (refSvcId).
|
||||||
|
Например: параметр «externalIp» ссылается на сервис External IP (svcId=25).
|
||||||
|
|
||||||
|
Если пользователь оставил такой параметр пустым — мы должны найти
|
||||||
|
существующий инстанс этого сервиса и подставить его UUID.
|
||||||
|
|
||||||
|
Это работает в двух местах:
|
||||||
|
1. Верхнеуровневый refSvcId — прямо в параметре
|
||||||
|
2. Вложенный refSvcId — внутри dataDescriptor (для map-параметров)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: HttpClient
|
||||||
|
cfs_params: list — cfsParams из шаблона операции
|
||||||
|
params: dict — {param_id: value} — МУТИРУЕТСЯ (добавляем UUID)
|
||||||
|
|
||||||
|
Returns: None (мутирует params)."""
|
||||||
|
# instances загружаем ЛЕНИВО — только если нужен refSvc
|
||||||
|
instances = None
|
||||||
|
|
||||||
|
for p in cfs_params:
|
||||||
|
pid = str(p["svcOperationCfsParamId"])
|
||||||
|
|
||||||
|
# ── Уровень 1: верхнеуровневый refSvcId ──
|
||||||
|
top_ref = p.get("refSvcId")
|
||||||
|
if top_ref:
|
||||||
|
# Проверяем: параметр пустой?
|
||||||
|
cur = (params.get(pid) or "").strip()
|
||||||
|
if not cur or cur == '""':
|
||||||
|
# Загружаем инстансы если ещё не загружены
|
||||||
|
if instances is None:
|
||||||
|
try:
|
||||||
|
instances = get_instances(client)
|
||||||
|
except Exception:
|
||||||
|
return # не можем загрузить — пропускаем
|
||||||
|
# Ищем первый НЕ deleted инстанс с нужным serviceId
|
||||||
|
match = next((i for i in instances
|
||||||
|
if i.get("serviceId") == top_ref
|
||||||
|
and i.get("explainedStatus") not in ("deleted",)), None)
|
||||||
|
if match:
|
||||||
|
params[pid] = match["instanceUid"]
|
||||||
|
|
||||||
|
# ── Уровень 2: вложенный refSvcId в dataDescriptor ──
|
||||||
|
dd = p.get("dataDescriptor")
|
||||||
|
if not isinstance(dd, dict):
|
||||||
|
continue # нет dataDescriptor — нечего проверять
|
||||||
|
|
||||||
|
# Парсим текущее значение параметра как JSON (map-параметр)
|
||||||
|
user_val = {}
|
||||||
|
if pid in params:
|
||||||
|
try:
|
||||||
|
user_val = json.loads(params[pid]) if params[pid] else {}
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
user_val = {}
|
||||||
|
|
||||||
|
# Проверяем каждый sub-ключ dataDescriptor на refSvcId
|
||||||
|
for sk, sv in dd.items():
|
||||||
|
ref_id = sv.get("refSvcId")
|
||||||
|
if not ref_id:
|
||||||
|
continue # не refSvc-параметр
|
||||||
|
|
||||||
|
# Текущее значение sub-ключа
|
||||||
|
cur = user_val.get(sk, "")
|
||||||
|
if cur and cur != '""':
|
||||||
|
continue # уже заполнено — не трогаем
|
||||||
|
|
||||||
|
# Загружаем инстансы лениво
|
||||||
|
if instances is None:
|
||||||
|
try:
|
||||||
|
instances = get_instances(client)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Ищем подходящий инстанс
|
||||||
|
match = next((i for i in instances
|
||||||
|
if i.get("serviceId") == ref_id
|
||||||
|
and i.get("explainedStatus") not in ("deleted",)), None)
|
||||||
|
if match:
|
||||||
|
user_val[sk] = match["instanceUid"]
|
||||||
|
|
||||||
|
# Если были изменения — обновляем значение параметра
|
||||||
|
if user_val:
|
||||||
|
params[pid] = json.dumps(user_val)
|
||||||
|
|
||||||
|
|
||||||
|
def send_params_terraform(client, op_uid, params):
|
||||||
|
"""Terraform steps 3-7: cfsParams → resolveRefSvc → send user → send defaults → validate.
|
||||||
|
|
||||||
|
Полный алгоритм отправки параметров операции:
|
||||||
|
|
||||||
|
3. GET /instanceOperations/{opUid}?fields=cfsParams — получить шаблон
|
||||||
|
4. resolveRefSvc — подставить UUID для refSvcId-параметров
|
||||||
|
5. Отправить пользовательские значения (те что переданы в params)
|
||||||
|
6. Отправить default-значения для НЕзаполненных параметров
|
||||||
|
(нормализуя через normalize_value)
|
||||||
|
7. GET /instanceOperations/{opUid}/validate-cfs — финальная проверка
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: HttpClient
|
||||||
|
op_uid: str — UUID операции
|
||||||
|
params: dict — {numeric_param_id: string_value} — пользовательские значения
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
Exception при ошибке валидации (кроме пустого JSON-ответа — это ок)."""
|
||||||
|
# Шаг 3: получить cfsParams шаблон операции
|
||||||
|
op_det = client.get(f"/instanceOperations/{op_uid}?fields=cfsParams")
|
||||||
|
cfs_params = op_det.get("instanceOperation", {}).get("cfsParams", [])
|
||||||
|
|
||||||
|
# Шаг 4: resolveRefSvc — мутирует params, подставляя UUID
|
||||||
|
resolve_ref_svc(client, cfs_params, params)
|
||||||
|
|
||||||
|
# Шаг 5: отправить пользовательские значения
|
||||||
|
# sent — множество paramId, чтобы не отправить повторно в шаге 6
|
||||||
|
sent = set()
|
||||||
|
for pid, pval in params.items():
|
||||||
|
client.post("/instanceOperationCfsParams", {
|
||||||
|
"instanceOperationUid": op_uid,
|
||||||
|
"svcOperationCfsParamId": int(pid),
|
||||||
|
"paramValue": str(pval)
|
||||||
|
})
|
||||||
|
sent.add(int(pid))
|
||||||
|
|
||||||
|
# Шаг 6: отправить default-значения для незаполненных параметров
|
||||||
|
for p in cfs_params:
|
||||||
|
pid = p["svcOperationCfsParamId"]
|
||||||
|
if pid in sent:
|
||||||
|
continue # пользователь уже заполнил — не перезаписываем
|
||||||
|
|
||||||
|
# Приоритет значений: paramValue → defaultValue → ""
|
||||||
|
val = p.get("paramValue")
|
||||||
|
if val is None:
|
||||||
|
val = p.get("defaultValue")
|
||||||
|
if val is None:
|
||||||
|
val = ""
|
||||||
|
|
||||||
|
# Нормализуем под тип (пустой integer → "0", map → "{}")
|
||||||
|
val = normalize_value(str(val), p.get("dataType", ""), p.get("dataDescriptor"))
|
||||||
|
|
||||||
|
client.post("/instanceOperationCfsParams", {
|
||||||
|
"instanceOperationUid": op_uid,
|
||||||
|
"svcOperationCfsParamId": pid,
|
||||||
|
"paramValue": val
|
||||||
|
})
|
||||||
|
|
||||||
|
# Шаг 7: validate-cfs — финальная проверка
|
||||||
|
# Если параметры невалидны — API вернёт ошибку → исключение
|
||||||
|
# Если ответ пустой или не-JSON — это ОК (значит валидация прошла)
|
||||||
|
try:
|
||||||
|
client.get(f"/instanceOperations/{op_uid}/validate-cfs")
|
||||||
|
except Exception as e:
|
||||||
|
# "Expecting value" / JSONDecodeError — это норма для validate-cfs
|
||||||
|
# (API возвращает 200 с пустым телом при успехе)
|
||||||
|
if "Expecting value" in str(e) or "JSON" in str(type(e).__name__):
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
raise # реальная ошибка — пробрасываем выше
|
||||||
+62
-27
@@ -1,20 +1,29 @@
|
|||||||
"""
|
"""
|
||||||
Трекер созданных инстансов.
|
Трекер созданных инстансов — краткосрочный JSON-файловый кеш.
|
||||||
|
|
||||||
Хранит UID'ы инстансов, созданных через приложение, в JSON-файлах.
|
ПРОБЛЕМА: когда мы создаём инстанс через POST /instances, он появляется
|
||||||
Нужен ТОЛЬКО как краткосрочный fallback — когда инстанс создан, но ещё
|
в Nubes НЕ МГНОВЕННО. GET /instances может задержать новый инстанс на 5-30 секунд.
|
||||||
не появился в ответе GET /instances (облако может задержать на секунды).
|
В это время пользователь не видит свой инстанс в списке → думает что create не сработал.
|
||||||
|
|
||||||
|
РЕШЕНИЕ: трекер — это JSON-файл в /tmp/, куда мы пишем UID инстанса СРАЗУ после
|
||||||
|
успешного create. UI сначала ищет инстансы в облаке (cloud-first), а то чего
|
||||||
|
нет в облаке — добирает из трекера (tracker-fallback).
|
||||||
|
|
||||||
Файлы изолированы по пользователю и стенду:
|
Файлы изолированы по пользователю и стенду:
|
||||||
/tmp/instances-{clientId}-{stand}.json
|
/tmp/instances-{clientId}-{stand}.json
|
||||||
|
|
||||||
Блокировка fcntl.flock для безопасной работы с несколькими воркерами gunicorn.
|
Блокировка fcntl.flock (эксклюзивная, неблокирующая с retry) —
|
||||||
При редеплое /tmp/ теряется — это НЕ критично, cloud-first выдача всё покажет.
|
для безопасной конкурентной работы нескольких gunicorn-воркеров.
|
||||||
|
|
||||||
|
ЖИЗНЕННЫЙ ЦИКЛ:
|
||||||
|
- add() — executor вызывает сразу после получения instanceUid
|
||||||
|
- remove() — после успешного delete (в _finish_op и CMDB-delete)
|
||||||
|
- При редеплое /tmp/ теряется — это НЕ критично (cloud-first всё покажет)
|
||||||
|
|
||||||
Функции:
|
Функции:
|
||||||
add(client_id, stand, uid, svc_id, name) — добавить инстанс
|
add(client_id, stand, uid, svc_id, name) — добавить инстанс
|
||||||
remove(client_id, stand, uid) — удалить (после успешного delete)
|
remove(client_id, stand, uid) — удалить (после успешного delete)
|
||||||
list_all(client_id, stand) — все записи списком
|
list_all(client_id, stand) — все записи списком {svcId, displayName, instanceUid}
|
||||||
"""
|
"""
|
||||||
import fcntl
|
import fcntl
|
||||||
import json
|
import json
|
||||||
@@ -23,7 +32,9 @@ import time
|
|||||||
|
|
||||||
|
|
||||||
def _path(client_id, stand):
|
def _path(client_id, stand):
|
||||||
"""Путь к файлу трекера для конкретного пользователя и стенда."""
|
"""Путь к файлу трекера: /tmp/instances-{clientId}-{stand}.json.
|
||||||
|
|
||||||
|
Изоляция по clientId + stand гарантирует что пользователи не видят чужие инстансы."""
|
||||||
return f"/tmp/instances-{client_id}-{stand}.json"
|
return f"/tmp/instances-{client_id}-{stand}.json"
|
||||||
|
|
||||||
|
|
||||||
@@ -31,38 +42,47 @@ def _acquire_lock(fd):
|
|||||||
"""Эксклюзивная блокировка файла с таймаутом 2 секунды.
|
"""Эксклюзивная блокировка файла с таймаутом 2 секунды.
|
||||||
|
|
||||||
Использует LOCK_NB (неблокирующий) + retry с шагом 50ms.
|
Использует LOCK_NB (неблокирующий) + retry с шагом 50ms.
|
||||||
Если за 2 секунды не взяли лок — возвращает False (не блокируемся навечно)."""
|
Почему не LOCK_EX с бесконечным ожиданием:
|
||||||
|
- При подвисании воркера все остальные повиснут навсегда.
|
||||||
|
- Лучше не записать в трекер, чем уронить запрос.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True — блокировка взята.
|
||||||
|
False — не смогли за 2 секунды (другой воркер держит слишком долго)."""
|
||||||
deadline = time.time() + 2
|
deadline = time.time() + 2
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
return True
|
return True # блокировка взята
|
||||||
except BlockingIOError:
|
except BlockingIOError:
|
||||||
if time.time() >= deadline:
|
if time.time() >= deadline:
|
||||||
return False
|
return False # таймаут — сдаёмся
|
||||||
time.sleep(0.05)
|
time.sleep(0.05)
|
||||||
|
|
||||||
|
|
||||||
def _locked_read(path):
|
def _locked_read(path):
|
||||||
"""Читает JSON-файл под эксклюзивной блокировкой.
|
"""Читает JSON-файл под эксклюзивной блокировкой.
|
||||||
|
|
||||||
Если файла нет — создаёт и возвращает пустой {}.
|
Если файла нет — создаёт (O_CREAT) и возвращает {}.
|
||||||
Если JSON битый — возвращает {} (переживёт перезапись).
|
Если JSON битый — возвращает {} (переживёт перезапись при следующем add).
|
||||||
Максимальный размер: 65536 байт."""
|
Максимальный размер чтения: 65536 байт (64KB) — больше трекеру не нужно.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict — содержимое файла или {}."""
|
||||||
try:
|
try:
|
||||||
# O_RDWR — чтение+запись, O_CREAT — создать если нет
|
# O_RDWR — чтение+запись, O_CREAT — создать если нет
|
||||||
fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o644)
|
fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o644)
|
||||||
except OSError:
|
except OSError:
|
||||||
return {} # нет прав — молча возвращаем пустой
|
return {} # нет прав (например /tmp/ только read) — молча
|
||||||
try:
|
try:
|
||||||
if not _acquire_lock(fd):
|
if not _acquire_lock(fd):
|
||||||
return {} # не смогли заблокировать за 2с — не рискуем
|
return {} # не смогли заблокировать → не читаем (гонка данных)
|
||||||
try:
|
try:
|
||||||
data = os.read(fd, 65536)
|
data = os.read(fd, 65536)
|
||||||
if data:
|
if data:
|
||||||
return json.loads(data.decode("utf-8"))
|
return json.loads(data.decode("utf-8"))
|
||||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||||
pass # битый файл — переживём, при следующей записи перезапишется
|
pass # битый JSON — при следующей записи перезапишется
|
||||||
return {}
|
return {}
|
||||||
finally:
|
finally:
|
||||||
fcntl.flock(fd, fcntl.LOCK_UN) # ВСЕГДА снимаем блокировку
|
fcntl.flock(fd, fcntl.LOCK_UN) # ВСЕГДА снимаем блокировку
|
||||||
@@ -72,27 +92,39 @@ def _locked_read(path):
|
|||||||
def _locked_write(path, data):
|
def _locked_write(path, data):
|
||||||
"""Пишет JSON-файл под эксклюзивной блокировкой.
|
"""Пишет JSON-файл под эксклюзивной блокировкой.
|
||||||
|
|
||||||
Полностью перезаписывает файл: lseek(0) + ftruncate + write."""
|
Полностью перезаписывает файл: lseek(0) + ftruncate + write.
|
||||||
|
Это атомарно под локом — другие воркеры увидят либо старую, либо новую версию.
|
||||||
|
|
||||||
|
Если не смогли взять лок — НЕ пишем.
|
||||||
|
Данные не потеряются — инстанс создан в Nubes, cloud-first его подхватит."""
|
||||||
try:
|
try:
|
||||||
fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o644)
|
fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o644)
|
||||||
except OSError:
|
except OSError:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
if not _acquire_lock(fd):
|
if not _acquire_lock(fd):
|
||||||
return # не смогли заблокировать — не пишем (данные не потеряются, просто не сохранятся)
|
return # не смогли заблокировать — пропускаем запись
|
||||||
os.lseek(fd, 0, 0) # в начало файла
|
# Три шага атомарной перезаписи:
|
||||||
os.ftruncate(fd, 0) # обрезать старый контент
|
os.lseek(fd, 0, 0) # 1. В начало файла
|
||||||
os.write(fd, json.dumps(data, indent=2).encode("utf-8"))
|
os.ftruncate(fd, 0) # 2. Обрезать старый контент
|
||||||
|
os.write(fd, json.dumps(data, indent=2).encode("utf-8")) # 3. Записать новый
|
||||||
finally:
|
finally:
|
||||||
fcntl.flock(fd, fcntl.LOCK_UN) # ВСЕГДА снимаем блокировку
|
fcntl.flock(fd, fcntl.LOCK_UN) # ВСЕГДА снимаем блокировку
|
||||||
os.close(fd)
|
os.close(fd)
|
||||||
|
|
||||||
|
|
||||||
def add(client_id, stand, instance_uid, svc_id, display_name):
|
def add(client_id, stand, instance_uid, svc_id, display_name):
|
||||||
"""Добавить инстанс в трекер.
|
"""Добавить инстанс в трекер (вызывается из executor сразу после create).
|
||||||
|
|
||||||
Читает текущий файл → добавляет запись → пишет обратно.
|
Читает текущий файл → добавляет/обновляет запись → пишет обратно.
|
||||||
Если инстанс уже есть — перезаписывает (идемпотентно)."""
|
Если инстанс уже есть — перезаписывает (идемпотентность).
|
||||||
|
|
||||||
|
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)
|
p = _path(client_id, stand)
|
||||||
data = _locked_read(p)
|
data = _locked_read(p)
|
||||||
data[instance_uid] = {
|
data[instance_uid] = {
|
||||||
@@ -106,7 +138,7 @@ def add(client_id, stand, instance_uid, svc_id, display_name):
|
|||||||
def remove(client_id, stand, instance_uid):
|
def remove(client_id, stand, instance_uid):
|
||||||
"""Удалить инстанс из трекера (после успешного delete).
|
"""Удалить инстанс из трекера (после успешного delete).
|
||||||
|
|
||||||
pop с default=None — не падает если инстанса уже нет."""
|
pop с default=None — не падает если инстанса уже нет (уже удалён ранее)."""
|
||||||
p = _path(client_id, stand)
|
p = _path(client_id, stand)
|
||||||
data = _locked_read(p)
|
data = _locked_read(p)
|
||||||
data.pop(instance_uid, None)
|
data.pop(instance_uid, None)
|
||||||
@@ -114,5 +146,8 @@ def remove(client_id, stand, instance_uid):
|
|||||||
|
|
||||||
|
|
||||||
def list_all(client_id, stand):
|
def list_all(client_id, stand):
|
||||||
"""Все записи трекера — список dict'ов {svcId, displayName, instanceUid}."""
|
"""Все записи трекера — список dict'ов {svcId, displayName, instanceUid}.
|
||||||
|
|
||||||
|
Используется в main.py → api_operations для tracker-fallback.
|
||||||
|
Конвертирует dict (indexed by UUID) в list."""
|
||||||
return list(_locked_read(_path(client_id, stand)).values())
|
return list(_locked_read(_path(client_id, stand)).values())
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
"""
|
||||||
|
API сценариев: CRUD определений + запуск + поллинг.
|
||||||
|
|
||||||
|
GET /api/scenarios — список из БД (для UI)
|
||||||
|
POST /api/scenario/run — запуск по definition_id (409 если уже RUNNING)
|
||||||
|
GET /api/scenario/run/<int:run_id> — статус конкретного запуска
|
||||||
|
GET /api/scenario/status — последние 10 запусков
|
||||||
|
|
||||||
|
CRUD определений:
|
||||||
|
GET /api/scenario/definitions — список
|
||||||
|
POST /api/scenario/definitions — создать
|
||||||
|
GET /api/scenario/definitions/<id> — один
|
||||||
|
PUT /api/scenario/definitions/<id> — обновить (version → 409)
|
||||||
|
DELETE /api/scenario/definitions/<id> — мягкое удаление
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import Blueprint, current_app, jsonify, request
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from api.auth import get_client, get_client_id, get_stand, get_token_info
|
||||||
|
from operations.scenario import run_scenario
|
||||||
|
from db.pool import get_conn, put_conn
|
||||||
|
from db.scenario_defs import (
|
||||||
|
list_definitions, get_definition, create_definition,
|
||||||
|
update_definition, delete_definition, lock_check,
|
||||||
|
)
|
||||||
|
|
||||||
|
bp = Blueprint("api_scenario", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Запуск ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
@bp.route("/api/scenarios")
|
||||||
|
def api_scenarios():
|
||||||
|
"""Список определений сценариев из БД (для UI запуска)."""
|
||||||
|
try:
|
||||||
|
defs = list_definitions(get_client_id(), get_stand())
|
||||||
|
result = [{"id": d["id"], "name": d["name"], "steps": len(d.get("steps", []))} for d in defs]
|
||||||
|
return jsonify(result)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/api/scenario/run", methods=["POST"])
|
||||||
|
def api_scenario_run():
|
||||||
|
"""Запустить сценарий по definition_id. 409 если уже RUNNING."""
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
def_id = data.get("definition_id")
|
||||||
|
if not def_id:
|
||||||
|
return jsonify({"error": "definition_id is required"}), 400
|
||||||
|
|
||||||
|
cid = get_client_id()
|
||||||
|
stand = get_stand()
|
||||||
|
|
||||||
|
# Lock 409
|
||||||
|
if not lock_check(cid, stand):
|
||||||
|
return jsonify({"error": "Another scenario is already running"}), 409
|
||||||
|
|
||||||
|
# Загрузить определение
|
||||||
|
defn = get_definition(def_id, cid, stand)
|
||||||
|
if not defn:
|
||||||
|
return jsonify({"error": "Definition not found"}), 404
|
||||||
|
|
||||||
|
steps = defn.get("steps", [])
|
||||||
|
if not steps:
|
||||||
|
return jsonify({"error": "Definition has no steps"}), 400
|
||||||
|
|
||||||
|
scenario_name = defn["name"]
|
||||||
|
user_email = get_token_info().get("email", "")
|
||||||
|
app_version = current_app.config.get("VERSION", "")
|
||||||
|
|
||||||
|
# Создать запись ДО потока
|
||||||
|
conn = get_conn()
|
||||||
|
if not conn:
|
||||||
|
return jsonify({"error": "no db"}), 500
|
||||||
|
run_id = None
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO scenario_runs (client_id, stand, user_email, scenario_name,
|
||||||
|
status, current_step, total_steps, app_version,
|
||||||
|
definition_id, definition_version)
|
||||||
|
VALUES (%s, %s, %s, %s, 'RUNNING', 0, %s, %s, %s, %s)
|
||||||
|
RETURNING id
|
||||||
|
""", (cid, stand, user_email, scenario_name, len(steps), app_version,
|
||||||
|
def_id, defn["version"]))
|
||||||
|
run_id = cur.fetchone()[0]
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[API] create scenario_run error: {e}", flush=True)
|
||||||
|
conn.rollback()
|
||||||
|
return jsonify({"error": "Failed to create scenario_run"}), 500
|
||||||
|
finally:
|
||||||
|
put_conn(conn)
|
||||||
|
|
||||||
|
# Запуск в фоне
|
||||||
|
client = get_client()
|
||||||
|
threading.Thread(
|
||||||
|
target=run_scenario,
|
||||||
|
args=(client, steps, cid, stand, user_email, app_version, run_id, scenario_name),
|
||||||
|
daemon=True,
|
||||||
|
).start()
|
||||||
|
|
||||||
|
return jsonify({"status": "RUNNING", "scenario": scenario_name, "run_id": run_id}), 202
|
||||||
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
print(f"[SCENARIO API] run error: {traceback.format_exc()}", flush=True)
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/api/scenario/run/<int:run_id>")
|
||||||
|
def api_scenario_run_status(run_id):
|
||||||
|
"""Статус конкретного запуска."""
|
||||||
|
conn = get_conn()
|
||||||
|
if not conn:
|
||||||
|
return jsonify({"error": "no db"}), 500
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, created_at, scenario_name, status, current_step, total_steps,
|
||||||
|
duration_sec, error_log, app_version
|
||||||
|
FROM scenario_runs
|
||||||
|
WHERE id = %s AND client_id = %s AND stand = %s
|
||||||
|
""", (run_id, get_client_id(), get_stand()))
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
return jsonify({"error": "not found"}), 404
|
||||||
|
cols = [d[0] for d in cur.description]
|
||||||
|
d = dict(zip(cols, row))
|
||||||
|
if d["created_at"]:
|
||||||
|
d["created_at"] = d["created_at"].isoformat()
|
||||||
|
return jsonify(d)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
finally:
|
||||||
|
put_conn(conn)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/api/scenario/status")
|
||||||
|
def api_scenario_status():
|
||||||
|
"""Последние 10 запусков."""
|
||||||
|
conn = get_conn()
|
||||||
|
if not conn:
|
||||||
|
return jsonify([])
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, created_at, scenario_name, status, current_step, total_steps,
|
||||||
|
duration_sec, error_log, app_version
|
||||||
|
FROM scenario_runs
|
||||||
|
WHERE client_id = %s AND stand = %s
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 10
|
||||||
|
""", (get_client_id(), get_stand()))
|
||||||
|
rows = cur.fetchall()
|
||||||
|
cols = [d[0] for d in cur.description]
|
||||||
|
result = []
|
||||||
|
for r in rows:
|
||||||
|
d = dict(zip(cols, r))
|
||||||
|
if d["created_at"]:
|
||||||
|
d["created_at"] = d["created_at"].isoformat()
|
||||||
|
result.append(d)
|
||||||
|
return jsonify(result)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
finally:
|
||||||
|
put_conn(conn)
|
||||||
|
|
||||||
|
|
||||||
|
# ── CRUD определений ────────────────────────────────────
|
||||||
|
|
||||||
|
@bp.route("/api/scenario/definitions", methods=["GET", "POST"])
|
||||||
|
def api_definitions():
|
||||||
|
cid = get_client_id()
|
||||||
|
stand = get_stand()
|
||||||
|
|
||||||
|
if request.method == "GET":
|
||||||
|
try:
|
||||||
|
defs = list_definitions(cid, stand)
|
||||||
|
return jsonify(defs)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
name = (data.get("name") or "").strip()
|
||||||
|
if not name:
|
||||||
|
return jsonify({"error": "name is required"}), 400
|
||||||
|
steps = data.get("steps", [])
|
||||||
|
if not isinstance(steps, list):
|
||||||
|
return jsonify({"error": "steps must be array"}), 400
|
||||||
|
user = get_token_info().get("email", "")
|
||||||
|
result = create_definition(cid, stand, name, steps, user)
|
||||||
|
if result is None:
|
||||||
|
return jsonify({"error": "create failed"}), 500
|
||||||
|
return jsonify(result), 201
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/api/scenario/definitions/<int:def_id>", methods=["GET", "PUT", "DELETE"])
|
||||||
|
def api_definition(def_id):
|
||||||
|
cid = get_client_id()
|
||||||
|
stand = get_stand()
|
||||||
|
|
||||||
|
if request.method == "GET":
|
||||||
|
d = get_definition(def_id, cid, stand)
|
||||||
|
if not d:
|
||||||
|
return jsonify({"error": "not found"}), 404
|
||||||
|
return jsonify(d)
|
||||||
|
|
||||||
|
if request.method == "PUT":
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
name = (data.get("name") or "").strip()
|
||||||
|
version = data.get("version")
|
||||||
|
if not name:
|
||||||
|
return jsonify({"error": "name is required"}), 400
|
||||||
|
if not isinstance(version, int):
|
||||||
|
return jsonify({"error": "version (int) is required"}), 400
|
||||||
|
steps = data.get("steps", [])
|
||||||
|
if not isinstance(steps, list):
|
||||||
|
return jsonify({"error": "steps must be array"}), 400
|
||||||
|
user = get_token_info().get("email", "")
|
||||||
|
result = update_definition(def_id, cid, stand, name, steps, version, user)
|
||||||
|
if result.get("conflict"):
|
||||||
|
return jsonify({"error": "Conflict: definition was modified"}), 409
|
||||||
|
if result.get("error"):
|
||||||
|
return jsonify({"error": result["error"]}), 500
|
||||||
|
return jsonify(result)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
if request.method == "DELETE":
|
||||||
|
ok = delete_definition(def_id, cid, stand)
|
||||||
|
if not ok:
|
||||||
|
return jsonify({"error": "not found"}), 404
|
||||||
|
return jsonify({"ok": True})
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"""
|
||||||
|
CRUD определений сценариев.
|
||||||
|
|
||||||
|
GET /api/scenario/definitions — список
|
||||||
|
POST /api/scenario/definitions — создать
|
||||||
|
GET /api/scenario/definitions/<id> — один
|
||||||
|
PUT /api/scenario/definitions/<id> — обновить (version → 409)
|
||||||
|
DELETE /api/scenario/definitions/<id> — мягкое удаление
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify, request
|
||||||
|
from api.auth import get_client_id, get_stand, get_token_info
|
||||||
|
from db.scenario_defs import (
|
||||||
|
list_definitions, get_definition, create_definition,
|
||||||
|
update_definition, delete_definition,
|
||||||
|
)
|
||||||
|
|
||||||
|
bp_defs = Blueprint("api_scenario_defs", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_steps(steps):
|
||||||
|
"""Проверить структуру шагов сценария. Возвращает None или str с ошибкой."""
|
||||||
|
if not isinstance(steps, list) or len(steps) == 0:
|
||||||
|
return "Шаги: непустой массив"
|
||||||
|
outputs = {} # output_name → step_index (для проверки ссылок и уникальности)
|
||||||
|
for i, s in enumerate(steps):
|
||||||
|
if not isinstance(s, dict):
|
||||||
|
return f"Шаг {i+1}: должен быть объектом"
|
||||||
|
svc_id = s.get("service_id")
|
||||||
|
if not isinstance(svc_id, int) or svc_id <= 0:
|
||||||
|
return f"Шаг {i+1}: service_id — положительный int"
|
||||||
|
op = (s.get("operation") or "").strip()
|
||||||
|
if not op:
|
||||||
|
return f"Шаг {i+1}: operation обязателен"
|
||||||
|
if not isinstance(s.get("params", {}), dict):
|
||||||
|
return f"Шаг {i+1}: params — объект"
|
||||||
|
|
||||||
|
# Новые опциональные ключи: output, instance_ref, instance_uid
|
||||||
|
out = (s.get("output") or "").strip()
|
||||||
|
ref = (s.get("instance_ref") or "").strip()
|
||||||
|
uid = (s.get("instance_uid") or "").strip()
|
||||||
|
|
||||||
|
# output — только для create, уникален
|
||||||
|
if out:
|
||||||
|
if op != "create":
|
||||||
|
return f"Шаг {i+1}: output допустим только для create"
|
||||||
|
if out in outputs:
|
||||||
|
return f"Шаг {i+1}: output '{out}' уже используется в шаге {outputs[out]+1}"
|
||||||
|
outputs[out] = i
|
||||||
|
|
||||||
|
# instance_ref — должен ссылаться на существующий output из предыдущих шагов
|
||||||
|
if ref:
|
||||||
|
if ref not in outputs:
|
||||||
|
return f"Шаг {i+1}: instance_ref '{ref}' — output не найден (должен быть в create-шаге выше)"
|
||||||
|
|
||||||
|
# Для не-create: должен быть instance_ref, instance_uid, или старый формат (fallback)
|
||||||
|
if op != "create":
|
||||||
|
has_target = bool(ref or uid or (not out)) # out в не-create — ошибка выше
|
||||||
|
# ok: ref есть, uid есть, или старый формат без новых ключей (fallback)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@bp_defs.route("/api/scenario/definitions", methods=["GET", "POST"])
|
||||||
|
def api_definitions():
|
||||||
|
cid = get_client_id()
|
||||||
|
stand = get_stand()
|
||||||
|
|
||||||
|
if request.method == "GET":
|
||||||
|
try:
|
||||||
|
defs = list_definitions(cid, stand)
|
||||||
|
return jsonify(defs)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
name = (data.get("name") or "").strip()
|
||||||
|
if not name:
|
||||||
|
return jsonify({"error": "name is required"}), 400
|
||||||
|
steps = data.get("steps", [])
|
||||||
|
err = _validate_steps(steps)
|
||||||
|
if err:
|
||||||
|
return jsonify({"error": err}), 400
|
||||||
|
user = get_token_info().get("email", "")
|
||||||
|
result = create_definition(cid, stand, name, steps, user)
|
||||||
|
if result is None:
|
||||||
|
return jsonify({"error": "create failed"}), 500
|
||||||
|
return jsonify(result), 201
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@bp_defs.route("/api/scenario/definitions/<int:def_id>", methods=["GET", "PUT", "DELETE"])
|
||||||
|
def api_definition(def_id):
|
||||||
|
cid = get_client_id()
|
||||||
|
stand = get_stand()
|
||||||
|
|
||||||
|
if request.method == "GET":
|
||||||
|
d = get_definition(def_id, cid, stand)
|
||||||
|
if not d:
|
||||||
|
return jsonify({"error": "not found"}), 404
|
||||||
|
return jsonify(d)
|
||||||
|
|
||||||
|
if request.method == "PUT":
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
name = (data.get("name") or "").strip()
|
||||||
|
version = data.get("version")
|
||||||
|
if not name:
|
||||||
|
return jsonify({"error": "name is required"}), 400
|
||||||
|
if not isinstance(version, int):
|
||||||
|
return jsonify({"error": "version (int) is required"}), 400
|
||||||
|
steps = data.get("steps", [])
|
||||||
|
err = _validate_steps(steps)
|
||||||
|
if err:
|
||||||
|
return jsonify({"error": err}), 400
|
||||||
|
user = get_token_info().get("email", "")
|
||||||
|
result = update_definition(def_id, cid, stand, name, steps, version, user)
|
||||||
|
if result.get("conflict"):
|
||||||
|
return jsonify({"error": "Conflict: definition was modified"}), 409
|
||||||
|
if result.get("error"):
|
||||||
|
return jsonify({"error": result["error"]}), 500
|
||||||
|
return jsonify(result)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
if request.method == "DELETE":
|
||||||
|
ok = delete_definition(def_id, cid, stand)
|
||||||
|
if not ok:
|
||||||
|
return jsonify({"error": "not found"}), 404
|
||||||
|
return jsonify({"ok": True})
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
"""
|
||||||
|
API сценариев: запуск + поллинг.
|
||||||
|
|
||||||
|
GET /api/scenarios — список из БД (для UI)
|
||||||
|
POST /api/scenario/run — запуск по definition_id (409 если уже RUNNING)
|
||||||
|
GET /api/scenario/run/<int:run_id> — статус конкретного запуска
|
||||||
|
GET /api/scenario/status — последние 10 запусков
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import Blueprint, current_app, jsonify, request
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from api.auth import get_client, get_client_id, get_stand, get_token_info
|
||||||
|
from operations.scenario import run_scenario
|
||||||
|
from db.pool import get_conn, put_conn
|
||||||
|
from db.scenario_defs import get_definition, lock_check, list_definitions
|
||||||
|
|
||||||
|
bp_run = Blueprint("api_scenario_run", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@bp_run.route("/api/scenarios")
|
||||||
|
def api_scenarios():
|
||||||
|
"""Список определений сценариев из БД (для UI запуска)."""
|
||||||
|
try:
|
||||||
|
defs = list_definitions(get_client_id(), get_stand())
|
||||||
|
result = [{"id": d["id"], "name": d["name"], "steps": len(d.get("steps", []))} for d in defs]
|
||||||
|
return jsonify(result)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@bp_run.route("/api/scenario/run", methods=["POST"])
|
||||||
|
def api_scenario_run():
|
||||||
|
"""Запустить сценарий по definition_id. 409 если уже RUNNING."""
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
def_id = data.get("definition_id")
|
||||||
|
if not def_id:
|
||||||
|
return jsonify({"error": "definition_id is required"}), 400
|
||||||
|
|
||||||
|
cid = get_client_id()
|
||||||
|
stand = get_stand()
|
||||||
|
|
||||||
|
if not lock_check(cid, stand):
|
||||||
|
return jsonify({"error": "Another scenario is already running"}), 409
|
||||||
|
|
||||||
|
defn = get_definition(def_id, cid, stand)
|
||||||
|
if not defn:
|
||||||
|
return jsonify({"error": "Definition not found"}), 404
|
||||||
|
|
||||||
|
steps = defn.get("steps", [])
|
||||||
|
if not steps:
|
||||||
|
return jsonify({"error": "Definition has no steps"}), 400
|
||||||
|
|
||||||
|
scenario_name = defn["name"]
|
||||||
|
user_email = get_token_info().get("email", "")
|
||||||
|
app_version = current_app.config.get("VERSION", "")
|
||||||
|
|
||||||
|
conn = get_conn()
|
||||||
|
if not conn:
|
||||||
|
return jsonify({"error": "no db"}), 500
|
||||||
|
run_id = None
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO scenario_runs (client_id, stand, user_email, scenario_name,
|
||||||
|
status, current_step, total_steps, app_version,
|
||||||
|
definition_id, definition_version)
|
||||||
|
VALUES (%s, %s, %s, %s, 'RUNNING', 0, %s, %s, %s, %s)
|
||||||
|
RETURNING id
|
||||||
|
""", (cid, stand, user_email, scenario_name, len(steps), app_version,
|
||||||
|
def_id, defn["version"]))
|
||||||
|
run_id = cur.fetchone()[0]
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[API] create scenario_run error: {e}", flush=True)
|
||||||
|
conn.rollback()
|
||||||
|
return jsonify({"error": "Failed to create scenario_run"}), 500
|
||||||
|
finally:
|
||||||
|
put_conn(conn)
|
||||||
|
|
||||||
|
client = get_client()
|
||||||
|
threading.Thread(
|
||||||
|
target=run_scenario,
|
||||||
|
args=(client, steps, cid, stand, user_email, app_version, run_id, scenario_name),
|
||||||
|
daemon=True,
|
||||||
|
).start()
|
||||||
|
|
||||||
|
return jsonify({"status": "RUNNING", "scenario": scenario_name, "run_id": run_id}), 202
|
||||||
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
print(f"[SCENARIO API] run error: {traceback.format_exc()}", flush=True)
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@bp_run.route("/api/scenario/run/<int:run_id>")
|
||||||
|
def api_scenario_run_status(run_id):
|
||||||
|
"""Статус конкретного запуска."""
|
||||||
|
conn = get_conn()
|
||||||
|
if not conn:
|
||||||
|
return jsonify({"error": "no db"}), 500
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, created_at, scenario_name, status, current_step, total_steps,
|
||||||
|
duration_sec, error_log, app_version
|
||||||
|
FROM scenario_runs
|
||||||
|
WHERE id = %s AND client_id = %s AND stand = %s
|
||||||
|
""", (run_id, get_client_id(), get_stand()))
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
return jsonify({"error": "not found"}), 404
|
||||||
|
cols = [d[0] for d in cur.description]
|
||||||
|
d = dict(zip(cols, row))
|
||||||
|
if d["created_at"]:
|
||||||
|
d["created_at"] = d["created_at"].isoformat()
|
||||||
|
return jsonify(d)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
finally:
|
||||||
|
put_conn(conn)
|
||||||
|
|
||||||
|
|
||||||
|
@bp_run.route("/api/scenario/status")
|
||||||
|
def api_scenario_status():
|
||||||
|
"""Последние 10 запусков."""
|
||||||
|
conn = get_conn()
|
||||||
|
if not conn:
|
||||||
|
return jsonify([])
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, created_at, scenario_name, status, current_step, total_steps,
|
||||||
|
duration_sec, error_log, app_version
|
||||||
|
FROM scenario_runs
|
||||||
|
WHERE client_id = %s AND stand = %s
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 10
|
||||||
|
""", (get_client_id(), get_stand()))
|
||||||
|
rows = cur.fetchall()
|
||||||
|
cols = [d[0] for d in cur.description]
|
||||||
|
result = []
|
||||||
|
for r in rows:
|
||||||
|
d = dict(zip(cols, r))
|
||||||
|
if d["created_at"]:
|
||||||
|
d["created_at"] = d["created_at"].isoformat()
|
||||||
|
result.append(d)
|
||||||
|
return jsonify(result)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
finally:
|
||||||
|
put_conn(conn)
|
||||||
+85
-220
@@ -31,9 +31,13 @@ import fcntl
|
|||||||
|
|
||||||
from api.http_client import HttpClient, detect_endpoint, stand_name
|
from api.http_client import HttpClient, detect_endpoint, stand_name
|
||||||
from api.auth import get_token, get_client, get_client_id, get_stand, get_token_info
|
from api.auth import get_token, get_client, get_client_id, get_stand, get_token_info
|
||||||
|
from api.utils import find_uid, uid_from_location
|
||||||
from operations.get_services import get_services, get_service_detail
|
from operations.get_services import get_services, get_service_detail
|
||||||
from operations.get_instances import get_instances
|
from operations.get_instances import get_instances
|
||||||
from operations.get_params import get_params_with_current_values, _normalize_value_list
|
from operations.get_params import get_params_with_current_values, _normalize_value_list
|
||||||
|
from operations.terraform import send_params_terraform, redact_params
|
||||||
|
from operations.executor import execute_operation
|
||||||
|
from operations.poll import poll_until_done
|
||||||
from operations.tracker import add as tracker_add, list_all as tracker_list
|
from operations.tracker import add as tracker_add, list_all as tracker_list
|
||||||
from operations.tracker import remove as tracker_remove
|
from operations.tracker import remove as tracker_remove
|
||||||
|
|
||||||
@@ -163,12 +167,24 @@ def api_test():
|
|||||||
import threading, time
|
import threading, time
|
||||||
|
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
svc_id = data["serviceId"]
|
svc_id = data.get("serviceId")
|
||||||
op_name = data["operation"]
|
op_name = (data.get("operation") or "").strip()
|
||||||
svc_op_id = data["svcOperationId"]
|
svc_op_id = data.get("svcOperationId")
|
||||||
params = data.get("params", {})
|
params = data.get("params", {})
|
||||||
instance_uid = data.get("instanceUid")
|
instance_uid = (data.get("instanceUid") or "").strip()
|
||||||
display_name = data.get("displayName") or ""
|
display_name = (data.get("displayName") or "").strip()
|
||||||
|
|
||||||
|
# --- Validation ---
|
||||||
|
if not isinstance(svc_id, int) or svc_id <= 0:
|
||||||
|
return jsonify({"status": "FAIL", "error": "invalid serviceId"}), 400
|
||||||
|
if not op_name:
|
||||||
|
return jsonify({"status": "FAIL", "error": "invalid operation name"}), 400
|
||||||
|
if not isinstance(svc_op_id, int) or svc_op_id <= 0:
|
||||||
|
return jsonify({"status": "FAIL", "error": "invalid svcOperationId"}), 400
|
||||||
|
if instance_uid and len(instance_uid) != 36:
|
||||||
|
return jsonify({"status": "FAIL", "error": "invalid instanceUid (must be UUID)"}), 400
|
||||||
|
if not isinstance(params, dict):
|
||||||
|
return jsonify({"status": "FAIL", "error": "invalid params (must be object)"}), 400
|
||||||
|
|
||||||
client = get_client()
|
client = get_client()
|
||||||
|
|
||||||
@@ -178,33 +194,18 @@ def api_test():
|
|||||||
display_name = f"autotest-{svc_id}"
|
display_name = f"autotest-{svc_id}"
|
||||||
display_name = _unique_display_name(client, display_name)
|
display_name = _unique_display_name(client, display_name)
|
||||||
descr = f"created by autotest v{current_app.config.get('VERSION', '')}"
|
descr = f"created by autotest v{current_app.config.get('VERSION', '')}"
|
||||||
payload = {"serviceId": svc_id, "displayName": display_name, "descr": descr}
|
result = execute_operation(
|
||||||
resp = client.post("/instances", payload)
|
client, svc_id, op_name, None, params,
|
||||||
instance_uid = resp.get("instanceUid") or _find_uid(resp) or _uid_from_location(resp.get("_location", ""))
|
svc_op_id=svc_op_id, display_name=display_name, descr=descr,
|
||||||
if not instance_uid:
|
client_id=get_client_id(), stand=get_stand()
|
||||||
return jsonify({"status": "FAIL", "error": "Не удалось получить instanceUid"}), 500
|
)
|
||||||
|
if not result["ok"]:
|
||||||
|
return jsonify({"status": "FAIL", "error": result["error"]}), 500
|
||||||
|
instance_uid = result["instance_uid"]
|
||||||
|
op_uid = result["op_uid"]
|
||||||
|
display_name = result["display_name"]
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
# Записать в трекер СРАЗУ после получения instanceUid, до params/run
|
|
||||||
# (если params упадут — инстанс уже отслежен, сирот не будет)
|
|
||||||
try:
|
|
||||||
tracker_add(get_client_id(), get_stand(), instance_uid, svc_id, display_name)
|
|
||||||
except Exception as e:
|
|
||||||
import traceback
|
|
||||||
print(f"[TRACKER ERROR] add failed: {e}", flush=True)
|
|
||||||
traceback.print_exc()
|
|
||||||
|
|
||||||
# Отправка параметров (шаги 3-7 Terraform)
|
|
||||||
_send_params_terraform(client, op_uid, params)
|
|
||||||
|
|
||||||
client.post(f"/instanceOperations/{op_uid}/run")
|
|
||||||
|
|
||||||
# фоном ждать завершения
|
|
||||||
user_email = get_token_info().get("email", "")
|
user_email = get_token_info().get("email", "")
|
||||||
app_version = current_app.config.get("VERSION", "")
|
app_version = current_app.config.get("VERSION", "")
|
||||||
threading.Thread(target=_finish_op, args=(client, op_uid, instance_uid, svc_id, display_name, op_name, svc_op_id, True, get_client_id(), get_stand(), False, params, user_email, app_version), daemon=True).start()
|
threading.Thread(target=_finish_op, args=(client, op_uid, instance_uid, svc_id, display_name, op_name, svc_op_id, True, get_client_id(), get_stand(), False, params, user_email, app_version), daemon=True).start()
|
||||||
@@ -227,20 +228,21 @@ def api_test():
|
|||||||
return jsonify({"status": "OK", "opUid": "cmdb-"+instance_uid[:8], "instanceUid": instance_uid, "displayName": display_name})
|
return jsonify({"status": "OK", "opUid": "cmdb-"+instance_uid[:8], "instanceUid": instance_uid, "displayName": display_name})
|
||||||
|
|
||||||
if op_name == "redeploy":
|
if op_name == "redeploy":
|
||||||
op_payload = {"instanceUid": instance_uid, "svcOperationId": svc_op_id, "operation": op_name}
|
result = execute_operation(
|
||||||
op_resp = client.post("/instanceOperations", op_payload)
|
client, svc_id, op_name, instance_uid, {},
|
||||||
op_uid = _find_uid(op_resp) or _uid_from_location(op_resp.get("_location", ""))
|
svc_op_id=svc_op_id
|
||||||
if not op_uid:
|
)
|
||||||
return jsonify({"status": "FAIL", "error": "Не удалось получить opUid"}), 500
|
if not result["ok"]:
|
||||||
client.post(f"/instanceOperations/{op_uid}/run")
|
return jsonify({"status": "FAIL", "error": result["error"]}), 500
|
||||||
|
op_uid = result["op_uid"]
|
||||||
else:
|
else:
|
||||||
op_payload = {"instanceUid": instance_uid, "svcOperationId": svc_op_id, "operation": op_name}
|
result = execute_operation(
|
||||||
op_resp = client.post("/instanceOperations", op_payload)
|
client, svc_id, op_name, instance_uid, params,
|
||||||
op_uid = _find_uid(op_resp) or _uid_from_location(op_resp.get("_location", ""))
|
svc_op_id=svc_op_id
|
||||||
if not op_uid:
|
)
|
||||||
return jsonify({"status": "FAIL", "error": "Не удалось получить opUid"}), 500
|
if not result["ok"]:
|
||||||
_send_params_terraform(client, op_uid, params)
|
return jsonify({"status": "FAIL", "error": result["error"]}), 500
|
||||||
client.post(f"/instanceOperations/{op_uid}/run")
|
op_uid = result["op_uid"]
|
||||||
|
|
||||||
# фоном ждать завершения
|
# фоном ждать завершения
|
||||||
is_delete = (op_name == "delete")
|
is_delete = (op_name == "delete")
|
||||||
@@ -253,7 +255,8 @@ def api_test():
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import traceback
|
import traceback
|
||||||
return jsonify({"status": "FAIL", "error": str(e) + " | " + traceback.format_exc()[-200:]})
|
_log(f"[api_test] EXCEPTION: {traceback.format_exc()}")
|
||||||
|
return jsonify({"status": "FAIL", "error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
# Результаты фоновых операций: opUid → {status, error, stages, duration, _ts}
|
# Результаты фоновых операций: opUid → {status, error, stages, duration, _ts}
|
||||||
@@ -262,116 +265,6 @@ _op_results = {}
|
|||||||
_MAX_OP_RESULTS = 500
|
_MAX_OP_RESULTS = 500
|
||||||
|
|
||||||
|
|
||||||
def _normalize_value(val, data_type, data_descriptor):
|
|
||||||
"""normalizeUniversalValueV6 — Terraform-equivalent.
|
|
||||||
Пустые значения нормализуются по типу: integer→"0", boolean→"false",
|
|
||||||
array→"[]", map/json→"{}", map-fixed→сборка из sub-param defaults."""
|
|
||||||
val = (val or "").strip()
|
|
||||||
if val.lower() == "null" or val == '""':
|
|
||||||
val = ""
|
|
||||||
dt = (data_type or "").lower()
|
|
||||||
# map-fixed/map с DataDescriptor: пусто или {} → собрать из sub-param defaults
|
|
||||||
if ("map" in dt) and isinstance(data_descriptor, dict) and data_descriptor:
|
|
||||||
if not val or val == "{}":
|
|
||||||
sub_obj = {}
|
|
||||||
for sk, sv in data_descriptor.items():
|
|
||||||
sd = sv.get("defaultValue")
|
|
||||||
sub_obj[sk] = str(sd) if sd is not None else ""
|
|
||||||
return json.dumps(sub_obj)
|
|
||||||
return val
|
|
||||||
if val:
|
|
||||||
return val
|
|
||||||
# Пустое — нормализовать по типу
|
|
||||||
if "array" in dt:
|
|
||||||
return "[]"
|
|
||||||
if "map" in dt or "json" in dt:
|
|
||||||
return "{}"
|
|
||||||
if "integer" in dt or "int" in dt:
|
|
||||||
return "0"
|
|
||||||
if "boolean" in dt or "bool" in dt:
|
|
||||||
return "false"
|
|
||||||
return val # пустая строка — отправляем как есть (Terraform тоже шлёт)
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_ref_svc(client, cfs_params, params):
|
|
||||||
"""resolveRefSvcParamValues — Terraform step 4.
|
|
||||||
Для параметров с refSvcId в dataDescriptor: если значение пустое —
|
|
||||||
подставить UUID существующего инстанса этого сервиса."""
|
|
||||||
instances = None # lazy load
|
|
||||||
for p in cfs_params:
|
|
||||||
dd = p.get("dataDescriptor")
|
|
||||||
if not isinstance(dd, dict):
|
|
||||||
continue
|
|
||||||
pid = str(p["svcOperationCfsParamId"])
|
|
||||||
user_val = {}
|
|
||||||
if pid in params:
|
|
||||||
try:
|
|
||||||
user_val = json.loads(params[pid]) if params[pid] else {}
|
|
||||||
except (json.JSONDecodeError, ValueError):
|
|
||||||
user_val = {}
|
|
||||||
for sk, sv in dd.items():
|
|
||||||
ref_id = sv.get("refSvcId")
|
|
||||||
if not ref_id:
|
|
||||||
continue
|
|
||||||
cur = user_val.get(sk, "")
|
|
||||||
if cur and cur != '""':
|
|
||||||
continue # уже заполнено
|
|
||||||
if instances is None:
|
|
||||||
try:
|
|
||||||
instances = get_instances(client)
|
|
||||||
except Exception:
|
|
||||||
return
|
|
||||||
match = next((i for i in instances if i.get("serviceId") == ref_id and i.get("explainedStatus") not in ("deleted",)), None)
|
|
||||||
if match:
|
|
||||||
user_val[sk] = match["instanceUid"]
|
|
||||||
if user_val:
|
|
||||||
params[pid] = json.dumps(user_val)
|
|
||||||
|
|
||||||
|
|
||||||
def _send_params_terraform(client, op_uid, params):
|
|
||||||
"""Terraform steps 3-7: cfsParams → resolveRefSvc → send user → send all unsent → validate.
|
|
||||||
Работает для CREATE и non-CREATE одинаково."""
|
|
||||||
# Шаг 3: GET cfsParams реальной операции
|
|
||||||
op_det = client.get(f"/instanceOperations/{op_uid}?fields=cfsParams")
|
|
||||||
cfs_params = op_det.get("instanceOperation", {}).get("cfsParams", [])
|
|
||||||
# Шаг 4: resolveRefSvcParamValues
|
|
||||||
_resolve_ref_svc(client, cfs_params, params)
|
|
||||||
# Шаг 5: отправить ВСЕ пользовательские params (включая пустые!)
|
|
||||||
sent = set()
|
|
||||||
for pid, pval in params.items():
|
|
||||||
client.post("/instanceOperationCfsParams", {
|
|
||||||
"instanceOperationUid": op_uid,
|
|
||||||
"svcOperationCfsParamId": int(pid),
|
|
||||||
"paramValue": str(pval)
|
|
||||||
})
|
|
||||||
sent.add(int(pid))
|
|
||||||
# Шаг 6: дослать ВСЕ неотправленные params из cfsParams
|
|
||||||
for p in cfs_params:
|
|
||||||
pid = p["svcOperationCfsParamId"]
|
|
||||||
if pid in sent:
|
|
||||||
continue
|
|
||||||
# nil-check: paramValue может быть None (отличаем от "")
|
|
||||||
val = p.get("paramValue")
|
|
||||||
if val is None:
|
|
||||||
val = p.get("defaultValue")
|
|
||||||
if val is None:
|
|
||||||
val = ""
|
|
||||||
val = _normalize_value(str(val), p.get("dataType", ""), p.get("dataDescriptor"))
|
|
||||||
client.post("/instanceOperationCfsParams", {
|
|
||||||
"instanceOperationUid": op_uid,
|
|
||||||
"svcOperationCfsParamId": pid,
|
|
||||||
"paramValue": val
|
|
||||||
})
|
|
||||||
# Шаг 7: validate-cfs (пустой ответ = успех)
|
|
||||||
try:
|
|
||||||
client.get(f"/instanceOperations/{op_uid}/validate-cfs")
|
|
||||||
except Exception as e:
|
|
||||||
if "Expecting value" in str(e) or "JSON" in str(type(e).__name__):
|
|
||||||
pass # пустой ответ = OK
|
|
||||||
else:
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
def _cleanup_op_results():
|
def _cleanup_op_results():
|
||||||
"""Удалить старые записи: старше 1 часа или сверх лимита."""
|
"""Удалить старые записи: старше 1 часа или сверх лимита."""
|
||||||
import time
|
import time
|
||||||
@@ -400,65 +293,55 @@ def _get_instance_display_name(client, instance_uid):
|
|||||||
def _finish_op(client, op_uid, instance_uid, svc_id, display_name, op_name, svc_op_id, is_create, client_id, stand, is_delete=False, params=None, user_email="", app_version=""):
|
def _finish_op(client, op_uid, instance_uid, svc_id, display_name, op_name, svc_op_id, is_create, client_id, stand, is_delete=False, params=None, user_email="", app_version=""):
|
||||||
"""Фоном ждать dtFinish и сохранить результат."""
|
"""Фоном ждать dtFinish и сохранить результат."""
|
||||||
import time
|
import time
|
||||||
_cleanup_op_results() # очистить старые записи перед добавлением новой
|
_cleanup_op_results()
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
deadline = t0 + 1800
|
|
||||||
while time.time() < deadline:
|
# Поллинг через общий модуль
|
||||||
try:
|
poll_result = poll_until_done(client, op_uid)
|
||||||
try:
|
|
||||||
data = client.get(f"/instanceOperations/{op_uid}?fields=dtFinish,isSuccessful,errorLog,isInProgress,duration,stages,svc")
|
# Обновить _op_results для UI
|
||||||
except Exception:
|
|
||||||
time.sleep(5)
|
|
||||||
continue
|
|
||||||
op = data.get("instanceOperation", {})
|
|
||||||
dt_finish = op.get("dtFinish")
|
|
||||||
# Обновляем stages по мере появления
|
|
||||||
_op_results[op_uid] = {
|
_op_results[op_uid] = {
|
||||||
"status": "RUNNING",
|
"status": poll_result["status"],
|
||||||
"displayName": display_name,
|
"displayName": display_name,
|
||||||
"stages": op.get("stages", []),
|
"error": poll_result["error_log"],
|
||||||
"duration": round(time.time() - t0, 1),
|
"stages": poll_result["stages"],
|
||||||
|
"duration": poll_result["duration"],
|
||||||
"_ts": time.time(),
|
"_ts": time.time(),
|
||||||
}
|
}
|
||||||
if dt_finish and str(dt_finish).strip():
|
|
||||||
is_ok = op.get("isSuccessful")
|
# tracker_remove при успешном delete
|
||||||
err = op.get("errorLog") or ""
|
if poll_result["status"] == "OK" and is_delete:
|
||||||
print(f"[DEBUG] _finish_op op_uid={op_uid} isSuccessful={is_ok!r}", flush=True)
|
try:
|
||||||
# tracker_add для create уже вызван синхронно в api_test()
|
|
||||||
if is_ok and is_delete:
|
|
||||||
tracker_remove(client_id, stand, instance_uid)
|
tracker_remove(client_id, stand, instance_uid)
|
||||||
_op_results[op_uid] = {
|
except Exception:
|
||||||
"status": "OK" if is_ok else "FAIL",
|
pass
|
||||||
"displayName": display_name,
|
|
||||||
"error": str(err) if err else "",
|
# Сохранить в БД
|
||||||
"stages": op.get("stages", []),
|
|
||||||
"duration": round(time.time() - t0, 1),
|
|
||||||
"_ts": time.time(),
|
|
||||||
}
|
|
||||||
# Сохранить в БД историю
|
|
||||||
try:
|
try:
|
||||||
from db.save_run import save_run
|
from db.save_run import save_run
|
||||||
save_run(client_id, stand,
|
# Получить полную информацию об инстансе
|
||||||
user_email,
|
instance_meta = None
|
||||||
svc_id, op.get("svc", ""), op_name, svc_op_id,
|
try:
|
||||||
|
inst_data = client.get(f"/instances/{instance_uid}")
|
||||||
|
inst = inst_data.get("instance", {}) if isinstance(inst_data, dict) else {}
|
||||||
|
if inst:
|
||||||
|
instance_meta = {k: v for k, v in inst.items()
|
||||||
|
if k not in ('instanceUid', 'displayName', 'svc', 'serviceId', 'explainedStatus')}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
saved_params = redact_params(params or {})
|
||||||
|
save_run(client_id, stand, user_email,
|
||||||
|
svc_id, poll_result["svc"], op_name, svc_op_id,
|
||||||
op_uid, instance_uid, display_name,
|
op_uid, instance_uid, display_name,
|
||||||
"OK" if is_ok else "FAIL",
|
poll_result["status"],
|
||||||
round(time.time() - t0, 1),
|
poll_result["duration"],
|
||||||
str(err) if err else "",
|
poll_result["error_log"],
|
||||||
params or {},
|
saved_params, poll_result["stages"], app_version,
|
||||||
op.get("stages", []),
|
instance_meta=instance_meta)
|
||||||
app_version)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import traceback
|
import traceback
|
||||||
print(f"[DB] save_run FAILED: {e}", flush=True)
|
print(f"[DB] save_run FAILED: {e}", flush=True)
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
return
|
|
||||||
time.sleep(5)
|
|
||||||
except Exception:
|
|
||||||
import traceback
|
|
||||||
print(f"[ERROR] _finish_op crashed: {traceback.format_exc()}", flush=True)
|
|
||||||
time.sleep(5)
|
|
||||||
_op_results[op_uid] = {"status": "TIMEOUT", "displayName": display_name, "duration": round(time.time() - t0, 1), "_ts": time.time()}
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/api/test/status/<op_uid>")
|
@bp.route("/api/test/status/<op_uid>")
|
||||||
@@ -515,7 +398,7 @@ def api_history():
|
|||||||
SELECT id, created_at, client_id, stand, user_email,
|
SELECT id, created_at, client_id, stand, user_email,
|
||||||
svc_id, svc_name, op_name, svc_op_id, op_uid,
|
svc_id, svc_name, op_name, svc_op_id, op_uid,
|
||||||
instance_uid, display_name,
|
instance_uid, display_name,
|
||||||
status, duration_sec, error_log, app_version
|
status, duration_sec, error_log, app_version, stages
|
||||||
FROM runs
|
FROM runs
|
||||||
WHERE client_id = %s AND stand = %s
|
WHERE client_id = %s AND stand = %s
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
@@ -541,21 +424,3 @@ def api_history():
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
def _find_uid(resp):
|
|
||||||
"""Извлечь instanceUid или instanceOperationUid из ответа API."""
|
|
||||||
if isinstance(resp, dict):
|
|
||||||
for key in ("instanceOperationUid", "instanceUid", "uid", "Uid"):
|
|
||||||
v = resp.get(key)
|
|
||||||
if isinstance(v, str) and v:
|
|
||||||
return v
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _uid_from_location(loc):
|
|
||||||
if loc:
|
|
||||||
parts = loc.rstrip("/").split("/")
|
|
||||||
if len(parts[-1]) == 36:
|
|
||||||
return parts[-1]
|
|
||||||
return None
|
|
||||||
|
|||||||
+64
-13
@@ -1,3 +1,12 @@
|
|||||||
|
"""
|
||||||
|
LEGACY-раннер — автоматический прогон тестов по конфигурации из config.yaml.
|
||||||
|
|
||||||
|
Этот модуль больше НЕ используется в основном потоке приложения.
|
||||||
|
Оставлен для обратной совместимости: эндпоинты /api/run, /api/status, /api/config.
|
||||||
|
|
||||||
|
Сейчас основной режим — ручной (через UI) и сценарный (через scenario.py).
|
||||||
|
"""
|
||||||
|
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import yaml
|
import yaml
|
||||||
@@ -5,34 +14,52 @@ import os
|
|||||||
|
|
||||||
from api.http_client import HttpClient
|
from api.http_client import HttpClient
|
||||||
|
|
||||||
|
# Путь к YAML-конфигу тестов
|
||||||
CONFIG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.yaml")
|
CONFIG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.yaml")
|
||||||
|
|
||||||
|
# _current_run — глобальное состояние последнего запуска.
|
||||||
|
# Защищено _lock для потокобезопасности.
|
||||||
_current_run = None
|
_current_run = None
|
||||||
_lock = threading.Lock()
|
_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
def load_config():
|
def load_config():
|
||||||
|
"""Загрузить YAML-конфиг тестов. Используется в /api/config (GET)."""
|
||||||
with open(CONFIG_PATH) as f:
|
with open(CONFIG_PATH) as f:
|
||||||
return yaml.safe_load(f)
|
return yaml.safe_load(f)
|
||||||
|
|
||||||
|
|
||||||
def save_config(data):
|
def save_config(data):
|
||||||
|
"""Сохранить YAML-конфиг. Используется в /api/config (POST)."""
|
||||||
with open(CONFIG_PATH, "w") as f:
|
with open(CONFIG_PATH, "w") as f:
|
||||||
yaml.dump(data, f, allow_unicode=True, default_flow_style=False)
|
yaml.dump(data, f, allow_unicode=True, default_flow_style=False)
|
||||||
|
|
||||||
|
|
||||||
def get_status():
|
def get_status():
|
||||||
|
"""Текущий статус последнего запуска тестов → /api/status."""
|
||||||
return _current_run
|
return _current_run
|
||||||
|
|
||||||
|
|
||||||
def _wait_for_ready(client, instance_uid, timeout=300):
|
def _wait_for_ready(client, instance_uid, timeout=300):
|
||||||
|
"""Ждать пока инстанс перейдёт в стабильное состояние (не creating/modifying/deleting).
|
||||||
|
|
||||||
|
Поллинг GET /instances с фильтрацией по instanceUid.
|
||||||
|
Таймаут 5 минут — после этого возвращаем None.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: HttpClient
|
||||||
|
instance_uid: str — UUID инстанса
|
||||||
|
timeout: int — макс. секунд ожидания
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict — инстанс, или None если таймаут."""
|
||||||
deadline = time.time() + timeout
|
deadline = time.time() + timeout
|
||||||
while time.time() < deadline:
|
while time.time() < deadline:
|
||||||
data = client.get("/instances", params={"pageSize": 500})
|
data = client.get("/instances", params={"pageSize": 500})
|
||||||
for inst in data.get("results", []):
|
for inst in data.get("results", []):
|
||||||
if inst.get("instanceUid") == instance_uid:
|
if inst.get("instanceUid") == instance_uid:
|
||||||
status = inst.get("explainedStatus", "")
|
status = inst.get("explainedStatus", "")
|
||||||
|
# Проверяем что операция не в процессе
|
||||||
in_progress = inst.get("operationIsInProgress", False)
|
in_progress = inst.get("operationIsInProgress", False)
|
||||||
pending = inst.get("operationIsPending", False)
|
pending = inst.get("operationIsPending", False)
|
||||||
if not in_progress and not pending and status not in ("creating", "modifying", "deleting"):
|
if not in_progress and not pending and status not in ("creating", "modifying", "deleting"):
|
||||||
@@ -42,6 +69,17 @@ def _wait_for_ready(client, instance_uid, timeout=300):
|
|||||||
|
|
||||||
|
|
||||||
def _find_svc_operation_id(client, service_id, operation_name):
|
def _find_svc_operation_id(client, service_id, operation_name):
|
||||||
|
"""Найти svcOperationId для операции в сервисе.
|
||||||
|
|
||||||
|
GET /services/{id} → svc.operations[] → ищем по operation.name.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: HttpClient
|
||||||
|
service_id: int
|
||||||
|
operation_name: str — "create"/"modify"/"delete"/...
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int или None — svcOperationId."""
|
||||||
data = client.get(f"/services/{service_id}")
|
data = client.get(f"/services/{service_id}")
|
||||||
for op in data.get("svc", {}).get("operations", []):
|
for op in data.get("svc", {}).get("operations", []):
|
||||||
if op.get("operation") == operation_name:
|
if op.get("operation") == operation_name:
|
||||||
@@ -50,10 +88,26 @@ def _find_svc_operation_id(client, service_id, operation_name):
|
|||||||
|
|
||||||
|
|
||||||
def run_tests(endpoint, token):
|
def run_tests(endpoint, token):
|
||||||
|
"""Главная функция LEGACY-раннера — последовательный прогон тестов.
|
||||||
|
|
||||||
|
Алгоритм:
|
||||||
|
1. Загрузить config.yaml
|
||||||
|
2. Для каждого сервиса (если enabled):
|
||||||
|
a. Для каждой операции (если enabled):
|
||||||
|
- create → POST /instances → ждать ready
|
||||||
|
- другие → POST /instanceOperations → ждать ready
|
||||||
|
b. Сохранить результат (OK/FAIL/SKIP)
|
||||||
|
|
||||||
|
Вызывается в отдельном потоке из /api/run.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
endpoint: str — URL API
|
||||||
|
token: str — JWT-токен"""
|
||||||
global _current_run
|
global _current_run
|
||||||
client = HttpClient(endpoint, token)
|
client = HttpClient(endpoint, token)
|
||||||
config = load_config()
|
config = load_config()
|
||||||
|
|
||||||
|
# Получить UUID организации (если не задан явно)
|
||||||
org_uid = config.get("test_org_uid", "auto")
|
org_uid = config.get("test_org_uid", "auto")
|
||||||
if org_uid == "auto":
|
if org_uid == "auto":
|
||||||
from operations.get_instances import get_organization
|
from operations.get_instances import get_organization
|
||||||
@@ -76,7 +130,7 @@ def run_tests(endpoint, token):
|
|||||||
|
|
||||||
service_id = svc_cfg["service_id"]
|
service_id = svc_cfg["service_id"]
|
||||||
display_name = svc_cfg.get("display_name", f"autotest-{svc_cfg['name']}")
|
display_name = svc_cfg.get("display_name", f"autotest-{svc_cfg['name']}")
|
||||||
instance_uid = None
|
instance_uid = None # сбрасываем для каждого сервиса
|
||||||
|
|
||||||
for op_cfg in svc_cfg.get("operations", []):
|
for op_cfg in svc_cfg.get("operations", []):
|
||||||
if not op_cfg.get("enabled"):
|
if not op_cfg.get("enabled"):
|
||||||
@@ -111,10 +165,7 @@ def run_tests(endpoint, token):
|
|||||||
raise Exception(f"Operation {op_cfg['name']} not found")
|
raise Exception(f"Operation {op_cfg['name']} not found")
|
||||||
|
|
||||||
if op_cfg["name"] == "create":
|
if op_cfg["name"] == "create":
|
||||||
payload = {
|
payload = {"serviceId": service_id, "displayName": display_name}
|
||||||
"serviceId": service_id,
|
|
||||||
"displayName": display_name,
|
|
||||||
}
|
|
||||||
params = op_cfg.get("params", {})
|
params = op_cfg.get("params", {})
|
||||||
if params:
|
if params:
|
||||||
cfs_params = [{"svcOperationCfsParamId": int(k), "paramValue": str(v)} for k, v in params.items()]
|
cfs_params = [{"svcOperationCfsParamId": int(k), "paramValue": str(v)} for k, v in params.items()]
|
||||||
@@ -129,10 +180,7 @@ def run_tests(endpoint, token):
|
|||||||
else:
|
else:
|
||||||
if not instance_uid:
|
if not instance_uid:
|
||||||
raise Exception(f"No instance for {op_cfg['name']}")
|
raise Exception(f"No instance for {op_cfg['name']}")
|
||||||
payload = {
|
payload = {"instanceUid": instance_uid, "svcOperationId": svc_op_id}
|
||||||
"instanceUid": instance_uid,
|
|
||||||
"svcOperationId": svc_op_id,
|
|
||||||
}
|
|
||||||
params = op_cfg.get("params", {})
|
params = op_cfg.get("params", {})
|
||||||
if params:
|
if params:
|
||||||
payload["cfsParams"] = [{"svcOperationCfsParamId": int(k), "paramValue": str(v)} for k, v in params.items()]
|
payload["cfsParams"] = [{"svcOperationCfsParamId": int(k), "paramValue": str(v)} for k, v in params.items()]
|
||||||
@@ -147,12 +195,11 @@ def run_tests(endpoint, token):
|
|||||||
result["status"] = "FAIL"
|
result["status"] = "FAIL"
|
||||||
result["error"] = str(e)
|
result["error"] = str(e)
|
||||||
if op_cfg["name"] == "create":
|
if op_cfg["name"] == "create":
|
||||||
break # дальше операции этого сервиса бессмысленны
|
break # create не удался → остальные операции сервиса бессмысленны
|
||||||
finally:
|
finally:
|
||||||
result["duration_sec"] = round(time.time() - t0, 1)
|
result["duration_sec"] = round(time.time() - t0, 1)
|
||||||
|
|
||||||
# wait between services
|
time.sleep(1) # пауза между сервисами
|
||||||
time.sleep(1)
|
|
||||||
|
|
||||||
with _lock:
|
with _lock:
|
||||||
_current_run["status"] = "done"
|
_current_run["status"] = "done"
|
||||||
@@ -160,6 +207,10 @@ def run_tests(endpoint, token):
|
|||||||
|
|
||||||
|
|
||||||
def _extract_uid_from_response(resp):
|
def _extract_uid_from_response(resp):
|
||||||
|
"""Извлечь UUID из ответа API (legacy-метод, заменён на api.utils.find_uid).
|
||||||
|
|
||||||
|
Простой перебор всех значений dict — если строка из 36 символов с 4 дефисами,
|
||||||
|
считаем UUID'ом."""
|
||||||
if isinstance(resp, dict):
|
if isinstance(resp, dict):
|
||||||
for v in resp.values():
|
for v in resp.values():
|
||||||
if isinstance(v, str) and len(v) == 36 and v.count("-") == 4:
|
if isinstance(v, str) and len(v) == 36 and v.count("-") == 4:
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Seed-сценарии — импортируются в БД при первом старте.
|
||||||
|
# INSERT ON CONFLICT DO NOTHING — безопасно для повторных запусков.
|
||||||
|
# После rollout файл можно удалить из репозитория.
|
||||||
|
scenarios:
|
||||||
|
- name: dummy_test
|
||||||
|
client_id: ""
|
||||||
|
stand: ""
|
||||||
|
steps:
|
||||||
|
- service_id: 1
|
||||||
|
operation: create
|
||||||
|
params:
|
||||||
|
durationMs: "5000"
|
||||||
+27
-464
@@ -1,471 +1,34 @@
|
|||||||
// window.APP — переменные из Jinja2 (заполняются в index.html)
|
// app.js — загрузчик и точка входа фронтенда
|
||||||
// window.APP = { version, stand, hasUserToken };
|
//
|
||||||
|
// Файлы подгружаются в index.html в СТРОГОМ порядке зависимостей:
|
||||||
|
// utils.js → instances.js → operations.js → history.js → scenario-list.js
|
||||||
|
// scenario-form.js → params-render.js → scenario-create/edit/delete.js
|
||||||
|
// icons.js, snackbar.js, views.js — без зависимостей
|
||||||
|
//
|
||||||
|
// Глобальные переменные из Jinja2 (window.APP):
|
||||||
|
// { version, stand, hasUserToken, firstServiceId }
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* === АРХИТЕКТУРА ФРОНТЕНДА ===
|
* Архитектура фронтенда — ванильный JS (без фреймворков).
|
||||||
|
* Модули по файлам, функции в глобальной области видимости:
|
||||||
*
|
*
|
||||||
* Один HTML-файл, ванильный JS (без фреймворков).
|
* utils.js — _esc(), validateJson(), busy, relativeTime(), лог-панель
|
||||||
* Три колонки: инфраструктура | сервисы | инстансы+операции+параметры.
|
* instances.js — selectService(), toggleInstance(), refreshInstances()
|
||||||
*
|
* operations.js — startCreate(), runOp(), showParams(), executeOp(), poll
|
||||||
* Глобальное состояние:
|
* history.js — toggleHistory(), loadHistory()
|
||||||
* svcInstances — кеш списка инстансов из /api/operations/{svcId}
|
* scenario-list.js — toggleScenario(), loadScenarios(), runScenario()
|
||||||
* selectedInst — UID выбранного инстанса (null если не выбран)
|
* scenario-form.js — showScenarioEditor(), renderEditor(), saveScenario()
|
||||||
* selectedOp — {opId, opName, svcId} выбранная операция
|
* params-render.js — renderParamRow(), renderMapFixedRow(), collectParams()
|
||||||
* pollTimer — setInterval таймер поллинга статуса операции
|
* icons.js — ICONS = {play, edit, trash, ...} (inline-SVG)
|
||||||
* currentSvcId — ID текущего выбранного сервиса (по умолчанию 1 = Болванка)
|
* snackbar.js — showSnackbar(msg, type)
|
||||||
* AUTOTEST_PREFIX — префикс имён autotest-инстансов
|
* views.js — switchView(viewId)
|
||||||
*
|
|
||||||
* Потоки:
|
|
||||||
* CREATE: startCreate() → showParams(18,'create') → executeOp(pp) → poll → refresh
|
|
||||||
* MODIFY: toggleInstance() → runOp('modify',id) → showParams(id,'modify') → validate → executeOp
|
|
||||||
* БЕЗ ПАРАМЕТРОВ (delete/suspend/resume/redeploy): runOp() → confirm → executeOp({})
|
|
||||||
*
|
|
||||||
* Хелперы:
|
|
||||||
* _esc(s) — HTML-экранирование (" → " & → & < → <)
|
|
||||||
* validateJson() — проверка JSON.parse для map-полей (onblur + перед отправкой)
|
|
||||||
* showStages() — отрисовка этапов выполнения операции (⏳/✅/❌)
|
|
||||||
*/
|
*/
|
||||||
let svcInstances=[], selectedInst=null, selectedOp=null, pollTimer=null;
|
|
||||||
let currentSvcId=(window.APP&&window.APP.firstServiceId)||1; // динамический — обновляется при selectService()
|
|
||||||
let currentSvcName=''; // имя текущего сервиса (колонка «тип инстанса»)
|
|
||||||
let currentSvcShort=''; // краткое имя (redis, postgres) для displayName
|
|
||||||
let busy=false; // блокировка: запрет операций пока идёт другая
|
|
||||||
const AUTOTEST_PREFIX='autotest-';
|
|
||||||
|
|
||||||
// Первый сервис из списка (рендерится Jinja2)
|
// ── Инициализация при загрузке страницы ──
|
||||||
selectService(currentSvcId);
|
|
||||||
|
|
||||||
async function selectService(svcId){
|
// Запускаем поллинг логов (каждые 2 секунды, только если панель видна)
|
||||||
if(busy) return; // операция выполняется — не менять сервис
|
|
||||||
currentSvcId=svcId;
|
|
||||||
selectedInst=null; selectedOp=null; stopPoll();
|
|
||||||
document.getElementById('params-area').style.display='none';
|
|
||||||
document.querySelectorAll('.stages-box').forEach(e=>e.remove());
|
|
||||||
// Подсветить активный сервис в боковой панели
|
|
||||||
document.querySelectorAll('.svc-item').forEach(e=>e.classList.remove('active'));
|
|
||||||
const svcEl=document.querySelector(`.svc-item[onclick*="${svcId}"]`);
|
|
||||||
if(svcEl) svcEl.classList.add('active');
|
|
||||||
|
|
||||||
try{
|
|
||||||
const r=await fetch('/api/operations/'+svcId);
|
|
||||||
const d=await r.json();
|
|
||||||
svcInstances=d.instances||[];
|
|
||||||
currentSvcName=d.svc||'';
|
|
||||||
currentSvcShort=d.svcShort||'';
|
|
||||||
// Обновить текст кнопки создания
|
|
||||||
const btnSpan=document.querySelector('#create-btn-area span');
|
|
||||||
if(btnSpan) btnSpan.textContent='+ Создать новый инстанс '+(currentSvcName||'сервиса');
|
|
||||||
|
|
||||||
let html='';
|
|
||||||
svcInstances.forEach(i=>{
|
|
||||||
const status=i.status||i.explainedStatus||'?';
|
|
||||||
const sc=status==='running'?'badge-success':'';
|
|
||||||
html+=`<div class="inst-item" onclick="toggleInstance('${i.instanceUid}')" data-iuid="${i.instanceUid}">
|
|
||||||
<span style="display:inline-block;width:220px;">${_esc(i.displayName)}</span>
|
|
||||||
<span style="display:inline-block;width:160px;color:var(--muted);font-size:11px;">${_esc(i.svc||'')}</span>
|
|
||||||
<span class="badge ${sc}" style="font-size:9px;">${_esc(status)}</span>
|
|
||||||
</div>`;
|
|
||||||
html+=`<div class="inst-ops" id="ops-${i.instanceUid}"></div>`;
|
|
||||||
});
|
|
||||||
document.getElementById('inst-list').innerHTML=html;
|
|
||||||
}catch(e){document.getElementById('inst-list').innerHTML='<span style="color:var(--destructive);font-size:11px;">Ошибка загрузки</span>';}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleInstance(iuid){
|
|
||||||
if(busy) return; // операция выполняется — ничего не делать
|
|
||||||
selectedInst=iuid; stopPoll();
|
|
||||||
document.querySelectorAll('[data-iuid]').forEach(e=>e.classList.toggle('active',e.dataset.iuid===iuid));
|
|
||||||
document.getElementById('params-area').style.display='none';
|
|
||||||
const opsEl=document.getElementById('ops-'+iuid);
|
|
||||||
if(opsEl.classList.contains('open')){opsEl.classList.remove('open');return;}
|
|
||||||
document.querySelectorAll('.inst-ops').forEach(e=>e.classList.remove('open'));
|
|
||||||
|
|
||||||
// Операции для ИНСТАНСА, не для выбранного сервиса
|
|
||||||
const inst=svcInstances.find(x=>x.instanceUid===iuid);
|
|
||||||
const svcId=inst?inst.serviceId:currentSvcId;
|
|
||||||
try{
|
|
||||||
const r=await fetch('/api/operations/'+svcId);
|
|
||||||
const d=await r.json();
|
|
||||||
let ops=(d.operations||[]).filter(o=>o.operation!=='reconcile'&&o.operation!=='create');
|
|
||||||
if(inst&&(inst.status||inst.explainedStatus)==='not created'){
|
|
||||||
ops=ops.filter(o=>o.operation==='delete');
|
|
||||||
}
|
|
||||||
opsEl.innerHTML=ops.map(o=>
|
|
||||||
`<button class="btn btn-sm op-btn ${opClass(o.operation)}" onclick="runOp('${o.operation}',${o.svcOperationId})">${o.operation}</button>`
|
|
||||||
).join('');
|
|
||||||
opsEl.classList.add('open');
|
|
||||||
}catch(e){opsEl.innerHTML='<span style="color:var(--destructive);font-size:11px;">Ошибка загрузки</span>';opsEl.classList.add('open');}
|
|
||||||
}
|
|
||||||
|
|
||||||
function opClass(opName){
|
|
||||||
if(opName==='modify') return 'op-btn-modify';
|
|
||||||
if(opName==='suspend') return 'op-btn-suspend';
|
|
||||||
if(opName==='resume') return 'op-btn-resume';
|
|
||||||
if(opName==='delete') return 'op-btn-delete';
|
|
||||||
if(opName==='redeploy') return 'op-btn-redeploy';
|
|
||||||
if(opName==='reconcile') return 'op-btn-reconcile';
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startCreate(){
|
|
||||||
if(busy) return;
|
|
||||||
selectedInst=null; stopPoll();
|
|
||||||
document.querySelectorAll('[data-iuid]').forEach(e=>e.classList.remove('active'));
|
|
||||||
document.querySelectorAll('.inst-ops').forEach(e=>e.classList.remove('open'));
|
|
||||||
// Найти svcOperationId для create у текущего сервиса
|
|
||||||
try{
|
|
||||||
const r=await fetch('/api/operations/'+currentSvcId);
|
|
||||||
const d=await r.json();
|
|
||||||
const createOp=d.operations.find(o=>o.operation==='create');
|
|
||||||
const opId=createOp?createOp.svcOperationId:18; // fallback — Болванка
|
|
||||||
showParams(opId,'create');
|
|
||||||
}catch(e){
|
|
||||||
showParams(18,'create'); // fallback
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeCreateDisplayName(){
|
|
||||||
const rand = (Math.random() * 46656 | 0).toString(36).padStart(3, '0');
|
|
||||||
return currentSvcShort ? currentSvcShort+'-'+rand : rand;
|
|
||||||
}
|
|
||||||
|
|
||||||
function runOp(opName,opId){
|
|
||||||
if(busy) return;
|
|
||||||
stopPoll();
|
|
||||||
showParams(opId,opName);
|
|
||||||
}
|
|
||||||
|
|
||||||
function findInstName(){
|
|
||||||
const i=svcInstances.find(x=>x.instanceUid===selectedInst);
|
|
||||||
return i?i.displayName:selectedInst;
|
|
||||||
}
|
|
||||||
|
|
||||||
function showParams(opId,opName){
|
|
||||||
selectedOp={opId,opName,svcId:currentSvcId};
|
|
||||||
document.getElementById('params-area').style.display='block';
|
|
||||||
document.querySelectorAll('.stages-box').forEach(e=>e.remove());
|
|
||||||
document.getElementById('test-status').textContent='';
|
|
||||||
|
|
||||||
const isCreate=opName==='create';
|
|
||||||
const qs=isCreate?'' : `?instanceUid=${selectedInst}`;
|
|
||||||
fetch('/api/params/'+opId+qs).then(r=>r.json()).then(async data=>{
|
|
||||||
if(data.error){document.getElementById('params-form').innerHTML=`<span style="color:var(--destructive);">Ошибка: ${_esc(data.error)}</span>`;return;}
|
|
||||||
const params=data.params||data;
|
|
||||||
if(!Array.isArray(params)){document.getElementById('params-form').innerHTML=`<span style="color:var(--destructive);">Неверный формат параметров</span>`;return;}
|
|
||||||
// Заранее загрузить все инстансы для refSvcId-параметров
|
|
||||||
let allInst=[];
|
|
||||||
const needRefs=params.some(p=>p.refSvcId);
|
|
||||||
if(needRefs){
|
|
||||||
try{
|
|
||||||
const r=await fetch('/api/instances/list');
|
|
||||||
const list=await r.json();
|
|
||||||
allInst=list||[];
|
|
||||||
}catch(e){}
|
|
||||||
}
|
|
||||||
const form=document.getElementById('params-form');
|
|
||||||
const displayNameValue=isCreate?makeCreateDisplayName():'';
|
|
||||||
const createHeader=isCreate?`<div style="font-weight:600;font-size:13px;margin-bottom:8px;color:var(--brand-primary);">Создание: ${currentSvcName}</div>`:'';
|
|
||||||
const opHeader=!isCreate?`<div style="font-weight:600;font-size:13px;margin-bottom:8px;color:var(--brand-primary);">${_esc(opName)} → ${_esc(findInstName())}</div>`:'';
|
|
||||||
form.innerHTML=createHeader+opHeader+(opName==='create'?`<div class="param-row"><label class="req">displayName</label><span style="font-size:12px;color:var(--muted);white-space:nowrap;">${AUTOTEST_PREFIX}</span><input type="text" id="param-displayname" value="${_esc(displayNameValue)}" autocomplete="off"></div>`:'')
|
|
||||||
+ params.map(p=>{
|
|
||||||
const req=p.isRequired;
|
|
||||||
const hasDfl=p.defaultValue!==null&&p.defaultValue!==undefined&&p.defaultValue!=='';
|
|
||||||
const labelCls=req?(hasDfl?'req':'req-nodfl'):'';
|
|
||||||
const dfl=hasDfl?p.defaultValue:(req?'':'');
|
|
||||||
const vl=p.valueList;
|
|
||||||
const isMap=(p.dataType||'').startsWith('map');
|
|
||||||
let input;
|
|
||||||
if(vl&&Array.isArray(vl)){
|
|
||||||
input=`<select name="p_${p.svcOperationCfsParamId}" data-type="${_esc(p.dataType)}">${vl.map(v=>`<option value="${_esc(v)}" ${v==dfl?'selected':''}>${_esc(v)}</option>`).join('')}</select>`;
|
|
||||||
}else if(p.refSvcId){
|
|
||||||
// Параметр ссылается на другой сервис — выпадающий список инстансов
|
|
||||||
const refInsts=allInst.filter(i=>i.serviceId===p.refSvcId&&i.explainedStatus!=='deleted');
|
|
||||||
let opts=refInsts.map(i=>`<option value="${_esc(i.instanceUid)}" ${i.instanceUid===dfl?'selected':''}>${_esc(i.displayName)}</option>`).join('');
|
|
||||||
if(!dfl) opts='<option value="">— выбрать —</option>'+opts;
|
|
||||||
input=`<select name="p_${p.svcOperationCfsParamId}" data-type="ref">${opts}</select>`;
|
|
||||||
}else if(p.dataType==='boolean'){
|
|
||||||
input=`<select name="p_${p.svcOperationCfsParamId}" data-type="boolean"><option value="true" ${dfl==='true'||dfl===true?'selected':''}>true</option><option value="false" ${dfl==='false'||dfl===false?'selected':''}>false</option></select>`;
|
|
||||||
}else if(p.dataDescriptor&&isMap){
|
|
||||||
// Раскрыть dataDescriptor — вложенные поля вместо JSON-строки
|
|
||||||
const dd=p.dataDescriptor;
|
|
||||||
let subHtml='';
|
|
||||||
let dflObj={};
|
|
||||||
try{dflObj=JSON.parse(dfl||'{}');}catch(e){}
|
|
||||||
Object.keys(dd).forEach(subKey=>{
|
|
||||||
const sub=dd[subKey];
|
|
||||||
const subDfl=dflObj[subKey]!==undefined?dflObj[subKey]:(sub.defaultValue||'');
|
|
||||||
const subVl=sub.valueList;
|
|
||||||
const subReq=sub.isRequired;
|
|
||||||
const subNoDfl=subReq&&!subDfl&&subDfl!==0&&subDfl!==false;
|
|
||||||
const subLblCls=subNoDfl?'req-nodfl':'';
|
|
||||||
let si;
|
|
||||||
if(subVl&&Array.isArray(subVl)){
|
|
||||||
si=`<select name="p_${p.svcOperationCfsParamId}.${subKey}" data-type="mapchild">${subVl.map(v=>`<option value="${_esc(v)}" ${v==subDfl?'selected':''}>${_esc(v)}</option>`).join('')}</select>`;
|
|
||||||
}else if(sub.dataType==='boolean'){
|
|
||||||
si=`<select name="p_${p.svcOperationCfsParamId}.${subKey}" data-type="mapchild"><option value="true" ${subDfl==='true'||subDfl===true?'selected':''}>true</option><option value="false" ${subDfl==='false'||subDfl===false?'selected':''}>false</option></select>`;
|
|
||||||
}else{
|
|
||||||
si=`<input type="text" name="p_${p.svcOperationCfsParamId}.${subKey}" value="${_esc(subDfl)}" data-type="mapchild">`;
|
|
||||||
}
|
|
||||||
subHtml+=`<div class="param-row"><label class="${subLblCls}">${_esc(subKey)}</label>${si}</div>`;
|
|
||||||
});
|
|
||||||
input=`<div style="border-left:2px solid var(--brand-gray);margin-left:8px;padding:0 0 4px 8px;">${subHtml}</div>`;
|
|
||||||
return `<div class="param-row"><label class="${labelCls}">${_esc(p.name)}</label></div>${input}`;
|
|
||||||
}else{
|
|
||||||
const dt=isMap?'map':'';
|
|
||||||
input=`<input type="text" name="p_${p.svcOperationCfsParamId}" value="${_esc(dfl)}" data-type="${dt}" placeholder="${req&&!hasDfl?'обязательно':''}"${isMap?' onblur="validateJson(this)"':''}>`;
|
|
||||||
if(isMap) input+=`<span class="json-err" style="display:none;color:var(--destructive);font-size:10px;margin-left:4px;"></span>`;
|
|
||||||
}
|
|
||||||
return `<div class="param-row"><label class="${labelCls}">${_esc(p.name)}</label>${input}</div>`;
|
|
||||||
}).join('');
|
|
||||||
const btn=document.getElementById('btn-test');
|
|
||||||
btn.style.display='inline-flex';
|
|
||||||
btn.textContent='Запустить';
|
|
||||||
btn.onclick=()=>{
|
|
||||||
// Проверить ВСЕ map-поля перед отправкой
|
|
||||||
let bad=[];
|
|
||||||
document.querySelectorAll('#params-form [data-type="map"]').forEach(el=>{
|
|
||||||
if(!validateJson(el,true)) bad.push(el);
|
|
||||||
});
|
|
||||||
if(bad.length>0){
|
|
||||||
document.getElementById('test-status').innerHTML=`<span class="badge">Ошибка</span> ${bad.length} полей с невалидным JSON — поправьте и нажмите Запустить снова`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const pp={};
|
|
||||||
const nested={}; // {parentKey: {subKey: value, ...}}
|
|
||||||
document.querySelectorAll('#params-form [name^="p_"]').forEach(el=>{
|
|
||||||
let v=el.value;
|
|
||||||
const dt=el.dataset.type||'';
|
|
||||||
if(dt==='mapchild'){
|
|
||||||
const name=el.name.replace('p_','');
|
|
||||||
const dot=name.indexOf('.');
|
|
||||||
const parentKey=name.substring(0,dot);
|
|
||||||
const subKey=name.substring(dot+1);
|
|
||||||
if(!nested[parentKey]) nested[parentKey]={};
|
|
||||||
nested[parentKey][subKey]=v;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if(dt==='array'||dt.startsWith('array')) v=JSON.stringify([v]);
|
|
||||||
if(dt==='map'||dt==='map-fixed') v=v||'{}';
|
|
||||||
pp[el.name.replace('p_','')]=v;
|
|
||||||
});
|
|
||||||
// Сериализовать вложенные объекты в JSON строки
|
|
||||||
for(const pk in nested) pp[pk]=JSON.stringify(nested[pk]);
|
|
||||||
executeOp(pp);
|
|
||||||
};
|
|
||||||
}).catch(e=>{
|
|
||||||
document.getElementById('params-form').innerHTML=`<span style="color:var(--destructive);">Ошибка загрузки: ${_esc(e.message)}</span>`;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Хелперы ===
|
|
||||||
|
|
||||||
function _esc(s){
|
|
||||||
/* HTML-экранирование: " → " & → & < → < */
|
|
||||||
return String(s||'').replace(/&/g,'&').replace(/"/g,'"').replace(/</g,'<');
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateJson(el,quiet){
|
|
||||||
/* Проверить что значение в поле — валидный JSON.
|
|
||||||
quiet=true — не менять внешний вид (для batch-проверки перед отправкой). */
|
|
||||||
const errEl=el.parentElement.querySelector('.json-err');
|
|
||||||
const v=el.value.trim();
|
|
||||||
if(!v) return true; // пусто — ок
|
|
||||||
try{ JSON.parse(v); }
|
|
||||||
catch(e){
|
|
||||||
if(!quiet&&errEl){ errEl.style.display='inline'; errEl.textContent='Ошибка JSON: '+e.message; }
|
|
||||||
el.style.borderColor='var(--destructive)';
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if(!quiet&&errEl) errEl.style.display='none';
|
|
||||||
el.style.borderColor='';
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function setFinishedState(statusText, statusClass, statusError, statusDuration, instanceName){
|
|
||||||
busy=false;
|
|
||||||
// Сбросить все «⏳ операция выполняется...» — при следующем клике перерендерятся
|
|
||||||
document.querySelectorAll('.inst-ops.open').forEach(e=>e.classList.remove('open'));
|
|
||||||
const btn=document.getElementById('btn-test');
|
|
||||||
btn.disabled=false;
|
|
||||||
btn.textContent='Готово';
|
|
||||||
btn.onclick=null;
|
|
||||||
const opName=selectedOp?.opName||'операция';
|
|
||||||
const durationText=statusDuration!==undefined&&statusDuration!==null?`${statusDuration}s`:'0s';
|
|
||||||
const extra=statusError||'';
|
|
||||||
const instName=instanceName||findInstName()||selectedInst||'';
|
|
||||||
document.getElementById('test-status').innerHTML=` <span class="badge ${statusClass}">${statusText}</span> <span style="color:var(--muted);">${opName}</span> <span style="color:var(--muted);">${durationText}</span> <span style="color:var(--muted);">${instName}</span> ${extra}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function setRunningState(opName, displayName){
|
|
||||||
const btn=document.getElementById('btn-test');
|
|
||||||
const title=displayName || opName;
|
|
||||||
btn.textContent=opName+'...';
|
|
||||||
document.getElementById('test-status').textContent=' ⏳ '+title+' выполняется...';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function executeOp(params){
|
|
||||||
busy=true;
|
|
||||||
stopPoll();
|
|
||||||
const isCreate=selectedOp?.opName==='create';
|
|
||||||
const displayNameInput=document.getElementById('param-displayname');
|
|
||||||
const displayNameSuffix=(displayNameInput?.value||'').trim();
|
|
||||||
const displayName=isCreate ? `${AUTOTEST_PREFIX}${displayNameSuffix || makeCreateDisplayName()}` : findInstName();
|
|
||||||
document.getElementById('params-area').style.display='block';
|
|
||||||
document.getElementById('params-form').innerHTML='';
|
|
||||||
const btn=document.getElementById('btn-test');
|
|
||||||
btn.style.display='inline-flex';
|
|
||||||
btn.textContent=selectedOp.opName+'...';
|
|
||||||
btn.disabled=true;
|
|
||||||
setRunningState(selectedOp.opName, displayName);
|
|
||||||
// Создать динамический stages-box после инстанса
|
|
||||||
const instId=selectedInst||'';
|
|
||||||
const afterEl=instId?document.getElementById('ops-'+instId):null;
|
|
||||||
document.querySelectorAll('.stages-box').forEach(e=>e.remove());
|
|
||||||
const stagesBox=document.createElement('div');
|
|
||||||
stagesBox.className='stages-box';
|
|
||||||
stagesBox.style.cssText='max-height:200px;overflow-y:auto;padding:4px 8px;margin-left:16px;background:var(--brand-grey-light);border-radius:4px;';
|
|
||||||
if(afterEl){afterEl.after(stagesBox);}
|
|
||||||
else{document.getElementById('create-btn-area').after(stagesBox);}
|
|
||||||
|
|
||||||
try{
|
|
||||||
const r=await fetch('/api/test',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({
|
|
||||||
serviceId:currentSvcId,
|
|
||||||
operation:selectedOp.opName,
|
|
||||||
svcOperationId:selectedOp.opId,
|
|
||||||
params,
|
|
||||||
instanceUid:selectedInst||'',
|
|
||||||
displayName:isCreate ? displayName : ''
|
|
||||||
})});
|
|
||||||
const d=await r.json();
|
|
||||||
if(d.status==='FAIL'){busy=false;document.getElementById('test-status').innerHTML=` <span class="badge">FAIL</span> ${d.error||''}`;btn.disabled=false;return;}
|
|
||||||
// CMDB delete — мгновенное удаление, не надо поллинга
|
|
||||||
if(d.status==='OK'&&(d.opUid||'').startsWith('cmdb-')){
|
|
||||||
busy=false;
|
|
||||||
setFinishedState('OK','badge-success','',0,d.displayName);
|
|
||||||
try{await refreshInstances();}catch(e){}
|
|
||||||
if(historyOpen) try{await loadHistory();}catch(e){}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// start polling
|
|
||||||
const opUid=d.opUid;
|
|
||||||
const instanceName=d.displayName||displayName||findInstName();
|
|
||||||
let _pollErrors=0;
|
|
||||||
pollTimer=setInterval(async()=>{
|
|
||||||
try{
|
|
||||||
const sr=await fetch('/api/test/status/'+opUid);
|
|
||||||
const sd=await sr.json();
|
|
||||||
_pollErrors=0;
|
|
||||||
showStages(sd.stages||[]);
|
|
||||||
if(sd.status!=='RUNNING'){
|
|
||||||
stopPoll();
|
|
||||||
setFinishedState(sd.status, sd.status==='OK'?'badge-success':'badge', sd.error||sd.errorLog, sd.duration, sd.displayName||instanceName);
|
|
||||||
await refreshInstances();
|
|
||||||
if(historyOpen) await loadHistory();
|
|
||||||
}
|
|
||||||
}catch(e){
|
|
||||||
_pollErrors++;
|
|
||||||
if(_pollErrors>=5){
|
|
||||||
stopPoll();
|
|
||||||
setFinishedState('TIMEOUT','','Связь потеряна',0,instanceName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},2000);
|
|
||||||
}catch(e){
|
|
||||||
busy=false;
|
|
||||||
document.getElementById('test-status').textContent=' Ошибка: '+e.message;
|
|
||||||
btn.disabled=false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function showStages(stages){
|
|
||||||
if(!stages||!stages.length) return;
|
|
||||||
let html='<div style="font-size:11px;font-weight:600;color:var(--muted);margin-top:4px;">Этапы</div>';
|
|
||||||
stages.forEach(s=>{
|
|
||||||
const done=!!s.dtFinish;
|
|
||||||
const icon=done?(s.isSuccessful?'✅':'❌'):'⏳';
|
|
||||||
html+=`<div style="font-size:11px;padding:2px 0;">${icon} ${_esc(s.stage)} — ${(s.duration||0).toFixed(1)}s</div>`;
|
|
||||||
});
|
|
||||||
const boxes=document.querySelectorAll('.stages-box');
|
|
||||||
if(boxes.length) boxes[boxes.length-1].innerHTML=html;
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopPoll(){
|
|
||||||
if(pollTimer){clearInterval(pollTimer);pollTimer=null;}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshInstances(){
|
|
||||||
try{
|
|
||||||
const r=await fetch('/api/operations/'+currentSvcId);
|
|
||||||
const d=await r.json();
|
|
||||||
const newInsts=d.instances||[];
|
|
||||||
svcInstances=newInsts;
|
|
||||||
let html='';
|
|
||||||
svcInstances.forEach(i=>{
|
|
||||||
const status=i.status||i.explainedStatus||'?';
|
|
||||||
const sc=status==='running'?'badge-success':'';
|
|
||||||
html+=`<div class="inst-item" onclick="toggleInstance('${i.instanceUid}')" data-iuid="${i.instanceUid}">
|
|
||||||
<span style="display:inline-block;width:220px;">${_esc(i.displayName)}</span>
|
|
||||||
<span style="display:inline-block;width:160px;color:var(--muted);font-size:11px;">${_esc(i.svc||'')}</span>
|
|
||||||
<span class="badge ${sc}" style="font-size:9px;">${_esc(status)}</span>
|
|
||||||
</div>`;
|
|
||||||
html+=`<div class="inst-ops" id="ops-${i.instanceUid}"></div>`;
|
|
||||||
});
|
|
||||||
document.getElementById('inst-list').innerHTML=html;
|
|
||||||
}catch(e){}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === История тестов ===
|
|
||||||
let historyOpen=false;
|
|
||||||
async function toggleHistory(){
|
|
||||||
const body=document.getElementById('history-body');
|
|
||||||
historyOpen=!historyOpen;
|
|
||||||
body.style.display=historyOpen?'block':'none';
|
|
||||||
if(historyOpen) await loadHistory();
|
|
||||||
}
|
|
||||||
async function loadHistory(){
|
|
||||||
const body=document.getElementById('history-body');
|
|
||||||
if(!body||body.style.display==='none') return;
|
|
||||||
try{
|
|
||||||
const r=await fetch('/api/history');
|
|
||||||
const rows=await r.json();
|
|
||||||
if(!rows||!rows.length){body.innerHTML='<span style="color:var(--muted);font-size:11px;">Нет записей</span>'; return;}
|
|
||||||
let html='<table style="width:100%;font-size:11px;">';
|
|
||||||
html+='<tr><th style="padding:2px 6px;">Время</th><th style="padding:2px 6px;">Операция</th><th style="padding:2px 6px;">Инстанс</th><th style="padding:2px 6px;">Статус</th><th style="padding:2px 6px;">Длит.</th></tr>';
|
|
||||||
rows.forEach(r=>{
|
|
||||||
const time=r.created_at?r.created_at.replace('T',' ').substring(0,19):'?';
|
|
||||||
const cls=r.status==='OK'?'badge-success':'';
|
|
||||||
html+=`<tr>
|
|
||||||
<td style="padding:2px 6px;">${time}</td>
|
|
||||||
<td style="padding:2px 6px;">${_esc(r.op_name||'?')}</td>
|
|
||||||
<td style="padding:2px 6px;">${_esc(r.display_name||r.instance_uid||'?')}</td>
|
|
||||||
<td style="padding:2px 6px;"><span class="badge ${cls}" style="font-size:9px;">${_esc(r.status||'?')}</span></td>
|
|
||||||
<td style="padding:2px 6px;">${r.duration_sec!=null?r.duration_sec.toFixed(1)+'s':'-'}</td>
|
|
||||||
</tr>`;
|
|
||||||
});
|
|
||||||
html+='</table>';
|
|
||||||
body.innerHTML=html;
|
|
||||||
}catch(e){body.innerHTML='<span style="color:var(--destructive);font-size:11px;">Ошибка загрузки</span>';}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Панель логов (скрыта, показывается по кнопке) ===
|
|
||||||
let logPollTimer=null;
|
|
||||||
function startLogPoll(){
|
|
||||||
if(logPollTimer) return;
|
|
||||||
logPollTimer=setInterval(async()=>{
|
|
||||||
const el=document.getElementById('log-panel');
|
|
||||||
if(!el||el.style.display==='none') return; // не дёргать если скрыта
|
|
||||||
try{
|
|
||||||
const r=await fetch('/api/log');
|
|
||||||
const lines=await r.json();
|
|
||||||
el.innerHTML=lines.map(l=>`<div>${l}</div>`).join('');
|
|
||||||
el.scrollTop=el.scrollHeight;
|
|
||||||
}catch(e){}
|
|
||||||
},2000);
|
|
||||||
}
|
|
||||||
function toggleLog(){
|
|
||||||
const el=document.getElementById('log-panel');
|
|
||||||
if(!el) return;
|
|
||||||
if(el.style.display==='none'){ el.style.display='block'; startLogPoll(); }
|
|
||||||
else el.style.display='none';
|
|
||||||
}
|
|
||||||
startLogPoll();
|
startLogPoll();
|
||||||
|
|
||||||
|
// Выбираем первый сервис из переданного Jinja2 значения
|
||||||
|
// currentSvcId берётся из window.APP.firstServiceId (задаётся в index.html)
|
||||||
|
selectService(currentSvcId);
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
// history.js — история тестов (вид «История»)
|
||||||
|
//
|
||||||
|
// Загружает последние 50 записей из GET /api/history.
|
||||||
|
// Каждая запись — строка таблицы: время, операция, инстанс, статус, длительность.
|
||||||
|
// При клике на строку — раскрываются этапы выполнения (stages).
|
||||||
|
//
|
||||||
|
// Цветовая маркировка:
|
||||||
|
// RUNNING → жёлтый фон (#fef3c7)
|
||||||
|
// FAIL → красный фон (#fef2f2)
|
||||||
|
|
||||||
|
let historyOpen=false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Переключить панель истории (открыть/закрыть).
|
||||||
|
* При открытии — автозагрузка через loadHistory().
|
||||||
|
*/
|
||||||
|
async function toggleHistory(){
|
||||||
|
const body=document.getElementById('history-body');
|
||||||
|
historyOpen=!historyOpen;
|
||||||
|
body.style.display=historyOpen?'block':'none';
|
||||||
|
if(historyOpen) await loadHistory();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Загрузить и отрисовать историю.
|
||||||
|
*
|
||||||
|
* Формат ответа API: массив объектов:
|
||||||
|
* [{created_at, op_name, display_name, instance_uid, status, duration_sec, stages}, ...]
|
||||||
|
*/
|
||||||
|
async function loadHistory(){
|
||||||
|
const body=document.getElementById('history-body');
|
||||||
|
if(!body||body.style.display==='none') return;
|
||||||
|
try{
|
||||||
|
const r=await fetch('/api/history');
|
||||||
|
const rows=await r.json();
|
||||||
|
if(!rows||!rows.length){
|
||||||
|
body.innerHTML='<span style="color:var(--muted);font-size:11px;">Нет записей</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Таблица с фиксированной шириной колонок
|
||||||
|
let html='<table style="width:100%;font-size:11px;table-layout:fixed;">';
|
||||||
|
html+='<tr><th style="padding:2px 6px;width:130px;">Время</th><th style="padding:2px 6px;width:90px;">Операция</th><th style="padding:2px 6px;">Инстанс</th><th style="padding:2px 6px;width:50px;">Статус</th><th style="padding:2px 6px;width:55px;">Длит.</th></tr>';
|
||||||
|
|
||||||
|
rows.forEach((r, i)=>{
|
||||||
|
const time=r.created_at?r.created_at.replace('T',' ').substring(0,19):'?';
|
||||||
|
const cls=r.status==='OK'?'badge-success':r.status==='FAIL'?'badge':'';
|
||||||
|
// Цвет фона строки: RUNNING → жёлтый, FAIL → красный
|
||||||
|
const stCls=r.status==='RUNNING'?'background:#fef3c7;':r.status==='FAIL'?'background:#fef2f2;':'';
|
||||||
|
|
||||||
|
// Строка таблицы — кликабельна (раскрывает этапы)
|
||||||
|
html+=`<tr style="cursor:pointer;${stCls}" onclick="toggleHistoryRow(${i})">
|
||||||
|
<td style="padding:2px 6px;">${time}</td>
|
||||||
|
<td style="padding:2px 6px;">${_esc(r.op_name||'?')}</td>
|
||||||
|
<td style="padding:2px 6px;">${_esc(r.display_name||r.instance_uid||'?')}</td>
|
||||||
|
<td style="padding:2px 6px;"><span class="badge ${cls}" style="font-size:9px;">${_esc(r.status||'?')}</span></td>
|
||||||
|
<td style="padding:2px 6px;">${r.duration_sec!=null?r.duration_sec.toFixed(1)+'s':'-'}</td>
|
||||||
|
</tr>`;
|
||||||
|
|
||||||
|
// Скрытая строка с этапами (раскрывается по клику)
|
||||||
|
html+=`<tr id="hist-stages-${i}" style="display:none;"><td colspan="5" style="padding:4px 8px;">${renderStages(r.stages)}</td></tr>`;
|
||||||
|
});
|
||||||
|
html+='</table>';
|
||||||
|
body.innerHTML=html;
|
||||||
|
}catch(e){
|
||||||
|
body.innerHTML='<span style="color:var(--destructive);font-size:11px;">Ошибка загрузки</span>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Раскрыть/скрыть строку с этапами для записи истории.
|
||||||
|
*
|
||||||
|
* @param {number} i — индекс записи в массиве rows
|
||||||
|
*/
|
||||||
|
function toggleHistoryRow(i) {
|
||||||
|
const el = document.getElementById('hist-stages-' + i);
|
||||||
|
if (!el) return;
|
||||||
|
el.style.display = el.style.display === 'none' ? 'table-row' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отрисовать этапы выполнения для записи истории.
|
||||||
|
*
|
||||||
|
* Каждый этап: иконка (✅/❌/⏳) + название + время + длительность.
|
||||||
|
*
|
||||||
|
* @param {Array} stages — [{stage, dtStart, dtFinish, isSuccessful, duration}, ...]
|
||||||
|
* @returns {string} HTML
|
||||||
|
*/
|
||||||
|
function renderStages(stages) {
|
||||||
|
if (!stages || !stages.length)
|
||||||
|
return '<span style="color:var(--muted);font-size:10px;">Нет данных об этапах</span>';
|
||||||
|
|
||||||
|
let html = '<div style="font-size:11px;"><b>Этапы выполнения</b></div>';
|
||||||
|
stages.forEach(s => {
|
||||||
|
const done = !!s.dtFinish; // этап завершён?
|
||||||
|
const icon = done ? (s.isSuccessful ? '✅' : '❌') : '⏳';
|
||||||
|
const start = s.dtStart ? s.dtStart.replace('T',' ').substring(0,19) : '?';
|
||||||
|
const finish = s.dtFinish ? s.dtFinish.replace('T',' ').substring(0,19) : '...';
|
||||||
|
const dur = s.duration != null ? s.duration.toFixed(1) + ' сек' : '—';
|
||||||
|
html += `<div style="margin:4px 0;padding:4px 6px;background:var(--brand-grey-light);border-radius:3px;">`;
|
||||||
|
html += `${icon} <b>${_esc(s.stage||'?')}</b>`;
|
||||||
|
html += `<span style="color:var(--muted);font-size:10px;margin-left:8px;">${start} → ${finish} (${dur})</span>`;
|
||||||
|
html += `</div>`;
|
||||||
|
});
|
||||||
|
return html;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// icons.js — 10 inline-SVG иконок (Lucide, MIT-лицензия)
|
||||||
|
//
|
||||||
|
// Все иконки используют stroke="currentColor" — цвет берётся из CSS (color).
|
||||||
|
// Размер: 20x20px, stroke-width: 2 (тонкие линии).
|
||||||
|
//
|
||||||
|
// Почему inline-SVG а не <img> или иконочный шрифт:
|
||||||
|
// - Не требует внешних зависимостей (никаких CDN)
|
||||||
|
// - Цвет controlled через CSS (currentColor)
|
||||||
|
// - Не грузит лишних файлов (иконки вшиты в JS)
|
||||||
|
//
|
||||||
|
// Использование: ICONS.play, ICONS.edit, ICONS.trash, ...
|
||||||
|
|
||||||
|
const ICONS = {
|
||||||
|
play: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>',
|
||||||
|
edit: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>',
|
||||||
|
trash: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>',
|
||||||
|
check: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>',
|
||||||
|
xIcon: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>',
|
||||||
|
chevronDown: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>',
|
||||||
|
search: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>',
|
||||||
|
plus: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>',
|
||||||
|
rotateCw: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>',
|
||||||
|
cloud: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z"/></svg>',
|
||||||
|
};
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
// instances.js — список инстансов, выбор сервиса, toggle операций
|
||||||
|
//
|
||||||
|
// Отвечает за:
|
||||||
|
// - Загрузку инстансов выбранного сервиса (GET /api/operations/{svcId})
|
||||||
|
// - Отображение списка инстансов с фильтром autotest-
|
||||||
|
// - Показ доступных операций для выбранного инстанса
|
||||||
|
// - Кнопки операций (modify, delete, suspend, resume, redeploy)
|
||||||
|
//
|
||||||
|
// Глобальные переменные (используются в operations.js и других модулях):
|
||||||
|
// svcInstances[] — текущий список инстансов
|
||||||
|
// selectedInst — UUID выбранного инстанса (или null)
|
||||||
|
// selectedOp — {opId, opName, svcId} текущая выбранная операция
|
||||||
|
// currentSvcId — ID текущего сервиса
|
||||||
|
// currentSvcName — имя текущего сервиса
|
||||||
|
// currentSvcShort — короткое имя (для генерации displayName)
|
||||||
|
// busy — флаг блокировки UI (пока идёт операция)
|
||||||
|
// AUTOTEST_PREFIX — "autotest-" (фильтр "наших" инстансов)
|
||||||
|
|
||||||
|
let svcInstances=[], selectedInst=null, selectedOp=null;
|
||||||
|
let currentSvcId=(window.APP&&window.APP.firstServiceId)||1;
|
||||||
|
let currentSvcName='';
|
||||||
|
let currentSvcShort='';
|
||||||
|
let busy=false;
|
||||||
|
const AUTOTEST_PREFIX='autotest-';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Выбрать сервис — загрузить его инстансы и показать в списке.
|
||||||
|
* Вызывается при клике на сервис в левой панели.
|
||||||
|
*
|
||||||
|
* @param {number} svcId — ID сервиса
|
||||||
|
*/
|
||||||
|
async function selectService(svcId){
|
||||||
|
if(busy) return; // блокировка — идёт операция
|
||||||
|
|
||||||
|
// Сброс предыдущего состояния
|
||||||
|
currentSvcId=svcId;
|
||||||
|
selectedInst=null;
|
||||||
|
selectedOp=null;
|
||||||
|
stopPoll(); // остановить поллинг предыдущей операции
|
||||||
|
|
||||||
|
// Скрыть панель параметров и этапы
|
||||||
|
document.getElementById('params-area').style.display='none';
|
||||||
|
document.querySelectorAll('.stages-box').forEach(e=>e.remove());
|
||||||
|
|
||||||
|
// Подсветка активного сервиса в списке
|
||||||
|
document.querySelectorAll('.svc-item').forEach(e=>e.classList.remove('active'));
|
||||||
|
const svcEl=document.querySelector(`.svc-item[onclick*="${svcId}"]`);
|
||||||
|
if(svcEl) svcEl.classList.add('active');
|
||||||
|
|
||||||
|
try{
|
||||||
|
// GET /api/operations/{svcId} — возвращает операции + autotest-инстансы
|
||||||
|
const r=await fetch('/api/operations/'+svcId);
|
||||||
|
const d=await r.json();
|
||||||
|
svcInstances=d.instances||[];
|
||||||
|
currentSvcName=d.svc||'';
|
||||||
|
currentSvcShort=d.svcShort||'';
|
||||||
|
// Обновить текст кнопки «Создать»
|
||||||
|
const btnSpan=document.querySelector('#create-btn-area span');
|
||||||
|
if(btnSpan) btnSpan.textContent='+ Создать новый инстанс '+(currentSvcName||'сервиса');
|
||||||
|
renderInstances(); // отрисовать список
|
||||||
|
}catch(e){
|
||||||
|
document.getElementById('inst-list').innerHTML=
|
||||||
|
'<span style="color:var(--destructive);font-size:11px;">Ошибка загрузки</span>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отрисовать список инстансов в #inst-list.
|
||||||
|
* Каждый инстанс — строка: имя + сервис + статус + created + modified.
|
||||||
|
*/
|
||||||
|
function renderInstances(){
|
||||||
|
let html='';
|
||||||
|
svcInstances.forEach(i=>{
|
||||||
|
// Статус: из нашего поля status (cloud-first) или explainedStatus
|
||||||
|
const status=i.status||i.explainedStatus||'?';
|
||||||
|
const sc=status==='running'?'badge-success':'';
|
||||||
|
// Строка инстанса — кликабельна
|
||||||
|
html+=`<div class="inst-item" onclick="toggleInstance('${i.instanceUid}')" data-iuid="${i.instanceUid}">
|
||||||
|
<span style="display:inline-block;width:220px;">${_esc(i.displayName)}</span>
|
||||||
|
<span style="display:inline-block;width:140px;color:var(--muted);font-size:11px;">${_esc(i.svc||'')}</span>
|
||||||
|
<span class="badge ${sc}" style="font-size:9px;">${_esc(status)}</span>
|
||||||
|
<span style="display:inline-block;width:60px;color:var(--muted);font-size:9px;text-align:right;" title="${i.instanceConfigDtCreated||''}">${relativeTime(i.instanceConfigDtCreated)}</span>
|
||||||
|
<span style="display:inline-block;width:60px;color:var(--muted);font-size:9px;text-align:right;" title="${i.instanceConfigDtModified||''}">${relativeTime(i.instanceConfigDtModified)}</span>
|
||||||
|
</div>`;
|
||||||
|
// Контейнер для кнопок операций (изначально скрыт)
|
||||||
|
html+=`<div class="inst-ops" id="ops-${i.instanceUid}"></div>`;
|
||||||
|
});
|
||||||
|
document.getElementById('inst-list').innerHTML=html;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновить список инстансов (без смены сервиса).
|
||||||
|
* Вызывается после завершения операции.
|
||||||
|
*/
|
||||||
|
async function refreshInstances(){
|
||||||
|
try{
|
||||||
|
const r=await fetch('/api/operations/'+currentSvcId);
|
||||||
|
const d=await r.json();
|
||||||
|
svcInstances=d.instances||[];
|
||||||
|
renderInstances();
|
||||||
|
}catch(e){}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Раскрыть/скрыть кнопки операций для инстанса.
|
||||||
|
* Загружает доступные операции через GET /api/operations/{svcId}.
|
||||||
|
*
|
||||||
|
* @param {string} iuid — instanceUid
|
||||||
|
*/
|
||||||
|
async function toggleInstance(iuid){
|
||||||
|
if(busy) return;
|
||||||
|
selectedInst=iuid;
|
||||||
|
stopPoll();
|
||||||
|
|
||||||
|
// Подсветка выбранного инстанса
|
||||||
|
document.querySelectorAll('[data-iuid]').forEach(e=>
|
||||||
|
e.classList.toggle('active',e.dataset.iuid===iuid));
|
||||||
|
|
||||||
|
// Скрыть панель параметров (если была открыта для другого инстанса)
|
||||||
|
document.getElementById('params-area').style.display='none';
|
||||||
|
|
||||||
|
const opsEl=document.getElementById('ops-'+iuid);
|
||||||
|
// Если уже открыт — закрыть (toggle)
|
||||||
|
if(opsEl.classList.contains('open')){opsEl.classList.remove('open');return;}
|
||||||
|
|
||||||
|
// Закрыть ВСЕ другие открытые панели операций
|
||||||
|
document.querySelectorAll('.inst-ops').forEach(e=>e.classList.remove('open'));
|
||||||
|
|
||||||
|
// Найти инстанс в списке (может быть из другого сервиса)
|
||||||
|
const inst=svcInstances.find(x=>x.instanceUid===iuid);
|
||||||
|
const svcId=inst?inst.serviceId:currentSvcId;
|
||||||
|
|
||||||
|
try{
|
||||||
|
const r=await fetch('/api/operations/'+svcId);
|
||||||
|
const d=await r.json();
|
||||||
|
|
||||||
|
// Фильтруем операции: убираем reconcile (техническая) и create (уже создан)
|
||||||
|
let ops=(d.operations||[]).filter(o=>o.operation!=='reconcile'&&o.operation!=='create');
|
||||||
|
|
||||||
|
// Для not created инстансов — только delete
|
||||||
|
if(inst&&(inst.status||inst.explainedStatus)==='not created'){
|
||||||
|
ops=ops.filter(o=>o.operation==='delete');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Кнопки операций с цветовой маркировкой
|
||||||
|
opsEl.innerHTML=ops.map(o=>
|
||||||
|
`<button class="btn btn-sm op-btn ${opClass(o.operation)}" onclick="runOp('${o.operation}',${o.svcOperationId})">${o.operation}</button>`
|
||||||
|
).join('');
|
||||||
|
opsEl.classList.add('open');
|
||||||
|
}catch(e){
|
||||||
|
opsEl.innerHTML='<span style="color:var(--destructive);font-size:11px;">Ошибка загрузки</span>';
|
||||||
|
opsEl.classList.add('open');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSS-класс для кнопки операции (цветовая маркировка).
|
||||||
|
*
|
||||||
|
* @param {string} opName — "modify"/"delete"/"suspend"/...
|
||||||
|
* @returns {string} CSS-класс
|
||||||
|
*/
|
||||||
|
function opClass(opName){
|
||||||
|
if(opName==='modify') return 'op-btn-modify'; // синий
|
||||||
|
if(opName==='suspend') return 'op-btn-suspend'; // оранжевый
|
||||||
|
if(opName==='resume') return 'op-btn-resume'; // зелёный
|
||||||
|
if(opName==='delete') return 'op-btn-delete'; // красный
|
||||||
|
if(opName==='redeploy') return 'op-btn-redeploy'; // фиолетовый
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
// operations.js — запуск операций: create, modify, delete, поллинг, этапы
|
||||||
|
//
|
||||||
|
// Это основной модуль ручного режима тестирования.
|
||||||
|
// Жизненный цикл операции:
|
||||||
|
// 1. Пользователь выбирает инстанс → жмёт кнопку операции (runOp)
|
||||||
|
// 2. Загружаются параметры с ТЕКУЩИМИ значениями (showParams)
|
||||||
|
// 3. Пользователь правит параметры → жмёт «Запустить» (executeOp)
|
||||||
|
// 4. POST /api/test → возвращает opUid → начинается поллинг (pollTimer)
|
||||||
|
// 5. Каждые 2 секунды GET /api/test/status/{opUid} → этапы + статус
|
||||||
|
// 6. При завершении → обновить список инстансов + историю
|
||||||
|
//
|
||||||
|
// Глобальные переменные (используются из instances.js):
|
||||||
|
// pollTimer — ID setInterval для поллинга
|
||||||
|
|
||||||
|
let pollTimer=null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сгенерировать короткое имя для нового инстанса.
|
||||||
|
* Формат: {svcShort}-{random3} (напр. "dummy-a3f").
|
||||||
|
* random3 — base36 (0-9a-z), 3 символа = 46656 вариантов.
|
||||||
|
*
|
||||||
|
* @returns {string} displayName suffix (без префикса autotest-)
|
||||||
|
*/
|
||||||
|
function makeCreateDisplayName(){
|
||||||
|
const rand = (Math.random() * 46656 | 0).toString(36).padStart(3, '0');
|
||||||
|
return currentSvcShort ? currentSvcShort+'-'+rand : rand;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Найти displayName выбранного инстанса.
|
||||||
|
*
|
||||||
|
* @returns {string} displayName или instanceUid (если не найден)
|
||||||
|
*/
|
||||||
|
function findInstName(){
|
||||||
|
const i=svcInstances.find(x=>x.instanceUid===selectedInst);
|
||||||
|
return i?i.displayName:selectedInst;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Остановить поллинг текущей операции.
|
||||||
|
*/
|
||||||
|
function stopPoll(){
|
||||||
|
if(pollTimer){clearInterval(pollTimer);pollTimer=null;}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Начать создание нового инстанса (кнопка «+ Создать»).
|
||||||
|
*
|
||||||
|
* Загружает операцию create для текущего сервиса через GET /api/operations/{svcId}.
|
||||||
|
* Если операция create не найдена — fallback на svcOperationId=18 (стандартный).
|
||||||
|
*/
|
||||||
|
async function startCreate(){
|
||||||
|
if(busy) return;
|
||||||
|
selectedInst=null; // create — нет выбранного инстанса
|
||||||
|
stopPoll();
|
||||||
|
// Снять выделение со всех инстансов
|
||||||
|
document.querySelectorAll('[data-iuid]').forEach(e=>e.classList.remove('active'));
|
||||||
|
document.querySelectorAll('.inst-ops').forEach(e=>e.classList.remove('open'));
|
||||||
|
try{
|
||||||
|
const r=await fetch('/api/operations/'+currentSvcId);
|
||||||
|
const d=await r.json();
|
||||||
|
const createOp=d.operations.find(o=>o.operation==='create');
|
||||||
|
const opId=createOp?createOp.svcOperationId:18; // fallback
|
||||||
|
showParams(opId,'create');
|
||||||
|
}catch(e){
|
||||||
|
showParams(18,'create'); // fallback при ошибке
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Запустить операцию над выбранным инстансом (кнопка modify/delete/...).
|
||||||
|
*
|
||||||
|
* @param {string} opName — "modify"/"delete"/"suspend"/"resume"/"redeploy"
|
||||||
|
* @param {number} opId — svcOperationId
|
||||||
|
*/
|
||||||
|
function runOp(opName,opId){
|
||||||
|
if(busy) return;
|
||||||
|
stopPoll();
|
||||||
|
showParams(opId,opName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Показать форму параметров для операции.
|
||||||
|
*
|
||||||
|
* Алгоритм:
|
||||||
|
* 1. Загрузить параметры: GET /api/params/{opId}[?instanceUid=xxx]
|
||||||
|
* - Для create: без instanceUid → шаблонные defaults
|
||||||
|
* - Для не-create: с instanceUid → текущие значения из state.params
|
||||||
|
* 2. Отрисовать форму через renderParamRow (из params-render.js)
|
||||||
|
* 3. Показать кнопку «Запустить»
|
||||||
|
*
|
||||||
|
* @param {number} opId — svcOperationId
|
||||||
|
* @param {string} opName — "create"/"modify"/...
|
||||||
|
*/
|
||||||
|
function showParams(opId,opName){
|
||||||
|
// Сохраняем выбранную операцию
|
||||||
|
selectedOp={opId,opName,svcId:currentSvcId};
|
||||||
|
|
||||||
|
// Показать панель параметров, скрыть старые этапы
|
||||||
|
document.getElementById('params-area').style.display='block';
|
||||||
|
document.querySelectorAll('.stages-box').forEach(e=>e.remove());
|
||||||
|
document.getElementById('test-status').textContent='';
|
||||||
|
|
||||||
|
const isCreate=opName==='create';
|
||||||
|
// Для create — шаблонные значения, для не-create — с instanceUid
|
||||||
|
const qs=isCreate?'' : `?instanceUid=${selectedInst}`;
|
||||||
|
|
||||||
|
fetch('/api/params/'+opId+qs).then(r=>r.json()).then(async data=>{
|
||||||
|
if(data.error){
|
||||||
|
document.getElementById('params-form').innerHTML=
|
||||||
|
`<span style="color:var(--destructive);">Ошибка: ${_esc(data.error)}</span>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const params=data.params||data;
|
||||||
|
if(!Array.isArray(params)){
|
||||||
|
document.getElementById('params-form').innerHTML=
|
||||||
|
`<span style="color:var(--destructive);">Неверный формат параметров</span>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если есть refSvcId-параметры — загружаем ВСЕ инстансы (для select'ов)
|
||||||
|
let allInst=[];
|
||||||
|
const needRefs=params.some(p=>p.refSvcId);
|
||||||
|
if(needRefs){
|
||||||
|
try{
|
||||||
|
const r=await fetch('/api/instances/list');
|
||||||
|
const list=await r.json();
|
||||||
|
allInst=list||[];
|
||||||
|
}catch(e){}
|
||||||
|
}
|
||||||
|
|
||||||
|
const form=document.getElementById('params-form');
|
||||||
|
const displayNameValue=isCreate?makeCreateDisplayName():'';
|
||||||
|
|
||||||
|
// Заголовок формы
|
||||||
|
const createHeader=isCreate
|
||||||
|
? `<div style="font-weight:600;font-size:13px;margin-bottom:8px;color:var(--brand-primary);">Создание: ${_esc(currentSvcName)}</div>`
|
||||||
|
: '';
|
||||||
|
const opHeader=!isCreate
|
||||||
|
? `<div style="font-weight:600;font-size:13px;margin-bottom:8px;color:var(--brand-primary);">${_esc(opName)} → ${_esc(findInstName())}</div>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
// Форма: заголовок + displayName (только create) + параметры
|
||||||
|
form.innerHTML=createHeader+opHeader
|
||||||
|
+ (opName==='create'
|
||||||
|
? `<div class="param-row"><label class="req">displayName</label><span style="font-size:12px;color:var(--muted);white-space:nowrap;">${AUTOTEST_PREFIX}</span><input type="text" id="param-displayname" value="${_esc(displayNameValue)}" autocomplete="off"></div>`
|
||||||
|
: '')
|
||||||
|
+ params.map(p=>renderParamRow(p,allInst)).join('');
|
||||||
|
|
||||||
|
// Кнопка «Запустить»
|
||||||
|
const btn=document.getElementById('btn-test');
|
||||||
|
btn.style.display='inline-flex';
|
||||||
|
btn.textContent='Запустить';
|
||||||
|
btn.onclick=()=>{
|
||||||
|
// Валидация JSON-полей перед отправкой
|
||||||
|
let bad=[];
|
||||||
|
document.querySelectorAll('#params-form [data-type="map"]').forEach(el=>{
|
||||||
|
if(!validateJson(el,true)) bad.push(el);
|
||||||
|
});
|
||||||
|
if(bad.length>0){
|
||||||
|
document.getElementById('test-status').innerHTML=
|
||||||
|
`<span class="badge">Ошибка</span> ${bad.length} полей с невалидным JSON — поправьте и нажмите Запустить снова`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const pp=collectParams(); // собрать значения из формы
|
||||||
|
executeOp(pp); // отправить
|
||||||
|
};
|
||||||
|
}).catch(e=>{
|
||||||
|
document.getElementById('params-form').innerHTML=
|
||||||
|
`<span style="color:var(--destructive);">Ошибка загрузки: ${_esc(e.message)}</span>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Установить состояние «завершено» — показать результат операции.
|
||||||
|
*
|
||||||
|
* @param {string} statusText — "OK"/"FAIL"/"TIMEOUT"
|
||||||
|
* @param {string} statusClass — CSS-класс бейджа
|
||||||
|
* @param {string} statusError — текст ошибки
|
||||||
|
* @param {number} statusDuration— длительность в секундах
|
||||||
|
* @param {string} instanceName — displayName инстанса
|
||||||
|
*/
|
||||||
|
function setFinishedState(statusText, statusClass, statusError, statusDuration, instanceName){
|
||||||
|
busy=false; // разблокировать UI
|
||||||
|
// Закрыть панели операций
|
||||||
|
document.querySelectorAll('.inst-ops.open').forEach(e=>e.classList.remove('open'));
|
||||||
|
const btn=document.getElementById('btn-test');
|
||||||
|
btn.disabled=false;
|
||||||
|
btn.textContent='Готово';
|
||||||
|
btn.onclick=null; // убрать обработчик
|
||||||
|
const opName=selectedOp?.opName||'операция';
|
||||||
|
const durationText=statusDuration!==undefined&&statusDuration!==null?`${statusDuration}s`:'0s';
|
||||||
|
const extra=_esc(statusError||'');
|
||||||
|
const instName=_esc(instanceName||findInstName()||selectedInst||'');
|
||||||
|
document.getElementById('test-status').innerHTML=
|
||||||
|
` <span class="badge ${statusClass}">${statusText}</span> <span style="color:var(--muted);">${opName}</span> <span style="color:var(--muted);">${durationText}</span> <span style="color:var(--muted);">${instName}</span> ${extra}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Установить состояние «выполняется».
|
||||||
|
*/
|
||||||
|
function setRunningState(opName, displayName){
|
||||||
|
const btn=document.getElementById('btn-test');
|
||||||
|
btn.textContent=opName+'...';
|
||||||
|
document.getElementById('test-status').textContent=' ⏳ '+ (displayName||opName) +' выполняется...';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отправить операцию на выполнение (POST /api/test) и начать поллинг.
|
||||||
|
*
|
||||||
|
* Алгоритм:
|
||||||
|
* 1. POST /api/test → {status: "RUNNING", opUid}
|
||||||
|
* 2. Если CMDB-delete → сразу OK (без поллинга)
|
||||||
|
* 3. Иначе → setInterval каждые 2 секунды:
|
||||||
|
* GET /api/test/status/{opUid} → этапы + статус
|
||||||
|
* 4. При OK/FAIL → остановить поллинг, обновить список инстансов и историю
|
||||||
|
* 5. При 5 ошибках подряд → TIMEOUT (связь потеряна)
|
||||||
|
*
|
||||||
|
* @param {Object} params — {numeric_param_id: value}
|
||||||
|
*/
|
||||||
|
async function executeOp(params){
|
||||||
|
busy=true; // заблокировать UI
|
||||||
|
stopPoll();
|
||||||
|
|
||||||
|
const isCreate=selectedOp?.opName==='create';
|
||||||
|
const displayNameInput=document.getElementById('param-displayname');
|
||||||
|
const displayNameSuffix=(displayNameInput?.value||'').trim();
|
||||||
|
const displayName=isCreate
|
||||||
|
? `${AUTOTEST_PREFIX}${displayNameSuffix || makeCreateDisplayName()}`
|
||||||
|
: findInstName();
|
||||||
|
|
||||||
|
// Подготовка UI
|
||||||
|
document.getElementById('params-area').style.display='block';
|
||||||
|
document.getElementById('params-form').innerHTML=''; // очистить форму
|
||||||
|
const btn=document.getElementById('btn-test');
|
||||||
|
btn.style.display='inline-flex';
|
||||||
|
btn.textContent=selectedOp.opName+'...';
|
||||||
|
btn.disabled=true;
|
||||||
|
setRunningState(selectedOp.opName, displayName);
|
||||||
|
|
||||||
|
// Создать контейнер для этапов (после кнопки create или после панели операций)
|
||||||
|
const instId=selectedInst||'';
|
||||||
|
const afterEl=instId?document.getElementById('ops-'+instId):null;
|
||||||
|
document.querySelectorAll('.stages-box').forEach(e=>e.remove());
|
||||||
|
const stagesBox=document.createElement('div');
|
||||||
|
stagesBox.className='stages-box';
|
||||||
|
stagesBox.style.cssText='max-height:200px;overflow-y:auto;padding:4px 8px;margin-left:16px;background:var(--brand-grey-light);border-radius:4px;';
|
||||||
|
if(afterEl){afterEl.after(stagesBox);}
|
||||||
|
else{document.getElementById('create-btn-area').after(stagesBox);}
|
||||||
|
|
||||||
|
try{
|
||||||
|
// POST /api/test — тело: serviceId, operation, svcOperationId, params, instanceUid, displayName
|
||||||
|
const r=await fetch('/api/test',{
|
||||||
|
method:'POST',
|
||||||
|
headers:{'Content-Type':'application/json'},
|
||||||
|
body:JSON.stringify({
|
||||||
|
serviceId:currentSvcId,
|
||||||
|
operation:selectedOp.opName,
|
||||||
|
svcOperationId:selectedOp.opId,
|
||||||
|
params,
|
||||||
|
instanceUid:selectedInst||'',
|
||||||
|
displayName:isCreate?displayName:''
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const d=await r.json();
|
||||||
|
|
||||||
|
// FAIL на старте — нечего поллить
|
||||||
|
if(d.status==='FAIL'){
|
||||||
|
busy=false;
|
||||||
|
document.getElementById('test-status').innerHTML=
|
||||||
|
` <span class="badge">FAIL</span> ${_esc(d.error||'')}`;
|
||||||
|
btn.disabled=false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CMDB-delete (opUid начинается с "cmdb-") — мгновенный успех
|
||||||
|
if(d.status==='OK'&&(d.opUid||'').startsWith('cmdb-')){
|
||||||
|
busy=false;
|
||||||
|
setFinishedState('OK','badge-success','',0,d.displayName);
|
||||||
|
try{await refreshInstances();}catch(e){}
|
||||||
|
if(historyOpen) try{await loadHistory();}catch(e){}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const opUid=d.opUid;
|
||||||
|
const instanceName=d.displayName||displayName;
|
||||||
|
|
||||||
|
// Поллинг: каждые 2 секунды
|
||||||
|
let _pollErrors=0;
|
||||||
|
pollTimer=setInterval(async()=>{
|
||||||
|
try{
|
||||||
|
const sr=await fetch('/api/test/status/'+opUid);
|
||||||
|
const sd=await sr.json();
|
||||||
|
_pollErrors=0; // сброс счётчика ошибок
|
||||||
|
showStages(sd.stages||[]); // обновить этапы
|
||||||
|
if(sd.status!=='RUNNING'){
|
||||||
|
stopPoll();
|
||||||
|
setFinishedState(
|
||||||
|
sd.status,
|
||||||
|
sd.status==='OK'?'badge-success':'badge',
|
||||||
|
sd.error||sd.errorLog,
|
||||||
|
sd.duration,
|
||||||
|
sd.displayName||instanceName
|
||||||
|
);
|
||||||
|
await refreshInstances(); // обновить список инстансов
|
||||||
|
if(historyOpen) await loadHistory(); // обновить историю
|
||||||
|
}
|
||||||
|
}catch(e){
|
||||||
|
_pollErrors++;
|
||||||
|
// 5 ошибок подряд (10 секунд) → считаем связь потерянной
|
||||||
|
if(_pollErrors>=5){
|
||||||
|
stopPoll();
|
||||||
|
setFinishedState('TIMEOUT','','Связь потеряна',0,instanceName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},2000);
|
||||||
|
}catch(e){
|
||||||
|
busy=false;
|
||||||
|
document.getElementById('test-status').textContent=' Ошибка: '+e.message;
|
||||||
|
btn.disabled=false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Показать этапы выполнения операции (stages из API).
|
||||||
|
*
|
||||||
|
* Каждый этап: иконка (✅/❌/⏳) + название + длительность.
|
||||||
|
*
|
||||||
|
* @param {Array} stages — [{stage, dtStart, dtFinish, isSuccessful, duration}, ...]
|
||||||
|
*/
|
||||||
|
function showStages(stages){
|
||||||
|
if(!stages||!stages.length) return;
|
||||||
|
let html='<div style="font-size:11px;font-weight:600;color:var(--muted);margin-top:4px;">Этапы</div>';
|
||||||
|
stages.forEach(s=>{
|
||||||
|
const done=!!s.dtFinish; // этап завершён?
|
||||||
|
const icon=done?(s.isSuccessful?'✅':'❌'):'⏳';
|
||||||
|
html+=`<div style="font-size:11px;padding:2px 0;">${icon} ${_esc(s.stage)} — ${(s.duration||0).toFixed(1)}s</div>`;
|
||||||
|
});
|
||||||
|
// Показать в ПОСЛЕДНЕМ контейнере этапов (самом новом)
|
||||||
|
const boxes=document.querySelectorAll('.stages-box');
|
||||||
|
if(boxes.length) boxes[boxes.length-1].innerHTML=html;
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
// params-render.js — общий рендер параметров операций (формы ввода)
|
||||||
|
//
|
||||||
|
// Используется в двух местах:
|
||||||
|
// 1. operations.js — ручной режим (форма параметров для modify/create)
|
||||||
|
// 2. scenario-form.js — редактор сценариев (параметры каждого шага)
|
||||||
|
//
|
||||||
|
// Зависимости: _esc() из utils.js, validateJson() из utils.js
|
||||||
|
//
|
||||||
|
// Экспортирует (в глобальную область):
|
||||||
|
// renderParamRow(p, allInst) — отрисовать ОДНУ строку параметра
|
||||||
|
// renderMapFixedRow(p, dfl) — отрисовать map-параметр с фиксированными ключами
|
||||||
|
// collectParams(containerSelector)— собрать значения из формы → {numeric_id: value}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отрисовать строку параметра: лейбл + поле ввода (input/select).
|
||||||
|
*
|
||||||
|
* Типы полей:
|
||||||
|
* - valueList → <select> с вариантами
|
||||||
|
* - refSvcId → <select> с инстансами сервиса
|
||||||
|
* - boolean → <select> true/false
|
||||||
|
* - map + dataDescriptor → renderMapFixedRow (вложенные поля)
|
||||||
|
* - map без dataDescriptor → <input> с onblur="validateJson(this)"
|
||||||
|
* - остальное → <input type="text">
|
||||||
|
*
|
||||||
|
* @param {Object} p — параметр из API:
|
||||||
|
* {svcOperationCfsParamId, name, dataType, isRequired, defaultValue, valueList, refSvcId, dataDescriptor}
|
||||||
|
* @param {Array} allInst — все инстансы (нужно для refSvcId-селектов)
|
||||||
|
* @returns {string} HTML-строка
|
||||||
|
*/
|
||||||
|
function renderParamRow(p, allInst) {
|
||||||
|
const req = p.isRequired;
|
||||||
|
const hasDfl = p.defaultValue !== null && p.defaultValue !== undefined && p.defaultValue !== '';
|
||||||
|
// CSS-класс лейбла: req (обязательный+заполнен), req-nodfl (обязательный+пустой)
|
||||||
|
const labelCls = req ? (hasDfl ? 'req' : 'req-nodfl') : '';
|
||||||
|
const dfl = hasDfl ? p.defaultValue : (req ? '' : '');
|
||||||
|
const vl = p.valueList;
|
||||||
|
const isMap = (p.dataType || '').startsWith('map');
|
||||||
|
|
||||||
|
let input;
|
||||||
|
|
||||||
|
if (vl && Array.isArray(vl)) {
|
||||||
|
// Выпадающий список с фиксированными значениями
|
||||||
|
input = `<select name="p_${p.svcOperationCfsParamId}" data-type="${_esc(p.dataType)}">${vl.map(v => `<option value="${_esc(v)}" ${v == dfl ? 'selected' : ''}>${_esc(v)}</option>`).join('')}</select>`;
|
||||||
|
|
||||||
|
} else if (p.refSvcId) {
|
||||||
|
// Ссылка на другой сервис → выпадающий список инстансов этого сервиса
|
||||||
|
const refInsts = allInst.filter(i => i.serviceId === p.refSvcId && i.explainedStatus !== 'deleted');
|
||||||
|
let opts = refInsts.map(i => `<option value="${_esc(i.instanceUid)}" ${i.instanceUid === dfl ? 'selected' : ''}>${_esc(i.displayName)}</option>`).join('');
|
||||||
|
if (!dfl) opts = '<option value="">— выбрать —</option>' + opts;
|
||||||
|
input = `<select name="p_${p.svcOperationCfsParamId}" data-type="ref">${opts}</select>`;
|
||||||
|
|
||||||
|
} else if (p.dataType === 'boolean') {
|
||||||
|
// Булево значение → выпадающий список true/false
|
||||||
|
input = `<select name="p_${p.svcOperationCfsParamId}" data-type="boolean"><option value="true" ${dfl === 'true' || dfl === true ? 'selected' : ''}>true</option><option value="false" ${dfl === 'false' || dfl === false ? 'selected' : ''}>false</option></select>`;
|
||||||
|
|
||||||
|
} else if (p.dataDescriptor && isMap) {
|
||||||
|
// Map с фиксированной структурой → renderMapFixedRow (вложенные поля)
|
||||||
|
return renderMapFixedRow(p, dfl);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// Обычное текстовое поле (string, integer, map без структуры)
|
||||||
|
const dt = isMap ? 'map' : '';
|
||||||
|
input = `<input type="text" name="p_${p.svcOperationCfsParamId}" value="${_esc(dfl)}" data-type="${dt}" placeholder="${req && !hasDfl ? 'обязательно' : ''}"${isMap ? ' onblur="validateJson(this)"' : ''}>`;
|
||||||
|
if (isMap) input += `<span class="json-err" style="display:none;color:var(--destructive);font-size:10px;margin-left:4px;"></span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<div class="param-row"><label class="${labelCls}">${_esc(p.name)}</label>${input}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отрисовать map-параметр с ФИКСИРОВАННЫМИ ключами (dataDescriptor).
|
||||||
|
*
|
||||||
|
* Каждый ключ dataDescriptor → отдельная строка с под-лейблом.
|
||||||
|
* Вложенные поля отбиваются вертикальной чертой слева.
|
||||||
|
*
|
||||||
|
* @param {Object} p — параметр с dataDescriptor
|
||||||
|
* @param {string} dfl — текущее значение (JSON-строка)
|
||||||
|
* @returns {string} HTML
|
||||||
|
*/
|
||||||
|
function renderMapFixedRow(p, dfl) {
|
||||||
|
const dd = p.dataDescriptor;
|
||||||
|
const labelCls = p.isRequired ? (dfl ? 'req' : 'req-nodfl') : '';
|
||||||
|
let subHtml = '';
|
||||||
|
// Парсим текущее значение (JSON → объект)
|
||||||
|
let dflObj = {};
|
||||||
|
try { dflObj = JSON.parse(dfl || '{}'); } catch (e) { }
|
||||||
|
|
||||||
|
Object.keys(dd).forEach(subKey => {
|
||||||
|
const sub = dd[subKey];
|
||||||
|
// Значение sub-ключа: из parsed JSON → default → пусто
|
||||||
|
const subDfl = dflObj[subKey] !== undefined ? dflObj[subKey] : (sub.defaultValue || '');
|
||||||
|
const subVl = sub.valueList;
|
||||||
|
const subReq = sub.isRequired;
|
||||||
|
const subNoDfl = subReq && !subDfl && subDfl !== 0 && subDfl !== false;
|
||||||
|
const subLblCls = subNoDfl ? 'req-nodfl' : '';
|
||||||
|
let si;
|
||||||
|
|
||||||
|
if (subVl && Array.isArray(subVl)) {
|
||||||
|
// Выпадающий список
|
||||||
|
si = `<select name="p_${p.svcOperationCfsParamId}.${subKey}" data-type="mapchild">${subVl.map(v => `<option value="${_esc(v)}" ${v == subDfl ? 'selected' : ''}>${_esc(v)}</option>`).join('')}</select>`;
|
||||||
|
} else if (sub.dataType === 'boolean') {
|
||||||
|
// Булево
|
||||||
|
si = `<select name="p_${p.svcOperationCfsParamId}.${subKey}" data-type="mapchild"><option value="true" ${subDfl === 'true' || subDfl === true ? 'selected' : ''}>true</option><option value="false" ${subDfl === 'false' || subDfl === false ? 'selected' : ''}>false</option></select>`;
|
||||||
|
} else {
|
||||||
|
// Текстовое поле
|
||||||
|
si = `<input type="text" name="p_${p.svcOperationCfsParamId}.${subKey}" value="${_esc(subDfl)}" data-type="mapchild">`;
|
||||||
|
}
|
||||||
|
subHtml += `<div class="param-row"><label class="${subLblCls}">${_esc(subKey)}</label>${si}</div>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Основной лейбл + вложенные поля с вертикальной чертой
|
||||||
|
return `<div class="param-row"><label class="${labelCls}">${_esc(p.name)}</label></div><div style="border-left:2px solid var(--brand-gray);margin-left:8px;padding:0 0 4px 8px;">${subHtml}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Собрать значения ВСЕХ полей формы → {numeric_id: string_value}.
|
||||||
|
*
|
||||||
|
* Для mapchild-полей (с точкой в имени) — собирает вложенный объект
|
||||||
|
* и сериализует в JSON.
|
||||||
|
*
|
||||||
|
* @param {string} containerSelector — CSS-селектор контейнера (по умолчанию '#params-form')
|
||||||
|
* @returns {Object} {paramId: value, ...}
|
||||||
|
*/
|
||||||
|
function collectParams(containerSelector) {
|
||||||
|
containerSelector = containerSelector || '#params-form';
|
||||||
|
const pp = {}; // результат: {numeric_id: value}
|
||||||
|
const nested = {}; // временно: {parentId: {subKey: value}} для mapchild
|
||||||
|
const container = document.querySelector(containerSelector);
|
||||||
|
if (!container) return pp;
|
||||||
|
|
||||||
|
// Перебираем ВСЕ элементы с name^="p_"
|
||||||
|
container.querySelectorAll('[name^="p_"]').forEach(el => {
|
||||||
|
let v = el.value;
|
||||||
|
const dt = el.dataset.type || '';
|
||||||
|
|
||||||
|
if (dt === 'mapchild') {
|
||||||
|
// Вложенный параметр: имя вида "p_198.host"
|
||||||
|
// Извлекаем parentId="198", subKey="host"
|
||||||
|
const name = el.name.replace('p_', '');
|
||||||
|
const dot = name.indexOf('.');
|
||||||
|
const parentKey = name.substring(0, dot);
|
||||||
|
const subKey = name.substring(dot + 1);
|
||||||
|
if (!nested[parentKey]) nested[parentKey] = {};
|
||||||
|
nested[parentKey][subKey] = v;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Тип array: оборачиваем в JSON-массив
|
||||||
|
if (dt === 'array' || dt.startsWith('array')) v = JSON.stringify([v]);
|
||||||
|
// Тип map: пустое значение → '{}'
|
||||||
|
if (dt === 'map' || dt === 'map-fixed') v = v || '{}';
|
||||||
|
|
||||||
|
pp[el.name.replace('p_', '')] = v;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Сериализовать вложенные mapchild в JSON-строки
|
||||||
|
for (const pk in nested) pp[pk] = JSON.stringify(nested[pk]);
|
||||||
|
|
||||||
|
return pp;
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// scenario-create.js — создание и клонирование сценариев
|
||||||
|
//
|
||||||
|
// createScenario() — открыть редактор для нового сценария
|
||||||
|
// cloneScenario(id) — клонировать существующий (prompt имени → POST /api/scenario/definitions)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Создать новый сценарий — открывает модальный редактор.
|
||||||
|
*/
|
||||||
|
async function createScenario() {
|
||||||
|
showScenarioEditor(null); // null = новый, без определения
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Клонировать существующий сценарий.
|
||||||
|
*
|
||||||
|
* Алгоритм:
|
||||||
|
* 1. prompt — спросить имя для копии
|
||||||
|
* 2. GET /api/scenario/definitions/{id} — загрузить исходные steps
|
||||||
|
* 3. POST /api/scenario/definitions — создать копию с новым именем
|
||||||
|
* 4. Обновить список сценариев
|
||||||
|
*
|
||||||
|
* @param {number} defId — ID исходного сценария
|
||||||
|
* @param {string} origName — имя исходного (для prompt)
|
||||||
|
*/
|
||||||
|
async function cloneScenario(defId, origName) {
|
||||||
|
const newName = prompt('Имя нового сценария (копия «' + origName + '»):', origName + '_copy');
|
||||||
|
if (!newName) return;
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/scenario/definitions/' + defId);
|
||||||
|
const def = await r.json();
|
||||||
|
if (def.error) { alert(def.error); return; }
|
||||||
|
const rr = await fetch('/api/scenario/definitions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: newName, steps: def.steps })
|
||||||
|
});
|
||||||
|
const d = await rr.json();
|
||||||
|
if (d.error) { alert(d.error); return; }
|
||||||
|
await loadScenarios(); // обновить список
|
||||||
|
} catch (e) {
|
||||||
|
alert('Ошибка клонирования: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// scenario-delete.js — удаление сценария (мягкое, is_active=false)
|
||||||
|
//
|
||||||
|
// deleteScenario(id, name) — confirm → DELETE /api/scenario/definitions/{id} → обновить список
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Удалить сценарий — мягкое удаление (is_active = FALSE).
|
||||||
|
*
|
||||||
|
* @param {number} defId — ID сценария
|
||||||
|
* @param {string} name — имя сценария (для confirm-сообщения)
|
||||||
|
*/
|
||||||
|
async function deleteScenario(defId, name) {
|
||||||
|
if (!confirm('Удалить сценарий «' + name + '»?')) return;
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/scenario/definitions/' + defId, { method: 'DELETE' });
|
||||||
|
const d = await r.json();
|
||||||
|
if (d.error) { alert(d.error); return; }
|
||||||
|
await loadScenarios(); // обновить список
|
||||||
|
} catch (e) {
|
||||||
|
alert('Ошибка: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// scenario-edit.js — редактирование существующего сценария
|
||||||
|
//
|
||||||
|
// editScenario(id) — загрузить определение и открыть редактор
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Открыть редактор для существующего сценария.
|
||||||
|
*
|
||||||
|
* Алгоритм:
|
||||||
|
* 1. GET /api/scenario/definitions/{id} — загрузить определение
|
||||||
|
* 2. showScenarioEditor(def) — открыть модал с заполненными полями
|
||||||
|
*
|
||||||
|
* @param {number} defId — ID сценария
|
||||||
|
*/
|
||||||
|
async function editScenario(defId) {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/scenario/definitions/' + defId);
|
||||||
|
const def = await r.json();
|
||||||
|
if (def.error) { alert(def.error); return; }
|
||||||
|
showScenarioEditor(def);
|
||||||
|
} catch (e) {
|
||||||
|
alert('Ошибка загрузки: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,570 @@
|
|||||||
|
// scenario-form.js — модальный редактор сценариев
|
||||||
|
//
|
||||||
|
// Это САМЫЙ СЛОЖНЫЙ модуль фронтенда (~358 строк).
|
||||||
|
// Редактор позволяет создавать/редактировать сценарии через модальное окно.
|
||||||
|
//
|
||||||
|
// СОСТОЯНИЕ РЕДАКТОРА: scenarioEditorState = {
|
||||||
|
// defId, version, name, steps: [{service_id, operation, output, instance_ref, instance_uid, params, _svcOpId}],
|
||||||
|
// services: [], cloudInstances: [], opsCache: {}, paramDefsCache: {}
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// ЖИЗНЕННЫЙ ЦИКЛ РЕДАКТИРОВАНИЯ:
|
||||||
|
// 1. showScenarioEditor(def) — инициализация, загрузка сервисов/инстансов, renderEditor()
|
||||||
|
// 2. renderEditor() — отрисовка модала с шагами
|
||||||
|
// 3. Пользователь меняет операцию → onOpChange → renderEditor (перерисовка)
|
||||||
|
// 4. Для каждого шага с операцией — loadStepParams (GET /api/params/{opId})
|
||||||
|
// 5. Параметры рендерятся через params-render.js
|
||||||
|
// 6. Перед перерисовкой — snapshotAllParams (сохранить несохранённые правки)
|
||||||
|
// 7. Сохранение — collectFormSteps → POST/PUT /api/scenario/definitions
|
||||||
|
//
|
||||||
|
// ОБРАТНЫЙ МАППИНГ ПАРАМЕТРОВ:
|
||||||
|
// В форме параметры хранятся с числовыми ID (как и в API).
|
||||||
|
// В БД сценария — с символьными именами.
|
||||||
|
// При сохранении: numeric_id → name (через paramDefsCache)
|
||||||
|
// При загрузке: name → numeric_id (подстановка в defaultValue)
|
||||||
|
|
||||||
|
let scenarioEditorState = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Открыть редактор сценария.
|
||||||
|
*
|
||||||
|
* @param {Object|null} def — определение сценария или null (новый)
|
||||||
|
*/
|
||||||
|
async function showScenarioEditor(def) {
|
||||||
|
// Инициализация состояния
|
||||||
|
scenarioEditorState = def ? {
|
||||||
|
defId: def.id, version: def.version, name: def.name,
|
||||||
|
steps: (def.steps || []).map(s => ({
|
||||||
|
service_id: s.service_id || '', operation: s.operation || '',
|
||||||
|
output: s.output || '', instance_ref: s.instance_ref || '',
|
||||||
|
instance_uid: s.instance_uid || '',
|
||||||
|
params: s.params || {} // {name: value} — символьные имена
|
||||||
|
})),
|
||||||
|
services: [], cloudInstances: [], opsCache: {}, paramDefsCache: {}
|
||||||
|
} : {
|
||||||
|
defId: null, version: 1, name: '', steps: [],
|
||||||
|
services: [], cloudInstances: [], opsCache: {}, paramDefsCache: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Загрузить справочные данные: сервисы + инстансы (для select'ов)
|
||||||
|
try {
|
||||||
|
const [svcR, instR] = await Promise.all([
|
||||||
|
fetch('/api/services').then(r => r.json()),
|
||||||
|
fetch('/api/instances/list').then(r => r.json())
|
||||||
|
]);
|
||||||
|
scenarioEditorState.services = svcR || [];
|
||||||
|
scenarioEditorState.cloudInstances = instR || [];
|
||||||
|
} catch (e) {
|
||||||
|
scenarioEditorState.services = [];
|
||||||
|
scenarioEditorState.cloudInstances = [];
|
||||||
|
}
|
||||||
|
renderEditor();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Открыть модальное окно с HTML-контентом.
|
||||||
|
*
|
||||||
|
* @param {string} html — HTML-контент модала
|
||||||
|
*/
|
||||||
|
function openModal(html) {
|
||||||
|
let modal = document.getElementById('scenario-modal');
|
||||||
|
if (!modal) {
|
||||||
|
// Создать модал при первом вызове
|
||||||
|
modal = document.createElement('div');
|
||||||
|
modal.id = 'scenario-modal';
|
||||||
|
// backdrop — закрытие по клику вне модала
|
||||||
|
modal.innerHTML = '<div class="modal-backdrop" onclick="closeModal()"></div><div class="modal-content" id="modal-content"></div>';
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
}
|
||||||
|
modal.style.display = 'block';
|
||||||
|
document.getElementById('modal-content').innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Закрыть модальное окно и обновить список сценариев.
|
||||||
|
*/
|
||||||
|
function closeModal() {
|
||||||
|
const modal = document.getElementById('scenario-modal');
|
||||||
|
if (modal) modal.style.display = 'none';
|
||||||
|
loadScenarios(); // обновить список (могли измениться)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
// Снапшот параметров — защита от потери правок
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сохранить параметры ОДНОГО шага перед перерисовкой.
|
||||||
|
*
|
||||||
|
* Зачем: renderEditor() пересоздаёт ВЕСЬ HTML → все несохранённые правки теряются.
|
||||||
|
* Эта функция собирает текущие значения из DOM и сохраняет в step.params.
|
||||||
|
*
|
||||||
|
* @param {number} idx — индекс шага
|
||||||
|
*/
|
||||||
|
function snapshotStepParams(idx) {
|
||||||
|
const st = scenarioEditorState;
|
||||||
|
const container = document.getElementById('step-' + idx + '-params');
|
||||||
|
if (!container || !container.innerHTML) return;
|
||||||
|
|
||||||
|
// Собрать значения из формы (числовые ID → значение)
|
||||||
|
const numericParams = collectParams('#step-' + idx + '-params');
|
||||||
|
const defs = st.paramDefsCache[st.steps[idx]._svcOpId];
|
||||||
|
if (!defs) return;
|
||||||
|
|
||||||
|
// Обратный маппинг: numeric_id → name
|
||||||
|
const idToName = {};
|
||||||
|
defs.forEach(p => { idToName[p.svcOperationCfsParamId] = p.name; });
|
||||||
|
|
||||||
|
const result = {};
|
||||||
|
for (const [id, val] of Object.entries(numericParams)) {
|
||||||
|
const name = idToName[parseInt(id)] || id;
|
||||||
|
// Сохраняем только НЕ-дефолтные значения
|
||||||
|
if (val !== '' && val !== '{}' && val !== '[]' && val !== 'false') {
|
||||||
|
result[name] = val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
st.steps[idx].params = result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Снапшот ВСЕХ шагов — вызывается перед ЛЮБОЙ перерисовкой.
|
||||||
|
*/
|
||||||
|
function snapshotAllParams() {
|
||||||
|
if (!scenarioEditorState) return;
|
||||||
|
scenarioEditorState.steps.forEach((_, idx) => snapshotStepParams(idx));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
// Резолв операции + загрузка параметров
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Найти svcOperationId для операции в шаге.
|
||||||
|
*
|
||||||
|
* Для create: использует service_id шага.
|
||||||
|
* Для не-create: определяет service_id через выбранный инстанс
|
||||||
|
* (облачный UUID → serviceId, или output-ref → service_id предыдущего create-шага).
|
||||||
|
*
|
||||||
|
* @param {number} idx — индекс шага
|
||||||
|
* @returns {number|null} svcOperationId или null
|
||||||
|
*/
|
||||||
|
async function resolveStepSvcOpId(idx) {
|
||||||
|
const st = scenarioEditorState;
|
||||||
|
const step = st.steps[idx];
|
||||||
|
const op = step.operation;
|
||||||
|
if (!op) return null;
|
||||||
|
|
||||||
|
let svcId = step.service_id;
|
||||||
|
|
||||||
|
if (op !== 'create') {
|
||||||
|
// Для не-create: определяем svcId через выбранный инстанс
|
||||||
|
const refEl = document.getElementById('step-' + idx + '-ref');
|
||||||
|
const ref = (refEl?.value || '').trim();
|
||||||
|
if (ref) {
|
||||||
|
// Проверяем: это облачный UUID?
|
||||||
|
const cloud = (st.cloudInstances || []).find(i => i.instanceUid === ref);
|
||||||
|
if (cloud) {
|
||||||
|
svcId = cloud.serviceId;
|
||||||
|
} else {
|
||||||
|
// Это output-ref — ищем в предыдущих create-шагах
|
||||||
|
for (let i = 0; i < idx; i++) {
|
||||||
|
if (st.steps[i].output === ref) {
|
||||||
|
svcId = st.steps[i].service_id;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!svcId) return null;
|
||||||
|
|
||||||
|
// Кеш операций сервиса
|
||||||
|
if (!st.opsCache[svcId]) {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/operations/' + svcId);
|
||||||
|
const d = await r.json();
|
||||||
|
st.opsCache[svcId] = d.operations || [];
|
||||||
|
} catch (e) { return null; }
|
||||||
|
}
|
||||||
|
const ops = st.opsCache[svcId];
|
||||||
|
const found = ops.find(o => o.operation === op);
|
||||||
|
return found ? found.svcOperationId : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Загрузить и отрисовать параметры для шага.
|
||||||
|
*
|
||||||
|
* Алгоритм:
|
||||||
|
* 1. resolveStepSvcOpId — найти ID операции
|
||||||
|
* 2. GET /api/params/{svcOpId} — шаблон параметров
|
||||||
|
* 3. Подставить сохранённые значения (step.params) в defaultValue
|
||||||
|
* 4. Отрисовать через renderParamRow
|
||||||
|
*
|
||||||
|
* @param {number} idx — индекс шага
|
||||||
|
*/
|
||||||
|
async function loadStepParams(idx) {
|
||||||
|
const st = scenarioEditorState;
|
||||||
|
const step = st.steps[idx];
|
||||||
|
if (!step.operation) return;
|
||||||
|
|
||||||
|
const svcOpId = await resolveStepSvcOpId(idx);
|
||||||
|
if (!svcOpId) return;
|
||||||
|
step._svcOpId = svcOpId; // сохраняем для обратного маппинга при сохранении
|
||||||
|
|
||||||
|
// Кеш параметров
|
||||||
|
if (!st.paramDefsCache[svcOpId]) {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/params/' + svcOpId);
|
||||||
|
const d = await r.json();
|
||||||
|
st.paramDefsCache[svcOpId] = d.params || d || [];
|
||||||
|
} catch (e) { return; }
|
||||||
|
}
|
||||||
|
const defs = st.paramDefsCache[svcOpId];
|
||||||
|
const savedParams = step.params || {};
|
||||||
|
|
||||||
|
// Подготовить allInst для refSvcId-параметров
|
||||||
|
let allInst = st.cloudInstances || [];
|
||||||
|
const needRefs = defs.some(p => p.refSvcId);
|
||||||
|
if (needRefs && !allInst.length) {
|
||||||
|
try { const r = await fetch('/api/instances/list'); allInst = await r.json(); } catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Рендер: клонируем def и подставляем сохранённые значения в defaultValue
|
||||||
|
const container = document.getElementById('step-' + idx + '-params');
|
||||||
|
if (!container) return;
|
||||||
|
let html = '';
|
||||||
|
defs.forEach(p => {
|
||||||
|
const clone = Object.assign({}, p); // shallow copy
|
||||||
|
const savedVal = savedParams[p.name]; // ищем по символическому имени
|
||||||
|
if (savedVal !== undefined) clone.defaultValue = savedVal;
|
||||||
|
html += renderParamRow(clone, allInst);
|
||||||
|
});
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
// Рендер редактора
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Главная функция рендера — перерисовывает ВЕСЬ модал.
|
||||||
|
*
|
||||||
|
* Вызывается при:
|
||||||
|
* - Открытии редактора
|
||||||
|
* - Изменении операции (onOpChange)
|
||||||
|
* - Добавлении/удалении/перемещении шагов
|
||||||
|
* - Переключении облачных инстансов
|
||||||
|
*/
|
||||||
|
function renderEditor() {
|
||||||
|
snapshotAllParams(); // сохранить несохранённые правки перед перерисовкой
|
||||||
|
const st = scenarioEditorState;
|
||||||
|
|
||||||
|
let html = '<div style="padding:20px;max-height:90vh;overflow-y:auto;">';
|
||||||
|
|
||||||
|
// Заголовок
|
||||||
|
html += '<h3 style="margin:0 0 12px;">' + (st.defId ? 'Редактирование' : 'Новый сценарий') + '</h3>';
|
||||||
|
|
||||||
|
// Поле названия
|
||||||
|
html += '<div style="margin-bottom:10px;"><label style="font-size:12px;font-weight:600;">Название</label><br>';
|
||||||
|
html += `<input type="text" id="scenario-name" value="${_esc(st.name)}" style="width:100%;padding:6px;font-size:13px;" placeholder="my_test">`;
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
// Справка «Как заполнять»
|
||||||
|
html += `<details style="margin-bottom:10px;font-size:11px;color:var(--muted);border:1px solid var(--brand-gray);border-radius:4px;padding:6px 8px;">
|
||||||
|
<summary style="cursor:pointer;font-weight:600;">📖 Как заполнять</summary>
|
||||||
|
<div style="margin-top:6px;line-height:1.6;">
|
||||||
|
<b>Шаг:</b> выбери <b>операцию</b> → укажи цель.<br>
|
||||||
|
<b>Create:</b> выбери сервис + задай <b>output</b> (имя, например <code>d1</code>).<br>
|
||||||
|
<b>Остальные:</b> выбери инстанс — 🆕 из create-шагов или ☁ из облака.<br>
|
||||||
|
<b>Параметры:</b> разверни «Параметры» — значения загружаются из API автоматически.<br>
|
||||||
|
<b>[↑][↓]</b> — порядок, <b>[✕]</b> — удалить.
|
||||||
|
</div>
|
||||||
|
</details>`;
|
||||||
|
|
||||||
|
// Шаги
|
||||||
|
html += '<div style="font-size:12px;font-weight:600;margin-bottom:6px;">Шаги</div>';
|
||||||
|
st.steps.forEach((s, idx) => { html += renderStepRow(idx, s); });
|
||||||
|
|
||||||
|
// Кнопка добавления шага
|
||||||
|
html += `<button class="btn btn-sm" style="margin-top:6px;" onclick="addStep()">+ Добавить шаг</button>`;
|
||||||
|
|
||||||
|
// Кнопки Сохранить / Отмена
|
||||||
|
html += '<div style="margin-top:14px;display:flex;gap:8px;">';
|
||||||
|
html += `<button class="btn btn-sm" style="background:var(--brand-primary);color:#fff;padding:6px 16px;" onclick="saveScenario()">Сохранить</button>`;
|
||||||
|
html += `<button class="btn btn-sm" style="padding:6px 16px;" onclick="closeModal()">Отмена</button>`;
|
||||||
|
html += '</div></div>';
|
||||||
|
|
||||||
|
openModal(html);
|
||||||
|
|
||||||
|
// Асинхронно загрузить параметры для шагов с выбранной операцией
|
||||||
|
st.steps.forEach((s, idx) => { if (s.operation) loadStepParams(idx); });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отрисовать ОДНУ строку шага в редакторе.
|
||||||
|
*
|
||||||
|
* @param {number} idx — индекс шага
|
||||||
|
* @param {Object} step — {service_id, operation, output, instance_ref, instance_uid, params}
|
||||||
|
* @returns {string} HTML
|
||||||
|
*/
|
||||||
|
function renderStepRow(idx, step) {
|
||||||
|
const st = scenarioEditorState;
|
||||||
|
const op = step.operation;
|
||||||
|
const out = step.output || '';
|
||||||
|
const ref = step.instance_ref || '';
|
||||||
|
|
||||||
|
// Собрать доступные output-ы из предыдущих create-шагов (для instance_ref select)
|
||||||
|
const prevOutputs = [];
|
||||||
|
for (let i = 0; i < idx; i++) {
|
||||||
|
const s = st.steps[i];
|
||||||
|
if (s.operation === 'create' && s.output) prevOutputs.push(s.output);
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = `<div style="border:1px solid var(--brand-gray);border-radius:6px;padding:8px;margin:6px 0;" id="step-${idx}">`;
|
||||||
|
|
||||||
|
// Заголовок шага: номер + кнопки [↑][↓][✕]
|
||||||
|
html += `<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;">`;
|
||||||
|
html += `<span style="font-size:11px;font-weight:600;">Шаг ${idx+1}</span>`;
|
||||||
|
// Кнопка вверх (кроме первого)
|
||||||
|
if (idx > 0) html += `<button class="btn btn-sm" style="font-size:9px;padding:0 4px;" onclick="moveStep(${idx},-1);renderEditor();">↑</button>`;
|
||||||
|
// Кнопка вниз (кроме последнего)
|
||||||
|
if (idx < st.steps.length - 1) html += `<button class="btn btn-sm" style="font-size:9px;padding:0 4px;" onclick="moveStep(${idx},1);renderEditor();">↓</button>`;
|
||||||
|
// Кнопка удаления
|
||||||
|
html += `<button class="btn btn-sm" style="font-size:9px;padding:0 4px;margin-left:auto;color:var(--destructive);" onclick="removeStep(${idx})">✕</button>`;
|
||||||
|
html += `</div>`;
|
||||||
|
|
||||||
|
// Выпадающий список операций
|
||||||
|
const allOps = ['create', 'delete', 'modify', 'suspend', 'resume', 'redeploy'];
|
||||||
|
let opOpts = '<option value="">— операция —</option>';
|
||||||
|
allOps.forEach(o => { opOpts += `<option value="${o}" ${o == op ? 'selected' : ''}>${o}</option>`; });
|
||||||
|
html += `<div style="margin-bottom:4px;"><select id="step-${idx}-op" onchange="onOpChange(${idx})" style="padding:4px;font-size:12px;">${opOpts}</select></div>`;
|
||||||
|
|
||||||
|
// Разное содержимое в зависимости от типа операции
|
||||||
|
if (op === 'create') {
|
||||||
|
// CREATE: выбор сервиса + output name
|
||||||
|
let svcOpts = '<option value="">— сервис —</option>';
|
||||||
|
st.services.forEach(s => {
|
||||||
|
svcOpts += `<option value="${s.svcId}" ${s.svcId == step.service_id ? 'selected' : ''}>${s.svcId}. ${_esc(s.svc)}</option>`;
|
||||||
|
});
|
||||||
|
html += `<div style="display:flex;gap:6px;align-items:center;"><select id="step-${idx}-svc" style="padding:4px;font-size:12px;">${svcOpts}</select></div>`;
|
||||||
|
html += `<div style="margin-top:4px;"><input type="text" id="step-${idx}-output" value="${_esc(out)}" placeholder="имя для ссылок (например: d1)" style="width:220px;padding:3px;font-size:11px;"></div>`;
|
||||||
|
|
||||||
|
} else if (op) {
|
||||||
|
// НЕ-CREATE: выбор инстанса (из create-шагов + облако)
|
||||||
|
let refOpts = '<option value="">— инстанс —</option>';
|
||||||
|
// Инстансы из предыдущих create-шагов
|
||||||
|
prevOutputs.forEach(o => {
|
||||||
|
refOpts += `<option value="${o}" ${o == ref ? 'selected' : ''}>🆕 ${_esc(o)} (из шага)</option>`;
|
||||||
|
});
|
||||||
|
// Облачные autotest-* инстансы (только если раскрыты)
|
||||||
|
if (step._showCloud && st.cloudInstances) {
|
||||||
|
refOpts += '<option disabled>── облако ──</option>';
|
||||||
|
st.cloudInstances.forEach(i => {
|
||||||
|
const dn = i.displayName || '';
|
||||||
|
// Только autotest-*, не deleted
|
||||||
|
if (!dn.startsWith('autotest-') || i.explainedStatus === 'deleted') return;
|
||||||
|
const sel = i.instanceUid == ref ? 'selected' : '';
|
||||||
|
refOpts += `<option value="${i.instanceUid}" ${sel}>☁ ${_esc(dn)} — ${_esc(i.svc||'')} (${_esc(i.explainedStatus||'?')})</option>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const btnLabel = step._showCloud ? '➖ Скрыть облако' : '📥 Из облака';
|
||||||
|
html += `<div style="display:flex;gap:4px;align-items:center;"><select id="step-${idx}-ref" style="padding:4px;font-size:12px;max-width:500px;">${refOpts}</select>`;
|
||||||
|
html += `<button class="btn btn-sm" style="font-size:10px;padding:0 6px;" onclick="toggleCloudInstances(${idx})">${btnLabel}</button></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Параметры — свёрнутый <details>, заполняется асинхронно в loadStepParams()
|
||||||
|
html += `<details style="margin-top:4px;" id="step-${idx}-params-box"><summary style="font-size:11px;cursor:pointer;">Параметры</summary><div id="step-${idx}-params" style="margin-top:4px;"></div></details>`;
|
||||||
|
|
||||||
|
html += '</div>';
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
// Действия редактора
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Изменение операции в выпадающем списке → сохранить выбор + сброс + перерисовка.
|
||||||
|
*
|
||||||
|
* ВАЖНО: сначала читаем новое значение из DOM-селекта и сохраняем в step.operation,
|
||||||
|
* иначе renderEditor() перерисует старый селект (предыдущее значение).
|
||||||
|
*/
|
||||||
|
function onOpChange(idx) {
|
||||||
|
// Считать новое значение из DOM ДО перерисовки
|
||||||
|
const opEl = document.getElementById('step-' + idx + '-op');
|
||||||
|
const newOp = (opEl?.value || '').trim();
|
||||||
|
|
||||||
|
const step = scenarioEditorState.steps[idx];
|
||||||
|
step.operation = newOp; // сохранить выбранную операцию
|
||||||
|
step.params = {}; // сброс параметров (для новой операции — другие)
|
||||||
|
step._svcOpId = null; // сброс кеша svcOperationId
|
||||||
|
|
||||||
|
// Для не-create: автоматически показать облачные инстансы
|
||||||
|
if (newOp && newOp !== 'create') {
|
||||||
|
step._showCloud = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderEditor();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Переключить показ облачных инстансов в select'е.
|
||||||
|
*/
|
||||||
|
function toggleCloudInstances(idx) {
|
||||||
|
snapshotAllParams();
|
||||||
|
scenarioEditorState.steps[idx]._showCloud = !scenarioEditorState.steps[idx]._showCloud;
|
||||||
|
renderEditor();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Добавить новый (пустой) шаг.
|
||||||
|
*/
|
||||||
|
function addStep() {
|
||||||
|
snapshotAllParams();
|
||||||
|
scenarioEditorState.steps.push({
|
||||||
|
service_id: '', operation: '', output: '', instance_ref: '',
|
||||||
|
instance_uid: '', params: {}, _showCloud: false
|
||||||
|
});
|
||||||
|
renderEditor();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Удалить шаг по индексу.
|
||||||
|
*/
|
||||||
|
function removeStep(idx) {
|
||||||
|
snapshotAllParams();
|
||||||
|
scenarioEditorState.steps.splice(idx, 1);
|
||||||
|
renderEditor();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Переместить шаг вверх/вниз.
|
||||||
|
*
|
||||||
|
* @param {number} idx — индекс шага
|
||||||
|
* @param {number} dir — -1 (вверх) или +1 (вниз)
|
||||||
|
*/
|
||||||
|
function moveStep(idx, dir) {
|
||||||
|
snapshotAllParams();
|
||||||
|
const steps = scenarioEditorState.steps;
|
||||||
|
const target = idx + dir;
|
||||||
|
if (target < 0 || target >= steps.length) return;
|
||||||
|
// Swap
|
||||||
|
[steps[idx], steps[target]] = [steps[target], steps[idx]];
|
||||||
|
renderEditor();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Собрать ВСЕ шаги из DOM для сохранения.
|
||||||
|
*
|
||||||
|
* Для каждого шага:
|
||||||
|
* - Извлечь service_id (из select для create, из инстанса для не-create)
|
||||||
|
* - Извлечь output/instance_ref/instance_uid
|
||||||
|
* - Собрать параметры через collectParams + обратный маппинг numeric→name
|
||||||
|
*
|
||||||
|
* @returns {{name: string, steps: Array}}
|
||||||
|
*/
|
||||||
|
function collectFormSteps() {
|
||||||
|
snapshotAllParams(); // финальный снапшот
|
||||||
|
const st = scenarioEditorState;
|
||||||
|
const name = (document.getElementById('scenario-name')?.value || '').trim();
|
||||||
|
const steps = [];
|
||||||
|
|
||||||
|
st.steps.forEach((step, idx) => {
|
||||||
|
const opEl = document.getElementById('step-' + idx + '-op');
|
||||||
|
const operation = (opEl?.value || '').trim();
|
||||||
|
let svcId = 0;
|
||||||
|
const s = { operation, params: {} };
|
||||||
|
|
||||||
|
if (operation === 'create') {
|
||||||
|
// CREATE: service_id из select
|
||||||
|
const svcEl = document.getElementById('step-' + idx + '-svc');
|
||||||
|
svcId = parseInt(svcEl?.value) || 0;
|
||||||
|
const outEl = document.getElementById('step-' + idx + '-output');
|
||||||
|
const output = (outEl?.value || '').trim();
|
||||||
|
if (output) s.output = output;
|
||||||
|
} else {
|
||||||
|
// НЕ-CREATE: определяем instance_ref или instance_uid
|
||||||
|
const refEl = document.getElementById('step-' + idx + '-ref');
|
||||||
|
const ref = (refEl?.value || '').trim();
|
||||||
|
if (ref) {
|
||||||
|
const cloud = (st.cloudInstances || []).find(i => i.instanceUid === ref);
|
||||||
|
if (cloud) {
|
||||||
|
svcId = cloud.serviceId;
|
||||||
|
s.instance_uid = ref; // облачный UUID
|
||||||
|
} else {
|
||||||
|
s.instance_ref = ref; // output-ссылка
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.service_id = svcId;
|
||||||
|
|
||||||
|
// Собрать параметры через collectParams + обратный маппинг numeric→name
|
||||||
|
const defs = st.paramDefsCache[step._svcOpId];
|
||||||
|
if (defs) {
|
||||||
|
const numericParams = collectParams('#step-' + idx + '-params');
|
||||||
|
const idToName = {};
|
||||||
|
defs.forEach(p => { idToName[p.svcOperationCfsParamId] = p.name; });
|
||||||
|
for (const [id, val] of Object.entries(numericParams)) {
|
||||||
|
const pname = idToName[parseInt(id)] || id;
|
||||||
|
// Сохраняем только НЕ-дефолтные значения
|
||||||
|
if (val !== '' && val !== '{}' && val !== '[]' && val !== 'false') {
|
||||||
|
s.params[pname] = val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
steps.push(s);
|
||||||
|
});
|
||||||
|
return { name, steps };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сохранить сценарий — POST (новый) или PUT (существующий).
|
||||||
|
*
|
||||||
|
* Валидация перед отправкой:
|
||||||
|
* - Название не пустое
|
||||||
|
* - Хотя бы один шаг
|
||||||
|
* - У каждого шага выбран сервис/инстанс и операция
|
||||||
|
*/
|
||||||
|
async function saveScenario() {
|
||||||
|
const { name, steps } = collectFormSteps();
|
||||||
|
|
||||||
|
// Валидация
|
||||||
|
if (!name) { alert('Введите название'); return; }
|
||||||
|
if (!steps.length) { alert('Добавьте хотя бы один шаг'); return; }
|
||||||
|
for (let i = 0; i < steps.length; i++) {
|
||||||
|
if (!steps[i].service_id) { alert('Шаг ' + (i+1) + ': выберите сервис или инстанс'); return; }
|
||||||
|
if (!steps[i].operation) { alert('Шаг ' + (i+1) + ': выберите операцию'); return; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const st = scenarioEditorState;
|
||||||
|
const isNew = !st.defId;
|
||||||
|
const url = isNew ? '/api/scenario/definitions' : '/api/scenario/definitions/' + st.defId;
|
||||||
|
const method = isNew ? 'POST' : 'PUT';
|
||||||
|
// Для PUT нужна version (оптимистичная блокировка)
|
||||||
|
const body = isNew ? { name, steps } : { name, steps, version: st.version };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const r = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
const d = await r.json();
|
||||||
|
|
||||||
|
// 409 Conflict — кто-то уже изменил
|
||||||
|
if (r.status === 409) {
|
||||||
|
alert('⚠️ Конфликт версий — кто-то уже изменил сценарий. Обновите страницу.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (d.error) { alert(d.error); return; }
|
||||||
|
|
||||||
|
// Обновить состояние (id и version могли измениться)
|
||||||
|
if (isNew && d.id) st.defId = d.id;
|
||||||
|
if (d.version) st.version = d.version;
|
||||||
|
|
||||||
|
closeModal();
|
||||||
|
} catch (e) {
|
||||||
|
alert('Ошибка сохранения: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
// scenario-list.js — список сценариев, запуск, поллинг
|
||||||
|
//
|
||||||
|
// Отвечает за:
|
||||||
|
// - Загрузку списка сценариев из GET /api/scenarios
|
||||||
|
// - Раскрытие шагов сценария (GET /api/scenario/definitions/{id})
|
||||||
|
// - Запуск сценария (POST /api/scenario/run)
|
||||||
|
// - Поллинг статуса запуска (GET /api/scenario/run/{run_id})
|
||||||
|
//
|
||||||
|
// Цветовая маркировка: RUNNING → жёлтый фон (#fef3c7)
|
||||||
|
|
||||||
|
let scenarioOpen=false, scenarioPollTimer=null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Переключить панель сценариев (открыть/закрыть).
|
||||||
|
* При открытии — загружает список + последний запуск.
|
||||||
|
*/
|
||||||
|
async function toggleScenario(){
|
||||||
|
const body=document.getElementById('scenario-body');
|
||||||
|
scenarioOpen=!scenarioOpen;
|
||||||
|
body.style.display=scenarioOpen?'block':'none';
|
||||||
|
if(!scenarioOpen){ stopScenarioPoll(); return; }
|
||||||
|
await loadScenarios();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Загрузить список сценариев + последний запуск.
|
||||||
|
*
|
||||||
|
* Два параллельных запроса:
|
||||||
|
* GET /api/scenarios — список определений [{id, name, steps}, ...]
|
||||||
|
* GET /api/scenario/status — последние 10 запусков [{scenario_name, status, ...}, ...]
|
||||||
|
*/
|
||||||
|
async function loadScenarios(){
|
||||||
|
const body=document.getElementById('scenario-body');
|
||||||
|
try{
|
||||||
|
const [sr, srHistory]=await Promise.all([
|
||||||
|
fetch('/api/scenarios').then(r=>r.json()),
|
||||||
|
fetch('/api/scenario/status').then(r=>r.json()),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let html='';
|
||||||
|
|
||||||
|
// Кнопка создания нового сценария
|
||||||
|
html+=`<button class="btn btn-sm" style="font-size:11px;margin-bottom:4px;" onclick="createScenario()">+ Создать сценарий</button>`;
|
||||||
|
|
||||||
|
if(sr.length){
|
||||||
|
// Список сценариев — каждый кликабелен
|
||||||
|
html+='<div style="display:flex;flex-direction:column;gap:4px;margin-bottom:4px;">';
|
||||||
|
sr.forEach(s=>{
|
||||||
|
const seed = s.is_seed; // seed-сценарий (из YAML) — нельзя удалить
|
||||||
|
html+=`<div style="border:1px solid var(--brand-gray);border-radius:4px;padding:4px 6px;">`;
|
||||||
|
// Заголовок сценария — клик раскрывает шаги
|
||||||
|
html+=`<div style="display:flex;align-items:center;gap:8px;cursor:pointer;padding:4px 0;" onclick="toggleScenarioSteps(${s.id})">`;
|
||||||
|
html+=`<span style="font-size:13px;font-weight:600;flex:1;">${_esc(s.name)}</span>`;
|
||||||
|
html+=`<span style="color:var(--muted);font-size:11px;min-width:50px;">${s.steps} шаг.</span>`;
|
||||||
|
if(seed) html+=`<span style="font-size:9px;color:var(--muted);">seed</span>`;
|
||||||
|
html+=`</div>`;
|
||||||
|
// Контейнер для раскрытых шагов (изначально скрыт)
|
||||||
|
html+=`<div id="scenario-steps-${s.id}" style="display:none;margin-top:4px;padding-left:8px;"></div>`;
|
||||||
|
html+=`</div>`;
|
||||||
|
});
|
||||||
|
html+='</div>';
|
||||||
|
}else{
|
||||||
|
html+='<span style="color:var(--muted);font-size:11px;">Нет сценариев в БД</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Последний запуск — фильтруем по текущей версии приложения
|
||||||
|
const curVer=window.APP&&window.APP.version||'';
|
||||||
|
const verHistory=srHistory&&srHistory.filter(r=>r.app_version===curVer);
|
||||||
|
if(verHistory && verHistory.length){
|
||||||
|
const last=verHistory[0];
|
||||||
|
const icon=last.status==='OK'?'✅':last.status==='FAIL'?'❌':last.status==='RUNNING'?'⏳':'⏱';
|
||||||
|
const time=last.created_at?last.created_at.replace('T',' ').substring(0,19):'?';
|
||||||
|
// RUNNING → жёлтый фон
|
||||||
|
const runningStyle = last.status==='RUNNING' ? 'background:#fef3c7;padding:2px 4px;border-radius:3px;' : '';
|
||||||
|
html+=`<div style="font-size:11px;margin-top:4px;color:var(--muted);${runningStyle}">Последний: ${icon} ${_esc(last.scenario_name)} — ${last.status} (шаг ${last.current_step}/${last.total_steps}) ${last.duration_sec!=null?last.duration_sec.toFixed(1)+'s':''} — ${time}</div>`;
|
||||||
|
if(last.error_log) html+=`<div style="font-size:10px;color:var(--destructive);">${_esc(last.error_log)}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.innerHTML=html;
|
||||||
|
}catch(e){
|
||||||
|
body.innerHTML='<span style="color:var(--destructive);font-size:11px;">Ошибка загрузки</span>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Раскрыть шаги сценария — загрузить полное определение.
|
||||||
|
*
|
||||||
|
* Показывает:
|
||||||
|
* - Список шагов: операция → сервис + параметры
|
||||||
|
* - Кнопки: ▶ Запустить, ✏ Редактировать, 📋 Копировать, 🗑 Удалить
|
||||||
|
*
|
||||||
|
* Закрывает другие раскрытые сценарии (только один открыт одновременно).
|
||||||
|
*
|
||||||
|
* @param {number} defId — ID сценария
|
||||||
|
*/
|
||||||
|
async function toggleScenarioSteps(defId) {
|
||||||
|
const el = document.getElementById('scenario-steps-' + defId);
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
// Если уже открыт — закрыть (toggle)
|
||||||
|
if (el.style.display === 'block') { el.style.display = 'none'; return; }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/scenario/definitions/' + defId);
|
||||||
|
const def = await r.json();
|
||||||
|
if (def.error) return;
|
||||||
|
|
||||||
|
const steps = def.steps || [];
|
||||||
|
let html = '<div style="font-size:11px;padding:4px 0;">';
|
||||||
|
|
||||||
|
// Перечисляем шаги
|
||||||
|
steps.forEach((s, i) => {
|
||||||
|
const svcName = s.service_id ? 'сервис ' + s.service_id : '?';
|
||||||
|
html += `<div style="padding:2px 0;">${i+1}. <b>${_esc(s.operation)}</b> → ${svcName}`;
|
||||||
|
if (s.output) html += ` <span style="color:var(--muted);">(${_esc(s.output)})</span>`;
|
||||||
|
if (s.instance_ref) html += ` <span style="color:var(--muted);">→ ${_esc(s.instance_ref)}</span>`;
|
||||||
|
if (s.instance_uid) html += ` <span style="color:var(--muted);">→ ${_esc(s.instance_uid.substring(0,8))}...</span>`;
|
||||||
|
const params = Object.entries(s.params || {});
|
||||||
|
if (params.length) html += ' <span style="color:var(--muted);">(' + params.map(([k,v]) => k+'='+v).join(', ') + ')</span>';
|
||||||
|
html += '</div>';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Кнопки действий
|
||||||
|
html += `<button class="btn btn-sm" style="margin-top:4px;font-size:11px;background:var(--brand-primary);color:#fff;" onclick="event.stopPropagation();runScenario(${defId})">▶ Запустить</button>`;
|
||||||
|
html += `<button class="btn btn-sm" style="margin-top:4px;margin-left:4px;font-size:11px;" onclick="event.stopPropagation();editScenario(${defId})">✏ Редактировать</button>`;
|
||||||
|
html += `<button class="btn btn-sm" style="margin-top:4px;margin-left:4px;font-size:11px;" onclick="event.stopPropagation();cloneScenario(${defId},'${_esc(def.name)}')">📋 Копировать</button>`;
|
||||||
|
// Удалить — только для НЕ-seed сценариев
|
||||||
|
if (!def.is_seed) html += `<button class="btn btn-sm" style="margin-top:4px;margin-left:4px;font-size:11px;color:var(--destructive);" onclick="event.stopPropagation();deleteScenario(${defId},'${_esc(def.name)}')">🗑 Удалить</button>`;
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
el.innerHTML = html;
|
||||||
|
el.style.display = 'block';
|
||||||
|
|
||||||
|
// Закрыть другие раскрытые сценарии
|
||||||
|
document.querySelectorAll('[id^="scenario-steps-"]').forEach(e => {
|
||||||
|
if (e !== el) e.style.display = 'none';
|
||||||
|
});
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Запустить сценарий.
|
||||||
|
*
|
||||||
|
* Алгоритм:
|
||||||
|
* 1. confirm — подтверждение
|
||||||
|
* 2. POST /api/scenario/run → {status: "RUNNING", run_id}
|
||||||
|
* 3. Поллинг каждые 3 секунды:
|
||||||
|
* GET /api/scenario/run/{run_id} → {status, current_step, total_steps, ...}
|
||||||
|
* 4. При OK/FAIL → остановить поллинг, обновить инстансы и историю
|
||||||
|
*
|
||||||
|
* @param {number} defId — ID определения сценария
|
||||||
|
*/
|
||||||
|
async function runScenario(defId){
|
||||||
|
if(busy){ return; } // другая операция уже идёт
|
||||||
|
if(!confirm('Запустить сценарий?')) return;
|
||||||
|
|
||||||
|
busy=true;
|
||||||
|
stopScenarioPoll();
|
||||||
|
|
||||||
|
const body=document.getElementById('scenario-body');
|
||||||
|
body.innerHTML=`<div style="font-size:11px;">⏳ Запуск...</div>`;
|
||||||
|
|
||||||
|
try{
|
||||||
|
const r=await fetch('/api/scenario/run',{
|
||||||
|
method:'POST',
|
||||||
|
headers:{'Content-Type':'application/json'},
|
||||||
|
body:JSON.stringify({definition_id:defId})
|
||||||
|
});
|
||||||
|
const d=await r.json();
|
||||||
|
|
||||||
|
if(d.error){
|
||||||
|
body.innerHTML=`<span style="color:var(--destructive);">Ошибка: ${_esc(d.error)}</span>`;
|
||||||
|
busy=false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const runId=d.run_id;
|
||||||
|
if(!runId){
|
||||||
|
body.innerHTML=`<span style="color:var(--destructive);">Ошибка: нет run_id в ответе</span>`;
|
||||||
|
busy=false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Поллинг статуса
|
||||||
|
scenarioPollTimer=setInterval(async()=>{
|
||||||
|
try{
|
||||||
|
const sr=await fetch('/api/scenario/run/'+runId);
|
||||||
|
if(!sr.ok){ return; }
|
||||||
|
const last=await sr.json();
|
||||||
|
if(!last||last.error) return;
|
||||||
|
|
||||||
|
const icon=last.status==='OK'?'✅':last.status==='FAIL'?'❌':last.status==='RUNNING'?'⏳':'⏱';
|
||||||
|
const time=last.created_at?last.created_at.replace('T',' ').substring(0,19):'?';
|
||||||
|
const runningBg = last.status==='RUNNING' ? 'background:#fef3c7;padding:2px 4px;border-radius:3px;' : '';
|
||||||
|
|
||||||
|
let html=`<div style="font-size:11px;${runningBg}">${icon} ${_esc(last.scenario_name)} — ${last.status} (шаг ${last.current_step}/${last.total_steps}) ${last.duration_sec!=null?last.duration_sec.toFixed(1)+'s':''} — ${time}</div>`;
|
||||||
|
if(last.error_log) html+=`<div style="font-size:10px;color:var(--destructive);">${_esc(last.error_log)}</div>`;
|
||||||
|
body.innerHTML=html;
|
||||||
|
|
||||||
|
if(last.status!=='RUNNING'){
|
||||||
|
stopScenarioPoll();
|
||||||
|
busy=false;
|
||||||
|
await refreshInstances(); // обновить список инстансов
|
||||||
|
if(historyOpen) await loadHistory(); // обновить историю
|
||||||
|
}
|
||||||
|
}catch(e){}
|
||||||
|
},3000);
|
||||||
|
}catch(e){
|
||||||
|
body.innerHTML=`<span style="color:var(--destructive);">Ошибка: ${_esc(e.message)}</span>`;
|
||||||
|
busy=false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Остановить поллинг сценария.
|
||||||
|
*/
|
||||||
|
function stopScenarioPoll(){
|
||||||
|
if(scenarioPollTimer){ clearInterval(scenarioPollTimer); scenarioPollTimer=null; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// snackbar.js — лёгкие toast-уведомления (без зависимостей)
|
||||||
|
//
|
||||||
|
// Использование: showSnackbar('Сообщение', 'success')
|
||||||
|
// Типы: 'info' (по умолчанию), 'success', 'error'
|
||||||
|
//
|
||||||
|
// Поведение:
|
||||||
|
// - Показывается в правом нижнем углу (position: fixed)
|
||||||
|
// - Автоматически исчезает: info/success через 4с, error через 8с
|
||||||
|
// - Клик по уведомлению — мгновенное закрытие
|
||||||
|
// - Максимум 4 уведомления одновременно (старые удаляются)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Показать snackbar-уведомление.
|
||||||
|
*
|
||||||
|
* @param {string} msg — текст уведомления
|
||||||
|
* @param {string} type — 'info' | 'success' | 'error' (по умолчанию 'info')
|
||||||
|
*/
|
||||||
|
function showSnackbar(msg, type) {
|
||||||
|
type = type || 'info';
|
||||||
|
|
||||||
|
// Найти или создать контейнер для всех уведомлений
|
||||||
|
let container = document.getElementById('snackbar-container');
|
||||||
|
if (!container) {
|
||||||
|
container = document.createElement('div');
|
||||||
|
container.id = 'snackbar-container';
|
||||||
|
container.className = 'snackbar-container';
|
||||||
|
container.setAttribute('role', 'status'); // accessibility
|
||||||
|
container.setAttribute('aria-live', 'polite'); // screen-reader зачитает
|
||||||
|
document.body.appendChild(container);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создать элемент уведомления
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'snackbar ' + type;
|
||||||
|
el.textContent = msg;
|
||||||
|
// Клик — закрыть
|
||||||
|
el.onclick = function() { el.remove(); };
|
||||||
|
container.appendChild(el);
|
||||||
|
|
||||||
|
// Ограничение: максимум 4 уведомления
|
||||||
|
const all = container.querySelectorAll('.snackbar');
|
||||||
|
if (all.length > 4) all[0].remove(); // удаляем самое старое
|
||||||
|
|
||||||
|
// Автоматическое скрытие: error — 8 секунд, остальные — 4
|
||||||
|
const delay = (type === 'error') ? 8000 : 4000;
|
||||||
|
setTimeout(function() {
|
||||||
|
if (el.parentNode) el.remove();
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
// utils.js — общие хелперы фронтенда
|
||||||
|
// Используется ВСЕМИ модулями (должен грузиться ПЕРВЫМ).
|
||||||
|
// Экспортирует (в глобальную область): _esc, validateJson, relativeTime,
|
||||||
|
// startLogPoll, toggleLog
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTML-экранирование — защита от XSS.
|
||||||
|
* Все данные из API/БД пропускаются через _esc() перед вставкой в innerHTML.
|
||||||
|
*
|
||||||
|
* @param {*} s — что угодно (приводится к строке через String())
|
||||||
|
* @returns {string} безопасная для HTML строка
|
||||||
|
*
|
||||||
|
* Заменяет:
|
||||||
|
* & → & (первым! иначе сломает уже заменённые сущности)
|
||||||
|
* " → " (атрибуты в кавычках)
|
||||||
|
* < → < (XSS-вектор: <script>)
|
||||||
|
*
|
||||||
|
* > и ' НЕ экранируем — в нашем контексте они безопасны,
|
||||||
|
* а лишние замены портят читаемость.
|
||||||
|
*/
|
||||||
|
function _esc(s){
|
||||||
|
return String(s||'').replace(/&/g,'&').replace(/"/g,'"').replace(/</g,'<');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверить что значение в поле — валидный JSON.
|
||||||
|
* Используется для map-параметров перед отправкой формы.
|
||||||
|
*
|
||||||
|
* @param {HTMLElement} el — input/textarea элемент
|
||||||
|
* @param {boolean} quiet — true = не менять внешний вид (batch-проверка)
|
||||||
|
* @returns {boolean} true если JSON валидный или поле пустое
|
||||||
|
*/
|
||||||
|
function validateJson(el,quiet){
|
||||||
|
// Ищем соседний span.json-err для показа ошибки
|
||||||
|
const errEl=el.parentElement.querySelector('.json-err');
|
||||||
|
const v=el.value.trim();
|
||||||
|
// Пустое поле — ок (не ошибка)
|
||||||
|
if(!v) return true;
|
||||||
|
try{ JSON.parse(v); }
|
||||||
|
catch(e){
|
||||||
|
// Показываем ошибку в span.json-err (если не quiet)
|
||||||
|
if(!quiet&&errEl){ errEl.style.display='inline'; errEl.textContent='Ошибка JSON: '+e.message; }
|
||||||
|
el.style.borderColor='var(--destructive)';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Всё ок — убираем ошибку
|
||||||
|
if(!quiet&&errEl) errEl.style.display='none';
|
||||||
|
el.style.borderColor='';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
// Панель логов — поллинг /api/log каждые 2 секунды
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
let logPollTimer=null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Запустить поллинг логов.
|
||||||
|
* Работает только если элемент #log-panel существует и видим.
|
||||||
|
* Безопасен для повторного вызова — не дублирует таймер.
|
||||||
|
*/
|
||||||
|
function startLogPoll(){
|
||||||
|
if(logPollTimer) return; // уже запущен
|
||||||
|
logPollTimer=setInterval(async()=>{
|
||||||
|
const el=document.getElementById('log-panel');
|
||||||
|
// Если панель скрыта — не грузим (экономия трафика)
|
||||||
|
if(!el||el.style.display==='none') return;
|
||||||
|
try{
|
||||||
|
const r=await fetch('/api/log');
|
||||||
|
const lines=await r.json();
|
||||||
|
// Каждая строка логирования в отдельном div, экранирована
|
||||||
|
el.innerHTML=lines.map(l=>`<div>${_esc(l)}</div>`).join('');
|
||||||
|
// Автопрокрутка вниз (следим за новыми записями)
|
||||||
|
el.scrollTop=el.scrollHeight;
|
||||||
|
}catch(e){}
|
||||||
|
},2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Переключить видимость лог-панели.
|
||||||
|
* При открытии — запускает поллинг.
|
||||||
|
*/
|
||||||
|
function toggleLog(){
|
||||||
|
const el=document.getElementById('log-panel');
|
||||||
|
if(!el) return;
|
||||||
|
if(el.style.display==='none'){ el.style.display='block'; startLogPoll(); }
|
||||||
|
else el.style.display='none';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Относительное время — "5м", "2ч 30м", "1д 3ч".
|
||||||
|
* Используется в списке инстансов (instances.js) и истории (history.js).
|
||||||
|
*
|
||||||
|
* @param {string} iso — ISO-8601 дата (напр. "2026-07-31T12:00:00")
|
||||||
|
* @returns {string} относительное время или "-" если невалидно
|
||||||
|
*/
|
||||||
|
function relativeTime(iso) {
|
||||||
|
if (!iso) return '-';
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (isNaN(d.getTime())) return '-';
|
||||||
|
const sec = Math.floor((Date.now() - d.getTime()) / 1000);
|
||||||
|
if (sec < 0) return 'только что'; // будущее (расхождение часов)
|
||||||
|
if (sec < 60) return sec + 'с';
|
||||||
|
const min = Math.floor(sec / 60);
|
||||||
|
if (min < 60) return min + 'м';
|
||||||
|
const hr = Math.floor(min / 60);
|
||||||
|
if (hr < 24) return hr + 'ч ' + (min % 60) + 'м';
|
||||||
|
const days = Math.floor(hr / 24);
|
||||||
|
return days + 'д ' + (hr % 24) + 'ч';
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
// views.js — переключение видов (Консоль / Обзор / История / Сценарии / Логи)
|
||||||
|
//
|
||||||
|
// Каждый вид — div.view с id="view-{name}".
|
||||||
|
// Активный вид — без класса .hidden.
|
||||||
|
// Все остальные — с классом .hidden (display: none).
|
||||||
|
//
|
||||||
|
// Переключение:
|
||||||
|
// switchView('overview') — Обзор (инфраструктура)
|
||||||
|
// switchView('console') — Консоль (ручной режим)
|
||||||
|
// switchView('history-view') — История (автозагрузка при открытии)
|
||||||
|
// switchView('scenarios-view') — Сценарии (автозагрузка)
|
||||||
|
// switchView('logs-view') — Логи (специальная обработка)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Переключиться на вид по его ID.
|
||||||
|
*
|
||||||
|
* @param {string} viewId — суффикс после "view-" (напр. "console" → #view-console)
|
||||||
|
*/
|
||||||
|
function switchView(viewId) {
|
||||||
|
// Скрыть ВСЕ виды
|
||||||
|
document.querySelectorAll('.view').forEach(v => v.classList.add('hidden'));
|
||||||
|
|
||||||
|
// Показать целевой вид
|
||||||
|
const target = document.getElementById('view-' + viewId);
|
||||||
|
if (target) target.classList.remove('hidden');
|
||||||
|
|
||||||
|
// Подсветка активного пункта в sidebar
|
||||||
|
document.querySelectorAll('.sidebar-item').forEach(el => el.classList.remove('active'));
|
||||||
|
const btn = document.querySelector('.sidebar-item[data-view="' + viewId + '"]');
|
||||||
|
if (btn) btn.classList.add('active');
|
||||||
|
|
||||||
|
// ── Автозагрузка при открытии вида ──
|
||||||
|
|
||||||
|
// История: автозагрузка только если ещё не загружена
|
||||||
|
if (viewId === 'history-view' && typeof toggleHistory === 'function') {
|
||||||
|
if (!historyOpen) toggleHistory();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сценарии: автозагрузка
|
||||||
|
if (viewId === 'scenarios-view' && typeof toggleScenario === 'function') {
|
||||||
|
if (!scenarioOpen) toggleScenario();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Логи: специальная обработка — копируем содержимое нижней панели в вид
|
||||||
|
if (viewId === 'logs-view') {
|
||||||
|
const lp = document.getElementById('log-panel');
|
||||||
|
const lvc = document.getElementById('log-view-content');
|
||||||
|
if (lp && lvc) {
|
||||||
|
// Копируем текущее содержимое лог-панели в вид
|
||||||
|
lvc.innerHTML = lp.innerHTML || '<span style="color:#888;">Логи загружаются...</span>';
|
||||||
|
lp.style.display = 'none'; // скрываем нижнюю панель
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// На всех видах кроме логов — скрываем нижнюю лог-панель
|
||||||
|
const lp = document.getElementById('log-panel');
|
||||||
|
if (lp) lp.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -150,3 +150,97 @@ tr:hover td {
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
border-color: var(--destructive);
|
border-color: var(--destructive);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* === Phase 0: Redesign Foundation === */
|
||||||
|
|
||||||
|
/* ── Sidebar ── */
|
||||||
|
.sidebar {
|
||||||
|
position: fixed; top: 0; left: 0; bottom: 0;
|
||||||
|
width: 192px; background: var(--background);
|
||||||
|
border-right: 1px solid var(--brand-gray);
|
||||||
|
display: flex; flex-direction: column; z-index: 100;
|
||||||
|
transition: width .2s;
|
||||||
|
}
|
||||||
|
.sidebar.collapsed { width: 72px; }
|
||||||
|
.sidebar-top {
|
||||||
|
height: 80px; border-top: 4px solid var(--brand-primary);
|
||||||
|
display: flex; align-items: center; padding: 0 16px;
|
||||||
|
}
|
||||||
|
.sidebar-nav { flex: 1; overflow-y: auto; padding: 8px 0; }
|
||||||
|
.sidebar-item {
|
||||||
|
display: flex; align-items: center; gap: 12px;
|
||||||
|
padding: 10px 16px; font-size: 14px; color: var(--foreground);
|
||||||
|
cursor: pointer; transition: background .15s;
|
||||||
|
border: none; background: none; width: 100%; text-align: left;
|
||||||
|
}
|
||||||
|
.sidebar-item:hover { background: var(--brand-grey-light); }
|
||||||
|
.sidebar-item.active { background: #dbeafe; font-weight: 600; }
|
||||||
|
.sidebar-item svg { width: 20px; height: 20px; flex-shrink: 0; }
|
||||||
|
.sidebar-bottom { padding: 8px 0; border-top: 1px solid var(--brand-gray); }
|
||||||
|
|
||||||
|
/* ── Views ── */
|
||||||
|
.view { margin-left: 192px; padding: 16px 20px; min-height: 100vh; }
|
||||||
|
.sidebar.collapsed ~ .view { margin-left: 72px; }
|
||||||
|
.view.hidden { display: none; }
|
||||||
|
.view-header { font-size: 20px; font-weight: 700; margin-bottom: 16px; }
|
||||||
|
|
||||||
|
/* ── Tabs ── */
|
||||||
|
.tabs { display: flex; gap: 0; border-bottom: 2px solid var(--brand-gray); margin-bottom: 12px; }
|
||||||
|
.tab {
|
||||||
|
padding: 8px 16px; font-size: 14px; color: var(--muted);
|
||||||
|
cursor: pointer; border: none; background: none;
|
||||||
|
border-bottom: 2px solid transparent; margin-bottom: -2px;
|
||||||
|
transition: color .15s, border-color .15s;
|
||||||
|
}
|
||||||
|
.tab:hover { color: var(--foreground); }
|
||||||
|
.tab.active { color: var(--brand-primary); border-bottom-color: var(--brand-primary); font-weight: 600; }
|
||||||
|
|
||||||
|
/* ── Toolbar ── */
|
||||||
|
.toolbar { display: flex; gap: 6px; flex-wrap: wrap; padding: 8px 0; border-top: 1px solid var(--brand-gray); border-bottom: 1px solid var(--brand-gray); margin: 8px 0; }
|
||||||
|
|
||||||
|
/* ── Field (label on top) ── */
|
||||||
|
.field { margin-bottom: 10px; }
|
||||||
|
.field label { display: block; font-size: 13px; font-weight: 500; margin-bottom: 4px; color: var(--foreground); }
|
||||||
|
.field label.required::after { content: " *"; color: var(--destructive); }
|
||||||
|
.field input, .field select, .field textarea {
|
||||||
|
width: 100%; padding: 8px 10px; font-size: 14px;
|
||||||
|
border: 1px solid var(--brand-gray); border-radius: var(--radius-sm);
|
||||||
|
background: var(--background);
|
||||||
|
}
|
||||||
|
.field input:focus, .field select:focus {
|
||||||
|
outline: none; border-color: var(--brand-primary);
|
||||||
|
box-shadow: 0 0 0 2px rgba(37,99,235,.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Skeleton ── */
|
||||||
|
.skeleton {
|
||||||
|
background: linear-gradient(90deg, #e5e7eb 25%, #f3f4f6 50%, #e5e7eb 75%);
|
||||||
|
background-size: 200% 100%; animation: skeleton-shimmer 1.5s infinite;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
@keyframes skeleton-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
|
||||||
|
.skeleton-line { height: 14px; margin-bottom: 8px; }
|
||||||
|
.skeleton-line:last-child { width: 60%; }
|
||||||
|
|
||||||
|
/* ── Snackbar ── */
|
||||||
|
.snackbar-container { position: fixed; bottom: 16px; right: 16px; z-index: 10001; display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.snackbar {
|
||||||
|
padding: 10px 16px; border-radius: var(--radius-sm); font-size: 13px;
|
||||||
|
color: #fff; box-shadow: 0 2px 8px rgba(0,0,0,.2);
|
||||||
|
animation: snackbar-in .3s ease; max-width: 360px;
|
||||||
|
}
|
||||||
|
.snackbar.info { background: var(--brand-primary); }
|
||||||
|
.snackbar.success { background: var(--green); }
|
||||||
|
.snackbar.error { background: var(--destructive); }
|
||||||
|
@keyframes snackbar-in { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
|
||||||
|
|
||||||
|
/* ── Combobox ── */
|
||||||
|
.combobox { position: relative; }
|
||||||
|
.combobox-input { width: 100%; padding: 6px 10px; font-size: 13px; border: 1px solid var(--brand-gray); border-radius: var(--radius-sm); }
|
||||||
|
.combobox-input:focus { outline: none; border-color: var(--brand-primary); }
|
||||||
|
.combobox-list { position: absolute; top: 100%; left: 0; right: 0; max-height: 200px; overflow-y: auto; background: var(--background); border: 1px solid var(--brand-gray); border-radius: 0 0 var(--radius-sm) var(--radius-sm); z-index: 10; }
|
||||||
|
.combobox-item { padding: 6px 10px; font-size: 13px; cursor: pointer; }
|
||||||
|
.combobox-item:hover { background: var(--brand-grey-light); }
|
||||||
|
|
||||||
|
/* ── Body for sidebar ── */
|
||||||
|
body.has-sidebar { padding-left: 0; }
|
||||||
|
|||||||
+116
-78
@@ -6,15 +6,9 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg') }}" />
|
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg') }}" />
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}" />
|
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}" />
|
||||||
<style>
|
<style>
|
||||||
.topbar { max-width:1200px; margin:0 auto; padding:10px 16px 0; display:flex; align-items:center; gap:12px; }
|
.svc-item { cursor:pointer; padding:4px 8px; font-size:13px; border-radius:4px; }
|
||||||
.layout { display:flex; gap:12px; max-width:1200px; margin:8px auto; padding:0 16px; }
|
|
||||||
.col-infra { width:280px; flex-shrink:0; }
|
|
||||||
.col-svc { width:240px; flex-shrink:0; max-height:calc(100vh - 50px); overflow-y:auto; }
|
|
||||||
.col-main { flex:1; min-width:0; }
|
|
||||||
#inst-list { max-height:calc(100vh - 250px); overflow-y:auto; }
|
|
||||||
.svc-item { cursor:pointer; padding:4px 8px; font-size:12px; border-radius:4px; }
|
|
||||||
.svc-item:hover, .svc-item.active { background:#dbeafe; font-weight:600; }
|
.svc-item:hover, .svc-item.active { background:#dbeafe; font-weight:600; }
|
||||||
.inst-item { cursor:pointer; display:flex; align-items:center; gap:6px; padding:4px 8px; font-size:12px; border-radius:4px; }
|
.inst-item { cursor:pointer; display:flex; align-items:center; gap:6px; padding:4px 8px; font-size:13px; border-radius:4px; }
|
||||||
.inst-item:hover, .inst-item.active { background:var(--brand-grey-light); }
|
.inst-item:hover, .inst-item.active { background:var(--brand-grey-light); }
|
||||||
.inst-ops { display:none; margin-left:16px; }
|
.inst-ops { display:none; margin-left:16px; }
|
||||||
.inst-ops.open { display:flex; flex-wrap:wrap; gap:4px; padding:4px 0; }
|
.inst-ops.open { display:flex; flex-wrap:wrap; gap:4px; padding:4px 0; }
|
||||||
@@ -31,62 +25,106 @@
|
|||||||
.op-btn-delete:hover { background:#fee2e2; }
|
.op-btn-delete:hover { background:#fee2e2; }
|
||||||
.op-btn-redeploy { background:#f5f3ff; border-color:#ddd6fe; color:#6d28d9; }
|
.op-btn-redeploy { background:#f5f3ff; border-color:#ddd6fe; color:#6d28d9; }
|
||||||
.op-btn-redeploy:hover { background:#ede9fe; }
|
.op-btn-redeploy:hover { background:#ede9fe; }
|
||||||
.op-btn-reconcile { background:#f8fafc; border-color:#dbe4ea; color:#475569; }
|
|
||||||
.op-btn-reconcile:hover { background:#eef2f7; }
|
|
||||||
.param-row { display:flex; align-items:center; gap:8px; margin-bottom:6px; }
|
.param-row { display:flex; align-items:center; gap:8px; margin-bottom:6px; }
|
||||||
.param-row label { width:160px; font-size:11px; text-align:right; flex-shrink:0; }
|
.param-row label { width:160px; font-size:11px; text-align:right; flex-shrink:0; }
|
||||||
.param-row label.req { font-weight:600; }
|
.param-row label.req { font-weight:600; }
|
||||||
.param-row label.req-nodfl { font-weight:600; color:var(--destructive); }
|
.param-row label.req-nodfl { font-weight:600; color:var(--destructive); }
|
||||||
.param-row input,.param-row select { flex:1; height:28px; font-size:12px; border:1px solid var(--brand-gray); border-radius:4px; padding:0 6px; }
|
.param-row input,.param-row select { flex:1; height:28px; font-size:12px; border:1px solid var(--brand-gray); border-radius:4px; padding:0 6px; }
|
||||||
|
#scenario-modal { display:none; position:fixed; top:0; left:0; right:0; bottom:0; z-index:10000; }
|
||||||
|
.modal-backdrop { position:absolute; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.5); }
|
||||||
|
.modal-content { position:relative; max-width:800px; margin:20px auto; background:#fff; border-radius:8px; box-shadow:0 4px 20px rgba(0,0,0,0.3); }
|
||||||
|
.workbench-layout { display:flex; gap:12px; }
|
||||||
|
.wb-left { width:260px; flex-shrink:0; }
|
||||||
|
.wb-right { flex:1; min-width:0; }
|
||||||
|
#inst-list { max-height:calc(100vh - 300px); overflow-y:auto; }
|
||||||
|
.token-row { display:flex; gap:4px; padding:8px 12px; }
|
||||||
|
.token-row input { flex:1; height:26px; font-size:11px; border:1px solid var(--brand-gray); border-radius:3px; padding:0 4px; background:var(--brand-grey-light); }
|
||||||
|
.token-row button { height:26px; font-size:10px; padding:0 6px; }
|
||||||
|
.overview-table { width:100%; table-layout:fixed; }
|
||||||
|
.overview-table td:first-child { width:auto; }
|
||||||
|
.overview-table td:last-child { width:70px; text-align:center; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<!--
|
<nav class="sidebar">
|
||||||
=== ПЕРЕМЕННЫЕ ШАБЛОНА JINJA2 ===
|
<div class="sidebar-top">
|
||||||
config.VERSION — версия приложения (топбар)
|
<img src="{{ url_for('static', filename='logo.svg') }}" alt="Nubes" style="height:20px;" />
|
||||||
token_info.email — email пользователя (топбар)
|
</div>
|
||||||
token_info.company — профиль/компания (топбар)
|
<div class="sidebar-nav">
|
||||||
stand — стенд: dev/test (топбар)
|
<button class="sidebar-item active" data-view="workbench" onclick="switchView('workbench')">🖥 Консоль</button>
|
||||||
env_token_masked — маскированный env-токен (placeholder)
|
<button class="sidebar-item" data-view="overview" onclick="switchView('overview')">📊 Обзор</button>
|
||||||
has_user_token — True если пользователь ввёл свой токен
|
<button class="sidebar-item" data-view="history-view" onclick="switchView('history-view')">📋 История</button>
|
||||||
organization — организация (serviceId=19)
|
<button class="sidebar-item" data-view="scenarios-view" onclick="switchView('scenarios-view')">🧪 Сценарии</button>
|
||||||
instance_groups — {svc_name: [instances]} для левой колонки
|
<button class="sidebar-item" data-view="logs-view" onclick="switchView('logs-view')">📜 Логи</button>
|
||||||
error — сообщение об ошибке
|
</div>
|
||||||
-->
|
<div class="sidebar-bottom">
|
||||||
|
<div style="padding:4px 12px;font-size:10px;color:var(--muted);">v{{ config.VERSION }} | {{ stand.upper() }}</div>
|
||||||
<div class="topbar">
|
{% if token_info.email %}<div style="padding:0 12px 4px;font-size:10px;color:var(--muted);">{{ token_info.email }}</div>{% endif %}
|
||||||
<img src="{{ url_for('static', filename='logo.svg') }}" alt="Nubes" style="height:22px;" />
|
<form method="post" class="token-row">
|
||||||
<span style="font-size:11px;color:var(--muted);">v{{ config.VERSION }}</span>
|
<input type="password" name="token" placeholder="env: {{ env_token_masked }}" value="{{ '' if not has_user_token else '••••••••' }}" />
|
||||||
{% if token_info.email %}<span style="font-size:11px;color:var(--muted);">EMail: {{ token_info.email }} | Профиль: {{ token_info.company }} | Стенд: {{ stand.upper() }}</span>{% endif %}
|
<button type="submit" name="action" value="save" class="btn btn-sm">OK</button>
|
||||||
<span style="flex:1;"></span>
|
{% if has_user_token %}<button type="submit" name="action" value="clear" class="btn btn-sm" style="color:var(--destructive);">✕</button>{% endif %}
|
||||||
<form method="post" style="display:flex;align-items:center;gap:6px;">
|
|
||||||
<input type="password" name="token" placeholder="env: {{ env_token_masked }}" value="{{ '' if not has_user_token else '••••••••' }}" style="height:28px;width:180px;font-size:12px;border:1px solid var(--brand-gray);border-radius:4px;padding:0 6px;" />
|
|
||||||
<button type="submit" name="action" value="save" class="btn" style="height:28px;font-size:12px;padding:0 8px;">OK</button>
|
|
||||||
{% if has_user_token %}<button type="submit" name="action" value="clear" class="btn btn-danger" style="height:28px;font-size:12px;padding:0 8px;">Выйти</button>{% endif %}
|
|
||||||
</form>
|
</form>
|
||||||
{% if error %}<span style="color:var(--destructive);font-size:12px;">{{ error }}</span>{% endif %}
|
{% if error %}<div style="padding:4px 12px;font-size:10px;color:var(--destructive);">{{ error }}</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- VIEW: Верстак -->
|
||||||
|
<div class="view" id="view-workbench">
|
||||||
|
<div class="view-header">Консоль</div>
|
||||||
|
<div class="workbench-layout">
|
||||||
|
<div class="wb-left">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header" style="font-size:13px;">Сервисы</div>
|
||||||
|
<div class="card-body" style="padding:4px;">
|
||||||
|
{% for svc in services %}{% if svc.svcId in config.service_ids %}<div class="svc-item {% if loop.first %}active{% endif %}" onclick="selectService({{ svc.svcId }})">{{ svc.svcId }}. {{ svc.svc }}</div>{% endif %}{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="wb-right">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header" style="font-size:13px;">Инстансы</div>
|
||||||
|
<div class="card-body" style="padding:4px;" id="inst-list">
|
||||||
|
<span style="color:var(--muted);font-size:12px;">Загрузка...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="params-area" style="display:none;padding:0 8px 8px;">
|
||||||
|
<div id="params-form"></div>
|
||||||
|
<div style="padding:4px 0;">
|
||||||
|
<button class="btn btn-primary btn-sm" id="btn-test" style="display:none;">Запустить</button>
|
||||||
|
<span id="test-status" style="font-size:12px;margin-left:8px;"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="create-btn-area" style="padding:0 8px 8px;">
|
||||||
|
<div class="inst-item" style="border-top:1px solid var(--brand-gray);padding-top:8px;margin-top:4px;" onclick="startCreate()">
|
||||||
|
<span style="flex:1;color:var(--brand-primary);cursor:pointer;font-size:13px;">+ Создать новый инстанс</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="layout">
|
<!-- VIEW: Обзор -->
|
||||||
<!-- Инфраструктура -->
|
<div class="view hidden" id="view-overview">
|
||||||
<div class="col-infra">
|
<div class="view-header">Обзор</div>
|
||||||
{% if organization %}
|
{% if organization %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">Организация</div>
|
<div class="card-header" style="cursor:pointer;" onclick="this.nextElementSibling.style.display=this.nextElementSibling.style.display==='none'?'block':'none'">Организация{% if client_id %} ({{ client_id }}){% endif %}</div>
|
||||||
<div class="card-body" style="padding:6px 10px;font-size:12px;">
|
<div class="card-body" style="padding:0;">
|
||||||
{{ organization.displayName }}{% if client_id %} ({{ client_id }}){% endif %}<br>
|
<table class="overview-table">
|
||||||
<span class="badge badge-success" style="font-size:10px;">{{ organization.explainedStatus or "OK" }}</span>
|
<tr><td style="font-size:12px;padding:3px 10px;">{{ organization.displayName }}<br><span style="color:var(--muted);font-size:10px;">{{ organization.instanceUid }}</span></td><td><span class="badge badge-success" style="font-size:10px;">{{ organization.explainedStatus or 'OK' }}</span></td></tr>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% for svc_name, items in instance_groups.items() %}
|
{% for svc_name, items in instance_groups.items() %}
|
||||||
<div class="card" style="margin-top:6px;">
|
<div class="card" style="margin-top:8px;">
|
||||||
<div class="card-header" style="font-size:12px;cursor:pointer;" onclick="this.nextElementSibling.style.display=this.nextElementSibling.style.display==='none'?'block':'none'">{{ svc_name }} ({{ items|length }})</div>
|
<div class="card-header" style="cursor:pointer;" onclick="this.nextElementSibling.style.display=this.nextElementSibling.style.display==='none'?'block':'none'">{{ svc_name }} ({{ items|length }})</div>
|
||||||
<div class="card-body" style="padding:0;max-height:150px;overflow-y:auto;">
|
<div class="card-body" style="padding:0;max-height:200px;overflow-y:auto;">
|
||||||
<table>
|
<table class="overview-table">
|
||||||
{% for i in items %}
|
{% for i in items %}
|
||||||
<tr><td style="font-size:11px;padding:2px 8px;">{{ i.displayName }}</td><td><span class="badge badge-success" style="font-size:9px;">{{ i.explainedStatus or '?' }}</span></td></tr>
|
<tr><td style="font-size:12px;padding:3px 10px;">{{ i.displayName }}<br><span style="color:var(--muted);font-size:10px;">{{ i.instanceUid }}</span></td><td><span class="badge badge-success" style="font-size:10px;">{{ i.explainedStatus or '?' }}</span></td></tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -94,47 +132,34 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Сервисы -->
|
<!-- VIEW: История -->
|
||||||
<div class="col-svc">
|
<div class="view hidden" id="view-history-view">
|
||||||
<div class="card">
|
<div class="view-header">История прогонов</div>
|
||||||
<div class="card-header" style="font-size:12px;">Сервисы</div>
|
<div class="card" id="history-card">
|
||||||
<div class="card-body" style="padding:4px;">
|
<div class="card-header" style="cursor:pointer;" onclick="toggleHistory()">Последние 50 запусков</div>
|
||||||
{% for svc in services %}{% if svc.svcId in config.service_ids %}<div class="svc-item {% if loop.first %}active{% endif %}" onclick="selectService({{ svc.svcId }})">{{ svc.svcId }}. {{ svc.svc }}</div>{% endif %}{% endfor %}
|
<div class="card-body" style="padding:4px;max-height:calc(100vh - 200px);overflow-y:auto;" id="history-body">
|
||||||
|
<span style="color:var(--muted);font-size:12px;">Загрузка...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Инстансы + Операции + Параметры -->
|
<!-- VIEW: Сценарии -->
|
||||||
<div class="col-main">
|
<div class="view hidden" id="view-scenarios-view">
|
||||||
<div class="card">
|
<div class="view-header">Сценарии (автотесты)</div>
|
||||||
<div class="card-header" style="font-size:12px;">Инстансы</div>
|
<div class="card" id="scenario-card">
|
||||||
<div class="card-body" style="padding:4px;" id="inst-list">
|
<div class="card-body" style="padding:8px;" id="scenario-body">
|
||||||
<span style="color:var(--muted);font-size:11px;">Загрузка...</span>
|
<span style="color:var(--muted);font-size:12px;">Загрузка...</span>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="params-area" style="display:none;padding:0 8px 8px;">
|
|
||||||
<div id="params-form"></div>
|
|
||||||
<div style="padding:4px 0;">
|
|
||||||
<button class="btn btn-primary btn-sm" id="btn-test" style="display:none;">Запустить</button>
|
|
||||||
<span id="test-status" style="font-size:11px;margin-left:8px;"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="create-btn-area" style="padding:0 8px 8px;">
|
|
||||||
<div class="inst-item" style="border-top:1px solid var(--brand-gray);padding-top:8px;margin-top:4px;" onclick="startCreate()">
|
|
||||||
<span style="flex:1;color:var(--brand-primary);cursor:pointer;">+ Создать новый инстанс</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card" id="history-card" style="margin-top:8px;">
|
|
||||||
<div class="card-header" style="font-size:12px;cursor:pointer;" onclick="toggleHistory()">История <span style="color:var(--muted);font-size:10px;">(последние 50)</span></div>
|
|
||||||
<div class="card-body" style="padding:4px;display:none;max-height:300px;overflow-y:auto;" id="history-body">
|
|
||||||
<span style="color:var(--muted);font-size:11px;">Загрузка...</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- VIEW: Логи -->
|
||||||
|
<div class="view hidden" id="view-logs-view">
|
||||||
|
<div class="view-header">Логи</div>
|
||||||
|
<div id="log-view-content" style="background:#1e1e1e;color:#d4d4d4;font:12px monospace;padding:10px;border-radius:6px;min-height:300px;max-height:calc(100vh - 200px);overflow-y:auto;"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Переменные из Jinja2 для app.js
|
|
||||||
window.APP = {
|
window.APP = {
|
||||||
version: "{{ config.VERSION | tojson }}",
|
version: "{{ config.VERSION | tojson }}",
|
||||||
stand: "{{ stand | tojson }}",
|
stand: "{{ stand | tojson }}",
|
||||||
@@ -142,10 +167,23 @@ window.APP = {
|
|||||||
firstServiceId: {{ (config.service_ids[0] if config.service_ids else 1) | tojson }}
|
firstServiceId: {{ (config.service_ids[0] if config.service_ids else 1) | tojson }}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
<script src="{{ url_for('static', filename='js/icons.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/snackbar.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/utils.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/views.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/params-render.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/instances.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/operations.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/history.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/scenario-form.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/scenario-create.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/scenario-edit.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/scenario-delete.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/scenario-list.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='app.js') }}"></script>
|
<script src="{{ url_for('static', filename='app.js') }}"></script>
|
||||||
|
|
||||||
<div id="log-panel" style="display:none;position:fixed;bottom:0;left:0;right:0;height:180px;background:#1e1e1e;color:#d4d4d4;font:11px monospace;overflow-y:auto;padding:6px 10px;border-top:2px solid #555;z-index:9999;"></div>
|
<div id="log-panel" style="display:none;position:fixed;bottom:0;left:192px;right:0;height:180px;background:#1e1e1e;color:#d4d4d4;font:12px monospace;overflow-y:auto;padding:8px 12px;border-top:2px solid #555;z-index:9999;"></div>
|
||||||
<button onclick="toggleLog()" title="Логи" style="position:fixed;bottom:4px;right:8px;z-index:10000;background:#1e1e1e;color:#888;border:1px solid #555;border-radius:3px;font:10px monospace;padding:2px 6px;cursor:pointer;">log</button>
|
<button onclick="toggleLog()" title="Логи" style="position:fixed;bottom:4px;right:8px;z-index:10000;background:#1e1e1e;color:#888;border:1px solid #555;border-radius:3px;font:11px monospace;padding:3px 8px;cursor:pointer;">log</button>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
Reference in New Issue
Block a user