Pull in frontend

This commit is contained in:
Laszlo Fogas 2019-11-12 14:27:39 +01:00
parent e69106809b
commit 61a14df51b
153 changed files with 25134 additions and 4 deletions

View file

@ -24,6 +24,16 @@ pipeline:
commands:
- go test -timeout 30s github.com/laszlocph/woodpecker/store/datastore
test_frontend:
image: node:10.17.0-stretch
commands:
- make test-frontend
build_frontend:
image: node:10.17.0-stretch
commands:
- make build-frontend
build:
image: golang:1.12.4
commands: sh .drone.sh

5
.gitignore vendored
View file

@ -10,3 +10,8 @@ cli/release/
server/swagger/files/*.json
server/swagger/swagger_gen.go
.idea/
web/node_modules
web/dist/files
web/*.log
web/.env

View file

@ -26,6 +26,9 @@ test-agent:
test-server:
$(DOCKER_RUN) go test -race -timeout 30s github.com/laszlocph/woodpecker/cmd/drone-server
test-frontend:
(cd web/; yarn run test)
test-lib:
$(DOCKER_RUN) go test -race -timeout 30s $(shell go list ./... | grep -v '/cmd/')
@ -37,6 +40,10 @@ build-agent:
build-server:
$(DOCKER_RUN) go build -o build/drone-server github.com/laszlocph/woodpecker/cmd/drone-server
build-frontend:
(cd web/; yarn run build)
build: build-agent build-server
install:

1
go.mod
View file

@ -35,7 +35,6 @@ require (
github.com/joho/godotenv v0.0.0-20150907010228-4ed13390c0ac
github.com/kr/pretty v0.0.0-20160708215748-737b74a46c4b
github.com/kr/text v0.0.0-20160504234017-7cafcd837844 // indirect
github.com/laszlocph/woodpecker-ui v0.0.0-20190724123035-653e49c05045
github.com/lib/pq v0.0.0-20151015211310-83c4f410d0ae
github.com/manucorporat/sse v0.0.0-20160126180136-ee05b128a739 // indirect
github.com/mattn/go-sqlite3 v0.0.0-20170901084005-05548ff55570

2
go.sum
View file

@ -79,8 +79,6 @@ github.com/kr/pretty v0.0.0-20160708215748-737b74a46c4b h1:LJ9zj3Zit+pLjAQtA1gxl
github.com/kr/pretty v0.0.0-20160708215748-737b74a46c4b/go.mod h1:Bvhd+E3laJ0AVkG0c9rmtZcnhV0HQ3+c3YxxqTvc/gA=
github.com/kr/text v0.0.0-20160504234017-7cafcd837844 h1:kpzneEBeC0dMewP3gr/fADv1OlblH9r1goWVwpOt3TU=
github.com/kr/text v0.0.0-20160504234017-7cafcd837844/go.mod h1:sjUstKUATFIcff4qlB53Kml0wQPtJVc/3fWrmuUmcfA=
github.com/laszlocph/woodpecker-ui v0.0.0-20190724123035-653e49c05045 h1:H2vySVhUS29MOkY1tdn9NOPsffndAf2/5kod0gwX1m4=
github.com/laszlocph/woodpecker-ui v0.0.0-20190724123035-653e49c05045/go.mod h1:+Ly1/ou5jW3YIvsEiJWNJTIe3Zt8dLPd0cgrmbjARoE=
github.com/lib/pq v0.0.0-20151015211310-83c4f410d0ae h1:rBqRT7VqVLePKGtyV6xDFLXeqD56CvZKEqI0XWzVTxM=
github.com/lib/pq v0.0.0-20151015211310-83c4f410d0ae/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/manucorporat/sse v0.0.0-20160126180136-ee05b128a739 h1:ykXz+pRRTibcSjG1yRhpdSHInF8yZY/mfn+Rz2Nd1rE=

View file

@ -24,7 +24,7 @@ import (
"path/filepath"
"time"
"github.com/laszlocph/woodpecker-ui/dist"
"github.com/laszlocph/woodpecker/web/dist"
"github.com/laszlocph/woodpecker/model"
"github.com/laszlocph/woodpecker/shared/token"
"github.com/laszlocph/woodpecker/version"

16
web/.babelrc Normal file
View file

@ -0,0 +1,16 @@
{
"sourceMaps": false,
"presets": [
["es2015", { "loose":true }],
"stage-0",
"react"
],
"plugins": [
["transform-decorators-legacy"],
["transform-object-rest-spread"],
["transform-react-jsx"],
["transform-es3-property-literals"],
["transform-es3-member-expression-literals"],
["transform-decorators-legacy"]
]
}

9
web/.drone.yml Normal file
View file

@ -0,0 +1,9 @@
pipeline:
build:
image: node:8
commands:
- yarn install
- yarn run lesshint
- yarn run test
- yarn run lint --quiet
- yarn run build

38
web/.eslintrc.js Normal file
View file

@ -0,0 +1,38 @@
module.exports = {
"extends": [
"standard",
"plugin:jest/recommended",
"plugin:react/recommended",
"prettier",
"prettier/react"
],
"plugins": [
"react",
"jest",
"prettier"
],
"parser": "babel-eslint",
"parserOptions": {
"ecmaVersion": 2016,
"sourceType": "module",
"ecmaFeatures": {
"jsx": true
}
},
"env": {
"es6": true,
"browser": true,
"node": true,
"jest/globals": true
},
"rules": {
"react/prop-types": 1,
"prettier/prettier": [
"error",
{
"trailingComma": "all",
"useTabs": true
}
]
}
};

17
web/.lesshintrc Normal file
View file

@ -0,0 +1,17 @@
{
"fileExtensions": [".less", ".css"],
"excludedFiles": ["ansi.less"],
"spaceAfterPropertyColon": {
"enabled": true,
"style": "one_space"
},
"emptyRule": true,
"qualifyingElement": false,
"trailingWhitespace": true,
"zeroUnit": {
"exclude": ["flex"]
}
}

20
web/LICENSE Normal file
View file

@ -0,0 +1,20 @@
Copyright 2017 Drone.IO Inc
Copyright 2019 Laszlo Fogas
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.
---
Woodpecker icon by Georgiana Ionescu from the Noun Project
Licensed as Creative Commons CCBY
https://thenounproject.com/term/woodpecker/1761314/

57
web/README.md Normal file
View file

@ -0,0 +1,57 @@
This project contains the source code for the drone user interface. The generated javascript and css assets are embedded into a Go source file which is imported into the main drone application, using go get.
## Building
To compile the source and create minified css and javascript assets:
```text
yarn install # install project dependencies
yarn run format # formats the codebase
yarn run lint # lints the codebase
yarn run test # tests the codebase
yarn run build # builds the production bundle
```
## Running
To run a devserver with watching, hotreloading and proxy to drone server:
```text
export DRONE_SERVER=<drone server>
export DRONE_TOKEN=<drone api token>
yarn run start
```
For example:
```text
export DRONE_SERVER=http://your.drone.server
export DRONE_TOKEN=eyJhbGciOiJIUzI1NiIsIn...
yarn run start
```
Note you will need to retrieve your drone user token from the tokens screen in the drone user interface. When the server is running you can open the following url in your browser:
```text
http://localhost:9999
```
## Releases
To bundle and embed the code in a Go source file install the following command line utility:
```text
go get github.com/bradrydzewski/togo
```
To generate the Go source file run the following command:
```text
go generate ./...
go install ./...
```
__Note__ that for security reasons we will not accept a pull request that updates embedded Go asset file since we are not able to easily review the embedded, minified code. This file is instead automatically generated by our build server to prevent tampering.

3
web/dist/dist.go vendored Normal file
View file

@ -0,0 +1,3 @@
package dist
//go:generate togo http -package dist -output dist_gen.go

11042
web/dist/dist_gen.go vendored Normal file

File diff suppressed because one or more lines are too long

13
web/package-lock.json generated Normal file
View file

@ -0,0 +1,13 @@
{
"name": "drone-ui-react",
"version": "1.0.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
"yarn": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/yarn/-/yarn-1.6.0.tgz",
"integrity": "sha1-nOxveYbcI3057HBc502VFV/lXUs="
}
}
}

104
web/package.json Normal file
View file

@ -0,0 +1,104 @@
{
"name": "drone-ui-react",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"prebuild": "rm -rf dist/files",
"build": "cross-env NODE_ENV=production webpack",
"lint": "eslint src/",
"lesshint": "lesshint --config .lesshintrc src/",
"test": "jest",
"start": "webpack-dev-server --progress --hot --inline",
"format": "prettier --use-tabs --trailing-comma all --write {src/*.js,src/**/*.js,src/**/*/*.js,src/*/*/*/*.js,src/*/*/*/*/*.js,src/*/*/*/*/*/*.js,src/*/*/*/*/*/*.js,src/*/*/*/*/*/*/*.js}"
},
"jest": {
"moduleFileExtensions": [
"js",
"jsx"
],
"moduleDirectories": [
"src",
"node_modules"
],
"moduleNameMapper": {
"\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "<rootDir>/__mocks__/fileMock.js",
"\\.(css|less)$": "identity-obj-proxy",
"^react$": "preact-compat-enzyme",
"^react-dom/server$": "preact-render-to-string",
"^react-dom$": "preact-compat-enzyme",
"^react-addons-test-utils$": "preact-test-utils"
},
"collectCoverageFrom": [
"src/**/*.{js,jsx}"
]
},
"author": "Brad Rydzewski",
"license": "Apache-2.0",
"dependencies": {
"ansi_up": "^2.0.2",
"babel-polyfill": "^6.23.0",
"baobab": "^2.4.3",
"baobab-react": "^2.1.2",
"classnames": "^2.2.5",
"drone-js": "file:./vendor/drone-js/",
"humanize-duration": "^3.10.1",
"preact": "^8.2.1",
"preact-compat": "^3.16.0",
"query-string": "^5.0.0",
"react-collapsible": "^2.6.0",
"react-router": "^4.1.2",
"react-router-dom": "^4.1.2",
"react-screen-size": "^1.0.1",
"react-timeago": "^3.4.3",
"react-title-component": "^1.0.1",
"react-transition-group": "^1.2.0",
"yarn": "^1.6.0"
},
"devDependencies": {
"babel-core": "^6.25.0",
"babel-eslint": "^7.2.3",
"babel-jest": "^21.0.0",
"babel-loader": "^7.1.1",
"babel-plugin-transform-decorators-legacy": "^1.3.4",
"babel-plugin-transform-es3-member-expression-literals": "^6.22.0",
"babel-plugin-transform-es3-property-literals": "^6.22.0",
"babel-plugin-transform-react-jsx": "^6.24.1",
"babel-preset-env": "^1.6.0",
"babel-preset-es2015": "^6.24.1",
"babel-preset-react": "^6.24.1",
"babel-preset-stage-0": "^6.24.1",
"cross-env": "^5.0.3",
"css-loader": "^0.28.4",
"dotenv": "^4.0.0",
"enzyme": "^2.9.1",
"eslint": "^4.6.1",
"eslint-config-prettier": "^2.4.0",
"eslint-config-standard": "^10.2.1",
"eslint-plugin-import": "^2.7.0",
"eslint-plugin-jest": "^21.0.2",
"eslint-plugin-node": "^5.1.1",
"eslint-plugin-prettier": "^2.2.0",
"eslint-plugin-promise": "^3.5.0",
"eslint-plugin-react": "^7.3.0",
"eslint-plugin-standard": "^3.0.1",
"file-loader": "^0.11.2",
"html-webpack-plugin": "^2.30.1",
"identity-obj-proxy": "^3.0.0",
"jasmine-expect": "^3.7.1",
"jest": "^21.0.1",
"jsdoc": "^3.5.4",
"less": "^2.7.2",
"less-loader": "^4.0.5",
"lesshint": "^4.1.3",
"preact-compat-enzyme": "^0.2.5",
"preact-render-to-string": "^3.6.3",
"preact-test-utils": "^0.1.3",
"prettier": "^1.6.0",
"sinon": "^3.2.1",
"sinon-chai": "^2.13.0",
"style-loader": "^0.18.2",
"url-loader": "^0.5.9",
"webpack": "^3.4.1",
"webpack-dev-server": "^2.6.1"
}
}

View file

@ -0,0 +1,3 @@
import DroneClient from "drone-js";
export default DroneClient.fromWindow();

View file

@ -0,0 +1,36 @@
import React from "react";
export const drone = (client, Component) => {
// @see https://github.com/yannickcr/eslint-plugin-react/issues/512
// eslint-disable-next-line react/display-name
const component = class extends React.Component {
getChildContext() {
return {
drone: client,
};
}
render() {
return <Component {...this.state} {...this.props} />;
}
};
component.childContextTypes = {
drone: (props, propName) => {},
};
return component;
};
export const inject = Component => {
// @see https://github.com/yannickcr/eslint-plugin-react/issues/512
// eslint-disable-next-line react/display-name
const component = class extends React.Component {
render() {
this.props.drone = this.context.drone;
return <Component {...this.state} {...this.props} />;
}
};
return component;
};

78
web/src/config/state.js Normal file
View file

