Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions client-v3/src/components/config/ConfigLogs.vue
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
/>
</BFormGroup>

<BFormGroup v-if="source === 'client'" label="User:" label-cols="auto" class="mb-0">
<BFormGroup label="User:" label-cols="auto" class="mb-0">
<BFormInput
v-model="usernameInput"
placeholder="Filter by username…"
Expand Down Expand Up @@ -182,8 +182,7 @@ async function fetchLogs(): Promise<void> {
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) {
Expand All @@ -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}`;
}

Expand Down
10 changes: 3 additions & 7 deletions client/src/vue_components/config/ConfigLogs.vue
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
/>
</b-form-group>

<b-form-group v-if="source === 'client'" class="mr-3 mb-2" label="User:" label-cols="auto">
<b-form-group class="mr-3 mb-2" label="User:" label-cols="auto">
<b-form-input
v-model="usernameInput"
placeholder="Filter by username…"
Expand Down Expand Up @@ -229,9 +229,7 @@ export default defineComponent({
limit: String(this.limit),
offset: '0',
});
if (this.source === 'client' && this.usernameInput) {
params.set('username', this.usernameInput);
}
if (this.usernameInput) params.set('username', this.usernameInput);
const response = await fetch(`${makeURL('/api/v1/logs/view')}?${params}`);
if (!response.ok) {
this.error = `Server returned ${response.status}`;
Expand All @@ -256,9 +254,7 @@ export default defineComponent({
level: this.levelFilter,
search: this.searchInput,
});
if (this.source === 'client' && this.usernameInput) {
params.set('username', this.usernameInput);
}
if (this.usernameInput) params.set('username', this.usernameInput);
return `${makeURL('/api/v1/logs/stream')}?${params}`;
},
async startStream(): Promise<void> {
Expand Down
20 changes: 20 additions & 0 deletions docs/pages/user_config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 4 additions & 5 deletions server/controllers/api/logs_viewer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
Expand All @@ -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
Expand All @@ -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
Expand Down
19 changes: 16 additions & 3 deletions server/controllers/ws_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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"""
Expand All @@ -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)
Expand All @@ -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"]
Expand Down
36 changes: 33 additions & 3 deletions server/digi_server/app_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
25 changes: 18 additions & 7 deletions server/test/controllers/api/test_logs_viewer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 16 additions & 2 deletions server/utils/web/base_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -180,14 +187,18 @@ 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)
try:
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 (
Expand All @@ -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()
Loading