feat(web-modules): страница «Обзор» со сводками модулей и последними пакетами
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
861e70e7aa
commit
fcb6502d8c
@ -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=` и открывший страницу, теряет в адресе строку поиска (в поле она остаётся) |
|
||||
|
||||
@ -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 ?? []),
|
||||
});
|
||||
|
||||
36
web/src/modules/jira/Summary.tsx
Normal file
36
web/src/modules/jira/Summary.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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" }],
|
||||
};
|
||||
|
||||
5
web/src/modules/jira/summary.css
Normal file
5
web/src/modules/jira/summary.css
Normal 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}
|
||||
31
web/src/modules/wiki/Summary.tsx
Normal file
31
web/src/modules/wiki/Summary.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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" }],
|
||||
};
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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>
|
||||
|
||||
113
web/src/shell/OverviewPage.test.tsx
Normal file
113
web/src/shell/OverviewPage.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
11
web/src/shell/activity/agentText.ts
Normal file
11
web/src/shell/activity/agentText.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
27
web/src/shell/overview.css
Normal file
27
web/src/shell/overview.css
Normal 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}
|
||||
@ -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 /> },
|
||||
];
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user