diff --git a/.gitignore b/.gitignore index 10896c1..6a6a340 100644 --- a/.gitignore +++ b/.gitignore @@ -16,7 +16,7 @@ venv/ env/ .venv/ ENV/ - +data/ # Pytest / coverage .pytest_cache/ .coverage diff --git a/backend/app.py b/backend/app.py index 26fe1d2..cf03a9b 100644 --- a/backend/app.py +++ b/backend/app.py @@ -288,6 +288,108 @@ def add_device(): return jsonify({"device": device_to_dict(device_instance)}), 201 +@app.put("/api/devices/") +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/' + + 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/") def remove_device(device_id: str): """Remove a single device and terminate its watchdog process. @@ -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) @@ -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") diff --git a/backend/models/device_config.py b/backend/models/device_config.py index a2e9ea7..85c40b4 100644 --- a/backend/models/device_config.py +++ b/backend/models/device_config.py @@ -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. @@ -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) @@ -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 @@ -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] diff --git a/backend/services/device_watchdog.py b/backend/services/device_watchdog.py index b40ec73..7d36109 100644 --- a/backend/services/device_watchdog.py +++ b/backend/services/device_watchdog.py @@ -230,6 +230,8 @@ 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 @@ -237,6 +239,12 @@ def get_log_file_content(self, log_config: dict) -> None: 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: @@ -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) \ No newline at end of file diff --git a/backend/utils/device_config_loader.py b/backend/utils/device_config_loader.py index 64e95d2..4b4001c 100644 --- a/backend/utils/device_config_loader.py +++ b/backend/utils/device_config_loader.py @@ -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//.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: diff --git a/backend/utils/pcap_decoder.py b/backend/utils/pcap_decoder.py index 8bcd771..49f0576 100644 --- a/backend/utils/pcap_decoder.py +++ b/backend/utils/pcap_decoder.py @@ -163,16 +163,37 @@ def get_plugin_args(self) -> list[str]: class PcapDecoder: """ - Decodes a pcap file using tshark and exposes the packets both as - PacketInfo objects and as a "time" + "content" DataFrame. Custom dissectors - will be used to support extra protocol fields. + Decodes a pcap file using tshark (default) or a custom binary/command + configured per-device, and exposes the packets both as PacketInfo objects + and as a "time" + "content" DataFrame. + + Two decoding modes are supported: + + * **tshark mode** (``decoder_cmd`` is ``None`` or ``"tshark"``): the full + tshark field-extraction pipeline is used, custom dissectors are loaded, + and ``get_packet_details`` returns a rich JSON protocol tree. + + * **custom-binary mode** (``decoder_cmd`` is any other non-empty string): + the command is treated as an opaque shell command that must accept a pcap + file path as its last argument and write tab-separated lines whose columns + match the order of ``_TSHARK_FIELDS`` to stdout. Dissector plugins are + **not** applied (they are tshark-specific), and ``get_packet_details`` + returns an empty dict because the JSON protocol-tree output format is + tshark-specific. The first token of ``decoder_cmd`` is checked for + existence on PATH before the command is run. """ TSHARK_BIN = "tshark" - def __init__(self, pcap_file: str, dissectors_dir: str | os.PathLike | DissectorRegistry | None = None): + def __init__( + self, + pcap_file: str, + dissectors_dir: str | os.PathLike | DissectorRegistry | None = None, + decoder_cmd: str | None = None, + ): self.pcap_file = pcap_file self.packets: list[PacketInfo] = [] + if isinstance(dissectors_dir, DissectorRegistry): self._registry: DissectorRegistry | None = dissectors_dir elif dissectors_dir is not None: @@ -180,8 +201,62 @@ def __init__(self, pcap_file: str, dissectors_dir: str | os.PathLike | Dissector else: self._registry = None + # Normalise: treat None / empty / "tshark" as the default tshark path. + raw = (decoder_cmd or "").strip() + self._custom_cmd: str | None = raw if raw and raw != self.TSHARK_BIN else None + + # ── helpers ─────────────────────────────────────────────────────────────── + + @property + def _is_custom(self) -> bool: + """True when a non-tshark decoder command has been configured.""" + return self._custom_cmd is not None + + @property + def _decoder_bin(self) -> str: + """The binary name/path actually used for decoding.""" + if self._is_custom: + # First whitespace-separated token is the executable. + return self._custom_cmd.split()[0] + return self.TSHARK_BIN + + def _check_decoder(self) -> None: + """ + Raise FileNotFoundError if the configured decoder binary is not on PATH. + """ + bin_name = self._decoder_bin + if shutil.which(bin_name) is None: + if self._is_custom: + raise FileNotFoundError( + f"Custom pcap decoder '{bin_name}' not found on PATH. " + f"Check the 'decoder_cmd' setting for this device." + ) + raise FileNotFoundError( + f"'{bin_name}' not found on PATH; install Wireshark/tshark to decode pcaps." + ) + + def _plugin_args(self) -> list[str]: + """ + Return extra tshark args for custom dissectors (empty list if none or + if a custom decoder is in use, since dissectors are tshark-specific). + + Returns: + (list): Extra tshark args for custom dissectors. + """ + if self._is_custom or self._registry is None: + return [] + return self._registry.get_plugin_args() + + # ── class-method constructors ───────────────────────────────────────────── + @classmethod - def for_session(cls, device_data_dir: str, session_id: str, dissectors_dir: str | os.PathLike | DissectorRegistry | None = None) -> PcapDecoder: + def for_session( + cls, + device_data_dir: str, + session_id: str, + dissectors_dir: str | os.PathLike | DissectorRegistry | None = None, + decoder_cmd: str | None = None, + ) -> "PcapDecoder": """ Build a PcapDecoder for a session's saved pcap file, i.e. /.pcap -- the naming convention used by @@ -191,14 +266,27 @@ def for_session(cls, device_data_dir: str, session_id: str, dissectors_dir: str device_data_dir (str): Per-device data directory. session_id (str): Session whose pcap should be decoded. dissectors_dir (str): Optional custom dissectors directory or registry. + decoder_cmd (str | None): Optional custom decoder command. When + ``None`` or ``"tshark"``, the standard tshark pipeline is used. Returns: (PcapDecoder): PcapDecoder class object. """ - return cls(os.path.join(device_data_dir, f"{session_id}.pcap"), dissectors_dir=dissectors_dir) + return cls( + os.path.join(device_data_dir, f"{session_id}.pcap"), + dissectors_dir=dissectors_dir, + decoder_cmd=decoder_cmd, + ) @classmethod - def get_session_packet_details(cls, device_data_dir: str, session_id: str, packet_number: int, dissectors_dir: str | os.PathLike | DissectorRegistry | None = None) -> dict: + def get_session_packet_details( + cls, + device_data_dir: str, + session_id: str, + packet_number: int, + dissectors_dir: str | os.PathLike | DissectorRegistry | None = None, + decoder_cmd: str | None = None, + ) -> dict: """ Convenience one-shot: resolve a session's pcap path and decode a single packet's full field detail from it. @@ -208,74 +296,68 @@ def get_session_packet_details(cls, device_data_dir: str, session_id: str, packe session_id (str): Session whose pcap to decode. packet_number (int): 1-based frame number. dissectors_dir (str): Optional custom dissectors directory or registry. + decoder_cmd (str | None): Optional custom decoder command. Packet + detail is only available in tshark mode; a custom decoder + returns an empty dict. Returns: - (dict): Single packet detaile extraced with tshark in dict format. + (dict): Single packet details extracted with tshark in dict format, + or an empty dict when a custom decoder is configured. Raises: - FileNotFoundError: tshark is not installed / not on PATH. + FileNotFoundError: The configured decoder is not installed / not on PATH. """ - decoder = cls.for_session(device_data_dir, session_id, dissectors_dir=dissectors_dir) + decoder = cls.for_session( + device_data_dir, + session_id, + dissectors_dir=dissectors_dir, + decoder_cmd=decoder_cmd, + ) if not os.path.exists(decoder.pcap_file): return {} return decoder.get_packet_details(packet_number) - - def _plugin_args(self) -> list[str]: - """ - Return extra tshark args for custom dissectors (empty list if none). - - Returns: - (list): Extra tshark args for custom dissectors. - """ - if self._registry is None: - return [] - return self._registry.get_plugin_args() - - def _check_tshark(self) -> None: - """ - Raise FileNotFoundError if tshark is not on PATH - """ - if shutil.which(self.TSHARK_BIN) is None: - raise FileNotFoundError( - f"'{self.TSHARK_BIN}' not found on PATH; install Wireshark/tshark to decode pcaps." - ) + # ── decoding ────────────────────────────────────────────────────────────── def decode(self) -> list[PacketInfo]: """ - Run tshark over the pcap file and populate self.packets. + Run the configured decoder over the pcap file and populate self.packets. - Parses whatever tshark writes to stdout on a best-effort basis; - a non-zero tshark exit status is not treated as fatal (tshark can - exit non-zero while still having emitted usable lines, e.g. for - warnings on link-layer types or a still-growing capture file). + In **tshark mode** the standard field-extraction pipeline is used and + custom dissectors are loaded automatically. - Custom dissectors registered in ``dissectors_dir`` are loaded - automatically; they do *not* affect the flat summary fields decoded - here but do enrich ``get_packet_details()`` output. + In **custom-binary mode** the ``decoder_cmd`` is split on whitespace, + the pcap file path is appended as the final argument, and the process + is expected to write tab-separated lines matching ``_TSHARK_FIELDS`` + order to stdout. A non-zero exit status is not treated as fatal + (matching the existing tshark behaviour). Returns: (list): The decoded list of PacketInfo objects (also stored on self.packets). Raises: - FileNotFoundError: tshark is not installed / not on PATH, or no pcap_file path was given. + FileNotFoundError: The decoder binary is not on PATH, or no + ``pcap_file`` path was given. """ if not self.pcap_file: raise FileNotFoundError("PcapDecoder was given no pcap_file path to decode.") - self._check_tshark() + self._check_decoder() - cmd = [ - self.TSHARK_BIN, - "-r", self.pcap_file, - "-T", "fields", - "-E", "separator=\t", - "-E", "quote=n", - "-E", "occurrence=f", - ] + self._plugin_args() - - for f in _TSHARK_FIELDS: - cmd += ["-e", f] + if self._is_custom: + cmd = self._custom_cmd.split() + [self.pcap_file] + logger.debug("Custom pcap decoder command: %s", cmd) + else: + cmd = [ + self.TSHARK_BIN, + "-r", self.pcap_file, + "-T", "fields", + "-E", "separator=\t", + "-E", "quote=n", + "-E", "occurrence=f", + ] + self._plugin_args() + for f in _TSHARK_FIELDS: + cmd += ["-e", f] result = subprocess.run(cmd, capture_output=True, text=True) @@ -288,16 +370,20 @@ def decode(self) -> list[PacketInfo]: def _parse_line(self, line: str) -> PacketInfo | None: """ - Parse a single tab-separated tshark output line into a PacketInfo. - + Parse a single tab-separated decoder output line into a PacketInfo. + + The expected column order is defined by ``_TSHARK_FIELDS``. Both the + built-in tshark pipeline and any custom decoder must produce output in + this format. + Args: - line (str): Single tab-separated tshark output line. + line (str): Single tab-separated decoder output line. Returns: - (PacketInfo): PacketInfo object. + (PacketInfo | None): PacketInfo object, or None if the line is malformed. """ fields = line.split("\t") - # Pad in case trailing empty fields were stripped by tshark. + # Pad in case trailing empty fields were stripped by the decoder. fields += [""] * (len(_TSHARK_FIELDS) - len(fields)) ( time_epoch, number, length, protocols, @@ -330,23 +416,38 @@ def get_packet_details(self, packet_number: int) -> dict: """ Decode a single packet's full field detail into a nested dict. + This method is only meaningful in **tshark mode**. When a custom + decoder is configured it returns an empty dict immediately, because the + JSON protocol-tree output format (``tshark -T json``) is tshark-specific + and cannot be emulated by an arbitrary binary. + When custom dissectors are registered via ``dissectors_dir``, tshark loads them before parsing so their protocol trees appear alongside the standard layers in the returned dict. Args: - packet_number (int): Frame number ("packet index" shown in the "content" column). + packet_number (int): Frame number ("packet index" shown in the + "content" column). Returns: - (dict): Nested dict of every layer/field tshark parsed for that packet. + (dict): Nested dict of every layer/field tshark parsed for that + packet, or an empty dict in custom-decoder mode. Raises: - FileNotFoundError: tshark is not installed / not on PATH, or the pcap file doesn't exist. + FileNotFoundError: tshark is not installed / not on PATH, or the + pcap file doesn't exist (tshark mode only). """ + if self._is_custom: + logger.debug( + "get_packet_details is not supported for custom decoder '%s'; returning empty dict.", + self._custom_cmd, + ) + return {} + if not self.pcap_file or not os.path.exists(self.pcap_file): raise FileNotFoundError(f"pcap file not found: {self.pcap_file}") - self._check_tshark() + self._check_decoder() cmd = [ self.TSHARK_BIN, @@ -410,4 +511,4 @@ def to_log_snapshot(self, data_unit, log_type, log_content, - ) + ) \ No newline at end of file diff --git a/frontend/src/LogOctopus.jsx b/frontend/src/LogOctopus.jsx index f206d0e..7b3998d 100644 --- a/frontend/src/LogOctopus.jsx +++ b/frontend/src/LogOctopus.jsx @@ -239,7 +239,7 @@ function PlotlyChart({ rows, title, index, dataUnit }) { * snapshot as its own titled Plotly panel inside the modal — side by side * (2-column grid) or stacked depending on count. */ -function ChartContentView({ chartGroups }) { +function ChartContentView({ chartGroups, onShareChart }) { // chartGroups: [{ snapInfo, rows }] if (!chartGroups || chartGroups.length === 0) return

