This commit is contained in:
dv 2026-04-07 12:49:59 +03:00
parent e8fd5a2516
commit 87bf93de89
22 changed files with 1335 additions and 4 deletions

View file

@ -1,7 +1,8 @@
{ {
"permissions": { "permissions": {
"allow": [ "allow": [
"Bash(xargs grep:*)" "Bash(xargs grep:*)",
"Bash(grep -E \"\\\\.py$\")"
] ]
} }
} }

View file

@ -0,0 +1,57 @@
"""0005 snapshots
Revision ID: 0005
Revises: 0004
Create Date: 2026-04-07
"""
from alembic import op
import sqlalchemy as sa
revision = "0005"
down_revision = "0004"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Content-addressed storage: one row per unique file content
op.create_table(
"snapshot_contents",
sa.Column("sha256", sa.String(64), primary_key=True),
sa.Column("content", sa.Text(), nullable=False),
sa.Column("size", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
)
# Each snapshot capture event points to a content row
op.create_table(
"config_snapshots",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column(
"device_id",
sa.Integer(),
sa.ForeignKey("devices.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"sha256",
sa.String(64),
sa.ForeignKey("snapshot_contents.sha256"),
nullable=False,
),
sa.Column("label", sa.String(255), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_config_snapshots_device_created",
"config_snapshots",
["device_id", "created_at"],
)
def downgrade() -> None:
op.drop_index("ix_config_snapshots_device_created", table_name="config_snapshots")
op.drop_table("config_snapshots")
op.drop_table("snapshot_contents")

View file

@ -6,7 +6,7 @@ from fastapi.openapi.utils import get_openapi
from app.middleware.auth import AuthMiddleware from app.middleware.auth import AuthMiddleware
from app.plugins.loader import load_plugins from app.plugins.loader import load_plugins
from app.routers import auth, devices, events, objects, racks, tags, tasks, zones from app.routers import auth, devices, events, objects, racks, snapshots, tags, tasks, zones
from app.workers.startup import recover_stale_tasks from app.workers.startup import recover_stale_tasks
@ -42,6 +42,7 @@ def create_app() -> FastAPI:
app.include_router(racks.router) app.include_router(racks.router)
app.include_router(zones.router) app.include_router(zones.router)
app.include_router(tags.router) app.include_router(tags.router)
app.include_router(snapshots.router)
app.include_router(tasks.router) app.include_router(tasks.router)
app.include_router(events.router) app.include_router(events.router)

View file

@ -69,3 +69,6 @@ class Device(Base, TimestampMixin):
object: Mapped["Object"] = relationship("Object", back_populates="devices") object: Mapped["Object"] = relationship("Object", back_populates="devices")
rack: Mapped[Optional["Rack"]] = relationship("Rack", back_populates="devices") rack: Mapped[Optional["Rack"]] = relationship("Rack", back_populates="devices")
config_snapshots: Mapped[list["ConfigSnapshot"]] = relationship(
"ConfigSnapshot", back_populates="device", cascade="all, delete-orphan"
)

View file

@ -0,0 +1,43 @@
from datetime import datetime
from typing import Optional
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .base import Base
class SnapshotContent(Base):
"""Stores unique file contents by SHA-256. One row per unique content."""
__tablename__ = "snapshot_contents"
sha256: Mapped[str] = mapped_column(String(64), primary_key=True)
content: Mapped[str] = mapped_column(Text(), nullable=False)
size: Mapped[int] = mapped_column(Integer(), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
snapshots: Mapped[list["ConfigSnapshot"]] = relationship(
"ConfigSnapshot", back_populates="snapshot_content"
)
class ConfigSnapshot(Base):
"""Each capture event: device + path + timestamp + content reference."""
__tablename__ = "config_snapshots"
id: Mapped[int] = mapped_column(Integer(), autoincrement=True, primary_key=True)
device_id: Mapped[int] = mapped_column(
Integer(), ForeignKey("devices.id", ondelete="CASCADE"), nullable=False
)
sha256: Mapped[str] = mapped_column(
String(64), ForeignKey("snapshot_contents.sha256"), nullable=False
)
label: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
device: Mapped["Device"] = relationship("Device", back_populates="config_snapshots")
snapshot_content: Mapped["SnapshotContent"] = relationship(
"SnapshotContent", back_populates="snapshots"
)

View file

@ -0,0 +1,49 @@
"""
CameraGetStatusAction queries Hikvision ISAPI for device info and system time.
"""
from app.models.device import Device
from app.models.object import Object
from app.plugins.base import ActionResult, BaseAction, Emitter
from app.services import camera as camera_service
class CameraGetStatusAction(BaseAction):
name = "camera_get_status"
display_name = "Camera: Get Status"
compatible_categories = ["camera"]
params_schema = []
async def execute(
self, device: Device, obj: Object, params: dict, emit: Emitter
) -> ActionResult:
await emit(f"[{device.ip}] GET /ISAPI/System/deviceInfo", "info")
info_result = await camera_service.isapi_get(device, obj, "System/deviceInfo")
if not info_result.success:
await emit(f"[{device.ip}] Error: {info_result.error or info_result.status_code}", "error")
return ActionResult(
success=False,
stdout=info_result.body,
error=info_result.error or f"HTTP {info_result.status_code}",
)
await emit(f"[{device.ip}] GET /ISAPI/System/time", "info")
time_result = await camera_service.isapi_get(device, obj, "System/time")
data = {
"device_info": info_result.data,
"system_time": time_result.data if time_result.success else {},
}
model = info_result.data.get("model", "")
fw = info_result.data.get("firmwareVersion", "")
summary = f"model={model} fw={fw}"
await emit(f"[{device.ip}] {summary}", "info")
return ActionResult(
success=True,
stdout=info_result.body,
exit_code=0,
data=data,
)

View file

@ -0,0 +1,96 @@
"""
CameraSetNTPAction configures NTP on a Hikvision camera via ISAPI.
Reads current NTP config, patches the server address, then PUTs it back.
"""
import xml.etree.ElementTree as ET
from app.models.device import Device
from app.models.object import Object
from app.plugins.base import ActionResult, BaseAction, Emitter
from app.services import camera as camera_service
class CameraSetNTPAction(BaseAction):
name = "camera_set_ntp"
display_name = "Camera: Set NTP Server"
compatible_categories = ["camera"]
params_schema = [
{
"name": "server",
"type": "string",
"label": "NTP Server",
"required": True,
"placeholder": "pool.ntp.org",
},
]
async def execute(
self, device: Device, obj: Object, params: dict, emit: Emitter
) -> ActionResult:
server = params.get("server", "").strip()
if not server:
return ActionResult(success=False, error="No NTP server specified")
# Step 1: read current NTP config
await emit(f"[{device.ip}] GET /ISAPI/System/time/ntpServers", "info")
get_result = await camera_service.isapi_get(device, obj, "System/time/ntpServers")
if not get_result.success:
# Fall back to simple XML PUT if read fails
await emit(f"[{device.ip}] Read failed ({get_result.error}), using minimal XML", "warn")
xml_body = (
'<?xml version="1.0" encoding="UTF-8"?>'
"<NTPServerList>"
"<NTPServer>"
"<id>1</id>"
f"<hostName>{server}</hostName>"
"<ipAddress></ipAddress>"
"<portNo>123</portNo>"
"<synchronizeInterval>60</synchronizeInterval>"
"</NTPServer>"
"</NTPServerList>"
)
else:
# Patch the first NTP server's hostName in the returned XML
try:
root = ET.fromstring(get_result.body)
ns = root.tag.split("}")[0].strip("{") if "}" in root.tag else ""
ns_prefix = f"{{{ns}}}" if ns else ""
# Find first NTPServer entry
entry = root.find(f".//{ns_prefix}NTPServer")
if entry is not None:
hn = entry.find(f"{ns_prefix}hostName")
if hn is not None:
hn.text = server
ip_el = entry.find(f"{ns_prefix}ipAddress")
if ip_el is not None:
ip_el.text = ""
xml_body = ET.tostring(root, encoding="unicode", xml_declaration=True)
except ET.ParseError:
xml_body = get_result.body # send back unchanged, let camera deal with it
# Step 2: PUT updated config
await emit(f"[{device.ip}] PUT /ISAPI/System/time/ntpServers server={server}", "info")
put_result = await camera_service.isapi_put(
device, obj, "System/time/ntpServers", xml_body
)
if not put_result.success:
msg = put_result.error or f"HTTP {put_result.status_code}"
await emit(f"[{device.ip}] PUT failed: {msg}", "error")
return ActionResult(
success=False,
stdout=put_result.body,
error=msg,
)
await emit(f"[{device.ip}] NTP server set to {server}", "info")
return ActionResult(
success=True,
stdout=put_result.body,
exit_code=0,
data={"server": server},
)

View file

@ -0,0 +1,59 @@
"""
MikrotikGetConfigAction exports the full RouterOS configuration via SSH and saves
it as a config snapshot.
Uses `/export compact` which produces a minimal, parseable RouterOS script.
"""
from app.models.device import Device
from app.models.object import Object
from app.plugins.base import ActionResult, BaseAction, Emitter
from app.services import snapshot as snapshot_service
from app.services import ssh as ssh_service
class MikrotikGetConfigAction(BaseAction):
name = "mikrotik_get_config"
display_name = "Mikrotik: Export Config"
compatible_categories = ["mikrotik"]
params_schema = []
async def execute(
self, device: Device, obj: Object, params: dict, emit: Emitter
) -> ActionResult:
await emit(f"[{device.ip}] Running /export compact", "info")
result = await ssh_service.run_command(
device, obj, "/export compact", timeout=60
)
if not result.success and not result.stdout:
msg = result.error or result.stderr or f"exit code {result.exit_code}"
await emit(f"[{device.ip}] Export failed: {msg}", "error")
return ActionResult(
success=False,
stdout=result.stdout,
stderr=result.stderr,
exit_code=result.exit_code,
error=msg,
)
# RouterOS export always returns exit 0 but may print errors to stdout
content = result.stdout
snap_id, sha256, size = await snapshot_service.save_snapshot(
device_id=device.id,
content=content,
label="routeros-export",
)
await emit(
f"[{device.ip}] Config snapshot saved: {sha256[:12]}… ({size} bytes, id={snap_id})",
"info",
)
return ActionResult(
success=True,
stdout=content,
exit_code=0,
data={"snapshot_id": snap_id, "sha256": sha256, "size": size},
)

View file

@ -0,0 +1,58 @@
"""
MikrotikGetNTPAction reads NTP client configuration from RouterOS via SSH.
"""
import re
from app.models.device import Device
from app.models.object import Object
from app.plugins.base import ActionResult, BaseAction, Emitter
from app.services import ssh as ssh_service
def _parse_ntp_print(output: str) -> dict:
"""Parse key=value pairs from RouterOS print output."""
data: dict = {}
for line in output.splitlines():
line = line.strip()
# RouterOS format: " servers: pool.ntp.org" or "enabled: yes"
match = re.match(r"^([\w-]+)\s*:\s*(.*)$", line)
if match:
data[match.group(1)] = match.group(2).strip()
return data
class MikrotikGetNTPAction(BaseAction):
name = "mikrotik_get_ntp"
display_name = "Mikrotik: Get NTP Config"
compatible_categories = ["mikrotik"]
params_schema = []
async def execute(
self, device: Device, obj: Object, params: dict, emit: Emitter
) -> ActionResult:
await emit(f"[{device.ip}] /system ntp client print", "info")
result = await ssh_service.run_command(
device, obj, "/system ntp client print"
)
if result.error:
await emit(f"[{device.ip}] Error: {result.error}", "error")
return ActionResult(
success=False,
stdout=result.stdout,
stderr=result.stderr,
exit_code=result.exit_code,
error=result.error,
)
parsed = _parse_ntp_print(result.stdout)
await emit(f"[{device.ip}] NTP: {result.stdout[:200]}", "info")
return ActionResult(
success=True,
stdout=result.stdout,
exit_code=result.exit_code,
data={"ntp": parsed},
)

View file

@ -0,0 +1,60 @@
"""
MikrotikSetNTPAction configures NTP server on RouterOS via SSH.
"""
from app.models.device import Device
from app.models.object import Object
from app.plugins.base import ActionResult, BaseAction, Emitter
from app.services import ssh as ssh_service
class MikrotikSetNTPAction(BaseAction):
name = "mikrotik_set_ntp"
display_name = "Mikrotik: Set NTP Server"
compatible_categories = ["mikrotik"]
params_schema = [
{
"name": "server",
"type": "string",
"label": "NTP Server",
"required": True,
"placeholder": "pool.ntp.org",
},
]
async def execute(
self, device: Device, obj: Object, params: dict, emit: Emitter
) -> ActionResult:
server = params.get("server", "").strip()
if not server:
return ActionResult(success=False, error="No NTP server specified")
# Enable NTP client and set server
commands = [
"/system ntp client set enabled=yes",
f"/system ntp client set servers={server}",
]
for cmd in commands:
await emit(f"[{device.ip}] {cmd}", "info")
result = await ssh_service.run_command(device, obj, cmd)
if result.error:
await emit(f"[{device.ip}] Error: {result.error}", "error")
return ActionResult(
success=False,
stdout=result.stdout,
stderr=result.stderr,
exit_code=result.exit_code,
error=result.error,
)
# Verify
verify = await ssh_service.run_command(device, obj, "/system ntp client print")
await emit(f"[{device.ip}] Verify: {verify.stdout[:200]}", "info")
return ActionResult(
success=True,
stdout=verify.stdout,
exit_code=0,
data={"server": server},
)

View file

@ -0,0 +1,66 @@
"""
GetFileAction reads a remote file via SSH and saves it as a config snapshot.
Compatible with Linux-based devices (raspberry, server, embedded, other).
"""
from app.models.device import Device
from app.models.object import Object
from app.plugins.base import ActionResult, BaseAction, Emitter
from app.services import snapshot as snapshot_service
from app.services import ssh as ssh_service
class GetFileAction(BaseAction):
name = "get_file"
display_name = "Get File (Snapshot)"
compatible_categories = ["raspberry", "server", "embedded", "other"]
params_schema = [
{
"name": "path",
"type": "string",
"label": "File path",
"required": True,
"placeholder": "/etc/ntp.conf",
},
]
async def execute(
self, device: Device, obj: Object, params: dict, emit: Emitter
) -> ActionResult:
path = params.get("path", "").strip()
if not path:
return ActionResult(success=False, error="No file path specified")
await emit(f"[{device.ip}] Reading {path}", "info")
result = await ssh_service.run_command(device, obj, f"cat {path}")
if not result.success:
msg = result.error or result.stderr or f"exit code {result.exit_code}"
await emit(f"[{device.ip}] Failed: {msg}", "error")
return ActionResult(
success=False,
stdout=result.stdout,
stderr=result.stderr,
exit_code=result.exit_code,
error=msg,
)
content = result.stdout
snap_id, sha256, size = await snapshot_service.save_snapshot(
device_id=device.id,
content=content,
label=path,
)
await emit(
f"[{device.ip}] Snapshot saved: {sha256[:12]}… ({size} bytes, id={snap_id})",
"info",
)
return ActionResult(
success=True,
stdout=content,
exit_code=0,
data={"snapshot_id": snap_id, "sha256": sha256, "size": size, "path": path},
)

View file

@ -1,20 +1,36 @@
""" """
PluginLoader discovers and registers action plugins at startup. PluginLoader discovers and registers action plugins at startup.
Etap 2: loads only hardcoded builtins (all enabled). Étape 3: builtins include SSH file snapshots, Mikrotik, and Camera plugins.
Etap 5: will scan plugins/custom/ and cross-reference with the plugins DB table. Étape 5: will scan plugins/custom/ and cross-reference with the plugins DB table.
""" """
from app.plugins.base import BaseAction from app.plugins.base import BaseAction
from app.plugins.builtin.camera.get_status import CameraGetStatusAction
from app.plugins.builtin.camera.set_ntp import CameraSetNTPAction
from app.plugins.builtin.common.ping import PingAction from app.plugins.builtin.common.ping import PingAction
from app.plugins.builtin.mikrotik.get_config import MikrotikGetConfigAction
from app.plugins.builtin.mikrotik.get_ntp import MikrotikGetNTPAction
from app.plugins.builtin.mikrotik.set_ntp import MikrotikSetNTPAction
from app.plugins.builtin.ssh.get_file import GetFileAction
from app.plugins.builtin.ssh.get_timedatectl import GetTimedatectlAction from app.plugins.builtin.ssh.get_timedatectl import GetTimedatectlAction
from app.plugins.builtin.ssh.ssh_command import SSHCommandAction from app.plugins.builtin.ssh.ssh_command import SSHCommandAction
from app.plugins.registry import action_registry from app.plugins.registry import action_registry
_BUILTIN_ACTIONS: list[BaseAction] = [ _BUILTIN_ACTIONS: list[BaseAction] = [
# Universal
PingAction(), PingAction(),
# SSH / Linux
SSHCommandAction(), SSHCommandAction(),
GetTimedatectlAction(), GetTimedatectlAction(),
GetFileAction(),
# Mikrotik (RouterOS via SSH)
MikrotikGetConfigAction(),
MikrotikGetNTPAction(),
MikrotikSetNTPAction(),
# Cameras (Hikvision ISAPI)
CameraGetStatusAction(),
CameraSetNTPAction(),
] ]

View file

@ -0,0 +1,135 @@
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.dependencies import get_current_user, get_db
from app.models.device import Device
from app.models.object import Object
from app.models.snapshot import ConfigSnapshot
from app.models.user import User
from app.schemas.snapshot import SnapshotBrief, SnapshotDetail, SnapshotDiffResponse
from app.services import snapshot as snapshot_service
router = APIRouter(prefix="/api/v1/objects/{obj_id}/snapshots", tags=["snapshots"])
async def _get_object(db: AsyncSession, obj_id: int) -> Object:
obj = await db.get(Object, obj_id)
if obj is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Object not found")
return obj
async def _get_snapshot_for_object(
db: AsyncSession, obj_id: int, snapshot_id: int
) -> ConfigSnapshot:
"""Load a snapshot, verifying it belongs to a device on obj_id."""
result = await db.execute(
select(ConfigSnapshot)
.join(Device, ConfigSnapshot.device_id == Device.id)
.options(
selectinload(ConfigSnapshot.snapshot_content),
selectinload(ConfigSnapshot.device),
)
.where(ConfigSnapshot.id == snapshot_id, Device.object_id == obj_id)
)
snap = result.scalar_one_or_none()
if snap is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Snapshot not found")
return snap
@router.get("", response_model=list[SnapshotBrief])
async def list_snapshots(
obj_id: int,
device_id: Optional[int] = Query(None),
label: Optional[str] = Query(None),
limit: int = Query(100, ge=1, le=500),
skip: int = Query(0, ge=0),
db: AsyncSession = Depends(get_db),
_: User = Depends(get_current_user),
):
await _get_object(db, obj_id)
query = (
select(ConfigSnapshot)
.join(Device, ConfigSnapshot.device_id == Device.id)
.options(
selectinload(ConfigSnapshot.snapshot_content),
selectinload(ConfigSnapshot.device),
)
.where(Device.object_id == obj_id)
.order_by(ConfigSnapshot.created_at.desc())
.offset(skip)
.limit(limit)
)
if device_id is not None:
query = query.where(ConfigSnapshot.device_id == device_id)
if label is not None:
query = query.where(ConfigSnapshot.label.ilike(f"%{label}%"))
result = await db.execute(query)
snaps = result.scalars().all()
out = []
for s in snaps:
out.append(
SnapshotBrief(
id=s.id,
device_id=s.device_id,
device_ip=s.device.ip if s.device else None,
device_hostname=s.device.hostname if s.device else None,
sha256=s.sha256,
label=s.label,
size=s.snapshot_content.size,
created_at=s.created_at,
)
)
return out
@router.get("/diff", response_model=SnapshotDiffResponse)
async def diff_snapshots(
obj_id: int,
a: int = Query(..., description="First snapshot ID"),
b: int = Query(..., description="Second snapshot ID"),
db: AsyncSession = Depends(get_db),
_: User = Depends(get_current_user),
):
await _get_object(db, obj_id)
# Verify both snapshots belong to this object
await _get_snapshot_for_object(db, obj_id, a)
await _get_snapshot_for_object(db, obj_id, b)
diff_text, is_identical = await snapshot_service.compute_diff(a, b)
return SnapshotDiffResponse(
snapshot_a_id=a,
snapshot_b_id=b,
diff=diff_text,
is_identical=is_identical,
)
@router.get("/{snapshot_id}", response_model=SnapshotDetail)
async def get_snapshot(
obj_id: int,
snapshot_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(get_current_user),
):
snap = await _get_snapshot_for_object(db, obj_id, snapshot_id)
return SnapshotDetail(
id=snap.id,
device_id=snap.device_id,
device_ip=snap.device.ip if snap.device else None,
device_hostname=snap.device.hostname if snap.device else None,
sha256=snap.sha256,
label=snap.label,
size=snap.snapshot_content.size,
created_at=snap.created_at,
content=snap.snapshot_content.content,
)

View file

@ -0,0 +1,32 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict
class SnapshotBrief(BaseModel):
"""Snapshot metadata without content — used in list endpoints."""
model_config = ConfigDict(from_attributes=True)
id: int
device_id: int
device_ip: Optional[str] = None
device_hostname: Optional[str] = None
sha256: str
label: Optional[str]
size: int
created_at: datetime
class SnapshotDetail(SnapshotBrief):
"""Snapshot with full content — used when viewing a single snapshot."""
content: str
class SnapshotDiffResponse(BaseModel):
snapshot_a_id: int
snapshot_b_id: int
diff: str
is_identical: bool

View file

@ -0,0 +1,125 @@
"""
CameraService HTTP client for Hikvision ISAPI.
All endpoints use HTTP Basic auth with the camera's web credentials.
Credentials are resolved from device SSH overrides (reusing the same
username/password fields) camera HTTP auth often matches SSH.
ISAPI base: http://<ip>/ISAPI/
"""
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from typing import Optional
import httpx
from app.models.device import Device
from app.models.object import Object
from app.services.crypto import decrypt
@dataclass
class CameraResult:
success: bool
status_code: int = 0
body: str = ""
data: dict = None
error: str = ""
def __post_init__(self):
if self.data is None:
self.data = {}
def _resolve_camera_credentials(device: Device, obj: Object) -> tuple[str, str, int]:
"""Returns (username, password, http_port) — device overrides take priority."""
user = device.ssh_user_override or obj.ssh_user or "admin"
port = device.device_meta.get("http_port", 80) if device.device_meta else 80
if device.ssh_pass_override_enc:
password = decrypt(device.ssh_pass_override_enc)
elif obj.ssh_pass_enc:
password = decrypt(obj.ssh_pass_enc)
else:
password = ""
return user, password, int(port)
def _xml_to_dict(element: ET.Element) -> dict:
"""Recursively convert an XML element to a dict (simple, no attributes)."""
result = {}
for child in element:
# Strip namespace if present: {ns}tag → tag
tag = child.tag.split("}")[-1] if "}" in child.tag else child.tag
if len(child):
result[tag] = _xml_to_dict(child)
else:
result[tag] = child.text
return result
async def isapi_get(
device: Device,
obj: Object,
path: str,
timeout: int = 10,
) -> CameraResult:
"""Send GET to ISAPI endpoint, return parsed XML body."""
user, password, port = _resolve_camera_credentials(device, obj)
url = f"http://{device.ip}:{port}/ISAPI/{path.lstrip('/')}"
try:
async with httpx.AsyncClient(auth=(user, password), timeout=timeout) as client:
resp = await client.get(url)
except httpx.TimeoutException:
return CameraResult(success=False, error="Connection timed out")
except Exception as exc:
return CameraResult(success=False, error=str(exc))
body = resp.text
data: dict = {}
if body.strip().startswith("<"):
try:
root = ET.fromstring(body)
data = _xml_to_dict(root)
except ET.ParseError:
pass
return CameraResult(
success=resp.status_code < 400,
status_code=resp.status_code,
body=body,
data=data,
)
async def isapi_put(
device: Device,
obj: Object,
path: str,
xml_body: str,
timeout: int = 10,
) -> CameraResult:
"""Send PUT with XML body to ISAPI endpoint."""
user, password, port = _resolve_camera_credentials(device, obj)
url = f"http://{device.ip}:{port}/ISAPI/{path.lstrip('/')}"
try:
async with httpx.AsyncClient(auth=(user, password), timeout=timeout) as client:
resp = await client.put(
url,
content=xml_body.encode(),
headers={"Content-Type": "application/xml"},
)
except httpx.TimeoutException:
return CameraResult(success=False, error="Connection timed out")
except Exception as exc:
return CameraResult(success=False, error=str(exc))
return CameraResult(
success=resp.status_code < 400,
status_code=resp.status_code,
body=resp.text,
)

View file

@ -0,0 +1,106 @@
"""
SnapshotService content-addressed storage for device config snapshots.
SHA-256 deduplication: identical content is stored once in snapshot_contents.
Each capture creates a new config_snapshots row pointing to the content.
"""
import difflib
import hashlib
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.models.snapshot import ConfigSnapshot, SnapshotContent
from app.services.database import AsyncSessionLocal
async def save_snapshot(
device_id: int,
content: str,
label: Optional[str] = None,
) -> tuple[int, str, int]:
"""
Persist a snapshot, deduplicating content by SHA-256.
Returns (snapshot_id, sha256, size).
"""
raw = content.encode("utf-8", errors="replace")
sha256 = hashlib.sha256(raw).hexdigest()
size = len(raw)
now = datetime.now(timezone.utc)
async with AsyncSessionLocal() as db:
async with db.begin():
# Insert content only if it doesn't exist yet (dedup)
existing = await db.get(SnapshotContent, sha256)
if existing is None:
db.add(SnapshotContent(sha256=sha256, content=content, size=size, created_at=now))
snap = ConfigSnapshot(
device_id=device_id,
sha256=sha256,
label=label,
created_at=now,
)
db.add(snap)
await db.flush()
snap_id = snap.id
return snap_id, sha256, size
async def get_snapshot_content(snapshot_id: int) -> Optional[str]:
"""Load content for a single snapshot."""
async with AsyncSessionLocal() as db:
result = await db.execute(
select(ConfigSnapshot)
.options(selectinload(ConfigSnapshot.snapshot_content))
.where(ConfigSnapshot.id == snapshot_id)
)
snap = result.scalar_one_or_none()
if snap is None:
return None
return snap.snapshot_content.content
async def compute_diff(snapshot_id_a: int, snapshot_id_b: int) -> tuple[str, bool]:
"""
Compute unified diff between two snapshots.
Returns (diff_text, is_identical).
"""
async with AsyncSessionLocal() as db:
result_a = await db.execute(
select(ConfigSnapshot)
.options(selectinload(ConfigSnapshot.snapshot_content))
.where(ConfigSnapshot.id == snapshot_id_a)
)
snap_a = result_a.scalar_one_or_none()
result_b = await db.execute(
select(ConfigSnapshot)
.options(selectinload(ConfigSnapshot.snapshot_content))
.where(ConfigSnapshot.id == snapshot_id_b)
)
snap_b = result_b.scalar_one_or_none()
if snap_a is None or snap_b is None:
return "", False
content_a = snap_a.snapshot_content.content
content_b = snap_b.snapshot_content.content
if content_a == content_b:
return "", True
lines_a = content_a.splitlines(keepends=True)
lines_b = content_b.splitlines(keepends=True)
label_a = f"#{snap_a.id} {snap_a.label or snap_a.sha256[:12]}"
label_b = f"#{snap_b.id} {snap_b.label or snap_b.sha256[:12]}"
diff = difflib.unified_diff(lines_a, lines_b, fromfile=label_a, tofile=label_b)
return "".join(diff), False

View file

@ -3,6 +3,7 @@ import { AppLayout } from './components/AppLayout'
import { LoginPage } from './pages/Login' import { LoginPage } from './pages/Login'
import { ObjectDetailPage } from './pages/ObjectDetail' import { ObjectDetailPage } from './pages/ObjectDetail'
import { ObjectsPage } from './pages/Objects' import { ObjectsPage } from './pages/Objects'
import { SnapshotsPage } from './pages/Snapshots'
import { TaskDetailPage } from './pages/TaskDetail' import { TaskDetailPage } from './pages/TaskDetail'
import { TasksPage } from './pages/Tasks' import { TasksPage } from './pages/Tasks'
import { useAuthStore } from './store/auth' import { useAuthStore } from './store/auth'
@ -27,6 +28,7 @@ export default function App() {
<Route index element={<Navigate to="/objects" replace />} /> <Route index element={<Navigate to="/objects" replace />} />
<Route path="objects" element={<ObjectsPage />} /> <Route path="objects" element={<ObjectsPage />} />
<Route path="objects/:id" element={<ObjectDetailPage />} /> <Route path="objects/:id" element={<ObjectDetailPage />} />
<Route path="objects/:id/snapshots" element={<SnapshotsPage />} />
<Route path="tasks" element={<TasksPage />} /> <Route path="tasks" element={<TasksPage />} />
<Route path="tasks/:id" element={<TaskDetailPage />} /> <Route path="tasks/:id" element={<TaskDetailPage />} />
</Route> </Route>

View file

@ -0,0 +1,62 @@
import { useQuery } from '@tanstack/react-query'
import { api } from './client'
export interface SnapshotBrief {
id: number
device_id: number
device_ip: string | null
device_hostname: string | null
sha256: string
label: string | null
size: number
created_at: string
}
export interface SnapshotDetail extends SnapshotBrief {
content: string
}
export interface SnapshotDiff {
snapshot_a_id: number
snapshot_b_id: number
diff: string
is_identical: boolean
}
export const useSnapshots = (
objId: number,
params?: { device_id?: number; label?: string; limit?: number; skip?: number }
) =>
useQuery({
queryKey: ['snapshots', objId, params],
queryFn: () =>
api
.get<SnapshotBrief[]>(`/api/v1/objects/${objId}/snapshots`, { params })
.then((r) => r.data),
})
export const useSnapshotDetail = (objId: number, snapshotId: number | null) =>
useQuery({
queryKey: ['snapshot', objId, snapshotId],
queryFn: () =>
api
.get<SnapshotDetail>(`/api/v1/objects/${objId}/snapshots/${snapshotId}`)
.then((r) => r.data),
enabled: snapshotId !== null,
})
export const useSnapshotDiff = (
objId: number,
a: number | null,
b: number | null
) =>
useQuery({
queryKey: ['snapshot-diff', objId, a, b],
queryFn: () =>
api
.get<SnapshotDiff>(`/api/v1/objects/${objId}/snapshots/diff`, {
params: { a, b },
})
.then((r) => r.data),
enabled: a !== null && b !== null,
})

View file

@ -1,6 +1,7 @@
import { import {
DeleteOutlined, DeleteOutlined,
EditOutlined, EditOutlined,
FileTextOutlined,
PlayCircleOutlined, PlayCircleOutlined,
PlusOutlined, PlusOutlined,
SyncOutlined, SyncOutlined,
@ -473,6 +474,12 @@ export function ObjectDetailPage() {
<Button icon={<PlusOutlined />} onClick={openAddDevice}> <Button icon={<PlusOutlined />} onClick={openAddDevice}>
Добавить устройство Добавить устройство
</Button> </Button>
<Button
icon={<FileTextOutlined />}
onClick={() => navigate(`/objects/${objId}/snapshots`)}
>
Снапшоты
</Button>
<Button icon={<EditOutlined />} onClick={() => setRackModal(true)}> <Button icon={<EditOutlined />} onClick={() => setRackModal(true)}>
Стойки и зоны Стойки и зоны
</Button> </Button>

View file

@ -0,0 +1,353 @@
import {
DiffOutlined,
EyeOutlined,
FileTextOutlined,
} from '@ant-design/icons'
import {
Breadcrumb,
Button,
Col,
Descriptions,
Empty,
Modal,
Row,
Select,
Space,
Spin,
Table,
Tag,
Tooltip,
Typography,
message,
} from 'antd'
import { useMemo, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { useObject } from '../api/objects'
import {
useSnapshotDiff,
useSnapshots,
type SnapshotBrief,
type SnapshotDetail,
} from '../api/snapshots'
import { api } from '../api/client'
import dayjs from 'dayjs'
// ─────────────────────────────────────────
// Inline diff viewer (no extra dependency)
// ─────────────────────────────────────────
function DiffViewer({ diff }: { diff: string }) {
if (!diff) {
return (
<Typography.Text type="secondary">Файлы идентичны изменений нет.</Typography.Text>
)
}
const lines = diff.split('\n')
return (
<pre
style={{
fontSize: 12,
lineHeight: 1.5,
overflowX: 'auto',
margin: 0,
background: '#0d0d0d',
padding: 12,
borderRadius: 4,
maxHeight: 520,
overflowY: 'auto',
}}
>
{lines.map((line, i) => {
let color = '#ccc'
let bg = 'transparent'
if (line.startsWith('+++') || line.startsWith('---')) {
color = '#9cdcfe'
} else if (line.startsWith('+')) {
color = '#b5f0a0'
bg = '#1a3a1a'
} else if (line.startsWith('-')) {
color = '#f0a0a0'
bg = '#3a1a1a'
} else if (line.startsWith('@@')) {
color = '#ce9178'
}
return (
<span key={i} style={{ display: 'block', color, background: bg }}>
{line || ' '}
</span>
)
})}
</pre>
)
}
// ─────────────────────────────────────────
// Content viewer modal
// ─────────────────────────────────────────
function ContentModal({
objId,
snapshot,
onClose,
}: {
objId: number
snapshot: SnapshotBrief | null
onClose: () => void
}) {
const [content, setContent] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const loadContent = async () => {
if (!snapshot) return
setLoading(true)
try {
const res = await api.get<SnapshotDetail>(
`/api/v1/objects/${objId}/snapshots/${snapshot.id}`
)
setContent(res.data.content)
} catch {
message.error('Не удалось загрузить содержимое')
} finally {
setLoading(false)
}
}
return (
<Modal
title={
snapshot
? `Содержимое: ${snapshot.label ?? snapshot.sha256.slice(0, 12)} (${snapshot.device_ip})`
: ''
}
open={!!snapshot}
onCancel={() => { onClose(); setContent(null) }}
afterOpenChange={(open) => { if (open) loadContent() }}
footer={null}
width={900}
>
{loading ? (
<Spin />
) : content !== null ? (
<pre
style={{
fontSize: 12,
background: '#1e1e1e',
color: '#d4d4d4',
padding: 12,
borderRadius: 4,
maxHeight: 560,
overflowY: 'auto',
overflowX: 'auto',
}}
>
{content}
</pre>
) : null}
</Modal>
)
}
// ─────────────────────────────────────────
// Main page
// ─────────────────────────────────────────
export function SnapshotsPage() {
const { id } = useParams<{ id: string }>()
const objId = Number(id)
const navigate = useNavigate()
const { data: obj } = useObject(objId)
const { data: snapshots, isLoading } = useSnapshots(objId)
const [viewSnap, setViewSnap] = useState<SnapshotBrief | null>(null)
const [diffModal, setDiffModal] = useState(false)
const [diffA, setDiffA] = useState<number | null>(null)
const [diffB, setDiffB] = useState<number | null>(null)
const { data: diffResult, isFetching: diffLoading } = useSnapshotDiff(
objId,
diffModal ? diffA : null,
diffModal ? diffB : null
)
const snapshotOptions = useMemo(
() =>
(snapshots ?? []).map((s) => ({
value: s.id,
label: `#${s.id} ${s.device_ip ?? '?'}${s.label ?? s.sha256.slice(0, 8)} (${dayjs(s.created_at).format('DD.MM HH:mm')})`,
})),
[snapshots]
)
const columns = [
{
title: 'Устройство',
key: 'device',
render: (_: unknown, row: SnapshotBrief) => (
<Space direction="vertical" size={0}>
<code>{row.device_ip ?? '—'}</code>
{row.device_hostname && (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{row.device_hostname}
</Typography.Text>
)}
</Space>
),
},
{
title: 'Метка',
dataIndex: 'label',
key: 'label',
render: (v: string | null) =>
v ? <Tag icon={<FileTextOutlined />}>{v}</Tag> : <Typography.Text type="secondary"></Typography.Text>,
},
{
title: 'Размер',
dataIndex: 'size',
key: 'size',
render: (v: number) => `${(v / 1024).toFixed(1)} KB`,
},
{
title: 'SHA-256',
dataIndex: 'sha256',
key: 'sha256',
render: (v: string) => (
<Tooltip title={v}>
<Typography.Text code style={{ fontSize: 11 }}>
{v.slice(0, 12)}
</Typography.Text>
</Tooltip>
),
},
{
title: 'Дата',
dataIndex: 'created_at',
key: 'created_at',
render: (v: string) => (
<Tooltip title={dayjs(v).format('DD.MM.YYYY HH:mm:ss')}>
{dayjs(v).format('DD.MM HH:mm')}
</Tooltip>
),
},
{
title: '',
key: 'actions',
width: 80,
render: (_: unknown, row: SnapshotBrief) => (
<Button
size="small"
icon={<EyeOutlined />}
onClick={() => setViewSnap(row)}
>
Просмотр
</Button>
),
},
]
return (
<div>
<Breadcrumb
style={{ marginBottom: 16 }}
items={[
{ title: <a onClick={() => navigate('/objects')}>Объекты</a> },
{ title: <a onClick={() => navigate(`/objects/${objId}`)}>{obj?.name}</a> },
{ title: 'Снапшоты' },
]}
/>
<Row justify="space-between" align="middle" style={{ marginBottom: 16 }}>
<Col>
<Typography.Title level={4} style={{ margin: 0 }}>
Снапшоты конфигураций
</Typography.Title>
</Col>
<Col>
<Button
icon={<DiffOutlined />}
onClick={() => setDiffModal(true)}
disabled={!snapshots?.length}
>
Сравнить снапшоты
</Button>
</Col>
</Row>
<Table
dataSource={snapshots ?? []}
columns={columns}
rowKey="id"
loading={isLoading}
size="middle"
pagination={{ pageSize: 50 }}
locale={{ emptyText: <Empty description="Снапшотов ещё нет. Запустите задачу «Get File» на устройстве." /> }}
/>
{/* ── Content Viewer Modal ── */}
<ContentModal objId={objId} snapshot={viewSnap} onClose={() => setViewSnap(null)} />
{/* ── Diff Modal ── */}
<Modal
title="Сравнение снапшотов"
open={diffModal}
onCancel={() => { setDiffModal(false); setDiffA(null); setDiffB(null) }}
footer={null}
width={960}
>
<Row gutter={12} style={{ marginBottom: 16 }}>
<Col span={11}>
<Typography.Text strong style={{ display: 'block', marginBottom: 4 }}>
Снапшот A (старый)
</Typography.Text>
<Select
style={{ width: '100%' }}
placeholder="Выберите снапшот A"
options={snapshotOptions}
value={diffA}
onChange={setDiffA}
showSearch
filterOption={(input, opt) =>
(opt?.label ?? '').toLowerCase().includes(input.toLowerCase())
}
/>
</Col>
<Col span={2} style={{ textAlign: 'center', paddingTop: 28 }}>
<Typography.Text type="secondary"></Typography.Text>
</Col>
<Col span={11}>
<Typography.Text strong style={{ display: 'block', marginBottom: 4 }}>
Снапшот B (новый)
</Typography.Text>
<Select
style={{ width: '100%' }}
placeholder="Выберите снапшот B"
options={snapshotOptions}
value={diffB}
onChange={setDiffB}
showSearch
filterOption={(input, opt) =>
(opt?.label ?? '').toLowerCase().includes(input.toLowerCase())
}
/>
</Col>
</Row>
{diffLoading && <Spin style={{ display: 'block', textAlign: 'center', margin: 24 }} />}
{diffResult && !diffLoading && (
<>
{diffResult.is_identical ? (
<Typography.Text type="success">
Файлы идентичны содержимое не изменилось.
</Typography.Text>
) : (
<DiffViewer diff={diffResult.diff} />
)}
</>
)}
{!diffResult && !diffLoading && diffA !== null && diffB !== null && (
<Typography.Text type="secondary">Загрузка</Typography.Text>
)}
</Modal>
</div>
)
}