@ -0,0 +1,78 @@
import Baobab from "baobab";
const user = window.DRONE_USER;
const sync = window.DRONE_SYNC;
const state = {
follow: false,
language: "en-US",
user: {
data: user,
error: undefined,
loaded: true,
syncing: sync,
},
feed: {
loaded: false,
error: undefined,
data: {},
},
repos: {
loaded: false,
error: undefined,
data: {},
},
secrets: {
loaded: false,
error: undefined,
data: {},
},
registry: {
error: undefined,
loaded: false,
data: {},
},
builds: {
loaded: false,
error: undefined,
data: {},
},
logs: {
follow: false,
loading: true,
error: false,
data: {},
},
token: {
value: undefined,
error: undefined,
loading: false,
},
message: {
show: false,
text: undefined,
error: false,
},
location: {
protocol: window.location.protocol,
host: window.location.host,
},
};
const tree = new Baobab(state);
if (window) {
window.tree = tree;
}
export default tree;

12
web/src/index.html Normal file
View file

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<!-- drone:version -->
<!-- drone:user -->
<!-- drone:csrf -->
<!-- drone:docs -->
</head>
<body>
</body>
</html>

14
web/src/index.js Normal file
View file

@ -0,0 +1,14 @@
import "babel-polyfill";
import React from "react";
import { render } from "react-dom";
let root;
function init() {
let App = require("./screens/drone").default;
root = render(<App />, document.body, root);
}
init();
if (module.hot) module.hot.accept("./screens/drone", init);

BIN
web/src/public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

52
web/src/screens/drone.js Normal file
View file

@ -0,0 +1,52 @@
import React, { Component } from "react";
import { root } from "baobab-react/higher-order";
import tree from "config/state";
import client from "config/client";
import { drone } from "config/client/inject";
import { LoginForm, LoginError } from "screens/login/screens";
import Title from "./titles";
import Layout from "./layout";
import RedirectRoot from "./redirect";
import { fetchFeedOnce, subscribeToFeedOnce } from "shared/utils/feed";
import { BrowserRouter, Route, Switch } from "react-router-dom";
// eslint-disable-next-line no-unused-vars
import styles from "./drone.less";
if (module.hot) {
require("preact/devtools");
}
class App extends Component {
render() {
return (
<BrowserRouter>
<div>
<Title />
<Switch>
<Route path="/" exact={true} component={RedirectRoot} />
<Route path="/login/form" exact={true} component={LoginForm} />
<Route path="/login/error" exact={true} component={LoginError} />
<Route path="/" exact={false} component={Layout} />
</Switch>
</div>
</BrowserRouter>
);
}
}
if (tree.exists(["user", "data"])) {
fetchFeedOnce(tree, client);
subscribeToFeedOnce(tree, client);
}
client.onerror = error => {
console.error(error);
if (error.status === 401) {
tree.unset(["user", "data"]);
}
};
export default root(tree, drone(client, App));

View file

@ -0,0 +1,15 @@
:global {
@import url('https://fonts.googleapis.com/css?family=Roboto+Mono|Roboto:300,400,500');
div,
span {
font-family: 'Roboto';
font-size: 16px;
}
html,
body {
margin: 0px;
padding: 0px;
}
}

View file

@ -0,0 +1,3 @@
import { List, Item } from "./list";
export { List, Item };

View file

@ -0,0 +1,55 @@
import React, { Component } from "react";
import Status from "shared/components/status";
import BuildTime from "shared/components/build_time";
import styles from "./list.less";
import { StarIcon } from "shared/components/icons/index";
export const List = ({ children }) => (
<div className={styles.list}>{children}</div>
);
export class Item extends Component {
constructor(props) {
super(props);
this.handleFave = this.handleFave.bind(this);
}
handleFave(e) {
e.preventDefault();
this.props.onFave(this.props.item.full_name);
}
render() {
const { item, faved } = this.props;
return (
<div className={styles.item}>
<div onClick={this.handleFave}>
<StarIcon filled={faved} size={16} className={styles.star} />
</div>
<div className={styles.header}>
<div className={styles.title}>{item.full_name}</div>
<div className={styles.icon}>
{item.status ? <Status status={item.status} /> : <noscript />}
</div>
</div>
<div className={styles.body}>
<BuildTime
start={item.started_at || item.created_at}
finish={item.finished_at}
/>
</div>
</div>
);
}
shouldComponentUpdate(nextProps, nextState) {
return (
this.props.item !== nextProps.item || this.props.faved !== nextProps.faved
);
}
}

View file

@ -0,0 +1,67 @@
@import '~shared/styles/colors';
@import '~shared/styles/utils';
.list {
a {
border-top: 1px solid @gray-light;
color: @gray-dark;
display: block;
text-decoration: none;
&:first-of-type {
border-top-width: 0px;
}
}
}
.item {
display: flex;
flex-direction: column;
padding: 20px;
text-decoration: none;
position: relative;
.header {
display: flex;
margin-bottom: 10px;
}
.title {
color: @gray-dark;
flex: 1 1 auto;
font-size: 15px;
line-height: 22px;
max-width: 250px;
padding-right: 20px;
.text-ellipsis
}
.body div time {
color: @gray-dark;
font-size: 13px;
}
.body time {
color: @gray-dark;
display: inline-block;
font-size: 13px;
line-height: 22px;
margin: 0px;
padding: 0px;
vertical-align: middle;
}
.body svg {
fill: @gray-dark;
line-height: 22px;
margin-right: 10px;
vertical-align: middle;
}
.star {
position: absolute;
bottom: 20px;
right: 20px;
fill: @gray;
}
}

View file

@ -0,0 +1,196 @@
import React, { Component } from "react";
import { Link } from "react-router-dom";
import { compareFeedItem } from "shared/utils/feed";
import { branch } from "baobab-react/higher-order";
import { inject } from "config/client/inject";
import DroneIcon from "shared/components/logo";
import { List, Item } from "./components";
import style from "./index.less";
import Collapsible from "react-collapsible";
const binding = (props, context) => {
return { feed: ["feed"] };
};
@inject
@branch(binding)
export default class Sidebar extends Component {
constructor(props, context) {
super(props, context);
this.setState({
starred: JSON.parse(localStorage.getItem("starred") || "[]"),
starredOpen: (localStorage.getItem("starredOpen") || "true") === "true",
reposOpen: (localStorage.getItem("reposOpen") || "true") === "true",
});
this.handleFilter = this.handleFilter.bind(this);
this.toggleStarred = this.toggleItem.bind(this, "starredOpen");
this.toggleAll = this.toggleItem.bind(this, "reposOpen");
}
shouldComponentUpdate(nextProps, nextState) {
return (
this.props.feed !== nextProps.feed ||
this.state.filter !== nextState.filter ||
this.state.starred.length !== nextState.starred.length
);
}
handleFilter(e) {
this.setState({
filter: e.target.value,
});
}
toggleItem = item => {
this.setState(state => {
return { [item]: !state[item] };
});
localStorage.setItem(item, this.state[item]);
};
renderFeed = (list, renderStarred) => {
return (
<div>
<List>{list.map(item => this.renderItem(item, renderStarred))}</List>
</div>
);
};
renderItem = (item, renderStarred) => {
const starred = this.state.starred;
if (renderStarred && !starred.includes(item.full_name)) {
return null;
}
return (
<Link to={`/${item.full_name}`} key={item.full_name}>
<Item
item={item}
onFave={this.onFave}
faved={starred.includes(item.full_name)}
/>
</Link>
);
};
onFave = fullName => {
if (!this.state.starred.includes(fullName)) {
this.setState(state => {
const list = state.starred.concat(fullName);
return { starred: list };
});
} else {
this.setState(state => {
const list = state.starred.filter(v => v !== fullName);
return { starred: list };
});
}
localStorage.setItem("starred", JSON.stringify(this.state.starred));
};
render() {
const { feed } = this.props;
const { filter } = this.state;
const list = feed.data ? Object.values(feed.data) : [];
const filterFunc = item => {
return !filter || item.full_name.indexOf(filter) !== -1;
};
const filtered = list.filter(filterFunc).sort(compareFeedItem);
const starredOpen = this.state.starredOpen;
const reposOpen = this.state.reposOpen;
return (
<div className={style.feed}>
{LOGO}
<Collapsible
trigger="Starred"
triggerTagName="div"
transitionTime={200}
open={starredOpen}
onOpen={this.toggleStarred}
onClose={this.toggleStarred}
triggerOpenedClassName={style.Collapsible__trigger}
triggerClassName={style.Collapsible__trigger}
>
{feed.loaded === false ? (
LOADING
) : feed.error ? (
ERROR
) : list.length === 0 ? (
EMPTY
) : (
this.renderFeed(list, true)
)}
</Collapsible>
<Collapsible
trigger="Repos"
triggerTagName="div"
transitionTime={200}
open={reposOpen}
onOpen={this.toggleAll}
onClose={this.toggleAll}
triggerOpenedClassName={style.Collapsible__trigger}
triggerClassName={style.Collapsible__trigger}
>
<input
type="text"
placeholder="Search …"
onChange={this.handleFilter}
/>
{feed.loaded === false ? (
LOADING
) : feed.error ? (
ERROR
) : list.length === 0 ? (
EMPTY
) : filtered.length > 0 ? (
this.renderFeed(filtered.sort(compareFeedItem), false)
) : (
NO_MATCHES
)}
</Collapsible>
</div>
);
}
}
const LOGO = (
<div className={style.brand}>
<DroneIcon />
<p>
Woodpecker<br />
<span>
yes,&nbsp;
<a
href="https://github.com/laszlocph/drone-oss-08/"
target="_blank"
rel="noopener noreferrer"
>
it&#39;s a fork
</a>
</span>
</p>
</div>
);
const LOADING = <div className={style.message}>Loading</div>;
const EMPTY = <div className={style.message}>Your build feed is empty</div>;
const NO_MATCHES = <div className={style.message}>No results found</div>;
const ERROR = (
<div className={style.message}>
Oops. It looks like there was a problem loading your feed
</div>
);

View file

@ -0,0 +1,70 @@
@import '~shared/styles/colors';
.feed {
width: 300px;
input {
border: 1px solid @gray-light;
font-size: 15px;
height: 24px;
line-height: 24px;
outline: none;
margin: 20px;
padding: 5px;
width: 250px;
border-radius: 2px;
}
::-moz-input-placeholder {
color: @gray;
font-size: 15px;
font-weight: 300;
}
::-webkit-input-placeholder {
color: @gray;
font-size: 15px;
font-weight: 300;
}
}
.message {
color: @gray;
font-size: 15px;
margin-top: 50px;
padding: 20px;
text-align: center;
}
.brand {
align-items: center;
border-bottom: 1px solid @gray-light;
box-sizing: border-box;
display: flex;
height: 60px;
padding: 0px 10px;
svg {
fill: @gray-dark;
height: 50px;
position: relative;
top: 5px;
}
p {
font-size: 18px;
}
span {
font-size: 13px;
color: @gray-dark
}
}
.Collapsible__trigger {
background-color: @gray-light;
border-radius: 2px;
display: flex;
padding: 10px 20px;
text-shadow: 0px 1px 2px rgba(0, 0, 0, 0.1);
}

227
web/src/screens/layout.js Normal file
View file

@ -0,0 +1,227 @@
import React, { Component } from "react";
import classnames from "classnames";
import { Route, Switch, Link } from "react-router-dom";
import { connectScreenSize } from "react-screen-size";
import { branch } from "baobab-react/higher-order";
import { inject } from "config/client/inject";
import MenuIcon from "shared/components/icons/menu";
import Feed from "screens/feed";
import RepoRegistry from "screens/repo/screens/registry";
import RepoSecrets from "screens/repo/screens/secrets";
import RepoSettings from "screens/repo/screens/settings";
import RepoBuilds from "screens/repo/screens/builds";
import UserRepos, { UserRepoTitle } from "screens/user/screens/repos";
import UserTokens from "screens/user/screens/tokens";
import RedirectRoot from "./redirect";
import RepoHeader from "screens/repo/screens/builds/header";
import UserReposMenu from "screens/user/screens/repos/menu";
import BuildLogs, { BuildLogsTitle } from "screens/repo/screens/build";
import BuildMenu from "screens/repo/screens/build/menu";
import RepoMenu from "screens/repo/screens/builds/menu";
import { Snackbar } from "shared/components/snackbar";
import { Drawer, DOCK_RIGHT } from "shared/components/drawer/drawer";
import styles from "./layout.less";
const binding = (props, context) => {
return {
user: ["user"],
message: ["message"],
sidebar: ["sidebar"],
menu: ["menu"],
};
};
const mapScreenSizeToProps = screenSize => {
return {
isTablet: screenSize["small"],
isMobile: screenSize["mobile"],
isDesktop: screenSize["> small"],
};
};
@inject
@branch(binding)
@connectScreenSize(mapScreenSizeToProps)
export default class Default extends Component {
constructor(props, context) {
super(props, context);
this.state = {
menu: false,
feed: false,
};
this.openMenu = this.openMenu.bind(this);
this.closeMenu = this.closeMenu.bind(this);
this.closeSnackbar = this.closeSnackbar.bind(this);
}
componentWillReceiveProps(nextProps) {
if (nextProps.location !== this.props.location) {
this.closeMenu(true);
}
}
openMenu() {
this.props.dispatch(tree => {
tree.set(["menu"], true);
});
}
closeMenu() {
this.props.dispatch(tree => {
tree.set(["menu"], false);
});
}
render() {
const { user, message, menu } = this.props;
const classes = classnames(!user || !user.data ? styles.guest : null);
return (
<div className={classes}>
<div className={styles.left}>
<Switch>
<Route path={"/"} component={Feed} />
</Switch>
</div>
<div className={styles.center}>
{!user || !user.data ? (
<a
href={"/login?url=" + window.location.href}
target="_self"
className={styles.login}
>
Click to Login
</a>
) : (
<noscript />
)}
<div className={styles.title}>
<Switch>
<Route path="/account/repos" component={UserRepoTitle} />
<Route
path="/:owner/:repo/:build(\d*)/:proc(\d*)"
exact={true}
component={BuildLogsTitle}
/>
<Route
path="/:owner/:repo/:build(\d*)"
component={BuildLogsTitle}
/>
<Route path="/:owner/:repo" component={RepoHeader} />
</Switch>
{user && user.data ? (
<div className={styles.avatar}>
<img src={user.data.avatar_url} />
</div>
) : (
undefined
)}
{user && user.data ? (
<button onClick={this.openMenu}>
<MenuIcon />
</button>
) : (
<noscript />
)}
</div>
<div className={styles.menu}>
<Switch>
<Route
path="/account/repos"
exact={true}
component={UserReposMenu}
/>
<Route
path="/account/"
exact={false}
component={undefined}
/>BuildMenu
<Route
path="/:owner/:repo/:build(\d*)/:proc(\d*)"
exact={true}
component={BuildMenu}
/>
<Route
path="/:owner/:repo/:build(\d*)"
exact={true}
component={BuildMenu}
/>
<Route path="/:owner/:repo" exact={false} component={RepoMenu} />
</Switch>
</div>
<Switch>
<Route path="/account/token" exact={true} component={UserTokens} />
<Route path="/account/repos" exact={true} component={UserRepos} />
<Route
path="/:owner/:repo/settings/secrets"
exact={true}
component={RepoSecrets}
/>
<Route
path="/:owner/:repo/settings/registry"
exact={true}
component={RepoRegistry}
/>
<Route
path="/:owner/:repo/settings"
exact={true}
component={RepoSettings}
/>
<Route
path="/:owner/:repo/:build(\d*)"
exact={true}
component={BuildLogs}
/>
<Route
path="/:owner/:repo/:build(\d*)/:proc(\d*)"
exact={true}
component={BuildLogs}
/>
<Route path="/:owner/:repo" exact={true} component={RepoBuilds} />
<Route path="/" exact={true} component={RedirectRoot} />
</Switch>
</div>
<Snackbar message={message.text} onClose={this.closeSnackbar} />
<Drawer onClick={this.closeMenu} position={DOCK_RIGHT} open={menu}>
<section>
<ul>
<li>
<Link to="/account/repos">Repositories</Link>
</li>
<li>
<Link to="/account/token">Token</Link>
</li>
</ul>
</section>
<section>
<ul>
<li>
<a href="/logout" target="_self">
Logout
</a>
</li>
</ul>
</section>
</Drawer>
</div>
);
}
closeSnackbar() {
this.props.dispatch(tree => {
tree.unset(["message", "text"]);
});
}
}

