feat(web): источник данных доски и «Обновить из Jira» — закрывает M-1…M-5 #20 (#21)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-14 22:41:58 +04:00
parent 5f012db829
commit 3c2c75e8fe
4 changed files with 430 additions and 0 deletions

View File

@ -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("файл не разобран: в файле нет строк с задачами");
});
});

38
web/src/board/status.ts Normal file
View File

@ -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 };
}
}

View File

@ -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 }) =>
<QueryClientProvider client={makeQueryClient()}>{children}</QueryClientProvider>;
const hook = () => renderHook(() => useBoardSource(dicts, { pollMs: 10 }), { wrapper });
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const deferred = <T,>() => { let resolve!: (v: T) => void; const promise = new Promise<T>((r) => { resolve = r; }); return { promise, resolve }; };
// хаб в памяти: снимок, список пакетов, пакет по id; счётчики запросов
function hub(over: Partial<Record<string, (url: URL, body: unknown) => Response | Promise<Response>>> = {}) {
const calls: string[] = [];
const inFlight: Record<string, number> = {}, maxInFlight: Record<string, number> = {};
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<string, unknown> = { 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<Response>();
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" });
});
});

View File

@ -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<void>;
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<Source>({ kind: "hub" });
const [event, setEvent] = useState<BoardStatus | null>(null);
const [active, setActive] = useState<Active | null>(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<Batch>(`/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<AgentView>("/agent");
const b = await api.post<CreatedBatch>("/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 };
}