Заглушка модели в useBoardSource стабильна между рендерами, readTheme не возвращает null. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
196 lines
9.8 KiB
TypeScript
196 lines
9.8 KiB
TypeScript
// источник данных доски: снимок хаба (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;
|
||
// заглушка без данных — один и тот же объект между рендерами (от 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 };
|
||
}
|