View file

@ -0,0 +1,85 @@
@import '~shared/styles/colors';
.title {
align-items: center;
border-bottom: 1px solid @gray-light;
box-sizing: border-box;
display: flex;
height: 60px;
padding: 0px 20px;
&> :first-child {
flex: 1;
}
.avatar {
align-items: center;
display: flex;
img {
border-radius: 50%;
height: 28px;
width: 28px;
}
}
button {
align-items: stretch;
background: @white;
border: 0px;
cursor: pointer;
display: flex;
margin: 0px;
margin-left: 10px;
outline: none;
padding: 0px;
}
}
.menu {}
.left {
border-right: 1px solid @splitter-border-color;
bottom: 0px;
box-sizing: border-box;
left: 0px;
overflow: hidden;
overflow-y: auto;
position: fixed;
right: 0px;
top: 0px;
width: 300px;
}
.center {
box-sizing: border-box;
padding-left: 300px;
}
.login {
background: @yellow;
box-sizing: border-box;
color: @white;
display: block;
font-size: 15px;
line-height: 50px;
// HACK
margin-top: -1px;
padding: 0px 30px;
text-align: center;
text-decoration: none;
text-shadow: 0px 1px 2px rgba(0, 0, 0, 0.1);
text-transform: uppercase;
}
.guest {
.left {
display: none;
}
.center {
padding-left: 0px;
}
}

View file

@ -0,0 +1,34 @@
import React, { Component } from "react";
import queryString from "query-string";
import Icon from "shared/components/icons/report";
import styles from "./index.less";
const DEFAULT_ERROR = "The system failed to process your Login request.";
class Error extends Component {
render() {
const parsed = queryString.parse(window.location.search);
let error = DEFAULT_ERROR;
switch (parsed.code || parsed.error) {
case "oauth_error":
break;
case "access_denied":
break;
}
return (
<div className={styles.root}>
<div className={styles.alert}>
<div>
<Icon />
</div>
<div>{error}</div>
</div>
</div>
);
}
}
export default Error;

View file

@ -0,0 +1,34 @@
@import '~shared/styles/colors';
@font: 'Roboto';
.root {
box-sizing: border-box;
margin: 50px auto;
max-width: 400px;
min-width: 400px;
padding: 30px;
.alert {
background: @yellow;
color: @white;
display: flex;
margin-bottom: 20px;
padding: 20px;
text-align: left;
&> :last-child {
font-family: @font;
font-size: 15px;
line-height: 20px;
padding-left: 10px;
padding-top: 2px;
}
}
svg {
fill: @white;
height: 26px;
width: 26px;
}
}

View file

@ -0,0 +1,21 @@
import React from "react";
import styles from "./index.less";
const LoginForm = props => (
<div className={styles.login}>
<form method="post" action="/authorize">
<p>Login with your version control system username and password.</p>
<input
placeholder="Username"
name="username"
type="text"
spellCheck="false"
/>
<input placeholder="Password" name="password" type="password" />
<input value="Login" type="submit" />
</form>
</div>
);
export default LoginForm;

View file

@ -0,0 +1,69 @@
@import '~shared/styles/colors';
@font: 'Roboto';
.login {
margin-top: 50px;
p {
color: @gray-dark;
font-family: @font;
line-height: 22px;
margin: 0px;
margin-bottom: 30px;
padding: 0px;
text-align: center;
user-select: none;
}
input {
box-sizing: border-box;
display: block;
outline: none;
width: 100%;
&[type='password'],
&[type='text'] {
background: @white;
border: 1px solid @gray-light;
font-family: @font;
margin-bottom: 20px;
padding: 10px;
&:focus {
border: 1px solid @gray-dark;
}
}
&[type='submit'] {
background: @gray-dark;
border: 0px;
color: @white;
font-family: @font;
line-height: 36px;
user-select: none;
}
}
form {
box-sizing: border-box;
margin: 0px auto;
max-width: 400px;
min-width: 400px;
padding: 30px;
}
::-moz-input-placeholder {
color: @gray;
font-size: 16px;
font-weight: 300;
user-select: none;
}
::-webkit-input-placeholder {
color: @gray;
font-size: 16px;
font-weight: 300;
user-select: none;
}
}

View file

@ -0,0 +1,4 @@
import LoginForm from "./form";
import LoginError from "./error";
export { LoginForm, LoginError };

View file

@ -0,0 +1,41 @@
import React, { Component } from "react";
import { Redirect } from "react-router-dom";
import { branch } from "baobab-react/higher-order";
import { Message } from "shared/components/sync";
const binding = (props, context) => {
return {
feed: ["feed"],
user: ["user", "data"],
syncing: ["user", "syncing"],
};
};
@branch(binding)
export default class RedirectRoot extends Component {
componentWillReceiveProps(nextProps) {
const { user } = nextProps;
if (!user && window) {
window.location.href = "/login?url=" + window.location.href;
}
}
render() {
const { user, syncing } = this.props;
const { latest, loaded } = this.props.feed;
return !loaded && syncing ? (
<Message />
) : !loaded ? (
undefined
) : !user ? (
undefined
) : !latest ? (
<Redirect to="/account/repos" />
) : !latest.number ? (
<Redirect to={`/${latest.full_name}`} />
) : (
<Redirect to={`/${latest.full_name}/${latest.number}`} />
);
}
}

View file

@ -0,0 +1,10 @@
import React from "react";
import style from "./approval.less";
export const Approval = ({ onapprove, ondecline }) => (
<div className={style.root}>
<p>Pipeline execution is blocked pending administrator approval</p>
<button onClick={onapprove}>Approve</button>
<button onClick={ondecline}>Decline</button>
</div>
);

View file

@ -0,0 +1,34 @@
@import '~shared/styles/colors';
.root {
background: @yellow;
border-radius: 2px;
margin-bottom: 20px;
padding: 20px;
button {
background: rgba(255, 255, 255, 0.2);
border: 0px;
border-radius: 2px;
color: @white;
cursor: pointer;
font-size: 13px;
line-height: 28px;
margin-right: 10px;
min-width: 100px;
padding: 0px 10px;
text-transform: uppercase;
&:focus {
border-radius: 2px;
outline: 1px solid @white;
}
}
p {
color: @white;
font-size: 15px;
margin-bottom: 20px;
margin-top: 0px;
}
}

View file

@ -0,0 +1,42 @@
import React, { Component } from "react";
import BuildMeta from "shared/components/build_event";
import BuildTime from "shared/components/build_time";
import { StatusLabel } from "shared/components/status";
import styles from "./details.less";
export class Details extends Component {
render() {
const { build } = this.props;
return (
<div className={styles.info}>
<StatusLabel status={build.status} />
<section className={styles.message} style={{ whiteSpace: "pre-line" }}>
{build.message}
</section>
<section>
<BuildTime
start={build.started_at || build.created_at}
finish={build.finished_at}
/>
</section>
<section>
<BuildMeta
link={build.link_url}
event={build.event}
commit={build.commit}
branch={build.branch}
target={build.deploy_to}
refspec={build.refspec}
refs={build.ref}
/>
</section>
</div>
);
}
}

View file

@ -0,0 +1,17 @@
@import '~shared/styles/colors';
.info {
section {
border-bottom: 1px solid @gray-light;
font-size: 14px;
line-height: 20px;
margin: 20px 0px;
padding: 0px 10px;
padding-bottom: 20px;
&:last-of-type {
border-bottom: 0px;
margin-bottom: 0px;
}
}
}

View file

@ -0,0 +1,63 @@
import React, { Component } from "react";
export class Elapsed extends Component {
constructor(props, context) {
super(props);
this.state = {
elapsed: 0,
};
this.tick = this.tick.bind(this);
}
componentDidMount() {
this.timer = setInterval(this.tick, 1000);
}
componentWillUnmount() {
clearInterval(this.timer);
}
tick() {
const { start } = this.props;
const stop = ~~(Date.now() / 1000);
this.setState({
elapsed: stop - start,
});
}
render() {
const { elapsed } = this.state;
const date = new Date(null);
date.setSeconds(elapsed);
return (
<time>
{!elapsed ? (
undefined
) : elapsed > 3600 ? (
date.toISOString().substr(11, 8)
) : (
date.toISOString().substr(14, 5)
)}
</time>
);
}
}
/*
* Returns the duration in hh:mm:ss format.
*
* @param {number} from - The start time in secnds
* @param {number} to - The end time in seconds
* @return {string}
*/
export const formatTime = (end, start) => {
const diff = end - start;
const date = new Date(null);
date.setSeconds(diff);
return diff > 3600
? date.toISOString().substr(11, 8)
: date.toISOString().substr(14, 5);
};

View file

@ -0,0 +1,5 @@
import { Approval } from "./approval";
import { Details } from "./details";
import { ProcList, ProcListItem } from "./procs";
export { Approval, Details, ProcList, ProcListItem };

View file

@ -0,0 +1,76 @@
import React, { Component } from "react";
import { Link } from "react-router-dom";
import classnames from "classnames";
import { Elapsed, formatTime } from "./elapsed";
import { default as Status, StatusText } from "shared/components/status";
import styles from "./procs.less";
const renderEnviron = data => {
return (
<div>
{data[0]}={data[1]}
</div>
);
};
const ProcListHolder = ({ vars, renderName, children }) => (
<div className={styles.list}>
{renderName && vars.name !== "drone" ? (
<div>
<StatusText status={vars.state} text={vars.name} />
</div>
) : null}
{vars.environ ? (
<div>
<StatusText
status={vars.state}
text={Object.entries(vars.environ).map(renderEnviron)}
/>
</div>
) : null}
{children}
</div>
);
export class ProcList extends Component {
render() {
const { repo, build, rootProc, selectedProc, renderName } = this.props;
return (
<ProcListHolder vars={rootProc} renderName={renderName}>
{this.props.rootProc.children.map(function(child) {
return (
<Link
to={`/${repo.full_name}/${build.number}/${child.pid}`}
key={`${repo.full_name}-${build.number}-${child.pid}`}
>
<ProcListItem
key={child.pid}
name={child.name}
start={child.start_time}
finish={child.end_time}
state={child.state}
selected={child.pid === selectedProc.pid}
/>
</Link>
);
})}
</ProcListHolder>
);
}
}
export const ProcListItem = ({ name, start, finish, state, selected }) => (
<div className={classnames(styles.item, selected ? styles.selected : null)}>
<h3>{name}</h3>
{finish ? (
<time>{formatTime(finish, start)}</time>
) : (
<Elapsed start={start} />
)}
<div>
<Status status={state} />
</div>
</div>
);

View file

