From 65434857bb730d28279ab623f1e6e69c80a79a31 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Fri, 25 Sep 2026 01:44:32 +0700 Subject: [PATCH] =?UTF-8?q?feat(web):=20URL-=D1=81=D0=BE=D1=81=D1=82=D0=BE?= =?UTF-8?q?=D1=8F=D0=BD=D0=B8=D0=B5=20=D0=B2=D0=BA=D0=BB=D0=B0=D0=B4=D0=BE?= =?UTF-8?q?=D0=BA/=D0=BF=D0=BE=D0=B8=D1=81=D0=BA=D0=B0/=D1=84=D0=B8=D0=BB?= =?UTF-8?q?=D1=8C=D1=82=D1=80=D0=BE=D0=B2=20=D0=BD=D0=B0=20=D1=81=D1=82?= =?UTF-8?q?=D1=80=D0=B0=D0=BD=D0=B8=D1=86=D0=B0=D1=85=20=D0=B0=D0=B3=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=BE=D0=B2,=20=D1=81=D0=BF=D0=B8=D1=81=D0=BA?= =?UTF-8?q?=D0=BE=D0=B2,=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D0=BB=20=D0=B8?= =?UTF-8?q?=20=D1=81=D1=82=D0=B0=D1=82=D0=B8=D1=81=D1=82=D0=B8=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agents/agent-fleet-data-grid.tsx | 3 + apps/web/src/lib/search-params.ts | 52 ++++++++ apps/web/src/routes/_auth/agents/index.tsx | 112 ++++++++++++------ apps/web/src/routes/_auth/lists/index.tsx | 78 +++++++++--- apps/web/src/routes/_auth/rules/index.tsx | 58 +++++++-- apps/web/src/routes/_auth/stats.tsx | 36 +++++- 6 files changed, 269 insertions(+), 70 deletions(-) create mode 100644 apps/web/src/lib/search-params.ts diff --git a/apps/web/src/components/agents/agent-fleet-data-grid.tsx b/apps/web/src/components/agents/agent-fleet-data-grid.tsx index 02f8fec..4db315a 100644 --- a/apps/web/src/components/agents/agent-fleet-data-grid.tsx +++ b/apps/web/src/components/agents/agent-fleet-data-grid.tsx @@ -56,6 +56,7 @@ export type AgentFleetDataGridProps = { error?: Error | null onRetry?: () => void emptyAction?: ReactNode + filteredEmptyMessage?: string toolbarExtra?: ReactNode /** Bulk-операции: controlled selection пробрасывается в ResourcePage. */ enableRowSelection?: boolean @@ -92,6 +93,7 @@ export function AgentFleetDataGrid({ error, onRetry, emptyAction, + filteredEmptyMessage, toolbarExtra, enableRowSelection, rowSelection, @@ -380,6 +382,7 @@ export function AgentFleetDataGrid({ error={error} onRetry={onRetry} toolbarExtra={toolbarExtra} + filteredEmptyMessage={filteredEmptyMessage} emptyState={{ title: 'Нет агентов', description: diff --git a/apps/web/src/lib/search-params.ts b/apps/web/src/lib/search-params.ts new file mode 100644 index 0000000..c915d2b --- /dev/null +++ b/apps/web/src/lib/search-params.ts @@ -0,0 +1,52 @@ +import type { Filter } from '@/components/reui/filters' + +/** + * Сериализация состояния страниц (таб/поиск/фильтры) в URL search-параметры: + * tab — id вкладки, q — строка поиска, f — JSON-массив фильтров. + * Пустые значения не пишутся в URL (deep-link остаётся чистым). + */ + +export function filtersToSearch(filters: Filter[]): string | undefined { + if (filters.length === 0) return undefined + try { + return JSON.stringify(filters) + } catch { + return undefined + } +} + +export function searchToFilters(raw: unknown): Filter[] { + if (typeof raw !== 'string' || raw.length === 0) return [] + try { + const parsed: unknown = JSON.parse(raw) + if (!Array.isArray(parsed)) return [] + return parsed.filter( + (x): x is Filter => + typeof x === 'object' && + x !== null && + typeof (x as Filter).field === 'string' && + typeof (x as Filter).operator === 'string' && + Array.isArray((x as Filter).values), + ) + } catch { + return [] + } +} + +/** Строка поиска: undefined при пустой, чтобы не писать пустой q в URL. */ +export function searchToQ(raw: unknown): string | undefined { + return typeof raw === 'string' && raw.length > 0 ? raw : undefined +} + +/** Вкладка: валидируется по списку допустимых id. */ +export function searchToTab( + raw: unknown, + allowed: readonly string[], +): string | undefined { + return typeof raw === 'string' && allowed.includes(raw) ? raw : undefined +} + +/** Флаг из search-параметра: только `true` пишется в URL. */ +export function searchToFlag(raw: unknown): boolean | undefined { + return raw === true || raw === '1' ? true : undefined +} diff --git a/apps/web/src/routes/_auth/agents/index.tsx b/apps/web/src/routes/_auth/agents/index.tsx index c69053b..94052e2 100644 --- a/apps/web/src/routes/_auth/agents/index.tsx +++ b/apps/web/src/routes/_auth/agents/index.tsx @@ -49,6 +49,12 @@ import { AgentOnlineDot } from '@/components/agents/agent-online-dot' import { agentsQueryOptions, settingsQueryOptions } from '@/queries' import { apiFetch } from '@/lib/api' import { isAgentOnline } from '@/lib/agent-online' +import { + searchToFilters, + searchToFlag, + searchToQ, + searchToTab, +} from '@/lib/search-params' import { useCan } from '@/lib/permissions' import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard' import { Button } from '@evofw/ui/components/button' @@ -70,24 +76,12 @@ type AgentsSearch = { view: 'cards' | 'table' /** Открыть sheet добавления агента (из ⌘K-палитры). */ add?: boolean + /** URL-состояние: вкладка, поиск, фильтры (deep-link и refresh сохраняют вид). */ + tab?: string + q?: string + f?: Filter[] } -function parseAgentsSearch(search: Record): AgentsSearch { - const view = search.view === 'table' ? 'table' : 'cards' - const agent = - typeof search.agent === 'string' && search.agent.length > 0 - ? search.agent - : undefined - const add = search.add === true - return { view, agent, add } -} - -export const Route = createFileRoute('/_auth/agents/')({ - validateSearch: (search: Record): AgentsSearch => - parseAgentsSearch(search), - component: AgentsPage, -}) - const AGENT_TABS = [ { id: 'all', label: 'Все' }, { id: 'invited', label: 'Приглашённые' }, @@ -96,6 +90,31 @@ const AGENT_TABS = [ { id: 'revoked', label: 'Отозванные' }, ] as const +const AGENT_TAB_IDS = AGENT_TABS.map((t) => t.id) as string[] + +function parseAgentsSearch(search: Record): AgentsSearch { + const view = search.view === 'table' ? 'table' : 'cards' + const agent = typeof search.agent === 'string' && search.agent.length > 0 + ? search.agent + : undefined + const add = searchToFlag(search.add) + return { + view, + agent, + add, + tab: searchToTab(search.tab, AGENT_TAB_IDS), + q: searchToQ(search.q), + f: searchToFilters(search.f), + } +} + +export const Route = createFileRoute('/_auth/agents/')({ + validateSearch: parseAgentsSearch, + component: AgentsPage, +}) + +const EMPTY_FILTERS: Filter[] = [] + /** Список имён для подтверждений: первые 5, дальше «и ещё N». */ function formatNames(names: string[]): string { if (names.length <= 5) return names.join(', ') @@ -116,9 +135,9 @@ function AgentsPage() { const [approveAllOpen, setApproveAllOpen] = useState(false) const [bulkDeleteIds, setBulkDeleteIds] = useState(null) const [rowSelection, setRowSelection] = useState({}) - const [filters, setFilters] = useState([]) - const [searchQuery, setSearchQuery] = useState('') - const [activeTab, setActiveTab] = useState('all') + const activeTab = Route.useSearch().tab ?? 'all' + const searchQuery = Route.useSearch().q ?? '' + const filters = Route.useSearch().f ?? EMPTY_FILTERS const detailOpen = Boolean(detailAgentId) const showQuickActions = settingsQ.data?.show_quick_actions !== 'false' @@ -138,6 +157,19 @@ function AgentsPage() { next.add !== undefined ? next.add || undefined : base.add, + tab: next.tab !== undefined + ? next.tab === 'all' + ? undefined + : next.tab + : base.tab, + q: next.q !== undefined + ? next.q || undefined + : base.q, + f: next.f !== undefined + ? next.f.length > 0 + ? next.f + : undefined + : base.f, } satisfies AgentsSearch }, replace: true, @@ -336,14 +368,21 @@ function AgentsPage() { }).map((card) => ({ ...card, onSelect: () => { - if (card.id === 'pending') setActiveTab('pending') - else if (card.id === 'invited') setActiveTab('invited') - else if (card.id === 'approved') setActiveTab('approved') + if (card.id === 'pending') setSearch({ tab: 'pending' }) + else if (card.id === 'invited') setSearch({ tab: 'invited' }) + else if (card.id === 'approved') setSearch({ tab: 'approved' }) else if (card.id === 'stale') { - setActiveTab('all') - setFilters([ - { id: 'stale', field: 'online', operator: 'is', values: ['offline'] }, - ]) + setSearch({ + tab: 'all', + f: [ + { + id: 'stale', + field: 'online', + operator: 'is', + values: ['offline'], + }, + ], + }) } }, })), @@ -480,7 +519,7 @@ function AgentsPage() { icon: , iconClassName: 'text-warning [&_svg]:text-current', badgeLabel: 'Показать', - onSelect: () => setActiveTab('pending'), + onSelect: () => setSearch({ tab: 'pending' }), }, { id: 'rules', @@ -539,9 +578,8 @@ function AgentsPage() { ) const handleClearFilters = useCallback(() => { - setFilters([]) - setSearchQuery('') - }, []) + setSearch({ f: [], q: '' }) + }, [setSearch]) const handleCopyInstall = useCallback( (curl: string) => { @@ -612,7 +650,7 @@ function AgentsPage() { @@ -667,15 +705,15 @@ function AgentsPage() { data={items} filterFields={filterFields} filters={filters} - onFiltersChange={setFilters} + onFiltersChange={(f) => setSearch({ f })} onClearFilters={handleClearFilters} getFilterFieldValue={getFilterFieldValue} searchQuery={searchQuery} - onSearchChange={setSearchQuery} + onSearchChange={(q) => setSearch({ q })} getSearchText={getSearchText} tabs={[...AGENT_TABS]} activeTab={activeTab} - onTabChange={setActiveTab} + onTabChange={(tab) => setSearch({ tab })} tabFilter={tabFilter} isLoading={agentsQ.isLoading} isError={agentsQ.isError} @@ -703,13 +741,13 @@ function AgentsPage() { setSearch({ tab })} filterFields={filterFields} filters={filters} - onFiltersChange={setFilters} + onFiltersChange={(f) => setSearch({ f })} onClearFilters={handleClearFilters} searchQuery={searchQuery} - onSearchChange={setSearchQuery} + onSearchChange={(q) => setSearch({ q })} toolbarExtra={viewToggle} > t.id) as string[] + +type ListsSearch = { + create?: boolean + tab?: string + q?: string + f?: Filter[] +} + +/** URL-состояние страницы: create/tab/q/f (deep-link и refresh сохраняют вид). */ +function parseListsSearch(search: Record): ListsSearch { + return { + create: searchToFlag(search.create), + tab: searchToTab(search.tab, LIST_TAB_IDS), + q: searchToQ(search.q), + f: searchToFilters(search.f), + } +} + export const Route = createFileRoute('/_auth/lists/')({ - validateSearch: (search: Record) => ({ - /** Открыть sheet создания (из ⌘K-палитры). */ - create: search.create === true || undefined, - }), + validateSearch: parseListsSearch, component: ListsPage, }) @@ -70,22 +92,44 @@ const CREATE_SOURCE_ITEMS = [ * Docs: https://reui.io/blocks */ function ListsPage() { - const navigate = useNavigate() + const navigate = useNavigate({ from: Route.fullPath }) const qc = useQueryClient() const canWrite = useCan()('fw:lists:write') - const createParam = Route.useSearch().create === true + const { create: createParam, q: qParam, f: fParam } = Route.useSearch() + const activeTab = Route.useSearch().tab ?? 'all' + const searchQuery = qParam ?? '' + const filters = fParam ?? [] const [createOpenState, setCreateOpenState] = useState(false) - const createOpen = createOpenState || createParam + const createOpen = createOpenState || createParam === true const [name, setName] = useState('') const [source, setSource] = useState('static') const [extra, setExtra] = useState('') /** Resolved EvoBGP community UUID (Base UI Autocomplete stores label in input). */ const [communityId, setCommunityId] = useState('') - const [filters, setFilters] = useState([]) - const [searchQuery, setSearchQuery] = useState('') - const [activeTab, setActiveTab] = useState('all') const [deleteListId, setDeleteListId] = useState(null) + /** Обновление URL-состояния (tab/q/f) без перезагрузки. */ + const setListSearch = useCallback( + (next: { tab?: string; q?: string; f?: Filter[]; create?: boolean }) => { + void navigate({ + search: (prev) => { + const base = parseListsSearch(prev as Record) + return { + create: + next.create !== undefined + ? next.create || undefined + : base.create, + tab: next.tab !== undefined ? searchToTab(next.tab, LIST_TAB_IDS) : base.tab, + q: next.q !== undefined ? searchToQ(next.q) : base.q, + f: next.f !== undefined ? next.f : base.f, + } + }, + replace: true, + }) + }, + [navigate], + ) + const listsQ = useQuery(listsQueryOptions()) const communitiesQ = useQuery({ ...evobgpCommunitiesQueryOptions(), @@ -135,7 +179,7 @@ function ListsPage() { setExtra('') setCommunityId('') setSource('static') - setCreateOpen(false) + setCreateOpenState(false) void qc.invalidateQueries({ queryKey: ['lists'] }) void navigate({ to: '/lists/$id', params: { id: row.id } }) }, @@ -225,16 +269,16 @@ function ListsPage() { getRowId={(r) => r.id} filterFields={listFilterFields} filters={filters} - onFiltersChange={setFilters} - onClearFilters={() => setFilters([])} + onFiltersChange={(f) => setListSearch({ f })} + onClearFilters={() => setListSearch({ f: [], q: '' })} getFilterFieldValue={getFilterFieldValue} searchQuery={searchQuery} - onSearchChange={setSearchQuery} + onSearchChange={(q) => setListSearch({ q })} searchPlaceholder="Поиск списков…" getSearchText={getSearchText} tabs={[...LIST_TABS]} activeTab={activeTab} - onTabChange={setActiveTab} + onTabChange={(tab) => setListSearch({ tab })} tabFilter={tabFilter} isLoading={listsQ.isLoading} isError={listsQ.isError} @@ -273,7 +317,7 @@ function ListsPage() { onOpenChange={(open) => { setCreateOpenState(open) if (!open && createParam) { - void navigate({ to: '/lists', search: { create: undefined }, replace: true }) + setListSearch({ create: false }) } }} > @@ -380,7 +424,7 @@ function ListsPage() { - ): RulesSearch { + return { + create: searchToFlag(search.create), + q: searchToQ(search.q), + f: searchToFilters(search.f), + } +} + export const Route = createFileRoute('/_auth/rules/')({ - validateSearch: (search: Record) => ({ - /** Открыть sheet создания (из ⌘K-палитры). */ - create: search.create === true || undefined, - }), + validateSearch: parseRulesSearch, component: PolicySetsPage, }) @@ -43,16 +56,37 @@ export const Route = createFileRoute('/_auth/rules/')({ * Create sheet: https://reui.io/preview/base/sheet-8 */ function PolicySetsPage() { - const navigate = useNavigate() + const navigate = useNavigate({ from: Route.fullPath }) const qc = useQueryClient() const setsQ = useQuery(policySetsQueryOptions()) - const createParam = Route.useSearch().create === true + const { create: createParam, q: qParam, f: fParam } = Route.useSearch() + const searchQuery = qParam ?? '' + const filters = fParam ?? [] const [sheetOpenState, setSheetOpenState] = useState(false) - const sheetOpen = sheetOpenState || createParam + const sheetOpen = sheetOpenState || createParam === true + + /** Обновление URL-состояния (q/f) без перезагрузки. */ + const setRulesSearch = useCallback( + (next: { q?: string; f?: Filter[]; create?: boolean }) => { + void navigate({ + search: (prev) => { + const base = parseRulesSearch(prev as Record) + return { + create: + next.create !== undefined + ? next.create || undefined + : base.create, + q: next.q !== undefined ? searchToQ(next.q) : base.q, + f: next.f !== undefined ? next.f : base.f, + } + }, + replace: true, + }) + }, + [navigate], + ) const [name, setName] = useState('') const [description, setDescription] = useState('') - const [filters, setFilters] = useState([]) - const [searchQuery, setSearchQuery] = useState('') const [deleteId, setDeleteId] = useState(null) const create = useMutation({ @@ -231,11 +265,11 @@ function PolicySetsPage() { getRowId={(r) => r.id} filterFields={filterFields} filters={filters} - onFiltersChange={setFilters} - onClearFilters={() => setFilters([])} + onFiltersChange={(f) => setRulesSearch({ f })} + onClearFilters={() => setRulesSearch({ f: [], q: '' })} getFilterFieldValue={getFilterFieldValue} searchQuery={searchQuery} - onSearchChange={setSearchQuery} + onSearchChange={(q) => setRulesSearch({ q })} searchPlaceholder="Поиск наборов…" getSearchText={getSearchText} isLoading={setsQ.isLoading} diff --git a/apps/web/src/routes/_auth/stats.tsx b/apps/web/src/routes/_auth/stats.tsx index 853b817..96eaecf 100644 --- a/apps/web/src/routes/_auth/stats.tsx +++ b/apps/web/src/routes/_auth/stats.tsx @@ -1,4 +1,4 @@ -import { createFileRoute } from '@tanstack/react-router' +import { createFileRoute, useNavigate } from '@tanstack/react-router' import { useQuery } from '@tanstack/react-query' import { useCallback, useMemo, useState } from 'react' import type { ColumnDef } from '@tanstack/react-table' @@ -24,6 +24,7 @@ import { import { UpdatedAtLabel } from '@/components/updated-at-label' import { dashboardQueryOptions, recentStatsQueryOptions } from '@/queries' import { formatNumber, formatStampDateTime } from '@/lib/format' +import { searchToFilters } from '@/lib/search-params' import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' import { ChartContainer, @@ -41,7 +42,19 @@ type StatRow = { recorded_at: string } +type StatsSearch = { + f?: Filter[] +} + +/** URL-состояние страницы: f (deep-link и refresh сохраняют вид). */ +function parseStatsSearch(search: Record): StatsSearch { + return { + f: searchToFilters(search.f), + } +} + export const Route = createFileRoute('/_auth/stats')({ + validateSearch: parseStatsSearch, component: StatsPage, }) @@ -51,9 +64,24 @@ const chartConfig = { } satisfies ChartConfig function StatsPage() { + const navigate = useNavigate({ from: Route.fullPath }) const dash = useQuery(dashboardQueryOptions()) const stats = useQuery(recentStatsQueryOptions()) - const [filters, setFilters] = useState([]) + const filters = Route.useSearch().f ?? [] + + /** Обновление URL-состояния (f) без перезагрузки. */ + const setStatsFilters = useCallback( + (nextFilters: Filter[]) => { + void navigate({ + search: (prev) => ({ + ...parseStatsSearch(prev as Record), + f: nextFilters, + }), + replace: true, + }) + }, + [navigate], + ) /** * Часовая агрегация: пик счётчиков за час. Счётчики агентов смешанной @@ -245,8 +273,8 @@ function StatsPage() { getRowId={(r) => r.id} filterFields={filterFields} filters={filters} - onFiltersChange={setFilters} - onClearFilters={() => setFilters([])} + onFiltersChange={setStatsFilters} + onClearFilters={() => setStatsFilters([])} getFilterFieldValue={getFilterFieldValue} isLoading={stats.isLoading} pageSize={20}