diff --git a/openspec/changes/web-modules/design.md b/openspec/changes/web-modules/design.md index 88bc8c5..21bf79a 100644 --- a/openspec/changes/web-modules/design.md +++ b/openspec/changes/web-modules/design.md @@ -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 на странице вики | diff --git a/web/src/modules/index.ts b/web/src/modules/index.ts index 6d213ef..71c941b 100644 --- a/web/src/modules/index.ts +++ b/web/src/modules/index.ts @@ -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)); -} diff --git a/web/src/modules/jira/board/BoardPage.test.tsx b/web/src/modules/jira/board/BoardPage.test.tsx index 6ab56f2..79826e5 100644 --- a/web/src/modules/jira/board/BoardPage.test.tsx +++ b/web/src/modules/jira/board/BoardPage.test.tsx @@ -20,7 +20,7 @@ function hub(over: Record 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" }); diff --git a/web/src/modules/jira/board/useBoardSource.test.tsx b/web/src/modules/jira/board/useBoardSource.test.tsx index 2d9f246..4f22be0 100644 --- a/web/src/modules/jira/board/useBoardSource.test.tsx +++ b/web/src/modules/jira/board/useBoardSource.test.tsx @@ -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 }) => {children} }) }); +const hook = (client = makeQueryClient()) => ({ client, ...renderHook(() => ({ ...useBoardSource(dicts, "AWPCA"), refresh: useModuleActivity("jira") }), + { wrapper: ({ children }: { children: ReactNode }) => {children} }) }); describe("useBoardSource", () => { beforeEach(() => setToken("tok")); diff --git a/web/src/modules/jira/feed/FeedPage.test.tsx b/web/src/modules/jira/feed/FeedPage.test.tsx index 423a356..3ac3009 100644 --- a/web/src/modules/jira/feed/FeedPage.test.tsx +++ b/web/src/modules/jira/feed/FeedPage.test.tsx @@ -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" }); }); diff --git a/web/src/modules/jira/index.tsx b/web/src/modules/jira/index.tsx index bcefab3..b8dd4b3 100644 --- a/web/src/modules/jira/index.tsx +++ b/web/src/modules/jira/index.tsx @@ -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: }, { path: "/board", element: {(p) => } }, @@ -29,10 +22,7 @@ export const jiraModule: HubModule = { { to: "/issues", label: "Задачи", icon: , padded: true }, { to: "/feed", label: "Лента", icon: , padded: true, keepSearch: true }, ], - Provider: RefreshProvider, - SidebarStatus, - TopbarNotice, + status: jiraStatus, useOverlay: useIssueOverlay, projectFields: [{ name: "board_id", label: "Номер доски Jira", hint: "scrum или kanban определится сам", kind: "int" }], - useOnProjectCreated, }; diff --git a/web/src/modules/jira/refresh/DataStatus.tsx b/web/src/modules/jira/refresh/DataStatus.tsx deleted file mode 100644 index e21c971..0000000 --- a/web/src/modules/jira/refresh/DataStatus.tsx +++ /dev/null @@ -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 = {rt?.text}; - if (compact) { - const title = ["Обновить из Jira", agentText, syncText, rt?.text].filter(Boolean).join(" · "); - return ( -
-
- ); - } - return ( -
- {agent && ( - - - )} - {syncText && {syncText}} - - {event} -
- ); -} diff --git a/web/src/modules/jira/refresh/RefreshProvider.tsx b/web/src/modules/jira/refresh/RefreshProvider.tsx deleted file mode 100644 index d7be62b..0000000 --- a/web/src/modules/jira/refresh/RefreshProvider.tsx +++ /dev/null @@ -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(null); - const [active, setActive] = useState(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("/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(`/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(`/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("/agent"); - const b = await api.post("/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(() => ({ - event, busy: starting || active !== null, agent: agent.data ?? null, start, track, - }), [event, starting, active, agent.data, start, track]); - - return {children}; -} diff --git a/web/src/modules/jira/refresh/SidebarStatus.tsx b/web/src/modules/jira/refresh/SidebarStatus.tsx deleted file mode 100644 index 2437a75..0000000 --- a/web/src/modules/jira/refresh/SidebarStatus.tsx +++ /dev/null @@ -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 ; -} - -// I-1: на телефоне подсказок нет — событие пакета строкой под верхней панелью -export function TopbarNotice() { - const refresh = useJiraRefresh(); - const rt = refresh.event ? refreshText(refresh.event) : null; - return rt ? : null; -} diff --git a/web/src/modules/jira/refresh/refresh.test.tsx b/web/src/modules/jira/refresh/refresh.test.tsx deleted file mode 100644 index f117583..0000000 --- a/web/src/modules/jira/refresh/refresh.test.tsx +++ /dev/null @@ -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 }) => - {children}; -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(); - hub({ "GET /agent": () => slow.promise }); - const { result } = refreshHook(); - let run!: Promise; - 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) => ( - {page && }); - 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)); - }); -}); diff --git a/web/src/modules/jira/refresh/refresh.ts b/web/src/modules/jira/refresh/refresh.ts deleted file mode 100644 index 5cfae20..0000000 --- a/web/src/modules/jira/refresh/refresh.ts +++ /dev/null @@ -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; - track(batchId: number): void; // опрашивать уже поставленный пакет (форма «Новый проект») -} - -export const RefreshContext = createContext(null); - -export function useJiraRefresh(): JiraRefresh { - const v = useContext(RefreshContext); - if (!v) throw new Error("useJiraRefresh вызван вне RefreshProvider: оберните приложение в "); - return v; -} diff --git a/web/src/modules/jira/refresh/refreshStatus.test.ts b/web/src/modules/jira/refresh/refreshStatus.test.ts deleted file mode 100644 index a4ed1a8..0000000 --- a/web/src/modules/jira/refresh/refreshStatus.test.ts +++ /dev/null @@ -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" }); - }); -}); diff --git a/web/src/modules/jira/status.ts b/web/src/modules/jira/status.ts new file mode 100644 index 0000000..96c0200 --- /dev/null +++ b/web/src/modules/jira/status.ts @@ -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("/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 }); + }, +}; diff --git a/web/src/modules/types.ts b/web/src/modules/types.ts index 0c1bf81..315fcfc 100644 --- a/web/src/modules/types.ts +++ b/web/src/modules/types.ts @@ -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; }; diff --git a/web/src/modules/wiki/SyncStatus.test.tsx b/web/src/modules/wiki/SyncStatus.test.tsx deleted file mode 100644 index be543b8..0000000 --- a/web/src/modules/wiki/SyncStatus.test.tsx +++ /dev/null @@ -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(); - 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(); - await tick(0); - expect(f.mock.calls.some(([u]) => String(u).startsWith("/wiki/status"))).toBe(false); - }); -}); diff --git a/web/src/modules/wiki/SyncStatus.tsx b/web/src/modules/wiki/SyncStatus.tsx deleted file mode 100644 index ffe777d..0000000 --- a/web/src/modules/wiki/SyncStatus.tsx +++ /dev/null @@ -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 ( -
- {text} - {compact && tone !== "plain" &&
- ); -} diff --git a/web/src/modules/wiki/WikiPage.tsx b/web/src/modules/wiki/WikiPage.tsx index cb24796..7098890 100644 --- a/web/src/modules/wiki/WikiPage.tsx +++ b/web/src/modules/wiki/WikiPage.tsx @@ -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 (
} loading={sync.isPending || running} onClick={() => sync.mutate()}>Обновить вики} /> + actions={} /> {s.error && {s.error}} - {syncError && {syncError}} {!synced ? ( {running ? "Вики синхронизируется…" : "Вики ещё не синхронизирована. Нажмите «Обновить вики»."} ) : ( diff --git a/web/src/modules/wiki/api/queries.ts b/web/src/modules/wiki/api/queries.ts index a387b28..0b02400 100644 --- a/web/src/modules/wiki/api/queries.ts +++ b/web/src/modules/wiki/api/queries.ts @@ -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) }), - }); -} diff --git a/web/src/modules/wiki/index.tsx b/web/src/modules/wiki/index.tsx index 264a9d6..735108c 100644 --- a/web/src/modules/wiki/index.tsx +++ b/web/src/modules/wiki/index.tsx @@ -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: {(p) => } }], nav: [{ to: "/wiki", label: "Вики", icon: , padded: true }], - SidebarStatus, + status: wikiStatus, projectFields: [{ name: "wiki_root", label: "Корень вики", hint: "DOC-… или ссылка на страницу Eva", kind: "text" }], }; diff --git a/web/src/modules/wiki/status.ts b/web/src/modules/wiki/status.ts new file mode 100644 index 0000000..6d66e26 --- /dev/null +++ b/web/src/modules/wiki/status.ts @@ -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] }); + }, +}; diff --git a/web/src/modules/wiki/wiki.css b/web/src/modules/wiki/wiki.css index a051ba6..e96adc5 100644 --- a/web/src/modules/wiki/wiki.css +++ b/web/src/modules/wiki/wiki.css @@ -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)} diff --git a/web/src/projects/NewProjectDialog.test.tsx b/web/src/projects/NewProjectDialog.test.tsx index 37c2474..479c98c 100644 --- a/web/src/projects/NewProjectDialog.test.tsx +++ b/web/src/projects/NewProjectDialog.test.tsx @@ -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] }; diff --git a/web/src/projects/projects.test.tsx b/web/src/projects/projects.test.tsx index 85cffdf..4cf292b 100644 --- a/web/src/projects/projects.test.tsx +++ b/web/src/projects/projects.test.tsx @@ -30,7 +30,7 @@ function hub(projects: unknown[] = [A, S], over: Record 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(); diff --git a/web/src/shell/Layout.tsx b/web/src/shell/Layout.tsx index 3081458..6b7a806 100644 --- a/web/src/shell/Layout.tsx +++ b/web/src/shell/Layout.tsx @@ -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(() => ({ state, projects: projects.data ?? [], select, openNew: () => setNewOpen(true) }), [state, projects.data, select]); - const onProjectCreated = useOnProjectCreated(); return ( +
n.to === section)?.padded ? "" : " page--board")}> @@ -60,7 +62,14 @@ export function Layout() {
{overlay?.element} - {newOpen && setNewOpen(false)} onCreated={(p, batchId) => { setNewOpen(false); onProjectCreated(batchId); select(p.label); }} />} + {newOpen && setNewOpen(false)} select={select} />} +
); } + +// окно «Новый проект»: пакет, поставленный хабом при создании, отслеживает трекер ядра (модуль берётся из ответа хаба) +function NewProject({ onClose, select }: { onClose(): void; select(label: string): void }) { + const trackBatch = useTrackBatch(); + return { onClose(); void trackBatch(batchId).catch(() => {}); select(p.label); }} />; +} diff --git a/web/src/shell/Sidebar.test.tsx b/web/src/shell/Sidebar.test.tsx index b96e5ee..c3ab326 100644 --- a/web/src/shell/Sidebar.test.tsx +++ b/web/src/shell/Sidebar.test.tsx @@ -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 Response> = {}) { @@ -26,7 +26,7 @@ function hub(over: Record 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"); diff --git a/web/src/shell/Sidebar.tsx b/web/src/shell/Sidebar.tsx index 6cfdb7b..3e0440b 100644 --- a/web/src/shell/Sidebar.tsx +++ b/web/src/shell/Sidebar.tsx @@ -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)}> SDLC Hub - {MODULES.map((m) => m.SidebarStatus && )} + - {MODULES.map((m) => m.TopbarNotice && )} + {open && <>
@@ -91,7 +92,7 @@ function Panel({ section, project, drawer }: Props & { drawer?: { close(): void; ))} - {MODULES.map((m) => m.SidebarStatus && )} +
diff --git a/web/src/shell/activity/ActivityProvider.tsx b/web/src/shell/activity/ActivityProvider.tsx new file mode 100644 index 0000000..e10a180 --- /dev/null +++ b/web/src/shell/activity/ActivityProvider.tsx @@ -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> => !!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(null); + const [active, setActive] = useState(null); + const [starting, setStarting] = useState(false); + const startingRef = useRef(false); + const failures = useRef(0); + const activeRef = useRef(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(`/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(() => ({ 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(`/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(() => ({ agents: agents ?? [], modules, summary, trackBatch }), [agents, modules, summary, trackBatch]); + return {children}; +} diff --git a/web/src/shell/activity/StatusBlock.tsx b/web/src/shell/activity/StatusBlock.tsx new file mode 100644 index 0000000..b7e733c --- /dev/null +++ b/web/src/shell/activity/StatusBlock.tsx @@ -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> => !!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 = {ev?.text}; + const button = action && ( +