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 { Notice } from "../../ui/index.ts";
|
||||||
import { useBoard } from "./api/queries.ts";
|
import { useBoard } from "./api/queries.ts";
|
||||||
import { isClosed, useClosedStatuses } from "./api/closed.ts";
|
import { isClosed, useClosedStatuses } from "./api/closed.ts";
|
||||||
|
import { isOverdue, todayISO } from "./issues/card.ts";
|
||||||
import "./summary.css";
|
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 для «Обзора»: свои задачи проекта (не соседи), из них открытые и просроченные — по снимку доски
|
// сводка jira для «Обзора»: свои задачи проекта (не соседи), из них открытые и просроченные — по снимку доски
|
||||||
export function Summary({ project }: { project: Project }) {
|
export function Summary({ project }: { project: Project }) {
|
||||||
const board = useBoard(project.label);
|
const board = useBoard(project.label);
|
||||||
const closed = useClosedStatuses();
|
const closed = useClosedStatuses();
|
||||||
const q = `?project=${encodeURIComponent(project.label)}`;
|
const q = `?project=${encodeURIComponent(project.label)}`;
|
||||||
const own = board.data?.issues.filter((i) => !i.neighbor);
|
const own = board.data?.issues.filter((i) => !i.neighbor);
|
||||||
|
const t = todayISO();
|
||||||
const open = own?.filter((i) => !isClosed(i.status, closed));
|
const open = own?.filter((i) => !isClosed(i.status, closed));
|
||||||
const t = today();
|
const late = own?.filter((i) => isOverdue(i.duedate?.slice(0, 10) ?? "", i.status, closed, t)).length ?? 0;
|
||||||
const late = open?.filter((i) => !!i.duedate && i.duedate.slice(0, 10) < t).length ?? 0;
|
|
||||||
return (
|
return (
|
||||||
<div className="jira-summary">
|
<div className="jira-summary">
|
||||||
{board.isError ? <Notice tone="error">{board.error.message}</Notice>
|
{board.isError ? <Notice tone="error">{board.error.message}</Notice>
|
||||||
|
|||||||
@ -25,7 +25,7 @@ describe("NewProject: пакеты каждому своему модулю", ()
|
|||||||
await userEvent.type(screen.getByLabelText("Название"), "Пэ");
|
await userEvent.type(screen.getByLabelText("Название"), "Пэ");
|
||||||
await userEvent.click(screen.getByRole("button", { name: "Создать" }));
|
await userEvent.click(screen.getByRole("button", { name: "Создать" }));
|
||||||
await waitFor(() => expect(select).toHaveBeenCalledWith("P"));
|
await waitFor(() => expect(select).toHaveBeenCalledWith("P"));
|
||||||
expect(modules.jira.track).toHaveBeenCalledExactlyOnceWith(3);
|
expect(modules.jira.track).toHaveBeenCalledExactlyOnceWith(3, "P");
|
||||||
expect(modules.wiki.track).toHaveBeenCalledExactlyOnceWith(4);
|
expect(modules.wiki.track).toHaveBeenCalledExactlyOnceWith(4, "P");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -6,7 +6,7 @@ export function NewProject({ onClose, select }: { onClose(): void; select(label:
|
|||||||
const track = useTrackModuleBatch();
|
const track = useTrackModuleBatch();
|
||||||
return <NewProjectDialog onClose={onClose} onCreated={(p, batches) => {
|
return <NewProjectDialog onClose={onClose} onCreated={(p, batches) => {
|
||||||
onClose();
|
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);
|
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 { 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";
|
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 terminal = (b: Batch | undefined) => !!b && (b.status === "done" || b.status === "cancelled");
|
||||||
const fetchAgents = () => api.get<{ agents: AgentView[] }>("/agents").then((r) => r.agents);
|
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];
|
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;
|
if (!open) return;
|
||||||
failures.current = 0;
|
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; });
|
})().catch(() => { /* не критично: подхватим при следующем проходе */ }).finally(() => { if (gen === generation.current) picking.current = false; });
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- offlineNow читает ref, имя и охват модуля постоянны
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- offlineNow читает ref, имя и охват модуля постоянны
|
||||||
}, [refresh, scoped, name, setActiveNow]);
|
}, [refresh, scoped, name, setActiveNow]);
|
||||||
@ -75,7 +75,8 @@ function useTracker(m: (typeof TRACKED)[number], { qc, project, pollMs, agents }
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (prevProject.current !== project) {
|
if (prevProject.current !== project) {
|
||||||
prevProject.current = project;
|
prevProject.current = project;
|
||||||
if (scoped) {
|
// пакет проекта, на который перешли (создание проекта: track до смены адреса), не сбрасывается — только чужой (M-2)
|
||||||
|
if (scoped && activeRef.current?.project !== project) {
|
||||||
generation.current += 1;
|
generation.current += 1;
|
||||||
picking.current = false;
|
picking.current = false;
|
||||||
failures.current = 0;
|
failures.current = 0;
|
||||||
@ -161,6 +162,7 @@ function useTracker(m: (typeof TRACKED)[number], { qc, project, pollMs, agents }
|
|||||||
|
|
||||||
const start = useCallback(async () => {
|
const start = useCallback(async () => {
|
||||||
const p = projectRef.current;
|
const p = projectRef.current;
|
||||||
|
const gen = generation.current; // вместе с проектом, до любого await: пакет старого проекта в строке нового не отслеживается (M-1)
|
||||||
if (!refresh || (scoped && p === null)) return;
|
if (!refresh || (scoped && p === null)) return;
|
||||||
if (startingRef.current || activeRef.current?.own) return;
|
if (startingRef.current || activeRef.current?.own) return;
|
||||||
startingRef.current = true;
|
startingRef.current = true;
|
||||||
@ -169,7 +171,6 @@ function useTracker(m: (typeof TRACKED)[number], { qc, project, pollMs, agents }
|
|||||||
try {
|
try {
|
||||||
const list = await qc.fetchQuery({ queryKey: AGENTS_KEY, queryFn: fetchAgents, staleTime: 0 });
|
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 offline = !!status.agent && list.find((a) => a.module === status.agent)?.online === false;
|
||||||
const gen = generation.current;
|
|
||||||
let b: { id: number; estimate_seconds?: number };
|
let b: { id: number; estimate_seconds?: number };
|
||||||
try {
|
try {
|
||||||
b = await refresh.start(p ?? "");
|
b = await refresh.start(p ?? "");
|
||||||
@ -179,7 +180,7 @@ function useTracker(m: (typeof TRACKED)[number], { qc, project, pollMs, agents }
|
|||||||
}
|
}
|
||||||
if (gen !== generation.current) return; // проект сменился во время постановки: пакет не этого проекта
|
if (gen !== generation.current) return; // проект сменился во время постановки: пакет не этого проекта
|
||||||
failures.current = 0;
|
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 });
|
setEvent({ kind: "batch", id: b.id, phase: "queued", remaining: b.estimate_seconds ?? 0, agentOffline: offline, retrying: false });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
report(e);
|
report(e);
|
||||||
@ -190,17 +191,21 @@ function useTracker(m: (typeof TRACKED)[number], { qc, project, pollMs, agents }
|
|||||||
}
|
}
|
||||||
}, [refresh, scoped, status, qc, report, pickup, setActiveNow]);
|
}, [refresh, scoped, status, qc, report, pickup, setActiveNow]);
|
||||||
|
|
||||||
const track = useCallback((id: number) => {
|
const track = useCallback((id: number, forProject?: string) => {
|
||||||
if (activeRef.current?.own) return;
|
if (activeRef.current?.own) return;
|
||||||
failures.current = 0;
|
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 });
|
setEvent({ kind: "batch", id, phase: "queued", remaining: 0, agentOffline: false, retrying: false });
|
||||||
}, [setActiveNow]);
|
}, [setActiveNow]);
|
||||||
|
|
||||||
const running = active && batch.data?.id === active.id ? batch.data : null;
|
const running = active && batch.data?.id === active.id ? batch.data : null;
|
||||||
const busy = starting || !!active?.own;
|
const busy = starting || !!active?.own;
|
||||||
const view = useMemo<ModuleActivity>(() => ({ agent, event, busy, running, start, track }),
|
// M-7: «агент офлайн» подхваченного пакета — по текущему состоянию агента, а не по снимку в момент подхвата
|
||||||
[agent, event, busy, running, start, track]);
|
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 };
|
return { view, tracking: starting || active !== null };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -337,6 +337,37 @@ describe("ActivityProvider", () => {
|
|||||||
expect(m.now().jira.event).toMatchObject({ kind: "batch", id: 5 });
|
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 () => {
|
it("wiki 409 tracks running batch: постановка получила 409 — подхвачен идущий пакет, события ошибки нет", async () => {
|
||||||
let conflict = false;
|
let conflict = false;
|
||||||
const h = hub({
|
const h = hub({
|
||||||
|
|||||||
@ -9,7 +9,7 @@ export interface ModuleActivity {
|
|||||||
busy: boolean; // запрос постановки в пути или отслеживаемый пакет — пакет действия обновления модуля
|
busy: boolean; // запрос постановки в пути или отслеживаемый пакет — пакет действия обновления модуля
|
||||||
running: Batch | null; // отслеживаемый пакет (последний ответ опроса)
|
running: Batch | null; // отслеживаемый пакет (последний ответ опроса)
|
||||||
start(): Promise<void>; // «обновить» текущий проект модуля; без проекта у модуля с охватом «проект» — ничего
|
start(): Promise<void>; // «обновить» текущий проект модуля; без проекта у модуля с охватом «проект» — ничего
|
||||||
track(batchId: number): void; // опрашивать уже поставленный пакет (после создания проекта)
|
track(batchId: number, project?: string): void; // опрашивать уже поставленный пакет (после создания проекта); project — его проект, по умолчанию текущий
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ActivityValue {
|
export interface ActivityValue {
|
||||||
@ -33,9 +33,9 @@ export function useModuleActivity(name: string): ModuleActivity {
|
|||||||
return m;
|
return m;
|
||||||
}
|
}
|
||||||
// отслеживать пакет, поставленный хабом конкретному модулю (после создания/правки проекта); неизвестный модуль пропускается
|
// отслеживать пакет, поставленный хабом конкретному модулю (после создания/правки проекта); неизвестный модуль пропускается
|
||||||
export function useTrackModuleBatch(): (module: string, batchId: number) => void {
|
export function useTrackModuleBatch(): (module: string, batchId: number, project?: string) => void {
|
||||||
const { modules } = useActivity();
|
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 useAgents = (): AgentView[] => useActivity().agents;
|
||||||
export const useActivitySummary = (): ActivityValue["summary"] => useActivity().summary;
|
export const useActivitySummary = (): ActivityValue["summary"] => useActivity().summary;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user