123
This commit is contained in:
parent
e8fd5a2516
commit
87bf93de89
22 changed files with 1335 additions and 4 deletions
|
|
@ -1,7 +1,8 @@
|
||||||
{
|
{
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"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.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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
)
|
||||||
|
|
|
||||||
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.
|
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(),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
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 { 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>
|
||||||
|
|
|
||||||
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 {
|
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>
|
||||||
|
|
|
||||||
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