Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa475848fd | ||
|
|
5d767e4341 | ||
|
|
f4259246d0 | ||
|
|
269f94edcf | ||
|
|
10b5bbc9cf |
No files matched your search
@@ -0,0 +1,300 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link, useNavigate } from '@tanstack/react-router'
|
||||
import { toast } from 'sonner'
|
||||
import { useState } from 'react'
|
||||
import { ActivityIcon, ArrowLeftIcon, CircleAlertIcon, ClockIcon, CpuIcon, ShieldIcon } from 'lucide-react'
|
||||
import { PageHead, PageTab, PageTabsList } from '@/components/portal/bits'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
|
||||
import { KpiStatGrid } from '@/components/reui-kit'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { platformLabel } from '@/components/agents/agent-platform-icon'
|
||||
import { AgentOnlineDot } from '@/components/agents/agent-online-dot'
|
||||
import { agentTrafficAccepted, agentTrafficDropped } from '@/components/agents/agent-traffic'
|
||||
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 { AgentBlockedIps } from '@/components/agents/agent-blocked-ips'
|
||||
import { AgentBlockedPorts } from '@/components/agents/agent-blocked-ports'
|
||||
import { AgentHostFirewall } from '@/components/agents/agent-host-firewall'
|
||||
import { AgentPortAcl } from '@/components/agents/agent-port-acl'
|
||||
import { AgentCloneSetsSheet, AgentOverrideSheet } from '@/components/agents/agent-settings-sheets'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { agentPreviewQueryOptions, agentQueryOptions } from '@/queries'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { formatDateTime, formatNumber, formatRelativeTime } from '@/lib/format'
|
||||
import { useCan } from '@/lib/permissions'
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import { Skeleton } from '@evofw/ui/components/skeleton'
|
||||
import { Tabs, TabsContent } from '@evofw/ui/components/tabs'
|
||||
|
||||
type Confirm = 'revoke' | 'delete' | 'reset' | null
|
||||
|
||||
/**
|
||||
* Страница агента по доске FwAgentDetail: заголовок с действиями, ошибка применения,
|
||||
* полоса показателей и линейные вкладки «Политика», «Хост-фаервол», «Правила портов», «Заблокированное».
|
||||
*/
|
||||
export function AgentDetailPage({ agentId }: { agentId: string }) {
|
||||
const qc = useQueryClient()
|
||||
const navigate = useNavigate()
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
const canWrite = useCan()('fw:agents:write')
|
||||
const agentQ = useQuery(agentQueryOptions(agentId))
|
||||
const previewQ = useQuery(agentPreviewQueryOptions(agentId))
|
||||
const [tab, setTab] = useState('policy')
|
||||
const [overrideOpen, setOverrideOpen] = useState(false)
|
||||
const [cloneOpen, setCloneOpen] = useState(false)
|
||||
const [confirm, setConfirm] = useState<Confirm>(null)
|
||||
|
||||
const invalidate = () => {
|
||||
void qc.invalidateQueries({ queryKey: ['agents'] })
|
||||
void qc.invalidateQueries({ queryKey: ['agents', agentId] })
|
||||
}
|
||||
const fail = (e: Error) => toast.error(e.message)
|
||||
|
||||
const approve = useMutation({
|
||||
mutationFn: () => apiFetch(`/api/v1/agents/${agentId}/approve`, { method: 'POST' }),
|
||||
onSuccess: () => {
|
||||
toast.success('Агент одобрен')
|
||||
invalidate()
|
||||
},
|
||||
onError: fail,
|
||||
})
|
||||
const revoke = useMutation({
|
||||
mutationFn: () => apiFetch(`/api/v1/agents/${agentId}/revoke`, { method: 'POST' }),
|
||||
onSuccess: () => {
|
||||
toast.success('Агент отозван')
|
||||
invalidate()
|
||||
},
|
||||
onError: fail,
|
||||
})
|
||||
const resetStats = useMutation({
|
||||
mutationFn: () => apiFetch(`/api/v1/agents/${agentId}/stats/reset`, { method: 'POST' }),
|
||||
onSuccess: () => {
|
||||
toast.success('Статистика сброшена')
|
||||
invalidate()
|
||||
for (const key of ['stats', 'blocked-ips', 'blocked-ports']) {
|
||||
void qc.invalidateQueries({ queryKey: ['agents', agentId, key] })
|
||||
}
|
||||
void qc.invalidateQueries({ queryKey: ['stats-recent'] })
|
||||
void qc.invalidateQueries({ queryKey: ['dashboard'] })
|
||||
},
|
||||
onError: fail,
|
||||
})
|
||||
const remove = useMutation({
|
||||
mutationFn: () => apiFetch(`/api/v1/agents/${agentId}`, { method: 'DELETE' }),
|
||||
onSuccess: () => {
|
||||
toast.success('Агент удалён')
|
||||
void qc.invalidateQueries({ queryKey: ['agents'] })
|
||||
void navigate({ to: '/agents', search: { view: 'cards' } })
|
||||
},
|
||||
onError: fail,
|
||||
})
|
||||
|
||||
const a = agentQ.data
|
||||
|
||||
if (agentQ.isError) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<CircleAlertIcon />
|
||||
<AlertTitle>Ошибка загрузки</AlertTitle>
|
||||
<AlertDescription className="flex flex-col gap-2">
|
||||
<span>{agentQ.error?.message ?? 'Не удалось загрузить агента'}</span>
|
||||
<Button type="button" variant="outline" size="sm" className="w-fit" onClick={() => void agentQ.refetch()}>
|
||||
Повторить
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
if (agentQ.isLoading || !a) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Skeleton className="h-10 w-56" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const isLinux = a.platform === 'linux'
|
||||
const sub = [
|
||||
a.hostname,
|
||||
platformLabel(a.platform),
|
||||
`поколение ${a.policy_generation}`,
|
||||
a.default_action === 'drop' ? 'по умолчанию: блокировать' : 'по умолчанию: пропускать',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
|
||||
const confirmCopy: Record<Exclude<Confirm, null>, { title: string; label: string; text: string; run: () => void; pending: boolean }> = {
|
||||
revoke: {
|
||||
title: 'Отозвать агента?',
|
||||
label: 'Отозвать',
|
||||
text: `Агент «${a.name}» потеряет доступ к API управления и перестанет получать обновления политики. Действие нельзя отменить.`,
|
||||
run: () => revoke.mutate(),
|
||||
pending: revoke.isPending,
|
||||
},
|
||||
delete: {
|
||||
title: 'Удалить агента?',
|
||||
label: 'Удалить',
|
||||
text: `Агент «${a.name}» будет удалён вместе с ссылками и статистикой. Это действие нельзя отменить.`,
|
||||
run: () => remove.mutate(),
|
||||
pending: remove.isPending,
|
||||
},
|
||||
reset: {
|
||||
title: 'Сбросить статистику?',
|
||||
label: 'Сбросить',
|
||||
text: `Счётчики пакетов и история статистики агента «${a.name}» будут обнулены и удалены. Действие нельзя отменить.`,
|
||||
run: () => resetStats.mutate(),
|
||||
pending: resetStats.isPending,
|
||||
},
|
||||
}
|
||||
const active = confirm ? confirmCopy[confirm] : null
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" className="-ml-2 w-fit" render={<Link to="/agents" search={{ view: 'cards' }} />} nativeButton={false}>
|
||||
<ArrowLeftIcon aria-hidden /> Агенты
|
||||
</Button>
|
||||
<PageHead
|
||||
title={a.name}
|
||||
sub={sub}
|
||||
actions={
|
||||
<>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<AgentOnlineDot agent={a} />
|
||||
<StatusBadge status={a.status} />
|
||||
</span>
|
||||
{canWrite ? (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setOverrideOpen(true)}>
|
||||
IP override
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setCloneOpen(true)}>
|
||||
Копировать наборы
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
{a.install_curl ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
copyToClipboard(a.install_curl!)
|
||||
toast.success('Команда установки скопирована')
|
||||
}}
|
||||
>
|
||||
Команда установки
|
||||
</Button>
|
||||
) : null}
|
||||
{canWrite && a.status === 'pending' ? (
|
||||
<Button onClick={() => approve.mutate()} disabled={approve.isPending}>
|
||||
Утвердить
|
||||
</Button>
|
||||
) : null}
|
||||
{canWrite && a.status === 'approved' ? (
|
||||
<Button variant="outline" onClick={() => setConfirm('revoke')}>
|
||||
Отозвать
|
||||
</Button>
|
||||
) : null}
|
||||
{canWrite ? (
|
||||
<Button variant="outline" className="text-destructive" onClick={() => setConfirm('delete')}>
|
||||
Удалить агента
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{a.last_apply_error ? (
|
||||
<Alert variant="destructive">
|
||||
<CircleAlertIcon />
|
||||
<AlertTitle>Ошибка применения политики.</AlertTitle>
|
||||
<AlertDescription>{a.last_apply_error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<KpiStatGrid
|
||||
aria-label="Показатели агента"
|
||||
cards={[
|
||||
{
|
||||
id: 'traffic',
|
||||
label: 'Трафик',
|
||||
value: `↓${formatNumber(agentTrafficDropped(a))} · ↑${formatNumber(agentTrafficAccepted(a))}`,
|
||||
hint: 'накопительно',
|
||||
icon: <ActivityIcon aria-hidden />,
|
||||
iconClassName: 'text-warning',
|
||||
variant: 'warning',
|
||||
onClick: canWrite ? () => setConfirm('reset') : undefined,
|
||||
},
|
||||
{ id: 'kernel', label: 'Ядро', value: a.last_apply_kernel_method ?? '—', hint: 'метод применения', icon: <CpuIcon aria-hidden />, iconClassName: 'text-info' },
|
||||
{
|
||||
id: 'apply',
|
||||
label: 'Последнее применение',
|
||||
value: formatDateTime(a.last_apply_at),
|
||||
hint: a.last_apply_at ? formatRelativeTime(a.last_apply_at) : undefined,
|
||||
icon: <ClockIcon aria-hidden />,
|
||||
},
|
||||
{
|
||||
id: 'default',
|
||||
label: 'Если не совпало',
|
||||
value: a.default_action === 'drop' ? 'Drop' : 'Accept',
|
||||
hint: 'default action',
|
||||
icon: <ShieldIcon aria-hidden />,
|
||||
variant: a.default_action === 'drop' ? 'warning' : 'default',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Tabs value={tab} onValueChange={setTab} className="min-w-0 gap-4">
|
||||
<PageTabsList>
|
||||
<PageTab value="policy" label="Политика" />
|
||||
{isLinux ? <PageTab value="host" label="Хост-фаервол" /> : null}
|
||||
{isLinux ? <PageTab value="acl" label="Правила портов" /> : null}
|
||||
<PageTab value="hits" label="Заблокированное" />
|
||||
</PageTabsList>
|
||||
<TabsContent value="policy" className="flex min-w-0 flex-col gap-5">
|
||||
<div className="@container grid gap-5 @4xl:grid-cols-3">
|
||||
<div className="@4xl:col-span-2">
|
||||
<AgentPolicyTrace preview={previewQ.data} isLoading={previewQ.isLoading} />
|
||||
</div>
|
||||
<AgentFactsPanel agent={a} />
|
||||
</div>
|
||||
<AgentPolicySetsSortable agentId={agentId} />
|
||||
<AgentEffectiveCidrs preview={previewQ.data} isLoading={previewQ.isLoading} />
|
||||
</TabsContent>
|
||||
{isLinux ? (
|
||||
<TabsContent value="host" className="min-w-0">
|
||||
<AgentHostFirewall agentId={agentId} />
|
||||
</TabsContent>
|
||||
) : null}
|
||||
{isLinux ? (
|
||||
<TabsContent value="acl" className="min-w-0">
|
||||
<AgentPortAcl agentId={agentId} />
|
||||
</TabsContent>
|
||||
) : null}
|
||||
<TabsContent value="hits" className="flex min-w-0 flex-col gap-5">
|
||||
{isLinux ? <AgentBlockedPorts agentId={agentId} /> : null}
|
||||
<AgentBlockedIps agentId={agentId} platform={a.platform} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<AgentOverrideSheet agentId={agentId} open={overrideOpen} onOpenChange={setOverrideOpen} />
|
||||
<AgentCloneSetsSheet agentId={agentId} open={cloneOpen} onOpenChange={setCloneOpen} />
|
||||
<ConfirmDialog
|
||||
open={confirm !== null}
|
||||
onOpenChange={(o) => !o && setConfirm(null)}
|
||||
title={active?.title ?? ''}
|
||||
confirmLabel={active?.label ?? ''}
|
||||
description={active?.text ?? ''}
|
||||
onConfirm={() => {
|
||||
active?.run()
|
||||
setConfirm(null)
|
||||
}}
|
||||
disabled={active?.pending}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { XIcon } from 'lucide-react'
|
||||
import { AgentDetailView } from '@/components/agents/agent-detail-view'
|
||||
import { agentQueryOptions } from '@/queries'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import { ScrollArea } from '@evofw/ui/components/scroll-area'
|
||||
import {
|
||||
Sheet,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@evofw/ui/components/sheet'
|
||||
import { cn } from '@evofw/ui/lib/utils'
|
||||
|
||||
/**
|
||||
* Wide agent detail Sheet — inventory-9 / CRM-4 shell + SA3 body.
|
||||
* Preview: https://reui.io/preview/base/solution-inventory-9
|
||||
* · https://reui.io/preview/base/solution-crm-4
|
||||
* · https://reui.io/preview/base/solution-agents-3
|
||||
* · https://reui.io/preview/base/components/c-sheet-1
|
||||
*/
|
||||
|
||||
type AgentDetailSheetProps = {
|
||||
agentId: string | null
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onDelete?: (id: string) => void
|
||||
}
|
||||
|
||||
export function AgentDetailSheet({
|
||||
agentId,
|
||||
open,
|
||||
onOpenChange,
|
||||
onDelete,
|
||||
}: AgentDetailSheetProps) {
|
||||
const agentQ = useQuery({
|
||||
...agentQueryOptions(agentId ?? ''),
|
||||
enabled: open && Boolean(agentId),
|
||||
})
|
||||
|
||||
const title = agentQ.data?.name ?? 'Агент'
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
showCloseButton={false}
|
||||
className={cn(
|
||||
// Override default data-[side=right]:sm:max-w-sm from @evofw/ui sheet
|
||||
'sm:max-w-none!',
|
||||
'inset-y-2 right-2 left-auto flex h-[calc(100svh-1rem)] max-w-none',
|
||||
'w-[calc(100vw-1rem)] md:w-[calc(100vw-var(--sidebar-width,240px)-1rem)]',
|
||||
'flex-col gap-0 overflow-hidden rounded-xl p-0 outline-none',
|
||||
)}
|
||||
>
|
||||
<SheetHeader className="shrink-0 gap-0 border-b p-0">
|
||||
<div className="flex min-h-11 items-center justify-between gap-2 px-4">
|
||||
<SheetTitle className="truncate text-sm font-medium leading-5">
|
||||
{title}
|
||||
</SheetTitle>
|
||||
<SheetClose
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Закрыть"
|
||||
className="shrink-0"
|
||||
>
|
||||
<XIcon aria-hidden="true" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<SheetDescription className="sr-only">
|
||||
Политика, apply counters и identity агента
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="min-h-0 flex-1">
|
||||
<ScrollArea className="h-full">
|
||||
{agentId && open ? (
|
||||
<AgentDetailView agentId={agentId} onDelete={onDelete} />
|
||||
) : null}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<SheetFooter className="bg-muted/40 shrink-0 border-t px-4 py-3">
|
||||
<SheetClose
|
||||
render={
|
||||
<Button type="button" variant="outline" className="w-full">
|
||||
Закрыть
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
@@ -1,424 +0,0 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { useRef, useState } from 'react'
|
||||
import {
|
||||
ActivityIcon,
|
||||
CircleAlertIcon,
|
||||
ClockIcon,
|
||||
Copy,
|
||||
CopyPlusIcon,
|
||||
CpuIcon,
|
||||
MoreHorizontalIcon,
|
||||
ShieldPlusIcon,
|
||||
TerminalIcon,
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import { DetailPanel } from '@/components/reui-kit'
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from '@/components/reui/alert'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { AgentPlatformIcon, platformLabel } from '@/components/agents/agent-platform-icon'
|
||||
import { AgentOnlineDot } from '@/components/agents/agent-online-dot'
|
||||
import {
|
||||
agentTrafficAccepted,
|
||||
agentTrafficDropped,
|
||||
} from '@/components/agents/agent-traffic'
|
||||
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 { AgentBlockedIps } from '@/components/agents/agent-blocked-ips'
|
||||
import { AgentBlockedPorts } from '@/components/agents/agent-blocked-ports'
|
||||
import { AgentHostFirewall } from '@/components/agents/agent-host-firewall'
|
||||
import { AgentPortAcl } from '@/components/agents/agent-port-acl'
|
||||
import { CountedLineTabs } from '@/components/counted-line-tabs'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import {
|
||||
AgentCloneSetsSheet,
|
||||
AgentOverrideSheet,
|
||||
} from '@/components/agents/agent-settings-sheets'
|
||||
import {
|
||||
agentPreviewQueryOptions,
|
||||
agentQueryOptions,
|
||||
} from '@/queries'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { formatDateTime, formatRelativeTime } from '@/lib/format'
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import { Skeleton } from '@evofw/ui/components/skeleton'
|
||||
import { TabsContent } from '@evofw/ui/components/tabs'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@evofw/ui/components/dropdown-menu'
|
||||
|
||||
/**
|
||||
* Full agent detail body — SA3 DNA for Sheet (and redirect target).
|
||||
* Preview: https://reui.io/preview/base/solution-agents-3
|
||||
* · https://reui.io/preview/base/stats-12
|
||||
* · https://reui.io/preview/base/form-7
|
||||
*/
|
||||
|
||||
type AgentDetailViewProps = {
|
||||
agentId: string
|
||||
onDelete?: (id: string) => void
|
||||
}
|
||||
|
||||
export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
||||
const qc = useQueryClient()
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
const agentQ = useQuery(agentQueryOptions(agentId))
|
||||
const previewQ = useQuery(agentPreviewQueryOptions(agentId))
|
||||
const installRef = useRef<HTMLDivElement>(null)
|
||||
const [overrideOpen, setOverrideOpen] = useState(false)
|
||||
const [cloneOpen, setCloneOpen] = useState(false)
|
||||
const [revokeOpen, setRevokeOpen] = useState(false)
|
||||
const [resetStatsOpen, setResetStatsOpen] = useState(false)
|
||||
const [fwTab, setFwTab] = useState('host')
|
||||
|
||||
const revoke = useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch(`/api/v1/agents/${agentId}/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/${agentId}/approve`, { method: 'POST' }),
|
||||
onSuccess: () => {
|
||||
toast.success('Агент одобрен')
|
||||
void qc.invalidateQueries({ queryKey: ['agents'] })
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const resetStats = useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch(`/api/v1/agents/${agentId}/stats/reset`, { method: 'POST' }),
|
||||
onSuccess: () => {
|
||||
toast.success('Статистика сброшена')
|
||||
void qc.invalidateQueries({ queryKey: ['agents'] })
|
||||
void qc.invalidateQueries({ queryKey: ['agents', agentId] })
|
||||
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'stats'] })
|
||||
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'blocked-ips'] })
|
||||
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'blocked-ports'] })
|
||||
void qc.invalidateQueries({ queryKey: ['stats-recent'] })
|
||||
void qc.invalidateQueries({ queryKey: ['dashboard'] })
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const a = agentQ.data
|
||||
|
||||
if (agentQ.isLoading || !a) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-4">
|
||||
<Skeleton className="h-10 w-56" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (agentQ.isError) {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<Alert variant="destructive">
|
||||
<CircleAlertIcon />
|
||||
<AlertTitle>Ошибка загрузки</AlertTitle>
|
||||
<AlertDescription className="flex flex-col gap-2">
|
||||
<span>
|
||||
{agentQ.error?.message ?? 'Не удалось загрузить агента'}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-fit"
|
||||
onClick={() => void agentQ.refetch()}
|
||||
>
|
||||
Повторить
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const headerDesc = [
|
||||
a.hostname,
|
||||
platformLabel(a.platform),
|
||||
`поколение ${a.policy_generation}`,
|
||||
a.default_action === 'drop' ? 'по умолчанию: блокировать' : 'по умолчанию: пропускать',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-4 p-4">
|
||||
<DetailPanel>
|
||||
<DetailPanel.Header
|
||||
title={a.name}
|
||||
description={headerDesc}
|
||||
actions={
|
||||
<>
|
||||
<AgentPlatformIcon platform={a.platform} />
|
||||
<span className="flex items-center gap-1.5">
|
||||
<AgentOnlineDot agent={a} />
|
||||
<StatusBadge status={a.status} />
|
||||
</span>
|
||||
<Badge
|
||||
variant={
|
||||
a.default_action === 'drop'
|
||||
? 'warning-light'
|
||||
: 'success-light'
|
||||
}
|
||||
size="sm"
|
||||
radius="full"
|
||||
>
|
||||
{a.default_action === 'drop' ? 'Drop' : 'Accept'}
|
||||
</Badge>
|
||||
{a.status === 'pending' ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => approve.mutate()}
|
||||
disabled={approve.isPending}
|
||||
>
|
||||
Утвердить
|
||||
</Button>
|
||||
) : null}
|
||||
{a.status === 'approved' ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setRevokeOpen(true)}
|
||||
disabled={revoke.isPending}
|
||||
>
|
||||
Отозвать
|
||||
</Button>
|
||||
) : null}
|
||||
{a.install_curl ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
copyToClipboard(a.install_curl!)
|
||||
toast.success('Команда установки скопирована')
|
||||
}}
|
||||
>
|
||||
<Copy data-icon="inline-start" />
|
||||
Установка
|
||||
</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" />
|
||||
Команда установки
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{onDelete ? (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => onDelete(agentId)}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
Удалить агента
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{a.last_apply_error ? (
|
||||
<Alert variant="destructive">
|
||||
<CircleAlertIcon />
|
||||
<AlertTitle>Ошибка применения политики</AlertTitle>
|
||||
<AlertDescription>{a.last_apply_error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<DetailPanel.Metrics
|
||||
cards={[
|
||||
{
|
||||
id: 'traffic',
|
||||
icon: <ActivityIcon aria-hidden />,
|
||||
iconClassName: 'text-warning',
|
||||
label: 'Трафик',
|
||||
description: `↓${agentTrafficDropped(a)} · ↑${agentTrafficAccepted(a)}`,
|
||||
hint: 'накопительно',
|
||||
variant: 'warning',
|
||||
footer: (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={resetStats.isPending}
|
||||
onClick={() => setResetStatsOpen(true)}
|
||||
>
|
||||
Сбросить
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'kernel',
|
||||
icon: <CpuIcon aria-hidden />,
|
||||
iconClassName: 'text-info',
|
||||
label: 'Ядро',
|
||||
description: a.last_apply_kernel_method ?? '—',
|
||||
},
|
||||
{
|
||||
id: 'apply',
|
||||
icon: <ClockIcon aria-hidden />,
|
||||
iconClassName: 'text-primary',
|
||||
label: 'Последнее применение',
|
||||
description: formatDateTime(a.last_apply_at),
|
||||
hint: a.last_apply_at
|
||||
? formatRelativeTime(a.last_apply_at)
|
||||
: undefined,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<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={agentId} />
|
||||
</div>
|
||||
|
||||
<AgentEffectiveCidrs
|
||||
preview={previewQ.data}
|
||||
isLoading={previewQ.isLoading}
|
||||
/>
|
||||
|
||||
{a.platform === 'linux' ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<CountedLineTabs
|
||||
value={fwTab}
|
||||
onValueChange={setFwTab}
|
||||
tabs={[
|
||||
{ id: 'host', label: 'Хост-фаервол' },
|
||||
{ id: 'acl', label: 'Правила портов' },
|
||||
{ id: 'hits', label: 'Заблокированное' },
|
||||
]}
|
||||
>
|
||||
<TabsContent value="host" className="mt-3">
|
||||
<AgentHostFirewall agentId={agentId} />
|
||||
</TabsContent>
|
||||
<TabsContent value="acl" className="mt-3">
|
||||
<AgentPortAcl agentId={agentId} />
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="hits"
|
||||
className="mt-3 flex flex-col gap-4"
|
||||
>
|
||||
<AgentBlockedPorts agentId={agentId} />
|
||||
<AgentBlockedIps
|
||||
agentId={agentId}
|
||||
platform={a.platform}
|
||||
/>
|
||||
</TabsContent>
|
||||
</CountedLineTabs>
|
||||
</div>
|
||||
) : (
|
||||
<AgentBlockedIps agentId={agentId} platform={a.platform} />
|
||||
)}
|
||||
</div>
|
||||
</DetailPanel.Section>
|
||||
</DetailPanel>
|
||||
</div>
|
||||
|
||||
<AgentOverrideSheet
|
||||
agentId={agentId}
|
||||
open={overrideOpen}
|
||||
onOpenChange={setOverrideOpen}
|
||||
/>
|
||||
<AgentCloneSetsSheet
|
||||
agentId={agentId}
|
||||
open={cloneOpen}
|
||||
onOpenChange={setCloneOpen}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={revokeOpen}
|
||||
onOpenChange={setRevokeOpen}
|
||||
title="Отозвать агента?"
|
||||
confirmLabel="Отозвать"
|
||||
description={`Агент «${a.name}» потеряет доступ к API управления и перестанет получать обновления политики. Действие нельзя отменить.`}
|
||||
onConfirm={() => {
|
||||
setRevokeOpen(false)
|
||||
revoke.mutate()
|
||||
}}
|
||||
disabled={revoke.isPending}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={resetStatsOpen}
|
||||
onOpenChange={setResetStatsOpen}
|
||||
title="Сбросить статистику?"
|
||||
confirmLabel="Сбросить"
|
||||
description={`Счётчики пакетов и история статистики агента «${a.name}» будут обнулены и удалены. Действие нельзя отменить.`}
|
||||
onConfirm={() => {
|
||||
setResetStatsOpen(false)
|
||||
resetStats.mutate()
|
||||
}}
|
||||
disabled={resetStats.isPending}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import { Link, useRouterState } from '@tanstack/react-router'
|
||||
import { AppSwitcher } from '@/components/app-switcher'
|
||||
import { NavUser } from '@/components/layout/nav-user'
|
||||
import { NAV_SECTIONS, navItemsForSection, type NavItem } from '@/lib/nav'
|
||||
import { useCan } from '@/lib/permissions'
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from '@evofw/ui/components/sidebar'
|
||||
|
||||
function isNavActive(pathname: string, to: string, exact?: boolean) {
|
||||
if (exact) return pathname === to
|
||||
return pathname === to || pathname.startsWith(`${to}/`)
|
||||
}
|
||||
|
||||
function NavSection({
|
||||
label,
|
||||
items,
|
||||
pathname,
|
||||
can,
|
||||
}: {
|
||||
label: string
|
||||
items: readonly NavItem[]
|
||||
pathname: string
|
||||
can: (permission: string) => boolean
|
||||
}) {
|
||||
const visible = items.filter(
|
||||
(item) => !item.permission || can(item.permission),
|
||||
)
|
||||
if (visible.length === 0) return null
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>{label}</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{visible.map((item) => (
|
||||
<SidebarMenuItem key={item.to}>
|
||||
<SidebarMenuButton
|
||||
tooltip={item.label}
|
||||
isActive={isNavActive(pathname, item.to, item.exact)}
|
||||
render={
|
||||
<Link to={item.to} activeOptions={{ exact: item.exact }} />
|
||||
}
|
||||
>
|
||||
<item.icon className="size-4" />
|
||||
<span>{item.label}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
)
|
||||
}
|
||||
|
||||
export function AppSidebar() {
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||
const can = useCan()
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="icon">
|
||||
<SidebarHeader>
|
||||
<AppSwitcher />
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
{NAV_SECTIONS.map((section) => (
|
||||
<NavSection
|
||||
key={section.id}
|
||||
label={section.label}
|
||||
items={navItemsForSection(section.id)}
|
||||
pathname={pathname}
|
||||
can={can}
|
||||
/>
|
||||
))}
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<NavUser />
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
)
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import type { CSSProperties, ReactNode } from 'react'
|
||||
import { AppSidebar } from '@/components/app-sidebar'
|
||||
import { SiteHeader } from '@/components/layout/site-header'
|
||||
import { SearchMenu } from '@/components/layout/search-menu'
|
||||
import { TooltipProvider } from '@evofw/ui/components/tooltip'
|
||||
import { SidebarInset, SidebarProvider } from '@evofw/ui/components/sidebar'
|
||||
import { SKIP_TO_CONTENT_CLASS } from '@/lib/ui-surface'
|
||||
|
||||
/** Shared ops chrome — etalon EvoBGP. @see docs/ui-design-contract.md */
|
||||
export function AppShell({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<TooltipProvider delay={0}>
|
||||
<SidebarProvider
|
||||
style={
|
||||
{
|
||||
'--sidebar-width': '240px',
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<a href="#main-content" className={SKIP_TO_CONTENT_CLASS}>
|
||||
К содержимому
|
||||
</a>
|
||||
<AppSidebar />
|
||||
<SidebarInset id="main-content">
|
||||
<SiteHeader />
|
||||
<div className="flex flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
|
||||
{children}
|
||||
</div>
|
||||
</SidebarInset>
|
||||
<SearchMenu hotkeyOnly />
|
||||
</SidebarProvider>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { LayoutGridIcon } from 'lucide-react'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@evofw/ui/components/dropdown-menu'
|
||||
import { authPortalUrl, isAuthEnabled } from '@/lib/auth'
|
||||
import {
|
||||
APP_SWITCHER_ICONS,
|
||||
CURRENT_APP_ID,
|
||||
} from '@/lib/app-switcher-config'
|
||||
import { useAppSwitcherConfig } from '@/hooks/use-app-switcher'
|
||||
|
||||
/** Header apps grid — app-shell-12 AppsMenu, wired to auth-portal App Switcher. */
|
||||
export function AppsMenu() {
|
||||
const { config, isLoading } = useAppSwitcherConfig()
|
||||
const portalAppsUrl = `${authPortalUrl().replace(/\/$/, '')}/admin/apps`
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button variant="ghost" size="icon" aria-label="Приложения" />
|
||||
}
|
||||
>
|
||||
<LayoutGridIcon
|
||||
className="size-4.5 transition-colors"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
side="bottom"
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
className="w-72"
|
||||
>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>
|
||||
{isLoading ? 'Загрузка…' : config.menuLabel}
|
||||
</DropdownMenuLabel>
|
||||
<div className="grid grid-cols-3 gap-1 p-1">
|
||||
{config.apps.map((app) => {
|
||||
const Icon = APP_SWITCHER_ICONS[app.icon] ?? APP_SWITCHER_ICONS.server
|
||||
const isCurrent = app.id === CURRENT_APP_ID
|
||||
|
||||
if (isCurrent) {
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={app.id}
|
||||
disabled
|
||||
className="h-auto flex-col gap-1.5 py-3 text-center [&_svg]:size-5"
|
||||
>
|
||||
<span className="text-muted-foreground">
|
||||
<Icon aria-hidden="true" />
|
||||
</span>
|
||||
<span className="text-xs font-medium">{app.name}</span>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={app.id}
|
||||
nativeButton={false}
|
||||
render={<a href={app.url} />}
|
||||
className="h-auto flex-col gap-1.5 py-3 text-center [&_svg]:size-5"
|
||||
>
|
||||
<span className="text-muted-foreground">
|
||||
<Icon aria-hidden="true" />
|
||||
</span>
|
||||
<span className="text-xs font-medium">{app.name}</span>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
{isAuthEnabled() ? (
|
||||
<DropdownMenuItem
|
||||
nativeButton={false}
|
||||
render={<a href={portalAppsUrl} />}
|
||||
className="justify-center text-sm font-medium"
|
||||
>
|
||||
Настроить на портале
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
nativeButton={false}
|
||||
render={<Link to="/settings" />}
|
||||
className="justify-center text-sm font-medium"
|
||||
>
|
||||
Настройки
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import {
|
||||
ChevronsUpDownIcon,
|
||||
LogOutIcon,
|
||||
MonitorIcon,
|
||||
MoonIcon,
|
||||
PaletteIcon,
|
||||
SettingsIcon,
|
||||
SunIcon,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { cn } from '@evofw/ui/lib/utils'
|
||||
import { Avatar, AvatarFallback } from '@evofw/ui/components/avatar'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@evofw/ui/components/dropdown-menu'
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from '@evofw/ui/components/sidebar'
|
||||
|
||||
import {
|
||||
authPortalUrl,
|
||||
getClaims,
|
||||
isAuthEnabled,
|
||||
logout,
|
||||
} from '@/lib/auth'
|
||||
|
||||
/** Sidebar footer NavUser — theme in footer, not header. @see https://reui.io/preview/base/app-shell-1 */
|
||||
|
||||
const THEMES = [
|
||||
{
|
||||
value: 'light',
|
||||
label: 'Светлая',
|
||||
icon: <SunIcon className="size-3.5" aria-hidden />,
|
||||
},
|
||||
{
|
||||
value: 'dark',
|
||||
label: 'Тёмная',
|
||||
icon: <MoonIcon className="size-3.5" aria-hidden />,
|
||||
},
|
||||
{
|
||||
value: 'system',
|
||||
label: 'Системная',
|
||||
icon: <MonitorIcon className="size-3.5" aria-hidden />,
|
||||
},
|
||||
] as const
|
||||
|
||||
function ThemeSegmentedToggle() {
|
||||
const { theme, setTheme } = useTheme()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
const currentTheme = mounted ? (theme ?? 'system') : 'system'
|
||||
|
||||
return (
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Тема"
|
||||
className="bg-muted/60 inline-flex items-center gap-0.5 rounded-full p-0.5"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{THEMES.map(({ value, label, icon }) => {
|
||||
const isActive = currentTheme === value
|
||||
return (
|
||||
<Button
|
||||
key={value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={isActive}
|
||||
aria-label={label}
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => setTheme(value)}
|
||||
className={cn(
|
||||
'rounded-full',
|
||||
isActive
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function initials(source: string): string {
|
||||
const base = source.trim()
|
||||
if (!base) return '?'
|
||||
const parts = base.split(/\s+/).filter(Boolean)
|
||||
if (parts.length >= 2) {
|
||||
return `${parts[0]![0] ?? ''}${parts[1]![0] ?? ''}`.toUpperCase()
|
||||
}
|
||||
return base.slice(0, 2).toUpperCase()
|
||||
}
|
||||
|
||||
export function NavUser() {
|
||||
const { isMobile } = useSidebar()
|
||||
const authOn = isAuthEnabled()
|
||||
const claims = getClaims()
|
||||
|
||||
const name = claims?.name?.trim() || (authOn ? 'Пользователь' : 'Гость')
|
||||
const email = claims?.email?.trim() || (authOn ? '' : 'auth выключен')
|
||||
const fallback = initials(name || email)
|
||||
|
||||
return (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
className="data-popup-open:bg-sidebar-accent data-popup-open:text-sidebar-accent-foreground"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Avatar className="size-8 rounded-lg">
|
||||
<AvatarFallback className="rounded-lg text-xs">{fallback}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">{name}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{email || '—'}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronsUpDownIcon className="ml-auto size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-(--anchor-width) min-w-56 rounded-lg"
|
||||
side={isMobile ? 'bottom' : 'right'}
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel className="flex items-center gap-2 py-2 font-normal text-foreground">
|
||||
<Avatar className="size-8 rounded-lg">
|
||||
<AvatarFallback className="rounded-lg text-xs">
|
||||
{fallback}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid min-w-0 flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">{name}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{email || '—'}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
</DropdownMenuGroup>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
nativeButton={false}
|
||||
render={<Link to="/settings" />}
|
||||
>
|
||||
<SettingsIcon aria-hidden />
|
||||
Настройки
|
||||
</DropdownMenuItem>
|
||||
{authOn ? (
|
||||
<DropdownMenuItem
|
||||
nativeButton={false}
|
||||
render={<a href={authPortalUrl()} />}
|
||||
>
|
||||
Auth Portal
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem className="cursor-default focus:bg-transparent">
|
||||
<PaletteIcon aria-hidden />
|
||||
Тема
|
||||
<div className="ml-auto">
|
||||
<ThemeSegmentedToggle />
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
|
||||
{authOn ? (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onClick={() => logout()}>
|
||||
<LogOutIcon aria-hidden />
|
||||
Выйти
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { PlusIcon, ServerIcon, ShieldPlusIcon } from 'lucide-react'
|
||||
import { AppSwitcher } from '@/components/app-switcher'
|
||||
import { CommandPalette, PortalHeader, PortalSidebar } from '@/components/portal/chrome'
|
||||
import { ChromeConfigProvider, PortalUiProvider } from '@/components/portal/chrome-context'
|
||||
import type { ChromeConfig, MonitorItem, MonitorMetric, NavItem, PaletteObject } from '@/components/portal/chrome-store'
|
||||
import { getClaims, isAuthEnabled, logout } from '@/lib/auth'
|
||||
import { NAV_ITEMS, NAV_SECTIONS } from '@/lib/nav'
|
||||
import { useCan } from '@/lib/permissions'
|
||||
import { SKIP_TO_CONTENT_CLASS } from '@/lib/ui-surface'
|
||||
import { agentsQueryOptions, dashboardQueryOptions, listsQueryOptions, policySetsQueryOptions } from '@/queries'
|
||||
import { Skeleton } from '@evofw/ui/components/skeleton'
|
||||
import { SidebarInset, SidebarProvider } from '@evofw/ui/components/sidebar'
|
||||
import { TooltipProvider } from '@evofw/ui/components/tooltip'
|
||||
|
||||
const FRESH: Record<string, { badge: string; text: string }> = {
|
||||
'/agents': { badge: 'Live', text: 'GET /api/v1/agents · статус и последняя связь агентов обновляются раз в минуту' },
|
||||
'/lists': { badge: 'Списки', text: 'GET /api/v1/lists · статические, JSON-URL и community EvoBGP' },
|
||||
'/rules': { badge: 'Политики', text: 'GET /api/v1/policy-sets · порядок правил задаёт приоритет' },
|
||||
'/stats': { badge: 'Live', text: 'Счётчики пакетов с агентов · накопительно до сброса' },
|
||||
'/settings': { badge: 'Настройки', text: 'Действие по умолчанию, интеграция с EvoBGP и ссылки установки' },
|
||||
'/': { badge: 'Live', text: 'GET /api/v1/dashboard · агенты, списки и пакеты' },
|
||||
}
|
||||
|
||||
function freshFor(pathname: string) {
|
||||
const key = Object.keys(FRESH)
|
||||
.filter((k) => k !== '/')
|
||||
.sort((a, b) => b.length - a.length)
|
||||
.find((k) => pathname === k || pathname.startsWith(`${k}/`))
|
||||
return FRESH[key ?? '/']!
|
||||
}
|
||||
|
||||
function initialsOf(name: string) {
|
||||
const parts = name.trim().split(/[\s@._-]+/).filter(Boolean)
|
||||
return ((parts[0]?.[0] ?? '?') + (parts[1]?.[0] ?? '')).toUpperCase()
|
||||
}
|
||||
|
||||
/** Меню и данные оболочки EvoFirewall: пункты по правам, точки проблем и монитор из живых запросов. */
|
||||
function useChromeData(): ChromeConfig {
|
||||
const can = useCan()
|
||||
const dashQ = useQuery({ ...dashboardQueryOptions(), refetchInterval: 30_000 })
|
||||
const agentsQ = useQuery({ ...agentsQueryOptions(), refetchInterval: 30_000 })
|
||||
const listsQ = useQuery({ ...listsQueryOptions(), refetchInterval: 60_000 })
|
||||
const setsQ = useQuery({ ...policySetsQueryOptions(), refetchInterval: 60_000 })
|
||||
|
||||
return useMemo<ChromeConfig>(() => {
|
||||
const d = dashQ.data
|
||||
const agents = agentsQ.data?.items ?? []
|
||||
const lists = listsQ.data?.items ?? []
|
||||
const sets = setsQ.data?.items ?? []
|
||||
const approved = agents.filter((a) => a.status === 'approved').length
|
||||
const online = d?.agents_online ?? agents.filter((a) => a.last_seen_at).length
|
||||
const pending = d?.agents_pending ?? agents.filter((a) => a.status === 'pending').length
|
||||
const offline = Math.max(0, approved - online)
|
||||
const onlinePct = approved > 0 ? Math.round((online / approved) * 100) : 0
|
||||
const apiOk = !dashQ.isError
|
||||
const failedLists = lists.filter((l) => l.last_error).length
|
||||
const rulesCount = sets.reduce((n, s) => n + (s.rules_count ?? 0), 0)
|
||||
|
||||
const problems: Record<string, NavItem['problem']> = {
|
||||
'/agents': pending > 0 ? { tone: 'wn', label: `${pending} агентов ждут одобрения` } : offline > 0 ? { tone: 'er', label: `${offline} агентов офлайн` } : undefined,
|
||||
'/lists': failedLists > 0 ? { tone: 'er', label: `Ошибок обновления списков: ${failedLists}` } : undefined,
|
||||
}
|
||||
const groupLabel = Object.fromEntries(NAV_SECTIONS.map((s) => [s.id, s.label]))
|
||||
const visible = NAV_ITEMS.filter((item) => !item.permission || can(item.permission))
|
||||
const navItems: NavItem[] = visible.map((item) => ({
|
||||
id: item.to,
|
||||
label: item.label,
|
||||
to: item.to,
|
||||
icon: item.icon,
|
||||
group: groupLabel[item.section]!,
|
||||
problem: problems[item.to],
|
||||
}))
|
||||
const groups = [...new Set(navItems.map((i) => i.group))]
|
||||
|
||||
const monitorItems: MonitorItem[] = []
|
||||
if (!apiOk) monitorItems.push({ id: 'api', tone: 'er', title: 'API недоступен', text: 'GET /api/v1/dashboard вернул ошибку', age: 'сейчас', to: '/' })
|
||||
if (pending > 0) monitorItems.push({ id: 'pending', tone: 'wn', title: `Ждут одобрения: ${pending}`, text: 'Новые агенты не получают политику, пока их не одобрят', age: 'сейчас', to: '/agents' })
|
||||
if (offline > 0) monitorItems.push({ id: 'offline', tone: 'er', title: `Агентов офлайн: ${offline}`, text: `Онлайн ${online} из ${approved} одобренных`, age: 'сейчас', to: '/agents' })
|
||||
if (failedLists > 0) monitorItems.push({ id: 'lists', tone: 'wn', title: `Списков с ошибкой: ${failedLists}`, text: 'Проверьте источник списка и повторите обновление', age: 'недавно', to: '/lists' })
|
||||
if (monitorItems.length === 0) monitorItems.push({ id: 'ok', tone: 'ok', title: 'Всё в норме', text: 'API, агенты и списки без проблем', age: 'сейчас', to: '/' })
|
||||
|
||||
const metrics: MonitorMetric[] = [
|
||||
{ l: 'API', v: apiOk ? 'OK' : 'ERR', p: apiOk ? 100 : 0, tone: apiOk ? 'success' : 'warning' },
|
||||
{ l: 'Агенты online', v: `${onlinePct}%`, p: onlinePct, tone: onlinePct >= 80 ? 'success' : 'warning' },
|
||||
{ l: 'Списки IP', v: String(d?.lists_total ?? lists.length), p: lists.length ? Math.round(((lists.length - failedLists) / lists.length) * 100) : 100, tone: failedLists > 0 ? 'warning' : 'success' },
|
||||
{ l: 'Правила', v: String(rulesCount), p: Math.min(100, rulesCount), tone: 'success' },
|
||||
]
|
||||
|
||||
const claims = getClaims()
|
||||
const name = claims?.name?.trim() || (isAuthEnabled() ? 'Пользователь' : 'Гость')
|
||||
const email = claims?.email?.trim() || (isAuthEnabled() ? '' : 'auth выключен')
|
||||
|
||||
const objects: PaletteObject[] = [
|
||||
...agents.slice(0, 50).map<PaletteObject>((a) => ({
|
||||
id: `agent-${a.id}`,
|
||||
group: 'Агенты',
|
||||
label: a.name,
|
||||
hint: `${a.platform}${a.last_seen_ip ? ` · ${a.last_seen_ip}` : ''}`,
|
||||
dot: a.status !== 'approved' ? 'mu' : a.last_seen_at ? 'ok' : 'er',
|
||||
to: `/agents/${a.id}`,
|
||||
})),
|
||||
...lists.slice(0, 50).map<PaletteObject>((l) => ({ id: `list-${l.id}`, group: 'Списки', label: l.name, hint: l.type, dot: l.last_error ? 'er' : 'ok', to: `/lists/${l.id}` })),
|
||||
...sets.slice(0, 50).map<PaletteObject>((s) => ({ id: `set-${s.id}`, group: 'Наборы правил', label: s.name, hint: `${s.rules_count ?? 0} правил`, dot: s.enabled ? 'ok' : 'mu', to: `/rules/${s.id}` })),
|
||||
]
|
||||
|
||||
return {
|
||||
navItems,
|
||||
navGroups: groups,
|
||||
brand: <AppSwitcher />,
|
||||
user: { name, email, initials: initialsOf(name || email) },
|
||||
securityTo: '/settings',
|
||||
onLogout: logout,
|
||||
fresh: freshFor,
|
||||
monitor: { items: monitorItems, metrics, updated: 'обновлено сейчас', footerTo: '/agents', footerLabel: 'Агенты' },
|
||||
palette: {
|
||||
actions: [
|
||||
{ id: 'act-agent', label: 'Добавить агента', hint: 'A', icon: ServerIcon, to: '/agents' },
|
||||
{ id: 'act-list', label: 'Создать список', hint: 'L', icon: PlusIcon, to: '/lists' },
|
||||
{ id: 'act-set', label: 'Создать набор правил', hint: 'R', icon: ShieldPlusIcon, to: '/rules' },
|
||||
],
|
||||
objects,
|
||||
assistantTo: '/agents',
|
||||
example: 'Подсказка: «агенты офлайн»',
|
||||
},
|
||||
}
|
||||
}, [can, dashQ.data, dashQ.isError, agentsQ.data, listsQ.data, setsQ.data])
|
||||
}
|
||||
|
||||
/** Оболочка EvoFirewall: боковое меню, шапка, монитор, палитра ⌘K — блоки общего макета. */
|
||||
export function PortalShell({ children }: { children: ReactNode }) {
|
||||
const [open, setOpen] = useState(true)
|
||||
const [mounted, setMounted] = useState(false)
|
||||
useEffect(() => {
|
||||
// До 1100px меню сворачивается в колонку иконок (на телефоне его заменяет Sheet).
|
||||
const mq = window.matchMedia('(min-width: 1100px)')
|
||||
const sync = () => setOpen(mq.matches)
|
||||
sync()
|
||||
mq.addEventListener('change', sync)
|
||||
setMounted(true)
|
||||
return () => mq.removeEventListener('change', sync)
|
||||
}, [])
|
||||
const config = useChromeData()
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<div className="flex min-h-svh flex-col gap-4 p-6" aria-busy>
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<PortalUiProvider>
|
||||
<ChromeConfigProvider value={config}>
|
||||
<TooltipProvider delay={0}>
|
||||
<SidebarProvider open={open} onOpenChange={setOpen} style={{ '--sidebar-width': '240px' } as CSSProperties}>
|
||||
<a href="#main-content" className={SKIP_TO_CONTENT_CLASS}>
|
||||
К содержимому
|
||||
</a>
|
||||
<PortalSidebar />
|
||||
<SidebarInset id="main-content" className="min-w-0">
|
||||
<PortalHeader />
|
||||
<div className="flex w-full min-w-0 flex-1 flex-col gap-5 p-4 md:p-6">{children}</div>
|
||||
</SidebarInset>
|
||||
<CommandPalette />
|
||||
</SidebarProvider>
|
||||
</TooltipProvider>
|
||||
</ChromeConfigProvider>
|
||||
</PortalUiProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,220 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
ListIcon,
|
||||
ListPlusIcon,
|
||||
PlusIcon,
|
||||
ServerIcon,
|
||||
ShieldIcon,
|
||||
ShieldPlusIcon,
|
||||
} from 'lucide-react'
|
||||
import { NAV_ITEMS } from '@/lib/nav'
|
||||
import { useCan } from '@/lib/permissions'
|
||||
import {
|
||||
agentsQueryOptions,
|
||||
listsQueryOptions,
|
||||
policySetsQueryOptions,
|
||||
} from '@/queries'
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from '@evofw/ui/components/command'
|
||||
|
||||
/**
|
||||
* Command-K palette — навигация, поиск ресурсов и действия.
|
||||
* DNA: https://reui.io/components/command/c-command-7 (hotkey-only по контракту).
|
||||
*/
|
||||
|
||||
export function SearchMenu({ hotkeyOnly = false }: { hotkeyOnly?: boolean }) {
|
||||
void hotkeyOnly
|
||||
return <SearchMenuDialog />
|
||||
}
|
||||
|
||||
function SearchMenuDialog() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
const can = useCan()
|
||||
|
||||
const agentsQ = useQuery({ ...agentsQueryOptions(), enabled: open })
|
||||
const listsQ = useQuery({ ...listsQueryOptions(), enabled: open })
|
||||
const setsQ = useQuery({ ...policySetsQueryOptions(), enabled: open })
|
||||
|
||||
useEffect(() => {
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.key.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault()
|
||||
setOpen((prev) => !prev)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [])
|
||||
|
||||
const go = (run: () => void) => {
|
||||
setOpen(false)
|
||||
run()
|
||||
}
|
||||
|
||||
const navItems = NAV_ITEMS.filter(
|
||||
(item) => !item.permission || can(item.permission),
|
||||
)
|
||||
|
||||
const agents = (agentsQ.data?.items ?? []).slice(0, 50)
|
||||
const lists = (listsQ.data?.items ?? []).slice(0, 50)
|
||||
const sets = (setsQ.data?.items ?? []).slice(0, 50)
|
||||
|
||||
return (
|
||||
<CommandDialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title="Палитра команд"
|
||||
description="Поиск разделов, агентов, списков и действий"
|
||||
className="max-w-xl"
|
||||
>
|
||||
<CommandInput placeholder="Поиск разделов, агентов, списков…" />
|
||||
<CommandList>
|
||||
<CommandEmpty>Ничего не найдено.</CommandEmpty>
|
||||
|
||||
<CommandGroup heading="Действия">
|
||||
{can('fw:agents:write') ? (
|
||||
<CommandItem
|
||||
value="Добавить агента agent invite"
|
||||
onSelect={() =>
|
||||
go(() =>
|
||||
void navigate({
|
||||
to: '/agents',
|
||||
search: { view: 'cards', add: true },
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
<PlusIcon aria-hidden />
|
||||
Добавить агента
|
||||
</CommandItem>
|
||||
) : null}
|
||||
{can('fw:lists:write') ? (
|
||||
<CommandItem
|
||||
value="Создать список blocklist"
|
||||
onSelect={() =>
|
||||
go(() =>
|
||||
void navigate({ to: '/lists', search: { create: true } }),
|
||||
)
|
||||
}
|
||||
>
|
||||
<ListPlusIcon aria-hidden />
|
||||
Создать список
|
||||
</CommandItem>
|
||||
) : null}
|
||||
{can('fw:policies:write') ? (
|
||||
<CommandItem
|
||||
value="Создать набор правил policy"
|
||||
onSelect={() =>
|
||||
go(() =>
|
||||
void navigate({ to: '/rules', search: { create: true } }),
|
||||
)
|
||||
}
|
||||
>
|
||||
<ShieldPlusIcon aria-hidden />
|
||||
Создать набор правил
|
||||
</CommandItem>
|
||||
) : null}
|
||||
</CommandGroup>
|
||||
|
||||
<CommandSeparator />
|
||||
|
||||
<CommandGroup heading="Навигация">
|
||||
{navItems.map((item) => (
|
||||
<CommandItem
|
||||
key={item.to}
|
||||
value={`${item.label} ${item.keywords.join(' ')}`}
|
||||
onSelect={() => go(() => void navigate({ to: item.to }))}
|
||||
>
|
||||
<item.icon aria-hidden />
|
||||
{item.label}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
|
||||
{agents.length > 0 ? (
|
||||
<>
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading="Агенты">
|
||||
{agents.map((a) => (
|
||||
<CommandItem
|
||||
key={a.id}
|
||||
value={`${a.name} ${a.hostname ?? ''} ${a.last_seen_ip ?? ''}`}
|
||||
onSelect={() =>
|
||||
go(() =>
|
||||
void navigate({
|
||||
to: '/agents',
|
||||
search: { view: 'cards', agent: a.id },
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
<ServerIcon aria-hidden />
|
||||
<span className="truncate">{a.name}</span>
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{a.hostname ?? a.status}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{lists.length > 0 ? (
|
||||
<>
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading="Списки">
|
||||
{lists.map((l) => (
|
||||
<CommandItem
|
||||
key={l.id}
|
||||
value={l.name}
|
||||
onSelect={() =>
|
||||
go(() => void navigate({ to: '/lists/$id', params: { id: l.id } }))
|
||||
}
|
||||
>
|
||||
<ListIcon aria-hidden />
|
||||
<span className="truncate">{l.name}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{sets.length > 0 ? (
|
||||
<>
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading="Наборы правил">
|
||||
{sets.map((s) => (
|
||||
<CommandItem
|
||||
key={s.id}
|
||||
value={s.name}
|
||||
onSelect={() =>
|
||||
go(() =>
|
||||
void navigate({
|
||||
to: '/rules/$setId',
|
||||
params: { setId: s.id },
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
<ShieldIcon aria-hidden />
|
||||
<span className="truncate">{s.name}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</>
|
||||
) : null}
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
)
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
import { Link, useMatches, useRouterState } from '@tanstack/react-router'
|
||||
import { useMemo } from 'react'
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from '@evofw/ui/components/breadcrumb'
|
||||
import { Separator } from '@evofw/ui/components/separator'
|
||||
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
|
||||
import { AppsMenu } from '@/components/layout/apps-menu'
|
||||
import { SidebarTrigger } from '@evofw/ui/components/sidebar'
|
||||
import { navLabel, navParentForDetail } from '@/lib/nav'
|
||||
|
||||
export interface RouteBreadcrumbLoaderData {
|
||||
breadcrumb?: string
|
||||
}
|
||||
|
||||
function getBreadcrumbs(
|
||||
pathname: string,
|
||||
dynamicLabels: Record<string, string>,
|
||||
) {
|
||||
if (pathname === '/') {
|
||||
return [{ label: navLabel('/') ?? 'Панель управления', href: '/' }]
|
||||
}
|
||||
|
||||
const parentTo = navParentForDetail(pathname)
|
||||
if (parentTo) {
|
||||
const parentLabel = navLabel(parentTo)
|
||||
const fallback =
|
||||
parentTo === '/agents'
|
||||
? 'Агент'
|
||||
: parentTo === '/lists'
|
||||
? 'Список'
|
||||
: 'Набор'
|
||||
return [
|
||||
{ label: parentLabel ?? parentTo, href: parentTo },
|
||||
{ label: dynamicLabels[pathname] ?? fallback, href: pathname },
|
||||
]
|
||||
}
|
||||
|
||||
const title = navLabel(pathname)
|
||||
if (title) {
|
||||
return [{ label: title, href: pathname }]
|
||||
}
|
||||
|
||||
return [{ label: navLabel('/') ?? 'Панель управления', href: '/' }]
|
||||
}
|
||||
|
||||
function useDynamicBreadcrumbLabels() {
|
||||
const matches = useMatches()
|
||||
return useMemo(() => {
|
||||
const labels: Record<string, string> = {}
|
||||
for (const match of matches) {
|
||||
const data = match.loaderData as RouteBreadcrumbLoaderData | undefined
|
||||
if (data?.breadcrumb && match.pathname) {
|
||||
labels[match.pathname] = data.breadcrumb
|
||||
}
|
||||
}
|
||||
return labels
|
||||
}, [matches])
|
||||
}
|
||||
|
||||
/** Header chrome — AppsMenu + SystemMonitor; theme in NavUser. */
|
||||
export function SiteHeader() {
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||
const dynamicLabels = useDynamicBreadcrumbLabels()
|
||||
const crumbs = getBreadcrumbs(pathname, dynamicLabels)
|
||||
|
||||
return (
|
||||
<header className="bg-background sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4 md:px-6">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
className="mr-2 data-vertical:h-4 data-vertical:self-center"
|
||||
/>
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
{crumbs.map((crumb, index) => {
|
||||
const isLast = index === crumbs.length - 1
|
||||
return (
|
||||
<span key={crumb.href} className="contents">
|
||||
{index > 0 && (
|
||||
<BreadcrumbSeparator className="hidden md:block" />
|
||||
)}
|
||||
<BreadcrumbItem
|
||||
className={
|
||||
index === 0 && !isLast ? 'hidden md:block' : undefined
|
||||
}
|
||||
>
|
||||
{isLast ? (
|
||||
<BreadcrumbPage>{crumb.label}</BreadcrumbPage>
|
||||
) : (
|
||||
<BreadcrumbLink render={<Link to={crumb.href} />}>
|
||||
{crumb.label}
|
||||
</BreadcrumbLink>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<AppsMenu />
|
||||
<SystemMonitorPopover />
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
import { useMemo, type CSSProperties, type ReactNode } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Activity, HeartPulse, List, Server, Shield } from 'lucide-react'
|
||||
import { formatTime } from '@/lib/format'
|
||||
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { cn } from '@evofw/ui/lib/utils'
|
||||
import { Item, ItemMedia } from '@evofw/ui/components/item'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@evofw/ui/components/popover'
|
||||
import { Progress } from '@evofw/ui/components/progress'
|
||||
import {
|
||||
agentsQueryOptions,
|
||||
dashboardQueryOptions,
|
||||
listsQueryOptions,
|
||||
policySetsQueryOptions,
|
||||
} from '@/queries'
|
||||
|
||||
type MonitorMetric = {
|
||||
id: string
|
||||
label: string
|
||||
value: string
|
||||
unit: string
|
||||
percent: number
|
||||
icon: ReactNode
|
||||
tone: 'success' | 'warning' | 'destructive' | 'info'
|
||||
alert: boolean
|
||||
}
|
||||
|
||||
function toneColor(tone: MonitorMetric['tone']) {
|
||||
switch (tone) {
|
||||
case 'success':
|
||||
return 'var(--color-success)'
|
||||
case 'warning':
|
||||
return 'var(--color-warning)'
|
||||
case 'destructive':
|
||||
return 'var(--color-destructive)'
|
||||
default:
|
||||
return 'var(--color-info)'
|
||||
}
|
||||
}
|
||||
|
||||
function MetricCell({ metric }: { metric: MonitorMetric }) {
|
||||
const color = toneColor(metric.tone)
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-3">
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<Item
|
||||
className="flex size-5 shrink-0 items-center justify-center p-0"
|
||||
style={{ backgroundColor: `${color}18` }}
|
||||
>
|
||||
<ItemMedia variant="icon" className="size-auto" style={{ color }}>
|
||||
{metric.icon}
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
<span className="text-muted-foreground truncate text-[11px]">
|
||||
{metric.label}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className="shrink-0 text-xs font-semibold tabular-nums"
|
||||
style={{ color }}
|
||||
>
|
||||
{metric.value}
|
||||
<span className="text-muted-foreground ml-0.5 text-[10px] font-normal">
|
||||
{metric.unit}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={metric.percent}
|
||||
className="**:data-[slot=progress-indicator]:bg-(--bar-color) **:data-[slot=progress-track]:h-1"
|
||||
style={{ '--bar-color': color } as CSSProperties}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Live system monitor — CFDM/EvoBGP chrome pill. Preview: app-shell-12 */
|
||||
export function SystemMonitorPopover() {
|
||||
const dashQ = useQuery({ ...dashboardQueryOptions(), refetchInterval: 30_000 })
|
||||
const agentsQ = useQuery({ ...agentsQueryOptions(), refetchInterval: 30_000 })
|
||||
const listsQ = useQuery({ ...listsQueryOptions(), refetchInterval: 60_000 })
|
||||
const setsQ = useQuery({
|
||||
...policySetsQueryOptions(),
|
||||
refetchInterval: 60_000,
|
||||
})
|
||||
|
||||
const d = dashQ.data
|
||||
const agents = agentsQ.data?.items ?? []
|
||||
const approved = agents.filter((a) => a.status === 'approved').length
|
||||
const online = d?.agents_online ?? agents.filter((a) => a.last_seen_at).length
|
||||
const pending =
|
||||
d?.agents_pending ?? agents.filter((a) => a.status === 'pending').length
|
||||
const onlinePct = approved > 0 ? Math.round((online / approved) * 100) : 0
|
||||
const listsCount = listsQ.data?.items?.length ?? d?.lists_total ?? 0
|
||||
const rulesCount =
|
||||
setsQ.data?.items.reduce((n, s) => n + (s.rules_count ?? 0), 0) ?? 0
|
||||
const apiOk = !dashQ.isError
|
||||
|
||||
const metrics = useMemo<MonitorMetric[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'api',
|
||||
label: 'API',
|
||||
value: apiOk ? 'OK' : 'ERR',
|
||||
unit: '',
|
||||
percent: apiOk ? 100 : 0,
|
||||
icon: <HeartPulse aria-hidden />,
|
||||
tone: apiOk ? 'success' : 'destructive',
|
||||
alert: !apiOk,
|
||||
},
|
||||
{
|
||||
id: 'agents',
|
||||
label: 'Агенты online',
|
||||
value: String(onlinePct),
|
||||
unit: '%',
|
||||
percent: onlinePct,
|
||||
icon: <Server aria-hidden />,
|
||||
tone:
|
||||
onlinePct >= 80
|
||||
? 'success'
|
||||
: onlinePct >= 40
|
||||
? 'warning'
|
||||
: 'destructive',
|
||||
alert: pending > 0 || (approved > 0 && onlinePct < 50),
|
||||
},
|
||||
{
|
||||
id: 'lists',
|
||||
label: 'Списки IP',
|
||||
value: String(listsCount),
|
||||
unit: '',
|
||||
percent: Math.min(100, listsCount * 10),
|
||||
icon: <List aria-hidden />,
|
||||
tone: 'info',
|
||||
alert: false,
|
||||
},
|
||||
{
|
||||
id: 'rules',
|
||||
label: 'Правила',
|
||||
value: String(rulesCount),
|
||||
unit: '',
|
||||
percent: Math.min(100, rulesCount * 10),
|
||||
icon: <Shield aria-hidden />,
|
||||
tone: 'info',
|
||||
alert: false,
|
||||
},
|
||||
],
|
||||
[apiOk, onlinePct, pending, approved, listsCount, rulesCount],
|
||||
)
|
||||
|
||||
const spiking = metrics.some((m) => m.alert)
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Монитор системы"
|
||||
className={cn(
|
||||
'relative inline-flex h-8 items-center gap-1.5 rounded-md border px-2 transition-colors outline-none',
|
||||
'border-border hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring',
|
||||
)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span className="relative flex size-3.5 items-center justify-center">
|
||||
<Activity
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'size-3.5 transition-colors',
|
||||
spiking ? 'text-destructive' : 'text-muted-foreground',
|
||||
)}
|
||||
/>
|
||||
{spiking ? (
|
||||
<span
|
||||
className="bg-destructive/25 absolute inset-0 animate-ping rounded-full"
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="text-foreground hidden text-xs font-medium sm:inline">
|
||||
Система
|
||||
</span>
|
||||
<Badge
|
||||
variant={spiking ? 'destructive-light' : 'success-light'}
|
||||
size="xs"
|
||||
className="h-4 px-1.5 text-[10px]"
|
||||
>
|
||||
{spiking ? 'Внимание' : 'Норма'}
|
||||
</Badge>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
className="flex w-80 flex-col gap-0! p-0!"
|
||||
>
|
||||
<div className="border-border flex items-center justify-between border-b px-3 py-2.5">
|
||||
<span className="text-foreground text-xs font-medium">
|
||||
Монитор EvoFirewall
|
||||
</span>
|
||||
<span className="text-muted-foreground text-[11px] tabular-nums">
|
||||
{formatTime(new Date())}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2">
|
||||
{metrics.map((metric, i) => (
|
||||
<div
|
||||
key={metric.id}
|
||||
className={cn(
|
||||
i % 2 === 1 && 'border-border border-l',
|
||||
i >= 2 && 'border-border border-t',
|
||||
)}
|
||||
>
|
||||
<MetricCell metric={metric} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-border text-muted-foreground border-t px-3 py-2 text-[11px]">
|
||||
Pending:{' '}
|
||||
<span className="text-foreground font-medium tabular-nums">
|
||||
{pending}
|
||||
</span>
|
||||
{' · '}
|
||||
Online:{' '}
|
||||
<span className="text-foreground font-medium tabular-nums">
|
||||
{online}/{approved || '—'}
|
||||
</span>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -6,17 +6,15 @@ interface PageHeaderProps {
|
||||
actions?: ReactNode
|
||||
}
|
||||
|
||||
/** Page-level section header — etalon EvoBGP / Domains list. */
|
||||
/** Заголовок страницы как на досках макета: название 20px, описание 14px muted, действия справа. */
|
||||
export function PageHeader({ title, description, actions }: PageHeaderProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex flex-col gap-px">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
||||
{description ? (
|
||||
<p className="text-muted-foreground text-sm">{description}</p>
|
||||
) : null}
|
||||
<header className="flex min-w-0 flex-wrap items-end justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<h1 className="text-xl font-semibold tracking-[-0.01em]">{title}</h1>
|
||||
{description ? <p className="text-muted-foreground text-sm text-pretty">{description}</p> : null}
|
||||
</div>
|
||||
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
|
||||
</div>
|
||||
{actions ? <div className="flex shrink-0 flex-wrap items-center justify-end gap-2">{actions}</div> : null}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { CircleAlertIcon, InfoIcon, TriangleAlertIcon, XIcon } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { Alert, AlertAction, AlertDescription, AlertTitle } from '@/components/reui/alert'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@evofw/ui/components/alert-dialog'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import { cn } from '@evofw/ui/lib/utils'
|
||||
import { Input } from '@evofw/ui/components/input'
|
||||
import { Label } from '@evofw/ui/components/label'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@evofw/ui/components/tabs'
|
||||
import {
|
||||
Sheet,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@evofw/ui/components/sheet'
|
||||
import type { Tone } from './tone'
|
||||
import { TONE_BADGE } from './tone'
|
||||
|
||||
const TONE_GLYPH: Record<Tone, string> = { ok: '●', wn: '▲', er: '✕', mu: '○', in: '' }
|
||||
|
||||
/** Плашка статуса: слово + глиф (● ▲ ✕ ○), чтобы цвет не был единственным признаком. */
|
||||
export function StatusBadge({ tone, children }: { tone: Tone; children: ReactNode }) {
|
||||
const glyph = TONE_GLYPH[tone]
|
||||
return (
|
||||
<Badge variant={TONE_BADGE[tone]} size="sm" className="whitespace-nowrap">
|
||||
{glyph ? <span aria-hidden className="text-[0.55rem]">{glyph}</span> : null}
|
||||
{children}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
/** Заголовок страницы: название, описание, метка свежести и действия. */
|
||||
export function PageHead({
|
||||
title,
|
||||
sub,
|
||||
actions,
|
||||
}: {
|
||||
title: string
|
||||
sub: string
|
||||
actions?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<header className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<h1 className="text-xl font-semibold tracking-[-0.01em]">{title}</h1>
|
||||
<p className="text-muted-foreground text-sm">{sub}</p>
|
||||
</div>
|
||||
{actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
const ALERT_ICON = {
|
||||
er: <CircleAlertIcon aria-hidden />,
|
||||
wn: <TriangleAlertIcon aria-hidden />,
|
||||
in: <InfoIcon aria-hidden />,
|
||||
} as const
|
||||
|
||||
const ALERT_VARIANT = {
|
||||
er: 'destructive',
|
||||
wn: 'warning',
|
||||
in: 'info',
|
||||
} as const
|
||||
|
||||
export function SignalAlert({
|
||||
tone,
|
||||
title,
|
||||
children,
|
||||
action,
|
||||
}: {
|
||||
tone: 'er' | 'wn' | 'in'
|
||||
title: string
|
||||
children: ReactNode
|
||||
action?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Alert variant={ALERT_VARIANT[tone]}>
|
||||
{ALERT_ICON[tone]}
|
||||
<AlertTitle>{title}</AlertTitle>
|
||||
<AlertDescription>{children}</AlertDescription>
|
||||
{action ? <AlertAction>{action}</AlertAction> : null}
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
/** Боковое окно (как CreateUserSheet): заголовок, прокручиваемое тело, подвал. */
|
||||
export function PortalSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
footer,
|
||||
size = 'md',
|
||||
dirty,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
title: string
|
||||
description: string
|
||||
children: ReactNode
|
||||
footer?: ReactNode
|
||||
/** Ширина панели: md — форма, lg — редактор с двумя колонками. */
|
||||
size?: 'md' | 'lg'
|
||||
/** Явный признак изменений (иначе ловим ввод в полях и переключатели). */
|
||||
dirty?: boolean
|
||||
}) {
|
||||
const [touchedState, setTouched] = useState(false)
|
||||
const touched = dirty ?? touchedState
|
||||
const [guard, setGuard] = useState(false)
|
||||
|
||||
function requestChange(next: boolean) {
|
||||
if (!next && touched) {
|
||||
setGuard(true)
|
||||
return
|
||||
}
|
||||
if (!next) setTouched(false)
|
||||
onOpenChange(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sheet open={open} onOpenChange={requestChange}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
showCloseButton={false}
|
||||
className={cn(
|
||||
'inset-y-0 right-0 left-auto flex h-svh max-w-none flex-col gap-0 overflow-hidden rounded-none p-0 outline-none sm:max-w-none',
|
||||
'data-[side=right]:sm:max-w-none',
|
||||
size === 'lg'
|
||||
? 'data-[side=right]:w-[min(45rem,100vw)]'
|
||||
: 'data-[side=right]:w-[min(30rem,100vw)]',
|
||||
)}
|
||||
>
|
||||
<SheetHeader className="shrink-0 gap-0 p-0">
|
||||
<div className="flex min-h-12 items-center justify-between gap-2 border-b px-4">
|
||||
<SheetTitle className="min-w-0 truncate text-base font-semibold">{title}</SheetTitle>
|
||||
<SheetClose
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Закрыть"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<XIcon aria-hidden="true" />
|
||||
</SheetClose>
|
||||
</div>
|
||||
<SheetDescription className="text-muted-foreground px-4 pt-3 text-sm">
|
||||
{description}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div
|
||||
className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4 py-4"
|
||||
onInput={() => setTouched(true)}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest('[role=switch]')) setTouched(true)
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{footer ? (
|
||||
<SheetFooter
|
||||
className="flex-row flex-wrap justify-end gap-2 border-t px-4 py-3"
|
||||
onClickCapture={(e) => {
|
||||
// «Закрыть» и «Отмена» при изменённой форме тоже спрашивают подтверждение.
|
||||
const label = (e.target as HTMLElement).closest('button')?.textContent?.trim()
|
||||
if (touched && (label === 'Закрыть' || label === 'Отмена')) {
|
||||
e.stopPropagation()
|
||||
setGuard(true)
|
||||
}
|
||||
}}
|
||||
>{footer}</SheetFooter>
|
||||
) : null}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<AlertDialog open={guard} onOpenChange={setGuard}>
|
||||
<AlertDialogContent size="sm">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Закрыть без сохранения?</AlertDialogTitle>
|
||||
<AlertDialogDescription>Изменения в форме будут потеряны.</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Остаться</AlertDialogCancel>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setGuard(false)
|
||||
setTouched(false)
|
||||
onOpenChange(false)
|
||||
}}
|
||||
>
|
||||
Закрыть без сохранения
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Подтверждение опасного действия вводом слова (email, «сбросить», «очистить»…). */
|
||||
export function TypedConfirm({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
expected,
|
||||
confirmLabel,
|
||||
onConfirm,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
title: string
|
||||
description: ReactNode
|
||||
expected: string
|
||||
confirmLabel: string
|
||||
onConfirm: () => void
|
||||
}) {
|
||||
const [value, setValue] = useState('')
|
||||
const matches = value.trim().toLowerCase() === expected.toLowerCase()
|
||||
|
||||
return (
|
||||
<AlertDialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) setValue('')
|
||||
onOpenChange(next)
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{description}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="typed-confirm">
|
||||
Введите <span className="font-mono font-semibold">{expected}</span> для подтверждения
|
||||
</Label>
|
||||
<Input
|
||||
id="typed-confirm"
|
||||
value={value}
|
||||
autoComplete="off"
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
aria-invalid={value.length > 0 && !matches}
|
||||
/>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Отмена</AlertDialogCancel>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
disabled={!matches}
|
||||
onClick={() => {
|
||||
setValue('')
|
||||
onConfirm()
|
||||
}}
|
||||
>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
|
||||
export function KvFrame({
|
||||
title,
|
||||
description,
|
||||
items,
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
items: Array<{ label: string; value: ReactNode }>
|
||||
}) {
|
||||
return (
|
||||
<Frame className="[--frame-panel-header-py-adjust:4px] w-full">
|
||||
<FrameHeader>
|
||||
<FrameTitle>{title}</FrameTitle>
|
||||
<FrameDescription>{description}</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel className="p-0">
|
||||
<dl className="grid divide-y @container sm:grid-cols-2 sm:divide-y-0">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
className="flex flex-col gap-0.5 border-b px-4 py-3 last:border-b-0 sm:odd:border-r"
|
||||
>
|
||||
<dt className="text-muted-foreground text-xs">{item.label}</dt>
|
||||
<dd className="text-sm font-medium break-words">{item.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
/** Список «значение + подпись + копировать» (параметры issuer). */
|
||||
export function CopyList({
|
||||
title,
|
||||
description,
|
||||
items,
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
items: Array<{ label: string; value: string }>
|
||||
}) {
|
||||
return (
|
||||
<Frame className="[--frame-panel-header-py-adjust:4px] w-full">
|
||||
<FrameHeader>
|
||||
<FrameTitle>{title}</FrameTitle>
|
||||
<FrameDescription>{description}</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel className="p-0">
|
||||
<ul className="flex flex-col">
|
||||
{items.map((item) => (
|
||||
<li
|
||||
key={item.label}
|
||||
className="flex flex-wrap items-center gap-x-3 gap-y-1 border-b px-4 py-2.5 last:border-b-0"
|
||||
>
|
||||
<code className="min-w-0 flex-1 basis-72 font-mono text-[13px] break-all">{item.value}</code>
|
||||
<span className="text-muted-foreground min-w-0 flex-1 basis-48 text-[13px]">{item.label}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
aria-label={`Скопировать: ${item.label}`}
|
||||
onClick={() => {
|
||||
void navigator.clipboard?.writeText(item.value).catch(() => undefined)
|
||||
toast.success(`${item.label} скопирован`)
|
||||
}}
|
||||
>
|
||||
Копировать
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
/** Компактный список «ключ — значение» для боковых окон. */
|
||||
export function KvList({ items }: { items: Array<[string, string]> }) {
|
||||
return (
|
||||
<dl className="flex flex-col divide-y rounded-lg border">
|
||||
{items.map(([k, v]) => (
|
||||
<div key={k} className="flex flex-col gap-0.5 px-3 py-2">
|
||||
<dt className="text-muted-foreground text-xs">{k}</dt>
|
||||
<dd className="text-sm break-words">{v}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)
|
||||
}
|
||||
|
||||
export function CodeBlock({ label, children }: { label: string; children: string }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-muted-foreground text-xs">{label}</span>
|
||||
<pre className="bg-muted/40 overflow-x-auto rounded-md border p-3 font-mono text-xs leading-relaxed">{children}</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Сегментированный переключатель на Tabs (вид таблицы, период). */
|
||||
export function Segmented({
|
||||
label,
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string
|
||||
options: string[]
|
||||
value: number
|
||||
onChange: (index: number) => void
|
||||
}) {
|
||||
return (
|
||||
<Tabs value={String(value)} onValueChange={(v) => onChange(Number(v))} aria-label={label} className="max-w-full">
|
||||
<TabsList className="max-w-full justify-start overflow-x-auto">
|
||||
{options.map((o, i) => (
|
||||
<TabsTrigger key={o} value={String(i)}>
|
||||
{o}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
|
||||
/** Плашка строки с накопленной правкой: «Не сохранено» или «Будет удалено». */
|
||||
export function PendingChip({ mark }: { mark: 'new' | 'dirty' | 'deleted' }) {
|
||||
return (
|
||||
<Badge variant={mark === 'deleted' ? 'destructive-light' : 'warning-light'} size="sm" className="whitespace-nowrap">
|
||||
{mark === 'deleted' ? 'Будет удалено' : 'Не сохранено'}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
/** Вкладки страницы как на досках: линия-подчёркивание, число — мелким индексом. */
|
||||
export function PageTabsList({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<TabsList variant="line" className="h-10! w-full justify-start gap-0.5 overflow-x-auto">
|
||||
{children}
|
||||
</TabsList>
|
||||
)
|
||||
}
|
||||
|
||||
export function PageTab({ value, label, count }: { value: string; label: string; count?: number | string }) {
|
||||
return (
|
||||
<TabsTrigger value={value} className="flex-none gap-1.5 px-4 text-sm">
|
||||
{label}
|
||||
{count !== undefined ? <span className="text-muted-foreground font-mono text-xs font-normal">{count}</span> : null}
|
||||
</TabsTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
/** Строка настройки как на досках: слева название и описание, справа элемент управления. */
|
||||
export function SettingRow({ title, description, children }: { title: string; description: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 border-b px-4 py-3.5 last:border-b-0">
|
||||
<div className="flex min-w-0 flex-1 basis-64 flex-col gap-0.5">
|
||||
<b className="text-sm font-medium">{title}</b>
|
||||
<span className="text-muted-foreground text-[13px]">{description}</span>
|
||||
</div>
|
||||
<div className="shrink-0">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Frame с заголовком, итоговой плашкой справа и строками настроек (блок «form» досок). */
|
||||
export function FormFrame({
|
||||
title,
|
||||
description,
|
||||
badge,
|
||||
tone = 'ok',
|
||||
children,
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
badge: string
|
||||
tone?: Tone
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Frame className="[--frame-panel-header-py-adjust:4px] w-full min-w-0">
|
||||
<FrameHeader className="flex-row flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<FrameTitle>{title}</FrameTitle>
|
||||
<FrameDescription>{description}</FrameDescription>
|
||||
</div>
|
||||
<StatusBadge tone={tone}>{badge}</StatusBadge>
|
||||
</FrameHeader>
|
||||
<FramePanel className="p-0">{children}</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
import { ChromeConfigContext, UiStoreContext, type ChromeConfig, type Contrast, type Density, type UiStore } from './chrome-store'
|
||||
|
||||
export function ChromeConfigProvider({ value, children }: { value: ChromeConfig; children: ReactNode }) {
|
||||
return <ChromeConfigContext.Provider value={value}>{children}</ChromeConfigContext.Provider>
|
||||
}
|
||||
|
||||
function readStored<T>(key: string, fallback: T): T {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(key)
|
||||
return raw ? (JSON.parse(raw) as T) : fallback
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
function writeStored(key: string, value: unknown) {
|
||||
try {
|
||||
window.localStorage.setItem(key, JSON.stringify(value))
|
||||
} catch {
|
||||
/* без сохранения настроек */
|
||||
}
|
||||
}
|
||||
|
||||
/** Плотность, контраст, закреплённые пункты и палитра ⌘K. Атрибуты html[data-density|contrast] читает portal-theme.css. */
|
||||
export function PortalUiProvider({ children, storageKey = 'portal-ui' }: { children: ReactNode; storageKey?: string }) {
|
||||
const [paletteOpen, setPaletteOpen] = useState(false)
|
||||
const [density, setDensityState] = useState<Density>(() => readStored(`${storageKey}:density`, 'comfortable'))
|
||||
const [contrast, setContrastState] = useState<Contrast>(() => readStored(`${storageKey}:contrast`, 'standard'))
|
||||
const [pins, setPins] = useState<string[]>(() => readStored(`${storageKey}:pins`, []))
|
||||
|
||||
const setDensity = useCallback(
|
||||
(v: Density) => {
|
||||
setDensityState(v)
|
||||
writeStored(`${storageKey}:density`, v)
|
||||
},
|
||||
[storageKey],
|
||||
)
|
||||
const setContrast = useCallback(
|
||||
(v: Contrast) => {
|
||||
setContrastState(v)
|
||||
writeStored(`${storageKey}:contrast`, v)
|
||||
},
|
||||
[storageKey],
|
||||
)
|
||||
const togglePin = useCallback(
|
||||
(id: string) => {
|
||||
setPins((prev) => {
|
||||
const next = prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
|
||||
writeStored(`${storageKey}:pins`, next)
|
||||
return next
|
||||
})
|
||||
},
|
||||
[storageKey],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement
|
||||
root.dataset.density = density
|
||||
root.dataset.contrast = contrast
|
||||
return () => {
|
||||
delete root.dataset.density
|
||||
delete root.dataset.contrast
|
||||
}
|
||||
}, [density, contrast])
|
||||
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
||||
e.preventDefault()
|
||||
setPaletteOpen((v) => !v)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [])
|
||||
|
||||
const value = useMemo<UiStore>(
|
||||
() => ({ paletteOpen, setPaletteOpen, pins, togglePin, density, setDensity, contrast, setContrast }),
|
||||
[paletteOpen, pins, togglePin, density, setDensity, contrast, setContrast],
|
||||
)
|
||||
return <UiStoreContext.Provider value={value}>{children}</UiStoreContext.Provider>
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { createContext, useContext, type ComponentType, type ReactNode } from 'react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
|
||||
export type NavItem = {
|
||||
id: string
|
||||
label: string
|
||||
to: string
|
||||
icon: LucideIcon
|
||||
group: string
|
||||
/** Точка проблемы в углу иконки. */
|
||||
problem?: { tone: 'er' | 'wn'; label: string }
|
||||
}
|
||||
|
||||
export type MonitorItem = {
|
||||
id: string
|
||||
tone: 'er' | 'wn' | 'ok'
|
||||
title: string
|
||||
text: string
|
||||
age: string
|
||||
to: string
|
||||
}
|
||||
|
||||
export type MonitorMetric = { l: string; v: string; p: number; tone: 'warning' | 'success' }
|
||||
|
||||
export type PaletteAction = {
|
||||
id: string
|
||||
label: string
|
||||
/** Горячая буква справа. */
|
||||
hint: string
|
||||
icon: ComponentType<{ className?: string; 'aria-hidden'?: boolean }>
|
||||
to: string
|
||||
}
|
||||
|
||||
export type PaletteObject = {
|
||||
id: string
|
||||
group: string
|
||||
label: string
|
||||
hint: string
|
||||
dot?: 'ok' | 'er' | 'mu'
|
||||
to: string
|
||||
}
|
||||
|
||||
/** Данные оболочки: меню, монитор, палитра и пользователь. В макете — суррогатные, в портале — из API. */
|
||||
export type ChromeConfig = {
|
||||
navItems: NavItem[]
|
||||
navGroups: string[]
|
||||
/** Верх бокового меню: бренд и переключатель приложений. */
|
||||
brand: ReactNode
|
||||
user: { name: string; email: string; initials: string }
|
||||
securityTo: string
|
||||
onLogout: () => void
|
||||
fresh: (pathname: string) => { badge: string; text: string }
|
||||
crumbFor?: (pathname: string, label: string | undefined) => string
|
||||
monitor: { items: MonitorItem[]; metrics: MonitorMetric[]; updated: string; footerTo: string; footerLabel: string }
|
||||
palette: { actions: PaletteAction[]; objects: PaletteObject[]; assistantTo: string; example: string }
|
||||
}
|
||||
|
||||
export const ChromeConfigContext = createContext<ChromeConfig | null>(null)
|
||||
|
||||
export function useChromeConfig(): ChromeConfig {
|
||||
const ctx = useContext(ChromeConfigContext)
|
||||
if (!ctx) throw new Error('ChromeConfigProvider отсутствует')
|
||||
return ctx
|
||||
}
|
||||
|
||||
export type Density = 'comfortable' | 'compact'
|
||||
export type Contrast = 'standard' | 'high'
|
||||
|
||||
export type UiStore = {
|
||||
paletteOpen: boolean
|
||||
setPaletteOpen: (open: boolean) => void
|
||||
pins: string[]
|
||||
togglePin: (id: string) => void
|
||||
density: Density
|
||||
setDensity: (v: Density) => void
|
||||
contrast: Contrast
|
||||
setContrast: (v: Contrast) => void
|
||||
}
|
||||
|
||||
export const UiStoreContext = createContext<UiStore | null>(null)
|
||||
|
||||
export function useUiStore(): UiStore {
|
||||
const ctx = useContext(UiStoreContext)
|
||||
if (!ctx) throw new Error('PortalUiProvider отсутствует')
|
||||
return ctx
|
||||
}
|
||||
@@ -0,0 +1,569 @@
|
||||
import {
|
||||
useMemo,
|
||||
useState,
|
||||
type ComponentType,
|
||||
} from 'react'
|
||||
import {
|
||||
Link,
|
||||
useNavigate,
|
||||
useRouterState,
|
||||
} from '@tanstack/react-router'
|
||||
import {
|
||||
ActivityIcon,
|
||||
ArrowRightIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronsUpDownIcon,
|
||||
FingerprintIcon,
|
||||
InfoIcon,
|
||||
LogOutIcon,
|
||||
SearchIcon,
|
||||
StarIcon,
|
||||
MoonIcon,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
useTheme,
|
||||
} from 'next-themes'
|
||||
import {
|
||||
Badge,
|
||||
} from '@/components/reui/badge'
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
} from '@evofw/ui/components/breadcrumb'
|
||||
import {
|
||||
Button,
|
||||
} from '@evofw/ui/components/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from '@evofw/ui/components/dialog'
|
||||
import {
|
||||
Kbd,
|
||||
} from '@evofw/ui/components/kbd'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@evofw/ui/components/popover'
|
||||
import {
|
||||
Separator,
|
||||
} from '@evofw/ui/components/separator'
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
} from '@evofw/ui/components/sidebar'
|
||||
import {
|
||||
cn,
|
||||
} from '@evofw/ui/lib/utils'
|
||||
import {
|
||||
Segmented,
|
||||
} from './bits'
|
||||
import {
|
||||
useChromeConfig,
|
||||
useUiStore,
|
||||
type NavItem,
|
||||
} from './chrome-store'
|
||||
|
||||
const LABEL_CLASS = 'text-muted-foreground text-[11px] font-medium uppercase'
|
||||
|
||||
function PortalNavUser() {
|
||||
const { isMobile } = useSidebar()
|
||||
const { theme, setTheme, resolvedTheme } = useTheme()
|
||||
const { density, setDensity, contrast, setContrast } = useUiStore()
|
||||
const { user, securityTo, onLogout } = useChromeConfig()
|
||||
const navigate = useNavigate()
|
||||
const current = theme === 'light' || theme === 'dark' ? theme : resolvedTheme === 'light' ? 'light' : 'dark'
|
||||
|
||||
return (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
aria-label={`Профиль и настройки интерфейса: ${user.name}, ${user.email}`}
|
||||
className="bg-muted/40 aria-expanded:bg-muted"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="bg-background text-foreground flex size-8 shrink-0 items-center justify-center rounded-full border text-xs font-medium">
|
||||
{user.initials}
|
||||
</div>
|
||||
<div className="grid min-w-0 flex-1 text-left leading-tight">
|
||||
<span className="truncate text-sm font-semibold">{user.name}</span>
|
||||
<span className="text-muted-foreground truncate text-xs">{user.email}</span>
|
||||
</div>
|
||||
<ChevronsUpDownIcon className="text-muted-foreground ml-auto size-4" aria-hidden />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side={isMobile ? 'top' : 'right'} align="end" sideOffset={8} className="w-72 gap-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="bg-background flex size-8 shrink-0 items-center justify-center rounded-full border text-xs font-medium">
|
||||
{user.initials}
|
||||
</div>
|
||||
<div className="grid leading-tight">
|
||||
<span className="text-sm font-semibold">{user.name}</span>
|
||||
<span className="text-muted-foreground text-xs">{user.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void navigate({ to: securityTo })}
|
||||
className="hover:bg-muted flex items-center gap-2 rounded-md px-1 py-1 text-left text-sm"
|
||||
>
|
||||
<FingerprintIcon className="text-muted-foreground size-4" aria-hidden />
|
||||
Безопасность
|
||||
</button>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className={LABEL_CLASS}>Тема</span>
|
||||
<Segmented
|
||||
label="Тема"
|
||||
options={['Тёмная', 'Светлая']}
|
||||
value={current === 'light' ? 1 : 0}
|
||||
onChange={(i) => setTheme(i === 1 ? 'light' : 'dark')}
|
||||
/>
|
||||
<span className={LABEL_CLASS}>Плотность</span>
|
||||
<Segmented
|
||||
label="Плотность"
|
||||
options={['Обычная', 'Компактная']}
|
||||
value={density === 'compact' ? 1 : 0}
|
||||
onChange={(i) => setDensity(i === 1 ? 'compact' : 'comfortable')}
|
||||
/>
|
||||
<span className={LABEL_CLASS}>Контраст</span>
|
||||
<Segmented
|
||||
label="Контраст"
|
||||
options={['Стандартный', 'Высокий']}
|
||||
value={contrast === 'high' ? 1 : 0}
|
||||
onChange={(i) => setContrast(i === 1 ? 'high' : 'standard')}
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLogout}
|
||||
className="text-muted-foreground hover:text-foreground flex items-center gap-2 text-left text-sm"
|
||||
>
|
||||
<LogOutIcon className="size-4" aria-hidden />
|
||||
Выйти
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
)
|
||||
}
|
||||
|
||||
const DOT_CLASS = { er: 'bg-destructive', wn: 'bg-warning' } as const
|
||||
|
||||
function NavEntry({ item, pathname, pinned, onTogglePin }: { item: NavItem; pathname: string; pinned: boolean; onTogglePin: () => void }) {
|
||||
const Icon = item.icon
|
||||
const active = pathname === item.to || pathname.startsWith(`${item.to}/`)
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
tooltip={item.problem ? `${item.label} · ${item.problem.label}` : item.label}
|
||||
isActive={active}
|
||||
className="relative pr-8 group-data-[collapsible=icon]:pr-2!"
|
||||
render={<Link to={item.to} />}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
<span>{item.label}</span>
|
||||
{item.problem ? (
|
||||
<span
|
||||
role="img"
|
||||
aria-label={item.problem.label}
|
||||
className={cn(
|
||||
'ml-auto size-2 shrink-0 rounded-full group-data-[collapsible=icon]:absolute group-data-[collapsible=icon]:top-1 group-data-[collapsible=icon]:right-1 group-data-[collapsible=icon]:ml-0 group-data-[collapsible=icon]:size-1.5 group-data-[collapsible=icon]:ring-2 group-data-[collapsible=icon]:ring-sidebar',
|
||||
DOT_CLASS[item.problem.tone],
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
</SidebarMenuButton>
|
||||
<SidebarMenuAction
|
||||
showOnHover={!pinned}
|
||||
aria-label={pinned ? `Открепить ${item.label}` : `Закрепить ${item.label}`}
|
||||
aria-pressed={pinned}
|
||||
onClick={onTogglePin}
|
||||
>
|
||||
<StarIcon className={cn(pinned && 'fill-current')} aria-hidden />
|
||||
</SidebarMenuAction>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
export function PortalSidebar() {
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||
const { pins, togglePin } = useUiStore()
|
||||
const { navItems, navGroups, brand } = useChromeConfig()
|
||||
const [closed, setClosed] = useState<Record<string, boolean>>({})
|
||||
const pinned = navItems.filter((i) => pins.includes(i.id))
|
||||
return (
|
||||
<Sidebar collapsible="icon">
|
||||
<SidebarHeader className="pb-1.5">
|
||||
{brand}
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
{pinned.length > 0 ? (
|
||||
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
|
||||
<SidebarGroupLabel className="text-[11px] font-semibold tracking-wide uppercase">Закреплённые</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{pinned.map((item) => (
|
||||
<NavEntry key={item.id} item={item} pathname={pathname} pinned onTogglePin={() => togglePin(item.id)} />
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
) : null}
|
||||
{navGroups.map((group, gi) => (
|
||||
<div key={group} className="flex flex-col">
|
||||
{gi > 0 ? <SidebarSeparator className="mx-2 my-1 h-px shrink-0 hidden group-data-[collapsible=icon]:block" /> : null}
|
||||
<SidebarGroup className="py-0">
|
||||
<SidebarGroupLabel
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={!closed[group]}
|
||||
onClick={() => setClosed((c) => ({ ...c, [group]: !c[group] }))}
|
||||
/>
|
||||
}
|
||||
className="h-7 cursor-pointer justify-between text-[11px] font-semibold tracking-wide uppercase"
|
||||
>
|
||||
{group}
|
||||
<ChevronDownIcon className={cn('size-3.5 transition-transform', closed[group] && '-rotate-90')} aria-hidden />
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent className={cn(closed[group] && 'group-data-[collapsible=icon]:block hidden')}>
|
||||
<SidebarMenu className="gap-px">
|
||||
{navItems.filter((i) => i.group === group).map((item) => (
|
||||
<NavEntry
|
||||
key={item.id}
|
||||
item={item}
|
||||
pathname={pathname}
|
||||
pinned={pins.includes(item.id)}
|
||||
onTogglePin={() => togglePin(item.id)}
|
||||
/>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</div>
|
||||
))}
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<PortalNavUser />
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
)
|
||||
}
|
||||
|
||||
/** Единый центр состояния: внимание + сводка + список проблем. Адаптивен по ширине окна, как на досках. */
|
||||
function MonitorPopover() {
|
||||
const { monitor } = useChromeConfig()
|
||||
const MONITOR_ITEMS = monitor.items
|
||||
const MONITOR_METRICS = monitor.metrics
|
||||
const bad = MONITOR_ITEMS.filter((m) => m.tone !== 'ok')
|
||||
const errors = bad.filter((m) => m.tone === 'er').length
|
||||
const warnings = bad.length - errors
|
||||
const ok = MONITOR_ITEMS.length - bad.length
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
aria-label={`Состояние системы: внимание, ${errors} сбой, ${warnings} предупреждения`}
|
||||
className="border-warning/40 bg-warning/10 hover:bg-warning/20 aria-expanded:bg-warning/20 relative h-8 gap-2 px-2 max-[760px]:w-8 max-[760px]:border-transparent max-[760px]:bg-transparent max-[760px]:px-0"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ActivityIcon className="text-warning size-4" aria-hidden />
|
||||
<span className="text-warning hidden text-sm font-medium min-[1000px]:inline">Внимание</span>
|
||||
<span className="hidden items-center gap-1.5 text-sm tabular-nums min-[1240px]:inline-flex">
|
||||
<span className="bg-destructive size-1.5 rounded-full" aria-hidden />
|
||||
{errors}
|
||||
<span className="bg-warning size-1.5 rounded-full" aria-hidden />
|
||||
{warnings}
|
||||
</span>
|
||||
<span className="bg-destructive hidden size-4.5 min-w-4.5 items-center justify-center rounded-full px-1 text-[11px] font-semibold text-white min-[760px]:inline-flex min-[1240px]:hidden">
|
||||
{bad.length}
|
||||
</span>
|
||||
<span className="bg-destructive ring-background absolute top-1 right-1 size-2 rounded-full ring-2 min-[760px]:hidden" aria-hidden />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="flex w-[24rem] max-w-[calc(100vw-1.5rem)] flex-col gap-2 p-2">
|
||||
<div className="flex items-start justify-between gap-2 px-1.5 pt-1.5">
|
||||
<div className="grid leading-tight">
|
||||
<b className="text-sm font-semibold">Состояние системы</b>
|
||||
<span className="text-muted-foreground text-xs">{monitor.updated}</span>
|
||||
</div>
|
||||
<Badge variant={errors > 0 ? "destructive-light" : warnings > 0 ? "warning-light" : "success-light"} size="sm">{errors > 0 ? "Сбой" : warnings > 0 ? "Внимание" : "Норма"}</Badge>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
{[
|
||||
{ n: errors, l: 'сбой', c: 'text-destructive' },
|
||||
{ n: warnings, l: 'предупреждения', c: 'text-warning' },
|
||||
{ n: ok, l: 'в норме', c: 'text-success' },
|
||||
].map((x) => (
|
||||
<span key={x.l} className="bg-muted/55 text-muted-foreground flex flex-col rounded-lg px-2.5 py-2 text-[11.5px]">
|
||||
<b className={cn('text-lg leading-tight font-semibold tabular-nums', x.c)}>{x.n}</b>
|
||||
{x.l}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<ul className="flex flex-col gap-0.5">
|
||||
{MONITOR_ITEMS.filter((m) => m.tone !== 'ok').map((m) => (
|
||||
<li key={m.id}>
|
||||
<Link to={m.to} className="hover:bg-muted/55 flex items-center gap-2.5 rounded-md px-1.5 py-2 outline-none focus-visible:bg-muted/55">
|
||||
<span aria-hidden className={cn('size-2 shrink-0 rounded-full', m.tone === 'er' ? 'bg-destructive' : 'bg-warning')} />
|
||||
<span className="grid min-w-0 flex-1 leading-tight">
|
||||
<b className="truncate text-[13px] font-medium">{m.title}</b>
|
||||
<span className="text-muted-foreground truncate text-xs">{m.text}</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground shrink-0 text-[11.5px]">{m.age}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{MONITOR_METRICS.map((m) => (
|
||||
<div key={m.l} className="bg-muted/55 flex min-w-0 flex-col gap-1.5 rounded-lg px-3 py-2.5">
|
||||
<span className="text-muted-foreground text-xs leading-snug">{m.l}</span>
|
||||
<b className={cn('text-base font-semibold whitespace-nowrap tabular-nums', m.tone === 'success' ? 'text-success' : 'text-warning')}>{m.v}</b>
|
||||
<div className="bg-muted-foreground/25 mt-auto h-[3px] overflow-hidden rounded-full">
|
||||
<i className={cn('block h-full rounded-full', m.tone === 'success' ? 'bg-success' : 'bg-warning')} style={{ width: `${m.p}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Link to={monitor.footerTo} className="text-muted-foreground hover:text-foreground flex items-center justify-between px-1.5 pt-1 pb-0.5 text-sm">
|
||||
{monitor.footerLabel}
|
||||
<ArrowRightIcon className="size-3.5" aria-hidden />
|
||||
</Link>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
function FreshnessPopover() {
|
||||
const pathname = useRouterState({ select: (st) => st.location.pathname })
|
||||
const { fresh: freshFor } = useChromeConfig()
|
||||
const fresh = freshFor(pathname)
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button type="button" variant="ghost" size="icon" aria-label="Источники и свежесть данных" title="Источники и свежесть данных" />
|
||||
}
|
||||
>
|
||||
<InfoIcon aria-hidden />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" sideOffset={8} className="flex w-80 max-w-[calc(100vw-2rem)] flex-col gap-2">
|
||||
<Badge variant="success-light" size="sm" className="w-fit">{fresh.badge}</Badge>
|
||||
<p className="text-muted-foreground text-sm">{fresh.text}</p>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
export function PortalHeader() {
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||
const { setPaletteOpen } = useUiStore()
|
||||
const { navItems, crumbFor } = useChromeConfig()
|
||||
const current = navItems.find((i) => pathname === i.to || pathname.startsWith(`${i.to}/`))
|
||||
|
||||
return (
|
||||
<header className="bg-background/80 sticky top-0 z-20 flex min-h-12 shrink-0 items-center gap-2 border-b px-4 py-1.5 backdrop-blur md:px-6">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
|
||||
<Breadcrumb className="min-w-0">
|
||||
<BreadcrumbList className="flex-nowrap">
|
||||
<BreadcrumbItem className="min-w-0">
|
||||
<BreadcrumbPage className="truncate">{crumbFor ? crumbFor(pathname, current?.label) : (current?.label ?? 'Auth Portal')}</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-2">
|
||||
<FreshnessPopover />
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label="Командная палитра (⌘K)"
|
||||
className="md:hidden"
|
||||
onClick={() => setPaletteOpen(true)}
|
||||
>
|
||||
<SearchIcon aria-hidden />
|
||||
</Button>
|
||||
<MonitorPopover />
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
type PaletteEntry = {
|
||||
id: string
|
||||
group: string
|
||||
label: string
|
||||
hint: string
|
||||
icon?: ComponentType<{ className?: string; 'aria-hidden'?: boolean }>
|
||||
dot?: 'ok' | 'er' | 'mu'
|
||||
run: () => void
|
||||
}
|
||||
|
||||
const DOT_BG = { ok: 'bg-success', er: 'bg-destructive', mu: 'bg-muted-foreground' } as const
|
||||
|
||||
/** Палитра ⌘K как на доске: 640px, группы заглавными, подсказки моноширинным справа, подвал с подсказкой. */
|
||||
export function CommandPalette() {
|
||||
const { paletteOpen, setPaletteOpen } = useUiStore()
|
||||
const { navItems, palette } = useChromeConfig()
|
||||
const navigate = useNavigate()
|
||||
const { theme, setTheme, resolvedTheme } = useTheme()
|
||||
const [query, setQuery] = useState('')
|
||||
const [active, setActive] = useState(0)
|
||||
|
||||
const shown = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
const match = (...t: string[]) => !q || t.some((x) => x.toLowerCase().includes(q))
|
||||
const go = (to: string) => () => void navigate({ to })
|
||||
const current = theme === 'light' || theme === 'dark' ? theme : resolvedTheme === 'light' ? 'light' : 'dark'
|
||||
const list: PaletteEntry[] = [
|
||||
...[
|
||||
...palette.actions.map((x) => ({ id: x.id, label: x.label, hint: x.hint, icon: x.icon, run: go(x.to) })),
|
||||
{ id: 'act-theme', label: 'Переключить тему', hint: 'T', icon: MoonIcon, run: () => setTheme(current === 'dark' ? 'light' : 'dark') },
|
||||
]
|
||||
.filter((e) => match(e.label))
|
||||
.map((e) => ({ ...e, group: 'Действия' })),
|
||||
...navItems.filter((i) => match(i.label, i.group))
|
||||
.slice(0, 7)
|
||||
.map((i) => ({ id: `nav-${i.id}`, group: 'Разделы', label: i.label, hint: i.group, icon: i.icon, run: go(i.to) })),
|
||||
...Object.values(
|
||||
palette.objects
|
||||
.filter((o) => match(o.label, o.hint))
|
||||
.reduce<Record<string, typeof palette.objects>>((acc, o) => ((acc[o.group] ??= []).push(o), acc), {}),
|
||||
).flatMap((g) => g.slice(0, 5).map((o) => ({ id: o.id, group: o.group, label: o.label, hint: o.hint, dot: o.dot, run: go(o.to) }))),
|
||||
]
|
||||
// Запрос из трёх и более слов — черновик ассистента (как на доске «Палитра ⌘K»).
|
||||
if (query.trim().split(/\s+/).filter(Boolean).length >= 3) {
|
||||
return [
|
||||
{
|
||||
id: 'ai',
|
||||
group: 'Ассистент · запрос на естественном языке',
|
||||
label: `Понял так: «${query.trim()}»`,
|
||||
hint: 'Черновик: запрос разобран автоматически, результат нужно проверить.',
|
||||
run: () => void navigate({ to: palette.assistantTo }),
|
||||
} as PaletteEntry,
|
||||
...list,
|
||||
]
|
||||
}
|
||||
return list
|
||||
}, [query, navigate, theme, resolvedTheme, setTheme, navItems, palette])
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const map = new Map<string, Array<PaletteEntry & { index: number }>>()
|
||||
shown.forEach((e, index) => {
|
||||
const list = map.get(e.group) ?? []
|
||||
list.push({ ...e, index })
|
||||
map.set(e.group, list)
|
||||
})
|
||||
return [...map.entries()]
|
||||
}, [shown])
|
||||
|
||||
function close() {
|
||||
setPaletteOpen(false)
|
||||
setQuery('')
|
||||
setActive(0)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={paletteOpen} onOpenChange={(o) => (o ? setPaletteOpen(true) : close())}>
|
||||
<DialogContent className="top-[90px] w-[min(40rem,94vw)] max-w-none translate-y-0 gap-0 overflow-hidden p-0 sm:max-w-none" showCloseButton={false}>
|
||||
<DialogTitle className="sr-only">Командная палитра</DialogTitle>
|
||||
<DialogDescription className="sr-only">Разделы, пользователи, приложения и действия</DialogDescription>
|
||||
<div className="flex items-center gap-2.5 border-b px-4 py-3.5">
|
||||
<SearchIcon className="text-muted-foreground size-[18px]" aria-hidden />
|
||||
<input
|
||||
autoFocus
|
||||
value={query}
|
||||
aria-label="Поиск и команды"
|
||||
placeholder="Раздел, пользователь, приложение или действие…"
|
||||
className="min-w-0 flex-1 bg-transparent text-[15px] outline-none"
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value)
|
||||
setActive(0)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setActive((a) => Math.min(a + 1, shown.length - 1))
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setActive((a) => Math.max(a - 1, 0))
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
shown[active]?.run()
|
||||
close()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Kbd>Esc</Kbd>
|
||||
</div>
|
||||
<div className="max-h-[430px] overflow-y-auto" role="listbox" aria-label="Результаты">
|
||||
{groups.length === 0 ? (
|
||||
<p className="text-muted-foreground px-4 py-6 text-center text-sm">Ничего не найдено</p>
|
||||
) : (
|
||||
groups.map(([group, list]) => (
|
||||
<div key={group} className="flex flex-col">
|
||||
<div className="text-muted-foreground px-4 pt-2.5 pb-1 text-[11px] tracking-[0.05em] uppercase">{group}</div>
|
||||
{list.map((e) => {
|
||||
const Icon = e.icon
|
||||
return (
|
||||
<button
|
||||
key={e.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={e.index === active}
|
||||
onMouseEnter={() => setActive(e.index)}
|
||||
onClick={() => {
|
||||
e.run()
|
||||
close()
|
||||
}}
|
||||
className={cn('flex w-full items-center gap-3 px-4 py-[9px] text-left text-sm outline-none', e.index === active && 'bg-muted/60')}
|
||||
>
|
||||
{Icon ? <Icon className="size-4 shrink-0" aria-hidden /> : null}
|
||||
{e.dot ? <span className={cn('size-2 shrink-0 rounded-full', DOT_BG[e.dot])} aria-hidden /> : null}
|
||||
<span className="min-w-0 truncate">{e.label}</span>
|
||||
<span className="text-muted-foreground ml-auto truncate font-mono text-xs">{e.hint}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="text-muted-foreground flex flex-wrap gap-x-4 gap-y-1 border-t px-4 py-2 text-[11.5px]">
|
||||
<span>↑↓ выбор</span>
|
||||
<span>↵ открыть</span>
|
||||
<span>Esc закрыть</span>
|
||||
<span className="ml-auto">{palette.example}</span>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import type { DataGrid } from '@/components/reui/data-grid/data-grid'
|
||||
|
||||
type GridProps = ComponentProps<typeof DataGrid>
|
||||
|
||||
export const DATA_GRID_CELL_PAD = 'py-3'
|
||||
export const DATA_GRID_CELL_PAD_FIRST = 'pl-5 py-3'
|
||||
export const DATA_GRID_CELL_PAD_LAST = 'pr-4 py-3'
|
||||
|
||||
export const DATA_GRID_LAYOUT: NonNullable<GridProps['tableLayout']> = {
|
||||
rowBorder: true,
|
||||
headerBackground: true,
|
||||
headerBorder: true,
|
||||
columnsResizable: false,
|
||||
}
|
||||
|
||||
export const DATA_GRID_CLASS_NAMES: NonNullable<GridProps['tableClassNames']> = {
|
||||
headerRow: 'border-b border-border',
|
||||
bodyRow: 'group/row',
|
||||
}
|
||||
|
||||
export const DATA_GRID_CONTAINER_CLASS = 'w-full rounded-none border-0'
|
||||
|
||||
/** Отступы ячейки по положению колонки: первая — pl-5, последняя — pr-4. */
|
||||
export function cellPad(index: number, count: number) {
|
||||
if (index === 0) return DATA_GRID_CELL_PAD_FIRST
|
||||
if (index === count - 1) return DATA_GRID_CELL_PAD_LAST
|
||||
return DATA_GRID_CELL_PAD
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Каркас списочной страницы как в MikrotikManager (/servers): карточка Frame без полей, тулбар
|
||||
* «сегменты · фильтры · поиск · счётчик», таблица с подложкой шапки и ячейками py-3.
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import { SearchIcon } from 'lucide-react'
|
||||
import { Frame, FramePanel } from '@/components/reui/frame'
|
||||
import { InputGroup, InputGroupAddon, InputGroupInput } from '@evofw/ui/components/input-group'
|
||||
import { cn } from '@evofw/ui/lib/utils'
|
||||
|
||||
export function DataPageCard({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<Frame dense className={cn('w-full min-w-0', className)}>
|
||||
<FramePanel className="flex w-full min-w-0 flex-col gap-0 overflow-hidden p-0 py-0 shadow-none">{children}</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
export function SegmentedControl<T extends string>({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
label,
|
||||
}: {
|
||||
value: T
|
||||
onChange: (value: T) => void
|
||||
options: { value: T; label: string; count?: number }[]
|
||||
label?: string
|
||||
}) {
|
||||
return (
|
||||
<div role="group" aria-label={label} className="border-border bg-muted/40 flex w-fit items-center gap-1 rounded-md border p-0.5">
|
||||
{options.map((o) => (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
aria-pressed={value === o.value}
|
||||
onClick={() => onChange(o.value)}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded px-3 py-1 text-sm transition-colors',
|
||||
value === o.value ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{o.label}
|
||||
{o.count != null ? <span className="text-xs tabular-nums">{o.count}</span> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DataPageToolbar({
|
||||
leading,
|
||||
segmented,
|
||||
filterControl,
|
||||
search,
|
||||
onSearchChange,
|
||||
searchPlaceholder = 'Поиск…',
|
||||
countLabel,
|
||||
actions,
|
||||
}: {
|
||||
leading?: ReactNode
|
||||
segmented?: { value: string; onChange: (value: string) => void; options: { value: string; label: string; count?: number }[]; label?: string }
|
||||
filterControl?: ReactNode
|
||||
search?: string
|
||||
onSearchChange?: (value: string) => void
|
||||
searchPlaceholder?: string
|
||||
countLabel?: string
|
||||
actions?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3 border-b px-5 py-3">
|
||||
{leading}
|
||||
{segmented ? <SegmentedControl {...segmented} /> : null}
|
||||
{filterControl}
|
||||
{onSearchChange ? (
|
||||
<InputGroup className="max-w-sm min-w-[220px]">
|
||||
<InputGroupAddon>
|
||||
<SearchIcon className="size-3.5" aria-hidden />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput aria-label={searchPlaceholder} placeholder={searchPlaceholder} value={search ?? ''} onChange={(e) => onSearchChange(e.target.value)} />
|
||||
</InputGroup>
|
||||
) : null}
|
||||
{countLabel ? <span className="text-muted-foreground ml-auto text-sm">{countLabel}</span> : null}
|
||||
{actions}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
const DATE = new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
const SHORT = new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: '2-digit' })
|
||||
const TIME = new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit' })
|
||||
|
||||
function parse(iso: string | null | undefined): Date | null {
|
||||
if (!iso) return null
|
||||
const d = new Date(iso)
|
||||
return Number.isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
|
||||
/** 12.09.2026 */
|
||||
export function fmtDate(iso: string | null | undefined, empty = '—') {
|
||||
const d = parse(iso)
|
||||
return d ? DATE.format(d) : empty
|
||||
}
|
||||
|
||||
/** «сегодня 09:41», «вчера 22:15», иначе «12.09 09:41». */
|
||||
export function fmtWhen(iso: string | null | undefined, empty = '—', now = Date.now()) {
|
||||
const d = parse(iso)
|
||||
if (!d) return empty
|
||||
const dayMs = 24 * 60 * 60 * 1000
|
||||
const startOfToday = new Date(now)
|
||||
startOfToday.setHours(0, 0, 0, 0)
|
||||
const diff = startOfToday.getTime() - new Date(d).setHours(0, 0, 0, 0)
|
||||
if (diff === 0) return `сегодня ${TIME.format(d)}`
|
||||
if (diff === dayMs) return `вчера ${TIME.format(d)}`
|
||||
return `${SHORT.format(d)} ${TIME.format(d)}`
|
||||
}
|
||||
|
||||
/** Время дня 14:02. */
|
||||
export function fmtTime(iso: string | null | undefined, empty = '—') {
|
||||
const d = parse(iso)
|
||||
return d ? TIME.format(d) : empty
|
||||
}
|
||||
|
||||
/** «5 дн назад», «3 ч назад», «только что». */
|
||||
export function fmtAgo(iso: string | null | undefined, empty = '—', now = Date.now()) {
|
||||
const d = parse(iso)
|
||||
if (!d) return empty
|
||||
const sec = Math.max(0, Math.round((now - d.getTime()) / 1000))
|
||||
if (sec < 60) return 'только что'
|
||||
if (sec < 3600) return `${Math.floor(sec / 60)} мин назад`
|
||||
if (sec < 86400) return `${Math.floor(sec / 3600)} ч назад`
|
||||
return `${Math.floor(sec / 86400)} дн назад`
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { IconTile } from '@/components/reui/icon-tile'
|
||||
import { Frame, FramePanel } from '@/components/reui/frame'
|
||||
import { cn } from '@evofw/ui/lib/utils'
|
||||
import type { KpiItem, KpiTone } from './kpi'
|
||||
|
||||
const VALUE: Record<KpiTone, string> = {
|
||||
neutral: 'text-foreground',
|
||||
ok: 'text-foreground',
|
||||
in: 'text-foreground',
|
||||
wn: 'text-warning',
|
||||
er: 'text-destructive',
|
||||
}
|
||||
|
||||
/** Колонки по числу ячеек и ширине окна (на досках — ширина приложения): 4 → 4/2/1, 3 → 3/1, 2 → 2/1. */
|
||||
const COLS: Record<number, string> = {
|
||||
1: 'grid-cols-1',
|
||||
2: 'grid-cols-2 max-[640px]:grid-cols-1',
|
||||
3: 'grid-cols-1 min-[1240px]:grid-cols-3',
|
||||
4: 'grid-cols-1 min-[640px]:grid-cols-2 min-[1240px]:grid-cols-4',
|
||||
}
|
||||
|
||||
const TILE: Record<KpiTone, string> = {
|
||||
neutral: 'text-muted-foreground',
|
||||
ok: 'text-success',
|
||||
in: 'text-info',
|
||||
wn: 'text-warning',
|
||||
er: 'text-destructive',
|
||||
}
|
||||
|
||||
const GLYPH: Record<KpiTone, string> = { neutral: '', ok: '●', in: '', wn: '▲', er: '✕' }
|
||||
|
||||
const BADGE = {
|
||||
neutral: 'outline',
|
||||
ok: 'success-light',
|
||||
in: 'info-light',
|
||||
wn: 'warning-light',
|
||||
er: 'destructive-light',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Полоса показателей как на досках: один Frame, ячейки разделены линиями,
|
||||
* в ячейке подпись, крупное значение и плашка-подсказка.
|
||||
*/
|
||||
export function KpiStrip({ items, 'aria-label': ariaLabel }: { items: KpiItem[]; 'aria-label'?: string }) {
|
||||
return (
|
||||
<Frame spacing="sm" className="w-full min-w-0" aria-label={ariaLabel}>
|
||||
<FramePanel className="overflow-hidden p-0">
|
||||
<div className={cn('bg-border grid gap-px', COLS[Math.min(Math.max(items.length, 1), 4)])}>
|
||||
{items.map((k) => {
|
||||
const Icon = k.icon
|
||||
return (
|
||||
<div key={k.id} className="bg-card flex min-w-0 items-center gap-3 px-[18px] py-3.5">
|
||||
<IconTile variant="outline" aria-hidden className={cn('shrink-0', TILE[k.tone])}>
|
||||
<Icon />
|
||||
</IconTile>
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<div className="text-muted-foreground text-[13px]">{k.label}</div>
|
||||
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
|
||||
<span className={cn('text-2xl font-semibold tracking-tight tabular-nums', VALUE[k.tone])}>{k.value}</span>
|
||||
<Badge variant={BADGE[k.tone]} size="sm" className="whitespace-nowrap">
|
||||
{GLYPH[k.tone] ? <span aria-hidden className="text-[0.55rem]">{GLYPH[k.tone]}</span> : null}
|
||||
{k.hint}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
|
||||
export type KpiTone = 'neutral' | 'ok' | 'wn' | 'er' | 'in'
|
||||
|
||||
export type KpiItem = {
|
||||
id: string
|
||||
label: string
|
||||
value: string
|
||||
/** Плашка-подсказка рядом со значением. */
|
||||
hint: string
|
||||
tone: KpiTone
|
||||
/** Иконка показателя: плитка слева от подписи. */
|
||||
icon: LucideIcon
|
||||
}
|
||||
|
||||
/** Плитка KPI: иконка, подпись, значение (цветом при wn/er) и плашка-подсказка. */
|
||||
export function kpi(
|
||||
id: string,
|
||||
label: string,
|
||||
value: string,
|
||||
opts: { icon: LucideIcon; tone?: KpiTone; badge: string },
|
||||
): KpiItem {
|
||||
return { id, label, value, hint: opts.badge, tone: opts.tone ?? 'neutral', icon: opts.icon }
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
export type Tone = 'ok' | 'wn' | 'er' | 'mu' | 'in'
|
||||
|
||||
/** Пометка строки с накопленной правкой. */
|
||||
export type RowMark = 'new' | 'dirty' | 'deleted'
|
||||
|
||||
|
||||
type BadgeVariant =
|
||||
| 'success-light'
|
||||
| 'warning-light'
|
||||
| 'destructive-light'
|
||||
| 'info-light'
|
||||
| 'outline'
|
||||
|
||||
export const TONE_BADGE: Record<Tone, BadgeVariant> = {
|
||||
ok: 'success-light',
|
||||
wn: 'warning-light',
|
||||
er: 'destructive-light',
|
||||
in: 'info-light',
|
||||
mu: 'outline',
|
||||
}
|
||||
|
||||
export const TONE_DOT: Record<Tone, string> = {
|
||||
ok: 'bg-success',
|
||||
wn: 'bg-warning',
|
||||
er: 'bg-destructive',
|
||||
in: 'bg-success',
|
||||
mu: 'bg-muted-foreground/50',
|
||||
}
|
||||
@@ -1,11 +1,4 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import {
|
||||
KpiStatGrid,
|
||||
type KpiStatCard,
|
||||
@@ -33,7 +26,7 @@ interface DetailPanelProps {
|
||||
|
||||
function DetailPanelRoot({ children, className }: DetailPanelProps) {
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-4 md:gap-6', className)}>
|
||||
<div className={cn('flex flex-col gap-5', className)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
@@ -53,24 +46,16 @@ function DetailPanelHeader({
|
||||
children,
|
||||
}: DetailPanelHeaderProps) {
|
||||
return (
|
||||
<Frame dense spacing="sm" className="w-full">
|
||||
<FrameHeader className="flex-row items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-px">
|
||||
<FrameTitle>{title}</FrameTitle>
|
||||
{description ? (
|
||||
<FrameDescription>{description}</FrameDescription>
|
||||
) : null}
|
||||
<>
|
||||
<header className="flex min-w-0 flex-wrap items-end justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<h1 className="text-xl font-semibold tracking-[-0.01em]">{title}</h1>
|
||||
{description ? <p className="text-muted-foreground text-sm text-pretty">{description}</p> : null}
|
||||
</div>
|
||||
{actions ? (
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
{actions}
|
||||
</div>
|
||||
) : null}
|
||||
</FrameHeader>
|
||||
{children ? (
|
||||
<FramePanel className="flex flex-col gap-4">{children}</FramePanel>
|
||||
) : null}
|
||||
</Frame>
|
||||
{actions ? <div className="flex shrink-0 flex-wrap items-center justify-end gap-2">{actions}</div> : null}
|
||||
</header>
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -223,39 +223,61 @@ interface KpiStatGridProps {
|
||||
'aria-label'?: string
|
||||
}
|
||||
|
||||
function KpiStatCardItem({ item }: { item: KpiStatItem }) {
|
||||
const onActivate = resolveActivate(item)
|
||||
const panelClass = panelClassName(item)
|
||||
/** Колонки полосы по ширине контейнера: 4 → 1/2/4, 3 → 1/3. */
|
||||
function stripCols(count: number): string {
|
||||
if (count <= 1) return 'grid-cols-1'
|
||||
if (count === 2) return 'grid-cols-1 @xl:grid-cols-2'
|
||||
if (count === 3) return 'grid-cols-1 @2xl:grid-cols-3'
|
||||
if (count === 4) return 'grid-cols-1 @xl:grid-cols-2 @4xl:grid-cols-4'
|
||||
return 'grid-cols-1 @xl:grid-cols-2 @4xl:grid-cols-3'
|
||||
}
|
||||
|
||||
/** Ячейка полосы показателей как на досках макета: плитка-иконка, подпись, значение и плашка рядом. */
|
||||
function KpiStripCell({ item }: { item: KpiStatItem }) {
|
||||
const onActivate = resolveActivate(item)
|
||||
const footer = resolveFooter(item)
|
||||
const valueVariant = item.variant ?? 'default'
|
||||
const clickable = Boolean(item.to || onActivate)
|
||||
const body = (
|
||||
<div className="flex min-w-0 items-center gap-3 px-[18px] py-3.5">
|
||||
{item.icon ? (
|
||||
<IconTile variant="outline" aria-hidden="true" className={cn('shrink-0', item.iconClassName ?? DEFAULT_ICON_CLASS)}>
|
||||
{item.icon}
|
||||
</IconTile>
|
||||
) : null}
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<div className="text-muted-foreground truncate text-[13px]">{item.label}</div>
|
||||
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
|
||||
<span className={cn('text-2xl font-semibold tracking-tight tabular-nums', VALUE_VARIANT_CLASS[valueVariant])}>
|
||||
{item.value}
|
||||
</span>
|
||||
{footer ? <span className="min-w-0 max-w-full">{footer}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
const cellClass = cn(
|
||||
'bg-card relative min-w-0',
|
||||
clickable && 'hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2',
|
||||
isSelected(item) && 'bg-muted/30',
|
||||
)
|
||||
if (item.to) {
|
||||
return (
|
||||
<FramePanel className={panelClass}>
|
||||
<Link to={item.to} search={item.search} className="focus-visible:outline-none">
|
||||
<KpiStatCardBody item={item} />
|
||||
<div className={cellClass}>
|
||||
<Link to={item.to} search={item.search} className="block focus-visible:outline-none">
|
||||
{body}
|
||||
</Link>
|
||||
</FramePanel>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (onActivate) {
|
||||
return (
|
||||
<FramePanel
|
||||
className={panelClass}
|
||||
onClick={onActivate}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => handleCardKeyDown(onActivate, e)}
|
||||
>
|
||||
<KpiStatCardBody item={item} />
|
||||
</FramePanel>
|
||||
<div className={cellClass} onClick={onActivate} role="button" tabIndex={0} onKeyDown={(e) => handleCardKeyDown(onActivate, e)}>
|
||||
{body}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<FramePanel className={panelClass}>
|
||||
<KpiStatCardBody item={item} />
|
||||
</FramePanel>
|
||||
)
|
||||
return <div className={cellClass}>{body}</div>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -305,12 +327,14 @@ export function KpiStatGrid({
|
||||
}
|
||||
|
||||
return (
|
||||
<Frame className={cn('@container w-full', className)} aria-label={ariaLabel}>
|
||||
<div className={cn('grid gap-2', kpiCols(list.length || 1))}>
|
||||
{list.map((item, index) => (
|
||||
<KpiStatCardItem key={kpiStatItemKey(item, index)} item={item} />
|
||||
))}
|
||||
</div>
|
||||
<Frame spacing="sm" className={cn('@container w-full min-w-0', className)} aria-label={ariaLabel}>
|
||||
<FramePanel className="overflow-hidden p-0">
|
||||
<div className={cn('bg-border grid gap-px', stripCols(list.length || 1))}>
|
||||
{list.map((item, index) => (
|
||||
<KpiStripCell key={kpiStatItemKey(item, index)} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { Skeleton } from '@evofw/ui/components/skeleton'
|
||||
import { KpiStatGrid, type KpiStatCard } from './kpi-stat-grid'
|
||||
|
||||
@@ -22,7 +15,7 @@ interface OpsDashboardProps {
|
||||
|
||||
/** Denser stack for KPI / charts / queue. PageHeader lives outside via PageShell. */
|
||||
const rootClassName =
|
||||
'text-foreground @container flex w-full flex-col gap-2 md:gap-3'
|
||||
'text-foreground @container flex w-full flex-col gap-5'
|
||||
|
||||
function OpsDashboardSkeleton() {
|
||||
return (
|
||||
@@ -58,22 +51,12 @@ export function OpsDashboard({
|
||||
|
||||
<section
|
||||
aria-label="Аналитика"
|
||||
className="grid min-w-0 items-start gap-2 @3xl:grid-cols-2"
|
||||
className="flex min-w-0 flex-col gap-5"
|
||||
>
|
||||
{charts}
|
||||
</section>
|
||||
|
||||
<section aria-label="Требуют внимания">
|
||||
<Frame dense spacing="sm" className="w-full">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Требуют внимания</FrameTitle>
|
||||
<FrameDescription>
|
||||
Pending-агенты, ошибки apply и списки с last_error
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel>{queue}</FramePanel>
|
||||
</Frame>
|
||||
</section>
|
||||
<section aria-label="Требуют внимания">{queue}</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from '@tanstack/react-table'
|
||||
import { CircleAlertIcon, FilterIcon, FilterXIcon, SearchIcon } from 'lucide-react'
|
||||
|
||||
import { CountedLineTabs } from '@/components/counted-line-tabs'
|
||||
import { SegmentedControl } from '@/components/portal/data-page'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { DataGrid } from '@/components/reui/data-grid/data-grid'
|
||||
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
|
||||
@@ -281,8 +281,10 @@ export function ResourcePage<T extends object>({
|
||||
|
||||
const tableLayout = useMemo(
|
||||
() => ({
|
||||
dense: true,
|
||||
dense: false,
|
||||
headerSticky: true,
|
||||
headerBackground: true,
|
||||
rowBorder: true,
|
||||
width: 'fixed' as const,
|
||||
...tableLayoutProp,
|
||||
...(columnPinningProp || Object.keys(columnPinning).length > 0
|
||||
@@ -464,81 +466,68 @@ export function ResourcePage<T extends object>({
|
||||
) : null}
|
||||
|
||||
<FramePanel className="p-0 shadow-none!">
|
||||
{countedTabs.length > 0 ? (
|
||||
<>
|
||||
<div className="px-(--frame-panel-header-px) pt-(--frame-panel-header-py)">
|
||||
<CountedLineTabs
|
||||
tabs={countedTabs}
|
||||
value={activeTab}
|
||||
onValueChange={handleTabChange}
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
|
||||
<Filters
|
||||
filters={filters}
|
||||
fields={filterFields}
|
||||
onChange={handleFiltersChange}
|
||||
size="default"
|
||||
i18n={FILTERS_I18N}
|
||||
trigger={
|
||||
<Button type="button" variant="outline" aria-label="Фильтры">
|
||||
<FilterIcon className="size-4" aria-hidden="true" />
|
||||
Фильтры
|
||||
</Button>
|
||||
}
|
||||
<div className="flex flex-wrap items-center gap-3 border-b px-5 py-3">
|
||||
{countedTabs.length > 0 ? (
|
||||
<SegmentedControl
|
||||
value={activeTab}
|
||||
onChange={handleTabChange}
|
||||
label="Фильтр по статусу"
|
||||
options={countedTabs.map((t) => ({ value: t.id, label: t.label, count: t.count }))}
|
||||
/>
|
||||
{showSearch ? (
|
||||
<InputGroup className="h-8 max-w-xs min-w-[12rem] flex-1">
|
||||
<InputGroupAddon align="inline-start">
|
||||
<SearchIcon aria-hidden="true" />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
value={searchQuery}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
placeholder={searchPlaceholder}
|
||||
aria-label={searchPlaceholder}
|
||||
/>
|
||||
</InputGroup>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
{toolbarExtra}
|
||||
{selectedCount > 0 ? (
|
||||
<Badge size="sm" variant="secondary">
|
||||
{selectedCount} выбрано
|
||||
</Badge>
|
||||
) : null}
|
||||
{showClear ? (
|
||||
<Button type="button" variant="outline" onClick={handleClear}>
|
||||
<FilterXIcon className="size-4" aria-hidden="true" />
|
||||
Сбросить
|
||||
) : null}
|
||||
<Filters
|
||||
filters={filters}
|
||||
fields={filterFields}
|
||||
onChange={handleFiltersChange}
|
||||
size="default"
|
||||
i18n={FILTERS_I18N}
|
||||
trigger={
|
||||
<Button type="button" variant="outline" aria-label="Фильтры">
|
||||
<FilterIcon className="size-4" aria-hidden="true" />
|
||||
Фильтры
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{showSearch ? (
|
||||
<InputGroup className="max-w-sm min-w-[220px]">
|
||||
<InputGroupAddon align="inline-start">
|
||||
<SearchIcon className="size-3.5" aria-hidden="true" />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
value={searchQuery}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
placeholder={searchPlaceholder}
|
||||
aria-label={searchPlaceholder}
|
||||
/>
|
||||
</InputGroup>
|
||||
) : null}
|
||||
<span className="text-muted-foreground ml-auto text-sm tabular-nums">{filteredData.length} записей</span>
|
||||
{toolbarExtra}
|
||||
{selectedCount > 0 ? (
|
||||
<Badge size="sm" variant="secondary">
|
||||
{selectedCount} выбрано
|
||||
</Badge>
|
||||
) : null}
|
||||
{showClear ? (
|
||||
<Button type="button" variant="outline" onClick={handleClear}>
|
||||
<FilterXIcon className="size-4" aria-hidden="true" />
|
||||
Сбросить
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<DataGridScrollArea>
|
||||
<DataGridTable />
|
||||
</DataGridScrollArea>
|
||||
|
||||
<Separator />
|
||||
|
||||
<FrameFooter>
|
||||
<DataGridPagination
|
||||
sizes={[5, 10, 20, 50]}
|
||||
rowsPerPageLabel="Строк на странице"
|
||||
info="{from}–{to} из {count}"
|
||||
previousPageLabel="Назад"
|
||||
nextPageLabel="Вперёд"
|
||||
/>
|
||||
</FrameFooter>
|
||||
<DataGridPagination
|
||||
className="px-5 py-3"
|
||||
sizes={[5, 10, 20, 50]}
|
||||
rowsPerPageLabel="Строк на странице"
|
||||
info="{from}–{to} из {count}"
|
||||
previousPageLabel="Назад"
|
||||
nextPageLabel="Вперёд"
|
||||
/>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</DataGrid>
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useMemo, useState, type ReactNode } from 'react'
|
||||
import {
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
type ColumnDef,
|
||||
type SortingState,
|
||||
} from '@tanstack/react-table'
|
||||
|
||||
import { DataGrid } from '@/components/reui/data-grid/data-grid'
|
||||
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
|
||||
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
|
||||
import { Frame, FrameDescription, FrameHeader, FramePanel, FrameTitle } from '@/components/reui/frame'
|
||||
import { DATA_GRID_CLASS_NAMES, DATA_GRID_LAYOUT } from '@/components/portal/data-page-layout'
|
||||
|
||||
/**
|
||||
* Блок-таблица доски: Frame с заголовком, описанием и действиями, ниже таблица в раскладке MikrotikManager.
|
||||
* Без поиска и пагинации — для коротких срезов («Первые 8», «Требует внимания»).
|
||||
*/
|
||||
export function SimpleGrid<T extends object>({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
columns,
|
||||
data,
|
||||
getRowId,
|
||||
onRowClick,
|
||||
emptyMessage = 'Нет записей.',
|
||||
footer,
|
||||
}: {
|
||||
title: string
|
||||
description?: string
|
||||
actions?: ReactNode
|
||||
columns: ColumnDef<T, unknown>[]
|
||||
data: T[]
|
||||
getRowId: (row: T) => string
|
||||
onRowClick?: (row: T) => void
|
||||
emptyMessage?: string
|
||||
footer?: ReactNode
|
||||
}) {
|
||||
const [sorting, setSorting] = useState<SortingState>([])
|
||||
const state = useMemo(() => ({ sorting }), [sorting])
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getRowId,
|
||||
state,
|
||||
onSortingChange: setSorting,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGrid
|
||||
table={table}
|
||||
recordCount={data.length}
|
||||
emptyMessage={emptyMessage}
|
||||
onRowClick={onRowClick}
|
||||
tableLayout={{ ...DATA_GRID_LAYOUT, dense: false, headerSticky: false, width: 'fixed' }}
|
||||
tableClassNames={DATA_GRID_CLASS_NAMES}
|
||||
>
|
||||
<Frame dense spacing="sm" className="w-full min-w-0">
|
||||
<FrameHeader className="flex-row items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col gap-px">
|
||||
<FrameTitle className="text-balance">{title}</FrameTitle>
|
||||
{description ? <FrameDescription className="text-xs text-pretty">{description}</FrameDescription> : null}
|
||||
</div>
|
||||
{actions ? <div className="flex shrink-0 flex-wrap items-center justify-end gap-2">{actions}</div> : null}
|
||||
</FrameHeader>
|
||||
<FramePanel className="overflow-hidden p-0 shadow-none!">
|
||||
<DataGridScrollArea>
|
||||
<DataGridTable />
|
||||
</DataGridScrollArea>
|
||||
{footer ? <div className="text-muted-foreground border-t px-5 py-3 text-xs">{footer}</div> : null}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</DataGrid>
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { Toaster } from '@evofw/ui/components/sonner'
|
||||
import { TooltipProvider } from '@evofw/ui/components/tooltip'
|
||||
import { routeTree } from './routeTree.gen'
|
||||
import '@evofw/ui/globals.css'
|
||||
import './portal-theme.css'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/* Плотность портала. Палитра, шрифты и таблицы — нативные токены ReUI из @authportal/ui/globals.css. */
|
||||
html[data-density='compact'] [data-slot='data-grid-table'] :is(th, td) {
|
||||
padding-block: 0.25rem;
|
||||
}
|
||||
|
||||
html[data-density='compact'] [data-slot='sidebar-inset'] > div:last-child {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* Видимый фокус и reduced motion — как в макете MikrotikManager v2. */
|
||||
html [data-slot='sidebar-wrapper'] :focus-visible,
|
||||
html #main-content :focus-visible {
|
||||
outline: 2px solid var(--ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html #main-content *,
|
||||
html [data-slot='sidebar-wrapper'] * {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { createFileRoute, Outlet } from '@tanstack/react-router'
|
||||
import { AppShell } from '@/components/layout/app-shell'
|
||||
import { PortalShell } from '@/components/layout/portal-shell'
|
||||
|
||||
export const Route = createFileRoute('/_auth')({
|
||||
component: () => (
|
||||
<AppShell>
|
||||
<PortalShell>
|
||||
<Outlet />
|
||||
</AppShell>
|
||||
</PortalShell>
|
||||
),
|
||||
})
|
||||
@@ -1,15 +1,12 @@
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||
|
||||
/**
|
||||
* Legacy deep-link → list + Sheet (`?agent=`).
|
||||
* Full UI: AgentDetailSheet on /agents (SA3 / inventory-9).
|
||||
*/
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { AgentDetailPage } from '@/components/agents/agent-detail-page'
|
||||
|
||||
/** Страница агента по доске FwAgentDetail. */
|
||||
export const Route = createFileRoute('/_auth/agents/$id')({
|
||||
beforeLoad: ({ params }) => {
|
||||
throw redirect({
|
||||
to: '/agents',
|
||||
search: { agent: params.id, view: 'cards' },
|
||||
})
|
||||
},
|
||||
component: AgentRoute,
|
||||
})
|
||||
|
||||
function AgentRoute() {
|
||||
const { id } = Route.useParams()
|
||||
return <AgentDetailPage agentId={id} />
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import type { RowSelectionState } from '@tanstack/react-table'
|
||||
import { toast } from 'sonner'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
|
||||
import {
|
||||
Check,
|
||||
@@ -35,7 +35,6 @@ import {
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
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 { AgentsFleetChrome } from '@/components/agents/agents-fleet-chrome'
|
||||
import {
|
||||
@@ -141,6 +140,12 @@ function AgentsPage() {
|
||||
const filters = Route.useSearch().f ?? EMPTY_FILTERS
|
||||
|
||||
const detailOpen = Boolean(detailAgentId)
|
||||
// Карточка агента — отдельная страница (доска FwAgentDetail); ссылки ?agent= ведут на неё.
|
||||
useEffect(() => {
|
||||
if (detailAgentId) {
|
||||
void navigate({ to: '/agents/$id', params: { id: detailAgentId }, replace: true })
|
||||
}
|
||||
}, [detailAgentId, navigate])
|
||||
const showQuickActions = settingsQ.data?.show_quick_actions !== 'false'
|
||||
|
||||
const setSearch = useCallback(
|
||||
@@ -781,15 +786,6 @@ function AgentsPage() {
|
||||
}}
|
||||
/>
|
||||
|
||||
<AgentDetailSheet
|
||||
agentId={detailAgentId ?? null}
|
||||
open={detailOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setSearch({ agent: '' })
|
||||
}}
|
||||
onDelete={handleDeleteAgent}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleteAgentId !== null}
|
||||
onOpenChange={(open) => {
|
||||
|
||||
+126
-146
@@ -1,41 +1,27 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useMemo } from 'react'
|
||||
import { BanIcon, CheckCircle2Icon, ListIcon, ServerIcon } from 'lucide-react'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import {
|
||||
ServerIcon,
|
||||
BanIcon,
|
||||
CheckCircle2Icon,
|
||||
ListIcon,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
PageHeader,
|
||||
PageShell,
|
||||
OpsDashboard,
|
||||
PageShell,
|
||||
QuickActionGrid,
|
||||
type KpiStatCard,
|
||||
type QuickActionItem,
|
||||
} from '@/components/reui-kit'
|
||||
import {
|
||||
Frame,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import {
|
||||
Item,
|
||||
ItemContent,
|
||||
ItemGroup,
|
||||
ItemTitle,
|
||||
} from '@evofw/ui/components/item'
|
||||
import {
|
||||
dashboardQueryOptions,
|
||||
agentsQueryOptions,
|
||||
settingsQueryOptions,
|
||||
} from '@/queries'
|
||||
import { SimpleGrid } from '@/components/reui-kit/simple-grid'
|
||||
import { PageHead } from '@/components/portal/bits'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import { dashboardQueryOptions, agentsQueryOptions, settingsQueryOptions } from '@/queries'
|
||||
import { AgentsFleetChart } from '@/components/agents/agents-fleet-chart'
|
||||
import { agentTrafficDropped } from '@/components/agents/agent-traffic'
|
||||
import { platformLabel } from '@/components/agents/agent-platform-icon'
|
||||
import { UpdatedAtLabel } from '@/components/updated-at-label'
|
||||
import { formatNumber, formatRelativeTime } from '@/lib/format'
|
||||
import type { Agent } from '@evofw/shared'
|
||||
|
||||
export const Route = createFileRoute('/_auth/')({
|
||||
@@ -44,7 +30,77 @@ export const Route = createFileRoute('/_auth/')({
|
||||
|
||||
const EMPTY_AGENTS: Agent[] = []
|
||||
|
||||
type Attention = { id: string; agent: Agent; kind: 'pending' | 'error'; message: string; when: string | null | undefined }
|
||||
|
||||
const agentColumns: ColumnDef<Agent>[] = [
|
||||
{
|
||||
id: 'name',
|
||||
accessorFn: (a) => a.name,
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Агент" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridPrimaryCell title={row.original.name} subtitle={platformLabel(row.original.platform)} />
|
||||
),
|
||||
meta: { headerTitle: 'Агент' },
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
accessorFn: (a) => a.status,
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Статус" />,
|
||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||
meta: { headerTitle: 'Статус' },
|
||||
},
|
||||
{
|
||||
id: 'dropped',
|
||||
accessorFn: (a) => agentTrafficDropped(a),
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Заблокировано" />,
|
||||
cell: ({ row }) =>
|
||||
row.original.status === 'approved' ? (
|
||||
<span className="tabular-nums">↓{formatNumber(agentTrafficDropped(row.original))}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
),
|
||||
meta: { headerTitle: 'Заблокировано' },
|
||||
},
|
||||
]
|
||||
|
||||
const attentionColumns: ColumnDef<Attention>[] = [
|
||||
{
|
||||
id: 'name',
|
||||
accessorFn: (r) => r.agent.name,
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Объект" />,
|
||||
cell: ({ row }) => <DataGridPrimaryCell title={row.original.agent.name} subtitle="Агент" />,
|
||||
meta: { headerTitle: 'Объект' },
|
||||
},
|
||||
{
|
||||
id: 'message',
|
||||
accessorFn: (r) => r.message,
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Сообщение" />,
|
||||
cell: ({ row }) => <span className="text-sm">{row.original.message}</span>,
|
||||
meta: { headerTitle: 'Сообщение' },
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
accessorFn: (r) => r.kind,
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Статус" />,
|
||||
cell: ({ row }) =>
|
||||
row.original.kind === 'error' ? (
|
||||
<StatusBadge status="error" label="Ошибка" />
|
||||
) : (
|
||||
<StatusBadge status="pending" label="Ожидает" />
|
||||
),
|
||||
meta: { headerTitle: 'Статус' },
|
||||
},
|
||||
{
|
||||
id: 'when',
|
||||
accessorFn: (r) => r.when ?? '',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Когда" />,
|
||||
cell: ({ row }) => <span className="text-muted-foreground text-xs">{formatRelativeTime(row.original.when)}</span>,
|
||||
meta: { headerTitle: 'Когда' },
|
||||
},
|
||||
]
|
||||
|
||||
function DashboardPage() {
|
||||
const navigate = useNavigate()
|
||||
const dash = useQuery(dashboardQueryOptions())
|
||||
const agents = useQuery(agentsQueryOptions())
|
||||
const settingsQ = useQuery(settingsQueryOptions())
|
||||
@@ -53,15 +109,18 @@ function DashboardPage() {
|
||||
const items = agents.data?.items ?? EMPTY_AGENTS
|
||||
const showQuickActions = settingsQ.data?.show_quick_actions !== 'false'
|
||||
|
||||
const pending = useMemo(
|
||||
() => items.filter((a) => a.status === 'pending'),
|
||||
[items],
|
||||
)
|
||||
const applyErrors = useMemo(
|
||||
() => items.filter((a) => a.last_apply_error),
|
||||
[items],
|
||||
)
|
||||
const previewAgents = useMemo(() => items.slice(0, 8), [items])
|
||||
const attention = useMemo<Attention[]>(
|
||||
() => [
|
||||
...items
|
||||
.filter((a) => a.last_apply_error)
|
||||
.map<Attention>((a) => ({ id: `err-${a.id}`, agent: a, kind: 'error', message: a.last_apply_error ?? '', when: a.last_apply_at })),
|
||||
...items
|
||||
.filter((a) => a.status === 'pending')
|
||||
.map<Attention>((a) => ({ id: `pend-${a.id}`, agent: a, kind: 'pending', message: 'Ожидает одобрения', when: a.created_at })),
|
||||
],
|
||||
[items],
|
||||
)
|
||||
|
||||
const kpiCards: KpiStatCard[] = useMemo(
|
||||
() => [
|
||||
@@ -81,8 +140,8 @@ function DashboardPage() {
|
||||
hint: 'сумма counters',
|
||||
to: '/stats',
|
||||
icon: <BanIcon aria-hidden />,
|
||||
iconClassName: 'text-warning',
|
||||
variant: 'warning',
|
||||
iconClassName: 'text-destructive',
|
||||
variant: 'destructive',
|
||||
},
|
||||
{
|
||||
id: 'accepted',
|
||||
@@ -97,6 +156,7 @@ function DashboardPage() {
|
||||
id: 'lists',
|
||||
label: 'Списки IP',
|
||||
value: d?.lists_total ?? '—',
|
||||
hint: 'ручные, JSON и EvoBGP',
|
||||
to: '/lists',
|
||||
icon: <ListIcon aria-hidden />,
|
||||
iconClassName: 'text-primary',
|
||||
@@ -107,133 +167,53 @@ function DashboardPage() {
|
||||
|
||||
const quickActions: QuickActionItem[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: 'agents',
|
||||
title: 'Агенты',
|
||||
description: 'Подключение и одобрение агентов',
|
||||
to: '/agents',
|
||||
icon: <ServerIcon aria-hidden />,
|
||||
iconClassName: 'text-info',
|
||||
},
|
||||
{
|
||||
id: 'lists',
|
||||
title: 'Списки',
|
||||
description: 'Блок-листы и источники',
|
||||
to: '/lists',
|
||||
icon: <ListIcon aria-hidden />,
|
||||
iconClassName: 'text-primary',
|
||||
},
|
||||
{
|
||||
id: 'rules',
|
||||
title: 'Правила',
|
||||
description: 'Политика блокировки',
|
||||
to: '/rules',
|
||||
icon: <BanIcon aria-hidden />,
|
||||
iconClassName: 'text-warning',
|
||||
},
|
||||
{ id: 'agents', title: 'Агенты', description: 'Подключение и одобрение агентов', to: '/agents', icon: <ServerIcon aria-hidden />, iconClassName: 'text-info' },
|
||||
{ id: 'lists', title: 'Списки', description: 'Блок-листы и источники', to: '/lists', icon: <ListIcon aria-hidden />, iconClassName: 'text-primary' },
|
||||
{ id: 'rules', title: 'Правила', description: 'Политика блокировки', to: '/rules', icon: <BanIcon aria-hidden />, iconClassName: 'text-warning' },
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
<PageHead
|
||||
title="Панель управления"
|
||||
description="Обзор агентов и пакетной статистики"
|
||||
sub="Обзор агентов и пакетной статистики"
|
||||
actions={<UpdatedAtLabel updatedAt={dash.dataUpdatedAt} />}
|
||||
/>
|
||||
<OpsDashboard
|
||||
isLoading={dash.isLoading}
|
||||
kpiCards={kpiCards}
|
||||
afterKpi={
|
||||
showQuickActions ? (
|
||||
<QuickActionGrid actions={quickActions} />
|
||||
) : undefined
|
||||
}
|
||||
afterKpi={showQuickActions ? <QuickActionGrid actions={quickActions} /> : undefined}
|
||||
charts={
|
||||
<>
|
||||
<AgentsFleetChart />
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Агенты</FrameTitle>
|
||||
</FrameHeader>
|
||||
<FramePanel>
|
||||
{previewAgents.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">Нет агентов</p>
|
||||
) : (
|
||||
<ItemGroup className="gap-2">
|
||||
{previewAgents.map((a) => (
|
||||
<Item key={a.id} variant="outline" size="sm">
|
||||
<ItemContent className="flex flex-row items-center justify-between gap-2">
|
||||
<ItemTitle className="truncate font-medium">
|
||||
<Link
|
||||
to="/agents"
|
||||
search={{ agent: a.id, view: 'cards' }}
|
||||
className="hover:underline"
|
||||
>
|
||||
{a.name}
|
||||
</Link>
|
||||
</ItemTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant={
|
||||
a.status === 'approved'
|
||||
? 'success-light'
|
||||
: a.status === 'pending'
|
||||
? 'warning-light'
|
||||
: 'secondary'
|
||||
}
|
||||
size="sm"
|
||||
>
|
||||
{a.status}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
↓{agentTrafficDropped(a)}
|
||||
</span>
|
||||
</div>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
))}
|
||||
</ItemGroup>
|
||||
)}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
<SimpleGrid
|
||||
title="Агенты"
|
||||
description="Первые 8 · клик открывает карточку агента"
|
||||
columns={agentColumns}
|
||||
data={previewAgents}
|
||||
getRowId={(a) => a.id}
|
||||
onRowClick={(a) => void navigate({ to: '/agents', search: { agent: a.id, view: 'cards' } })}
|
||||
emptyMessage="Нет агентов"
|
||||
/>
|
||||
</>
|
||||
}
|
||||
queue={
|
||||
<div className="flex flex-col gap-2">
|
||||
{pending.length === 0 && applyErrors.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Нет проблем, требующих внимания
|
||||
</p>
|
||||
) : null}
|
||||
{pending.map((a) => (
|
||||
<div
|
||||
key={a.id}
|
||||
className="flex items-center justify-between gap-2 text-sm"
|
||||
>
|
||||
<span>
|
||||
Pending: <strong>{a.name}</strong>
|
||||
</span>
|
||||
<Link
|
||||
to="/agents"
|
||||
search={{ agent: a.id, view: 'cards' }}
|
||||
className="underline-offset-4 hover:underline"
|
||||
>
|
||||
Открыть
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
{applyErrors.map((a) => (
|
||||
<div key={`err-${a.id}`} className="text-sm">
|
||||
<span className="text-destructive font-medium">{a.name}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{' '}
|
||||
— {a.last_apply_error}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<SimpleGrid
|
||||
title="Требует внимания"
|
||||
description="Ожидают одобрения и ошибки применения политики"
|
||||
actions={
|
||||
<Button variant="outline" render={<Link to="/agents" search={{ view: "cards" }} />} nativeButton={false}>
|
||||
Открыть агентов
|
||||
</Button>
|
||||
}
|
||||
columns={attentionColumns}
|
||||
data={attention}
|
||||
getRowId={(r) => r.id}
|
||||
onRowClick={(r) => void navigate({ to: '/agents', search: { agent: r.agent.id, view: 'cards' } })}
|
||||
emptyMessage="Нет проблем, требующих внимания"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</PageShell>
|
||||
|
||||
@@ -4,11 +4,13 @@ import { toast } from 'sonner'
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
CircleAlertIcon,
|
||||
ClockIcon,
|
||||
HashIcon,
|
||||
RefreshCwIcon,
|
||||
TagIcon,
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import { formatDateTime, formatRelativeTime } from '@/lib/format'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
|
||||
@@ -471,6 +473,14 @@ function ListDetailPage() {
|
||||
),
|
||||
hint: 'materialized',
|
||||
},
|
||||
{
|
||||
id: 'updated',
|
||||
icon: <ClockIcon aria-hidden />,
|
||||
iconClassName: 'text-muted-foreground',
|
||||
label: 'Обновлён',
|
||||
description: formatRelativeTime(detail.refreshed_at ?? detail.updated_at),
|
||||
hint: formatDateTime(detail.refreshed_at ?? detail.updated_at),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ import {
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { PageTab, PageTabsList, SignalAlert } from '@/components/portal/bits'
|
||||
import { Tabs, TabsContent } from '@evofw/ui/components/tabs'
|
||||
import { UpdatedAtLabel } from '@/components/updated-at-label'
|
||||
import { dashboardQueryOptions, recentStatsQueryOptions } from '@/queries'
|
||||
import { formatNumber, formatStampDateTime } from '@/lib/format'
|
||||
@@ -133,14 +135,16 @@ function StatsPage() {
|
||||
{
|
||||
id: 'd',
|
||||
label: 'Заблокировано (сумма)',
|
||||
hint: 'пакетов · сумма пиков',
|
||||
value: dash.data?.packets_dropped ?? 0,
|
||||
icon: <BanIcon aria-hidden />,
|
||||
iconClassName: 'text-warning',
|
||||
variant: 'warning',
|
||||
iconClassName: 'text-destructive',
|
||||
variant: 'destructive',
|
||||
},
|
||||
{
|
||||
id: 'a',
|
||||
label: 'Пропущено (сумма)',
|
||||
hint: 'пакетов · сумма пиков',
|
||||
value: dash.data?.packets_accepted ?? 0,
|
||||
icon: <CheckCircle2Icon aria-hidden />,
|
||||
iconClassName: 'text-success',
|
||||
@@ -148,6 +152,7 @@ function StatsPage() {
|
||||
{
|
||||
id: 'o',
|
||||
label: 'Агентов онлайн',
|
||||
hint: `из ${dash.data?.agents_approved ?? 0} одобренных`,
|
||||
value: dash.data?.agents_online ?? 0,
|
||||
icon: <ServerIcon aria-hidden />,
|
||||
iconClassName: 'text-info',
|
||||
@@ -225,65 +230,77 @@ function StatsPage() {
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
title="Статистика"
|
||||
description="История apply-report counters"
|
||||
description="История apply-report counters · заблокированный и пропущенный трафик агентов"
|
||||
actions={<UpdatedAtLabel updatedAt={stats.dataUpdatedAt} />}
|
||||
/>
|
||||
<SignalAlert tone="in" title="Как считаются значения.">
|
||||
Счётчики агентов смешанной семантики (накопительные и сбросные): суммирование сэмплов дало бы двойной счёт,
|
||||
поэтому за час берём максимум наблюдавшегося уровня.
|
||||
</SignalAlert>
|
||||
<KpiStatGrid cards={kpiCards} isLoading={dash.isLoading} />
|
||||
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Активность по часам (пик за час)</FrameTitle>
|
||||
</FrameHeader>
|
||||
<FramePanel>
|
||||
{series.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Нет данных — дождитесь apply-report от агентов.
|
||||
</p>
|
||||
) : (
|
||||
<ChartContainer config={chartConfig} className="aspect-[2/1] w-full">
|
||||
<AreaChart data={series}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis dataKey="t" tickLine={false} axisLine={false} />
|
||||
<YAxis tickLine={false} axisLine={false} />
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
<Area
|
||||
dataKey="dropped"
|
||||
type="monotone"
|
||||
fill="var(--color-dropped)"
|
||||
stroke="var(--color-dropped)"
|
||||
fillOpacity={0.3}
|
||||
/>
|
||||
<Area
|
||||
dataKey="accepted"
|
||||
type="monotone"
|
||||
fill="var(--color-accepted)"
|
||||
stroke="var(--color-accepted)"
|
||||
fillOpacity={0.2}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
)}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
|
||||
<ResourcePage
|
||||
title="Samples"
|
||||
hideHeader
|
||||
data={rows}
|
||||
columns={columns}
|
||||
getRowId={(r) => r.id}
|
||||
filterFields={filterFields}
|
||||
filters={filters}
|
||||
onFiltersChange={setStatsFilters}
|
||||
onClearFilters={() => setStatsFilters([])}
|
||||
getFilterFieldValue={getFilterFieldValue}
|
||||
isLoading={stats.isLoading}
|
||||
pageSize={20}
|
||||
emptyState={{
|
||||
title: 'Нет данных',
|
||||
description: 'Агенты ещё не отправили apply-report.',
|
||||
}}
|
||||
/>
|
||||
<Tabs defaultValue="activity" className="min-w-0 gap-4">
|
||||
<PageTabsList>
|
||||
<PageTab value="activity" label="Активность" />
|
||||
<PageTab value="samples" label="Samples" count={rows.length} />
|
||||
</PageTabsList>
|
||||
<TabsContent value="activity" className="min-w-0">
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Активность по часам (пик за час)</FrameTitle>
|
||||
</FrameHeader>
|
||||
<FramePanel>
|
||||
{series.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Нет данных — дождитесь apply-report от агентов.
|
||||
</p>
|
||||
) : (
|
||||
<ChartContainer config={chartConfig} className="aspect-[2/1] w-full">
|
||||
<AreaChart data={series}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis dataKey="t" tickLine={false} axisLine={false} />
|
||||
<YAxis tickLine={false} axisLine={false} />
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
<Area
|
||||
dataKey="dropped"
|
||||
type="monotone"
|
||||
fill="var(--color-dropped)"
|
||||
stroke="var(--color-dropped)"
|
||||
fillOpacity={0.3}
|
||||
/>
|
||||
<Area
|
||||
dataKey="accepted"
|
||||
type="monotone"
|
||||
fill="var(--color-accepted)"
|
||||
stroke="var(--color-accepted)"
|
||||
fillOpacity={0.2}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
)}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</TabsContent>
|
||||
<TabsContent value="samples" className="min-w-0">
|
||||
<ResourcePage
|
||||
title="Samples"
|
||||
hideHeader
|
||||
data={rows}
|
||||
columns={columns}
|
||||
getRowId={(r) => r.id}
|
||||
filterFields={filterFields}
|
||||
filters={filters}
|
||||
onFiltersChange={setStatsFilters}
|
||||
onClearFilters={() => setStatsFilters([])}
|
||||
getFilterFieldValue={getFilterFieldValue}
|
||||
isLoading={stats.isLoading}
|
||||
pageSize={20}
|
||||
emptyState={{
|
||||
title: 'Нет данных',
|
||||
description: 'Агенты ещё не отправили apply-report.',
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
@@ -8,37 +8,37 @@
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--background: oklch(0.973 0.004 247.858);
|
||||
--foreground: oklch(0.141 0.006 264);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--card-foreground: oklch(0.141 0.006 264);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--popover-foreground: oklch(0.141 0.006 264);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary: oklch(0.958 0.005 247.858);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--muted: oklch(0.958 0.005 247.858);
|
||||
--muted-foreground: oklch(0.52 0.01 264);
|
||||
--accent: oklch(0.942 0.016 264.376);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--border: oklch(0.904 0.006 264);
|
||||
--input: oklch(0.904 0.006 264);
|
||||
--ring: oklch(0.488 0.243 264.376);
|
||||
--chart-1: oklch(0.546 0.215 264.376);
|
||||
--chart-2: oklch(0.527 0.154 150.069);
|
||||
--chart-3: oklch(0.625 0.162 41.5);
|
||||
--chart-4: oklch(0.591 0.224 296.4);
|
||||
--chart-5: oklch(0.577 0.245 27.325);
|
||||
--sidebar: oklch(0.152 0.01 264.376);
|
||||
--sidebar-foreground: oklch(0.92 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
--sidebar-accent: oklch(0.24 0.012 264.376);
|
||||
--sidebar-accent-foreground: oklch(0.95 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 8%);
|
||||
--sidebar-ring: oklch(0.488 0.243 264.376);
|
||||
--destructive-foreground: var(--color-red-800);
|
||||
--success: var(--color-emerald-500);
|
||||
--success-foreground: var(--color-emerald-900);
|
||||
@@ -49,41 +49,43 @@
|
||||
--invert: var(--color-zinc-900);
|
||||
--invert-foreground: var(--color-zinc-50);
|
||||
--focus: var(--color-blue-500);
|
||||
--chart-rx: oklch(0.527 0.154 150.069);
|
||||
--chart-tx: oklch(0.546 0.215 264.376);
|
||||
--focus-foreground: var(--color-blue-900);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--background: oklch(0.133 0.011 264.376);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card: oklch(0.191 0.011 264.376);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover: oklch(0.191 0.011 264.376);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary: oklch(0.252 0.01 264.376);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--muted: oklch(0.252 0.01 264.376);
|
||||
--muted-foreground: oklch(0.648 0.008 264.376);
|
||||
--accent: oklch(0.285 0.022 264.376);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--ring: oklch(0.488 0.243 264.376);
|
||||
--chart-1: oklch(0.637 0.222 264.376);
|
||||
--chart-2: oklch(0.696 0.17 151.328);
|
||||
--chart-3: oklch(0.713 0.176 47.6);
|
||||
--chart-4: oklch(0.714 0.173 303.9);
|
||||
--chart-5: oklch(0.704 0.191 22.216);
|
||||
--sidebar: oklch(0.191 0.011 264.376);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent: oklch(0.262 0.014 264.376);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
--sidebar-ring: oklch(0.488 0.243 264.376);
|
||||
--destructive-foreground: var(--color-red-600);
|
||||
--success: var(--color-emerald-500);
|
||||
--success-foreground: var(--color-emerald-600);
|
||||
|
||||
Reference in new issue
Block a user