Git commit ea5ed02ce5cd08b70af1db171279600d373d008b by Michael Pyne. Committed on 08/10/2018 at 17:44. Pushed by mpyne into branch 'make_it_mojo'.
mojo: Add a backend-support class to allow headless ops. This adds a backend support class (BackendServer, no ksb:: prefix for now since Mojolicious uses the basedir for this module to look for supporting files, like the HTML/embedded Perl templates). To use the backend, for now run kdesrc-build with only the option --backend, which will start the server and output the URL to stdout. This introduces another class as well (ksb::UserInterface::TTY) to serve as the default user interface, and makes kdesrc-build the driver for this interface (instead of the more generic ksb::Application). This uses the backend interface directly as well, enforcing its normal use. There's a lot more to do to refactor a clean separation between model/controller and the user interface / "view" here, especially at the TTY, but this code does at least work and I think it's a clean enough foundation that all the heavy lifting should be done. M +0 -11 doc/index.docbook M +0 -23 doc/man-kdesrc-build.1.docbook M +69 -42 kdesrc-build A +251 -0 modules/BackendServer.pm M +50 -579 modules/ksb/Application.pm M +1 -10 modules/ksb/BuildContext.pm A +227 -0 modules/ksb/UserInterface/TTY.pm M +2 -1 modules/ksb/Util.pm A +206 -0 modules/templates/event_viewer.html.ep A +112 -0 modules/templates/index.html.ep A +31 -0 modules/templates/layouts/default.html.ep https://commits.kde.org/kdesrc-build/ea5ed02ce5cd08b70af1db171279600d373d008b diff --git a/doc/index.docbook b/doc/index.docbook index 3ccbe25..1a03236 100644 --- a/doc/index.docbook +++ b/doc/index.docbook @@ -3265,17 +3265,6 @@ kdepim: master </listitem> </varlistentry> -<varlistentry id="cmdline-launch-browser"> -<term><parameter>--launch-browser</parameter></term> -<listitem><para> - When &kdesrc-build; is already running (that is, in a separate terminal - session), you can use this option to have &kdesrc-build; launch a web - browser that will show a web page showing the status of the build process. - This does not require Internet access, and can be more convenient than the - command-line output. -</para></listitem> -</varlistentry> - <varlistentry id="cmdline-no-rebuild-on-fail"> <term><parameter>--no-rebuild-on-fail</parameter></term> <listitem><para> diff --git a/doc/man-kdesrc-build.1.docbook b/doc/man-kdesrc-build.1.docbook index b04f6be..8abdca5 100644 --- a/doc/man-kdesrc-build.1.docbook +++ b/doc/man-kdesrc-build.1.docbook @@ -51,12 +51,6 @@ <arg rep="repeat"><replaceable>Module name</replaceable></arg> </cmdsynopsis> -<cmdsynopsis> -<command>kdesrc-build</command> -<arg choice='plain'>--launch-browser</arg> -</cmdsynopsis> -</refsynopsisdiv> - <refsect1> <title>DESCRIPTION</title> @@ -580,23 +574,6 @@ combining short options into one at this point. (E.g. running </listitem> </varlistentry> -<varlistentry> -<term> -<option>--launch-browser</option> -</term> - -<listitem> -<para> - When <command>kdesrc-build</command> is already running (in a separate - terminal), you can run this command to run a Web browser to show a web page - that will track the status of the running kdesrc-build session. The - browser is opened using the <command>xdg-open</command> so this requires - your environment to be configured to associate your preferred browser to - web pages. -</para> -</listitem> -</varlistentry> - <varlistentry> <term> <option>--run=<replaceable>foo</replaceable></option> diff --git a/kdesrc-build b/kdesrc-build index 299cd30..680a2e7 100755 --- a/kdesrc-build +++ b/kdesrc-build @@ -36,10 +36,6 @@ use FindBin qw($RealBin); use lib "$RealBin/../share/kdesrc-build/modules"; use lib "$RealBin/modules"; -# Force all symbols to be in this package. We can tell if we're being called -# through require/eval/etc. by using the "caller" function. -package main; - use strict; use warnings; @@ -48,10 +44,15 @@ use Data::Dumper; use File::Find; # For our lndir reimplementation. use File::Path qw(remove_tree); +use Mojo::IOLoop; +use Mojo::Server::Daemon; + use ksb::Debug; use ksb::Util; use ksb::Version qw(scriptVersion); use ksb::Application; +use ksb::UserInterface::TTY; +use BackendServer; use 5.014; # Require Perl 5.14 @@ -229,6 +230,34 @@ sub findMissingModules return @missingModules; } +# Rather than running an interactive build, launches a web server that can be +# interacted with by and outside user interface, printing the URL to the server +# on stdout and then remaining in the foreground until killed. +sub launchBackend +{ + # Manually setup the daemon so that we can figure out what port it + # ends up on. + my $daemon = Mojo::Server::Daemon->new( + app => BackendServer->new, + listen => ['http://localhost'], + silent => 1, + ); + + $daemon->start; # Grabs the socket to listen on + + my $port = $daemon->ports->[0] or do { + say STDERR "Can't autodetect which TCP port was assigned!"; + exit 1; + }; + + say "http://localhost:$port"; + + Mojo::IOLoop->start + unless Mojo::IOLoop->is_running; + + exit 0; +} + # Script starts. # Ensure some critical Perl modules are available so that the user isn't surprised @@ -247,17 +276,10 @@ EOF exit 1; } -# Adding in a way to load all the functions without running the program to -# enable some kind of automated QA testing. -if (defined caller && caller eq 'test') -{ - my $scriptVersion = scriptVersion(); - say "kdesrc-build being run from testing framework, BRING IT."; - say "kdesrc-build is version $scriptVersion"; - return 1; -} +# Drop here if we're in backend-only mode. +launchBackend() + if (scalar @ARGV == 1 && $ARGV[0] eq '--backend'); -my $app; our @atexit_subs; END { @@ -270,16 +292,8 @@ END { # Use some exception handling to avoid ucky error messages eval { - $app = ksb::Application->new; - - my @selectors = $app->establishContext(@ARGV); - my @modules = $app->modulesFromSelectors(@selectors); - if (!@modules) { - say "Nothing to build"; - exit 0; - } - - $app->setModulesToProcess(@modules); + my $app = BackendServer->new(@ARGV); + my $ui = ksb::UserInterface::TTY->new($app); # Hack for debugging current state. if (exists $ENV{KDESRC_BUILD_DUMP_CONTEXT}) { @@ -288,37 +302,50 @@ eval # This method call dumps the first list with the variables named by the # second list. - print Data::Dumper->Dump([$app->context()], [qw(ctx)]); + print Data::Dumper->Dump([$app->ksb->context()], [qw(ctx)]); } - push @atexit_subs, sub { $app->finish(99) }; - my $result = $app->runAllModulePhases(); + push @atexit_subs, sub { $app->ksb->finish(99) }; + + # TODO: Reimplement --print-modules, --query modes, which wouldn't go through ->start + my $result = $ui->start(); @atexit_subs = (); # Clear exit handlers - $app->finish($result); + + # env driver is just the ~/.config/kde-env-*.sh, session driver is that + ~/.xsession + my $ctx = $app->context; + if ($ctx->getOption('install-environment-driver') || + $ctx->getOption('install-session-driver')) + { + ksb::Application::_installCustomSessionDriver($ctx); + } + + # Exits the script + my $logdir = $app->context()->getLogDir(); + note ("Your logs are saved in y[$logdir]"); + exit $result; }; if (my $err = $@) { if (had_an_exception()) { - print "kdesrc-build encountered an exceptional error condition:\n"; - print " ========\n"; - print " $err\n"; - print " ========\n"; - print "\tCan't continue, so stopping now.\n"; - - if ($err->{'exception_type'} eq 'Internal') { - print "\nPlease submit a bug against kdesrc-build on https://bugs.kde.org/\n" - } + say "kdesrc-build encountered an exceptional error condition:"; + say " ========"; + say " $err"; + say " ========"; + say "\tCan't continue, so stopping now."; + + say "\nPlease submit a bug against kdesrc-build on https://bugs.kde.org/" + if ($err->{exception_type} eq 'Internal'); } else { - # We encountered an error. - print "Encountered an error in the execution of the script.\n"; - print "The error reported was $err\n"; - print "Please submit a bug against kdesrc-build on https://bugs.kde.org/\n"; + # An exception was raised, but not one that kdesrc-build generated + say "Encountered an error in the execution of the script."; + say "The error reported was $err"; + say "Please submit a bug against kdesrc-build on https://bugs.kde.org/"; } exit 99; } -# vim: set et sw=4 ts=4 fdm=marker: +# vim: set et sw=4 ts=4: diff --git a/modules/BackendServer.pm b/modules/BackendServer.pm new file mode 100644 index 0000000..6b689f2 --- /dev/null +++ b/modules/BackendServer.pm @@ -0,0 +1,251 @@ +package BackendServer; + +# Make this subclass a Mojolicious app +use Mojo::Base 'Mojolicious'; +use Mojo::Util qw(trim); + +use ksb::Application; + +# 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]); +} + +# Adds a helper method to each HTTP context object to return the +# ksb::Application class in use +sub make_new_ksb +{ + my $c = shift; + my $app = ksb::Application->new->setHeadless; + + my @selectors = $app->establishContext(@{$c->app->{options}}); + $c->app->selectors([@selectors]); + $c->app->log->info("Selectors are ", join(', ', @selectors)); + + 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; + + $self->helper(ksb => sub { + my ($c, $new_ksb) = @_; + + $KSB_APP //= make_new_ksb($c); + $KSB_APP = $new_ksb if $new_ksb; + + return $KSB_APP; + }); + + $self->helper(in_build => sub { $IN_PROGRESS }); + $self->helper(context => sub { shift->ksb->context() }); + + my $r = $self->routes; + $self->_generateRoutes; + + return; +} + +sub _generateRoutes { + my $self = shift; + my $r = $self->routes; + + $r->get('/' => sub { + my $c = shift; + my $app = $c->ksb(); + my $isApp = $app->isa('ksb::Application') ? 'app' : 'not app'; + $c->stash(app => "Application is a $isApp"); + $c->render(template => 'index'); + } => '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 { + $c->res->code(400); + return $c->render; + }; + + if (defined $ctx->{options}->{$opt}) { + $c->render(json => { $opt => $ctx->{options}->{$opt} }); + } + else { + $c->res->code(404); + $c->reply->not_found; + } + }); + + $r->get('/modules' => sub { + my $c = shift; + $c->render(json => $c->ksb->context()->moduleList()); + } => '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'); + + # Remove empty selectors + my @modules = grep { !!$_ } map { trim($_ // '') } @{$selectorList}; + + # If not building all then ensure there's at least one module to build + if ($c->in_build || !$selectorList || (!@modules && !$build_all) || (@modules && $build_all)) { + $c->app->log->error("Something was wrong with modules to assign to build"); + return $c->render(text => "Invalid request sent", status => 400); + } + + eval { + @modules = $c->ksb->modulesFromSelectors(@modules); + $c->ksb->setModulesToProcess(@modules); + }; + + if ($@) { + return $c->render(text => $@->{message}, status => 400); + } + + my $numSels = @modules; # count + + $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->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; diff --git a/modules/ksb/Application.pm b/modules/ksb/Application.pm index e444ceb..efb8587 100644 --- a/modules/ksb/Application.pm +++ b/modules/ksb/Application.pm @@ -73,6 +73,10 @@ sub new return $self; } +# Call after establishContext (to read in config file and do one-time metadata +# reading). +# +# Need to call this before you call startHeadlessBuild sub setModulesToProcess { my ($self, @modules) = @_; @@ -80,6 +84,18 @@ sub setModulesToProcess $self->context()->addModule($_) foreach @modules; + + # i.e. niceness, ulimits, etc. + $self->context()->setupOperatingEnvironment(); +} + +# Sets the application to be non-interactive, intended to make this suitable as +# a backend for a Mojolicious-based web server with a separate U/I. +sub setHeadless +{ + my $self = shift; + $self->{run_mode} = 'headless'; + return $self; } # Method: _readCommandLineOptionsAndSelectors @@ -349,10 +365,6 @@ sub establishContext my %ignoredSelectors = map { $_, 1 } @{$cmdlineGlobalOptions->{'ignore-modules'}}; - if (exists $cmdlineGlobalOptions->{'launch-browser'}) { - _launchStatusViewerBrowser(); # does not return - } - my @startProgramAndArgs = @{$cmdlineGlobalOptions->{'start-program'}}; delete @{$cmdlineGlobalOptions}{qw/ignore-modules start-program/}; @@ -622,31 +634,26 @@ sub _resolveModuleDependencies return @modules; } -# Runs all update, build, install, etc. phases. Basically this *is* the -# script. -# The metadata module must already have performed its update by this point. -sub runAllModulePhases +# Similar to the old interactive runAllModulePhases. Actually performs the +# build for the modules selected by setModulesToProcess. +# +# Returns a Mojo::Promise that must be waited on. The promise resolves to +# return a single success/failure result; use the event handler for now to get +# more detail during a build. +sub startHeadlessBuild { my $self = shift; my $ctx = $self->context(); - my @modules = $self->modules(); + $ctx->statusMonitor()->createBuildPlan($ctx); - if ($ctx->getOption('print-modules')) { - for my $m (@modules) { - say ((" " x ($m->getOption('#dependency-level', 'module') // 0)), "$m"); - } - return 0; # Abort execution early! - } + my $promiseChain = ksb::PromiseChain->new; + my $startPromise = Mojo::Promise->new; - $self->context()->setupOperatingEnvironment(); # i.e. niceness, ulimits, etc. + # These succeed or die outright + $startPromise = _handle_updates ($ctx, $promiseChain, $startPromise); + $startPromise = _handle_build ($ctx, $promiseChain, $startPromise); - # After this call, we must run the finish() method - # to cleanly complete process execution. - if (!pretending() && !$self->context()->takeLock()) - { - print "$0 is already running!\n"; - exit 1; # Don't finish(), it's not our lockfile!! - } + die "Can't obtain build lock" unless $ctx->takeLock(); # Install signal handlers to ensure that the lockfile gets closed. _installSignalHandlers(sub { @@ -655,79 +662,32 @@ sub runAllModulePhases $self->finish(5); }); - my $runMode = $self->runMode(); + $startPromise->resolve; # allow build to start + my $promise = $promiseChain->makePromiseChain($startPromise)->finally(sub { + my @results = @_; + my $result = 0; # success, non-zero is failure - if ($runMode eq 'query') { - my $queryMode = $ctx->getOption('query', 'module'); + # Must use ! here to make '0 but true' hack work + $result = 1 if defined first { !($_->[0] // 1) } @results; - # Default to ->getOption as query method. - # $_[0] is short name for first param. - my $query = sub { $_[0]->getOption($queryMode) }; - $query = sub { $_[0]->fullpath('source') } if $queryMode eq 'source-dir'; - $query = sub { $_[0]->fullpath('build') } if $queryMode eq 'build-dir'; - $query = sub { $_[0]->installationPath() } if $queryMode eq 'install-dir'; - $query = sub { $_[0]->fullProjectPath() } if $queryMode eq 'project-path'; - $query = sub { ($_[0]->scm()->_determinePreferredCheckoutSource())[0] // '' } - if $queryMode eq 'branch'; + $ctx->statusMonitor()->markBuildDone(); + $ctx->closeLock(); - if (@modules == 1) { - # No leading module name, just the value - say $query->($modules[0]); + my $failedModules = join(',', map { "$_" } $ctx->listFailedModules()); + if ($failedModules) { + # We don't clear the list of failed modules on success so that + # someone can build one or two modules and still use + # --rebuild-failures + $ctx->setPersistentOption('global', 'last-failed-module-list', $failedModules); } - else { - for my $m (@modules) { - say "$m: ", $query->($m); - } - } - - return 0; - } - my $result; + $ctx->storePersistentOptions(); + _cleanup_log_directory($ctx); - if ($runMode eq 'build') - { - # Build then install packages - $result = _handle_async_build ($ctx); - } - elsif ($runMode eq 'install') - { - # Install directly - # TODO: Merge with previous by splitting 'install' into a separate - # phase - $result = _handle_install ($ctx); - } - elsif ($runMode eq 'uninstall') - { - $result = _handle_uninstall ($ctx); - } - - _cleanup_log_directory($ctx) if $ctx->getOption('purge-old-logs'); - _output_failed_module_lists($ctx); - - # Record all failed modules. Unlike the 'resume-list' option this doesn't - # include any successfully-built modules in between failures. - my $failedModules = join(',', map { "$_" } $ctx->listFailedModules()); - if ($failedModules) { - # We don't clear the list of failed modules on success so that - # someone can build one or two modules and still use - # --rebuild-failures - $ctx->setPersistentOption('global', 'last-failed-module-list', $failedModules); - } - - # env driver is just the ~/.config/kde-env-*.sh, session driver is that + ~/.xsession - if ($ctx->getOption('install-environment-driver') || - $ctx->getOption('install-session-driver')) - { - _installCustomSessionDriver($ctx); - } - - my $color = 'g[b['; - $color = 'r[b[' if $result; - - info ("${color}", $result ? ":-(" : ":-)") unless pretending(); + return $result; + }); - return $result; + return $promise; } # Method: finish @@ -743,11 +703,6 @@ sub finish my $ctx = $self->context(); my $exitcode = shift // 0; - # This is created even under --pretend, make sure it's removed - my $run = $ENV{XDG_RUNTIME_DIR} // 'tmp'; - my $path = "$run/kdesrc-build-status-server"; - unlink $path if -e $path; - if (pretending() || $self->{_base_pid} != $$) { # Abort early if pretending or if we're not the same process # that was started by the user (for async mode) @@ -1475,13 +1430,6 @@ sub _handle_build # If there's an update phase we need to depend on it and show status if (my $updatePromise = $promiseChain->promiseFor("$module/update")) { $promiseChain->addDep("$module/build", "$module/update"); - $updatePromise->catch(sub { - my $err = shift; - # TODO: The error msg needs to be handled by status viewer. - $ctx->statusViewer()->_clearLine(); - error ("\ty[b[$module] failed to update! $err"); - return $updatePromise; # Don't change the promise we're just whining - }); } }; @@ -1508,450 +1456,6 @@ sub _handle_build sub { $ctx->unsetPersistentOption('global', 'resume-list') }); } -# Finds a decent port for the monitoring server, creates a file at a known -# location with the URL that will match the server, and returns the port and -# path to the file (so that it may be unlinked once the server is shutdown) -sub _find_open_monitor_port -{ - # Ensure the file containing our listen URL is available. - my $run = $ENV{XDG_RUNTIME_DIR}; - if (!$run) { - note (" b[r[*] b[y[XDG_RUNTIME_DIR] is not set, using /tmp for now"); - $run = '/tmp'; - } - - my $path = "$run/kdesrc-build-status-server"; - error (" b[r[*] stale status server runtime socket file leftover, removing.") - if (-e $path); - - # We set sticky bit (in the 01666) to indicate this file should not be - # removed during long-running builds (e.g. by systemd). - sysopen (my $fh, $path, O_CREAT | O_WRONLY, 01666) or do { - error (" b[r[*] Unable to open status server runtime socket file, external viewers won't work."); - return; - }; - - # With the file open we can generate a port and create a URL - my $port = Mojo::IOLoop::Server->generate_port; - - say $fh "http://localhost:$port"; - close $fh or do { - error (" b[y[*] Received an error closing runtime socket file: $!"); - unlink ($path); - return; - }; - - return ($port, $path); -} - -# Returns an HTML page suitable for display in a modern browser, that can read -# status events over a WebSocket -sub _generate_status_viewer_page -{ - my $url = shift; - my $templater = Mojo::Template->new; - - my $template = <<'EOF'; -% my $url = shift; -<!DOCTYPE html> -<html> -<head> - <meta charset="utf-8"/> - <title>kdesrc-build status viewer</title> - - <style> -td.pending { - background-color: lightgray; -} - -td.done { - background-color: lightblue; -} - -td.done.success { - background-color: lightgreen; -} - -td.done.error { - background-color: pink; -} - </style> -</head> - -<body> - <h1>kdesrc-build status</h1> - <div id="divStatus"> - Building... - </div> - <table id="tblResult"> - <tr><th>Module</th><th>Update</th><th>Build / Install</th></tr> - </table> - <div id="logEntries"> - </div> -</body> - -<script> - let addRow = (moduleName) => { - let eventTable = document.getElementById('tblResult'); - let newRow = document.createElement('tr'); - let moduleNameCell = document.createElement('td'); - let updateDoneCell = document.createElement('td'); - let buildDoneCell = document.createElement('td'); - - moduleNameCell.textContent = moduleName; - updateDoneCell.id = 'updateCell_' + moduleName; - updateDoneCell.className = 'pending'; - buildDoneCell.id = 'buildCell_' + moduleName; - buildDoneCell.className = 'pending'; - - newRow.appendChild(moduleNameCell); - newRow.appendChild(updateDoneCell); - newRow.appendChild(buildDoneCell); - eventTable.appendChild(newRow); - } - - let handleEvent = (ev) => { - if (ev.event === "build_plan") { - for (const module of ev.build_plan) { - addRow(module.name); - } - } - else if (ev.event === "build_done") { - document.getElementById('divStatus').textContent = 'Build complete'; - } - else if (ev.event === "phase_started") { - const phase = ev.phase_started.phase; - const module = ev.phase_started.module; - - let cell = document.getElementById(phase + "Cell_" + module); - if (!cell) { - return; - } - - cell.className = 'working'; - cell.textContent = 'Working...'; - } - else if (ev.event === "phase_progress") { - const phase = ev.phase_progress.phase; - const module = ev.phase_progress.module; - const progressAry = ev.phase_progress.progress; - - let cell = document.getElementById(phase + "Cell_" + module); - if (!cell) { - return; - } - - cell.textContent = `${progressAry[0]} / ${progressAry[1]}`; - } - else if (ev.event === "phase_completed") { - const phase = ev.phase_completed.phase; - const module = ev.phase_completed.module; - - let cell = document.getElementById(phase + "Cell_" + module); - if (!cell) { - return; - } - - cell.className = 'done'; - if (['success', 'error']. - includes(ev.phase_completed.result)) - { - cell.classList.add(ev.phase_completed.result); - } - - if (ev.phase_completed.error_log) { - const logUrl = ev.phase_completed.error_log; - cell.innerHTML = `<a target='_blank' href='${logUrl}'>${ev.phase_completed.result}</a>`; - } else { - cell.innerHTML = ev.phase_completed.result; - } - } - else if (ev.event === "log_entries") { - const phase = ev.log_entries.phase; - const module = ev.log_entries.module; - const entries = ev.log_entries.entries; - - console.dir(ev); - - let newText = ''; - for(const entry of entries) { - newText += module + ": " + entry + "<br>"; - } - - let entriesDiv = document.getElementById('logEntries'); - entriesDiv.innerHTML = entriesDiv.innerHTML + newText; - } - else { - console.log("Unhandled event ", ev.event); - console.dir(ev); - } - } - - let ws = new WebSocket('<%= "$url/" %>'); - - ws.onmessage = (msg_event) => { - const events = JSON.parse(msg_event.data); - - if (!events) { - console.log(`Received invalid JSON object in WebSocket handler ${msg_event}`); - return; - } - - // event should be an array of JSON objects - for (const e of events) { - handleEvent(e); - } - } -</script> -</html> -EOF - - return $templater->render($template, $url); -} - -# Launches a server to handle responding to status requests. -# -# - $ctx, the build context -# - $done_promise should be a promise that, once resolved, should indicate that -# it is time to shut the server down. -# -# returns a promise that can be waited on until the server is shut down -sub _handle_monitoring -{ - my ($ctx, $done_promise) = @_; - - my ($port, $server_url_path) = _find_open_monitor_port(); - - # Clients which have open websocket subscriptions to event updates - my %subscribers; - - # Clients who are current on events. Normally should be same as above. - my %currentSubscribers; - - # If we can't find a port to listen on, don't hold up the rest of the run - return Mojo::Promise->new->resolve if !$port; - - # Setup a simple server to respond to requests about kdesrc-build status - my $daemon = Mojo::Server::Daemon->new( - # IPv4 and IPv6 localhost-only - listen => ["http://127.0.0.1:$port", "http://[::1]:$port"] - ); - $daemon->silent(!ksb::Debug::debugging()); - $daemon->inactivity_timeout(0); # Disable timeouts to allow long polling - - # Remove existing default handler and install our own - $daemon->unsubscribe('request')->on(request => sub { - my ($daemon, $tx) = @_; - - my $method = $tx->req->method; - my $path = $tx->req->url->path; - - if ($tx->is_websocket && !$tx->established) { - # WebSocket request comes in, which must be manually accepted and - # upgraded - - # Add to the list of subscribers. The 'newEvent' handler below - # will make them current (so that we don't potentially miss events - # already pending in the event loop). - $subscribers{$tx->connection} = $tx; - - $tx->on(finish => sub { - my $tx = shift; - delete $subscribers{$tx->connection}; - delete $currentSubscribers{$tx->connection}; - }); - - $tx->res->code(101); # Signal to Mojolicious to accept the upgrade - } - elsif ($method eq 'GET') { - # HTTP or WS - if ($path->contains('/list')) { - my %seen; - my @modules; - my @events = $ctx->statusMonitor()->events(); - - # unique items, preserve order - foreach my $result (@events) { - my $m = $result->{module}; - push @modules, $m unless exists $seen{$m}; - $seen{$m} = 1; - } - - $tx->res->code(200); - $tx->res->headers->content_type('application/json'); - $tx->res->body(Mojo::JSON::encode_json(\@modules)); - } - elsif ($path->to_string eq '/') { - my $response = _generate_status_viewer_page("ws://localhost:$port"); - - $tx->res->code(200); - $tx->res->headers->content_type('text/html'); - $tx->res->body($response); - } - elsif ($path->contains('/error_log')) { - my $moduleName = $path->[1] // ''; - my $module = $ctx->lookupModule($moduleName); - my $logfile; - - $logfile = $module->getOption('#error-log-file', 'module') if $module; - - if ($logfile && -f $logfile) { - my $asset = Mojo::Asset::File->new(path => $logfile); - $tx->res->content->asset($asset); - $tx->res->headers->content_type('text/plain'); - $tx->res->code(200); - } - elsif ($module && !$logfile) { - $tx->res->code(404); - } - else { - $tx->res->code(400); - } - } - else { - $tx->res->code(404); - } - } - else { - $tx->res->code(500); - } - - # Mojolicious will complete processing and send response - $tx->resume; - }); - - $daemon->start; - - my $stop_sent = Mojo::Promise->new; - - # Announce changes as they happen to subscribers - $ctx->statusMonitor()->on(newEvent => sub { - my ($statusMonitor, $resultRef) = @_; - - if ($resultRef->{event} eq 'build_done' && !%subscribers) { - # Resolve this early if no one is waiting on us, otherwise we'll - # block forever waiting to let someone know we're done - $stop_sent->resolve; - } - - foreach my $tx (values %subscribers) { - if ($resultRef->{event} eq 'build_done') { - # Don't exit until we've sent the last event - $tx->on(drain => sub { $stop_sent->resolve }); - } - - if (exists $currentSubscribers{$tx->connection}) { - # Should match schema for send below - $tx->send({ json => [ $resultRef ] }); - } else { - # This includes the new event we just recv'd - my @events = $ctx->statusMonitor()->events(); - $tx->send({ json => \@events }); - $currentSubscribers{$tx->connection} = 1; - } - } - }); - - my $time_promise = Mojo::Promise->new; - - # useful for debugging to ensure server is available for at least a few - # seconds. - # Mojo::IOLoop->timer(10, sub { $time_promise->resolve; }); - Mojo::IOLoop->timer(0, sub { $time_promise->resolve; }); - - my $stop_promise = Mojo::Promise->all($stop_sent, $done_promise, $time_promise)->then(sub { - $daemon->stop; - unlink($server_url_path); - }); - - return $stop_promise; -} - -sub getStatusServerURL -{ - my $run = $ENV{XDG_RUNTIME_DIR} // '/tmp'; - open my $fh, '<', "$run/kdesrc-build-status-server" - or croak_internal("Couldn't find status server"); - my $path = <$fh>; - croak_internal("Error reading status server URL: $!") - unless defined $path; - close $fh - or croak_internal("I/O error reading status server URL: $!"); - - chomp($path); - return $path; -} - -sub _handle_ui -{ - my ($ctx, $stop_promise) = @_; - my $path = getStatusServerURL(); - - # Note on object lifetimes: Perl is convenient like C++ in that it will - # typically destroy 'lexical' objects (declared with 'my') when no scope - # has a reference to that object. - # - # What this means for callback-heavy code is that the object creating the - # events being fed to callbacks needs to outlive the callbacks somehow, - # otherwise the death of the controller will close all the connections it - # had created. - # - # Since the UserAgent we create is controlling the callbacks being fed to - # our U/I handler, it needs to outlive this function in the chain of - # callbacks that we return to the caller. This is handled in one of the - # promise handlers below. - - my $ua = Mojo::UserAgent->new; - my $ui = $ctx->statusViewer(); - my $url_ws = Mojo::URL->new($path)->clone->scheme('ws'); - $ua->connect_timeout(5); - $ua->request_timeout(20); - $ua->inactivity_timeout(0); # Allow long-poll - $ua->max_redirects(0); - $ua->max_connections(0); # disable keepalive to avoid server closing connection on us - $ua->max_response_size(16384); - - return $ua->websocket_p($url_ws->clone->path("events")) - ->then(sub { - my $ws = shift; - - $ws->on(json => sub { - my ($ws, $resultRef) = @_; - foreach my $modRef (@{$resultRef}) { - eval { $ui->notifyEvent($modRef); }; - - if ($@) { - error ("Failure encountered $@"); - $ws->finish; - undef $ua; - $stop_promise->reject($@); - } - - if ($modRef->{event} eq 'build_done') { - # We've reported the build is complete, activate the - # promise holding things together - $stop_promise->resolve; - } - } - }); - - $ws->on(finish => sub { - # Shouldn't happen in a normal build but it's probably possible - $stop_promise->resolve; - }); - - # The 'stop' promise is resolved when update/build done. - $stop_promise->then(sub { - # Keep UserAgent alive until we close the WebSocket. - my $lifetime_extender = \$ua; - - $ws->finish; - }); - - return; - }); -} - # Function: _handle_async_build # # This subroutine special-cases the handling of the update and build phases, by @@ -1968,22 +1472,10 @@ sub _handle_ui sub _handle_async_build { my ($ctx) = @_; - - my $kdesrc = $ctx->getSourceDir(); - my $result = 0; - my $update_done = 0; - my $module_promises = { }; - my $stop_everything_p = Mojo::Promise->new; $ctx->statusMonitor()->createBuildPlan($ctx); - # The U/I will declare when we're done, which will cause monitor to halt - my $monitor_p = _handle_monitoring ($ctx, $stop_everything_p); - # Keep a reference to U/I promise since that's where the U/I code will actually - # run, allowing the ref to be GC'd stops the U/I updates. - my $ui_ready = _handle_ui($ctx, $stop_everything_p); - my $promiseChain = ksb::PromiseChain->new; my $start_promise = Mojo::Promise->new; @@ -2013,9 +1505,7 @@ sub _handle_async_build $start_promise->resolve; Mojo::IOLoop->stop; # Force the wait below to block - Mojo::Promise->all($chain, $ui_ready, $monitor_p)->then(sub { - Mojo::IOLoop->stop; # FIN - })->wait; + $chain->wait; return $result; } @@ -2746,25 +2236,6 @@ sub _reachableModuleLogs return keys %tempHash; } -# Runs xdg-open to the URL at $XDG_RUNTIME_DIR/kdesrc-build-status-server, if -# that file exists and is readable. Otherwise lets the user know there was an -# error. Either way this function always exits the process immediately. -sub _launchStatusViewerBrowser -{ - my $run = $ENV{XDG_RUNTIME_DIR} // '/tmp'; - my $file = "$run/kdesrc-build-status-server"; - my $url = eval { Mojo::File->new($file)->slurp }; - - if ($url) { - exec { 'xdg-open' } 'xdg-open', $url or die - "Failed to launch browser, couldn't run xdg-open: $!"; - } - else { - say "Unable to launch browser for the status server, couldn't find right URL"; - exit 1; - } -} - # Installs the given subroutine as a signal handler for a set of signals which # could kill the program. # diff --git a/modules/ksb/BuildContext.pm b/modules/ksb/BuildContext.pm index a75e12b..b6ecb28 100644 --- a/modules/ksb/BuildContext.pm +++ b/modules/ksb/BuildContext.pm @@ -1,4 +1,4 @@ -package ksb::BuildContext 0.35; +package ksb::BuildContext 0.36; # Class: BuildContext # @@ -154,13 +154,11 @@ sub new rcFiles => [@rcfiles], rcFile => undef, env => { }, - # pending => { }, # exists only under a subprocess persistent_options => { }, # These are kept across multiple script runs ignore_list => [ ], # List of KDE project paths to ignore completely kde_projects_metadata => undef, # Enumeration of kde-projects kde_dependencies_metadata => undef, # Dependency resolution of kde-projects logical_module_resolver => undef, # For branch-group option - status_view => ksb::StatusView->new(), status_monitor => ksb::StatusMonitor->new(), projects_db => undef, # See getProjectDataReader ); @@ -1058,13 +1056,6 @@ sub moduleBranchGroupResolver return $self->{logical_module_resolver}; } -# Manages the output of the TTY to keep the user in the know -sub statusViewer -{ - my $self = shift; - return $self->{status_view}; -} - # An event-based aggregator for update events, to be used by user interfaces, # including remote interfaces. sub statusMonitor diff --git a/modules/ksb/UserInterface/TTY.pm b/modules/ksb/UserInterface/TTY.pm new file mode 100755 index 0000000..6452ba7 --- /dev/null +++ b/modules/ksb/UserInterface/TTY.pm @@ -0,0 +1,227 @@ +#!/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 = 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); + +# This is essentially ksb::Application but across a socket connection. It reads +# the options and module selectors like normal. +use BackendServer; +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 = ' '; + open my $event_stream, '>', $ctx->getLogDirFor($ctx) . '/event-stream' + 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; diff --git a/modules/ksb/Util.pm b/modules/ksb/Util.pm index d1eaaa9..3e27247 100644 --- a/modules/ksb/Util.pm +++ b/modules/ksb/Util.pm @@ -83,7 +83,8 @@ sub make_exception # Remove this subroutine from the backtrace local $Carp::CarpLevel = 1 + $levels; - $message = Carp::cluck($message) if $exception_type eq 'Internal'; + $message = Carp::longmess($message) + if $exception_type eq 'Internal'; return ksb::BuildException->new($exception_type, $message); } diff --git a/modules/templates/event_viewer.html.ep b/modules/templates/event_viewer.html.ep new file mode 100644 index 0000000..ac5b47b --- /dev/null +++ b/modules/templates/event_viewer.html.ep @@ -0,0 +1,206 @@ +% layout 'default'; +% title 'kdesrc-build status viewer'; + +<h1>kdesrc-build status</h1> +<div id="divBanner"></div> +<div id="divStatus"> + Building... +</div> +<table id="tblResult"> + <tr><th>Module</th><th>Update</th><th>Build / Install</th></tr> +</table> +<div class="progress_div" style="float: right"> + <label for="update_progress_bar">Update progress:</label> + <progress id="update_progress_bar" max="100"></progress> <br/> + + <label for="build_progress_bar">Build progress:</label> + <progress id="build_progress_bar" max="100"></progress> +</div> + +<textarea id="logEntries" cols="80" rows="50"> +</textarea> + +<script defer> +let addRow = (moduleName) => { + if (!moduleName) { + console.trace("Stupidity afoot"); + return; + } + + let eventTable = lkup('tblResult'); + let newRow = document.createElement('tr'); + let moduleNameCell = document.createElement('td'); + let updateDoneCell = document.createElement('td'); + let buildDoneCell = document.createElement('td'); + + newRow.id = 'row_' + moduleName; + moduleNameCell.textContent = moduleName; + updateDoneCell.id = 'updateCell_' + moduleName; + updateDoneCell.className = 'pending'; + buildDoneCell.id = 'buildCell_' + moduleName; + buildDoneCell.className = 'pending'; + + newRow.appendChild(moduleNameCell); + newRow.appendChild(updateDoneCell); + newRow.appendChild(buildDoneCell); + eventTable.appendChild(newRow); +} + +const divStatus = document.getElementById('divStatus'); +let entriesDiv = document.getElementById('logEntries'); + +const logEvent = (module, event) => { + newText = `${module}: ${event}\n`; + entriesDiv.value += newText; +} + +let updates_complete = 0; +let builds_complete = 0; +let handleEvent = (ev) => { + + if (ev.event === "build_plan") { + const text = "Working on " + ev.build_plan.length + " modules"; + + let num_updates = 0; + let num_builds = 0; + ev.build_plan.forEach((module_plan) => { + module_plan.phases.forEach((phase) => { + num_updates += phase === "update" ? 1 : 0; + num_builds += phase === "build" ? 1 : 0; + }); + }); + + lkup('update_progress_bar').setAttribute('max', num_updates); + lkup('build_progress_bar' ).setAttribute('max', num_builds ); + } + else if (ev.event === "build_done") { + const resetLink = '<%= url_for q(reset) %>'; + const homeLink = '<%= url_for q(index) %>'; + + divStatus.innerHTML = `Build complete, <button id="btnReset">click to reset</button>`; + + const btnReset = document.getElementById('btnReset'); + btnReset.addEventListener('click', (ev) => { + fetch(resetLink, { + method: 'POST' + }) + .then(resp => { + if (!resp.ok) { + throw new Error("Invalid response resetting kdesrc-build"); + } + return resp.json(); + }) + .then(last_result => { + console.log("Last result was ", last_result.last_result) + document.location.assign (homeLink); + }) + .catch(error => console.error(error)); + }, { passive: false }); + } + else if (ev.event === "phase_started") { + const phase = ev.phase_started.phase; + const module = ev.phase_started.module; + + let row = lkup("row_" + module); + if (!row) { + addRow(module); + } + + let cell = lkup(phase + "Cell_" + module); + if (!cell) { + return; + } + + cell.className = 'working'; + cell.textContent = 'Working...'; + } + else if (ev.event === "phase_progress") { + const phase = ev.phase_progress.phase; + const module = ev.phase_progress.module; + const progressAry = ev.phase_progress.progress; + + let cell = lkup(phase + "Cell_" + module); + if (!cell) { + return; + } + + if (progressAry[1] == 0) { + cell.textContent = "???????"; + } else { + cell.textContent = `${progressAry[0]} / ${progressAry[1]}`; + } + } + else if (ev.event === "phase_completed") { + const phase = ev.phase_completed.phase; + const module = ev.phase_completed.module; + + let cell = lkup(phase + "Cell_" + module); + if (!cell) { + return; + } + + if (phase === "update") { + updates_complete++; + } else { + builds_complete++; + } + + lkup('update_progress_bar').setAttribute('value', updates_complete); + lkup('build_progress_bar' ).setAttribute('value', builds_complete ); + + cell.className = 'done'; + if (['success', 'error']. + includes(ev.phase_completed.result)) + { + cell.classList.add(ev.phase_completed.result); + } + + if (ev.phase_completed.error_log) { + const logUrl = ev.phase_completed.error_log; + cell.innerHTML = `<a target='_blank' href='${logUrl}'>${ev.phase_completed.result}</a>`; + logEvent(module, 'Failed to ' + phase); + } else if (phase !== 'update') { + // If all successful for the module, remove the row + const updateResult = lkup('updateCell_' + module); + if (updateResult && updateResult.classList.contains('success')) { + const moduleRow = lkup('row_' + module); + if (moduleRow) { + moduleRow.remove(); // "Experimental" ChildNode iface + } + logEvent(module, 'Success'); + } + } else { + cell.innerHTML = ev.phase_completed.result; + } + } + else if (ev.event === "log_entries") { + const phase = ev.log_entries.phase; + const module = ev.log_entries.module; + const entries = ev.log_entries.entries; + + for(const entry of entries) { + logEvent(module, entry); + } + } + else { + console.error("Unhandled event ", ev.event); + console.error(ev); + } +} + +let ws = new WebSocket('<%= url_for("events")->to_abs %>'); + +ws.onmessage = (msg_event) => { + const events = JSON.parse(msg_event.data); + + if (!events) { + console.log(`Received invalid JSON object in WebSocket handler ${msg_event}`); + return; + } + + // event should be an array of JSON objects + for (const e of events) { + handleEvent(e); + } +} +</script> diff --git a/modules/templates/index.html.ep b/modules/templates/index.html.ep new file mode 100644 index 0000000..87fbcf5 --- /dev/null +++ b/modules/templates/index.html.ep @@ -0,0 +1,112 @@ +% layout 'default'; +% title 'kdesrc-build'; +<h1>kdesrc-build <%= $app %></h1> + +<label for="modules_list">List of modules to build:</label> +<input id="modules_list"/><br /> +<button id="btnSubmitModuleList">Enter modules to build:</button> + +<div class="module_list_div"> + <label for="module_select">Or use the dropdown to select</label> + <select multiple id="module_select" size="15"> + <option value="">All</option> + <option value="###">--- Module names loading ---</option> + </select> +</div> + +<div id="modules_result_div"></div> +<br/> +<button id="btnStartBuild" disabled>Start Build!</button> + +<script defer> +const btnSubmitModuleList = lkup('btnSubmitModuleList'); +btnSubmitModuleList.addEventListener('click', (ev) => { + const modList = lkup('modules_list'); + const modArray = modList.value.split(/[, ]+/); + console.dir(modArray); + if (modArray) { + const postUrl = '<%= url_for q(post_modules) %>'; + fetch(postUrl, { + method: 'POST', + body: JSON.stringify(modArray), + headers: { + 'Content-Type': 'application/json' + } + }) + .then(resp => resp.json()) + .then(resp => { + console.log(`Response received ${resp}`); + lkup('btnStartBuild').disabled = false; + + const lookupUrl = '<%= url_for q(module_lookup) %>'; + return fetch(lookupUrl); // TODO Merge with the result we already get? + }) + .then(mod_list_resp => mod_list_resp.json()) + .then(mod_list => { + const strOfMods = mod_list.join("<br/>"); + lkup('modules_result_div').innerHTML = "Building: " + strOfMods; + }) + .catch(err => console.error(err)); + } +}, { passive: false }); + +const btnStartBuild = lkup('btnStartBuild'); +btnStartBuild.addEventListener('click', (ev) => { + const postUrl = '<%= url_for q(build) %>'; + fetch(postUrl, { + method: 'POST', + }) + .then(resp => { + if(!resp.ok) { + console.error(resp); + throw new Error ('Invalid response!'); + } + return resp.text() + }) + .then(text => { + console.log(`Response received ${text}`) + document.location.assign(text); + }) + .catch(err => console.error(err)); +}, { passive: false }); + +// Load the list of all modules to see where we're at +fetch('<%= url_for q(known_modules) %>') +.then(resp => resp.json()) +.then(mod_list => { + console.dir(mod_list); + const selList = lkup('module_select'); + while (selList.firstChild) { + selList.removeChild(selList.firstChild); + } + + const optTag = (name) => { + const newOpt = document.createElement('option'); + const newText = document.createTextNode(name); + newOpt.appendChild(newText); + return newOpt; + } + + console.group(); + for (const module of mod_list) { + var tagToAdd; + if (typeof module === "string") { + console.log("Adding " + module); + tagToAdd = optTag(module); + } + else { + // module is really an array with [ set-name, @module-names ] + const setName = module.shift(); + console.log("Adding module set " + setName); + tagToAdd = document.createElement('optgroup'); + tagToAdd.setAttribute('label', setName); + module.forEach(name => tagToAdd.appendChild(optTag(name))); + } + + selList.appendChild(tagToAdd); + } + console.groupEnd(); +}) +.catch(error => console.error(error)); + +</script> diff --git a/modules/templates/layouts/default.html.ep b/modules/templates/layouts/default.html.ep new file mode 100644 index 0000000..eb7fecf --- /dev/null +++ b/modules/templates/layouts/default.html.ep @@ -0,0 +1,31 @@ +<!DOCTYPE html> +<html> +<head> +<meta charset="utf-8"/> +<title><%= title %></title> + +<style> +td.pending { + background-color: lightgray; +} + +td.done { + background-color: lightblue; +} + +td.done.success { + background-color: lightgreen; +} + +td.done.error { + background-color: pink; +} +</style> + +<script defer> +// Add common functions for use by all generated pages +const lkup = (name) => document.getElementById(name); +</script> +</head> +<body><%= content %></body> +</html>
