Skip to content
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ venv/
env/
.venv/
ENV/

data/
# Pytest / coverage
.pytest_cache/
.coverage
Expand Down
171 changes: 170 additions & 1 deletion backend/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,108 @@ def add_device():
return jsonify({"device": device_to_dict(device_instance)}), 201


@app.put("/api/devices/<device_id>")
def update_device(device_id: str):
"""Replace the configuration of an existing device in-place.

The device_id (and its data directory) is preserved across the edit —
it is not re-derived from the new config content — so existing log
snapshots that reference this device_id stay linked to it. Runtime/
watchdog state (connection status, active session, pid, etc.) is also
preserved, and the watchdog process is restarted so it picks up the
new config.

PUT '/api/devices/<device_id>'

Path parameters:
- device_id (str) - The config ID of the device to update.

Request body (JSON):
- contents (str) - Base-64-encoded replacement config file content.
Optionally prefixed with a data-URI header; the prefix is stripped
automatically.

Returns:
200 OK:
JSON object containing the updated device:

- device (dict) - Serialised device (see :func:`device_to_dict`).

404 Not Found:
'{ "error": "not_found" }' - No device with the given ID exists.

422 Unprocessable Entity:
'{ "error": "invalid_config" }' - The decoded config failed
validation; the original config is left untouched.
"""
import base64

device = get_target_device(device_id)
if not device:
return _bad("not_found", 404)

body = request.get_json(force=True)
contents = body.get("contents", "")

# Strip data-URI prefix if present
if "," in contents:
contents = contents.split(",", 1)[1]

try:
decoded_cfg = json.loads(base64.b64decode(contents).decode())
except Exception:
return _bad("invalid_config", 422)

if not isinstance(decoded_cfg, dict) or not decoded_cfg.get("device_name"):
return _bad("invalid_config", 422)

# Carry forward the live watchdog/runtime state (connection status,
# active session, pid, etc.). The config editor (frontend) only ever
# knows about connection + log-entry fields, so left to itself it would
# submit a payload missing these entirely — the next Device load would
# then crash with e.g. KeyError: 'connected'.
old_cfg = device.device_config or {}
existing_watchdog_data = {
key: old_cfg.get(key, default)
for key, default in DeviceConfig.WATCHDOG_DATA_DEFAULTS.items()
}

# Force a watchdog restart against the new config: terminate the
# process that was running against the old config, and mark the pid as
# gone so Device.__init__ spawns a fresh watchdog against the
# just-saved config next time this device is loaded (see the
# `if self.watchdog_process_pid == 0 or not self.is_process_active()`
# check there).
old_pid = existing_watchdog_data.get("watchdog_process_pid")
if old_pid:
try:
os.kill(old_pid, signal.SIGTERM)
except ProcessLookupError:
pass
except Exception:
pass
existing_watchdog_data["watchdog_process_pid"] = 0

# Save via DeviceConfig, the same as add_device does, but pinning the
# ID to the existing one — DeviceConfig.get_device_config_id() derives
# an ID from the config *content*, which would otherwise change on
# every edit and orphan this device's directory and all its log
# snapshots (which reference it by device_id).
device_config = DeviceConfig(
contents,
existing_device_config_id=device_id,
existing_watchdog_data=existing_watchdog_data,
)
if not device_config.validate_device_config():
device_config.remove_device_config()
return _bad("invalid_config", 422)

updated_device = get_target_device(device_id)
if not updated_device:
return _bad("not_found", 404)
return jsonify({"device": device_to_dict(updated_device)})


