001/* 002 * Copyright 2025 devteam@scivics-lab.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.cli; 019 020import java.io.File; 021import java.nio.file.Path; 022import java.sql.SQLException; 023import java.time.LocalDateTime; 024import java.time.ZoneId; 025import java.time.format.DateTimeFormatter; 026import java.util.List; 027import java.util.concurrent.Callable; 028 029import com.scivicslab.actoriac.log.H2LogReader; 030import com.scivicslab.actoriac.log.H2LogReader.NodeInfo; 031import com.scivicslab.actoriac.log.LogEntry; 032import com.scivicslab.actoriac.log.LogLevel; 033import com.scivicslab.actoriac.log.SessionSummary; 034 035import picocli.CommandLine; 036import picocli.CommandLine.Command; 037import picocli.CommandLine.Option; 038 039/** 040 * CLI tool for querying workflow execution logs from H2 database. 041 * 042 * <p>Usage examples:</p> 043 * <pre> 044 * # Show summary of the latest session 045 * java -jar actor-IaC.jar logs --db logs --summary 046 * 047 * # Show logs for a specific node 048 * java -jar actor-IaC.jar logs --db logs --node node-001 049 * 050 * # Show only error logs 051 * java -jar actor-IaC.jar logs --db logs --level ERROR 052 * 053 * # List all sessions 054 * java -jar actor-IaC.jar logs --db logs --list 055 * </pre> 056 * 057 * @author devteam@scivics-lab.com 058 */ 059@Command( 060 name = "log-search", 061 mixinStandardHelpOptions = true, 062 version = "actor-IaC log-search 2.10.0", 063 description = "Search workflow execution logs from H2 database." 064) 065public class LogsCLI implements Callable<Integer> { 066 067 /** 068 * ISO 8601 format with timezone offset (e.g., 2026-01-05T10:30:00+09:00). 069 */ 070 private static final DateTimeFormatter ISO_FORMATTER = 071 DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssXXX"); 072 073 /** 074 * System timezone for display. 075 */ 076 private static final ZoneId SYSTEM_ZONE = ZoneId.systemDefault(); 077 078 @Option( 079 names = {"--db"}, 080 description = "H2 database path (without .mv.db extension)", 081 required = true 082 ) 083 private File dbPath; 084 085 @Option( 086 names = {"--server"}, 087 description = "H2 log server address (host:port). Use with log-server command." 088 ) 089 private String server; 090 091 @Option( 092 names = {"-s", "--session"}, 093 description = "Session ID to query (default: latest session)" 094 ) 095 private Long sessionId; 096 097 @Option( 098 names = {"-n", "--node"}, 099 description = "Filter logs by node ID" 100 ) 101 private String nodeId; 102 103 @Option( 104 names = {"--level"}, 105 description = "Minimum log level to show (DEBUG, INFO, WARN, ERROR)", 106 defaultValue = "DEBUG" 107 ) 108 private LogLevel minLevel; 109 110 @Option( 111 names = {"--summary"}, 112 description = "Show session summary only" 113 ) 114 private boolean summaryOnly; 115 116 @Option( 117 names = {"--list"}, 118 description = "List recent sessions" 119 ) 120 private boolean listSessions; 121 122 @Option( 123 names = {"-w", "--workflow"}, 124 description = "Filter sessions by workflow name" 125 ) 126 private String workflowFilter; 127 128 @Option( 129 names = {"-o", "--overlay"}, 130 description = "Filter sessions by overlay name" 131 ) 132 private String overlayFilter; 133 134 @Option( 135 names = {"-i", "--inventory"}, 136 description = "Filter sessions by inventory name" 137 ) 138 private String inventoryFilter; 139 140 @Option( 141 names = {"--after"}, 142 description = "Filter sessions started after this time (ISO format: YYYY-MM-DDTHH:mm:ss)" 143 ) 144 private String startedAfter; 145 146 @Option( 147 names = {"--since"}, 148 description = "Filter sessions started within the specified duration (e.g., 12h, 1d, 3d, 1w)" 149 ) 150 private String since; 151 152 @Option( 153 names = {"--ended-since"}, 154 description = "Filter sessions ended within the specified duration (e.g., 1h, 12h, 1d)" 155 ) 156 private String endedSince; 157 158 @Option( 159 names = {"--limit"}, 160 description = "Maximum number of entries to show", 161 defaultValue = "100" 162 ) 163 private int limit; 164 165 @Option( 166 names = {"--list-nodes"}, 167 description = "List all nodes in the specified session" 168 ) 169 private boolean listNodes; 170 171 /** 172 * Main entry point for the logs CLI. 173 * 174 * @param args command line arguments 175 */ 176 public static void main(String[] args) { 177 int exitCode = new CommandLine(new LogsCLI()).execute(args); 178 System.exit(exitCode); 179 } 180 181 @Override 182 public Integer call() { 183 try (H2LogReader reader = createReader()) { 184 if (listSessions) { 185 return listRecentSessions(reader); 186 } 187 188 // Determine session ID 189 long targetSession = sessionId != null ? sessionId : reader.getLatestSessionId(); 190 if (targetSession < 0) { 191 System.err.println("No sessions found in database."); 192 return 1; 193 } 194 195 if (listNodes) { 196 return listNodesInSession(reader, targetSession); 197 } 198 199 if (summaryOnly) { 200 return showSummary(reader, targetSession); 201 } 202 203 return showLogs(reader, targetSession); 204 205 } catch (SQLException e) { 206 System.err.println("Database error: " + e.getMessage()); 207 return 1; 208 } catch (Exception e) { 209 System.err.println("Error: " + e.getMessage()); 210 return 1; 211 } 212 } 213 214 /** 215 * Creates an H2LogReader, using TCP connection if --server is specified. 216 * 217 * @return H2LogReader instance 218 * @throws SQLException if connection fails 219 */ 220 private H2LogReader createReader() throws SQLException { 221 if (server != null && !server.isBlank()) { 222 String[] parts = server.split(":"); 223 String host = parts[0]; 224 int port = parts.length > 1 ? Integer.parseInt(parts[1]) : 29090; 225 return new H2LogReader(host, port, dbPath.getAbsolutePath()); 226 } else { 227 return new H2LogReader(dbPath.toPath()); 228 } 229 } 230 231 private Integer listRecentSessions(H2LogReader reader) { 232 // Parse start time filter (--since takes precedence over --after) 233 LocalDateTime startedAfterTime = null; 234 if (since != null) { 235 startedAfterTime = parseSince(since); 236 if (startedAfterTime == null) { 237 System.err.println("Invalid --since format. Use: 12h, 1d, 3d, 1w (h=hours, d=days, w=weeks)"); 238 return 1; 239 } 240 } else if (startedAfter != null) { 241 try { 242 startedAfterTime = LocalDateTime.parse(startedAfter); 243 } catch (Exception e) { 244 System.err.println("Invalid date format. Use ISO format: YYYY-MM-DDTHH:mm:ss"); 245 return 1; 246 } 247 } 248 249 // Parse end time filter 250 LocalDateTime endedAfterTime = null; 251 if (endedSince != null) { 252 endedAfterTime = parseSince(endedSince); 253 if (endedAfterTime == null) { 254 System.err.println("Invalid --ended-since format. Use: 1h, 12h, 1d, 3d (h=hours, d=days, w=weeks)"); 255 return 1; 256 } 257 } 258 259 // Apply filters if any are specified 260 List<SessionSummary> sessions; 261 if (workflowFilter != null || overlayFilter != null || inventoryFilter != null || startedAfterTime != null || endedAfterTime != null) { 262 sessions = reader.listSessionsFiltered(workflowFilter, overlayFilter, inventoryFilter, startedAfterTime, endedAfterTime, limit); 263 } else { 264 sessions = reader.listSessions(limit); 265 } 266 267 if (sessions.isEmpty()) { 268 System.out.println("No sessions found."); 269 return 0; 270 } 271 272 System.out.println("Sessions:"); 273 System.out.println("=".repeat(80)); 274 for (SessionSummary summary : sessions) { 275 System.out.printf("#%-4d %-30s %-10s%n", 276 summary.getSessionId(), 277 summary.getWorkflowName(), 278 summary.getStatus()); 279 if (summary.getOverlayName() != null) { 280 System.out.printf(" Overlay: %s%n", summary.getOverlayName()); 281 } 282 if (summary.getInventoryName() != null) { 283 System.out.printf(" Inventory: %s%n", summary.getInventoryName()); 284 } 285 System.out.printf(" Started: %s%n", formatTimestamp(summary.getStartedAt())); 286 System.out.println("-".repeat(80)); 287 } 288 return 0; 289 } 290 291 private Integer listNodesInSession(H2LogReader reader, long targetSession) { 292 List<NodeInfo> nodes = reader.getNodesInSession(targetSession); 293 if (nodes.isEmpty()) { 294 System.out.println("No nodes found in session #" + targetSession); 295 return 0; 296 } 297 298 SessionSummary summary = reader.getSummary(targetSession); 299 System.out.println("Nodes in session #" + targetSession + " (" + summary.getWorkflowName() + "):"); 300 System.out.println("=".repeat(70)); 301 System.out.printf("%-30s %-10s %-10s%n", "NODE_ID", "STATUS", "LOG_COUNT"); 302 System.out.println("-".repeat(70)); 303 for (NodeInfo node : nodes) { 304 System.out.printf("%-30s %-10s %-10d%n", 305 node.nodeId(), 306 node.status() != null ? node.status() : "-", 307 node.logCount()); 308 } 309 System.out.println("=".repeat(70)); 310 System.out.println("Total: " + nodes.size() + " nodes"); 311 return 0; 312 } 313 314 private Integer showSummary(H2LogReader reader, long targetSession) { 315 SessionSummary summary = reader.getSummary(targetSession); 316 if (summary == null) { 317 System.err.println("Session not found: " + targetSession); 318 return 1; 319 } 320 System.out.println(summary); 321 return 0; 322 } 323 324 private Integer showLogs(H2LogReader reader, long targetSession) { 325 List<LogEntry> logs; 326 327 if (nodeId != null) { 328 logs = reader.getLogsByNode(targetSession, nodeId); 329 System.out.println("Logs for node: " + nodeId); 330 } else { 331 logs = reader.getLogsByLevel(targetSession, minLevel); 332 System.out.println("Logs (level >= " + minLevel + "):"); 333 } 334 335 System.out.println("=".repeat(80)); 336 337 int count = 0; 338 for (LogEntry entry : logs) { 339 if (count >= limit) { 340 System.out.println("... (truncated, use --limit to show more)"); 341 break; 342 } 343 344 // Format: [timestamp] LEVEL [node] message 345 String levelColor = getLevelPrefix(entry.getLevel()); 346 System.out.printf("%s[%s] %-5s [%s] %s%s%n", 347 levelColor, 348 formatTimestamp(entry.getTimestamp()), 349 entry.getLevel(), 350 entry.getNodeId(), 351 entry.getMessage(), 352 "\u001B[0m"); // Reset color 353 354 count++; 355 } 356 357 System.out.println("=".repeat(80)); 358 System.out.println("Total: " + logs.size() + " entries"); 359 360 return 0; 361 } 362 363 private String getLevelPrefix(LogLevel level) { 364 return switch (level) { 365 case ERROR -> "\u001B[31m"; // Red 366 case WARN -> "\u001B[33m"; // Yellow 367 case INFO -> "\u001B[32m"; // Green 368 case DEBUG -> "\u001B[36m"; // Cyan 369 }; 370 } 371 372 /** 373 * Formats a LocalDateTime as ISO 8601 string with timezone offset. 374 * 375 * <p>Example output: 2026-01-05T10:30:00+09:00</p> 376 * 377 * @param timestamp the timestamp to format 378 * @return ISO 8601 formatted string, or "N/A" if null 379 */ 380 private String formatTimestamp(LocalDateTime timestamp) { 381 if (timestamp == null) { 382 return "N/A"; 383 } 384 return timestamp.atZone(SYSTEM_ZONE).format(ISO_FORMATTER); 385 } 386 387 /** 388 * Parses a relative time string into a LocalDateTime. 389 * 390 * <p>Supported formats:</p> 391 * <ul> 392 * <li>{@code 12h} - 12 hours ago</li> 393 * <li>{@code 1d} - 1 day ago</li> 394 * <li>{@code 3d} - 3 days ago</li> 395 * <li>{@code 1w} - 1 week ago</li> 396 * </ul> 397 * 398 * @param sinceStr the relative time string 399 * @return LocalDateTime representing the calculated time, or null if invalid format 400 */ 401 private LocalDateTime parseSince(String sinceStr) { 402 if (sinceStr == null || sinceStr.isEmpty()) { 403 return null; 404 } 405 406 try { 407 String numPart = sinceStr.substring(0, sinceStr.length() - 1); 408 char unit = Character.toLowerCase(sinceStr.charAt(sinceStr.length() - 1)); 409 long amount = Long.parseLong(numPart); 410 411 LocalDateTime now = LocalDateTime.now(); 412 return switch (unit) { 413 case 'h' -> now.minusHours(amount); 414 case 'd' -> now.minusDays(amount); 415 case 'w' -> now.minusWeeks(amount); 416 case 'm' -> now.minusMinutes(amount); 417 default -> null; 418 }; 419 } catch (NumberFormatException | StringIndexOutOfBoundsException e) { 420 return null; 421 } 422 } 423}