feat(web): «Обновить из Jira», перенос раскладки импортом JSON, смена токена (#17)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-14 18:10:38 +04:00
parent e92e7711d6
commit 4f4fb500dd
2 changed files with 60 additions and 7 deletions

View File

@ -46,6 +46,7 @@ h1{font-size:20px;font-weight:600;margin:0}
.file-name{font-size:14px;color:var(--muted);max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.link{font:inherit;font-size:14px;color:var(--accent);cursor:pointer;background:none;border:0;padding:0;text-decoration:underline dotted;white-space:nowrap}
.link:hover,.link:focus-visible{text-decoration-style:solid;outline:none}
.link:disabled{color:var(--muted);cursor:default;text-decoration:none}
.stats{font-size:14px;color:var(--muted);font-variant-numeric:tabular-nums;white-space:nowrap}
.stats b{color:var(--fg);font-weight:500}
@ -114,6 +115,7 @@ svg.guides{z-index:3}
svg.guides line{stroke:var(--guide);stroke-width:1;stroke-dasharray:4 3}
.empty{padding:40px 16px;color:var(--muted);text-align:center}
.bar>span:empty{display:none}
#hubState{white-space:normal;min-width:0}
.login{max-width:560px;margin:48px auto;padding:20px 24px;background:var(--panel);border:1px solid var(--line);border-radius:8px}
.login h2{margin:0 0 8px;font-size:17px;font-weight:600}
.login p{margin:0 0 12px;font-size:14px;color:var(--muted)}
@ -238,7 +240,7 @@ td.k{font-family:var(--font-mono);font-size:12px;white-space:nowrap}
<div class="sticky">
<div class="top">
<h1>Спринт 2 линк</h1>
<span class="src"><span class="file-name" id="fileName"></span><button type="button" class="link" id="hubReload" hidden title="Загрузить снимок зеркала хаба заново, без обращения к Jira">Перечитать</button><label class="link" for="file" title="CSV из фильтра Jira или JSON, выгруженный с этой страницы. JSON при работе с хабом переносит только расстановку карточек, дорожки и фильтры; CSV показывает снимок файла до «Перечитать»">файл…</label><input type="file" id="file" accept=".csv,.json,text/csv,application/json" hidden></span>
<span class="src"><span class="file-name" id="fileName"></span><button type="button" class="link" id="hubReload" hidden title="Загрузить снимок зеркала хаба заново, без обращения к Jira">Перечитать</button><button type="button" class="link" id="hubRefresh" hidden title="Поставить в хаб пакет «обновление по базовой JQL» и дождаться его: агент перечитает задачи из Jira">Обновить из Jira</button><label class="link" for="file" title="CSV из фильтра Jira или JSON, выгруженный с этой страницы. JSON при работе с хабом переносит только расстановку карточек, дорожки и фильтры; CSV показывает снимок файла до «Перечитать»">файл…</label><input type="file" id="file" accept=".csv,.json,text/csv,application/json" hidden></span>
<span class="stats" id="hubState"></span>
<span class="stats" id="stats"></span>
<span class="spacer"></span>
@ -250,6 +252,7 @@ td.k{font-family:var(--font-mono);font-size:12px;white-space:nowrap}
<button type="button" id="tblToggle" aria-pressed="false">Таблица связей</button>
<button type="button" id="snap" aria-pressed="true">Привязка к сетке</button>
<button type="button" id="theme">Тема: системная</button>
<button type="button" id="logout">Сменить токен хаба</button>
<hr>
<span id="resetWrap"><button type="button" class="danger" id="reset">Сбросить раскладку</button></span>
</div>
@ -1075,15 +1078,56 @@ $("loginForm").addEventListener("submit", ev => {
ev.preventDefault();
const t = $("tokenInput").value.trim(); if (!t) return;
if (!/^[\x21-\x7e]+$/.test(t)) { $("loginErr").hidden = false; $("loginErr").textContent = "В токене недопустимые символы: скопируйте строку после «клиентский токен:» целиком, без пробелов и кириллицы."; return; }
TOKEN = t; store.set(TOKEN_KEY, t); say(""); loadHub();
TOKEN = t; store.set(TOKEN_KEY, t); say(""); loadHub().then(st => { if (st === "ok") resumeWatch(); });
});
$("hubReload").addEventListener("click", () => { say(""); loadHub(); });
// «Обновить из Jira»: пакет из одного refresh по базовой JQL от имени viewer; ход пакета — опросом раз в POLL_MS, затем снимок заново.
// Агент офлайн — пакет всё равно ставится и выполнится, когда агент выйдет на связь
async function refreshFromJira() {
if (polling !== null) return;
$("hubRefresh").disabled = true;
try {
const agent = await hubFetch("/agent");
const b = await hubFetch("/refresh", { method: "POST", body: JSON.stringify({ full: true, author: "viewer" }) });
watchBatch(b.id, agent && agent.online ? "" : "агент офлайн — пакет выполнится, когда он выйдет на связь; ");
} catch (e) { hubError(e); syncHubButtons(); }
}
async function watchBatch(id, warn = "") {
polling = id; syncHubButtons();
for (;;) {
let b;
try { b = await hubFetch(`/batches/${id}`); }
catch (e) {
if (e instanceof HubAuthError) { polling = null; hubError(e); return; }
say(`<span class="warn">пакет #${id}: ${esc(e.message)}, повтор через ${POLL_MS / 1000} с</span>`); await sleep(POLL_MS); continue;
}
if (b.status === "done" || b.status === "cancelled") {
polling = null;
const cmd = (b.commands || [])[0] || {};
const verdict = cmd.status === "failed" ? `<span class="warn">пакет #${id} не выполнен: ${esc(cmd.error || "ошибка агента")}</span>`
: b.status === "cancelled" || cmd.status === "cancelled" || cmd.status === "skipped" ? `<span class="warn">пакет #${id} отменён</span>`
: `пакет #${id} выполнен`;
await loadHub(); say(verdict); syncHubButtons(); return;
}
say(`${warn}пакет #${id} ${b.status === "queued" ? "в очереди" : "выполняется"}${b.remaining_seconds ? `, осталось ~${b.remaining_seconds} с` : ""}…`);
await sleep(POLL_MS);
}
}
// после перезагрузки страницы: незавершённый пакет доски показываем дальше, вторую загрузку не ставим
async function resumeWatch() {
try {
const list = await hubFetch("/batches?limit=20");
const b = (list.batches || []).find(x => x.author === "viewer" && (x.status === "queued" || x.status === "running"));
if (b && polling === null) watchBatch(b.id);
} catch (e) {}
}
$("hubRefresh").addEventListener("click", refreshFromJira);
$("logout").addEventListener("click", () => { TOKEN = ""; store.del(TOKEN_KEY); moreDd.close(); say(""); showLogin(""); });
function loadCsv(text, fileName) {
showModel(fromJiraCsv(text, fileName), `${fileName} (из файла)`, `${fileName} — снимок из файла; «Перечитать» вернёт данные хаба`);
}
// импорт JSON: раскладка и фильтры кладутся в хранилище доски, так они применятся и к следующей выгрузке CSV
function loadJson(text, fileName) {
const r = fromJson(text, fileName); MODEL = r.model;
// раскладка и фильтры из JSON кладутся в хранилище доски, так они применятся и к следующей загрузке данных
function applyJsonSettings(r) {
if (Object.keys(r.layout).length) store.set(fileKey("pos"), JSON.stringify(r.layout));
if (Object.keys(r.layoutByDate).length) store.set(fileKey("posdate"), JSON.stringify(r.layoutByDate));
if (r.laneOrder.length) store.set(fileKey("lanes"), JSON.stringify(r.laneOrder));
@ -1102,7 +1146,15 @@ function loadJson(text, fileName) {
if (Array.isArray(f.hiddenKinds)) { S.hideKind = new Set(strList(f.hiddenKinds)); store.set(pref("hidekind"), JSON.stringify([...S.hideKind])); }
if (Array.isArray(f.hiddenEdges)) { S.hideEdge = new Set(strList(f.hiddenEdges)); store.set(pref("hideedge"), JSON.stringify([...S.hideEdge])); }
drawLegend();
showModel(MODEL, `${MODEL.fileName} (из файла)`, `${MODEL.fileName} — загружено из ${fileName}`);
}
// импорт JSON: раскладка и фильтры кладутся в хранилище доски. С хабом задачи и связи берутся из хаба, а не из файла;
// хаб не ответил — показываем снимок из файла
function loadJson(text, fileName) {
const r = fromJson(text, fileName);
applyJsonSettings(r);
const showFile = () => showModel(r.model, `${r.model.fileName} (из файла)`, `${r.model.fileName} — загружено из ${fileName}`);
if (!TOKEN) return showFile();
loadHub().then(st => { if (st === "down") { showFile(); say(`<span class="warn">хаб не отвечает — показан снимок из ${esc(fileName)}</span>`); } else if (st === "ok") say(`раскладка и фильтры из ${esc(fileName)} применены к данным хаба`); });
}
$("file").addEventListener("change", ev => {
const f = ev.target.files && ev.target.files[0]; if (!f) return;
@ -1161,5 +1213,5 @@ showProblems(store.get(pref("prob"), "0") === "1");
applyTheme(theme); setHeadH();
migrateStore();
MODEL = emptyModel("Загрузка данных хаба…");
if (TOKEN) { showModel(MODEL, "Хаб"); loadHub(); } else showLogin("");
if (TOKEN) { showModel(MODEL, "Хаб"); loadHub().then(st => { if (st === "ok") resumeWatch(); }); } else showLogin("");
</script>

View File

@ -392,3 +392,4 @@ def test_page_reads_hub_and_has_no_embedded_export():
assert "DEFAULT_CSV" not in html and "Ключ проблемы,Тема" not in html # встроенной выгрузки нет
assert '"jira-hub-token"' in html and 'hubFetch("/board")' in html
assert "Нет в зеркале" in html
assert 'author: "viewer"' in html and "/batches/" in html