feat(web): «Обновить из Jira», перенос раскладки импортом JSON, смена токена (#17)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
e92e7711d6
commit
4f4fb500dd
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user