Skip to content

Commit d9e3a3a

Browse files
committed
administration: add log view
* closes inveniosoftware#67
1 parent 2c3e622 commit d9e3a3a

File tree

20 files changed

+485
-99
lines changed

20 files changed

+485
-99
lines changed

invenio_jobs/administration/runs.py

+62-16
Original file line numberDiff line numberDiff line change
@@ -11,20 +11,66 @@
1111
from invenio_administration.views.base import AdminResourceListView
1212
from invenio_i18n import lazy_gettext as _
1313

14+
from invenio_jobs.administration.jobs import JobsAdminMixin
15+
from flask import g
16+
from invenio_jobs.proxies import current_app_log_service, current_runs_service
17+
from dateutil import parser
1418

15-
class RunsListView(AdminResourceListView):
16-
"""Configuration for System Runs sets list view."""
17-
18-
api_endpoint = "/runs"
19-
name = "Runs"
20-
search_request_headers = {"Accept": "application/vnd.inveniordm.v1+json"}
21-
title = "Runs"
22-
category = "System"
23-
resource_config = "jobs_resource"
24-
icon = "signal"
25-
extension_name = "invenio-rdm-records"
26-
display_search = False
27-
display_delete = False
28-
display_edit = False
29-
display_create = False
30-
actions = None
19+
class RunsDetailsView(JobsAdminMixin, AdminResourceListView):
20+
"""Configuration for System Runs details view."""
21+
22+
url = "/runs/<pid_value>"
23+
search_request_headers = {"Accept": "application/json"}
24+
request_headers = {"Accept": "application/json"}
25+
name = "run-details"
26+
resource_config = "runs_resource"
27+
title = "Run Details"
28+
disabled = lambda _: True
29+
30+
template = "invenio_jobs/system/runs/runs-details.html"
31+
32+
list_view_name = "jobs"
33+
pid_value = "<pid_value>"
34+
35+
def get_context(self, **kwargs):
36+
"""Compute admin view context."""
37+
pid_value = kwargs.get("pid_value", "")
38+
logs = self._get_logs(pid_value)
39+
job_id = logs[0]["resource"]["id"]
40+
run_dict = self._get_run_dict(job_id, pid_value)
41+
42+
ctx = super().get_context(**kwargs)
43+
ctx["logs"] = logs
44+
ctx["run"] = run_dict
45+
ctx["run_duration"] = self.get_duration_in_minutes(run_dict.get("started_at"), run_dict.get("finished_at"))
46+
return ctx
47+
48+
def _get_logs(self, pid_value):
49+
"""Retrieve and format logs."""
50+
params = dict(q=pid_value)
51+
logs_result = current_app_log_service.search(g.identity, params)
52+
logs = logs_result.to_dict()["hits"]["hits"]
53+
54+
for log in logs:
55+
log["formatted_timestamp"] = self._format_datetime(log["timestamp"])
56+
57+
return logs
58+
59+
def _get_run_dict(self, job_id, pid_value):
60+
"""Retrieve and format run dictionary."""
61+
run_dict = current_runs_service.read(g.identity, job_id, pid_value).to_dict()
62+
run_dict["formatted_started_at"] = self._format_datetime(run_dict["started_at"])
63+
return run_dict
64+
65+
def _format_datetime(self, timestamp):
66+
"""Format ISO datetime to a user-friendly string."""
67+
dt = parser.isoparse(timestamp)
68+
return dt.strftime("%Y-%m-%d %H:%M")
69+
70+
def get_duration_in_minutes(self, started_at, finished_at):
71+
"""Calculate duration in minutes."""
72+
start_time = parser.isoparse(started_at)
73+
end_time = parser.isoparse(finished_at)
74+
75+
duration = (end_time - start_time).total_seconds() / 60
76+
return int(duration)

invenio_jobs/assets/semantic-ui/js/invenio_jobs/administration/RunsSearchResultItemLayout.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ class SearchResultItemComponent extends Component {
6969
className="word-break-all"
7070
>
7171
<StatusFormatter status={status} />
72-
<a href={result.links.self}>{createdFormatted}</a>
72+
<a href={`/administration/runs/${result.id}`}>{createdFormatted}</a>
7373
</Table.Cell>
7474
<Table.Cell
7575
key={`run-last-run-${status}`}

invenio_jobs/ext.py

+1-2
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,6 @@
2727
TasksResourceConfig,
2828
)
2929
from .services import (
30-
AppLogService,
31-
AppLogServiceConfig,
3230
JobsService,
3331
JobsServiceConfig,
3432
RunsService,
@@ -37,6 +35,7 @@
3735
TasksServiceConfig,
3836
)
3937