@ -0,0 +1,49 @@
@import '~shared/styles/colors';
.list {
a {
color: @gray-dark;
display: block;
text-decoration: none;
}
}
.vars {
padding: 30px 0 0 10px;
}
.item {
background: @white;
box-sizing: border-box;
display: flex;
padding: 0px 10px;
&.selected,
&:hover {
background: @gray-light;
}
time {
color: @gray;
display: inline-block;
font-size: 13px;
line-height: 32px;
margin-right: 15px;
vertical-align: middle;
}
h3 {
flex: 1 1 auto;
font-size: 14px;
font-weight: normal;
line-height: 36px;
margin: 0px;
padding: 0px;
vertical-align: middle;
}
&:last-child {
align-items: center;
display: flex;
}
}

View file

@ -0,0 +1,257 @@
import React, { Component } from "react";
import { Link } from "react-router-dom";
import { fetchBuild, approveBuild, declineBuild } from "shared/utils/build";
import {
STATUS_BLOCKED,
STATUS_DECLINED,
STATUS_ERROR,
} from "shared/constants/status";
import { findChildProcess } from "shared/utils/proc";
import { fetchRepository } from "shared/utils/repository";
import Breadcrumb, { SEPARATOR } from "shared/components/breadcrumb";
import { Approval, Details, ProcList } from "./components";
import { branch } from "baobab-react/higher-order";
import { inject } from "config/client/inject";
import Output from "./logs";
import styles from "./index.less";
const binding = (props, context) => {
const { owner, repo, build } = props.match.params;
const slug = `${owner}/${repo}`;
const number = parseInt(build);
return {
repo: ["repos", "data", slug],
build: ["builds", "data", slug, number],
};
};
@inject
@branch(binding)
export default class BuildLogs extends Component {
constructor(props, context) {
super(props, context);
this.handleApprove = this.handleApprove.bind(this);
this.handleDecline = this.handleDecline.bind(this);
}
componentWillMount() {
this.synchronize(this.props);
}
handleApprove() {
const { repo, build, drone } = this.props;
this.props.dispatch(
approveBuild,
drone,
repo.owner,
repo.name,
build.number,
);
}
handleDecline() {
const { repo, build, drone } = this.props;
this.props.dispatch(
declineBuild,
drone,
repo.owner,
repo.name,
build.number,
);
}
componentWillUpdate(nextProps) {
if (this.props.match.url !== nextProps.match.url) {
this.synchronize(nextProps);
}
}
synchronize(props) {
if (!props.repo) {
this.props.dispatch(
fetchRepository,
props.drone,
props.match.params.owner,
props.match.params.repo,
);
}
if (!props.build || !props.build.procs) {
this.props.dispatch(
fetchBuild,
props.drone,
props.match.params.owner,
props.match.params.repo,
props.match.params.build,
);
}
}
shouldComponentUpdate(nextProps, nextState) {
return this.props !== nextProps;
}
render() {
const { repo, build } = this.props;
if (!build || !repo) {
return this.renderLoading();
}
if (build.status === STATUS_DECLINED || build.status === STATUS_ERROR) {
return this.renderError();
}
if (build.status === STATUS_BLOCKED) {
return this.renderBlocked();
}
if (!build.procs) {
return this.renderLoading();
}
return this.renderSimple();
}
renderLoading() {
return (
<div className={styles.host}>
<div className={styles.columns}>
<div className={styles.right}>Loading ...</div>
</div>
</div>
);
}
renderBlocked() {
const { build } = this.props;
return (
<div className={styles.host}>
<div className={styles.columns}>
<div className={styles.right}>
<Details build={build} />
</div>
<div className={styles.left}>
<Approval
onapprove={this.handleApprove}
ondecline={this.handleDecline}
/>
</div>
</div>
</div>
);
}
renderError() {
const { build } = this.props;
return (
<div className={styles.host}>
<div className={styles.columns}>
<div className={styles.right}>
<Details build={build} />
</div>
<div className={styles.left}>
<div className={styles.logerror}>
{build.status === STATUS_ERROR ? (
build.error
) : (
"Pipeline execution was declined"
)}
</div>
</div>
</div>
</div>
);
}
highlightedLine() {
if (location.hash.startsWith("#L")) {
return parseInt(location.hash.substr(2)) - 1;
}
return undefined;
}
renderSimple() {
// if (nextProps.build.procs[0].children !== undefined){
// return null;
// }
const { repo, build, match } = this.props;
const selectedProc = match.params.proc
? findChildProcess(build.procs, match.params.proc)
: build.procs[0].children[0];
const selectedProcParent = findChildProcess(build.procs, selectedProc.ppid);
const highlighted = this.highlightedLine();
return (
<div className={styles.host}>
<div className={styles.columns}>
<div className={styles.right}>
<Details build={build} />
<section className={styles.sticky}>
{build.procs.map(function(rootProc) {
return (
<div style="padding-bottom: 50px;" key={rootProc.pid}>
<ProcList
key={rootProc.pid}
repo={repo}
build={build}
rootProc={rootProc}
selectedProc={selectedProc}
renderName={build.procs.length > 1}
/>
</div>
);
})}
</section>
</div>
<div className={styles.left}>
{selectedProc && selectedProc.error ? (
<div className={styles.logerror}>{selectedProc.error}</div>
) : null}
{selectedProcParent && selectedProcParent.error ? (
<div className={styles.logerror}>{selectedProcParent.error}</div>
) : null}
<Output
match={this.props.match}
build={this.props.build}
proc={selectedProc}
highlighted={highlighted}
/>
</div>
</div>
</div>
);
}
}
export class BuildLogsTitle extends Component {
render() {
const { owner, repo, build } = this.props.match.params;
return (
<Breadcrumb
elements={[
<Link to={`/${owner}/${repo}`} key={`${owner}-${repo}`}>
{owner} / {repo}
</Link>,
SEPARATOR,
<Link
to={`/${owner}/${repo}/${build}`}
key={`${owner}-${repo}-${build}`}
>
{build}
</Link>,
]}
/>
);
}
}

View file

@ -0,0 +1,51 @@
@import '~shared/styles/colors';
.host {
padding: 0px 20px;
padding-bottom: 20px;
padding-right: 0px;
.columns {
display: flex;
.left {
box-sizing: border-box;
flex: 1;
min-width: 0px;
padding-right: 20px;
padding-top: 20px;
}
.right {
box-sizing: border-box;
flex: 0 0 350px;
min-width: 0px;
padding-right: 20px;
padding-top: 20px;
&> section {
border-top: 1px solid @gray-light;
padding-top: 20px;
}
}
}
}
section.sticky {
position: sticky;
top: 0px;
&:stuck {
border-top-width: 0px;
}
}
.logerror {
background: @gray-light;
border-radius: 2px;
color: @red;
display: block;
font-size: 14px;
margin-bottom: 10px;
padding: 20px;
}

View file

@ -0,0 +1,15 @@
import React from "react";
import styles from "./anchor.less";
export const Top = () => <div className={styles.top} />;
export const Bottom = () => <div className={styles.bottom} />;
export const scrollToTop = () => {
document.querySelector(`.${styles.top}`).scrollIntoView();
};
export const scrollToBottom = () => {
document.querySelector(`.${styles.bottom}`).scrollIntoView();
};

View file

@ -0,0 +1,4 @@
.top,
.bottom {
font-size: 0px;
}

View file

@ -0,0 +1,93 @@
import React, { Component } from "react";
import AnsiUp from "ansi_up";
import style from "./term.less";
import { Link } from "react-router-dom";
let formatter = new AnsiUp();
formatter.use_classes = true;
class Term extends Component {
render() {
const { lines, exitcode, highlighted } = this.props;
return (
<div className={style.term}>
{lines.map(line => renderTermLine(line, highlighted))}
{exitcode !== undefined ? renderExitCode(exitcode) : undefined}
</div>
);
}
shouldComponentUpdate(nextProps, nextState) {
return (
this.props.lines !== nextProps.lines ||
this.props.exitcode !== nextProps.exitcode ||
this.props.highlighted !== nextProps.highlighted
);
}
}
class TermLine extends Component {
render() {
const { line, highlighted } = this.props;
return (
<div
className={highlighted === line.pos ? style.highlight : style.line}
key={line.pos}
ref={highlighted === line.pos ? ref => (this.ref = ref) : null}
>
<div>
<Link to={`#L${line.pos + 1}`} key={line.pos + 1}>
{line.pos + 1}
</Link>
</div>
<div dangerouslySetInnerHTML={{ __html: this.colored }} />
<div>{line.time || 0}s</div>
</div>
);
}
componentDidMount() {
if (this.ref !== undefined) {
scrollToRef(this.ref);
}
}
get colored() {
return formatter.ansi_to_html(this.props.line.out || "");
}
shouldComponentUpdate(nextProps, nextState) {
return (
this.props.line.out !== nextProps.line.out ||
this.props.highlighted !== nextProps.highlighted
);
}
}
const renderTermLine = (line, highlighted) => {
return <TermLine line={line} highlighted={highlighted} />;
};
const renderExitCode = code => {
return <div className={style.exitcode}>exit code {code}</div>;
};
const TermError = () => {
return (
<div className={style.error}>
Oops. There was a problem loading the logs.
</div>
);
};
const TermLoading = () => {
return <div className={style.loading}>Loading ...</div>;
};
const scrollToRef = ref => window.scrollTo(0, ref.offsetTop - 100);
Term.Line = TermLine;
Term.Error = TermError;
Term.Loading = TermLoading;
export default Term;

View file

@ -0,0 +1,86 @@
@import '~shared/styles/colors';
@import '~shared/styles/ansi';
.term {
background: @gray-light;
border-radius: 2px;
padding: 20px;
.exitcode {
-moz-user-select: none;
-webkit-user-select: none;
color: rgba(0, 0, 0, 0.3);
font-family: 'Roboto Mono', monospace;
font-size: 13px;
margin-top: 10px;
min-width: 20px;
padding: 0px;
user-select: none;
}
}
.line {
color: @gray-dark;
display: flex;
line-height: 19px;
max-width: 100%;
a,
span,
div {
font-family: 'Roboto Mono', monospace;
font-size: 12px;
}
a {
text-decoration: none;
color: rgba(0, 0, 0, 0.3);
}
div:first-child {
-webkit-user-select: none;
color: rgba(0, 0, 0, 0.3);
min-width: 20px;
padding-right: 20px;
user-select: none;
}
div:nth-child(2) {
flex: 1 1 auto;
min-width: 0px;
white-space: pre-wrap;
word-wrap: break-word;
}
div:last-child {
-webkit-user-select: none;
color: rgba(0, 0, 0, 0.3);
padding-left: 20px;
user-select: none;
}
}
.highlight {
.line;
background-color: @yellow;
}
// log loading message
.loading {
background: @gray-light;
border-radius: 2px;
font-family: 'Roboto Mono', monospace;
font-size: 13px;
padding: 20px;
}
// log error message
.error {
background: @gray-light;
border-radius: 2px;
color: @red;
font-size: 14px;
margin-bottom: 10px;
padding: 20px;
}

View file

@ -0,0 +1,143 @@
import React, { Component } from "react";
import { inject } from "config/client/inject";
import { branch } from "baobab-react/higher-order";
import { repositorySlug } from "shared/utils/repository";
import { assertProcFinished, assertProcRunning } from "shared/utils/proc";
import { fetchLogs, subscribeToLogs, toggleLogs } from "shared/utils/logs";
import Term from "./components/term";
import { Top, Bottom, scrollToTop, scrollToBottom } from "./components/anchor";
import { ExpandIcon, PauseIcon, PlayIcon } from "shared/components/icons/index";
import styles from "./index.less";
const binding = (props, context) => {
const { owner, repo, build } = props.match.params;
const slug = repositorySlug(owner, repo);
const number = parseInt(build);
const pid = parseInt(props.proc.pid);
return {
logs: ["logs", "data", slug, number, pid, "data"],
eof: ["logs", "data", slug, number, pid, "eof"],
loading: ["logs", "data", slug, number, pid, "loading"],
error: ["logs", "data", slug, number, pid, "error"],
follow: ["logs", "follow"],
};
};
@inject
@branch(binding)
export default class Output extends Component {
constructor(props, context) {
super(props, context);
this.handleFollow = this.handleFollow.bind(this);
}
componentWillMount() {
if (this.props.proc) {
this.componentWillUpdate(this.props);
}
}
componentWillUpdate(nextProps) {
const { loading, logs, eof, error } = nextProps;
const routeChange = this.props.match.url !== nextProps.match.url;
if (loading || error || (logs && eof)) {
return;
}
if (assertProcFinished(nextProps.proc)) {
return this.props.dispatch(
fetchLogs,
nextProps.drone,
nextProps.match.params.owner,
nextProps.match.params.repo,
nextProps.build.number,
nextProps.proc.pid,
);
}
if (assertProcRunning(nextProps.proc) && (!logs || routeChange)) {
this.props.dispatch(
subscribeToLogs,
nextProps.drone,
nextProps.match.params.owner,
nextProps.match.params.repo,
nextProps.build.number,
nextProps.proc,
);
}
}
componentDidUpdate() {
if (this.props.follow) {
scrollToBottom();
}
}
handleFollow() {
this.props.dispatch(toggleLogs, !this.props.follow);
}
render() {
const { logs, error, proc, loading, follow, highlighted } = this.props;
if (loading || !proc) {
return <Term.Loading />;
}
if (error) {
return <Term.Error />;
}
return (
<div>
<Top />
<Term
lines={logs || []}
highlighted={highlighted}
exitcode={assertProcFinished(proc) ? proc.exit_code : undefined}
/>
<Bottom />
<Actions
running={assertProcRunning(proc)}
following={follow}
onfollow={this.handleFollow}
onunfollow={this.handleFollow}
/>
</div>
);
}
}
/**
* Component renders floating log actions. These can be used
* to follow, unfollow, scroll to top and scroll to bottom.
*/
const Actions = ({ following, running, onfollow, onunfollow }) => (
<div className={styles.actions}>
{running && !following ? (
<button onClick={onfollow} className={styles.follow}>
<PlayIcon />
</button>
) : null}
{running && following ? (
<button onClick={onunfollow} className={styles.unfollow}>
<PauseIcon />
</button>
) : null}
<button onClick={scrollToTop} className={styles.bottom}>
<ExpandIcon />
</button>
<button onClick={scrollToBottom} className={styles.top}>
<ExpandIcon />
</button>
</div>
);

