@@ -22,26 +22,63 @@ import { http, withCancel } from "react-invenio-forms";
2222import { DateTime } from "luxon" ;
2323import { i18next } from "@translations/invenio_jobs/i18next" ;
2424
25+ /**
26+ * TaskGroup component - displays logs for a single task group
27+ * @param {Object } taskGroup - Task group containing taskId, taskName, parentTaskId, and logs
28+ * @param {Object } levelClass - Mapping of log levels to CSS classes
29+ */
30+ const TaskGroup = ( { taskGroup, levelClass } ) => (
31+ < div className = { taskGroup . parentTaskId ? "subtask-container" : "" } >
32+ { taskGroup . logs . map ( ( log ) => (
33+ < div
34+ key = { `${ log . timestamp } -${ log . level } -${ log . message } ` }
35+ className = { `log-line ${ log . level . toLowerCase ( ) } ` }
36+ >
37+ < span className = "log-timestamp" > [{ log . formatted_timestamp } ]</ span > { " " }
38+ < span className = { levelClass [ log . level ] || "" } > { log . level } </ span > { " " }
39+ < span className = "log-message" > { log . message } </ span >
40+ </ div >
41+ ) ) }
42+ </ div >
43+ ) ;
44+
45+ TaskGroup . propTypes = {
46+ taskGroup : PropTypes . shape ( {
47+ taskId : PropTypes . string . isRequired ,
48+ parentTaskId : PropTypes . string ,
49+ logs : PropTypes . array . isRequired ,
50+ } ) . isRequired ,
51+ levelClass : PropTypes . object . isRequired ,
52+ } ;
53+
2554export class RunsLogs extends Component {
2655 constructor ( props ) {
2756 super ( props ) ;
2857
2958 const { logs, run, sort, warnings } = props ;
3059
60+ const formattedLogs = logs . map ( ( log ) => ( {
61+ ...log ,
62+ formatted_timestamp : DateTime . fromISO ( log . timestamp ) . toFormat (
63+ "yyyy-MM-dd HH:mm"
64+ ) ,
65+ } ) ) ;
66+
3167 this . state = {
3268 error : null ,
33- logs : logs . map ( ( log ) => ( {
34- ...log ,
35- formatted_timestamp : DateTime . fromISO ( log . timestamp ) . toFormat (
36- "yyyy-MM-dd HH:mm"
37- ) ,
38- } ) ) ,
69+ logs : formattedLogs ,
3970 run,
4071 sort,
4172 warnings : warnings || [ ] ,
4273 runDuration : this . getDurationInMinutes ( run . started_at , run . finished_at ) ,
4374 formatted_started_at : this . formatDatetime ( run . started_at ) ,
4475 } ;
76+
77+ // Cache for memoized log tree
78+ this . logTreeCache = {
79+ logs : null ,
80+ tree : null ,
81+ } ;
4582 }
4683
4784 componentDidMount ( ) {
@@ -60,6 +97,18 @@ export class RunsLogs extends Component {
6097 this . statusFetchCancel ?. cancel ( ) ;
6198 }
6299
100+ getLogTree ( ) {
101+ const { logs } = this . state ;
102+ // Return cached tree if logs haven't changed
103+ if ( this . logTreeCache . logs === logs ) {
104+ return this . logTreeCache . tree ;
105+ }
106+ // Rebuild tree and update cache
107+ const tree = this . buildLogTree ( logs ) ;
108+ this . logTreeCache = { logs, tree } ;
109+ return tree ;
110+ }
111+
63112 getDurationInMinutes ( startedAt , finishedAt ) {
64113 if ( ! startedAt ) return 0 ;
65114 const start = DateTime . fromISO ( startedAt ) ;
@@ -71,6 +120,32 @@ export class RunsLogs extends Component {
71120 return ts ? DateTime . fromISO ( ts ) . toFormat ( "yyyy-MM-dd HH:mm" ) : null ;
72121 }
73122
123+ buildLogTree = ( logs ) => {
124+ /**
125+ * Build flat task groups from log list.
126+ * Returns array of task groups, each with taskId, parentTaskId, and logs.
127+ * Root tasks have parentTaskId == null; subtasks have parentTaskId set.
128+ */
129+ const taskGroups = { } ;
130+
131+ logs . forEach ( ( log ) => {
132+ const context = log . context || { } ;
133+ const taskId = context . task_id || "unknown" ;
134+
135+ if ( ! taskGroups [ taskId ] ) {
136+ taskGroups [ taskId ] = {
137+ taskId,
138+ parentTaskId : context . parent_task_id || null ,
139+ logs : [ ] ,
140+ } ;
141+ }
142+
143+ taskGroups [ taskId ] . logs . push ( log ) ;
144+ } ) ;
145+
146+ return Object . values ( taskGroups ) ;
147+ } ;
148+
74149 fetchLogs = async ( runId , sort ) => {
75150 try {
76151 const searchAfterParams = ( sort || [ ] )
@@ -153,6 +228,7 @@ export class RunsLogs extends Component {
153228 formatted_started_at : formattedStartedAt ,
154229 warnings,
155230 } = this . state ;
231+ const logTree = this . getLogTree ( ) ;
156232 const levelClass = {
157233 DEBUG : "" ,
158234 INFO : "primary" ,
@@ -254,7 +330,7 @@ export class RunsLogs extends Component {
254330 </ List . Item >
255331 </ List >
256332 </ Grid . Column >
257- < Grid . Column className = "log-table" width = { 13 } >
333+ < Grid . Column className = "job- log-table" width = { 13 } >
258334 { /* Display error message for failed jobs */ }
259335 { ( run . status === "FAILED" ||
260336 run . status === "PARTIAL_SUCCESS" ) && (
@@ -278,19 +354,15 @@ export class RunsLogs extends Component {
278354 </ Message >
279355 ) }
280356 < Segment >
281- { logs . map ( ( log ) => (
282- < div
283- key = { `${ log . timestamp } -${ log . level } -${ log . message } ` }
284- className = { `log-line ${ log . level . toLowerCase ( ) } ` }
285- >
286- < span className = "log-timestamp" >
287- [{ log . formatted_timestamp } ]
288- </ span > { " " }
289- < span className = { levelClass [ log . level ] || "" } >
290- { log . level }
291- </ span > { " " }
292- < span className = "log-message" > { log . message } </ span >
293- </ div >
357+ { logTree . map ( ( taskGroup ) => (
358+ < React . Fragment key = { taskGroup . taskId } >
359+ { ! taskGroup . parentTaskId &&
360+ logTree . indexOf ( taskGroup ) > 0 && < Divider /> }
361+ < TaskGroup
362+ taskGroup = { taskGroup }
363+ levelClass = { levelClass }
364+ />
365+ </ React . Fragment >
294366 ) ) }
295367 </ Segment >
296368 </ Grid . Column >
0 commit comments