diff --git a/modules/ksb/StatusView.pm b/modules/ksb/StatusView.pm index bc4a3db..b9d6a5b 100644 --- a/modules/ksb/StatusView.pm +++ b/modules/ksb/StatusView.pm @@ -1,338 +1,351 @@ package ksb::StatusView 0.30; +use utf8; # Source code is utf8-encoded + # Helper used to handle a generic 'progress update' status for the module # build, update, install, etc. processes. # # Currently supports TTY output only but it's not impossible to visualize # extending this to a GUI or even web server as options. use strict; use warnings; use 5.014; +# our output to STDOUT should match locale (esp UTF-8 locale, which induces +# warnings for UTF-8 output unless we specifically opt-in) +use open OUT => ':locale'; + use ksb::Debug 0.20 qw(colorize); use ksb::Util; use List::Util qw(min max reduce); use IO::Handle; sub new { my $class = shift; my $tty_width = int(`tput cols` // $ENV{COLUMNS} // 80); my $defaultOpts = { tty_width => $tty_width, max_name_width => 1, # Updated from the build plan cur_update => '', # moduleName under update cur_working => '', # moduleName under any other phase cur_progress => '', # Percentage (0% - 100%) module_in_phase => { }, # $phase -> $moduleName done_in_phase => { }, # $phase -> int todo_in_phase => { }, # $phase -> int failed_at_phase => { }, # $moduleName -> $phase log_entries => { }, # $moduleName -> $phase -> [ $entry ... ] last_mod_entry => '', # $moduleName/$phase, see onLogEntries last_msg_type => '', # If 'progress' we can clear line }; # Must bless a hash ref since subclasses expect it. return bless $defaultOpts, $class; } # Accepts a single event, as a hashref decoded from its source JSON format (as # described in ksb::StatusMonitor), and updates the user interface # appropriately. sub notifyEvent { my ($self, $ev) = @_; state $handlers = { phase_started => \&onPhaseStarted, phase_progress => \&onPhaseProgress, phase_completed => \&onPhaseCompleted, build_plan => \&onBuildPlan, build_done => \&onBuildDone, log_entries => \&onLogEntries, }; state $err = sub { croak_internal("Invalid event! $_[1]"); }; my $handler = $handlers->{$ev->{event}} // $err; # This is a method call though we don't use normal Perl method call syntax $handler->($self, $ev); } # Event handlers # A module has started on a given phase. Multiple phases can be in-flight at # once! sub onPhaseStarted { my ($self, $ev) = @_; my ($moduleName, $phase) = @{$ev->{phase_started}}{qw/module phase/}; $self->{module_in_phase}->{$phase} = $moduleName; my $phaseKey = $phase eq 'update' ? 'cur_update' : 'cur_working'; $self->{$phaseKey} = $moduleName; $self->update(); } # Progress has been made within a phase of a module build. Only supported for # the build phase, currently. sub onPhaseProgress { my ($self, $ev) = @_; my ($moduleName, $phase, $progress) = @{$ev->{phase_progress}}{qw/module phase progress/}; $progress = sprintf ("%3.1f", 100.0 * $progress); $self->{cur_progress} = $progress; $self->update(); } # A phase of a module build is finished sub onPhaseCompleted { my ($self, $ev) = @_; my ($moduleName, $phase, $result) = @{$ev->{phase_completed}}{qw/module phase result/}; $self->_checkForBuildPlan(); if ($result eq 'error') { $self->{failed_at_phase}->{$moduleName} = $phase; my $failure = $phase eq 'buildsystem' ? 'setup buildsystem' : $phase; my $log = $ev->{phase_completed}->{error_file}; my $msg = " r[b[*] b[r[$moduleName] failed"; $msg .= ", see b[file://$log]" if $log; $self->_clearLineAndUpdate(colorize("$msg\n")); } $self->{done_in_phase}->{$phase}++; my $phase_done = ( ($self->{done_in_phase}->{$phase} // 0) == ($self->{todo_in_phase}->{$phase} // 999)); my $phaseKey = $phase eq 'update' ? 'cur_update' : 'cur_working'; $self->{$phaseKey} = $phase_done ? '---' : ''; $self->update(); } # The one-time build plan has been given, can be used for deciding best way to # show progress sub onBuildPlan { my ($self, $ev) = @_; my (@modules) = @{$ev->{build_plan}}; croak_internal ("Empty build plan!") unless @modules; my %num_todo = ( # These are the 'core' phases we expect to be here even with # --no-src, --no-build, etc. update => 0, build => 0, ); my $max_name_width = 0; for my $m (@modules) { $max_name_width = max($max_name_width, length $m->{name}); $num_todo{$_}++ foreach (@{$m->{phases}}); } $self->{done_in_phase}->{$_} = 0 foreach keys %num_todo; $self->{todo_in_phase} = \%num_todo; $self->{max_name_width} = $max_name_width; } # The whole build/install process has completed. sub onBuildDone { my ($self, $ev) = @_; my ($statsRef) = %{$ev->{build_done}}; my $numFailedModules = keys %{$self->{failed_at_phase}}; my $numBuiltModules = max( map { $self->{todo_in_phase}->{$_} } ( keys %{$self->{todo_in_phase}})); my $numSuccesses = $numBuiltModules - $numFailedModules; - my $built = $numFailedModules == 0 ? 'Built all' : 'Worked on'; - my $msg = "\n$built b[$numBuiltModules] modules"; + my $unicode = $ENV{LC_ALL} =~ /UTF-?8$/; + my $happy = $unicode ? '✓' : ':-)'; + my $frown = $unicode ? '✗' : ':-('; + + my $built = $numFailedModules == 0 + ? " g[b[$happy] - Built all" + : " r[b[$frown] - Worked on"; + + my $msg = "$built b[$numBuiltModules] modules"; $msg .= " (b[g[$numSuccesses] built OK)" if $numFailedModules > 0 and $numSuccesses > 0; $self->_clearLineAndUpdate (colorize("$msg\n")); } # The build/install process has forwarded new notices that should be shown. sub onLogEntries { my ($self, $ev) = @_; my ($module, $phase, $entriesRef) = @{$ev->{log_entries}}{qw/module phase entries/}; # Current line may have a transient update msg still, otherwise we're already on # suitable line to print _clearLine() if $self->{last_msg_type} eq 'progress'; if ("$module/$phase" ne $self->{last_mod_entry} && @$entriesRef) { say colorize(" b[y[*] b[$module] $phase:"); $self->{last_mod_entry} = "$module/$phase"; } for my $entry (@$entriesRef) { say $entry; $self->{log_entries}->{$module} //= { build => [ ], update => [ ] }; $self->{log_entries}->{$module}->{$phase} //= [ ]; push @{$self->{log_entries}->{$module}->{$phase}}, $entry; } $self->{last_msg_type} = 'log'; $self->update(); } # TTY helpers sub _checkForBuildPlan { my $self = shift; croak_internal ("Did not receive build plan!") unless keys %{$self->{todo_in_phase}}; } # Generates a string like "update [20/74] build [02/74]" for the requested # phases. sub _progressStringForPhases { my ($self, @phases) = @_; my $result = ''; my $base = ''; foreach my $phase (@phases) { my $cur = $self->{done_in_phase}->{$phase} // 0; my $max = $self->{todo_in_phase}->{$phase} // 0; my $strWidth = length("$max"); my $progress = sprintf("%0*s/$max", $strWidth, $cur); $result .= "$base$phase [$progress]"; $base = ' '; } return $result; } # Generates a string like "update: kcoreaddons build: kconfig" for the # requested phases. You must pass in a hashref mapping each phase name to the # current module name. sub _currentModuleStringForPhases { my ($self, $currentModulesRef, @phases) = @_; my $result = ''; my $base = ''; my $longestNameWidth = $self->{max_name_width}; for my $phase (@phases) { my $curModule = $currentModulesRef->{$phase} // '???'; $curModule .= (' ' x ($longestNameWidth - length ($curModule))); $result .= "$base$phase: $curModule"; $base = ' '; } return $result; } # Returns integer length of the worst-case output line (i.e. the one with a # long module name for each of the given phases). sub _getMinimumOutputWidth { my ($self, @phases) = @_; my $longestName = 'x' x $self->{max_name_width}; my %mockModules = map { ($_, $longestName) } @phases; # fake that the worst-case module is set and find resultant length my $str = $self->_progressStringForPhases(@phases) . " " . $self->_currentModuleStringForPhases(\%mockModules, @phases); return length($str); } sub update { my @phases = qw(update build); my $self = shift; my $term_width = $self->{tty_width}; $self->{min_output} //= $self->_getMinimumOutputWidth(@phases); my $min_width = $self->{min_output}; my $progress = $self->_progressStringForPhases(@phases); my $current_modules = $self->_currentModuleStringForPhases( { update => $self->{cur_update}, build => $self->{cur_working} }, @phases ); my $msg; if ($min_width >= ($term_width - 12)) { # No room for fancy progress, just display what we can $msg = "$progress $current_modules"; } else { my $max_prog_width = ($term_width - $min_width) - 5; my $num_all_done = min(@{$self->{done_in_phase}}{@phases}) // 0; my $num_some_done = max(@{$self->{done_in_phase}}{@phases}, 0) // 0; my $max_todo = max(@{$self->{todo_in_phase}}{@phases}, 1) // 1; my $width = $max_prog_width * $num_all_done / $max_todo; # Leave at least one empty space if we're not fully done $width-- if ($width == $max_prog_width && $num_all_done < $max_todo); my $bar = ('=' x $width); # Show a smaller character entry for updates that are done before the # corresponding build/install. if ($num_some_done > $num_all_done) { $width = $max_prog_width * $num_some_done / $max_todo; $bar .= ('.' x ($width - length ($bar))); } $msg = sprintf("%s [%*s] %s", $progress, -$max_prog_width, $bar, $current_modules); } $self->_clearLineAndUpdate($msg); $self->{last_msg_type} = 'progress'; } sub _clearLine { print "\e[1G\e[K"; } sub _clearLineAndUpdate { my ($self, $msg) = @_; # If last message was a transient progress meter, gives the escape sequence # to return to column 1 and clear the entire line before printing message $msg = "\e[1G\e[K$msg" if $self->{last_msg_type} eq 'progress'; print $msg; STDOUT->flush; $self->{last_msg_type} = 'log'; # update() will change it back if needed } 1; diff --git a/modules/ksb/UserInterface/TTY.pm b/modules/ksb/UserInterface/TTY.pm index f9e1d2a..0f87711 100755 --- a/modules/ksb/UserInterface/TTY.pm +++ b/modules/ksb/UserInterface/TTY.pm @@ -1,226 +1,225 @@ #!/usr/bin/env perl package ksb::UserInterface::TTY 0.10; =pod =head1 NAME ksb::UserInterface::TTY -- A command-line interface to the kdesrc-build backend =head1 DESCRIPTION This class is used to show a user interface for a kdesrc-build run at the command line (as opposed to a browser-based or GUI interface). Since the kdesrc-build backend is now meant to be headless and controlled via a Web-style API set (powered by Mojolicious), this class manages the interaction with that backend, also using Mojolicious to power the HTTP and WebSocket requests necessary. =head1 SYNOPSIS my $app = web::BackendServer->new(@ARGV); my $ui = ksb::UserInterface::TTY->new($app); exit $ui->start(); # Blocks! Returns a shell-style return code =cut use strict; use warnings; use 5.014; use Mojo::Base -base; use Mojo::Server::Daemon; use Mojo::IOLoop; use Mojo::UserAgent; use Mojo::JSON qw(to_json); use ksb::StatusView; use ksb::Util; use ksb::Debug; use IO::Handle; # For methods on event_stream file use List::Util qw(max); has ua => sub { Mojo::UserAgent->new->inactivity_timeout(0) }; has ui => sub { ksb::StatusView->new() }; has 'app'; sub new { my ($class, $app) = @_; my $self = $class->SUPER::new(app => $app); # Mojo::UserAgent can be tied to a Mojolicious application server directly to # handle relative URLs, which is perfect for what we want. Making this # attachment will startup the Web server behind the scenes and allow $ua to # make HTTP requests. $self->ua->server->app($app); $self->ua->server->app->log->level('fatal'); return $self; } sub _check_error { my $tx = shift; my $err = $tx->error or return; my $body = $tx->res->body // ''; open my $fh, '<', \$body; my ($first_line) = <$fh> // ''; $err->{message} .= "\n$first_line" if $first_line; die $err; }; # Just a giant huge promise handler that actually processes U/I events and # keeps the TTY up to date. Note the TTY-specific stuff is actually itself # buried in a separate class for now. sub start { my $self = shift; my $ui = $self->ui; my $ua = $self->ua; my $app = $self->app; my $result = 0; # notes errors from module builds or internal errors my @module_failures; # Open a file to log the event stream my $ctx = $app->context(); my $separator = ' '; my $dest = pretending() ? '/dev/null' : $ctx->getLogDirFor($ctx) . '/event-stream'; open my $event_stream, '>', $dest or croak_internal("Unable to open event log $!"); $event_stream->say("["); # Try to make it valid JSON syntax # This call just reads an option from the BuildContext as a sanity check $ua->get_p('/context/options/pretend')->then(sub { my $tx = shift; _check_error($tx); # If we get here things are mostly working? my $selectorsRef = $app->{selectors}; # We need to specifically ask for all modules if we're not passing a # specific list of modules to build. my $headers = { }; $headers->{'X-BuildAllModules'} = 1 unless @{$selectorsRef}; # Tell the backend which modules to build. return $ua->post_p('/modules', $headers, json => $selectorsRef); })->then(sub { my $tx = shift; _check_error($tx); # We've received a successful response from the backend that it's able to # build the requested modules, so proceed to setup the U/I and start the # build. return $ua->websocket_p('/events'); })->then(sub { # Websocket Event handler my $ws = shift; my $everFailed = 0; my $stop_promise = Mojo::Promise->new; # Websockets seem to be inherently event-driven instead of simply # client/server. So attach the event handlers and then return to the event # loop to await progress. $ws->on(json => sub { # This handler is called by the backend when there is something notable # to report my ($ws, $resultRef) = @_; foreach my $modRef (@{$resultRef}) { # Update the U/I eval { $ui->notifyEvent($modRef); $event_stream->say($separator . to_json($modRef)); $separator = ', '; }; if ($@) { $ws->finish; $stop_promise->reject($@); } # See ksb::StatusMonitor for where events defined if ($modRef->{event} eq 'phase_completed') { my $results = $modRef->{phase_completed}; push @module_failures, $results if $results->{result} eq 'error'; } if ($modRef->{event} eq 'build_done') { # We've reported the build is complete, activate the promise # holding things together. The value we pass is what is passed # to the next promise handler. $stop_promise->resolve(scalar @module_failures); } } }); $ws->on(finish => sub { # Shouldn't happen in a normal build but it's probably possible $stop_promise->reject; # ignored if we resolved first }); # Blocking call to kick off the build my $tx = $ua->post('/build'); if (my $err = $tx->error) { $stop_promise->reject('Unable to start build: ' . $err->{message}); } # Once we return here we'll wait in Mojolicious event loop for awhile until # the build is done, before moving into the promise handler below return $stop_promise; })->then(sub { # Build done, value comes from stop_promise->resolve above $result ||= shift; })->catch(sub { # Catches all errors in any of the prior promises my $err = shift; say "Error: ", $err->{code}, " ", $err->{message}; # See if we made it to an rc-file my $ctx = $app->ksb->context(); my $rcFile = $ctx ? $ctx->rcFile() // 'Unknown' : undef; say "Using configuration file found at $rcFile" if $rcFile; $result = 1; # error })->wait; $event_stream->say("]"); $event_stream->close() or $result = 1; # _report_on_failures(@module_failures); - say $result == 0 ? ":-)" : ":-("; return $result; }; sub _report_on_failures { my @failures = @_; my $max_width = max map { length ($_->{module}) } @failures; foreach my $mod (@failures) { my $module = $mod->{module}; my $phase = $mod->{phase}; my $log = $mod->{error_file}; my $padding = $max_width - length $module; $module .= (' ' x $padding); # Left-align $phase = 'setup buildsystem' if $phase eq 'buildsystem'; error("b[*] r[b[$module] failed to b[$phase]"); error("b[*]\tFind the log at file://$log") if $log; } } 1;