feat(web-modules): состояние модуля в контракте, трекер пакетов и агенты в ядре

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-24 13:31:32 +04:00
parent 1a09086a9a
commit 844ddbde26
34 changed files with 952 additions and 694 deletions

View File

@ -125,4 +125,10 @@ type HubModule = {
| 21 | 24.10.2026 | Task 2: `ModuleProviders.tsx` перенесён из `modules/` в `shell/` (импортирует только `modules/index.ts`) | тест-страж границы допускает из ядра лишь `modules/index.ts` и `modules/types.ts`; `App.tsx` импортировал `modules/ModuleProviders.tsx` | вернуть файл и ослабить страж |
| 22 | 24.10.2026 | Task 2: `fmtDate` доски (`board/model/util.ts`, принимает не-ISO даты Jira, год не показывает) остаётся в модуле; в ядре `ui/format.ts` — свой `fmtDate`, таблица и карточка задач берут его (даты другого года — `ДД.ММ.ГГГГ`); `fmtStamp(null)` → «—» | задача требует форматтеры в ядре; доска сравнивает строки дат Jira в разных локалях | свести две функции в одну |
| 23 | 24.10.2026 | Task 2: словари jira-настроек — тип `JiraSettings extends HubSettings` в `modules/jira/api/types.ts`; `HubSettings` ядра — только `project_key` и индексная подпись | страж запрещает термины jira в ядре | вернуть словари в `HubSettings` |
| 24 | 24.10.2026 | Task 3: `ActivityProvider` стоит в `Layout` (внутри `ProjectContext`), а не в `App.tsx`; «проект создан» — `trackBatch(batchId)` ядра (модуль берётся из `Batch.module`, запасной путь — `refresh.isOwn`) вместо `useOnProjectCreated` модуля | `project` провайдера берётся из `ProjectContext`, он создаётся в `Layout`; модуль не называется в ядре | перенести провайдер выше и передавать проект иначе |
| 25 | 24.10.2026 | Task 3: подхват пакетов — только при монтировании, смене проекта, раз в 60 с и после 409; прежний подхват «после загрузки снимка доски» (`skipPickup`, T10.1/T4.1) убран | ядро не знает ключей данных модуля; правила подхвата заданы спекой; счётчик `skipPickup` больше не нужен | вернуть подписку на кэш по ключу, объявляемому модулем |
| 26 | 24.10.2026 | Task 3: состояние на модуль — хук-трекер на модуль (`useTracker`, вызывается по константному списку `TRACKED`) и `useQuery` пакета в каждом, а не общий `useQueries` | перенос логики остановки опроса без изменений; `useQueries` потребовал бы переписать эффект завершения | заменить на `useQueries` |
| 27 | 24.10.2026 | Task 3: `start` берёт агента свежим `fetchQuery(["agents"], staleTime 0)`, а не из кэша; ошибка этого запроса — событие `hub-down`/`error`, как у прежнего `GET /agent` | сохраняет поведение прежних сценариев (hub-down, 401 на предпроверке) | брать из кэша `useAgents()` |
| 28 | 24.10.2026 | Task 3: wiki `refresh.start` приводит ответ `{batch_id}` к `{id}`; `track()` перехватывает отслеживание, если текущий пакет не «свой» (раньше — только если ничего не отслеживалось) | контракт ядра единый `{id}`; пакет нового проекта важнее чужого | отдельная форма ответа / прежняя проверка |
| 29 | 24.10.2026 | Task 3: сводка `useActivitySummary`: error > offline > busy > online | ошибка и офлайн агента важнее, чем «идёт пакет» | поменять порядок |
| 30 | 24.10.2026 | Task 3: временный `shell/activity/StatusBlock.tsx` (строки модулей и строка событий узкого экрана) вместо `SidebarStatus`/`TopbarNotice` модулей; Task 4 заменяет его; страница вики берёт действие из трекера, уведомление об ошибке постановки (`syncError`) убрано — ошибка видна событием модуля | оболочка должна работать и набор быть зелёным до Task 4 | вернуть Notice на странице вики |

View File

@ -6,15 +6,8 @@ import type { HubModule, Overlay } from "./types.ts";
export const MODULES: readonly HubModule[] = [jiraModule, wikiModule];
const OVERLAY_HOOKS = MODULES.flatMap((m) => m.useOverlay ?? []);
const CREATED_HOOKS = MODULES.flatMap((m) => m.useOnProjectCreated ?? []);
// окна всех модулей; их число и порядок не меняются между отрисовками
export function useOverlays(): Overlay[] {
return OVERLAY_HOOKS.map((use) => use());
}
// обработчик «проект создан»: вызывает обработчики всех модулей
export function useOnProjectCreated(): (batchId: number) => void {
const handlers = CREATED_HOOKS.map((use) => use());
return (batchId) => handlers.forEach((h) => h(batchId));
}

View File

@ -20,7 +20,7 @@ function hub(over: Record<string, () => Response> = {}) {
if (k === "GET /settings") return json(200, settings);
if (k === "GET /board") return json(200, SNAP);
if (k === "GET /batches") return json(200, { batches: [] });
if (k === "GET /agent") return json(200, { online: true });
if (k === "GET /agents") return json(200, { agents: [] });
if (k === "POST /refresh") return json(201, { id: 7, estimate_seconds: 5, count: 1 });
if (k === "GET /batches/7") return json(200, { id: 7, author: "viewer", status: "running", remaining_seconds: 3, counts: {} });
return json(404, { error: "not_found" });

View File

@ -7,8 +7,8 @@ import { makeQueryClient } from "../../../api/queryClient.ts";
import { setToken } from "../../../auth/token.ts";
import { json } from "../../../test/fetchMock.ts";
import { batch, deferred, fixture as fx, hub, SNAP } from "../../../test/batchHub.ts";
import { useJiraRefresh } from "../refresh/refresh.ts";
import { RefreshProvider } from "../refresh/RefreshProvider.tsx";
import { ActivityProvider } from "../../../shell/activity/ActivityProvider.tsx";
import { useModuleActivity } from "../../../shell/activity/activity.ts";
import { dictsFromSettings } from "./dicts.ts";
import settings from "./__fixtures__/settings.json";
import { useBoardSource } from "./useBoardSource.ts";
@ -16,8 +16,8 @@ import { useBoardSource } from "./useBoardSource.ts";
const CSV = fx("jira-export.csv");
const { dicts } = dictsFromSettings(settings);
// доска под провайдером обновления из Jira (как в приложении); refresh — состояние оболочки
const hook = (client = makeQueryClient()) => ({ client, ...renderHook(() => ({ ...useBoardSource(dicts, "AWPCA"), refresh: useJiraRefresh() }),
{ wrapper: ({ children }: { children: ReactNode }) => <QueryClientProvider client={client}><RefreshProvider pollMs={10}>{children}</RefreshProvider></QueryClientProvider> }) });
const hook = (client = makeQueryClient()) => ({ client, ...renderHook(() => ({ ...useBoardSource(dicts, "AWPCA"), refresh: useModuleActivity("jira") }),
{ wrapper: ({ children }: { children: ReactNode }) => <QueryClientProvider client={client}><ActivityProvider project="AWPCA" pollMs={10}>{children}</ActivityProvider></QueryClientProvider> }) });
describe("useBoardSource", () => {
beforeEach(() => setToken("tok"));

View File

@ -38,7 +38,7 @@ function hub(changes: () => Changes = () => ({ changes: [] })) {
if (k === "GET /board") return json(200, SNAP);
if (k === "GET /issues") return json(200, { issues: [] });
if (k === "GET /batches") return json(200, { batches: [] });
if (k === "GET /agent") return json(200, { online: true, last_seen_at: null });
if (k === "GET /agents") return json(200, { agents: [] });
if (k.startsWith("GET /issues/")) return json(200, issue(decodeURIComponent(url.pathname.split("/").pop()!)));
return json(404, { error: "not_found" });
});

View File

@ -6,18 +6,11 @@ import { BoardPage } from "./board/BoardPage.tsx";
import { FeedPage } from "./feed/FeedPage.tsx";
import { useIssueOverlay } from "./issues/overlay.tsx";
import { IssuesPage } from "./issues/IssuesPage.tsx";
import { useJiraRefresh } from "./refresh/refresh.ts";
import { RefreshProvider } from "./refresh/RefreshProvider.tsx";
import { SidebarStatus, TopbarNotice } from "./refresh/SidebarStatus.tsx";
// после создания проекта пакет обновления, поставленный хабом, показывается в плашке данных
function useOnProjectCreated() {
const refresh = useJiraRefresh();
return (batchId: number) => refresh.track(batchId);
}
import { jiraStatus } from "./status.ts";
export const jiraModule: HubModule = {
name: "jira",
title: "Jira",
pages: [
{ path: "/", element: <Navigate to="/board" replace /> },
{ path: "/board", element: <ProjectGate>{(p) => <BoardPage key={p.label} project={p.label} />}</ProjectGate> },
@ -29,10 +22,7 @@ export const jiraModule: HubModule = {
{ to: "/issues", label: "Задачи", icon: <IconList />, padded: true },
{ to: "/feed", label: "Лента", icon: <IconHistory />, padded: true, keepSearch: true },
],
Provider: RefreshProvider,
SidebarStatus,
TopbarNotice,
status: jiraStatus,
useOverlay: useIssueOverlay,
projectFields: [{ name: "board_id", label: "Номер доски Jira", hint: "scrum или kanban определится сам", kind: "int" }],
useOnProjectCreated,
};

View File

@ -1,48 +0,0 @@
import { useBoard } from "../api/queries.ts";
import { fmtStamp } from "../../../ui/format.ts";
import { Button, IconButton, IconRefresh } from "../../../ui/index.ts";
import { useJiraRefresh } from "./refresh.ts";
import { refreshText } from "./refreshStatus.ts";
const HINT = "Поставить в хаб пакет «обновление по базовой JQL» и дождаться его: агент перечитает задачи из Jira";
// состояние данных: агент, время последнего полного обновления, кнопка «Обновить из Jira» и ход пакета;
// compact — свёрнутая боковая панель: кнопка-значок с точкой агента, тексты в подсказке (#69, design 3)
export function DataStatus({ project, compact = false }: { project: string | null; compact?: boolean }) {
const refresh = useJiraRefresh();
const board = useBoard(project);
const ls = board.data?.last_sync ?? null;
const agent = refresh.agent;
const rt = refresh.event ? refreshText(refresh.event) : null;
const seen = agent?.last_seen_at ? `последний выход на связь: ${fmtStamp(agent.last_seen_at)}` : "выхода на связь ещё не было";
const agentText = agent ? (agent.online ? "агент на связи" : "агент офлайн") : null;
const syncText = board.data ? (ls ? `данные на ${fmtStamp(ls.finished_at)}` : "полного обновления не было") : null;
const event = <span className={`data-status__event${rt ? ` data-status__event--${rt.tone}` : ""}${compact ? " visually-hidden" : ""}`}
role="status" title={rt?.text}>{rt?.text}</span>;
if (compact) {
const title = ["Обновить из Jira", agentText, syncText, rt?.text].filter(Boolean).join(" · ");
return (
<div className="data-status data-status--compact">
<IconButton label="Обновить из Jira" title={title} disabled={refresh.busy} aria-busy={refresh.busy ? "true" : undefined}
icon={refresh.busy ? <span className="ui-spinner" aria-hidden="true" /> : <IconRefresh />} onClick={() => void refresh.start()} />
{agent && <span className={"data-status__dot" + (agent.online ? " is-online" : "")} aria-hidden="true" />}
{event}
</div>
);
}
return (
<div className="data-status">
{agent && (
<span className="data-status__agent" title={seen}>
<span className={"data-status__dot" + (agent.online ? " is-online" : "")} aria-hidden="true" />
{agentText}
</span>
)}
{syncText && <span className="data-status__sync">{syncText}</span>}
<Button variant="primary" icon={<IconRefresh />} loading={refresh.busy} onClick={() => void refresh.start()} title={HINT}>
Обновить из Jira
</Button>
{event}
</div>
);
}

View File

@ -1,174 +0,0 @@
// владелец состояния «Обновить из 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>;
}

View File

@ -1,15 +0,0 @@
import { DataStatus } from "./DataStatus.tsx";
import { useJiraRefresh } from "./refresh.ts";
import { refreshText } from "./refreshStatus.ts";
// плашка данных и кнопка «Обновить из Jira» в боковой панели
export function SidebarStatus({ project, compact }: { project: string | null; compact: boolean }) {
return <DataStatus project={project} compact={compact} />;
}
// I-1: на телефоне подсказок нет — событие пакета строкой под верхней панелью
export function TopbarNotice() {
const refresh = useJiraRefresh();
const rt = refresh.event ? refreshText(refresh.event) : null;
return rt ? <div className={`topbar-event data-status__event--${rt.tone}`} data-testid="topbar-event" aria-hidden="true">{rt.text}</div> : null;
}

View File

@ -1,253 +0,0 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { act, render, renderHook, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useBoard, useIssues } from "../api/queries.ts";
import { makeQueryClient } from "../../../api/queryClient.ts";
import { getToken, setToken } from "../../../auth/token.ts";
import { json } from "../../../test/fetchMock.ts";
import { batch, deferred, hub, SNAP, sleep } from "../../../test/batchHub.ts";
import { useJiraRefresh, type JiraRefresh } from "./refresh.ts";
import { RefreshProvider } from "./RefreshProvider.tsx";
const wrap = (client = makeQueryClient()) => ({ children }: { children: ReactNode }) =>
<QueryClientProvider client={client}><RefreshProvider pollMs={10}>{children}</RefreshProvider></QueryClientProvider>;
const refreshHook = (client = makeQueryClient()) => ({ client, ...renderHook(() => useJiraRefresh(), { wrapper: wrap(client) }) });
describe("RefreshProvider", () => {
beforeEach(() => setToken("tok"));
afterEach(() => { vi.useRealTimers(); });
it("вне провайдера — понятная ошибка", () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
expect(() => renderHook(() => useJiraRefresh())).toThrow(/RefreshProvider/);
spy.mockRestore();
});
it("M-2: двойное нажатие — один пакет и один опрос за раз; по завершении board перечитан, список пакетов — нет", async () => {
let polls = 0;
const h = hub({ "GET /batches/:id": () => (++polls < 4 ? batch("running") : batch("done")) });
const { result } = renderHook(() => ({ r: useJiraRefresh(), b: useBoard("AWPCA") }), { wrapper: wrap() });
await waitFor(() => expect(result.current.b.data).toBeDefined());
await waitFor(() => expect(h.count("GET /batches")).toBeGreaterThanOrEqual(1));
const listed = h.count("GET /batches");
await act(async () => { void result.current.r.start(); await result.current.r.start(); });
expect(h.count("POST /refresh")).toBe(1);
expect(result.current.r.busy).toBe(true);
await waitFor(() => expect(result.current.r.event).toEqual({ kind: "batch-done", id: 7 }));
expect(result.current.r.busy).toBe(false);
expect(h.maxInFlight("GET /batches/7")).toBe(1);
await waitFor(() => expect(h.count("GET /board")).toBe(2)); // по завершении снимок перечитан
await sleep(30);
expect(h.count("GET /batches")).toBe(listed); // T10.1: после своего пакета список пакетов не перечитывается
});
it("M-3: незавершённый пакет viewer подхватывается после загрузки снимка (в том числе повторной)", async () => {
let listed: unknown[] = [];
const h = hub({ "GET /batches": () => json(200, { batches: listed }), "GET /batches/:id": () => batch("running") });
const { result } = renderHook(() => ({ r: useJiraRefresh(), b: useBoard("AWPCA") }), { wrapper: wrap() });
await waitFor(() => expect(h.count("GET /batches")).toBeGreaterThanOrEqual(1));
expect(result.current.r.busy).toBe(false);
listed = [{ id: 7, author: "viewer", status: "running" }, { id: 6, author: "chat", status: "running" }];
await act(async () => { await result.current.b.refetch(); });
await waitFor(() => expect(result.current.r.busy).toBe(true));
await waitFor(() => expect(result.current.r.event).toMatchObject({ kind: "batch", id: 7, phase: "running" }));
});
it("M-3: пакет подхватывается при монтировании провайдера", async () => {
hub({ "GET /batches": () => json(200, { batches: [{ id: 7, author: "viewer", status: "queued" }] }), "GET /batches/:id": () => batch("running") });
const { result } = refreshHook();
await waitFor(() => expect(result.current.event).toMatchObject({ kind: "batch", id: 7 }));
expect(result.current.busy).toBe(true);
});
it("M-4: 404 на опросе — опрос остановлен, кнопка доступна", async () => {
const h = hub({ "GET /batches/:id": () => json(404, { error: "not_found", detail: "batch" }) });
const { result } = refreshHook();
await act(() => result.current.start());
await waitFor(() => expect(result.current.event).toMatchObject({ kind: "batch-lost", id: 7, reason: "пакета нет в хабе" }));
expect(result.current.busy).toBe(false);
const n = h.count("GET /batches/7");
await sleep(50);
expect(h.count("GET /batches/7")).toBe(n);
});
for (const status of [400, 422]) {
it(`T10.2: ${status} на опросе — опрос остановлен, batch-lost с detail, busy сброшен`, async () => {
const h = hub({ "GET /batches/:id": () => json(status, { error: "bad_request", detail: "плохой пакет" }) });
const { result } = refreshHook();
await act(() => result.current.start());
await waitFor(() => expect(result.current.event).toMatchObject({ kind: "batch-lost", id: 7, reason: expect.stringContaining("плохой пакет") }));
expect(result.current.busy).toBe(false);
const n = h.count("GET /batches/7");
await sleep(50);
expect(h.count("GET /batches/7")).toBe(n);
});
}
it("M-4: 5 ошибок «хаб не отвечает» подряд — опрос остановлен", async () => {
const h = hub({ "GET /batches/:id": () => { throw new TypeError("Failed to fetch"); } });
const { result } = refreshHook();
await act(() => result.current.start());
await waitFor(() => expect(result.current.event).toMatchObject({ kind: "batch-lost", reason: "хаб не отвечает" }));
expect(h.count("GET /batches/7")).toBe(5);
await sleep(50);
expect(h.count("GET /batches/7")).toBe(5);
expect(result.current.busy).toBe(false);
});
it("пакет с ошибкой команды — текст ошибки; агент офлайн — предупреждение в событии", async () => {
let polls = 0;
hub({
"GET /agent": () => json(200, { online: false, last_seen_at: null, warning: null }),
"GET /batches/:id": () => (++polls < 3 ? batch("running") : batch("done", { status: "failed", error: "Jira 500" })),
});
const { result } = refreshHook();
await act(() => result.current.start());
await waitFor(() => expect(result.current.event).toMatchObject({ kind: "batch", agentOffline: true }));
await waitFor(() => expect(result.current.event).toEqual({ kind: "batch-failed", id: 7, error: "Jira 500" }));
});
it("«Обновить из Jira» при недоступном хабе — событие hub-down", async () => {
hub({ "GET /agent": () => { throw new TypeError("Failed to fetch"); } });
const { result } = refreshHook();
await act(() => result.current.start());
expect(result.current.event).toEqual({ kind: "hub-down" });
expect(result.current.busy).toBe(false);
});
it("T10.6: незавершённый пакет viewer без refresh не подхватывается", async () => {
const h = hub({
"GET /batches": () => json(200, { batches: [{ id: 7, author: "viewer", status: "running" }] }),
"GET /batches/:id": () => batch("running", { type: "ensure_fields" }),
});
const { result } = refreshHook();
await waitFor(() => expect(h.count("GET /batches/7")).toBe(1));
await sleep(50);
expect(result.current.busy).toBe(false);
expect(h.count("GET /batches/7")).toBe(1); // только проверка типа, опроса нет
});
it("M-12: пакет wiki_sync viewer не заслоняет более старый refresh", async () => {
hub({
"GET /batches": () => json(200, { batches: [{ id: 9, author: "viewer", status: "queued" }, { id: 7, author: "viewer", status: "running" }] }),
"GET /batches/:id": (url) => batch("running", { type: url.pathname.endsWith("/9") ? "wiki_sync" : "refresh" }),
});
const { result } = refreshHook();
await waitFor(() => expect(result.current.event).toMatchObject({ kind: "batch", id: 7 }));
expect(result.current.busy).toBe(true);
});
it("401 на запуске пакета с текущим токеном — токен сброшен", async () => {
hub({ "GET /agent": () => json(401, { error: "unauthorized" }) });
const { result } = refreshHook();
await act(() => result.current.start());
expect(getToken()).toBe("");
});
it("запоздавший 401 прежнему токену не сбрасывает новый токен", async () => {
const slow = deferred<Response>();
hub({ "GET /agent": () => slow.promise });
const { result } = refreshHook();
let run!: Promise<void>;
act(() => { run = result.current.start(); });
setToken("new-tok");
slow.resolve(json(401, { error: "unauthorized" }));
await act(() => run);
expect(getToken()).toBe("new-tok");
});
it("T4.1: упавшее перечитывание снимка после своего пакета не гасит подхват следующей независимой загрузки", async () => {
let boardFails = false, listed: unknown[] = [];
const h = hub({
"GET /board": () => (boardFails ? json(500, { error: "boom" }) : json(200, SNAP)),
"GET /batches": () => json(200, { batches: listed }),
"GET /batches/:id": (url) => (url.pathname.endsWith("/8") ? json(200, { id: 8, author: "viewer", status: "running", remaining_seconds: 3, counts: {}, commands: [{ type: "refresh", status: "running" }] }) : batch("done")),
});
const { result } = renderHook(() => ({ r: useJiraRefresh(), b: useBoard("AWPCA") }), { wrapper: wrap() });
await waitFor(() => expect(result.current.b.data).toBeDefined());
await waitFor(() => expect(h.count("GET /batches")).toBeGreaterThanOrEqual(1));
boardFails = true;
await act(() => result.current.r.start());
await waitFor(() => expect(result.current.r.event).toEqual({ kind: "batch-done", id: 7 }));
await waitFor(() => expect(h.count("GET /board")).toBe(2)); // перечитывание после пакета упало
await sleep(30);
boardFails = false;
listed = [{ id: 8, author: "viewer", status: "running" }];
await act(async () => { await result.current.b.refetch(); });
await waitFor(() => expect(result.current.r.event).toMatchObject({ kind: "batch", id: 8 }));
});
it("по завершении инвалидируются board, issues, issue, projects", async () => {
let polls = 0;
const h = hub({ "GET /batches/:id": () => (++polls < 2 ? batch("running") : batch("done")) });
const { result } = renderHook(() => ({ r: useJiraRefresh(), b: useBoard("AWPCA"), i: useIssues({}) }), { wrapper: wrap() });
await waitFor(() => expect(result.current.b.data).toBeDefined());
await waitFor(() => expect(result.current.i.data).toBeDefined());
await act(() => result.current.r.start());
await waitFor(() => expect(result.current.r.event).toMatchObject({ kind: "batch-done" }));
await waitFor(() => expect(h.count("GET /board")).toBe(2));
await waitFor(() => expect(h.count("GET /issues")).toBe(2));
});
it("инвалидируются ключи board, issues, issue, projects, changes (шпион)", async () => {
const client = makeQueryClient();
const spy = vi.spyOn(client, "invalidateQueries");
hub({ "GET /batches/:id": () => batch("done") });
const { result } = refreshHook(client);
await act(() => result.current.start());
await waitFor(() => expect(result.current.event).toEqual({ kind: "batch-done", id: 7 }));
const keys = spy.mock.calls.map(([f]) => (f as { queryKey: unknown[] }).queryKey[0]);
expect(keys).toEqual(expect.arrayContaining(["board", "issues", "issue", "projects", "changes"]));
});
it("track(9) опрашивает пакет 9 без POST /refresh", async () => {
const h = hub({ "GET /batches/:id": () => json(200, { id: 9, title: "x", author: "viewer", created_at: "", cancelled_at: null, status: "done", counts: {}, remaining_seconds: 0, commands: [{ id: 1, seq: 1, type: "refresh", error: null, finished_at: null, status: "done" }] }) });
const { result } = refreshHook();
await waitFor(() => expect(h.count("GET /batches")).toBe(1));
act(() => result.current.track(9));
expect(result.current.busy).toBe(true);
await waitFor(() => expect(result.current.event).toEqual({ kind: "batch-done", id: 9 }));
expect(h.count("POST /refresh")).toBe(0);
expect(h.count("GET /batches/9")).toBeGreaterThanOrEqual(1);
});
it("batch-done исчезает через 10 с", async () => {
hub({ "GET /batches/:id": () => batch("done") });
vi.useFakeTimers({ shouldAdvanceTime: true }); // время идёт и само (опрос, waitFor), но сдвигается и вручную
const { result } = refreshHook();
await act(() => result.current.start());
await waitFor(() => expect(result.current.event).toEqual({ kind: "batch-done", id: 7 }));
await act(async () => { vi.advanceTimersByTime(9_800); });
expect(result.current.event).toEqual({ kind: "batch-done", id: 7 });
await act(async () => { vi.advanceTimersByTime(200); });
expect(result.current.event).toBeNull();
});
it("переход между страницами во время пакета: один POST, один опрос, busy до завершения; по завершении board перечитан", async () => {
let finished = false;
const h = hub({ "GET /batches/:id": () => (finished ? batch("done") : batch("running")) });
const client = makeQueryClient();
let r!: JiraRefresh;
const Probe = () => { r = useJiraRefresh(); return null; };
const Page = () => { useBoard("AWPCA"); return null; }; // «страница»: монтируется и размонтируется, провайдер — один
const ui = (page: boolean) => (
<QueryClientProvider client={client}><RefreshProvider pollMs={10}><Probe />{page && <Page />}</RefreshProvider></QueryClientProvider>);
const view = render(ui(true));
await waitFor(() => expect(h.count("GET /board")).toBe(1));
await act(() => r.start());
view.rerender(ui(false));
expect(r.busy).toBe(true);
view.rerender(ui(true));
await waitFor(() => expect(h.count("GET /board")).toBeGreaterThanOrEqual(1));
await sleep(40);
expect(h.count("POST /refresh")).toBe(1);
expect(h.maxInFlight("GET /batches/7")).toBe(1);
expect(r.busy).toBe(true);
const boards = h.count("GET /board");
finished = true;
await waitFor(() => expect(r.busy).toBe(false));
expect(r.event).toMatchObject({ kind: "batch-done" });
await waitFor(() => expect(h.count("GET /board")).toBe(boards + 1));
});
});

View File

@ -1,20 +0,0 @@
import { createContext, useContext } from "react";
import type { AgentView } from "../../../api/types.ts";
import type { RefreshEvent } from "./refreshStatus.ts";
// обновление из Jira: состояние живёт в оболочке (RefreshProvider), страницы и верхняя панель только читают его
export interface JiraRefresh {
event: RefreshEvent | null;
busy: boolean; // запрос /refresh в пути или пакет выполняется
agent: AgentView | null;
start(): Promise<void>;
track(batchId: number): void; // опрашивать уже поставленный пакет (форма «Новый проект»)
}
export const RefreshContext = createContext<JiraRefresh | null>(null);
export function useJiraRefresh(): JiraRefresh {
const v = useContext(RefreshContext);
if (!v) throw new Error("useJiraRefresh вызван вне RefreshProvider: оберните приложение в <RefreshProvider>");
return v;
}

View File

@ -1,20 +0,0 @@
import { describe, expect, it } from "vitest";
import { refreshText } from "./refreshStatus.ts";
describe("refreshText", () => {
it("пакет: офлайн, очередь, остаток, повтор", () => {
expect(refreshText({ kind: "batch", id: 7, phase: "queued", remaining: 12, agentOffline: true, retrying: false }))
.toEqual({ text: "агент офлайн — пакет выполнится, когда он выйдет на связь; пакет #7 в очереди, осталось ~12 с…", tone: "warning" });
expect(refreshText({ kind: "batch", id: 7, phase: "running", remaining: 0, agentOffline: false, retrying: true }))
.toEqual({ text: "пакет #7 выполняется… (хаб не отвечает, повтор через 3 с)", tone: "warning" });
});
it("исходы и ошибки", () => {
expect(refreshText({ kind: "batch-done", id: 7 })).toEqual({ text: "пакет #7 выполнен", tone: "success" });
expect(refreshText({ kind: "batch-failed", id: 7, error: "429" })).toEqual({ text: "пакет #7 не выполнен: 429", tone: "error" });
expect(refreshText({ kind: "batch-cancelled", id: 7 })).toEqual({ text: "пакет #7 отменён", tone: "warning" });
expect(refreshText({ kind: "batch-lost", id: 7, reason: "пакета нет в хабе" }).text)
.toBe("пакет #7: пакета нет в хабе — опрос остановлен, «Обновить из Jira» снова доступна");
expect(refreshText({ kind: "hub-down" })).toEqual({ text: "хаб не отвечает — пакет не поставлен", tone: "error" });
expect(refreshText({ kind: "error", message: "boom" })).toEqual({ text: "boom", tone: "error" });
});
});

View File

@ -0,0 +1,20 @@
import { api } from "../../api/client.ts";
import type { CreatedBatch } from "../../api/types.ts";
import type { ModuleStatus } from "../types.ts";
import { qk } from "./api/queries.ts";
// состояние модуля jira (контракт ModuleStatus): свежесть — по всем проектам, обновление — полное по базовой JQL
export const jiraStatus: ModuleStatus = {
agent: "jira",
scope: "all",
freshness: (p) => ({ available: true, at: (p.jira_synced_at as string | null | undefined) ?? null, error: null }),
refresh: {
label: "Обновить из Jira",
start: () => api.post<CreatedBatch>("/refresh", { full: true, author: "viewer" }),
isOwn: (b) => b.commands?.[0]?.type === "refresh",
},
// перечитывание активных снимков досок — после своего пакета; показанный файл не заменяется (M-1)
invalidate: (qc) => {
for (const key of [qk.boardAll, ["issues"], ["issue"], ["changes"]]) void qc.invalidateQueries({ queryKey: key });
},
};

View File

@ -1,5 +1,7 @@
import type { QueryClient } from "@tanstack/react-query";
import type { ComponentType, ReactNode } from "react";
import type { Location, RouteObject } from "react-router";
import type { Batch, Project } from "../api/types.ts";
export type NavItem = {
to: string; label: string; icon: ReactNode;
@ -12,16 +14,28 @@ export type Overlay = { pageLocation: Location | null; element: ReactNode };
// поле окна «Новый проект», которое вносит модуль: пустое значение в тело запроса не попадает, int уходит числом
export type ProjectFieldDef = { name: string; label: string; hint: string; kind: "int" | "text" };
// состояние модуля — данные; рисует и ведёт его ядро (shell/activity, design web-modules «Контракт веб-модуля»)
export type ModuleStatus = {
agent?: string; // имя модуля агента в GET /agents
scope: "all" | "project"; // охват свежести и обновления
freshness(p: Project): { available: boolean; at: string | null; error: string | null };
refresh?: {
label: string; // «Обновить из Jira», «Обновить вики»
start(project: string): Promise<{ id: number; estimate_seconds?: number }>; // 409 → ApiError, ядро подхватит идущий
isOwn(b: Batch): boolean; // пакет этого действия (по commands[0].type)
};
invalidate(qc: QueryClient, project: string | null): void; // что перечитать после пакета модуля
};
// описание веб-модуля (design hub-modules 10): оболочка берёт из него страницы, пункты панели, состояние данных, окна
export type HubModule = {
name: string;
title: string; // название для показа: «Jira», «Вики»
pages: RouteObject[]; // без "*" — NotFound добавляет оболочка последним
nav: NavItem[];
Provider?: ComponentType<{ children: ReactNode }>;
// плашка данных и кнопка обновления в боковой панели; compact — свёрнутая панель или строка узкого экрана
SidebarStatus?: ComponentType<{ project: string | null; compact: boolean }>;
TopbarNotice?: ComponentType; // строка события под верхней панелью узкого экрана
status?: ModuleStatus;
Summary?: ComponentType<{ project: Project }>; // карточка модуля на «Обзоре»
useOverlay?: () => Overlay; // вызывается оболочкой в постоянном порядке модулей
projectFields?: ProjectFieldDef[]; // поля проекта модуля: окно создания собирает их у всех модулей
useOnProjectCreated?: () => (batchId: number) => void;
};

View File

@ -1,44 +0,0 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { act, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { makeQueryClient } from "../../api/queryClient.ts";
import { setToken } from "../../auth/token.ts";
import { json, mockFetch } from "../../test/fetchMock.ts";
import { POLL_MS } from "./api/queries.ts";
import { SidebarStatus } from "./SyncStatus.tsx";
const tick = (ms: number) => act(async () => { await vi.advanceTimersByTimeAsync(ms); });
const base = { root: "DOC-1", root_title: "К", synced_at: "2026-10-20T10:00:00+00:00", pages: 3, error: null };
describe("SidebarStatus вики", () => {
beforeEach(() => { setToken("tok"); vi.useFakeTimers(); });
afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); });
it("опрос раз в 3 с, пока пакет идёт; при терминальном статусе опрос прекращается", async () => {
let calls = 0;
mockFetch((_m, url) => {
if (url.pathname === "/projects") return json(200, { projects: [{ label: "AWPCA", name: "A", created_at: "", wiki_root: "DOC-1" }] });
calls++;
return json(200, { ...base, batch: { id: 5, status: calls < 3 ? "running" : "done" } });
});
render(<QueryClientProvider client={makeQueryClient()}><SidebarStatus project="AWPCA" compact={false} /></QueryClientProvider>);
await tick(10); // сначала список проектов, затем статус
expect(screen.getByRole("status")).toHaveTextContent("пакет #5 выполняется");
expect(calls).toBe(1);
await tick(POLL_MS);
expect(calls).toBe(2);
await tick(POLL_MS);
expect(calls).toBe(3); // третий ответ — done
expect(screen.queryByRole("status")).toBeNull();
expect(screen.getByText(/^Вики на/)).toBeInTheDocument();
await tick(5 * POLL_MS);
expect(calls).toBe(3);
});
it("проект без корня: запросов статуса нет", async () => {
const f = mockFetch((_m, url) => url.pathname === "/projects" ? json(200, { projects: [{ label: "P", name: "P", created_at: "", wiki_root: null }] }) : json(409, {}));
render(<QueryClientProvider client={makeQueryClient()}><SidebarStatus project="P" compact={false} /></QueryClientProvider>);
await tick(0);
expect(f.mock.calls.some(([u]) => String(u).startsWith("/wiki/status"))).toBe(false);
});
});

View File

@ -1,28 +0,0 @@
import { useProjects } from "../../api/queries.ts";
import { fmtStamp } from "../../ui/format.ts";
import { useWikiStatus } from "./api/queries.ts";
// ход синхронизации вики текущего проекта в боковой панели; запрос — только у проекта с корнем (по списку проектов)
export function SidebarStatus({ project, compact }: { project: string | null; compact: boolean }) {
const projects = useProjects();
const hasRoot = !!project && !!projects.data?.find((p) => p.label === project)?.wiki_root;
const status = useWikiStatus(project ?? "", hasRoot);
const s = status.data;
if (!hasRoot || !s) return null;
let text: string, tone: "info" | "error" | "success" | "plain" = "plain";
if (s.batch && s.batch.status !== "done" && s.batch.status !== "cancelled") {
text = `Вики: пакет #${s.batch.id} ${s.batch.status === "queued" ? "в очереди" : "выполняется"}…`; tone = "info";
} else if (s.error) {
text = `Вики: синхронизация не удалась — ${s.error}`; tone = "error";
} else if (s.synced_at) {
text = `Вики на ${fmtStamp(s.synced_at)}`;
} else {
text = "Вики ещё не синхронизирована";
}
return (
<div className={`data-status wiki-status wiki-status--${tone}`}>
<span className={compact ? "visually-hidden" : "wiki-status__text"} role={tone === "plain" ? undefined : "status"} title={text}>{text}</span>
{compact && tone !== "plain" && <span className={`data-status__dot wiki-status__dot wiki-status__dot--${tone}`} aria-hidden="true" title={text} />}
</div>
);
}

View File

@ -2,7 +2,8 @@ import { useCallback, useEffect, useState } from "react";
import { useNavigate, useSearchParams } from "react-router";
import { ApiError } from "../../api/client.ts";
import { Button, IconRefresh, Notice, PageHeader, Panel } from "../../ui/index.ts";
import { useWikiSearch, useWikiStatus, useWikiSync, useWikiTree } from "./api/queries.ts";
import { useModuleActivity } from "../../shell/activity/activity.ts";
import { useWikiSearch, useWikiStatus, useWikiTree } from "./api/queries.ts";
import { PageView } from "./PageView.tsx";
import { RootForm } from "./RootForm.tsx";
import { SearchBox, SearchResults } from "./Search.tsx";
@ -22,7 +23,7 @@ export function WikiPage({ project }: { project: string }) {
const noRoot = status.error instanceof ApiError && status.error.error === "no_root";
const synced = !!status.data && (status.data.synced_at !== null || status.data.pages > 0);
const tree = useWikiTree(project, synced);
const sync = useWikiSync(project);
const sync = useModuleActivity("wiki");
const [q, setQ] = useState("");
const [dq, setDq] = useState("");
@ -39,13 +40,11 @@ export function WikiPage({ project }: { project: string }) {
const s = status.data;
const running = !!s.batch && s.batch.status !== "done" && s.batch.status !== "cancelled";
const syncError = sync.error instanceof ApiError && sync.error.status === 409 ? null : sync.error?.message;
return (
<div className="wiki" data-testid="page-wiki">
<PageHeader title="Вики" subtitle={s.root_title ? `${s.root_title} · страниц: ${s.pages}` : undefined}
actions={<Button variant="primary" icon={<IconRefresh />} loading={sync.isPending || running} onClick={() => sync.mutate()}>Обновить вики</Button>} />
actions={<Button variant="primary" icon={<IconRefresh />} loading={sync.busy || running} onClick={() => void sync.start()}>Обновить вики</Button>} />
{s.error && <Notice tone="error" title="Последняя синхронизация не удалась">{s.error}</Notice>}
{syncError && <Notice tone="error">{syncError}</Notice>}
{!synced ? (
<Notice tone="info">{running ? "Вики синхронизируется…" : "Вики ещё не синхронизирована. Нажмите «Обновить вики»."}</Notice>
) : (

View File

@ -1,4 +1,4 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useRef } from "react";
import { api } from "../../../api/client.ts";
@ -62,12 +62,3 @@ export const useWikiSearch = (project: string, q: string) =>
queryKey: wikiQk.search(project, q), enabled: q !== "",
queryFn: () => api.get<{ results: WikiHit[] }>("/wiki/search", { project, q, limit: SEARCH_LIMIT }).then((r) => r.results),
});
// «Обновить вики»: пакет ставит хаб; 409 — синхронизация уже идёт, статус перечитывается и показывает её пакет
export function useWikiSync(project: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: () => api.post<{ batch_id: number }>("/wiki/sync", { project, author: "viewer" }),
onSettled: () => qc.invalidateQueries({ queryKey: wikiQk.status(project) }),
});
}

View File

@ -1,13 +1,14 @@
import { ProjectGate } from "../../projects/ProjectGate.tsx";
import { IconBook } from "../../ui/index.ts";
import type { HubModule } from "../types.ts";
import { SidebarStatus } from "./SyncStatus.tsx";
import { wikiStatus } from "./status.ts";
import { WikiPage } from "./WikiPage.tsx";
export const wikiModule: HubModule = {
name: "wiki",
title: "Вики",
pages: [{ path: "/wiki", element: <ProjectGate>{(p) => <WikiPage key={p.label} project={p.label} />}</ProjectGate> }],
nav: [{ to: "/wiki", label: "Вики", icon: <IconBook />, padded: true }],
SidebarStatus,
status: wikiStatus,
projectFields: [{ name: "wiki_root", label: "Корень вики", hint: "DOC-… или ссылка на страницу Eva", kind: "text" }],
};

View File

@ -0,0 +1,24 @@
import { api } from "../../api/client.ts";
import type { ModuleStatus } from "../types.ts";
import { wikiQk } from "./api/queries.ts";
// состояние модуля wiki (контракт ModuleStatus): данные есть только у проекта с корнем вики, обновление — проектное
export const wikiStatus: ModuleStatus = {
agent: "wiki",
scope: "project",
freshness: (p) => ({
available: !!p.wiki_root,
at: (p.wiki_synced_at as string | null | undefined) ?? null,
error: (p.wiki_sync_error as string | null | undefined) ?? null,
}),
refresh: {
label: "Обновить вики",
// хаб отвечает {batch_id}; 409 (синхронизация уже идёт) — ApiError, ядро подхватит идущий пакет
start: (project) => api.post<{ batch_id: number }>("/wiki/sync", { project, author: "viewer" }).then((r) => ({ id: r.batch_id })),
isOwn: (b) => b.commands?.[0]?.type === "wiki_sync",
},
invalidate: (qc, project) => {
if (project === null) return;
for (const key of ["tree", "page", "search"]) void qc.invalidateQueries({ queryKey: [...wikiQk.project(project), key] });
},
};

View File

@ -61,9 +61,3 @@
.wiki-att__error{color:var(--color-status-error)}
/* плашка хода синхронизации в боковой панели */
.wiki-status{flex-direction:row;align-items:center}
.wiki-status--info .wiki-status__text{color:var(--color-text-primary)}
.wiki-status--error .wiki-status__text{color:var(--color-status-error)}
.wiki-status .wiki-status__dot{position:static;box-shadow:none}
.wiki-status__dot--info{background:var(--color-action-primary)}
.wiki-status__dot--error{background:var(--color-status-error)}

View File

@ -12,7 +12,7 @@ import { NewProjectDialog } from "./NewProjectDialog.tsx";
vi.mock("../modules/index.ts", async () => {
const { jiraModule: jira } = await import("../modules/jira/index.tsx");
const extra: HubModule = {
name: "extra", pages: [], nav: [],
name: "extra", title: "Extra", pages: [], nav: [],
projectFields: [{ name: "space_key", label: "Пространство", hint: "ключ", kind: "text" }],
};
return { MODULES: [jira, extra] };

View File

@ -30,7 +30,7 @@ function hub(projects: unknown[] = [A, S], over: Record<string, (body: unknown)
if (k === "GET /issues") return json(200, { issues: [ROW] });
if (k === "GET /issues/AURORA-9001") return json(200, issue("AURORA-9001"));
if (k === "GET /batches") return json(200, { batches: [] });
if (k === "GET /agent") return json(200, { online: true });
if (k === "GET /agents") return json(200, { agents: [] });
return json(404, { error: "not_found" });
});
const get = (path: string) => calls.filter((c) => c.method === "GET" && c.url.pathname === path);
@ -97,8 +97,10 @@ describe("проект в приложении", () => {
});
it("создание проекта из формы", async () => {
let created = false;
const h = hub([A], {
"POST /projects": () => json(201, { project: S, batch_id: 7 }),
"POST /projects": () => { created = true; return json(201, { project: S, batch_id: 7 }); },
"GET /projects": () => json(200, { projects: created ? [A, S] : [A] }), // блок состояния берёт проект из списка
"GET /batches/7": () => json(200, { id: 7, author: "viewer", status: "queued", remaining_seconds: 5, counts: {}, commands: [{ type: "refresh", status: "queued" }] }),
});
const router = open("/app/board?project=AWPCA");
@ -110,9 +112,9 @@ describe("проект в приложении", () => {
await userEvent.type(within(dlg).getByLabelText("Название"), "Запасы ДРРК");
await userEvent.click(within(dlg).getByRole("button", { name: "Создать" }));
await waitFor(() => expect(router.state.location.search).toBe("?project=STOCKS_DRRK"));
// пакет проекта отслеживается по batch_id (track): верхняя панель показывает ход, список пакетов не запрашивается
// пакет проекта отслеживается по batch_id (trackBatch): панель показывает ход
expect(await within(screen.getByRole("complementary", { name: "Панель приложения" })).findByRole("status")).toHaveTextContent(/пакет #7 в очереди/);
expect(h.get("/batches").length).toBe(listed);
expect(h.get("/batches").length).toBeGreaterThanOrEqual(listed);
const post = h.calls.find((c) => c.method === "POST" && c.url.pathname === "/projects")!;
expect(post.body).toEqual({ label: "STOCKS_DRRK", name: "Запасы ДРРК", author: "viewer" });
expect(screen.queryByRole("dialog", { name: "Новый проект" })).not.toBeInTheDocument();

View File

@ -1,10 +1,12 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useLocation, useNavigate, useRoutes } from "react-router";
import { useProjects } from "../api/queries.ts";
import { MODULES, useOnProjectCreated, useOverlays } from "../modules/index.ts";
import { MODULES, useOverlays } from "../modules/index.ts";
import { rememberedProject, rememberProject, resolveProject, withProject } from "../projects/current.ts";
import { NewProjectDialog } from "../projects/NewProjectDialog.tsx";
import { ProjectContext, type ProjectCtx, type ProjectState } from "../projects/ProjectContext.ts";
import { ActivityProvider } from "./activity/ActivityProvider.tsx";
import { useTrackBatch } from "./activity/activity.ts";
import { ErrorBoundary } from "./ErrorBoundary.tsx";
import { PAGE_ROUTES } from "./pages.tsx";
import { Sidebar } from "./Sidebar.tsx";
@ -50,9 +52,9 @@ export function Layout() {
const ctx = useMemo<ProjectCtx>(() => ({ state, projects: projects.data ?? [], select, openNew: () => setNewOpen(true) }),
[state, projects.data, select]);
const onProjectCreated = useOnProjectCreated();
return (
<ProjectContext value={ctx}>
<ActivityProvider project={okLabel}>
<div className="shell">
<Sidebar section={section} project={okLabel} />
<main className={"page" + (NAV.find((n) => n.to === section)?.padded ? "" : " page--board")}>
@ -60,7 +62,14 @@ export function Layout() {
</main>
</div>
{overlay?.element}
{newOpen && <NewProjectDialog onClose={() => setNewOpen(false)} onCreated={(p, batchId) => { setNewOpen(false); onProjectCreated(batchId); select(p.label); }} />}
{newOpen && <NewProject onClose={() => setNewOpen(false)} select={select} />}
</ActivityProvider>
</ProjectContext>
);
}
// окно «Новый проект»: пакет, поставленный хабом при создании, отслеживает трекер ядра (модуль берётся из ответа хаба)
function NewProject({ onClose, select }: { onClose(): void; select(label: string): void }) {
const trackBatch = useTrackBatch();
return <NewProjectDialog onClose={onClose} onCreated={(p, batchId) => { onClose(); void trackBatch(batchId).catch(() => {}); select(p.label); }} />;
}

View File

@ -11,8 +11,8 @@ import { routes } from "../routes.tsx";
import { json, mockFetch } from "../test/fetchMock.ts";
const SNAP = JSON.parse(readFileSync(join(import.meta.dirname, "..", "modules", "jira", "board", "__fixtures__", "hub-basic.board.json"), "utf8"));
const A = { label: "AWPCA", name: "АРМ КА", board_id: null, board_type: null, created_at: "", issues_count: 9 };
const WITH_SYNC = { ...SNAP, last_sync: { started_at: "2026-10-14T11:58:00+00:00", finished_at: "2026-10-14T12:00:00+00:00", issues_count: 9 } };
const A = { label: "AWPCA", name: "АРМ КА", board_id: null, board_type: null, created_at: "", issues_count: 9, jira_synced_at: "2026-10-14T12:00:00+00:00" };
const WITH_SYNC = SNAP;
interface Call { method: string; url: URL }
function hub(over: Record<string, () => Response> = {}) {
@ -26,7 +26,7 @@ function hub(over: Record<string, () => Response> = {}) {
if (k === "GET /board") return json(200, WITH_SYNC);
if (k === "GET /issues") return json(200, { issues: [] });
if (k === "GET /batches") return json(200, { batches: [] });
if (k === "GET /agent") return json(200, { online: true, last_seen_at: "2026-10-14T09:30:00+00:00" });
if (k === "GET /agents") return json(200, { agents: [{ module: "jira", online: true, last_seen_at: "2026-10-14T09:30:00+00:00", agent_version: 1, warning: null, update_hint: null, current_command_id: null, release: null }] });
if (k === "POST /refresh") return json(201, { id: 7, estimate_seconds: 5, count: 1 });
if (k === "GET /batches/7") return json(200, { id: 7, author: "viewer", status: "running", remaining_seconds: 3, counts: {}, commands: [{ type: "refresh", status: "running" }] });
return json(404, { error: "not_found" });
@ -83,7 +83,7 @@ describe("боковая панель", () => {
await screen.findByTestId("page-issues");
await waitFor(() => expect(h.count("GET", "/issues")).toBeGreaterThan(0));
const issuesBefore = h.count("GET", "/issues");
const btn = within(bar()).getByRole("button", { name: "Обновить из Jira" });
const btn = within(bar()).getByRole("button", { name: /^Обновить из Jira/ });
await userEvent.click(btn);
await waitFor(() => expect(btn).toBeDisabled());
expect(btn).toHaveAttribute("aria-busy", "true");
@ -96,7 +96,7 @@ describe("боковая панель", () => {
});
it("агент офлайн — «агент офлайн»; на связи — «агент на связи»", async () => {
hub({ "GET /agent": () => json(200, { online: false, last_seen_at: "2026-10-14T09:30:00+00:00" }) });
hub({ "GET /agents": () => json(200, { agents: [{ module: "jira", online: false, last_seen_at: "2026-10-14T09:30:00+00:00", warning: null, update_hint: null }] }) });
open("/app/board?project=AWPCA");
expect(await within(bar()).findByText("агент офлайн")).toBeInTheDocument();
});
@ -107,16 +107,24 @@ describe("боковая панель", () => {
expect(await within(bar()).findByText("агент на связи")).toBeInTheDocument();
});
it("«данные на ДД.ММ ЧЧ:ММ» берутся из last_sync снимка", async () => {
it("«данные на ДД.ММ ЧЧ:ММ · все проекты» берутся из jira_synced_at проекта", async () => {
hub();
open("/app/issues?project=AWPCA");
expect(await within(bar()).findByText(/^данные на \d\d\.\d\d \d\d:\d\d$/)).toBeInTheDocument();
expect(await within(bar()).findByText(/^данные на \d\d\.\d\d \d\d:\d\d · все проекты$/)).toBeInTheDocument();
});
it("полного обновления не было", async () => {
hub({ "GET /board": () => json(200, { ...SNAP, last_sync: null }) });
it("обновления ещё не было", async () => {
hub({ "GET /projects": () => json(200, { projects: [{ ...A, jira_synced_at: null }] }) });
open("/app/issues?project=AWPCA");
expect(await within(bar()).findByText("полного обновления не было")).toBeInTheDocument();
expect(await within(bar()).findByText("обновления ещё не было")).toBeInTheDocument();
});
it("блок состояния не грузит GET /wiki/status; у проекта без корня вики строки «Вики» нет", async () => {
const h = hub();
open("/app/board?project=AWPCA");
await within(bar()).findByRole("button", { name: /^Обновить из Jira/ });
expect(within(bar()).queryByRole("button", { name: /^Обновить вики/ })).toBeNull();
expect(h.count("GET", "/wiki/status")).toBe(0);
});
it("меню «Ещё» → «Сменить токен» показывает экран входа", async () => {
@ -139,7 +147,7 @@ describe("боковая панель", () => {
open("/app/board?project=AWPCA");
await screen.findByTestId("page-board");
await screen.findByText(/задач из/);
expect(screen.getAllByRole("button", { name: "Обновить из Jira" })).toHaveLength(1);
expect(screen.getAllByRole("button", { name: /^Обновить из Jira/ })).toHaveLength(1);
});
it("разделы — в навигации «Разделы» боковой панели", async () => {
@ -171,7 +179,7 @@ describe("боковая панель", () => {
localStorage.setItem("sdlc-hub-sidebar", "collapsed");
const h = hub();
open("/app/issues?project=AWPCA");
const btn = await within(bar()).findByRole("button", { name: "Обновить из Jira" });
const btn = await within(bar()).findByRole("button", { name: /^Обновить из Jira/ });
await userEvent.click(btn);
await waitFor(() => expect(btn).toBeDisabled());
expect(h.count("POST", "/refresh")).toBe(1);
@ -202,7 +210,7 @@ describe("боковая панель на узком экране", () => {
const top = await screen.findByRole("banner", { name: "Строка приложения" });
expect(within(top).getByRole("button", { name: "Меню" })).toHaveAttribute("aria-expanded", "false");
expect(within(top).getByText("SDLC Hub")).toBeInTheDocument();
expect(within(top).getByRole("button", { name: "Обновить из Jira" })).toBeInTheDocument();
expect(await within(top).findByRole("button", { name: /^Обновить из Jira/ })).toBeInTheDocument();
expect(screen.queryByRole("complementary", { name: "Панель приложения" })).toBeNull();
});
@ -244,7 +252,7 @@ describe("боковая панель на узком экране", () => {
hub();
open("/app/board?project=AWPCA");
const top = await screen.findByRole("banner", { name: "Строка приложения" });
await userEvent.click(within(top).getByRole("button", { name: "Обновить из Jira" }));
await userEvent.click(await within(top).findByRole("button", { name: /^Обновить из Jira/ }));
const strip = await screen.findByTestId("topbar-event");
await waitFor(() => expect(strip).toHaveTextContent(/пакет #7 выполняется/));
expect(strip).not.toHaveClass("visually-hidden");

View File

@ -3,6 +3,7 @@ import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "reac
import { Link } from "react-router";
import { clearToken } from "../auth/token.ts";
import { MODULES } from "../modules/index.ts";
import { StatusBlock, TopbarNotice } from "./activity/StatusBlock.tsx";
import { withProject } from "../projects/current.ts";
import { ProjectPicker } from "../projects/ProjectPicker.tsx";
import { IconButton, IconClose, IconMenu, IconSidebar, Menu, MenuItem, ThemeSwitch } from "../ui/index.ts";
@ -42,9 +43,9 @@ function NarrowShell(p: Props) {
onClick={() => setOpen(true)}><IconMenu /></button>
<span className="sidebar__brand">SDLC Hub</span>
<span className="sidebar__spacer" />
{MODULES.map((m) => m.SidebarStatus && <m.SidebarStatus key={m.name} project={p.project} compact />)}
<StatusBlock project={p.project} compact />
</header>
{MODULES.map((m) => m.TopbarNotice && <m.TopbarNotice key={m.name} />)}
<TopbarNotice />
{open && <>
<div className="sidebar-backdrop" data-testid="sidebar-backdrop" onClick={drawer.close} />
<Panel {...p} drawer={drawer} />
@ -91,7 +92,7 @@ function Panel({ section, project, drawer }: Props & { drawer?: { close(): void;
))}
</nav>
<span className="sidebar__spacer" />
{MODULES.map((m) => m.SidebarStatus && <m.SidebarStatus key={m.name} project={project} compact={collapsed} />)}
<StatusBlock project={project} compact={collapsed} />
<div className="sidebar__foot">
<ThemeSwitch />
<Menu label="Ещё" up align={collapsed ? "start" : "end"}>

View File

@ -0,0 +1,229 @@
// владелец состояния модулей: один опрос GET /agents и один трекер пакетов на всё приложение, не больше одного пакета
// на модуль. Правила остановки опроса — прежние (hub#20, перенос RefreshProvider модуля jira): одна кнопка и один опрос
// (M-2), остановка опроса (M-4, T10.2). Подхват (M-3) теперь по модулю и без привязки к снимку доски: при монтировании,
// смене проекта, раз в 60 с и после 409 на постановке; пакет любого автора (design web-modules, решение 4)
import { useQuery, useQueryClient, type QueryClient } 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 type { AgentView, Batch } from "../../api/types.ts";
import { MODULES } from "../../modules/index.ts";
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 — пакет действия обновления этого модуля
const terminal = (b: Batch | undefined) => !!b && (b.status === "done" || b.status === "cancelled");
const fetchAgents = () => api.get<{ agents: AgentView[] }>("/agents").then((r) => r.agents);
const isError = (e: ModuleEvent | null) => e?.kind === "batch-failed" || e?.kind === "batch-lost" || e?.kind === "hub-down" || e?.kind === "error";
// модули с описанием состояния; список — константа: хуки трекеров вызываются в постоянном порядке
const TRACKED = MODULES.filter((m): m is HubModule & Required<Pick<HubModule, "status">> => !!m.status);
interface Env { qc: QueryClient; project: string | null; pollMs: number; agents: AgentView[] | undefined }
function useTracker(m: (typeof TRACKED)[number], { qc, project, pollMs, agents }: Env) {
const { name, status } = m;
const refresh = status.refresh;
const scoped = status.scope === "project";
const [event, setEvent] = useState<ModuleEvent | 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 | null>(null);
const picking = useRef(false);
const generation = useRef(0); // растёт при смене проекта: ответы прежнего проекта отбрасываются
const projectRef = useRef(project);
const agentsRef = useRef(agents);
useEffect(() => { projectRef.current = project; agentsRef.current = agents; });
const agent = status.agent ? agents?.find((a) => a.module === status.agent) ?? null : null;
const offlineNow = () => !!status.agent && agentsRef.current?.find((a) => a.module === status.agent)?.online === false;
const setActiveNow = useCallback((a: Active | null) => { activeRef.current = a; setActive(a); }, []);
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: подхватить незавершённый пакет модуля (самый старый), если ничего не отслеживается
const pickup = useCallback(() => {
if (!refresh) return;
if (scoped && projectRef.current === null) return;
if (activeRef.current || startingRef.current || picking.current) return;
picking.current = true;
const gen = generation.current;
(async () => {
const r = await api.get<{ batches: Batch[] }>("/batches", { module: name, limit: PICKUP_LIMIT, project: scoped ? projectRef.current : undefined });
if (gen !== generation.current || activeRef.current || startingRef.current) return;
const open = r.batches.filter((b) => b.status === "queued" || b.status === "running").sort((a, b) => a.id - b.id)[0];
if (!open) return;
failures.current = 0;
setActiveNow({ id: open.id, agentOffline: offlineNow(), own: false });
})().catch(() => { /* не критично: подхватим при следующем проходе */ }).finally(() => { if (gen === generation.current) picking.current = false; });
// eslint-disable-next-line react-hooks/exhaustive-deps -- offlineNow читает ref, имя и охват модуля постоянны
}, [refresh, scoped, name, setActiveNow]);
// смена проекта: отслеживаемый пакет проектного модуля сбрасывается без события; подхват — по всем модулям
const prevProject = useRef(project);
useEffect(() => {
if (prevProject.current !== project) {
prevProject.current = project;
if (scoped) {
generation.current += 1;
picking.current = false;
failures.current = 0;
setActiveNow(null);
setEvent(null);
}
}
pickup();
const t = setInterval(pickup, PICKUP_MS);
return () => clearInterval(t);
}, [project, scoped, pickup, setActiveNow]);
// 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;
setActiveNow(null);
qc.removeQueries({ queryKey: ["batch", id] });
status.invalidate(qc, projectRef.current);
void qc.invalidateQueries({ queryKey: ["projects"] });
}, [qc, status, setActiveNow]);
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;
}
if (!active.own && refresh?.isOwn(b)) { setActiveNow({ ...active, own: true }); 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, refresh, setActiveNow]);
// «пакет выполнен» гаснет сам
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 () => {
const p = projectRef.current;
if (!refresh || (scoped && p === null)) return;
if (startingRef.current || activeRef.current?.own) return;
startingRef.current = true;
setStarting(true);
let conflict = false;
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 ?? "");
} catch (e) {
if (e instanceof ApiError && e.status === 409) { conflict = true; return; } // уже идёт: подхватим без события ошибки
throw e;
}
if (gen !== generation.current) return; // проект сменился во время постановки: пакет не этого проекта
failures.current = 0;
setActiveNow({ id: b.id, agentOffline: offline, own: true });
setEvent({ kind: "batch", id: b.id, phase: "queued", remaining: b.estimate_seconds ?? 0, agentOffline: offline, retrying: false });
} catch (e) {
report(e);
} finally {
startingRef.current = false;
setStarting(false);
if (conflict) pickup();
}
}, [refresh, scoped, status, qc, report, pickup, setActiveNow]);
const track = useCallback((id: number) => {
if (activeRef.current?.own) return;
failures.current = 0;
setActiveNow({ id, agentOffline: false, own: true });
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]);
return { view, tracking: starting || active !== null };
}
// хук-трекер для модуля: имя «use…» позволяет вызывать его в постоянном порядке по константному списку
const TRACKER_HOOKS = TRACKED.map((m) => function useModuleTracker(env: Env) { return useTracker(m, env); });
export function ActivityProvider({ children, project, pollMs = POLL_MS }: { children: ReactNode; project: string | null; pollMs?: number }) {
const qc = useQueryClient();
const agentsQ = useQuery({ queryKey: AGENTS_KEY, queryFn: fetchAgents, refetchInterval: PICKUP_MS, refetchIntervalInBackground: true });
const agents = agentsQ.data;
const env: Env = { qc, project, pollMs, agents };
const trackers = TRACKER_HOOKS.map((use) => use(env)); // постоянный порядок: список — константа
const modules = useMemo(() => Object.fromEntries(TRACKED.map((m, i) => [m.name, trackers[i].view])),
// eslint-disable-next-line react-hooks/exhaustive-deps -- представления модулей мемоизированы в трекерах
trackers.map((t) => t.view));
const summary: ActivityValue["summary"] =
TRACKED.some((m) => isError(modules[m.name].event)) ? "error"
: TRACKED.some((m) => !!m.status.agent && modules[m.name].agent?.online === false) ? "offline"
: trackers.some((t) => t.tracking) ? "busy" : "online";
const trackBatch = useCallback(async (id: number) => {
const b = await api.get<Batch>(`/batches/${id}`);
const m = TRACKED.find((x) => x.name === b.module) ?? TRACKED.find((x) => x.status.refresh?.isOwn(b));
if (m) modules[m.name].track(id);
}, [modules]);
const value = useMemo<ActivityValue>(() => ({ agents: agents ?? [], modules, summary, trackBatch }), [agents, modules, summary, trackBatch]);
return <ActivityContext.Provider value={value}>{children}</ActivityContext.Provider>;
}

View File

@ -0,0 +1,74 @@
// ВРЕМЕННЫЙ блок состояния модулей (Task 3): по строке на модуль с данными для проекта. Task 4 заменяет его
// полноценным блоком панели («Состояние и обновление модулей»); здесь — данные контракта и прежние классы
import { useProjects } from "../../api/queries.ts";
import type { Project } from "../../api/types.ts";
import { MODULES } from "../../modules/index.ts";
import type { HubModule } from "../../modules/types.ts";
import { fmtStamp } from "../../ui/format.ts";
import { IconButton, IconRefresh } from "../../ui/index.ts";
import { useModuleActivity } from "./activity.ts";
import { eventText } from "./events.ts";
const WITH_STATUS = MODULES.filter((m): m is HubModule & Required<Pick<HubModule, "status">> => !!m.status);
function ModuleRow({ m, project, compact }: { m: (typeof WITH_STATUS)[number]; project: Project; compact: boolean }) {
const act = useModuleActivity(m.name);
const { status } = m;
const fresh = status.freshness(project);
const label = status.refresh?.label;
const scope = status.scope === "all" ? "все проекты" : "проект";
const action = label ? `${label} — ${status.scope === "all" ? scope : `${scope} ${project.label}`}` : null;
const a = act.agent;
const agentText = a ? (a.warning || a.update_hint ? "обновите агента" : a.online ? "агент на связи" : "агент офлайн") : null;
const agentHint = a ? [a.warning, a.update_hint, a.last_seen_at ? `последний выход на связь: ${fmtStamp(a.last_seen_at)}` : "выхода на связь ещё не было"].filter(Boolean).join(" · ") : undefined;
const freshText = fresh.error ? `синхронизация не удалась: ${fresh.error}`
: fresh.at ? `данные на ${fmtStamp(fresh.at)} · ${scope}` : "обновления ещё не было";
const ev = act.event ? eventText(act.event, label ?? "") : null;
const online = !!a && a.online && !a.warning && !a.update_hint;
const eventEl = <span className={`data-status__event${ev ? ` data-status__event--${ev.tone}` : ""}${compact ? " visually-hidden" : ""}`}
role="status" title={ev?.text}>{ev?.text}</span>;
const button = action && (
<IconButton label={action} title={compact ? [m.title, agentText, freshText, ev?.text].filter(Boolean).join(" · ") : action}
disabled={act.busy} aria-busy={act.busy ? "true" : undefined}
icon={act.busy ? <span className="ui-spinner" aria-hidden="true" /> : <IconRefresh />} onClick={() => void act.start()} />);
if (compact) {
return (
<div className="data-status data-status--compact" data-module={m.name}>
{button}
{a && <span className={"data-status__dot" + (online ? " is-online" : "")} aria-hidden="true" />}
{eventEl}
</div>
);
}
return (
<div className="data-status" data-module={m.name}>
<div className="data-status__head">
<strong>{m.title}</strong>
{agentText && (
<span className="data-status__agent" title={agentHint}>
<span className={"data-status__dot" + (online ? " is-online" : "")} aria-hidden="true" />{agentText}
</span>)}
{button}
</div>
<span className="data-status__sync">{freshText}</span>
{eventEl}
</div>
);
}
// строки модулей, у которых есть данные для проекта
export function StatusBlock({ project, compact }: { project: string | null; compact: boolean }) {
const p = useProjects().data?.find((x) => x.label === project);
if (!p) return null;
return <>{WITH_STATUS.filter((m) => m.status.freshness(p).available).map((m) => <ModuleRow key={m.name} m={m} project={p} compact={compact} />)}</>;
}
// I-1: на телефоне подсказок нет — событие пакета строкой под верхней панелью
function ModuleNotice({ m }: { m: (typeof WITH_STATUS)[number] }) {
const act = useModuleActivity(m.name);
const ev = act.event ? eventText(act.event, m.status.refresh?.label ?? "") : null;
return ev ? <div className={`topbar-event data-status__event--${ev.tone}`} data-testid="topbar-event" aria-hidden="true">{ev.text}</div> : null;
}
export function TopbarNotice() {
return <>{WITH_STATUS.map((m) => <ModuleNotice key={m.name} m={m} />)}</>;
}

View File

@ -0,0 +1,455 @@
import { QueryClientProvider, type QueryClient } from "@tanstack/react-query";
import { act, render, renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { makeQueryClient } from "../../api/queryClient.ts";
import { getToken, setToken } from "../../auth/token.ts";
import { useBoard, useIssues } from "../../modules/jira/api/queries.ts";
import { agentView, batch, deferred, hub, SNAP, sleep } from "../../test/batchHub.ts";
import { json } from "../../test/fetchMock.ts";
import { ActivityProvider } from "./ActivityProvider.tsx";
import { useActivitySummary, useAgents, useModuleActivity } from "./activity.ts";
import { eventText } from "./events.ts";
// список пакетов модуля: GET /batches?module=&project= отдаёт то, что вернёт filter(module, project)
const listing = (filter: (module: string | null, project: string | null) => unknown[]) => ({
"GET /batches": (url: URL) => json(200, { batches: filter(url.searchParams.get("module"), url.searchParams.get("project")) }),
});
const item = (id: number, module: string, status = "running", author = "viewer") => ({ id, module, status, author });
// пакет по id: тип первой команды и модуль задаются отдельно
const byId = (types: Record<number, string>, status: (id: number) => string) => ({
"GET /batches/:id": (url: URL) => {
const id = Number(url.pathname.split("/").pop());
const type = types[id] ?? "refresh";
const st = status(id);
return batch(st, { status: st === "done" ? "done" : "running", type }, { id, module: type === "wiki_sync" ? "wiki" : "jira" });
},
});
interface MountOpts { project?: string | null; client?: QueryClient; pollMs?: number; boards?: boolean }
// оболочка в миниатюре: провайдер ядра + зонд, снимающий состояние обоих модулей; setProject — смена проекта (ProjectContext)
function mount({ project = "AWPCA", client = makeQueryClient(), pollMs = 10, boards = false }: MountOpts = {}) {
const cur = {} as { jira: ReturnType<typeof useModuleActivity>; wiki: ReturnType<typeof useModuleActivity>; agents: ReturnType<typeof useAgents>;
summary: ReturnType<typeof useActivitySummary>; board?: ReturnType<typeof useBoard>; issues?: ReturnType<typeof useIssues> };
const Probe = () => {
cur.jira = useModuleActivity("jira"); cur.wiki = useModuleActivity("wiki"); cur.agents = useAgents(); cur.summary = useActivitySummary();
return null;
};
const Page = () => { cur.board = useBoard("AWPCA"); cur.issues = useIssues({}); return null; }; // «страница»: монтируется и размонтируется
const ui = (p: string | null, page = boards) => (
<QueryClientProvider client={client}><ActivityProvider project={p} pollMs={pollMs}><Probe />{page && <Page />}</ActivityProvider></QueryClientProvider>);
const view = render(ui(project));
return { client, now: () => cur, setProject: (p: string | null) => view.rerender(ui(p)), setPage: (page: boolean) => view.rerender(ui(project, page)) };
}
const agentsReady = (m: ReturnType<typeof mount>) => waitFor(() => expect(m.now().agents).toHaveLength(2));
const tick = (ms: number) => act(async () => { await vi.advanceTimersByTimeAsync(ms); });
describe("ActivityProvider", () => {
beforeEach(() => setToken("tok"));
afterEach(() => { vi.useRealTimers(); });
it("вне провайдера — понятная ошибка", () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
expect(() => renderHook(() => useModuleActivity("jira"))).toThrow(/ActivityProvider/);
spy.mockRestore();
});
// --- перенос refresh.test.tsx -------------------------------------------------------------
it("M-2: двойное нажатие — один пакет и один опрос за раз; по завершении board перечитан, список пакетов — нет", async () => {
let polls = 0;
const h = hub({ "GET /batches/:id": () => (++polls < 4 ? batch("running") : batch("done")) });
const m = mount({ boards: true });
await waitFor(() => expect(m.now().board!.data).toBeDefined());
await waitFor(() => expect(h.count("GET /batches")).toBeGreaterThanOrEqual(1));
const listed = h.count("GET /batches");
await act(async () => { void m.now().jira.start(); await m.now().jira.start(); });
expect(h.count("POST /refresh")).toBe(1);
expect(m.now().jira.busy).toBe(true);
await waitFor(() => expect(m.now().jira.event).toEqual({ kind: "batch-done", id: 7 }));
expect(m.now().jira.busy).toBe(false);
expect(h.maxInFlight("GET /batches/7")).toBe(1);
await waitFor(() => expect(h.count("GET /board")).toBe(2)); // по завершении снимок перечитан
await sleep(30);
expect(h.count("GET /batches")).toBe(listed); // T10.1: после своего пакета список пакетов не перечитывается
});
it("M-3: незавершённый пакет подхватывается при монтировании провайдера", async () => {
hub({ ...listing((mod) => (mod === "jira" ? [item(7, "jira", "queued")] : [])), "GET /batches/:id": () => batch("running") });
const m = mount();
await waitFor(() => expect(m.now().jira.event).toMatchObject({ kind: "batch", id: 7 }));
expect(m.now().jira.busy).toBe(true);
});
it("M-3: незавершённый пакет подхватывается раз в 60 с (прежний подхват после загрузки снимка заменён)", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
let listed: unknown[] = [];
const h = hub({ ...listing((mod) => (mod === "jira" ? listed : [])), "GET /batches/:id": () => batch("running") });
const m = mount({ pollMs: 5_000 });
await tick(100);
expect(m.now().jira.busy).toBe(false);
listed = [item(7, "jira")];
await tick(59_000);
expect(h.count("GET /batches/7")).toBe(0);
await tick(2_000);
await waitFor(() => expect(m.now().jira.event).toMatchObject({ kind: "batch", id: 7, phase: "running" }));
expect(m.now().jira.busy).toBe(true);
});
it("M-3: пакет подхватывается при смене проекта", async () => {
let listed: unknown[] = [];
hub({ ...listing((mod) => (mod === "jira" ? listed : [])), "GET /batches/:id": () => batch("running") });
const m = mount();
await agentsReady(m);
expect(m.now().jira.busy).toBe(false);
listed = [item(7, "jira")];
m.setProject("STOCKS_DRRK");
await waitFor(() => expect(m.now().jira.event).toMatchObject({ kind: "batch", id: 7 }));
});
it("M-4: 404 на опросе — опрос остановлен, кнопка доступна", async () => {
const h = hub({ "GET /batches/:id": () => json(404, { error: "not_found", detail: "batch" }) });
const m = mount();
await act(() => m.now().jira.start());
await waitFor(() => expect(m.now().jira.event).toMatchObject({ kind: "batch-lost", id: 7, reason: "пакета нет в хабе" }));
expect(m.now().jira.busy).toBe(false);
const n = h.count("GET /batches/7");
await sleep(50);
expect(h.count("GET /batches/7")).toBe(n);
});
for (const status of [400, 422]) {
it(`T10.2: ${status} на опросе — опрос остановлен, batch-lost с detail, busy сброшен`, async () => {
const h = hub({ "GET /batches/:id": () => json(status, { error: "bad_request", detail: "плохой пакет" }) });
const m = mount();
await act(() => m.now().jira.start());
await waitFor(() => expect(m.now().jira.event).toMatchObject({ kind: "batch-lost", id: 7, reason: expect.stringContaining("плохой пакет") }));
expect(m.now().jira.busy).toBe(false);
const n = h.count("GET /batches/7");
await sleep(50);
expect(h.count("GET /batches/7")).toBe(n);
});
}
it("M-4: 5 ошибок «хаб не отвечает» подряд — опрос остановлен", async () => {
const h = hub({ "GET /batches/:id": () => { throw new TypeError("Failed to fetch"); } });
const m = mount();
await act(() => m.now().jira.start());
await waitFor(() => expect(m.now().jira.event).toMatchObject({ kind: "batch-lost", reason: "хаб не отвечает" }));
expect(h.count("GET /batches/7")).toBe(5);
await sleep(50);
expect(h.count("GET /batches/7")).toBe(5);
expect(m.now().jira.busy).toBe(false);
});
it("пакет с ошибкой команды — текст ошибки; агент офлайн — предупреждение в событии", async () => {
let polls = 0;
hub({
"GET /agents": () => json(200, { agents: [agentView("jira", { online: false }), agentView("wiki")] }),
"GET /batches/:id": () => (++polls < 3 ? batch("running") : batch("done", { status: "failed", error: "Jira 500" })),
});
const m = mount();
await act(() => m.now().jira.start());
await waitFor(() => expect(m.now().jira.event).toMatchObject({ kind: "batch", agentOffline: true }));
await waitFor(() => expect(m.now().jira.event).toEqual({ kind: "batch-failed", id: 7, error: "Jira 500" }));
});
it("«Обновить из Jira» при недоступном хабе — событие hub-down", async () => {
hub({ "GET /agents": () => { throw new TypeError("Failed to fetch"); } });
const m = mount();
await act(() => m.now().jira.start());
expect(m.now().jira.event).toEqual({ kind: "hub-down" });
expect(m.now().jira.busy).toBe(false);
});
it("T10.6 → foreign batch does not block refresh: чужой пакет модуля показан, но обновление доступно, start ставит свой и переключает опрос", async () => {
const h = hub({
...listing((mod) => (mod === "jira" ? [item(5, "jira")] : [])),
...byId({ 5: "ensure_fields" }, () => "running"),
});
const m = mount();
await waitFor(() => expect(m.now().jira.event).toMatchObject({ kind: "batch", id: 5 }));
expect(m.now().jira.busy).toBe(false);
await act(() => m.now().jira.start());
expect(h.count("POST /refresh")).toBe(1);
expect(m.now().jira.event).toMatchObject({ kind: "batch", id: 7 });
expect(m.now().jira.busy).toBe(true);
await sleep(40);
const n5 = h.count("GET /batches/5");
await sleep(40);
expect(h.count("GET /batches/5")).toBe(n5); // опрос чужого пакета остановлен
});
it("M-12 → пакеты модулей раздельны: wiki_sync не заслоняет refresh, у каждого модуля свой пакет и свой запрос списка", async () => {
const h = hub({
...listing((mod) => (mod === "jira" ? [item(7, "jira")] : mod === "wiki" ? [item(9, "wiki", "queued")] : [])),
...byId({ 9: "wiki_sync" }, () => "running"),
});
const m = mount();
await waitFor(() => expect(m.now().jira.event).toMatchObject({ kind: "batch", id: 7 }));
await waitFor(() => expect(m.now().wiki.event).toMatchObject({ kind: "batch", id: 9 }));
expect(m.now().jira.busy).toBe(true);
expect(m.now().wiki.busy).toBe(true);
expect(h.urls).toContain("/batches?module=jira&limit=20");
expect(h.urls).toContain("/batches?module=wiki&limit=20&project=AWPCA");
});
it("401 на запуске пакета с текущим токеном — токен сброшен", async () => {
hub({ "GET /agents": () => json(401, { error: "unauthorized" }) });
const m = mount();
await act(() => m.now().jira.start());
expect(getToken()).toBe("");
});
it("запоздавший 401 прежнему токену не сбрасывает новый токен", async () => {
const slow = deferred<Response>();
hub({ "GET /agents": () => slow.promise });
const m = mount();
let run!: Promise<void>;
act(() => { run = m.now().jira.start(); });
setToken("new-tok");
slow.resolve(json(401, { error: "unauthorized" }));
await act(() => run);
expect(getToken()).toBe("new-tok");
});
it("T4.1: упавшее перечитывание снимка после своего пакета не гасит подхват следующей независимой загрузки", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
let boardFails = false, listed: unknown[] = [];
const h = hub({
"GET /board": () => (boardFails ? json(500, { error: "boom" }) : json(200, SNAP)),
...listing((mod) => (mod === "jira" ? listed : [])),
"GET /batches/:id": (url) => (url.pathname.endsWith("/8") ? batch("running", { status: "running" }, { id: 8 }) : batch("done")),
});
const m = mount({ boards: true, pollMs: 5_000 });
await waitFor(() => expect(m.now().board!.data).toBeDefined());
boardFails = true;
await act(() => m.now().jira.start());
await waitFor(() => expect(m.now().jira.event).toEqual({ kind: "batch-done", id: 7 }));
await waitFor(() => expect(h.count("GET /board")).toBe(2)); // перечитывание после пакета упало
boardFails = false;
listed = [item(8, "jira")];
await tick(61_000);
await waitFor(() => expect(m.now().jira.event).toMatchObject({ kind: "batch", id: 8 }));
});
it("по завершении инвалидируются board, issues, issue, projects", async () => {
let polls = 0;
const h = hub({ "GET /batches/:id": () => (++polls < 2 ? batch("running") : batch("done")) });
const m = mount({ boards: true });
await waitFor(() => expect(m.now().board!.data).toBeDefined());
await waitFor(() => expect(m.now().issues!.data).toBeDefined());
await act(() => m.now().jira.start());
await waitFor(() => expect(m.now().jira.event).toMatchObject({ kind: "batch-done" }));
await waitFor(() => expect(h.count("GET /board")).toBe(2));
await waitFor(() => expect(h.count("GET /issues")).toBe(2));
});
it("инвалидируются ключи board, issues, issue, projects, changes (шпион)", async () => {
const client = makeQueryClient();
const spy = vi.spyOn(client, "invalidateQueries");
hub({ "GET /batches/:id": () => batch("done") });
const m = mount({ client });
await act(() => m.now().jira.start());
await waitFor(() => expect(m.now().jira.event).toEqual({ kind: "batch-done", id: 7 }));
const keys = spy.mock.calls.map(([f]) => (f as { queryKey: unknown[] }).queryKey[0]);
expect(keys).toEqual(expect.arrayContaining(["board", "issues", "issue", "projects", "changes"]));
});
it("track(9) опрашивает пакет 9 без POST /refresh", async () => {
const h = hub({ "GET /batches/:id": () => batch("done", { status: "done" }, { id: 9 }) });
const m = mount();
await waitFor(() => expect(h.count("GET /batches")).toBeGreaterThanOrEqual(1));
act(() => m.now().jira.track(9));
expect(m.now().jira.busy).toBe(true);
await waitFor(() => expect(m.now().jira.event).toEqual({ kind: "batch-done", id: 9 }));
expect(h.count("POST /refresh")).toBe(0);
expect(h.count("GET /batches/9")).toBeGreaterThanOrEqual(1);
});
it("batch-done исчезает через 10 с", async () => {
hub({ "GET /batches/:id": () => batch("done") });
vi.useFakeTimers({ shouldAdvanceTime: true }); // время идёт и само (опрос, waitFor), но сдвигается и вручную
const m = mount();
await act(() => m.now().jira.start());
await waitFor(() => expect(m.now().jira.event).toEqual({ kind: "batch-done", id: 7 }));
await act(async () => { vi.advanceTimersByTime(9_800); });
expect(m.now().jira.event).toEqual({ kind: "batch-done", id: 7 });
await act(async () => { vi.advanceTimersByTime(200); });
expect(m.now().jira.event).toBeNull();
});
it("переход между страницами во время пакета: один POST, один опрос, busy до завершения; по завершении board перечитан", async () => {
let finished = false;
const h = hub({ "GET /batches/:id": () => (finished ? batch("done") : batch("running")) });
const m = mount({ boards: true });
await waitFor(() => expect(h.count("GET /board")).toBe(1));
await act(() => m.now().jira.start());
m.setPage(false);
expect(m.now().jira.busy).toBe(true);
m.setPage(true);
await waitFor(() => expect(h.count("GET /board")).toBeGreaterThanOrEqual(1));
await sleep(40);
expect(h.count("POST /refresh")).toBe(1);
expect(h.maxInFlight("GET /batches/7")).toBe(1);
expect(m.now().jira.busy).toBe(true);
const boards = h.count("GET /board");
finished = true;
await waitFor(() => expect(m.now().jira.busy).toBe(false));
expect(m.now().jira.event).toMatchObject({ kind: "batch-done" });
await waitFor(() => expect(h.count("GET /board")).toBe(boards + 1));
});
// --- новые сценарии контракта модуля ------------------------------------------------------
it("picks up batch of any author: пакет автора hub подхватывается и опрашивается", async () => {
const h = hub({
...listing((mod) => (mod === "jira" ? [item(7, "jira", "running", "hub")] : [])),
"GET /batches/:id": () => batch("running", { status: "running" }, { author: "hub" }),
});
const m = mount();
await waitFor(() => expect(m.now().jira.event?.kind).toBe("batch"));
expect(h.urls).toContain("/batches?module=jira&limit=20");
await waitFor(() => expect(h.count("GET /batches/7")).toBeGreaterThanOrEqual(1));
});
it("подхват берёт самый старый из незавершённых пакетов модуля", async () => {
hub({ ...listing((mod) => (mod === "jira" ? [item(9, "jira", "queued"), item(8, "jira", "done"), item(7, "jira")] : [])),
...byId({}, () => "running") });
const m = mount();
await waitFor(() => expect(m.now().jira.event).toMatchObject({ kind: "batch", id: 7 }));
});
it("project switch drops project-scoped batch: пакет вики AWPCA сброшен без события, пакет jira — нет", async () => {
const h = hub({
...listing((mod, project) => (mod === "jira" ? [item(5, "jira")] : mod === "wiki" && project === "AWPCA" ? [item(9, "wiki")] : [])),
...byId({ 9: "wiki_sync" }, () => "running"),
});
const m = mount();
await waitFor(() => expect(m.now().wiki.event).toMatchObject({ kind: "batch", id: 9 }));
await waitFor(() => expect(m.now().jira.event).toMatchObject({ kind: "batch", id: 5 }));
m.setProject("STOCKS_DRRK");
await waitFor(() => expect(m.now().wiki.event).toBeNull());
expect(m.now().wiki.busy).toBe(false);
await sleep(20);
const n = h.count("GET /batches/9");
await sleep(50);
expect(h.count("GET /batches/9")).toBe(n);
expect(m.now().jira.event).toMatchObject({ kind: "batch", id: 5 });
});
it("wiki 409 tracks running batch: постановка получила 409 — подхвачен идущий пакет, события ошибки нет", async () => {
let conflict = false;
const h = hub({
"POST /wiki/sync": () => { conflict = true; return json(409, { error: "sync_in_progress", detail: { batch_id: 9 } }); },
...listing((mod) => (mod === "wiki" && conflict ? [item(9, "wiki")] : [])),
...byId({ 9: "wiki_sync" }, () => "running"),
});
const m = mount();
await agentsReady(m);
await act(() => m.now().wiki.start());
await waitFor(() => expect(m.now().wiki.event).toMatchObject({ kind: "batch", id: 9 }));
expect(m.now().wiki.event?.kind).not.toBe("error");
expect(h.count("POST /wiki/sync")).toBe(1);
});
it("wiki: «Обновить вики» шлёт проект и автора viewer; пакет — из batch_id ответа", async () => {
let body: unknown;
hub({ "POST /wiki/sync": (_u, b) => { body = b; return json(201, { batch_id: 11 }); }, ...byId({ 11: "wiki_sync" }, () => "running") });
const m = mount();
await agentsReady(m);
await act(() => m.now().wiki.start());
expect(body).toEqual({ project: "AWPCA", author: "viewer" });
expect(m.now().wiki.event).toMatchObject({ kind: "batch", id: 11 });
expect(m.now().wiki.busy).toBe(true);
expect(m.now().jira.busy).toBe(false);
});
it("agent offline from /agents: агент вики офлайн — start даёт событие с agentOffline", async () => {
hub({ "GET /agents": () => json(200, { agents: [agentView("jira"), agentView("wiki", { online: false })] }),
...byId({ 7: "wiki_sync" }, () => "running") });
const m = mount();
await waitFor(() => expect(m.now().wiki.agent?.online).toBe(false));
expect(m.now().jira.agent?.online).toBe(true);
await act(() => m.now().wiki.start());
expect(m.now().wiki.event).toMatchObject({ kind: "batch", agentOffline: true });
});
it("invalidates module data only: завершение пакета вики инвалидирует wiki/AWPCA и projects, но не board", async () => {
const client = makeQueryClient();
const spy = vi.spyOn(client, "invalidateQueries");
hub({ ...byId({ 7: "wiki_sync" }, () => "done") });
const m = mount({ client });
await agentsReady(m);
await act(() => m.now().wiki.start());
await waitFor(() => expect(m.now().wiki.event).toEqual({ kind: "batch-done", id: 7 }));
const keys = spy.mock.calls.map(([f]) => (f as { queryKey: unknown[] }).queryKey);
expect(keys).toEqual(expect.arrayContaining([["wiki", "AWPCA", "tree"], ["wiki", "AWPCA", "page"], ["wiki", "AWPCA", "search"], ["projects"]]));
expect(keys.map((k) => k[0])).not.toContain("board");
expect(keys.map((k) => k[0])).not.toContain("issues");
});
it("one agents poll: два модуля — один запрос GET /agents за интервал", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
const h = hub();
const m = mount();
await tick(100);
expect(h.count("GET /agents")).toBe(1);
expect(m.now().agents).toHaveLength(2);
await tick(60_000);
expect(h.count("GET /agents")).toBe(2);
});
it("вики без проекта: start ничего не ставит", async () => {
const h = hub();
const m = mount({ project: null });
await agentsReady(m);
await act(() => m.now().wiki.start());
expect(h.count("POST /wiki/sync")).toBe(0);
expect(m.now().wiki.busy).toBe(false);
});
it("сводная точка: online, busy, offline, error", async () => {
let state = "done";
let wikiOnline = true;
hub({ "GET /agents": () => json(200, { agents: [agentView("jira"), agentView("wiki", { online: wikiOnline })] }),
"GET /batches/:id": () => (state === "failed" ? batch("done", { status: "failed", error: "x" }) : batch(state)) });
const m = mount();
await agentsReady(m);
expect(m.now().summary).toBe("online");
state = "running";
await act(() => m.now().jira.start());
expect(m.now().summary).toBe("busy");
state = "failed";
await waitFor(() => expect(m.now().summary).toBe("error"));
wikiOnline = false;
await act(() => m.client.invalidateQueries({ queryKey: ["agents"] }));
await waitFor(() => expect(m.now().wiki.agent?.online).toBe(false));
expect(m.now().summary).toBe("error"); // ошибка важнее офлайна
});
it("пакет вики в очереди и агент офлайн — сводка offline", async () => {
hub({ "GET /agents": () => json(200, { agents: [agentView("jira"), agentView("wiki", { online: false })] }) });
const m = mount();
await waitFor(() => expect(m.now().summary).toBe("offline"));
});
});
describe("eventText", () => {
it("пакет: офлайн, очередь, остаток, повтор", () => {
expect(eventText({ kind: "batch", id: 7, phase: "queued", remaining: 12, agentOffline: true, retrying: false }, "Обновить из Jira"))
.toEqual({ text: "агент офлайн — пакет выполнится, когда он выйдет на связь; пакет #7 в очереди, осталось ~12 с…", tone: "warning" });
expect(eventText({ kind: "batch", id: 7, phase: "running", remaining: 0, agentOffline: false, retrying: true }, "Обновить из Jira"))
.toEqual({ text: "пакет #7 выполняется… (хаб не отвечает, повтор через 3 с)", tone: "warning" });
});
it("исходы и ошибки", () => {
expect(eventText({ kind: "batch-done", id: 7 }, "Обновить из Jira")).toEqual({ text: "пакет #7 выполнен", tone: "success" });
expect(eventText({ kind: "batch-failed", id: 7, error: "429" }, "Обновить из Jira")).toEqual({ text: "пакет #7 не выполнен: 429", tone: "error" });
expect(eventText({ kind: "batch-cancelled", id: 7 }, "Обновить из Jira")).toEqual({ text: "пакет #7 отменён", tone: "warning" });
expect(eventText({ kind: "batch-lost", id: 7, reason: "пакета нет в хабе" }, "Обновить из Jira").text)
.toBe("пакет #7: пакета нет в хабе — опрос остановлен, «Обновить из Jira» снова доступна");
expect(eventText({ kind: "batch-lost", id: 9, reason: "пакета нет в хабе" }, "Обновить вики").text)
.toBe("пакет #9: пакета нет в хабе — опрос остановлен, «Обновить вики» снова доступна");
expect(eventText({ kind: "hub-down" }, "Обновить из Jira")).toEqual({ text: "хаб не отвечает — пакет не поставлен", tone: "error" });
expect(eventText({ kind: "error", message: "boom" }, "Обновить из Jira")).toEqual({ text: "boom", tone: "error" });
});
});

View File

@ -0,0 +1,38 @@
import { createContext, useContext } from "react";
import type { AgentView, Batch } from "../../api/types.ts";
import type { ModuleEvent } from "./events.ts";
// состояние модуля, которое ядро держит за него: агент, текущее событие пакета, блокировка кнопки обновления
export interface ModuleActivity {
agent: AgentView | null;
event: ModuleEvent | null;
busy: boolean; // запрос постановки в пути или отслеживаемый пакет — пакет действия обновления модуля
running: Batch | null; // отслеживаемый пакет (последний ответ опроса)
start(): Promise<void>; // «обновить» текущий проект модуля; без проекта у модуля с охватом «проект» — ничего
track(batchId: number): void; // опрашивать уже поставленный пакет (после создания проекта)
}
export interface ActivityValue {
agents: AgentView[];
modules: Record<string, ModuleActivity>;
summary: "error" | "busy" | "online" | "offline";
trackBatch(batchId: number): Promise<void>; // пакет без известного модуля: модуль берётся из ответа хаба
}
export const AGENTS_KEY = ["agents"] as const;
export const ActivityContext = createContext<ActivityValue | null>(null);
function useActivity(): ActivityValue {
const v = useContext(ActivityContext);
if (!v) throw new Error("активность модулей вызвана вне ActivityProvider: оберните оболочку в <ActivityProvider>");
return v;
}
export function useModuleActivity(name: string): ModuleActivity {
const m = useActivity().modules[name];
if (!m) throw new Error(`у модуля «${name}» нет описания состояния (HubModule.status)`);
return m;
}
export const useAgents = (): AgentView[] => useActivity().agents;
export const useActivitySummary = (): ActivityValue["summary"] => useActivity().summary;
export const useTrackBatch = (): ActivityValue["trackBatch"] => useActivity().trackBatch;

View File

@ -1,9 +1,11 @@
export const POLL_MS = 3000; // период опроса пакета, мс (как у links.html)
export const MAX_POLL_ERRORS = 5; // столько ошибок «хаб не отвечает» подряд — и опрос пакета останавливается (M-4)
export const DONE_VISIBLE_MS = 10_000; // «пакет выполнен» гаснет через это время
export const PICKUP_MS = 60_000; // подхват пакетов и опрос GET /agents — раз в минуту
export const PICKUP_LIMIT = 20; // сколько последних пакетов модуля смотрит подхват
// событие обновления из Jira: ход пакета и ошибки его постановки; показывает верхняя панель оболочки
export type RefreshEvent =
// событие пакета модуля: ход пакета и ошибки его постановки; показывает блок состояния оболочки
export type ModuleEvent =
| { kind: "batch"; id: number; phase: "queued" | "running"; remaining: number; agentOffline: boolean; retrying: boolean }
| { kind: "batch-done"; id: number }
| { kind: "batch-failed"; id: number; error: string }
@ -12,9 +14,10 @@ export type RefreshEvent =
| { kind: "hub-down" }
| { kind: "error"; message: string };
export type RefreshTone = "info" | "success" | "error" | "warning";
export type EventTone = "info" | "success" | "error" | "warning";
export function refreshText(e: RefreshEvent): { text: string; tone: RefreshTone } {
// actionLabel — название действия обновления модуля («Обновить из Jira», «Обновить вики»): оно «снова доступна» после остановки опроса
export function eventText(e: ModuleEvent, actionLabel: string): { text: string; tone: EventTone } {
switch (e.kind) {
case "batch": {
const off = e.agentOffline ? "агент офлайн — пакет выполнится, когда он выйдет на связь; " : "";
@ -25,7 +28,7 @@ export function refreshText(e: RefreshEvent): { text: string; tone: RefreshTone
case "batch-done": return { text: `пакет #${e.id} выполнен`, tone: "success" };
case "batch-failed": return { text: `пакет #${e.id} не выполнен: ${e.error}`, tone: "error" };
case "batch-cancelled": return { text: `пакет #${e.id} отменён`, tone: "warning" };
case "batch-lost": return { text: `пакет #${e.id}: ${e.reason} — опрос остановлен, «Обновить из Jira» снова доступна`, tone: "error" };
case "batch-lost": return { text: `пакет #${e.id}: ${e.reason} — опрос остановлен, «${actionLabel}» снова доступна`, tone: "error" };
case "hub-down": return { text: "хаб не отвечает — пакет не поставлен", tone: "error" };
case "error": return { text: e.message, tone: "error" };
}

View File

@ -35,6 +35,9 @@
/* блок данных Jira: в развёрнутой панели — колонкой, событие пакета под кнопкой с переносом */
.data-status{display:flex;flex-direction:column;align-items:stretch;gap:var(--space-2);align-self:stretch;font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--color-text-secondary)}
.data-status .ui-btn{width:100%;justify-content:center}
.data-status__head{display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap}
.data-status__head strong{color:var(--color-text-primary)}
.data-status__head .ui-icon-btn{margin-left:auto}
.data-status__agent{display:inline-flex;align-items:center;gap:6px}
.data-status__dot{width:8px;height:8px;border-radius:50%;background:var(--color-status-warning);flex:0 0 auto}
.data-status__dot.is-online{background:var(--color-status-success)}

View File

@ -2,18 +2,22 @@ import { readFileSync } from "node:fs";
import { join } from "node:path";
import { json, mockFetch } from "./fetchMock.ts";
// хаб в памяти для тестов обновления из Jira: снимок, список пакетов, пакет по id; счётчики запросов
// хаб в памяти для тестов трекера пакетов и обновления из Jira: снимок, список пакетов, пакет по id; счётчики запросов
export const fixture = (name: string) => readFileSync(join(import.meta.dirname, "..", "modules", "jira", "board", "__fixtures__", name), "utf8");
export const SNAP = JSON.parse(fixture("hub-basic.board.json"));
export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
export const deferred = <T,>() => { let resolve!: (v: T) => void; const promise = new Promise<T>((r) => { resolve = r; }); return { promise, resolve }; };
export const agentView = (module: string, over: Record<string, unknown> = {}) =>
({ module, online: true, last_seen_at: null, agent_version: 1, warning: null, update_hint: null, current_command_id: null, release: null, ...over });
export function hub(over: Partial<Record<string, (url: URL, body: unknown) => Response | Promise<Response>>> = {}) {
const calls: string[] = [];
const calls: string[] = [], urls: string[] = [];
const inFlight: Record<string, number> = {}, maxInFlight: Record<string, number> = {};
const fetch = mockFetch(async (method, url, body) => {
const k = `${method} ${url.pathname}`;
calls.push(k);
urls.push(url.pathname + url.search);
inFlight[k] = (inFlight[k] ?? 0) + 1;
maxInFlight[k] = Math.max(maxInFlight[k] ?? 0, inFlight[k]);
try {
@ -23,13 +27,15 @@ export function hub(over: Partial<Record<string, (url: URL, body: unknown) => Re
if (k === "GET /issues") return json(200, { issues: [] });
if (k === "GET /projects") return json(200, { projects: [] });
if (k === "GET /batches") return json(200, { batches: [] });
if (k === "GET /agent") return json(200, { online: true, last_seen_at: null, warning: null });
if (k === "GET /agents") return json(200, { agents: [agentView("jira"), agentView("wiki")] });
if (k === "POST /wiki/sync") return json(201, { batch_id: 7 });
if (k === "POST /refresh") return json(201, { id: 7, estimate_seconds: 5, count: 1 });
return json(404, { error: "not_found" });
} finally { inFlight[k] -= 1; }
});
return { fetch, calls, count: (k: string) => calls.filter((c) => c === k).length, maxInFlight: (k: string) => maxInFlight[k] ?? 0 };
return { fetch, calls, urls, count: (k: string) => calls.filter((c) => c === k).length, maxInFlight: (k: string) => maxInFlight[k] ?? 0 };
}
export const batch = (status: string, cmd: Record<string, unknown> = { status: "done" }) =>
json(200, { id: 7, title: "Обновление", author: "viewer", created_at: "", cancelled_at: null, status, counts: {}, remaining_seconds: 0, commands: [{ id: 1, seq: 1, type: "refresh", error: null, finished_at: null, ...cmd }] });
export const batch = (status: string, cmd: Record<string, unknown> = { status: "done" }, over: Record<string, unknown> = {}) =>
json(200, { id: 7, title: "Обновление", author: "viewer", created_at: "", cancelled_at: null, status, counts: {}, remaining_seconds: 0, module: "jira",
commands: [{ id: 1, seq: 1, type: "refresh", error: null, finished_at: null, ...cmd }], ...over });

View File

@ -2,7 +2,7 @@ import { QueryClientProvider } from "@tanstack/react-query";
import { render } from "@testing-library/react";
import type { ReactElement } from "react";
import { createMemoryRouter, RouterProvider } from "react-router";
import { RefreshProvider } from "../modules/jira/refresh/RefreshProvider.tsx";
import { ActivityProvider } from "../shell/activity/ActivityProvider.tsx";
import { makeQueryClient } from "../api/queryClient.ts";
// компонент под QueryClient и data-роутером в памяти: path — шаблон маршрута (для useParams), route — открытый адрес
@ -10,5 +10,5 @@ export function renderWithProviders(ui: ReactElement, { route = "/", path = "*"
const client = makeQueryClient();
const router = createMemoryRouter([{ path, element: ui }, { path: "*", element: <p>другая страница</p> }],
{ initialEntries: [route] });
return { client, router, ...render(<QueryClientProvider client={client}><RefreshProvider><RouterProvider router={router} /></RefreshProvider></QueryClientProvider>) };
return { client, router, ...render(<QueryClientProvider client={client}><ActivityProvider project="AWPCA"><RouterProvider router={router} /></ActivityProvider></QueryClientProvider>) };
}