123
This commit is contained in:
parent
e8fd5a2516
commit
87bf93de89
22 changed files with 1335 additions and 4 deletions
|
|
@ -1,7 +1,8 @@
|
|||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(xargs grep:*)"
|
||||
"Bash(xargs grep:*)",
|
||||
"Bash(grep -E \"\\\\.py$\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
57
backend/alembic/versions/0005_snapshots.py
Normal file
57
backend/alembic/versions/0005_snapshots.py
Normal 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")
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
43
backend/app/models/snapshot.py
Normal file
43
backend/app/models/snapshot.py
Normal 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"
|
||||
)
|
||||
0
backend/app/plugins/builtin/camera/__init__.py
Normal file
0
backend/app/plugins/builtin/camera/__init__.py
Normal file
49
backend/app/plugins/builtin/camera/get_status.py
Normal file
49
backend/app/plugins/builtin/camera/get_status.py
Normal 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,
|
||||
)
|
||||
96
backend/app/plugins/builtin/camera/set_ntp.py
Normal file
96
backend/app/plugins/builtin/camera/set_ntp.py
Normal 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},
|
||||
)
|
||||
0
backend/app/plugins/builtin/mikrotik/__init__.py
Normal file
0
backend/app/plugins/builtin/mikrotik/__init__.py
Normal file
59
backend/app/plugins/builtin/mikrotik/get_config.py
Normal file
59
backend/app/plugins/builtin/mikrotik/get_config.py
Normal 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},
|
||||
)
|
||||
58
backend/app/plugins/builtin/mikrotik/get_ntp.py
Normal file
58
backend/app/plugins/builtin/mikrotik/get_ntp.py
Normal 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},
|
||||
)
|
||||
60
backend/app/plugins/builtin/mikrotik/set_ntp.py
Normal file
60
backend/app/plugins/builtin/mikrotik/set_ntp.py
Normal 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},
|
||||
)
|
||||
66
backend/app/plugins/builtin/ssh/get_file.py
Normal file
66
backend/app/plugins/builtin/ssh/get_file.py
Normal 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},
|
||||
)
|
||||
|
|
@ -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(),
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
135
backend/app/routers/snapshots.py
Normal file
135
backend/app/routers/snapshots.py
Normal 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,
|
||||
)
|
||||
32
backend/app/schemas/snapshot.py
Normal file
32
backend/app/schemas/snapshot.py
Normal 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
|
||||
125
backend/app/services/camera.py
Normal file
125
backend/app/services/camera.py
Normal 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,
|
||||
)
|
||||
106
backend/app/services/snapshot.py
Normal file
106
backend/app/services/snapshot.py
Normal 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
|
||||
|
|
@ -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() {
|
|||
<Route index element={<Navigate to="/objects" replace />} />
|
||||
<Route path="objects" element={<ObjectsPage />} />
|
||||
<Route path="objects/:id" element={<ObjectDetailPage />} />
|
||||
<Route path="objects/:id/snapshots" element={<SnapshotsPage />} />
|
||||
<Route path="tasks" element={<TasksPage />} />
|
||||
<Route path="tasks/:id" element={<TaskDetailPage />} />
|
||||
</Route>
|
||||
|
|
|
|||
62
frontend/src/api/snapshots.ts
Normal file
62
frontend/src/api/snapshots.ts
Normal 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,
|
||||
})
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import {
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
FileTextOutlined,
|
||||
PlayCircleOutlined,
|
||||
PlusOutlined,
|
||||
SyncOutlined,
|
||||
|
|
@ -473,6 +474,12 @@ export function ObjectDetailPage() {
|
|||
<Button icon={<PlusOutlined />} onClick={openAddDevice}>
|
||||
Добавить устройство
|
||||
</Button>
|
||||
<Button
|
||||
icon={<FileTextOutlined />}
|
||||
onClick={() => navigate(`/objects/${objId}/snapshots`)}
|
||||
>
|
||||
Снапшоты
|
||||
</Button>
|
||||
<Button icon={<EditOutlined />} onClick={() => setRackModal(true)}>
|
||||
Стойки и зоны
|
||||
</Button>
|
||||
|
|
|
|||
353
frontend/src/pages/Snapshots.tsx
Normal file
353
frontend/src/pages/Snapshots.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue