// источник данных доски: снимок хаба (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; // заглушка без данных — один и тот же объект между рендерами (от identity модели зависит выбор спринтов на странице) const pending = board.isPending; const emptyHub = useMemo(() => emptyModel(pending ? "Загрузка данных хаба…" : "Нет данных: хаб не отвечает. Запустите хаб (jira-hub serve) и нажмите «Перечитать» или загрузите файл CSV из фильтра Jira."), [pending]); const model = source.kind === "file" ? source.model : hubModel ?? emptyHub; 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 }; }