feat(web): refactor agent detail and agents page for improved navigation and layout
- Updated the AgentDetailSheet component to enhance layout and integrate a new detail view for agents, improving user experience. - Refactored the agents page to support a toggle between card and table views, allowing for better organization and accessibility of agent information. - Adjusted routing for agent links to utilize search parameters, streamlining navigation to specific agent details. - Removed unused imports and optimized component structure for better maintainability. These changes contribute to a more intuitive and user-friendly interface across the application.
This commit is contained in:
@@ -1,285 +1,15 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { useRef, useState } from 'react'
|
||||
import {
|
||||
BanIcon,
|
||||
CheckCircle2Icon,
|
||||
CircleAlertIcon,
|
||||
ClockIcon,
|
||||
Copy,
|
||||
CopyPlusIcon,
|
||||
CpuIcon,
|
||||
MoreHorizontalIcon,
|
||||
ShieldPlusIcon,
|
||||
TerminalIcon,
|
||||
} from 'lucide-react'
|
||||
import { DetailPanel, PageShell } from '@/components/reui-kit'
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from '@/components/reui/alert'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import {
|
||||
AgentPlatformIcon,
|
||||
platformLabel,
|
||||
} from '@/components/agents/agent-platform-icon'
|
||||
import { AgentPolicySetsSortable } from '@/components/agents/agent-policy-sets-sortable'
|
||||
import { AgentPolicyTrace } from '@/components/agents/agent-policy-trace'
|
||||
import { AgentFactsPanel } from '@/components/agents/agent-facts-panel'
|
||||
import { AgentEffectiveCidrs } from '@/components/agents/agent-effective-cidrs'
|
||||
import {
|
||||
AgentCloneSetsSheet,
|
||||
AgentOverrideSheet,
|
||||
} from '@/components/agents/agent-settings-sheets'
|
||||
import {
|
||||
agentPreviewQueryOptions,
|
||||
agentQueryOptions,
|
||||
} from '@/queries'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import { Skeleton } from '@evofw/ui/components/skeleton'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@evofw/ui/components/dropdown-menu'
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||
|
||||
/**
|
||||
* Agent detail — SA3 layout DNA (Header + Trace 2/3 + Facts 1/3).
|
||||
* Preview: https://reui.io/preview/base/solution-agents-3
|
||||
* · https://reui.io/preview/base/stats-12
|
||||
* Docs: https://reui.io/blocks/solutions/agents
|
||||
* Legacy deep-link → list + Sheet (`?agent=`).
|
||||
* Full UI: AgentDetailSheet on /agents (SA3 / inventory-9).
|
||||
*/
|
||||
|
||||
export const Route = createFileRoute('/_auth/agents/$id')({
|
||||
loader: async ({ context: { queryClient }, params }) => {
|
||||
const agent = await queryClient.ensureQueryData(
|
||||
agentQueryOptions(params.id),
|
||||
)
|
||||
void queryClient.ensureQueryData(agentPreviewQueryOptions(params.id))
|
||||
return { breadcrumb: agent.name }
|
||||
beforeLoad: ({ params }) => {
|
||||
throw redirect({
|
||||
to: '/agents',
|
||||
search: { agent: params.id, view: 'cards' },
|
||||
})
|
||||
},
|
||||
component: AgentDetailPage,
|
||||
})
|
||||
|
||||
function AgentDetailPage() {
|
||||
const { id } = Route.useParams()
|
||||
const qc = useQueryClient()
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
const agentQ = useQuery(agentQueryOptions(id))
|
||||
const previewQ = useQuery(agentPreviewQueryOptions(id))
|
||||
const installRef = useRef<HTMLDivElement>(null)
|
||||
const [overrideOpen, setOverrideOpen] = useState(false)
|
||||
const [cloneOpen, setCloneOpen] = useState(false)
|
||||
|
||||
const revoke = useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch(`/api/v1/agents/${id}/revoke`, { method: 'POST' }),
|
||||
onSuccess: () => {
|
||||
toast.success('Агент отозван')
|
||||
void qc.invalidateQueries({ queryKey: ['agents'] })
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const approve = useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch(`/api/v1/agents/${id}/approve`, { method: 'POST' }),
|
||||
onSuccess: () => {
|
||||
toast.success('Агент одобрен')
|
||||
void qc.invalidateQueries({ queryKey: ['agents'] })
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const a = agentQ.data
|
||||
|
||||
if (agentQ.isLoading || !a) {
|
||||
return (
|
||||
<PageShell>
|
||||
<DetailPanel>
|
||||
<DetailPanel.Header title="Агент" description="Загрузка…" />
|
||||
</DetailPanel>
|
||||
<Skeleton className="h-40 w-full rounded-xl" />
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
const headerDesc = [
|
||||
a.hostname,
|
||||
platformLabel(a.platform),
|
||||
`gen ${a.policy_generation}`,
|
||||
a.default_action === 'drop' ? 'default Drop' : 'default Accept',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<DetailPanel>
|
||||
<DetailPanel.Header
|
||||
title={a.name}
|
||||
description={headerDesc}
|
||||
actions={
|
||||
<>
|
||||
<AgentPlatformIcon platform={a.platform} />
|
||||
<StatusBadge status={a.status} />
|
||||
{a.status === 'pending' ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => approve.mutate()}
|
||||
disabled={approve.isPending}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
) : null}
|
||||
{a.status === 'approved' ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => revoke.mutate()}
|
||||
disabled={revoke.isPending}
|
||||
>
|
||||
Revoke
|
||||
</Button>
|
||||
) : null}
|
||||
{a.install_curl ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
copyToClipboard(a.install_curl!)
|
||||
toast.success('Скопировано')
|
||||
}}
|
||||
>
|
||||
<Copy data-icon="inline-start" />
|
||||
Install
|
||||
</Button>
|
||||
) : null}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button variant="outline" size="icon-sm" aria-label="Ещё" />
|
||||
}
|
||||
>
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setOverrideOpen(true)}>
|
||||
<ShieldPlusIcon className="size-4" />
|
||||
IP override
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setCloneOpen(true)}>
|
||||
<CopyPlusIcon className="size-4" />
|
||||
Копировать наборы
|
||||
</DropdownMenuItem>
|
||||
{a.install_curl ? (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
copyToClipboard(a.install_curl!)
|
||||
toast.success('Скопировано')
|
||||
installRef.current?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}}
|
||||
>
|
||||
<TerminalIcon className="size-4" />
|
||||
Install curl
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
render={<Link to="/agents" />}
|
||||
>
|
||||
К списку
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{a.last_apply_error ? (
|
||||
<Alert variant="destructive">
|
||||
<CircleAlertIcon />
|
||||
<AlertTitle>Ошибка apply</AlertTitle>
|
||||
<AlertDescription>{a.last_apply_error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<DetailPanel.Metrics
|
||||
cards={[
|
||||
{
|
||||
id: 'dropped',
|
||||
icon: <BanIcon aria-hidden />,
|
||||
iconClassName: 'text-warning',
|
||||
label: 'Dropped',
|
||||
description: String(a.last_apply_packets_dropped ?? 0),
|
||||
hint: 'сумма counters',
|
||||
variant: 'warning',
|
||||
},
|
||||
{
|
||||
id: 'accepted',
|
||||
icon: <CheckCircle2Icon aria-hidden />,
|
||||
iconClassName: 'text-success',
|
||||
label: 'Accepted',
|
||||
description: String(a.last_apply_packets_accepted ?? 0),
|
||||
hint: 'сумма counters',
|
||||
},
|
||||
{
|
||||
id: 'kernel',
|
||||
icon: <CpuIcon aria-hidden />,
|
||||
iconClassName: 'text-info',
|
||||
label: 'Kernel',
|
||||
description: a.last_apply_kernel_method ?? '—',
|
||||
},
|
||||
{
|
||||
id: 'apply',
|
||||
icon: <ClockIcon aria-hidden />,
|
||||
iconClassName: 'text-primary',
|
||||
label: 'Last apply',
|
||||
description: a.last_apply_at ?? '—',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<DetailPanel.Section>
|
||||
<div className="@container flex flex-col gap-4">
|
||||
<div className="grid gap-4 @4xl:grid-cols-3">
|
||||
<div className="@4xl:col-span-2">
|
||||
<AgentPolicyTrace
|
||||
preview={previewQ.data}
|
||||
isLoading={previewQ.isLoading}
|
||||
/>
|
||||
</div>
|
||||
<AgentFactsPanel agent={a} />
|
||||
</div>
|
||||
|
||||
<div ref={installRef}>
|
||||
<AgentPolicySetsSortable agentId={id} />
|
||||
</div>
|
||||
|
||||
<AgentEffectiveCidrs
|
||||
preview={previewQ.data}
|
||||
isLoading={previewQ.isLoading}
|
||||
/>
|
||||
</div>
|
||||
</DetailPanel.Section>
|
||||
</DetailPanel>
|
||||
|
||||
<AgentOverrideSheet
|
||||
agentId={id}
|
||||
open={overrideOpen}
|
||||
onOpenChange={setOverrideOpen}
|
||||
/>
|
||||
<AgentCloneSetsSheet
|
||||
agentId={id}
|
||||
open={cloneOpen}
|
||||
onOpenChange={setCloneOpen}
|
||||
/>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
@@ -7,10 +7,12 @@ import {
|
||||
CircleAlertIcon,
|
||||
FilterIcon,
|
||||
Inbox,
|
||||
LayoutGridIcon,
|
||||
ListIcon,
|
||||
Plus,
|
||||
SearchIcon,
|
||||
ShieldIcon,
|
||||
TableIcon,
|
||||
UserPlus,
|
||||
WifiOff,
|
||||
} from 'lucide-react'
|
||||
@@ -40,12 +42,14 @@ import { CountedLineTabs } from '@/components/counted-line-tabs'
|
||||
import { AddAgentSheet } from '@/components/agents/add-agent-sheet'
|
||||
import { AgentCardsGrid } from '@/components/agents/agent-cards-grid'
|
||||
import { AgentDetailSheet } from '@/components/agents/agent-detail-sheet'
|
||||
import { AgentFleetDataGrid } from '@/components/agents/agent-fleet-data-grid'
|
||||
import {
|
||||
computeFleetCounts,
|
||||
fleetKpiCards,
|
||||
} from '@/components/agents/agents-fleet-kpis'
|
||||
import { agentsQueryOptions } from '@/queries'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import {
|
||||
InputGroup,
|
||||
@@ -53,14 +57,36 @@ import {
|
||||
InputGroupInput,
|
||||
} from '@evofw/ui/components/input-group'
|
||||
import { Separator } from '@evofw/ui/components/separator'
|
||||
import {
|
||||
ToggleGroup,
|
||||
ToggleGroupItem,
|
||||
} from '@evofw/ui/components/toggle-group'
|
||||
import type { Agent } from '@evofw/shared'
|
||||
|
||||
/**
|
||||
* Agents ops console — card catalog + detail Sheet.
|
||||
* Preview: https://reui.io/preview/base/solution-agents-1 · card-3 · stats-12 · c-sheet-1
|
||||
* Agents ops console — cards/table toggle + full detail Sheet.
|
||||
* Preview: https://reui.io/preview/base/solution-agents-1 · card-3 · stats-12
|
||||
* · https://reui.io/preview/base/data-grid-filtering-2 · solution-agents-2
|
||||
* · https://reui.io/preview/base/solution-inventory-9 · solution-agents-3
|
||||
*/
|
||||
|
||||
type AgentsSearch = {
|
||||
agent?: string
|
||||
view: 'cards' | 'table'
|
||||
}
|
||||
|
||||
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
|
||||
return { view, agent }
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/_auth/agents/')({
|
||||
validateSearch: (search: Record<string, unknown>): AgentsSearch =>
|
||||
parseAgentsSearch(search),
|
||||
component: AgentsPage,
|
||||
})
|
||||
|
||||
@@ -73,14 +99,46 @@ const AGENT_TABS = [
|
||||
] as const
|
||||
|
||||
function AgentsPage() {
|
||||
const navigate = useNavigate({ from: Route.fullPath })
|
||||
const { agent: detailAgentId, view } = Route.useSearch()
|
||||
const qc = useQueryClient()
|
||||
const agentsQ = useQuery(agentsQueryOptions())
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [filters, setFilters] = useState<Filter[]>([])
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [activeTab, setActiveTab] = useState('all')
|
||||
const [detailAgentId, setDetailAgentId] = useState<string | null>(null)
|
||||
const [detailOpen, setDetailOpen] = useState(false)
|
||||
|
||||
const detailOpen = Boolean(detailAgentId)
|
||||
|
||||
const setSearch = useCallback(
|
||||
(next: Partial<AgentsSearch>) => {
|
||||
void navigate({
|
||||
search: (prev) => {
|
||||
const base = parseAgentsSearch(prev as Record<string, unknown>)
|
||||
return {
|
||||
view: next.view ?? base.view,
|
||||
agent:
|
||||
next.agent !== undefined
|
||||
? next.agent || undefined
|
||||
: base.agent,
|
||||
} satisfies AgentsSearch
|
||||
},
|
||||
replace: true,
|
||||
})
|
||||
},
|
||||
[navigate],
|
||||
)
|
||||
|
||||
const approve = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiFetch(`/api/v1/agents/${id}/approve`, { method: 'POST' }),
|
||||
onSuccess: () => {
|
||||
toast.success('Агент одобрен')
|
||||
void qc.invalidateQueries({ queryKey: ['agents'] })
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const approveAllPending = useMutation({
|
||||
mutationFn: async (ids: string[]) => {
|
||||
@@ -177,6 +235,14 @@ function AgentsPage() {
|
||||
[searchQuery],
|
||||
)
|
||||
|
||||
const getSearchText = useCallback(
|
||||
(item: Agent) =>
|
||||
[item.name, item.hostname ?? '', item.last_seen_ip ?? '']
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
[],
|
||||
)
|
||||
|
||||
const tabFilter = useCallback((item: Agent, tabId: string) => {
|
||||
if (tabId === 'all') return true
|
||||
return item.status === tabId
|
||||
@@ -257,16 +323,50 @@ function AgentsPage() {
|
||||
[counts.pending],
|
||||
)
|
||||
|
||||
const handleSelectAgent = useCallback((id: string) => {
|
||||
setDetailAgentId(id)
|
||||
setDetailOpen(true)
|
||||
}, [])
|
||||
const handleSelectAgent = useCallback(
|
||||
(id: string) => {
|
||||
setSearch({ agent: id })
|
||||
},
|
||||
[setSearch],
|
||||
)
|
||||
|
||||
const handleClearFilters = useCallback(() => {
|
||||
setFilters([])
|
||||
setSearchQuery('')
|
||||
}, [])
|
||||
|
||||
const handleCopyInstall = useCallback(
|
||||
(curl: string) => {
|
||||
copyToClipboard(curl)
|
||||
toast.success('Скопировано')
|
||||
},
|
||||
[copyToClipboard],
|
||||
)
|
||||
|
||||
const viewToggle = (
|
||||
<ToggleGroup
|
||||
multiple={false}
|
||||
value={[view]}
|
||||
onValueChange={(next) => {
|
||||
const v = next[0]
|
||||
if (v === 'cards' || v === 'table') {
|
||||
setSearch({ view: v })
|
||||
}
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
spacing={0}
|
||||
aria-label="Вид списка"
|
||||
>
|
||||
<ToggleGroupItem value="cards" aria-label="Карточки">
|
||||
<LayoutGridIcon className="size-4" />
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="table" aria-label="Таблица">
|
||||
<TableIcon className="size-4" />
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
)
|
||||
|
||||
const addButton = (
|
||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||
<Plus data-icon="inline-start" />
|
||||
@@ -329,12 +429,40 @@ function AgentsPage() {
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{agentsQ.isError ? (
|
||||
{view === 'table' ? (
|
||||
<AgentFleetDataGrid
|
||||
data={items}
|
||||
filterFields={filterFields}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
onClearFilters={handleClearFilters}
|
||||
getFilterFieldValue={getFilterFieldValue}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
getSearchText={getSearchText}
|
||||
tabs={[...AGENT_TABS]}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
tabFilter={tabFilter}
|
||||
isLoading={agentsQ.isLoading}
|
||||
isError={agentsQ.isError}
|
||||
error={agentsQ.error}
|
||||
onRetry={() => void agentsQ.refetch()}
|
||||
emptyAction={addButton}
|
||||
toolbarExtra={viewToggle}
|
||||
onSelect={handleSelectAgent}
|
||||
onApprove={(id) => approve.mutate(id)}
|
||||
approvePending={approve.isPending}
|
||||
onCopyInstall={handleCopyInstall}
|
||||
/>
|
||||
) : agentsQ.isError ? (
|
||||
<Alert variant="destructive">
|
||||
<CircleAlertIcon />
|
||||
<AlertTitle>Ошибка загрузки</AlertTitle>
|
||||
<AlertDescription className="flex flex-col gap-2">
|
||||
<span>{agentsQ.error?.message ?? 'Не удалось загрузить данные'}</span>
|
||||
<span>
|
||||
{agentsQ.error?.message ?? 'Не удалось загрузить данные'}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -364,7 +492,11 @@ function AgentsPage() {
|
||||
onChange={setFilters}
|
||||
size="default"
|
||||
trigger={
|
||||
<Button type="button" variant="outline" aria-label="Фильтры">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
aria-label="Фильтры"
|
||||
>
|
||||
<FilterIcon className="size-4" aria-hidden="true" />
|
||||
Фильтры
|
||||
</Button>
|
||||
@@ -382,16 +514,19 @@ function AgentsPage() {
|
||||
/>
|
||||
</InputGroup>
|
||||
</div>
|
||||
{filters.length > 0 || searchQuery ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClearFilters}
|
||||
>
|
||||
Сбросить
|
||||
</Button>
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{viewToggle}
|
||||
{filters.length > 0 || searchQuery ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClearFilters}
|
||||
>
|
||||
Сбросить
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-(--frame-panel-header-px) pb-(--frame-panel-header-py)">
|
||||
<AgentCardsGrid
|
||||
@@ -400,7 +535,9 @@ function AgentsPage() {
|
||||
onSelect={handleSelectAgent}
|
||||
isLoading={agentsQ.isLoading}
|
||||
emptyTitle={
|
||||
items.length === 0 ? 'Нет агентов' : 'Нет записей по фильтрам'
|
||||
items.length === 0
|
||||
? 'Нет агентов'
|
||||
: 'Нет записей по фильтрам'
|
||||
}
|
||||
emptyDescription={
|
||||
items.length === 0
|
||||
@@ -417,11 +554,10 @@ function AgentsPage() {
|
||||
<AddAgentSheet open={createOpen} onOpenChange={setCreateOpen} />
|
||||
|
||||
<AgentDetailSheet
|
||||
agentId={detailAgentId}
|
||||
agentId={detailAgentId ?? null}
|
||||
open={detailOpen}
|
||||
onOpenChange={(open) => {
|
||||
setDetailOpen(open)
|
||||
if (!open) setDetailAgentId(null)
|
||||
if (!open) setSearch({ agent: '' })
|
||||
}}
|
||||
/>
|
||||
</PageShell>
|
||||
|
||||
@@ -161,8 +161,8 @@ function DashboardPage() {
|
||||
<ItemContent className="flex flex-row items-center justify-between gap-2">
|
||||
<ItemTitle className="truncate font-medium">
|
||||
<Link
|
||||
to="/agents/$id"
|
||||
params={{ id: a.id }}
|
||||
to="/agents"
|
||||
search={{ agent: a.id, view: 'cards' }}
|
||||
className="hover:underline"
|
||||
>
|
||||
{a.name}
|
||||
@@ -236,8 +236,8 @@ function DashboardPage() {
|
||||
Pending: <strong>{a.name}</strong>
|
||||
</span>
|
||||
<Link
|
||||
to="/agents/$id"
|
||||
params={{ id: a.id }}
|
||||
to="/agents"
|
||||
search={{ agent: a.id, view: 'cards' }}
|
||||
className="underline-offset-4 hover:underline"
|
||||
>
|
||||
Открыть
|
||||
|
||||
Reference in New Issue
Block a user