diff --git a/client-v3/src/components/config/ConfigLogs.vue b/client-v3/src/components/config/ConfigLogs.vue index 40d46355..182d26ae 100644 --- a/client-v3/src/components/config/ConfigLogs.vue +++ b/client-v3/src/components/config/ConfigLogs.vue @@ -26,7 +26,7 @@ /> - + { limit: String(limit.value), offset: '0', }); - if (source.value === 'client' && usernameInput.value) - params.set('username', usernameInput.value); + if (usernameInput.value) params.set('username', usernameInput.value); const response = await fetch(`${makeURL('/api/v1/logs/view')}?${params}`); if (!response.ok) { @@ -209,7 +208,7 @@ function buildStreamUrl(): string { level: levelFilter.value, search: searchInput.value, }); - if (source.value === 'client' && usernameInput.value) params.set('username', usernameInput.value); + if (usernameInput.value) params.set('username', usernameInput.value); return `${makeURL('/api/v1/logs/stream')}?${params}`; } diff --git a/client/src/vue_components/config/ConfigLogs.vue b/client/src/vue_components/config/ConfigLogs.vue index 6c8189b8..8fc5370a 100644 --- a/client/src/vue_components/config/ConfigLogs.vue +++ b/client/src/vue_components/config/ConfigLogs.vue @@ -31,7 +31,7 @@ /> - + { diff --git a/docs/pages/user_config.md b/docs/pages/user_config.md index 32b23a5b..6355ca3b 100644 --- a/docs/pages/user_config.md +++ b/docs/pages/user_config.md @@ -68,6 +68,26 @@ Once users have been created, their permissions can be configured by clicking th RBAC configuration determines what shows a user can access and what actions they can perform within those shows. +### Log Viewer + +The **Logs** tab (admin only) provides a real-time view of server and client log entries stored in the in-memory log buffer. + +#### Sources + +- **Server** — Application-level logs from the DigiScript backend: HTTP request access logs, request body debug logs, WebSocket connection events, and general application messages. +- **Client** — Logs forwarded from connected browser clients via the `/api/v1/logs/batch` endpoint. + +#### Username Attribution + +All log entries are attributed to the logged-in user where one is present: + +- **Server logs**: Each HTTP access log line and request-body debug line includes `[username]` in the message (e.g. `200 GET /api/v1/user/settings (127.0.0.1) [alice] 5.23ms`). WebSocket messages and close events also include the username once the connection has been authenticated. +- **Client logs**: The server extracts the username from the JWT token on each batch submission, so all client log entries are attributed even if the client sends no user information itself. + +#### Filtering + +Use the **Username** filter field to show only entries from a specific user. This filter applies to both Server and Client sources. Combined with the **Level** and **Search** filters, you can quickly isolate activity from a particular user across the full log stream. + ### Backup Management The **Backups** tab allows admin users to view and manage database backup files. DigiScript automatically creates a timestamped copy of the database file before running any database migration, ensuring you can recover data if a migration causes issues. diff --git a/server/controllers/api/logs_viewer.py b/server/controllers/api/logs_viewer.py index dcd63e0a..e1da0584 100644 --- a/server/controllers/api/logs_viewer.py +++ b/server/controllers/api/logs_viewer.py @@ -59,8 +59,7 @@ def _filter_entries( :param level_name: Uppercase level name (e.g. ``"ERROR"``); empty means no level filter. :param search: Lowercase search string; empty means no search filter. - :param username_filter: Lowercase username substring; only applied when - *source* is ``"client"``; empty means no filter. + :param username_filter: Lowercase username substring; empty means no filter. :param source: ``"server"`` or ``"client"``. :returns: Filtered list of entry dicts. """ @@ -72,7 +71,7 @@ def _filter_entries( if search: entries = [e for e in entries if search in e["message"].lower()] - if username_filter and source == "client": + if username_filter: entries = [ e for e in entries @@ -97,8 +96,8 @@ class LogViewerController(BaseAPIController): search : str Case-insensitive substring match on the ``message`` field. username : str - (Client source only) case-insensitive substring match on the - ``username`` field. + Case-insensitive substring match on the ``username`` field. + Applies to both client and server sources. limit : int Maximum number of entries to return (capped at 1000, default 500). offset : int diff --git a/server/controllers/ws_controller.py b/server/controllers/ws_controller.py index d202f753..ab57bac8 100644 --- a/server/controllers/ws_controller.py +++ b/server/controllers/ws_controller.py @@ -29,6 +29,7 @@ def __init__(self, application, request, **kwargs): super().__init__(application, request, **kwargs) self.application: DigiScriptServer = application self.current_user_id = None + self.current_username: str | None = None self._last_ping = 0.0 self._last_pong = 0.0 @@ -158,7 +159,12 @@ def on_close(self) -> None: } ) - get_logger().info(f"WebSocket closed from: {self.request.remote_ip}") + user_part = ( + f"{self.current_username} ({self.request.remote_ip})" + if self.current_username + else self.request.remote_ip + ) + get_logger().info(f"WebSocket closed from: {user_part}") async def authenticate_with_token(self, token): """Authenticate using JWT token""" @@ -184,6 +190,10 @@ async def authenticate_with_token(self, token): # Update the user ID for this connection self.current_user_id = user.id + self.current_username = user.username + get_logger().info( + f"WebSocket authenticated: {user.username} from {self.request.remote_ip}" + ) # Update the session with the user ID self.update_session(user_id=user.id) @@ -198,9 +208,12 @@ async def authenticate_with_token(self, token): return True async def on_message(self, message: Union[str, bytes]): - get_logger().debug( - f"WebSocket received data from {self.request.remote_ip}: {message}" + user_part = ( + f"{self.current_username} ({self.request.remote_ip})" + if self.current_username + else self.request.remote_ip ) + get_logger().debug(f"WebSocket message from {user_part}: {message}") message = json.loads(message) ws_op = message["OP"] diff --git a/server/digi_server/app_server.py b/server/digi_server/app_server.py index a63d407e..f118fe2a 100644 --- a/server/digi_server/app_server.py +++ b/server/digi_server/app_server.py @@ -290,10 +290,40 @@ class AlembicVersion(self._db.Model): ) def log_request(self, handler): - ignored_routes = Route.ignored_logging_routes() - if handler.request.path in ignored_routes: + from tornado.log import access_log # noqa: PLC0415 + + if handler.request.path in Route.ignored_logging_routes(): return - super().log_request(handler) + + username = None + user_id = None + if handler.current_user: + username = handler.current_user.get("username") + user_id = handler.current_user.get("id") + + if handler.get_status() < 400: + log_method = access_log.info + elif handler.get_status() < 500: + log_method = access_log.warning + else: + log_method = access_log.error + + request_time = 1000.0 * handler.request.request_time() + request_summary = f"{handler.request.method} {handler.request.uri} ({handler.request.remote_ip})" + user_suffix = f" [{username}]" if username else "" + + log_method( + "%d %s%s %.2fms", + handler.get_status(), + request_summary, + user_suffix, + request_time, + extra={ + "username": username, + "user_id": user_id, + "remote_ip": handler.request.remote_ip, + }, + ) @property def _alembic_config(self): diff --git a/server/test/controllers/api/test_logs_viewer.py b/server/test/controllers/api/test_logs_viewer.py index 05586ffb..974aa6f8 100644 --- a/server/test/controllers/api/test_logs_viewer.py +++ b/server/test/controllers/api/test_logs_viewer.py @@ -238,18 +238,29 @@ def test_username_filter_client_source(self): self.assertIn("alice log", messages) self.assertNotIn("bob log", messages) - def test_username_filter_ignored_for_server_source(self): - """username param should have no effect on the server source.""" - self._inject_server_entry("server_entry_for_username_test") + def test_username_filter_applies_to_server_source(self): + """username filter should work for server source, matching the client behaviour.""" + get_server_buffer().emit(_make_record("alice server log", username="alice")) + get_server_buffer().emit(_make_record("bob server log", username="bob")) token = self._create_and_login_admin() - resp_no_filter = escape.json_decode( - self._fetch_view(token=token, source="server").body + resp = escape.json_decode( + self._fetch_view(token=token, source="server", username="alice").body ) - resp_with_filter = escape.json_decode( + messages = [e["message"] for e in resp["entries"]] + self.assertIn("alice server log", messages) + self.assertNotIn("bob server log", messages) + + def test_username_filter_excludes_entries_without_username(self): + """Server entries that carry no username field are excluded by a username filter.""" + self._inject_server_entry("no_user_server_entry") + token = self._create_and_login_admin() + + resp = escape.json_decode( self._fetch_view(token=token, source="server", username="alice").body ) - self.assertEqual(resp_no_filter["total"], resp_with_filter["total"]) + messages = [e["message"] for e in resp["entries"]] + self.assertNotIn("no_user_server_entry", messages) # ------------------------------------------------------------------ # Pagination diff --git a/server/utils/web/base_controller.py b/server/utils/web/base_controller.py index 3ecd84f7..9f34cca2 100644 --- a/server/utils/web/base_controller.py +++ b/server/utils/web/base_controller.py @@ -171,6 +171,13 @@ def _unimplemented_method(self, *args: str, **kwargs: str) -> None: self.set_status(405) self.write({"message": "405 not allowed"}) + def _log_extra(self) -> dict: + extra: dict = {"remote_ip": self.request.remote_ip} + if self.current_user: + extra["username"] = self.current_user.get("username") + extra["user_id"] = self.current_user.get("id") + return extra + def on_finish(self): from utils.web.route import Route # noqa: PLC0415 @@ -180,6 +187,9 @@ def on_finish(self): log_method = get_logger().debug if self.request.body: + username = self.current_user.get("username") if self.current_user else None + user_suffix = f" [{username}]" if username else "" + method_name = self.request.method.lower() handler_method = getattr(self, method_name, None) redacted_data_paths = getattr(handler_method, "_redacted_data_paths", None) @@ -187,7 +197,8 @@ def on_finish(self): body = escape.json_decode(self.request.body) except BaseException: get_logger().debug( - f"{self.request.method} {self.request.path} {self.request.body}" + f"{self.request.method} {self.request.path} {self.request.body}{user_suffix}", + extra=self._log_extra(), ) else: if ( @@ -199,6 +210,9 @@ def on_finish(self): body = deepcopy(body) redacted_data_paths.apply(body) - log_method(f"{self.request.method} {self.request.path} {body}") + log_method( + f"{self.request.method} {self.request.path} {body}{user_suffix}", + extra=self._log_extra(), + ) super().on_finish()