diff --git a/frontend/src/pages/DeviceDetail.tsx b/frontend/src/pages/DeviceDetail.tsx
index 44ae1a6..0ee3c8b 100644
--- a/frontend/src/pages/DeviceDetail.tsx
+++ b/frontend/src/pages/DeviceDetail.tsx
@@ -4,6 +4,7 @@ import {
DeleteOutlined,
EditOutlined,
EyeOutlined,
+ LinkOutlined,
} from '@ant-design/icons'
import {
Badge,
@@ -29,6 +30,7 @@ import { useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { useAlerts, useAcknowledgeAlert, useResolveAlert, type AlertItem } from '../api/alerts'
import { useDevice, useDeleteDevice, useObject } from '../api/objects'
+import { getDeviceWebUrl } from '../utils/deviceUrl'
import { useSnapshots, useSnapshotDiff } from '../api/snapshots'
import { useDeviceTasks, useTask, type Task } from '../api/tasks'
import { DeviceEditModal } from '../components/DeviceEditModal'
@@ -397,6 +399,7 @@ export function DeviceDetailPage() {
const effectiveStatus = liveStatus?.status ?? device?.status ?? 'unknown'
const effectivePing = liveStatus !== null ? liveStatus.ping_ms : (device?.last_ping_ms ?? null)
+ const webUrl = device ? getDeviceWebUrl(device) : null
const handleDelete = async () => {
try {
@@ -581,6 +584,18 @@ export function DeviceDetailPage() {
+ {webUrl && (
+
+ }
+ href={webUrl}
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ Веб-интерфейс
+
+
+ )}
} onClick={() => setEditOpen(true)}>
Редактировать
diff --git a/frontend/src/pages/InventoryObjectDetail.tsx b/frontend/src/pages/InventoryObjectDetail.tsx
index c0deb97..bb982b7 100644
--- a/frontend/src/pages/InventoryObjectDetail.tsx
+++ b/frontend/src/pages/InventoryObjectDetail.tsx
@@ -56,34 +56,11 @@ import { CategoryTag, DeviceStatusBadge } from '../components/StatusBadge'
import { DeviceEditModal } from '../components/DeviceEditModal'
import { DiscoveryDrawer } from '../components/DiscoveryDrawer'
import { stripEmpty } from '../utils/form'
+import { getDeviceWebUrl } from '../utils/deviceUrl'
import dayjs from 'dayjs'
const SERVER_CATEGORIES = new Set(['main_server', 'vm', 'router'])
-/** Возвращает URL веб-интерфейса устройства или null, если ссылка не применима. */
-function getDeviceWebUrl(device: DeviceRow): string | null {
- const hostname = (device.hostname ?? '').toLowerCase()
-
- // Proxmox — определяем по hostname
- if (hostname.includes('proxmox') || hostname.includes('pve')) {
- return `http://${device.ip}:8006`
- }
-
- // RTSP-прокси (go2rtc/mediamtx) — определяем по capabilities
- if (device.capabilities?.includes('rtsp')) {
- return `http://${device.ip}:1984`
- }
-
- switch (device.category) {
- case 'main_server': return `http://${device.ip}:80`
- case 'router': return `http://${device.ip}:80` // MikroTik WebFig
- case 'embedded': return `http://${device.ip}:5000`
- case 'camera': return `http://${device.ip}:80`
- // vm без proxmox/pve в hostname — asterisk, db и прочие, ссылка не нужна
- default: return null
- }
-}
-
const rackLabel = (name: string, type: string | null | undefined): string =>
type ? `${name} ${type}` : name
const DEVICE_COL_SPAN = 8
diff --git a/frontend/src/utils/deviceUrl.ts b/frontend/src/utils/deviceUrl.ts
new file mode 100644
index 0000000..9048757
--- /dev/null
+++ b/frontend/src/utils/deviceUrl.ts
@@ -0,0 +1,40 @@
+import type { DeviceItem } from '../api/objects'
+
+/**
+ * Возвращает URL веб-интерфейса устройства или null, если ссылка не применима.
+ *
+ * Приоритет проверок:
+ * 1. Proxmox — hostname содержит «proxmox» или «pve» → :8006
+ * 2. RTSP-прокси (go2rtc/mediamtx) — capabilities содержит «rtsp»
+ * ИЛИ device_meta.cls === «rtsp_server»
+ * ИЛИ device_meta.plugin содержит «rtsp» → :1984
+ * 3. Категория: main_server/:80, router (WebFig)/:80, embedded/:5000, camera/:80
+ * 4. vm без proxmox/pve, io_board, bank_terminal, cash_register, other → null
+ */
+export function getDeviceWebUrl(device: DeviceItem): string | null {
+ const hostname = (device.hostname ?? '').toLowerCase()
+ const metaCls = String(device.device_meta?.cls ?? '').toLowerCase()
+ const metaPlugin = String(device.device_meta?.plugin ?? '').toLowerCase()
+
+ // Proxmox
+ if (hostname.includes('proxmox') || hostname.includes('pve')) {
+ return `http://${device.ip}:8006`
+ }
+
+ // RTSP-прокси (go2rtc / mediamtx)
+ if (
+ device.capabilities?.includes('rtsp') ||
+ metaCls === 'rtsp_server' ||
+ metaPlugin.includes('rtsp')
+ ) {
+ return `http://${device.ip}:1984`
+ }
+
+ switch (device.category) {
+ case 'main_server': return `http://${device.ip}:80`
+ case 'router': return `http://${device.ip}:80` // MikroTik WebFig
+ case 'embedded': return `http://${device.ip}:5000`
+ case 'camera': return `http://${device.ip}:80`
+ default: return null
+ }
+}