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(null) const [settingsOpen, setSettingsOpen] = useState(false) const [form] = Form.useForm() 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() 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 = {} 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 = [ { title: 'Название', dataIndex: 'label', key: 'label' }, { title: 'Категория', dataIndex: 'category', key: 'category', render: (cat: string) => {cat}, }, { 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) => ( {settingsOpen && ( )} {isLoading && Загрузка...} {!isLoading && filtered.length === 0 && ( {(policies ?? []).length === 0 ? 'Нет политик. Нажмите «Политики» чтобы настроить сбор снапшотов.' : 'Нет объектов с устройствами подходящих категорий.'} )} city)} items={cityGroups.map(([city, objects]) => ({ key: city, label: ( {city === UNGROUPED ? 'Без города' : city} {objects.length} ), children: ( ), }))} /> setPolicyModalOpen(false)} onOk={handlePolicySubmit} confirmLoading={createMutation.isPending || updateMutation.isPending} okText="Сохранить" cancelText="Отмена" >
{actionType === 'get_file' && ( )}
) } function ObjectsTable({ objects, onCollect, }: { objects: ObjectSnapshotSummary[] onCollect: (policyId: number, objId: number) => void }) { const [expandedKeys, setExpandedKeys] = useState([]) const columns: ColumnsType = [ { title: 'Объект', key: 'name', render: (_, obj) => ( {obj.object_name} {obj.client && — {obj.client}} ), }, { title: 'Последний сбор', key: 'last_collected', width: 160, render: (_, obj) => { const latest = obj.policies.reduce((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 ? ( {dayjs(latest).fromNow()} ) : ( ) }, }, { 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 нет устройств const ratio = success / total const color = ratio >= 1 ? 'green' : ratio > 0 ? 'orange' : 'red' return ( {success} / {total} ) }, }, { title: '', key: 'actions', width: 50, render: (_, obj) => (
({ 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) => ( ), }} /> ) } function ObjectDevicesExpanded({ obj, onCollect, }: { obj: ObjectSnapshotSummary onCollect: (policyId: number, objId: number) => void }) { if (obj.policies.length === 1) { return ( ) } return (
{obj.policies.map((p) => (
{p.policy_label} {p.category}
))}
) } function highlightIni(text: string): React.ReactNode[] { return text.split('\n').map((line, i) => { let node: React.ReactNode if (/^\s*[#;]/.test(line)) { node = {line} } else if (/^\s*\[.+\]/.test(line) && !line.includes('=')) { node = {line} } else if (line.includes('=')) { const eqIdx = line.indexOf('=') const key = line.slice(0, eqIdx) const eq = '=' const value = line.slice(eqIdx + 1) node = ( <> {key} {eq} {highlightValue(value)} ) } else { node = {line} } return {node}{'\n'} }) } 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({str}) i = end + 1 } else { parts.push({value.slice(i)}) break } } else if (ch === '{' || ch === '}' || ch === '[' || ch === ']' || ch === ':' || ch === ',') { parts.push({ch}) i++ } else if (/\d/.test(ch)) { let num = '' while (i < value.length && /[\d.]/.test(value[i])) { num += value[i] i++ } parts.push({num}) } 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({word}) i += word.length } else { parts.push({ch}) 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(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 Загрузка... if (!devices || devices.length === 0) return Нет устройств const columns: ColumnsType = [ { title: 'IP', dataIndex: 'ip', key: 'ip', render: (ip: string) => {ip}, }, { title: 'Hostname', dataIndex: 'hostname', key: 'hostname', render: (val: string | null) => val || , }, { title: 'Снапшот', key: 'status', render: (_, row) => { if (row.has_snapshot) return Есть if (row.status === 'offline') return Нет пинга return Нет снапшота }, }, { title: 'Собран', dataIndex: 'last_collected_at', key: 'last_collected_at', render: (val: string | null) => val ? ( {dayjs(val).fromNow()} ) : ( ), }, { title: '', key: 'actions', width: 120, render: (_, row) => row.snapshot_id ? (
{viewTitle} } open={viewContent !== null} onCancel={() => setViewContent(null)} footer={null} width={800} maskClosable destroyOnClose >
          {viewContent && highlightIni(viewContent)}
        
) }