[kdesrc-build/make_it_mojo] /: mojo: Add a backend-support class to allow headless ops.

Michael Pyne null at kde.org
Mon Oct 8 18:57:43 BST 2018


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>



More information about the kde-doc-english mailing list