mirror of
https://github.com/ohwgiles/laminar.git
synced 2026-10-17 08:40:54 +00:00
Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ded13ed9fe | ||
|
|
15dbed4cac | ||
|
|
e67e0bc453 | ||
|
|
2de8b91ad2 | ||
|
|
399f07cf3a | ||
|
|
2941a5abdd | ||
|
|
a50514a135 | ||
|
|
60f7ee5402 | ||
|
|
7f1c293588 | ||
|
|
fe4caa155b | ||
|
|
f3a6ba2f4b | ||
|
|
dff4c93e15 | ||
|
|
747ae3ada8 | ||
|
|
9a5ccc70e3 | ||
|
|
d01cf1c9b0 | ||
|
|
02810309fc | ||
|
|
b16991b17a | ||
|
|
c7c586167c | ||
|
|
7e77ec1211 | ||
|
|
9b8c3762ec | ||
|
|
c42b6d4207 | ||
|
|
8df882b273 | ||
|
|
14ea1f0f43 | ||
|
|
381fd8b55e | ||
|
|
9f969ae847 | ||
|
|
907f3926ce | ||
|
|
c140fb51eb | ||
|
|
63bbb8a6e7 | ||
|
|
882978fa77 |
@@ -1,5 +1,5 @@
|
||||
###
|
||||
### Copyright 2015-2020 Oliver Giles
|
||||
### Copyright 2015-2021 Oliver Giles
|
||||
###
|
||||
### This file is part of Laminar
|
||||
###
|
||||
@@ -17,12 +17,12 @@
|
||||
### along with Laminar. If not, see <http://www.gnu.org/licenses/>
|
||||
###
|
||||
project(laminar)
|
||||
cmake_minimum_required(VERSION 2.8)
|
||||
cmake_policy(SET CMP0058 NEW)
|
||||
cmake_minimum_required(VERSION 3.6)
|
||||
|
||||
set(CMAKE_INCLUDE_CURRENT_DIR ON)
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
|
||||
add_definitions("-std=c++17 -Wall -Wextra -Wno-unused-parameter -Wno-sign-compare")
|
||||
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wall -Wextra -Wno-unused-parameter -Wno-sign-compare")
|
||||
set(CMAKE_CXX_FLAGS_DEBUG "${CMAKE_CXX_FLAGS_DEBUG} -Werror -DDEBUG")
|
||||
|
||||
# Allow passing in the version string, for e.g. patched/packaged versions
|
||||
@@ -85,14 +85,12 @@ add_custom_command(OUTPUT index_html_size.h
|
||||
# Download 3rd-party frontend JS libs...
|
||||
file(DOWNLOAD https://cdnjs.cloudflare.com/ajax/libs/vue/2.6.12/vue.min.js
|
||||
js/vue.min.js EXPECTED_MD5 fb192338844efe86ec759a40152fcb8e)
|
||||
file(DOWNLOAD https://cdnjs.cloudflare.com/ajax/libs/vue-router/3.4.8/vue-router.min.js
|
||||
js/vue-router.min.js EXPECTED_MD5 5f51d4dbbf68fd6725956a5a2b865f3b)
|
||||
file(DOWNLOAD https://raw.githubusercontent.com/drudru/ansi_up/v1.3.0/ansi_up.js
|
||||
js/ansi_up.js EXPECTED_MD5 158566dc1ff8f2804de972f7e841e2f6)
|
||||
file(DOWNLOAD https://raw.githubusercontent.com/drudru/ansi_up/v4.0.4/ansi_up.js
|
||||
js/ansi_up.js EXPECTED_MD5 b31968e1a8fed0fa82305e978161f7f5)
|
||||
file(DOWNLOAD https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.7.2/Chart.min.js
|
||||
js/Chart.min.js EXPECTED_MD5 f6c8efa65711e0cbbc99ba72997ecd0e)
|
||||
# ...and compile them
|
||||
generate_compressed_bins(${CMAKE_BINARY_DIR} js/vue-router.min.js js/vue.min.js
|
||||
generate_compressed_bins(${CMAKE_BINARY_DIR} js/vue.min.js
|
||||
js/ansi_up.js js/Chart.min.js)
|
||||
# (see resources.cpp where these are fetched)
|
||||
|
||||
@@ -138,7 +136,7 @@ if(BUILD_TESTS)
|
||||
find_package(GTest REQUIRED)
|
||||
include_directories(${GTEST_INCLUDE_DIRS} src)
|
||||
add_executable(laminar-tests ${LAMINARD_CORE_SOURCES} ${COMPRESSED_BINS} test/main.cpp test/laminar-functional.cpp test/unit-conf.cpp test/unit-database.cpp)
|
||||
target_link_libraries(laminar-tests ${GTEST_LIBRARY} capnp-rpc capnp kj-http kj-async kj pthread sqlite3 z)
|
||||
target_link_libraries(laminar-tests ${GTEST_LIBRARIES} capnp-rpc capnp kj-http kj-async kj pthread sqlite3 z)
|
||||
endif()
|
||||
|
||||
set(SYSTEMD_UNITDIR /lib/systemd/system CACHE PATH "Path to systemd unit files")
|
||||
|
||||
@@ -12,11 +12,11 @@ See [the website](https://laminar.ohwg.net) and the [documentation](https://lami
|
||||
|
||||
First install development packages for `capnproto (version 0.7.0 or newer)`, `rapidjson`, `sqlite` and `boost` (for the header-only `multi_index_container` library) from your distribution's repository or other source.
|
||||
|
||||
On Debian Buster, this can be done with:
|
||||
On Debian Bullseye, this can be done with:
|
||||
|
||||
```bash
|
||||
sudo apt install \
|
||||
capnproto cmake g++ libboost-dev libcapnp-dev libsqlite-dev libsqlite3-dev make rapidjson-dev zlib1g-dev
|
||||
capnproto cmake g++ libboost-dev libcapnp-dev libsqlite3-dev rapidjson-dev zlib1g-dev
|
||||
```
|
||||
|
||||
Then compile and install laminar with:
|
||||
@@ -25,7 +25,7 @@ Then compile and install laminar with:
|
||||
git clone https://github.com/ohwgiles/laminar.git
|
||||
cd laminar
|
||||
cmake -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/usr
|
||||
make -j4
|
||||
make -j "$(nproc)"
|
||||
# Warning: the following will overwrite an existing /etc/laminar.conf
|
||||
sudo make install
|
||||
```
|
||||
|
||||
@@ -17,24 +17,26 @@ Throughout this document, the fixed base path `/var/lib/laminar` is used. This i
|
||||
|
||||
# Installing Laminar
|
||||
|
||||
Pre-built packages are available for Debian 9 (Stretch) and CentOS 7 on x86_64. Alternatively, Laminar may be built from source for any Linux distribution.
|
||||
Since Debian Bullseye, Laminar is available in [the official repositories](https://packages.debian.org/search?searchon=sourcenames&keywords=laminar).
|
||||
|
||||
## Installation from binaries
|
||||
Alternatively, pre-built upstream packages are available for Debian 10 (Bullseye) on x86_64 and armhf, and for Rocky/CentOS/RHEL 7 and 8 on x86_64.
|
||||
|
||||
Alternatively to the source-based approach shown above, precompiled packages are supplied for x86_64 Debian 9 (Stretch) and CentOS 7
|
||||
Finally, Laminar may be built from source for any Linux distribution.
|
||||
|
||||
## Installation from upstream packages
|
||||
|
||||
Under Debian:
|
||||
|
||||
```bash
|
||||
wget https://github.com/ohwgiles/laminar/releases/download/0.6/laminar-0.6-1-amd64.deb
|
||||
sudo apt install laminar-0.6-1-amd64.deb
|
||||
wget https://github.com/ohwgiles/laminar/releases/download/1.1/laminar_1.1-1.upstream-debian10_amd64.deb
|
||||
sudo apt install ./laminar_1.1-1.upstream-debian10_amd64.deb
|
||||
```
|
||||
|
||||
Under CentOS:
|
||||
Under Rocky/CentOS/RHEL:
|
||||
|
||||
```bash
|
||||
wget https://github.com/ohwgiles/laminar/releases/download/0.5/laminar-0.6-1.x86_64.rpm
|
||||
sudo yum install laminar-0.6-1.x86_64.rpm
|
||||
wget https://github.com/ohwgiles/laminar/releases/download/1.1/laminar-1.1.upstream_rocky8-1.x86_64.rpm
|
||||
sudo dnf install ./laminar-1.1.upstream_rocky8-1.x86_64.rpm
|
||||
```
|
||||
|
||||
Both install packages will create a new `laminar` user and install (but not activate) a systemd service for launching the laminar daemon.
|
||||
@@ -86,7 +88,7 @@ Laminar's configuration file may be found at `/etc/laminar.conf`. Laminar will s
|
||||
|
||||
Edit `/etc/laminar.conf` and change `LAMINAR_BIND_HTTP` to `IPADDR:PORT`, `unix:PATH/TO/SOCKET` or `unix-abstract:SOCKETNAME`. `IPADDR` may be `*` to bind on all interfaces. The default is `*:8080`.
|
||||
|
||||
Do not attempt to run laminar on port 80. This requires running as `root`, and Laminar will not drop privileges when executing job scripts! For a more complete integrated solution (including SSL), run laminar as a reverse proxy behind a regular webserver.
|
||||
Do not attempt to run laminar on port 80. This requires running as `root`, and Laminar will not drop privileges when executing job scripts! For a more complete integrated solution (including SSL), run laminar behind a regular webserver acting as a reverse proxy.
|
||||
|
||||
## Running behind a reverse proxy
|
||||
|
||||
@@ -94,10 +96,12 @@ A reverse proxy is required if you want Laminar to share a port with other web s
|
||||
|
||||
If you use [artefacts](#Archiving-artefacts), note that Laminar is not designed as a file server, and better performance will be achieved by allowing the frontend web server to serve the archive directory directly (e.g. using a `Location` directive).
|
||||
|
||||
Laminar uses Sever Sent Events to provide a responsive, auto-updating display without polling. Most frontend webservers should handle this without any extra configuration.
|
||||
Laminar uses Server Sent Events to provide a responsive, auto-updating display without polling. Most frontend webservers should handle this without any extra configuration.
|
||||
|
||||
If you use a reverse proxy to host Laminar at a subfolder instead of a subdomain root, the `<base href>` needs to be updated to ensure all links point to their proper targets. This can be done by setting `LAMINAR_BASE_URL` in `/etc/laminar.conf`.
|
||||
|
||||
See [this example configuration file for nginx](https://github.com/ohwgiles/laminar/blob/master/examples/nginx-ssl-reverse-proxy.conf).
|
||||
|
||||
## More configuration options
|
||||
|
||||
See the [reference section](#Service-configuration-file)
|
||||
@@ -196,6 +200,8 @@ This is what [git hooks](https://git-scm.com/book/gr/v2/Customizing-Git-Git-Hook
|
||||
LAMINAR_REASON="Push to git repository" laminarc queue example-build
|
||||
```
|
||||
|
||||
For a more advanced example, see [examples/git-post-receive-hook-notes](https://github.com/ohwgiles/laminar/blob/master/examples/git-post-receive-hook-notes)
|
||||
|
||||
What if your git server is not the same machine as the laminar instance?
|
||||
|
||||
## Triggering on a remote laminar instance
|
||||
@@ -240,7 +246,7 @@ Additionally, the raw log output may be fetched over a plain HTTP request to htt
|
||||
|
||||
# Job chains
|
||||
|
||||
A typical pipeline may involve several steps, such as build, test and deploy. Depending on the project, these may be broken up into seperate laminar jobs for maximal flexibility.
|
||||
A typical pipeline may involve several steps, such as build, test and deploy. Depending on the project, these may be broken up into separate laminar jobs for maximal flexibility.
|
||||
|
||||
The preferred way to accomplish this in Laminar is to use the same method as [regular run triggering](#Triggering-a-run), that is, calling `laminarc` directly in your `example.run` scripts.
|
||||
|
||||
@@ -366,7 +372,7 @@ fi
|
||||
|
||||
Of course, you can make this as pretty as you like. A [helper script](#Helper-scripts) can be a good choice here.
|
||||
|
||||
If you want to send to different addresses dependending on the job, replace `engineering@company.com` above with a variable, e.g. `$RECIPIENTS`, and set `RECIPIENTS=nora@company.com,joe@company.com` in `/var/lib/laminar/cfg/jobs/JOB.env`. See [Environment variables](#Environment-variables).
|
||||
If you want to send to different addresses depending on the job, replace `engineering@company.com` above with a variable, e.g. `$RECIPIENTS`, and set `RECIPIENTS=nora@company.com,joe@company.com` in `/var/lib/laminar/cfg/jobs/JOB.env`. See [Environment variables](#Environment-variables).
|
||||
|
||||
You could also update the `$RECIPIENTS` variable dynamically based on the build itself. For example, if your run script accepts a parameter `$rev` which is a git commit id, as part of your job's `.after` script you could do the following:
|
||||
|
||||
@@ -375,7 +381,7 @@ author_email=$(git show -s --format='%ae' $rev)
|
||||
laminarc set RECIPIENTS $author_email
|
||||
```
|
||||
|
||||
See [notify-email-pretty.sh](https://github.com/ohwgiles/laminar/blob/master/examples/notify-email-pretty.sh) and [notify-email-text-log.sh](https://github.com/ohwgiles/laminar/blob/master/examples/notify-email-text-log.sh).
|
||||
See [examples/notify-email-pretty](https://github.com/ohwgiles/laminar/blob/master/examples/notify-email-pretty) and [examples/notify-email-text-log](https://github.com/ohwgiles/laminar/blob/master/examples/notify-email-text-log).
|
||||
|
||||
---
|
||||
|
||||
@@ -588,6 +594,8 @@ docker run --rm -ti -v $PWD:/root ubuntu /bin/bash -xe <<EOF
|
||||
EOF
|
||||
```
|
||||
|
||||
For more advanced usage, see [examples/docker-advanced](https://github.com/ohwgiles/laminar/blob/master/examples/docker-advanced)
|
||||
|
||||
---
|
||||
|
||||
# Colours in log output
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
[Unit]
|
||||
Description=Laminar continuous integration service
|
||||
After=network.target
|
||||
Documentation=man:laminard(8)
|
||||
Documentation=https://laminar.ohwg.net/docs.html
|
||||
|
||||
[Service]
|
||||
User=laminar
|
||||
|
||||
@@ -47,14 +47,14 @@ The laminar server to connect to is read from the
|
||||
environment variable. If empty, it falls back to
|
||||
.Ev LAMINAR_BIND_RPC
|
||||
and finally defaults to
|
||||
.Ad
|
||||
unix-abstract:laminar.
|
||||
.Ad unix-abstract:laminar
|
||||
.Sh ENVIRONMENT
|
||||
.Bl -tag
|
||||
.It Ev LAMINAR_HOST
|
||||
address of server to connect. May be of the form
|
||||
.Ad IP:PORT,
|
||||
.Ad unix:PATH/TO/SOCKET or
|
||||
.Ad unix:PATH/TO/SOCKET
|
||||
or
|
||||
.Ad unix-abstract:NAME
|
||||
.It Ev LAMINAR_BIND_RPC
|
||||
fallback server address variable. It is set by
|
||||
|
||||
53
examples/docker-advanced
Executable file
53
examples/docker-advanced
Executable file
@@ -0,0 +1,53 @@
|
||||
#!/bin/bash -eu
|
||||
|
||||
# Any failing command in a pipe will cause an error, instead
|
||||
# of just an error in the last command in the pipe
|
||||
set -o pipefail
|
||||
|
||||
# Log commands executed
|
||||
set -x
|
||||
|
||||
# Simple way of getting the docker build tag:
|
||||
tag=$(docker build -q - <<\EOF
|
||||
FROM debian:bullseye
|
||||
RUN apt-get update && apt-get install -y build-essential
|
||||
EOF
|
||||
)
|
||||
|
||||
# But -q suppresses the log output. If you want to keep it,
|
||||
# you could use the following fancier way:
|
||||
|
||||
exec {pfd}<><(:) # get a new pipe
|
||||
docker build - <<\EOF |
|
||||
FROM debian:bullseye
|
||||
RUN apt-get update && apt-get install -y build-essential
|
||||
EOF
|
||||
tee >(awk '/Successfully built/{print $3}' >&$pfd) # parse output to pipe
|
||||
read tag <&$pfd # read tag back from pipe
|
||||
exec {pfd}<&- # close pipe
|
||||
|
||||
# Alternatively, you can use the -t option to docker build
|
||||
# to give the built image a name to refer to later. But then
|
||||
# you need to ensure that it does not conflict with any other
|
||||
# images, and handle cases where multiple instances of the
|
||||
# job attempt to update the tagged image.
|
||||
|
||||
# If you want the image to be cleaned up on exit:
|
||||
trap "docker rmi $tag" EXIT
|
||||
|
||||
# Now use the image to build something:
|
||||
docker run -i --rm \
|
||||
-v "$PWD:$PWD" \
|
||||
-w "$PWD" \
|
||||
-u $(id -u):$(id -g) \
|
||||
$tag /bin/bash -eux \
|
||||
<<EOF
|
||||
# The passed options mean we keep our current working
|
||||
# directory and user, so no permission problems on the
|
||||
# artifacts produced within the container.
|
||||
echo 'main(){puts("hello world");}' | gcc -x c -static -o hello -
|
||||
EOF
|
||||
|
||||
# Test the result
|
||||
./hello
|
||||
|
||||
42
examples/git-post-receive-hook-notes
Executable file
42
examples/git-post-receive-hook-notes
Executable file
@@ -0,0 +1,42 @@
|
||||
#!/bin/bash -e
|
||||
# Simple post-receive hook that triggers a laminar run
|
||||
# for every commit pushed to every branch, and annotates
|
||||
# the commit with the run number using a git note.
|
||||
# On the cloned repository, useful config is
|
||||
# git config --add remote.origin.fetch "+refs/notes/*:refs/notes/*"
|
||||
# to automatically fetch all notes from the origin, and
|
||||
# git config --add notes.displayRef "refs/notes/*"
|
||||
# to display all notes in the git log by default
|
||||
|
||||
# The laminar job to trigger
|
||||
LAMINAR_JOB=my-project
|
||||
|
||||
# Default notes ref is refs/notes/commits
|
||||
NOTES_REF=refs/notes/ci
|
||||
|
||||
# For each ref pushed...
|
||||
while read old new ref; do
|
||||
# Skip tags, notes, etc. Only do heads.
|
||||
# Extend this to only trigger on specific branches.
|
||||
if [[ $ref != refs/heads/* ]]; then
|
||||
continue
|
||||
fi
|
||||
# Otherwise, for each new commit in the ref...
|
||||
# (to only trigger on the newest, set commit=$new and delete the loop)
|
||||
git rev-list $([[ $old =~ ^0+$ ]] && echo $new || echo $old..$new) | while read commit; do
|
||||
# Queue the laminar run
|
||||
run=$(laminarc queue $LAMINAR_JOB commit=$commit ref=$ref)
|
||||
echo "Started Laminar $run for commit $commit to ref $ref"
|
||||
|
||||
# Add a git note about the run
|
||||
blob=$(echo -n "Laminar-Run: $run" | git hash-object -w --stdin)
|
||||
if last_note=$(git show-ref -s $NOTES_REF); then
|
||||
git read-tree $last_note
|
||||
p_arg=-p
|
||||
fi
|
||||
git update-index --add --cacheinfo 100644 $blob $commit
|
||||
tree=$(git write-tree)
|
||||
new_note=$(echo "Notes added by post-receive hook" | git commit-tree $tree $p_arg $last_note)
|
||||
git update-ref $NOTES_REF $new_note $last_note
|
||||
done
|
||||
done
|
||||
52
examples/nginx-ssl-reverse-proxy.conf
Normal file
52
examples/nginx-ssl-reverse-proxy.conf
Normal file
@@ -0,0 +1,52 @@
|
||||
server {
|
||||
listen [::]:80;
|
||||
listen 80;
|
||||
server_name laminar.example.com;
|
||||
|
||||
# rule for letsencrypt ACME challenge requests
|
||||
location ^~ /.well-known/acme-challenge/ {
|
||||
default_type "text/plain";
|
||||
alias /srv/www/acme-challenge/;
|
||||
}
|
||||
|
||||
# redirect all other http to https
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
# http2 is recommended because browsers will only open a small number of concurrent SSE streams over http1
|
||||
listen [::]:443 ssl http2;
|
||||
listen 443 ssl http2;
|
||||
server_name laminar.example.com;
|
||||
|
||||
# modern tls only, see https://syslink.pl/cipherlist/ for a more complete example
|
||||
ssl_protocols TLSv1.3;
|
||||
ssl_ciphers EECDH+AESGCM:EDH+AESGCM;
|
||||
|
||||
# set according to ACME/letsencrypt client
|
||||
ssl_certificate /path/to/certificate.crt;
|
||||
ssl_certificate_key /path/to/private.key;
|
||||
|
||||
# use "location /" if laminar is to be accessible at the (sub)domain root.
|
||||
# alteratively, use a subdirectory such as "location /my-laminar/" and ensure that
|
||||
# LAMINAR_BASE_URL=/my-laminar/ accordingly.
|
||||
location / {
|
||||
# set proxy_pass according to LAMINAR_BIND_HTTP.
|
||||
# note that the laminar default for LAMINAR_BIND_HTTP is *:8080, which binds on all interfaces
|
||||
# instead of just the loopback device and is almost certainly not what you want if you are using
|
||||
# a reverse proxy. It should be set to 127.0.0.1:8080 at a minimum, or use unix sockets for more
|
||||
# fine-grained control of permissions.
|
||||
# see http://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_pass
|
||||
# and https://laminar.ohwg.net/docs.html#Running-on-a-different-HTTP-port-or-Unix-socket
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
|
||||
# required to allow laminar's SSE stream to pass correctly
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
}
|
||||
|
||||
# have nginx serve artefacts directly rather than having laminard do it
|
||||
location /archive/ {
|
||||
alias /var/lib/laminar/archive/;
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,11 @@ OUTPUT_DIR=$PWD
|
||||
|
||||
SOURCE_DIR=$(readlink -f $(dirname ${BASH_SOURCE[0]})/..)
|
||||
|
||||
VERSION=$(cd "$SOURCE_DIR" && git describe --tags --abbrev=8 --dirty | tr - .)~upstream_centos8
|
||||
VERSION=$(cd "$SOURCE_DIR" && git describe --tags --abbrev=8 --dirty | tr - .)~upstream_rocky8
|
||||
|
||||
DOCKER_TAG=$(docker build -q - <<EOS
|
||||
FROM centos:8
|
||||
RUN dnf -y install rpm-build cmake make gcc-c++ wget sqlite-devel boost-devel zlib-devel
|
||||
FROM rockylinux/rockylinux:8
|
||||
RUN dnf -y update && dnf -y install rpm-build cmake make gcc-c++ wget sqlite-devel boost-devel zlib-devel
|
||||
EOS
|
||||
)
|
||||
|
||||
@@ -27,7 +27,7 @@ tar xzf capnproto.tar.gz
|
||||
tar xzf rapidjson.tar.gz
|
||||
|
||||
cd /build/capnproto-0.7.0/c++/
|
||||
cmake3 -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=off .
|
||||
cmake -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=off .
|
||||
make -j4
|
||||
make install
|
||||
|
||||
@@ -94,7 +94,7 @@ static void usage(std::ostream& out) {
|
||||
out << " start JOB_LIST... queues one or more jobs for execution and blocks until it starts.\n";
|
||||
out << " run JOB_LIST... queues one or more jobs for execution and blocks until it finishes.\n";
|
||||
out << " set PARAMETER_LIST... sets the given parameters as environment variables in the currently\n";
|
||||
out << " runing job. Fails if run outside of a job context.\n";
|
||||
out << " running job. Fails if run outside of a job context.\n";
|
||||
out << " abort NAME NUMBER aborts the run identified by NAME and NUMBER.\n";
|
||||
out << " show-jobs lists all known jobs.\n";
|
||||
out << " show-queued lists currently queued jobs.\n";
|
||||
|
||||
16
src/http.cpp
16
src/http.cpp
@@ -221,9 +221,6 @@ kj::Promise<void> Http::request(kj::HttpMethod method, kj::StringPtr url, const
|
||||
return stream->write(array.begin(), array.size()).attach(kj::mv(array)).attach(kj::mv(file)).attach(kj::mv(stream));
|
||||
}
|
||||
} else if(parseLogEndpoint(url, name, num)) {
|
||||
auto lw = kj::heap<WithSetRef<LogWatcher>>(logWatchers);
|
||||
lw->job = name;
|
||||
lw->run = num;
|
||||
bool complete;
|
||||
std::string output;
|
||||
if(laminar.handleLogRequest(name, num, output, complete)) {
|
||||
@@ -232,11 +229,16 @@ kj::Promise<void> Http::request(kj::HttpMethod method, kj::StringPtr url, const
|
||||
// Disables nginx reverse-proxy's buffering. Necessary for dynamic log output.
|
||||
responseHeaders.add("X-Accel-Buffering", "no");
|
||||
auto stream = response.send(200, "OK", responseHeaders, nullptr);
|
||||
return stream->write(output.data(), output.size()).then([=,s=stream.get(),c=lw.get()]{
|
||||
auto s = stream.get();
|
||||
auto lw = kj::heap<WithSetRef<LogWatcher>>(logWatchers);
|
||||
lw->job = name;
|
||||
lw->run = num;
|
||||
auto promise = writeLogChunk(lw.get(), stream.get()).attach(kj::mv(stream)).attach(kj::mv(lw));
|
||||
return s->write(output.data(), output.size()).attach(kj::mv(output)).then([p=kj::mv(promise),complete]() mutable {
|
||||
if(complete)
|
||||
return kj::Promise<void>(kj::READY_NOW);
|
||||
return writeLogChunk(c, s);
|
||||
}).attach(kj::mv(output)).attach(kj::mv(stream)).attach(kj::mv(lw));
|
||||
return kj::mv(p);
|
||||
});
|
||||
}
|
||||
} else if(resources->handleRequest(url.cStr(), &start, &end, &content_type)) {
|
||||
responseHeaders.set(kj::HttpHeaderId::CONTENT_TYPE, content_type);
|
||||
@@ -288,7 +290,7 @@ void Http::notifyLog(std::string job, uint run, std::string log_chunk, bool eot)
|
||||
{
|
||||
for(LogWatcher* lw : logWatchers) {
|
||||
if(lw->job == job && lw->run == run) {
|
||||
lw->pendingOutput.push_back(kj::mv(log_chunk));
|
||||
lw->pendingOutput.push_back(log_chunk);
|
||||
lw->fulfiller->fulfill(kj::mv(eot));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,18 +203,21 @@ std::list<std::string> Laminar::listKnownJobs() {
|
||||
return res;
|
||||
}
|
||||
|
||||
void Laminar::populateArtifacts(Json &j, std::string job, uint num) const {
|
||||
void Laminar::populateArtifacts(Json &j, std::string job, uint num, kj::Path subdir) const {
|
||||
kj::Path runArchive{job,std::to_string(num)};
|
||||
runArchive = runArchive.append(subdir);
|
||||
KJ_IF_MAYBE(dir, fsHome->tryOpenSubdir("archive"/runArchive)) {
|
||||
for(kj::StringPtr file : (*dir)->listNames()) {
|
||||
kj::FsNode::Metadata meta = (*dir)->lstat(kj::Path{file});
|
||||
if(meta.type != kj::FsNode::Type::FILE)
|
||||
continue;
|
||||
j.StartObject();
|
||||
j.set("url", archiveUrl + (runArchive/file).toString().cStr());
|
||||
j.set("filename", file.cStr());
|
||||
j.set("size", meta.size);
|
||||
j.EndObject();
|
||||
if(meta.type == kj::FsNode::Type::FILE) {
|
||||
j.StartObject();
|
||||
j.set("url", archiveUrl + (runArchive/file).toString().cStr());
|
||||
j.set("filename", (subdir/file).toString().cStr());
|
||||
j.set("size", meta.size);
|
||||
j.EndObject();
|
||||
} else if(meta.type == kj::FsNode::Type::DIRECTORY) {
|
||||
populateArtifacts(j, job, num, subdir/file);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -361,15 +364,17 @@ std::string Laminar::getStatus(MonitorScope scope) {
|
||||
j.EndObject();
|
||||
} else { // Home page
|
||||
j.startArray("recent");
|
||||
db->stmt("SELECT * FROM builds WHERE completedAt IS NOT NULL ORDER BY completedAt DESC LIMIT 20")
|
||||
.fetch<str,uint,str,time_t,time_t,time_t,int>([&](str name,uint build,str context,time_t,time_t started,time_t completed,int result){
|
||||
db->stmt("SELECT name,number,node,queuedAt,startedAt,completedAt,result,reason FROM builds WHERE completedAt IS NOT NULL ORDER BY completedAt DESC LIMIT 20")
|
||||
.fetch<str,uint,str,time_t,time_t,time_t,int,str>([&](str name,uint build,str context,time_t queued,time_t started,time_t completed,int result,str reason){
|
||||
j.StartObject();
|
||||
j.set("name", name)
|
||||
.set("number", build)
|
||||
.set("context", context)
|
||||
.set("queued", queued)
|
||||
.set("started", started)
|
||||
.set("completed", completed)
|
||||
.set("result", to_string(RunState(result)))
|
||||
.set("reason", reason)
|
||||
.EndObject();
|
||||
});
|
||||
j.EndArray();
|
||||
@@ -465,6 +470,12 @@ std::string Laminar::getStatus(MonitorScope scope) {
|
||||
j.EndObject();
|
||||
});
|
||||
j.EndArray();
|
||||
j.startObject("completedCounts");
|
||||
db->stmt("SELECT name, COUNT(*) FROM builds WHERE result IS NOT NULL GROUP BY name")
|
||||
.fetch<str, uint>([&](str job, uint count){
|
||||
j.set(job.c_str(), count);
|
||||
});
|
||||
j.EndObject();
|
||||
}
|
||||
j.EndObject();
|
||||
return j.str();
|
||||
@@ -769,7 +780,14 @@ void Laminar::handleRunFinished(Run * r) {
|
||||
// anyway so hence this (admittedly debatable) optimization.
|
||||
if(!fsHome->exists(d))
|
||||
break;
|
||||
fsHome->remove(d);
|
||||
// must use a try/catch because remove will throw if deletion fails. Using
|
||||
// tryRemove does not help because it still throws an exception for some
|
||||
// errors such as EACCES
|
||||
try {
|
||||
fsHome->remove(d);
|
||||
} catch(kj::Exception& e) {
|
||||
LLOG(ERROR, "Could not remove directory", e.getDescription());
|
||||
}
|
||||
}
|
||||
|
||||
fsHome->symlink(kj::Path{"archive", r->name, "latest"}, std::to_string(r->build), kj::WriteMode::CREATE|kj::WriteMode::MODIFY);
|
||||
|
||||
@@ -104,7 +104,7 @@ private:
|
||||
bool tryStartRun(std::shared_ptr<Run> run, int queueIndex);
|
||||
void handleRunFinished(Run*);
|
||||
// expects that Json has started an array
|
||||
void populateArtifacts(Json& out, std::string job, uint num) const;
|
||||
void populateArtifacts(Json& out, std::string job, uint num, kj::Path subdir = kj::Path::parse(".")) const;
|
||||
|
||||
Run* activeRun(const std::string name, uint num) {
|
||||
auto it = activeJobs.byNameNumber().find(boost::make_tuple(name, num));
|
||||
|
||||
@@ -44,7 +44,6 @@ Resources::Resources()
|
||||
INIT_RESOURCE("/js/app.js", js_app_js, CONTENT_TYPE_JS);
|
||||
INIT_RESOURCE("/js/ansi_up.js", js_ansi_up_js, CONTENT_TYPE_JS);
|
||||
INIT_RESOURCE("/js/vue.min.js", js_vue_min_js, CONTENT_TYPE_JS);
|
||||
INIT_RESOURCE("/js/vue-router.min.js", js_vue_router_min_js, CONTENT_TYPE_JS);
|
||||
INIT_RESOURCE("/js/ansi_up.js", js_ansi_up_js, CONTENT_TYPE_JS);
|
||||
INIT_RESOURCE("/js/Chart.min.js", js_Chart_min_js, CONTENT_TYPE_JS);
|
||||
INIT_RESOURCE("/style.css", style_css, CONTENT_TYPE_CSS);
|
||||
|
||||
@@ -11,26 +11,24 @@
|
||||
<link rel="manifest" href="/manifest.webmanifest">
|
||||
<title>Laminar</title>
|
||||
<script src="js/vue.min.js"></script>
|
||||
<script src="js/vue-router.min.js"></script>
|
||||
<script src="js/ansi_up.js"></script>
|
||||
<script src="js/Chart.min.js"></script>
|
||||
<script src="js/app.js" defer></script>
|
||||
|
||||
<link href="style.css" rel="stylesheet">
|
||||
<link href="custom/style.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<template id="home"><div id="page-home-main">
|
||||
<nav>
|
||||
<table class="table striped">
|
||||
<tr v-for="job in jobsQueued">
|
||||
<td><router-link :to="'/jobs/'+job.name">{{job.name}}</router-link> <i>queued</i></td>
|
||||
<td><router-link :to="'jobs/'+job.name">{{job.name}}</router-link> <i>queued</i></td>
|
||||
</tr>
|
||||
<tr v-for="job in jobsRunning">
|
||||
<td>
|
||||
<span v-html="runIcon(job.result)"></span>
|
||||
<router-link :to="'/jobs/'+job.name">{{job.name}}</router-link>
|
||||
<router-link :to="'/jobs/'+job.name+'/'+job.number">#{{job.number}}</router-link>
|
||||
<router-link :to="'jobs/'+job.name">{{job.name}}</router-link>
|
||||
<router-link :to="'jobs/'+job.name+'/'+job.number">#{{job.number}}</router-link>
|
||||
<small style="float:right;">{{formatDuration(job.started, job.completed)}}</small>
|
||||
<div class="progress" style="margin-top: 5px;">
|
||||
<div class="progress-bar" :class="{overtime:job.overtime,indeterminate:!job.etc}" :style="job.etc && {width:job.progress+'%'}"></div>
|
||||
@@ -40,8 +38,8 @@
|
||||
<tr v-for="job in jobsRecent">
|
||||
<td>
|
||||
<span v-html="runIcon(job.result)"></span>
|
||||
<router-link :to="'/jobs/'+job.name">{{job.name}}</router-link>
|
||||
<router-link :to="'/jobs/'+job.name+'/'+job.number">#{{job.number}}</router-link><br>
|
||||
<router-link :to="'jobs/'+job.name">{{job.name}}</router-link>
|
||||
<router-link :to="'jobs/'+job.name+'/'+job.number">#{{job.number}}</router-link><br>
|
||||
<small>Took {{formatDuration(job.started, job.completed)}} at {{formatDate(job.started)}}</small>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -52,13 +50,13 @@
|
||||
<div>
|
||||
<h3>Recent regressions</h3>
|
||||
<table>
|
||||
<tr v-for="job in resultChanged" v-if="job.lastFailure>job.lastSuccess"><td><router-link :to="'/jobs/'+job.name+'/'+job.lastFailure">{{job.name}} #{{job.lastFailure}}</router-link> since <router-link :to="'/jobs/'+job.name+'/'+job.lastSuccess">#{{job.lastSuccess}}</router-link></tr>
|
||||
<tr v-for="job in resultChanged" v-if="job.lastFailure>job.lastSuccess"><td><router-link :to="'jobs/'+job.name+'/'+job.lastFailure">{{job.name}} #{{job.lastFailure}}</router-link> since <router-link :to="'jobs/'+job.name+'/'+job.lastSuccess">#{{job.lastSuccess}}</router-link></tr>
|
||||
</table>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Low pass rates</h3>
|
||||
<table>
|
||||
<tr v-for="job in lowPassRates"><td><router-link :to="'/jobs/'+job.name">{{job.name}}</router-link></td><td>{{Math.round(job.passRate*100)}} %</td></tr>
|
||||
<tr v-for="job in lowPassRates"><td><router-link :to="'jobs/'+job.name">{{job.name}}</router-link></td><td>{{Math.round(job.passRate*100)}} %</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
<div>
|
||||
@@ -97,8 +95,8 @@
|
||||
</nav>
|
||||
<table class="striped" id="job-list">
|
||||
<tr v-for="job in filteredJobs()">
|
||||
<td><router-link :to="'/jobs/'+job.name">{{job.name}}</router-link></td>
|
||||
<td style="white-space: nowrap;"><span v-html="runIcon(job.result)"></span> <router-link :to="'/jobs/'+job.name+'/'+job.number">#{{job.number}}</router-link></td>
|
||||
<td><router-link :to="'jobs/'+job.name">{{job.name}}</router-link></td>
|
||||
<td style="white-space: nowrap;"><span v-html="runIcon(job.result)"></span> <router-link :to="'jobs/'+job.name+'/'+job.number">#{{job.number}}</router-link></td>
|
||||
<td>{{formatDate(job.started)}}</td>
|
||||
<td>{{formatDuration(job.started,job.completed)}}</td>
|
||||
</tr>
|
||||
@@ -106,7 +104,7 @@
|
||||
</div></template>
|
||||
|
||||
<template id="wallboard"><div class="wallboard">
|
||||
<router-link :to="'/jobs/'+job.name+'/'+job.number" tag="div" v-for="job in wallboardJobs()" :data-result="job.result">
|
||||
<router-link :to="'jobs/'+job.name+'/'+job.number" tag="div" v-for="job in wallboardJobs()" :data-result="job.result">
|
||||
<span style="font-size: 36px; font-weight: bold;">{{job.name}} #{{job.number}}</span><br>
|
||||
<span style="font-size: 30px;">{{formatDate(job.started)}}</span><br>
|
||||
<span style="font-size: 26px;">{{job.reason}}</span>
|
||||
@@ -115,13 +113,13 @@
|
||||
|
||||
<template id="job"><div id="page-job-main">
|
||||
<div style="padding: 15px;">
|
||||
<h2>{{$route.params.name}}</h2>
|
||||
<h2>{{route.params.name}}</h2>
|
||||
<div v-html="description"></div>
|
||||
<dl>
|
||||
<dt>Last Successful Run</dt>
|
||||
<dd><router-link v-if="lastSuccess" :to="'/jobs/'+$route.params.name+'/'+lastSuccess.number">#{{lastSuccess.number}}</router-link> {{lastSuccess?' - at '+formatDate(lastSuccess.started):'never'}}</dd>
|
||||
<dd><router-link v-if="lastSuccess" :to="'jobs/'+route.params.name+'/'+lastSuccess.number">#{{lastSuccess.number}}</router-link> {{lastSuccess?' - at '+formatDate(lastSuccess.started):'never'}}</dd>
|
||||
<dt>Last Failed Run</dt>
|
||||
<dd><router-link v-if="lastFailed" :to="'/jobs/'+$route.params.name+'/'+lastFailed.number">#{{lastFailed.number}}</router-link> {{lastFailed?' - at '+formatDate(lastFailed.started):'never'}}</dd>
|
||||
<dd><router-link v-if="lastFailed" :to="'jobs/'+route.params.name+'/'+lastFailed.number">#{{lastFailed.number}}</router-link> {{lastFailed?' - at '+formatDate(lastFailed.started):'never'}}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div style="display: grid; justify-content: center; padding: 15px;">
|
||||
@@ -142,7 +140,7 @@
|
||||
</tr>
|
||||
<tr v-for="job in jobsRunning.concat(jobsRecent)" track-by="$index">
|
||||
<td style="width:1px"><span v-html="runIcon(job.result)"></span></td>
|
||||
<td><router-link :to="'/jobs/'+$route.params.name+'/'+job.number">#{{job.number}}</router-link></td>
|
||||
<td><router-link :to="'jobs/'+route.params.name+'/'+job.number">#{{job.number}}</router-link></td>
|
||||
<td class="text-center">{{formatDate(job.started)}}</td>
|
||||
<td class="text-center">{{formatDuration(job.started, job.completed)}}</td>
|
||||
<td class="text-center vp-sm-hide">{{job.reason}}</td>
|
||||
@@ -159,10 +157,10 @@
|
||||
<template id="run"><div style="display: grid; grid-template-rows: auto 1fr">
|
||||
<div style="padding: 15px">
|
||||
<div style="display: grid; grid-template-columns: auto 25px auto auto 1fr 400px; gap: 5px; align-items: center">
|
||||
<h2 style="white-space: nowrap"><span v-html="runIcon(job.result)"></span> {{$route.params.name}} #{{$route.params.number}}</h2>
|
||||
<h2 style="white-space: nowrap"><span v-html="runIcon(job.result)"></span> {{route.params.name}} #{{route.params.number}}</h2>
|
||||
<span></span>
|
||||
<router-link :disabled="$route.params.number == 1" :to="'/jobs/'+$route.params.name+'/'+($route.params.number-1)" tag="button">«</router-link>
|
||||
<router-link :disabled="$route.params.number == latestNum" :to="'/jobs/'+$route.params.name+'/'+(parseInt($route.params.number)+1)" tag="button">»</router-link>
|
||||
<router-link :disabled="route.params.number == 1" :to="'jobs/'+route.params.name+'/'+(route.params.number-1)" tag="button">«</router-link>
|
||||
<router-link :disabled="route.params.number == latestNum" :to="'jobs/'+route.params.name+'/'+(parseInt(route.params.number)+1)" tag="button">»</router-link>
|
||||
<span></span>
|
||||
<div class="progress" v-show="job.result == 'running'">
|
||||
<div class="progress-bar" :class="{overtime:job.overtime,indeterminate:!job.etc}" :style="job.etc && {width:job.progress+'%'}"></div>
|
||||
@@ -171,7 +169,7 @@
|
||||
<div id="page-run-detail">
|
||||
<dl>
|
||||
<dt>Reason</dt><dd>{{job.reason}}</dd>
|
||||
<dt v-show="job.upstream.num > 0">Upstream</dt><dd v-show="job.upstream.num > 0"><router-link :to="'/jobs/'+job.upstream.name">{{job.upstream.name}}</router-link> <router-link :to="'/jobs/'+job.upstream.name+'/'+job.upstream.num">#{{job.upstream.num}}</router-link></li></dd>
|
||||
<dt v-show="job.upstream.num > 0">Upstream</dt><dd v-show="job.upstream.num > 0"><router-link :to="'jobs/'+job.upstream.name">{{job.upstream.name}}</router-link> <router-link :to="'jobs/'+job.upstream.name+'/'+job.upstream.num">#{{job.upstream.num}}</router-link></li></dd>
|
||||
<dt>Queued for</dt><dd>{{formatDuration(job.queued, job.started ? job.started : Math.floor(Date.now()/1000))}}</dd>
|
||||
<dt v-show="job.started">Started</dt><dd v-show="job.started">{{formatDate(job.started)}}</dd>
|
||||
<dt v-show="runComplete(job)">Completed</dt><dd v-show="job.completed">{{formatDate(job.completed)}}</dd>
|
||||
@@ -199,8 +197,8 @@
|
||||
<img src="icon.png"> {{title}}
|
||||
</router-link>
|
||||
<div id="nav-top-links" style="display: grid; grid-auto-flow: column; justify-content: start; gap: 15px; padding: 0 15px; align-items: center; font-size: 16px;">
|
||||
<router-link to="/jobs">Jobs</router-link>
|
||||
<router-link v-for="(crumb,i) in _route.path.slice(1).split('/').slice(1,-1)" :to="_route.path.split('/').slice(0,i+3).join('/')">{{crumb}}</router-link>
|
||||
<router-link to="jobs">Jobs</router-link>
|
||||
<router-link v-for="(crumb,i) in route.path.slice(1).split('/').slice(1,-1)" :to="route.path.split('/').slice(0,i+3).join('/')">{{crumb}}</router-link>
|
||||
</div>
|
||||
<div></div>
|
||||
<span class="version">{{version}}</span>
|
||||
|
||||
@@ -18,86 +18,6 @@ Vue.filter('iecFileSize', bytes => {
|
||||
['B', 'KiB', 'MiB', 'GiB', 'TiB'][exp];
|
||||
});
|
||||
|
||||
// Mixin handling retrieving dynamic updates from the backend
|
||||
Vue.mixin((() => {
|
||||
const setupEventSource = (to, query, next, comp) => {
|
||||
const es = new EventSource(document.head.baseURI + to.path.substr(1) + query);
|
||||
es.comp = comp; // When reconnecting, we already have a component. Usually this will be null.
|
||||
es.to = to; // Save a ref, needed for adding query params for pagination.
|
||||
es.onmessage = function(msg) {
|
||||
msg = JSON.parse(msg.data);
|
||||
// "status" is the first message the server always delivers.
|
||||
// Use this to confirm the navigation. The component is not
|
||||
// created until next() is called, so creating a reference
|
||||
// for other message types must be deferred. There are some extra
|
||||
// subtle checks here. If this eventsource already has a component,
|
||||
// then this is not the first time the status message has been
|
||||
// received. If the frontend requests an update, the status message
|
||||
// should not be handled here, but treated the same as any other
|
||||
// message. An exception is if the connection has been lost - in
|
||||
// that case we should treat this as a "first-time" status message.
|
||||
// !this.comp.es is used to test this condition.
|
||||
if (msg.type === 'status' && (!this.comp || !this.comp.es)) {
|
||||
next(comp => {
|
||||
// Set up bidirectional reference
|
||||
// 1. needed to reference the component for other msg types
|
||||
this.comp = comp;
|
||||
// 2. needed to close the ws on navigation away
|
||||
comp.es = this;
|
||||
comp.esReconnectInterval = 500;
|
||||
// Update html and nav titles
|
||||
document.title = comp.$root.title = msg.title;
|
||||
comp.$root.version = msg.version;
|
||||
// Calculate clock offset (used by ProgressUpdater)
|
||||
comp.$root.clockSkew = msg.time - Math.floor((new Date()).getTime()/1000);
|
||||
comp.$root.connected = true;
|
||||
// Component-specific callback handler
|
||||
comp[msg.type](msg.data, to.params);
|
||||
});
|
||||
} else {
|
||||
// at this point, the component must be defined
|
||||
if (!this.comp)
|
||||
return console.error("Page component was undefined");
|
||||
else {
|
||||
this.comp.$root.connected = true;
|
||||
this.comp.$root.showNotify(msg.type, msg.data);
|
||||
if(typeof this.comp[msg.type] === 'function')
|
||||
this.comp[msg.type](msg.data);
|
||||
}
|
||||
}
|
||||
}
|
||||
es.onerror = function(e) {
|
||||
this.comp.$root.connected = false;
|
||||
setTimeout(() => {
|
||||
// Recrate the EventSource, passing in the existing component
|
||||
this.comp.es = setupEventSource(to, query, null, this.comp);
|
||||
}, this.comp.esReconnectInterval);
|
||||
if(this.comp.esReconnectInterval < 7500)
|
||||
this.comp.esReconnectInterval *= 1.5;
|
||||
this.close();
|
||||
}
|
||||
return es;
|
||||
}
|
||||
return {
|
||||
beforeRouteEnter(to, from, next) {
|
||||
setupEventSource(to, '', (fn) => { next(fn); });
|
||||
},
|
||||
beforeRouteUpdate(to, from, next) {
|
||||
this.es.close();
|
||||
setupEventSource(to, '', (fn) => { fn(this); next(); });
|
||||
},
|
||||
beforeRouteLeave(to, from, next) {
|
||||
this.es.close();
|
||||
next();
|
||||
},
|
||||
methods: {
|
||||
query(q) {
|
||||
this.es.close();
|
||||
setupEventSource(this.es.to, '?' + Object.entries(q).map(([k,v])=>`${k}=${v}`).join('&'), fn => fn(this));
|
||||
}
|
||||
}
|
||||
};
|
||||
})());
|
||||
|
||||
// Mixin for periodically updating a progress bar
|
||||
Vue.mixin({
|
||||
@@ -170,9 +90,9 @@ Vue.mixin({
|
||||
let m = d.getMinutes();
|
||||
if (m < 10)
|
||||
m = '0' + m;
|
||||
return d.getHours() + ':' + m + ' on ' +
|
||||
return d.getHours() + ':' + m + ' on ' +
|
||||
['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][d.getDay()] + ' ' + d.getDate() + '. ' +
|
||||
['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'][d.getMonth()] + ' ' +
|
||||
['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'][d.getMonth()] + ' ' +
|
||||
d.getFullYear();
|
||||
},
|
||||
// Pretty-print a duration
|
||||
@@ -283,15 +203,19 @@ const Charts = (() => {
|
||||
if (c.data.labels[j] == name) {
|
||||
c.data.datasets[0].data[j]++;
|
||||
c.update();
|
||||
break;
|
||||
return;
|
||||
}
|
||||
}
|
||||
// if we get here, it's a new/unknown job
|
||||
c.data.labels.push(name);
|
||||
c.data.datasets[0].data.push(1);
|
||||
c.update();
|
||||
}
|
||||
return c;
|
||||
},
|
||||
createTimePerJobChart: (id, data) => {
|
||||
createTimePerJobChart: (id, data, completedCounts) => {
|
||||
const scale = timeScale(Math.max(...Object.values(data)));
|
||||
return new Chart(document.getElementById(id), {
|
||||
const c = new Chart(document.getElementById(id), {
|
||||
type: 'horizontalBar',
|
||||
data: {
|
||||
labels: Object.keys(data),
|
||||
@@ -312,23 +236,38 @@ const Charts = (() => {
|
||||
}
|
||||
}]},
|
||||
tooltips:{callbacks:{
|
||||
label: (tip, data) => data.datasets[tip.datasetIndex].label + ': ' + tip.xLabel + ' ' + scale.label.toLowerCase()
|
||||
label: (tip, data) => data.datasets[tip.datasetIndex].label + ': ' + tip.xLabel.toFixed(2) + ' ' + scale.label.toLowerCase()
|
||||
}}
|
||||
}
|
||||
});
|
||||
c.jobCompleted = (name, time) => {
|
||||
for (var j = 0; j < c.data.datasets[0].data.length; ++j) {
|
||||
if (c.data.labels[j] == name) {
|
||||
c.data.datasets[0].data[j] = ((completedCounts[name]-1) * c.data.datasets[0].data[j] + time * scale.factor) / completedCounts[name];
|
||||
c.update();
|
||||
return;
|
||||
}
|
||||
}
|
||||
// if we get here, it's a new/unknown job
|
||||
c.data.labels.push(name);
|
||||
c.data.datasets[0].data.push(time * scale.factor);
|
||||
c.update();
|
||||
};
|
||||
return c;
|
||||
},
|
||||
createRunTimeChangesChart: (id, data) => {
|
||||
const scale = timeScale(Math.max(...data.map(e => Math.max(...e.durations))));
|
||||
return new Chart(document.getElementById(id), {
|
||||
const dataValue = (name, durations) => ({
|
||||
label: name,
|
||||
data: durations.map(x => x * scale.factor),
|
||||
borderColor: 'hsl('+(name.hashCode() % 360)+', 27%, 57%)',
|
||||
backgroundColor: 'transparent'
|
||||
});
|
||||
const c = new Chart(document.getElementById(id), {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: [...Array(10).keys()],
|
||||
datasets: data.map(e => ({
|
||||
label: e.name,
|
||||
data: e.durations.map(x => x * scale.factor),
|
||||
borderColor: 'hsl('+(e.name.hashCode() % 360)+', 27%, 57%)',
|
||||
backgroundColor: 'transparent'
|
||||
}))
|
||||
datasets: data.map(e => dataValue(e.name, e.durations))
|
||||
},
|
||||
options:{
|
||||
title: { display: true, text: 'Run time changes' },
|
||||
@@ -348,10 +287,25 @@ const Charts = (() => {
|
||||
}
|
||||
}
|
||||
});
|
||||
c.jobCompleted = (name, time) => {
|
||||
for (var j = 0; j < c.data.datasets.length; ++j) {
|
||||
if (c.data.datasets[j].label == name) {
|
||||
if(c.data.datasets[j].data.length == 10)
|
||||
c.data.datasets[j].data.shift();
|
||||
c.data.datasets[j].data.push(time * scale.factor);
|
||||
c.update();
|
||||
return;
|
||||
}
|
||||
}
|
||||
// if we get here, it's a new/unknown job
|
||||
c.data.datasets.push(dataValue(name, [time]));
|
||||
c.update();
|
||||
};
|
||||
return c;
|
||||
},
|
||||
createRunTimeChart: (id, jobs, avg) => {
|
||||
const scale = timeScale(Math.max(...jobs.map(v=>v.completed-v.started)));
|
||||
return new Chart(document.getElementById(id), {
|
||||
const c = new Chart(document.getElementById(id), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: jobs.map(e => '#' + e.number).reverse(),
|
||||
@@ -399,6 +353,22 @@ const Charts = (() => {
|
||||
}}
|
||||
}
|
||||
});
|
||||
c.jobCompleted = (num, result, time) => {
|
||||
let avg = c.data.datasets[0].data[0].y / scale.factor;
|
||||
avg = ((avg * (num - 1)) + time) / num;
|
||||
c.data.datasets[0].data[0].y = avg * scale.factor;
|
||||
c.data.datasets[0].data[1].y = avg * scale.factor;
|
||||
if(c.data.datasets[1].data.length == 20) {
|
||||
c.data.labels.shift();
|
||||
c.data.datasets[1].data.shift();
|
||||
c.data.datasets[1].backgroundColor.shift();
|
||||
}
|
||||
c.data.labels.push('#' + num);
|
||||
c.data.datasets[1].data.push(time * scale.factor);
|
||||
c.data.datasets[1].backgroundColor.push(result == 'success' ? '#74af77': '#883d3d');
|
||||
c.update();
|
||||
};
|
||||
return c;
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -421,6 +391,7 @@ const Home = templateId => {
|
||||
lowPassRates: [],
|
||||
};
|
||||
let chtUtilization, chtBuildsPerDay, chtBuildsPerJob, chtTimePerJob;
|
||||
let completedCounts;
|
||||
return {
|
||||
template: templateId,
|
||||
data: () => state,
|
||||
@@ -431,6 +402,7 @@ const Home = templateId => {
|
||||
state.jobsRecent = msg.recent;
|
||||
state.resultChanged = msg.resultChanged;
|
||||
state.lowPassRates = msg.lowPassRates;
|
||||
completedCounts = msg.completedCounts;
|
||||
this.$forceUpdate();
|
||||
|
||||
// defer charts to nextTick because they get DOM elements which aren't rendered yet
|
||||
@@ -438,7 +410,7 @@ const Home = templateId => {
|
||||
chtUtilization = Charts.createExecutorUtilizationChart("chartUtil", msg.executorsBusy, msg.executorsTotal);
|
||||
chtBuildsPerDay = Charts.createRunsPerDayChart("chartBpd", msg.buildsPerDay);
|
||||
chtBuildsPerJob = Charts.createRunsPerJobChart("chartBpj", msg.buildsPerJob);
|
||||
chtTimePerJob = Charts.createTimePerJobChart("chartTpj", msg.timePerJob);
|
||||
chtTimePerJob = Charts.createTimePerJobChart("chartTpj", msg.timePerJob, completedCounts);
|
||||
chtBuildTimeChanges = Charts.createRunTimeChangesChart("chartBuildTimeChanges", msg.buildTimeChanges);
|
||||
});
|
||||
},
|
||||
@@ -453,8 +425,10 @@ const Home = templateId => {
|
||||
chtUtilization.executorBusyChanged(true);
|
||||
},
|
||||
job_completed: function(data) {
|
||||
for (var i = 0; i < state.jobsRunning.length; ++i) {
|
||||
var job = state.jobsRunning[i];
|
||||
if(!(job.name in completedCounts))
|
||||
completedCounts[job.name] = 0;
|
||||
for(let i = 0; i < state.jobsRunning.length; ++i) {
|
||||
const job = state.jobsRunning[i];
|
||||
if (job.name == data.name && job.number == data.number) {
|
||||
state.jobsRunning.splice(i, 1);
|
||||
state.jobsRecent.splice(0, 0, data);
|
||||
@@ -462,9 +436,28 @@ const Home = templateId => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
for(let i = 0; i < state.resultChanged.length; ++i) {
|
||||
const job = state.resultChanged[i];
|
||||
if(job.name == data.name) {
|
||||
job[data.result === 'success' ? 'lastSuccess' : 'lastFailure'] = data.number;
|
||||
this.$forceUpdate();
|
||||
break;
|
||||
}
|
||||
}
|
||||
for(let i = 0; i < state.lowPassRates.length; ++i) {
|
||||
const job = state.lowPassRates[i];
|
||||
if(job.name == data.name) {
|
||||
job.passRate = ((completedCounts[job.name] - 1) * job.passRate + (data.result === 'success' ? 1 : 0)) / completedCounts[job.name];
|
||||
this.$forceUpdate();
|
||||
break;
|
||||
}
|
||||
}
|
||||
completedCounts[job.name]++;
|
||||
chtBuildsPerDay.jobCompleted(data.result === 'success')
|
||||
chtUtilization.executorBusyChanged(false);
|
||||
chtBuildsPerJob.jobCompleted(data.name)
|
||||
chtBuildsPerJob.jobCompleted(data.name);
|
||||
chtTimePerJob.jobCompleted(data.name, data.completed - data.started);
|
||||
chtBuildTimeChanges.jobCompleted(data.name, data.completed - data.started);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -556,7 +549,7 @@ const All = templateId => {
|
||||
return ret;
|
||||
},
|
||||
wallboardLink: function() {
|
||||
return '/wallboard' + (state.group ? '?filter=' + state.groups[state.group] : '');
|
||||
return 'wallboard' + (state.group ? '?filter=' + state.groups[state.group] : '');
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -574,9 +567,10 @@ const Job = templateId => {
|
||||
pages: 0,
|
||||
sort: {}
|
||||
};
|
||||
let chtBt = null;
|
||||
let chtBuildTime = null;
|
||||
return {
|
||||
template: templateId,
|
||||
props: ['route'],
|
||||
data: () => state,
|
||||
methods: {
|
||||
status: function(msg) {
|
||||
@@ -591,12 +585,12 @@ const Job = templateId => {
|
||||
|
||||
// "status" comes again if we change page/sorting. Delete the
|
||||
// old chart and recreate it to prevent flickering of old data
|
||||
if(chtBt)
|
||||
chtBt.destroy();
|
||||
if(chtBuildTime)
|
||||
chtBuildTime.destroy();
|
||||
|
||||
// defer chart to nextTick because they get DOM elements which aren't rendered yet
|
||||
this.$nextTick(() => {
|
||||
chtBt = Charts.createRunTimeChart("chartBt", msg.recent, msg.averageRuntime);
|
||||
chtBuildTime = Charts.createRunTimeChart("chartBt", msg.recent, msg.averageRuntime);
|
||||
});
|
||||
},
|
||||
job_queued: function() {
|
||||
@@ -613,8 +607,8 @@ const Job = templateId => {
|
||||
state.jobsRunning.splice(i, 1);
|
||||
state.jobsRecent.splice(0, 0, data);
|
||||
this.$forceUpdate();
|
||||
// TODO: update the chart
|
||||
}
|
||||
chtBuildTime.jobCompleted(data.number, data.result, data.completed - data.started);
|
||||
},
|
||||
page_next: function() {
|
||||
state.sort.page++;
|
||||
@@ -632,6 +626,9 @@ const Job = templateId => {
|
||||
state.sort.field = field;
|
||||
}
|
||||
this.query(state.sort)
|
||||
},
|
||||
query: function(q) {
|
||||
this.$root.$emit('navigate', q);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -640,6 +637,8 @@ const Job = templateId => {
|
||||
// Component for the /job/:name/:number endpoint
|
||||
const Run = templateId => {
|
||||
const utf8decoder = new TextDecoder('utf-8');
|
||||
const ansi_up = new AnsiUp;
|
||||
ansi_up.use_classes = true;
|
||||
const state = {
|
||||
job: { artifacts: [], upstream: {} },
|
||||
latestNum: null,
|
||||
@@ -657,9 +656,7 @@ const Run = templateId => {
|
||||
if (done)
|
||||
return;
|
||||
state.log += ansi_up.ansi_to_html(
|
||||
value.replace(/</g,'<')
|
||||
.replace(/>/g,'>')
|
||||
.replace(/\033\[\{([^:]+):(\d+)\033\\/g, (m, $1, $2) =>
|
||||
value.replace(/\033\[\{([^:]+):(\d+)\033\\/g, (m, $1, $2) =>
|
||||
'<a href="jobs/'+$1+'" onclick="return vroute(this);">'+$1+'</a>:'+
|
||||
'<a href="jobs/'+$1+'/'+$2+'" onclick="return vroute(this);">#'+$2+'</a>'
|
||||
)
|
||||
@@ -674,11 +671,13 @@ const Run = templateId => {
|
||||
return {
|
||||
template: templateId,
|
||||
data: () => state,
|
||||
props: ['route'],
|
||||
methods: {
|
||||
status: function(data, params) {
|
||||
status: function(data) {
|
||||
// Check for the /latest endpoint
|
||||
const params = this._props.route.params;
|
||||
if(params.number === 'latest')
|
||||
return this.$router.replace('/jobs/' + params.name + '/' + data.latestNum);
|
||||
return this.$router.replace('jobs/' + params.name + '/' + data.latestNum);
|
||||
|
||||
state.number = parseInt(params.number);
|
||||
state.jobsRunning = [];
|
||||
@@ -719,6 +718,113 @@ const Run = templateId => {
|
||||
};
|
||||
};
|
||||
|
||||
Vue.component('RouterLink', {
|
||||
name: 'router-link',
|
||||
props: {
|
||||
to: { type: String },
|
||||
tag: { type: String, default: 'a' }
|
||||
},
|
||||
template: `<component :is="tag" @click="navigate" :href="to"><slot></slot></component>`,
|
||||
methods: {
|
||||
navigate: function(e) {
|
||||
e.preventDefault();
|
||||
history.pushState(null, null, this.to);
|
||||
this.$root.$emit('navigate');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Vue.component('RouterView', (() => {
|
||||
const routes = [
|
||||
{ path: /^$/, component: Home('#home') },
|
||||
{ path: /^jobs$/, component: All('#jobs') },
|
||||
{ path: /^wallboard$/, component: All('#wallboard') },
|
||||
{ path: /^jobs\/(?<name>[^\/]+)$/, component: Job('#job') },
|
||||
{ path: /^jobs\/(?<name>[^\/]+)\/(?<number>\d+)$/, component: Run('#run') }
|
||||
];
|
||||
|
||||
const resolveRoute = path => {
|
||||
for(i in routes) {
|
||||
const r = routes[i].path.exec(path);
|
||||
if(r)
|
||||
return [routes[i].component, r.groups];
|
||||
}
|
||||
}
|
||||
|
||||
let eventSource = null;
|
||||
|
||||
const setupEventSource = (view, query) => {
|
||||
// drop any existing event source
|
||||
if(eventSource)
|
||||
eventSource.close();
|
||||
|
||||
const path = (location.origin+location.pathname).substr(document.head.baseURI.length);
|
||||
const search = query ? '?' + Object.entries(query).map(([k,v])=>`${k}=${v}`).join('&') : '';
|
||||
|
||||
eventSource = new EventSource(document.head.baseURI + path + search);
|
||||
eventSource.reconnectInterval = 500;
|
||||
eventSource.onmessage = msg => {
|
||||
msg = JSON.parse(msg.data);
|
||||
if(msg.type === 'status') {
|
||||
// Event source is connected. Update static data
|
||||
document.title = view.$root.title = msg.title;
|
||||
view.$root.version = msg.version;
|
||||
// Calculate clock offset (used by ProgressUpdater)
|
||||
view.$root.clockSkew = msg.time - Math.floor((new Date()).getTime()/1000);
|
||||
view.$root.connected = true;
|
||||
[view.currentView, route.params] = resolveRoute(path);
|
||||
// the component won't be instantiated until nextTick
|
||||
view.$nextTick(() => {
|
||||
// component is ready, update it with the data from the eventsource
|
||||
eventSource.comp = view.$children[0];
|
||||
// and finally run the component handler
|
||||
eventSource.comp[msg.type](msg.data);
|
||||
});
|
||||
} else {
|
||||
// at this point, the component must be defined
|
||||
if (!eventSource.comp)
|
||||
return console.error("Page component was undefined");
|
||||
view.$root.connected = true;
|
||||
view.$root.showNotify(msg.type, msg.data);
|
||||
if(typeof eventSource.comp[msg.type] === 'function')
|
||||
eventSource.comp[msg.type](msg.data);
|
||||
}
|
||||
}
|
||||
eventSource.onerror = err => {
|
||||
let ri = eventSource.reconnectInterval;
|
||||
view.$root.connected = false;
|
||||
setTimeout(() => {
|
||||
setupEventSource(view);
|
||||
if(ri < 7500)
|
||||
ri *= 1.5;
|
||||
eventSource.reconnectInterval = ri
|
||||
}, ri);
|
||||
eventSource.close();
|
||||
}
|
||||
};
|
||||
|
||||
let route = {};
|
||||
|
||||
return {
|
||||
name: 'router-view',
|
||||
template: `<component :is="currentView" :route="route"></component>`,
|
||||
data: () => ({
|
||||
currentView: routes[0].component, // default to home
|
||||
route: route
|
||||
}),
|
||||
created: function() {
|
||||
this.$root.$on('navigate', query => {
|
||||
setupEventSource(this, query);
|
||||
});
|
||||
window.addEventListener('popstate', () => {
|
||||
this.$root.$emit('navigate');
|
||||
});
|
||||
// initial navigation
|
||||
this.$root.$emit('navigate');
|
||||
}
|
||||
};
|
||||
})());
|
||||
|
||||
new Vue({
|
||||
el: '#app',
|
||||
data: {
|
||||
@@ -726,7 +832,8 @@ new Vue({
|
||||
version: '',
|
||||
clockSkew: 0,
|
||||
connected: false,
|
||||
notify: 'localStorage' in window && localStorage.getItem('showNotifications') == 1
|
||||
notify: 'localStorage' in window && localStorage.getItem('showNotifications') == 1,
|
||||
route: { path: '', params: {} }
|
||||
},
|
||||
computed: {
|
||||
supportsNotifications: () =>
|
||||
@@ -748,16 +855,5 @@ new Vue({
|
||||
},
|
||||
watch: {
|
||||
notify: e => localStorage.setItem('showNotifications', e ? 1 : 0)
|
||||
},
|
||||
router: new VueRouter({
|
||||
mode: 'history',
|
||||
base: document.head.baseURI.substr(location.origin.length),
|
||||
routes: [
|
||||
{ path: '/', component: Home('#home') },
|
||||
{ path: '/jobs', component: All('#jobs') },
|
||||
{ path: '/wallboard', component: All('#wallboard') },
|
||||
{ path: '/jobs/:name', component: Job('#job') },
|
||||
{ path: '/jobs/:name/:number', component: Run('#run') }
|
||||
],
|
||||
}),
|
||||
}
|
||||
});
|
||||
|
||||
@@ -12,8 +12,6 @@
|
||||
--running: #4786ab;
|
||||
--warning: #de9a34;
|
||||
--link-fg: #2f4579;
|
||||
--console-bg: #313235;
|
||||
--console-fg: #fff;
|
||||
--alt-row-bg: #fafafa;
|
||||
--border-grey: #d0d0d0;
|
||||
}
|
||||
@@ -121,10 +119,46 @@ a.sort:not(.dsc):hover:after { border-top-color: var(--main-fg); }
|
||||
a.active { color: var(--main-fg); }
|
||||
a.active:hover { text-decoration: none; }
|
||||
|
||||
/* run console output */
|
||||
.console-log { padding: 15px; background-color: var(--console-bg); }
|
||||
.console-log code { white-space: pre-wrap; color: var(--console-fg); }
|
||||
.console-log a { color: var(--console-fg); }
|
||||
/* run console ansi colors (based on base16-default-dark and base16-bright) */
|
||||
:root {
|
||||
--ansi-black: #181818;
|
||||
--ansi-red: #f8f8f8;
|
||||
--ansi-green: #a1b56c;
|
||||
--ansi-yellow: #f7ca88;
|
||||
--ansi-blue: #7cafc2;
|
||||
--ansi-magenta: #ba8baf;
|
||||
--ansi-cyan: #86c1b9;
|
||||
--ansi-white: #d8d8d8;
|
||||
--ansi-brightblack: #000000;
|
||||
--ansi-brightred: #fb0120;
|
||||
--ansi-brightgreen: #a1c659;
|
||||
--ansi-brightyellow: #fda331;
|
||||
--ansi-brightblue: #6fb3d2;
|
||||
--ansi-brightmagenta: #d381c3;
|
||||
--ansi-brightcyan: #76c7b7;
|
||||
--ansi-brightwhite: #e0e0e0;
|
||||
}
|
||||
.ansi-black-fg { color: var(--ansi-black); } .ansi-black-bg { background-color: var(--ansi-black); }
|
||||
.ansi-red-fg { color: var(--ansi-red); } .ansi-red-bg { background-color: var(--ansi-red); }
|
||||
.ansi-green-fg { color: var(--ansi-green); } .ansi-green-bg { background-color: var(--ansi-green); }
|
||||
.ansi-yellow-fg { color: var(--ansi-yellow); } .ansi-yellow-bg { background-color: var(--ansi-yellow); }
|
||||
.ansi-blue-fg { color: var(--ansi-blue); } .ansi-blue-bg { background-color: var(--ansi-blue); }
|
||||
.ansi-magenta-fg { color: var(--ansi-magenta); } .ansi-magenta-bg { background-color: var(--ansi-magenta); }
|
||||
.ansi-cyan-fg { color: var(--ansi-cyan); } .ansi-cyan-bg { background-color: var(--ansi-cyan); }
|
||||
.ansi-white-fg { color: var(--ansi-white); } .ansi-white-bg { background-color: var(--ansi-white); }
|
||||
.ansi-bright-black-fg { color: var(--ansi-brightblack); } .ansi-bright-black-bg { background-color: var(--ansi-brightblack); }
|
||||
.ansi-bright-red-fg { color: var(--ansi-brightred); } .ansi-bright-red-bg { background-color: var(--ansi-brightred); }
|
||||
.ansi-bright-green-fg { color: var(--ansi-brightgreen); } .ansi-bright-green-bg { background-color: var(--ansi-brightgreen); }
|
||||
.ansi-bright-yellow-fg { color: var(--ansi-brightyellow); } .ansi-bright-yellow-bg { background-color: var(--ansi-brightyellow); }
|
||||
.ansi-bright-blue-fg { color: var(--ansi-brightblue); } .ansi-bright-blue-bg { background-color: var(--ansi-brightblue); }
|
||||
.ansi-bright-magenta-fg { color: var(--ansi-brightmagenta); } .ansi-bright-magenta-bg { background-color: var(--ansi-brightmagenta); }
|
||||
.ansi-bright-cyan-fg { color: var(--ansi-brightcyan); } .ansi-bright-cyan-bg { background-color: var(--ansi-brightcyan); }
|
||||
.ansi-bright-white-fg { color: var(--ansi-brightwhite); } .ansi-bright-white-bg { background-color: var(--ansi-brightwhite); }
|
||||
|
||||
/* run console */
|
||||
.console-log { padding: 15px; background-color: var(--ansi-black); }
|
||||
.console-log code { white-space: pre-wrap; color: var(--ansi-white); }
|
||||
.console-log a { color: var(--ansi-brightwhite); }
|
||||
|
||||
/* text input (job filtering) */
|
||||
input { padding: 5px 8px; }
|
||||
|
||||
@@ -157,7 +157,5 @@ kj::Promise<void> Server::handleFdRead(kj::AsyncInputStream* stream, char* buffe
|
||||
|
||||
void Server::taskFailed(kj::Exception &&exception) {
|
||||
//kj::throwFatalException(kj::mv(exception));
|
||||
// prettier
|
||||
fprintf(stderr, "fatal: %s\n", exception.getDescription().cStr());
|
||||
exit(EXIT_FAILURE);
|
||||
fprintf(stderr, "taskFailed: %s\n", exception.getDescription().cStr());
|
||||
}
|
||||
|
||||
@@ -133,7 +133,7 @@ TEST_F(LaminarFixture, ParamsToEnv) {
|
||||
}
|
||||
|
||||
TEST_F(LaminarFixture, Abort) {
|
||||
defineJob("job1", "yes");
|
||||
defineJob("job1", "sleep inf");
|
||||
auto req = client().runRequest();
|
||||
req.setJobName("job1");
|
||||
auto res = req.send();
|
||||
|
||||
Reference in New Issue
Block a user