When using Hydra to build machine configurations, you'll often see "nixosConfigurations.foo" five times, i.e. for each build step being run. This isn't very helpful I think because in such a case, a single build step can also be compiling the Linux kernel. This change also fetches the `drvpath` and `type` from the `buildsteps` relation. We're already joining it, so this doesn't make much difference (confirmed via query logging that this doesn't cause extra SQL queries). Unfortunately build steps don't have a human readable name, so I'm deriving it from the drvpath by stripping away the hash (assuming that it'll never contain a `-` and that `/nix/store/` is used as prefix). I decided against using the Nix bindings for that to avoid too much overhead due to store operations for each build step.
585 lines
18 KiB
Perl
585 lines
18 KiB
Perl
package Hydra::Controller::Root;
|
||
|
||
use utf8;
|
||
use strict;
|
||
use warnings;
|
||
use base 'Hydra::Base::Controller::ListBuilds';
|
||
use Hydra::Helper::Nix;
|
||
use Hydra::Helper::CatalystUtils;
|
||
use Hydra::View::TT;
|
||
use Nix::Store;
|
||
use Nix::Config;
|
||
use Encode;
|
||
use File::Basename;
|
||
use JSON::MaybeXS;
|
||
use List::Util qw[min max];
|
||
use List::SomeUtils qw{any};
|
||
use Net::Prometheus;
|
||
use Types::Standard qw/StrMatch/;
|
||
use WWW::Form::UrlEncoded::PP qw();
|
||
|
||
use constant NARINFO_REGEX => qr{^([a-z0-9]{32})\.narinfo$};
|
||
# e.g.: https://hydra.example.com/realisations/sha256:a62128132508a3a32eef651d6467695944763602f226ac630543e947d9feb140!out.doi
|
||
use constant REALISATIONS_REGEX => qr{^(sha256:[a-z0-9]{64}![a-z]+)\.doi$};
|
||
|
||
# Put this controller at top-level.
|
||
__PACKAGE__->config->{namespace} = '';
|
||
|
||
|
||
sub noLoginNeeded {
|
||
my ($c) = @_;
|
||
|
||
my $hostname = $c->request->headers->header('X-Forwarded-For') || $c->request->hostname;
|
||
my $readonly_ips = $c->config->{readonly_ips} // "";
|
||
my $whitelisted = any { $_ eq $hostname } split(/,/, $readonly_ips);
|
||
|
||
return $whitelisted ||
|
||
$c->request->path eq "api/push-github" ||
|
||
$c->request->path eq "api/push-gitea" ||
|
||
$c->request->path eq "google-login" ||
|
||
$c->request->path eq "github-redirect" ||
|
||
$c->request->path eq "github-login" ||
|
||
$c->request->path eq "login" ||
|
||
$c->request->path eq "logo" ||
|
||
$c->request->path =~ /^static\//;
|
||
}
|
||
|
||
|
||
sub begin :Private {
|
||
my ($self, $c, @args) = @_;
|
||
|
||
$c->stash->{curUri} = $c->request->uri;
|
||
$c->stash->{version} = $ENV{"HYDRA_RELEASE"} || "<devel>";
|
||
$c->stash->{nixVersion} = $ENV{"NIX_RELEASE"} || "<devel>";
|
||
$c->stash->{nixEvalJobsVersion} = $ENV{"NIX_EVAL_JOBS_RELEASE"} || "<devel>";
|
||
$c->stash->{curTime} = time;
|
||
$c->stash->{logo} = defined $c->config->{hydra_logo} ? "/logo" : "";
|
||
$c->stash->{tracker} = defined $c->config->{tracker} ? $c->config->{tracker} : "";
|
||
$c->stash->{flashMsg} = $c->flash->{flashMsg};
|
||
$c->stash->{successMsg} = $c->flash->{successMsg};
|
||
|
||
$c->stash->{isPrivateHydra} = $c->config->{private} // "0" ne "0";
|
||
|
||
if ($c->stash->{isPrivateHydra} && ! noLoginNeeded($c)) {
|
||
requireUser($c);
|
||
}
|
||
|
||
if (scalar(@args) == 0 || $args[0] ne "static") {
|
||
$c->stash->{nrRunningBuilds} = dbh($c)->selectrow_array(
|
||
"select count(distinct build) from buildsteps where busy != 0");
|
||
$c->stash->{nrQueuedBuilds} = $c->model('DB::Builds')->search({ finished => 0 })->count();
|
||
}
|
||
|
||
# Gather the supported input types.
|
||
$c->stash->{inputTypes} = {
|
||
'string' => 'String value',
|
||
'boolean' => 'Boolean',
|
||
'nix' => 'Nix expression',
|
||
'build' => 'Previous Hydra build',
|
||
'sysbuild' => 'Previous Hydra build (same system)',
|
||
'eval' => 'Previous Hydra evaluation'
|
||
};
|
||
$_->supportedInputTypes($c->stash->{inputTypes}) foreach @{$c->hydra_plugins};
|
||
|
||
# XSRF protection: require POST requests to have the same origin.
|
||
if ($c->req->method eq "POST" && $c->req->path ne "api/push-github" && $c->req->path ne "api/push-gitea") {
|
||
my $referer = $c->req->header('Referer');
|
||
$referer //= $c->req->header('Origin');
|
||
my $base = $c->req->base;
|
||
die unless $base =~ /\/$/;
|
||
$referer .= "/";
|
||
error($c, "POST requests should come from ‘$base’.")
|
||
unless defined $referer && substr($referer, 0, length $base) eq $base;
|
||
}
|
||
|
||
$c->forward('deserialize');
|
||
|
||
$c->stash->{params} = $c->request->data or $c->request->params;
|
||
unless (defined $c->stash->{params} and %{$c->stash->{params}}) {
|
||
$c->stash->{params} = $c->request->params;
|
||
}
|
||
|
||
# Set the Vary header to "Accept" to ensure that browsers don't
|
||
# mix up HTML and JSON responses.
|
||
$c->response->headers->header('Vary', 'Accept');
|
||
}
|
||
|
||
|
||
sub deserialize :ActionClass('Deserialize') { }
|
||
|
||
|
||
sub index :Path :Args(0) {
|
||
my ($self, $c) = @_;
|
||
$c->stash->{template} = 'overview.tt';
|
||
$c->stash->{projects} = [$c->model('DB::Projects')->search({}, {order_by => ['enabled DESC', 'name']})];
|
||
$c->stash->{newsItems} = [$c->model('DB::NewsItems')->search({}, { order_by => ['createtime DESC'], rows => 5 })];
|
||
$self->status_ok($c,
|
||
entity => $c->stash->{projects}
|
||
);
|
||
}
|
||
|
||
|
||
sub queue :Local :Args(0) :ActionClass('REST') { }
|
||
|
||
sub queue_GET {
|
||
my ($self, $c) = @_;
|
||
$c->stash->{template} = 'queue.tt';
|
||
$c->stash->{flashMsg} //= $c->flash->{buildMsg};
|
||
$self->status_ok(
|
||
$c,
|
||
entity => [$c->model('DB::Builds')->search(
|
||
{ finished => 0 },
|
||
{ order_by => ["globalpriority desc", "id"],
|
||
, columns => [@buildListColumns]
|
||
})]
|
||
);
|
||
}
|
||
|
||
|
||
sub queue_summary :Local :Path('queue-summary') :Args(0) {
|
||
my ($self, $c) = @_;
|
||
$c->stash->{template} = 'queue-summary.tt';
|
||
|
||
$c->stash->{queued} = dbh($c)->selectall_arrayref(
|
||
"select jobsets.project as project, jobsets.name as jobset, count(*) as queued, min(timestamp) as oldest, max(timestamp) as newest from Builds " .
|
||
"join Jobsets jobsets on jobsets.id = builds.jobset_id " .
|
||
"where finished = 0 group by jobsets.project, jobsets.name order by queued desc",
|
||
{ Slice => {} });
|
||
|
||
$c->stash->{systems} = dbh($c)->selectall_arrayref(
|
||
"select system, count(*) as c from Builds where finished = 0 group by system order by c desc",
|
||
{ Slice => {} });
|
||
}
|
||
|
||
|
||
sub status :Local :Args(0) :ActionClass('REST') { }
|
||
|
||
sub status_GET {
|
||
my ($self, $c) = @_;
|
||
$self->status_ok(
|
||
$c,
|
||
entity => [$c->model('DB::Builds')->search(
|
||
{ "buildsteps.busy" => { '!=', 0 } },
|
||
{ order_by => ["globalpriority DESC", "id"],
|
||
join => "buildsteps",
|
||
columns => [@buildListColumns, 'buildsteps.drvpath', 'buildsteps.type']
|
||
})]
|
||
);
|
||
}
|
||
|
||
|
||
sub queue_runner_status :Local :Path('queue-runner-status') :Args(0) :ActionClass('REST') { }
|
||
|
||
sub queue_runner_status_GET {
|
||
my ($self, $c) = @_;
|
||
|
||
#my $status = from_json($c->model('DB::SystemStatus')->find('queue-runner')->status);
|
||
my $status = decode_json(`hydra-queue-runner --status`);
|
||
if ($?) { $status->{status} = "unknown"; }
|
||
my $json = JSON->new->pretty()->canonical();
|
||
|
||
$c->stash->{template} = 'queue-runner-status.tt';
|
||
$c->stash->{status} = $json->encode($status);
|
||
$self->status_ok($c, entity => $status);
|
||
}
|
||
|
||
|
||
sub machines :Local Args(0) {
|
||
my ($self, $c) = @_;
|
||
my $machines = getMachines;
|
||
|
||
# Add entry for localhost.
|
||
$machines->{''} //= {};
|
||
delete $machines->{'localhost'};
|
||
|
||
my $status = $c->model('DB::SystemStatus')->find("queue-runner");
|
||
if ($status) {
|
||
my $ms = decode_json($status->status)->{"machines"};
|
||
foreach my $name (keys %{$ms}) {
|
||
$name = "" if $name eq "localhost";
|
||
$machines->{$name} //= {disabled => 1};
|
||
$machines->{$name}->{nrStepsDone} = $ms->{$name}->{nrStepsDone};
|
||
$machines->{$name}->{avgStepBuildTime} = $ms->{$name}->{avgStepBuildTime} // 0;
|
||
}
|
||
}
|
||
|
||
$c->stash->{machines} = $machines;
|
||
$c->stash->{steps} = dbh($c)->selectall_arrayref(
|
||
"select build, stepnr, s.system as system, s.drvpath as drvpath, machine, s.starttime as starttime, jobsets.project as project, jobsets.name as jobset, job, s.busy as busy " .
|
||
"from BuildSteps s " .
|
||
"join Builds b on s.build = b.id " .
|
||
"join Jobsets jobsets on jobsets.id = b.jobset_id " .
|
||
"where busy != 0 order by machine, stepnr",
|
||
{ Slice => {} });
|
||
$c->stash->{template} = 'machine-status.tt';
|
||
$self->status_ok($c, entity => $c->stash->{machines});
|
||
}
|
||
|
||
sub prometheus :Local Args(0) {
|
||
my ($self, $c) = @_;
|
||
my $machines = getMachines;
|
||
|
||
my $client = Net::Prometheus->new;
|
||
my $duration = $client->new_histogram(
|
||
name => "hydra_machine_build_duration",
|
||
help => "How long builds are taking per server. Note: counts are gauges, NOT counters.",
|
||
labels => [ "machine" ],
|
||
buckets => [
|
||
60,
|
||
600,
|
||
1800,
|
||
3600,
|
||
7200,
|
||
21600,
|
||
43200,
|
||
86400,
|
||
172800,
|
||
259200,
|
||
345600,
|
||
518400,
|
||
604800,
|
||
691200
|
||
]
|
||
);
|
||
|
||
my $steps = dbh($c)->selectall_arrayref(
|
||
"select machine, s.starttime as starttime " .
|
||
"from BuildSteps s join Builds b on s.build = b.id " .
|
||
"where busy != 0 order by machine, stepnr",
|
||
{ Slice => {} });
|
||
|
||
foreach my $step (@$steps) {
|
||
my $name = $step->{machine} ? Hydra::View::TT->stripSSHUser(undef, $step->{machine}) : "";
|
||
$name = "localhost" unless $name;
|
||
$duration->labels($name)->observe(time - $step->{starttime});
|
||
}
|
||
|
||
$c->stash->{'plain'} = { data => $client->render };
|
||
$c->forward('Hydra::View::Plain');
|
||
}
|
||
|
||
|
||
# Hydra::Base::Controller::ListBuilds needs this.
|
||
sub get_builds : Chained('/') PathPart('') CaptureArgs(0) {
|
||
my ($self, $c) = @_;
|
||
$c->stash->{allBuilds} = $c->model('DB::Builds');
|
||
$c->stash->{latestSucceeded} = $c->model('DB')->resultset('LatestSucceeded');
|
||
$c->stash->{channelBaseName} = "everything";
|
||
$c->stash->{total} = $c->model('DB::NrBuilds')->find('finished')->count;
|
||
}
|
||
|
||
|
||
sub robots_txt : Path('robots.txt') {
|
||
my ($self, $c) = @_;
|
||
$c->stash->{'plain'} = { data => "User-agent: *\nDisallow: /*\n" };
|
||
$c->forward('Hydra::View::Plain');
|
||
}
|
||
|
||
|
||
sub default :Path {
|
||
my ($self, $c) = @_;
|
||
notFound($c, "Page not found.");
|
||
}
|
||
|
||
|
||
sub end : ActionClass('RenderView') {
|
||
my ($self, $c) = @_;
|
||
|
||
if (defined $c->stash->{json}) {
|
||
if (scalar @{$c->error}) {
|
||
# FIXME: dunno why we need to do decode_utf8 here.
|
||
$c->stash->{json}->{error} = join "\n", map { decode_utf8($_); } @{$c->error};
|
||
$c->clear_errors;
|
||
}
|
||
$c->forward('View::JSON');
|
||
}
|
||
|
||
elsif (scalar @{$c->error}) {
|
||
$c->stash->{resource} = { error => join "\n", @{$c->error} };
|
||
if ($c->stash->{lazy}) {
|
||
$c->response->headers->header('X-Hydra-Lazy', 'Yes');
|
||
$c->stash->{template} = 'lazy_error.tt';
|
||
}
|
||
else {
|
||
$c->stash->{template} = 'error.tt';
|
||
}
|
||
$c->stash->{errors} = $c->error;
|
||
$c->response->status(500) if $c->response->status == 200;
|
||
if ($c->response->status >= 300) {
|
||
$c->stash->{httpStatus} =
|
||
$c->response->status . " " . HTTP::Status::status_message($c->response->status);
|
||
}
|
||
$c->clear_errors;
|
||
}
|
||
|
||
$c->forward('serialize') if defined $c->stash->{resource};
|
||
}
|
||
|
||
|
||
sub serialize : ActionClass('Serialize') { }
|
||
|
||
|
||
sub nar :Local :Args(1) {
|
||
my ($self, $c, $path) = @_;
|
||
|
||
die if $path =~ /\//;
|
||
|
||
if (!isLocalStore) {
|
||
notFound($c, "There is no binary cache here.");
|
||
}
|
||
|
||
else {
|
||
$path = $Nix::Config::storeDir . "/$path";
|
||
|
||
gone($c, "Path " . $path . " is no longer available.") unless $MACHINE_LOCAL_STORE->isValidPath($path);
|
||
|
||
$c->stash->{current_view} = 'NixNAR';
|
||
$c->stash->{storePath} = $path;
|
||
}
|
||
}
|
||
|
||
|
||
sub nix_cache_info :Path('nix-cache-info') :Args(0) {
|
||
my ($self, $c) = @_;
|
||
|
||
if (!isLocalStore) {
|
||
notFound($c, "There is no binary cache here.");
|
||
}
|
||
|
||
else {
|
||
$c->response->content_type('text/plain');
|
||
$c->stash->{plain}->{data} =
|
||
"StoreDir: $Nix::Config::storeDir\n" .
|
||
"WantMassQuery: 0\n" .
|
||
# Give Hydra binary caches a very low priority (lower than the
|
||
# static binary cache http://nixos.org/binary-cache).
|
||
"Priority: 100\n";
|
||
setCacheHeaders($c, 24 * 60 * 60);
|
||
$c->forward('Hydra::View::Plain');
|
||
}
|
||
}
|
||
|
||
|
||
sub realisations :Path('realisations') :Args(StrMatch[REALISATIONS_REGEX]) {
|
||
my ($self, $c, $realisation) = @_;
|
||
|
||
if (!isLocalStore) {
|
||
notFound($c, "There is no binary cache here.");
|
||
}
|
||
|
||
else {
|
||
my ($rawDrvOutput) = $realisation =~ REALISATIONS_REGEX;
|
||
my $rawRealisation = $MACHINE_LOCAL_STORE->queryRawRealisation($rawDrvOutput);
|
||
|
||
if (!$rawRealisation) {
|
||
$c->response->status(404);
|
||
$c->response->content_type('text/plain');
|
||
$c->stash->{plain}->{data} = "does not exist\n";
|
||
$c->forward('Hydra::View::Plain');
|
||
setCacheHeaders($c, 60 * 60);
|
||
return;
|
||
}
|
||
|
||
$c->response->content_type('text/plain');
|
||
$c->stash->{plain}->{data} = $rawRealisation;
|
||
$c->forward('Hydra::View::Plain');
|
||
}
|
||
}
|
||
|
||
|
||
sub narinfo :Path :Args(StrMatch[NARINFO_REGEX]) {
|
||
my ($self, $c, $narinfo) = @_;
|
||
|
||
if (!isLocalStore) {
|
||
notFound($c, "There is no binary cache here.");
|
||
}
|
||
|
||
else {
|
||
my ($hash) = $narinfo =~ NARINFO_REGEX;
|
||
|
||
die("Hash length was not 32") if length($hash) != 32;
|
||
my $path = $MACHINE_LOCAL_STORE->queryPathFromHashPart($hash);
|
||
|
||
if (!$path) {
|
||
$c->response->status(404);
|
||
$c->response->content_type('text/plain');
|
||
$c->stash->{plain}->{data} = "does not exist\n";
|
||
$c->forward('Hydra::View::Plain');
|
||
setCacheHeaders($c, 60 * 60);
|
||
return;
|
||
}
|
||
|
||
$c->stash->{storePath} = $path;
|
||
$c->forward('Hydra::View::NARInfo');
|
||
}
|
||
}
|
||
|
||
|
||
sub logo :Local {
|
||
my ($self, $c) = @_;
|
||
my $path = $c->config->{hydra_logo} // die("Logo not set!");
|
||
$c->serve_static_file($path);
|
||
}
|
||
|
||
|
||
sub evals :Local Args(0) {
|
||
my ($self, $c) = @_;
|
||
|
||
$c->stash->{template} = 'evals.tt';
|
||
|
||
my $page = int($c->req->param('page') || "1") || 1;
|
||
|
||
my $resultsPerPage = 20;
|
||
|
||
my $evals = $c->model('DB::JobsetEvals');
|
||
|
||
$c->stash->{page} = $page;
|
||
$c->stash->{resultsPerPage} = $resultsPerPage;
|
||
$c->stash->{total} = $evals->search({hasnewbuilds => 1})->count;
|
||
$c->stash->{evals} = getEvals($c, $evals, ($page - 1) * $resultsPerPage, $resultsPerPage);
|
||
|
||
$self->status_ok($c, entity => $c->stash->{evals});
|
||
}
|
||
|
||
|
||
sub steps :Local Args(0) {
|
||
my ($self, $c) = @_;
|
||
|
||
$c->stash->{template} = 'steps.tt';
|
||
|
||
my $page = int($c->req->param('page') || "1") || 1;
|
||
|
||
my $resultsPerPage = 20;
|
||
|
||
$c->stash->{page} = $page;
|
||
$c->stash->{resultsPerPage} = $resultsPerPage;
|
||
$c->stash->{steps} = [ $c->model('DB::BuildSteps')->search(
|
||
{ starttime => { '!=', undef },
|
||
stoptime => { '!=', undef }
|
||
},
|
||
{ order_by => [ "stoptime desc" ],
|
||
rows => $resultsPerPage,
|
||
offset => ($page - 1) * $resultsPerPage
|
||
}) ];
|
||
|
||
$c->stash->{total} = approxTableSize($c, "IndexBuildStepsOnStopTime");
|
||
}
|
||
|
||
|
||
sub search :Local Args(0) {
|
||
my ($self, $c) = @_;
|
||
$c->stash->{template} = 'search.tt';
|
||
|
||
my $query = trim $c->request->params->{"query"};
|
||
|
||
error($c, "Query is empty.") if $query eq "";
|
||
error($c, "Invalid character in query.")
|
||
unless $query =~ /^[a-zA-Z0-9_\-\/.]+$/;
|
||
|
||
my $limit = int(trim ($c->request->params->{"limit"} || "10"));
|
||
$c->stash->{limit} = min(50, max(1, $limit));
|
||
|
||
$c->model('DB')->schema->txn_do(sub {
|
||
$c->model('DB')->schema->storage->dbh->do("SET LOCAL statement_timeout = 20000");
|
||
$c->stash->{projects} = [ $c->model('DB::Projects')->search(
|
||
{ -and =>
|
||
[ { -or => [ name => { ilike => "%$query%" }, displayName => { ilike => "%$query%" }, description => { ilike => "%$query%" } ] }
|
||
, { hidden => 0 }
|
||
]
|
||
},
|
||
{ order_by => ["name"] } ) ];
|
||
|
||
$c->stash->{jobsets} = [ $c->model('DB::Jobsets')->search(
|
||
{ -and =>
|
||
[ { -or => [ "me.name" => { ilike => "%$query%" }, "me.description" => { ilike => "%$query%" } ] }
|
||
, { "project.hidden" => 0, "me.hidden" => 0 }
|
||
]
|
||
},
|
||
{ order_by => ["project", "name"], join => ["project"] } ) ];
|
||
|
||
$c->stash->{jobs} = [ $c->model('DB::Builds')->search(
|
||
{ "job" => { ilike => "%$query%" }
|
||
, "project.hidden" => 0
|
||
, "jobset.hidden" => 0
|
||
, iscurrent => 1
|
||
},
|
||
{
|
||
order_by => ["jobset.project", "jobset.name", "job"],
|
||
join => { "jobset" => "project" },
|
||
rows => $c->stash->{limit} + 1
|
||
} )
|
||
];
|
||
|
||
# Perform build search in separate queries to prevent seq scan on buildoutputs table.
|
||
$c->stash->{builds} = [ $c->model('DB::Builds')->search(
|
||
{ "buildoutputs.path" => { ilike => "%$query%" } },
|
||
{ order_by => ["id desc"], join => ["buildoutputs"]
|
||
, rows => $c->stash->{limit}
|
||
} ) ];
|
||
|
||
$c->stash->{buildsdrv} = [ $c->model('DB::Builds')->search(
|
||
{ "drvpath" => { ilike => "%$query%" } },
|
||
{ order_by => ["id desc"]
|
||
, rows => $c->stash->{limit}
|
||
} ) ];
|
||
|
||
$c->stash->{resource} = { projects => $c->stash->{projects},
|
||
jobsets => $c->stash->{jobsets},
|
||
builds => $c->stash->{builds},
|
||
buildsdrv => $c->stash->{buildsdrv} };
|
||
});
|
||
}
|
||
|
||
sub serveLogFile {
|
||
my ($c, $logPath, $tail) = @_;
|
||
$c->stash->{logPath} = $logPath;
|
||
$c->stash->{tail} = $tail;
|
||
$c->forward('Hydra::View::NixLog');
|
||
}
|
||
|
||
sub log :Local :Args(1) {
|
||
my ($self, $c, $drvPath) = @_;
|
||
|
||
$drvPath = "/nix/store/$drvPath";
|
||
|
||
my $tail = $c->request->params->{"tail"};
|
||
|
||
die if defined $tail && $tail !~ /^[0-9]+$/;
|
||
|
||
my $logFile = findLog($c, $drvPath);
|
||
|
||
if (defined $logFile) {
|
||
serveLogFile($c, $logFile, $tail);
|
||
return;
|
||
}
|
||
|
||
my $logPrefix = $c->config->{log_prefix};
|
||
|
||
if (defined $logPrefix) {
|
||
$c->res->redirect($logPrefix . "log/" . WWW::Form::UrlEncoded::PP::url_encode(basename($drvPath)));
|
||
} else {
|
||
notFound($c, "The build log of $drvPath is not available.");
|
||
}
|
||
}
|
||
|
||
sub runcommandlog :Local :Args(1) {
|
||
my ($self, $c, $uuid) = @_;
|
||
|
||
my $tail = $c->request->params->{"tail"};
|
||
|
||
die if defined $tail && $tail !~ /^[0-9]+$/;
|
||
|
||
my $runlog = $c->model('DB')->resultset('RunCommandLogs')->find({ uuid => $uuid })
|
||
or notFound($c, "The RunCommand log is not available.");
|
||
|
||
my $logFile = constructRunCommandLogPath($runlog);
|
||
if (-f $logFile) {
|
||
serveLogFile($c, $logFile, $tail);
|
||
return;
|
||
} else {
|
||
notFound($c, "The RunCommand log is not available.");
|
||
}
|
||
}
|
||
|
||
1;
|