feat: живой цикл M2M с НРД + мастер установки ключа на флешку
Инфраструктура M2M (живой обмен с НРД через ИШ): - обработка M2MTransferResponse: ERROR(M2Mxx) → заявка Отклонена, сохранение ответа; INFO → ждём Decision; идемпотентность поллера - fallback-корреляция ответов с нулевым GUID (M2M14/M2M17) по FIFO - сырой XML ответа НРД в карточке заявки (для пересылки в ТП) - тестовый пакет роботу приведён к эталону m2m_robot_samples (CostInfo=Yes, 4 бумаги, IsolationStatus, DocumentSeries=сценарий); override паспорта - редирект из теста сразу в карточку заявки Мастер установки ключа Валидаты на флешку (admin/setup/keywizard): - пошаговый: загрузка .7z+пароль → выбор флешки → запись → справочник сертификатов (CRL) → перезапуск+проверка ИШ → готово - привилегированный воркер (bj-keymedia) в host-namespace через файл-обмен, bj-server остаётся в песочнице - сохранение структуры профиля архива (spr<N>), перечисление съёмных USB Прочее: - пакет-доказательство для ТП НРД + форма регистрации участника M2M - эталонные образцы робота (DOC/m2m_robot_samples) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
// Минимальный клиент wizard'а: рендерит страницы, ловит события из SSE,
|
||||
// отправляет POST'ы на backend для перехода между стадиями.
|
||||
|
||||
let state = {
|
||||
stage: "welcome",
|
||||
precheck: [],
|
||||
config: {},
|
||||
steps: [],
|
||||
errorMsg: "",
|
||||
};
|
||||
|
||||
const STAGE_ORDER = ["welcome", "precheck", "config", "installing", "done"];
|
||||
const STEP_ICONS = {
|
||||
pending: "○",
|
||||
running: "◐",
|
||||
done: "✓",
|
||||
skipped: "—",
|
||||
failed: "✗",
|
||||
};
|
||||
|
||||
function $(sel) { return document.querySelector(sel); }
|
||||
function $$(sel) { return [...document.querySelectorAll(sel)]; }
|
||||
|
||||
function render() {
|
||||
// stepper
|
||||
$$("#stepper span").forEach(el => {
|
||||
el.classList.remove("active", "done");
|
||||
const stage = el.dataset.stage;
|
||||
if (stage === state.stage) el.classList.add("active");
|
||||
if (STAGE_ORDER.indexOf(stage) < STAGE_ORDER.indexOf(state.stage)) el.classList.add("done");
|
||||
});
|
||||
// pages
|
||||
$$(".page").forEach(p => p.classList.toggle("active", p.dataset.stage === state.stage));
|
||||
|
||||
if (state.stage === "precheck") renderPrecheck();
|
||||
if (state.stage === "installing" || state.stage === "done") renderSteps();
|
||||
if (state.stage === "error") $("#error-message").textContent = state.errorMsg || "(нет деталей)";
|
||||
if (state.stage === "done") {
|
||||
// подставляем хост машины в админскую ссылку
|
||||
const adminURL = window.location.protocol + "//" + window.location.hostname + ":8080/admin/setup";
|
||||
$("#adminLink").href = adminURL;
|
||||
$("#adminLink").textContent = "Перейти в " + adminURL + " →";
|
||||
}
|
||||
}
|
||||
|
||||
function renderPrecheck() {
|
||||
const root = $("#precheck-results");
|
||||
root.innerHTML = "";
|
||||
let allOK = true;
|
||||
for (const r of state.precheck || []) {
|
||||
const div = document.createElement("div");
|
||||
div.className = "check " + (r.ok ? "ok" : "bad");
|
||||
div.innerHTML = `
|
||||
<span class="check-icon">${r.ok ? "✓" : "✗"}</span>
|
||||
<div>
|
||||
<div class="check-title">${escapeHTML(r.title)}</div>
|
||||
${r.message ? `<div class="check-msg">${escapeHTML(r.message)}</div>` : ""}
|
||||
</div>`;
|
||||
root.appendChild(div);
|
||||
if (!r.ok) allOK = false;
|
||||
}
|
||||
$("#goConfigBtn").disabled = !allOK;
|
||||
}
|
||||
|
||||
function renderSteps() {
|
||||
const root = $("#step-list");
|
||||
root.innerHTML = "";
|
||||
let done = 0;
|
||||
for (const s of state.steps || []) {
|
||||
const li = document.createElement("li");
|
||||
li.className = "step-" + s.status;
|
||||
li.innerHTML = `
|
||||
<span class="step-icon">${STEP_ICONS[s.status] || "○"}</span>
|
||||
<div>
|
||||
<div class="step-title">${escapeHTML(s.title)}</div>
|
||||
${s.message ? `<div class="step-msg">${escapeHTML(s.message)}</div>` : ""}
|
||||
</div>`;
|
||||
root.appendChild(li);
|
||||
if (s.status === "done" || s.status === "skipped") done++;
|
||||
}
|
||||
const total = state.steps.length;
|
||||
const pct = total ? Math.round(100 * done / total) : 0;
|
||||
$("#progress-bar").style.width = pct + "%";
|
||||
}
|
||||
|
||||
function escapeHTML(s) {
|
||||
return String(s).replace(/[&<>"']/g, c => ({
|
||||
"&": "&", "<": "<", ">": ">", "\"": """, "'": "'"
|
||||
}[c]));
|
||||
}
|
||||
|
||||
// ------------- transitions -------------
|
||||
|
||||
async function startPrecheck() {
|
||||
await fetch("/api/precheck", { method: "POST" });
|
||||
}
|
||||
|
||||
function goWelcome() {
|
||||
state.stage = "welcome";
|
||||
render();
|
||||
}
|
||||
|
||||
function goPrecheck() {
|
||||
state.stage = "precheck";
|
||||
render();
|
||||
}
|
||||
|
||||
async function goConfig() {
|
||||
state.stage = "config";
|
||||
render();
|
||||
}
|
||||
|
||||
async function startInstall() {
|
||||
const form = $("#config-form");
|
||||
const data = Object.fromEntries(new FormData(form).entries());
|
||||
await fetch("/api/config", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
await fetch("/api/install", { method: "POST" });
|
||||
}
|
||||
|
||||
async function resetWizard() {
|
||||
await fetch("/api/reset", { method: "POST" });
|
||||
}
|
||||
|
||||
// ------------- SSE -------------
|
||||
|
||||
function connectSSE() {
|
||||
const es = new EventSource("/api/events");
|
||||
es.addEventListener("snapshot", e => {
|
||||
const snap = JSON.parse(e.data);
|
||||
state.stage = snap.stage;
|
||||
state.precheck = snap.precheck || [];
|
||||
state.config = snap.config || {};
|
||||
state.steps = snap.steps || [];
|
||||
state.errorMsg = snap.errorMsg || "";
|
||||
render();
|
||||
});
|
||||
es.addEventListener("stage", e => {
|
||||
state.stage = JSON.parse(e.data).stage;
|
||||
render();
|
||||
});
|
||||
es.addEventListener("precheck", e => {
|
||||
state.precheck = JSON.parse(e.data);
|
||||
render();
|
||||
});
|
||||
es.addEventListener("step", e => {
|
||||
const s = JSON.parse(e.data);
|
||||
const idx = state.steps.findIndex(x => x.id === s.id);
|
||||
if (idx >= 0) state.steps[idx] = s;
|
||||
render();
|
||||
});
|
||||
es.addEventListener("error", e => {
|
||||
state.errorMsg = JSON.parse(e.data).message;
|
||||
state.stage = "error";
|
||||
render();
|
||||
});
|
||||
es.addEventListener("reset", () => {
|
||||
location.reload();
|
||||
});
|
||||
es.onerror = () => {
|
||||
// авто-реконнект делает EventSource сам, ничего не делаем
|
||||
};
|
||||
}
|
||||
|
||||
connectSSE();
|
||||
@@ -0,0 +1,110 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>bj-installer — мастер установки Bridge-and-Join-s</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="logo">Bridge-and-Join-s</div>
|
||||
<div class="subtitle">мастер установки</div>
|
||||
</header>
|
||||
|
||||
<main id="app">
|
||||
<!-- ===== Stepper ===== -->
|
||||
<nav class="stepper" id="stepper">
|
||||
<span data-stage="welcome">1. Старт</span>
|
||||
<span data-stage="precheck">2. Проверка</span>
|
||||
<span data-stage="config">3. Настройка</span>
|
||||
<span data-stage="installing">4. Установка</span>
|
||||
<span data-stage="done">5. Готово</span>
|
||||
</nav>
|
||||
|
||||
<!-- ===== Welcome ===== -->
|
||||
<section class="page" data-stage="welcome">
|
||||
<h1>Добро пожаловать</h1>
|
||||
<p>Этот мастер установит на сервер <b>СКЗИ «Валидата Клиент L»</b>,
|
||||
<b>bj-server</b>, <b>bj-crypto</b> и <b>ИШ НРД</b>, настроит
|
||||
systemd-сервисы и подготовит окружение для подписи документов
|
||||
по ГОСТ 34.10-2012.</p>
|
||||
<p class="muted">После установки откроется <code>/admin/setup</code> в bj-server, где можно
|
||||
загрузить тестовый профиль от MOEX (.7z) и активировать подпись.</p>
|
||||
<div class="buttons">
|
||||
<button class="primary" onclick="startPrecheck()">Начать →</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== Precheck ===== -->
|
||||
<section class="page" data-stage="precheck">
|
||||
<h1>Проверка системы</h1>
|
||||
<div id="precheck-results" class="checks"></div>
|
||||
<div class="buttons">
|
||||
<button onclick="goWelcome()">← Назад</button>
|
||||
<button class="primary" id="goConfigBtn" onclick="goConfig()">Дальше →</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== Config ===== -->
|
||||
<section class="page" data-stage="config">
|
||||
<h1>Настройка</h1>
|
||||
<form id="config-form" onsubmit="event.preventDefault(); startInstall();">
|
||||
<label>ИНН организации
|
||||
<input type="text" name="orgInn" placeholder="7702077840" pattern="\d{10}|\d{12}">
|
||||
</label>
|
||||
<label>Название организации (для отображения)
|
||||
<input type="text" name="orgName" placeholder="ПАО Московская Биржа">
|
||||
</label>
|
||||
<label>Email администратора
|
||||
<input type="email" name="adminEmail" placeholder="admin@example.com">
|
||||
</label>
|
||||
<label>Лицензионный ключ (опционально)
|
||||
<input type="text" name="licenseKey" placeholder="BJ-XXXX-XXXX-XXXX">
|
||||
<span class="muted">Без ключа сервис работает, но обновления заблокированы. Получить можно в личном кабинете.</span>
|
||||
</label>
|
||||
<div class="buttons">
|
||||
<button type="button" onclick="goPrecheck()">← Назад</button>
|
||||
<button class="primary" type="submit">Установить →</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!-- ===== Installing ===== -->
|
||||
<section class="page" data-stage="installing">
|
||||
<h1>Установка</h1>
|
||||
<ol id="step-list" class="steps"></ol>
|
||||
<div class="progress"><div id="progress-bar" class="progress-bar"></div></div>
|
||||
</section>
|
||||
|
||||
<!-- ===== Done ===== -->
|
||||
<section class="page" data-stage="done">
|
||||
<h1>✓ Готово</h1>
|
||||
<p>bj-server и все сервисы запущены. Откройте панель администратора и
|
||||
импортируйте профиль:</p>
|
||||
<div class="next-link">
|
||||
<a href="" id="adminLink" class="primary-link">Перейти в /admin/setup →</a>
|
||||
</div>
|
||||
<p class="muted">Что дальше:</p>
|
||||
<ol>
|
||||
<li>Подключите USB с .vdk → он автоматически смонтируется в <code>/var/lib/bj/usb/</code></li>
|
||||
<li>На <code>/admin/setup</code> загрузите .7z с профилем от MOEX и введите пароль</li>
|
||||
<li>Нажмите «Активировать» — bj-crypto подтянет ключ и подтвердит готовность</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<!-- ===== Error ===== -->
|
||||
<section class="page" data-stage="error">
|
||||
<h1>✗ Установка прервана</h1>
|
||||
<p>Произошла ошибка:</p>
|
||||
<pre id="error-message" class="error"></pre>
|
||||
<p class="muted">Логи: <code>journalctl -u bj-installer</code> и <code>journalctl -u bj-crypto</code></p>
|
||||
<div class="buttons">
|
||||
<button onclick="resetWizard()">Начать заново</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,179 @@
|
||||
:root {
|
||||
--bg: #f6f7fb;
|
||||
--card: #ffffff;
|
||||
--text: #1d2330;
|
||||
--muted: #6b7280;
|
||||
--accent: #2563eb;
|
||||
--accent-dark: #1d4ed8;
|
||||
--ok: #16a34a;
|
||||
--err: #dc2626;
|
||||
--border: #e5e7eb;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body {
|
||||
margin: 0; padding: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.topbar {
|
||||
display: flex; align-items: baseline; gap: 16px;
|
||||
padding: 18px 32px;
|
||||
background: #0f172a;
|
||||
color: #fff;
|
||||
border-bottom: 1px solid #1e293b;
|
||||
}
|
||||
.logo { font-weight: 700; font-size: 18px; letter-spacing: 0.3px; }
|
||||
.subtitle { color: #94a3b8; font-size: 14px; }
|
||||
main {
|
||||
max-width: 760px;
|
||||
margin: 24px auto;
|
||||
padding: 0 16px;
|
||||
}
|
||||
.stepper {
|
||||
display: flex; gap: 8px;
|
||||
margin-bottom: 24px;
|
||||
padding: 12px 14px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.stepper span {
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.stepper span.active {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
.stepper span.done {
|
||||
color: var(--ok);
|
||||
}
|
||||
.page {
|
||||
display: none;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 28px 32px;
|
||||
}
|
||||
.page.active { display: block; }
|
||||
h1 { margin: 0 0 16px; font-size: 22px; }
|
||||
p, label { font-size: 15px; }
|
||||
.muted { color: var(--muted); font-size: 13px; }
|
||||
code {
|
||||
background: #f1f5f9;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.buttons {
|
||||
display: flex; gap: 12px;
|
||||
margin-top: 24px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
button, .primary-link {
|
||||
padding: 10px 18px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
border: 1px solid var(--border);
|
||||
background: #fff;
|
||||
color: var(--text);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
}
|
||||
button.primary, .primary-link {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
button.primary:hover, .primary-link:hover { background: var(--accent-dark); }
|
||||
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* Precheck */
|
||||
.checks { display: flex; flex-direction: column; gap: 10px; margin-top: 12px; }
|
||||
.check {
|
||||
display: flex; gap: 12px; align-items: center;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.check.ok { border-color: var(--ok); }
|
||||
.check.bad { border-color: var(--err); }
|
||||
.check-icon { font-size: 18px; }
|
||||
.check.ok .check-icon { color: var(--ok); }
|
||||
.check.bad .check-icon { color: var(--err); }
|
||||
.check-title { font-weight: 500; }
|
||||
.check-msg { font-size: 13px; color: var(--muted); }
|
||||
|
||||
/* Config form */
|
||||
#config-form { display: flex; flex-direction: column; gap: 16px; }
|
||||
#config-form label {
|
||||
display: flex; flex-direction: column; gap: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
#config-form input {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font: inherit;
|
||||
background: #fff;
|
||||
}
|
||||
#config-form input:focus { outline: 2px solid var(--accent); outline-offset: -1px; border-color: var(--accent); }
|
||||
|
||||
/* Installing steps */
|
||||
.steps { list-style: none; padding: 0; margin: 16px 0; }
|
||||
.steps li {
|
||||
display: flex; gap: 12px; align-items: flex-start;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.steps li:last-child { border-bottom: none; }
|
||||
.step-icon { font-size: 16px; min-width: 24px; line-height: 1.5; }
|
||||
.step-title { font-weight: 500; }
|
||||
.step-msg { font-size: 12px; color: var(--muted); margin-top: 2px; word-break: break-word; }
|
||||
.step-pending .step-icon { color: var(--muted); }
|
||||
.step-running .step-icon { color: var(--accent); animation: spin 1.2s linear infinite; }
|
||||
.step-done .step-icon { color: var(--ok); }
|
||||
.step-skipped .step-icon { color: var(--muted); }
|
||||
.step-failed .step-icon { color: var(--err); }
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.progress {
|
||||
margin-top: 16px;
|
||||
background: var(--border);
|
||||
border-radius: 4px;
|
||||
height: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
background: var(--accent);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
/* Done */
|
||||
.next-link { margin: 20px 0; }
|
||||
|
||||
/* Error */
|
||||
.error {
|
||||
background: #fef2f2;
|
||||
border: 1px solid var(--err);
|
||||
color: var(--err);
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
||||
font-size: 13px;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
Reference in New Issue
Block a user