diff --git a/web/src/board/status.test.ts b/web/src/board/status.test.ts
new file mode 100644
index 0000000..4d44af7
--- /dev/null
+++ b/web/src/board/status.test.ts
@@ -0,0 +1,19 @@
+import { describe, expect, it } from "vitest";
+import { statusText } from "./status.ts";
+
+describe("statusText", () => {
+ it("пакет: офлайн, очередь, остаток, повтор", () => {
+ expect(statusText({ kind: "batch", id: 7, phase: "queued", remaining: 12, agentOffline: true, retrying: false }))
+ .toEqual({ text: "агент офлайн — пакет выполнится, когда он выйдет на связь; пакет #7 в очереди, осталось ~12 с…", warn: false });
+ expect(statusText({ kind: "batch", id: 7, phase: "running", remaining: 0, agentOffline: false, retrying: true }))
+ .toEqual({ text: "пакет #7 выполняется… (хаб не отвечает, повтор через 3 с)", warn: true });
+ });
+ it("исходы и ошибки", () => {
+ expect(statusText({ kind: "batch-failed", id: 7, error: "429" })).toEqual({ text: "пакет #7 не выполнен: 429", warn: true });
+ expect(statusText({ kind: "batch-lost", id: 7, reason: "пакета нет в хабе" }).text)
+ .toBe("пакет #7: пакета нет в хабе — опрос остановлен, «Обновить из Jira» снова доступна");
+ expect(statusText({ kind: "hub-down", hasData: true }).text).toBe("хаб не отвечает — показаны прежние данные");
+ expect(statusText({ kind: "file-error", fileName: "a.csv", reason: "в файле нет строк с задачами" }).text)
+ .toBe("файл не разобран: в файле нет строк с задачами");
+ });
+});
diff --git a/web/src/board/status.ts b/web/src/board/status.ts
new file mode 100644
index 0000000..169a5f7
--- /dev/null
+++ b/web/src/board/status.ts
@@ -0,0 +1,38 @@
+export const POLL_MS = 3000; // период опроса пакета, мс (как у links.html)
+export const MAX_POLL_ERRORS = 5; // столько ошибок «хаб не отвечает» подряд — и опрос пакета останавливается (M-4)
+
+export type BoardStatus =
+ | { kind: "loading" }
+ | { kind: "batch"; id: number; phase: "queued" | "running"; remaining: number; agentOffline: boolean; retrying: boolean }
+ | { kind: "batch-done"; id: number }
+ | { kind: "batch-failed"; id: number; error: string }
+ | { kind: "batch-cancelled"; id: number }
+ | { kind: "batch-lost"; id: number; reason: string }
+ | { kind: "hub-down"; hasData: boolean }
+ | { kind: "file-error"; fileName: string; reason: string }
+ | { kind: "json-applied"; fileName: string }
+ | { kind: "error"; message: string };
+
+// одна строка состояния доски: текст и признак предупреждения (M-5)
+export function statusText(s: BoardStatus): { text: string; warn: boolean } {
+ switch (s.kind) {
+ case "loading": return { text: "загрузка данных хаба…", warn: false };
+ case "batch": {
+ const off = s.agentOffline ? "агент офлайн — пакет выполнится, когда он выйдет на связь; " : "";
+ const left = s.remaining ? `, осталось ~${s.remaining} с` : "";
+ const retry = s.retrying ? ` (хаб не отвечает, повтор через ${POLL_MS / 1000} с)` : "";
+ return { text: `${off}пакет #${s.id} ${s.phase === "queued" ? "в очереди" : "выполняется"}${left}…${retry}`, warn: s.retrying };
+ }
+ case "batch-done": return { text: `пакет #${s.id} выполнен`, warn: false };
+ case "batch-failed": return { text: `пакет #${s.id} не выполнен: ${s.error}`, warn: true };
+ case "batch-cancelled": return { text: `пакет #${s.id} отменён`, warn: true };
+ case "batch-lost": return { text: `пакет #${s.id}: ${s.reason} — опрос остановлен, «Обновить из Jira» снова доступна`, warn: true };
+ case "hub-down": return {
+ text: s.hasData ? "хаб не отвечает — показаны прежние данные" : "хаб не отвечает: запустите хаб (jira-hub serve) и нажмите «Перечитать»",
+ warn: true,
+ };
+ case "file-error": return { text: `файл не разобран: ${s.reason}`, warn: true };
+ case "json-applied": return { text: `раскладка и фильтры из ${s.fileName} применены к данным хаба`, warn: false };
+ case "error": return { text: s.message, warn: true };
+ }
+}
diff --git a/web/src/board/useBoardSource.test.tsx b/web/src/board/useBoardSource.test.tsx
new file mode 100644
index 0000000..dfd30c1
--- /dev/null
+++ b/web/src/board/useBoardSource.test.tsx
@@ -0,0 +1,181 @@
+import { QueryClientProvider } from "@tanstack/react-query";
+import { act, renderHook, waitFor } from "@testing-library/react";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+import type { ReactNode } from "react";
+import { beforeEach, describe, expect, it } from "vitest";
+import { makeQueryClient } from "../api/queryClient.ts";
+import { setToken } from "../auth/token.ts";
+import { json, mockFetch } from "../test/fetchMock.ts";
+import { dictsFromSettings } from "./dicts.ts";
+import settings from "./__fixtures__/settings.json";
+import { useBoardSource } from "./useBoardSource.ts";
+
+const fx = (name: string) => readFileSync(join(import.meta.dirname, "__fixtures__", name), "utf8");
+const SNAP = JSON.parse(fx("hub-basic.board.json"));
+const CSV = fx("jira-export.csv");
+const { dicts } = dictsFromSettings(settings);
+const wrapper = ({ children }: { children: ReactNode }) =>
+ {children};
+const hook = () => renderHook(() => useBoardSource(dicts, { pollMs: 10 }), { wrapper });
+const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
+const deferred = () => { let resolve!: (v: T) => void; const promise = new Promise((r) => { resolve = r; }); return { promise, resolve }; };
+
+// хаб в памяти: снимок, список пакетов, пакет по id; счётчики запросов
+function hub(over: Partial Response | Promise>> = {}) {
+ const calls: string[] = [];
+ const inFlight: Record = {}, maxInFlight: Record = {};
+ const fetch = mockFetch(async (method, url, body) => {
+ const k = `${method} ${url.pathname}`;
+ calls.push(k);
+ inFlight[k] = (inFlight[k] ?? 0) + 1;
+ maxInFlight[k] = Math.max(maxInFlight[k] ?? 0, inFlight[k]);
+ try {
+ const h = over[k] ?? over[`${method} ${url.pathname.replace(/\d+$/, ":id")}`];
+ if (h) return await h(url, body);
+ if (k === "GET /board") return json(200, SNAP);
+ if (k === "GET /batches") return json(200, { batches: [] });
+ if (k === "GET /agent") return json(200, { online: true, last_seen_at: null, warning: null });
+ if (k === "POST /refresh") return json(201, { id: 7, estimate_seconds: 5, count: 1 });
+ return json(404, { error: "not_found" });
+ } finally { inFlight[k] -= 1; }
+ });
+ return { fetch, calls, count: (k: string) => calls.filter((c) => c === k).length, maxInFlight: (k: string) => maxInFlight[k] ?? 0 };
+}
+const batch = (status: string, cmd: Record = { status: "done" }) =>
+ json(200, { id: 7, title: "Обновление", author: "viewer", created_at: "", cancelled_at: null, status, counts: {}, remaining_seconds: 0, commands: [{ id: 1, seq: 1, type: "refresh", error: null, finished_at: null, ...cmd }] });
+
+describe("useBoardSource", () => {
+ beforeEach(() => setToken("tok"));
+
+ it("строит доску из снимка хаба и подписывает источник", async () => {
+ hub();
+ const { result } = hook();
+ expect(result.current.status).toEqual({ kind: "loading" });
+ await waitFor(() => expect(result.current.model.nodes.length).toBe(10));
+ expect(result.current.source).toBe("hub");
+ expect(result.current.label).toMatch(/^Хаб · данные на /);
+ expect(result.current.status).toBeNull();
+ });
+
+ it("M-1: поздний ответ хаба не затирает загруженный CSV; «Перечитать» возвращает хаб", async () => {
+ const slow = deferred();
+ const h = hub({ "GET /board": () => slow.promise });
+ const { result } = hook();
+ act(() => { result.current.loadFile("jira-export.csv", CSV); });
+ expect(result.current.source).toBe("file");
+ slow.resolve(json(200, SNAP));
+ await waitFor(() => expect(h.count("GET /board")).toBe(1));
+ await sleep(20);
+ expect(result.current.source).toBe("file");
+ expect(result.current.label).toBe("jira-export.csv (из файла)");
+ act(() => result.current.reload());
+ await waitFor(() => expect(result.current.source).toBe("hub"));
+ await waitFor(() => expect(result.current.model.nodes.length).toBe(10));
+ });
+
+ it("M-2: двойное нажатие — один пакет и один опрос за раз", async () => {
+ let polls = 0;
+ const h = hub({ "GET /batches/:id": () => (++polls < 4 ? batch("running") : batch("done")) });
+ const { result } = hook();
+ await waitFor(() => expect(result.current.model.nodes.length).toBe(10));
+ await act(async () => { void result.current.refreshFromJira(); await result.current.refreshFromJira(); });
+ expect(h.count("POST /refresh")).toBe(1);
+ expect(result.current.refreshing).toBe(true);
+ await waitFor(() => expect(result.current.status).toEqual({ kind: "batch-done", id: 7 }));
+ expect(result.current.refreshing).toBe(false);
+ expect(h.maxInFlight("GET /batches/7")).toBe(1);
+ expect(h.count("GET /board")).toBe(2); // по завершении снимок перечитан
+ });
+
+ it("M-3: незавершённый пакет viewer подхватывается и после «Перечитать»", async () => {
+ let listed: unknown[] = [];
+ const h = hub({ "GET /batches": () => json(200, { batches: listed }), "GET /batches/:id": () => batch("running") });
+ const { result } = hook();
+ await waitFor(() => expect(h.count("GET /batches")).toBe(1));
+ expect(result.current.refreshing).toBe(false);
+ listed = [{ id: 7, author: "viewer", status: "running" }, { id: 6, author: "chat", status: "running" }];
+ act(() => result.current.reload());
+ await waitFor(() => expect(result.current.refreshing).toBe(true));
+ await waitFor(() => expect(result.current.status).toMatchObject({ kind: "batch", id: 7, phase: "running" }));
+ });
+
+ it("M-4: 404 на опросе — опрос остановлен, кнопка доступна", async () => {
+ const h = hub({ "GET /batches/:id": () => json(404, { error: "not_found", detail: "batch" }) });
+ const { result } = hook();
+ await waitFor(() => expect(result.current.model.nodes.length).toBe(10));
+ await act(() => result.current.refreshFromJira());
+ await waitFor(() => expect(result.current.status).toMatchObject({ kind: "batch-lost", id: 7 }));
+ expect(result.current.refreshing).toBe(false);
+ const n = h.count("GET /batches/7");
+ await sleep(50);
+ expect(h.count("GET /batches/7")).toBe(n);
+ });
+
+ it("M-4: 5 ошибок «хаб не отвечает» подряд — опрос остановлен", async () => {
+ const h = hub({ "GET /batches/:id": () => { throw new TypeError("Failed to fetch"); } });
+ const { result } = hook();
+ await waitFor(() => expect(result.current.model.nodes.length).toBe(10));
+ await act(() => result.current.refreshFromJira());
+ await waitFor(() => expect(result.current.status).toMatchObject({ kind: "batch-lost", reason: "хаб не отвечает" }));
+ expect(h.count("GET /batches/7")).toBe(5);
+ await sleep(50);
+ expect(h.count("GET /batches/7")).toBe(5);
+ expect(result.current.refreshing).toBe(false);
+ });
+
+ it("пакет с ошибкой команды — текст ошибки; агент офлайн — предупреждение в статусе", async () => {
+ let polls = 0;
+ hub({
+ "GET /agent": () => json(200, { online: false, last_seen_at: null, warning: null }),
+ "GET /batches/:id": () => (++polls < 3 ? batch("running") : batch("done", { status: "failed", error: "Jira 500" })),
+ });
+ const { result } = hook();
+ await waitFor(() => expect(result.current.model.nodes.length).toBe(10));
+ await act(() => result.current.refreshFromJira());
+ await waitFor(() => expect(result.current.status).toMatchObject({ kind: "batch", agentOffline: true }));
+ await waitFor(() => expect(result.current.status).toEqual({ kind: "batch-failed", id: 7, error: "Jira 500" }));
+ });
+
+ it("M-1: пакет, завершившийся при показанном файле, не заменяет файл", async () => {
+ let polls = 0;
+ hub({ "GET /batches/:id": () => (++polls < 3 ? batch("running") : batch("done")) });
+ const { result } = hook();
+ await waitFor(() => expect(result.current.model.nodes.length).toBe(10));
+ await act(() => result.current.refreshFromJira());
+ act(() => { result.current.loadFile("jira-export.csv", CSV); });
+ await waitFor(() => expect(result.current.status).toEqual({ kind: "batch-done", id: 7 }));
+ expect(result.current.source).toBe("file");
+ });
+
+ it("хаб остановлен при «Перечитать» — прежние данные и hub-down", async () => {
+ let down = false;
+ hub({ "GET /board": () => { if (down) throw new TypeError("Failed to fetch"); return json(200, SNAP); } });
+ const { result } = hook();
+ await waitFor(() => expect(result.current.model.nodes.length).toBe(10));
+ down = true;
+ act(() => result.current.reload());
+ await waitFor(() => expect(result.current.status).toEqual({ kind: "hub-down", hasData: true }));
+ expect(result.current.model.nodes.length).toBe(10);
+ });
+
+ it("плохой файл — file-error, прежняя доска на месте", async () => {
+ hub();
+ const { result } = hook();
+ await waitFor(() => expect(result.current.model.nodes.length).toBe(10));
+ act(() => { result.current.loadFile("bad.csv", "Номер,Название\n1,x\n"); });
+ expect(result.current.status).toMatchObject({ kind: "file-error", fileName: "bad.csv" });
+ expect(result.current.source).toBe("hub");
+ });
+
+ it("импорт JSON при живом хабе — настройки возвращаются, данные из хаба", async () => {
+ hub();
+ const { result } = hook();
+ await waitFor(() => expect(result.current.model.nodes.length).toBe(10));
+ let r: unknown = null;
+ act(() => { r = result.current.loadFile("old-board.json", fx("old-board.json")); });
+ expect(r).toMatchObject({ laneOrder: ["Гудвилл", "Тестовый эпик"] });
+ expect(result.current.source).toBe("hub");
+ expect(result.current.status).toEqual({ kind: "json-applied", fileName: "old-board.json" });
+ });
+});
diff --git a/web/src/board/useBoardSource.ts b/web/src/board/useBoardSource.ts
new file mode 100644
index 0000000..bad97f0
--- /dev/null
+++ b/web/src/board/useBoardSource.ts
@@ -0,0 +1,192 @@
+// источник данных доски: снимок хаба (GET /board) или файл (CSV/JSON); обновление из Jira — пакет refresh от имени
+// viewer и опрос его хода. Устройство закрывает недочёты jira-hub#20: явный источник (M-1), одна кнопка и один опрос
+// (M-2), подхват незавершённого пакета после любой загрузки (M-3), остановка опроса (M-4), одно событие состояния (M-5)
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { api, ApiError, AuthError, HubDownError } from "../api/client.ts";
+import { qk, useBoard } from "../api/queries.ts";
+import type { AgentView, Batch, CreatedBatch } from "../api/types.ts";
+import { clearToken } from "../auth/token.ts";
+import { fromCsv } from "./model/fromCsv.ts";
+import { fromHub } from "./model/fromHub.ts";
+import { fromJson } from "./model/fromJson.ts";
+import { emptyModel, fmtStamp } from "./model/util.ts";
+import { MAX_POLL_ERRORS, POLL_MS, type BoardStatus } from "./status.ts";
+import type { BoardModel, Dicts, JsonImport } from "./types.ts";
+
+type Source = { kind: "hub" } | { kind: "file"; model: BoardModel };
+interface Active { id: number; agentOffline: boolean }
+
+export interface BoardSource {
+ model: BoardModel;
+ source: "hub" | "file";
+ label: string;
+ title: string;
+ status: BoardStatus | null;
+ refreshing: boolean;
+ reload(): void;
+ refreshFromJira(): Promise;
+ loadFile(fileName: string, text: string): JsonImport | null;
+}
+
+const terminal = (b: Batch | undefined) => !!b && (b.status === "done" || b.status === "cancelled");
+
+export function useBoardSource(dicts: Dicts | null, { pollMs = POLL_MS }: { pollMs?: number } = {}): BoardSource {
+ const qc = useQueryClient();
+ const board = useBoard();
+ const [source, setSource] = useState({ kind: "hub" });
+ const [event, setEvent] = useState(null);
+ const [active, setActive] = useState(null);
+ const [starting, setStarting] = useState(false);
+ const startingRef = useRef(false);
+ const failures = useRef(0);
+
+ const hubModel = useMemo(() => (board.data && dicts ? fromHub(board.data, dicts) : null), [board.data, dicts]);
+
+ const report = useCallback((e: unknown, hasData: boolean) => {
+ if (e instanceof AuthError) clearToken("bad");
+ else if (e instanceof HubDownError) setEvent({ kind: "hub-down", hasData });
+ else setEvent({ kind: "error", message: e instanceof Error ? e.message : String(e) });
+ }, []);
+
+ // хаб не ответил на снимок: прежние данные остаются (M-5 — отдельное событие, не затирается другими)
+ useEffect(() => {
+ if (board.error && !(board.error instanceof AuthError)) report(board.error, !!board.data);
+ }, [board.error, board.errorUpdatedAt, board.data, report]);
+
+ // M-3: после каждой успешной загрузки снимка — подхватить незавершённый пакет viewer
+ useEffect(() => {
+ if (!board.dataUpdatedAt || active) return;
+ let gone = false;
+ api.get<{ batches: Batch[] }>("/batches", { limit: 20 }).then((r) => {
+ const b = r.batches.find((x) => x.author === "viewer" && (x.status === "queued" || x.status === "running"));
+ if (b && !gone) { failures.current = 0; setActive((cur) => cur ?? { id: b.id, agentOffline: false }); }
+ }).catch(() => { /* не критично: подхватим при следующей загрузке */ });
+ return () => { gone = true; };
+ }, [board.dataUpdatedAt, active]);
+
+ // M-2, M-4: один опрос пакета; остановка на завершении, 404, 401 и после MAX_POLL_ERRORS ошибок подряд
+ const batch = useQuery({
+ queryKey: ["batch", active?.id ?? 0],
+ enabled: active !== null,
+ gcTime: 0,
+ staleTime: 0,
+ queryFn: async () => {
+ try {
+ const b = await api.get(`/batches/${active!.id}`);
+ failures.current = 0;
+ return b;
+ } catch (e) {
+ if (e instanceof HubDownError) failures.current += 1;
+ throw e;
+ }
+ },
+ refetchInterval: (q) => {
+ const e = q.state.error;
+ if (terminal(q.state.data)) return false;
+ if (e instanceof AuthError || (e instanceof ApiError && e.status === 404)) return false;
+ if (failures.current >= MAX_POLL_ERRORS) return false;
+ return pollMs;
+ },
+ refetchIntervalInBackground: true,
+ });
+
+ const finish = useCallback((id: number) => {
+ failures.current = 0;
+ setActive(null);
+ qc.removeQueries({ queryKey: ["batch", id] });
+ void qc.invalidateQueries({ queryKey: qk.board }); // снимок обновится; показанный файл не заменяется (M-1)
+ }, [qc]);
+
+ useEffect(() => {
+ if (!active) return;
+ const b = batch.data, e = batch.error;
+ if (b && b.id === active.id && !batch.isError) {
+ if (terminal(b)) {
+ const cmd = b.commands?.[0];
+ setEvent(cmd?.status === "failed" ? { kind: "batch-failed", id: b.id, error: cmd.error || "ошибка агента" }
+ : b.status === "cancelled" || cmd?.status === "cancelled" || cmd?.status === "skipped" ? { kind: "batch-cancelled", id: b.id }
+ : { kind: "batch-done", id: b.id });
+ finish(b.id);
+ return;
+ }
+ setEvent({ kind: "batch", id: b.id, phase: b.status === "queued" ? "queued" : "running",
+ remaining: b.remaining_seconds, agentOffline: active.agentOffline, retrying: false });
+ return;
+ }
+ if (e instanceof ApiError && e.status === 404) {
+ setEvent({ kind: "batch-lost", id: active.id, reason: "пакета нет в хабе" });
+ finish(active.id);
+ } else if (e instanceof HubDownError) {
+ if (failures.current >= MAX_POLL_ERRORS) {
+ setEvent({ kind: "batch-lost", id: active.id, reason: "хаб не отвечает" });
+ finish(active.id);
+ } else {
+ setEvent((cur) => (cur?.kind === "batch" ? { ...cur, retrying: true } : cur));
+ }
+ }
+ }, [batch.data, batch.error, batch.isError, batch.errorUpdatedAt, batch.dataUpdatedAt, active, finish]);
+
+ const refreshFromJira = useCallback(async () => {
+ if (startingRef.current || active) return;
+ startingRef.current = true;
+ setStarting(true);
+ try {
+ const agent = await api.get("/agent");
+ const b = await api.post("/refresh", { full: true, author: "viewer" });
+ failures.current = 0;
+ setActive({ id: b.id, agentOffline: !agent.online });
+ setEvent({ kind: "batch", id: b.id, phase: "queued", remaining: b.estimate_seconds, agentOffline: !agent.online, retrying: false });
+ } catch (e) {
+ report(e, !!board.data);
+ } finally {
+ startingRef.current = false;
+ setStarting(false);
+ }
+ }, [active, report, board.data]);
+
+ const reload = useCallback(() => {
+ setSource({ kind: "hub" });
+ setEvent(null);
+ void qc.refetchQueries({ queryKey: qk.board });
+ }, [qc]);
+
+ const loadFile = useCallback((fileName: string, text: string): JsonImport | null => {
+ if (!dicts) return null;
+ const isJson = /\.json$/i.test(fileName) || /^\uFEFF?\s*\{/.test(text);
+ try {
+ if (!isJson) {
+ setSource({ kind: "file", model: fromCsv(text, fileName, dicts) });
+ setEvent(null);
+ return null;
+ }
+ const r = fromJson(text, fileName, dicts);
+ if (board.data) { // режим хаба: из файла — раскладка и фильтры, данные — из хаба
+ setSource({ kind: "hub" });
+ setEvent({ kind: "json-applied", fileName });
+ void qc.refetchQueries({ queryKey: qk.board });
+ } else { // хаб не ответил — снимок из файла
+ setSource({ kind: "file", model: r.model });
+ setEvent({ kind: "hub-down", hasData: true });
+ }
+ return r;
+ } catch (e) {
+ setEvent({ kind: "file-error", fileName, reason: e instanceof Error ? e.message : String(e) });
+ return null;
+ }
+ }, [dicts, board.data, qc]);
+
+ const ls = board.data?.last_sync ?? null;
+ const model = source.kind === "file" ? source.model : hubModel ?? emptyModel(board.isPending
+ ? "Загрузка данных хаба…"
+ : "Нет данных: хаб не отвечает. Запустите хаб (jira-hub serve) и нажмите «Перечитать» или загрузите файл CSV из фильтра Jira.");
+ const label = source.kind === "file" ? `${source.model.fileName} (из файла)`
+ : board.data ? (ls ? `Хаб · данные на ${fmtStamp(ls.finished_at)}` : "Хаб · полного обновления не было")
+ : board.isPending ? "Хаб" : "Хаб недоступен";
+ const title = source.kind === "file" ? `${source.model.fileName} — снимок из файла; «Перечитать» вернёт данные хаба`
+ : ls && board.data ? `Последнее полное обновление из Jira: ${fmtStamp(ls.started_at)} – ${fmtStamp(ls.finished_at)}, задач: ${ls.issues_count}. Снимок хаба: ${fmtStamp(board.data.generated_at)}`
+ : label;
+ const status: BoardStatus | null = event ?? (source.kind === "hub" && board.isPending ? { kind: "loading" } : null);
+
+ return { model, source: source.kind, label, title, status, refreshing: starting || active !== null, reload, refreshFromJira, loadFile };
+}