фикс камер
This commit is contained in:
parent
ee38abbecb
commit
e4d8249e83
4 changed files with 377 additions and 8 deletions
|
|
@ -4,6 +4,7 @@ from datetime import datetime, timezone
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
|
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
|
||||||
|
from fastapi.responses import Response as FastAPIResponse
|
||||||
from sqlalchemy import select, update as sa_update
|
from sqlalchemy import select, update as sa_update
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
@ -20,6 +21,7 @@ from app.schemas.device import (
|
||||||
DeviceRead,
|
DeviceRead,
|
||||||
DeviceUpdate,
|
DeviceUpdate,
|
||||||
)
|
)
|
||||||
|
from app.services import camera as camera_service
|
||||||
from app.services import crypto
|
from app.services import crypto
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1/objects/{obj_id}/devices", tags=["devices"])
|
router = APIRouter(prefix="/api/v1/objects/{obj_id}/devices", tags=["devices"])
|
||||||
|
|
@ -261,6 +263,26 @@ async def get_device_credentials(
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{device_id}/camera/snapshot")
|
||||||
|
async def camera_snapshot(
|
||||||
|
obj_id: int,
|
||||||
|
device_id: int,
|
||||||
|
channel: int = 1,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Proxy a JPEG snapshot from a camera via ISAPI (or frame_url from connection_params)."""
|
||||||
|
device = await _get_device(db, obj_id, device_id)
|
||||||
|
if device.category != "camera":
|
||||||
|
raise HTTPException(status_code=400, detail="Device is not a camera")
|
||||||
|
obj = await _get_object(db, obj_id)
|
||||||
|
try:
|
||||||
|
image_bytes, content_type = await camera_service.isapi_snapshot(device, obj, channel=channel)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
raise HTTPException(status_code=502, detail=str(exc))
|
||||||
|
return FastAPIResponse(content=image_bytes, media_type=content_type)
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{device_id}", response_model=DeviceRead)
|
@router.patch("/{device_id}", response_model=DeviceRead)
|
||||||
async def update_device(
|
async def update_device(
|
||||||
obj_id: int,
|
obj_id: int,
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,44 @@ async def isapi_get(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def isapi_snapshot(
|
||||||
|
device: Device,
|
||||||
|
obj: Object,
|
||||||
|
channel: int = 1,
|
||||||
|
timeout: int = 10,
|
||||||
|
) -> tuple[bytes, str]:
|
||||||
|
"""
|
||||||
|
Fetch a JPEG snapshot from the camera via ISAPI.
|
||||||
|
Returns (image_bytes, content_type). Raises RuntimeError on failure.
|
||||||
|
"""
|
||||||
|
user, password, port = _resolve_camera_credentials(device, obj)
|
||||||
|
|
||||||
|
# Try connection_params.frame_url first (custom snapshot URL)
|
||||||
|
frame_url: Optional[str] = (device.connection_params or {}).get("frame_url")
|
||||||
|
if frame_url:
|
||||||
|
url = frame_url
|
||||||
|
else:
|
||||||
|
# Hikvision ISAPI channel format: channel 1 main = 101
|
||||||
|
ch = f"{channel:02d}1"
|
||||||
|
url = f"http://{device.ip}:{port}/ISAPI/Streaming/channels/{ch}/picture"
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(auth=(user, password), timeout=timeout) as client:
|
||||||
|
resp = await client.get(url)
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
raise RuntimeError("Камера не отвечает (timeout)")
|
||||||
|
except Exception as exc:
|
||||||
|
raise RuntimeError(str(exc))
|
||||||
|
|
||||||
|
if resp.status_code == 401:
|
||||||
|
raise RuntimeError("Ошибка авторизации (401) — проверьте логин/пароль камеры")
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
raise RuntimeError(f"Камера вернула HTTP {resp.status_code}")
|
||||||
|
|
||||||
|
content_type = resp.headers.get("content-type", "image/jpeg")
|
||||||
|
return resp.content, content_type
|
||||||
|
|
||||||
|
|
||||||
async def isapi_put(
|
async def isapi_put(
|
||||||
device: Device,
|
device: Device,
|
||||||
obj: Object,
|
obj: Object,
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import {
|
||||||
Select,
|
Select,
|
||||||
message,
|
message,
|
||||||
} from 'antd'
|
} from 'antd'
|
||||||
import { useEffect } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
useCreateDevice,
|
useCreateDevice,
|
||||||
useUpdateDevice,
|
useUpdateDevice,
|
||||||
|
|
@ -54,6 +54,7 @@ interface Props {
|
||||||
|
|
||||||
export function DeviceEditModal({ open, device, objId, onClose, onSaved }: Props) {
|
export function DeviceEditModal({ open, device, objId, onClose, onSaved }: Props) {
|
||||||
const [form] = Form.useForm()
|
const [form] = Form.useForm()
|
||||||
|
const [category, setCategory] = useState(device?.category ?? 'other')
|
||||||
const { data: racks } = useRacks(objId)
|
const { data: racks } = useRacks(objId)
|
||||||
const { data: sshKeys } = useSSHKeys()
|
const { data: sshKeys } = useSSHKeys()
|
||||||
const createDevice = useCreateDevice(objId)
|
const createDevice = useCreateDevice(objId)
|
||||||
|
|
@ -64,6 +65,7 @@ export function DeviceEditModal({ open, device, objId, onClose, onSaved }: Props
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
if (device) {
|
if (device) {
|
||||||
|
const cp = device.connection_params ?? {}
|
||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
ip: device.ip,
|
ip: device.ip,
|
||||||
hostname: device.hostname ?? '',
|
hostname: device.hostname ?? '',
|
||||||
|
|
@ -78,23 +80,65 @@ export function DeviceEditModal({ open, device, objId, onClose, onSaved }: Props
|
||||||
ssh_user_override: device.ssh_user_override ?? '',
|
ssh_user_override: device.ssh_user_override ?? '',
|
||||||
ssh_port_override: device.ssh_port_override ?? undefined,
|
ssh_port_override: device.ssh_port_override ?? undefined,
|
||||||
ssh_key_id_override: device.ssh_key_id_override ?? undefined,
|
ssh_key_id_override: device.ssh_key_id_override ?? undefined,
|
||||||
db_via_tunnel: !!(device.connection_params?.db_via_tunnel),
|
db_via_tunnel: !!(cp.db_via_tunnel),
|
||||||
|
// camera fields
|
||||||
|
cam_http_port: (device.device_meta as any)?.http_port ?? undefined,
|
||||||
|
cam_auth_type: (cp.auth_type as string) ?? undefined,
|
||||||
|
cam_username: (cp.username as string) ?? undefined,
|
||||||
|
cam_password: (cp.password as string) ?? undefined,
|
||||||
|
cam_frame_url: (cp.frame_url as string) ?? undefined,
|
||||||
|
cam_stream_url: (cp.stream_url as string) ?? undefined,
|
||||||
})
|
})
|
||||||
|
setCategory(device.category)
|
||||||
} else {
|
} else {
|
||||||
form.resetFields()
|
form.resetFields()
|
||||||
|
setCategory('other')
|
||||||
}
|
}
|
||||||
}, [open, device])
|
}, [open, device])
|
||||||
|
|
||||||
const handleOk = async () => {
|
const handleOk = async () => {
|
||||||
const values = await form.validateFields()
|
const values = await form.validateFields()
|
||||||
const { db_via_tunnel, ...rest } = values
|
const {
|
||||||
|
db_via_tunnel,
|
||||||
|
cam_http_port,
|
||||||
|
cam_auth_type,
|
||||||
|
cam_username,
|
||||||
|
cam_password,
|
||||||
|
cam_frame_url,
|
||||||
|
cam_stream_url,
|
||||||
|
...rest
|
||||||
|
} = values
|
||||||
const payload = stripEmpty(rest)
|
const payload = stripEmpty(rest)
|
||||||
if ('rack_id' in values && values.rack_id === undefined) {
|
if ('rack_id' in values && values.rack_id === undefined) {
|
||||||
payload.rack_id = null
|
payload.rack_id = null
|
||||||
}
|
}
|
||||||
// Merge db_via_tunnel into connection_params
|
|
||||||
const existingParams = device?.connection_params ?? {}
|
const existingParams = device?.connection_params ?? {}
|
||||||
|
const existingMeta = device?.device_meta ?? {}
|
||||||
|
|
||||||
|
if (category === 'camera') {
|
||||||
|
// Camera: store plaintext password + urls in connection_params, http_port in device_meta
|
||||||
|
const camParams: Record<string, unknown> = { ...existingParams }
|
||||||
|
if (cam_auth_type) camParams.auth_type = cam_auth_type
|
||||||
|
else delete camParams.auth_type
|
||||||
|
if (cam_username) camParams.username = cam_username
|
||||||
|
else delete camParams.username
|
||||||
|
if (cam_password) camParams.password = cam_password // plaintext, no encryption
|
||||||
|
else delete camParams.password
|
||||||
|
if (cam_frame_url) camParams.frame_url = cam_frame_url
|
||||||
|
else delete camParams.frame_url
|
||||||
|
if (cam_stream_url) camParams.stream_url = cam_stream_url
|
||||||
|
else delete camParams.stream_url
|
||||||
|
payload.connection_params = camParams
|
||||||
|
|
||||||
|
const camMeta: Record<string, unknown> = { ...existingMeta }
|
||||||
|
if (cam_http_port) camMeta.http_port = cam_http_port
|
||||||
|
else delete camMeta.http_port
|
||||||
|
payload.device_meta = camMeta
|
||||||
|
} else {
|
||||||
|
// Non-camera: only db_via_tunnel in connection_params
|
||||||
payload.connection_params = { ...existingParams, db_via_tunnel: !!db_via_tunnel }
|
payload.connection_params = { ...existingParams, db_via_tunnel: !!db_via_tunnel }
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
let saved: DeviceItem
|
let saved: DeviceItem
|
||||||
if (device) {
|
if (device) {
|
||||||
|
|
@ -127,7 +171,14 @@ export function DeviceEditModal({ open, device, objId, onClose, onSaved }: Props
|
||||||
okText={device ? 'Сохранить' : 'Добавить'}
|
okText={device ? 'Сохранить' : 'Добавить'}
|
||||||
width={640}
|
width={640}
|
||||||
>
|
>
|
||||||
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
style={{ marginTop: 16 }}
|
||||||
|
onValuesChange={(changed) => {
|
||||||
|
if (changed.category !== undefined) setCategory(changed.category)
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
<Form.Item name="ip" label="IP-адрес" rules={[{ required: true, message: 'Обязательное поле' }]}>
|
<Form.Item name="ip" label="IP-адрес" rules={[{ required: true, message: 'Обязательное поле' }]}>
|
||||||
|
|
@ -227,6 +278,49 @@ export function DeviceEditModal({ open, device, objId, onClose, onSaved }: Props
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{category === 'camera' && (
|
||||||
|
<>
|
||||||
|
<Divider orientation="left" plain>Настройки камеры</Divider>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={8}>
|
||||||
|
<Form.Item name="cam_http_port" label="HTTP порт">
|
||||||
|
<InputNumber style={{ width: '100%' }} min={1} max={65535} placeholder="80" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<Form.Item name="cam_auth_type" label="Авторизация">
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
placeholder="basic"
|
||||||
|
options={[
|
||||||
|
{ value: 'basic', label: 'Basic' },
|
||||||
|
{ value: 'digest', label: 'Digest' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item name="cam_username" label="Логин (веб-интерфейс)">
|
||||||
|
<Input placeholder="admin" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item name="cam_password" label="Пароль (веб-интерфейс, открытый текст)">
|
||||||
|
<Input.Password placeholder="••••••••" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Form.Item name="cam_frame_url" label="Frame URL (MJPEG снимок)">
|
||||||
|
<Input placeholder="http://192.168.1.100/cgi-bin/snapshot.jpg" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="cam_stream_url" label="Stream URL (RTSP)">
|
||||||
|
<Input placeholder="rtsp://192.168.1.100:554/Streaming/Channels/101" />
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
FileSearchOutlined,
|
FileSearchOutlined,
|
||||||
InfoCircleOutlined,
|
InfoCircleOutlined,
|
||||||
CameraOutlined,
|
CameraOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
|
|
@ -574,6 +575,216 @@ function AlertsTab({ deviceId }: { deviceId: number }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Camera tab ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function CameraStatusInfo({ deviceId, tasks }: { deviceId: number; tasks: Task[] | undefined }) {
|
||||||
|
const latestTask = tasks
|
||||||
|
?.filter((t) => t.type === 'camera_get_status' && t.status === 'success')
|
||||||
|
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())[0]
|
||||||
|
|
||||||
|
if (!latestTask) return null
|
||||||
|
|
||||||
|
return <CameraStatusFromTask taskId={latestTask.id} deviceId={deviceId} taskDate={latestTask.created_at} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function CameraStatusFromTask({ taskId, deviceId, taskDate }: { taskId: string; deviceId: number; taskDate: string }) {
|
||||||
|
const { data: detail, isLoading } = useTask(taskId)
|
||||||
|
const dr = detail?.device_results.find((r) => r.device_id === deviceId)
|
||||||
|
const data = dr?.data as { device_info?: Record<string, unknown>; system_time?: Record<string, unknown> } | undefined
|
||||||
|
|
||||||
|
if (isLoading) return <Spin size="small" />
|
||||||
|
if (!data?.device_info) return null
|
||||||
|
|
||||||
|
const di = data.device_info
|
||||||
|
const st = data.system_time ?? {}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
Последний статус
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 11, fontWeight: 'normal' }}>
|
||||||
|
({dayjs(taskDate).format('DD.MM.YY HH:mm')})
|
||||||
|
</Typography.Text>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
>
|
||||||
|
<Descriptions size="small" column={2}>
|
||||||
|
{di.model && <Descriptions.Item label="Модель">{String(di.model)}</Descriptions.Item>}
|
||||||
|
{di.firmwareVersion && <Descriptions.Item label="Прошивка">{String(di.firmwareVersion)}</Descriptions.Item>}
|
||||||
|
{di.serialNumber && <Descriptions.Item label="Серийный №">{String(di.serialNumber)}</Descriptions.Item>}
|
||||||
|
{di.macAddress && <Descriptions.Item label="MAC">{String(di.macAddress)}</Descriptions.Item>}
|
||||||
|
{st.localTime && <Descriptions.Item label="Время камеры">{String(st.localTime)}</Descriptions.Item>}
|
||||||
|
{st.timeZone && <Descriptions.Item label="Часовой пояс">{String(st.timeZone)}</Descriptions.Item>}
|
||||||
|
</Descriptions>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CameraTab({
|
||||||
|
device,
|
||||||
|
objId,
|
||||||
|
tasks,
|
||||||
|
creds,
|
||||||
|
}: {
|
||||||
|
device: DeviceItem
|
||||||
|
objId: number
|
||||||
|
tasks: Task[] | undefined
|
||||||
|
creds: DeviceCredentials | undefined
|
||||||
|
}) {
|
||||||
|
const [snapshotKey, setSnapshotKey] = useState(0)
|
||||||
|
const [snapshotError, setSnapshotError] = useState<string | null>(null)
|
||||||
|
const [channel, setChannel] = useState(1)
|
||||||
|
const createTask = useCreateTask()
|
||||||
|
|
||||||
|
const params = device.connection_params ?? {}
|
||||||
|
const meta = device.device_meta ?? {}
|
||||||
|
const httpPort = meta.http_port ?? 80
|
||||||
|
const streamUrl: string | null = (params.stream_url as string) ?? null
|
||||||
|
const authType: string | null = (params.auth_type as string) ?? null
|
||||||
|
const webUser: string | null = (params.username as string) ?? device.ssh_user_override ?? null
|
||||||
|
|
||||||
|
// Build default RTSP URL if not in connection_params
|
||||||
|
const rtspUrl = streamUrl ?? `rtsp://${device.ip}:554/Streaming/Channels/${channel}01`
|
||||||
|
|
||||||
|
const snapshotSrc = `/api/v1/objects/${objId}/devices/${device.id}/camera/snapshot?channel=${channel}&_k=${snapshotKey}`
|
||||||
|
|
||||||
|
const handleGetStatus = async () => {
|
||||||
|
try {
|
||||||
|
const task = await createTask.mutateAsync({
|
||||||
|
type: 'camera_get_status',
|
||||||
|
target_scope: { type: 'device', id: device.id },
|
||||||
|
params: {},
|
||||||
|
})
|
||||||
|
message.success(
|
||||||
|
<span>Задача запущена. <a href={`/tasks/${task.id}`} target="_blank" rel="noopener noreferrer">Открыть</a></span>,
|
||||||
|
5,
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
message.error('Не удалось запустить задачу')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Row gutter={24}>
|
||||||
|
<Col xs={24} lg={14}>
|
||||||
|
{/* Connection parameters */}
|
||||||
|
<Card size="small" title="Параметры подключения" style={{ marginBottom: 16 }}>
|
||||||
|
<Descriptions size="small" column={1}>
|
||||||
|
<Descriptions.Item label="HTTP порт">
|
||||||
|
<Typography.Text code>{String(httpPort)}</Typography.Text>
|
||||||
|
</Descriptions.Item>
|
||||||
|
{authType && (
|
||||||
|
<Descriptions.Item label="Авторизация">
|
||||||
|
<Tag color={authType === 'digest' ? 'orange' : 'blue'}>{authType}</Tag>
|
||||||
|
</Descriptions.Item>
|
||||||
|
)}
|
||||||
|
{webUser && (
|
||||||
|
<Descriptions.Item label="Логин (веб)">
|
||||||
|
<Typography.Text code copyable>{webUser}</Typography.Text>
|
||||||
|
</Descriptions.Item>
|
||||||
|
)}
|
||||||
|
{creds?.connection_password && (
|
||||||
|
<Descriptions.Item label="Пароль (веб)">
|
||||||
|
<Typography.Text code copyable>{creds.connection_password}</Typography.Text>
|
||||||
|
</Descriptions.Item>
|
||||||
|
)}
|
||||||
|
<Descriptions.Item label="RTSP поток">
|
||||||
|
<Space>
|
||||||
|
<Typography.Text code copyable style={{ fontSize: 11 }}>{rtspUrl}</Typography.Text>
|
||||||
|
<Tooltip title="Открыть в VLC (если установлен)">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="link"
|
||||||
|
onClick={() => { window.open(`vlc://${rtspUrl}`) }}
|
||||||
|
>
|
||||||
|
VLC
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</Space>
|
||||||
|
</Descriptions.Item>
|
||||||
|
{params.frame_url && (
|
||||||
|
<Descriptions.Item label="Frame URL">
|
||||||
|
<Typography.Text code copyable style={{ fontSize: 11 }}>{String(params.frame_url)}</Typography.Text>
|
||||||
|
</Descriptions.Item>
|
||||||
|
)}
|
||||||
|
</Descriptions>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Last camera status */}
|
||||||
|
<CameraStatusInfo deviceId={device.id} tasks={tasks} />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
icon={<InfoCircleOutlined />}
|
||||||
|
loading={createTask.isPending}
|
||||||
|
onClick={handleGetStatus}
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
>
|
||||||
|
Получить статус камеры
|
||||||
|
</Button>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col xs={24} lg={10}>
|
||||||
|
{/* Live snapshot */}
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
title="Снимок с камеры"
|
||||||
|
extra={
|
||||||
|
<Space size={8}>
|
||||||
|
<Select
|
||||||
|
size="small"
|
||||||
|
value={channel}
|
||||||
|
onChange={setChannel}
|
||||||
|
options={[
|
||||||
|
{ value: 1, label: 'Канал 1' },
|
||||||
|
{ value: 2, label: 'Канал 2' },
|
||||||
|
{ value: 3, label: 'Канал 3' },
|
||||||
|
]}
|
||||||
|
style={{ width: 90 }}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
onClick={() => { setSnapshotError(null); setSnapshotKey((k) => k + 1) }}
|
||||||
|
>
|
||||||
|
Обновить
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{snapshotError ? (
|
||||||
|
<div style={{ padding: '16px 0', textAlign: 'center' }}>
|
||||||
|
<Typography.Text type="danger">{snapshotError}</Typography.Text>
|
||||||
|
<br />
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
style={{ marginTop: 8 }}
|
||||||
|
onClick={() => { setSnapshotError(null); setSnapshotKey((k) => k + 1) }}
|
||||||
|
>
|
||||||
|
Повторить
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
key={snapshotKey}
|
||||||
|
src={snapshotSrc}
|
||||||
|
alt="Camera snapshot"
|
||||||
|
style={{ width: '100%', borderRadius: 4, display: 'block' }}
|
||||||
|
onError={() => setSnapshotError('Не удалось загрузить снимок. Проверьте подключение и кредиалы камеры.')}
|
||||||
|
onLoad={() => setSnapshotError(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 11, display: 'block', marginTop: 8 }}>
|
||||||
|
Снимок загружается через сервер — не требует прямого доступа к камере из браузера
|
||||||
|
</Typography.Text>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Snapshots tab ────────────────────────────────────────────────────────────
|
// ─── Snapshots tab ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function SnapshotsTab({ objId, deviceId, device }: { objId: number; deviceId: number; device: DeviceItem }) {
|
function SnapshotsTab({ objId, deviceId, device }: { objId: number; deviceId: number; device: DeviceItem }) {
|
||||||
|
|
@ -1054,8 +1265,7 @@ export function DeviceDetailPage() {
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
|
|
||||||
{/* SSH block — always shown */}
|
{/* SSH block — временно скрыт */}
|
||||||
<SshBlock device={device} obj={obj} creds={creds} credsLoading={credsLoading} />
|
|
||||||
|
|
||||||
{/* System info (Linux-like devices) */}
|
{/* System info (Linux-like devices) */}
|
||||||
{isLinuxLike && <SystemInfoBlock device={device} tasks={tasks} />}
|
{isLinuxLike && <SystemInfoBlock device={device} tasks={tasks} />}
|
||||||
|
|
@ -1094,6 +1304,11 @@ export function DeviceDetailPage() {
|
||||||
</Row>
|
</Row>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
...(device.category === 'camera' ? [{
|
||||||
|
key: 'camera',
|
||||||
|
label: 'Камера',
|
||||||
|
children: <CameraTab device={device} objId={objId} tasks={tasks} creds={creds} />,
|
||||||
|
}] : []),
|
||||||
{
|
{
|
||||||
key: 'alerts',
|
key: 'alerts',
|
||||||
label: 'Алерты',
|
label: 'Алерты',
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue