55import time
66from dataclasses import asdict
77from datetime import datetime
8+ from datetime import timezone
89from pathlib import Path
910from typing import List
1011from typing import Mapping
1718import httpx
1819import typer
1920from rich .console import Console
21+ from rich .console import Group
22+ from rich .live import Live
23+ from rich .panel import Panel
24+ from rich .spinner import Spinner
2025from rich .table import Table
2126from rich .text import Text
2227
@@ -539,6 +544,71 @@ def print_action_templates_table(templates: Sequence[ActionTemplate], *, pprint:
539544 print (template .uuid )
540545
541546
547+ def generate_live_layout (run_details : Run ) -> Group :
548+ verbose = get_shared_state ().verbose
549+
550+ # Calculate elapsed time
551+ if run_details .updated_at :
552+ elapsed = run_details .updated_at - run_details .created_at
553+ else :
554+ elapsed = datetime .now (timezone .utc ) - run_details .created_at
555+
556+ elapsed_seconds = int (elapsed .total_seconds ())
557+ hours , remainder = divmod (elapsed_seconds , 3600 )
558+ minutes , seconds = divmod (remainder , 60 )
559+ elapsed_str = f"{ hours } :{ minutes :02} :{ seconds :02} "
560+
561+ state_upper = run_details .state .upper ()
562+
563+ if state_upper == "DONE" :
564+ status_color = "green"
565+ icon = Text ("[✔]" , style = "green" )
566+ state_text = "DONE"
567+ elif state_upper in {"FAILED" , "UNPROCESSABLE" }:
568+ status_color = "red"
569+ icon = Text ("[✘]" , style = "red" )
570+ state_text = f"{ run_details .state } "
571+ else :
572+ status_color = "yellow"
573+ icon = Spinner ("dots" , style = "blue" )
574+ state_text = f"{ run_details .state } "
575+
576+ header_text = Text ()
577+ header_text .append (f"Running Action { run_details .uuid } " , style = "bold" )
578+ header_text .append (f"{ elapsed_str } " , style = "dim" )
579+ header_text .append (f"({ state_text } )" , style = status_color )
580+
581+ header_table = Table .grid (padding = (0 , 1 ))
582+ header_table .add_column ()
583+ header_table .add_column ()
584+ header_table .add_row (icon , header_text )
585+
586+ logs_text = Text ()
587+ last_logs = run_details .logs [- 20 :]
588+ if not last_logs :
589+ logs_text .append (" => " , style = "blue" )
590+ if state_upper in {"DONE" , "FAILED" , "UNPROCESSABLE" }:
591+ logs_text .append ("No logs produced." , style = "dim" )
592+ else :
593+ logs_text .append ("Waiting for logs..." , style = "dim" )
594+ else :
595+ for i , log in enumerate (last_logs ):
596+ logs_text .append (" => " , style = "blue" )
597+
598+ if verbose :
599+ level = log .level .upper ().strip ()
600+ color = LOG_LEVEL_COLORS .get (level , "white" )
601+ timestamp_str = log .timestamp .strftime ("%Y-%m-%d %H:%M:%S" )
602+ level_str = f"[{ level .ljust (8 )} ]"
603+ logs_text .append (f"[{ timestamp_str } ] { level_str } " , style = str (color ))
604+
605+ logs_text .append (log .message .strip ())
606+ if i < len (last_logs ) - 1 :
607+ logs_text .append ("\n " )
608+
609+ return Group (header_table , logs_text )
610+
611+
542612def follow_run_logs (client : AuthenticatedClient , run_uuid : str ) -> int :
543613 """
544614 Polls the API for run details and prints new logs as they arrive.
@@ -549,40 +619,67 @@ def follow_run_logs(client: AuthenticatedClient, run_uuid: str) -> int:
549619 typer .echo (f"Following logs for run { run_uuid } ..." )
550620
551621 TERMINAL_STATES = {"DONE" , "FAILED" , "UNPROCESSABLE" }
552- printed_log_count = 0
553622 current_run_state = None
554623 exit_code = 0 # Assume success
555624
556625 try :
557- while current_run_state not in TERMINAL_STATES :
558- try :
559- run_details : Run = kleinkram .api .routes .get_run (client , run_uuid )
560- current_run_state = run_details .state .upper ()
561-
562- # Print only new logs
563- new_logs = run_details .logs [printed_log_count :]
564- if new_logs :
565- # Always pretty-print when following
566- print_run_logs (new_logs , pprint = True )
567- printed_log_count = len (run_details .logs )
568-
569- if current_run_state in TERMINAL_STATES :
570- color = typer .colors .GREEN if run_details .state .upper () == "DONE" else typer .colors .RED
571- typer .secho (
572- f"\n Run finished with state: { run_details .state } ({ run_details .state_cause } )" ,
573- fg = color ,
574- )
575- if run_details .state .upper () != "DONE" :
576- exit_code = 1 # Set failure exit code
626+ with Live (refresh_per_second = 10 ) as live :
627+ while current_run_state not in TERMINAL_STATES :
628+ try :
629+ run_details : Run = kleinkram .api .routes .get_run (client , run_uuid )
630+ current_run_state = run_details .state .upper ()
631+
632+ live .update (generate_live_layout (run_details ), refresh = True )
633+
634+ if current_run_state in TERMINAL_STATES :
635+ if current_run_state != "DONE" :
636+ exit_code = 1 # Set failure exit code
637+ break
638+
639+ time .sleep (1 ) # Poll every 1 seconds for a more responsive timer
640+
641+ except kleinkram .errors .RunNotFound :
642+ time .sleep (1 )
643+ except httpx .HTTPStatusError :
644+ time .sleep (3 ) # Wait longer on API errors
645+ except httpx .RequestError as e :
646+ # Catch raw socket errors (like Connection reset by peer)
647+ err_msg = Text (f"\n Connection lost. Please check your network and try again. ({ e } )" , style = "red" )
648+ live .console .print (err_msg )
649+ time .sleep (3 )
650+
651+ # After the Live block finishes, print the final state clearly
652+ try :
653+ run_details : Run = kleinkram .api .routes .get_run (client , run_uuid )
654+ state_upper = run_details .state .upper ()
655+
656+ has_warnings = False
657+ for log in run_details .logs :
658+ if "CORRUPTED" in log .message .upper () or log .level .upper () in {"WARN" , "WARNING" , "ERROR" }:
659+ has_warnings = True
577660 break
578661
579- time .sleep (2 ) # Poll every 2 seconds
580-
581- except kleinkram .errors .RunNotFound :
582- time .sleep (1 )
583- except httpx .HTTPStatusError as e :
584- typer .secho (f"Error fetching run status: { e } " , fg = typer .colors .RED )
585- time .sleep (5 ) # Wait longer on API errors
662+ if state_upper == "DONE" and has_warnings :
663+ color = typer .colors .YELLOW
664+ state_display = "DONE (Completed with Warnings)"
665+ elif state_upper == "DONE" :
666+ color = typer .colors .GREEN
667+ state_display = "DONE"
668+ else :
669+ color = typer .colors .RED
670+ state_display = run_details .state
671+
672+ state_cause_str = f" ({ run_details .state_cause } )" if run_details .state_cause else ""
673+ typer .secho (
674+ f"\n Run finished with state: { state_display } { state_cause_str } " ,
675+ fg = color ,
676+ )
677+ except httpx .HTTPStatusError as e :
678+ typer .secho (f"\n Failed to fetch final run details (API error): { e } " , fg = typer .colors .RED )
679+ except httpx .RequestError as e :
680+ typer .secho (f"\n Failed to fetch final run details (network error): { e } " , fg = typer .colors .RED )
681+ except Exception as e :
682+ typer .secho (f"\n Failed to fetch final run details: { e } " , fg = typer .colors .RED )
586683
587684 except KeyboardInterrupt :
588685 typer .secho (
0 commit comments