@app.delete("/api/devices/<device_id>")
def remove_device(device_id: str):
"""Remove a single device and terminate its watchdog process.
Expand Down Expand Up @@ -583,14 +685,23 @@ def get_packet_details(snapshot_id: str, packet_number: int):
if not target or getattr(target, "log_name", "") != "network capture":
return _bad("not_found", 404)

# Resolve the device so we can read its optional custom decoder command.
snap_device = get_target_device(target.device_id)
decoder_cmd = (
snap_device.device_config.get("packets_capture_config", {}).get("decoder_cmd")
if snap_device
else None
)

device_data_dir = os.path.join("data", target.device_id)
try:
details = PcapDecoder.get_session_packet_details(
device_data_dir, target.session_id, packet_number,
dissectors_dir=dissector_registry,
decoder_cmd=decoder_cmd,
)
except FileNotFoundError as exc:
return _bad(f"tshark not available: {exc}", 500)
return _bad(f"pcap decoder not available: {exc}", 500)

if not details:
return _bad("not_found", 404)
Expand Down Expand Up @@ -1187,6 +1298,64 @@ def delete_dissector(filename: str):
return jsonify({"deleted": deleted})


# ── device groups ─────────────────────────────────────────────────────────────

@app.get("/api/settings/device-groups")
def get_device_groups():
"""Return the persisted device-group configuration.

Device groups are stored server-side so that every user/browser session
sees the same grouping without having to configure it independently.

GET '/api/settings/device-groups'

Returns:
200 OK:
JSON array of group objects. Each element contains:

- id (str) - Stable group identifier.
- name (str) - Human-readable group name.
- deviceIds (list[str]) - Ordered list of device config IDs in this group.

Example::

[{"id": "abc", "name": "Core Routers", "deviceIds": ["d1", "d2"]}]
"""
settings = _load_settings()
return jsonify(settings.get("device_groups", []))


@app.put("/api/settings/device-groups")
def save_device_groups():
"""Persist the full device-group configuration.

Replaces the stored groups array atomically. The frontend sends the
complete array on every mutation (create, rename, reorder, delete).

PUT '/api/settings/device-groups'

Request body (JSON):
- groups (list[dict]) - Full replacement groups array.
Each entry must have id (str), name (str), and deviceIds (list[str]).

Returns:
200 OK:
'{ "status": "ok" }'

400 Bad Request:
'{ "error": "groups must be a list" }'
"""
body = request.get_json(force=True)
groups = body.get("groups")
if not isinstance(groups, list):
return _bad("groups must be a list")

settings = _load_settings()
settings["device_groups"] = groups
_save_settings(settings)
return jsonify({"status": "ok"})


# ── login ─────────────────────────────────────────────────────────────────────

@app.post("/api/auth/login")
Expand Down
64 changes: 48 additions & 16 deletions backend/models/device_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,24 +9,50 @@ class DeviceConfig:
"""
A class used to store and manage device configuration.
"""
def __init__(self, file_content_str):
self.watchdog_data = {
"logs_collection": False,
"current_session_id": "no_active_session",
"session_scenario": "no_active_session",
"connected": False,
"logs_available": False,
"watchdog_process_pid": 0,
"auto_collection_enabled": False,
"auto_collection_interval": 0
}

# Keys that represent live/runtime watchdog state rather than editable
# device configuration. Exposed as a class attribute (not just an
# instance default) so callers such as the API layer can read the
# current values of these keys without having to construct a
# DeviceConfig instance first.
WATCHDOG_DATA_DEFAULTS = {
"logs_collection": False,
"current_session_id": "no_active_session",
"session_scenario": "no_active_session",
"connected": False,
"logs_available": False,
"watchdog_process_pid": 0,
"auto_collection_enabled": False,
"auto_collection_interval": 0
}

def __init__(self, file_content_str, existing_device_config_id=None, existing_watchdog_data=None):
"""
Args:
file_content_str (str): Config file content, base-64-encoded.
existing_device_config_id (str, optional): When editing an
already-existing device, pass its current device_config_id
here so the ID is preserved instead of being re-derived
from the (edited) content. Device IDs double as the data
directory name and are referenced from log-snapshot
metadata, so they must stay stable across edits.
existing_watchdog_data (dict, optional): When editing an
already-existing device, pass its current runtime/watchdog
state here so it survives the edit instead of being reset
to fresh defaults (which would wipe out things like the
live connection status or an in-progress session).
"""
self.watchdog_data = dict(existing_watchdog_data) if existing_watchdog_data is not None else dict(self.WATCHDOG_DATA_DEFAULTS)
self._existing_device_config_id = existing_device_config_id
self.device_config_id = self.save_config_file(file_content_str)
self.device_config_path = f"/tmp/{self.device_config_id}.json"
self.device_config = None

def save_config_file(self, file_content_str):
"""
Save JSON config file to '/tmp/' directory under generated device config ID
Save JSON config file to '/tmp/' directory under the device config ID:
either a pre-existing ID passed in for an edit, or a freshly
generated one derived from the config content for a new device.

