175 lines
8.6 KiB
TypeScript
175 lines
8.6 KiB
TypeScript
// владелец состояния «Обновить из Jira»: один опрос пакета на всё приложение. Правила — прежние (jira-hub#20):
|
||
// одна кнопка и один опрос (M-2), подхват незавершённого пакета после любой загрузки снимка (M-3), остановка опроса
|
||
// (M-4, T10.2), только пакеты с первой командой refresh (T10.6)
|
||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||
import { api, ApiError, AuthError, dropTokenOn, HubDownError } from "../../../api/client.ts";
|
||
import { qk } from "../api/queries.ts";
|
||
import type { AgentView, Batch, CreatedBatch } from "../../../api/types.ts";
|
||
import { RefreshContext, type JiraRefresh } from "./refresh.ts";
|
||
import { DONE_VISIBLE_MS, MAX_POLL_ERRORS, POLL_MS, type RefreshEvent } from "./refreshStatus.ts";
|
||
|
||
interface Active { id: number; agentOffline: boolean }
|
||
|
||
const terminal = (b: Batch | undefined) => !!b && (b.status === "done" || b.status === "cancelled");
|
||
|
||
export function RefreshProvider({ children, pollMs = POLL_MS }: { children: ReactNode; pollMs?: number }) {
|
||
const qc = useQueryClient();
|
||
const [event, setEvent] = useState<RefreshEvent | null>(null);
|
||
const [active, setActive] = useState<Active | null>(null);
|
||
const [starting, setStarting] = useState(false);
|
||
const startingRef = useRef(false);
|
||
const failures = useRef(0);
|
||
const activeRef = useRef(active);
|
||
useEffect(() => { activeRef.current = active; }, [active]);
|
||
// T10.1: сколько ближайших загрузок снимка — перечитывание после своего пакета (пакет уже завершён, подхватывать нечего)
|
||
const skipPickup = useRef(0);
|
||
const picking = useRef(false);
|
||
|
||
const agent = useQuery({
|
||
queryKey: ["agent"], refetchInterval: 60_000, refetchIntervalInBackground: true,
|
||
queryFn: () => api.get<AgentView>("/agent"),
|
||
});
|
||
|
||
const report = useCallback((e: unknown) => {
|
||
if (e instanceof AuthError) dropTokenOn(e);
|
||
else if (e instanceof HubDownError) setEvent({ kind: "hub-down" });
|
||
else setEvent({ kind: "error", message: e instanceof Error ? e.message : String(e) });
|
||
}, []);
|
||
|
||
// M-3 #20: подхватить незавершённый пакет обновления от viewer
|
||
const pickup = useCallback(() => {
|
||
if (activeRef.current || startingRef.current || picking.current) return;
|
||
picking.current = true;
|
||
(async () => {
|
||
const r = await api.get<{ batches: Batch[] }>("/batches", { limit: 20 });
|
||
const candidates = r.batches.filter((x) => x.author === "viewer" && (x.status === "queued" || x.status === "running"));
|
||
for (const b of candidates) { // M-12: пакет wiki_sync не заслоняет более старый refresh
|
||
const full = await api.get<Batch>(`/batches/${b.id}`); // T10.6: в списке нет команд — тип смотрим в пакете (R-2)
|
||
if (activeRef.current || startingRef.current) return;
|
||
if (full.commands?.[0]?.type !== "refresh") continue;
|
||
failures.current = 0;
|
||
setActive((cur) => cur ?? { id: b.id, agentOffline: false });
|
||
return;
|
||
}
|
||
})().catch(() => { /* не критично: подхватим при следующей загрузке */ }).finally(() => { picking.current = false; });
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
pickup();
|
||
return qc.getQueryCache().subscribe((ev) => {
|
||
if (ev.type !== "updated" || ev.action.type !== "success" || ev.query.queryKey[0] !== "board") return;
|
||
if (skipPickup.current > 0) { skipPickup.current -= 1; return; }
|
||
pickup();
|
||
});
|
||
}, [qc, pickup]);
|
||
|
||
// 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] });
|
||
// перечитывание активных снимков досок — после своего пакета, подхват не нужен; показанный файл не заменяется (M-1)
|
||
skipPickup.current = qc.getQueryCache().findAll({ queryKey: qk.boardAll, type: "active" }).length;
|
||
// T4.1: перечитывание могло упасть (успеха нет, счётчик не уменьшился) — по его завершению счётчик гасится в любом случае
|
||
void qc.invalidateQueries({ queryKey: qk.boardAll }).finally(() => { skipPickup.current = 0; });
|
||
for (const key of [["issues"], ["issue"], ["changes"], qk.projects]) void qc.invalidateQueries({ queryKey: key });
|
||
}, [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]);
|
||
|
||
// «пакет выполнен» гаснет сам
|
||
useEffect(() => {
|
||
if (event?.kind !== "batch-done") return;
|
||
const t = setTimeout(() => setEvent((cur) => (cur === event ? null : cur)), DONE_VISIBLE_MS);
|
||
return () => clearTimeout(t);
|
||
}, [event]);
|
||
|
||
const start = useCallback(async () => {
|
||
if (startingRef.current || activeRef.current) return;
|
||
startingRef.current = true;
|
||
setStarting(true);
|
||
try {
|
||
const a = await api.get<AgentView>("/agent");
|
||
const b = await api.post<CreatedBatch>("/refresh", { full: true, author: "viewer" });
|
||
failures.current = 0;
|
||
const next = { id: b.id, agentOffline: !a.online };
|
||
activeRef.current = next;
|
||
setActive(next);
|
||
setEvent({ kind: "batch", id: b.id, phase: "queued", remaining: b.estimate_seconds, agentOffline: !a.online, retrying: false });
|
||
} catch (e) {
|
||
report(e);
|
||
} finally {
|
||
startingRef.current = false;
|
||
setStarting(false);
|
||
}
|
||
}, [report]);
|
||
|
||
const track = useCallback((id: number) => {
|
||
if (activeRef.current) return;
|
||
failures.current = 0;
|
||
const next = { id, agentOffline: false };
|
||
activeRef.current = next;
|
||
setActive(next);
|
||
setEvent({ kind: "batch", id, phase: "queued", remaining: 0, agentOffline: false, retrying: false });
|
||
}, []);
|
||
|
||
const value = useMemo<JiraRefresh>(() => ({
|
||
event, busy: starting || active !== null, agent: agent.data ?? null, start, track,
|
||
}), [event, starting, active, agent.data, start, track]);
|
||
|
||
return <RefreshContext.Provider value={value}>{children}</RefreshContext.Provider>;
|
||
}
|