diff --git a/openspec/changes/web-modules/design.md b/openspec/changes/web-modules/design.md
index 20efa5d..4e77c81 100644
--- a/openspec/changes/web-modules/design.md
+++ b/openspec/changes/web-modules/design.md
@@ -140,3 +140,7 @@ type HubModule = {
| 36 | 24.10.2026 | Task 4: `--topbar-h` удалён из `shell.css` и из `board.css` (`.drawer` на ширине страницы < 852 px статичен, а строка приложения бывает только на < 640 px) | M-6 #73: переменная мертва | вернуть переменную |
| 37 | 24.10.2026 | Task 4: прокручивается середина панели (`.sidebar__scroll`: разделы и блок состояния), низ с темой и меню «Ещё» остаётся вне прокрутки; поправка R-1 решения #69 (меню «Ещё» свёрнутой панели выходит за 64 px, при `overflow` на всей панели обрезалось бы) | M-5 #73: на низком окне панель не должна обрезаться | прокрутка всей панели и обрезанное меню |
| 38 | 24.10.2026 | Task 4: сводная точка узкого экрана — `role="img"` с `aria-label` «Состояние модулей: ошибка / идёт обновление / агент на связи / агент офлайн»; «идёт обновление» — нейтральный цвет (токена «info» нет); событие пакета узкого экрана — постоянный `role="status"` под строкой (`data-testid="topbar-events"`) | цвет не единственный признак; T10.1 #94 | вернуть `aria-hidden`-строку |
+| 39 | 24.10.2026 | Task 5: `GET /board` на `/` («Обзор» грузит снимок доски для сводки jira, решение 13) — тест `no wiki row without root` блока состояния проверяет только отсутствие `GET /wiki/status` | `open()` теста открывает `/`, а он теперь тянет доску; суть теста — строка вики без корня | тест строже, чем нужно; ослабление скрывает запрос статуса вики без корня |
+| 40 | 24.10.2026 | Task 5: общий `agentText(a)` (`shell/activity/agentText.ts`) — текст, подсказка и признак «на связи» агента; им пользуются блок состояния и карточки «Обзора» | текст «агент на связи/офлайн/обновите агента» не должен разойтись между двумя местами | рефакторинг блока состояния; тесты блока его держат |
+| 41 | 24.10.2026 | Task 5: `useRecentBatches(project)` опрашивает `GET /batches?project=&limit=10` раз в 15 с (`refetchInterval`) | ход пакетов меняется без участия страницы; инвалидация из активности ядра усложнила бы контракт | лишние запросы раз в 15 с на «Обзоре» |
+| 42 | 24.10.2026 | Task 5: поле поиска вики на «Обзоре» не ведёт на пустой запрос (Enter при пустой строке ничего не делает); `q` в «Вики» — только начальное значение поля, выбор страницы убирает `q` из адреса | пустой поиск = страница «Вики» без `q`, для неё есть ссылка «Вики»; `hrefOf` страницы по контракту Task 4 не несёт поиск | пользователь, вставший на `?q=` и открывший страницу, теряет в адресе строку поиска (в поле она остаётся) |
diff --git a/web/src/api/queries.ts b/web/src/api/queries.ts
index 4f3353f..a957969 100644
--- a/web/src/api/queries.ts
+++ b/web/src/api/queries.ts
@@ -1,11 +1,12 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "./client.ts";
-import type { CreatedProject, HubSettings, UpdatedProject, Project } from "./types.ts";
+import type { Batch, CreatedProject, HubSettings, UpdatedProject, Project } from "./types.ts";
// запросы ядра: настройки и проекты; запросы модулей — в web/src/modules/<модуль>/api/queries.ts
export const qk = {
settings: ["settings"] as const,
projects: ["projects"] as const,
+ recentBatches: (project: string) => ["batches", project, "recent"] as const,
};
export const useSettings = () =>
@@ -33,3 +34,11 @@ export function useUpdateProject() {
onSuccess: () => qc.invalidateQueries({ queryKey: qk.projects }),
});
}
+
+// «Последние пакеты проекта» на «Обзоре»: до 10 пакетов проекта; ход пакетов меняется без нашего участия — опрос раз в 15 с
+export const RECENT_BATCHES = 10;
+export const useRecentBatches = (project: string) =>
+ useQuery({
+ queryKey: qk.recentBatches(project), refetchInterval: 15_000,
+ queryFn: () => api.get<{ batches?: Batch[] }>("/batches", { project, limit: RECENT_BATCHES }).then((r) => r.batches ?? []),
+ });
diff --git a/web/src/modules/jira/Summary.tsx b/web/src/modules/jira/Summary.tsx
new file mode 100644
index 0000000..477bdf0
--- /dev/null
+++ b/web/src/modules/jira/Summary.tsx
@@ -0,0 +1,36 @@
+import { Link } from "react-router";
+import type { Project } from "../../api/types.ts";
+import { Notice } from "../../ui/index.ts";
+import { useBoard } from "./api/queries.ts";
+import { isClosed, useClosedStatuses } from "./api/closed.ts";
+import "./summary.css";
+
+// сегодня по местному времени, ГГГГ-ММ-ДД: срок задачи Jira — дата без времени
+const today = (now = new Date()): string =>
+ `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
+
+// сводка jira для «Обзора»: свои задачи проекта (не соседи), из них открытые и просроченные — по снимку доски
+export function Summary({ project }: { project: Project }) {
+ const board = useBoard(project.label);
+ const closed = useClosedStatuses();
+ const q = `?project=${encodeURIComponent(project.label)}`;
+ const own = board.data?.issues.filter((i) => !i.neighbor);
+ const open = own?.filter((i) => !isClosed(i.status, closed));
+ const t = today();
+ const late = open?.filter((i) => !!i.duedate && i.duedate.slice(0, 10) < t).length ?? 0;
+ return (
+
+ {board.isError ?
{board.error.message}
+ : !own || !open ?
Загрузка…
+ : (
+
+ {own.length} задач · {open.length} открытых · {late} просрочено
+
)}
+
+ Доска связей
+ Задачи
+ Лента
+
+
+ );
+}
diff --git a/web/src/modules/jira/index.tsx b/web/src/modules/jira/index.tsx
index 94322b3..2fc7d5d 100644
--- a/web/src/modules/jira/index.tsx
+++ b/web/src/modules/jira/index.tsx
@@ -7,6 +7,7 @@ import { FeedPage } from "./feed/FeedPage.tsx";
import { useIssueOverlay } from "./issues/overlay.tsx";
import { IssuesPage } from "./issues/IssuesPage.tsx";
import { jiraStatus } from "./status.ts";
+import { Summary } from "./Summary.tsx";
// прежний адрес → новый; keyParam — параметр пути (ключ задачи), который дописывается к новому адресу
function LegacyRedirect({ to, keyParam }: { to: string; keyParam?: string }) {
@@ -35,6 +36,7 @@ export const jiraModule: HubModule = {
{ to: "/jira/feed", label: "Лента", icon: , padded: true, keepSearch: true },
],
status: jiraStatus,
+ Summary,
useOverlay: useIssueOverlay,
projectFields: [{ name: "board_id", label: "Номер доски Jira", hint: "scrum или kanban определится сам", kind: "int" }],
};
diff --git a/web/src/modules/jira/summary.css b/web/src/modules/jira/summary.css
new file mode 100644
index 0000000..36e44a8
--- /dev/null
+++ b/web/src/modules/jira/summary.css
@@ -0,0 +1,5 @@
+.jira-summary{display:flex;flex-direction:column;gap:var(--space-3)}
+.jira-summary__counts{margin:0;font-size:var(--fs-lg);line-height:var(--lh-lg)}
+.jira-summary__late{color:var(--color-status-error);font-weight:600}
+.jira-summary__links{display:flex;flex-wrap:wrap;gap:var(--space-3)}
+.jira-summary__links a{color:var(--color-action-primary);font-weight:600}
diff --git a/web/src/modules/wiki/Summary.tsx b/web/src/modules/wiki/Summary.tsx
new file mode 100644
index 0000000..7e611ea
--- /dev/null
+++ b/web/src/modules/wiki/Summary.tsx
@@ -0,0 +1,31 @@
+import { useState, type FormEvent } from "react";
+import { Link, useNavigate } from "react-router";
+import type { Project } from "../../api/types.ts";
+import { SearchBox } from "./Search.tsx";
+
+// сводка вики для «Обзора»: число страниц и поиск (Enter открывает «Вики» с этим поиском); без корня — объяснение и привязка
+export function Summary({ project }: { project: Project }) {
+ const navigate = useNavigate();
+ const [q, setQ] = useState("");
+ const base = `?project=${encodeURIComponent(project.label)}`;
+ if (!project.wiki_root) {
+ return (
+
+
У проекта нет корня вики, поэтому вики не синхронизируется.
+
Привязать корень вики
+
+ );
+ }
+ const pages = typeof project.wiki_pages === "number" ? project.wiki_pages : 0;
+ const submit = (e: FormEvent) => {
+ e.preventDefault();
+ if (q.trim() !== "") void navigate(`/wiki${base}&q=${encodeURIComponent(q.trim())}`);
+ };
+ return (
+
+
{pages} страниц
+
+
Вики
+
+ );
+}
diff --git a/web/src/modules/wiki/WikiPage.tsx b/web/src/modules/wiki/WikiPage.tsx
index 7098890..e08907a 100644
--- a/web/src/modules/wiki/WikiPage.tsx
+++ b/web/src/modules/wiki/WikiPage.tsx
@@ -25,8 +25,9 @@ export function WikiPage({ project }: { project: string }) {
const tree = useWikiTree(project, synced);
const sync = useModuleActivity("wiki");
- const [q, setQ] = useState("");
- const [dq, setDq] = useState("");
+ // начальная строка поиска — из адреса (?q=…): так на страницу ведёт поиск с «Обзора»
+ const [q, setQ] = useState(() => params.get("q") ?? "");
+ const [dq, setDq] = useState(() => (params.get("q") ?? "").trim());
useEffect(() => {
const t = setTimeout(() => setDq(q.trim()), q.trim() === "" ? 0 : Q_DELAY);
return () => clearTimeout(t);
diff --git a/web/src/modules/wiki/index.tsx b/web/src/modules/wiki/index.tsx
index 735108c..36d9a82 100644
--- a/web/src/modules/wiki/index.tsx
+++ b/web/src/modules/wiki/index.tsx
@@ -2,6 +2,7 @@ import { ProjectGate } from "../../projects/ProjectGate.tsx";
import { IconBook } from "../../ui/index.ts";
import type { HubModule } from "../types.ts";
import { wikiStatus } from "./status.ts";
+import { Summary } from "./Summary.tsx";
import { WikiPage } from "./WikiPage.tsx";
export const wikiModule: HubModule = {
@@ -10,5 +11,6 @@ export const wikiModule: HubModule = {
pages: [{ path: "/wiki", element: {(p) => } }],
nav: [{ to: "/wiki", label: "Вики", icon: , padded: true }],
status: wikiStatus,
+ Summary,
projectFields: [{ name: "wiki_root", label: "Корень вики", hint: "DOC-… или ссылка на страницу Eva", kind: "text" }],
};
diff --git a/web/src/modules/wiki/wiki.css b/web/src/modules/wiki/wiki.css
index e96adc5..4e9c8e8 100644
--- a/web/src/modules/wiki/wiki.css
+++ b/web/src/modules/wiki/wiki.css
@@ -61,3 +61,6 @@
.wiki-att__error{color:var(--color-status-error)}
/* плашка хода синхронизации в боковой панели */
+.wiki-summary{display:flex;flex-direction:column;gap:var(--space-3)}
+.wiki-summary__text{margin:0;font-size:var(--fs-lg);line-height:var(--lh-lg)}
+.wiki-summary__link{color:var(--color-action-primary);font-weight:600;align-self:flex-start}
diff --git a/web/src/shell/ModuleStatusBlock.test.tsx b/web/src/shell/ModuleStatusBlock.test.tsx
index 1e3063e..ae9cb59 100644
--- a/web/src/shell/ModuleStatusBlock.test.tsx
+++ b/web/src/shell/ModuleStatusBlock.test.tsx
@@ -70,13 +70,13 @@ describe("блок состояния модулей", () => {
expect(err).toHaveClass("data-status__sync--error");
});
- it("no wiki row without root: одна строка, запросов /wiki/status и /board нет", async () => {
+ it("no wiki row without root: одна строка, запроса /wiki/status нет", async () => {
const paths = hub({ wiki_root: null, wiki_synced_at: null });
open();
await within(bar()).findByRole("button", { name: "Обновить из Jira — все проекты" });
expect(rowNow("wiki")).toBeNull();
expect(within(bar()).queryByRole("button", { name: /^Обновить вики/ })).toBeNull();
- expect(paths.filter((p) => p === "GET /wiki/status" || p === "GET /board")).toEqual([]);
+ expect(paths.filter((p) => p === "GET /wiki/status")).toEqual([]);
});
it("живой регион role=status стоит в строке модуля постоянно, до появления события", async () => {
diff --git a/web/src/shell/ModuleStatusBlock.tsx b/web/src/shell/ModuleStatusBlock.tsx
index f05558f..02bf685 100644
--- a/web/src/shell/ModuleStatusBlock.tsx
+++ b/web/src/shell/ModuleStatusBlock.tsx
@@ -5,6 +5,7 @@ import type { HubModule } from "../modules/types.ts";
import { fmtStamp } from "../ui/format.ts";
import { IconButton, IconRefresh } from "../ui/index.ts";
import { useModuleActivity } from "./activity/activity.ts";
+import { agentText } from "./activity/agentText.ts";
import { eventText } from "./activity/events.ts";
type WithStatus = HubModule & Required>;
@@ -20,17 +21,17 @@ function ModuleRow({ m, project, variant }: { m: WithStatus; project: Project; v
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 av = a ? agentText(a) : null;
+ const agentLabel = av?.text ?? null, agentHint = av?.hint;
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 online = !!av?.online;
const collapsed = variant === "collapsed";
const eventEl = {ev?.text} ;
const button = action && (
- : } onClick={() => void act.start()} />);
if (collapsed) {
@@ -46,9 +47,9 @@ function ModuleRow({ m, project, variant }: { m: WithStatus; project: Project; v
{m.title}
- {agentText && (
+ {agentLabel && (
- {agentText}
+ {agentLabel}
)}
{button}
diff --git a/web/src/shell/OverviewPage.test.tsx b/web/src/shell/OverviewPage.test.tsx
new file mode 100644
index 0000000..3191e2e
--- /dev/null
+++ b/web/src/shell/OverviewPage.test.tsx
@@ -0,0 +1,113 @@
+import { cleanup, render, screen, waitFor, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { createMemoryRouter } from "react-router";
+import { beforeEach, describe, expect, it } from "vitest";
+import { App } from "../App.tsx";
+import { setToken } from "../auth/token.ts";
+import { routes } from "../routes.tsx";
+import { json, mockFetch } from "../test/fetchMock.ts";
+
+const AWPCA = { label: "AWPCA", name: "АРМ КА", board_id: null, board_type: null, created_at: "", issues_count: 9,
+ jira_synced_at: "2026-10-24T11:07:00", wiki_root: "DOC-1", wiki_synced_at: "2026-10-24T11:05:00", wiki_sync_error: null, wiki_pages: 28 };
+const KEDR = { ...AWPCA, label: "sfm_kedr", name: "Кедр", wiki_root: null, wiki_synced_at: null, wiki_pages: null };
+const AGENTS = [
+ { module: "jira", online: true, last_seen_at: "2026-10-24T09:30:00", agent_version: 14, warning: null, update_hint: null, current_command_id: null, release: null },
+ { module: "wiki", online: true, last_seen_at: "2026-10-24T09:30:00", agent_version: 1, warning: null, update_hint: null, current_command_id: null, release: null },
+];
+const iss = (key: string, o: Record
= {}) => ({ key, summary: key, issue_type: "Задача", status: "В работе", priority: null, assignee: null,
+ sprint: null, duedate: null, planned_start: null, planned_end: null, epic_key: null, labels: [], neighbor: false, work_kind: null, ...o });
+const BOARD = { generated_at: "", last_sync: null, links: [], issues: [
+ iss("A-1"), iss("A-2", { duedate: "2020-01-01" }), iss("A-3", { status: "Готово", duedate: "2020-01-01" }),
+ iss("A-4", { duedate: "2999-01-01" }), iss("N-1", { neighbor: true, duedate: "2020-01-01" }),
+] };
+const BATCHES = [
+ { id: 7, title: "Обновление вики AWPCA", author: "viewer", created_at: "2026-10-24T10:00:00", status: "done", module: "wiki", counts: {}, remaining_seconds: 0 },
+ { id: 6, title: "Полное обновление", author: "claude", created_at: "2026-10-24T09:00:00", status: "queued", module: "jira", counts: {}, remaining_seconds: 0 },
+];
+
+function hub() {
+ const calls: string[] = [];
+ mockFetch((method, url) => {
+ calls.push(`${method} ${url.pathname}${url.search}`);
+ const k = `${method} ${url.pathname}`;
+ if (k === "GET /projects") return json(200, { projects: [AWPCA, KEDR] });
+ if (k === "GET /agents") return json(200, { agents: AGENTS });
+ if (k === "GET /batches") return json(200, { batches: url.searchParams.get("limit") === "10" ? BATCHES : [] });
+ if (k === "GET /board") return json(200, BOARD);
+ if (k === "GET /settings") return json(200, { closed_statuses: ["Готово"] });
+ if (k === "GET /wiki/status") return json(200, { root: "DOC-1", root_title: "Корень", synced_at: "2026-10-24T11:05:00", pages: 28, error: null, batch: null });
+ if (k === "GET /wiki/tree") return json(200, { pages: [{ code: "DOC-1", title: "Корень", depth: 0, parent_code: null, size: 1 }] });
+ if (k === "GET /wiki/search") return json(200, { results: [{ code: "DOC-2", title: "Согласование", path: "DOC-2", snippet: "порядок согласования" }] });
+ return json(404, { error: "not_found" });
+ });
+ return calls;
+}
+const open = (project = "AWPCA") => {
+ const router = createMemoryRouter(routes, { initialEntries: [`/app/?project=${project}`], basename: "/app" });
+ render( );
+ return router;
+};
+const page = async () => within(await screen.findByTestId("page-overview"));
+const card = async (name: string) => (await page()).findByRole("region", { name });
+
+describe("страница «Обзор»", () => {
+ beforeEach(() => { setToken("tok"); localStorage.clear(); cleanup(); });
+
+ it("две карточки: агент с версией, свежесть, вторичные кнопки обновления", async () => {
+ hub();
+ open();
+ const jira = within(await card("Jira"));
+ const wiki = within(await card("Вики"));
+ expect(await jira.findByText("агент на связи")).toBeInTheDocument();
+ expect(jira.getByText("v14")).toBeInTheDocument();
+ expect(await wiki.findByText("v1")).toBeInTheDocument();
+ expect(jira.getByText("данные на 24.10 11:07 · все проекты")).toBeInTheDocument();
+ expect(wiki.getByText("данные на 24.10 11:05 · проект")).toBeInTheDocument();
+ for (const [c, name] of [[jira, "Обновить из Jira"], [wiki, "Обновить вики"]] as const) {
+ expect(c.getByRole("button", { name })).toHaveClass("ui-btn--secondary");
+ }
+ });
+
+ it("сводка jira: свои задачи, открытые и просроченные; ссылки на разделы", async () => {
+ hub();
+ open();
+ const jira = within(await card("Jira"));
+ expect(await jira.findByText((_, el) => el?.classList.contains("jira-summary__counts") === true && el.textContent === "4 задач · 3 открытых · 1 просрочено")).toBeInTheDocument();
+ expect(jira.getByRole("link", { name: "Доска связей" })).toHaveAttribute("href", "/app/jira/board?project=AWPCA");
+ expect(jira.getByRole("link", { name: "Задачи" })).toHaveAttribute("href", "/app/jira/issues?project=AWPCA");
+ expect(jira.getByRole("link", { name: "Лента" })).toHaveAttribute("href", "/app/jira/feed?project=AWPCA");
+ });
+
+ it("overview card without root: объяснение и «Привязать корень вики»", async () => {
+ hub();
+ open("sfm_kedr");
+ const wiki = within(await card("Вики"));
+ const link = await wiki.findByRole("link", { name: "Привязать корень вики" });
+ expect(link).toHaveAttribute("href", "/app/wiki?project=sfm_kedr");
+ expect(wiki.queryByRole("button", { name: "Обновить вики" })).toBeNull();
+ expect(wiki.queryByRole("searchbox")).toBeNull();
+ });
+
+ it("«Последние пакеты проекта»: номер, модуль, название, автор, статус, время", async () => {
+ const calls = hub();
+ open();
+ const p = await page();
+ const rows = await p.findAllByRole("row");
+ const r7 = within(rows.find((r) => r.textContent?.includes("#7"))!);
+ for (const t of ["#7", "Вики", "Обновление вики AWPCA", "viewer", "выполнен", "24.10 10:00"]) expect(r7.getByText(t)).toBeInTheDocument();
+ expect(within(rows.find((r) => r.textContent?.includes("#6"))!).getByText("в очереди")).toBeInTheDocument();
+ expect(p.getByRole("heading", { name: "Последние пакеты проекта" })).toBeInTheDocument();
+ expect(calls).toContain("GET /batches?project=AWPCA&limit=10");
+ });
+
+ it("поиск по вики: Enter ведёт на «Вики» с q, строка поиска заполнена, результаты показаны", async () => {
+ hub();
+ const router = open();
+ const wiki = within(await card("Вики"));
+ await userEvent.type(await wiki.findByRole("searchbox", { name: "Поиск по вики" }), "согласов{Enter}");
+ await waitFor(() => expect(router.state.location.pathname).toBe("/app/wiki"));
+ expect(router.state.location.search).toBe("?project=AWPCA&q=%D1%81%D0%BE%D0%B3%D0%BB%D0%B0%D1%81%D0%BE%D0%B2");
+ expect(await screen.findByRole("searchbox", { name: "Поиск по вики" })).toHaveValue("согласов");
+ expect(await screen.findByText("Согласование")).toBeInTheDocument();
+ });
+});
diff --git a/web/src/shell/OverviewPage.tsx b/web/src/shell/OverviewPage.tsx
index b21db96..e3ed8c1 100644
--- a/web/src/shell/OverviewPage.tsx
+++ b/web/src/shell/OverviewPage.tsx
@@ -1,10 +1,95 @@
-import { PageHeader } from "../ui/index.ts";
+import { useRecentBatches } from "../api/queries.ts";
+import type { Batch, BatchStatus, Project } from "../api/types.ts";
+import { MODULES } from "../modules/index.ts";
+import type { HubModule } from "../modules/types.ts";
+import { Badge, Button, IconRefresh, Notice, PageHeader, Panel } from "../ui/index.ts";
+import { fmtStamp } from "../ui/format.ts";
+import { agentText } from "./activity/agentText.ts";
+import { useModuleActivity } from "./activity/activity.ts";
+import { eventText } from "./activity/events.ts";
+import "./overview.css";
-// ВРЕМЕННАЯ заглушка «Обзора» (Task 4): Task 5 заменяет её страницей со сводками модулей
-export function OverviewPage() {
+const STATUS: Record = {
+ queued: { text: "в очереди", tone: "neutral" },
+ running: { text: "выполняется", tone: "info" },
+ done: { text: "выполнен", tone: "success" },
+ cancelled: { text: "отменён", tone: "warning" },
+};
+
+// карточка модуля: состояние рисует ядро (агент, свежесть, событие, обновление), сводку — модуль (HubModule.Summary).
+// Без данных для проекта (у вики — без корня) остаётся только сводка модуля: она объясняет и предлагает действие
+function ModuleCard({ m, project }: { m: HubModule; project: Project }) {
+ const act = useModuleActivity(m.name);
+ const { status, Summary } = m;
+ const fresh = status?.freshness(project);
+ const shown = !!status && !!fresh?.available;
+ const label = status?.refresh?.label;
+ const scope = status?.scope === "all" ? "все проекты" : "проект";
+ const a = act.agent ? agentText(act.agent) : null;
+ const ev = act.event ? eventText(act.event, label ?? "") : null;
+ const freshText = !fresh ? null : fresh.error ? `синхронизация не удалась: ${fresh.error}`
+ : fresh.at ? `данные на ${fmtStamp(fresh.at)} · ${scope}` : "обновления ещё не было";
return (
-
-
+
+
+
{m.title}
+ {shown && a && (
+
+ {a.text}
+ {act.agent?.agent_version != null && v{act.agent.agent_version} }
+ )}
+ {shown && label && (
+ } loading={act.busy} onClick={() => void act.start()} className="overview-card__refresh">{label})}
+
+ {shown && (
+ <>
+ {freshText}
+ {ev?.text}
+ >)}
+ {Summary && }
+
+ );
+}
+
+function RecentBatches({ project }: { project: string }) {
+ const batches = useRecentBatches(project);
+ const moduleTitle = (b: Batch) => MODULES.find((m) => m.name === b.module)?.title ?? b.module ?? "—";
+ return (
+
+ Последние пакеты проекта
+ {batches.isError ? {batches.error.message}
+ : batches.isPending ? Загрузка…
+ : !batches.data.length ? Пакетов пока не было.
+ : (
+
+
+ Пакет Модуль Название Автор Статус Создан
+
+ {batches.data.map((b) => {
+ const st = STATUS[b.status] ?? { text: b.status, tone: "neutral" as const };
+ return (
+
+ #{b.id}
+ {moduleTitle(b)}
+ {b.title}
+ {b.author}
+ {st.text}
+ {fmtStamp(b.created_at)}
+ );
+ })}
+
+
+ )}
+
+ );
+}
+
+export function OverviewPage({ project }: { project: Project }) {
+ return (
+
+
+
{MODULES.map((m) => )}
+
);
}
diff --git a/web/src/shell/activity/agentText.ts b/web/src/shell/activity/agentText.ts
new file mode 100644
index 0000000..da7e8c6
--- /dev/null
+++ b/web/src/shell/activity/agentText.ts
@@ -0,0 +1,11 @@
+import type { AgentView } from "../../api/types.ts";
+import { fmtStamp } from "../../ui/format.ts";
+
+// состояние агента для показа: текст (цвет не единственный признак), подсказка, признак «на связи и без замечаний»
+export function agentText(a: AgentView): { text: string; hint: string; online: boolean } {
+ return {
+ text: a.warning || a.update_hint ? "обновите агента" : a.online ? "агент на связи" : "агент офлайн",
+ hint: [a.warning, a.update_hint, a.last_seen_at ? `последний выход на связь: ${fmtStamp(a.last_seen_at)}` : "выхода на связь ещё не было"].filter(Boolean).join(" · "),
+ online: a.online && !a.warning && !a.update_hint,
+ };
+}
diff --git a/web/src/shell/overview.css b/web/src/shell/overview.css
new file mode 100644
index 0000000..9854841
--- /dev/null
+++ b/web/src/shell/overview.css
@@ -0,0 +1,27 @@
+.overview-grid{display:grid;grid-template-columns:minmax(0,1fr);gap:var(--space-4);margin-block:var(--space-4)}
+@container page (min-width:1024px){.overview-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
+.overview-card{display:flex;flex-direction:column;gap:var(--space-3)}
+.overview-card__head{display:flex;align-items:center;gap:var(--space-3);flex-wrap:wrap}
+.overview-card__title{margin:0;font-size:var(--fs-lg);line-height:var(--lh-lg);font-weight:700}
+.overview-card__agent{display:inline-flex;align-items:center;gap:6px;color:var(--color-text-secondary);font-size:var(--fs-caption);line-height:var(--lh-caption)}
+.overview-card__version{font-family:var(--font-mono);color:var(--color-text-primary)}
+.overview-card__refresh{margin-left:auto}
+.overview-dot{width:8px;height:8px;border-radius:50%;background:var(--color-status-warning);flex:0 0 auto}
+.overview-dot.is-online{background:var(--color-status-success)}
+.overview-card__fresh{margin:0;color:var(--color-text-secondary);font-size:var(--fs-caption);line-height:var(--lh-caption)}
+.overview-card__fresh--error{color:var(--color-status-error);font-weight:600;overflow-wrap:anywhere}
+.overview-card__event{margin:0;font-size:var(--fs-caption);line-height:var(--lh-caption);font-weight:600;overflow-wrap:anywhere}
+.overview-card__event:empty{display:none}
+.overview-card__event--info{color:var(--color-text-secondary)}
+.overview-card__event--success{color:var(--color-status-success)}
+.overview-card__event--warning{color:color-mix(in srgb, var(--color-status-warning) 55%, var(--color-text-primary))}
+.overview-card__event--error{color:var(--color-status-error)}
+.overview-section-title{margin:0 0 var(--space-3);font-size:var(--fs-lg);line-height:var(--lh-lg);font-weight:700}
+.overview-hint{margin:0;color:var(--color-text-secondary)}
+.overview-table-wrap{overflow-x:auto}
+.overview-table{width:100%;min-width:640px;border-collapse:separate;border-spacing:0;font-size:var(--fs-body);line-height:var(--lh-body)}
+.overview-table th,.overview-table td{height:44px;padding:0 var(--space-3);text-align:left;vertical-align:middle;border-bottom:1px solid var(--color-border-default);white-space:nowrap}
+.overview-table tbody tr:last-child td{border-bottom:0}
+.overview-table th{color:var(--color-text-secondary);font-size:var(--fs-caption);line-height:var(--lh-caption);font-weight:600}
+.overview-table__title{white-space:normal;min-width:200px}
+.overview-table__num{font-variant-numeric:tabular-nums}
diff --git a/web/src/shell/pages.tsx b/web/src/shell/pages.tsx
index 02c3c08..a01d855 100644
--- a/web/src/shell/pages.tsx
+++ b/web/src/shell/pages.tsx
@@ -6,7 +6,7 @@ import { OverviewPage } from "./OverviewPage.tsx";
// «Обзор» ядра, страницы модулей; Layout рисует их по адресу страницы под окном карточки (design 10); NotFound — последним
export const PAGE_ROUTES: RouteObject[] = [
- { path: "/", element:
{() => } },
+ { path: "/", element:
{(p) => } },
...MODULES.flatMap((m) => m.pages),
{ path: "*", element:
},
];