diff --git a/.claude/settings.local.json b/.claude/settings.local.json index cbd3cfd..ea06cf3 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,7 +1,8 @@ { "permissions": { "allow": [ - "Bash(xargs grep:*)" + "Bash(xargs grep:*)", + "Bash(grep -E \"\\\\.py$\")" ] } } diff --git a/backend/alembic/versions/0005_snapshots.py b/backend/alembic/versions/0005_snapshots.py new file mode 100644 index 0000000..a5797f0 --- /dev/null +++ b/backend/alembic/versions/0005_snapshots.py @@ -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") diff --git a/backend/app/main.py b/backend/app/main.py index 4d68420..cc9483b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -6,7 +6,7 @@ from fastapi.openapi.utils import get_openapi from app.middleware.auth import AuthMiddleware 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 @@ -42,6 +42,7 @@ def create_app() -> FastAPI: app.include_router(racks.router) app.include_router(zones.router) app.include_router(tags.router) + app.include_router(snapshots.router) app.include_router(tasks.router) app.include_router(events.router) diff --git a/backend/app/models/device.py b/backend/app/models/device.py index 335c461..576092d 100644 --- a/backend/app/models/device.py +++ b/backend/app/models/device.py @@ -69,3 +69,6 @@ class Device(Base, TimestampMixin): object: Mapped["Object"] = relationship("Object", 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" + ) diff --git a/backend/app/models/snapshot.py b/backend/app/models/snapshot.py new file mode 100644 index 0000000..c82ed8f --- /dev/null +++ b/backend/app/models/snapshot.py @@ -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" + ) diff --git a/backend/app/plugins/builtin/camera/__init__.py b/backend/app/plugins/builtin/camera/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/plugins/builtin/camera/get_status.py b/backend/app/plugins/builtin/camera/get_status.py new file mode 100644 index 0000000..3145851 --- /dev/null +++ b/backend/app/plugins/builtin/camera/get_status.py @@ -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, + ) diff --git a/backend/app/plugins/builtin/camera/set_ntp.py b/backend/app/plugins/builtin/camera/set_ntp.py new file mode 100644 index 0000000..265e792 --- /dev/null +++ b/backend/app/plugins/builtin/camera/set_ntp.py @@ -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 = ( + '' + "" + "" + "1" + f"{server}" + "" + "123" + "60" + "" + "" + ) + 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}, + ) diff --git a/backend/app/plugins/builtin/mikrotik/__init__.py b/backend/app/plugins/builtin/mikrotik/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/plugins/builtin/mikrotik/get_config.py b/backend/app/plugins/builtin/mikrotik/get_config.py new file mode 100644 index 0000000..d81c850 --- /dev/null +++ b/backend/app/plugins/builtin/mikrotik/get_config.py @@ -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}, + ) diff --git a/backend/app/plugins/builtin/mikrotik/get_ntp.py b/backend/app/plugins/builtin/mikrotik/get_ntp.py new file mode 100644 index 0000000..19e6be1 --- /dev/null +++ b/backend/app/plugins/builtin/mikrotik/get_ntp.py @@ -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}, + ) diff --git a/backend/app/plugins/builtin/mikrotik/set_ntp.py b/backend/app/plugins/builtin/mikrotik/set_ntp.py new file mode 100644 index 0000000..62f699f --- /dev/null +++ b/backend/app/plugins/builtin/mikrotik/set_ntp.py @@ -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}, + ) diff --git a/backend/app/plugins/builtin/ssh/get_file.py b/backend/app/plugins/builtin/ssh/get_file.py new file mode 100644 index 0000000..4a9c357 --- /dev/null +++ b/backend/app/plugins/builtin/ssh/get_file.py @@ -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}, + ) diff --git a/backend/app/plugins/loader.py b/backend/app/plugins/loader.py index 75c3a4b..77a40d4 100644 --- a/backend/app/plugins/loader.py +++ b/backend/app/plugins/loader.py @@ -1,20 +1,36 @@ """ PluginLoader — discovers and registers action plugins at startup. -Etap 2: loads only hardcoded builtins (all enabled). -Etap 5: will scan plugins/custom/ and cross-reference with the plugins DB table. +Étape 3: builtins include SSH file snapshots, Mikrotik, and Camera plugins. +Étape 5: will scan plugins/custom/ and cross-reference with the plugins DB table. """ 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.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.ssh_command import SSHCommandAction from app.plugins.registry import action_registry _BUILTIN_ACTIONS: list[BaseAction] = [ + # Universal PingAction(), + # SSH / Linux SSHCommandAction(), GetTimedatectlAction(), + GetFileAction(), + # Mikrotik (RouterOS via SSH) + MikrotikGetConfigAction(), + MikrotikGetNTPAction(), + MikrotikSetNTPAction(), + # Cameras (Hikvision ISAPI) + CameraGetStatusAction(), + CameraSetNTPAction(), ] diff --git a/backend/app/routers/snapshots.py b/backend/app/routers/snapshots.py new file mode 100644 index 0000000..259bbeb --- /dev/null +++ b/backend/app/routers/snapshots.py @@ -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, + ) diff --git a/backend/app/schemas/snapshot.py b/backend/app/schemas/snapshot.py new file mode 100644 index 0000000..baf2e96 --- /dev/null +++ b/backend/app/schemas/snapshot.py @@ -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 diff --git a/backend/app/services/camera.py b/backend/app/services/camera.py new file mode 100644 index 0000000..c685496 --- /dev/null +++ b/backend/app/services/camera.py @@ -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:///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, + ) diff --git a/backend/app/services/snapshot.py b/backend/app/services/snapshot.py new file mode 100644 index 0000000..734700c --- /dev/null +++ b/backend/app/services/snapshot.py @@ -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 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e30adbc..269b747 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,6 +3,7 @@ import { AppLayout } from './components/AppLayout' import { LoginPage } from './pages/Login' import { ObjectDetailPage } from './pages/ObjectDetail' import { ObjectsPage } from './pages/Objects' +import { SnapshotsPage } from './pages/Snapshots' import { TaskDetailPage } from './pages/TaskDetail' import { TasksPage } from './pages/Tasks' import { useAuthStore } from './store/auth' @@ -27,6 +28,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> diff --git a/frontend/src/api/snapshots.ts b/frontend/src/api/snapshots.ts new file mode 100644 index 0000000..10bd544 --- /dev/null +++ b/frontend/src/api/snapshots.ts @@ -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(`/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(`/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(`/api/v1/objects/${objId}/snapshots/diff`, { + params: { a, b }, + }) + .then((r) => r.data), + enabled: a !== null && b !== null, + }) diff --git a/frontend/src/pages/ObjectDetail.tsx b/frontend/src/pages/ObjectDetail.tsx index 066ae08..5f00492 100644 --- a/frontend/src/pages/ObjectDetail.tsx +++ b/frontend/src/pages/ObjectDetail.tsx @@ -1,6 +1,7 @@ import { DeleteOutlined, EditOutlined, + FileTextOutlined, PlayCircleOutlined, PlusOutlined, SyncOutlined, @@ -473,6 +474,12 @@ export function ObjectDetailPage() { + diff --git a/frontend/src/pages/Snapshots.tsx b/frontend/src/pages/Snapshots.tsx new file mode 100644 index 0000000..54690dd --- /dev/null +++ b/frontend/src/pages/Snapshots.tsx @@ -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 ( + Файлы идентичны — изменений нет. + ) + } + + const lines = diff.split('\n') + return ( +
+      {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 (
+          
+            {line || ' '}
+          
+        )
+      })}
+    
+ ) +} + +// ───────────────────────────────────────── +// Content viewer modal +// ───────────────────────────────────────── +function ContentModal({ + objId, + snapshot, + onClose, +}: { + objId: number + snapshot: SnapshotBrief | null + onClose: () => void +}) { + const [content, setContent] = useState(null) + const [loading, setLoading] = useState(false) + + const loadContent = async () => { + if (!snapshot) return + setLoading(true) + try { + const res = await api.get( + `/api/v1/objects/${objId}/snapshots/${snapshot.id}` + ) + setContent(res.data.content) + } catch { + message.error('Не удалось загрузить содержимое') + } finally { + setLoading(false) + } + } + + return ( + { onClose(); setContent(null) }} + afterOpenChange={(open) => { if (open) loadContent() }} + footer={null} + width={900} + > + {loading ? ( + + ) : content !== null ? ( +
+          {content}
+        
+ ) : null} +
+ ) +} + +// ───────────────────────────────────────── +// 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(null) + const [diffModal, setDiffModal] = useState(false) + const [diffA, setDiffA] = useState(null) + const [diffB, setDiffB] = useState(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) => ( + + {row.device_ip ?? '—'} + {row.device_hostname && ( + + {row.device_hostname} + + )} + + ), + }, + { + title: 'Метка', + dataIndex: 'label', + key: 'label', + render: (v: string | null) => + v ? }>{v} : , + }, + { + title: 'Размер', + dataIndex: 'size', + key: 'size', + render: (v: number) => `${(v / 1024).toFixed(1)} KB`, + }, + { + title: 'SHA-256', + dataIndex: 'sha256', + key: 'sha256', + render: (v: string) => ( + + + {v.slice(0, 12)}… + + + ), + }, + { + title: 'Дата', + dataIndex: 'created_at', + key: 'created_at', + render: (v: string) => ( + + {dayjs(v).format('DD.MM HH:mm')} + + ), + }, + { + title: '', + key: 'actions', + width: 80, + render: (_: unknown, row: SnapshotBrief) => ( + + ), + }, + ] + + return ( +
+ navigate('/objects')}>Объекты }, + { title: navigate(`/objects/${objId}`)}>{obj?.name} }, + { title: 'Снапшоты' }, + ]} + /> + + + + + Снапшоты конфигураций + + + + + + + + }} + /> + + {/* ── Content Viewer Modal ── */} + setViewSnap(null)} /> + + {/* ── Diff Modal ── */} + { setDiffModal(false); setDiffA(null); setDiffB(null) }} + footer={null} + width={960} + > + + + + Снапшот A (старый) + + + (opt?.label ?? '').toLowerCase().includes(input.toLowerCase()) + } + /> + + + + {diffLoading && } + + {diffResult && !diffLoading && ( + <> + {diffResult.is_identical ? ( + + ✓ Файлы идентичны — содержимое не изменилось. + + ) : ( + + )} + + )} + + {!diffResult && !diffLoading && diffA !== null && diffB !== null && ( + Загрузка… + )} + + + ) +}