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.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@scivicslab.com 058 */ 059@Command( 060 name = "log-search", 061 mixinStandardHelpOptions = true, 062 versionProvider = VersionProvider.class, 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 lines to show (default: unlimited)" 161 ) 162 private int limit = 0; 163 164 @Option( 165 names = {"--list-nodes"}, 166 description = "List all nodes in the specified session" 167 ) 168 private boolean listNodes; 169 170 /** 171 * Main entry point for the logs CLI. 172 * 173 * @param args command line arguments 174 */ 175 public static void main(String[] args) { 176 int exitCode = new CommandLine(new LogsCLI()).execute(args); 177 System.exit(exitCode); 178 } 179 180 @Override 181 public Integer call() { 182 try (H2LogReader reader = createReader()) { 183 if (listSessions) { 184 return listRecentSessions(reader); 185 } 186 187 // Determine session ID 188 long targetSession = sessionId != null ? sessionId : reader.getLatestSessionId(); 189 if (targetSession < 0) { 190 System.err.println("No sessions found in database."); 191 return 1; 192 } 193 194 if (listNodes) { 195 return listNodesInSession(reader, targetSession); 196 } 197 198 if (summaryOnly) { 199 return showSummary(reader, targetSession); 200 } 201 202 return showLogs(reader, targetSession); 203 204 } catch (SQLException e) { 205 System.err.println("Database error: " + e.getMessage()); 206 e.printStackTrace(); 207 return 1; 208 } catch (Exception e) { 209 System.err.println("Error: " + e.getMessage()); 210 e.printStackTrace(); 211 return 1; 212 } 213 } 214 215 /** 216 * Creates an H2LogReader, using TCP connection if --server is specified. 217 * 218 * @return H2LogReader instance 219 * @throws SQLException if connection fails 220 */ 221 private H2LogReader createReader() throws SQLException { 222 if (server != null && !server.isBlank()) { 223 String[] parts = server.split(":"); 224 String host = parts[0]; 225 int port = parts.length > 1 ? Integer.parseInt(parts[1]) : 29090; 226 return new H2LogReader(host, port, dbPath.getAbsolutePath()); 227 } else { 228 return new H2LogReader(dbPath.toPath()); 229 } 230 } 231 232 private Integer listRecentSessions(H2LogReader reader) { 233 // Parse start time filter (--since takes precedence over --after) 234 LocalDateTime startedAfterTime = null; 235 if (since != null) { 236 startedAfterTime = parseSince(since); 237 if (startedAfterTime == null) { 238 System.err.println("Invalid --since format. Use: 12h, 1d, 3d, 1w (h=hours, d=days, w=weeks)"); 239 return 1; 240 } 241 } else if (startedAfter != null) { 242 try { 243 startedAfterTime = LocalDateTime.parse(startedAfter); 244 } catch (Exception e) { 245 System.err.println("Invalid date format. Use ISO format: YYYY-MM-DDTHH:mm:ss"); 246 return 1; 247 } 248 } 249 250 // Parse end time filter 251 LocalDateTime endedAfterTime = null; 252 if (endedSince != null) { 253 endedAfterTime = parseSince(endedSince); 254 if (endedAfterTime == null) { 255 System.err.println("Invalid --ended-since format. Use: 1h, 12h, 1d, 3d (h=hours, d=days, w=weeks)"); 256 return 1; 257 } 258 } 259 260 // Apply filters if any are specified 261 List<SessionSummary> sessions; 262 if (workflowFilter != null || overlayFilter != null || inventoryFilter != null || startedAfterTime != null || endedAfterTime != null) { 263 sessions = reader.listSessionsFiltered(workflowFilter, overlayFilter, inventoryFilter, startedAfterTime, endedAfterTime, limit); 264 } else { 265 sessions = reader.listSessions(limit); 266 } 267 268 if (sessions.isEmpty()) { 269 System.out.println("No sessions found."); 270 return 0; 271 } 272 273 System.out.println("Sessions:"); 274 System.out.println("=".repeat(80)); 275 for (SessionSummary summary : sessions) { 276 System.out.printf("#%-4d %-30s %-10s%n", 277 summary.getSessionId(), 278 summary.getWorkflowName(), 279 summary.getStatus()); 280 if (summary.getOverlayName() != null) { 281 System.out.printf(" Overlay: %s%n", summary.getOverlayName()); 282 } 283 if (summary.getInventoryName() != null) { 284 System.out.printf(" Inventory: %s%n", summary.getInventoryName()); 285 } 286 System.out.printf(" Started: %s%n", formatTimestamp(summary.getStartedAt())); 287 if (summary.getEndedAt() != null) { 288 System.out.printf(" Ended: %s%n", formatTimestamp(summary.getEndedAt())); 289 } 290 if (summary.getCwd() != null) { 291 System.out.printf(" CWD: %s%n", summary.getCwd()); 292 } 293 if (summary.getGitCommit() != null) { 294 String gitInfo = summary.getGitCommit(); 295 if (summary.getGitBranch() != null) { 296 gitInfo += " (" + summary.getGitBranch() + ")"; 297 } 298 System.out.printf(" Git: %s%n", gitInfo); 299 } 300 if (summary.getCommandLine() != null) { 301 System.out.printf(" Command: %s%n", summary.getCommandLine()); 302 } 303 if (summary.getActorIacVersion() != null) { 304 String versionInfo = summary.getActorIacVersion(); 305 if (summary.getActorIacCommit() != null) { 306 versionInfo += " (commit: " + summary.getActorIacCommit() + ")"; 307 } 308 System.out.printf(" actor-IaC: %s%n", versionInfo); 309 } 310 System.out.println("-".repeat(80)); 311 } 312 return 0; 313 } 314 315 private Integer listNodesInSession(H2LogReader reader, long targetSession) { 316 List<NodeInfo> nodes = reader.getNodesInSession(targetSession); 317 if (nodes.isEmpty()) { 318 System.out.println("No nodes found in session #" + targetSession); 319 return 0; 320 } 321 322 SessionSummary summary = reader.getSummary(targetSession); 323 System.out.println("Nodes in session #" + targetSession + " (" + summary.getWorkflowName() + "):"); 324 System.out.println("=".repeat(70)); 325 System.out.printf("%-30s %-10s %-10s%n", "NODE_ID", "STATUS", "LOG_LINES"); 326 System.out.println("-".repeat(70)); 327 for (NodeInfo node : nodes) { 328 System.out.printf("%-30s %-10s %-10d%n", 329 node.nodeId(), 330 node.status() != null ? node.status() : "-", 331 node.logCount()); 332 } 333 System.out.println("=".repeat(70)); 334 System.out.println("Total: " + nodes.size() + " nodes"); 335 return 0; 336 } 337 338 private Integer showSummary(H2LogReader reader, long targetSession) { 339 SessionSummary summary = reader.getSummary(targetSession); 340 if (summary == null) { 341 System.err.println("Session not found: " + targetSession); 342 return 1; 343 } 344 System.out.println(summary); 345 return 0; 346 } 347 348 private Integer showLogs(H2LogReader reader, long targetSession) { 349 List<LogEntry> logs; 350 351 if (nodeId != null) { 352 logs = reader.getLogsByNode(targetSession, nodeId); 353 System.out.println("Logs for node: " + nodeId); 354 } else { 355 logs = reader.getLogsByLevel(targetSession, minLevel); 356 System.out.println("Logs (level >= " + minLevel + "):"); 357 } 358 359 System.out.println("=".repeat(80)); 360 361 int count = 0; 362 for (LogEntry entry : logs) { 363 if (limit > 0 && count >= limit) { 364 System.out.println("... (truncated at " + limit + " lines, use --limit to change)"); 365 break; 366 } 367 368 // Format: [timestamp] LEVEL [node] message 369 String levelColor = getLevelPrefix(entry.getLevel()); 370 System.out.printf("%s[%s] %-5s [%s] %s%s%n", 371 levelColor, 372 formatTimestamp(entry.getTimestamp()), 373 entry.getLevel(), 374 entry.getNodeId(), 375 entry.getMessage(), 376 "\u001B[0m"); // Reset color 377 378 count++; 379 } 380 381 System.out.println("=".repeat(80)); 382 System.out.println("Total: " + logs.size() + " lines"); 383 384 return 0; 385 } 386 387 private String getLevelPrefix(LogLevel level) { 388 return switch (level) { 389 case ERROR -> "\u001B[31m"; // Red 390 case WARN -> "\u001B[33m"; // Yellow 391 case INFO -> "\u001B[32m"; // Green 392 case DEBUG -> "\u001B[36m"; // Cyan 393 }; 394 } 395 396 /** 397 * Formats a LocalDateTime as ISO 8601 string with timezone offset. 398 * 399 * <p>Example output: 2026-01-05T10:30:00+09:00</p> 400 * 401 * @param timestamp the timestamp to format 402 * @return ISO 8601 formatted string, or "N/A" if null 403 */ 404 private String formatTimestamp(LocalDateTime timestamp) { 405 if (timestamp == null) { 406 return "N/A"; 407 } 408 return timestamp.atZone(SYSTEM_ZONE).format(ISO_FORMATTER); 409 } 410 411 /** 412 * Parses a relative time string into a LocalDateTime. 413 * 414 * <p>Supported formats:</p> 415 * <ul> 416 * <li>{@code 12h} - 12 hours ago</li> 417 * <li>{@code 1d} - 1 day ago</li> 418 * <li>{@code 3d} - 3 days ago</li> 419 * <li>{@code 1w} - 1 week ago</li> 420 * </ul> 421 * 422 * @param sinceStr the relative time string 423 * @return LocalDateTime representing the calculated time, or null if invalid format 424 */ 425 private LocalDateTime parseSince(String sinceStr) { 426 if (sinceStr == null || sinceStr.isEmpty()) { 427 return null; 428 } 429 430 try { 431 String numPart = sinceStr.substring(0, sinceStr.length() - 1); 432 char unit = Character.toLowerCase(sinceStr.charAt(sinceStr.length() - 1)); 433 long amount = Long.parseLong(numPart); 434 435 LocalDateTime now = LocalDateTime.now(); 436 return switch (unit) { 437 case 'h' -> now.minusHours(amount); 438 case 'd' -> now.minusDays(amount); 439 case 'w' -> now.minusWeeks(amount); 440 case 'm' -> now.minusMinutes(amount); 441 default -> null; 442 }; 443 } catch (NumberFormatException | StringIndexOutOfBoundsException e) { 444 return null; 445 } 446 } 447}