standkit 0.3.7__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- standkit/__init__.py +11 -0
- standkit/health.py +163 -0
- standkit/lifecycle.py +191 -0
- standkit/logs.py +221 -0
- standkit/models.py +211 -0
- standkit/platform.py +174 -0
- standkit/registry.py +223 -0
- standkit/secrets.py +144 -0
- standkit-0.3.7.dist-info/METADATA +155 -0
- standkit-0.3.7.dist-info/RECORD +38 -0
- standkit-0.3.7.dist-info/WHEEL +5 -0
- standkit-0.3.7.dist-info/entry_points.txt +4 -0
- standkit-0.3.7.dist-info/licenses/LICENSE +21 -0
- standkit-0.3.7.dist-info/top_level.txt +3 -0
- standkit_agent/__init__.py +9 -0
- standkit_agent/__main__.py +192 -0
- standkit_agent/audit.py +93 -0
- standkit_agent/security.py +300 -0
- standkit_agent/server.py +386 -0
- standkit_hub/__init__.py +13 -0
- standkit_hub/__main__.py +131 -0
- standkit_hub/agent_control.py +216 -0
- standkit_hub/assets/bpmkit-icon.ico +0 -0
- standkit_hub/assets/icon.png +0 -0
- standkit_hub/client.py +159 -0
- standkit_hub/config.py +156 -0
- standkit_hub/logs_browser.py +174 -0
- standkit_hub/redis_min.py +326 -0
- standkit_hub/security.py +146 -0
- standkit_hub/server.py +881 -0
- standkit_hub/shortcut.py +264 -0
- standkit_hub/web/app.js +803 -0
- standkit_hub/web/bpmkit-logo-dark.svg +5 -0
- standkit_hub/web/bpmkit-logo.svg +5 -0
- standkit_hub/web/favicon.png +0 -0
- standkit_hub/web/favicon.svg +4 -0
- standkit_hub/web/index.html +267 -0
- standkit_hub/web/style.css +843 -0
standkit_hub/web/app.js
ADDED
|
@@ -0,0 +1,803 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Vanilla JS дашборда standkit — без CDN, без сборки, без фреймворков.
|
|
3
|
+
*
|
|
4
|
+
* Аутентификация: сессионная HttpOnly-cookie ставится сервером при первом
|
|
5
|
+
* переходе по "/?t=<token>" (см. standkit_hub/server.py::_handle_root).
|
|
6
|
+
* Дальше все запросы к /api/* идут через fetch с credentials: "same-origin"
|
|
7
|
+
* (cookie летит автоматически). Мутации (POST/DELETE) ДОПОЛНИТЕЛЬНО несут
|
|
8
|
+
* заголовок X-Standkit-Token — сервер сверяет его с cookie/сессией
|
|
9
|
+
* (double-submit) и с Origin/Referer запроса. Значение токена для заголовка
|
|
10
|
+
* читается из той же cookie на клиенте (она HttpOnly, поэтому JS её прочитать
|
|
11
|
+
* не может напрямую — вместо этого сервер один раз, в момент редиректа с "/?t=",
|
|
12
|
+
* даёт странице возможность запомнить токен через query-параметр текущего
|
|
13
|
+
* перехода; если параметр отсутствует (обычный повторный визит на "/"),
|
|
14
|
+
* запросы на чтение всё равно проходят по cookie, а мутации в этом случае
|
|
15
|
+
* требуют, чтобы пользователь заново открыл дашборд по ссылке с токеном).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
(() => {
|
|
19
|
+
"use strict";
|
|
20
|
+
|
|
21
|
+
// Токен для заголовка X-Standkit-Token берём из query-параметра "t" ТЕКУЩЕГО
|
|
22
|
+
// запроса (если он есть — значит, страница только что открыта по ссылке
|
|
23
|
+
// из standkit_hub.__main__ и сервер поставил cookie в этом же обмене).
|
|
24
|
+
const urlParams = new URLSearchParams(window.location.search);
|
|
25
|
+
// Токен сервер инжектит в <meta name="standkit-token"> для аутентифицированного
|
|
26
|
+
// запроса (валидный ?t= ИЛИ session-cookie) — работает и после refresh/чистого "/".
|
|
27
|
+
const metaEl = document.querySelector('meta[name="standkit-token"]');
|
|
28
|
+
const metaToken =
|
|
29
|
+
metaEl && metaEl.content && metaEl.content !== "__STANDKIT_TOKEN__" ? metaEl.content : "";
|
|
30
|
+
let sessionToken =
|
|
31
|
+
metaToken || urlParams.get("t") || sessionStorage.getItem("standkit_token") || "";
|
|
32
|
+
if (sessionToken) {
|
|
33
|
+
sessionStorage.setItem("standkit_token", sessionToken);
|
|
34
|
+
}
|
|
35
|
+
if (urlParams.get("t")) {
|
|
36
|
+
// Токен в адресной строке больше не нужен — уберём из URL.
|
|
37
|
+
window.history.replaceState({}, "", window.location.pathname);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function apiHeaders(mutation) {
|
|
41
|
+
const headers = { "Content-Type": "application/json" };
|
|
42
|
+
if (mutation && sessionToken) {
|
|
43
|
+
headers["X-Standkit-Token"] = sessionToken;
|
|
44
|
+
} else if (!mutation && sessionToken) {
|
|
45
|
+
// Не обязателен для чтения (cookie достаточно), но не мешает.
|
|
46
|
+
headers["X-Standkit-Token"] = sessionToken;
|
|
47
|
+
}
|
|
48
|
+
return headers;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function apiGet(path) {
|
|
52
|
+
const resp = await fetch(path, { credentials: "same-origin", headers: apiHeaders(false) });
|
|
53
|
+
return handleResponse(resp);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function apiSend(method, path, body) {
|
|
57
|
+
const resp = await fetch(path, {
|
|
58
|
+
method,
|
|
59
|
+
credentials: "same-origin",
|
|
60
|
+
headers: apiHeaders(true),
|
|
61
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
62
|
+
});
|
|
63
|
+
return handleResponse(resp);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function handleResponse(resp) {
|
|
67
|
+
let data = null;
|
|
68
|
+
try {
|
|
69
|
+
data = await resp.json();
|
|
70
|
+
} catch (e) {
|
|
71
|
+
data = null;
|
|
72
|
+
}
|
|
73
|
+
if (!resp.ok) {
|
|
74
|
+
const message = (data && data.error) || `HTTP ${resp.status}`;
|
|
75
|
+
throw new Error(message);
|
|
76
|
+
}
|
|
77
|
+
return data;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// --- вкладки ---
|
|
81
|
+
|
|
82
|
+
function setupTabs() {
|
|
83
|
+
document.querySelectorAll(".tab-btn").forEach((btn) => {
|
|
84
|
+
btn.addEventListener("click", () => {
|
|
85
|
+
document.querySelectorAll(".tab-btn").forEach((b) => b.classList.remove("active"));
|
|
86
|
+
document.querySelectorAll(".tab-panel").forEach((p) => p.classList.remove("active"));
|
|
87
|
+
btn.classList.add("active");
|
|
88
|
+
document.getElementById(`tab-${btn.dataset.tab}`).classList.add("active");
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// --- тема (light/dark) ---
|
|
94
|
+
|
|
95
|
+
const THEME_STORAGE_KEY = "standkit_theme";
|
|
96
|
+
|
|
97
|
+
function applyTheme(theme) {
|
|
98
|
+
document.documentElement.setAttribute("data-theme", theme === "dark" ? "dark" : "light");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function setupTheme() {
|
|
102
|
+
const stored = localStorage.getItem(THEME_STORAGE_KEY);
|
|
103
|
+
const prefersDark =
|
|
104
|
+
!stored && window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
|
|
105
|
+
applyTheme(stored || (prefersDark ? "dark" : "light"));
|
|
106
|
+
|
|
107
|
+
document.getElementById("theme-toggle-btn").addEventListener("click", () => {
|
|
108
|
+
const current = document.documentElement.getAttribute("data-theme") === "dark" ? "dark" : "light";
|
|
109
|
+
const next = current === "dark" ? "light" : "dark";
|
|
110
|
+
applyTheme(next);
|
|
111
|
+
localStorage.setItem(THEME_STORAGE_KEY, next);
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// --- модалка "О программе" ---
|
|
116
|
+
|
|
117
|
+
let aboutVersionLoaded = false;
|
|
118
|
+
|
|
119
|
+
function openAboutModal() {
|
|
120
|
+
const overlay = document.getElementById("about-modal-overlay");
|
|
121
|
+
overlay.hidden = false;
|
|
122
|
+
if (!aboutVersionLoaded) {
|
|
123
|
+
const versionEl = document.getElementById("about-modal-version");
|
|
124
|
+
apiGet("/api/version")
|
|
125
|
+
.then((data) => {
|
|
126
|
+
versionEl.textContent = data.version || "н/д";
|
|
127
|
+
aboutVersionLoaded = true;
|
|
128
|
+
})
|
|
129
|
+
.catch((e) => {
|
|
130
|
+
versionEl.textContent = `ошибка: ${e.message}`;
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function closeAboutModal() {
|
|
136
|
+
document.getElementById("about-modal-overlay").hidden = true;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function setupAboutModal() {
|
|
140
|
+
document.getElementById("about-btn").addEventListener("click", openAboutModal);
|
|
141
|
+
document.getElementById("about-modal-close-btn").addEventListener("click", closeAboutModal);
|
|
142
|
+
document.getElementById("about-modal-close-footer-btn").addEventListener("click", closeAboutModal);
|
|
143
|
+
document.getElementById("about-modal-overlay").addEventListener("click", (evt) => {
|
|
144
|
+
// Клик по затемнённому фону (не по самому окну) закрывает модалку.
|
|
145
|
+
if (evt.target.id === "about-modal-overlay") closeAboutModal();
|
|
146
|
+
});
|
|
147
|
+
document.addEventListener("keydown", (evt) => {
|
|
148
|
+
if (evt.key === "Escape" && !document.getElementById("about-modal-overlay").hidden) {
|
|
149
|
+
closeAboutModal();
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// --- стилизованное подтверждение (замена window.confirm), Promise-обёртка ---
|
|
155
|
+
//
|
|
156
|
+
// Переиспользует разметку/классы модалки "О программе" (единый стиль сайта).
|
|
157
|
+
// Используется для Стоп/Рестарт/Очистить Redis — код действий остаётся
|
|
158
|
+
// линейным (await styledConfirm(...)) вместо колбэков.
|
|
159
|
+
|
|
160
|
+
function styledConfirm(title, text) {
|
|
161
|
+
return new Promise((resolve) => {
|
|
162
|
+
const overlay = document.getElementById("confirm-modal-overlay");
|
|
163
|
+
const okBtn = document.getElementById("confirm-modal-ok-btn");
|
|
164
|
+
const cancelBtn = document.getElementById("confirm-modal-cancel-btn");
|
|
165
|
+
const closeBtn = document.getElementById("confirm-modal-close-btn");
|
|
166
|
+
|
|
167
|
+
document.getElementById("confirm-modal-title").textContent = title;
|
|
168
|
+
document.getElementById("confirm-modal-text").textContent = text;
|
|
169
|
+
overlay.hidden = false;
|
|
170
|
+
okBtn.focus();
|
|
171
|
+
|
|
172
|
+
function cleanup(result) {
|
|
173
|
+
overlay.hidden = true;
|
|
174
|
+
okBtn.removeEventListener("click", onOk);
|
|
175
|
+
cancelBtn.removeEventListener("click", onCancel);
|
|
176
|
+
closeBtn.removeEventListener("click", onCancel);
|
|
177
|
+
overlay.removeEventListener("click", onOverlayClick);
|
|
178
|
+
document.removeEventListener("keydown", onKeydown);
|
|
179
|
+
resolve(result);
|
|
180
|
+
}
|
|
181
|
+
function onOk() {
|
|
182
|
+
cleanup(true);
|
|
183
|
+
}
|
|
184
|
+
function onCancel() {
|
|
185
|
+
cleanup(false);
|
|
186
|
+
}
|
|
187
|
+
function onOverlayClick(evt) {
|
|
188
|
+
if (evt.target.id === "confirm-modal-overlay") cleanup(false);
|
|
189
|
+
}
|
|
190
|
+
function onKeydown(evt) {
|
|
191
|
+
if (evt.key === "Escape") cleanup(false);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
okBtn.addEventListener("click", onOk);
|
|
195
|
+
cancelBtn.addEventListener("click", onCancel);
|
|
196
|
+
closeBtn.addEventListener("click", onCancel);
|
|
197
|
+
overlay.addEventListener("click", onOverlayClick);
|
|
198
|
+
document.addEventListener("keydown", onKeydown);
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// --- область статуса действий (тост над таблицей стендов) ---
|
|
203
|
+
|
|
204
|
+
let actionStatusTimer = null;
|
|
205
|
+
|
|
206
|
+
function showActionStatus(message, isError) {
|
|
207
|
+
const el = document.getElementById("action-status");
|
|
208
|
+
el.textContent = message;
|
|
209
|
+
el.classList.toggle("action-status-error", !!isError);
|
|
210
|
+
el.classList.toggle("action-status-ok", !isError);
|
|
211
|
+
el.classList.add("action-status-visible");
|
|
212
|
+
if (actionStatusTimer) {
|
|
213
|
+
clearTimeout(actionStatusTimer);
|
|
214
|
+
actionStatusTimer = null;
|
|
215
|
+
}
|
|
216
|
+
// Успешные сообщения гаснут сами через паузу, ошибки остаются, пока их не сменит новое действие.
|
|
217
|
+
if (!isError) {
|
|
218
|
+
actionStatusTimer = setTimeout(() => {
|
|
219
|
+
el.classList.remove("action-status-visible");
|
|
220
|
+
}, 6000);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// --- бэйджи статуса ---
|
|
225
|
+
|
|
226
|
+
function badgeClass(state) {
|
|
227
|
+
switch (state) {
|
|
228
|
+
case "ok":
|
|
229
|
+
return "badge badge-ok";
|
|
230
|
+
case "down":
|
|
231
|
+
return "badge badge-down";
|
|
232
|
+
case "skipped":
|
|
233
|
+
return "badge badge-skipped";
|
|
234
|
+
default:
|
|
235
|
+
return "badge badge-unknown";
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function badge(state) {
|
|
240
|
+
return `<span class="${badgeClass(state)}">${state || "unknown"}</span>`;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function processBadge(state) {
|
|
244
|
+
const label = state === "ok" ? "up" : state === "down" ? "down" : state || "unknown";
|
|
245
|
+
return `<span class="${badgeClass(state)}">${label}</span>`;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function valueClass(state) {
|
|
249
|
+
switch (state) {
|
|
250
|
+
case "ok":
|
|
251
|
+
return "value-ok";
|
|
252
|
+
case "down":
|
|
253
|
+
return "value-down";
|
|
254
|
+
case "skipped":
|
|
255
|
+
return "value-skipped";
|
|
256
|
+
default:
|
|
257
|
+
return "value-unknown";
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function escapeHtml(text) {
|
|
262
|
+
const div = document.createElement("div");
|
|
263
|
+
div.textContent = text;
|
|
264
|
+
return div.innerHTML;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function valueSpan(text, state) {
|
|
268
|
+
return `<span class="value-cell ${valueClass(state)}">${escapeHtml(text)}</span>`;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// --- иконки действий (инлайн-SVG, без внешних шрифтов/CDN) ---
|
|
272
|
+
|
|
273
|
+
const ICON_PLAY =
|
|
274
|
+
'<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor"><path d="M4 2.5v11l9-5.5-9-5.5z"/></svg>';
|
|
275
|
+
const ICON_STOP =
|
|
276
|
+
'<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor"><rect x="3.5" y="3.5" width="9" height="9" rx="1"/></svg>';
|
|
277
|
+
const ICON_RESTART =
|
|
278
|
+
'<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M13.2 8A5.2 5.2 0 1 1 10.9 3.6"/><path d="M13.4 2.6v3.4h-3.4"/></svg>';
|
|
279
|
+
// Корзина ("очистить") — кнопка "Очистить Redis". Форма отличима от прочих
|
|
280
|
+
// трёх (play/stop/restart), чтобы её не путали по силуэту.
|
|
281
|
+
const ICON_REDIS_CLEAR =
|
|
282
|
+
'<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M3 4.2h10"/><path d="M6.4 4.2V2.9c0-.4.3-.7.7-.7h1.8c.4 0 .7.3.7.7v1.3"/><path d="M4.6 4.2l.6 8.2c.05.7.6 1.2 1.3 1.2h3c.7 0 1.25-.5 1.3-1.2l.6-8.2"/><path d="M6.7 6.6v4.3M9.3 6.6v4.3"/></svg>';
|
|
283
|
+
|
|
284
|
+
// --- клиентское состояние "стенд запускается" (см. onStandAction/checkStartingTransitions) ---
|
|
285
|
+
//
|
|
286
|
+
// POST /start только ЗАПУСКАЕТ процесс — он ещё прогревается (dotnet/
|
|
287
|
+
// компиляция схем занимают ~10-40с, иногда дольше), поэтому "Стенд запущен"
|
|
288
|
+
// пишем только по факту готовности (http.state === "ok" на очередном
|
|
289
|
+
// опросе), а не сразу после ответа POST.
|
|
290
|
+
//
|
|
291
|
+
// ВАЖНО: пока стенд в состоянии "starting", TCP-порт ещё может не
|
|
292
|
+
// слушаться — это НОРМА прогрева, а не провал. Раньше клиент ошибочно
|
|
293
|
+
// объявлял "не поднялся" по одному лишь process.state==="down" во время
|
|
294
|
+
// прогрева. Теперь единственный вердикт "успех" — http.state==="ok";
|
|
295
|
+
// единственный вердикт "мягкий провал" — истечение таймаута прогрева
|
|
296
|
+
// (реально упавший процесс пользователь увидит в панели состояния/логах,
|
|
297
|
+
// здесь мы больше не гадаем по process.state).
|
|
298
|
+
|
|
299
|
+
const STARTING_SOFT_TIMEOUT_MS = 180000; // 3 минуты — мягкий таймаут прогрева
|
|
300
|
+
|
|
301
|
+
const startingStands = new Map(); // name -> timestamp старта (Date.now())
|
|
302
|
+
let lastStandsData = [];
|
|
303
|
+
let fastPollTimer = null;
|
|
304
|
+
|
|
305
|
+
function ensureFastPolling() {
|
|
306
|
+
if (fastPollTimer) return;
|
|
307
|
+
fastPollTimer = setInterval(() => {
|
|
308
|
+
if (startingStands.size === 0) {
|
|
309
|
+
clearInterval(fastPollTimer);
|
|
310
|
+
fastPollTimer = null;
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
refreshStands();
|
|
314
|
+
}, 2000);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function checkStartingTransitions(stands) {
|
|
318
|
+
const now = Date.now();
|
|
319
|
+
stands.forEach((s) => {
|
|
320
|
+
if (!startingStands.has(s.name)) return;
|
|
321
|
+
const httpState = s.http && s.http.state;
|
|
322
|
+
if (httpState === "ok") {
|
|
323
|
+
startingStands.delete(s.name);
|
|
324
|
+
showActionStatus(`Стенд ${s.name} запущен`, false);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
const startedAt = startingStands.get(s.name);
|
|
328
|
+
if (now - startedAt >= STARTING_SOFT_TIMEOUT_MS) {
|
|
329
|
+
// Мягкий таймаут: не факт провала — стенд может просто долго
|
|
330
|
+
// прогреваться (или быть недоступен по другой причине). Снимаем
|
|
331
|
+
// спиннер, но НЕ объявляем "не поднялся" — реальную картину
|
|
332
|
+
// пользователь увидит в панели "Текущее состояние"/логах.
|
|
333
|
+
startingStands.delete(s.name);
|
|
334
|
+
showActionStatus(
|
|
335
|
+
`Стенд ${s.name} всё ещё запускается — проверьте «Текущее состояние»/логи`,
|
|
336
|
+
false
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
// Иначе — прогрев продолжается (process.state==="down" на этом этапе
|
|
340
|
+
// это норма, порт ещё не открыт): оставляем спиннер, вердикт не выносим.
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function processCell(s) {
|
|
345
|
+
if (startingStands.has(s.name)) {
|
|
346
|
+
return '<span class="process-starting"><span class="mini-spinner" aria-hidden="true"></span>Запускается…</span>';
|
|
347
|
+
}
|
|
348
|
+
return processBadge(s.process ? s.process.state : "unknown");
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function actionButtons(s) {
|
|
352
|
+
const processState = s.process ? s.process.state : "unknown";
|
|
353
|
+
const isStarting = startingStands.has(s.name);
|
|
354
|
+
const startDisabled = processState === "ok" || isStarting;
|
|
355
|
+
const stopDisabled = processState === "down";
|
|
356
|
+
const restartDisabled = processState === "down";
|
|
357
|
+
const redisNumber = s.redis && s.redis.number;
|
|
358
|
+
const redisKnown = redisNumber !== null && redisNumber !== undefined;
|
|
359
|
+
const redisDisabled = !redisKnown;
|
|
360
|
+
const redisTitle = redisKnown ? "Очистить Redis" : "redis не настроен у стенда";
|
|
361
|
+
const name = escapeHtml(s.name);
|
|
362
|
+
return `
|
|
363
|
+
<button class="icon-btn icon-btn-play" data-action="start" data-name="${name}" title="Запустить"${startDisabled ? " disabled" : ""}>${ICON_PLAY}</button>
|
|
364
|
+
<button class="icon-btn icon-btn-stop" data-action="stop" data-name="${name}" title="Остановить"${stopDisabled ? " disabled" : ""}>${ICON_STOP}</button>
|
|
365
|
+
<button class="icon-btn icon-btn-restart" data-action="restart" data-name="${name}" title="Перезапустить"${restartDisabled ? " disabled" : ""}>${ICON_RESTART}</button>
|
|
366
|
+
<button class="icon-btn icon-btn-redis" data-action="redis-clear" data-name="${name}" title="${escapeHtml(redisTitle)}"${redisDisabled ? " disabled" : ""}>${ICON_REDIS_CLEAR}</button>
|
|
367
|
+
`;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// --- стенды ---
|
|
371
|
+
|
|
372
|
+
let selectedStand = null;
|
|
373
|
+
|
|
374
|
+
async function refreshStands() {
|
|
375
|
+
const errorEl = document.getElementById("stands-error");
|
|
376
|
+
errorEl.textContent = "";
|
|
377
|
+
try {
|
|
378
|
+
const data = await apiGet("/api/stands");
|
|
379
|
+
lastStandsData = data.stands || [];
|
|
380
|
+
checkStartingTransitions(lastStandsData);
|
|
381
|
+
renderStands(lastStandsData);
|
|
382
|
+
updateLogsMenuState();
|
|
383
|
+
setConnStatus(true);
|
|
384
|
+
} catch (e) {
|
|
385
|
+
errorEl.textContent = `Ошибка обновления: ${e.message}`;
|
|
386
|
+
setConnStatus(false);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function renderStands(stands) {
|
|
391
|
+
const tbody = document.getElementById("stands-tbody");
|
|
392
|
+
tbody.innerHTML = "";
|
|
393
|
+
stands.forEach((s) => {
|
|
394
|
+
const http = s.http || {};
|
|
395
|
+
const db = s.db || {};
|
|
396
|
+
const tr = document.createElement("tr");
|
|
397
|
+
tr.dataset.name = s.name;
|
|
398
|
+
if (s.name === selectedStand) tr.classList.add("selected");
|
|
399
|
+
tr.innerHTML = `
|
|
400
|
+
<td>${escapeHtml(s.name)}</td>
|
|
401
|
+
<td>${escapeHtml(s.transport)}</td>
|
|
402
|
+
<td>${processCell(s)}</td>
|
|
403
|
+
<td>${valueSpan(http.url || "—", http.state)}</td>
|
|
404
|
+
<td>${valueSpan(db.name || "—", db.state)}</td>
|
|
405
|
+
<td class="row-actions">${actionButtons(s)}</td>
|
|
406
|
+
`;
|
|
407
|
+
tr.addEventListener("click", (evt) => {
|
|
408
|
+
if (evt.target.closest("button")) return;
|
|
409
|
+
selectStand(s.name);
|
|
410
|
+
});
|
|
411
|
+
tbody.appendChild(tr);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
tbody.querySelectorAll("button[data-action]").forEach((btn) => {
|
|
415
|
+
btn.addEventListener("click", (evt) => {
|
|
416
|
+
evt.stopPropagation();
|
|
417
|
+
if (btn.disabled) return;
|
|
418
|
+
onStandAction(btn.dataset.name, btn.dataset.action);
|
|
419
|
+
});
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function selectStand(name) {
|
|
424
|
+
selectedStand = name;
|
|
425
|
+
document.querySelectorAll(".stands-table tbody tr").forEach((tr) => {
|
|
426
|
+
tr.classList.toggle("selected", tr.dataset.name === name);
|
|
427
|
+
});
|
|
428
|
+
updateLogsMenuState();
|
|
429
|
+
refreshState();
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// --- подтверждения (стилизованная модалка) + обратная связь по действиям ---
|
|
433
|
+
|
|
434
|
+
const _ACTION_LABELS = { start: "старт", stop: "остановка", restart: "рестарт", "redis-clear": "очистка Redis" };
|
|
435
|
+
|
|
436
|
+
async function onStandAction(name, action) {
|
|
437
|
+
if (action === "stop") {
|
|
438
|
+
const confirmed = await styledConfirm("Остановка стенда", `Остановить стенд ${name}?`);
|
|
439
|
+
if (!confirmed) return;
|
|
440
|
+
} else if (action === "restart") {
|
|
441
|
+
const confirmed = await styledConfirm("Перезапуск стенда", `Перезапустить стенд ${name}?`);
|
|
442
|
+
if (!confirmed) return;
|
|
443
|
+
} else if (action === "redis-clear") {
|
|
444
|
+
const confirmed = await styledConfirm(
|
|
445
|
+
"Очистка Redis",
|
|
446
|
+
`Очистить Redis стенда ${name}? Это действие необратимо.`
|
|
447
|
+
);
|
|
448
|
+
if (!confirmed) return;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const errorEl = document.getElementById("stands-error");
|
|
452
|
+
errorEl.textContent = "";
|
|
453
|
+
|
|
454
|
+
if (action === "start") {
|
|
455
|
+
// ЧЕСТНЫЙ старт: только сообщаем, что запуск отправлен, "запущен" —
|
|
456
|
+
// только по факту готовности (см. checkStartingTransitions).
|
|
457
|
+
showActionStatus(`Запуск стенда ${name}…`, false);
|
|
458
|
+
} else if (action === "restart") {
|
|
459
|
+
showActionStatus(`Запущен рестарт стенда ${name}`, false);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
try {
|
|
463
|
+
const data = await apiSend("POST", `/api/stand/${encodeURIComponent(name)}/${action}`);
|
|
464
|
+
const pidSuffix = data && typeof data.pid === "number" ? ` (pid ${data.pid})` : "";
|
|
465
|
+
if (action === "start") {
|
|
466
|
+
startingStands.set(name, Date.now());
|
|
467
|
+
renderStands(lastStandsData);
|
|
468
|
+
ensureFastPolling();
|
|
469
|
+
} else if (action === "stop") {
|
|
470
|
+
showActionStatus(`Стенд ${name} остановлен`, false);
|
|
471
|
+
} else if (action === "restart") {
|
|
472
|
+
showActionStatus(`Стенд ${name} перезапущен${pidSuffix}`, false);
|
|
473
|
+
} else if (action === "redis-clear") {
|
|
474
|
+
showActionStatus(data && data.message ? data.message : `Redis стенда ${name} очищен`, false);
|
|
475
|
+
}
|
|
476
|
+
await refreshStands();
|
|
477
|
+
if (name === selectedStand) refreshState();
|
|
478
|
+
} catch (e) {
|
|
479
|
+
const label = _ACTION_LABELS[action] || action;
|
|
480
|
+
showActionStatus(`Ошибка (${label} стенда ${name}): ${e.message}`, true);
|
|
481
|
+
errorEl.textContent = `Ошибка (${name}/${action}): ${e.message}`;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// --- текущее состояние выбранного стенда (только консоль стенда, source=stand) ---
|
|
486
|
+
|
|
487
|
+
async function refreshState() {
|
|
488
|
+
if (!selectedStand) return;
|
|
489
|
+
document.getElementById("state-stand-name").textContent = `(${selectedStand})`;
|
|
490
|
+
try {
|
|
491
|
+
const data = await apiGet(`/api/stand/${encodeURIComponent(selectedStand)}/state?source=stand`);
|
|
492
|
+
document.getElementById("state-output").textContent = data.text || "";
|
|
493
|
+
} catch (e) {
|
|
494
|
+
document.getElementById("state-output").textContent = `Ошибка чтения состояния: ${e.message}`;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// --- "Открыть папку логов" — сплит-кнопка с выбором источника ---
|
|
499
|
+
//
|
|
500
|
+
// У стенда ДВА разных места логов (см. standkit_hub/logs_browser.py):
|
|
501
|
+
// "stand" — логи самого стенда (<stand_dir>/logs, платформа/сборка) и
|
|
502
|
+
// "bpmkit" — логи BPMkit-ПРОЕКТА (scaffold, <docs_folder>/logs, куда
|
|
503
|
+
// пишутся логи разработки). Это разные каталоги — основная кнопка
|
|
504
|
+
// выполняет последний выбранный источник (по умолчанию "stand"), стрелка
|
|
505
|
+
// раскрывает меню с явным выбором. Пункт "Логи BPMkit-проекта"
|
|
506
|
+
// дизейблится, если у выбранного стенда нет logs.bpmkit_available (см.
|
|
507
|
+
// /api/stands — не задан extra["docs_folder"] либо нет папки
|
|
508
|
+
// <docs_folder>/logs).
|
|
509
|
+
|
|
510
|
+
let logsFolderSource = "stand";
|
|
511
|
+
|
|
512
|
+
async function openLogsFolder(source) {
|
|
513
|
+
if (!selectedStand) return;
|
|
514
|
+
try {
|
|
515
|
+
const data = await apiSend(
|
|
516
|
+
"POST",
|
|
517
|
+
`/api/stand/${encodeURIComponent(selectedStand)}/logs/open-folder?source=${encodeURIComponent(source)}`
|
|
518
|
+
);
|
|
519
|
+
showActionStatus(
|
|
520
|
+
data.message || (data.ok ? "Папка логов открыта." : "Не удалось открыть папку логов."),
|
|
521
|
+
!data.ok
|
|
522
|
+
);
|
|
523
|
+
} catch (e) {
|
|
524
|
+
showActionStatus(`Ошибка открытия папки логов: ${e.message}`, true);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function closeLogsMenu() {
|
|
529
|
+
const menu = document.getElementById("log-folder-menu");
|
|
530
|
+
const toggleBtn = document.getElementById("log-folder-menu-toggle-btn");
|
|
531
|
+
menu.hidden = true;
|
|
532
|
+
toggleBtn.setAttribute("aria-expanded", "false");
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function toggleLogsMenu() {
|
|
536
|
+
const menu = document.getElementById("log-folder-menu");
|
|
537
|
+
const toggleBtn = document.getElementById("log-folder-menu-toggle-btn");
|
|
538
|
+
const willOpen = menu.hidden;
|
|
539
|
+
menu.hidden = !willOpen;
|
|
540
|
+
toggleBtn.setAttribute("aria-expanded", willOpen ? "true" : "false");
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Дизейблит пункт меню "Логи BPMkit-проекта", если у ВЫБРАННОГО стенда
|
|
544
|
+
// источник bpmkit недоступен (нет extra["docs_folder"] или нет папки
|
|
545
|
+
// <docs_folder>/logs) — см. /api/stands::logs.bpmkit_available. Вызывается
|
|
546
|
+
// после каждого refreshStands() и при смене выбранного стенда, т.к.
|
|
547
|
+
// доступность может измениться между обновлениями (например, после
|
|
548
|
+
// первого запуска разработки в scaffold-проекте).
|
|
549
|
+
function updateLogsMenuState() {
|
|
550
|
+
const bpmkitItem = document.getElementById("log-folder-menu-bpmkit");
|
|
551
|
+
const stand = selectedStand ? lastStandsData.find((s) => s.name === selectedStand) : null;
|
|
552
|
+
const available = Boolean(stand && stand.logs && stand.logs.bpmkit_available);
|
|
553
|
+
bpmkitItem.disabled = !available;
|
|
554
|
+
bpmkitItem.title = available
|
|
555
|
+
? "Логи BPMkit-проекта"
|
|
556
|
+
: "у стенда не задан docs_folder / нет папки logs";
|
|
557
|
+
// Если ранее выбранный источник стал недоступен — откатываемся на
|
|
558
|
+
// всегда-доступный "stand" по умолчанию.
|
|
559
|
+
if (logsFolderSource === "bpmkit" && !available) {
|
|
560
|
+
logsFolderSource = "stand";
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function setupStatePanel() {
|
|
565
|
+
document.getElementById("log-folder-open-btn").addEventListener("click", () => {
|
|
566
|
+
openLogsFolder(logsFolderSource);
|
|
567
|
+
});
|
|
568
|
+
document.getElementById("log-folder-menu-toggle-btn").addEventListener("click", (evt) => {
|
|
569
|
+
evt.stopPropagation();
|
|
570
|
+
toggleLogsMenu();
|
|
571
|
+
});
|
|
572
|
+
document.querySelectorAll(".split-btn-menu-item").forEach((item) => {
|
|
573
|
+
item.addEventListener("click", (evt) => {
|
|
574
|
+
evt.stopPropagation();
|
|
575
|
+
if (item.disabled) return;
|
|
576
|
+
logsFolderSource = item.dataset.source;
|
|
577
|
+
closeLogsMenu();
|
|
578
|
+
openLogsFolder(logsFolderSource);
|
|
579
|
+
});
|
|
580
|
+
});
|
|
581
|
+
document.addEventListener("click", (evt) => {
|
|
582
|
+
const splitBtn = document.getElementById("log-folder-split-btn");
|
|
583
|
+
if (!splitBtn.contains(evt.target)) closeLogsMenu();
|
|
584
|
+
});
|
|
585
|
+
document.addEventListener("keydown", (evt) => {
|
|
586
|
+
if (evt.key === "Escape") closeLogsMenu();
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function setConnStatus(ok) {
|
|
591
|
+
// Понятный индикатор связи с хабом: цветная точка + короткая подпись.
|
|
592
|
+
// Раньше здесь было одинокое "…" — его путали с неактивным "меню из
|
|
593
|
+
// трёх точек". До первого ответа (см. init/index.html) точка серая и
|
|
594
|
+
// подпись "подключение…"; после первого запроса — зелёная/красная.
|
|
595
|
+
const wrapEl = document.getElementById("conn-status");
|
|
596
|
+
const dotEl = document.getElementById("conn-status-dot");
|
|
597
|
+
const labelEl = document.getElementById("conn-status-label");
|
|
598
|
+
dotEl.classList.remove("conn-status-dot-pending", "conn-status-dot-ok", "conn-status-dot-down");
|
|
599
|
+
dotEl.classList.add(ok ? "conn-status-dot-ok" : "conn-status-dot-down");
|
|
600
|
+
labelEl.textContent = ok ? "онлайн" : "нет связи";
|
|
601
|
+
wrapEl.title = ok ? "Связь с хабом установлена" : "Нет связи с хабом";
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// --- локальный агент ---
|
|
605
|
+
|
|
606
|
+
async function refreshAgentStatus() {
|
|
607
|
+
const el = document.getElementById("agent-status");
|
|
608
|
+
try {
|
|
609
|
+
const data = await apiGet("/api/agent/status");
|
|
610
|
+
el.textContent = data.running ? "запущен" : "остановлен";
|
|
611
|
+
} catch (e) {
|
|
612
|
+
el.textContent = `ошибка: ${e.message}`;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function setupAgentTab() {
|
|
617
|
+
document.getElementById("agent-start-btn").addEventListener("click", async () => {
|
|
618
|
+
const errorEl = document.getElementById("agent-error");
|
|
619
|
+
errorEl.textContent = "";
|
|
620
|
+
try {
|
|
621
|
+
await apiSend("POST", "/api/agent/start");
|
|
622
|
+
await refreshAgentStatus();
|
|
623
|
+
} catch (e) {
|
|
624
|
+
errorEl.textContent = e.message;
|
|
625
|
+
}
|
|
626
|
+
});
|
|
627
|
+
document.getElementById("agent-stop-btn").addEventListener("click", async () => {
|
|
628
|
+
const errorEl = document.getElementById("agent-error");
|
|
629
|
+
errorEl.textContent = "";
|
|
630
|
+
try {
|
|
631
|
+
await apiSend("POST", "/api/agent/stop");
|
|
632
|
+
await refreshAgentStatus();
|
|
633
|
+
} catch (e) {
|
|
634
|
+
errorEl.textContent = e.message;
|
|
635
|
+
}
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// --- настройки ---
|
|
640
|
+
|
|
641
|
+
const SETTINGS_FIELDS = [
|
|
642
|
+
"registry_path",
|
|
643
|
+
"run_dir",
|
|
644
|
+
"log_dir",
|
|
645
|
+
"refresh_interval_sec",
|
|
646
|
+
"agent_host",
|
|
647
|
+
"agent_port",
|
|
648
|
+
"token_ref",
|
|
649
|
+
"readonly_token_ref",
|
|
650
|
+
"tls_cert",
|
|
651
|
+
"tls_key",
|
|
652
|
+
"tls_client_ca",
|
|
653
|
+
"audit_log",
|
|
654
|
+
"lockout_max_failures",
|
|
655
|
+
"lockout_window_sec",
|
|
656
|
+
];
|
|
657
|
+
|
|
658
|
+
let currentAgents = [];
|
|
659
|
+
|
|
660
|
+
function renderAgentsList() {
|
|
661
|
+
const container = document.getElementById("agents-list");
|
|
662
|
+
container.innerHTML = "";
|
|
663
|
+
currentAgents.forEach((agent, idx) => {
|
|
664
|
+
const row = document.createElement("div");
|
|
665
|
+
row.className = "agent-row";
|
|
666
|
+
row.innerHTML = `
|
|
667
|
+
<input type="text" placeholder="имя" data-field="name" value="${agent.name || ""}" />
|
|
668
|
+
<input type="text" placeholder="url (https://host:8765)" data-field="url" value="${agent.url || ""}" />
|
|
669
|
+
<input type="text" placeholder="token_ref" data-field="token_ref" value="${agent.token_ref || ""}" />
|
|
670
|
+
<button type="button" data-idx="${idx}">Удалить</button>
|
|
671
|
+
`;
|
|
672
|
+
row.querySelectorAll("input").forEach((input) => {
|
|
673
|
+
input.addEventListener("input", () => {
|
|
674
|
+
currentAgents[idx][input.dataset.field] = input.value;
|
|
675
|
+
});
|
|
676
|
+
});
|
|
677
|
+
row.querySelector("button").addEventListener("click", () => {
|
|
678
|
+
currentAgents.splice(idx, 1);
|
|
679
|
+
renderAgentsList();
|
|
680
|
+
});
|
|
681
|
+
container.appendChild(row);
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
async function loadSettings() {
|
|
686
|
+
const data = await apiGet("/api/settings");
|
|
687
|
+
const form = document.getElementById("settings-form");
|
|
688
|
+
SETTINGS_FIELDS.forEach((field) => {
|
|
689
|
+
const input = form.elements.namedItem(field);
|
|
690
|
+
if (input) input.value = data[field] ?? "";
|
|
691
|
+
});
|
|
692
|
+
form.elements.namedItem("insecure").checked = !!data.insecure;
|
|
693
|
+
currentAgents = (data.agents || []).map((a) => ({ ...a }));
|
|
694
|
+
renderAgentsList();
|
|
695
|
+
await refreshSecretStatuses();
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
async function refreshSecretStatuses() {
|
|
699
|
+
for (const field of ["token_ref", "readonly_token_ref"]) {
|
|
700
|
+
const statusEl = document.querySelector(`[data-ref-status="${field}"]`);
|
|
701
|
+
const input = document.getElementById("settings-form").elements.namedItem(field);
|
|
702
|
+
const ref = input ? input.value : "";
|
|
703
|
+
if (!statusEl) continue;
|
|
704
|
+
if (!ref) {
|
|
705
|
+
statusEl.textContent = "";
|
|
706
|
+
continue;
|
|
707
|
+
}
|
|
708
|
+
try {
|
|
709
|
+
const data = await apiGet(`/api/secret/${encodeURIComponent(ref)}`);
|
|
710
|
+
statusEl.textContent = data.has_secret ? "секрет задан" : "секрет НЕ задан";
|
|
711
|
+
statusEl.style.color = data.has_secret ? "var(--bpmkit-ok)" : "var(--bpmkit-down)";
|
|
712
|
+
} catch (e) {
|
|
713
|
+
statusEl.textContent = `ошибка: ${e.message}`;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function setupSettingsForm() {
|
|
719
|
+
const form = document.getElementById("settings-form");
|
|
720
|
+
form.addEventListener("submit", async (evt) => {
|
|
721
|
+
evt.preventDefault();
|
|
722
|
+
const statusEl = document.getElementById("settings-status");
|
|
723
|
+
statusEl.textContent = "";
|
|
724
|
+
const payload = {};
|
|
725
|
+
SETTINGS_FIELDS.forEach((field) => {
|
|
726
|
+
const input = form.elements.namedItem(field);
|
|
727
|
+
if (!input) return;
|
|
728
|
+
payload[field] = input.type === "number" ? Number(input.value) : input.value;
|
|
729
|
+
});
|
|
730
|
+
payload.insecure = form.elements.namedItem("insecure").checked;
|
|
731
|
+
payload.agents = currentAgents;
|
|
732
|
+
try {
|
|
733
|
+
await apiSend("POST", "/api/settings", payload);
|
|
734
|
+
statusEl.textContent = "Настройки сохранены";
|
|
735
|
+
await refreshSecretStatuses();
|
|
736
|
+
} catch (e) {
|
|
737
|
+
statusEl.textContent = `Ошибка сохранения: ${e.message}`;
|
|
738
|
+
}
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
document.getElementById("add-agent-btn").addEventListener("click", () => {
|
|
742
|
+
currentAgents.push({ name: "", url: "", token_ref: "" });
|
|
743
|
+
renderAgentsList();
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
document.querySelectorAll(".set-secret-btn").forEach((btn) => {
|
|
747
|
+
btn.addEventListener("click", async () => {
|
|
748
|
+
const fieldName = btn.dataset.refField;
|
|
749
|
+
const input = form.elements.namedItem(fieldName);
|
|
750
|
+
const ref = input ? input.value : "";
|
|
751
|
+
if (!ref) {
|
|
752
|
+
window.alert("Сначала укажите ссылку на секрет (*_ref) в поле выше.");
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
const value = window.prompt(`Значение секрета для '${ref}' (не будет отображено повторно):`);
|
|
756
|
+
if (value === null || value === "") return;
|
|
757
|
+
try {
|
|
758
|
+
await apiSend("POST", `/api/secret/${encodeURIComponent(ref)}`, { value });
|
|
759
|
+
await refreshSecretStatuses();
|
|
760
|
+
} catch (e) {
|
|
761
|
+
window.alert(`Не удалось задать секрет: ${e.message}`);
|
|
762
|
+
}
|
|
763
|
+
});
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
document.getElementById("install-shortcut-btn").addEventListener("click", async () => {
|
|
767
|
+
const statusEl = document.getElementById("settings-status");
|
|
768
|
+
try {
|
|
769
|
+
const data = await apiSend("POST", "/api/shortcut/install");
|
|
770
|
+
statusEl.textContent = data.message;
|
|
771
|
+
} catch (e) {
|
|
772
|
+
statusEl.textContent = `Ошибка: ${e.message}`;
|
|
773
|
+
}
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// --- инициализация ---
|
|
778
|
+
|
|
779
|
+
function init() {
|
|
780
|
+
setupTheme();
|
|
781
|
+
setupTabs();
|
|
782
|
+
setupAboutModal();
|
|
783
|
+
setupAgentTab();
|
|
784
|
+
setupSettingsForm();
|
|
785
|
+
setupStatePanel();
|
|
786
|
+
document.getElementById("refresh-stands-btn").addEventListener("click", refreshStands);
|
|
787
|
+
|
|
788
|
+
refreshStands();
|
|
789
|
+
refreshAgentStatus();
|
|
790
|
+
loadSettings().catch((e) => {
|
|
791
|
+
document.getElementById("settings-status").textContent = `Ошибка загрузки настроек: ${e.message}`;
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
const intervalMs = 10000;
|
|
795
|
+
setInterval(() => {
|
|
796
|
+
refreshStands();
|
|
797
|
+
refreshAgentStatus();
|
|
798
|
+
if (selectedStand) refreshState();
|
|
799
|
+
}, intervalMs);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
document.addEventListener("DOMContentLoaded", init);
|
|
803
|
+
})();
|