View file

@ -0,0 +1,105 @@
@import '~shared/styles/colors';
.loading {
background: @gray-light;
border-radius: 2px;
font-family: 'Roboto Mono', monospace;
font-size: 12px;
padding: 20px;
}
.error {
background: @gray-light;
border-radius: 2px;
color: @red;
font-size: 14px;
margin-bottom: 10px;
padding: 20px;
}
.actions {
bottom: 30px;
display: flex;
flex-direction: row;
position: fixed;
right: 30px;
button {
align-items: center;
background: @white;
border: 1px solid @gray;
color: @gray-dark;
cursor: pointer;
display: flex;
flex-direction: row;
justify-content: center;
margin-left: -1px;
min-height: 32px;
min-width: 32px;
outline: none;
padding: 2px;
&.bottom svg {
transform: rotate(180deg);
}
&.follow svg,
&.unfollow svg {
height: 18px;
width: 18px;
}
}
svg {
fill: @gray-dark;
}
}
.logactions {
bottom: 30px;
display: flex;
position: fixed;
right: 30px;
div {
display: flex;
}
button {
align-items: center;
background: @white;
border: 1px solid @gray-light;
color: @gray-dark;
cursor: pointer;
display: flex;
flex-direction: row;
justify-content: center;
margin-left: -1px;
min-height: 32px;
min-width: 32px;
outline: none;
padding: 2px;
svg {
fill: @gray-dark;
}
&.gotoTop {
transform: rotate(180deg);
}
&.followButton {
svg {
height: 18px;
width: 18px;
}
}
&.unfollowButton {
svg {
height: 18px;
width: 18px;
}
}
}
}

View file

@ -0,0 +1,78 @@
import React, { Component } from "react";
import RepoMenu from "../builds/menu";
import { RefreshIcon, CloseIcon } from "shared/components/icons";
import { cancelBuild, restartBuild } from "shared/utils/build";
import { findChildProcess } from "shared/utils/proc";
import { repositorySlug } from "shared/utils/repository";
import { branch } from "baobab-react/higher-order";
import { inject } from "config/client/inject";
const binding = (props, context) => {
const { owner, repo, build } = props.match.params;
const slug = repositorySlug(owner, repo);
const number = parseInt(build);
return {
repo: ["repos", "data", slug],
build: ["builds", "data", slug, number],
};
};
@inject
@branch(binding)
export default class BuildMenu extends Component {
constructor(props, context) {
super(props, context);
this.handleCancel = this.handleCancel.bind(this);
this.handleRestart = this.handleRestart.bind(this);
}
handleRestart() {
const { dispatch, drone, repo, build } = this.props;
dispatch(restartBuild, drone, repo.owner, repo.name, build.number);
}
handleCancel() {
const { dispatch, drone, repo, build, match } = this.props;
const proc = findChildProcess(build.procs, match.params.proc || 2);
dispatch(
cancelBuild,
drone,
repo.owner,
repo.name,
build.number,
proc.ppid,
);
}
render() {
const { build } = this.props;
const rightSide = !build ? (
undefined
) : (
<section>
{build.status === "pending" || build.status === "running" ? (
<button onClick={this.handleCancel}>
<CloseIcon />
<span>Cancel</span>
</button>
) : (
<button onClick={this.handleRestart}>
<RefreshIcon />
<span>Restart Build</span>
</button>
)}
</section>
);
return (
<div>
<RepoMenu {...this.props} right={rightSide} />
</div>
);
}
}

View file

@ -0,0 +1,3 @@
import { List, Item } from "./list";
export { List, Item };

View file

@ -0,0 +1,55 @@
import React, { Component } from "react";
import Status from "shared/components/status";
import StatusNumber from "shared/components/status_number";
import BuildTime from "shared/components/build_time";
import BuildMeta from "shared/components/build_event";
import styles from "./list.less";
export const List = ({ children }) => (
<div className={styles.list}>{children}</div>
);
export class Item extends Component {
render() {
const { build } = this.props;
return (
<div className={styles.item}>
<div className={styles.icon}>
<img src={build.author_avatar} />
</div>
<div className={styles.body}>
<h3>{build.message.split("\n")[0]}</h3>
</div>
<div className={styles.meta}>
<BuildMeta
link={build.link_url}
event={build.event}
commit={build.commit}
branch={build.branch}
target={build.deploy_to}
refspec={build.refspec}
refs={build.ref}
/>
</div>
<div className={styles.break} />
<div className={styles.time}>
<BuildTime
start={build.started_at || build.created_at}
finish={build.finished_at}
/>
</div>
<div className={styles.status}>
<StatusNumber status={build.status} number={build.number} />
<Status status={build.status} />
</div>
</div>
);
}
}

View file

@ -0,0 +1,163 @@
@import '~shared/styles/colors';
.list {
&> a {
border-bottom: 1px solid @gray-light;
box-sizing: border-box;
color: @gray-dark;
display: block;
padding: 20px 0px;
text-decoration: none;
&:last-child {
border-bottom: 0px;
}
a {
// no links inside links
display: none;
}
}
}
.item {
display: flex;
.break {
display: none;
}
}
@media (max-width: 1100px) {
.item {
flex-wrap: wrap;
.icon {
order: 0px;
}
.body {
flex: 1;
order: 1;
h3 {
padding-right: 20px;
}
}
.meta {
border-left-width: 0px;
margin: 0px;
margin-right: 20px;
margin-top: 20px;
order: 4;
padding: 0px;
padding-left: 52px;
}
.time {
margin-top: 20px;
order: 5;
}
.status {
order: 2;
}
.break {
display: block;
flex-basis: 100%;
height: 0px;
order: 3;
overflow: hidden;
width: 0px;
}
}
}
.item h3 {
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
display: -webkit-box;
font-size: 15px;
font-weight: normal;
line-height: 22px;
margin: 0px;
min-height: 22px;
overflow: hidden;
}
.item em {
font-size: 14px;
font-style: normal;
}
.item span {
color: @gray;
font-size: 14px;
margin: 0px 5px;
}
.icon {
margin-left: 10px;
margin-right: 20px;
max-width: 22px;
min-width: 22px;
width: 22px;
}
.icon img {
border-radius: 50%;
height: 22px;
width: 22px;
}
.status {
display: inline-block;
text-align: right;
white-space: nowrap;
}
.status span {
border: 2px solid @green;
border-radius: 2px;
color: @green;
display: inline-block;
line-height: 20px;
margin-right: 10px;
min-width: 65px;
text-align: center;
}
.status div {
display: inline-block;
vertical-align: middle;
&:last-child {
margin-left: 20px;
}
}
.body {
flex: 1;
}
.meta {
border-left: 1px solid @gray-light;
border-right: 1px solid @gray-light;
box-sizing: border-box;
flex: 0 0 200px;
margin-left: 20px;
margin-right: 20px;
min-width: 200px;
padding-left: 20px;
padding-right: 20px;
}
.time {
box-sizing: border-box;
flex: 0 0 200px;
margin-right: 20px;
min-width: 200px;
padding-right: 20px;
}

View file

@ -0,0 +1,20 @@
import React, { Component } from "react";
import { Link } from "react-router-dom";
import Breadcrumb from "shared/components/breadcrumb";
export default class Header extends Component {
render() {
const { owner, repo } = this.props.match.params;
return (
<div>
<Breadcrumb
elements={[
<Link to={`/${owner}/${repo}`} key={`${owner}-${repo}`}>
{owner} / {repo}
</Link>,
]}
/>
</div>
);
}
}

View file

@ -0,0 +1,124 @@
import React, { Component } from "react";
import { Link } from "react-router-dom";
import { List, Item } from "./components";
import { fetchBuildList, compareBuild } from "shared/utils/build";
import { fetchRepository, repositorySlug } from "shared/utils/repository";
import { branch } from "baobab-react/higher-order";
import { inject } from "config/client/inject";
import styles from "./index.less";
const binding = (props, context) => {
const { owner, repo } = props.match.params;
const slug = repositorySlug(owner, repo);
return {
repo: ["repos", "data", slug],
builds: ["builds", "data", slug],
loaded: ["builds", "loaded"],
error: ["builds", "error"],
};
};
@inject
@branch(binding)
export default class Main extends Component {
constructor(props, context) {
super(props, context);
this.fetchNextBuildPage = this.fetchNextBuildPage.bind(this);
}
componentWillMount() {
this.synchronize(this.props);
}
shouldComponentUpdate(nextProps, nextState) {
return (
this.props.repo !== nextProps.repo ||
(nextProps.builds !== undefined &&
this.props.builds !== nextProps.builds) ||
this.props.error !== nextProps.error ||
this.props.loaded !== nextProps.loaded
);
}
componentWillUpdate(nextProps) {
if (this.props.match.url !== nextProps.match.url) {
this.synchronize(nextProps);
}
}
componentDidUpdate(prevProps) {
if (this.props.location !== prevProps.location) {
window.scrollTo(0, 0);
}
}
synchronize(props) {
const { drone, dispatch, match, repo } = props;
if (!repo) {
dispatch(fetchRepository, drone, match.params.owner, match.params.repo);
}
dispatch(fetchBuildList, drone, match.params.owner, match.params.repo);
}
fetchNextBuildPage(buildList) {
const { drone, dispatch, match } = this.props;
const page = Math.floor(buildList.length / 50) + 1;
dispatch(
fetchBuildList,
drone,
match.params.owner,
match.params.repo,
page,
);
}
render() {
const { repo, builds, loaded, error } = this.props;
const list = Object.values(builds || {});
function renderBuild(build) {
return (
<Link to={`/${repo.full_name}/${build.number}`} key={build.number}>
<Item build={build} />
</Link>
);
}
if (error) {
return <div>Not Found</div>;
}
if (!loaded && list.length === 0) {
return <div>Loading</div>;
}
if (!repo) {
return <div>Loading</div>;
}
if (list.length === 0) {
return <div>Build list is empty</div>;
}
return (
<div className={styles.root}>
<List>{list.sort(compareBuild).map(renderBuild)}</List>
{list.length < repo.last_build && (
<button
onClick={() => this.fetchNextBuildPage(list)}
className={styles.more}
>
Show more builds
</button>
)}
</div>
);
}
}

View file

@ -0,0 +1,24 @@
@import '~shared/styles/colors';
.root {
padding: 20px;
}
button {
background: @white;
border: 1px solid @gray-dark;
border-radius: 2px;
color: @gray-dark;
cursor: pointer;
font-family: 'Roboto';
font-size: 14px;
line-height: 28px;
outline: none;
padding: 0px 20px;
text-transform: uppercase;
user-select: none;
&.more {
margin-top: 10px;
}
}

View file

@ -0,0 +1,15 @@
import React, { Component } from "react";
import Menu from "shared/components/menu";
export default class RepoMenu extends Component {
render() {
const { owner, repo } = this.props.match.params;
const menu = [
{ to: `/${owner}/${repo}`, label: "Builds" },
{ to: `/${owner}/${repo}/settings/secrets`, label: "Secrets" },
{ to: `/${owner}/${repo}/settings/registry`, label: "Registry" },
{ to: `/${owner}/${repo}/settings`, label: "Settings" },
];
return <Menu items={menu} {...this.props} />;
}
}

View file

@ -0,0 +1,16 @@
@import '~shared/styles/colors';
.root {
border-bottom: 1px solid @gray-light;
box-sizing: border-box;
height: 45px;
line-height: 45px;
padding: 0px 20px;
a {
color: @gray-dark;
font-size: 15px;
margin-right: 20px;
text-decoration: none;
}
}

View file

@ -0,0 +1,80 @@
import React, { Component } from "react";
import styles from "./form.less";
export class Form extends Component {
constructor(props, context) {
super(props, context);
this.state = {
address: "",
username: "",
password: "",
};
this._handleAddressChange = this._handleAddressChange.bind(this);
this._handleUsernameChange = this._handleUsernameChange.bind(this);
this._handlePasswordChange = this._handlePasswordChange.bind(this);
this._handleSubmit = this._handleSubmit.bind(this);
this.clear = this.clear.bind(this);
}
_handleAddressChange(event) {
this.setState({ address: event.target.value });
}
_handleUsernameChange(event) {
this.setState({ username: event.target.value });
}
_handlePasswordChange(event) {
this.setState({ password: event.target.value });
}
_handleSubmit() {
const { onsubmit } = this.props;
const detail = {
address: this.state.address,
username: this.state.username,
password: this.state.password,
};
onsubmit({ detail });
this.clear();
}
clear() {
this.setState({ address: "" });
this.setState({ username: "" });
this.setState({ password: "" });
}
render() {
return (
<div className={styles.form}>
<input
type="text"
value={this.state.address}
onChange={this._handleAddressChange}
placeholder="Registry Address (e.g. docker.io)"
/>
<input
type="text"
value={this.state.username}
onChange={this._handleUsernameChange}
placeholder="Registry Username"
/>
<textarea
rows="1"
value={this.state.password}
onChange={this._handlePasswordChange}
placeholder="Registry Password"
/>
<div className={styles.actions}>
<button onClick={this._handleSubmit}>Save</button>
</div>
</div>
);
}
}

