sse ssh discovery drawer upgrade
This commit is contained in:
parent
c8429814bf
commit
b917ad6432
9 changed files with 202 additions and 42 deletions
22
backend/alembic/versions/0010_rack_type.py
Normal file
22
backend/alembic/versions/0010_rack_type.py
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
"""add rack_type to racks
|
||||||
|
|
||||||
|
Revision ID: 0010
|
||||||
|
Revises: 0009
|
||||||
|
Create Date: 2026-04-08
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "0010"
|
||||||
|
down_revision = "0009"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("racks", sa.Column("rack_type", sa.String(50), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("racks", "rack_type")
|
||||||
|
|
@ -17,6 +17,7 @@ class Rack(Base, TimestampMixin):
|
||||||
index=True,
|
index=True,
|
||||||
)
|
)
|
||||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
rack_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
|
||||||
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
location: Mapped[Optional[str]] = mapped_column(String(200), nullable=True)
|
location: Mapped[Optional[str]] = mapped_column(String(200), nullable=True)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,7 @@ class DeviceRead(BaseModel):
|
||||||
external_id: Optional[str] = None
|
external_id: Optional[str] = None
|
||||||
rack_id: Optional[int] = None
|
rack_id: Optional[int] = None
|
||||||
rack_name: Optional[str] = None
|
rack_name: Optional[str] = None
|
||||||
|
rack_type: Optional[str] = None
|
||||||
ssh_user_override: Optional[str] = None
|
ssh_user_override: Optional[str] = None
|
||||||
ssh_port_override: Optional[int] = None
|
ssh_port_override: Optional[int] = None
|
||||||
device_meta: dict[str, Any] = {}
|
device_meta: dict[str, Any] = {}
|
||||||
|
|
@ -91,6 +92,7 @@ class DeviceRead(BaseModel):
|
||||||
obj = cls.model_validate(device)
|
obj = cls.model_validate(device)
|
||||||
if device.rack is not None:
|
if device.rack is not None:
|
||||||
obj.rack_name = device.rack.name
|
obj.rack_name = device.rack.name
|
||||||
|
obj.rack_type = device.rack.rack_type
|
||||||
return obj
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ from pydantic import BaseModel
|
||||||
|
|
||||||
class RackCreate(BaseModel):
|
class RackCreate(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
|
rack_type: Optional[str] = None
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
location: Optional[str] = None
|
location: Optional[str] = None
|
||||||
zone_id: Optional[int] = None
|
zone_id: Optional[int] = None
|
||||||
|
|
@ -13,6 +14,7 @@ class RackCreate(BaseModel):
|
||||||
|
|
||||||
class RackUpdate(BaseModel):
|
class RackUpdate(BaseModel):
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
|
rack_type: Optional[str] = None
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
location: Optional[str] = None
|
location: Optional[str] = None
|
||||||
zone_id: Optional[int] = None
|
zone_id: Optional[int] = None
|
||||||
|
|
@ -22,6 +24,7 @@ class RackRead(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
object_id: int
|
object_id: int
|
||||||
name: str
|
name: str
|
||||||
|
rack_type: Optional[str] = None
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
location: Optional[str] = None
|
location: Optional[str] = None
|
||||||
zone_id: Optional[int] = None
|
zone_id: Optional[int] = None
|
||||||
|
|
|
||||||
|
|
@ -525,6 +525,7 @@ async def discover_via_ssh(
|
||||||
return result
|
return result
|
||||||
elif obj.server_ip:
|
elif obj.server_ip:
|
||||||
# Autodetect: SSH into server, read its caps.conf to get DB URI
|
# Autodetect: SSH into server, read its caps.conf to get DB URI
|
||||||
|
await _emit("disc_action", {"message": f"Чтение конфигурации сервера {obj.server_ip}..."})
|
||||||
detected = await _autodetect_db_from_server(
|
detected = await _autodetect_db_from_server(
|
||||||
server_ip=obj.server_ip,
|
server_ip=obj.server_ip,
|
||||||
ssh_port=ssh_port,
|
ssh_port=ssh_port,
|
||||||
|
|
@ -572,6 +573,7 @@ async def discover_via_ssh(
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# Step 1: get rack hosts from object's DB
|
# Step 1: get rack hosts from object's DB
|
||||||
|
await _emit("disc_action", {"message": "Получение списка стоек из БД..."})
|
||||||
try:
|
try:
|
||||||
rack_hosts = await _get_rack_hosts_direct(
|
rack_hosts = await _get_rack_hosts_direct(
|
||||||
host=db_host,
|
host=db_host,
|
||||||
|
|
@ -720,6 +722,7 @@ async def discover_via_ssh(
|
||||||
rack_hostname = external_id
|
rack_hostname = external_id
|
||||||
rack_ssh_ok = False
|
rack_ssh_ok = False
|
||||||
config_text = None
|
config_text = None
|
||||||
|
await _emit("disc_action", {"message": f"SSH → {rack_host} ({rack_name})"})
|
||||||
try:
|
try:
|
||||||
config_text = await _read_remote_config(
|
config_text = await _read_remote_config(
|
||||||
host=rack_host,
|
host=rack_host,
|
||||||
|
|
@ -820,6 +823,7 @@ async def discover_via_ssh(
|
||||||
|
|
||||||
# Get all rack IPs to exclude them from device discovery
|
# Get all rack IPs to exclude them from device discovery
|
||||||
all_rack_ips = [host for _, host, _ in rack_hosts]
|
all_rack_ips = [host for _, host, _ in rack_hosts]
|
||||||
|
await _emit("disc_action", {"message": f"Парсинг конфигурации {rack_name}..."})
|
||||||
devices_found = _parse_config(config_text, rack_host, all_rack_ips)
|
devices_found = _parse_config(config_text, rack_host, all_rack_ips)
|
||||||
logger.info(f"[ssh_discovery] Found {len(devices_found)} devices from plugins in {rack_host}")
|
logger.info(f"[ssh_discovery] Found {len(devices_found)} devices from plugins in {rack_host}")
|
||||||
|
|
||||||
|
|
@ -962,10 +966,15 @@ async def discover_via_ssh(
|
||||||
- infra_ips
|
- infra_ips
|
||||||
- common_gateways
|
- common_gateways
|
||||||
)
|
)
|
||||||
|
await _emit("disc_phase", {"phase": "slaves", "candidate_count": len(slave_candidates)})
|
||||||
if slave_candidates:
|
if slave_candidates:
|
||||||
logger.info(f"[ssh_discovery] Step 2.5: Slave discovery — {len(slave_candidates)} ARP candidate(s): {slave_candidates}")
|
logger.info(f"[ssh_discovery] Step 2.5: Slave discovery — {len(slave_candidates)} ARP candidate(s): {slave_candidates}")
|
||||||
|
await _emit("disc_action", {"message": f"Поиск слейв-стоек: проверяем {len(slave_candidates)} ARP-кандидатов..."})
|
||||||
|
else:
|
||||||
|
await _emit("disc_action", {"message": "Слейв-стойки: ARP-кандидатов не найдено"})
|
||||||
for candidate_ip in slave_candidates:
|
for candidate_ip in slave_candidates:
|
||||||
logger.info(f"[ssh_discovery] Checking slave candidate {candidate_ip}...")
|
logger.info(f"[ssh_discovery] Checking slave candidate {candidate_ip}...")
|
||||||
|
await _emit("disc_action", {"message": f"Проверяем слейв-кандидата {candidate_ip}..."})
|
||||||
# Check each rack host that could be the master — slave's linking.uri must match
|
# Check each rack host that could be the master — slave's linking.uri must match
|
||||||
is_slave = False
|
is_slave = False
|
||||||
slave_identifier = ""
|
slave_identifier = ""
|
||||||
|
|
@ -997,6 +1006,7 @@ async def discover_via_ssh(
|
||||||
continue
|
continue
|
||||||
|
|
||||||
logger.info(f"[ssh_discovery] {candidate_ip} — SLAVE rack confirmed (name={slave_identifier}, master={master_ip_for_slave})")
|
logger.info(f"[ssh_discovery] {candidate_ip} — SLAVE rack confirmed (name={slave_identifier}, master={master_ip_for_slave})")
|
||||||
|
await _emit("disc_action", {"message": f"Слейв подтверждён: {slave_identifier} ({candidate_ip})"})
|
||||||
|
|
||||||
# Create Rack entry for the slave in our DB
|
# Create Rack entry for the slave in our DB
|
||||||
slave_rack_id: int | None = None
|
slave_rack_id: int | None = None
|
||||||
|
|
@ -1063,6 +1073,7 @@ async def discover_via_ssh(
|
||||||
all_rack_ips_with_slave = list(all_rack_ips_set) + [candidate_ip]
|
all_rack_ips_with_slave = list(all_rack_ips_set) + [candidate_ip]
|
||||||
slave_devices = _parse_config(slave_config_text, candidate_ip, all_rack_ips_with_slave)
|
slave_devices = _parse_config(slave_config_text, candidate_ip, all_rack_ips_with_slave)
|
||||||
logger.info(f"[ssh_discovery] Slave {candidate_ip}: found {len(slave_devices)} plugin devices")
|
logger.info(f"[ssh_discovery] Slave {candidate_ip}: found {len(slave_devices)} plugin devices")
|
||||||
|
await _emit("disc_slave_found", {"rack_name": slave_identifier, "ip": candidate_ip, "found": len(slave_devices)})
|
||||||
for found in slave_devices:
|
for found in slave_devices:
|
||||||
is_shared = found.category == "vm"
|
is_shared = found.category == "vm"
|
||||||
if is_shared:
|
if is_shared:
|
||||||
|
|
@ -1105,6 +1116,8 @@ async def discover_via_ssh(
|
||||||
result.errors.append(f"Flush error after slave {candidate_ip}: {exc}")
|
result.errors.append(f"Flush error after slave {candidate_ip}: {exc}")
|
||||||
|
|
||||||
# Step 3: Discover MikroTik via SSH_CLIENT
|
# Step 3: Discover MikroTik via SSH_CLIENT
|
||||||
|
await _emit("disc_phase", {"phase": "mikrotik"})
|
||||||
|
await _emit("disc_action", {"message": "Поиск MikroTik (SSH_CLIENT)..."})
|
||||||
logger.info(f"[ssh_discovery] Step 3: Attempting to discover MikroTik via SSH_CLIENT on {obj.server_ip or 'main_server'}")
|
logger.info(f"[ssh_discovery] Step 3: Attempting to discover MikroTik via SSH_CLIENT on {obj.server_ip or 'main_server'}")
|
||||||
mikrotik_ip = await _discover_mikrotik_via_ssh_client(
|
mikrotik_ip = await _discover_mikrotik_via_ssh_client(
|
||||||
host=obj.server_ip or rack_hosts[0][1],
|
host=obj.server_ip or rack_hosts[0][1],
|
||||||
|
|
@ -1114,6 +1127,7 @@ async def discover_via_ssh(
|
||||||
)
|
)
|
||||||
if mikrotik_ip:
|
if mikrotik_ip:
|
||||||
logger.info(f"[ssh_discovery] MikroTik found via SSH_CLIENT at {mikrotik_ip}")
|
logger.info(f"[ssh_discovery] MikroTik found via SSH_CLIENT at {mikrotik_ip}")
|
||||||
|
await _emit("disc_infra_found", {"type": "mikrotik", "ip": mikrotik_ip})
|
||||||
try:
|
try:
|
||||||
existing = (await db.execute(
|
existing = (await db.execute(
|
||||||
select(Device).where(
|
select(Device).where(
|
||||||
|
|
@ -1169,6 +1183,8 @@ async def discover_via_ssh(
|
||||||
logger.info(f"[ssh_discovery] MikroTik: No IP found via SSH_CLIENT (reason: SSH_CLIENT env var not available or parsing failed)")
|
logger.info(f"[ssh_discovery] MikroTik: No IP found via SSH_CLIENT (reason: SSH_CLIENT env var not available or parsing failed)")
|
||||||
|
|
||||||
# Step 4: Discover Proxmox via port 8006 scan
|
# Step 4: Discover Proxmox via port 8006 scan
|
||||||
|
await _emit("disc_phase", {"phase": "proxmox"})
|
||||||
|
await _emit("disc_action", {"message": "Сканирование порта 8006 (Proxmox)..."})
|
||||||
logger.info(f"[ssh_discovery] Step 4: Attempting to discover Proxmox servers via port 8006 scan on {obj.server_ip or 'main_server'}")
|
logger.info(f"[ssh_discovery] Step 4: Attempting to discover Proxmox servers via port 8006 scan on {obj.server_ip or 'main_server'}")
|
||||||
proxmox_ips = await _discover_proxmox_via_port_scan(
|
proxmox_ips = await _discover_proxmox_via_port_scan(
|
||||||
host=obj.server_ip or rack_hosts[0][1],
|
host=obj.server_ip or rack_hosts[0][1],
|
||||||
|
|
@ -1178,6 +1194,8 @@ async def discover_via_ssh(
|
||||||
)
|
)
|
||||||
if proxmox_ips:
|
if proxmox_ips:
|
||||||
logger.info(f"[ssh_discovery] Proxmox discovery found {len(proxmox_ips)} server(s) on port 8006: {proxmox_ips}")
|
logger.info(f"[ssh_discovery] Proxmox discovery found {len(proxmox_ips)} server(s) on port 8006: {proxmox_ips}")
|
||||||
|
for proxmox_ip in proxmox_ips:
|
||||||
|
await _emit("disc_infra_found", {"type": "proxmox", "ip": proxmox_ip})
|
||||||
for idx, proxmox_ip in enumerate(proxmox_ips, 1):
|
for idx, proxmox_ip in enumerate(proxmox_ips, 1):
|
||||||
hostname = f"proxmox_{idx}"
|
hostname = f"proxmox_{idx}"
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ export interface DeviceItem {
|
||||||
source: string
|
source: string
|
||||||
rack_id: number | null
|
rack_id: number | null
|
||||||
rack_name: string | null
|
rack_name: string | null
|
||||||
|
rack_type: string | null
|
||||||
ssh_user_override: string | null
|
ssh_user_override: string | null
|
||||||
ssh_port_override: number | null
|
ssh_port_override: number | null
|
||||||
status: string
|
status: string
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ export interface RackItem {
|
||||||
id: number
|
id: number
|
||||||
object_id: number
|
object_id: number
|
||||||
name: string
|
name: string
|
||||||
|
rack_type: string | null
|
||||||
description: string | null
|
description: string | null
|
||||||
location: string | null
|
location: string | null
|
||||||
zone_id: number | null
|
zone_id: number | null
|
||||||
|
|
@ -22,7 +23,7 @@ export const useRacks = (objId: number) =>
|
||||||
export const useCreateRack = (objId: number) => {
|
export const useCreateRack = (objId: number) => {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (body: { name: string; description?: string; location?: string; zone_id?: number | null }) =>
|
mutationFn: (body: { name: string; rack_type?: string; description?: string; location?: string; zone_id?: number | null }) =>
|
||||||
api.post<RackItem>(`/api/v1/objects/${objId}/racks`, body).then((r) => r.data),
|
api.post<RackItem>(`/api/v1/objects/${objId}/racks`, body).then((r) => r.data),
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['racks', objId] }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['racks', objId] }),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -17,13 +17,19 @@ interface RackStatus {
|
||||||
ssh_ok: boolean
|
ssh_ok: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DiscoveryResult {
|
interface SlaveItem {
|
||||||
created: number
|
rack_name: string
|
||||||
updated: number
|
ip: string
|
||||||
skipped: number
|
found: number
|
||||||
errors: string[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface InfraItem {
|
||||||
|
type: 'mikrotik' | 'proxmox'
|
||||||
|
ip: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type PhaseState = 'idle' | 'running' | 'done'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean
|
open: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
|
|
@ -34,20 +40,32 @@ interface Props {
|
||||||
|
|
||||||
export function DiscoveryDrawer({ open, onClose, objectName, taskId, onComplete }: Props) {
|
export function DiscoveryDrawer({ open, onClose, objectName, taskId, onComplete }: Props) {
|
||||||
const accessToken = useAuthStore((s) => s.accessToken)
|
const accessToken = useAuthStore((s) => s.accessToken)
|
||||||
const [phase, setPhase] = useState<'connecting' | 'running' | 'done'>('connecting')
|
const [phase, setPhase] = useState<'connecting' | 'racks' | 'slaves' | 'infra' | 'done'>('connecting')
|
||||||
|
const [currentAction, setCurrentAction] = useState<string | null>(null)
|
||||||
const [racks, setRacks] = useState<RackStatus[]>([])
|
const [racks, setRacks] = useState<RackStatus[]>([])
|
||||||
const [current, setCurrent] = useState(0)
|
const [current, setCurrent] = useState(0)
|
||||||
const [total, setTotal] = useState(0)
|
const [total, setTotal] = useState(0)
|
||||||
const [result, setResult] = useState<DiscoveryResult | null>(null)
|
const [slaves, setSlaves] = useState<SlaveItem[]>([])
|
||||||
|
const [slavesPhase, setSlavesPhase] = useState<PhaseState>('idle')
|
||||||
|
const [mikrotikPhase, setMikrotikPhase] = useState<PhaseState>('idle')
|
||||||
|
const [proxmoxPhase, setProxmoxPhase] = useState<PhaseState>('idle')
|
||||||
|
const [infraFound, setInfraFound] = useState<InfraItem[]>([])
|
||||||
|
const [result, setResult] = useState<{ created: number; updated: number; skipped: number; errors: string[] } | null>(null)
|
||||||
const ctrlRef = useRef<AbortController | null>(null)
|
const ctrlRef = useRef<AbortController | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !taskId || !accessToken) return
|
if (!open || !taskId || !accessToken) return
|
||||||
|
|
||||||
setPhase('connecting')
|
setPhase('connecting')
|
||||||
|
setCurrentAction(null)
|
||||||
setRacks([])
|
setRacks([])
|
||||||
setCurrent(0)
|
setCurrent(0)
|
||||||
setTotal(0)
|
setTotal(0)
|
||||||
|
setSlaves([])
|
||||||
|
setSlavesPhase('idle')
|
||||||
|
setMikrotikPhase('idle')
|
||||||
|
setProxmoxPhase('idle')
|
||||||
|
setInfraFound([])
|
||||||
setResult(null)
|
setResult(null)
|
||||||
|
|
||||||
const ctrl = new AbortController()
|
const ctrl = new AbortController()
|
||||||
|
|
@ -60,9 +78,12 @@ export function DiscoveryDrawer({ open, onClose, objectName, taskId, onComplete
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(ev.data)
|
const data = JSON.parse(ev.data)
|
||||||
|
|
||||||
if (ev.event === 'disc_server') {
|
if (ev.event === 'disc_action') {
|
||||||
|
setCurrentAction(data.message)
|
||||||
|
|
||||||
|
} else if (ev.event === 'disc_server') {
|
||||||
setTotal(data.rack_count)
|
setTotal(data.rack_count)
|
||||||
setPhase('running')
|
setPhase('racks')
|
||||||
|
|
||||||
} else if (ev.event === 'disc_rack_start') {
|
} else if (ev.event === 'disc_rack_start') {
|
||||||
setTotal(data.total)
|
setTotal(data.total)
|
||||||
|
|
@ -89,6 +110,25 @@ export function DiscoveryDrawer({ open, onClose, objectName, taskId, onComplete
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
} else if (ev.event === 'disc_phase') {
|
||||||
|
if (data.phase === 'slaves') {
|
||||||
|
setPhase('slaves')
|
||||||
|
setSlavesPhase('running')
|
||||||
|
} else if (data.phase === 'mikrotik') {
|
||||||
|
setPhase('infra')
|
||||||
|
setSlavesPhase((s) => s === 'running' ? 'done' : s)
|
||||||
|
setMikrotikPhase('running')
|
||||||
|
} else if (data.phase === 'proxmox') {
|
||||||
|
setMikrotikPhase((s) => s === 'running' ? 'done' : s)
|
||||||
|
setProxmoxPhase('running')
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if (ev.event === 'disc_slave_found') {
|
||||||
|
setSlaves((prev) => [...prev, { rack_name: data.rack_name, ip: data.ip, found: data.found }])
|
||||||
|
|
||||||
|
} else if (ev.event === 'disc_infra_found') {
|
||||||
|
setInfraFound((prev) => [...prev, { type: data.type, ip: data.ip }])
|
||||||
|
|
||||||
} else if (ev.event === 'disc_done') {
|
} else if (ev.event === 'disc_done') {
|
||||||
setResult({
|
setResult({
|
||||||
created: data.created ?? 0,
|
created: data.created ?? 0,
|
||||||
|
|
@ -96,8 +136,11 @@ export function DiscoveryDrawer({ open, onClose, objectName, taskId, onComplete
|
||||||
skipped: data.skipped ?? 0,
|
skipped: data.skipped ?? 0,
|
||||||
errors: data.errors ?? [],
|
errors: data.errors ?? [],
|
||||||
})
|
})
|
||||||
|
setSlavesPhase((s) => s === 'running' ? 'done' : s)
|
||||||
|
setMikrotikPhase((s) => s === 'running' ? 'done' : s)
|
||||||
|
setProxmoxPhase((s) => s === 'running' ? 'done' : s)
|
||||||
setPhase('done')
|
setPhase('done')
|
||||||
setCurrent(data.rack_count ?? total)
|
setCurrentAction(null)
|
||||||
ctrl.abort()
|
ctrl.abort()
|
||||||
onComplete?.()
|
onComplete?.()
|
||||||
}
|
}
|
||||||
|
|
@ -122,6 +165,12 @@ export function DiscoveryDrawer({ open, onClose, objectName, taskId, onComplete
|
||||||
return <MinusCircleOutlined style={{ color: '#d9d9d9' }} />
|
return <MinusCircleOutlined style={{ color: '#d9d9d9' }} />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const phaseIcon = (state: PhaseState) => {
|
||||||
|
if (state === 'running') return <LoadingOutlined style={{ color: '#1677ff', marginRight: 6 }} />
|
||||||
|
if (state === 'done') return <CheckCircleOutlined style={{ color: '#52c41a', marginRight: 6 }} />
|
||||||
|
return <MinusCircleOutlined style={{ color: '#d9d9d9', marginRight: 6 }} />
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer
|
<Drawer
|
||||||
title={`SSH Discovery — ${objectName}`}
|
title={`SSH Discovery — ${objectName}`}
|
||||||
|
|
@ -130,22 +179,31 @@ export function DiscoveryDrawer({ open, onClose, objectName, taskId, onComplete
|
||||||
width={440}
|
width={440}
|
||||||
footer={null}
|
footer={null}
|
||||||
>
|
>
|
||||||
{/* Connecting phase */}
|
{/* Current action */}
|
||||||
{phase === 'connecting' && (
|
{(phase === 'connecting' || currentAction) && (
|
||||||
<Space style={{ marginBottom: 16 }}>
|
<div style={{ marginBottom: 12, minHeight: 20 }}>
|
||||||
|
{phase === 'connecting' && !currentAction ? (
|
||||||
|
<Space>
|
||||||
<LoadingOutlined />
|
<LoadingOutlined />
|
||||||
<Typography.Text>Подключение к серверу...</Typography.Text>
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>Запуск discovery...</Typography.Text>
|
||||||
</Space>
|
</Space>
|
||||||
|
) : currentAction ? (
|
||||||
|
<Space>
|
||||||
|
<LoadingOutlined style={{ fontSize: 11, color: '#8c8c8c' }} />
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{currentAction}</Typography.Text>
|
||||||
|
</Space>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Progress bar */}
|
{/* Rack progress bar */}
|
||||||
{phase !== 'connecting' && (
|
{(phase === 'racks' || phase === 'slaves' || phase === 'infra' || phase === 'done') && (
|
||||||
<div style={{ marginBottom: 16 }}>
|
<div style={{ marginBottom: 16 }}>
|
||||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
{phase === 'done' ? 'Завершено' : `Обход стоек: ${current} / ${total}`}
|
Стойки: {Math.min(current, total)} / {total}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
<Progress
|
<Progress
|
||||||
percent={phase === 'done' ? 100 : percent}
|
percent={phase === 'done' || current >= total ? 100 : percent}
|
||||||
status={phase === 'done' ? 'success' : 'active'}
|
status={phase === 'done' ? 'success' : 'active'}
|
||||||
style={{ marginBottom: 0 }}
|
style={{ marginBottom: 0 }}
|
||||||
/>
|
/>
|
||||||
|
|
@ -158,33 +216,72 @@ export function DiscoveryDrawer({ open, onClose, objectName, taskId, onComplete
|
||||||
{racks.map((r) => (
|
{racks.map((r) => (
|
||||||
<div
|
<div
|
||||||
key={r.rack_name}
|
key={r.rack_name}
|
||||||
style={{
|
style={{ display: 'flex', alignItems: 'center', padding: '4px 0', gap: 8, borderBottom: '1px solid #f5f5f5' }}
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
padding: '5px 0',
|
|
||||||
gap: 8,
|
|
||||||
borderBottom: '1px solid #f0f0f0',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{rackIcon(r.state)}
|
{rackIcon(r.state)}
|
||||||
<Typography.Text style={{ flex: 1 }}>{r.rack_name}</Typography.Text>
|
<Typography.Text style={{ flex: 1, fontSize: 13 }}>{r.rack_name}</Typography.Text>
|
||||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
<Typography.Text type="secondary" style={{ fontSize: 11 }}>{r.rack_host}</Typography.Text>
|
||||||
{r.rack_host}
|
|
||||||
</Typography.Text>
|
|
||||||
{r.state === 'done' && (
|
{r.state === 'done' && (
|
||||||
<Tag color="blue" style={{ marginLeft: 4 }}>
|
<Tag color="blue" style={{ marginLeft: 4, fontSize: 11 }}>{r.found} уст.</Tag>
|
||||||
{r.found} уст.
|
|
||||||
</Tag>
|
|
||||||
)}
|
)}
|
||||||
{r.state === 'error' && <Tag color="red">SSH ошибка</Tag>}
|
{r.state === 'error' && <Tag color="red" style={{ fontSize: 11 }}>SSH ошибка</Tag>}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Slave phase */}
|
||||||
|
{slavesPhase !== 'idle' && (
|
||||||
|
<div style={{ marginBottom: 12, padding: '8px 0', borderTop: '1px solid #f0f0f0' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', marginBottom: slaves.length > 0 ? 6 : 0 }}>
|
||||||
|
{phaseIcon(slavesPhase)}
|
||||||
|
<Typography.Text style={{ fontSize: 13 }}>
|
||||||
|
Слейв-стойки
|
||||||
|
{slaves.length > 0 && <Tag color="purple" style={{ marginLeft: 8 }}>{slaves.length} найдено</Tag>}
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
{slaves.map((s) => (
|
||||||
|
<div key={s.rack_name} style={{ display: 'flex', alignItems: 'center', gap: 8, paddingLeft: 22, paddingBottom: 4 }}>
|
||||||
|
<CheckCircleOutlined style={{ color: '#52c41a', fontSize: 11 }} />
|
||||||
|
<Typography.Text style={{ fontSize: 12 }}>{s.rack_name}</Typography.Text>
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 11 }}>{s.ip}</Typography.Text>
|
||||||
|
<Tag color="blue" style={{ fontSize: 11 }}>{s.found} уст.</Tag>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* MikroTik phase */}
|
||||||
|
{mikrotikPhase !== 'idle' && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', padding: '6px 0', borderTop: '1px solid #f0f0f0' }}>
|
||||||
|
{phaseIcon(mikrotikPhase)}
|
||||||
|
<Typography.Text style={{ fontSize: 13, flex: 1 }}>MikroTik (SSH_CLIENT)</Typography.Text>
|
||||||
|
{infraFound.filter((i) => i.type === 'mikrotik').map((i) => (
|
||||||
|
<Tag key={i.ip} color="orange" style={{ fontSize: 11 }}>{i.ip}</Tag>
|
||||||
|
))}
|
||||||
|
{mikrotikPhase === 'done' && infraFound.filter((i) => i.type === 'mikrotik').length === 0 && (
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 11 }}>не найден</Typography.Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Proxmox phase */}
|
||||||
|
{proxmoxPhase !== 'idle' && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', padding: '6px 0', borderTop: '1px solid #f0f0f0' }}>
|
||||||
|
{phaseIcon(proxmoxPhase)}
|
||||||
|
<Typography.Text style={{ fontSize: 13, flex: 1 }}>Proxmox (порт 8006)</Typography.Text>
|
||||||
|
{infraFound.filter((i) => i.type === 'proxmox').map((i) => (
|
||||||
|
<Tag key={i.ip} color="cyan" style={{ fontSize: 11 }}>{i.ip}</Tag>
|
||||||
|
))}
|
||||||
|
{proxmoxPhase === 'done' && infraFound.filter((i) => i.type === 'proxmox').length === 0 && (
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 11 }}>не найдено</Typography.Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Final result */}
|
{/* Final result */}
|
||||||
{phase === 'done' && result && (
|
{phase === 'done' && result && (
|
||||||
<div style={{ borderTop: '1px solid #f0f0f0', paddingTop: 12 }}>
|
<div style={{ borderTop: '1px solid #f0f0f0', paddingTop: 12, marginTop: 8 }}>
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
<Tag color="green">+{result.created} добавлено</Tag>
|
<Tag color="green">+{result.created} добавлено</Tag>
|
||||||
<Tag color="blue">{result.updated} обновлено</Tag>
|
<Tag color="blue">{result.updated} обновлено</Tag>
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,9 @@ import { stripEmpty } from '../utils/form'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
const SERVER_CATEGORIES = new Set(['main_server', 'vm', 'router'])
|
const SERVER_CATEGORIES = new Set(['main_server', 'vm', 'router'])
|
||||||
|
|
||||||
|
const rackLabel = (name: string, type: string | null | undefined): string =>
|
||||||
|
type ? `${name} ${type}` : name
|
||||||
const DEVICE_COL_SPAN = 8
|
const DEVICE_COL_SPAN = 8
|
||||||
|
|
||||||
type GroupRow = { _type: 'group'; _key: string; label: string; count: number; onlineCount: number }
|
type GroupRow = { _type: 'group'; _key: string; label: string; count: number; onlineCount: number }
|
||||||
|
|
@ -350,7 +353,10 @@ export function InventoryObjectDetailPage() {
|
||||||
if (servers.length > 0) addGroup('Серверная инфраструктура', '__servers__', servers)
|
if (servers.length > 0) addGroup('Серверная инфраструктура', '__servers__', servers)
|
||||||
|
|
||||||
const sortedRacks = [...rackMap.entries()].sort(([a], [b]) => a.localeCompare(b))
|
const sortedRacks = [...rackMap.entries()].sort(([a], [b]) => a.localeCompare(b))
|
||||||
for (const [rackName, items] of sortedRacks) addGroup(rackName, `rack:${rackName}`, items)
|
for (const [rackName, items] of sortedRacks) {
|
||||||
|
const label = rackLabel(rackName, items[0]?.rack_type)
|
||||||
|
addGroup(label, `rack:${rackName}`, items)
|
||||||
|
}
|
||||||
|
|
||||||
if (noRack.length > 0) addGroup('Без привязки', '__norack__', noRack)
|
if (noRack.length > 0) addGroup('Без привязки', '__norack__', noRack)
|
||||||
|
|
||||||
|
|
@ -396,7 +402,7 @@ export function InventoryObjectDetailPage() {
|
||||||
render: (_: unknown, row: TableRow) => {
|
render: (_: unknown, row: TableRow) => {
|
||||||
if (row._type === 'group') return null
|
if (row._type === 'group') return null
|
||||||
const d = row as DeviceRow
|
const d = row as DeviceRow
|
||||||
if (d.rack_name) return <Tag color="blue">{d.rack_name}</Tag>
|
if (d.rack_name) return <Tag color="blue">{rackLabel(d.rack_name, d.rack_type)}</Tag>
|
||||||
const loc = (d as any).location
|
const loc = (d as any).location
|
||||||
if (loc === 'server_room') return <Tag color="purple">Серверная</Tag>
|
if (loc === 'server_room') return <Tag color="purple">Серверная</Tag>
|
||||||
if (loc === 'street') return <Tag color="orange">Улица</Tag>
|
if (loc === 'street') return <Tag color="orange">Улица</Tag>
|
||||||
|
|
@ -495,7 +501,13 @@ export function InventoryObjectDetailPage() {
|
||||||
]
|
]
|
||||||
|
|
||||||
const rackColumns = [
|
const rackColumns = [
|
||||||
{ title: 'Название', dataIndex: 'name', key: 'name' },
|
{
|
||||||
|
title: 'Название',
|
||||||
|
key: 'name',
|
||||||
|
render: (_: unknown, row: RackItem) => (
|
||||||
|
<span>{row.name}{row.rack_type ? <Tag style={{ marginLeft: 6 }}>{row.rack_type}</Tag> : null}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
{ title: 'Зона', dataIndex: 'zone_name', key: 'zone_name', render: (v: string | null) => v ? <Tag>{v}</Tag> : '—' },
|
{ title: 'Зона', dataIndex: 'zone_name', key: 'zone_name', render: (v: string | null) => v ? <Tag>{v}</Tag> : '—' },
|
||||||
{ title: 'Расположение', dataIndex: 'location', key: 'location', render: (v: string | null) => v ?? '—' },
|
{ title: 'Расположение', dataIndex: 'location', key: 'location', render: (v: string | null) => v ?? '—' },
|
||||||
{
|
{
|
||||||
|
|
@ -653,10 +665,13 @@ export function InventoryObjectDetailPage() {
|
||||||
<Typography.Title level={5}>Стойки</Typography.Title>
|
<Typography.Title level={5}>Стойки</Typography.Title>
|
||||||
<Form form={rackForm} layout="inline" style={{ marginBottom: 12 }}>
|
<Form form={rackForm} layout="inline" style={{ marginBottom: 12 }}>
|
||||||
<Form.Item name="name" rules={[{ required: true, message: 'Введите название' }]}>
|
<Form.Item name="name" rules={[{ required: true, message: 'Введите название' }]}>
|
||||||
<Input placeholder="Название стойки" style={{ width: 180 }} />
|
<Input placeholder="Название стойки" style={{ width: 160 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="rack_type">
|
||||||
|
<Input placeholder="Тип (entry, exit...)" style={{ width: 140 }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="zone_id">
|
<Form.Item name="zone_id">
|
||||||
<Select placeholder="Зона (опц.)" allowClear style={{ width: 150 }} options={(zones ?? []).map((z) => ({ value: z.id, label: z.name }))} />
|
<Select placeholder="Зона (опц.)" allowClear style={{ width: 130 }} options={(zones ?? []).map((z) => ({ value: z.id, label: z.name }))} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="location">
|
<Form.Item name="location">
|
||||||
<Input placeholder="Расположение (опц.)" style={{ width: 150 }} />
|
<Input placeholder="Расположение (опц.)" style={{ width: 150 }} />
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue