Revert "web: rewrite interface in react"

Revert "Fix publish-openstack-javascript-content"

This reverts commit ca199eb9db.
This reverts commit 1082faae95.

This appears to remove the tarball publishing system that we rely on.

Change-Id: Id746fb826dfc01b157c5b772adc1d2991ddcd93a
This commit is contained in:
James E. Blair 2018-09-29 11:50:22 -07:00
parent ca199eb9db
commit 3dba813c64
95 changed files with 5931 additions and 5972 deletions

8
.babelrc Normal file
View File

@ -0,0 +1,8 @@
{
"presets": ['env'],
"plugins": [[
"angularjs-annotate", {
"explicitOnly": false
}
]]
}

7
.eslintrc Normal file
View File

@ -0,0 +1,7 @@
parser: babel-eslint
plugins:
- standard
rules:
camelcase: off
extends:
- ./node_modules/eslint-config-standard/eslintrc.json

View File

@ -42,20 +42,27 @@
- job:
name: zuul-build-dashboard
parent: build-javascript-content
success-url: 'npm/html/'
success-url: 'npm/html/status.html'
files:
- package.json
- tsconfig.json
- tslint.json
- web/.*
- webpack.config.js
- yarn.lock
vars:
javascript_content_dir: "../zuul/web/static"
zuul_work_dir: "{{ zuul.project.src_dir }}/web"
javascript_content_dir: zuul/web/static
zuul_api_url: https://zuul.openstack.org
run: playbooks/dashboard/run.yaml
- job:
name: zuul-build-dashboard-multi-tenant
parent: zuul-build-dashboard
success-url: 'npm/html/tenants.html'
post-run: playbooks/dashboard/multi.yaml
vars:
zuul_api_url: https://softwarefactory-project.io/zuul
javascript_copy_links: false
- project:
check:
@ -74,16 +81,16 @@
- zuul-build-dashboard
- zuul-build-dashboard-multi-tenant
- nodejs-npm-run-lint:
vars:
node_version: 8
zuul_work_dir: "{{ zuul.project.src_dir }}/web"
- nodejs-npm-run-test:
vars:
node_version: 8
zuul_work_dir: "{{ zuul.project.src_dir }}/web"
success-url: 'npm/reports/bundle.html'
files:
- package.json
- tsconfig.json
- tslint.json
- web/.*
- webpack.config.js
- yarn.lock
- zuul-stream-functional
- zuul-tox-remote
- pbrx-build-container-images:
@ -106,16 +113,16 @@
- playbooks/zuul-migrate/.*
- zuul-build-dashboard
- nodejs-npm-run-lint:
vars:
node_version: 8
zuul_work_dir: "{{ zuul.project.src_dir }}/web"
- nodejs-npm-run-test:
vars:
node_version: 8
zuul_work_dir: "{{ zuul.project.src_dir }}/web"
success-url: 'npm/reports/bundle.html'
files:
- package.json
- tsconfig.json
- tslint.json
- web/.*
- webpack.config.js
- yarn.lock
- zuul-stream-functional
- zuul-tox-remote
- pbrx-build-container-images:
@ -128,7 +135,6 @@
- publish-openstack-javascript-content:
vars:
node_version: 8
zuul_work_dir: "{{ zuul.project.src_dir }}/web"
- openstackzuul-pbrx-push-container-images:
vars:
pbrx_prefix: zuul

View File

@ -1,7 +1,6 @@
include AUTHORS
include ChangeLog
include zuul/web/static/*
include zuul/web/static/static/*/*
exclude .gitignore
exclude .gitreview

View File

@ -38,15 +38,11 @@ As of zuul v3, a running zookeeper is required to execute tests.
*Install javascript dependencies*::
pushd web
yarn install
popd
*Build javascript assets*::
pushd web
yarn build
popd
npm run build:dev
Run The Tests
-------------

View File

@ -95,7 +95,7 @@ Static External
Sub-URL
Serve a Zuul dashboard from a location below the root URL as part of
presenting integration with other application.
https://softwarefactory-project.io/zuul/ is an example of a Zuul dashboard
https://softwarefactory-project.io/zuul3/ is an example of a Zuul dashboard
that is being served from a Sub-URL.
None of those make any sense for simple non-production oriented deployments, so
@ -121,71 +121,18 @@ simplest reverse-proxy case is::
Static Offload
--------------
To have the Reverse Proxy serve the static html/javascript assets instead of
To have the Reverse Proxy serve the static html/javscript assets instead of
proxying them to the REST layer, register the location where you unpacked
the web application as the document root and add rewrite rules::
the web application as the document root and add a simple rewrite rule::
<Directory /usr/share/zuul>
DocumentRoot /var/lib/html
<Directory /var/lib/html>
Require all granted
</Directory>
Alias / /usr/share/zuul/
<Location />
RewriteEngine on
RewriteBase /
# Rewrite api to the zuul-web endpoint
RewriteRule api/tenant/(.*)/console-stream ws://localhost:9000/api/tenant/$1/console-stream [P,L]
RewriteRule api/(.*)$ http://localhost:9000/api/$1 [P,L]
# Backward compatible rewrite
RewriteRule t/(.*)/(.*).html(.*) /t/$1/$2$3 [R=301,L,NE]
# Don't rewrite files or directories
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.html [L]
</Location>
Sub directory serving
---------------------
The web application needs to be rebuild to update the internal location of
the static files. Set the homepage setting in the package.json to an
absolute path or url. For example, to deploy the web interface through a
'/zuul/' sub directory:
.. note::
The web dashboard source code and package.json are located in the ``web``
directory. All the yarn commands need to be executed from the ``web``
directory.
.. code-block:: bash
sed -e 's#"homepage": "/"#"homepage": "/zuul/"#' -i package.json
yarn build
Then assuming the web application is unpacked in /usr/share/zuul,
add the following rewrite rules::
<Directory /usr/share/zuul>
Require all granted
</Directory>
Alias /zuul /usr/share/zuul/
<Location /zuul>
RewriteEngine on
RewriteBase /zuul
# Rewrite api to the zuul-web endpoint
RewriteRule api/tenant/(.*)/console-stream ws://localhost:9000/api/tenant/$1/console-stream [P,L]
RewriteRule api/(.*)$ http://localhost:9000/api/$1 [P,L]
# Backward compatible rewrite
RewriteRule t/(.*)/(.*).html(.*) /t/$1/$2$3 [R=301,L,NE]
# Don't rewrite files or directories
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /zuul/index.html [L]
</Location>
RewriteEngine on
RewriteRule ^/t/.*/(.*)$ /$1 [L]
RewriteRule ^/api/tenant/(.*)/console-stream ws://localhost:9000/api/tenant/$1/console-stream [P]
RewriteRule ^/api/(.*)$ http://localhost:9000/api/$1 [P]
White Labeled Tenant
--------------------
@ -200,27 +147,14 @@ rule to ensure connection webhooks don't try to get put into the tenant scope.
Assuming the zuul tenant name is "example", the rewrite rules are::
<Directory /usr/share/zuul>
DocumentRoot /var/lib/html
<Directory /var/lib/html>
Require all granted
</Directory>
Alias / /usr/share/zuul/
<Location />
RewriteEngine on
RewriteBase /
# Rewrite api to the zuul-web endpoint
RewriteRule api/connection/(.*)$ http://localhost:9000/api/connection/$1 [P,L]
RewriteRule api/console-stream ws://localhost:9000/api/tenant/example/console-stream [P,L]
RewriteRule api/(.*)$ http://localhost:9000/api/tenant/example/$1 [P,L]
# Backward compatible rewrite
RewriteRule t/(.*)/(.*).html(.*) /t/$1/$2$3 [R=301,L,NE]
# Don't rewrite files or directories
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.html [L]
</Location>
RewriteEngine on
RewriteRule ^/api/connection/(.*)$ http://localhost:9000/api/connection/$1 [P]
RewriteRule ^/api/console-stream ws://localhost:9000/api/tenant/example/console-stream [P]
RewriteRule ^/api/(.*)$ http://localhost:9000/api/tenant/example/$1 [P]
Static External
---------------
@ -231,8 +165,9 @@ Static External
dynamic url rewrite rules only works for white-labeled deployments.
In order to serve the zuul dashboard code from an external static location,
``REACT_APP_ZUUl_API`` must be set at javascript build time:
``ZUUL_API_URL`` must be set at javascript build time by passing the
``--define`` flag to the ``npm build:dist`` command.
.. code-block:: bash
REACT_APP_ZUUL_API='http://zuul-web.example.com' yarn build
npm build:dist -- --define "ZUUL_API_URL='http://zuul-web.example.com'"

View File

@ -6,15 +6,9 @@ is managed using Javascript toolchains. It is intended to be served by zuul-web
directly from zuul/web/static in the simple case, or to be published to
an alternate static web location, such as an Apache server.
The web dashboard is written in `React`_ and `Patternfly`_ and is
managed by `create-react-app`_ and `yarn`_ which in turn both assume a
functioning and recent `nodejs`_ installation.
.. note::
The web dashboard source code and package.json are located in the ``web``
directory. All the yarn commands need to be executed from the ``web``
directory.
The web dashboard is written in `Typescript`_ and `Angular`_ and is
managed by `yarn`_ and `webpack`_ which in turn both assume a functioning
and recent `nodejs`_ installation.
For the impatient who don't want deal with javascript toolchains
----------------------------------------------------------------
@ -104,27 +98,28 @@ conflicts is to first resolve the conflicts, if any, in ``package.json``. Then:
Which causes yarn to discard the ``yarn.lock`` file, recalculate the
dependencies and write new content.
React Components
----------------
webpack asset management
------------------------
Each page is a React Component. For instance the status.html page code is
``web/src/pages/status.jsx``.
`webpack`_ takes care of bundling web assets for deployment, including tasks
such as minifying and transpiling for older browsers. It takes a
javascript-first approach, and generates a html file that includes the
appropriate javascript and CSS to get going.
Mapping of pages/urls to components can be found in the route list in
``web/src/routes.js``.
The main `webpack`_ config file is ``webpack.config.js``. In the Zuul tree that
file is a stub file that includes either a dev or a prod environment from
``web/config/webpack.dev.js`` or ``web/config/webpack.prod.js``. Most of the
important bits are in ``web/config/webpack.common.js``.
Here are some useful documentation about the different libraries:
Angular Components
------------------
- https://reactjs.org/docs/getting-started.html
- https://reacttraining.com/react-router/web/guides/philosophy
- https://react-bootstrap.github.io/components/forms/
- https://redux.js.org/introduction/coreconcepts
- https://www.patternfly.org/pattern-library/
- https://rawgit.com/patternfly/patternfly-react/gh-pages/
Each page has an `Angular Component`_ associated with it. For instance, the
``status.html`` page has code in ``web/status/status.component.ts`` and the
relevant HTML can be found in ``web/status/status.component.html``.
The gh-pages are built from storybook present in the patternfly-react
repository. Sometime the 'View Info' is not enough and using grep in the
repository may yield better documentation.
Mapping of pages/urls to components can be found in the routing module in
``web/app-routing.module.ts``.
Development
-----------
@ -133,43 +128,68 @@ Building the code can be done with:
.. code-block:: bash
yarn build
npm run build
zuul-web has a ``static`` route defined which serves files from
``zuul/web/static``. ``yarn build`` will put the build output files
``zuul/web/static``. ``npm run build`` will put the build output files
into the ``zuul/web/static`` directory so that zuul-web can serve them.
Development server that handles things like reloading and
hot-updating of code can be started with:
There is a also a development-oriented version of that same command:
.. code-block:: bash
yarn start
npm run build:dev
will build the code and launch the dev server on `localhost:3000`. Fake
api response needs to be set in the ``web/public/api`` directory::
which will build for the ``dev`` environment. This causes some sample data
to be bundled and included.
Webpack includes a development server that handles things like reloading and
hot-updating of code. The following:
.. code-block:: bash
mkdir public/api/
for route in info status jobs builds; do
curl -o public/api/${route} https://zuul.openstack.org/api/${route}
done
npm run start
To use an existing zuul api, uses the REACT_APP_ZUUl_API environment
variable:
will build the code and launch the dev server on `localhost:8080`. It will
be configured to use the API endpoint from OpenStack's Zuul. The
``webpack-dev-server`` watches for changes to the files and
re-compiles/refresh as needed.
.. code-block:: bash
# Use openstack zuul's api:
yarn start:openstack
npm run start:multi
# Use software-factory multi-tenant zuul's api:
yarn start:multi
will do the same but will be pointed at the SoftwareFactory Project Zuul, which
is multi-tenant.
# Use a custom zuul:
REACT_APP_ZUUL_API="https://zuul.example.com/api/" yarn start
Arbitrary command line options will be passed through after a ``--`` such as:
.. code-block:: bash
npm run start -- --open-file='status.html'
That's kind of annoying though, so additional targets exist for common tasks:
Run status against `basic` built-in demo data.
.. code-block:: bash
npm run start:basic
Run status against `openstack` built-in demo data
.. code-block:: bash
npm run start:openstack
Run status against `tree` built-in demo data.
.. code-block:: bash
npm run start:tree
Additional run commands can be added in `package.json` in the ``scripts``
section.
Deploying
---------
@ -179,16 +199,31 @@ by zuul-web from its ``static`` route. In order to make sure this works
properly, the javascript build needs to be performed so that the javascript
files are in the ``zuul/web/static`` directory. Because the javascript
build outputs into the ``zuul/web/static`` directory, as long as
``yarn build`` has been done before ``pip install .`` or
``npm run build`` has been done before ``pip install .`` or
``python setup.py sdist``, all the files will be where they need to be.
As long as `yarn`_ is installed, the installation of zuul will run
``yarn build`` appropriately.
``npm run build`` appropriately.
Debugging minified code
-----------------------
Both the ``dev`` and ``prod`` ennvironments use the same `devtool`_
called ``source-map`` which makes debugging errors easier by including mapping
information from the minified and bundled resources to their approriate
non-minified source code locations. Javascript errors in the browser as seen
in the developer console can be clicked on and the appropriate actual source
code location will be shown.
``source-map`` is considered an appropriate `devtool`_ for production, but has
the downside that it is slower to update. However, since it includes the most
complete mapping information and doesn't impact execution performance, so in
our case we use it for both.
.. _yarn: https://yarnpkg.com/en/
.. _nodejs: https://nodejs.org/
.. _webpack: https://webpack.js.org/
.. _devtool: https://webpack.js.org/configuration/devtool/#devtool
.. _nodeenv: https://pypi.org/project/nodeenv
.. _React: https://reactjs.org/
.. _Patternfly: https://www.patternfly.org/
.. _create-react-app: https://github.com/facebook/create-react-app/blob/master/packages/react-scripts/template/README.md
.. _Angular: https://angular.io
.. _Angular Component: https://angular.io/guide/architecture-components
.. _Typescript: https://www.typescriptlang.org/

86
package.json Normal file
View File

@ -0,0 +1,86 @@
{
"name": "@zuul-ci/dashboard",
"version": "1.0.0",
"description": "Web Dashboard for Zuul",
"main": "web/main.ts",
"repository": "https://git.zuul-ci.org/zuul",
"author": "OpenStack Infra",
"license": "Apache-2.0",
"babel": {
"presets": [
"env"
]
},
"dependencies": {
"@angular/common": "^6.0.3",
"@angular/compiler": "^6.0.3",
"@angular/core": "^6.0.3",
"@angular/forms": "^6.0.3",
"@angular/platform-browser": "^6.0.3",
"@angular/platform-browser-dynamic": "^6.0.3",
"@angular/router": "^6.0.3",
"bootstrap": "3.1.1",
"core-js": "^2.5.3",
"graphitejs": "https://github.com/prestontimmons/graphitejs/archive/master.tar.gz",
"jquery": "^3.3.1",
"jquery-visibility": "https://github.com/mathiasbynens/jquery-visibility/archive/master.tar.gz",
"reflect-metadata": "^0.1.12",
"rxjs": "^6.2.0",
"rxjs-compat": "^6.0.0-rc.0",
"zone.js": "^0.8.26"
},
"scripts": {
"build": "npm run build:dist",
"build:dev": "webpack --env=dev",
"build:dist": "webpack --env=prod",
"build:dist-with-depends": "yarn install && npm run build:dist",
"format": "tslint --project tsconfig.json -c tslint.json --fix",
"lint": "webpack --env=lint",
"start": "webpack-dev-server --env=dev --define ZUUL_API_URL=\"'https://zuul.openstack.org'\" --open-page='status.html'",
"start:multi": "webpack-dev-server --env=dev --define ZUUL_API_URL=\"'https://softwarefactory-project.io/zuul'\" --open-page='tenants.html'",
"start:basic": "webpack-dev-server --env=dev --open-page='status.html?demo=basic'",
"start:openstack": "webpack-dev-server --env=dev --open-page='status.html?demo=openstack'",
"start:tree": "webpack-dev-server --env=dev --open-page='status.html?demo=tree'"
},
"devDependencies": {
"@types/angular": "^1.6.43",
"@types/bootstrap": "^4.0.0",
"@types/core-js": "^0.9.46",
"@types/jquery": "^3.3.1",
"@types/node": "^9.4.7",
"babel-core": "^6.26.0",
"babel-eslint": "^8.0.3",
"babel-loader": "^7.1.2",
"babel-plugin-angularjs-annotate": "^0.8.2",
"babel-preset-env": "^1.6.1",
"clean-webpack-plugin": "^0.1.16",
"codelyzer": "^4.2.1",
"css-loader": "^0.28.10",
"eslint": ">=3.19.0",
"eslint-config-standard": "^11.0.0-beta.0",
"eslint-loader": "^2.0.0",
"eslint-plugin-import": "^2.8.0",
"eslint-plugin-node": "^6.0.0",
"eslint-plugin-promise": "^3.6.0",
"eslint-plugin-standard": "^3.0.1",
"file-loader": "^1.1.11",
"fork-ts-checker-webpack-plugin": "^0.4.1",
"html-loader": "^0.5.5",
"html-webpack-plugin": "^3.0.0",
"resolve-url-loader": "^2.1.0",
"style-loader": "^0.20.3",
"ts-loader": "^4.1.0",
"ts-node": "^5.0.1",
"tslint": "^5.9.1",
"tslint-angular": "^1.1.1",
"tslint-loader": "^3.6.0",
"typescript": "2.7.2",
"url-loader": "^0.5.9",
"webpack": "^4.4.0",
"webpack-archive-plugin": "^3.0.0",
"webpack-bundle-analyzer": "^2.9.1",
"webpack-cli": "^2.0.11",
"webpack-dev-server": "^3.0.0",
"webpack-merge": "^4.1.0"
}
}

View File

@ -0,0 +1,21 @@
- hosts: all
tasks:
- name: Make tenant subdir
file:
state: directory
dest: '{{ zuul.project.src_dir }}/{{ javascript_content_dir }}/t'
- name: Copy the html/javascript content into subdirs
shell: |
CONTENT_DIR="{{ zuul.project.src_dir }}/{{ javascript_content_dir }}"
mkdir $CONTENT_DIR/t/{{ item }}
for f in $(find $CONTENT_DIR -type f -mindepth 1 -maxdepth 1) ; do
cp $f $CONTENT_DIR/t/{{ item }}
done
with_items:
- local
- ansible
- ansible-dev
- openstack.org
- rdoproject.org

View File

@ -1,19 +1,18 @@
- hosts: all
pre_tasks:
- name: Update homepage for sub directory deployment
replace:
path: '{{ zuul.project.src_dir }}/web/package.json'
regexp: '"homepage": "/"'
replace: '"homepage": "./"'
# NOTE: using "./" is not enough to support html5 links, even with
# rewrite rules for unknown files, accessing 'job/devstack' will make
# the dashboard load static files from 'job/static/...'
# This works for the preview dashboard that can only be loaded from the
# npm/html directory anyway.
roles:
- revoke-sudo
- set-zuul-log-path-fact
# Both sets of quotes are required.
# The "" quotes are for the shell to protect the '' quotes.
# We need the '' quotes because defines here are essentially
# direct string substitutions. Therefore:
# --define "ZUUL_API_URL='https://zuul.openstack.org'"
# with the javascript code:
# return ZUUL_API_URL
# results in
# return 'https://zuul.openstack.org'
# in the compiled javascript.
- role: npm
npm_command: build
environment:
REACT_APP_ZUUL_API: "{{ zuul_api_url }}/api/"
npm_command: >-
build:dist --
--define "ZUUL_API_URL='{{ zuul_api_url }}'"

View File

@ -1,11 +0,0 @@
---
features:
- |
The Zuul web dashboard has been rewritten in React.
upgrade:
- |
The Zuul web dashboard is now a single index.html and static offload
server needs new rewrite rules. Check the install instruction for backward
compatible rewrite rules. Note that serving the web dashboard from a
sub-directory requires the application to be rebuilt using the desired
homepage location.

View File

@ -245,9 +245,9 @@ class TestWeb(BaseTestWeb):
self.assertEqual(1, data[0]['queue'])
def test_web_bad_url(self):
# do we redirect to index.html
# do we 404 correctly
resp = self.get_url("status/foo")
self.assertEqual(200, resp.status_code)
self.assertEqual(404, resp.status_code)
def test_web_find_change(self):
# can we filter by change id

View File

@ -51,8 +51,10 @@ class TestWebURLs(ZuulTestCase):
]:
for item in page.find_all(tag):
suburl = item.get(attr)
if suburl.startswith('/'):
suburl = suburl[1:]
# Skip empty urls. Also skip the navbar relative link for now.
# TODO(mordred) Remove when we have the top navbar link sorted.
if suburl is None or suburl == "../":
continue
link = urllib.parse.urljoin(url, suburl)
self._get(self.port, link)
@ -64,8 +66,7 @@ class TestDirect(TestWebURLs):
self.port = self.web.port
def test_status_page(self):
self._crawl('/')
self._crawl('/t/tenant-one/status')
self._crawl('/t/tenant-one/status.html')
class TestWhiteLabel(TestWebURLs):
@ -80,8 +81,7 @@ class TestWhiteLabel(TestWebURLs):
self.port = self.proxy.port
def test_status_page(self):
self._crawl('/')
self._crawl('/status')
self._crawl('/status.html')
class TestWhiteLabelAPI(TestWebURLs):
@ -108,11 +108,11 @@ class TestSuburl(TestWebURLs):
def setUp(self):
super(TestSuburl, self).setUp()
rules = [
('^/zuul/(.*)$', 'http://localhost:{}/\\1'.format(
('^/zuul3/(.*)$', 'http://localhost:{}/\\1'.format(
self.web.port)),
]
self.proxy = self.useFixture(WebProxyFixture(rules))
self.port = self.proxy.port
def test_status_page(self):
self._crawl('/zuul/')
self._crawl('/zuul3/t/tenant-one/status.html')

6
tools/pip.sh Executable file → Normal file
View File

@ -32,9 +32,7 @@ then
fi
if [[ ! -f zuul/web/static/status.html ]]
then
pushd web/
yarn install
yarn build
popd
yarn install
npm run build:dev
fi
pip install $*

17
tsconfig.json Normal file
View File

@ -0,0 +1,17 @@
{
"compilerOptions": {
"sourceMap": true,
"moduleResolution": "node",
"outDir": "./zuul/web/static/",
"noImplicitAny": false,
"target": "es6",
"lib": [ "es2015", "dom" ],
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowJs": false
},
"include": [
"webpack.config.ts",
"web/**/*.ts"
]
}

7
tslint.json Normal file
View File

@ -0,0 +1,7 @@
{
"extends": ["tslint-angular"],
"rules": {
"array-type": [true, "array-simple"],
"semicolon": [true, "never"]
}
}

View File

@ -1,23 +0,0 @@
parser: babel-eslint
plugins:
- standard
- jest
rules:
no-console: off
semi: [error, never]
quotes: [error, single]
lines-between-class-members: error
react/prop-types: error
react/jsx-key: error
react/no-did-mount-set-state: error
react/no-did-update-set-state: error
react/no-deprecated: error
extends:
- eslint:recommended
- plugin:react/recommended
settings:
react:
version: 16.4
env:
jest/globals: true
browser: true

1
web/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
public_html/lib

1
web/.jshintignore Normal file
View File

@ -0,0 +1 @@
public_html/lib

21
web/.jshintrc Normal file
View File

@ -0,0 +1,21 @@
{
"bitwise": true,
"eqeqeq": true,
"forin": true,
"latedef": true,
"newcap": true,
"noarg": true,
"noempty": true,
"nonew": true,
"undef": true,
"unused": true,
"strict": false,
"laxbreak": true,
"browser": true,
"predef": [
"jQuery",
"zuul"
]
}

84
web/app-routing.module.ts Normal file
View File

@ -0,0 +1,84 @@
// Routing information for Zuul dashboard pages
//
// Copyright 2018 Red Hat, Inc
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License. You may obtain
// a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations
// under the License.
import { NgModule, isDevMode } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
import BuildsComponent from './builds/builds.component'
import JobsComponent from './jobs/jobs.component'
import StatusComponent from './status/status.component'
import StreamComponent from './stream/stream.component'
import TenantsComponent from './tenants/tenants.component'
// Have all routes go to builds.html for now
const appRoutes: Routes = [
{
path: 't/:tenant/builds.html',
component: BuildsComponent
},
{
path: 'builds.html',
component: BuildsComponent
},
{
path: 't/:tenant/status.html',
component: StatusComponent
},
{
path: 'status.html',
component: StatusComponent
},
{
path: 't/:tenant/jobs.html',
component: JobsComponent
},
{
path: 'jobs.html',
component: JobsComponent
},
{
path: 'stream.html',
component: StreamComponent
},
{
path: 't/:tenant/stream.html',
component: StreamComponent
},
{
path: 'tenants.html',
component: TenantsComponent
},
{
path: 't/tenants.html',
component: TenantsComponent
},
{
path: '**',
component: StatusComponent
}
]
@NgModule({
imports: [
RouterModule.forRoot(
appRoutes,
// Enable router tracing in devel mode. This prints router decisions
// to the console.log.
{ enableTracing: isDevMode() }
)],
exports: [RouterModule]
})
export class AppRoutingModule { }

28
web/app.component.ts Normal file
View File

@ -0,0 +1,28 @@
// Main dashboard component
//
// Copyright 2018 Red Hat, Inc
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License. You may obtain
// a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations
// under the License.
import { Component } from '@angular/core'
@Component({
selector: 'zuul-dashboard',
template: `
<navigation></navigation>
<div class="container-fluid">
<router-outlet></router-outlet>
</div>
`
})
export class AppComponent {}

73
web/app.module.ts Normal file
View File

@ -0,0 +1,73 @@
// Entrypoint for Zuul dashboard pages
//
// Copyright 2018 Red Hat, Inc
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License. You may obtain
// a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations
// under the License.
import './styles/zuul.css'
import { APP_BASE_HREF } from '@angular/common'
import { NgModule } from '@angular/core'
import { BrowserModule } from '@angular/platform-browser'
import { HttpClientModule } from '@angular/common/http'
import { FormsModule } from '@angular/forms'
import { CoreModule } from './core/core.module'
import { AppRoutingModule } from './app-routing.module'
import { AppComponent } from './app.component'
import { getAppBaseHref } from './zuul/zuul.service'
import BuildsComponent from './builds/builds.component'
import NavigationComponent from './navigation/navigation.component'
import JobsComponent from './jobs/jobs.component'
import StatusComponent from './status/status.component'
import StreamComponent from './stream/stream.component'
import TenantsComponent from './tenants/tenants.component'
import ZuulService from './zuul/zuul.service'
@NgModule({
imports: [
BrowserModule,
HttpClientModule,
FormsModule,
CoreModule.forRoot({}),
AppRoutingModule,
],
declarations: [
AppComponent,
BuildsComponent,
NavigationComponent,
JobsComponent,
StatusComponent,
StreamComponent,
TenantsComponent
],
entryComponents: [
BuildsComponent,
NavigationComponent,
JobsComponent,
StatusComponent,
StreamComponent,
TenantsComponent
],
providers: [
ZuulService,
{provide: APP_BASE_HREF, useValue: getAppBaseHref()}
],
bootstrap: [
AppComponent
]
})
export class AppModule { }

View File

@ -1 +0,0 @@
../zuul/web/static/

28
web/builds/build.ts Normal file
View File

@ -0,0 +1,28 @@
// Copyright 2017 Red Hat
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License. You may obtain
// a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations
// under the License.
export default class Build {
constructor(
public job_name: string,
public result: string,
public project: string,
public pipeline: string,
public ref_url: string,
public duration: number,
public start_time: string,
public log_url?: string,
) {}
}

View File

@ -0,0 +1,67 @@
<!--
Copyright 2017 Red Hat
Licensed under the Apache License, Version 2.0 (the "License"); you may
not use this file except in compliance with the License. You may obtain
a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
License for the specific language governing permissions and limitations
under the License.
-->
<div class="container-fluid">
<span style="float: right; margin-top: 7px;">
<form class="form-inline" #buildsForm="ngForm">
<div class="form-group">
<label for="pipeline">Pipeline:</label>
<input class="form-control" id="pipeline"
[(ngModel)]="pipeline" name="pipeline" />
</div>
<div class="form-group">
<label for="job_name">Job:</label>
<input class="form-control" id="job_name"
[(ngModel)]="job_name" name="job_name" />
</div>
<div class="form-group">
<label for="project">Project:</label>
<input class="form-control" id="project"
[(ngModel)]="project" name="project">
</div>
<button type="submit" class="btn" (click)='buildsFetch()'>
Refresh
</button>
</form>
</span>
</div>
<table class="table table-hover table-condensed">
<thead>
<tr>
<th>Job</th>
<th>Project</th>
<th>Branch</th>
<th>Pipeline</th>
<th>Change</th>
<th>Duration</th>
<th>Log url</th>
<th>Start time</th>
<th>Result</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let build of builds" [class]="getRowClass(build)">
<td>{{ build.job_name }}</td>
<td>{{ build.project }}</td>
<td>{{ build.branch }}</td>
<td>{{ build.pipeline }}</td>
<td><a href="{{ build.ref_url }}" target="_self">change</a></td>
<td>{{ build.duration }} seconds</td>
<td><a *ngIf="build.log_url" href="{{ build.log_url }}" target="_self">logs</a></td>
<td>{{ build.start_time }}</td>
<td>{{ build.result }}</td>
</tr>
</tbody>
</table>

View File

@ -0,0 +1,78 @@
// Copyright 2017 Red Hat
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License. You may obtain
// a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations
// under the License.
import { Component, OnInit } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { HttpClient, HttpParams } from '@angular/common/http'
import { Observable } from 'rxjs/Observable'
import 'rxjs/add/operator/map'
import ZuulService from '../zuul/zuul.service'
import Build from './build'
@Component({
template: require('./builds.component.html')
})
export default class BuildsComponent implements OnInit {
builds: Build[]
pipeline: string
job_name: string
project: string
constructor(
private http: HttpClient, private route: ActivatedRoute,
private zuul: ZuulService
) {}
async ngOnInit() {
await this.zuul.setTenant(this.route.snapshot.paramMap.get('tenant'))
this.pipeline = this.route.snapshot.queryParamMap.get('pipeline')
this.job_name = this.route.snapshot.queryParamMap.get('job_name')
this.project = this.route.snapshot.queryParamMap.get('project')
this.buildsFetch()
}
buildsFetch(): void {
let params = new HttpParams()
if (this.pipeline) { params = params.set('pipeline', this.pipeline) }
if (this.job_name) { params = params.set('job_name', this.job_name) }
if (this.project) { params = params.set('project', this.project) }
const remoteLocation = this.zuul.getSourceUrl('builds')
if (remoteLocation) {
this.http.get<Build[]>(remoteLocation, {params: params})
.subscribe(builds => {
for (const build of builds) {
/* Fix incorect url for post_failure job */
/* TODO(mordred) Maybe let's fix this server side? */
if (build.log_url === build.job_name) {
build.log_url = undefined
}
}
this.builds = builds
})
}
}
getRowClass(build: Build): string {
if (build.result === 'SUCCESS') {
return 'success'
} else {
return 'warning'
}
}
}

24
web/config/main.ejs Normal file
View File

@ -0,0 +1,24 @@
<!DOCTYPE html>
<!--
Copyright 2017 Red Hat
Licensed under the Apache License, Version 2.0 (the "License"); you may
not use this file except in compliance with the License. You may obtain
a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
License for the specific language governing permissions and limitations
under the License.
-->
<html>
<head>
<meta http-equiv="Content-type" content="text/html; charset=utf-8"/>
<title id='pagetitle'><%= htmlWebpackPlugin.options.title %></title>
</head>
<body>
<zuul-dashboard></zuul-dashboard>
</body></html>

View File

@ -0,0 +1,178 @@
{
"last_reconfigured": 1389381756000,
"message": "Example info message",
"pipelines": [
{
"name": "test",
"description": "Lint and unit tests",
"change_queues": [
{
"name": "some-jobs@worker301.ci-example.org",
"heads": [
[
{
"id": "10101,1",
"url": "#!/review.example.org/r/10101",
"project": "openstack/infra/zuul",
"jobs": [
{
"name": "zuul-merge",
"url": "#!/jenkins.example.org/job/zuul-merge/201",
"result": "SUCCESS",
"voting": true
},
{
"name": "zuul-lint",
"url": "#!/jenkins.example.org/job/zuul-lint/201",
"result": "SUCCESS",
"voting": true
},
{
"name": "zuul-test",
"url": "#!/jenkins.example.org/job/zuul-test/201",
"result": "SUCCESS",
"voting": true
}
]
}
],
[
{
"id": "10103,1",
"url": "#!/review.example.org/r/10103",
"project": "google/gerrit",
"jobs": [
{
"name": "gerrit-merge",
"url": "#!/jenkins.example.org/job/gerrit-merge/203",
"result": "SUCCESS",
"voting": false
}
]
}
]
]
},
{
"name": "other-jobs@worker301.ci-example.org",
"heads": [
[
{
"id": "10102,1",
"url": "#!/review.example.org/r/10102",
"project": "google/gerrit",
"jobs": [
{
"name": "gerrit-merge",
"url": "#!/jenkins.example.org/job/gerrit-merge/202",
"result": "UNSTABLE",
"voting": false
}
]
}
],
[
{
"id": "10104,1",
"url": "#!/review.example.org/r/10104",
"project": "openstack/infra/zuul",
"jobs": [
{
"name": "zuul-merge",
"url": "#!/jenkins.example.org/job/zuul-merge/204",
"result": "SUCCESS",
"voting": true
},
{
"name": "zuul-lint",
"url": "#!/jenkins.example.org/job/zuul-lint/204",
"result": "FAILURE",
"voting": true
},
{
"name": "zuul-test",
"url": "#!/jenkins.example.org/job/zuul-test/204",
"result": null,
"voting": true
}
]
}
]
]
}
]
},
{
"name": "gate-and-submit",
"change_queues": []
},
{
"name": "postmerge",
"change_queues": [
{
"name": "some-jobs@worker301.ci-example.org",
"heads": [
[
{
"id": "7f1d65cb0f663c907698f915da01c008c7ef4748",
"url": "#!/review.example.org/r/10100",
"project": "openstack/infra/zuul",
"jobs": [
{
"name": "zuul-lint",
"url": "#!/jenkins.example.org/job/zuul-lint/200",
"result": "SUCCESS",
"voting": true
},
{
"name": "zuul-test",
"url": "#!/jenkins.example.org/job/zuul-test/200",
"result": "FAILURE",
"voting": true
},
{
"name": "zuul-regression-python2",
"url": "#!/jenkins.example.org/job/zuul-regression-python2/200",
"result": "SUCCESS",
"voting": true
},
{
"name": "zuul-regression-python3",
"url": "#!/jenkins.example.org/job/zuul-regression-python3/200",
"result": "FAILURE",
"voting": true
},
{
"name": "zuul-performance-python2",
"url": null,
"result": null,
"voting": true
},
{
"name": "zuul-performance-python3",
"url": null,
"result": null,
"voting": true
},
{
"name": "zuul-docs-publish",
"url": null,
"result": null,
"voting": true
}
]
}
]
]
}
]
}
],
"trigger_event_queue": {
"length": 0
},
"result_event_queue": {
"length": 0
},
"zuul_version": "2.0.0.19"
}

View File

@ -0,0 +1,312 @@
{
"last_reconfigured": 1389381756000,
"pipelines": [
{
"name": "check",
"description": "Newly uploaded patchsets enter this pipeline to receive an initial +/-1 Verified vote from Jenkins.",
"change_queues": [
{
"heads": [],
"name": "stackforge/tripleo-image-elements"
}
]
},
{
"description": "Changes that have been approved by core developers are enqueued in order in this pipeline, and .",
"change_queues": [
{
"heads": [],
"name": "openstack-detackforge/reddwarf-integration"
},
{
"heads": [],
"name": "stackforge/moniker"
},
{
"heads": [
[
{
"url": "https://review.openstack.org/26292",
"project": "openstack/quantum",
"jobs": [
{
"url": "https://jenkins.openstack.org/job/gate-quantum-docs/5501/consoleFull",
"voting": true,
"result": "SUCCESS",
"name": "gate-quantum-docs"
},
{
"url": "https://jenkins.openstack.org/job/gate-quantum-pep8/6254/consoleFull",
"voting": true,
"result": "SUCCESS",
"name": "gate-quantum-pep8"
},
{
"url": "https://jenkins.openstack.org/job/gate-quantum-python26/5876/",
"voting": true,
"result": null,
"name": "gate-quantum-python26"
},
{
"url": "https://jenkins.openstack.org/job/gate-quantum-python27/5887/",
"voting": true,
"result": null,
"name": "gate-quantum-python27"
},
{
"url": "https://jenkins.openstack.org/job/gate-tempest-devstack-vm-quantum/17548/",
"voting": true,
"result": null,
"name": "gate-tempest-devstack-vm-quantum"
}
],
"id": "26292,1"
}
]
],
"name": "openstack-dev/devstack, openstack-infra/devstack-gate, openstack/cinder, openstack/glance, openstack/horizon, openstack/keystone, openstack/nova, openstack/python-cinderclient, openstack/python-glanceclient, openstack/python-keystoneclient, openstack/python-novaclient, openstack/python-quantumclient, openstack/quantum, openstack/swift, openstack/tempest, z/tempest"
},
{
"heads": [],
"name": "openstack/ceilometer"
},
{
"heads": [],
"name": "stackforge/puppet-openstack"
},
{
"heads": [],
"name": "stackforge/puppet-cinder"
},
{
"heads": [],
"name": "stackforge/marconi"
},
{
"heads": [],
"name": "openstack-infra/config"
},
{
"heads": [],
"name": "stackforge/tripleo-image-elements"
},
{
"heads": [],
"name": "stackforge/kwapi"
},
{
"heads": [],
"name": "stackforge/python-reddwarfclient"
},
{
"heads": [],
"name": "stackforge/python-savannaclient"
},
{
"heads": [],
"name": "stackforge/python-monikerclient"
},
{
"heads": [],
"name": "stackforge/packstack"
},
{
"heads": [],
"name": "openstack/oslo.config"
},
{
"heads": [],
"name": "openstack-infra/jenkins-job-builder"
},
{
"heads": [],
"name": "stackforge/puppet-horizon"
},
{
"heads": [],
"name": "openstack/heat-cfntools"
},
{
"heads": [],
"name": "openstack/oslo-incubator"
},
{
"heads": [],
"name": "stackforge/os-config-applier"
},
{
"heads": [],
"name": "openstack/requirements"
},
{
"heads": [],
"name": "stackforge/puppet-glance"
},
{
"heads": [],
"name": "openstack-infra/gearman-plugin"
},
{
"heads": [],
"name": "stackforge/puppet-keystone"
},
{
"heads": [],
"name": "stackforge/puppet-nova"
},
{
"heads": [],
"name": "stackforge/climate"
},
{
"heads": [],
"name": "openstack/python-swiftclient"
},
{
"heads": [],
"name": "openstack/python-ceilometerclient"
},
{
"heads": [],
"name": "openstack-infra/git-review"
},
{
"heads": [],
"name": "stackforge/bufunfa"
},
{
"heads": [],
"name": "stackforge/puppet-swift"
},
{
"heads": [],
"name": "openstack-infra/statusbot"
},
{
"heads": [],
"name": "openstack/openstack-planet"
},
{
"heads": [],
"name": "openstack/python-openstackclient"
},
{
"heads": [],
"name": "stackforge/diskimage-builder"
},
{
"heads": [],
"name": "openstack-infra/gerritlib"
},
{
"heads": [],
"name": "openstack-infra/zuul"
},
{
"heads": [],
"name": "stackforge/reddwarf"
},
{
"heads": [],
"name": "openstack-dev/hacking"
},
{
"heads": [],
"name": "openstack/python-heatclient"
},
{
"heads": [],
"name": "stackforge/python-libraclient"
},
{
"heads": [],
"name": "openstack-infra/reviewday"
},
{
"heads": [],
"name": "openstack-infra/jeepyb"
},
{
"heads": [],
"name": "openstack/heat"
},
{
"heads": [],
"name": "stackforge/libra"
},
{
"heads": [],
"name": "openstack-infra/gerrit"
},
{
"heads": [],
"name": "stackforge/healthnmon"
},
{
"heads": [],
"name": "openstack-infra/gerritbot"
},
{
"heads": [],
"name": "openstack-dev/pbr"
},
{
"heads": [],
"name": "stackforge/savanna"
},
{
"heads": [],
"name": "openstack/openstack-manuals"
}
],
"name": "gate"
},
{
"description": "This pipeline runs jobs that operate after each change is merged.",
"change_queues": [
{
"heads": [],
"name": "openstack-dev/hacking, openstack-dev/openstack-qa, openstack-dev/pbr, openstack-infra/config, openstack-infra/gearman-plugin, openstack-infra/gerrit, openstack-infra/gerritbot, openstack-infra/git-review, openstack-infra/jenkins-job-builder, openstack-infra/nose-html-output, openstack-infra/reviewday, openstack-infra/statusbot, openstack-infra/zuul, openstack/api-site, openstack/ceilometer, openstack/cinder, openstack/compute-api, openstack/glance, openstack/heat, openstack/heat-cfntools, openstack/horizon, openstack/identity-api, openstack/image-api, openstack/keystone, openstack/netconn-api, openstack/nova, openstack/object-api, openstack/openstack-manuals, openstack/oslo-incubator, openstack/oslo.config, openstack/python-ceilometerclient, openstack/python-cinderclient, openstack/python-glanceclient, openstack/python-heatclient, openstack/python-keystoneclient, openstack/python-novaclient, openstack/python-openstackclient, openstack/python-quantumclient, openstack/python-swiftclient, openstack/quantum, openstack/requirements, openstack/swift, openstack/volume-api, stackforge/bufunfa, stackforge/diskimage-builder, stackforge/moniker, stackforge/os-config-applier, stackforge/python-monikerclient, stackforge/python-savannaclient, stackforge/reddwarf, stackforge/savanna, stackforge/tripleo-image-elements"
}
],
"name": "post"
},
{
"description": "This pipeline runs jobs on projects in response to pre-release tags.",
"change_queues": [
{
"heads": [],
"name": "openstack-dev/hacking, openstack-dev/pbr, openstack-infra/gerritbot, openstack-infra/gerritlib, openstack-infra/git-review, openstack-infra/jeepyb, openstack-infra/jenkins-job-builder, openstack-infra/nose-html-output, openstack-infra/reviewday, openstack-infra/statusbot, openstack-infra/zuul, openstack/ceilometer, openstack/cinder, openstack/glance, openstack/heat, openstack/heat-cfntools, openstack/horizon, openstack/keystone, openstack/nova, openstack/oslo.config, openstack/python-ceilometerclient, openstack/python-cinderclient, openstack/python-glanceclient, openstack/python-heatclient, openstack/python-keystoneclient, openstack/python-novaclient, openstack/python-openstackclient, openstack/python-quantumclient, openstack/python-swiftclient, openstack/quantum, openstack/swift, stackforge/moniker, stackforge/python-monikerclient, stackforge/python-reddwarfclient, stackforge/python-savannaclient, stackforge/savanna"
}
],
"name": "pre-release"
},
{
"description": "When a commit is tagged as a release, this pipeline runs jobs that publish archives and documentation.",
"change_queues": [
{
"heads": [],
"name": "openstack-dev/hacking, openstack-dev/openstack-qa, openstack-dev/pbr, openstack-infra/gerritbot, openstack-infra/gerritlib, openstack-infra/git-review, openstack-infra/jeepyb, openstack-infra/jenkins-job-builder, openstack-infra/nose-html-output, openstack-infra/reviewday, openstack-infra/statusbot, openstack-infra/zuul, openstack/ceilometer, openstack/cinder, openstack/glance, openstack/heat, openstack/heat-cfntools, openstack/horizon, openstack/keystone, openstack/nova, openstack/oslo-incubator, openstack/oslo.config, openstack/python-ceilometerclient, openstack/python-cinderclient, openstack/python-glanceclient, openstack/python-heatclient, openstack/python-keystoneclient, openstack/python-novaclient, openstack/python-openstackclient, openstack/python-quantumclient, openstack/python-swiftclient, openstack/quantum, openstack/swift, stackforge/moniker, stackforge/python-monikerclient, stackforge/python-reddwarfclient, stackforge/python-savannaclient, stackforge/savanna"
}
],
"name": "release"
},
{
"description": "This pipeline is used for silently testing new jobs.",
"change_queues": [
{
"heads": [],
"name": ""
}
],
"name": "silent"
}
],
"trigger_event_queue": {
"length": 0
},
"result_event_queue": {
"length": 0
},
"zuul_version": "2.0.0.19"
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,147 @@
const path = require('path')
const webpack = require('webpack')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin')
module.exports = {
entry: './web/main.ts',
resolve: {
extensions: [ '.tsx', '.ts', '.js' ]
},
output: {
filename: '[name].js',
// path.resolve(__dirname winds up relative to the config dir
path: path.resolve(__dirname, '../../zuul/web/static'),
publicPath: ''
},
// Some folks prefer "cheaper" source-map for dev and one that is more
// expensive to build for prod. Debugging without the full source-map sucks,
// so define it here in common.
devtool: 'source-map',
optimization: {
runtimeChunk: true,
splitChunks: {
cacheGroups: {
commons: {
test: /node_modules/,
name: 'vendor',
chunks: 'all'
}
}
}
},
plugins: [
new ForkTsCheckerWebpackPlugin(),
new webpack.ProvidePlugin({
$: 'jquery/dist/jquery',
jQuery: 'jquery/dist/jquery',
}),
// Each of the entries below lists a specific 'chunk' which is one of the
// entry items from above. We can collapse this to just do one single
// output file.
new HtmlWebpackPlugin({
filename: 'index.html',
template: 'web/config/main.ejs',
title: 'Zuul Status'
}),
new HtmlWebpackPlugin({
filename: 'status.html',
template: 'web/config/main.ejs',
title: 'Zuul Status'
}),
new HtmlWebpackPlugin({
title: 'Zuul Builds',
template: 'web/config/main.ejs',
filename: 'builds.html'
}),
new HtmlWebpackPlugin({
title: 'Zuul Jobs',
template: 'web/config/main.ejs',
filename: 'jobs.html'
}),
new HtmlWebpackPlugin({
title: 'Zuul Tenants',
template: 'web/config/main.ejs',
filename: 'tenants.html'
}),
new HtmlWebpackPlugin({
title: 'Zuul Console Stream',
template: 'web/config/main.ejs',
filename: 'stream.html'
})
],
module: {
rules: [
{
test: /\.ts$/,
exclude: /node_modules/,
use: {
loader: 'ts-loader',
options: {
// disable type checker - we will use it in fork plugin
transpileOnly: true
}
}
},
{
test: /\.js$/,
exclude: /node_modules/,
use: [
'babel-loader'
]
},
{
test: /.css$/,
use: [
'style-loader',
'css-loader'
]
},
{
test: /\.(png|svg|jpg|gif)$/,
use: ['file-loader'],
},
// The majority of the rules below are all about getting bootstrap copied
// appropriately.
{
test: /\.woff(2)?(\?v=\d+\.\d+\.\d+)?$/,
use: {
loader: 'url-loader',
options: {
limit: 10000,
mimetype: 'application/font-woff'
}
}
},
{
test: /\.ttf(\?v=\d+\.\d+\.\d+)?$/,
use: {
loader: 'url-loader',
options: {
limit: 10000,
mimetype: 'application/octet-stream'
}
}
},
{
test: /\.eot(\?v=\d+\.\d+\.\d+)?$/,
use: ['file-loader'],
},
{
test: /\.svg(\?v=\d+\.\d+\.\d+)?$/,
use: {
loader: 'url-loader',
options: {
limit: 10000,
mimetype: 'image/svg+xml'
}
}
},
{
test: /\.html$/,
use: ['html-loader'],
exclude: /node_modules/
}
]
}
};

23
web/config/webpack.dev.js Normal file
View File

@ -0,0 +1,23 @@
const path = require('path');
const webpack = require('webpack');
const Merge = require('webpack-merge');
const CommonConfig = require('./webpack.common.js');
module.exports = Merge(CommonConfig, {
mode: 'development',
// Enable Hot Module Replacement for devServer
devServer: {
hot: true,
contentBase: path.resolve(__dirname, './zuul/web/static'),
publicPath: '/'
},
plugins: [
new webpack.HotModuleReplacementPlugin(),
// We only need to bundle the demo files when we're running locally
new webpack.ProvidePlugin({
DemoStatusBasic: '../config/status-basic.json',
DemoStatusOpenStack: '../config/status-openstack.json',
DemoStatusTree: '../config/status-tree.json'
}),
]
})

View File

@ -0,0 +1,47 @@
const path = require('path');
const webpack = require('webpack');
const Merge = require('webpack-merge');
const CommonConfig = require('./webpack.common.js');
const BundleAnalyzer = require('webpack-bundle-analyzer');
module.exports = Merge(CommonConfig, {
mode: 'development',
module: {
rules: [
{
enforce: 'pre',
test: /\.ts$/,
exclude: /node_modules/,
use: [
{
loader: 'tslint-loader',
options: {
emitErrors: true,
typeCheck: false,
}
}
]
},
{
enforce: 'pre',
test: /\.js$/,
use: [
'babel-loader',
'eslint-loader'
],
exclude: /node_modules/,
}
]
},
plugins: [
new webpack.HotModuleReplacementPlugin(),
new BundleAnalyzer.BundleAnalyzerPlugin({
analyzerMode: 'static',
reportFilename: '../../../reports/bundle.html',
generateStatsFile: true,
openAnalyzer: false,
statsFilename: '../../../reports/stats.json',
}),
]
})

View File

@ -0,0 +1,32 @@
const path = require('path');
const webpack = require('webpack');
const Merge = require('webpack-merge');
const CommonConfig = require('./webpack.common.js');
const CleanWebpackPlugin = require('clean-webpack-plugin');
const ArchivePlugin = require('webpack-archive-plugin');
module.exports = Merge(CommonConfig, {
mode: 'production',
output: {
filename: '[name].[chunkhash].js',
// path.resolve(__dirname winds up relative to the config dir
path: path.resolve(__dirname, '../../zuul/web/static'),
publicPath: ''
},
optimization: {
minimize: true
},
plugins: [
new CleanWebpackPlugin(
['zuul/web/static'], { root: path.resolve(__dirname, '../..')}),
// Keeps the vendor bundle from changing needlessly.
new webpack.HashedModuleIdsPlugin(),
new ArchivePlugin({
output: path.resolve(__dirname, '../../zuul-web'),
format: [
'tar',
],
ext: 'tgz'
})
]
})

24
web/core/core.module.ts Normal file
View File

@ -0,0 +1,24 @@
import { ModuleWithProviders, NgModule, Optional, SkipSelf } from '@angular/core'
import { CommonModule } from '@angular/common'
import { ZuulService } from '../zuul/zuul.service'
@NgModule({
imports: [ CommonModule ],
providers: [ ZuulService ]
})
export class CoreModule {
constructor (@Optional() @SkipSelf() parentModule: CoreModule) {
if (parentModule) {
throw new Error(
'CoreModule is already loaded. Import it in the AppModule only')
}
}
static forRoot(config: {}): ModuleWithProviders {
return {
ngModule: CoreModule,
}
}
}

BIN
web/images/black.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 267 B

BIN
web/images/green.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 B

BIN
web/images/grey.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 B

View File

Before

Width:  |  Height:  |  Size: 262 B

After

Width:  |  Height:  |  Size: 262 B

View File

Before

Width:  |  Height:  |  Size: 204 B

After

Width:  |  Height:  |  Size: 204 B

View File

Before

Width:  |  Height:  |  Size: 183 B

After

Width:  |  Height:  |  Size: 183 B

BIN
web/images/red.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 B

17
web/jobs/details.ts Normal file
View File

@ -0,0 +1,17 @@
// Copyright 2018 Red Hat
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License. You may obtain
// a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations
// under the License.
export default class JobDetails {
source_context: string
}

25
web/jobs/job.ts Normal file
View File

@ -0,0 +1,25 @@
// Copyright 2018 Red Hat
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License. You may obtain
// a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations
// under the License.
import JobDetails from './details'
export default class Job {
expanded: boolean
details: JobDetails
name: string
constructor() {
this.expanded = false
}
}

View File

@ -0,0 +1,47 @@
<!--
Copyright 2017 Red Hat
Licensed under the Apache License, Version 2.0 (the "License"); you may
not use this file except in compliance with the License. You may obtain
a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
License for the specific language governing permissions and limitations
under the License.
-->
<div class="container-fluid">
<table class="table table-hover table-condensed">
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th>Last builds</th>
</tr>
</thead>
<tbody>
<ng-container *ngFor="let job of jobs">
<tr>
<td>{{ job.name }}</td>
<td>{{ job.description }}</td>
<td><a [routerLink]="['../builds.html']"
[queryParams]="{job_name: job.name}" target="_self">
builds</a></td>
</tr>
<tr *ngIf="job.expanded">
<td colspan="3">
<ul class="list-group">
<li class="list-group-item" *ngFor="let detail of job.details">
<!-- TODO: make clickable link to cgit files ? -->
{{ detail.source_context }}
</li>
</ul>
</td>
</tr>
</ng-container>
</tbody>
</table>
</div>

View File

@ -0,0 +1,54 @@
// Copyright 2017 Red Hat
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License. You may obtain
// a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations
// under the License.
import { Component, OnInit } from '@angular/core'
import { HttpClient } from '@angular/common/http'
import { ActivatedRoute } from '@angular/router'
import ZuulService from '../zuul/zuul.service'
import JobDetails from './details'
import Job from './job'
@Component({
template: require('./jobs.component.html')
})
export default class JobsComponent implements OnInit {
jobs: Job[]
constructor(
private http: HttpClient, private route: ActivatedRoute,
private zuul: ZuulService
) {}
async ngOnInit() {
await this.zuul.setTenant(this.route.snapshot.paramMap.get('tenant'))
this.jobsFetch()
}
jobsFetch(): void {
const remoteLocation = this.zuul.getSourceUrl('jobs')
if (remoteLocation) {
this.http.get<Job[]>(remoteLocation)
.subscribe(jobs => this.injestJobs(jobs))
}
}
injestJobs(jobs: Job[]): void {
for (const job of jobs) {
job.expanded = false
}
this.jobs = jobs
}
}

22
web/main.ts Normal file
View File

@ -0,0 +1,22 @@
// Copyright 2018 Red Hat, Inc
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License. You may obtain
// a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations
// under the License.
import 'zone.js'
import 'reflect-metadata'
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'
import { AppModule } from './app.module'
platformBrowserDynamic().bootstrapModule(AppModule)

View File

@ -0,0 +1,30 @@
<!--
Copyright 2017 Red Hat
Licensed under the Apache License, Version 2.0 (the "License"); you may
not use this file except in compliance with the License. You may obtain
a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
License for the specific language governing permissions and limitations
under the License.
-->
<nav class="navbar navbar-default">
<div class="container-fluid">
<div class="navbar-header">
<a *ngIf="zuul.info && !zuul.info.whiteLabel" class="navbar-brand" [routerLink]="dashboardLink" target="_self">Zuul Dashboard</a>
<span *ngIf="zuul.info && zuul.info.whiteLabel" class="navbar-brand">Zuul Dashboard</span>
</div>
<ul class="nav navbar-nav" *ngIf="zuul.info && zuul.info.tenant !== ''">
<li routerLinkActive="active" *ngFor="let route of zuul.navbarRoutes">
<a [routerLink]="route.url">
{{ route.title }}
</a>
</li>
</ul>
</div>
</nav>

View File

@ -0,0 +1,34 @@
// Copyright 2018 Red Hat, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License. You may obtain
// a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations
// under the License.
import { OnInit, Component } from '@angular/core'
import { Router, ResolveEnd } from '@angular/router'
import { Observable } from 'rxjs/Observable'
import { filter } from 'rxjs/operators'
import ZuulService from '../zuul/zuul.service'
@Component({
selector: 'navigation',
template: require('./navigation.component.html')
})
export default class NavigationComponent implements OnInit {
dashboardLink: string
constructor(private router: Router, private zuul: ZuulService) {}
async ngOnInit() {
this.dashboardLink = '/t/tenants.html'
}
}

View File

@ -1,41 +0,0 @@
{
"name": "@zuul-ci/dashboard",
"version": "1.0.0",
"description": "Web Dashboard for Zuul",
"repository": "https://git.zuul-ci.org/zuul",
"author": "OpenStack Infra",
"license": "Apache-2.0",
"homepage": "/",
"private": true,
"dependencies": {
"axios": "^0.18.0",
"lodash": "^4.17.10",
"patternfly-react": "^2.13.1",
"prop-types": "^15.6.2",
"react": "^16.4.2",
"react-dom": "^16.4.2",
"react-redux": "^5.0.7",
"react-router": "^4.3.1",
"react-router-dom": "^4.3.1",
"react-scripts": "1.1.4",
"redux": "<4.0.0",
"redux-thunk": "^2.3.0",
"sockette": "^2.0.0"
},
"devDependencies": {
"eslint": "^5.3.0",
"eslint-plugin-jest": "^21.21.0",
"eslint-plugin-react": "^7.11.1",
"eslint-plugin-standard": "^3.1.0",
"yarn": "^1.9.4"
},
"scripts": {
"start:openstack": "REACT_APP_ZUUL_API='https://zuul.openstack.org/api/' react-scripts start",
"start:multi": "REACT_APP_ZUUL_API='https://softwarefactory-project.io/zuul/api/' react-scripts start",
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test --env=jsdom",
"eject": "react-scripts eject",
"lint": "eslint --ext .js --ext .jsx src"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View File

@ -1,17 +0,0 @@
<!DOCTYPE html>
<html lang="en" class="layout-pf layout-pf-fixed">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="theme-color" content="#000000">
<link rel="manifest" href="%PUBLIC_URL%/manifest.json">
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
<title>Zuul</title>
</head>
<body>
<noscript>
You need to enable JavaScript to run this app.
</noscript>
<div id="root"></div>
</body>
</html>

View File

@ -1,15 +0,0 @@
{
"short_name": "Zuul",
"name": "Zuul Dashboard",
"icons": [
{
"src": "favicon.ico",
"sizes": "48x48 32x32",
"type": "image/x-icon"
}
],
"start_url": "./index.html",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

View File

@ -1,162 +0,0 @@
// Copyright 2018 Red Hat, Inc
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License. You may obtain
// a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations
// under the License.
// The App is the parent component of every pages. Each page content is
// rendered by the Route object according to the current location.
import React from 'react'
import PropTypes from 'prop-types'
import { matchPath, withRouter } from 'react-router'
import { Link, Redirect, Route, Switch } from 'react-router-dom'
import { connect } from 'react-redux'
import { Masthead } from 'patternfly-react'
import logo from './images/logo.png'
import { routes } from './routes'
import { setTenantAction } from './reducers'
class App extends React.Component {
static propTypes = {
info: PropTypes.object,
tenant: PropTypes.object,
location: PropTypes.object,
dispatch: PropTypes.func
}
constructor() {
super()
this.menu = routes()
}
renderMenu() {
const { location } = this.props
const activeItem = this.menu.find(
item => location.pathname === item.to
)
return (
<ul className='nav navbar-nav navbar-primary'>
{this.menu.filter(item => item.title).map(item => (
<li key={item.to} className={item === activeItem ? 'active' : ''}>
<Link to={this.props.tenant.linkPrefix + item.to}>
{item.title}
</Link>
</li>
))}
</ul>
)
}
renderContent = () => {
const { tenant } = this.props
const allRoutes = []
this.menu
// Do not include '/tenants' route in white-label setup
.filter(item =>
(tenant.whiteLabel && !item.globalRoute) || !tenant.whiteLabel)
.forEach((item, index) => {
allRoutes.push(
<Route
key={index}
path={item.globalRoute ? item.to : tenant.routePrefix + item.to}
component={item.component}
exact
/>
)
})
return (
<Switch>
{allRoutes}
<Redirect from='*' to={tenant.defaultRoute} key='default-route' />
</Switch>
)
}
componentDidUpdate() {
// This method is called when info property is updated
const { tenant, info } = this.props
if (info.capabilities) {
let tenantName, whiteLabel
if (info.tenant) {
// White label
whiteLabel = true
tenantName = info.tenant
} else if (!info.tenant) {
// Multi tenant, look for tenant name in url
whiteLabel = false
const match = matchPath(
this.props.location.pathname, {path: '/t/:tenant'})
if (match) {
tenantName = match.params.tenant
} else {
tenantName = ''
}
}
// Set tenant only if it changed to prevent DidUpdate loop
if (typeof tenant.name === 'undefined' || tenant.name !== tenantName) {
this.props.dispatch(setTenantAction(tenantName, whiteLabel))
}
}
}
render() {
const { tenant } = this.props
if (typeof tenant.name === 'undefined') {
return (<h2>Loading...</h2>)
}
return (
<React.Fragment>
<Masthead
iconImg={logo}
navToggle
thin
>
<div className='collapse navbar-collapse'>
{tenant.name && this.renderMenu()}
<ul className='nav navbar-nav navbar-utility'>
<li>
<a href='https://zuul-ci.org/docs'
rel='noopener noreferrer' target='_blank'>
Documentation
</a>
</li>
{tenant.name && (
<li>
<Link to={tenant.defaultRoute}>
<strong>Tenant</strong> {tenant.name}
</Link>
</li>
)}
</ul>
</div>
</Masthead>
<div className='container-fluid container-cards-pf'>
{this.renderContent()}
</div>
</React.Fragment>
)
}
}
// This connect the info state from the store to the info property of the App.
export default withRouter(connect(
state => ({
info: state.info,
tenant: state.tenant
})
)(App))

View File

@ -1,102 +0,0 @@
/* global Promise, expect, jest, it, location */
// Copyright 2018 Red Hat, Inc
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License. You may obtain
// a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations
// under the License.
import React from 'react'
import ReactTestUtils from 'react-dom/test-utils'
import ReactDOM from 'react-dom'
import { Link, BrowserRouter as Router } from 'react-router-dom'
import { Provider } from 'react-redux'
import { createZuulStore, fetchInfoAction } from './reducers'
import App from './App'
import TenantsPage from './pages/Tenants'
import StatusPage from './pages/Status'
import * as api from './api'
api.fetchInfo = jest.fn()
api.fetchTenants = jest.fn()
api.fetchStatus = jest.fn()
it('renders without crashing', () => {
const div = document.createElement('div')
const store = createZuulStore()
ReactDOM.render(<Provider store={store}><Router><App /></Router></Provider>,
div)
ReactDOM.unmountComponentAtNode(div)
})
it('renders multi tenant', () => {
api.fetchInfo.mockImplementation(
() => Promise.resolve({data: {
info: {capabilities: {}}
}})
)
api.fetchTenants.mockImplementation(
() => Promise.resolve({data: [{name: 'openstack'}]})
)
const store = createZuulStore()
const application = ReactTestUtils.renderIntoDocument(
<Provider store={store}><Router><App /></Router></Provider>
)
store.dispatch(fetchInfoAction()).then(() => {
// Link should be tenant scoped
const topMenuLinks = ReactTestUtils.scryRenderedComponentsWithType(
application, Link)
expect(topMenuLinks[0].props.to).toEqual('/t/openstack/status')
expect(topMenuLinks[1].props.to).toEqual('/t/openstack/jobs')
// Location should be /tenants
expect(location.pathname).toEqual('/tenants')
// Info should tell multi tenants
expect(store.getState().info.tenant).toEqual(undefined)
// Tenants list has been rendered
expect(ReactTestUtils.findRenderedComponentWithType(
application, TenantsPage)).not.toEqual(null)
// Fetch tenants has been called
expect(api.fetchTenants).toBeCalled()
})
})
it('renders single tenant', () => {
api.fetchInfo.mockImplementation(
() => Promise.resolve({data: {
info: {capabilities: {}, tenant: 'openstack'}
}})
)
api.fetchStatus.mockImplementation(
() => Promise.resolve({data: {pipelines: []}})
)
const store = createZuulStore()
const application = ReactTestUtils.renderIntoDocument(
<Provider store={store}><Router><App /></Router></Provider>
)
store.dispatch(fetchInfoAction()).then(() => {
// Link should be white-label scoped
const topMenuLinks = ReactTestUtils.scryRenderedComponentsWithType(
application, Link)
expect(topMenuLinks[0].props.to).toEqual('/status')
expect(topMenuLinks[1].props.to).toEqual('/jobs')
// Location should be /status
expect(location.pathname).toEqual('/status')
// Info should tell white label tenant openstack
expect(store.getState().info.tenant).toEqual('openstack')
// Status page has been rendered
expect(ReactTestUtils.findRenderedComponentWithType(
application, StatusPage)).not.toEqual(null)
// Fetch status has been called
expect(api.fetchStatus).toBeCalled()
})
})

View File

@ -1,133 +0,0 @@
/* global process, window */
// Copyright 2018 Red Hat, Inc
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License. You may obtain
// a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations
// under the License.
import Axios from 'axios'
function getHomepageUrl (url) {
//
// Discover serving location from href.
//
// This is only needed for sub-directory serving.
// Serving the application from '/' may simply default to '/'
//
// Note that this is not enough for sub-directory serving,
// The static files location also needs to be adapted with the 'homepage'
// settings of the package.json file.
//
// This homepage url is used for the Router and Link resolution logic
//
let baseUrl
if (url) {
baseUrl = url
} else {
baseUrl = window.location.href
}
// Get dirname of the current url
baseUrl = baseUrl.replace(/\\/g, '/').replace(/\/[^/]*$/, '/')
// Remove any query strings
if (baseUrl.includes('?')) {
baseUrl = baseUrl.slice(0, baseUrl.lastIndexOf('?'))
}
// Remove any hash anchor
if (baseUrl.includes('/#')) {
baseUrl = baseUrl.slice(0, baseUrl.lastIndexOf('/#') + 1)
}
// Remove known sub-path
const subDir = [
'/build/',
'/job/',
'/project/',
'/stream/',
]
subDir.forEach(path => {
if (baseUrl.includes(path)) {
baseUrl = baseUrl.slice(0, baseUrl.lastIndexOf(path) + 1)
}
})
// Remove tenant scope
if (baseUrl.includes('/t/')) {
baseUrl = baseUrl.slice(0, baseUrl.lastIndexOf('/t/') + 1)
}
if (! baseUrl.endsWith('/')) {
baseUrl = baseUrl + '/'
}
// console.log('Homepage url is ', baseUrl)
return baseUrl
}
function getZuulUrl () {
// Return the zuul root api absolute url
const ZUUL_API = process.env.REACT_APP_ZUUL_API
let apiUrl
if (ZUUL_API) {
// Api url set at build time, use it
apiUrl = ZUUL_API
} else {
// Api url is relative to homepage path
apiUrl = getHomepageUrl () + 'api/'
}
if (! apiUrl.endsWith('/')) {
apiUrl = apiUrl + '/'
}
if (! apiUrl.endsWith('/api/')) {
apiUrl = apiUrl + 'api/'
}
// console.log('Api url is ', apiUrl)
return apiUrl
}
const apiUrl = getZuulUrl()
function getStreamUrl (apiPrefix) {
const streamUrl = (apiUrl + apiPrefix)
.replace(/(http)(s)?:\/\//, 'ws$2://') + 'console-stream'
// console.log('Stream url is ', streamUrl)
return streamUrl
}
// Direct APIs
function fetchInfo () {
return Axios.get(apiUrl + 'info')
}
function fetchTenants () {
return Axios.get(apiUrl + 'tenants')
}
function fetchStatus (apiPrefix) {
return Axios.get(apiUrl + apiPrefix + 'status')
}
function fetchBuilds (apiPrefix, queryString) {
let path = 'builds'
if (queryString) {
path += '?' + queryString.slice(1)
}
return Axios.get(apiUrl + apiPrefix + path)
}
function fetchJobs (apiPrefix) {
return Axios.get(apiUrl + apiPrefix + 'jobs')
}
export {
getHomepageUrl,
getStreamUrl,
fetchStatus,
fetchBuilds,
fetchJobs,
fetchTenants,
fetchInfo
}

View File

@ -1,237 +0,0 @@
/* global URLSearchParams */
// Copyright 2018 Red Hat, Inc
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License. You may obtain
// a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations
// under the License.
// Boiler plate code to manage table filtering
import * as React from 'react'
import PropTypes from 'prop-types'
import { Button, Filter, FormControl, Toolbar } from 'patternfly-react'
class TableFilters extends React.Component {
static propTypes = {
location: PropTypes.object
}
getFilterFromUrl = () => {
const urlParams = new URLSearchParams(this.props.location.search)
let activeFilters = []
this.filterTypes.forEach(item => {
urlParams.getAll(item.id).forEach(param => {
activeFilters.push({
label: item.title + ': ' + param,
key: item.id,
value: param})
})
})
this.setState({activeFilters: activeFilters})
return activeFilters
}
updateUrl (activeFilters) {
let path = this.props.location.pathname
if (activeFilters.length > 0) {
path += '?'
activeFilters.forEach((item, idx) => {
if (idx > 0) {
path += '&'
}
path += (
encodeURIComponent(item.key)
+ '=' +
encodeURIComponent(item.value)
)
})
}
window.history.pushState({path: path}, '', path)
}
filterAdded = (field, value) => {
let filterText = ''
if (field.title) {
filterText = field.title
} else {
filterText = field
}
filterText += ': '
if (value.filterCategory) {
filterText +=
(value.filterCategory.title || value.filterCategory) +
'-' +
(value.filterValue.title || value.filterValue)
} else if (value.title) {
filterText += value.title
} else {
filterText += value
}
let activeFilters = [...this.state.activeFilters, {
label: filterText,
key: field.id,
value: value
}]
this.setState({ activeFilters: activeFilters })
this.updateData(activeFilters)
this.updateUrl(activeFilters)
}
selectFilterType = filterType => {
const { currentFilterType } = this.state
if (currentFilterType !== filterType) {
this.setState(prevState => {
return {
currentValue: '',
currentFilterType: filterType,
filterCategory:
filterType.filterType === 'complex-select'
? undefined
: prevState.filterCategory,
categoryValue:
filterType.filterType === 'complex-select'
? ''
: prevState.categoryValue
}
})
}
}
filterValueSelected = filterValue => {
const { currentFilterType, currentValue } = this.state
if (filterValue !== currentValue) {
this.setState({ currentValue: filterValue })
if (filterValue) {
this.filterAdded(currentFilterType, filterValue)
}
}
}
filterCategorySelected = category => {
const { filterCategory } = this.state
if (filterCategory !== category) {
this.setState({ filterCategory: category, currentValue: '' })
}
}
categoryValueSelected = value => {
const { currentValue, currentFilterType, filterCategory } = this.state
if (filterCategory && currentValue !== value) {
this.setState({ currentValue: value })
if (value) {
let filterValue = {
filterCategory: filterCategory,
filterValue: value
}
this.filterAdded(currentFilterType, filterValue)
}
}
}
updateCurrentValue = event => {
this.setState({ currentValue: event.target.value })
}
onValueKeyPress = keyEvent => {
const { currentValue, currentFilterType } = this.state
if (keyEvent.key === 'Enter' && currentValue && currentValue.length > 0) {
this.setState({ currentValue: '' })
this.filterAdded(currentFilterType, currentValue)
keyEvent.stopPropagation()
keyEvent.preventDefault()
}
}
removeFilter = filter => {
const { activeFilters } = this.state
let index = activeFilters.indexOf(filter)
if (index > -1) {
let updated = [
...activeFilters.slice(0, index),
...activeFilters.slice(index + 1)
]
this.setState({ activeFilters: updated })
this.updateData(updated)
this.updateUrl(updated)
}
}
clearFilters = () => {
this.setState({ activeFilters: [] })
this.updateData()
this.updateUrl([])
}
renderFilterInput() {
const { currentFilterType, currentValue } = this.state
if (!currentFilterType) {
return null
}
return (
<FormControl
type={currentFilterType.filterType}
value={currentValue}
placeholder={currentFilterType.placeholder}
onChange={e => this.updateCurrentValue(e)}
onKeyPress={e => this.onValueKeyPress(e)}
/>
)
}
renderFilter = () => {
const { currentFilterType, activeFilters } = this.state
return (
<React.Fragment>
<div style={{ width: 300 }}>
<Filter>
<Filter.TypeSelector
filterTypes={this.filterTypes}
currentFilterType={currentFilterType}
onFilterTypeSelected={this.selectFilterType}
/>
{this.renderFilterInput()}
</Filter>
</div>
{activeFilters && activeFilters.length > 0 && (
<Toolbar.Results>
<Filter.ActiveLabel>{'Active Filters:'}</Filter.ActiveLabel>
<Filter.List>
{activeFilters.map((item, index) => {
return (
<Filter.Item
key={index}
onRemove={this.removeFilter}
filterData={item}
>
{item.label}
</Filter.Item>
)
})}
</Filter.List>
<Button onClick={e => {
e.preventDefault()
this.clearFilters()
}}>Clear All Filters</Button>
</Toolbar.Results>
)}
</React.Fragment>
)
}
}
export default TableFilters

View File

@ -1,99 +0,0 @@
// Copyright 2018 Red Hat, Inc
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License. You may obtain
// a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations
// under the License.
import * as React from 'react'
import PropTypes from 'prop-types'
import LineAngleImage from '../../images/line-angle.png'
import LineTImage from '../../images/line-t.png'
import ChangePanel from './ChangePanel'
class Change extends React.Component {
static propTypes = {
change: PropTypes.object.isRequired,
queue: PropTypes.object.isRequired,
expanded: PropTypes.bool.isRequired
}
renderStatusIcon (change) {
let iconGlyph = 'pficon pficon-ok'
let iconTitle = 'Succeeding'
if (change.active !== true) {
iconGlyph = 'pficon pficon-pending'
iconTitle = 'Waiting until closer to head of queue to' +
' start jobs'
} else if (change.live !== true) {
iconGlyph = 'pficon pficon-info'
iconTitle = 'Dependent change required for testing'
} else if (change.failing_reasons &&
change.failing_reasons.length > 0) {
let reason = change.failing_reasons.join(', ')
iconTitle = 'Failing because ' + reason
if (reason.match(/merge conflict/)) {
iconGlyph = 'pficon pficon-error-circle-o zuul-build-merge-conflict'
} else {
iconGlyph = 'pficon pficon-error-circle-o'
}
}
return (
<span className={'zuul-build-status ' + iconGlyph}
title={iconTitle} />
)
}
renderLineImg (change, i) {
let image = LineTImage
if (change._tree_branches.indexOf(i) === change._tree_branches.length - 1) {
// Angle line
image = LineAngleImage
}
return <img alt="Line" src={image} style={{verticalAlign: 'baseline'}} />
}
render () {
const { change, queue, expanded } = this.props
let row = []
let i
for (i = 0; i < queue._tree_columns; i++) {
let className = ''
if (i < change._tree.length && change._tree[i] !== null) {
className = ' zuul-change-row-line'
}
row.push(
<td key={i} className={'zuul-change-row' + className}>
{i === change._tree_index ? this.renderStatusIcon(change) : ''}
{change._tree_branches.indexOf(i) !== -1 ? (
this.renderLineImg(change, i)) : ''}
</td>)
}
let changeWidth = 360 - 16 * queue._tree_columns
row.push(
<td key={i + 1}
className="zuul-change-cell"
style={{width: changeWidth + 'px'}}>
<ChangePanel change={change} globalExpanded={expanded} />
</td>
)
return (
<table className="zuul-change-box" style={{boxSizing: 'content-box'}}>
<tbody>
<tr>{row}</tr>
</tbody>
</table>
)
}
}
export default Change

View File

@ -1,317 +0,0 @@
// Copyright 2018 Red Hat, Inc
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License. You may obtain
// a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations
// under the License.
import * as React from 'react'
import PropTypes from 'prop-types'
import { connect } from 'react-redux'
import { Link } from 'react-router-dom'
class ChangePanel extends React.Component {
static propTypes = {
globalExpanded: PropTypes.bool.isRequired,
change: PropTypes.object.isRequired,
tenant: PropTypes.object
}
constructor () {
super()
this.state = {
expanded: false
}
this.onClick = this.onClick.bind(this)
this.clicked = false
}
onClick () {
let expanded = this.state.expanded
if (!this.clicked) {
expanded = this.props.globalExpanded
}
this.clicked = true
this.setState({ expanded: !expanded })
}
time (ms, words) {
if (typeof (words) === 'undefined') {
words = false
}
let seconds = (+ms) / 1000
let minutes = Math.floor(seconds / 60)
let hours = Math.floor(minutes / 60)
seconds = Math.floor(seconds % 60)
minutes = Math.floor(minutes % 60)
let r = ''
if (words) {
if (hours) {
r += hours
r += ' hr '
}
r += minutes + ' min'
} else {
if (hours < 10) {
r += '0'
}
r += hours + ':'
if (minutes < 10) {
r += '0'
}
r += minutes + ':'
if (seconds < 10) {
r += '0'
}
r += seconds
}
return r
}
enqueueTime (ms) {
// Special format case for enqueue time to add style
let hours = 60 * 60 * 1000
let now = Date.now()
let delta = now - ms
let status = 'text-success'
let text = this.time(delta, true)
if (delta > (4 * hours)) {
status = 'text-danger'
} else if (delta > (2 * hours)) {
status = 'text-warning'
}
return <span className={status}>{text}</span>
}
renderChangeLink (change) {
let changeId = change.id || 'NA'
let changeTitle = changeId
let changeText = ''
if (change.url !== null) {
let githubId = changeId.match(/^([0-9]+),([0-9a-f]{40})$/)
if (githubId) {
changeTitle = githubId
changeText = '#' + githubId[1]
} else if (/^[0-9a-f]{40}$/.test(changeId)) {
changeText = changeId.slice(0, 7)
}
} else if (changeId.length === 40) {
changeText = changeId.slice(0, 7)
}
return (
<small>
<a href={change.url}>
{changeText !== '' ? (
<abbr title={changeTitle}>{changeText}</abbr>) : changeTitle}
</a>
</small>)
}
renderProgressBar (change) {
let jobPercent = Math.floor(100 / change.jobs.length)
return (
<div className='progress zuul-change-total-result'>
{change.jobs.map((job, idx) => {
let result = job.result ? job.result.toLowerCase() : null
if (result === null) {
result = job.url ? 'in progress' : 'queued'
}
if (result !== 'queued') {
let className = ''
switch (result) {
case 'success':
className = ' progress-bar-success'
break
case 'lost':
case 'failure':
className = ' progress-bar-danger'
break
case 'unstable':
className = ' progress-bar-warning'
break
case 'in progress':
case 'queued':
break
default:
break
}
return <div className={'progress-bar' + className}
key={idx}
title={job.name}
style={{width: jobPercent + '%'}}/>
} else {
return ''
}
})}
</div>
)
}
renderTimer (change) {
let remainingTime
if (change.remaining_time === null) {
remainingTime = 'unknown'
} else {
remainingTime = this.time(change.remaining_time, true)
}
return (
<React.Fragment>
<small title='Remaining Time' className='time'>
{remainingTime}
</small>
<br />
<small title='Elapsed Time' className='time'>
{this.enqueueTime(change.enqueue_time)}
</small>
</React.Fragment>
)
}
renderJobProgressBar (elapsedTime, remainingTime) {
let progressPercent = 100 * (elapsedTime / (elapsedTime +
remainingTime))
return (
<div className='progress zuul-job-result'>
<div className='progress-bar'
role='progressbar'
aria-valuenow={progressPercent}
aria-valuemin={0}
aria-valuemax={100}
style={{'width': progressPercent + '%'}}
/>
</div>
)
}
renderJobStatusLabel (result) {
let className
switch (result) {
case 'success':
className = 'label-success'
break
case 'failure':
className = 'label-danger'
break
case 'unstable':
className = 'label-warning'
break
case 'skipped':
className = 'label-info'
break
// 'in progress' 'queued' 'lost' 'aborted' ...
default:
className = 'label-default'
}
return (
<span className={'zuul-job-result label ' + className}>{result}</span>
)
}
renderJob (job) {
const { tenant } = this.props
let name = ''
if (job.result !== null) {
name = <a className='zuul-job-name' href={job.report_url}>{job.name}</a>
} else if (job.url !== null) {
let url = job.url
if (job.url.match('stream.html')) {
const buildUuid = job.url.split('?')[1].split('&')[0].split('=')[1]
const to = (
tenant.linkPrefix + '/stream/' + buildUuid + '?logfile=console.log'
)
name = <Link to={to}>{job.name}</Link>
} else {
name = <a className='zuul-job-name' href={url}>{job.name}</a>
}
} else {
name = <span className='zuul-job-name'>{job.name}</span>
}
let resultBar
let result = job.result ? job.result.toLowerCase() : null
if (result === null) {
if (job.url === null) {
result = 'queued'
} else if (job.paused !== null && job.paused) {
result = 'paused'
} else {
result = 'in progress'
}
}
if (result === 'in progress') {
resultBar = this.renderJobProgressBar(
job.elapsed_time, job.remaining_time)
} else {
resultBar = this.renderJobStatusLabel(result)
}
return (
<span>
{name}
{resultBar}
{job.voting === false ? (
<small className='zuul-non-voting-desc'> (non-voting)</small>) : ''}
<div style={{clear: 'both'}} />
</span>)
}
renderJobList (jobs) {
return (
<ul className='list-group zuul-patchset-body'>
{jobs.map((job, idx) => (
<li key={idx} className='list-group-item zuul-change-job'>
{this.renderJob(job)}
</li>
))}
</ul>)
}
render () {
const { expanded } = this.state
const { change, globalExpanded } = this.props
let expand = globalExpanded
if (this.clicked) {
expand = expanded
}
const header = (
<div className='panel panel-default zuul-change' onClick={this.onClick}>
<div className='panel-heading zuul-patchset-header'>
<div className='row'>
<div className='col-xs-8'>
<span className='change_project'>{change.project}</span>
<div className='row'>
<div className='col-xs-4'>
{this.renderChangeLink(change)}
</div>
<div className='col-xs-8'>
{this.renderProgressBar(change)}
</div>
</div>
</div>
{change.live === true ? (
<div className='col-xs-4 text-right'>
{this.renderTimer(change)}
</div>
) : ''}
</div>
</div>
{expand ? this.renderJobList(change.jobs) : ''}
</div>
)
return (
<React.Fragment>
{header}
</React.Fragment>
)
}
}
export default connect(state => ({tenant: state.tenant}))(ChangePanel)

View File

@ -1,64 +0,0 @@
/* global expect, jest, it */
// Copyright 2018 Red Hat, Inc
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License. You may obtain
// a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations
// under the License.
import React from 'react'
import ReactTestUtils from 'react-dom/test-utils'
import { Link, BrowserRouter as Router } from 'react-router-dom'
import { Provider } from 'react-redux'
import { createZuulStore, setTenantAction } from '../../reducers'
import ChangePanel from './ChangePanel'
const fakeChange = {
project: 'org-project',
jobs: [{
name: 'job-name',
url: 'stream.html?build=42',
result: null
}]
}
it('change panel render multi tenant links', () => {
const store = createZuulStore()
store.dispatch(setTenantAction('tenant-one', false))
const application = ReactTestUtils.renderIntoDocument(
<Provider store={store}>
<Router>
<ChangePanel change={fakeChange} globalExpanded={true} />
</Router>
</Provider>
)
const jobLink = ReactTestUtils.findRenderedComponentWithType(
application, Link)
expect(jobLink.props.to).toEqual(
'/t/tenant-one/stream/42?logfile=console.log')
})
it('change panel render white-label tenant links', () => {
const store = createZuulStore()
store.dispatch(setTenantAction('tenant-one', true))
const application = ReactTestUtils.renderIntoDocument(
<Provider store={store}>
<Router>
<ChangePanel change={fakeChange} globalExpanded={true} />
</Router>
</Provider>
)
const jobLink = ReactTestUtils.findRenderedComponentWithType(
application, Link)
expect(jobLink.props.to).toEqual(
'/stream/42?logfile=console.log')
})

View File

@ -1,54 +0,0 @@
// Copyright 2018 Red Hat, Inc
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License. You may obtain
// a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations
// under the License.
import * as React from 'react'
import PropTypes from 'prop-types'
import Change from './Change'
class ChangeQueue extends React.Component {
static propTypes = {
pipeline: PropTypes.string.isRequired,
queue: PropTypes.object.isRequired,
expanded: PropTypes.bool.isRequired
}
render () {
const { queue, pipeline, expanded } = this.props
let shortName = queue.name
if (shortName.length > 32) {
shortName = shortName.substr(0, 32) + '...'
}
let changesList = []
queue.heads.forEach((changes, changeIdx) => {
changes.forEach((change, idx) => {
changesList.push(
<Change
change={change}
queue={queue}
expanded={expanded}
key={changeIdx.toString() + idx}
/>)
})
})
return (
<div className="change-queue" data-zuul-pipeline={pipeline}>
<p>Queue: <abbr title={queue.name}>{shortName}</abbr></p>
{changesList}
</div>)
}
}
export default ChangeQueue

View File

@ -1,136 +0,0 @@
// Copyright 2018 Red Hat, Inc
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License. You may obtain
// a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations
// under the License.
import * as React from 'react'
import PropTypes from 'prop-types'
import { Badge } from 'patternfly-react'
import ChangeQueue from './ChangeQueue'
class Pipeline extends React.Component {
static propTypes = {
expanded: PropTypes.bool.isRequired,
pipeline: PropTypes.object.isRequired,
filter: PropTypes.string
}
createTree (pipeline) {
let count = 0
let pipelineMaxTreeColumns = 1
pipeline.change_queues.forEach(changeQueue => {
let tree = []
let maxTreeColumns = 1
let changes = []
let lastTreeLength = 0
changeQueue.heads.forEach(head => {
head.forEach((change, changeIndex) => {
changes[change.id] = change
change._tree_position = changeIndex
})
})
changeQueue.heads.forEach(head => {
head.forEach(change => {
if (change.live === true) {
count += 1
}
let idx = tree.indexOf(change.id)
if (idx > -1) {
change._tree_index = idx
// remove...
tree[idx] = null
while (tree[tree.length - 1] === null) {
tree.pop()
}
} else {
change._tree_index = 0
}
change._tree_branches = []
change._tree = []
if (typeof (change.items_behind) === 'undefined') {
change.items_behind = []
}
change.items_behind.sort(function (a, b) {
return (changes[b]._tree_position - changes[a]._tree_position)
})
change.items_behind.forEach(id => {
tree.push(id)
if (tree.length > lastTreeLength && lastTreeLength > 0) {
change._tree_branches.push(tree.length - 1)
}
})
if (tree.length > maxTreeColumns) {
maxTreeColumns = tree.length
}
if (tree.length > pipelineMaxTreeColumns) {
pipelineMaxTreeColumns = tree.length
}
change._tree = tree.slice(0) // make a copy
lastTreeLength = tree.length
})
})
changeQueue._tree_columns = maxTreeColumns
})
pipeline._tree_columns = pipelineMaxTreeColumns
return count
}
filterQueue(queue, filter) {
let found = false
queue.heads.forEach(changes => {
changes.forEach(change => {
if ((change.project && change.project.indexOf(filter) !== -1) ||
(change.id && change.id.indexOf(filter) !== -1)) {
found = true
return
}
})
if (found) {
return
}
})
return found
}
render () {
const { pipeline, filter, expanded } = this.props
const count = this.createTree(pipeline)
return (
<div className="zuul-pipeline col-md-4">
<div className="zuul-pipeline-header">
<h3>{pipeline.name} <Badge>{count}</Badge></h3>
{pipeline.description ? (
<small>
<p>{pipeline.description.split(/\r?\n\r?\n/)}</p>
</small>) : ''}
</div>
{pipeline.change_queues.filter(item => item.heads.length > 0)
.filter(item => (!filter || (
pipeline.name.indexOf(filter) !== -1 ||
this.filterQueue(item, filter)
)))
.map((changeQueue, idx) => (
<ChangeQueue
queue={changeQueue}
expanded={expanded}
pipeline={pipeline.name}
key={idx}
/>
))}
</div>
)
}
}
export default Pipeline

Binary file not shown.

Before

Width:  |  Height:  |  Size: 930 B

View File

@ -1,83 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
version="1.1"
id="Layer_1"
x="0px"
y="0px"
viewBox="0 0 144 144"
style="enable-background:new 0 0 144 144;"
xml:space="preserve"
inkscape:version="0.91 r13725"
sodipodi:docname="logo.svg"
inkscape:export-filename="/data/logo.png"
inkscape:export-xdpi="8.7290258"
inkscape:export-ydpi="8.7290258"><metadata
id="metadata21"><rdf:RDF><cc:Work
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
id="defs19" /><sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1220"
inkscape:window-height="740"
id="namedview17"
showgrid="false"
showborder="false"
inkscape:zoom="0.81944444"
inkscape:cx="250.26215"
inkscape:cy="186.8512"
inkscape:window-x="252"
inkscape:window-y="337"
inkscape:window-maximized="0"
inkscape:current-layer="Layer_1"><inkscape:grid
type="xygrid"
id="grid3360" /></sodipodi:namedview><style
type="text/css"
id="style3">
.st0{fill:#071D49;}
</style><path
style="fill:#e6e6e6"
inkscape:connector-curvature="0"
id="path7"
d="m 12.8,102.6 118.5,0 -25.3,-43.8 0,-15 7,-9.2 -21,0 L 72,0 52,34.7 l -20.9,0 7,9.2 0,15 -25.3,43.7 z m 25.2,-6 -14.9,0 14.9,-25.8 0,25.8 z m 10.4,0 -4.4,0 0,-35.3 4.3,0 0,35.3 z m 0,-41.3 -4.4,0 0,-4.3 4.3,0 0,4.3 z m 20.6,41.3 -14.7,0 0,-35.3 14.7,0 0,35.3 z m 20.7,0 -14.7,0 0,-35.3 14.7,0 0,35.3 z m 0,-41.3 -35.4,0 0,-4.3 35.3,0 0,4.3 z m 10.3,41.3 -4.3,0 0,-35.3 4.3,0 0,35.3 z m 0,-41.3 -4.3,0 0,-4.3 4.3,0 0,4.3 z m 6,15.5 14.9,25.8 -14.9,0 0,-25.8 z M 72,12 85.1,34.7 58.9,34.7 72,12 Z m 28.9,28.7 -0.9,1.2 0,3.1 -56,0 0,-3.2 -0.9,-1.2 57.8,0 z"
class="st0"
inkscape:export-xdpi="14.038373"
inkscape:export-ydpi="14.038373" /><g
id="g3354"
transform="matrix(3,0,0,3,155.07357,-334.75)"
style="fill:#e6e6e6"
inkscape:export-xdpi="14.038373"
inkscape:export-ydpi="14.038373"><polygon
class="st0"
points="138.2,137.3 125.1,137.3 125.1,114.6 119.1,118.1 119.1,137.3 119.1,139.6 119.1,143.3 141.6,143.3 "
id="polygon9"
style="fill:#e6e6e6" /><path
class="st0"
d="m 99.1,131.5 0,0 0,0 c 0,3.6 -2.9,6.5 -6.5,6.5 -3.6,0 -6.5,-2.9 -6.5,-6.5 l 0,0 0,0 0,-16.9 -6,3.5 0,13.5 0,0 c 0,6.9 5.6,12.5 12.5,12.5 6.9,0 12.5,-5.6 12.5,-12.5 l 0,0 0,-16.9 -6,3.5 0,13.3 z"
id="path11"
inkscape:connector-curvature="0"
style="fill:#e6e6e6" /><path
class="st0"
d="m 60.2,131.5 0,0 0,0 c 0,3.6 -2.9,6.5 -6.5,6.5 -3.6,0 -6.5,-2.9 -6.5,-6.5 l 0,0 0,0 0,-16.9 -6,3.5 0,13.5 0,0 c 0,6.9 5.6,12.5 12.5,12.5 6.9,0 12.5,-5.6 12.5,-12.5 l 0,0 0,-16.9 -6,3.5 0,13.3 z"
id="path13"
inkscape:connector-curvature="0"
style="fill:#e6e6e6" /><polygon
class="st0"
points="2.4,143.3 23.8,143.3 27.3,137.3 12.7,137.3 25.8,114.6 25.4,114.6 18.9,114.6 5.8,114.6 2.4,120.6 15.5,120.6 "
id="polygon15"
style="fill:#e6e6e6" /></g></svg>

Before

Width:  |  Height:  |  Size: 3.7 KiB

View File

@ -1,122 +0,0 @@
body {
margin: 0;
padding: 0;
font-family: sans-serif;
}
a.refresh {
cursor: pointer;
border-bottom-style: none;
text-decoration: none;
}
/* Status page */
.zuul-change {
margin-bottom: 10px;
}
.zuul-change-id {
float: right;
}
.zuul-job-result {
float: right;
width: 70px;
height: 15px;
margin: 2px 0 0 0;
}
.zuul-change-total-result {
height: 10px;
width: 100px;
margin: 0;
display: inline-block;
vertical-align: middle;
}
.zuul-spinner,
.zuul-spinner:hover {
opacity: 0;
transition: opacity 0.5s ease-out;
cursor: default;
pointer-events: none;
}
.zuul-spinner-on,
.zuul-spinner-on:hover {
opacity: 1;
transition-duration: 0.2s;
cursor: progress;
}
.zuul-change-cell {
padding-left: 5px;
}
.zuul-change-job {
padding: 2px 8px;
}
.zuul-job-name {
font-size: small;
}
.zuul-non-voting-desc {
font-size: smaller;
}
.zuul-patchset-header {
font-size: small;
padding: 8px 12px;
}
.form-inline > .form-group {
padding-right: 5px;
}
.zuul-change-row {
height: 100%;
padding: 0 0 10px 0;
margin: 0;
width: 16px;
min-width: 16px;
overflow: hidden;
vertical-align: top;
}
.zuul-build-status {
background: white;
font-size: 16px;
}
.zuul-build-merge-conflict:before {
color: black;
}
.zuul-change-row-line {
background-image: url('images/line.png');
background-repeat: 'repeat-y';
}
/* Stream page */
#zuulstreamoverlay {
float: right;
position: fixed;
top: 70px;
right: 5px;
background-color: white;
padding: 2px 0px 0px 2px;
color: black;
}
pre#zuulstreamcontent {
font-family: monospace;
white-space: pre;
margin: 0px 10px;
background-color: black;
color: lightgrey;
border: none;
}
p.zuulstreamline {
margin: 0px 0px;
line-height: 1.4;
}

View File

@ -1,40 +0,0 @@
// Copyright 2018 Red Hat, Inc
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License. You may obtain
// a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations
// under the License.
// The index is the main of the project. The App is wrapped with
// a Provider to share the redux store and a Router to manage the location.
import React from 'react'
import ReactDOM from 'react-dom'
import { BrowserRouter as Router } from 'react-router-dom'
import { Provider } from 'react-redux'
import 'patternfly/dist/css/patternfly.min.css'
import 'patternfly/dist/css/patternfly-additions.min.css'
import './index.css'
import { getHomepageUrl } from './api'
import registerServiceWorker from './registerServiceWorker'
import { createZuulStore, fetchInfoAction } from './reducers'
import App from './App'
// This calls the /api/info endpoint asynchronously, the App is connected
// with redux and it will update the info prop when fetch succeed.
const store = createZuulStore()
store.dispatch(fetchInfoAction())
ReactDOM.render(
<Provider store={store}>
<Router basename={new URL(getHomepageUrl()).pathname}><App /></Router>
</Provider>, document.getElementById('root'))
registerServiceWorker()

View File

@ -1,159 +0,0 @@
// Copyright 2018 Red Hat, Inc
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License. You may obtain
// a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations
// under the License.
import * as React from 'react'
import PropTypes from 'prop-types'
import { connect } from 'react-redux'
import { Table } from 'patternfly-react'
import { fetchBuilds } from '../api'
import TableFilters from '../containers/TableFilters'
class BuildsPage extends TableFilters {
static propTypes = {
tenant: PropTypes.object
}
constructor () {
super()
this.prepareTableHeaders()
this.state = {
builds: null,
currentFilterType: this.filterTypes[0],
activeFilters: [],
currentValue: ''
}
}
updateData = (filters) => {
let queryString = ''
if (filters) {
filters.forEach(item => queryString += '&' + item.key + '=' + item.value)
}
this.setState({builds: null})
fetchBuilds(this.props.tenant.apiPrefix, queryString).then(response => {
this.setState({builds: response.data})
})
}
componentDidMount () {
document.title = 'Zuul Build'
if (this.props.tenant.name) {
this.updateData(this.getFilterFromUrl())
}
}
componentDidUpdate (prevProps) {
if (this.props.tenant.name !== prevProps.tenant.name) {
this.updateData(this.getFilterFromUrl())
}
}
prepareTableHeaders() {
const headerFormat = value => <Table.Heading>{value}</Table.Heading>
const cellFormat = (value) => (
<Table.Cell>{value}</Table.Cell>)
const linkCellFormat = (value) => (
<Table.Cell>
<a href={value} target='_blank' rel='noopener noreferrer'>link</a>
</Table.Cell>
)
this.columns = []
this.filterTypes = []
const myColumns = [
'job',
'project',
'branch',
'pipeline',
'change',
'duration',
'log',
'start time',
'result']
myColumns.forEach(column => {
let prop = column
let formatter = cellFormat
// Adapt column name and property name
if (column === 'job') {
prop = 'job_name'
} else if (column === 'start time') {
prop = 'start_time'
} else if (column === 'change') {
prop = 'ref_url'
formatter = linkCellFormat
} else if (column === 'log') {
prop = 'log_url'
formatter = linkCellFormat
}
const label = column.charAt(0).toUpperCase() + column.slice(1)
this.columns.push({
header: {label: label, formatters: [headerFormat]},
property: prop,
cell: {formatters: [formatter]}
})
if (prop !== 'start_time' && prop !== 'ref_url' && prop !== 'duration'
&& prop !== 'log_url' && prop !== 'uuid') {
this.filterTypes.push({
id: prop,
title: label,
placeholder: 'Filter by ' + label,
filterType: 'text',
})
}
})
// Add build filter at the end
this.filterTypes.push({
id: 'uuid',
title: 'Build',
palceholder: 'Filter by Build UUID',
fileterType: 'text',
})
}
renderTable (builds) {
return (
<Table.PfProvider
striped
bordered
columns={this.columns}
>
<Table.Header/>
<Table.Body
rows={builds}
rowKey='uuid'
onRow={(row) => {
switch (row.result) {
case 'SUCCESS':
return { className: 'success' }
default:
return { className: 'warning' }
}
}} />
</Table.PfProvider>)
}
render() {
const { builds } = this.state
return (
<React.Fragment>
{this.renderFilter()}
{builds ? this.renderTable(builds) : <p>Loading...</p>}
</React.Fragment>
)
}
}
export default connect(state => ({tenant: state.tenant}))(BuildsPage)

View File

@ -1,99 +0,0 @@
// Copyright 2018 Red Hat, Inc
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License. You may obtain
// a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations
// under the License.
import * as React from 'react'
import PropTypes from 'prop-types'
import { connect } from 'react-redux'
import { Link } from 'react-router-dom'
import { Table } from 'patternfly-react'
import { fetchJobs } from '../api'
class JobsPage extends React.Component {
static propTypes = {
tenant: PropTypes.object
}
state = {
jobs: null
}
updateData () {
fetchJobs(this.props.tenant.apiPrefix).then(response => {
this.setState({jobs: response.data})
})
}
componentDidMount () {
document.title = 'Zuul Jobs'
if (this.props.tenant.name) {
this.updateData()
}
}
componentDidUpdate (prevProps) {
if (this.props.tenant.name !== prevProps.tenant.name) {
this.updateData()
}
}
render () {
const { jobs } = this.state
if (!jobs) {
return (<p>Loading...</p>)
}
const headerFormat = value => <Table.Heading>{value}</Table.Heading>
const cellFormat = (value) => (
<Table.Cell>{value}</Table.Cell>)
const cellBuildFormat = (value) => (
<Table.Cell>
<Link to={this.props.tenant.linkPrefix + '/builds?job_name=' + value}>
builds
</Link>
</Table.Cell>)
const columns = []
const myColumns = ['name', 'description', 'Last builds']
myColumns.forEach(column => {
let formatter = cellFormat
let prop = column
if (column === 'Last builds') {
prop = 'name'
formatter = cellBuildFormat
}
columns.push({
header: {label: column,
formatters: [headerFormat]},
property: prop,
cell: {formatters: [formatter]}
})
})
return (
<Table.PfProvider
striped
bordered
hover
columns={columns}
>
<Table.Header/>
<Table.Body
rows={jobs}
rowKey="name"
/>
</Table.PfProvider>)
}
}
export default connect(state => ({tenant: state.tenant}))(JobsPage)

View File

@ -1,289 +0,0 @@
/* global setTimeout, clearTimeout */
// Copyright 2018 Red Hat, Inc
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License. You may obtain
// a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations
// under the License.
import * as React from 'react'
import PropTypes from 'prop-types'
import { connect } from 'react-redux'
import {
Alert,
Checkbox,
Icon,
Form,
FormGroup,
FormControl,
Spinner
} from 'patternfly-react'
import { fetchStatus } from '../api'
import Pipeline from '../containers/status/Pipeline'
class StatusPage extends React.Component {
static propTypes = {
location: PropTypes.object,
tenant: PropTypes.object
}
state = {
status: null,
filter: null,
expanded: false,
error: null,
loading: false,
autoReload: true
}
visibilityListener = () => {
if (document[this.visibilityStateProperty] === 'visible') {
this.visible = true
this.updateData()
} else {
this.visible = false
}
}
constructor () {
super()
this.timer = null
this.visible = true
// Stop refresh when page is not visible
if (typeof document.hidden !== 'undefined') {
this.visibilityChangeEvent = 'visibilitychange'
this.visibilityStateProperty = 'visibilityState'
} else if (typeof document.mozHidden !== 'undefined') {
this.visibilityChangeEvent = 'mozvisibilitychange'
this.visibilityStateProperty = 'mozVisibilityState'
} else if (typeof document.msHidden !== 'undefined') {
this.visibilityChangeEvent = 'msvisibilitychange'
this.visibilityStateProperty = 'msVisibilityState'
} else if (typeof document.webkitHidden !== 'undefined') {
this.visibilityChangeEvent = 'webkitvisibilitychange'
this.visibilityStateProperty = 'webkitVisibilityState'
}
document.addEventListener(
this.visibilityChangeEvent, this.visibilityListener, false)
}
setCookie (name, value) {
document.cookie = name + '=' + value + '; path=/'
}
updateData = (force) => {
/* // Create fake delay
function sleeper(ms) {
return function(x) {
return new Promise(resolve => setTimeout(() => resolve(x), ms));
};
}
*/
if (force || (this.visible && this.state.autoReload)) {
this.setState({error: null, loading: true})
fetchStatus(this.props.tenant.apiPrefix)
// .then(sleeper(2000))
.then(response => {
this.setState({status: response.data, loading: false})
}).catch(error => {
this.setState({error: error.message, status: null})
})
}
// Clear any running timer
if (this.timer) {
clearTimeout(this.timer)
this.timer = null
}
if (this.state.autoReload) {
this.timer = setTimeout(this.updateData, 5000)
}
}
componentDidMount () {
document.title = 'Zuul Status'
this.loadState()
if (this.props.tenant.name) {
this.updateData()
}
}
componentDidUpdate (prevProps, prevState) {
// When autoReload is set, also call updateData to retrigger the setTimeout
if (this.props.tenant.name !== prevProps.tenant.name || (
this.state.autoReload &&
this.state.autoReload !== prevState.autoReload)) {
this.updateData()
}
}
componentWillUnmount () {
if (this.timer) {
clearTimeout(this.timer)
this.timer = null
}
document.removeEventListener(
this.visibilityChangeEvent, this.visibilityListener)
}
setFilter = (filter) => {
this.filter.value = filter
this.setState({filter: filter})
this.setCookie('zuul_filter_string', filter)
}
handleKeyPress = (e) => {
if (e.charCode === 13) {
this.setFilter(e.target.value)
e.preventDefault()
e.target.blur()
}
}
handleCheckBox = (e) => {
this.setState({expanded: e.target.checked})
this.setCookie('zuul_expand_by_default', e.target.checked)
}
loadState = () => {
function readCookie (name, defaultValue) {
let nameEQ = name + '='
let ca = document.cookie.split(';')
for (let i = 0; i < ca.length; i++) {
let c = ca[i]
while (c.charAt(0) === ' ') {
c = c.substring(1, c.length)
}
if (c.indexOf(nameEQ) === 0) {
return c.substring(nameEQ.length, c.length)
}
}
return defaultValue
}
let filter = readCookie('zuul_filter_string', '')
let expanded = readCookie('zuul_expand_by_default', false)
if (typeof expanded === 'string') {
expanded = (expanded === 'true')
}
if (this.props.location.hash) {
filter = this.props.location.hash.slice(1)
}
if (filter || expanded) {
this.setState({
filter: filter,
expanded: expanded
})
}
}
renderStatusHeader (status) {
return (
<p>
Queue lengths: <span>{status.trigger_event_queue ?
status.trigger_event_queue.length : '0'
}</span> events,
<span>{status.management_event_queue ?
status.management_event_queue.length : '0'
}</span> management events,
<span>{status.result_event_queue ?
status.result_event_queue.length : '0'
}</span> results.
</p>
)
}
renderStatusFooter (status) {
return (
<React.Fragment>
<p>Zuul version: <span>{status.zuul_version}</span></p>
{status.last_reconfigured ? (
<p>Last reconfigured: <span>
{new Date(status.last_reconfigured).toString()}
</span></p>) : ''}
</React.Fragment>
)
}
render () {
const { autoReload, error, status, filter, expanded, loading } = this.state
if (error) {
return (<Alert>{this.state.error}</Alert>)
}
if (this.filter && filter) {
this.filter.value = filter
}
const statusControl = (
<Form inline>
<FormGroup controlId='status'>
<FormControl
type='text'
placeholder='change or project name'
defaultValue={filter}
inputRef={i => this.filter = i}
onKeyPress={this.handleKeyPress} />
{filter && (
<FormControl.Feedback>
<span
onClick={() => {this.setFilter('')}}
style={{cursor: 'pointer', zIndex: 10, pointerEvents: 'auto'}}
>
<Icon type='pf' title='Clear filter' name='delete' />
&nbsp;
</span>
</FormControl.Feedback>
)}
</FormGroup>
<FormGroup controlId='status'>
&nbsp; Expand by default:&nbsp;
<Checkbox
defaultChecked={expanded}
onChange={this.handleCheckBox} />
</FormGroup>
</Form>
)
return (
<React.Fragment>
<div className="pull-right" style={{display: 'flex'}}>
<Spinner loading={loading}>
<a className="refresh" onClick={() => {this.updateData(true)}}>
<Icon type="fa" name="refresh" /> refresh&nbsp;&nbsp;
</a>
</Spinner>
<Checkbox
defaultChecked={autoReload}
onChange={(e) => {this.setState({autoReload: e.target.checked})}}
style={{marginTop: '0px'}}>
auto reload
</Checkbox>
</div>
{status && this.renderStatusHeader(status)}
{statusControl}
<div className='row'>
{status && status.pipelines.map(item => (
<Pipeline
pipeline={item}
filter={filter}
expanded={expanded}
key={item.name}
/>
))}
</div>
{status && this.renderStatusFooter(status)}
</React.Fragment>)
}
}
export default connect(state => ({tenant: state.tenant}))(StatusPage)

View File

@ -1,158 +0,0 @@
/* global clearTimeout, setTimeout, JSON, URLSearchParams */
// Copyright 2018 Red Hat, Inc
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License. You may obtain
// a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations
// under the License.
import * as React from 'react'
import PropTypes from 'prop-types'
import { connect } from 'react-redux'
import { Checkbox, Form, FormGroup } from 'patternfly-react'
import Sockette from 'sockette'
import { getStreamUrl } from '../api'
class StreamPage extends React.Component {
static propTypes = {
match: PropTypes.object.isRequired,
location: PropTypes.object.isRequired,
tenant: PropTypes.object
}
state = {
autoscroll: true,
}
constructor() {
super()
this.receiveBuffer = ''
this.displayRef = React.createRef()
this.lines = []
}
refreshLoop = () => {
if (this.displayRef.current) {
let newLine = false
this.lines.forEach(line => {
newLine = true
this.displayRef.current.appendChild(line)
})
this.lines = []
if (newLine) {
const { autoscroll } = this.state
if (autoscroll) {
this.messagesEnd.scrollIntoView({ behavior: 'instant' })
}
}
}
this.timer = setTimeout(this.refreshLoop, 250)
}
componentWillUnmount () {
if (this.timer) {
clearTimeout(this.timer)
this.timer = null
}
if (this.ws) {
console.log('Remove ws')
this.ws.close()
}
}
onLine = (line) => {
// Create dom elements
const lineDom = document.createElement('p')
lineDom.className = 'zuulstreamline'
lineDom.appendChild(document.createTextNode(line))
this.lines.push(lineDom)
}
onMessage = (message) => {
this.receiveBuffer += message
const lines = this.receiveBuffer.split('\n')
const lastLine = lines.slice(-1)[0]
// Append all completed lines
lines.slice(0, -1).forEach(line => {
this.onLine(line)
})
// Check if last chunk is completed
if (lastLine && this.receiveBuffer.slice(-1) === '\n') {
this.onLine(lastLine)
this.receiveBuffer = ''
} else {
this.receiveBuffer = lastLine
}
this.refreshLoop()
}
componentDidMount() {
const params = {
uuid: this.props.match.params.buildId
}
const urlParams = new URLSearchParams(this.props.location.search)
const logfile = urlParams.get('logfile')
if (logfile) {
params.logfile = logfile
}
document.title = 'Zuul Stream | ' + params.uuid.slice(0, 7)
this.ws = new Sockette(getStreamUrl(this.props.tenant.apiPrefix), {
timeout: 5e3,
maxAttempts: 3,
onopen: () => {
console.log('onopen')
this.ws.send(JSON.stringify(params))
},
onmessage: e => {
this.onMessage(e.data)
},
onreconnect: e => {
console.log('Reconnecting...', e)
},
onmaximum: e => {
console.log('Stop Attempting!', e)
},
onclose: e => {
console.log('onclose', e)
this.onMessage('\n--- END OF STREAM ---\n')
},
onerror: e => {
console.log('onerror:', e)
}
})
}
handleCheckBox = (e) => {
this.setState({autoscroll: e.target.checked})
}
render () {
return (
<React.Fragment>
<Form inline id='zuulstreamoverlay'>
<FormGroup controlId='stream'>
<Checkbox
checked={this.state.autoscroll}
onChange={this.handleCheckBox}>
autoscroll
</Checkbox>
</FormGroup>
</Form>
<pre id='zuulstreamcontent' ref={this.displayRef} />
<div ref={(el) => { this.messagesEnd = el }} />
</React.Fragment>
)
}
}
export default connect(state => ({tenant: state.tenant}))(StreamPage)

View File

@ -1,79 +0,0 @@
// Copyright 2018 Red Hat, Inc
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License. You may obtain
// a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations
// under the License.
import * as React from 'react'
import { Link } from 'react-router-dom'
import { Table } from 'patternfly-react'
import { fetchTenants } from '../api'
class TenantsPage extends React.Component {
constructor () {
super()
this.state = {
tenants: []
}
}
componentDidMount () {
document.title = 'Zuul Tenants'
fetchTenants().then(response => {
this.setState({tenants: response.data})
})
}
render () {
const { tenants } = this.state
if (tenants.length === 0) {
return (<p>Loading...</p>)
}
const headerFormat = value => <Table.Heading>{value}</Table.Heading>
const cellFormat = (value) => (
<Table.Cell>{value}</Table.Cell>)
const columns = []
const myColumns = ['name', 'status', 'jobs', 'builds', 'projects', 'queue']
myColumns.forEach(column => {
columns.push({
header: {label: column,
formatters: [headerFormat]},
property: column,
cell: {formatters: [cellFormat]}
})
})
tenants.forEach(tenant => {
tenant.status = (
<Link to={'/t/' + tenant.name + '/status'}>Status</Link>)
tenant.jobs = (
<Link to={'/t/' + tenant.name + '/jobs'}>Jobs</Link>)
tenant.builds = (
<Link to={'/t/' + tenant.name + '/builds'}>Builds</Link>)
})
return (
<Table.PfProvider
striped
bordered
hover
columns={columns}
>
<Table.Header/>
<Table.Body
rows={tenants}
rowKey="name"
/>
</Table.PfProvider>)
}
}
export default TenantsPage

View File

@ -1,94 +0,0 @@
// Copyright 2018 Red Hat, Inc
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License. You may obtain
// a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations
// under the License.
// Redux store enable to share global variables through state
// To update the store, use a reducer and dispatch method,
// see the App.setTenant method
//
// The store contains:
// info: the info object, tenant is set when white-label api
// tenant: the current tenant name, only used with multi-tenant api
import { applyMiddleware, createStore, combineReducers } from 'redux'
import thunk from 'redux-thunk'
import { fetchInfo } from './api'
const infoReducer = (state = {}, action) => {
switch (action.type) {
case 'FETCH_INFO_SUCCESS':
return action.info
default:
return state
}
}
const tenantReducer = (state = {}, action) => {
switch (action.type) {
case 'SET_TENANT':
return action.tenant
default:
return state
}
}
function createZuulStore() {
return createStore(combineReducers({
info: infoReducer,
tenant: tenantReducer
}), applyMiddleware(thunk))
}
// Reducer actions
function fetchInfoAction () {
return (dispatch) => {
return fetchInfo()
.then(response => {
dispatch({type: 'FETCH_INFO_SUCCESS', info: response.data.info})
})
.catch(error => {
throw (error)
})
}
}
function setTenantAction (name, whiteLabel) {
let apiPrefix = ''
let linkPrefix = ''
let routePrefix = ''
let defaultRoute = '/status'
if (!whiteLabel) {
apiPrefix = 'tenant/' + name + '/'
linkPrefix = '/t/' + name
routePrefix = '/t/:tenant'
defaultRoute = '/tenants'
}
return {
type: 'SET_TENANT',
tenant: {
name: name,
whiteLabel: whiteLabel,
defaultRoute: defaultRoute,
linkPrefix: linkPrefix,
apiPrefix: apiPrefix,
routePrefix: routePrefix
}
}
}
export {
createZuulStore,
setTenantAction,
fetchInfoAction
}

View File

@ -1,119 +0,0 @@
/* global process */
// In production, we register a service worker to serve assets from local cache.
// This lets the app load faster on subsequent visits in production, and gives
// it offline capabilities. However, it also means that developers (and users)
// will only see deployed updates on the "N+1" visit to a page, since previously
// cached resources are updated in the background.
// To learn more about the benefits of this model, read
// https://github.com/facebook/create-react-app/blob/master/packages/react-scripts/template/README.md#making-a-progressive-web-app
// This link also includes instructions on opting out of this behavior.
const isLocalhost = Boolean(
window.location.hostname === 'localhost' ||
// [::1] is the IPv6 localhost address.
window.location.hostname === '[::1]' ||
// 127.0.0.1/8 is considered localhost for IPv4.
window.location.hostname.match(
/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
)
)
export default function register () {
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
// The URL constructor is available in all browsers that support SW.
const publicUrl = new URL(process.env.PUBLIC_URL, window.location)
if (publicUrl.origin !== window.location.origin) {
// Our service worker won't work if PUBLIC_URL is on a different origin
// from what our page is served on. This might happen if a CDN is used to
// serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374
return
}
window.addEventListener('load', () => {
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`
if (isLocalhost) {
// This is running on localhost. Lets check if a service worker still exists or not.
checkValidServiceWorker(swUrl)
// Add some additional logging to localhost, pointing developers to the
// service worker/PWA documentation.
navigator.serviceWorker.ready.then(() => {
console.log(
'This web app is being served cache-first by a service ' +
'worker. To learn more, visit https://goo.gl/SC7cgQ'
)
})
} else {
// Is not local host. Just register service worker
registerValidSW(swUrl)
}
})
}
}
function registerValidSW (swUrl) {
navigator.serviceWorker
.register(swUrl)
.then(registration => {
registration.onupdatefound = () => {
const installingWorker = registration.installing
installingWorker.onstatechange = () => {
if (installingWorker.state === 'installed') {
if (navigator.serviceWorker.controller) {
// At this point, the old content will have been purged and
// the fresh content will have been added to the cache.
// It's the perfect time to display a "New content is
// available; please refresh." message in your web app.
console.log('New content is available; please refresh.')
} else {
// At this point, everything has been precached.
// It's the perfect time to display a
// "Content is cached for offline use." message.
console.log('Content is cached for offline use.')
}
}
}
}
})
.catch(error => {
console.error('Error during service worker registration:', error)
})
}
function checkValidServiceWorker (swUrl) {
// Check if the service worker can be found. If it can't reload the page.
fetch(swUrl)
.then(response => {
// Ensure service worker exists, and that we really are getting a JS file.
if (
response.status === 404 ||
response.headers.get('content-type').indexOf('javascript') === -1
) {
// No service worker found. Probably a different app. Reload the page.
navigator.serviceWorker.ready.then(registration => {
registration.unregister().then(() => {
window.location.reload()
})
})
} else {
// Service worker found. Proceed as normal.
registerValidSW(swUrl)
}
})
.catch(() => {
console.log(
'No internet connection found. App is running in offline mode.'
)
})
}
export function unregister () {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready.then(registration => {
registration.unregister()
})
}
}

View File

@ -1,52 +0,0 @@
// Copyright 2018 Red Hat, Inc
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License. You may obtain
// a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations
// under the License.
import StatusPage from './pages/Status'
import JobsPage from './pages/Jobs'
import BuildsPage from './pages/Builds'
import TenantsPage from './pages/Tenants'
import StreamPage from './pages/Stream'
// The Route object are created in the App component.
// Object with a title are created in the menu.
// Object with globalRoute are not tenant scoped.
// Remember to update the api getHomepageUrl subDir list for route with params
const routes = () => [
{
title: 'Status',
to: '/status',
component: StatusPage
},
{
title: 'Jobs',
to: '/jobs',
component: JobsPage
},
{
title: 'Builds',
to: '/builds',
component: BuildsPage
},
{
to: '/stream/:buildId',
component: StreamPage
},
{
to: '/tenants',
component: TenantsPage,
globalRoute: true
}
]
export { routes }

944
web/status/jquery.zuul.js Normal file
View File

@ -0,0 +1,944 @@
/* global Image, jQuery */
// jquery plugin for Zuul status page
//
// Copyright 2012 OpenStack Foundation
// Copyright 2013 Timo Tijhof
// Copyright 2013 Wikimedia Foundation
// Copyright 2014 Rackspace Australia
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License. You may obtain
// a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations
// under the License.
import RedImage from '../images/red.png'
import GreyImage from '../images/grey.png'
import GreenImage from '../images/green.png'
import BlackImage from '../images/black.png'
import LineImage from '../images/line.png'
import LineAngleImage from '../images/line-angle.png'
import LineTImage from '../images/line-t.png';
(function ($) {
function setCookie (name, value) {
document.cookie = name + '=' + value + '; path=/'
}
function readCookie (name, defaultValue) {
let nameEQ = name + '='
let ca = document.cookie.split(';')
for (let i = 0; i < ca.length; i++) {
let c = ca[i]
while (c.charAt(0) === ' ') {
c = c.substring(1, c.length)
}
if (c.indexOf(nameEQ) === 0) {
return c.substring(nameEQ.length, c.length)
}
}
return defaultValue
}
$.zuul = function (options, zuulService) {
options = $.extend({
'enabled': true,
'graphite_url': '',
'source': 'status',
'source_data': null,
'msg_id': '#zuul_msg',
'pipelines_id': '#zuul_pipelines',
'queue_events_num': '#zuul_queue_events_num',
'queue_management_events_num': '#zuul_queue_management_events_num',
'queue_results_num': '#zuul_queue_results_num'
}, options)
let collapsedExceptions = []
let currentFilter = readCookie('zuul_filter_string', '')
let changeSetInURL = window.location.href.split('#')[1]
if (changeSetInURL) {
currentFilter = changeSetInURL
}
let $jq
let xhr
let zuulGraphUpdateCount = 0
let zuulSparklineURLs = {}
function getSparklineURL (pipelineName) {
if (options.graphite_url !== '') {
if (!(pipelineName in zuulSparklineURLs)) {
zuulSparklineURLs[pipelineName] = $.fn.graphite
.geturl({
url: options.graphite_url,
from: '-8hours',
width: 100,
height: 26,
margin: 0,
hideLegend: true,
hideAxes: true,
hideGrid: true,
target: [
'color(stats.gauges.zuul.pipeline.' + pipelineName +
".current_changes, '6b8182')"
]
})
}
return zuulSparklineURLs[pipelineName]
}
return false
}
let format = {
job: function (job) {
let $jobLine = $('<span />')
if (job.result !== null) {
$jobLine.append(
$('<a />')
.addClass('zuul-job-name')
.attr('href', job.report_url)
.text(job.name)
)
} else if (job.url !== null) {
$jobLine.append(
$('<a />')
.addClass('zuul-job-name')
.attr('href', job.url)
.text(job.name)
)
} else {
$jobLine.append(
$('<span />')
.addClass('zuul-job-name')
.text(job.name)
)
}
$jobLine.append(this.job_status(job))
if (job.voting === false) {
$jobLine.append(
$(' <small />')
.addClass('zuul-non-voting-desc')
.text(' (non-voting)')
)
}
$jobLine.append($('<div style="clear: both"></div>'))
return $jobLine
},
job_status: function (job) {
let result = job.result ? job.result.toLowerCase() : null
if (result === null) {
if (job.url === null) {
result = 'queued'
} else if (job.paused !== null && job.paused) {
result = 'paused'
} else {
result = 'in progress'
}
}
if (result === 'in progress') {
return this.job_progress_bar(job.elapsed_time,
job.remaining_time)
} else {
return this.status_label(result)
}
},
status_label: function (result) {
let $status = $('<span />')
$status.addClass('zuul-job-result label')
switch (result) {
case 'success':
$status.addClass('label-success')
break
case 'failure':
$status.addClass('label-danger')
break
case 'unstable':
$status.addClass('label-warning')
break
case 'skipped':
$status.addClass('label-info')
break
// 'in progress' 'queued' 'lost' 'aborted' ...
default:
$status.addClass('label-default')
}
$status.text(result)
return $status
},
job_progress_bar: function (elapsedTime, remainingTime) {
let progressPercent = 100 * (elapsedTime / (elapsedTime +
remainingTime))
let $barInner = $('<div />')
.addClass('progress-bar')
.attr('role', 'progressbar')
.attr('aria-valuenow', 'progressbar')
.attr('aria-valuemin', progressPercent)
.attr('aria-valuemin', '0')
.attr('aria-valuemax', '100')
.css('width', progressPercent + '%')
let $barOutter = $('<div />')
.addClass('progress zuul-job-result')
.append($barInner)
return $barOutter
},
enqueueTime: function (ms) {
// Special format case for enqueue time to add style
let hours = 60 * 60 * 1000
let now = Date.now()
let delta = now - ms
let status = 'text-success'
let text = this.time(delta, true)
if (delta > (4 * hours)) {
status = 'text-danger'
} else if (delta > (2 * hours)) {
status = 'text-warning'
}
return '<span class="' + status + '">' + text + '</span>'
},
time: function (ms, words) {
if (typeof (words) === 'undefined') {
words = false
}
let seconds = (+ms) / 1000
let minutes = Math.floor(seconds / 60)
let hours = Math.floor(minutes / 60)
seconds = Math.floor(seconds % 60)
minutes = Math.floor(minutes % 60)
let r = ''
if (words) {
if (hours) {
r += hours
r += ' hr '
}
r += minutes + ' min'
} else {
if (hours < 10) {
r += '0'
}
r += hours + ':'
if (minutes < 10) {
r += '0'
}
r += minutes + ':'
if (seconds < 10) {
r += '0'
}
r += seconds
}
return r
},
changeTotalProgressBar: function (change) {
let jobPercent = Math.floor(100 / change.jobs.length)
let $barOutter = $('<div />')
.addClass('progress zuul-change-total-result')
$.each(change.jobs, function (i, job) {
let result = job.result ? job.result.toLowerCase() : null
if (result === null) {
result = job.url ? 'in progress' : 'queued'
}
if (result !== 'queued') {
let $barInner = $('<div />')
.addClass('progress-bar')
switch (result) {
case 'success':
$barInner.addClass('progress-bar-success')
break
case 'lost':
case 'failure':
$barInner.addClass('progress-bar-danger')
break
case 'unstable':
$barInner.addClass('progress-bar-warning')
break
case 'in progress':
case 'queued':
break
}
$barInner.attr('title', job.name)
.css('width', jobPercent + '%')
$barOutter.append($barInner)
}
})
return $barOutter
},
changeHeader: function (change) {
let changeId = change.id || 'NA'
let $changeLink = $('<small />')
if (change.url !== null) {
let githubId = changeId.match(/^([0-9]+),([0-9a-f]{40})$/)
if (githubId) {
$changeLink.append(
$('<a />').attr('href', change.url).append(
$('<abbr />')
.attr('title', changeId)
.text('#' + githubId[1])
)
)
} else if (/^[0-9a-f]{40}$/.test(changeId)) {
let changeIdShort = changeId.slice(0, 7)
$changeLink.append(
$('<a />').attr('href', change.url).append(
$('<abbr />')
.attr('title', changeId)
.text(changeIdShort)
)
)
} else {
$changeLink.append(
$('<a />').attr('href', change.url).text(changeId)
)
}
} else {
if (changeId.length === 40) {
changeId = changeId.substr(0, 7)
}
$changeLink.text(changeId)
}
let $changeProgressRowLeft = $('<div />')
.addClass('col-xs-4')
.append($changeLink)
let $changeProgressRowRight = $('<div />')
.addClass('col-xs-8')
.append(this.changeTotalProgressBar(change))
let $changeProgressRow = $('<div />')
.addClass('row')
.append($changeProgressRowLeft)
.append($changeProgressRowRight)
let $projectSpan = $('<span />')
.addClass('change_project')
.text(change.project)
let $left = $('<div />')
.addClass('col-xs-8')
.append($projectSpan, $changeProgressRow)
let remainingTime
if (change.remaining_time === null) {
remainingTime = 'unknown'
} else {
remainingTime = this.time(change.remaining_time, true)
}
let enqueueTime = this.enqueueTime(change.enqueue_time)
let $remainingTime = $('<small />').addClass('time')
.attr('title', 'Remaining Time').html(remainingTime)
let $enqueueTime = $('<small />').addClass('time')
.attr('title', 'Elapsed Time').html(enqueueTime)
let $right = $('<div />')
if (change.live === true) {
$right.addClass('col-xs-4 text-right')
.append($remainingTime, $('<br />'), $enqueueTime)
}
let $header = $('<div />')
.addClass('row')
.append($left, $right)
return $header
},
change_list: function (jobs) {
let format = this
let $list = $('<ul />')
.addClass('list-group zuul-patchset-body')
$.each(jobs, function (i, job) {
let $item = $('<li />')
.addClass('list-group-item')
.addClass('zuul-change-job')
.append(format.job(job))
$list.append($item)
})
return $list
},
changePanel: function (change) {
let $header = $('<div />')
.addClass('panel-heading zuul-patchset-header')
.append(this.changeHeader(change))
let panelId = change.id ? change.id.replace(',', '_')
: change.project.replace('/', '_') +
'-' + change.enqueue_time
let $panel = $('<div />')
.attr('id', panelId)
.addClass('panel panel-default zuul-change')
.append($header)
.append(this.change_list(change.jobs))
$header.click(this.toggle_patchset)
return $panel
},
change_status_icon: function (change) {
let iconFile = GreenImage
let iconTitle = 'Succeeding'
if (change.active !== true) {
// Grey icon
iconFile = GreyImage
iconTitle = 'Waiting until closer to head of queue to' +
' start jobs'
} else if (change.live !== true) {
// Grey icon
iconFile = GreyImage
iconTitle = 'Dependent change required for testing'
} else if (change.failing_reasons &&
change.failing_reasons.length > 0) {
let reason = change.failing_reasons.join(', ')
iconTitle = 'Failing because ' + reason
if (reason.match(/merge conflict/)) {
// Black icon
iconFile = BlackImage
} else {
// Red icon
iconFile = RedImage
}
}
let $icon = $('<img />')
.attr('src', zuulService.appBaseHref + iconFile)
.attr('title', iconTitle)
.css('display', 'block')
return $icon
},
change_with_status_tree: function (change, changeQueue) {
let $changeRow = $('<tr />')
for (let i = 0; i < changeQueue._tree_columns; i++) {
let $treeCell = $('<td />')
.css('height', '100%')
.css('padding', '0 0 10px 0')
.css('margin', '0')
.css('width', '16px')
.css('min-width', '16px')
.css('overflow', 'hidden')
.css('vertical-align', 'top')
if (i < change._tree.length && change._tree[i] !== null) {
$treeCell.css('background-image',
'url(' + LineImage + ')')
.css('background-repeat', 'repeat-y')
}
if (i === change._tree_index) {
$treeCell.append(
this.change_status_icon(change))
}
if (change._tree_branches.indexOf(i) !== -1) {
let $image = $('<img />')
.css('vertical-align', 'baseline')
if (change._tree_branches.indexOf(i) ===
change._tree_branches.length - 1) {
// Angle line
$image.attr('src', LineAngleImage)
} else {
// T line
$image.attr('src', LineTImage)
}
$treeCell.append($image)
}
$changeRow.append($treeCell)
}
let changeWidth = 360 - 16 * changeQueue._tree_columns
let $changeColumn = $('<td />')
.css('width', changeWidth + 'px')
.addClass('zuul-change-cell')
.append(this.changePanel(change))
$changeRow.append($changeColumn)
let $changeTable = $('<table />')
.addClass('zuul-change-box')
.css('-moz-box-sizing', 'content-box')
.css('box-sizing', 'content-box')
.append($changeRow)
return $changeTable
},
pipeline_sparkline: function (pipelineName) {
if (options.graphite_url !== '') {
let $sparkline = $('<img />')
.addClass('pull-right')
.attr('src', getSparklineURL(pipelineName))
return $sparkline
}
return false
},
pipeline_header: function (pipeline, count) {
// Format the pipeline name, sparkline and description
let $headerDiv = $('<div />')
.addClass('zuul-pipeline-header')
let $heading = $('<h3 />')
.css('vertical-align', 'middle')
.text(pipeline.name)
.append(
$('<span />')
.addClass('badge pull-right')
.css('vertical-align', 'middle')
.css('margin-top', '0.5em')
.text(count)
)
.append(this.pipeline_sparkline(pipeline.name))
$headerDiv.append($heading)
if (typeof pipeline.description === 'string') {
let descr = $('<small />')
$.each(pipeline.description.split(/\r?\n\r?\n/),
function (index, descrPart) {
descr.append($('<p />').text(descrPart))
})
$headerDiv.append($('<p />').append(descr))
}
return $headerDiv
},
pipeline: function (pipeline, count) {
let format = this
let $html = $('<div />')
.addClass('zuul-pipeline col-md-4')
.append(this.pipeline_header(pipeline, count))
$.each(pipeline.change_queues, function (queueIndex, changeQueue) {
$.each(changeQueue.heads, function (headIndex, changes) {
let $changeQueueHtml = $('<div />')
.addClass('change-queue')
.data('zuul-pipeline', pipeline.name)
$html.append($changeQueueHtml)
if (pipeline.change_queues.length > 1 && headIndex === 0) {
let name = changeQueue.name
let shortName = name
if (shortName.length > 32) {
shortName = shortName.substr(0, 32) + '...'
}
$changeQueueHtml.append($('<p />')
.text('Queue: ')
.append(
$('<abbr />')
.attr('title', name)
.text(shortName)
)
)
}
let $changeBoxes = $.map(changes, function (change) {
return format.change_with_status_tree(change, changeQueue)
})
let visible = $.map($changeBoxes, function (changeBox) {
$changeQueueHtml.append(changeBox)
return format.display_patchset(changeBox)
}).some(function (visible) {
return visible
})
if (!visible) $changeQueueHtml.remove()
})
})
return $html
},
toggle_patchset: function (e) {
// Toggle showing/hiding the patchset when the header is clicked.
if (e.target.nodeName.toLowerCase() === 'a') {
// Ignore clicks from gerrit patch set link
return
}
// Grab the patchset panel
let $panel = $(e.target).parents('.zuul-change')
let $body = $panel.children('.zuul-patchset-body')
$body.toggle(200)
let collapsedIndex = collapsedExceptions.indexOf(
$panel.attr('id'))
if (collapsedIndex === -1) {
// Currently not an exception, add it to list
collapsedExceptions.push($panel.attr('id'))
} else {
// Currently an except, remove from exceptions
collapsedExceptions.splice(collapsedIndex, 1)
}
},
display_patchset: function ($changeBox, animate) {
// Determine if to show or hide the patchset and/or the results
// when loaded
// See if we should hide the body/results
let $panel = $changeBox.find('.zuul-change')
let panelChange = $panel.attr('id')
let $body = $panel.children('.zuul-patchset-body')
let expandByDefault = $('#expand_by_default')
.prop('checked')
let collapsedIndex = collapsedExceptions
.indexOf(panelChange)
if ((expandByDefault && collapsedIndex === -1) ||
(!expandByDefault && collapsedIndex !== -1)) {
// Expand by default, or is an exception
$body.show(animate)
} else {
$body.hide(animate)
}
// Check if we should hide the whole panel
let panelProject = $panel.find('.change_project').text()
.toLowerCase()
let panelPipeline = $changeBox
.parents('.zuul-pipeline')
.find('.zuul-pipeline-header > h3')
.html()
.toLowerCase()
let showPanel = true
if (currentFilter !== '') {
showPanel = false
let filter = currentFilter.trim().split(/[\s,]+/)
$.each(filter, function (index, filterVal) {
if (filterVal !== '') {
filterVal = filterVal.toLowerCase()
if (panelProject.indexOf(filterVal) !== -1 ||
panelPipeline.indexOf(filterVal) !== -1 ||
panelChange.indexOf(filterVal) !== -1) {
showPanel = true
}
}
})
}
if (showPanel === true) {
$changeBox.show(animate)
} else {
$changeBox.hide(animate)
}
return showPanel
}
}
let app = {
schedule: function (app) {
app = app || this
if (!options.enabled) {
app.timer = setTimeout(function () { app.schedule(app) }, 5000)
return
}
app.update().always(function () {
app.timer = setTimeout(function () { app.schedule(app) }, 5000)
})
// Only update graphs every minute
if (zuulGraphUpdateCount > 11) {
zuulGraphUpdateCount = 0
$.zuul.update_sparklines()
}
},
injest: function (data, $msg) {
if ('message' in data) {
$msg.removeClass('alert-danger')
.addClass('alert-info')
.text(data.message)
.show()
} else {
$msg.empty()
.hide()
}
if ('zuul_version' in data) {
$('#zuul-version-span').text(data.zuul_version)
}
if ('last_reconfigured' in data) {
let lastReconfigured =
new Date(data.last_reconfigured)
$('#last-reconfigured-span').text(
lastReconfigured.toString())
}
let $pipelines = $(options.pipelines_id)
$pipelines.html('')
$.each(data.pipelines, function (i, pipeline) {
let count = app.create_tree(pipeline)
$pipelines.append(
format.pipeline(pipeline, count))
})
$(options.queue_events_num).text(
data.trigger_event_queue
? data.trigger_event_queue.length : '0'
)
$(options.queue_results_num).text(
data.result_event_queue
? data.result_event_queue.length : '0'
)
},
/** @return {jQuery.Promise} */
update: function () {
// Cancel the previous update if it hasn't completed yet.
if (xhr) {
xhr.abort()
}
this.emit('update-start')
let app = this
let $msg = $(options.msg_id)
if (options.source_data !== null) {
app.injest(options.source_data, $msg)
return
}
xhr = $.getJSON(options.source)
.done(function (data) {
app.injest(data, $msg)
})
.fail(function (jqXHR, statusText, errMsg) {
if (statusText === 'abort') {
return
}
$msg.text(options.source + ': ' + errMsg)
.addClass('alert-danger')
.removeClass('zuul-msg-wrap-off')
.show()
})
.always(function () {
xhr = undefined
app.emit('update-end')
})
return xhr
},
update_sparklines: function () {
$.each(zuulSparklineURLs, function (name, url) {
let newimg = new Image()
let parts = url.split('#')
newimg.src = parts[0] + '#' + new Date().getTime()
$(newimg).load(function () {
zuulSparklineURLs[name] = newimg.src
})
})
},
emit: function () {
$jq.trigger.apply($jq, arguments)
return this
},
on: function () {
$jq.on.apply($jq, arguments)
return this
},
one: function () {
$jq.one.apply($jq, arguments)
return this
},
controlForm: function () {
// Build the filter form filling anything from cookies
let $controlForm = $('<form />')
.attr('role', 'form')
.addClass('form-inline')
.submit(this.handleFilterChange)
$controlForm
.append(this.filterFormGroup())
.append(this.expandFormGroup())
return $controlForm
},
filterFormGroup: function () {
// Update the filter form with a clear button if required
let $label = $('<label />')
.addClass('control-label')
.attr('for', 'filter_string')
.text('Filters')
.css('padding-right', '0.5em')
let $input = $('<input />')
.attr('type', 'text')
.attr('id', 'filter_string')
.addClass('form-control')
.attr('title',
'project(s), pipeline(s) or review(s) comma ' +
'separated')
.attr('value', currentFilter)
$input.change(this.handleFilterChange)
let $clearIcon = $('<span />')
.addClass('form-control-feedback')
.addClass('glyphicon glyphicon-remove-circle')
.attr('id', 'filter_form_clear_box')
.attr('title', 'clear filter')
.css('cursor', 'pointer')
$clearIcon.click(function () {
$('#filter_string').val('').change()
})
if (currentFilter === '') {
$clearIcon.hide()
}
let $formGroup = $('<div />')
.addClass('form-group has-feedback')
.append($label, $input, $clearIcon)
return $formGroup
},
expandFormGroup: function () {
let expandByDefault = (
readCookie('zuul_expand_by_default', false) === 'true')
let $checkbox = $('<input />')
.attr('type', 'checkbox')
.attr('id', 'expand_by_default')
.prop('checked', expandByDefault)
.change(this.handleExpandByDefault)
let $label = $('<label />')
.css('padding-left', '1em')
.html('Expand by default: ')
.append($checkbox)
let $formGroup = $('<div />')
.addClass('checkbox')
.append($label)
return $formGroup
},
handleFilterChange: function () {
// Update the filter and save it to a cookie
currentFilter = $('#filter_string').val()
setCookie('zuul_filter_string', currentFilter)
if (currentFilter === '') {
$('#filter_form_clear_box').hide()
} else {
$('#filter_form_clear_box').show()
}
this.update()
return false
},
handleExpandByDefault: function (e) {
// Handle toggling expand by default
setCookie('zuul_expand_by_default', e.target.checked)
collapsedExceptions = []
$('.zuul-change-box').each(function (index, obj) {
let $changeBox = $(obj)
format.display_patchset($changeBox, 200)
})
},
create_tree: function (pipeline) {
let count = 0
let pipelineMaxTreeColumns = 1
$.each(pipeline.change_queues,
function (changeQueueIndex, changeQueue) {
let tree = []
let maxTreeColumns = 1
let changes = []
let lastTreeLength = 0
$.each(changeQueue.heads, function (headIndex, head) {
$.each(head, function (changeIndex, change) {
changes[change.id] = change
change._tree_position = changeIndex
})
})
$.each(changeQueue.heads, function (headIndex, head) {
$.each(head, function (changeIndex, change) {
if (change.live === true) {
count += 1
}
let idx = tree.indexOf(change.id)
if (idx > -1) {
change._tree_index = idx
// remove...
tree[idx] = null
while (tree[tree.length - 1] === null) {
tree.pop()
}
} else {
change._tree_index = 0
}
change._tree_branches = []
change._tree = []
if (typeof (change.items_behind) === 'undefined') {
change.items_behind = []
}
change.items_behind.sort(function (a, b) {
return (changes[b]._tree_position - changes[a]._tree_position)
})
$.each(change.items_behind, function (i, id) {
tree.push(id)
if (tree.length > lastTreeLength && lastTreeLength > 0) {
change._tree_branches.push(tree.length - 1)
}
})
if (tree.length > maxTreeColumns) {
maxTreeColumns = tree.length
}
if (tree.length > pipelineMaxTreeColumns) {
pipelineMaxTreeColumns = tree.length
}
change._tree = tree.slice(0) // make a copy
lastTreeLength = tree.length
})
})
changeQueue._tree_columns = maxTreeColumns
})
pipeline._tree_columns = pipelineMaxTreeColumns
return count
}
}
$jq = $(app)
return {
options: options,
format: format,
app: app,
jq: $jq
}
}
}(jQuery))

View File

@ -0,0 +1,32 @@
<!--
Copyright 2017 Red Hat
Licensed under the Apache License, Version 2.0 (the "License"); you may
not use this file except in compliance with the License. You may obtain
a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
License for the specific language governing permissions and limitations
under the License.
-->
<div class="container">
<div class="zuul-container" id="zuul-container">
<div style="display: none;" class="alert" id="zuul_msg"></div>
<button class="btn pull-right zuul-spinner">
updating <span class="glyphicon glyphicon-refresh"></span>
</button>
<p>Queue lengths:
<span id="zuul_queue_events_num">0</span> events,
<span id="zuul_queue_management_events_num">0</span> management events,
<span id="zuul_queue_results_num">0</span> results.
</p>
<div id="zuul_controls"></div>
<div id="zuul_pipelines" class="row"></div>
<p>Zuul version: <span id="zuul-version-span"></span></p>
<p>Last reconfigured: <span id="last-reconfigured-span"></span></p>
</div>
</div>

View File

@ -0,0 +1,57 @@
// Copyright 2018 Red Hat, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License. You may obtain
// a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations
// under the License.
import { Component, OnInit, OnDestroy } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import ZuulService from '../zuul/zuul.service'
import zuulStart from './zuulStart'
interface ZuulStatusOption {
enabled: boolean
}
interface ZuulStatus {
options: ZuulStatusOption
timer: number
}
@Component({
template: require('./status.component.html')
})
export default class StatusComponent implements OnInit, OnDestroy {
tenant: string
app: ZuulStatus
constructor(private route: ActivatedRoute, private zuul: ZuulService) {}
async ngOnInit() {
await this.zuul.setTenant(this.route.snapshot.paramMap.get('tenant'))
if (typeof this.app === 'undefined') {
this.app = zuulStart(
jQuery, this.zuul)
}
this.app.options.enabled = true
}
ngOnDestroy() {
this.app.options.enabled = false
if (typeof this.app.timer !== 'undefined') {
clearTimeout(this.app.timer)
this.app.timer = 0
}
jQuery(document).off()
}
}

102
web/status/zuulStart.js Normal file
View File

@ -0,0 +1,102 @@
/* global URL, DemoStatusBasic, DemoStatusOpenStack, DemoStatusTree, BuiltinConfig */
// Client script for Zuul status page
//
// Copyright 2013 OpenStack Foundation
// Copyright 2013 Timo Tijhof
// Copyright 2013 Wikimedia Foundation
// Copyright 2014 Rackspace Australia
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License. You may obtain
// a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations
// under the License.
import 'jquery-visibility/jquery-visibility'
import 'graphitejs/jquery.graphite.js'
import './jquery.zuul'
/**
* @return The $.zuul instance
*/
function zuulStart ($, zuulService) {
// Start the zuul app (expects default dom)
let $container, $indicator
let url = new URL(window.location)
let params = {
// graphite_url: 'http://graphite.openstack.org/render/'
}
if (typeof BuiltinConfig !== 'undefined') {
params['source'] = BuiltinConfig.api_endpoint + '/' + 'status'
} else if (url.searchParams.has('source_url')) {
params['source'] = url.searchParams.get('source_url') + '/' + 'status'
} else if (url.searchParams.has('demo')) {
let demo = url.searchParams.get('demo') || 'basic'
if (demo === 'basic') {
params['source_data'] = DemoStatusBasic
} else if (demo === 'openstack') {
params['source_data'] = DemoStatusOpenStack
} else if (demo === 'tree') {
params['source_data'] = DemoStatusTree
}
} else {
params['source'] = zuulService.getSourceUrl('status')
}
let zuul = $.zuul(params, zuulService)
zuul.jq.on('update-start', function () {
$container.addClass('zuul-container-loading')
$indicator.addClass('zuul-spinner-on')
})
zuul.jq.on('update-end', function () {
$container.removeClass('zuul-container-loading')
setTimeout(function () {
$indicator.removeClass('zuul-spinner-on')
}, 500)
})
zuul.jq.one('update-end', function () {
// Do this asynchronous so that if the first update adds a
// message, it will not animate while we fade in the content.
// Instead it simply appears with the rest of the content.
setTimeout(function () {
// Fade in the content
$container.addClass('zuul-container-ready')
})
})
$(function ($) {
// DOM ready
$container = $('#zuul-container')
$indicator = $('#zuul-spinner')
$('#zuul_controls').append(zuul.app.controlForm())
zuul.app.schedule()
$(document).on({
'show.visibility': function () {
zuul.options.enabled = true
zuul.app.update()
},
'hide.visibility': function () {
zuul.options.enabled = false
}
})
})
return zuul
}
export default zuulStart

View File

@ -0,0 +1,18 @@
#zuulstreamoverlay {
float: right;
position: fixed;
top: 5px;
right: 5px;
background-color: white;
padding: 2px;
color: black;
}
pre#zuulstreamcontent {
font-family: monospace;
white-space: pre;
margin: 0px 10px;
background-color: black;
color: lightgrey;
border: none;
}

View File

@ -0,0 +1,24 @@
<!--
Copyright 2017 BMW Car IT GmbH
Licensed under the Apache License, Version 2.0 (the "License"); you may
not use this file except in compliance with the License. You may obtain
a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
License for the specific language governing permissions and limitations
under the License.
-->
<div class="container-fluid">
<span id="zuulstreamoverlay">
<form>
<input type="checkbox" id="autoscroll" checked> autoscroll
</form>
</span>
</div>
<pre id="zuulstreamcontent"></pre>

View File

@ -0,0 +1,90 @@
// Copyright 2018 Red Hat, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License. You may obtain
// a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations
// under the License.
declare var BuiltinConfig: object
import { Component, OnInit } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import ZuulService from '../zuul/zuul.service'
function escapeLog (text) {
const pattern = /[<>&"']/g
return text.replace(pattern, function (match) {
return '&#' + match.charCodeAt(0) + ';'
})
}
@Component({
styles: [require('./stream.component.css').toString()],
template: require('./stream.component.html')
})
export default class StreamComponent implements OnInit {
constructor(private route: ActivatedRoute, private zuul: ZuulService) {}
async ngOnInit() {
await this.zuul.setTenant(this.route.snapshot.paramMap.get('tenant'))
this.startStream()
}
startStream () {
const pageUpdateInMS = 250
let receiveBuffer = ''
setInterval(function () {
console.log('autoScroll')
if (receiveBuffer !== '') {
document.getElementById('zuulstreamcontent').innerHTML += receiveBuffer
receiveBuffer = ''
if ((<HTMLInputElement>document.getElementById('autoscroll')).checked) {
window.scrollTo(0, document.body.scrollHeight)
}
}
}, pageUpdateInMS)
const queryParamMap = this.route.snapshot.queryParamMap
const params = {
uuid: queryParamMap.get('uuid')
}
if (queryParamMap.has('logfile')) {
params['logfile'] = queryParamMap.get('logfile')
const logfileSuffix = `(${params['logfile']})`
}
if (typeof BuiltinConfig !== 'undefined') {
params['websocket_url'] = BuiltinConfig['websocket_url']
} else if (queryParamMap.has('websocket_url')) {
params['websocket_url'] = queryParamMap.get('websocket_url')
} else {
params['websocket_url'] = this.zuul.getWebsocketUrl('console-stream')
}
const ws = new WebSocket(params['websocket_url'])
ws.onmessage = function (event) {
console.log('onmessage')
receiveBuffer = receiveBuffer + escapeLog(event.data)
}
ws.onopen = function (event) {
console.log('onopen')
ws.send(JSON.stringify(params))
}
ws.onclose = function (event) {
console.log('onclose')
receiveBuffer = receiveBuffer + '\n--- END OF STREAM ---\n'
}
}
}

65
web/styles/zuul.css Normal file
View File

@ -0,0 +1,65 @@
@import url('~bootstrap/dist/css/bootstrap.css');
.zuul-change {
margin-bottom: 10px;
}
.zuul-change-id {
float: right;
}
.zuul-job-result {
float: right;
width: 70px;
height: 15px;
margin: 2px 0 0 0;
}
.zuul-change-total-result {
height: 10px;
width: 100px;
margin: 0;
display: inline-block;
vertical-align: middle;
}
.zuul-spinner,
.zuul-spinner:hover {
opacity: 0;
transition: opacity 0.5s ease-out;
cursor: default;
pointer-events: none;
}
.zuul-spinner-on,
.zuul-spinner-on:hover {
opacity: 1;
transition-duration: 0.2s;
cursor: progress;
}
.zuul-change-cell {
padding-left: 5px;
}
.zuul-change-job {
padding: 2px 8px;
}
.zuul-job-name {
font-size: small;
}
.zuul-non-voting-desc {
font-size: smaller;
}
.zuul-patchset-header {
font-size: small;
padding: 8px 12px;
cursor: pointer;
}
.form-inline > .form-group {
padding-right: 5px;
}

18
web/tenants/tenant.ts Normal file
View File

@ -0,0 +1,18 @@
// Copyright 2018 Red Hat
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License. You may obtain
// a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations
// under the License.
class Tenant {
name: string
projects: number
}

View File

@ -0,0 +1,40 @@
<!--
Copyright 2017 Red Hat
Licensed under the Apache License, Version 2.0 (the "License"); you may
not use this file except in compliance with the License. You may obtain
a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
License for the specific language governing permissions and limitations
under the License.
-->
<div class="container-fluid">
<!-- TODO(mordred) Make navigation smarter to handle tenants list -->
<table class="table table-hover table-condensed">
<thead>
<tr>
<th>Name</th>
<th>Status</th>
<th>Jobs</th>
<th>Builds</th>
<th>Projects count</th>
<th>Queue size</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let tenant of tenants">
<td>{{ tenant.name }}</td>
<td><a [routerLink]="['/t', tenant.name, 'status.html']">status</a></td>
<td><a [routerLink]="['/t', tenant.name, 'jobs.html']">jobs</a></td>
<td><a [routerLink]="['/t', tenant.name, 'builds.html']">builds</a></td>
<td>{{ tenant.projects }}</td>
<td>{{ tenant.queue }}</td>
</tr>
</tbody>
</table>
</div>

View File

@ -0,0 +1,38 @@
// Copyright 2017 Red Hat
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License. You may obtain
// a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations
// under the License.
import { Component, OnInit } from '@angular/core'
import { HttpClient } from '@angular/common/http'
import ZuulService from '../zuul/zuul.service'
@Component({
template: require('./tenants.component.html')
})
export default class TenantsComponent implements OnInit {
tenants: Tenant[]
constructor(private http: HttpClient, private zuul: ZuulService) {}
async ngOnInit() {
await this.zuul.setTenant()
this.tenantsFetch()
}
tenantsFetch(): void {
this.http.get<Tenant[]>(this.zuul.getSourceUrl('tenants'))
.subscribe(tenants => { this.tenants = tenants })
}
}

18
web/zuul/description.ts Normal file
View File

@ -0,0 +1,18 @@
// Copyright 2018 Red Hat
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License. You may obtain
// a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations
// under the License.
export default class RouteDescription {
title: string
url: string[]
}

18
web/zuul/info.ts Normal file
View File

@ -0,0 +1,18 @@
// Copyright 2018 Red Hat
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License. You may obtain
// a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations
// under the License.
export default class Info {
tenant: string
whiteLabel: boolean
}

19
web/zuul/infoResponse.ts Normal file
View File

@ -0,0 +1,19 @@
// Copyright 2018 Red Hat
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License. You may obtain
// a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations
// under the License.
import Info from './info'
export default class InfoResponse {
info: Info
}

152
web/zuul/zuul.service.ts Normal file
View File

@ -0,0 +1,152 @@
// Copyright 2017 Red Hat
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License. You may obtain
// a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations
// under the License.
import { Injectable, EventEmitter, Output } from '@angular/core'
import { HttpClient } from '@angular/common/http'
import { Router } from '@angular/router'
import { BehaviorSubject } from 'rxjs/BehaviorSubject'
import * as url from 'url'
import Info from './info'
import InfoResponse from './infoResponse'
import RouteDescription from './description'
declare var ZUUL_API_URL: string
declare var ZUUL_BASE_HREF: string
function getAppBaseHrefFromPath () {
const path = window.location.pathname
if (path.includes('/t/')) {
return path.slice(0, path.lastIndexOf('/t/') + 1)
} else {
return path.split('/').slice(0, -1).join('/') || '/'
}
}
export function getAppBaseHref (): string {
/*
* Return a value suitable for use in
* https://angular.io/api/common/APP_BASE_HREF
*/
let path
if (typeof ZUUL_BASE_HREF !== 'undefined') {
path = ZUUL_BASE_HREF
} else {
// Use window.location.pathname because we're looking for a path
// prefix, not a URL.
path = getAppBaseHrefFromPath()
}
if (! path.endsWith('/')) {
path = path + '/'
}
return path
}
@Injectable({
providedIn: 'root',
})
export class ZuulService {
public baseApiUrl: string
public appBaseHref: string
public info: Info
navbarRoutes: RouteDescription[]
private routePages = ['status', 'jobs', 'builds']
constructor(private router: Router, private http: HttpClient) {
this.baseApiUrl = this.getBaseApiUrl()
this.appBaseHref = getAppBaseHref()
}
async setTenant (tenant?: string) {
if (!this.info) {
const infoEndpoint = this.baseApiUrl + 'api/info'
const infoResponse = await this.http.get<InfoResponse>(
infoEndpoint).toPromise()
this.info = infoResponse.info
if (this.info.tenant && !tenant) {
this.info.whiteLabel = true
} else {
this.info.whiteLabel = false
}
}
if (tenant) {
this.info.tenant = tenant
}
this.navbarRoutes = this.getNavbarRoutes()
}
getBaseApiUrl (): string {
let path
if (typeof ZUUL_API_URL !== 'undefined') {
path = ZUUL_API_URL
} else {
path = url.resolve(window.location.href, getAppBaseHrefFromPath())
}
if (! path.endsWith('/')) {
path = path + '/'
}
return path
}
getSourceUrl (filename: string): string {
const tenant = this.info.tenant
if (this.info.whiteLabel || filename === 'tenants') {
if (!this.info.whiteLabel) {
// Reset selected tenant
this.info.tenant = ''
}
return url.resolve(this.baseApiUrl, `api/${filename}`)
}
if (!tenant) {
// No tenant selected, go to tenant list
console.log('No tenant selected, navigate to tenants list')
this.router.navigate(['/tenants.html'])
}
return url.resolve(this.baseApiUrl, `api/tenant/${tenant}/${filename}`)
}
getWebsocketUrl (filename: string): string {
return this.getSourceUrl(filename)
.replace(/(http)(s)?\:\/\//, 'ws$2://')
}
getNavbarRoutes(): RouteDescription[] {
const routes = []
for (const routePage of this.routePages) {
const description: RouteDescription = {
title: this.getRouteTitle(routePage),
url: this.getRouterLink(routePage)
}
routes.push(description)
}
return routes
}
getRouteTitle(target: string): string {
return target.charAt(0).toUpperCase() + target.slice(1)
}
getRouterLink(target: string): string[] {
const htmlTarget = target + '.html'
if (this.info.whiteLabel) {
return ['/' + htmlTarget]
} else {
return ['/t', this.info.tenant, htmlTarget]
}
}
}
export default ZuulService

3
webpack.config.js Normal file
View File

@ -0,0 +1,3 @@
module.exports = function(env) {
return require(`./web/config/webpack.${env}.js`)
}

File diff suppressed because it is too large Load Diff

View File

@ -24,14 +24,10 @@ _old_from_git = pbr.packaging._from_git
def _build_javascript():
if subprocess.call(['which', 'yarn']) != 0:
return
if not os.path.exists('web/node_modules/.bin/webpack'):
r = subprocess.Popen(['yarn', 'install', '-d'], cwd="web/").wait()
if r:
raise RuntimeError("Yarn install failed")
if not os.path.exists('web/build/index.html'):
r = subprocess.Popen(['yarn', 'build'], cwd="web/").wait()
if r:
raise RuntimeError("Yarn build failed")
if not os.path.exists('node_modules/.bin/webpack'):
subprocess.check_call(['yarn', 'install', '-d'])
if not os.path.exists('zuul/web/static/status.bundle.js'):
subprocess.check_call(['npm', 'run', 'build:dist'])
def _from_git(distribution):

View File

@ -448,24 +448,22 @@ class ZuulWebAPI(object):
cherrypy.request.ws_handler.zuulweb = self.zuulweb
class StaticHandler(object):
def __init__(self, root):
self.root = root
class TenantStaticHandler(object):
def __init__(self, path):
self._cp_config = {
'tools.staticdir.on': True,
'tools.staticdir.dir': path,
'tools.staticdir.index': 'status.html',
}
def default(self, path):
# Try to handle static file first
handled = cherrypy.lib.static.staticdir(
section="",
dir=self.root,
index='index.html')
if not path or not handled:
# When not found, serve the index.html
return cherrypy.lib.static.serve_file(
path=os.path.join(self.root, "index.html"),
content_type="text/html")
else:
return cherrypy.lib.static.serve_file(
path=os.path.join(self.root, path))
class RootStaticHandler(object):
def __init__(self, path):
self._cp_config = {
'tools.staticdir.on': True,
'tools.staticdir.dir': path,
'tools.staticdir.index': 'tenants.html',
}
class StreamManager(object):
@ -558,6 +556,8 @@ class ZuulWeb(object):
route_map = cherrypy.dispatch.RoutesDispatcher()
api = ZuulWebAPI(self)
tenant_static = TenantStaticHandler(self.static_path)
root_static = RootStaticHandler(self.static_path)
route_map.connect('api', '/api/info',
controller=api, action='info')
route_map.connect('api', '/api/tenants',
@ -600,10 +600,10 @@ class ZuulWeb(object):
'/api/connection/%s' % connection.connection_name)
# Add fallthrough routes at the end for the static html/js files
route_map.connect(
'root_static', '/{path:.*}',
controller=StaticHandler(self.static_path),
action='default')
route_map.connect('root_static', '/{path:.*}',
controller=root_static, action='default')
route_map.connect('tenant_static', '/t/{tenant}/{path:.*}',
controller=tenant_static, action='default')
conf = {
'/': {

View File