mirror of
https://github.com/ohwgiles/laminar.git
synced 2026-10-18 00:54:25 +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
|
### This file is part of Laminar
|
||||||
###
|
###
|
||||||
@@ -17,12 +17,12 @@
|
|||||||
### along with Laminar. If not, see <http://www.gnu.org/licenses/>
|
### along with Laminar. If not, see <http://www.gnu.org/licenses/>
|
||||||
###
|
###
|
||||||
project(laminar)
|
project(laminar)
|
||||||
cmake_minimum_required(VERSION 2.8)
|
cmake_minimum_required(VERSION 3.6)
|
||||||
cmake_policy(SET CMP0058 NEW)
|
|
||||||
|
|
||||||
set(CMAKE_INCLUDE_CURRENT_DIR ON)
|
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")
|
set(CMAKE_CXX_FLAGS_DEBUG "${CMAKE_CXX_FLAGS_DEBUG} -Werror -DDEBUG")
|
||||||
|
|
||||||
# Allow passing in the version string, for e.g. patched/packaged versions
|
# 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...
|
# Download 3rd-party frontend JS libs...
|
||||||
file(DOWNLOAD https://cdnjs.cloudflare.com/ajax/libs/vue/2.6.12/vue.min.js
|
file(DOWNLOAD https://cdnjs.cloudflare.com/ajax/libs/vue/2.6.12/vue.min.js
|
||||||
js/vue.min.js EXPECTED_MD5 fb192338844efe86ec759a40152fcb8e)
|
js/vue.min.js EXPECTED_MD5 fb192338844efe86ec759a40152fcb8e)
|
||||||
file(DOWNLOAD https://cdnjs.cloudflare.com/ajax/libs/vue-router/3.4.8/vue-router.min.js
|
file(DOWNLOAD https://raw.githubusercontent.com/drudru/ansi_up/v4.0.4/ansi_up.js
|
||||||
js/vue-router.min.js EXPECTED_MD5 5f51d4dbbf68fd6725956a5a2b865f3b)
|
js/ansi_up.js EXPECTED_MD5 b31968e1a8fed0fa82305e978161f7f5)
|
||||||
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://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.7.2/Chart.min.js
|
file(DOWNLOAD https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.7.2/Chart.min.js
|
||||||
js/Chart.min.js EXPECTED_MD5 f6c8efa65711e0cbbc99ba72997ecd0e)
|
js/Chart.min.js EXPECTED_MD5 f6c8efa65711e0cbbc99ba72997ecd0e)
|
||||||
# ...and compile them
|
# ...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)
|
js/ansi_up.js js/Chart.min.js)
|
||||||
# (see resources.cpp where these are fetched)
|
# (see resources.cpp where these are fetched)
|
||||||
|
|
||||||
@@ -138,7 +136,7 @@ if(BUILD_TESTS)
|
|||||||
find_package(GTest REQUIRED)
|
find_package(GTest REQUIRED)
|
||||||
include_directories(${GTEST_INCLUDE_DIRS} src)
|
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)
|
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()
|
endif()
|
||||||
|
|
||||||
set(SYSTEMD_UNITDIR /lib/systemd/system CACHE PATH "Path to systemd unit files")
|
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.
|
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
|
```bash
|
||||||
sudo apt install \
|
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:
|
Then compile and install laminar with:
|
||||||
@@ -25,7 +25,7 @@ Then compile and install laminar with:
|
|||||||
git clone https://github.com/ohwgiles/laminar.git
|
git clone https://github.com/ohwgiles/laminar.git
|
||||||
cd laminar
|
cd laminar
|
||||||
cmake -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/usr
|
cmake -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/usr
|
||||||
make -j4
|
make -j "$(nproc)"
|
||||||
# Warning: the following will overwrite an existing /etc/laminar.conf
|
# Warning: the following will overwrite an existing /etc/laminar.conf
|
||||||
sudo make install
|
sudo make install
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -17,24 +17,26 @@ Throughout this document, the fixed base path `/var/lib/laminar` is used. This i
|
|||||||
|
|
||||||
# Installing Laminar
|
# 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:
|
Under Debian:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/ohwgiles/laminar/releases/download/0.6/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-0.6-1-amd64.deb
|
sudo apt install ./laminar_1.1-1.upstream-debian10_amd64.deb
|
||||||
```
|
```
|
||||||
|
|
||||||
Under CentOS:
|
Under Rocky/CentOS/RHEL:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/ohwgiles/laminar/releases/download/0.5/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 yum install laminar-0.6-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.
|
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`.
|
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
|
## 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).
|
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`.
|
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
|
## More configuration options
|
||||||
|
|
||||||
See the [reference section](#Service-configuration-file)
|
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
|
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?
|
What if your git server is not the same machine as the laminar instance?
|
||||||
|
|
||||||
## Triggering on a remote 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
|
# 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.
|
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.
|
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:
|
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
|
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
|
EOF
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For more advanced usage, see [examples/docker-advanced](https://github.com/ohwgiles/laminar/blob/master/examples/docker-advanced)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Colours in log output
|
# Colours in log output
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=Laminar continuous integration service
|
Description=Laminar continuous integration service
|
||||||
After=network.target
|
After=network.target
|
||||||
|
Documentation=man:laminard(8)
|
||||||
|
Documentation=https://laminar.ohwg.net/docs.html
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
User=laminar
|
User=laminar
|
||||||
|
|||||||
@@ -47,14 +47,14 @@ The laminar server to connect to is read from the
|
|||||||
environment variable. If empty, it falls back to
|
environment variable. If empty, it falls back to
|
||||||
.Ev LAMINAR_BIND_RPC
|
.Ev LAMINAR_BIND_RPC
|
||||||
and finally defaults to
|
and finally defaults to
|
||||||
.Ad
|
.Ad unix-abstract:laminar
|
||||||
unix-abstract:laminar.
|
|
||||||
.Sh ENVIRONMENT
|
.Sh ENVIRONMENT
|
||||||
.Bl -tag
|
.Bl -tag
|
||||||
.It Ev LAMINAR_HOST
|
.It Ev LAMINAR_HOST
|
||||||
address of server to connect. May be of the form
|
address of server to connect. May be of the form
|
||||||
.Ad IP:PORT,
|
.Ad IP:PORT,
|
||||||
.Ad unix:PATH/TO/SOCKET or
|
.Ad unix:PATH/TO/SOCKET
|
||||||
|
or
|
||||||
.Ad unix-abstract:NAME
|
.Ad unix-abstract:NAME
|
||||||
.It Ev LAMINAR_BIND_RPC
|
.It Ev LAMINAR_BIND_RPC
|
||||||
fallback server address variable. It is set by
|
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]})/..)
|
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
|
DOCKER_TAG=$(docker build -q - <<EOS
|
||||||
FROM centos:8
|
FROM rockylinux/rockylinux:8
|
||||||
RUN dnf -y install rpm-build cmake make gcc-c++ wget sqlite-devel boost-devel zlib-devel
|
RUN dnf -y update && dnf -y install rpm-build cmake make gcc-c++ wget sqlite-devel boost-devel zlib-devel
|
||||||
EOS
|
EOS
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ tar xzf capnproto.tar.gz
|
|||||||
tar xzf rapidjson.tar.gz
|
tar xzf rapidjson.tar.gz
|
||||||
|
|
||||||
cd /build/capnproto-0.7.0/c++/
|
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 -j4
|
||||||
make install
|
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 << " 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 << " 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 << " 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 << " abort NAME NUMBER aborts the run identified by NAME and NUMBER.\n";
|
||||||
out << " show-jobs lists all known jobs.\n";
|
out << " show-jobs lists all known jobs.\n";
|
||||||
out << " show-queued lists currently queued 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));
|
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)) {
|
} else if(parseLogEndpoint(url, name, num)) {
|
||||||
auto lw = kj::heap<WithSetRef<LogWatcher>>(logWatchers);
|
|
||||||
lw->job = name;
|
|
||||||
lw->run = num;
|
|
||||||
bool complete;
|
bool complete;
|
||||||
std::string output;
|
std::string output;
|
||||||
if(laminar.handleLogRequest(name, num, output, complete)) {
|
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.
|
// Disables nginx reverse-proxy's buffering. Necessary for dynamic log output.
|
||||||
responseHeaders.add("X-Accel-Buffering", "no");
|
responseHeaders.add("X-Accel-Buffering", "no");
|
||||||
auto stream = response.send(200, "OK", responseHeaders, nullptr);
|
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)
|
if(complete)
|
||||||
return kj::Promise<void>(kj::READY_NOW);
|
return kj::Promise<void>(kj::READY_NOW);
|
||||||
return writeLogChunk(c, s);
|
return kj::mv(p);
|
||||||
}).attach(kj::mv(output)).attach(kj::mv(stream)).attach(kj::mv(lw));
|
});
|
||||||
}
|
}
|
||||||
} else if(resources->handleRequest(url.cStr(), &start, &end, &content_type)) {
|
} else if(resources->handleRequest(url.cStr(), &start, &end, &content_type)) {
|
||||||
responseHeaders.set(kj::HttpHeaderId::CONTENT_TYPE, 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) {
|
for(LogWatcher* lw : logWatchers) {
|
||||||
if(lw->job == job && lw->run == run) {
|
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));
|
lw->fulfiller->fulfill(kj::mv(eot));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -203,18 +203,21 @@ std::list<std::string> Laminar::listKnownJobs() {
|
|||||||
return res;
|
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)};
|
kj::Path runArchive{job,std::to_string(num)};
|
||||||
|
runArchive = runArchive.append(subdir);
|
||||||
KJ_IF_MAYBE(dir, fsHome->tryOpenSubdir("archive"/runArchive)) {
|
KJ_IF_MAYBE(dir, fsHome->tryOpenSubdir("archive"/runArchive)) {
|
||||||
for(kj::StringPtr file : (*dir)->listNames()) {
|
for(kj::StringPtr file : (*dir)->listNames()) {
|
||||||
kj::FsNode::Metadata meta = (*dir)->lstat(kj::Path{file});
|
kj::FsNode::Metadata meta = (*dir)->lstat(kj::Path{file});
|
||||||
if(meta.type != kj::FsNode::Type::FILE)
|
if(meta.type == kj::FsNode::Type::FILE) {
|
||||||
continue;
|
j.StartObject();
|
||||||
j.StartObject();
|
j.set("url", archiveUrl + (runArchive/file).toString().cStr());
|
||||||
j.set("url", archiveUrl + (runArchive/file).toString().cStr());
|
j.set("filename", (subdir/file).toString().cStr());
|
||||||
j.set("filename", file.cStr());
|
j.set("size", meta.size);
|
||||||
j.set("size", meta.size);
|
j.EndObject();
|
||||||
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();
|
j.EndObject();
|
||||||
} else { // Home page
|
} else { // Home page
|
||||||
j.startArray("recent");
|
j.startArray("recent");
|
||||||
db->stmt("SELECT * FROM builds WHERE completedAt IS NOT NULL ORDER BY completedAt DESC LIMIT 20")
|
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 name,uint build,str context,time_t,time_t started,time_t completed,int result){
|
.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.StartObject();
|
||||||
j.set("name", name)
|
j.set("name", name)
|
||||||
.set("number", build)
|
.set("number", build)
|
||||||
.set("context", context)
|
.set("context", context)
|
||||||
|
.set("queued", queued)
|
||||||
.set("started", started)
|
.set("started", started)
|
||||||
.set("completed", completed)
|
.set("completed", completed)
|
||||||
.set("result", to_string(RunState(result)))
|
.set("result", to_string(RunState(result)))
|
||||||
|
.set("reason", reason)
|
||||||
.EndObject();
|
.EndObject();
|
||||||
});
|
});
|
||||||
j.EndArray();
|
j.EndArray();
|
||||||
@@ -465,6 +470,12 @@ std::string Laminar::getStatus(MonitorScope scope) {
|
|||||||
j.EndObject();
|
j.EndObject();
|
||||||
});
|
});
|
||||||
j.EndArray();
|
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();
|
j.EndObject();
|
||||||
return j.str();
|
return j.str();
|
||||||
@@ -769,7 +780,14 @@ void Laminar::handleRunFinished(Run * r) {
|
|||||||
// anyway so hence this (admittedly debatable) optimization.
|
// anyway so hence this (admittedly debatable) optimization.
|
||||||
if(!fsHome->exists(d))
|
if(!fsHome->exists(d))
|
||||||
break;
|
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);
|
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);
|
bool tryStartRun(std::shared_ptr<Run> run, int queueIndex);
|
||||||
void handleRunFinished(Run*);
|
void handleRunFinished(Run*);
|
||||||
// expects that Json has started an array
|
// 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) {
|
Run* activeRun(const std::string name, uint num) {
|
||||||
auto it = activeJobs.byNameNumber().find(boost::make_tuple(name, 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/app.js", js_app_js, CONTENT_TYPE_JS);
|
||||||
INIT_RESOURCE("/js/ansi_up.js", js_ansi_up_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.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/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("/js/Chart.min.js", js_Chart_min_js, CONTENT_TYPE_JS);
|
||||||
INIT_RESOURCE("/style.css", style_css, CONTENT_TYPE_CSS);
|
INIT_RESOURCE("/style.css", style_css, CONTENT_TYPE_CSS);
|
||||||
|
|||||||
@@ -11,26 +11,24 @@
|
|||||||
<link rel="manifest" href="/manifest.webmanifest">
|
<link rel="manifest" href="/manifest.webmanifest">
|
||||||
<title>Laminar</title>
|
<title>Laminar</title>
|
||||||
<script src="js/vue.min.js"></script>
|
<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/ansi_up.js"></script>
|
||||||
<script src="js/Chart.min.js"></script>
|
<script src="js/Chart.min.js"></script>
|
||||||
<script src="js/app.js" defer></script>
|
<script src="js/app.js" defer></script>
|
||||||
|
|
||||||
<link href="style.css" rel="stylesheet">
|
<link href="style.css" rel="stylesheet">
|
||||||
<link href="custom/style.css" rel="stylesheet">
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<template id="home"><div id="page-home-main">
|
<template id="home"><div id="page-home-main">
|
||||||
<nav>
|
<nav>
|
||||||
<table class="table striped">
|
<table class="table striped">
|
||||||
<tr v-for="job in jobsQueued">
|
<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>
|
||||||
<tr v-for="job in jobsRunning">
|
<tr v-for="job in jobsRunning">
|
||||||
<td>
|
<td>
|
||||||
<span v-html="runIcon(job.result)"></span>
|
<span v-html="runIcon(job.result)"></span>
|
||||||
<router-link :to="'/jobs/'+job.name">{{job.name}}</router-link>
|
<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.number">#{{job.number}}</router-link>
|
||||||
<small style="float:right;">{{formatDuration(job.started, job.completed)}}</small>
|
<small style="float:right;">{{formatDuration(job.started, job.completed)}}</small>
|
||||||
<div class="progress" style="margin-top: 5px;">
|
<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>
|
<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">
|
<tr v-for="job in jobsRecent">
|
||||||
<td>
|
<td>
|
||||||
<span v-html="runIcon(job.result)"></span>
|
<span v-html="runIcon(job.result)"></span>
|
||||||
<router-link :to="'/jobs/'+job.name">{{job.name}}</router-link>
|
<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.number">#{{job.number}}</router-link><br>
|
||||||
<small>Took {{formatDuration(job.started, job.completed)}} at {{formatDate(job.started)}}</small>
|
<small>Took {{formatDuration(job.started, job.completed)}} at {{formatDate(job.started)}}</small>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -52,13 +50,13 @@
|
|||||||
<div>
|
<div>
|
||||||
<h3>Recent regressions</h3>
|
<h3>Recent regressions</h3>
|
||||||
<table>
|
<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>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3>Low pass rates</h3>
|
<h3>Low pass rates</h3>
|
||||||
<table>
|
<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>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -97,8 +95,8 @@
|
|||||||
</nav>
|
</nav>
|
||||||
<table class="striped" id="job-list">
|
<table class="striped" id="job-list">
|
||||||
<tr v-for="job in filteredJobs()">
|
<tr v-for="job in filteredJobs()">
|
||||||
<td><router-link :to="'/jobs/'+job.name">{{job.name}}</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 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>{{formatDate(job.started)}}</td>
|
||||||
<td>{{formatDuration(job.started,job.completed)}}</td>
|
<td>{{formatDuration(job.started,job.completed)}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -106,7 +104,7 @@
|
|||||||
</div></template>
|
</div></template>
|
||||||
|
|
||||||
<template id="wallboard"><div class="wallboard">
|
<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: 36px; font-weight: bold;">{{job.name}} #{{job.number}}</span><br>
|
||||||
<span style="font-size: 30px;">{{formatDate(job.started)}}</span><br>
|
<span style="font-size: 30px;">{{formatDate(job.started)}}</span><br>
|
||||||
<span style="font-size: 26px;">{{job.reason}}</span>
|
<span style="font-size: 26px;">{{job.reason}}</span>
|
||||||
@@ -115,13 +113,13 @@
|
|||||||
|
|
||||||
<template id="job"><div id="page-job-main">
|
<template id="job"><div id="page-job-main">
|
||||||
<div style="padding: 15px;">
|
<div style="padding: 15px;">
|
||||||
<h2>{{$route.params.name}}</h2>
|
<h2>{{route.params.name}}</h2>
|
||||||
<div v-html="description"></div>
|
<div v-html="description"></div>
|
||||||
<dl>
|
<dl>
|
||||||
<dt>Last Successful Run</dt>
|
<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>
|
<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>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
<div style="display: grid; justify-content: center; padding: 15px;">
|
<div style="display: grid; justify-content: center; padding: 15px;">
|
||||||
@@ -142,7 +140,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
<tr v-for="job in jobsRunning.concat(jobsRecent)" track-by="$index">
|
<tr v-for="job in jobsRunning.concat(jobsRecent)" track-by="$index">
|
||||||
<td style="width:1px"><span v-html="runIcon(job.result)"></span></td>
|
<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">{{formatDate(job.started)}}</td>
|
||||||
<td class="text-center">{{formatDuration(job.started, job.completed)}}</td>
|
<td class="text-center">{{formatDuration(job.started, job.completed)}}</td>
|
||||||
<td class="text-center vp-sm-hide">{{job.reason}}</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">
|
<template id="run"><div style="display: grid; grid-template-rows: auto 1fr">
|
||||||
<div style="padding: 15px">
|
<div style="padding: 15px">
|
||||||
<div style="display: grid; grid-template-columns: auto 25px auto auto 1fr 400px; gap: 5px; align-items: center">
|
<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>
|
<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 == 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 == latestNum" :to="'jobs/'+route.params.name+'/'+(parseInt(route.params.number)+1)" tag="button">»</router-link>
|
||||||
<span></span>
|
<span></span>
|
||||||
<div class="progress" v-show="job.result == 'running'">
|
<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>
|
<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">
|
<div id="page-run-detail">
|
||||||
<dl>
|
<dl>
|
||||||
<dt>Reason</dt><dd>{{job.reason}}</dd>
|
<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>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="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>
|
<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}}
|
<img src="icon.png"> {{title}}
|
||||||
</router-link>
|
</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;">
|
<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 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 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></div>
|
<div></div>
|
||||||
<span class="version">{{version}}</span>
|
<span class="version">{{version}}</span>
|
||||||
|
|||||||
@@ -18,86 +18,6 @@ Vue.filter('iecFileSize', bytes => {
|
|||||||
['B', 'KiB', 'MiB', 'GiB', 'TiB'][exp];
|
['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
|
// Mixin for periodically updating a progress bar
|
||||||
Vue.mixin({
|
Vue.mixin({
|
||||||
@@ -170,9 +90,9 @@ Vue.mixin({
|
|||||||
let m = d.getMinutes();
|
let m = d.getMinutes();
|
||||||
if (m < 10)
|
if (m < 10)
|
||||||
m = '0' + m;
|
m = '0' + m;
|
||||||
return d.getHours() + ':' + m + ' on ' +
|
return d.getHours() + ':' + m + ' on ' +
|
||||||
['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][d.getDay()] + ' ' + d.getDate() + '. ' +
|
['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();
|
d.getFullYear();
|
||||||
},
|
},
|
||||||
// Pretty-print a duration
|
// Pretty-print a duration
|
||||||
@@ -283,15 +203,19 @@ const Charts = (() => {
|
|||||||
if (c.data.labels[j] == name) {
|
if (c.data.labels[j] == name) {
|
||||||
c.data.datasets[0].data[j]++;
|
c.data.datasets[0].data[j]++;
|
||||||
c.update();
|
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;
|
return c;
|
||||||
},
|
},
|
||||||
createTimePerJobChart: (id, data) => {
|
createTimePerJobChart: (id, data, completedCounts) => {
|
||||||
const scale = timeScale(Math.max(...Object.values(data)));
|
const scale = timeScale(Math.max(...Object.values(data)));
|
||||||
return new Chart(document.getElementById(id), {
|
const c = new Chart(document.getElementById(id), {
|
||||||
type: 'horizontalBar',
|
type: 'horizontalBar',
|
||||||
data: {
|
data: {
|
||||||
labels: Object.keys(data),
|
labels: Object.keys(data),
|
||||||
@@ -312,23 +236,38 @@ const Charts = (() => {
|
|||||||
}
|
}
|
||||||
}]},
|
}]},
|
||||||
tooltips:{callbacks:{
|
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) => {
|
createRunTimeChangesChart: (id, data) => {
|
||||||
const scale = timeScale(Math.max(...data.map(e => Math.max(...e.durations))));
|
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',
|
type: 'line',
|
||||||
data: {
|
data: {
|
||||||
labels: [...Array(10).keys()],
|
labels: [...Array(10).keys()],
|
||||||
datasets: data.map(e => ({
|
datasets: data.map(e => dataValue(e.name, e.durations))
|
||||||
label: e.name,
|
|
||||||
data: e.durations.map(x => x * scale.factor),
|
|
||||||
borderColor: 'hsl('+(e.name.hashCode() % 360)+', 27%, 57%)',
|
|
||||||
backgroundColor: 'transparent'
|
|
||||||
}))
|
|
||||||
},
|
},
|
||||||
options:{
|
options:{
|
||||||
title: { display: true, text: 'Run time changes' },
|
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) => {
|
createRunTimeChart: (id, jobs, avg) => {
|
||||||
const scale = timeScale(Math.max(...jobs.map(v=>v.completed-v.started)));
|
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',
|
type: 'bar',
|
||||||
data: {
|
data: {
|
||||||
labels: jobs.map(e => '#' + e.number).reverse(),
|
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: [],
|
lowPassRates: [],
|
||||||
};
|
};
|
||||||
let chtUtilization, chtBuildsPerDay, chtBuildsPerJob, chtTimePerJob;
|
let chtUtilization, chtBuildsPerDay, chtBuildsPerJob, chtTimePerJob;
|
||||||
|
let completedCounts;
|
||||||
return {
|
return {
|
||||||
template: templateId,
|
template: templateId,
|
||||||
data: () => state,
|
data: () => state,
|
||||||
@@ -431,6 +402,7 @@ const Home = templateId => {
|
|||||||
state.jobsRecent = msg.recent;
|
state.jobsRecent = msg.recent;
|
||||||
state.resultChanged = msg.resultChanged;
|
state.resultChanged = msg.resultChanged;
|
||||||
state.lowPassRates = msg.lowPassRates;
|
state.lowPassRates = msg.lowPassRates;
|
||||||
|
completedCounts = msg.completedCounts;
|
||||||
this.$forceUpdate();
|
this.$forceUpdate();
|
||||||
|
|
||||||
// defer charts to nextTick because they get DOM elements which aren't rendered yet
|
// 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);
|
chtUtilization = Charts.createExecutorUtilizationChart("chartUtil", msg.executorsBusy, msg.executorsTotal);
|
||||||
chtBuildsPerDay = Charts.createRunsPerDayChart("chartBpd", msg.buildsPerDay);
|
chtBuildsPerDay = Charts.createRunsPerDayChart("chartBpd", msg.buildsPerDay);
|
||||||
chtBuildsPerJob = Charts.createRunsPerJobChart("chartBpj", msg.buildsPerJob);
|
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);
|
chtBuildTimeChanges = Charts.createRunTimeChangesChart("chartBuildTimeChanges", msg.buildTimeChanges);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -453,8 +425,10 @@ const Home = templateId => {
|
|||||||
chtUtilization.executorBusyChanged(true);
|
chtUtilization.executorBusyChanged(true);
|
||||||
},
|
},
|
||||||
job_completed: function(data) {
|
job_completed: function(data) {
|
||||||
for (var i = 0; i < state.jobsRunning.length; ++i) {
|
if(!(job.name in completedCounts))
|
||||||
var job = state.jobsRunning[i];
|
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) {
|
if (job.name == data.name && job.number == data.number) {
|
||||||
state.jobsRunning.splice(i, 1);
|
state.jobsRunning.splice(i, 1);
|
||||||
state.jobsRecent.splice(0, 0, data);
|
state.jobsRecent.splice(0, 0, data);
|
||||||
@@ -462,9 +436,28 @@ const Home = templateId => {
|
|||||||
break;
|
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')
|
chtBuildsPerDay.jobCompleted(data.result === 'success')
|
||||||
chtUtilization.executorBusyChanged(false);
|
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;
|
return ret;
|
||||||
},
|
},
|
||||||
wallboardLink: function() {
|
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,
|
pages: 0,
|
||||||
sort: {}
|
sort: {}
|
||||||
};
|
};
|
||||||
let chtBt = null;
|
let chtBuildTime = null;
|
||||||
return {
|
return {
|
||||||
template: templateId,
|
template: templateId,
|
||||||
|
props: ['route'],
|
||||||
data: () => state,
|
data: () => state,
|
||||||
methods: {
|
methods: {
|
||||||
status: function(msg) {
|
status: function(msg) {
|
||||||
@@ -591,12 +585,12 @@ const Job = templateId => {
|
|||||||
|
|
||||||
// "status" comes again if we change page/sorting. Delete the
|
// "status" comes again if we change page/sorting. Delete the
|
||||||
// old chart and recreate it to prevent flickering of old data
|
// old chart and recreate it to prevent flickering of old data
|
||||||
if(chtBt)
|
if(chtBuildTime)
|
||||||
chtBt.destroy();
|
chtBuildTime.destroy();
|
||||||
|
|
||||||
// defer chart to nextTick because they get DOM elements which aren't rendered yet
|
// defer chart to nextTick because they get DOM elements which aren't rendered yet
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
chtBt = Charts.createRunTimeChart("chartBt", msg.recent, msg.averageRuntime);
|
chtBuildTime = Charts.createRunTimeChart("chartBt", msg.recent, msg.averageRuntime);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
job_queued: function() {
|
job_queued: function() {
|
||||||
@@ -613,8 +607,8 @@ const Job = templateId => {
|
|||||||
state.jobsRunning.splice(i, 1);
|
state.jobsRunning.splice(i, 1);
|
||||||
state.jobsRecent.splice(0, 0, data);
|
state.jobsRecent.splice(0, 0, data);
|
||||||
this.$forceUpdate();
|
this.$forceUpdate();
|
||||||
// TODO: update the chart
|
|
||||||
}
|
}
|
||||||
|
chtBuildTime.jobCompleted(data.number, data.result, data.completed - data.started);
|
||||||
},
|
},
|
||||||
page_next: function() {
|
page_next: function() {
|
||||||
state.sort.page++;
|
state.sort.page++;
|
||||||
@@ -632,6 +626,9 @@ const Job = templateId => {
|
|||||||
state.sort.field = field;
|
state.sort.field = field;
|
||||||
}
|
}
|
||||||
this.query(state.sort)
|
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
|
// Component for the /job/:name/:number endpoint
|
||||||
const Run = templateId => {
|
const Run = templateId => {
|
||||||
const utf8decoder = new TextDecoder('utf-8');
|
const utf8decoder = new TextDecoder('utf-8');
|
||||||
|
const ansi_up = new AnsiUp;
|
||||||
|
ansi_up.use_classes = true;
|
||||||
const state = {
|
const state = {
|
||||||
job: { artifacts: [], upstream: {} },
|
job: { artifacts: [], upstream: {} },
|
||||||
latestNum: null,
|
latestNum: null,
|
||||||
@@ -657,9 +656,7 @@ const Run = templateId => {
|
|||||||
if (done)
|
if (done)
|
||||||
return;
|
return;
|
||||||
state.log += ansi_up.ansi_to_html(
|
state.log += ansi_up.ansi_to_html(
|
||||||
value.replace(/</g,'<')
|
value.replace(/\033\[\{([^:]+):(\d+)\033\\/g, (m, $1, $2) =>
|
||||||
.replace(/>/g,'>')
|
|
||||||
.replace(/\033\[\{([^:]+):(\d+)\033\\/g, (m, $1, $2) =>
|
|
||||||
'<a href="jobs/'+$1+'" onclick="return vroute(this);">'+$1+'</a>:'+
|
'<a href="jobs/'+$1+'" onclick="return vroute(this);">'+$1+'</a>:'+
|
||||||
'<a href="jobs/'+$1+'/'+$2+'" onclick="return vroute(this);">#'+$2+'</a>'
|
'<a href="jobs/'+$1+'/'+$2+'" onclick="return vroute(this);">#'+$2+'</a>'
|
||||||
)
|
)
|
||||||
@@ -674,11 +671,13 @@ const Run = templateId => {
|
|||||||
return {
|
return {
|
||||||
template: templateId,
|
template: templateId,
|
||||||
data: () => state,
|
data: () => state,
|
||||||
|
props: ['route'],
|
||||||
methods: {
|
methods: {
|
||||||
status: function(data, params) {
|
status: function(data) {
|
||||||
// Check for the /latest endpoint
|
// Check for the /latest endpoint
|
||||||
|
const params = this._props.route.params;
|
||||||
if(params.number === 'latest')
|
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.number = parseInt(params.number);
|
||||||
state.jobsRunning = [];
|
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({
|
new Vue({
|
||||||
el: '#app',
|
el: '#app',
|
||||||
data: {
|
data: {
|
||||||
@@ -726,7 +832,8 @@ new Vue({
|
|||||||
version: '',
|
version: '',
|
||||||
clockSkew: 0,
|
clockSkew: 0,
|
||||||
connected: false,
|
connected: false,
|
||||||
notify: 'localStorage' in window && localStorage.getItem('showNotifications') == 1
|
notify: 'localStorage' in window && localStorage.getItem('showNotifications') == 1,
|
||||||
|
route: { path: '', params: {} }
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
supportsNotifications: () =>
|
supportsNotifications: () =>
|
||||||
@@ -748,16 +855,5 @@ new Vue({
|
|||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
notify: e => localStorage.setItem('showNotifications', e ? 1 : 0)
|
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;
|
--running: #4786ab;
|
||||||
--warning: #de9a34;
|
--warning: #de9a34;
|
||||||
--link-fg: #2f4579;
|
--link-fg: #2f4579;
|
||||||
--console-bg: #313235;
|
|
||||||
--console-fg: #fff;
|
|
||||||
--alt-row-bg: #fafafa;
|
--alt-row-bg: #fafafa;
|
||||||
--border-grey: #d0d0d0;
|
--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 { color: var(--main-fg); }
|
||||||
a.active:hover { text-decoration: none; }
|
a.active:hover { text-decoration: none; }
|
||||||
|
|
||||||
/* run console output */
|
/* run console ansi colors (based on base16-default-dark and base16-bright) */
|
||||||
.console-log { padding: 15px; background-color: var(--console-bg); }
|
:root {
|
||||||
.console-log code { white-space: pre-wrap; color: var(--console-fg); }
|
--ansi-black: #181818;
|
||||||
.console-log a { color: var(--console-fg); }
|
--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) */
|
/* text input (job filtering) */
|
||||||
input { padding: 5px 8px; }
|
input { padding: 5px 8px; }
|
||||||
|
|||||||
@@ -157,7 +157,5 @@ kj::Promise<void> Server::handleFdRead(kj::AsyncInputStream* stream, char* buffe
|
|||||||
|
|
||||||
void Server::taskFailed(kj::Exception &&exception) {
|
void Server::taskFailed(kj::Exception &&exception) {
|
||||||
//kj::throwFatalException(kj::mv(exception));
|
//kj::throwFatalException(kj::mv(exception));
|
||||||
// prettier
|
fprintf(stderr, "taskFailed: %s\n", exception.getDescription().cStr());
|
||||||
fprintf(stderr, "fatal: %s\n", exception.getDescription().cStr());
|
|
||||||
exit(EXIT_FAILURE);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ TEST_F(LaminarFixture, ParamsToEnv) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
TEST_F(LaminarFixture, Abort) {
|
TEST_F(LaminarFixture, Abort) {
|
||||||
defineJob("job1", "yes");
|
defineJob("job1", "sleep inf");
|
||||||
auto req = client().runRequest();
|
auto req = client().runRequest();
|
||||||
req.setJobName("job1");
|
req.setJobName("job1");
|
||||||
auto res = req.send();
|
auto res = req.send();
|
||||||
|
|||||||
Reference in New Issue
Block a user