745 lines
22 KiB
TypeScript
745 lines
22 KiB
TypeScript
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>
|
||
</>
|
||
)
|
||
}
|