diff --git a/src/jira_hub/web/links.html b/src/jira_hub/web/links.html index 9b22e76..9c9c262 100644 --- a/src/jira_hub/web/links.html +++ b/src/jira_hub/web/links.html @@ -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}

Спринт 2 линк

- + @@ -250,6 +252,7 @@ td.k{font-family:var(--font-mono);font-size:12px;white-space:nowrap} +
@@ -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(`пакет #${id}: ${esc(e.message)}, повтор через ${POLL_MS / 1000} с`); await sleep(POLL_MS); continue; + } + if (b.status === "done" || b.status === "cancelled") { + polling = null; + const cmd = (b.commands || [])[0] || {}; + const verdict = cmd.status === "failed" ? `пакет #${id} не выполнен: ${esc(cmd.error || "ошибка агента")}` + : b.status === "cancelled" || cmd.status === "cancelled" || cmd.status === "skipped" ? `пакет #${id} отменён` + : `пакет #${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(`хаб не отвечает — показан снимок из ${esc(fileName)}`); } 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(""); diff --git a/tests/test_api.py b/tests/test_api.py index 93607d4..a54059a 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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