View file

@ -0,0 +1,65 @@
@import '~shared/styles/colors';
.form {
input {
border: 1px solid @gray-light;
box-sizing: border-box;
display: block;
margin-bottom: 20px;
outline: none;
padding: 10px;
width: 100%;
&:focus {
border: 1px solid @gray-dark;
}
}
textarea {
border: 1px solid @gray-light;
box-sizing: border-box;
display: block;
height: 100px;
margin-bottom: 20px;
outline: none;
padding: 10px;
width: 100%;
&:focus {
border: 1px solid @gray-dark;
}
}
.actions {
text-align: right;
}
button {
background: @white;
border: 1px solid @gray-dark;
border-radius: 2px;
color: @gray-dark;
cursor: pointer;
font-family: 'Roboto';
font-size: 14px;
line-height: 28px;
outline: none;
padding: 0px 20px;
text-transform: uppercase;
user-select: none;
}
::-moz-input-placeholder {
color: @gray;
font-size: 15px;
font-weight: 300;
user-select: none;
}
::-webkit-input-placeholder {
color: @gray;
font-size: 15px;
font-weight: 300;
user-select: none;
}
}

View file

@ -0,0 +1,4 @@
import { Form } from "./form";
import { List, Item } from "./list";
export { Form, List, Item };

View file

@ -0,0 +1,15 @@
import React from "react";
import styles from "./list.less";
export const List = ({ children }) => (
<div className={styles.list}>{children}</div>
);
export const Item = props => (
<div className={styles.item} key={props.name}>
<div>{props.name}</div>
<div>
<button onClick={props.ondelete}>delete</button>
</div>
</div>
);

View file

@ -0,0 +1,45 @@
@import '~shared/styles/colors';
.item {
border-bottom: 1px solid @gray-light;
display: flex;
padding: 10px 10px;
padding-bottom: 20px;
&:last-child {
border-bottom: 0px;
}
&:first-child {
padding-top: 0px;
}
&> div:first-child {
flex: 1 1 auto;
font-size: 15px;
line-height: 32px;
text-transform: lowercase;
}
&> div:last-child {
align-content: stretch;
display: flex;
flex-direction: column;
justify-content: center;
text-align: right;
}
button {
background: @white;
border: 1px solid @red;
border-radius: 2px;
color: @red;
cursor: pointer;
display: block;
font-size: 13px;
padding: 2px 10px;
text-align: center;
text-decoration: none;
text-transform: uppercase;
}
}

View file

@ -0,0 +1,103 @@
import React, { Component } from "react";
import { repositorySlug } from "shared/utils/repository";
import {
fetchRegistryList,
createRegistry,
deleteRegistry,
} from "shared/utils/registry";
import { branch } from "baobab-react/higher-order";
import { inject } from "config/client/inject";
import { List, Item, Form } from "./components";
import styles from "./index.less";
const binding = (props, context) => {
const { owner, repo } = props.match.params;
const slug = repositorySlug(owner, repo);
return {
loaded: ["registry", "loaded"],
registries: ["registry", "data", slug],
};
};
@inject
@branch(binding)
export default class RepoRegistry extends Component {
constructor(props, context) {
super(props, context);
this.handleDelete = this.handleDelete.bind(this);
this.handleSave = this.handleSave.bind(this);
}
shouldComponentUpdate(nextProps, nextState) {
return this.props.registries !== nextProps.registries;
}
componentWillMount() {
const { dispatch, drone, match } = this.props;
const { owner, repo } = match.params;
dispatch(fetchRegistryList, drone, owner, repo);
}
handleSave(e) {
const { dispatch, drone, match } = this.props;
const { owner, repo } = match.params;
const registry = {
address: e.detail.address,
username: e.detail.username,
password: e.detail.password,
};
dispatch(createRegistry, drone, owner, repo, registry);
}
handleDelete(registry) {
const { dispatch, drone, match } = this.props;
const { owner, repo } = match.params;
dispatch(deleteRegistry, drone, owner, repo, registry.address);
}
render() {
const { registries, loaded } = this.props;
if (!loaded) {
return LOADING;
}
return (
<div className={styles.root}>
<div className={styles.left}>
{Object.keys(registries || {}).length === 0 ? EMPTY : undefined}
<List>
{Object.values(registries || {}).map(renderRegistry.bind(this))}
</List>
</div>
<div className={styles.right}>
<Form onsubmit={this.handleSave} />
</div>
</div>
);
}
}
function renderRegistry(registry) {
return (
<Item
name={registry.address}
ondelete={this.handleDelete.bind(this, registry)}
/>
);
}
const LOADING = <div className={styles.loading}>Loading</div>;
const EMPTY = (
<div className={styles.empty}>
There are no registry credentials for this repository.
</div>
);

View file

@ -0,0 +1,34 @@
@import '~shared/styles/colors';
.root {
display: flex;
padding: 20px;
}
.left {
flex: 1;
margin-right: 20px;
}
.right {
border-left: 1px solid @gray-light;
flex: 1;
padding-left: 20px;
padding-top: 10px;
}
@media (max-width: 960px) {
.root {
flex-direction: column;
}
.list {
margin-right: 0px;
}
.right {
border-left: 0px;
padding-left: 0px;
padding-top: 20px;
}
}

View file

@ -0,0 +1,140 @@
import React, { Component } from "react";
import {
EVENT_PUSH,
EVENT_TAG,
EVENT_PULL_REQUEST,
EVENT_DEPLOY,
} from "shared/constants/events";
import styles from "./form.less";
export class Form extends Component {
constructor(props, context) {
super(props, context);
this.state = {
name: "",
value: "",
event: [EVENT_PUSH, EVENT_TAG, EVENT_DEPLOY],
};
this._handleNameChange = this._handleNameChange.bind(this);
this._handleValueChange = this._handleValueChange.bind(this);
this._handleEventChange = this._handleEventChange.bind(this);
this._handleSubmit = this._handleSubmit.bind(this);
this.clear = this.clear.bind(this);
}
_handleNameChange(event) {
this.setState({ name: event.target.value });
}
_handleValueChange(event) {
this.setState({ value: event.target.value });
}
_handleEventChange(event) {
const selected = this.state.event;
let index;
if (event.target.checked) {
selected.push(event.target.value);
} else {
index = selected.indexOf(event.target.value);
selected.splice(index, 1);
}
this.setState({ event: selected });
}
_handleSubmit() {
const { onsubmit } = this.props;
const detail = {
name: this.state.name,
value: this.state.value,
event: this.state.event,
};
onsubmit({ detail });
this.clear();
}
clear() {
this.setState({ name: "" });
this.setState({ value: "" });
this.setState({ event: [EVENT_PUSH, EVENT_TAG, EVENT_DEPLOY] });
}
render() {
let checked = this.state.event.reduce((map, event) => {
map[event] = true;
return map;
}, {});
return (
<div className={styles.form}>
<input
type="text"
name="name"
value={this.state.name}
placeholder="Secret Name"
onChange={this._handleNameChange}
/>
<textarea
rows="1"
name="value"
value={this.state.value}
placeholder="Secret Value"
onChange={this._handleValueChange}
/>
<section>
<h2>Events</h2>
<div>
<label>
<input
type="checkbox"
checked={checked[EVENT_PUSH]}
value={EVENT_PUSH}
onChange={this._handleEventChange}
/>
<span>push</span>
</label>
<label>
<input
type="checkbox"
checked={checked[EVENT_TAG]}
value={EVENT_TAG}
onChange={this._handleEventChange}
/>
<span>tag</span>
</label>
<label>
<input
type="checkbox"
checked={checked[EVENT_PULL_REQUEST]}
value={EVENT_PULL_REQUEST}
onChange={this._handleEventChange}
/>
<span>pull request</span>
</label>
<label>
<input
type="checkbox"
checked={checked[EVENT_DEPLOY]}
value={EVENT_DEPLOY}
onChange={this._handleEventChange}
/>
<span>deploy</span>
</label>
</div>
</section>
<div className={styles.actions}>
<button onClick={this._handleSubmit}>Save</button>
</div>
</div>
);
}
}

View file

@ -0,0 +1,121 @@
@import '~shared/styles/colors';
.form {
input {
border: 1px solid @gray-light;
box-sizing: border-box;
display: block;
margin-bottom: 20px;
outline: none;
padding: 10px;
width: 100%;
&:focus {
border: 1px solid @gray-dark;
}
}
textarea {
border: 1px solid @gray-light;
box-sizing: border-box;
display: block;
height: 100px;
margin-bottom: 20px;
outline: none;
padding: 10px;
width: 100%;
&:focus {
border: 1px solid @gray-dark;
}
}
section {
display: flex;
flex: 1 1 auto;
padding-bottom: 20px;
&> div {
flex: 1;
}
&:first-child {
padding-top: 0px;
}
&:last-child {
border-bottom-width: 0px;
}
@media (max-width: 600px) {
display: flex;
flex-direction: column;
h2 {
flex: none;
margin-bottom: 20px;
}
&> :last-child {
padding-left: 20px;
}
}
h2 {
flex: 0 0 100px;
font-size: 15px;
font-weight: normal;
line-height: 26px;
margin: 0px;
padding: 0px;
}
label {
display: block;
padding: 0px;
span {
font-size: 15px;
}
}
input[type='checkbox'] {
width: initial;
display: inline;
margin: 0px 10px 0px 0px;
}
}
.actions {
text-align: right;
}
button {
background: @white;
border: 1px solid @gray-dark;
border-radius: 2px;
color: @gray-dark;
cursor: pointer;
font-family: 'Roboto';
font-size: 14px;
line-height: 28px;
outline: none;
padding: 0px 20px;
text-transform: uppercase;
user-select: none;
}
::-moz-input-placeholder {
color: @gray;
font-size: 15px;
font-weight: 300;
user-select: none;
}
::-webkit-input-placeholder {
color: @gray;
font-size: 15px;
font-weight: 300;
user-select: none;
}
}

View file

@ -0,0 +1,4 @@
import { Form } from "./form";
import { List, Item } from "./list";
export { Form, List, Item };

View file

@ -0,0 +1,20 @@
import React from "react";
import styles from "./list.less";
export const List = ({ children }) => <div>{children}</div>;
export const Item = props => (
<div className={styles.item} key={props.name}>
<div>
{props.name}
<ul>{props.event ? props.event.map(renderEvent) : null}</ul>
</div>
<div>
<button onClick={props.ondelete}>delete</button>
</div>
</div>
);
const renderEvent = event => {
return <li>{event}</li>;
};

View file

@ -0,0 +1,65 @@
@import '~shared/styles/colors';
.item {
border-bottom: 1px solid @gray-light;
display: flex;
padding: 10px 10px;
padding-bottom: 20px;
&:last-child {
border-bottom: 0px;
}
&:first-child {
padding-top: 0px;
}
&> div:first-child {
flex: 1 1 auto;
font-size: 15px;
line-height: 32px;
text-transform: lowercase;
}
&> div:last-child {
align-content: stretch;
display: flex;
flex-direction: column;
justify-content: center;
text-align: right;
}
button {
background: @white;
border: 1px solid @red;
border-radius: 2px;
color: @red;
cursor: pointer;
display: block;
font-size: 13px;
padding: 2px 10px;
text-align: center;
text-decoration: none;
text-transform: uppercase;
}
ul {
line-height: 0px;
list-style: none;
margin: 0px;
padding: 0px;
}
li {
background: @gray-light;
border-radius: 2px;
color: @gray-dark;
display: inline-block;
font-size: 12px;
line-height: 20px;
margin-bottom: 2px;
margin-right: 2px;
padding: 0px 10px;
text-transform: uppercase;
}
}

View file

@ -0,0 +1,99 @@
import React, { Component } from "react";
import { repositorySlug } from "shared/utils/repository";
import {
fetchSecretList,
createSecret,
deleteSecret,
} from "shared/utils/secrets";
import { branch } from "baobab-react/higher-order";
import { inject } from "config/client/inject";
import { List, Item, Form } from "./components";
import styles from "./index.less";
const binding = (props, context) => {
const { owner, repo } = props.match.params;
const slug = repositorySlug(owner, repo);
return {
loaded: ["secrets", "loaded"],
secrets: ["secrets", "data", slug],
};
};
@inject
@branch(binding)
export default class RepoSecrets extends Component {
constructor(props, context) {
super(props, context);
this.handleSave = this.handleSave.bind(this);
}
shouldComponentUpdate(nextProps, nextState) {
return this.props.secrets !== nextProps.secrets;
}
componentWillMount() {
const { owner, repo } = this.props.match.params;
this.props.dispatch(fetchSecretList, this.props.drone, owner, repo);
}
handleSave(e) {
const { dispatch, drone, match } = this.props;
const { owner, repo } = match.params;
const secret = {
name: e.detail.name,
value: e.detail.value,
event: e.detail.event,
};
dispatch(createSecret, drone, owner, repo, secret);
}
handleDelete(secret) {
const { dispatch, drone, match } = this.props;
const { owner, repo } = match.params;
dispatch(deleteSecret, drone, owner, repo, secret.name);
}
render() {
const { secrets, loaded } = this.props;
if (!loaded) {
return LOADING;
}
return (
<div className={styles.root}>
<div className={styles.left}>
{Object.keys(secrets || {}).length === 0 ? EMPTY : undefined}
<List>
{Object.values(secrets || {}).map(renderSecret.bind(this))}
</List>
</div>
<div className={styles.right}>
<Form onsubmit={this.handleSave} />
</div>
</div>
);
}
}
function renderSecret(secret) {
return (
<Item
name={secret.name}
event={secret.event}
ondelete={this.handleDelete.bind(this, secret)}
/>
);
}
const LOADING = <div className={styles.loading}>Loading</div>;
const EMPTY = (
<div className={styles.empty}>There are no secrets for this repository.</div>
);