No chart data.

; @@ -265,6 +265,7 @@ function ChartContentView({ chartGroups }) { marginTop: -12, marginBottom: 8, paddingLeft: 4, + alignItems: "center", }} > {g.snapInfo.logName} @@ -272,6 +273,28 @@ function ChartContentView({ chartGroups }) { {g.rows.length} points Session: {g.snapInfo.sessionId} Data unit: {g.snapInfo.dataUnit} + {onShareChart && ( + + )} ); @@ -511,12 +534,13 @@ function parsePacketNumber(content) { * Every other log line is untouched — the glyph margin * only ever gets a decoration for packet_capture rows. */ -function MonacoLogViewer({ rows, colorMode, onPacketClick }) { +function MonacoLogViewer({ rows, colorMode, onPacketClick, highlightLine, onEditorReady }) { const containerRef = useRef(null); const editorRef = useRef(null); const modelRef = useRef(null); const decorationsRef = useRef([]); // current line-color decoration IDs const glyphDecorationsRef = useRef([]); // current packet-glyph decoration IDs + const shareDecorationsRef = useRef([]); // highlight for shared line const [ready, setReady] = useState(false); const [loadErr, setLoadErr] = useState(null); @@ -678,6 +702,13 @@ function MonacoLogViewer({ rows, colorMode, onPacketClick }) { } }); + // Expose a function the parent can call to get the current cursor line + if (onEditorReady) { + onEditorReady({ + getCurrentLine: () => editor.getPosition()?.lineNumber ?? 1, + }); + } + setReady(true); }) .catch((e) => { @@ -692,6 +723,7 @@ function MonacoLogViewer({ rows, colorMode, onPacketClick }) { modelRef.current = null; decorationsRef.current = []; glyphDecorationsRef.current = []; + shareDecorationsRef.current = []; }; }, []); // eslint-disable-line react-hooks/exhaustive-deps @@ -734,6 +766,52 @@ function MonacoLogViewer({ rows, colorMode, onPacketClick }) { return () => ro.disconnect(); }, [ready]); + // Highlight and scroll to the shared line when highlightLine changes. + // Also re-applies whenever `rows` changes after mount: model.setValue() + // (used to sync content when rows update, e.g. when a shared link's + // filters are re-applied right after the editor first mounts) fully + // replaces the buffer and drops any previously-set decorations, so + // without `rows` as a dependency here the highlight would silently + // disappear the moment that happens. + useEffect(() => { + const editor = editorRef.current; + const model = modelRef.current; + if (!ready || !editor || !model || !highlightLine) return; + // The shared line may no longer exist in the current (e.g. filtered) view. + if (highlightLine > model.getLineCount()) return; + + // Ensure style element for the highlight class exists + const styleId = "lo-share-highlight-style"; + if (!document.getElementById(styleId)) { + const el = document.createElement("style"); + el.id = styleId; + el.textContent = `.lo-share-line { background: rgba(251,191,36,0.18) !important; border-left: 3px solid #fbbf24 !important; } + .lo-share-line-number { color: #fbbf24 !important; font-weight: 700 !important; }`; + document.head.appendChild(el); + } + + shareDecorationsRef.current = editor.deltaDecorations( + shareDecorationsRef.current, + [{ + range: { + startLineNumber: highlightLine, + startColumn: 1, + endLineNumber: highlightLine, + endColumn: 1, + }, + options: { + isWholeLine: true, + className: "lo-share-line", + lineNumberClassName: "lo-share-line-number", + }, + }] + ); + + // Scroll the highlighted line into view (center it) + editor.revealLineInCenter(highlightLine); + editor.setPosition({ lineNumber: highlightLine, column: 1 }); + }, [ready, highlightLine, rows]); + if (loadErr) { return (
void + * filteredCount – rows after filtering + * totalCount – rows before filtering + */ +const FILTER_SEP = "\x00"; +const makeFilterKey = (device, logName) => `${device}${FILTER_SEP}${logName}`; + +const DEVICE_PALETTE = [ + { accent: "#818cf8", bg: "rgba(129,140,248,0.12)", border: "rgba(129,140,248,0.32)" }, + { accent: "#34d399", bg: "rgba(52,211,153,0.12)", border: "rgba(52,211,153,0.32)" }, + { accent: "#fb923c", bg: "rgba(251,146,60,0.12)", border: "rgba(251,146,60,0.32)" }, + { accent: "#f472b6", bg: "rgba(244,114,182,0.12)", border: "rgba(244,114,182,0.32)" }, + { accent: "#60a5fa", bg: "rgba(96,165,250,0.12)", border: "rgba(96,165,250,0.32)" }, + { accent: "#a78bfa", bg: "rgba(167,139,250,0.12)", border: "rgba(167,139,250,0.32)" }, + { accent: "#facc15", bg: "rgba(250,204,21,0.12)", border: "rgba(250,204,21,0.32)" }, + { accent: "#2dd4bf", bg: "rgba(45,212,191,0.12)", border: "rgba(45,212,191,0.32)" }, +]; + +function LogFilterBar({ logRows, filters, onFiltersChange, filteredCount, totalCount }) { + // Build ordered map: deviceName → [logName, …] (insertion order preserved) + const deviceLogMap = useMemo(() => { + const map = new Map(); // device → Set of logNames + for (const r of logRows) { + const dev = r.device_name ?? ""; + const log = r.log_name ?? ""; + if (!map.has(dev)) map.set(dev, []); + if (!map.get(dev).includes(log)) map.get(dev).push(log); + } + return map; + }, [logRows]); + + const deviceNames = useMemo(() => [...deviceLogMap.keys()], [deviceLogMap]); + + // Draft state: same key format as `filters` + const [drafts, setDrafts] = useState(() => ({ ...filters })); + + // Sync drafts when filters reset externally (modal re-open) + const prevFiltersRef = useRef(filters); + useEffect(() => { + if (prevFiltersRef.current !== filters) { + prevFiltersRef.current = filters; + setDrafts({ ...filters }); + } + }, [filters]); + + // Which device chip is expanded + const [expanded, setExpanded] = useState(null); + + // Close popover when clicking outside + const popoverRef = useRef(null); + useEffect(() => { + if (!expanded) return; + const handler = (e) => { + if (popoverRef.current && !popoverRef.current.contains(e.target)) { + setExpanded(null); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [expanded]); + + // Regex validity for all current drafts + const regexValid = useMemo(() => { + const v = {}; + for (const [k, pat] of Object.entries(drafts)) { + if (!pat || !pat.trim()) { v[k] = true; continue; } + try { new RegExp(pat, "i"); v[k] = true; } catch { v[k] = false; } + } + return v; + }, [drafts]); + + // Per-device helpers + const deviceHasFilter = (dev) => + (deviceLogMap.get(dev) || []).some(log => { + const k = makeFilterKey(dev, log); + return (filters[k] ?? "").trim().length > 0; + }); + + const deviceActiveCount = (dev) => + (deviceLogMap.get(dev) || []).filter(log => (filters[makeFilterKey(dev, log)] ?? "").trim()).length; + + const allDraftsValid = Object.values(regexValid).every(Boolean); + const isFiltered = Object.values(filters).some(v => v && v.trim()); + + // Apply: push all drafts for the open device into applied filters + const applyDevice = (dev) => { + if (!allDraftsValid) return; + const next = { ...filters }; + (deviceLogMap.get(dev) || []).forEach(log => { + const k = makeFilterKey(dev, log); + const val = drafts[k] ?? ""; + if (val.trim()) next[k] = val; else delete next[k]; + }); + onFiltersChange(next); + setExpanded(null); + }; + + const clearDevice = (dev, e) => { + e.stopPropagation(); + const next = { ...filters }; + (deviceLogMap.get(dev) || []).forEach(log => delete next[makeFilterKey(dev, log)]); + const nextDrafts = { ...drafts }; + (deviceLogMap.get(dev) || []).forEach(log => { nextDrafts[makeFilterKey(dev, log)] = ""; }); + setDrafts(nextDrafts); + onFiltersChange(next); + }; + + const clearAll = () => { + setDrafts({}); + onFiltersChange({}); + }; + + return ( +
+ {/* Label */} + + Filter + + + {/* One chip per device */} + {deviceNames.map((dev, i) => { + const palette = DEVICE_PALETTE[i % DEVICE_PALETTE.length]; + const filtered = deviceHasFilter(dev); + const count = deviceActiveCount(dev); + const isOpen = expanded === dev; + const logNames = deviceLogMap.get(dev) || []; + + return ( +
+ {/* Chip button */} + + + {/* Popover — per-logName rows */} + {isOpen && ( +
+ {/* Popover header */} +
+
+ + {dev} +
+ + regex per log — case-insensitive + +
+ + {/* One row per logName */} +
+ {logNames.map((log, li) => { + const k = makeFilterKey(dev, log); + const draft = drafts[k] ?? ""; + const valid = regexValid[k] ?? true; + const applied = (filters[k] ?? "").trim().length > 0; + + return ( +
+ {/* Log name label */} +
+ + {log} + + {applied && ( + active + )} +
+ + {/* Regex input */} +
+ setDrafts(prev => ({ ...prev, [k]: e.target.value }))} + onKeyDown={e => { + if (e.key === "Enter") applyDevice(dev); + if (e.key === "Escape") setExpanded(null); + }} + placeholder={`e.g. ERROR|WARN`} + style={{ + width: "100%", + background: "var(--card-bg)", + border: `1px solid ${!valid ? "#f87171" : draft ? palette.border : "var(--border)"}`, + borderRadius: 7, + color: "var(--text)", + fontFamily: "var(--font-mono)", fontSize: 12, + padding: "7px 30px 7px 10px", + outline: "none", boxSizing: "border-box", + transition: "border-color 0.15s", + }} + /> + {draft && ( + + )} +
+ {!valid && ( +
+ ⚠ Invalid regular expression +
+ )} + {/* Divider between log entries */} + {li < logNames.length - 1 && ( +
+ )} +
+ ); + })} +
+ + {/* Footer actions */} +
+ +
+ + +
+
+
+ )} +
+ ); + })} + + {/* Spacer */} +
+ + {/* Row count + clear all */} + {isFiltered ? ( +
+ + {filteredCount.toLocaleString()} / {totalCount.toLocaleString()} rows + + +
+ ) : ( + + {totalCount.toLocaleString()} rows + + )} +
+ ); +} + // ── LOG CONTENT VIEW ────────────────────────────────────────────────────────── -function LogContentView({ rows, isChart, colorMode, chartGroups, onPacketClick }) { - if (isChart) return ; +function LogContentView({ rows, isChart, colorMode, chartGroups, onPacketClick, onShareChart, onEditorReady, highlightLine }) { + if (isChart) return ; if (!rows || rows.length === 0) return

No data.

; return (
- +
); } @@ -2734,18 +3221,60 @@ requests.post(f"{BASE}/api/stop-logs-collection", } // ── DEVICE DETAILS ──────────────────────────────────────────────────────────── -function DeviceDetails({ device, isAdmin, onRequestLogin }) { +// Small pill used in the device header to show a boolean status at a glance. +function StatusPill({ label, ok, onLabel, offLabel, pulse }) { + return ( +
+ + {label} + {ok ? onLabel : offLabel} +
+ ); +} + +function DeviceDetails({ device, isAdmin, onRequestLogin, onEdit }) { const [configVisible, setConfigVisible] = useState(false); const [errors, setErrors] = useState(null); const [errorsLoading, setErrorsLoading] = useState(false); const [errorsLoadError, setErrorsLoadError] = useState(null); const [errorsVisible, setErrorsVisible] = useState(false); + const [idCopied, setIdCopied] = useState(false); const handleShowConfig = () => { if (!isAdmin) { onRequestLogin(); return; } setConfigVisible((v) => !v); }; + const copyConfigId = () => { + if (!device.id) return; + navigator.clipboard.writeText(String(device.id)).then(() => { + setIdCopied(true); + setTimeout(() => setIdCopied(false), 1600); + }); + }; + const fetchErrors = async () => { setErrorsLoading(true); setErrorsLoadError(null); @@ -2772,41 +3301,122 @@ function DeviceDetails({ device, isAdmin, onRequestLogin }) { return ( -
-
- {[ - ["Name", device.name], - ["Connection", device.connection ? "✅ Online" : "❌ Offline"], - ["Log Access", device.logAccess ? "✅ Yes" : "❌ No"], - ["Collecting", device.collecting ? "🟢 Active" : "🟡 Idle"], - ].map(([k, v]) => ( -
+ {/* Header card — device identity, live status pills, and the config id */} +
+
+
+ + + {device.name} + +
+ +
+ + + +
+
+ + {/* Device Config ID — copyable */} +
+ -
{k}
-
{v}
-
- ))} + Device ID + + + {device.id || "—"} + + + {idCopied ? "✓ Copied" : "⧉ Copy"} + +
{/* Config section — guarded by admin role */} -
+
-

- JSON Configuration +

+ 🛠️ JSON Configuration

{isAdmin - ? configVisible ? "🙈 Hide" : "👁 Show" + ? configVisible ? "Hide" : "Show" : "🔐 Admin only"} + {isAdmin && ( + onEdit(device)}> + ✏️ Edit Config + + )}
{!isAdmin && ( @@ -2859,10 +3469,10 @@ function DeviceDetails({ device, isAdmin, onRequestLogin }) {
{/* Error Logs section */} -
+
-

- Error Logs +

+ ⚠️ Error Logs

{errors !== null && errors.length > 0 && ( ({ log_type: "text", data_unit: "", description: "", + append_unmatched_to_last: false, }); const FIELD_LABEL = { @@ -3385,6 +3996,28 @@ function LogEntryEditor({ entry, conn, index, onChange, onRemove, onDuplicate }) style={{ ...inputStyle, fontSize: 11, padding: "7px 12px", background: "rgba(255,255,255,0.02)" }} />
+ {/* ── Append unmatched lines (text logs only) ── */} + {entry.log_type === "text" && } + {/* ── Terminal Panel ── */}
{/* Tab bar */} @@ -3561,28 +4194,121 @@ function LogEntryEditor({ entry, conn, index, onChange, onRemove, onDuplicate }) } // ── CONFIG BUILDER WIZARD ────────────────────────────────────────────────────── -function ConfigBuilderModal({ open, onClose, onSave }) { +// ── CONFIG → BUILDER STATE ─────────────────────────────────────────────────── +// Converts a raw device.config object (as returned by the API) into the +// conn / entries / packetCapture shapes that ConfigBuilderModal uses. +// Called when the user clicks "Edit Config" on an existing device. +function configToBuilderState(config) { + // ── connection fields ──────────────────────────────────────────────────── + // Flatten the nested gateway chain back into the flat gateways[] array + // that the builder uses internally. + const flatGateways = []; + let hop = config.gateway; + while (hop) { + flatGateways.push({ + _id: Math.random().toString(36).slice(2), + ip_address: hop.ip_address || "", + port: hop.port ?? 22, + user: hop.user || "", + password: hop.password || "", + ssh_key_string: hop.ssh_key_string || "", + authMode: hop.ssh_key_string ? "key" : "password", + }); + hop = hop.gateway; + } + + const conn = { + device_name: config.device_name || "", + ip_address: config.ip_address || "", + port: config.port ?? 22, + user: config.user || "pi", + password: config.password || "", + ssh_key_string: config.ssh_key_string || "", + authMode: config.ssh_key_string ? "key" : "password", + collection_interval: config.collection_interval ?? 30, + gateways: flatGateways, + }; + + // ── log entries ────────────────────────────────────────────────────────── + const entries = (config.log_file_configs || []).map(e => ({ + _id: Math.random().toString(36).slice(2), + log_name: e.log_name || "", + log_file_cmd: e.log_file_cmd || "", + data_extraction_regex: e.data_extraction_regex || "", + log_activation_cmd: e.log_activation_cmd || "", + log_deactivation_cmd: e.log_deactivation_cmd || "", + custom_shell_prompt: e.custom_shell_prompt || "", + log_type: e.log_type || "text", + data_unit: e.data_unit || "", + description: e.description || "", + append_unmatched_to_last: e.append_unmatched_to_last ?? false, + })); + + // ── packet capture ─────────────────────────────────────────────────────── + const pcc = config.packets_capture_config; + const packetCapture = pcc + ? { + enabled: true, + capture_start_cmd: pcc.capture_start_cmd || "tcpdump -i any", + capture_stop_cmd: pcc.capture_stop_cmd || "pkill -INT -f 'tcpdump -i any'", + capture_description: pcc.capture_description || "Network packet capture", + max_pcap_size_mb: pcc.max_pcap_size_mb != null ? String(pcc.max_pcap_size_mb) : "", + decoder_cmd: pcc.decoder_cmd || "", + } + : { + enabled: false, + capture_start_cmd: "tcpdump -i any", + capture_stop_cmd: "pkill -INT -f 'tcpdump -i any'", + capture_description: "Network packet capture", + max_pcap_size_mb: "", + decoder_cmd: "", + }; + + return { conn, entries, packetCapture }; +} + +function ConfigBuilderModal({ open, onClose, onSave, initialDevice }) { const [step, setStep] = useState(1); // 1=connection, 2=log entries const EMPTY_CONN = () => ({ device_name: "", ip_address: "", port: 22, user: "pi", password: "", ssh_key_string: "", authMode: "password", collection_interval: 30, gateways: [], }); - const [conn, setConn] = useState(EMPTY_CONN); - const [entries, setEntries] = useState([EMPTY_LOG_ENTRY()]); - const [connStatus, setConnStatus] = useState(null); // null | "testing" | {success, message} - const [saving, setSaving] = useState(false); - const EMPTY_PACKET_CAPTURE = () => ({ enabled: false, capture_start_cmd: "tcpdump -i any", capture_stop_cmd: "pkill -INT -f 'tcpdump -i any'", capture_description: "Network packet capture", max_pcap_size_mb: "", + decoder_cmd: "", }); + + const [conn, setConn] = useState(EMPTY_CONN); + const [entries, setEntries] = useState([EMPTY_LOG_ENTRY()]); + const [connStatus, setConnStatus] = useState(null); // null | "testing" | {success, message} + const [saving, setSaving] = useState(false); const [packetCapture, setPacketCapture] = useState(EMPTY_PACKET_CAPTURE); const setPC = (k, v) => setPacketCapture(prev => ({ ...prev, [k]: v })); + // When the modal opens for editing an existing device, pre-populate all + // fields from the device's current config. When it opens for a new device + // (initialDevice is null/undefined) reset everything to blank. + useEffect(() => { + if (!open) return; + if (initialDevice?.config) { + const { conn: c, entries: e, packetCapture: pc } = configToBuilderState(initialDevice.config); + setConn(c); + setEntries(e.length > 0 ? e : [EMPTY_LOG_ENTRY()]); + setPacketCapture(pc); + } else { + setConn(EMPTY_CONN()); + setEntries([EMPTY_LOG_ENTRY()]); + setPacketCapture(EMPTY_PACKET_CAPTURE()); + } + setStep(1); + setConnStatus(null); + }, [open, initialDevice]); + const setC = (k, v) => setConn(prev => ({ ...prev, [k]: v })); const testConnection = async () => { @@ -3623,9 +4349,10 @@ function ConfigBuilderModal({ open, onClose, onSave }) { const log_file_configs = entries.map(({ _id, ...rest }) => { // Drop optional keys that were left empty so they don't appear in the config const entry = { ...rest }; - if (!entry.log_activation_cmd) delete entry.log_activation_cmd; - if (!entry.log_deactivation_cmd) delete entry.log_deactivation_cmd; - if (!entry.custom_shell_prompt) delete entry.custom_shell_prompt; + if (!entry.log_activation_cmd) delete entry.log_activation_cmd; + if (!entry.log_deactivation_cmd) delete entry.log_deactivation_cmd; + if (!entry.custom_shell_prompt) delete entry.custom_shell_prompt; + if (!entry.append_unmatched_to_last) delete entry.append_unmatched_to_last; return entry; }); const config = { @@ -3672,6 +4399,10 @@ function ConfigBuilderModal({ open, onClose, onSave }) { if (packetCapture.max_pcap_size_mb !== "" && packetCapture.max_pcap_size_mb != null) { pcc.max_pcap_size_mb = Number(packetCapture.max_pcap_size_mb); } + // Omit when blank so the backend falls back to the default tshark binary. + if (packetCapture.decoder_cmd && packetCapture.decoder_cmd.trim()) { + pcc.decoder_cmd = packetCapture.decoder_cmd.trim(); + } config.packets_capture_config = pcc; } @@ -3682,15 +4413,16 @@ function ConfigBuilderModal({ open, onClose, onSave }) { setSaving(true); try { const config = buildConfig(); - const b64 = btoa(JSON.stringify(config, null, 2)); - await onSave(`data:application/json;base64,${b64}`); + const b64 = btoa(unescape(encodeURIComponent(JSON.stringify(config, null, 2)))); + const contents = `data:application/json;base64,${b64}`; + if (initialDevice) { + // Edit mode: update the existing device in-place via PUT + await onSave(contents, initialDevice.id); + } else { + // Create mode: add a new device via POST + await onSave(contents, null); + } onClose(); - // Reset - setStep(1); - setConn(EMPTY_CONN()); - setEntries([EMPTY_LOG_ENTRY()]); - setPacketCapture(EMPTY_PACKET_CAPTURE()); - setConnStatus(null); } finally { setSaving(false); } @@ -3742,8 +4474,12 @@ function ConfigBuilderModal({ open, onClose, onSave }) { 🛠
-
Device Config Builder
-
Build and test your configuration interactively
+
+ {initialDevice ? `Edit Config — ${initialDevice.name}` : "Device Config Builder"} +
+
+ {initialDevice ? "Modify the device configuration. Changes take effect on the next collection cycle." : "Build and test your configuration interactively"} +
+
+
Custom PCAP Decoder Command
+ setPC("decoder_cmd", e.target.value)} + placeholder="Default: tshark" + style={{ ...inputStyle, fontFamily: "var(--font-mono)" }} + /> +
+ Optional. Override the local binary used to decode pcap files. The command must accept the pcap file path as its last argument and write tab-separated lines in tshark field order to stdout. Leave blank to use the default tshark. +
+
)}
@@ -4163,7 +4911,7 @@ function ConfigBuilderModal({ open, onClose, onSave }) { <> setStep(1)}>← Back - {saving ? "Saving…" : "💾 Save Device"} + {saving ? "Saving…" : initialDevice ? "💾 Save Changes" : "💾 Save Device"} )} @@ -4658,9 +5406,10 @@ export default function App() { const [systemStats, setSystemStats] = useState(null); const [selectedDevices, setSelectedDevices] = useState([]); // Device groups: { id, name, deviceIds[] } - const [groups, setGroups] = useState(() => { - try { return JSON.parse(localStorage.getItem("lo_device_groups") || "[]"); } catch { return []; } - }); + // Loaded from the server so all users share the same grouping configuration. + const [groups, setGroups] = useState([]); + // Collapsed state is intentionally kept local — it is a UI preference, not + // shared data, so each user can expand/collapse independently. const [collapsedGroups, setCollapsedGroups] = useState(() => { try { return new Set(JSON.parse(localStorage.getItem("lo_collapsed_groups") || "[]")); } catch { return new Set(); } }); @@ -4676,10 +5425,17 @@ export default function App() { }); const [snapsLoading, setSnapsLoading] = useState(true); const [selectedSnaps, setSelectedSnaps] = useState([]); - const [isChart, setIsChart] = useState(false); - const [searchParam, setSearchParam] = useState(""); - const [searchValue, setSearchValue] = useState(""); - const [filterActive, setFilterActive] = useState(false); + // Initialise filter state directly from the URL so the correct values are + // available before the first render and before any fetch fires. This + // eliminates all races: no effect needs to read the URL and set state after + // mount, so there is no window where the wrong (empty) filter is visible. + const [isChart, setIsChart] = useState(() => new URLSearchParams(window.location.search).get("log_type") === "chart"); + const [searchParam, setSearchParam] = useState(() => new URLSearchParams(window.location.search).get("search_param") || ""); + const [searchValue, setSearchValue] = useState(() => new URLSearchParams(window.location.search).get("search_value") || ""); + const [filterActive, setFilterActive] = useState(() => { + const p = new URLSearchParams(window.location.search); + return !!(p.get("search_param") || p.get("search_value")); + }); // stop-collection loading overlay const [stoppingCollection, setStoppingCollection] = useState(false); @@ -4693,6 +5449,8 @@ export default function App() { const [viewingSnaps, setViewingSnaps] = useState([]); // snapshots currently open in the log modal const [logLoadProgress, setLogLoadProgress] = useState({ done: 0, total: 0 }); const [colorMode, setColorMode] = useState(false); + // Per-device regex filter: Map + const [deviceRegexFilters, setDeviceRegexFilters] = useState({}); // packet_capture "view packet details" modal const [packetModal, setPacketModal] = useState(false); @@ -4703,6 +5461,7 @@ export default function App() { const [sessionModal, setSessionModal] = useState(null); const [apiModal, setApiModal] = useState(false); const [builderModal, setBuilderModal] = useState(false); + const [editBuilderDevice, setEditBuilderDevice] = useState(null); // Device being edited, or null for new const [loginModal, setLoginModal] = useState(false); const [settingsModal, setSettingsModal] = useState(false); const [scenarioModal, setScenarioModal] = useState(false); @@ -4716,6 +5475,12 @@ export default function App() { const [confirmRemoveSnaps, setConfirmRemoveSnaps] = useState(false); const [removingSnaps, setRemovingSnaps] = useState(false); + // share-link feature: ref to Monaco's getCurrentLine helper, and the + // highlighted line number injected when opening via a shared URL + const monacoEditorApiRef = useRef(null); // { getCurrentLine: () => number } + const [shareLinkCopied, setShareLinkCopied] = useState(false); + const [highlightLine, setHighlightLine] = useState(null); // line number to highlight on open + // toasts const [toasts, setToasts] = useState([]); const addToast = useCallback((message, type = "error") => setToasts((prev) => [...prev, { id: Date.now(), message, type }]), []); @@ -4759,8 +5524,33 @@ export default function App() { } }, [addToast, pageSize]); + // Load groups from the server once on mount so all users share the same + // device-group configuration. useEffect(() => { - localStorage.setItem("lo_device_groups", JSON.stringify(groups)); + apiFetch("/api/settings/device-groups") + .then((data) => { if (Array.isArray(data)) setGroups(data); }) + .catch(() => {}); // non-critical — fall back to empty groups + }, []); + + // Persist the full groups array to the server whenever it changes. + // We skip the first render (empty initial state) by checking length, but + // still save when the user explicitly empties all groups. + const groupsRef = useRef(null); + useEffect(() => { + // Don't save the uninitialised empty array that exists before the server + // fetch completes — only save after the first real server response has + // set groupsRef.current to a non-null value. + if (groupsRef.current === null) { + // Mark that we have now received the server state; subsequent changes + // (including user-driven deletions down to []) will be persisted. + groupsRef.current = groups; + return; + } + groupsRef.current = groups; + apiFetch("/api/settings/device-groups", { + method: "PUT", + body: JSON.stringify({ groups }), + }).catch(() => {}); // best-effort }, [groups]); useEffect(() => { @@ -4851,7 +5641,14 @@ export default function App() { ); useEffect(() => { fetchDevices(); }, [fetchDevices]); - useEffect(() => { fetchSnapshots("", "", false); }, [fetchSnapshots]); + + // On mount, filter state is already correct (initialised from URL params via + // lazy useState), so we just fire the fetch directly with those values. + // No URL-reading or setState needed here — that's what prevents the flicker. + useEffect(() => { + fetchSnapshots(filterActive ? searchParam : "", filterActive ? searchValue : "", isChart); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + useEffect(() => { fetchSystemStats(); }, [fetchSystemStats]); useEffect(() => { @@ -4864,23 +5661,92 @@ export default function App() { return () => clearInterval(id); }, [fetchSystemStats]); - // FIX: this effect previously had no dependency array, causing it to run - // after every render and rely on a manual ref comparison to detect changes. - // Using [isChart] as the dependency array is correct and idiomatic. + // Re-fetch when the user manually toggles text/chart. + // isChart is stable on mount (set from URL before first render) so this + // effect only fires on genuine user-driven toggles, never on initial load. + const isMountedRef = useRef(false); useEffect(() => { + if (!isMountedRef.current) { isMountedRef.current = true; return; } fetchSnapshots(filterActive ? searchParam : "", filterActive ? searchValue : "", isChart); }, [isChart]); // eslint-disable-line react-hooks/exhaustive-deps + // Auto-open a snapshot view (and optionally jump to a line) when the URL + // contains ?open_snaps=&log_type=chart|text, with optional + // &line= (text, single snapshot) and &log_filters= (text, the + // per-device regex filters that were active when the link was shared). + // The legacy singular ?open_snap= is still accepted for old links. + // These params are written by the share-link feature; clean them from the + // URL after consuming them so refreshing doesn't re-trigger the open. + const autoOpenHandledRef = useRef(false); useEffect(() => { + if (autoOpenHandledRef.current || snapsLoading) return; const p = new URLSearchParams(window.location.search); - const sp = p.get("search_param") || ""; - const sv = p.get("search_value") || ""; - const lt = p.get("log_type") === "chart"; - if (sp || sv) { - setSearchParam(sp); setSearchValue(sv); setIsChart(lt); setFilterActive(true); - fetchSnapshots(sp, sv, lt); - } - }, []); // eslint-disable-line react-hooks/exhaustive-deps + const snapsParam = p.get("open_snaps") || p.get("open_snap"); + if (!snapsParam) return; + autoOpenHandledRef.current = true; + + const snapIds = [...new Set(snapsParam.split(",").map((s) => s.trim()).filter(Boolean))]; + const lineParam = p.get("line"); + const filtersParam = p.get("log_filters"); + + // Remove share-link params from the URL without a page reload + p.delete("open_snap"); + p.delete("open_snaps"); + p.delete("line"); + p.delete("log_filters"); + const newSearch = p.toString(); + window.history.replaceState(null, "", newSearch ? `?${newSearch}` : window.location.pathname); + + // Find the snapshots in the current page; if any are missing, fetch the + // full list for the relevant log type to locate them (they may be on a + // different page, or excluded by the current list filter). + const findAndOpen = async () => { + let targets = snapshots.filter((s) => snapIds.includes(s.id)); + if (targets.length < snapIds.length) { + try { + const logType = isChart ? "chart" : "text"; + const data = await apiFetch(`/api/snapshots?log_type=${logType}&page_size=9999`); + const all = data.items ?? []; + targets = snapIds.map((id) => all.find((s) => s.id === id)).filter(Boolean); + } catch { /* ignore */ } + } + + if (targets.length === 0) { + addToast("Shared snapshot(s) not found.", "error"); + return; + } + if (targets.length < snapIds.length) { + addToast(`Only found ${targets.length} of ${snapIds.length} shared snapshots.`, "error"); + } + + // Set highlight line before opening so it is available when Monaco + // mounts. Safe even for multi-snapshot merged views: rows are always + // sorted by timestamp with a stable sort, and `targets` preserves the + // same snapshot order the link was shared with, so the merged row + // order — and therefore the line number — reproduces identically as + // long as the underlying snapshot content hasn't changed. + if (lineParam) { + const ln = parseInt(lineParam, 10); + if (ln > 0) setHighlightLine(ln); + } + + // Parse the shared per-device regex filters (if any) and hand them to + // openLogContent directly so they land in the very first render of + // the modal's content, rather than one render later — see the note + // on openLogContent for why that ordering matters for share links. + let initialFilters; + if (filtersParam) { + try { + const parsed = JSON.parse(filtersParam); + if (parsed && typeof parsed === "object") initialFilters = parsed; + } catch { /* ignore malformed filter param */ } + } + + await openLogContent(targets, { initialFilters }); + }; + + findAndOpen(); + }, [snapsLoading]); // eslint-disable-line react-hooks/exhaustive-deps // ── handlers ─────────────────────────────────────────────────────────────── const uploadOne = async (contents) => { @@ -4891,7 +5757,25 @@ export default function App() { setDevices((prev) => [...prev, device]); }; - const handleUpload = async (contents) => { + // handleUpload is called by ConfigBuilderModal.onSave with (contents, deviceId). + // When deviceId is set it is an edit (PUT); otherwise it is a new device (POST). + const handleUpload = async (contents, deviceId = null) => { + if (deviceId) { + // ── Edit existing device ──────────────────────────────────────────── + try { + const { device } = await apiFetch(`/api/devices/${encodeURIComponent(deviceId)}`, { + method: "PUT", + body: JSON.stringify({ contents }), + }); + setDevices(prev => prev.map(d => d.id === deviceId ? device : d)); + addToast(`Device "${device.name}" updated successfully.`, "success"); + } catch (e) { + addToast(e.status === 422 ? "Invalid configuration — could not update device." : `Update failed: ${e.message}`); + } + return; + } + + // ── Add new device ──────────────────────────────────────────────────── // Single file: keep the original behaviour/messages unchanged. if (!Array.isArray(contents)) { try { @@ -5080,14 +5964,22 @@ export default function App() { * For chart mode: fetches each snapshot separately and builds chartGroups * so each snapshot gets its own Plotly panel inside the modal. * For text mode: merges all rows as before. + * + * `initialFilters`, when provided (e.g. by a shared-link URL), is applied + * in the same pass as the reset instead of being set in a follow-up call + * after this function returns — setting it a render later would mean the + * editor first mounts with the unfiltered content, then has its entire + * buffer replaced once the filters land, which clears any decoration + * (such as the shared-line highlight) applied in between. */ - const openLogContent = async (snapsToView) => { + const openLogContent = async (snapsToView, { initialFilters } = {}) => { setLogModal(true); setLogRowsLoading(true); setLogRows([]); setChartGroups([]); setViewingSnaps(snapsToView); setLogLoadProgress({ done: 0, total: snapsToView.length }); + setDeviceRegexFilters(initialFilters || {}); try { let done = 0; @@ -5158,6 +6050,67 @@ export default function App() { } }; + // ── share-link helpers ───────────────────────────────────────────────────── + + /** + * Builds a shareable URL that reproduces the currently open log/chart + * view: every snapshot in `snapsToShare` (via ?open_snaps=id1,id2,...), + * the active log_type, and — for text logs — any per-device regex filters + * currently applied (?log_filters=). List-level filters + * (search_param/search_value) already live in the current URL and are + * carried over automatically since we start from the existing query + * string. + */ + const buildShareUrl = (snapsToShare, { line } = {}) => { + const p = new URLSearchParams(window.location.search); + p.set("open_snaps", snapsToShare.map((s) => s.id).join(",")); + p.delete("open_snap"); // legacy singular param, superseded by open_snaps + p.set("log_type", isChart ? "chart" : "text"); + + if (line) p.set("line", String(line)); + else p.delete("line"); + + const activeFilters = isChart + ? {} + : Object.fromEntries(Object.entries(deviceRegexFilters).filter(([, v]) => v && v.trim())); + if (Object.keys(activeFilters).length > 0) p.set("log_filters", JSON.stringify(activeFilters)); + else p.delete("log_filters"); + + return `${window.location.origin}${window.location.pathname}?${p.toString()}`; + }; + + const copyShareUrl = (url, successMessage) => { + navigator.clipboard.writeText(url) + .then(() => addToast(successMessage, "success")) + .catch(() => addToast("Could not copy to clipboard.", "error")); + }; + + /** + * Copies a shareable URL for the current cursor line in the Monaco viewer. + * Includes every snapshot currently open (not just the first) plus any + * active per-device filters, so the recipient sees the exact same merged, + * filtered view before landing on the highlighted line. + */ + const shareCurrentLine = () => { + if (viewingSnaps.length === 0) return; + const lineNumber = monacoEditorApiRef.current?.getCurrentLine?.() ?? 1; + const url = buildShareUrl(viewingSnaps, { line: lineNumber }); + setShareLinkCopied(true); + setTimeout(() => setShareLinkCopied(false), 2500); + copyShareUrl(url, `Link to line ${lineNumber} copied to clipboard.`); + }; + + /** + * Copies a shareable URL for a single chart snapshot — used by the + * per-chart "🔗 Share Chart" button inside a multi-chart view. + */ + const shareChart = (snapId) => { + const target = viewingSnaps.find((s) => s.id === snapId); + if (!target) return; + const url = buildShareUrl([target]); + copyShareUrl(url, "Chart link copied to clipboard."); + }; + const applyFilter = () => { setFilterActive(true); fetchSnapshots(searchParam, searchValue, isChart); @@ -5606,6 +6559,28 @@ ${rowsHtml} } }; + // Apply per-(device, logName) regex filters to the full log rows. + // Filter keys use the same FILTER_SEP-delimited format as LogFilterBar. + const filteredLogRows = useMemo(() => { + if (!logRows || logRows.length === 0) return logRows; + const hasFilter = Object.values(deviceRegexFilters).some(v => v && v.trim()); + if (!hasFilter) return logRows; + return logRows.filter(row => { + const deviceName = row.device_name ?? ""; + const logName = row.log_name ?? ""; + const key = `${deviceName}\x00${logName}`; + const pattern = deviceRegexFilters[key]; + if (!pattern || !pattern.trim()) return true; // no filter for this pair → keep row + try { + const re = new RegExp(pattern, "i"); + const line = `[${row.time ?? ""}] [${deviceName}] [${logName}] ${row.content ?? ""}`; + return re.test(line); + } catch { + return true; // invalid regex → keep row (safe fallback) + } + }); + }, [logRows, deviceRegexFilters]); + // Modal title with chart count info const logModalTitle = isChart && chartGroups.length > 0 ? `Chart Data — ${chartGroups.length} snapshot${chartGroups.length > 1 ? "s" : ""}` @@ -5934,11 +6909,12 @@ ${rowsHtml} {/* MODALS */} - {/* Config Builder Modal */} + {/* Config Builder Modal — used for both creating new devices and editing existing ones */} setBuilderModal(false)} + onClose={() => { setBuilderModal(false); setEditBuilderDevice(null); }} onSave={handleUpload} + initialDevice={editBuilderDevice} /> {/* Session scenario modal — shown when the user clicks ▶ Start Collection */} @@ -6041,19 +7017,29 @@ ${rowsHtml} setLogModal(false)} + onClose={() => { setLogModal(false); setHighlightLine(null); monacoEditorApiRef.current = null; setShareLinkCopied(false); }} title={logModalTitle} size="full" footer={ <> {!isChart && } + {!isChart && !logRowsLoading && filteredLogRows.length > 0 && ( + + {shareLinkCopied ? "✓ Copied!" : "🔗 Share"} + + )} {networkCaptureSnaps.map((s) => ( downloadRawPcap(s)}> ⬇ Raw PCAP{networkCaptureSnaps.length > 1 ? `: ${s.deviceName}` : ""} ))} - setLogModal(false)}>Close + { setLogModal(false); setHighlightLine(null); monacoEditorApiRef.current = null; setShareLinkCopied(false); }}>Close } > @@ -6061,12 +7047,24 @@ ${rowsHtml} ) : (
+ {!isChart && logRows.length > 0 && ( + + )} { monacoEditorApiRef.current = api; }} + highlightLine={highlightLine} />
)} @@ -6105,6 +7103,11 @@ ${rowsHtml} device={deviceModal} isAdmin={auth.isAdmin} onRequestLogin={() => setLoginModal(true)} + onEdit={(device) => { + setEditBuilderDevice(device); + setBuilderModal(true); + setDeviceModal(null); + }} /> )}
diff --git a/settings.json b/settings.json index e69de29..8266d0a 100644 --- a/settings.json +++ b/settings.json @@ -0,0 +1,3 @@ +{ + "device_groups": [] +} \ No newline at end of file