38+
from invenio_jobs.logging.app_logs.services import AppLogService, AppLogServiceConfig
4039

4140
class InvenioJobs:
4241
"""Invenio-Jobs extension."""
+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# -*- coding: utf-8 -*-
2+
#
3+
# Copyright (C) 2025 CERN.
4+
#
5+
# Invenio is free software; you can redistribute it and/or modify it
6+
# under the terms of the MIT License; see LICENSE file for more details.
7+
8+
"""Loggigng module for app."""
9+
10+
from .backends import SearchAppLogBackend
11+
from .builders import AppLogBuilder
12+
13+
__all__ = (
14+
"SearchAppLogBackend",
15+
"AppLogBuilder",
16+
)
+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# -*- coding: utf-8 -*-
2+
#
3+
# Copyright (C) 2025 CERN.
4+
#
5+
# Invenio is free software; you can redistribute it and/or modify it
6+
# under the terms of the MIT License; see LICENSE file for more details.
7+
8+
"""Loggigng module for app."""
9+
10+
from invenio_logging.datastreams.backends import SearchBackend
11+
12+
13+
class SearchAppLogBackend(SearchBackend):
14+
"""Backend for storing app logs in datastreams."""
15+
16+
def __init__(self):
17+
"""Initialize backend for app logs."""
18+
super().__init__(log_type="app")
+40
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# -*- coding: utf-8 -*-
2+
#
3+
# Copyright (C) 2025 CERN.
4+
#
5+
# Invenio is free software; you can redistribute it and/or modify it
6+
# under the terms of the MIT License; see LICENSE file for more details.
7+
8+
"""Loggigng module for app."""
9+
10+
from invenio_logging.engine.builders import LogBuilder
11+
from invenio_logging.datastreams.schema import LogEventSchema
12+
from .backends import SearchAppLogBackend
13+
14+
15+
class AppLogBuilder(LogBuilder):
16+
"""Builder for structured app logs."""
17+
18+
type = "app"
19+
20+
backend_cls = SearchAppLogBackend
21+
22+
@classmethod
23+
def validate(cls, log_event):
24+
"""Validate the log event against the schema."""
25+
return LogEventSchema().load(log_event)
26+
27+
@classmethod
28+
def build(cls, log_event):
29+
"""Build an app log event context."""
30+
return cls.validate(log_event)
31+
32+
@classmethod
33+
def send(cls, log_event):
34+
"""Send log event using the backend."""
35+
cls.backend_cls().store(log_event)
36+
37+
@classmethod
38+
def search(cls, query):
39+
"""Search logs."""
40+
return cls.backend_cls().search(query)
+47
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# -*- coding: utf-8 -*-
2+
#
3+
# This file is part of Invenio.
4+
# Copyright (C) 2025 CERN.
5+
#
6+
# Invenio is free software; you can redistribute it and/or modify it
7+
# under the terms of the MIT License; see LICENSE file for more details.
8+
9+
"""Decorator for logging."""
10+
11+
from .log_event import LogEvent
12+
from invenio_logging.proxies import current_logging_manager
13+
14+
15+
def log_task():
16+
"""Decorate log task events.
17+
Useful for celery tasks that need to log events by passing down the log type and log data.
18+
"""
19+
20+
def decorator(func):
21+
def wrapper(*args, **kwargs):
22+
log_type = kwargs.get(
23+
"log_type", "TODO"
24+
) # Should we have a default log type?
25+
log_data = kwargs.get("log_data", {})
26+
27+
def _log_event(
28+
message=None, event=None, user=None, resource=None, extra=None
29+
):
30+
"""Log event."""
31+
log_data["log_type"] = log_type
32+
log_data["message"] = message if message else log_data.get("message")
33+
log_data["event"] = event if event else log_data.get("event")
34+
log_data["user"] = user if user else log_data.get("user")
35+
log_data["resource"] = (
36+
resource if resource else log_data.get("resource")
37+
)
38+
log_data["extra"] = extra if extra else log_data.get("extra")
39+
log_event = LogEvent(**log_data)
40+
current_logging_manager.log(log_event)
41+
42+
kwargs["_log_event"] = _log_event
43+
return func(*args, **kwargs)
44+
45+
return wrapper
46+
47+
return decorator
+53
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# -*- coding: utf-8 -*-
2+
#
3+
# Copyright (C) 2025 CERN.
4+
#
5+
# Invenio is free software; you can redistribute it and/or modify it
6+
# under the terms of the MIT License; see LICENSE file for more details.
7+
8+
"""Structured log event model."""
9+
10+
from invenio_logging.engine.log_event import BaseLogEvent
11+
12+
class LogEvent(BaseLogEvent):
13+
"""Class to represent a structured log event."""
14+
15+
def __init__(
16+
self,
17+
log_type="app",
18+
status="INFO",
19+
event={},
20+
resource={},
21+
user={},
22+
extra={},
23+
timestamp=None,
24+
message=None,
25+
):
26+
"""
27+
Create a LogEvent instance.
28+
29+
:param log_type: Type of log event.
30+
:param event: Dict with `action` (required) and optional `description`.
31+
:param resource: Dict with `type`, `id`, and optional `metadata`.
32+
:param user: Dict with `id`, `email`, and optional `roles` (default: empty).
33+
:param extra: Additional metadata dictionary (default: empty).
34+
:param timestamp: Optional timestamp (defaults to now).
35+
:param message: Optional human-readable message.
36+
"""
37+
super().__init__(log_type, timestamp, event, message)
38+
self.resource = resource
39+
self.user = user
40+
self.extra = extra
41+
self.status = status
42+
43+
def to_dict(self):
44+
"""Convert the log event to a dictionary matching the schema."""
45+
return {
46+
"timestamp": self.timestamp,
47+
"event": self.event,
48+
"message": self.message,
49+
"user": self.user,
50+
"resource": self.resource,
51+
"extra": self.extra,
52+
"status": self.status,
53+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# -*- coding: utf-8 -*-
2+
#
3+
# Copyright (C) 2025 CERN.
4+
#
5+
# Invenio is free software; you can redistribute it and/or modify it
6+
# under the terms of the MIT License; see LICENSE file for more details.
7+
8+
"""Loggigng module for app."""
9+
10+
from .service import AppLogService
11+
from .config import AppLogServiceConfig
12+
13+
__all__ = (
14+
"AppLogService",
15+
"AppLogServiceConfig",
16+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# -*- coding: utf-8 -*-
2+
#
3+
# Copyright (C) 2024 CERN.
4+
# Copyright (C) 2024 University of Münster.
5+
#
6+
# Invenio-Jobs is free software; you can redistribute it and/or modify it
7+
# under the terms of the MIT License; see LICENSE file for more details.
8+
9+
"""Services config."""
10+
11+
from functools import partial
12+
13+
from invenio_i18n import gettext as _
14+
from invenio_logging.datastreams.schema import LogEventSchema
15+
from invenio_records_resources.services.base import ServiceConfig
16+
from invenio_records_resources.services.base.config import ConfiguratorMixin, FromConfig
17+
from invenio_records_resources.services.records.config import (
18+
SearchOptions as SearchOptionsBase,
19+
)
20+
from sqlalchemy import asc, desc
21+
22+
from . import results
23+
from invenio_jobs.services.results import Item
24+
25+
from .permissions import (
26+
AppLogsPermissionPolicy,
27+
)
28+
29+
30+
class AppLogSearchOptions(SearchOptionsBase):
31+
"""App log search options."""
32+
33+
sort_default = "timestamp"
34+
sort_direction_default = "desc"
35+
sort_direction_options = {
36+
"asc": dict(title=_("Ascending"), fn=asc),
37+
"desc": dict(title=_("Descending"), fn=desc),
38+
}
39+
sort_options = {
40+
"timestamp": dict(title=_("Timestamp"), fields=["@timestamp"]),
41+
}
42+
43+
pagination_options = {"default_results_per_page": 25}
44+
45+
46+
class AppLogServiceConfig(ServiceConfig, ConfiguratorMixin):
47+
"""App log service configuration."""
48+
49+
service_id = "app-logs"
50+
permission_policy_cls = FromConfig(
51+
"APP_LOGS_PERMISSION_POLICY",
52+
default=AppLogsPermissionPolicy,
53+
)
54+
search = AppLogSearchOptions
55+
schema = LogEventSchema
56+
components = []
57+
links_item = None
58+
result_item_cls = Item
59+
result_list_cls = results.AppLogsList
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# -*- coding: utf-8 -*-
2+
#
3+
# Copyright (C) 2025 CERN.
4+
#
5+
# Invenio-Jobs is free software; you can redistribute it and/or modify it
6+
# under the terms of the MIT License; see LICENSE file for more details.
7+
8+
"""Service permissions."""
9+
10+
from invenio_administration.generators import Administration
11+
from invenio_records_permissions.generators import Disable, SystemProcess
12+
from invenio_records_permissions.policies import BasePermissionPolicy
13+
14+
class AppLogsPermissionPolicy(BasePermissionPolicy):
15+
"""Access control configuration for app logs."""
16+
17+
can_search = [Administration(), SystemProcess()]
18+
can_create = [Disable()]
19+
can_read = [Disable()]
20+
can_update = [Disable()]
21+
can_delete = [Disable()]

0 commit comments

Comments
 (0)