diff --git a/modules/ksb/ModuleSet/KDEProjects.pm b/modules/ksb/ModuleSet/KDEProjects.pm index a50bad5..57edafd 100644 --- a/modules/ksb/ModuleSet/KDEProjects.pm +++ b/modules/ksb/ModuleSet/KDEProjects.pm @@ -1,223 +1,224 @@ package ksb::ModuleSet::KDEProjects 0.30; # Class: ModuleSet::KDEProjects # # This represents a collective grouping of modules that share common options, # based on the KDE project repositories. Metadata for that repository is # itself housed in a dedicated KDE.org git repository "sysadmin/repo-metadata", # which this class uses to imbue ksb::Modules generated by this ModuleSet. # # The only changes here are to allow for expanding out module specifications # (except for ignored modules), by using KDEProjectsReader. # # See also: ModuleSet use strict; use warnings; use 5.014; use parent qw(ksb::ModuleSet); no if $] >= 5.018, 'warnings', 'experimental::smartmatch'; use ksb::BuildContext 0.20; use ksb::BuildException; use ksb::Debug; use ksb::KDEProjectsReader 0.50; use ksb::Module; use ksb::Util; sub new { my $self = ksb::ModuleSet::new(@_); $self->{projectsDataReader} = undef; # Will be filled in when we get fh return $self; } # Simple utility subroutine. See List::Util's perldoc sub none_true { ($_ && return 0) for @_; return 1; } sub _createMetadataModule { my ($ctx, $moduleName) = @_; my $metadataModule = ksb::Module->new($ctx, $moduleName =~ s,/,-,r); # Hardcode the results instead of expanding out the project info $metadataModule->setOption('repository', "kde:$moduleName"); $metadataModule->setOption('#xml-full-path', $moduleName); $metadataModule->setOption('#branch:stable', 'master'); $metadataModule->setScmType('metadata'); $metadataModule->setOption('disable-snapshots', 1); $metadataModule->setOption('branch', 'master'); my $moduleSet = ksb::ModuleSet::KDEProjects->new($ctx, ''); $metadataModule->setModuleSet($moduleSet); # Ensure we only ever try to update source, not build. $metadataModule->phases()->phases('update'); return $metadataModule; } # Function: getDependenciesModule # # Static. Returns a that can be used to download the # 'kde-build-metadata' module, which itself contains module dependencies # in the KDE build system. The module is meant to be held by the # # Parameters: # ctx - the for this script execution. sub getDependenciesModule { my $ctx = assert_isa(shift, 'ksb::BuildContext'); return _createMetadataModule($ctx, 'kde-build-metadata'); } # Function: getProjectMetadataModule # # Static. Returns a that can be used to download the # 'repo-metadata' module, which itself contains information on each # repository in the KDE build system (though currently not # dependencies). The module is meant to be held by the # # Parameters: # ctx - the for this script execution. sub getProjectMetadataModule { my $ctx = assert_isa(shift, 'ksb::BuildContext'); return _createMetadataModule($ctx, 'sysadmin/repo-metadata'); } # Function: _expandModuleCandidates # # A class method which goes through the modules in our search list (assumed to # be found in kde-projects), expands them into their equivalent git modules, # and returns the fully expanded list. Non kde-projects modules cause an error, # as do modules that do not exist at all within the database. # # *Note*: Before calling this function, the kde-projects database itself must # have been downloaded first. See getProjectMetadataModule, which ties to the # BuildContext. # # Modules that are part of a module-set requiring a specific branch, that don't # have that branch, are still listed in the return result since there's no way # to tell that the branch won't be there. These should be removed later. # # Parameters: # ctx - The in use. # moduleSearchItem - The search description to expand in ksb::Modules. See # _projectPathMatchesWildcardSearch for a description of the syntax. # # Returns: # @modules - List of expanded git . # # Throws: # Runtime - if the kde-projects database was required but couldn't be # downloaded or read. # Runtime - if the git-desired-protocol is unsupported. # Runtime - if an "assumed" kde-projects module was not actually one. sub _expandModuleCandidates { my $self = assert_isa(shift, 'ksb::ModuleSet::KDEProjects'); my $ctx = assert_isa(shift, 'ksb::BuildContext'); my $moduleSearchItem = shift; my @allModuleResults = $ctx-> getProjectDataReader()-> getModulesForProject($moduleSearchItem); - croak_internal ("Unknown KDE project: $moduleSearchItem") unless @allModuleResults; + croak_runtime ("Unknown KDE project: $moduleSearchItem") + unless @allModuleResults; # It's possible to match modules which are marked as inactive on # projects.kde.org, elide those. my @activeResults = grep { $_->{'active'} } (@allModuleResults); if (!@activeResults) { warning (" y[b[*] Module y[$moduleSearchItem] is apparently a KDE collection, but contains no\n" . "active modules to build!"); my $count = scalar @allModuleResults; if ($count > 0) { warning ("\tAlthough no active modules are available, there were\n" . "\t$count inactive modules. Perhaps the git modules are not ready?"); } } # Setup module options. my @moduleList; my @ignoreList = $self->modulesToIgnore(); foreach (@activeResults) { my $result = $_; my $newModule = ksb::Module->new($ctx, $result->{'name'}); $self->_initializeNewModule($newModule); $newModule->setOption('repository', $result->{'repo'}); $newModule->setOption('#xml-full-path', $result->{'fullName'}); $newModule->setOption('#branch:stable', undef); $newModule->setOption('#found-by', $result->{found_by}); $newModule->setScmType('proj'); if (none_true( map { ksb::KDEProjectsReader::_projectPathMatchesWildcardSearch( $result->{'fullName'}, $_ ) } (@ignoreList))) { push @moduleList, $newModule; } else { debug ("--- Ignoring matched active module $newModule in module set " . $self->name()); } }; return @moduleList; } # This function should be called after options are read and build metadata is # available in order to convert this module set to a list of ksb::Module. # Any modules ignored by this module set are excluded from the returned list. # The modules returned have not been added to the build context. sub convertToModules { my ($self, $ctx) = @_; my @moduleList; # module names converted to ksb::Module objects. my %foundModules; # Setup default options for each module # Extraction of relevant kde-project modules will be handled immediately # after this phase of execution. for my $moduleItem ($self->modulesToFind()) { # We might have already grabbed the right module recursively. next if exists $foundModules{$moduleItem}; # eval in case the YAML processor throws an exception. undef $@; my @candidateModules = eval { $self->_expandModuleCandidates($ctx, $moduleItem); }; if ($@) { die $@ if had_an_exception(); # Forward exception objects up croak_runtime("The KDE Project database could not be understood: $@"); } my @moduleNames = map { $_->name() } @candidateModules; @foundModules{@moduleNames} = (1) x @moduleNames; push @moduleList, @candidateModules; } if (not scalar @moduleList) { warning ("No modules were defined for the module-set " . $self->name()); warning ("You should use the g[b[use-modules] option to make the module-set useful."); } return @moduleList; } 1; diff --git a/modules/ksb/UserInterface/TTY.pm b/modules/ksb/UserInterface/TTY.pm index 49a1dda..c0ff2fc 100755 --- a/modules/ksb/UserInterface/TTY.pm +++ b/modules/ksb/UserInterface/TTY.pm @@ -1,332 +1,343 @@ #!/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 Mojo::Util qw(dumper); use ksb::BuildException; use ksb::StatusView; use ksb::Util; use ksb::Debug; use ksb::UserInterface::DependencyGraph; use Mojo::Promise; 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('debug'); $self->ua->server->app->log->level('fatal'); return $self; } sub _check_error { my $tx = shift; my $err = $tx->error or return $tx; + + # Most ksb::BuildException should be thrown as a json object that will be + # decoded by ->catch handler + my $err_block = $tx->res->json; + die $err_block if $err_block; + + # But just in case, try to extract an error message. my $body = $tx->res->body // ''; open my $fh, '<', \$body; my ($first_line) = <$fh> // ''; $err->{message} .= "\n$first_line" if $first_line; die $err; }; sub dumpDependencyTree { my ($ua, $tree) = @_; my $errors = $tree->{errors} // {}; my $errorCount = $errors->{errors} // 0; if ($errorCount != 0) { say "Unable to resolve dependencies, encountered $errorCount errors"; return Mojo::Promise->new->reject(1); } my $data = $tree->{data}; if (!defined($data)) { say "Unable to resolve dependencies, did not obtain (valid) results"; return Mojo::Promise->new->reject(1); } return $ua->get_p('/modulesFromCommand')->then(sub { my $tx = _check_error(shift); my @modules = map { $_->{name} } @{$tx->result->json}; my $err = ksb::UserInterface::DependencyGraph::printTrees( $data, @modules ); return Mojo::Promise->new->reject(1) if $err; return 0; }); } # Returns a promise chain to handle the "debug and show some output but don't # actually build anything" use case. sub _runModeDebug { my $self = shift; my $app = $self->app; my $ua = $self->ua; my %debugFlags = %{$app->ksb->{debugFlags}}; $app->log->debug("Run mode: DEBUG"); if ($debugFlags{'dependency-tree'}) { $app->log->debug("Dumping dependency tree"); return $ua->get_p('/moduleGraph')->then(sub { my $tx = _check_error(shift); my $tree = $tx->result->json; return dumpDependencyTree($ua, $tree); }); } elsif ($debugFlags{'list-build'} || $debugFlags{'print-modules'}) { $app->log->debug("Listing modules to build"); return $ua->get_p('/modules')->then(sub { my $tx = _check_error(shift); my @modules = @{$tx->result->json}; say $_ foreach @modules; return 0; }); } # Bail early return Mojo::Promise->new->reject('Told to debug for no reason'); } # Returns a promise chain to handle the normal build case. sub _runModeBuild { my $self = shift; my $module_failures_ref = shift; my $ui = $self->ui; my $ua = $self->ua; my $app = $self->app; $app->log->debug("Run mode: BUILD"); # 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 # We track the build using a JSON-based event stream which is published as # a WebSocket IPC using Mojolicious. We need to return a promise which # ultimately resolves to the exit status of 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_ref}, $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_ref}); } } }); $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; })->finally(sub { $event_stream->say("]"); $event_stream->close(); my $logdir = $ctx->getLogDir(); note ("Your logs are saved in file://y[$logdir]"); }); } # 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 $ua = $self->ua; my $app = $self->app; my $result = 0; # notes errors from module builds or internal errors my @module_failures; $app->log->debug("Sending test msg to backend"); # 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}; $app->log->debug("Test msg success, sending selectors to build"); # Tell the backend which modules to build. return $ua->post_p('/modules', $headers, json => $selectorsRef); })->then(sub { my $tx = shift; _check_error($tx); my $result = eval { $tx->result->json->[0]; }; $app->log->debug("Selectors sent to backend, $result"); # We've received a successful response from the backend that it's able to # build the requested modules, so proceed as appropriate based on the run mode # the user has requested. return $self->_runModeDebug() if (%{$app->ksb->{debugFlags} // 0}); return $self->_runModeBuild(\@module_failures); })->then(sub { # Build done, value comes from runMode promise above $result ||= shift; $app->log->debug("Chosen run mode complete, result (0 == success): $result"); })->catch(sub { # Catches all errors in any of the prior promises my $err = shift; - if (ref $err) { - say STDERR "Caught an error: ", dumper($err); + if (ref $err eq 'HASH') { + # JSON response decoded to a hashref + say STDERR "Error encountered during build:" + if ($err->{exception_type} // '') eq 'Internal'; + say STDERR $err->{message}; } else { say STDERR "Caught an error: $err"; } # See if we made it to an rc-file - my $ctx = $app->ksb->context(); - my $rcFile = $ctx ? $ctx->rcFile() // 'Unknown' : undef; - say STDERR "Using configuration file found at $rcFile" if $rcFile; + # TODO: Put this into a 'show debugging info' type of option + #my $ctx = $app->ksb->context(); + #my $rcFile = $ctx ? $ctx->rcFile() // 'Unknown' : undef; + #say STDERR "Using configuration file found at $rcFile" if $rcFile; $result = 1; # error })->wait; # _report_on_failures(@module_failures); 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; diff --git a/modules/web/BackendServer.pm b/modules/web/BackendServer.pm index d7109b0..8e65a49 100644 --- a/modules/web/BackendServer.pm +++ b/modules/web/BackendServer.pm @@ -1,336 +1,352 @@ package web::BackendServer; # Make this subclass a Mojolicious app use Mojo::Base 'Mojolicious'; use Mojo::Util qw(trim); use ksb::Application; use ksb::Debug qw(pretending); use ksb::dto::ModuleGraph; use ksb::dto::ModuleInfo; use ksb::DependencyResolver; use Cwd; # This is written in a kind of domain-specific language for Mojolicious for # now, to setup a web server backend for clients / frontends to communicate # with. # See https://mojolicious.org/perldoc/Mojolicious/Guides/Tutorial has 'options'; has 'selectors'; sub new { my ($class, @opts) = @_; return $class->SUPER::new(options => [@opts], ksbhome => getcwd()); } # Adds a helper method to each HTTP context object to return the # ksb::Application class in use sub make_new_ksb { my $c = shift; # ksb::Application startup uses current dir to find right rc-file # by default. chdir($c->app->{ksbhome}); my $app = ksb::Application->new->setHeadless; # Note that we shouldn't /have/ any selectors at this point, it's now a # separate user input. my @selectors = $app->establishContext(@{$c->app->{options}}); $c->app->selectors([@selectors]); # Reset log handler my $ctx = $app->context(); if (pretending()) { # Mojolicious will install a file watch on the log path so it has to exist # if we set it. Instead just de-spam the output to TTY for now. $c->app->log->level('error'); } else { $c->app->log(Mojo::Log->new( path => $ctx->getLogDirFor($ctx) . "/mojo-backend.log" )); } if(@selectors) { $c->app->log->info("Module selectors requested:" . join(', ', @selectors)); } else { $c->app->log->info("All modules to be built"); } return $app; } # Package-shared variables for helpers and closures my $LAST_RESULT; my $BUILD_PROMISE; my $IN_PROGRESS; my $KSB_APP; sub startup { my $self = shift; # Force use of 'modules/web' as the home directory, would normally be # 'modules' alone $self->home($self->home->child('web')); # Fixup templates and public base directories $self->static->paths->[0] = $self->home->child('public'); $self->renderer->paths->[0] = $self->home->child('templates'); $self->helper(ksb => sub { my ($c, $new_ksb) = @_; $KSB_APP = $new_ksb if $new_ksb; $KSB_APP //= make_new_ksb($c); return $KSB_APP; }); $self->helper(in_build => sub { $IN_PROGRESS }); $self->helper(context => sub { shift->ksb->context() }); my $r = $self->routes; $self->_generateRoutes; return; } +# Generates HTTP response for ksb::BuildExceptions. Note that the 'to_string' +# overload is called by Mojo if you don't specifically copy the needed values +# into a plain map. +sub _renderException { + my ($self, $c, $err) = @_; + + my $out = { }; + + if (ref $err eq 'STRING') { + $out->{message} = $err; + $out->{exception_type} = 'Runtime'; + } else { + $out->{message} = $@->{message}; + $out->{exception_type} = $@->{exception_type}; + } + + return $c->render(json => $out, status => 400); +} + sub _generateRoutes { my $self = shift; my $r = $self->routes; $r->get('/' => 'index'); $r->post('/reset' => sub { my $c = shift; if ($c->in_build || !defined $LAST_RESULT) { $c->res->code(400); return $c->render; } my $old_result = $LAST_RESULT; $c->ksb(make_new_ksb($c)); undef $LAST_RESULT; $c->render(json => { last_result => $old_result }); }); $r->get('/context/options' => sub { my $c = shift; $c->render(json => $c->ksb->context()->{options}); }); $r->get('/context/options/:option' => sub { my $c = shift; my $ctx = $c->ksb->context(); my $opt = $c->param('option') or do { - return $c->render(text => "Invalid request sent", status => 400); + return $self->_renderException($c, 'Invalid request sent'); }; if (defined $ctx->{options}->{$opt}) { $c->render(json => { $opt => $ctx->{options}->{$opt} }); } else { $c->reply->not_found; } }); $r->get('/modules' => sub { my $c = shift; eval { $c->render(json => [$c->ksb->modules()]); }; - if ($@) { - return $c->render(text => $@->{message}, status => 400); - } + return $self->_renderException($c, $@) if $@; } => 'module_lookup'); $r->get('/known_modules' => sub { my $c = shift; my $resolver = $c->ksb->{module_resolver}; my @setsAndModules = @{$resolver->{inputModulesAndOptions}}; my @output = map { $_->isa('ksb::ModuleSet') ? [ $_->name(), $_->moduleNamesToFind() ] : $_->name() # should be a ksb::Module } @setsAndModules; $c->render(json => \@output); }); $r->post('/modules' => sub { my $c = shift; my $selectorList = $c->req->json; my $build_all = $c->req->headers->header('X-BuildAllModules'); my $log = $c->app->log; # Remove empty selectors my @selectors = grep { !!$_ } map { trim($_ // '') } @{$selectorList}; $log->warn("We're already in a build") if $c->in_build; if ($build_all) { $log->info("User requested to build all modules"); } else { my $exactList = $c->req->text; $log->info("User requested to build $exactList: [" . join(', ', @selectors) . "]"); } # If not building all then ensure there's at least one module to build if ($c->in_build || !$selectorList || (!@selectors && !$build_all) || (@selectors && $build_all)) { $log->error("Something was wrong with modules to assign to build"); - return $c->render(text => "Invalid request sent", status => 400); + return $self->_renderException($c, 'Invalid selectors requested to build'); } eval { my $workload = $c->ksb->modulesFromSelectors(@selectors); $c->ksb->setModulesToProcess($workload); }; - if ($@) { - return $c->render(text => $@->{message}, status => 400); - } + return $self->_renderException($c, $@) + if $@; my $numSels = scalar @selectors; $c->render(json => ["$numSels handled"]); }, 'post_modules'); $r->get('/module/:modname' => sub { my $c = shift; my $name = $c->stash('modname'); my $module = $c->ksb->context()->lookupModule($name); if (!$module) { $c->render(template => 'does_not_exist'); return; } my $opts = { options => $module->{options}, persistent => $c->ksb->context()->{persistent_options}->{$name}, }; $c->render(json => $opts); }); $r->get('/module/:modname/logs/error' => sub { my $c = shift; my $name = $c->stash('modname'); $c->render(text => "TODO: Error logs for $name"); }); $r->get('/config' => sub { my $c = shift; $c->render(text => $c->ksb->context()->rcFile()); }); $r->post('/config' => sub { # TODO If new filename can be loaded, load it and reset application object die "Unimplemented"; }); $r->get('/build-metadata' => sub { die "Unimplemented"; }); $r->websocket('/events' => sub { my $c = shift; $c->inactivity_timeout(0); my $ctx = $c->ksb->context(); my $monitor = $ctx->statusMonitor(); # Send prior events the receiver wouldn't have received yet my @curEvents = $monitor->events(); $c->send({json => \@curEvents}); # Hook up an event handler to send future events as they're generated $monitor->on(newEvent => sub { my ($monitor, $resultRef) = @_; $c->on(drain => sub { $c->finish }) if ($resultRef->{event} eq 'build_done'); $c->send({json => [ $resultRef ]}); }); }); $r->get('/event_viewer' => sub { my $c = shift; $c->render(template => 'event_viewer'); }); $r->get('/building' => sub { my $c = shift; $c->render(text => $c->in_build ? 'True' : 'False'); }); $r->get('/moduleGraph' => sub { my $c = shift; my $work = $c->app->ksb->workLoad() // {}; my $info = $work->{dependencyInfo}; if (defined($info)) { my $dto = ksb::dto::ModuleGraph::dependencyInfoToDto($info); $c->render(json => $dto); } else { $c->reply->not_found; } }); $r->get('/modulesFromCommand' => sub { my $c = shift; my $work = $c->app->ksb->workLoad() // {}; my $info = $work->{dependencyInfo}; if (!defined($info) || ksb::DependencyResolver::hasErrors($info) || !exists $info->{graph}) { $c->reply->not_found; return; } my $graph = $info->{graph}; my $modules = $work->{modulesFromCommand}; my @dtos = ksb::dto::ModuleInfo::selectedModulesToDtos( $graph, $modules ); # # Trap for the unwary: make sure to return a reference. # Without this Mojolicious won't encode the array properly # $c->render(json => \@dtos); }); $r->post('/build' => sub { my $c = shift; if ($c->in_build) { $c->res->code(400); $c->render(text => 'Build already in progress, cancel it first.'); return; } $c->app->log->debug('Starting build'); $IN_PROGRESS = 1; $BUILD_PROMISE = $c->ksb->startHeadlessBuild->finally(sub { my ($result) = @_; $c->app->log->debug("Build done"); $IN_PROGRESS = 0; return $LAST_RESULT = $result; }); $c->render(text => $c->url_for('event_viewer')->to_abs->to_string); }); } 1;