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