feat(web): URL-состояние вкладок/поиска/фильтров на страницах агентов, списков, правил и статистики

This commit is contained in:
Denozordec
2026-09-25 01:44:32 +07:00
parent 3d4edbad8b
commit 65434857bb
6 changed files with 269 additions and 70 deletions
@@ -56,6 +56,7 @@ export type AgentFleetDataGridProps = {
error?: Error | null error?: Error | null
onRetry?: () => void onRetry?: () => void
emptyAction?: ReactNode emptyAction?: ReactNode
filteredEmptyMessage?: string
toolbarExtra?: ReactNode toolbarExtra?: ReactNode
/** Bulk-операции: controlled selection пробрасывается в ResourcePage. */ /** Bulk-операции: controlled selection пробрасывается в ResourcePage. */
enableRowSelection?: boolean enableRowSelection?: boolean
@@ -92,6 +93,7 @@ export function AgentFleetDataGrid({
error, error,
onRetry, onRetry,
emptyAction, emptyAction,
filteredEmptyMessage,
toolbarExtra, toolbarExtra,
enableRowSelection, enableRowSelection,
rowSelection, rowSelection,
@@ -380,6 +382,7 @@ export function AgentFleetDataGrid({
error={error} error={error}
onRetry={onRetry} onRetry={onRetry}
toolbarExtra={toolbarExtra} toolbarExtra={toolbarExtra}
filteredEmptyMessage={filteredEmptyMessage}
emptyState={{ emptyState={{
title: 'Нет агентов', title: 'Нет агентов',
description: description:
+52
View File
@@ -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
}
+75 -37
View File
@@ -49,6 +49,12 @@ import { AgentOnlineDot } from '@/components/agents/agent-online-dot'
import { agentsQueryOptions, settingsQueryOptions } from '@/queries' import { agentsQueryOptions, settingsQueryOptions } from '@/queries'
import { apiFetch } from '@/lib/api' import { apiFetch } from '@/lib/api'
import { isAgentOnline } from '@/lib/agent-online' import { isAgentOnline } from '@/lib/agent-online'
import {
searchToFilters,
searchToFlag,
searchToQ,
searchToTab,
} from '@/lib/search-params'
import { useCan } from '@/lib/permissions' import { useCan } from '@/lib/permissions'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard' import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { Button } from '@evofw/ui/components/button' import { Button } from '@evofw/ui/components/button'
@@ -70,24 +76,12 @@ type AgentsSearch = {
view: 'cards' | 'table' view: 'cards' | 'table'
/** Открыть sheet добавления агента (из ⌘K-палитры). */ /** Открыть sheet добавления агента (из ⌘K-палитры). */
add?: boolean add?: boolean
/** URL-состояние: вкладка, поиск, фильтры (deep-link и refresh сохраняют вид). */
tab?: string
q?: string
f?: Filter[]
} }
function parseAgentsSearch(search: Record<string, unknown>): 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<string, unknown>): AgentsSearch =>
parseAgentsSearch(search),
component: AgentsPage,
})
const AGENT_TABS = [ const AGENT_TABS = [
{ id: 'all', label: 'Все' }, { id: 'all', label: 'Все' },
{ id: 'invited', label: 'Приглашённые' }, { id: 'invited', label: 'Приглашённые' },
@@ -96,6 +90,31 @@ const AGENT_TABS = [
{ id: 'revoked', label: 'Отозванные' }, { id: 'revoked', label: 'Отозванные' },
] as const ] as const
const AGENT_TAB_IDS = AGENT_TABS.map((t) => t.id) as string[]
function parseAgentsSearch(search: Record<string, unknown>): 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». */ /** Список имён для подтверждений: первые 5, дальше «и ещё N». */
function formatNames(names: string[]): string { function formatNames(names: string[]): string {
if (names.length <= 5) return names.join(', ') if (names.length <= 5) return names.join(', ')
@@ -116,9 +135,9 @@ function AgentsPage() {
const [approveAllOpen, setApproveAllOpen] = useState(false) const [approveAllOpen, setApproveAllOpen] = useState(false)
const [bulkDeleteIds, setBulkDeleteIds] = useState<string[] | null>(null) const [bulkDeleteIds, setBulkDeleteIds] = useState<string[] | null>(null)
const [rowSelection, setRowSelection] = useState<RowSelectionState>({}) const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [filters, setFilters] = useState<Filter[]>([]) const activeTab = Route.useSearch().tab ?? 'all'
const [searchQuery, setSearchQuery] = useState('') const searchQuery = Route.useSearch().q ?? ''
const [activeTab, setActiveTab] = useState('all') const filters = Route.useSearch().f ?? EMPTY_FILTERS
const detailOpen = Boolean(detailAgentId) const detailOpen = Boolean(detailAgentId)
const showQuickActions = settingsQ.data?.show_quick_actions !== 'false' const showQuickActions = settingsQ.data?.show_quick_actions !== 'false'
@@ -138,6 +157,19 @@ function AgentsPage() {
next.add !== undefined next.add !== undefined
? next.add || undefined ? next.add || undefined
: base.add, : 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 } satisfies AgentsSearch
}, },
replace: true, replace: true,
@@ -336,14 +368,21 @@ function AgentsPage() {
}).map((card) => ({ }).map((card) => ({
...card, ...card,
onSelect: () => { onSelect: () => {
if (card.id === 'pending') setActiveTab('pending') if (card.id === 'pending') setSearch({ tab: 'pending' })
else if (card.id === 'invited') setActiveTab('invited') else if (card.id === 'invited') setSearch({ tab: 'invited' })
else if (card.id === 'approved') setActiveTab('approved') else if (card.id === 'approved') setSearch({ tab: 'approved' })
else if (card.id === 'stale') { else if (card.id === 'stale') {
setActiveTab('all') setSearch({
setFilters([ tab: 'all',
{ id: 'stale', field: 'online', operator: 'is', values: ['offline'] }, f: [
]) {
id: 'stale',
field: 'online',
operator: 'is',
values: ['offline'],
},
],
})
} }
}, },
})), })),
@@ -480,7 +519,7 @@ function AgentsPage() {
icon: <Inbox aria-hidden />, icon: <Inbox aria-hidden />,
iconClassName: 'text-warning [&_svg]:text-current', iconClassName: 'text-warning [&_svg]:text-current',
badgeLabel: 'Показать', badgeLabel: 'Показать',
onSelect: () => setActiveTab('pending'), onSelect: () => setSearch({ tab: 'pending' }),
}, },
{ {
id: 'rules', id: 'rules',
@@ -539,9 +578,8 @@ function AgentsPage() {
) )
const handleClearFilters = useCallback(() => { const handleClearFilters = useCallback(() => {
setFilters([]) setSearch({ f: [], q: '' })
setSearchQuery('') }, [setSearch])
}, [])
const handleCopyInstall = useCallback( const handleCopyInstall = useCallback(
(curl: string) => { (curl: string) => {
@@ -612,7 +650,7 @@ function AgentsPage() {
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
onClick={() => setActiveTab('pending')} onClick={() => setSearch({ tab: 'pending' })}
> >
Показать Показать
</Button> </Button>
@@ -667,15 +705,15 @@ function AgentsPage() {
data={items} data={items}
filterFields={filterFields} filterFields={filterFields}
filters={filters} filters={filters}
onFiltersChange={setFilters} onFiltersChange={(f) => setSearch({ f })}
onClearFilters={handleClearFilters} onClearFilters={handleClearFilters}
getFilterFieldValue={getFilterFieldValue} getFilterFieldValue={getFilterFieldValue}
searchQuery={searchQuery} searchQuery={searchQuery}
onSearchChange={setSearchQuery} onSearchChange={(q) => setSearch({ q })}
getSearchText={getSearchText} getSearchText={getSearchText}
tabs={[...AGENT_TABS]} tabs={[...AGENT_TABS]}
activeTab={activeTab} activeTab={activeTab}
onTabChange={setActiveTab} onTabChange={(tab) => setSearch({ tab })}
tabFilter={tabFilter} tabFilter={tabFilter}
isLoading={agentsQ.isLoading} isLoading={agentsQ.isLoading}
isError={agentsQ.isError} isError={agentsQ.isError}
@@ -703,13 +741,13 @@ function AgentsPage() {
<AgentsFleetChrome <AgentsFleetChrome
tabs={countedTabs} tabs={countedTabs}
activeTab={activeTab} activeTab={activeTab}
onTabChange={setActiveTab} onTabChange={(tab) => setSearch({ tab })}
filterFields={filterFields} filterFields={filterFields}
filters={filters} filters={filters}
onFiltersChange={setFilters} onFiltersChange={(f) => setSearch({ f })}
onClearFilters={handleClearFilters} onClearFilters={handleClearFilters}
searchQuery={searchQuery} searchQuery={searchQuery}
onSearchChange={setSearchQuery} onSearchChange={(q) => setSearch({ q })}
toolbarExtra={viewToggle} toolbarExtra={viewToggle}
> >
<AgentCardsGrid <AgentCardsGrid
+61 -17
View File
@@ -16,6 +16,12 @@ import { ConfirmDialog } from '@/components/confirm-dialog'
import { listsQueryOptions, evobgpCommunitiesQueryOptions } from '@/queries' import { listsQueryOptions, evobgpCommunitiesQueryOptions } from '@/queries'
import { apiFetch } from '@/lib/api' import { apiFetch } from '@/lib/api'
import { useCan } from '@/lib/permissions' import { useCan } from '@/lib/permissions'
import {
searchToFilters,
searchToFlag,
searchToQ,
searchToTab,
} from '@/lib/search-params'
import { import {
Autocomplete, Autocomplete,
AutocompleteContent, AutocompleteContent,
@@ -46,11 +52,27 @@ import {
} from '@evofw/ui/components/sheet' } from '@evofw/ui/components/sheet'
import { guessListSourceFromInput } from '@evofw/shared' import { guessListSourceFromInput } from '@evofw/shared'
const LIST_TAB_IDS = LIST_TABS.map((t) => 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<string, unknown>): 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/')({ export const Route = createFileRoute('/_auth/lists/')({
validateSearch: (search: Record<string, unknown>) => ({ validateSearch: parseListsSearch,
/** Открыть sheet создания (из ⌘K-палитры). */
create: search.create === true || undefined,
}),
component: ListsPage, component: ListsPage,
}) })
@@ -70,22 +92,44 @@ const CREATE_SOURCE_ITEMS = [
* Docs: https://reui.io/blocks * Docs: https://reui.io/blocks
*/ */
function ListsPage() { function ListsPage() {
const navigate = useNavigate() const navigate = useNavigate({ from: Route.fullPath })
const qc = useQueryClient() const qc = useQueryClient()
const canWrite = useCan()('fw:lists:write') 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 [createOpenState, setCreateOpenState] = useState(false)
const createOpen = createOpenState || createParam const createOpen = createOpenState || createParam === true
const [name, setName] = useState('') const [name, setName] = useState('')
const [source, setSource] = useState<CreateSource>('static') const [source, setSource] = useState<CreateSource>('static')
const [extra, setExtra] = useState('') const [extra, setExtra] = useState('')
/** Resolved EvoBGP community UUID (Base UI Autocomplete stores label in input). */ /** Resolved EvoBGP community UUID (Base UI Autocomplete stores label in input). */
const [communityId, setCommunityId] = useState('') const [communityId, setCommunityId] = useState('')
const [filters, setFilters] = useState<Filter[]>([])
const [searchQuery, setSearchQuery] = useState('')
const [activeTab, setActiveTab] = useState('all')
const [deleteListId, setDeleteListId] = useState<string | null>(null) const [deleteListId, setDeleteListId] = useState<string | null>(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<string, unknown>)
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 listsQ = useQuery(listsQueryOptions())
const communitiesQ = useQuery({ const communitiesQ = useQuery({
...evobgpCommunitiesQueryOptions(), ...evobgpCommunitiesQueryOptions(),
@@ -135,7 +179,7 @@ function ListsPage() {
setExtra('') setExtra('')
setCommunityId('') setCommunityId('')
setSource('static') setSource('static')
setCreateOpen(false) setCreateOpenState(false)
void qc.invalidateQueries({ queryKey: ['lists'] }) void qc.invalidateQueries({ queryKey: ['lists'] })
void navigate({ to: '/lists/$id', params: { id: row.id } }) void navigate({ to: '/lists/$id', params: { id: row.id } })
}, },
@@ -225,16 +269,16 @@ function ListsPage() {
getRowId={(r) => r.id} getRowId={(r) => r.id}
filterFields={listFilterFields} filterFields={listFilterFields}
filters={filters} filters={filters}
onFiltersChange={setFilters} onFiltersChange={(f) => setListSearch({ f })}
onClearFilters={() => setFilters([])} onClearFilters={() => setListSearch({ f: [], q: '' })}
getFilterFieldValue={getFilterFieldValue} getFilterFieldValue={getFilterFieldValue}
searchQuery={searchQuery} searchQuery={searchQuery}
onSearchChange={setSearchQuery} onSearchChange={(q) => setListSearch({ q })}
searchPlaceholder="Поиск списков…" searchPlaceholder="Поиск списков…"
getSearchText={getSearchText} getSearchText={getSearchText}
tabs={[...LIST_TABS]} tabs={[...LIST_TABS]}
activeTab={activeTab} activeTab={activeTab}
onTabChange={setActiveTab} onTabChange={(tab) => setListSearch({ tab })}
tabFilter={tabFilter} tabFilter={tabFilter}
isLoading={listsQ.isLoading} isLoading={listsQ.isLoading}
isError={listsQ.isError} isError={listsQ.isError}
@@ -273,7 +317,7 @@ function ListsPage() {
onOpenChange={(open) => { onOpenChange={(open) => {
setCreateOpenState(open) setCreateOpenState(open)
if (!open && createParam) { if (!open && createParam) {
void navigate({ to: '/lists', search: { create: undefined }, replace: true }) setListSearch({ create: false })
} }
}} }}
> >
@@ -380,7 +424,7 @@ function ListsPage() {
</div> </div>
</ScrollArea> </ScrollArea>
<SheetFooter className="mt-0 shrink-0 flex-row flex-wrap gap-2 border-t"> <SheetFooter className="mt-0 shrink-0 flex-row flex-wrap gap-2 border-t">
<Button variant="outline" onClick={() => setCreateOpen(false)}> <Button variant="outline" onClick={() => setCreateOpenState(false)}>
Отмена Отмена
</Button> </Button>
<LoadingButton <LoadingButton
+46 -12
View File
@@ -5,6 +5,7 @@ import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner' import { toast } from 'sonner'
import type { Filter, FilterFieldConfig } from '@/components/reui/filters' import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
import { searchToFilters, searchToFlag, searchToQ } from '@/lib/search-params'
import { PageHeader, PageShell, ResourcePage } from '@/components/reui-kit' import { PageHeader, PageShell, ResourcePage } from '@/components/reui-kit'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { DataGridPrimaryCell } from '@/components/data-grid-cell' import { DataGridPrimaryCell } from '@/components/data-grid-cell'
@@ -28,11 +29,23 @@ import {
} from '@evofw/ui/components/sheet' } from '@evofw/ui/components/sheet'
import type { PolicySet } from '@evofw/shared' import type { PolicySet } from '@evofw/shared'
type RulesSearch = {
create?: boolean
q?: string
f?: Filter[]
}
/** URL-состояние страницы: create/q/f (deep-link и refresh сохраняют вид). */
function parseRulesSearch(search: Record<string, unknown>): RulesSearch {
return {
create: searchToFlag(search.create),
q: searchToQ(search.q),
f: searchToFilters(search.f),
}
}
export const Route = createFileRoute('/_auth/rules/')({ export const Route = createFileRoute('/_auth/rules/')({
validateSearch: (search: Record<string, unknown>) => ({ validateSearch: parseRulesSearch,
/** Открыть sheet создания (из ⌘K-палитры). */
create: search.create === true || undefined,
}),
component: PolicySetsPage, component: PolicySetsPage,
}) })
@@ -43,16 +56,37 @@ export const Route = createFileRoute('/_auth/rules/')({
* Create sheet: https://reui.io/preview/base/sheet-8 * Create sheet: https://reui.io/preview/base/sheet-8
*/ */
function PolicySetsPage() { function PolicySetsPage() {
const navigate = useNavigate() const navigate = useNavigate({ from: Route.fullPath })
const qc = useQueryClient() const qc = useQueryClient()
const setsQ = useQuery(policySetsQueryOptions()) 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 [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<string, unknown>)
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 [name, setName] = useState('')
const [description, setDescription] = useState('') const [description, setDescription] = useState('')
const [filters, setFilters] = useState<Filter[]>([])
const [searchQuery, setSearchQuery] = useState('')
const [deleteId, setDeleteId] = useState<string | null>(null) const [deleteId, setDeleteId] = useState<string | null>(null)
const create = useMutation({ const create = useMutation({
@@ -231,11 +265,11 @@ function PolicySetsPage() {
getRowId={(r) => r.id} getRowId={(r) => r.id}
filterFields={filterFields} filterFields={filterFields}
filters={filters} filters={filters}
onFiltersChange={setFilters} onFiltersChange={(f) => setRulesSearch({ f })}
onClearFilters={() => setFilters([])} onClearFilters={() => setRulesSearch({ f: [], q: '' })}
getFilterFieldValue={getFilterFieldValue} getFilterFieldValue={getFilterFieldValue}
searchQuery={searchQuery} searchQuery={searchQuery}
onSearchChange={setSearchQuery} onSearchChange={(q) => setRulesSearch({ q })}
searchPlaceholder="Поиск наборов…" searchPlaceholder="Поиск наборов…"
getSearchText={getSearchText} getSearchText={getSearchText}
isLoading={setsQ.isLoading} isLoading={setsQ.isLoading}
+32 -4
View File
@@ -1,4 +1,4 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useCallback, useMemo, useState } from 'react' import { useCallback, useMemo, useState } from 'react'
import type { ColumnDef } from '@tanstack/react-table' import type { ColumnDef } from '@tanstack/react-table'
@@ -24,6 +24,7 @@ import {
import { UpdatedAtLabel } from '@/components/updated-at-label' import { UpdatedAtLabel } from '@/components/updated-at-label'
import { dashboardQueryOptions, recentStatsQueryOptions } from '@/queries' import { dashboardQueryOptions, recentStatsQueryOptions } from '@/queries'
import { formatNumber, formatStampDateTime } from '@/lib/format' import { formatNumber, formatStampDateTime } from '@/lib/format'
import { searchToFilters } from '@/lib/search-params'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { import {
ChartContainer, ChartContainer,
@@ -41,7 +42,19 @@ type StatRow = {
recorded_at: string recorded_at: string
} }
type StatsSearch = {
f?: Filter[]
}
/** URL-состояние страницы: f (deep-link и refresh сохраняют вид). */
function parseStatsSearch(search: Record<string, unknown>): StatsSearch {
return {
f: searchToFilters(search.f),
}
}
export const Route = createFileRoute('/_auth/stats')({ export const Route = createFileRoute('/_auth/stats')({
validateSearch: parseStatsSearch,
component: StatsPage, component: StatsPage,
}) })
@@ -51,9 +64,24 @@ const chartConfig = {
} satisfies ChartConfig } satisfies ChartConfig
function StatsPage() { function StatsPage() {
const navigate = useNavigate({ from: Route.fullPath })
const dash = useQuery(dashboardQueryOptions()) const dash = useQuery(dashboardQueryOptions())
const stats = useQuery(recentStatsQueryOptions()) const stats = useQuery(recentStatsQueryOptions())
const [filters, setFilters] = useState<Filter[]>([]) const filters = Route.useSearch().f ?? []
/** Обновление URL-состояния (f) без перезагрузки. */
const setStatsFilters = useCallback(
(nextFilters: Filter[]) => {
void navigate({
search: (prev) => ({
...parseStatsSearch(prev as Record<string, unknown>),
f: nextFilters,
}),
replace: true,
})
},
[navigate],
)
/** /**
* Часовая агрегация: пик счётчиков за час. Счётчики агентов смешанной * Часовая агрегация: пик счётчиков за час. Счётчики агентов смешанной
@@ -245,8 +273,8 @@ function StatsPage() {
getRowId={(r) => r.id} getRowId={(r) => r.id}
filterFields={filterFields} filterFields={filterFields}
filters={filters} filters={filters}
onFiltersChange={setFilters} onFiltersChange={setStatsFilters}
onClearFilters={() => setFilters([])} onClearFilters={() => setStatsFilters([])}
getFilterFieldValue={getFilterFieldValue} getFilterFieldValue={getFilterFieldValue}
isLoading={stats.isLoading} isLoading={stats.isLoading}
pageSize={20} pageSize={20}