feat(web-modules): страница «Обзор» со сводками модулей и последними пакетами

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-24 14:04:19 +04:00
parent 861e70e7aa
commit fcb6502d8c
16 changed files with 347 additions and 17 deletions

View File

@ -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=` и открывший страницу, теряет в адресе строку поиска (в поле она остаётся) |

View File

@ -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 ?? []),
});

View File

@ -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 (
<div className="jira-summary">
{board.isError ? <Notice tone="error">{board.error.message}</Notice>
: !own || !open ? <p className="jira-summary__counts">Загрузка…</p>
: (
<p className="jira-summary__counts">
{own.length} задач · {open.length} открытых · <span className={late ? "jira-summary__late" : undefined}>{late} просрочено</span>
</p>)}
<nav className="jira-summary__links" aria-label="Разделы Jira">
<Link to={`/jira/board${q}`}>Доска связей</Link>
<Link to={`/jira/issues${q}`}>Задачи</Link>
<Link to={`/jira/feed${q}`}>Лента</Link>
</nav>
</div>
);
}

View File

@ -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: <IconHistory />, padded: true, keepSearch: true },
],
status: jiraStatus,
Summary,
useOverlay: useIssueOverlay,
projectFields: [{ name: "board_id", label: "Номер доски Jira", hint: "scrum или kanban определится сам", kind: "int" }],
};

View File

@ -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}

View File

@ -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 (
<div className="wiki-summary">
<p className="wiki-summary__text">У проекта нет корня вики, поэтому вики не синхронизируется.</p>
<Link to={`/wiki${base}`} className="wiki-summary__link">Привязать корень вики</Link>
</div>
);
}
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 (
<div className="wiki-summary">
<p className="wiki-summary__text">{pages} страниц</p>
<form onSubmit={submit} role="search" className="wiki-summary__search"><SearchBox value={q} onChange={setQ} /></form>
<Link to={`/wiki${base}`} className="wiki-summary__link">Вики</Link>
</div>
);
}

View File

@ -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);

View File

@ -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: <ProjectGate>{(p) => <WikiPage key={p.label} project={p.label} />}</ProjectGate> }],
nav: [{ to: "/wiki", label: "Вики", icon: <IconBook />, padded: true }],
status: wikiStatus,
Summary,
projectFields: [{ name: "wiki_root", label: "Корень вики", hint: "DOC-… или ссылка на страницу Eva", kind: "text" }],
};

View File

@ -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}

View File

@ -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 () => {

View File

@ -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<Pick<HubModule, "status">>;
@ -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 = <span className={`data-status__event${ev ? ` data-status__event--${ev.tone}` : ""}${collapsed ? " visually-hidden" : ""}`}
role="status" title={ev?.text}>{ev?.text}</span>;
const button = action && (
<IconButton label={action} title={collapsed ? [m.title, agentText, freshText, ev?.text].filter(Boolean).join(" · ") : action}
<IconButton label={action} title={collapsed ? [m.title, agentLabel, freshText, ev?.text].filter(Boolean).join(" · ") : action}
disabled={act.busy} aria-busy={act.busy ? "true" : undefined}
icon={act.busy ? <span className="ui-spinner" aria-hidden="true" /> : <IconRefresh />} onClick={() => void act.start()} />);
if (collapsed) {
@ -46,9 +47,9 @@ function ModuleRow({ m, project, variant }: { m: WithStatus; project: Project; v
<div className="data-status" data-module={m.name}>
<div className="data-status__head">
<strong>{m.title}</strong>
{agentText && (
{agentLabel && (
<span className="data-status__agent" title={agentHint}>
<span className={"data-status__dot" + (online ? " is-online" : "")} aria-hidden="true" />{agentText}
<span className={"data-status__dot" + (online ? " is-online" : "")} aria-hidden="true" />{agentLabel}
</span>)}
{button}
</div>

View File

@ -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<string, unknown> = {}) => ({ 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(<App router={router} />);
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();
});
});

View File

@ -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<BatchStatus, { text: string; tone: "neutral" | "success" | "warning" | "info" }> = {
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 (
<div data-testid="page-overview">
<PageHeader title="Обзор" />
<Panel as="section" padding="lg" className="overview-card" aria-label={m.title} data-module={m.name}>
<div className="overview-card__head">
<h2 className="overview-card__title">{m.title}</h2>
{shown && a && (
<span className="overview-card__agent" title={a.hint}>
<span className={"overview-dot" + (a.online ? " is-online" : "")} aria-hidden="true" />{a.text}
{act.agent?.agent_version != null && <span className="overview-card__version">v{act.agent.agent_version}</span>}
</span>)}
{shown && label && (
<Button variant="secondary" icon={<IconRefresh />} loading={act.busy} onClick={() => void act.start()} className="overview-card__refresh">{label}</Button>)}
</div>
{shown && (
<>
<p className={"overview-card__fresh" + (fresh.error ? " overview-card__fresh--error" : "")}>{freshText}</p>
<p className={`overview-card__event${ev ? ` overview-card__event--${ev.tone}` : ""}`} role="status">{ev?.text}</p>
</>)}
{Summary && <Summary project={project} />}
</Panel>
);
}
function RecentBatches({ project }: { project: string }) {
const batches = useRecentBatches(project);
const moduleTitle = (b: Batch) => MODULES.find((m) => m.name === b.module)?.title ?? b.module ?? "—";
return (
<section className="overview-batches" aria-labelledby="overview-batches-title">
<h2 id="overview-batches-title" className="overview-section-title">Последние пакеты проекта</h2>
{batches.isError ? <Notice tone="error">{batches.error.message}</Notice>
: batches.isPending ? <p className="overview-hint">Загрузка…</p>
: !batches.data.length ? <p className="overview-hint">Пакетов пока не было.</p>
: (
<Panel padding="none" className="overview-table-wrap">
<table className="overview-table">
<thead><tr><th>Пакет</th><th>Модуль</th><th>Название</th><th>Автор</th><th>Статус</th><th>Создан</th></tr></thead>
<tbody>
{batches.data.map((b) => {
const st = STATUS[b.status] ?? { text: b.status, tone: "neutral" as const };
return (
<tr key={b.id}>
<td className="overview-table__num">#{b.id}</td>
<td>{moduleTitle(b)}</td>
<td className="overview-table__title">{b.title}</td>
<td>{b.author}</td>
<td><Badge tone={st.tone}>{st.text}</Badge></td>
<td className="overview-table__num">{fmtStamp(b.created_at)}</td>
</tr>);
})}
</tbody>
</table>
</Panel>)}
</section>
);
}
export function OverviewPage({ project }: { project: Project }) {
return (
<div data-testid="page-overview" className="overview">
<PageHeader title="Обзор" subtitle={`${project.name} · ${project.label}`} />
<div className="overview-grid">{MODULES.map((m) => <ModuleCard key={m.name} m={m} project={project} />)}</div>
<RecentBatches project={project.label} />
</div>
);
}

View File

@ -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,
};
}

View File

@ -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}

View File

@ -6,7 +6,7 @@ import { OverviewPage } from "./OverviewPage.tsx";
// «Обзор» ядра, страницы модулей; Layout рисует их по адресу страницы под окном карточки (design 10); NotFound — последним
export const PAGE_ROUTES: RouteObject[] = [
{ path: "/", element: <ProjectGate>{() => <OverviewPage />}</ProjectGate> },
{ path: "/", element: <ProjectGate>{(p) => <OverviewPage key={p.label} project={p} />}</ProjectGate> },
...MODULES.flatMap((m) => m.pages),
{ path: "*", element: <NotFound /> },
];