View file

@ -0,0 +1,34 @@
@import '~shared/styles/colors';
.root {
display: flex;
padding: 20px;
}
.left {
flex: 1;
margin-right: 20px;
}
.right {
border-left: 1px solid @gray-light;
flex: 1;
padding-left: 20px;
padding-top: 10px;
}
@media (max-width: 960px) {
.root {
flex-direction: column;
}
.list {
margin-right: 0px;
}
.right {
border-left: 0px;
padding-left: 0px;
padding-top: 20px;
}
}

View file

@ -0,0 +1,244 @@
import React, { Component } from "react";
import { branch } from "baobab-react/higher-order";
import { inject } from "config/client/inject";
import {
fetchRepository,
updateRepository,
repositorySlug,
} from "shared/utils/repository";
import {
VISIBILITY_PUBLIC,
VISIBILITY_PRIVATE,
VISIBILITY_INTERNAL,
} from "shared/constants/visibility";
import styles from "./index.less";
const binding = (props, context) => {
const { owner, repo } = props.match.params;
const slug = repositorySlug(owner, repo);
return {
user: ["user", "data"],
repo: ["repos", "data", slug],
};
};
@inject
@branch(binding)
export default class Settings extends Component {
constructor(props, context) {
super(props, context);
this.handlePushChange = this.handlePushChange.bind(this);
this.handlePullChange = this.handlePullChange.bind(this);
this.handleTagChange = this.handleTagChange.bind(this);
this.handleDeployChange = this.handleDeployChange.bind(this);
this.handleTrustedChange = this.handleTrustedChange.bind(this);
this.handleProtectedChange = this.handleProtectedChange.bind(this);
this.handleVisibilityChange = this.handleVisibilityChange.bind(this);
this.handleTimeoutChange = this.handleTimeoutChange.bind(this);
this.handlePathChange = this.handlePathChange.bind(this);
this.handleFallbackChange = this.handleFallbackChange.bind(this);
this.handleChange = this.handleChange.bind(this);
}
shouldComponentUpdate(nextProps, nextState) {
return this.props.repo !== nextProps.repo;
}
componentWillMount() {
const { drone, dispatch, match, repo } = this.props;
if (!repo) {
dispatch(fetchRepository, drone, match.params.owner, match.params.repo);
}
}
render() {
const { repo } = this.props;
if (!repo) {
return undefined;
}
return (
<div className={styles.root}>
<section>
<h2>Pipeline Path</h2>
<div>
<input
type="text"
value={repo.config_file}
onBlur={this.handlePathChange}
/>
<label>
<input
type="checkbox"
checked={repo.fallback}
onChange={this.handleFallbackChange}
/>
<span>Fallback to .drone.yml if path not exists</span>
</label>
</div>
</section>
<section>
<h2>Repository Hooks</h2>
<div>
<label>
<input
type="checkbox"
checked={repo.allow_push}
onChange={this.handlePushChange}
/>
<span>push</span>
</label>
<label>
<input
type="checkbox"
checked={repo.allow_pr}
onChange={this.handlePullChange}
/>
<span>pull request</span>
</label>
<label>
<input
type="checkbox"
checked={repo.allow_tags}
onChange={this.handleTagChange}
/>
<span>tag</span>
</label>
<label>
<input
type="checkbox"
checked={repo.allow_deploys}
onChange={this.handleDeployChange}
/>
<span>deployment</span>
</label>
</div>
</section>
<section>
<h2>Project Settings</h2>
<div>
<label>
<input
type="checkbox"
checked={repo.gated}
onChange={this.handleProtectedChange}
/>
<span>Protected</span>
</label>
<label>
<input
type="checkbox"
checked={repo.trusted}
onChange={this.handleTrustedChange}
/>
<span>Trusted</span>
</label>
</div>
</section>
<section>
<h2>Project Visibility</h2>
<div>
<label>
<input
type="radio"
name="visibility"
value="public"
checked={repo.visibility === VISIBILITY_PUBLIC}
onChange={this.handleVisibilityChange}
/>
<span>Public</span>
</label>
<label>
<input
type="radio"
name="visibility"
value="private"
checked={repo.visibility === VISIBILITY_PRIVATE}
onChange={this.handleVisibilityChange}
/>
<span>Private</span>
</label>
<label>
<input
type="radio"
name="visibility"
value="internal"
checked={repo.visibility === VISIBILITY_INTERNAL}
onChange={this.handleVisibilityChange}
/>
<span>Internal</span>
</label>
</div>
</section>
<section>
<h2>Timeout</h2>
<div>
<input
type="number"
value={repo.timeout}
onBlur={this.handleTimeoutChange}
/>
<span className={styles.minutes}>minutes</span>
</div>
</section>
</div>
);
}
handlePushChange(e) {
this.handleChange("allow_push", e.target.checked);
}
handlePullChange(e) {
this.handleChange("allow_pr", e.target.checked);
}
handleTagChange(e) {
this.handleChange("allow_tag", e.target.checked);
}
handleDeployChange(e) {
this.handleChange("allow_deploy", e.target.checked);
}
handleTrustedChange(e) {
this.handleChange("trusted", e.target.checked);
}
handleProtectedChange(e) {
this.handleChange("gated", e.target.checked);
}
handleVisibilityChange(e) {
this.handleChange("visibility", e.target.value);
}
handleTimeoutChange(e) {
this.handleChange("timeout", parseInt(e.target.value));
}
handlePathChange(e) {
this.handleChange("config_file", e.target.value);
}
handleFallbackChange(e) {
this.handleChange("fallback", e.target.checked);
}
handleChange(prop, value) {
const { dispatch, drone, repo } = this.props;
let data = {};
data[prop] = value;
dispatch(updateRepository, drone, repo.owner, repo.name, data);
}
}

View file

@ -0,0 +1,72 @@
@import '~shared/styles/colors';
.root {
padding: 20px;
section {
border-bottom: 1px solid @gray-light;
display: flex;
flex: 1 1 auto;
padding: 20px 10px;
&> div {
flex: 1;
}
&:first-child {
padding-top: 0px;
}
&:last-child {
border-bottom-width: 0px;
}
@media (max-width: 600px) {
display: flex;
flex-direction: column;
h2 {
flex: none;
margin-bottom: 20px;
}
&> :last-child {
padding-left: 20px;
}
}
}
h2 {
flex: 0 0 200px;
font-size: 15px;
font-weight: normal;
line-height: 26px;
margin: 0px;
padding: 0px;
}
label {
display: block;
padding: 0px;
span {
font-size: 15px;
}
}
input[type='checkbox'],
input[type='radio'] {
margin-right: 10px;
}
input[type='number'] {
border: 1px solid @gray-light;
font-size: 15px;
padding: 5px 10px;
width: 50px;
}
.minutes {
margin-left: 5px;
}
}

29
web/src/screens/titles.js Normal file
View file

@ -0,0 +1,29 @@
import React from "react";
import { Route, Switch } from "react-router-dom";
import Title from "react-title-component";
// @see https://github.com/yannickcr/eslint-plugin-react/issues/512
// eslint-disable-next-line react/display-name
export default function() {
return (
<Switch>
<Route path="/account/tokens" exact={true} component={accountTitle} />
<Route path="/account/repos" exact={true} component={accountRepos} />
<Route path="/login" exact={false} component={loginTitle} />
<Route path="/:owner/:repo" exact={false} component={repoTitle} />
<Route path="/" exact={false} component={defautTitle} />
</Switch>
);
}
const accountTitle = () => <Title render="Tokens | drone" />;
const accountRepos = () => <Title render="Repositories | drone" />;
const loginTitle = () => <Title render="Login | drone" />;
const repoTitle = ({ match }) => (
<Title render={`${match.params.owner}/${match.params.repo} | drone`} />
);
const defautTitle = () => <Title render="Welcome | drone" />;

View file

@ -0,0 +1,3 @@
import { List, Item } from "./list";
export { List, Item };

View file

@ -0,0 +1,40 @@
import React, { Component } from "react";
import { Link } from "react-router-dom";
import { LaunchIcon } from "shared/components/icons";
import { Switch } from "./switch";
import styles from "./list.less";
export const List = ({ children }) => (
<div className={styles.list}>{children}</div>
);
export class Item extends Component {
render() {
const { owner, name, active, link, onchange } = this.props;
return (
<div className={styles.item}>
<div>
{owner}/{name}
</div>
<div className={active ? styles.active : styles.inactive}>
<Link to={link}>
<LaunchIcon />
</Link>
</div>
<div>
<Switch onchange={onchange} checked={active} />
</div>
</div>
);
}
shouldComponentUpdate(nextProps) {
return (
this.props.owner !== nextProps.owner ||
this.props.name !== nextProps.name ||
this.props.active !== nextProps.active
);
}
}

View file

@ -0,0 +1,39 @@
@import '~shared/styles/colors';
.item {
border-bottom: 1px solid @gray-light;
display: flex;
padding: 10px 10px;
&:last-child {
border-bottom-width: 0px;
}
&> div:first-child {
flex: 1 1 auto;
line-height: 24px;
}
&> div:nth-child(3) {
align-content: stretch;
display: flex;
flex-direction: column;
justify-content: center;
text-align: right;
}
a {
margin-right: 20px;
width: 100px;
svg {
fill: @gray;
height: 20px;
width: 20px;
}
}
.inactive {
display: none;
}
}

View file

@ -0,0 +1,13 @@
import React, { Component } from "react";
import styles from "./switch.less";
export class Switch extends Component {
render() {
const { checked, onchange } = this.props;
return (
<label className={styles.switch}>
<input type="checkbox" checked={checked} onChange={onchange} />
</label>
);
}
}

View file

@ -0,0 +1,62 @@
@import '~shared/styles/colors';
.switch {
label {
align-items: center;
cursor: pointer;
display: flex;
margin-bottom: 10px;
}
input[type='checkbox'] {
-moz-appearance: none;
-ms-appearance: none;
-webkit-appearance: none;
appearance: none;
cursor: pointer;
height: 12px;
margin-right: 30px;
outline: none;
position: relative;
width: 12px;
}
input[type='checkbox']::before,
input[type='checkbox']::after {
content: '';
position: absolute;
}
input[type='checkbox']::before {
background-color: lighten(@gray, 15%);
border-radius: 30px;
height: 100%;
transform: translate(-25%, 0);
transition: all 0.25s ease-in-out;
width: 250%;
}
input[type='checkbox']::after {
background-color: @gray;
border-radius: 30px;
height: 150%;
margin-left: 10%;
margin-top: -25%;
transform: translate(-60%, 0);
transition: all 0.2s;
width: 150%;
}
//
// Checked
//
input[type='checkbox']:checked::after {
background-color: @green;
transform: translate(25%, 0);
}
input[type='checkbox']:checked::before {
background-color: lighten(@green, 15%);
}
}

View file

@ -0,0 +1,137 @@
import React, { Component } from "react";
import { branch } from "baobab-react/higher-order";
import { inject } from "config/client/inject";
import {
fetchRepostoryList,
disableRepository,
enableRepository,
} from "shared/utils/repository";
import { List, Item } from "./components";
import Breadcrumb, { SEPARATOR } from "shared/components/breadcrumb";
import styles from "./index.less";
const binding = (props, context) => {
return {
repos: ["repos", "data"],
loaded: ["repos", "loaded"],
error: ["repos", "error"],
};
};
@inject
@branch(binding)
export default class UserRepos extends Component {
constructor(props, context) {
super(props, context);
this.handleFilter = this.handleFilter.bind(this);
this.renderItem = this.renderItem.bind(this);
this.handleToggle = this.handleToggle.bind(this);
}
handleFilter(e) {
this.setState({
search: e.target.value,
});
}
handleToggle(repo, e) {
const { dispatch, drone } = this.props;
if (e.target.checked) {
dispatch(enableRepository, drone, repo.owner, repo.name);
} else {
dispatch(disableRepository, drone, repo.owner, repo.name);
}
}
componentWillMount() {
if (!this._dispatched) {
this._dispatched = true;
this.props.dispatch(fetchRepostoryList, this.props.drone);
}
}
shouldComponentUpdate(nextProps, nextState) {
return (
this.props.repos !== nextProps.repos ||
this.state.search !== nextState.search
);
}
render() {
const { repos, loaded, error } = this.props;
const { search } = this.state;
const list = Object.values(repos || {});
if (error) {
return ERROR;
}
if (!loaded) {
return LOADING;
}
if (list.length === 0) {
return EMPTY;
}
const filter = repo => {
return !search || repo.full_name.indexOf(search) !== -1;
};
const filtered = list.filter(filter);
return (
<div>
<div className={styles.search}>
<input
type="text"
placeholder="Search …"
onChange={this.handleFilter}
/>
</div>
<div className={styles.root}>
{filtered.length === 0 ? NO_MATCHES : null}
<List>{list.filter(filter).map(this.renderItem)}</List>
</div>
</div>
);
}
renderItem(repo) {
return (
<Item
key={repo.full_name}
owner={repo.owner}
name={repo.name}
active={repo.active}
link={`/${repo.full_name}`}
onchange={this.handleToggle.bind(this, repo)}
/>
);
}
}
const LOADING = <div>Loading</div>;
const EMPTY = <div>Your repository list is empty</div>;
const NO_MATCHES = <div>No matches found</div>;
const ERROR = <div>Error</div>;
/* eslint-disable react/jsx-key */
export class UserRepoTitle extends Component {
render() {
return (
<Breadcrumb
elements={[<span>Account</span>, SEPARATOR, <span>Repositories</span>]}
/>
);
}
}
/* eslint-enable react/jsx-key */

