001/* 002 * Copyright 2025 devteam@scivicslab.com 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, 011 * software distributed under the License is distributed on an 012 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, 013 * either express or implied. See the License for the 014 * specific language governing permissions and limitations 015 * under the License. 016 */ 017 018package com.scivicslab.actoriac.report.sections.basic; 019 020import com.scivicslab.actoriac.report.SectionBuilder; 021 022import java.sql.Connection; 023import java.sql.PreparedStatement; 024import java.sql.ResultSet; 025import java.sql.Timestamp; 026import java.time.format.DateTimeFormatter; 027import java.util.ArrayList; 028import java.util.List; 029import java.util.logging.Logger; 030 031/** 032 * POJO section builder that outputs workflow transition history. 033 * 034 * <p>Pure business logic - no {@code CallableByActionName}. 035 * Use {@link TransitionHistorySectionIIAR} to expose as an actor.</p> 036 * 037 * <p>Retrieves transition logs from the database and displays them 038 * in a human-readable format with success/failure status.</p> 039 * 040 * <h2>Output example:</h2> 041 * <pre> 042 * [Transition History: nodeGroup] 043 * o [2026-01-30 10:15:23] 0 -> 1 [Initialize] 044 * o [2026-01-30 10:15:24] 1 -> 2 [Collect data] 045 * x [2026-01-30 10:15:25] 2 -> 3 [Process] Connection refused 046 * 047 * Summary: 3 transitions, 2 succeeded, 1 failed 048 * </pre> 049 * 050 * @author devteam@scivicslab.com 051 * @since 2.16.0 052 */ 053public class TransitionHistorySection implements SectionBuilder { 054 055 private static final Logger logger = Logger.getLogger(TransitionHistorySection.class.getName()); 056 private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); 057 058 private Connection connection; 059 private long sessionId = -1; 060 private String targetActorName; 061 private boolean includeChildren = false; 062 063 /** 064 * Sets the database connection for log queries. 065 * 066 * @param connection the JDBC connection to the H2 log database 067 */ 068 public void setConnection(Connection connection) { 069 this.connection = connection; 070 } 071 072 /** 073 * Sets the session ID to query logs from. 074 * 075 * @param sessionId the session ID 076 */ 077 public void setSessionId(long sessionId) { 078 this.sessionId = sessionId; 079 } 080 081 /** 082 * Sets the target actor name to display transitions for. 083 * 084 * @param targetActorName the target actor name (e.g., "nodeGroup", "node-server1") 085 */ 086 public void setTargetActorName(String targetActorName) { 087 this.targetActorName = targetActorName; 088 } 089 090 /** 091 * Sets whether to include child nodes in the output. 092 * 093 * <p>When true and target is "nodeGroup", transitions from all 094 * child nodes are also included in the output.</p> 095 * 096 * @param includeChildren true to include children 097 */ 098 public void setIncludeChildren(boolean includeChildren) { 099 this.includeChildren = includeChildren; 100 } 101 102 @Override 103 public String generate() { 104 if (connection == null || sessionId < 0) { 105 logger.warning("TransitionHistorySection: connection or sessionId not set"); 106 return ""; 107 } 108 109 if (targetActorName == null || targetActorName.isEmpty()) { 110 targetActorName = "nodeGroup"; // Default target 111 } 112 113 try { 114 if (includeChildren && "nodeGroup".equals(targetActorName)) { 115 return buildAggregatedOutput(); 116 } else { 117 return buildSingleActorOutput(); 118 } 119 } catch (Exception e) { 120 logger.warning("TransitionHistorySection: error: " + e.getMessage()); 121 return ""; 122 } 123 } 124 125 /** 126 * Builds output for a single actor. 127 */ 128 private String buildSingleActorOutput() throws Exception { 129 List<TransitionEntry> entries = queryTransitions(targetActorName); 130 if (entries.isEmpty()) { 131 return ""; // No transitions, skip this section 132 } 133 134 StringBuilder sb = new StringBuilder(); 135 sb.append("[Transition History: ").append(targetActorName).append("]\n"); 136 137 int succeeded = 0; 138 int failed = 0; 139 140 for (TransitionEntry entry : entries) { 141 sb.append(entry.success ? "o " : "x "); 142 sb.append("[").append(entry.timestamp).append("] "); 143 sb.append(entry.label); 144 if (entry.note != null && !entry.note.isEmpty()) { 145 sb.append(" [").append(entry.note).append("]"); 146 } 147 if (!entry.success && entry.errorMessage != null && !entry.errorMessage.isEmpty()) { 148 sb.append(" ").append(entry.errorMessage); 149 } 150 sb.append("\n"); 151 152 if (entry.success) { 153 succeeded++; 154 } else { 155 failed++; 156 } 157 } 158 159 sb.append("\nSummary: ").append(entries.size()).append(" transitions, "); 160 sb.append(succeeded).append(" succeeded, ").append(failed).append(" failed\n"); 161 162 return sb.toString(); 163 } 164 165 /** 166 * Builds aggregated output for nodeGroup and all children. 167 */ 168 private String buildAggregatedOutput() throws Exception { 169 List<String> sources = queryDistinctSources(); 170 if (sources.isEmpty()) { 171 return ""; 172 } 173 174 StringBuilder sb = new StringBuilder(); 175 sb.append("[Transition History: nodeGroup (with children)]\n"); 176 177 int totalTransitions = 0; 178 int totalSucceeded = 0; 179 int totalFailed = 0; 180 181 for (String source : sources) { 182 List<TransitionEntry> entries = queryTransitions(source); 183 if (entries.isEmpty()) continue; 184 185 sb.append("\n [").append(source).append("]\n"); 186 187 for (TransitionEntry entry : entries) { 188 sb.append(" ").append(entry.success ? "o " : "x "); 189 sb.append("[").append(entry.timestamp).append("] "); 190 sb.append(entry.label); 191 if (entry.note != null && !entry.note.isEmpty()) { 192 sb.append(" [").append(entry.note).append("]"); 193 } 194 if (!entry.success && entry.errorMessage != null && !entry.errorMessage.isEmpty()) { 195 sb.append(" ").append(entry.errorMessage); 196 } 197 sb.append("\n"); 198 199 totalTransitions++; 200 if (entry.success) { 201 totalSucceeded++; 202 } else { 203 totalFailed++; 204 } 205 } 206 } 207 208 sb.append("\nSummary: ").append(totalTransitions).append(" transitions, "); 209 sb.append(totalSucceeded).append(" succeeded, ").append(totalFailed).append(" failed\n"); 210 211 return sb.toString(); 212 } 213 214 /** 215 * Queries transition logs for a specific actor. 216 */ 217 private List<TransitionEntry> queryTransitions(String source) throws Exception { 218 List<TransitionEntry> entries = new ArrayList<>(); 219 220 String sql = "SELECT timestamp, label, level, message FROM logs " + 221 "WHERE session_id = ? AND node_id = ? AND message LIKE '%Transition %' " + 222 "ORDER BY timestamp"; 223 224 try (PreparedStatement ps = connection.prepareStatement(sql)) { 225 ps.setLong(1, sessionId); 226 ps.setString(2, source); 227 228 try (ResultSet rs = ps.executeQuery()) { 229 while (rs.next()) { 230 Timestamp ts = rs.getTimestamp("timestamp"); 231 String label = rs.getString("label"); 232 String message = rs.getString("message"); 233 234 boolean success = message.contains("SUCCESS"); 235 String errorMessage = null; 236 if (!success) { 237 int dashIdx = message.indexOf(" - "); 238 if (dashIdx > 0) { 239 errorMessage = message.substring(dashIdx + 3); 240 } 241 } 242 243 String[] transitionAndNote = extractTransitionAndNote(message); 244 String transition = transitionAndNote[0]; 245 String note = transitionAndNote[1]; 246 247 String displayLabel = (label != null && label.contains("->")) ? label : transition; 248 String timeStr = ts.toLocalDateTime().format(TIME_FORMAT); 249 250 entries.add(new TransitionEntry(timeStr, displayLabel, note, success, errorMessage)); 251 } 252 } 253 } 254 255 return entries; 256 } 257 258 /** 259 * Queries distinct actor sources that have transition logs. 260 */ 261 private List<String> queryDistinctSources() throws Exception { 262 List<String> sources = new ArrayList<>(); 263 264 String sql = "SELECT DISTINCT node_id FROM logs " + 265 "WHERE session_id = ? AND message LIKE '%Transition %' " + 266 "ORDER BY node_id"; 267 268 try (PreparedStatement ps = connection.prepareStatement(sql)) { 269 ps.setLong(1, sessionId); 270 271 try (ResultSet rs = ps.executeQuery()) { 272 while (rs.next()) { 273 sources.add(rs.getString("node_id")); 274 } 275 } 276 } 277 278 return sources; 279 } 280 281 /** 282 * Extracts transition and note from message. 283 * 284 * @return String[2] where [0]=transition, [1]=note 285 */ 286 private String[] extractTransitionAndNote(String message) { 287 if (message == null) return new String[]{"unknown", ""}; 288 289 int idx = message.indexOf("Transition "); 290 if (idx < 0) return new String[]{"unknown", ""}; 291 292 String afterTransition = message.substring(idx + "Transition ".length()); 293 int colonIdx = afterTransition.indexOf(": "); 294 if (colonIdx < 0) return new String[]{"unknown", ""}; 295 296 String statesPart = afterTransition.substring(colonIdx + 2); 297 298 // Extract note [xxx] 299 String note = ""; 300 int bracketStart = statesPart.indexOf(" ["); 301 int bracketEnd = statesPart.indexOf("]"); 302 if (bracketStart > 0 && bracketEnd > bracketStart) { 303 note = statesPart.substring(bracketStart + 2, bracketEnd); 304 statesPart = statesPart.substring(0, bracketStart); 305 } 306 307 // Remove " - error message" if present 308 int dashIdx = statesPart.indexOf(" - "); 309 if (dashIdx > 0) { 310 statesPart = statesPart.substring(0, dashIdx); 311 } 312 313 return new String[]{statesPart.trim(), note}; 314 } 315 316 @Override 317 public String getTitle() { 318 return null; // Title is embedded in content 319 } 320 321 /** 322 * Internal class to hold transition entry data. 323 */ 324 private static class TransitionEntry { 325 final String timestamp; 326 final String label; 327 final String note; 328 final boolean success; 329 final String errorMessage; 330 331 TransitionEntry(String timestamp, String label, String note, boolean success, String errorMessage) { 332 this.timestamp = timestamp; 333 this.label = label; 334 this.note = note; 335 this.success = success; 336 this.errorMessage = errorMessage; 337 } 338 } 339}