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:
parent
36565d23b5
commit
2bc43953dc
@ -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>
|
||||
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
}} />;
|
||||
}
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user