fix(web-modules): M-1, M-2, M-7, M-8 трекер пакетов и сводка jira

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-24 15:18:37 +04:00
parent 36565d23b5
commit 2bc43953dc
6 changed files with 54 additions and 21 deletions

View File

@ -3,21 +3,18 @@ import type { Project } from "../../api/types.ts";
import { Notice } from "../../ui/index.ts";
import { useBoard } from "./api/queries.ts";
import { isClosed, useClosedStatuses } from "./api/closed.ts";
import { isOverdue, todayISO } from "./issues/card.ts";
import "./summary.css";
// сегодня по местному времени, ГГГГ-ММ-ДД: срок задачи Jira — дата без времени
const today = (now = new Date()): string =>
`${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
// сводка jira для «Обзора»: свои задачи проекта (не соседи), из них открытые и просроченные — по снимку доски
export function Summary({ project }: { project: Project }) {
const board = useBoard(project.label);
const closed = useClosedStatuses();
const q = `?project=${encodeURIComponent(project.label)}`;
const own = board.data?.issues.filter((i) => !i.neighbor);
const t = todayISO();
const open = own?.filter((i) => !isClosed(i.status, closed));
const t = today();
const late = open?.filter((i) => !!i.duedate && i.duedate.slice(0, 10) < t).length ?? 0;
const late = own?.filter((i) => isOverdue(i.duedate?.slice(0, 10) ?? "", i.status, closed, t)).length ?? 0;
return (
<div className="jira-summary">
{board.isError ? <Notice tone="error">{board.error.message}</Notice>

View File

@ -25,7 +25,7 @@ describe("NewProject: пакеты каждому своему модулю", ()
await userEvent.type(screen.getByLabelText("Название"), "Пэ");
await userEvent.click(screen.getByRole("button", { name: "Создать" }));
await waitFor(() => expect(select).toHaveBeenCalledWith("P"));
expect(modules.jira.track).toHaveBeenCalledExactlyOnceWith(3);
expect(modules.wiki.track).toHaveBeenCalledExactlyOnceWith(4);
expect(modules.jira.track).toHaveBeenCalledExactlyOnceWith(3, "P");
expect(modules.wiki.track).toHaveBeenCalledExactlyOnceWith(4, "P");
});
});

View File

@ -6,7 +6,7 @@ export function NewProject({ onClose, select }: { onClose(): void; select(label:
const track = useTrackModuleBatch();
return <NewProjectDialog onClose={onClose} onCreated={(p, batches) => {
onClose();
for (const b of batches) track(b.module, b.id);
for (const b of batches) track(b.module, b.id, p.label); // пакет принадлежит новому проекту: смена проекта его не сбрасывает
select(p.label);
}} />;
}

View File

@ -12,7 +12,7 @@ import type { HubModule } from "../../modules/types.ts";
import { ActivityContext, AGENTS_KEY, type ActivityValue, type ModuleActivity } from "./activity.ts";
import { DONE_VISIBLE_MS, MAX_POLL_ERRORS, PICKUP_LIMIT, PICKUP_MS, POLL_MS, type ModuleEvent } from "./events.ts";
interface Active { id: number; agentOffline: boolean; own: boolean } // own — пакет действия обновления этого модуля
interface Active { id: number; agentOffline: boolean; own: boolean; project: string | null } // own — пакет действия обновления этого модуля; project — проект, которому принадлежит пакет
const terminal = (b: Batch | undefined) => !!b && (b.status === "done" || b.status === "cancelled");
const fetchAgents = () => api.get<{ agents: AgentView[] }>("/agents").then((r) => r.agents);
@ -65,7 +65,7 @@ function useTracker(m: (typeof TRACKED)[number], { qc, project, pollMs, agents }
const open = r.batches.filter((b) => (b.status === "queued" || b.status === "running") && b.id !== skip).sort((a, b) => a.id - b.id)[0];
if (!open) return;
failures.current = 0;
setActiveNow({ id: open.id, agentOffline: offlineNow(), own: false });
setActiveNow({ id: open.id, agentOffline: offlineNow(), own: false, project: projectRef.current });
})().catch(() => { /* не критично: подхватим при следующем проходе */ }).finally(() => { if (gen === generation.current) picking.current = false; });
// eslint-disable-next-line react-hooks/exhaustive-deps -- offlineNow читает ref, имя и охват модуля постоянны
}, [refresh, scoped, name, setActiveNow]);
@ -75,7 +75,8 @@ function useTracker(m: (typeof TRACKED)[number], { qc, project, pollMs, agents }
useEffect(() => {
if (prevProject.current !== project) {
prevProject.current = project;
if (scoped) {
// пакет проекта, на который перешли (создание проекта: track до смены адреса), не сбрасывается — только чужой (M-2)
if (scoped && activeRef.current?.project !== project) {
generation.current += 1;
picking.current = false;
failures.current = 0;
@ -161,6 +162,7 @@ function useTracker(m: (typeof TRACKED)[number], { qc, project, pollMs, agents }
const start = useCallback(async () => {
const p = projectRef.current;
const gen = generation.current; // вместе с проектом, до любого await: пакет старого проекта в строке нового не отслеживается (M-1)
if (!refresh || (scoped && p === null)) return;
if (startingRef.current || activeRef.current?.own) return;
startingRef.current = true;
@ -169,7 +171,6 @@ function useTracker(m: (typeof TRACKED)[number], { qc, project, pollMs, agents }
try {
const list = await qc.fetchQuery({ queryKey: AGENTS_KEY, queryFn: fetchAgents, staleTime: 0 });
const offline = !!status.agent && list.find((a) => a.module === status.agent)?.online === false;
const gen = generation.current;
let b: { id: number; estimate_seconds?: number };
try {
b = await refresh.start(p ?? "");
@ -179,7 +180,7 @@ function useTracker(m: (typeof TRACKED)[number], { qc, project, pollMs, agents }
}
if (gen !== generation.current) return; // проект сменился во время постановки: пакет не этого проекта
failures.current = 0;
setActiveNow({ id: b.id, agentOffline: offline, own: true });
setActiveNow({ id: b.id, agentOffline: offline, own: true, project: p });
setEvent({ kind: "batch", id: b.id, phase: "queued", remaining: b.estimate_seconds ?? 0, agentOffline: offline, retrying: false });
} catch (e) {
report(e);
@ -190,17 +191,21 @@ function useTracker(m: (typeof TRACKED)[number], { qc, project, pollMs, agents }
}
}, [refresh, scoped, status, qc, report, pickup, setActiveNow]);
const track = useCallback((id: number) => {
const track = useCallback((id: number, forProject?: string) => {
if (activeRef.current?.own) return;
failures.current = 0;
setActiveNow({ id, agentOffline: false, own: true });
setActiveNow({ id, agentOffline: false, own: true, project: forProject ?? projectRef.current });
setEvent({ kind: "batch", id, phase: "queued", remaining: 0, agentOffline: false, retrying: false });
}, [setActiveNow]);
const running = active && batch.data?.id === active.id ? batch.data : null;
const busy = starting || !!active?.own;
const view = useMemo<ModuleActivity>(() => ({ agent, event, busy, running, start, track }),
[agent, event, busy, running, start, track]);
// M-7: «агент офлайн» подхваченного пакета — по текущему состоянию агента, а не по снимку в момент подхвата
const shown = event?.kind === "batch" && !event.agentOffline && agent?.online === false && active?.id === event.id
? { ...event, agentOffline: true } : event;
const view = useMemo<ModuleActivity>(() => ({ agent, event: shown, busy, running, start, track }),
// eslint-disable-next-line react-hooks/exhaustive-deps -- shown зависит от event, agent и active
[agent, event, agent?.online, active?.id, busy, running, start, track]);
return { view, tracking: starting || active !== null };
}

View File

@ -337,6 +337,37 @@ describe("ActivityProvider", () => {
expect(m.now().jira.event).toMatchObject({ kind: "batch", id: 5 });
});
it("M-2: пакет, отданный track для нового проекта, переживает смену проекта на него; чужой — сбрасывается", async () => {
hub({ ...byId({ 9: "wiki_sync" }, () => "running") });
const m = mount();
await agentsReady(m);
act(() => m.now().wiki.track(9, "NEW"));
m.setProject("NEW");
await sleep(40);
expect(m.now().wiki.event).toMatchObject({ kind: "batch", id: 9 });
m.setProject("OTHER");
await waitFor(() => expect(m.now().wiki.event).toBeNull());
});
it("M-1: проект сменился во время запроса агентов — пакет старого проекта в строке нового не отслеживается", async () => {
const agents = deferred<void>();
let first = true;
hub({
"GET /agents": async () => { if (!first) await agents.promise; first = false; return json(200, { agents: [agentView("jira"), agentView("wiki")] }); },
"POST /wiki/sync": () => json(201, { batch_id: 11 }),
...byId({ 11: "wiki_sync" }, () => "running"),
});
const m = mount();
await agentsReady(m);
let started!: Promise<void>;
act(() => { started = m.now().wiki.start(); });
m.setProject("STOCKS_DRRK");
agents.resolve();
await act(() => started);
expect(m.now().wiki.event).toBeNull();
expect(m.now().wiki.busy).toBe(false);
});
it("wiki 409 tracks running batch: постановка получила 409 — подхвачен идущий пакет, события ошибки нет", async () => {
let conflict = false;
const h = hub({

View File

@ -9,7 +9,7 @@ export interface ModuleActivity {
busy: boolean; // запрос постановки в пути или отслеживаемый пакет — пакет действия обновления модуля
running: Batch | null; // отслеживаемый пакет (последний ответ опроса)
start(): Promise<void>; // «обновить» текущий проект модуля; без проекта у модуля с охватом «проект» — ничего
track(batchId: number): void; // опрашивать уже поставленный пакет (после создания проекта)
track(batchId: number, project?: string): void; // опрашивать уже поставленный пакет (после создания проекта); project — его проект, по умолчанию текущий
}
export interface ActivityValue {
@ -33,9 +33,9 @@ export function useModuleActivity(name: string): ModuleActivity {
return m;
}
// отслеживать пакет, поставленный хабом конкретному модулю (после создания/правки проекта); неизвестный модуль пропускается
export function useTrackModuleBatch(): (module: string, batchId: number) => void {
export function useTrackModuleBatch(): (module: string, batchId: number, project?: string) => void {
const { modules } = useActivity();
return useCallback((module, id) => { modules[module]?.track(id); }, [modules]);
return useCallback((module, id, project) => { if (project === undefined) modules[module]?.track(id); else modules[module]?.track(id, project); }, [modules]);
}
export const useAgents = (): AgentView[] => useActivity().agents;
export const useActivitySummary = (): ActivityValue["summary"] => useActivity().summary;