MVP: config.yaml, pipeline runner, API routes, UI with checkboxes and run button
This commit is contained in:
@@ -2,3 +2,4 @@
|
|||||||
Flask>=3.0
|
Flask>=3.0
|
||||||
gunicorn>=21.2
|
gunicorn>=21.2
|
||||||
requests>=2.31
|
requests>=2.31
|
||||||
|
PyYAML>=6.0
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ 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
|
||||||
|
|
||||||
app = Flask(__name__, template_folder="templates", static_folder="static")
|
app = Flask(__name__, template_folder="templates", static_folder="static")
|
||||||
app.config["NUBES_API_ENDPOINT"] = os.getenv("NUBES_API_ENDPOINT", "https://lk-api-gateway-dev.ngcloud.ru/api/v1/svc")
|
app.config["NUBES_API_ENDPOINT"] = os.getenv("NUBES_API_ENDPOINT", "https://lk-api-gateway-dev.ngcloud.ru/api/v1/svc")
|
||||||
app.config["NUBES_API_TOKEN"] = os.getenv("NUBES_API_TOKEN", "")
|
app.config["NUBES_API_TOKEN"] = os.getenv("NUBES_API_TOKEN", "")
|
||||||
app.register_blueprint(main_bp)
|
app.register_blueprint(main_bp)
|
||||||
|
app.register_blueprint(api_bp)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
test_org_uid: "auto"
|
||||||
|
|
||||||
|
services:
|
||||||
|
- service_id: 1
|
||||||
|
name: dummy
|
||||||
|
display_name: "autotest-dummy"
|
||||||
|
enabled: true
|
||||||
|
operations:
|
||||||
|
- name: create
|
||||||
|
enabled: true
|
||||||
|
params:
|
||||||
|
durationMs: "0"
|
||||||
|
failAtStart: "false"
|
||||||
|
- name: delete
|
||||||
|
enabled: true
|
||||||
|
params: {}
|
||||||
|
|
||||||
|
- service_id: 90
|
||||||
|
name: PostgreSQL
|
||||||
|
display_name: "autotest-pg"
|
||||||
|
enabled: false
|
||||||
|
operations:
|
||||||
|
- name: create
|
||||||
|
enabled: false
|
||||||
|
params: {}
|
||||||
|
- name: delete
|
||||||
|
enabled: false
|
||||||
|
params: {}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import threading
|
||||||
|
|
||||||
|
from flask import Blueprint, current_app, jsonify, request
|
||||||
|
|
||||||
|
from runner import run_tests, get_status, load_config, save_config
|
||||||
|
|
||||||
|
bp = Blueprint("api", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/api/run", methods=["POST"])
|
||||||
|
def api_run():
|
||||||
|
t = threading.Thread(
|
||||||
|
target=run_tests,
|
||||||
|
args=(current_app.config["NUBES_API_ENDPOINT"], current_app.config["NUBES_API_TOKEN"]),
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
t.start()
|
||||||
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/api/status")
|
||||||
|
def api_status():
|
||||||
|
return jsonify(get_status() or {})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/api/config", methods=["GET"])
|
||||||
|
def api_config_get():
|
||||||
|
return jsonify(load_config())
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/api/config", methods=["POST"])
|
||||||
|
def api_config_save():
|
||||||
|
save_config(request.get_json())
|
||||||
|
return jsonify({"ok": True})
|
||||||
+5
-1
@@ -3,6 +3,7 @@ from flask import Blueprint, current_app, render_template, request, make_respons
|
|||||||
from api.http_client import HttpClient
|
from api.http_client import HttpClient
|
||||||
from operations.get_instances import get_organization, get_instances
|
from operations.get_instances import get_organization, get_instances
|
||||||
from operations.get_services import get_services, get_service_detail
|
from operations.get_services import get_services, get_service_detail
|
||||||
|
from runner import load_config
|
||||||
|
|
||||||
bp = Blueprint("main", __name__)
|
bp = Blueprint("main", __name__)
|
||||||
|
|
||||||
@@ -43,6 +44,7 @@ def index():
|
|||||||
|
|
||||||
services = []
|
services = []
|
||||||
instances = []
|
instances = []
|
||||||
|
config = {}
|
||||||
if active_token:
|
if active_token:
|
||||||
try:
|
try:
|
||||||
client = HttpClient(
|
client = HttpClient(
|
||||||
@@ -55,6 +57,7 @@ def index():
|
|||||||
instances = get_instances(client)
|
instances = get_instances(client)
|
||||||
# org first, then rest
|
# org first, then rest
|
||||||
instances.sort(key=lambda i: (0 if i.get("serviceId") == 19 else 1, i.get("displayName", "")))
|
instances.sort(key=lambda i: (0 if i.get("serviceId") == 19 else 1, i.get("displayName", "")))
|
||||||
|
config = load_config()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error = str(e)
|
error = str(e)
|
||||||
|
|
||||||
@@ -63,7 +66,8 @@ def index():
|
|||||||
env_token_masked=_mask(env_token),
|
env_token_masked=_mask(env_token),
|
||||||
has_user_token=bool(user_token),
|
has_user_token=bool(user_token),
|
||||||
services=services,
|
services=services,
|
||||||
instances=instances)
|
instances=instances,
|
||||||
|
config=config)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/api/operations/<int:svc_id>")
|
@bp.route("/api/operations/<int:svc_id>")
|
||||||
|
|||||||
+167
@@ -0,0 +1,167 @@
|
|||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import yaml
|
||||||
|
import os
|
||||||
|
|
||||||
|
from api.http_client import HttpClient
|
||||||
|
|
||||||
|
|
||||||
|
CONFIG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.yaml")
|
||||||
|
|
||||||
|
_current_run = None
|
||||||
|
_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def load_config():
|
||||||
|
with open(CONFIG_PATH) as f:
|
||||||
|
return yaml.safe_load(f)
|
||||||
|
|
||||||
|
|
||||||
|
def save_config(data):
|
||||||
|
with open(CONFIG_PATH, "w") as f:
|
||||||
|
yaml.dump(data, f, allow_unicode=True, default_flow_style=False)
|
||||||
|
|
||||||
|
|
||||||
|
def get_status():
|
||||||
|
return _current_run
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_for_ready(client, instance_uid, timeout=300):
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
while time.time() < deadline:
|
||||||
|
data = client.get("/instances", params={"pageSize": 500})
|
||||||
|
for inst in data.get("results", []):
|
||||||
|
if inst.get("instanceUid") == instance_uid:
|
||||||
|
status = inst.get("explainedStatus", "")
|
||||||
|
in_progress = inst.get("operationIsInProgress", False)
|
||||||
|
pending = inst.get("operationIsPending", False)
|
||||||
|
if not in_progress and not pending and status not in ("creating", "modifying", "deleting"):
|
||||||
|
return inst
|
||||||
|
time.sleep(10)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _find_svc_operation_id(client, service_id, operation_name):
|
||||||
|
data = client.get(f"/services/{service_id}")
|
||||||
|
for op in data.get("svc", {}).get("operations", []):
|
||||||
|
if op.get("operation") == operation_name:
|
||||||
|
return op["svcOperationId"]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def run_tests(endpoint, token):
|
||||||
|
global _current_run
|
||||||
|
client = HttpClient(endpoint, token)
|
||||||
|
config = load_config()
|
||||||
|
|
||||||
|
org_uid = config.get("test_org_uid", "auto")
|
||||||
|
if org_uid == "auto":
|
||||||
|
from operations.get_instances import get_organization
|
||||||
|
org = get_organization(client)
|
||||||
|
org_uid = org["instanceUid"] if org else ""
|
||||||
|
|
||||||
|
run_id = time.strftime("%Y%m%d-%H%M%S")
|
||||||
|
with _lock:
|
||||||
|
_current_run = {
|
||||||
|
"run_id": run_id,
|
||||||
|
"status": "running",
|
||||||
|
"started_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
||||||
|
"finished_at": None,
|
||||||
|
"results": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
for svc_cfg in config.get("services", []):
|
||||||
|
if not svc_cfg.get("enabled"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
service_id = svc_cfg["service_id"]
|
||||||
|
display_name = svc_cfg.get("display_name", f"autotest-{svc_cfg['name']}")
|
||||||
|
instance_uid = None
|
||||||
|
|
||||||
|
for op_cfg in svc_cfg.get("operations", []):
|
||||||
|
if not op_cfg.get("enabled"):
|
||||||
|
with _lock:
|
||||||
|
_current_run["results"].append({
|
||||||
|
"service_id": service_id,
|
||||||
|
"service_name": svc_cfg["name"],
|
||||||
|
"operation": op_cfg["name"],
|
||||||
|
"status": "SKIP",
|
||||||
|
"instance_uid": None,
|
||||||
|
"error": None,
|
||||||
|
"duration_sec": 0,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
t0 = time.time()
|
||||||
|
result = {
|
||||||
|
"service_id": service_id,
|
||||||
|
"service_name": svc_cfg["name"],
|
||||||
|
"operation": op_cfg["name"],
|
||||||
|
"status": "RUNNING",
|
||||||
|
"instance_uid": None,
|
||||||
|
"error": None,
|
||||||
|
"duration_sec": 0,
|
||||||
|
}
|
||||||
|
with _lock:
|
||||||
|
_current_run["results"].append(result)
|
||||||
|
|
||||||
|
try:
|
||||||
|
svc_op_id = _find_svc_operation_id(client, service_id, op_cfg["name"])
|
||||||
|
if not svc_op_id:
|
||||||
|
raise Exception(f"Operation {op_cfg['name']} not found")
|
||||||
|
|
||||||
|
if op_cfg["name"] == "create":
|
||||||
|
payload = {
|
||||||
|
"serviceId": service_id,
|
||||||
|
"displayName": display_name,
|
||||||
|
}
|
||||||
|
params = op_cfg.get("params", {})
|
||||||
|
if params:
|
||||||
|
cfs_params = [{"svcOperationCfsParamId": int(k), "paramValue": str(v)} for k, v in params.items()]
|
||||||
|
payload["cfsParams"] = cfs_params
|
||||||
|
create_resp = client.post("/instances", payload)
|
||||||
|
instance_uid = create_resp.get("instanceUid") or _extract_uid_from_response(create_resp)
|
||||||
|
if not instance_uid:
|
||||||
|
raise Exception("No instanceUid in create response")
|
||||||
|
inst = _wait_for_ready(client, instance_uid)
|
||||||
|
if not inst:
|
||||||
|
raise Exception("Timeout waiting for create")
|
||||||
|
else:
|
||||||
|
if not instance_uid:
|
||||||
|
raise Exception(f"No instance for {op_cfg['name']}")
|
||||||
|
payload = {
|
||||||
|
"instanceUid": instance_uid,
|
||||||
|
"svcOperationId": svc_op_id,
|
||||||
|
}
|
||||||
|
params = op_cfg.get("params", {})
|
||||||
|
if params:
|
||||||
|
payload["cfsParams"] = [{"svcOperationCfsParamId": int(k), "paramValue": str(v)} for k, v in params.items()]
|
||||||
|
client.post("/instanceOperations", payload)
|
||||||
|
inst = _wait_for_ready(client, instance_uid)
|
||||||
|
if not inst:
|
||||||
|
raise Exception(f"Timeout waiting for {op_cfg['name']}")
|
||||||
|
|
||||||
|
result["status"] = "OK"
|
||||||
|
result["instance_uid"] = instance_uid
|
||||||
|
except Exception as e:
|
||||||
|
result["status"] = "FAIL"
|
||||||
|
result["error"] = str(e)
|
||||||
|
if op_cfg["name"] == "create":
|
||||||
|
break # дальше операции этого сервиса бессмысленны
|
||||||
|
finally:
|
||||||
|
result["duration_sec"] = round(time.time() - t0, 1)
|
||||||
|
|
||||||
|
# wait between services
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
with _lock:
|
||||||
|
_current_run["status"] = "done"
|
||||||
|
_current_run["finished_at"] = time.strftime("%Y-%m-%dT%H:%M:%S")
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_uid_from_response(resp):
|
||||||
|
if isinstance(resp, dict):
|
||||||
|
for v in resp.values():
|
||||||
|
if isinstance(v, str) and len(v) == 36 and v.count("-") == 4:
|
||||||
|
return v
|
||||||
|
return None
|
||||||
+71
-70
@@ -11,7 +11,6 @@
|
|||||||
.left { width:380px; flex-shrink:0; }
|
.left { width:380px; flex-shrink:0; }
|
||||||
.right { flex:1; min-width:0; }
|
.right { flex:1; min-width:0; }
|
||||||
.svc-row { cursor:pointer; }
|
.svc-row { cursor:pointer; }
|
||||||
.inst-row { cursor:default; }
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -26,8 +25,6 @@
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="layout">
|
<div class="layout">
|
||||||
|
|
||||||
<!-- ЛЕВАЯ КОЛОНКА -->
|
|
||||||
<div class="left">
|
<div class="left">
|
||||||
{% if organization %}
|
{% if organization %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -41,20 +38,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if instances %}
|
{% if instances %}
|
||||||
<div class="card" style="margin-top:8px;">
|
<div class="card" style="margin-top:8px;">
|
||||||
<div class="card-header" style="cursor:pointer;" onclick="this.nextElementSibling.style.display=this.nextElementSibling.style.display==='none'?'block':'none'">
|
<div class="card-header" style="cursor:pointer;" onclick="this.nextElementSibling.style.display=this.nextElementSibling.style.display==='none'?'block':'none'">Инстансы ({{ instances|length }})</div>
|
||||||
Инстансы ({{ instances|length }})
|
|
||||||
</div>
|
|
||||||
<div class="card-body" style="padding:0;max-height:300px;overflow-y:auto;">
|
<div class="card-body" style="padding:0;max-height:300px;overflow-y:auto;">
|
||||||
<table>
|
<table>
|
||||||
{% for i in instances %}
|
{% for i in instances %}
|
||||||
<tr class="inst-row">
|
<tr><td style="font-size:12px;">{{ i.displayName }}</td><td style="font-size:11px;color:var(--muted);">{{ i.svc }}</td><td><span class="badge badge-success" style="font-size:10px;">{{ i.explainedStatus or "?" }}</span></td></tr>
|
||||||
<td style="font-size:12px;">{{ i.displayName }}</td>
|
|
||||||
<td style="font-size:11px;color:var(--muted);">{{ i.svc }}</td>
|
|
||||||
<td><span class="badge badge-success" style="font-size:10px;">{{ i.explainedStatus or "?" }}</span></td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -62,33 +52,27 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ПРАВАЯ КОЛОНКА -->
|
|
||||||
<div class="right">
|
<div class="right">
|
||||||
|
<div style="display:flex;gap:8px;margin-bottom:8px;">
|
||||||
|
<button class="btn btn-primary" onclick="runTests()" id="btn-run" style="height:36px;"><i data-lucide="play" style="width:14px;height:14px;"></i> Запустить тесты</button>
|
||||||
|
<button class="btn" onclick="saveConfig()" style="height:36px;"><i data-lucide="save" style="width:14px;height:14px;"></i> Сохранить конфиг</button>
|
||||||
|
</div>
|
||||||
{% if services %}
|
{% if services %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header" style="cursor:pointer;" onclick="document.getElementById('svc-table').style.display=document.getElementById('svc-table').style.display==='none'?'':'none'">
|
<div class="card-header" style="cursor:pointer;" onclick="document.getElementById('svc-table').style.display=document.getElementById('svc-table').style.display==='none'?'':'none'">Сервисы ({{ services|length }})</div>
|
||||||
Сервисы ({{ services|length }})
|
<div id="svc-table">
|
||||||
</div>
|
<div class="card-body" style="padding:0;max-height:45vh;overflow-y:auto;">
|
||||||
<div id="svc-table" style="display:none;">
|
|
||||||
<div class="card-body" style="padding:0;max-height:60vh;overflow-y:auto;">
|
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead><tr><th style="width:30px;"></th><th style="width:50px;">#</th><th>Сервис</th><th style="width:70px;">Опер.</th></tr></thead>
|
||||||
<tr>
|
|
||||||
<th style="width:50px;">#</th>
|
|
||||||
<th>Сервис</th>
|
|
||||||
<th style="width:70px;">Опер.</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for s in services %}
|
{% for s in services %}
|
||||||
<tr class="svc-row" data-svc-id="{{ s.svcId }}">
|
<tr class="svc-row" data-svc-id="{{ s.svcId }}">
|
||||||
|
<td><input type="checkbox" class="svc-enabled" data-svc-id="{{ s.svcId }}"></td>
|
||||||
<td>{{ s.svcId }}</td>
|
<td>{{ s.svcId }}</td>
|
||||||
<td style="font-size:12px;">{{ s.svc }}{% if s.svcExtendedName %} <span style="color:var(--muted);">— {{ s.svcExtendedName }}</span>{% endif %}</td>
|
<td style="font-size:12px;">{{ s.svc }}{% if s.svcExtendedName %} <span style="color:var(--muted);">— {{ s.svcExtendedName }}</span>{% endif %}</td>
|
||||||
<td style="text-align:center;"><span class="ops-count" id="ops-{{ s.svcId }}">?</span></td>
|
<td style="text-align:center;"><span class="ops-count" id="ops-{{ s.svcId }}">?</span></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr class="ops-detail" id="detail-{{ s.svcId }}" style="display:none;">
|
<tr class="ops-detail" id="detail-{{ s.svcId }}" style="display:none;"><td colspan="4" style="padding:4px 10px;background:var(--brand-grey-light);"></td></tr>
|
||||||
<td colspan="3" style="padding:4px 10px;background:var(--brand-grey-light);"></td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -96,54 +80,71 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<div class="card" id="results-card" style="margin-top:8px;display:none;">
|
||||||
|
<div class="card-header">Результаты <span id="results-status"></span></div>
|
||||||
|
<div class="card-body" style="padding:0;">
|
||||||
|
<table><thead><tr><th>Сервис</th><th>Операция</th><th style="width:70px;">Статус</th><th style="width:60px;">Время</th></tr></thead><tbody id="results-body"></tbody></table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const opsCache = {};
|
const opsCache={},STATUS={OK:'<span class="badge badge-success">OK</span>',FAIL:'<span class="badge" style="background:#fecaca;color:#991b1b;">FAIL</span>',SKIP:'<span class="badge" style="background:var(--brand-grey-light);color:var(--muted);">SKIP</span>',RUNNING:'<span class="badge" style="background:#dbeafe;color:#1e40af;">⏳</span>',TIMEOUT:'<span class="badge" style="background:#fecaca;color:#991b1b;">TIMEOUT</span>'};
|
||||||
document.querySelectorAll('.svc-row').forEach(row => {
|
let pollTimer=null,configDirty=false;
|
||||||
row.addEventListener('click', async () => {
|
|
||||||
const svcId = row.dataset.svcId;
|
|
||||||
const detail = document.getElementById('detail-' + svcId);
|
|
||||||
if (detail.style.display === 'table-row') {
|
|
||||||
detail.style.display = 'none';
|
|
||||||
row.style.background = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
document.querySelectorAll('.ops-detail').forEach(d => d.style.display = 'none');
|
|
||||||
document.querySelectorAll('.svc-row').forEach(r => r.style.background = '');
|
|
||||||
row.style.background = 'var(--brand-grey-light)';
|
|
||||||
detail.style.display = 'table-row';
|
|
||||||
if (opsCache[svcId]) {
|
|
||||||
fillDetail(svcId, opsCache[svcId]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
detail.cells[0].innerHTML = '<span style=\"color:var(--muted);font-size:12px;\">Загрузка...</span>';
|
|
||||||
try {
|
|
||||||
const r = await fetch('/api/operations/' + svcId);
|
|
||||||
const d = await r.json();
|
|
||||||
opsCache[svcId] = d;
|
|
||||||
fillDetail(svcId, d);
|
|
||||||
} catch(e) {
|
|
||||||
detail.cells[0].innerHTML = '<span style=\"color:var(--destructive);font-size:12px;\">Ошибка</span>';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function fillDetail(svcId, data) {
|
(async function(){
|
||||||
const detail = document.getElementById('detail-' + svcId);
|
try{const r=await fetch('/api/config');const d=await r.json();
|
||||||
if (data.error) {
|
(d.services||[]).forEach(s=>{const cb=document.querySelector('.svc-enabled[data-svc-id="'+s.service_id+'"]');if(cb)cb.checked=s.enabled;});}
|
||||||
detail.cells[0].innerHTML = '<span style=\"color:var(--destructive);font-size:12px;\">' + data.error + '</span>';
|
catch(e){}
|
||||||
return;
|
})();
|
||||||
}
|
|
||||||
const ops = data.operations || [];
|
function saveConfig(){
|
||||||
document.getElementById('ops-' + svcId).textContent = ops.length;
|
const cfg={test_org_uid:"auto",services:[]};
|
||||||
detail.cells[0].innerHTML = ops.map(o =>
|
document.querySelectorAll('.svc-row').forEach(row=>{
|
||||||
'<span class=\"badge\" style=\"margin:2px;font-size:11px;\">' + o.operation + '</span>'
|
const svcId=parseInt(row.dataset.svcId);
|
||||||
).join('') || '<span style=\"color:var(--muted);font-size:12px;\">Нет операций</span>';
|
const name=row.cells[2].textContent.split(' — ')[0].trim();
|
||||||
|
cfg.services.push({service_id:svcId,name:name,display_name:"autotest-"+name,enabled:row.querySelector('.svc-enabled').checked,operations:[]});
|
||||||
|
});
|
||||||
|
fetch('/api/config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(cfg)}).then(()=>{configDirty=false;}).catch(e=>alert('Ошибка сохранения'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function runTests(){
|
||||||
|
if(configDirty){alert('Сначала сохраните конфиг');return;}
|
||||||
|
document.getElementById('btn-run').disabled=true;
|
||||||
|
document.getElementById('results-card').style.display='block';
|
||||||
|
document.getElementById('results-status').textContent=' запуск...';
|
||||||
|
fetch('/api/run',{method:'POST'}).then(()=>{pollTimer=setInterval(pollStatus,2000);}).catch(()=>{document.getElementById('btn-run').disabled=false;});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollStatus(){
|
||||||
|
try{const r=await fetch('/api/status');const d=await r.json();if(!d.results)return;
|
||||||
|
document.getElementById('results-body').innerHTML=d.results.map(x=>'<tr><td style="font-size:12px;">'+x.service_name+'</td><td style="font-size:12px;">'+x.operation+'</td><td>'+STATUS[x.status]+(x.error?'<br><span style="font-size:10px;color:var(--destructive);">'+x.error+'</span>':'')+'</td><td style="font-size:12px;">'+(x.duration_sec||'')+'s</td></tr>').join('');
|
||||||
|
document.getElementById('results-status').textContent=d.status==='done'?' ✓ завершён':' выполняется...';
|
||||||
|
if(d.status==='done'){clearInterval(pollTimer);document.getElementById('btn-run').disabled=false;}}
|
||||||
|
catch(e){}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('.svc-row').forEach(row=>{row.addEventListener('click',async e=>{
|
||||||
|
if(e.target.tagName==='INPUT'){configDirty=true;return;}
|
||||||
|
const svcId=row.dataset.svcId,detail=document.getElementById('detail-'+svcId);
|
||||||
|
if(detail.style.display==='table-row'){detail.style.display='none';row.style.background='';return;}
|
||||||
|
document.querySelectorAll('.ops-detail').forEach(d=>d.style.display='none');
|
||||||
|
document.querySelectorAll('.svc-row').forEach(r=>r.style.background='');
|
||||||
|
row.style.background='var(--brand-grey-light)';detail.style.display='table-row';
|
||||||
|
if(opsCache[svcId]){fillDetail(svcId,opsCache[svcId]);return;}
|
||||||
|
detail.cells[0].innerHTML='<span style="color:var(--muted);font-size:12px;">Загрузка...</span>';
|
||||||
|
try{const r=await fetch('/api/operations/'+svcId);const d=await r.json();opsCache[svcId]=d;fillDetail(svcId,d);}
|
||||||
|
catch(e){detail.cells[0].innerHTML='<span style="color:var(--destructive);font-size:12px;">Ошибка</span>';}
|
||||||
|
});});
|
||||||
|
|
||||||
|
function fillDetail(svcId,data){
|
||||||
|
const d=document.getElementById('detail-'+svcId);
|
||||||
|
if(data.error){d.cells[0].innerHTML='<span style="color:var(--destructive);font-size:12px;">'+data.error+'</span>';return;}
|
||||||
|
const ops=data.operations||[];
|
||||||
|
document.getElementById('ops-'+svcId).textContent=ops.length;
|
||||||
|
d.cells[0].innerHTML=ops.map(o=>'<span class="badge" style="margin:2px;font-size:11px;">'+o.operation+'</span>').join('')||'<span style="color:var(--muted);font-size:12px;">Нет операций</span>';
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
Reference in New Issue
Block a user