Args:
file_content_str (str): Config file in raw str format.
Expand All @@ -35,7 +61,10 @@ def save_config_file(self, file_content_str):
str: Unique device config ID.
"""
decoded = base64.b64decode(file_content_str)
device_config_id = self.get_device_config_id(json.loads(decoded))
if self._existing_device_config_id:
device_config_id = self._existing_device_config_id
else:
device_config_id = self.get_device_config_id(json.loads(decoded))
with open(f"/tmp/{device_config_id}.json", "wb") as f:
f.write(decoded)

Expand All @@ -54,8 +83,11 @@ def validate_device_config(self):
json.dump(config_data, config_file, indent=2)
target_device_directory = f"data/{self.device_config_id}"
target_config_path = f"{target_device_directory}/{self.device_config_path.split('/')[-1]}"
if not os.path.exists(target_device_directory):
os.mkdir(target_device_directory)
os.makedirs(target_device_directory, exist_ok=True)
if os.path.exists(target_config_path):
# Editing an existing device: same ID, same directory, same
# filename — replace the old config file in place.
os.remove(target_config_path)
shutil.move(self.device_config_path, target_config_path)
self.device_config_path = target_config_path

Expand Down Expand Up @@ -111,7 +143,7 @@ def get_device_config_id(self, device_config):
Returns:
str: Unique device config ID.
"""
for not_const_key in list(self.watchdog_data.keys()):
for not_const_key in list(self.WATCHDOG_DATA_DEFAULTS.keys()):
if not_const_key in device_config.keys():
device_config.pop(not_const_key)
return hashlib.sha256(json.dumps(device_config, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()[:12]
10 changes: 9 additions & 1 deletion backend/services/device_watchdog.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,13 +230,21 @@ def get_log_file_content(self, log_config: dict) -> None:
if lock is None:
return

append_unmatched = log_config.get("append_unmatched_to_last", False)

with lock:
existing: list[dict] = self.collected_data[log_name]
last_ts: datetime | None = existing[-1]["time"] if existing else None
new_entries: list[dict] = []
for line in raw.splitlines():
m = pattern.search(line)
if not m:
if append_unmatched and new_entries:
# Append unmatched line to the last new entry's content
new_entries[-1]["content"] += "\n" + line
elif append_unmatched and existing:
# No new entries yet — fold into the last persisted entry
existing[-1]["content"] += "\n" + line
continue
ts = parser.parse(m.group("TIME"))
if last_ts is None or ts > last_ts:
Expand Down Expand Up @@ -513,4 +521,4 @@ def update_device_config_parameters(path_to_config_file: str, updates: dict) ->
device_watchdog.errors.to_feather(errors_file_path)
last_errors_len = current_errors_len

sleep(5)
sleep(5)
12 changes: 11 additions & 1 deletion backend/utils/device_config_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,17 @@ def load_device_from_config(self, config_path):
config_content = base64.b64encode(config_file.read())
config_file.close()
if config_content:
device_config = DeviceConfig(config_content)
# A device's config lives at data/<device_config_id>/<device_config_id>.json
# (see DeviceConfig.validate_device_config), so the parent
# directory name *is* the device's ID. Passing it in here stops
# DeviceConfig from re-deriving an ID by hashing the file's
# current content on every load — which would silently produce
# a different ID any time the config is edited, since the hash
# depends on the (now-changed) content. That drift breaks
# lookups by ID, device-group membership, and the link between
# a device and its historical log snapshots.
device_config_id = Path(config_path).parent.name
device_config = DeviceConfig(config_content, existing_device_config_id=device_config_id)
device_config.device_config_path = config_path
return Device(device_config_instance=device_config)
else:
Expand Down
Loading
Loading