utp_service/frontend/src/pages/SnapshotDashboard.tsx
2026-05-28 16:38:10 +03:00

745 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
CopyOutlined,
DeleteOutlined,
DownloadOutlined,
EditOutlined,
EyeOutlined,
PlayCircleOutlined,
PlusOutlined,
SettingOutlined,
} from '@ant-design/icons'
import {
Button,
Card,
Collapse,
Form,
Input,
Modal,
Popconfirm,
Select,
Space,
Table,
Tag,
Tooltip,
Typography,
message,
} from 'antd'
import type { ColumnsType } from 'antd/es/table'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import 'dayjs/locale/ru'
import { useMemo, useState } from 'react'
import {
useSnapshotDashboard,
useSnapshotPolicies,
useCreateSnapshotPolicy,
useUpdateSnapshotPolicy,
useDeleteSnapshotPolicy,
useCollectPolicy,
useCollectPolicyForObject,
useDashboardDevices,
fetchSnapshotContent,
type SnapshotPolicy,
type ObjectSnapshotSummary,
type PolicySnapshotStatus,
type DeviceSnapshotStatus,
} from '../api/snapshotPolicies'
dayjs.extend(relativeTime)
dayjs.locale('ru')
function copyToClipboard(text: string) {
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text)
} else {
const textarea = document.createElement('textarea')
textarea.value = text
textarea.style.position = 'fixed'
textarea.style.left = '-9999px'
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
}
}
const UNGROUPED = '__none__'
const CATEGORY_OPTIONS = [
{ value: 'main_server', label: 'Main Server' },
{ value: 'vm', label: 'VM' },
{ value: 'router', label: 'Router' },
{ value: 'embedded', label: 'Embedded' },
{ value: 'camera', label: 'Camera' },
{ value: 'io_board', label: 'IO Board' },
{ value: 'bank_terminal', label: 'Bank Terminal' },
{ value: 'cash_register', label: 'Cash Register' },
{ value: 'other', label: 'Other' },
]
const ACTION_OPTIONS = [
{ value: 'get_file', label: 'Get File (SSH)' },
{ value: 'mikrotik_get_config', label: 'MikroTik Get Config' },
]
interface PolicyFormValues {
label: string
category: string
action_type: string
path?: string
}
export function SnapshotDashboardPage() {
const { data: dashboard, isLoading } = useSnapshotDashboard()
const { data: policies } = useSnapshotPolicies()
const [searchText, setSearchText] = useState('')
const [policyModalOpen, setPolicyModalOpen] = useState(false)
const [editPolicy, setEditPolicy] = useState<SnapshotPolicy | null>(null)
const [settingsOpen, setSettingsOpen] = useState(false)
const [form] = Form.useForm<PolicyFormValues>()
const actionType = Form.useWatch('action_type', form)
const createMutation = useCreateSnapshotPolicy()
const updateMutation = useUpdateSnapshotPolicy()
const deleteMutation = useDeleteSnapshotPolicy()
const collectAllMutation = useCollectPolicy()
const collectObjMutation = useCollectPolicyForObject()
const filtered = useMemo(() => {
if (!searchText || !dashboard) return dashboard ?? []
const q = searchText.toLowerCase()
return dashboard.filter(
(o) =>
o.object_name.toLowerCase().includes(q) ||
(o.city ?? '').toLowerCase().includes(q) ||
(o.client ?? '').toLowerCase().includes(q)
)
}, [dashboard, searchText])
const cityGroups = useMemo(() => {
const map = new Map<string, ObjectSnapshotSummary[]>()
for (const obj of filtered) {
const key = obj.city ?? UNGROUPED
if (!map.has(key)) map.set(key, [])
map.get(key)!.push(obj)
}
const sorted = [...map.entries()].sort(([a], [b]) => {
if (a === UNGROUPED) return 1
if (b === UNGROUPED) return -1
return a.localeCompare(b, 'ru')
})
return sorted
}, [filtered])
const openCreatePolicy = () => {
form.resetFields()
setEditPolicy(null)
setPolicyModalOpen(true)
}
const openEditPolicy = (policy: SnapshotPolicy) => {
form.resetFields()
form.setFieldsValue({
label: policy.label,
category: policy.category,
action_type: policy.action_type,
path: policy.action_type === 'get_file' ? (policy.params as any)?.path ?? '' : undefined,
})
setEditPolicy(policy)
setPolicyModalOpen(true)
}
const handlePolicySubmit = async () => {
const values = await form.validateFields()
const params: Record<string, unknown> = {}
if (values.action_type === 'get_file' && values.path) {
params.path = values.path
}
try {
if (editPolicy) {
await updateMutation.mutateAsync({
policyId: editPolicy.id,
label: values.label,
category: values.category,
action_type: values.action_type,
params,
})
message.success('Политика обновлена')
} else {
await createMutation.mutateAsync({
label: values.label,
category: values.category,
action_type: values.action_type,
params,
})
message.success('Политика создана')
}
setPolicyModalOpen(false)
} catch {
message.error('Ошибка при сохранении')
}
}
const handleDeletePolicy = async (id: number) => {
try {
await deleteMutation.mutateAsync(id)
message.success('Политика удалена')
} catch {
message.error('Ошибка при удалении')
}
}
const handleCollectAll = async (policyId: number) => {
try {
const result = await collectAllMutation.mutateAsync(policyId)
message.success(`Задача создана: ${result.task_id.slice(0, 8)}...`)
} catch {
message.error('Ошибка при запуске сбора')
}
}
const handleCollectObj = async (policyId: number, objId: number) => {
try {
const result = await collectObjMutation.mutateAsync({ policyId, objId })
message.success(`Задача создана: ${result.task_id.slice(0, 8)}...`)
} catch {
message.error('Ошибка при запуске сбора')
}
}
// Policy settings columns
const policyColumns: ColumnsType<SnapshotPolicy> = [
{ title: 'Название', dataIndex: 'label', key: 'label' },
{
title: 'Категория',
dataIndex: 'category',
key: 'category',
render: (cat: string) => <Tag>{cat}</Tag>,
},
{
title: 'Действие',
dataIndex: 'action_type',
key: 'action_type',
render: (val: string, row) =>
val === 'get_file' ? `Get File: ${(row.params as any)?.path ?? ''}` : 'MikroTik Export',
},
{
title: '',
key: 'actions',
width: 160,
render: (_, row) => (
<Space size="small">
<Tooltip title="Собрать со всех объектов">
<Button
type="text"
size="small"
icon={<PlayCircleOutlined />}
onClick={() => handleCollectAll(row.id)}
/>
</Tooltip>
<Button type="text" size="small" icon={<EditOutlined />} onClick={() => openEditPolicy(row)} />
<Popconfirm title="Удалить политику?" onConfirm={() => handleDeletePolicy(row.id)}>
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),
},
]
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Typography.Title level={4} style={{ margin: 0 }}>
Снапшоты
</Typography.Title>
<Space>
<Input.Search
placeholder="Поиск по объекту, городу..."
allowClear
style={{ width: 300 }}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
/>
<Button icon={<SettingOutlined />} onClick={() => setSettingsOpen(!settingsOpen)}>
Политики
</Button>
</Space>
</div>
{settingsOpen && (
<Card size="small" style={{ marginBottom: 16 }} title="Глобальные политики сбора">
<Table
dataSource={policies ?? []}
columns={policyColumns}
rowKey="id"
size="small"
pagination={false}
/>
<Button
type="dashed"
icon={<PlusOutlined />}
onClick={openCreatePolicy}
style={{ marginTop: 8, width: '100%' }}
>
Добавить политику
</Button>
</Card>
)}
{isLoading && <Typography.Text type="secondary">Загрузка...</Typography.Text>}
{!isLoading && filtered.length === 0 && (
<Typography.Text type="secondary">
{(policies ?? []).length === 0
? 'Нет политик. Нажмите «Политики» чтобы настроить сбор снапшотов.'
: 'Нет объектов с устройствами подходящих категорий.'}
</Typography.Text>
)}
<Collapse
defaultActiveKey={cityGroups.map(([city]) => city)}
items={cityGroups.map(([city, objects]) => ({
key: city,
label: (
<Space>
<span>{city === UNGROUPED ? 'Без города' : city}</span>
<Tag>{objects.length}</Tag>
</Space>
),
children: (
<ObjectsTable
objects={objects}
onCollect={handleCollectObj}
/>
),
}))}
/>
<Modal
title={editPolicy ? 'Редактировать политику' : 'Новая политика'}
open={policyModalOpen}
onCancel={() => setPolicyModalOpen(false)}
onOk={handlePolicySubmit}
confirmLoading={createMutation.isPending || updateMutation.isPending}
okText="Сохранить"
cancelText="Отмена"
>
<Form form={form} layout="vertical">
<Form.Item name="label" label="Название" rules={[{ required: true, message: 'Введите название' }]}>
<Input placeholder="CAPS config" />
</Form.Item>
<Form.Item name="category" label="Категория устройств" rules={[{ required: true }]}>
<Select options={CATEGORY_OPTIONS} placeholder="Выберите категорию" />
</Form.Item>
<Form.Item name="action_type" label="Действие" rules={[{ required: true }]}>
<Select options={ACTION_OPTIONS} placeholder="Выберите действие" />
</Form.Item>
{actionType === 'get_file' && (
<Form.Item name="path" label="Путь к файлу" rules={[{ required: true, message: 'Укажите путь' }]}>
<Input placeholder="/etc/caps/config.ini" />
</Form.Item>
)}
</Form>
</Modal>
</div>
)
}
function ObjectsTable({
objects,
onCollect,
}: {
objects: ObjectSnapshotSummary[]
onCollect: (policyId: number, objId: number) => void
}) {
const [expandedKeys, setExpandedKeys] = useState<number[]>([])
const columns: ColumnsType<ObjectSnapshotSummary> = [
{
title: 'Объект',
key: 'name',
render: (_, obj) => (
<span>
<Typography.Text strong>{obj.object_name}</Typography.Text>
{obj.client && <Typography.Text type="secondary"> {obj.client}</Typography.Text>}
</span>
),
},
{
title: 'Последний сбор',
key: 'last_collected',
width: 160,
render: (_, obj) => {
const latest = obj.policies.reduce<string | null>((acc, p) => {
if (!p.last_collected_at) return acc
if (!acc) return p.last_collected_at
return p.last_collected_at > acc ? p.last_collected_at : acc
}, null)
return latest ? (
<Tooltip title={dayjs(latest).format('DD.MM.YYYY HH:mm:ss')}>
{dayjs(latest).fromNow()}
</Tooltip>
) : (
<Typography.Text type="secondary"></Typography.Text>
)
},
},
{
title: 'Покрытие',
key: 'coverage',
width: 120,
render: (_, obj) => {
const total = obj.policies.reduce((s, p) => s + p.total_devices, 0)
const success = obj.policies.reduce((s, p) => s + p.success_count, 0)
if (total === 0) return <Tag>нет устройств</Tag>
const ratio = success / total
const color = ratio >= 1 ? 'green' : ratio > 0 ? 'orange' : 'red'
return (
<Tag color={color}>
{success} / {total}
</Tag>
)
},
},
{
title: '',
key: 'actions',
width: 50,
render: (_, obj) => (
<Tooltip title="Собрать все политики для объекта">
<Button
type="text"
size="small"
icon={<PlayCircleOutlined />}
onClick={(e) => {
e.stopPropagation()
obj.policies.forEach((p) => onCollect(p.policy_id, obj.object_id))
}}
/>
</Tooltip>
),
},
]
return (
<Table
dataSource={objects}
columns={columns}
rowKey="object_id"
size="small"
pagination={false}
onRow={(record) => ({
onClick: () =>
setExpandedKeys(
expandedKeys.includes(record.object_id)
? expandedKeys.filter((k) => k !== record.object_id)
: [...expandedKeys, record.object_id]
),
style: { cursor: 'pointer' },
})}
expandable={{
expandedRowKeys: expandedKeys,
onExpand: (expanded, record) =>
setExpandedKeys(
expanded
? [...expandedKeys, record.object_id]
: expandedKeys.filter((k) => k !== record.object_id)
),
expandedRowRender: (record) => (
<ObjectDevicesExpanded
obj={record}
onCollect={onCollect}
/>
),
}}
/>
)
}
function ObjectDevicesExpanded({
obj,
onCollect,
}: {
obj: ObjectSnapshotSummary
onCollect: (policyId: number, objId: number) => void
}) {
if (obj.policies.length === 1) {
return (
<DevicesList
objId={obj.object_id}
objName={obj.object_name}
policyId={obj.policies[0].policy_id}
/>
)
}
return (
<div>
{obj.policies.map((p) => (
<div key={p.policy_id} style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<Typography.Text strong>{p.policy_label}</Typography.Text>
<Tag>{p.category}</Tag>
<Tooltip title="Собрать">
<Button
type="text"
size="small"
icon={<PlayCircleOutlined />}
onClick={() => onCollect(p.policy_id, obj.object_id)}
/>
</Tooltip>
</div>
<DevicesList
objId={obj.object_id}
objName={obj.object_name}
policyId={p.policy_id}
/>
</div>
))}
</div>
)
}
function highlightIni(text: string): React.ReactNode[] {
return text.split('\n').map((line, i) => {
let node: React.ReactNode
if (/^\s*[#;]/.test(line)) {
node = <span style={{ color: '#6a9955' }}>{line}</span>
} else if (/^\s*\[.+\]/.test(line) && !line.includes('=')) {
node = <span style={{ color: '#ce9178', fontWeight: 600 }}>{line}</span>
} else if (line.includes('=')) {
const eqIdx = line.indexOf('=')
const key = line.slice(0, eqIdx)
const eq = '='
const value = line.slice(eqIdx + 1)
node = (
<>
<span style={{ color: '#9cdcfe' }}>{key}</span>
<span style={{ color: '#d4d4d4' }}>{eq}</span>
{highlightValue(value)}
</>
)
} else {
node = <span>{line}</span>
}
return <span key={i}>{node}{'\n'}</span>
})
}
function highlightValue(value: string): React.ReactNode {
const parts: React.ReactNode[] = []
let i = 0
let key = 0
while (i < value.length) {
const ch = value[i]
if (ch === '"') {
const end = value.indexOf('"', i + 1)
if (end !== -1) {
const str = value.slice(i, end + 1)
parts.push(<span key={key++} style={{ color: '#dcdcaa' }}>{str}</span>)
i = end + 1
} else {
parts.push(<span key={key++} style={{ color: '#dcdcaa' }}>{value.slice(i)}</span>)
break
}
} else if (ch === '{' || ch === '}' || ch === '[' || ch === ']' || ch === ':' || ch === ',') {
parts.push(<span key={key++} style={{ color: '#808080' }}>{ch}</span>)
i++
} else if (/\d/.test(ch)) {
let num = ''
while (i < value.length && /[\d.]/.test(value[i])) {
num += value[i]
i++
}
parts.push(<span key={key++} style={{ color: '#b5cea8' }}>{num}</span>)
} else if (value.slice(i).startsWith('true') || value.slice(i).startsWith('false') || value.slice(i).startsWith('null')) {
const word = value.slice(i).startsWith('true') ? 'true' : value.slice(i).startsWith('false') ? 'false' : 'null'
parts.push(<span key={key++} style={{ color: '#569cd6' }}>{word}</span>)
i += word.length
} else {
parts.push(<span key={key++} style={{ color: '#d4d4d4' }}>{ch}</span>)
i++
}
}
return <>{parts}</>
}
function DevicesList({
objId,
objName,
policyId,
}: {
objId: number
objName: string
policyId: number
}) {
const { data: devices, isLoading } = useDashboardDevices(objId, policyId, true)
const [viewContent, setViewContent] = useState<string | null>(null)
const [viewTitle, setViewTitle] = useState('')
const handleCopy = async (snapshotId: number) => {
try {
const content = await fetchSnapshotContent(snapshotId)
copyToClipboard(content)
message.success('Скопировано в буфер обмена')
} catch {
message.error('Ошибка при копировании')
}
}
const handleDownload = async (device: DeviceSnapshotStatus) => {
if (!device.snapshot_id) return
try {
const content = await fetchSnapshotContent(device.snapshot_id)
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${objName}-${device.hostname || 'device'}-${device.ip}.txt`
a.click()
URL.revokeObjectURL(url)
} catch {
message.error('Ошибка при скачивании')
}
}
const handleView = async (device: DeviceSnapshotStatus) => {
if (!device.snapshot_id) return
try {
const content = await fetchSnapshotContent(device.snapshot_id)
setViewTitle(`${device.hostname || device.ip}${objName}`)
setViewContent(content)
} catch {
message.error('Ошибка при загрузке')
}
}
const handleViewCopy = () => {
if (viewContent) {
copyToClipboard(viewContent)
message.success('Скопировано в буфер обмена')
}
}
if (isLoading) return <Typography.Text type="secondary">Загрузка...</Typography.Text>
if (!devices || devices.length === 0) return <Typography.Text type="secondary">Нет устройств</Typography.Text>
const columns: ColumnsType<DeviceSnapshotStatus> = [
{
title: 'IP',
dataIndex: 'ip',
key: 'ip',
render: (ip: string) => <Typography.Text code>{ip}</Typography.Text>,
},
{
title: 'Hostname',
dataIndex: 'hostname',
key: 'hostname',
render: (val: string | null) => val || <Typography.Text type="secondary"></Typography.Text>,
},
{
title: 'Снапшот',
key: 'status',
render: (_, row) => {
if (row.has_snapshot) return <Tag color="green">Есть</Tag>
if (row.status === 'offline') return <Tag color="red">Нет пинга</Tag>
return <Tag color="orange">Нет снапшота</Tag>
},
},
{
title: 'Собран',
dataIndex: 'last_collected_at',
key: 'last_collected_at',
render: (val: string | null) =>
val ? (
<Tooltip title={dayjs(val).format('DD.MM.YYYY HH:mm:ss')}>
{dayjs(val).fromNow()}
</Tooltip>
) : (
<Typography.Text type="secondary"></Typography.Text>
),
},
{
title: '',
key: 'actions',
width: 120,
render: (_, row) =>
row.snapshot_id ? (
<Space size="small">
<Tooltip title="Просмотр">
<Button
type="text"
size="small"
icon={<EyeOutlined />}
onClick={() => handleView(row)}
/>
</Tooltip>
<Tooltip title="Скопировать">
<Button
type="text"
size="small"
icon={<CopyOutlined />}
onClick={() => handleCopy(row.snapshot_id!)}
/>
</Tooltip>
<Tooltip title="Скачать">
<Button
type="text"
size="small"
icon={<DownloadOutlined />}
onClick={() => handleDownload(row)}
/>
</Tooltip>
</Space>
) : null,
},
]
return (
<>
<Table
dataSource={devices}
columns={columns}
rowKey="device_id"
size="small"
pagination={false}
/>
<Modal
title={
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span>{viewTitle}</span>
<Button size="small" icon={<CopyOutlined />} onClick={handleViewCopy}>
Скопировать
</Button>
</div>
}
open={viewContent !== null}
onCancel={() => setViewContent(null)}
footer={null}
width={800}
maskClosable
destroyOnClose
>
<pre
style={{
background: '#1e1e1e',
color: '#d4d4d4',
padding: 16,
borderRadius: 6,
fontSize: 12,
lineHeight: 1.5,
maxHeight: '70vh',
overflow: 'auto',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
margin: 0,
fontFamily: "'Consolas', 'Monaco', 'Courier New', monospace",
}}
>
{viewContent && highlightIni(viewContent)}
</pre>
</Modal>
</>
)
}