sdlc-hub/web/src/board/useBoardSource.ts

196 lines
9.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// источник данных доски: снимок хаба (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: один опрос пакета; остановка на завершении, любой 4xx (T10.2), 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) return false; // любая 4xx не лечится повтором
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) {
setEvent({ kind: "batch-lost", id: active.id, reason: e.status === 404 ? "пакета нет в хабе" : e.detail ?? e.message });
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;
// заглушка без данных — один и тот же объект между рендерами (от 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 };
}