View file

@ -0,0 +1,31 @@
@import '~shared/styles/colors';
.root {
padding: 20px;
}
.search {
input {
border: 0px;
border-bottom: 1px solid @gray-light;
box-sizing: border-box;
font-size: 15px;
height: 45px;
line-height: 24px;
outline: none;
padding: 0px 20px;
width: 100%;
}
::-moz-input-placeholder {
color: @gray;
font-size: 15px;
font-weight: 300;
}
::-webkit-input-placeholder {
color: @gray;
font-size: 15px;
font-weight: 300;
}
}

View file

@ -0,0 +1,41 @@
import React, { Component } from "react";
import { syncRepostoryList } from "shared/utils/repository";
import { branch } from "baobab-react/higher-order";
import { inject } from "config/client/inject";
import { SyncIcon } from "shared/components/icons";
import Menu from "shared/components/menu";
const binding = (props, context) => {
return {
repos: ["repos"],
};
};
@inject
@branch(binding)
export default class UserReposMenu extends Component {
constructor(props, context) {
super(props, context);
this.handleClick = this.handleClick.bind(this);
}
handleClick() {
const { dispatch, drone } = this.props;
dispatch(syncRepostoryList, drone);
}
render() {
const { loaded } = this.props.repos;
const right = (
<section>
<button disabled={!loaded} onClick={this.handleClick}>
<SyncIcon />
<span>Synchronize</span>
</button>
</section>
);
return <Menu items={[]} right={right} />;
}
}

View file

@ -0,0 +1,59 @@
import React, { Component } from "react";
import { generateToken } from "shared/utils/users";
import { branch } from "baobab-react/higher-order";
import { inject } from "config/client/inject";
import styles from "./index.less";
const binding = (props, context) => {
return {
location: ["location"],
token: ["token"],
};
};
@inject
@branch(binding)
export default class Tokens extends Component {
shouldComponentUpdate(nextProps, nextState) {
return (
this.props.location !== nextProps.location ||
this.props.token !== nextProps.token
);
}
componentWillMount() {
const { drone, dispatch } = this.props;
dispatch(generateToken, drone);
}
render() {
const { location, token } = this.props;
if (!location || !token) {
return <div>Loading</div>;
}
return (
<div className={styles.root}>
<h2>Your Personal Token:</h2>
<pre>{token}</pre>
<h2>Example API Usage:</h2>
<pre>{usageWithCURL(location, token)}</pre>
<h2>Example CLI Usage:</h2>
<pre>{usageWithCLI(location, token)}</pre>
</div>
);
}
}
const usageWithCURL = (location, token) => {
return `curl -i ${location.protocol}//${location.host}/api/user -H "Authorization: Bearer ${token}"`;
};
const usageWithCLI = (location, token) => {
return `export DRONE_SERVER=${location.protocol}//${location.host}
export DRONE_TOKEN=${token}
drone info`;
};

View file

@ -0,0 +1,25 @@
@import '~shared/styles/colors';
.root {
padding: 20px;
pre {
background: @gray-light;
font-family: 'Roboto Mono', monospace;
font-size: 12px;
margin-bottom: 40px;
max-width: 650px;
padding: 20px;
white-space: pre-line;
word-wrap: break-word;
}
h2 {
font-size: 15px;
font-weight: normal;
&:first-of-type {
margin-top: 0px;
}
}
}

View file

@ -0,0 +1,32 @@
import React from "react";
import { mount } from "enzyme";
import Status from "../status";
import {
STATUS_FAILURE,
STATUS_RUNNING,
STATUS_SUCCESS,
} from "shared/constants/status";
jest.dontMock("../status");
describe("Status component", () => {
test("updates on status change", () => {
const status = mount(<Status status={STATUS_FAILURE} />);
const instance = status.instance();
expect(
instance.shouldComponentUpdate({ status: STATUS_FAILURE }),
).toBeFalsy();
expect(
instance.shouldComponentUpdate({ status: STATUS_SUCCESS }),
).toBeTruthy();
expect(status.hasClass("failure")).toBeTruthy();
});
test("uses the status as the class name", () => {
const status = mount(<Status status={STATUS_RUNNING} />);
expect(status.hasClass("running")).toBeTruthy();
});
});

View file

@ -0,0 +1,12 @@
import React, { Component } from "react";
import styles from "./avatar.less";
export default class Avatar extends Component {
render() {
const image = this.props.image;
const style = {
backgroundImage: `url(${image})`,
};
return <div className={styles.avatar} style={style} />;
}
}

View file

@ -0,0 +1,15 @@
.avatar {
align-items: center;
display: flex;
img {
border-radius: 50%;
height: 32px;
width: 32px;
}
&.small img {
height: 28px;
width: 28px;
}
}

View file

@ -0,0 +1,21 @@
import React, { Component } from "react";
import { ExpandIcon, BackIcon } from "shared/components/icons/index";
import style from "./breadcrumb.less";
// breadcrumb separater icon.
export const SEPARATOR = <ExpandIcon size={18} className={style.separator} />;
// breadcrumb back button.
export const BACK_BUTTON = <BackIcon size={18} className={style.back} />;
// helper function to render a list item.
const renderItem = (element, index) => {
return <li key={index}>{element}</li>;
};
export default class Breadcrumb extends Component {
render() {
const { elements } = this.props;
return <ol className={style.breadcrumb}>{elements.map(renderItem)}</ol>;
}
}

View file

@ -0,0 +1,38 @@
@import '~shared/styles/colors';
.breadcrumb {
display: inline-block;
margin: 0px;
padding: 0px;
text-align: left;
li {
display: inline-block;
vertical-align: middle;
}
li > span,
li > div,
a,
a:visited,
a:active {
color: @gray-dark;
font-size: 20px;
text-decoration: none;
}
svg {
height: 24px;
vertical-align: middle;
width: 24px;
}
.svg.separator {
margin: 0px 5px;
transform: rotate(270deg);
}
.svg.back {
margin-right: 20px;
}
}

View file

@ -0,0 +1,73 @@
import React, { Component } from "react";
import {
BranchIcon,
CommitIcon,
DeployIcon,
LaunchIcon,
MergeIcon,
TagIcon,
} from "shared/components/icons/index";
import {
EVENT_TAG,
EVENT_PULL_REQUEST,
EVENT_DEPLOY,
} from "shared/constants/events";
import styles from "./build_event.less";
export default class BuildEvent extends Component {
render() {
const { event, branch, commit, refs, refspec, link, target } = this.props;
return (
<div className={styles.host}>
<div className={styles.row}>
<div>
<CommitIcon />
</div>
<div>{commit && commit.substr(0, 10)}</div>
</div>
<div className={styles.row}>
<div>
{event === EVENT_TAG ? (
<TagIcon />
) : event === EVENT_PULL_REQUEST ? (
<MergeIcon />
) : event === EVENT_DEPLOY ? (
<DeployIcon />
) : (
<BranchIcon />
)}
</div>
<div>
{event === EVENT_TAG && refs ? (
trimTagRef(refs)
) : event === EVENT_PULL_REQUEST && refspec ? (
trimMergeRef(refs)
) : event === EVENT_DEPLOY && target ? (
target
) : (
branch
)}
</div>
</div>
<a href={link} target="_blank">
<LaunchIcon />
</a>
</div>
);
}
}
const trimMergeRef = ref => {
return ref.match(/\d/g) || ref;
};
const trimTagRef = ref => {
return ref.startsWith("refs/tags/") ? ref.substr(10) : ref;
};
// push
// pull request (ref)
// tag (ref)
// deploy

View file

@ -0,0 +1,34 @@
@import '~shared/styles/utils';
.host {
position: relative;
svg {
height: 18px;
width: 18px;
}
a {
display: block;
position: absolute;
right: 0px;
top: 0px;
}
}
.row {
display: flex;
:first-child {
align-items: center;
display: flex;
margin-right: 5px;
}
:last-child {
flex: 1;
font-size: 14px;
line-height: 24px;
.text-ellipsis
}
}

View file

@ -0,0 +1,37 @@
import React, { Component } from "react";
import { ScheduleIcon, TimelapseIcon } from "shared/components/icons/index";
import TimeAgo from "react-timeago";
import Duration from "./duration";
import styles from "./build_time.less";
export default class Runtime extends Component {
render() {
const { start, finish } = this.props;
return (
<div className={styles.host}>
<div className={styles.row}>
<div>
<ScheduleIcon />
</div>
<div>{start ? <TimeAgo date={start * 1000} /> : <span>--</span>}</div>
</div>
<div className={styles.row}>
<div>
<TimelapseIcon />
</div>
<div>
{finish ? (
<Duration start={start} finished={finish} />
) : start ? (
<TimeAgo date={start * 1000} />
) : (
<span>--</span>
)}
</div>
</div>
</div>
);
}
}

View file

@ -0,0 +1,23 @@
.host {
svg {
height: 16px;
width: 16px;
}
}
.row {
display: flex;
:first-child {
align-items: center;
display: flex;
margin-right: 5px;
}
:last-child {
flex: 1;
font-size: 14px;
line-height: 24px;
white-space: nowrap;
}
}

View file

@ -0,0 +1,62 @@
import React, { Component } from "react";
import CloseIcon from "shared/components/icons/close";
import styles from "./drawer.less";
import { CSSTransitionGroup } from "react-transition-group";
export const DOCK_LEFT = styles.left;
export const DOCK_RIGHT = styles.right;
export class Drawer extends Component {
render() {
const { open, position } = this.props;
let classes = [styles.drawer];
if (open) {
classes.push(styles.open);
}
if (position) {
classes.push(position);
}
var child = open ? (
<div key={0} onClick={this.props.onClick} className={styles.backdrop} />
) : null;
return (
<div className={classes.join(" ")}>
<CSSTransitionGroup
transitionName="fade"
transitionEnterTimeout={150}
transitionLeaveTimeout={150}
transitionAppearTimeout={150}
transitionAppear={true}
transitionEnter={true}
transitionLeave={true}
>
{child}
</CSSTransitionGroup>
<div className={styles.inner}>{this.props.children}</div>
</div>
);
}
}
export class CloseButton extends Component {
render() {
return (
<button className={styles.close} onClick={this.props.onClick}>
<CloseIcon />
</button>
);
}
}
export class MenuButton extends Component {
render() {
return (
<button className={styles.close} onClick={this.props.onClick}>
Show Menu
</button>
);
}
}

View file

@ -0,0 +1,182 @@
@import '~shared/styles/colors';
//
// backdrop
//
.backdrop {
background-color: rgba(0, 0, 0, 0.54);
bottom: 0px;
left: 0px;
position: fixed;
right: 0px;
top: 0px;
}
//
// drawer wrapper
//
.inner {
background: @white;
bottom: 0px;
box-shadow: 0px 8px 10px -5px rgba(0, 0, 0, 0.2), 0px 16px 24px 2px rgba(0, 0, 0, 0.14), 0px 6px
30px 5px rgba(0, 0, 0, 0.12);
box-sizing: border-box;
display: flex;
flex-direction: column;
left: 0px;
overflow: hidden;
position: fixed;
right: 0px;
top: 0px;
transition: left ease-in 0.15s;
width: 300px;
}
//
// drawer
//
.drawer {
display: none;
height: 0px;
left: -1000px;
position: fixed;
top: -1000px;
width: 0px;
&.open {
display: flex;
.inner {
left: 0px;
transition: left ease-in 0.15s;
}
}
&.right {
.inner {
left: auto;
right: 0px;
}
}
}
//
// close button
//
.close {
align-items: center;
background: transparent;
border: 0px;
cursor: pointer;
display: flex;
margin: 0px;
outline: none;
padding: 10px 10px;
text-align: right;
width: 100%;
svg {
fill: @gray-light;
}
}
.right .close {
flex-direction: row-reverse;
}
//
// menu
//
.drawer ul {
border-top: 1px solid @gray-light;
margin: 0px;
padding: 10px 0px;
li {
display: block;
margin: 0px;
padding: 0px 10px;
}
a {
color: @gray-dark;
display: block;
line-height: 32px;
padding: 0px 10px;
text-decoration: none;
&:hover {
background: @gray-light;
}
}
button {
align-items: center;
background: @white;
border: 0px;
cursor: pointer;
display: flex;
margin: 0px;
padding: 0px 10px;
width: 100%;
&:hover {
background: @gray-light;
}
&[disabled] {
color: @gray;
cursor: wait;
&:hover {
background: @gray-light;
}
svg {
fill: @gray;
}
}
span {
flex: 1;
line-height: 32px;
padding-left: 10px;
text-align: left;
}
svg {
display: inline-block;
height: 22px;
width: 22px;
}
}
}
.drawer > section:first-of-type ul {
border-top: 0px;
}
:global {
.fade-enter {
opacity: 0.01;
&.fade-enter-active {
opacity: 1;
transition: opacity 150ms ease-in;
}
}
.fade-leave {
opacity: 1;
&.fade-leave-active {
opacity: 0.01;
transition: opacity 150ms ease-in;
}
}
}

Some files were not shown because too many files have changed in this diff Show more