@@ -0,0 +1,220 @@
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 BuildMenu from "screens/repo/screens/build/menu";
import BuildLogs, { BuildLogsTitle } from "screens/repo/screens/build";
import RepoMenu from "screens/repo/screens/build/menu";

import { Snackbar } from "shared/components/snackbar";
import {
Drawer,
CloseButton,
DOCK_RIGHT,
DOCK_LEFT,
} 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" 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>

<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}>
<Switch>
<Route
path="/account/repos"
exact={true}
component={UserReposMenu}
/>
<Route
path="/account/"
exact={false}
component={undefined}
/>BuildMenu
<Route
path="/:owner/:repo/:build(\d*)"
exact={false}
component={BuildMenu}
/>
<Route path="/:owner/:repo" exact={false} component={RepoMenu} />
</Switch>
<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"]);
});
}
}
@@ -0,0 +1,88 @@

.title {
border-bottom: 1px solid #ECEFF1;
display: flex;
box-sizing: border-box;
height: 60px;
display: flex;
align-items: center;

&> :first-child {
flex: 1;
}
padding:0px 20px;

.avatar {
display: flex;
align-items: center;

img {
border-radius: 50%;
width: 28px;
height: 28px;
}
}

button {
background:#FFF;
padding:0px;
margin:0px;
border:none;
cursor: pointer;
margin-left:10px;
outline:none;
display: flex;
align-items: stretch;
}
}

.left {
position:fixed;
top:0px;
left:0px;
right:0px;
width:300px;
overflow:hidden;
overflow-y: auto;
bottom:0px;
border-right:1px solid #CFD8DC;
box-sizing: border-box;
}

.center {
// position:fixed;
// top:0px;
// left:300px;
// right:0px;
// bottom:0px;
// overflow:scroll;
box-sizing: border-box;
padding-left:300px;
}

.login {
padding:0px 30px;
text-align: center;
line-height: 50px;
box-sizing: border-box;
text-transform: uppercase;
display: block;
text-decoration: none;
color: #FFF;
font-size: 15px;
background: #fdb835;
text-shadow: 0 1px 2px rgba(0,0,0,0.1);

// HACK
margin-top:-1px;
}

.guest {
.left {
display: none;
}

.center {
padding-left: 0px;
}
}
@@ -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;
@@ -0,0 +1,35 @@
@font: "Roboto";
@red: #fc4758;
@yellow: #fdb835;

.root {
margin:50px auto;
min-width: 400px;
max-width: 400px;
padding: 30px;
box-sizing: border-box;

.alert {
margin-bottom: 20px;
text-align: left;
display: block;
color: #FFF;
background: @yellow;
padding: 20px;
display:flex;

&> :last-child {
padding-top: 2px;
padding-left: 10px;
line-height: 20px;
font-family: @font;
font-size: 15px;
}
}

svg {
fill: #FFF;
width: 26px;
height: 26px;
}
}
@@ -0,0 +1,21 @@
import React, { Component } 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;
@@ -0,0 +1,67 @@
@font: "Roboto";

.login {
margin-top: 50px;

p {
color: #424242;
padding: 0px;
margin: 0px;
margin-bottom: 30px;
text-align: center;
line-height: 22px;
font-family: @font;
user-select: none;
}

input {
outline: none;
display: block;
width: 100%;
box-sizing: border-box;

&[type=password],
&[type=text] {
padding: 10px;
margin-bottom: 20px;
border: 1px solid #ECEFF1;
background: #FFF;
font-family: @font;

&:focus {
border: 1px solid #424242;
}
}

&[type=submit] {
color: #FFF;
border: none;
background: #424242;
line-height: 36px;
font-family: @font;
user-select: none;
}
}

form {
margin:0px auto;
min-width: 400px;
max-width: 400px;
padding: 30px;
box-sizing: border-box;
}

::-moz-input-placeholder {
color: #CFD8DC;
font-weight:300;
font-size:16px;
user-select: none;
}

::-webkit-input-placeholder {
color: #CFD8DC;
font-weight:300;
font-size:16px;
user-select: none;
}
}
@@ -0,0 +1,4 @@
import LoginForm from "./form";
import LoginError from "./error";

export { LoginForm, LoginError };
@@ -0,0 +1,30 @@
import React, { Component } from "react";
import { Redirect } from "react-router-dom";
import { branch } from "baobab-react/higher-order";

const binding = (props, context) => {
return { feed: ["feed"], user: ["user", "data"] };
};

@branch(binding)
export default class RedirectRoot extends Component {
componentWillReceiveProps(nextProps) {
const { user } = nextProps;
if (!user && window) {
window.location.href = "/login";
}
}
render() {
const { latest, loaded } = this.props.feed;
console.log(latest, loaded);
return !loaded ? (
undefined
) : !latest ? (
<Redirect to="/account/repos" />
) : !latest.number ? (
<Redirect to={`/${latest.full_name}`} />
) : (
<Redirect to={`/${latest.full_name}/${latest.number}`} />
);
}
}
@@ -0,0 +1,10 @@
import React, { Component } 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>
);
@@ -0,0 +1,32 @@
.root {
background: #fdb835;
margin-bottom: 20px;
padding: 20px;
border-radius: 2px;

button {
margin-right: 10px;
background: rgba(255,255,255,0.2);
border: none;
color: #FFF;
font-size: 13px;
border-radius: 2px;
padding: 0px 10px;
line-height: 28px;
min-width: 100px;
cursor: pointer;
text-transform: uppercase;

&:focus {
outline: 1px solid #FFF;
border-radius: 2px;
}
}

p {
margin-top:0px;
margin-bottom:20px;
color: #FFF;
font-size: 15px;
}
}
@@ -0,0 +1,36 @@
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}>{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}
/>
</section>
</div>
);
}
}
@@ -0,0 +1,15 @@
.info {
section {
margin:20px 0px;
padding:0px 10px;
padding-bottom:20px;
line-height: 20px;
font-size: 14px;
border-bottom:1px solid #ECEFF1;

&:last-of-type {
border-bottom:0px;
margin-bottom:0px;
}
}
}
@@ -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);
};
@@ -0,0 +1,6 @@
import { Approval } from "./approval";
import { Details } from "./details";
import { MatrixList, MatrixItem } from "./matrix";
import { ProcList, ProcListItem } from "./procs";

export { Approval, Details, MatrixList, MatrixItem, ProcList, ProcListItem };
@@ -0,0 +1,34 @@
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 styles from "./matrix.less";

export const MatrixList = ({ children }) => (
<div className={styles.list}>{children}</div>
);

export const MatrixItem = ({ environ, start, finish, status, number }) => (
<div className={styles.item}>
<div className={styles.header}>
{Object.entries(environ).map(renderEnviron)}
</div>
<div className={styles.body}>
<BuildTime start={start} finish={finish} />
</div>
<div className={styles.status}>
<StatusNumber status={status} number={number} />
<Status status={status} />
</div>
</div>
);

const renderEnviron = data => {
return (
<div>
{data[0]}={data[1]}
</div>
);
};
@@ -0,0 +1,56 @@
.list {
a {
text-decoration: none;
border-bottom: 1px solid #ECEFF1;
display:block;
padding: 20px 10px;
color: #212121;
cursor: pointer;

&:hover {
background: #ECEFF1;
.body {
border-color: #CFD8DC;
}
}

&:first-of-type {
padding-top:10px;
}

&:last-of-type {
border-bottom: none;
}
}
}

.item {
display:flex;
flex-direction: row;
}

.header {
flex: 1;

div {
font-size: 14px;
line-height: 26px;
font-family: 'Roboto Mono';
}
}

.body {
border-left: 1px solid #ECEFF1;
padding-left:20px;
flex: 0 0 200px;
}

.status {
padding-left:20px;
display: flex;
align-items: right;

&> :last-child {
margin-left:20px;
}
}
@@ -0,0 +1,48 @@
import React, { Component } from "react";
import classnames from "classnames";

import Status from "shared/components/status";
import { Elapsed, formatTime } from "./elapsed";

import styles from "./procs.less";

export const ProcList = ({ children }) => (
<div className={styles.list}>{children}</div>
);

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>
);

// function List({ children }) {
// return <div className={styles.list}>{children}</div>;
// }
//
// function ListItem({ name, start, finish, state, selected }) {
// const classes = classnames(styles.item, selected ? styles.selected : null);
// return (
// <div className={classes}>
// <h3>{name}</h3>
//
// {finish ? (
// <time>{formatTime(finish, start)}</time>
// ) : (
// <Timer start={start} />
// )}
//
// <div>
// <Status status={state} />
// </div>
// </div>
// );
// }
@@ -0,0 +1,43 @@
.list {
a {
display:block;
text-decoration: none;
color: #212121;
}
}

.item {
box-sizing:border-box;
display:flex;
padding: 0px 10px;
background: #FFF;

&.selected,
&:hover {
background: #ECEFF1;
}

time {
color: #BDBDBD;
font-size: 13px;
line-height: 32px;
display: inline-block;
margin-right: 15px;
vertical-align: middle;
}

h3 {
margin: 0px;
padding: 0px;
font-weight: normal;
font-size: 14px;
line-height: 36px;
vertical-align: middle;
flex:1 1 auto;
}

&:last-child {
display: flex;
align-items: center;
}
}
@@ -0,0 +1,305 @@
import React, { Component } from "react";
import ReactDOM from "react-dom";
import { Link } from "react-router-dom";

import {
fetchBuild,
approveBuild,
declineBuild,
assertBuildMatrix,
} from "shared/utils/build";

import { findChildProcess } from "shared/utils/proc";
import { fetchRepository } from "shared/utils/repository";

import Breadcrumb, {
SEPARATOR,
BACK_BUTTON,
} from "shared/components/breadcrumb";

import {
Top,
Bottom,
scrollToTop,
scrollToBottom,
} from "./logs/components/anchor";

import {
Approval,
Details,
MatrixList,
MatrixItem,
ProcList,
ProcListItem,
} 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, proc } = props.match.params;
const slug = `${owner}/${repo}`;
const number = parseInt(build);
const pid = parseInt(proc || 2);

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,
);
}
}

render() {
const { repo, build, match, follow } = this.props;

if (!build) {
return this.renderLoading();
}

if (build.status === "declined" || build.status === "error") {
return this.renderError();
}

if (build.status === "blocked") {
return this.renderBlocked();
}

if (!build.procs) {
return this.renderLoading();
}

if (assertBuildMatrix(build)) {
return this.renderMatrix();
}

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 === "error" ? (
build.error
) : (
"Pipeline execution was declined"
)}
</div>
</div>
</div>
</div>
);
}

renderSimple() {
const { repo, build, match, follow } = this.props;
const proc = findChildProcess(build.procs || [], match.params.proc || 2);
const parent = findChildProcess(build.procs, proc.ppid);

let data = Object.assign({}, build);
if (assertBuildMatrix(data)) {
data.started_at = parent.start_time;
data.finish_at = parent.finish_time;
data.status = parent.state;
}

return (
<div className={styles.host}>
<div className={styles.columns}>
<div className={styles.right}>
<Details build={data} />
<section className={styles.sticky}>
<ProcList>
{parent.children.map(function(child) {
return (
<Link
to={`/${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 === proc.pid}
/>
</Link>
);
})}
</ProcList>
</section>
</div>
<div className={styles.left}>
{proc && proc.error ? (
<div className={styles.logerror}>{proc.error}</div>
) : null}
{parent && parent.error ? (
<div className={styles.logerror}>{parent.error}</div>
) : null}
<Output
match={this.props.match}
build={this.props.build}
parent={parent}
proc={proc}
/>
</div>
</div>
</div>
);
}

renderMatrix() {
const { repo, build, match, follow } = this.props;

if (this.props.match.params.proc) {
return this.renderSimple();
}

return (
<div className={styles.host}>
<div className={styles.columns}>
<div className={styles.right}>
<Details build={build} />
</div>
<div className={styles.left}>
<MatrixList>
{build.procs.map(child => {
return (
<Link
to={`/${repo.full_name}/${build.number}/${child.children[0]
.pid}`}
>
<MatrixItem
number={child.pid}
start={child.start_time}
finish={child.end_time}
status={child.state}
environ={child.environ}
/>
</Link>
);
})}
</MatrixList>
</div>
</div>
</div>
);
}
}

export class BuildLogsTitle extends Component {
render() {
const { owner, repo, build } = this.props.match.params;
return (
<Breadcrumb
elements={[
<Link to={`/${owner}/${repo}`}>
{owner} / {repo}
</Link>,
SEPARATOR,
<Link to={`/${owner}/${repo}/${build}`}>{build}</Link>,
]}
/>
);
}
}
@@ -0,0 +1,49 @@
.host {
padding: 0px 20px;
padding-bottom: 20px;
padding-right:0px;

.columns {
display: flex;

.left {
flex: 1;
padding-top: 20px;
padding-right: 20px;
min-width: 0px;
box-sizing: border-box;
}

.right {
box-sizing: border-box;
padding-top: 20px;
padding-right: 20px;
flex: 0 0 350px;
min-width: 0px;

&> section {
border-top: 1px solid #ECEFF1;
padding-top:20px;
}
}
}
}

section.sticky {
position: sticky;
top: 0px;

&:stuck {
border-top-width: 0px;
}
}

.logerror {
background: #ECEFF1;
color: #fc4758;
padding: 20px;
display: block;
border-radius:2px;
font-size: 14px;
margin-bottom:10px;
}
@@ -0,0 +1,15 @@
import React, { Component } 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();
};
@@ -0,0 +1,3 @@
.top, .bottom {
font-size: 0px;
}
@@ -0,0 +1,72 @@
import React, { Component } from "react";
import ansi_up from "ansi_up";
import style from "./term.less";

let formatter = new ansi_up();
formatter.use_classes = true;

class Term extends Component {
render() {
const { lines, exitcode } = this.props;
return (
<div className={style.term}>
{lines.map(renderTermLine)}
{exitcode !== undefined ? renderExitCode(exitcode) : undefined}
</div>
);
}

shouldComponentUpdate(nextProps, nextState) {
return (
this.props.lines !== nextProps.lines ||
this.props.exitcode !== nextProps.exitcode
);
}
}

class TermLine extends Component {
render() {
const { line } = this.props;
return (
<div className={style.line} key={line.pos}>
<div>{line.pos + 1}</div>
<div dangerouslySetInnerHTML={{ __html: this.colored }} />
<div>{line.time || 0}s</div>
</div>
);
}

get colored() {
return formatter.ansi_to_html(this.props.line.out || "");
}

shouldComponentUpdate(nextProps, nextState) {
return this.props.line.out !== nextProps.line.out;
}
}

const renderTermLine = line => {
return <TermLine line={line} />;
};

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>;
};

Term.Line = TermLine;
Term.Error = TermError;
Term.Loading = TermLoading;

export default Term;
@@ -0,0 +1,112 @@
.term {
padding: 20px;
border-radius: 2px;
background: #ECEFF1;

.exitcode {
margin-top: 10px;
padding: 0;
min-width: 20px;
color: rgba(0,0,0,.3);
user-select: none;
font-family:'Roboto Mono',monospace;
font-size: 13px;
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
}
}


.line {
color: #212121;
line-height: 19px;
display: flex;
max-width:100%;

* {
font-family:'Roboto Mono',monospace;
font-size: 12px;
}

div:first-child {
padding-right: 20px;
min-width: 20px;
color: rgba(0,0,0,.3);
-webkit-user-select: none;
user-select: none;
}

div:nth-child(2) {
flex: 1 1 auto;
min-width: 0;
white-space: pre-wrap;
word-wrap: break-word;
}

div:last-child {
padding-left: 20px;
color: rgba(0,0,0,.3);
-webkit-user-select: none;
user-select: none;
}
}

// log loading message
.loading {
padding: 20px;
border-radius: 2px;
background: #ECEFF1;
font-family: 'Roboto Mono', monospace;
font-size: 13px;
}

// log error message
.error {
background: #ECEFF1;
color: #fc4758;
padding: 20px;
border-radius:2px;
font-size: 14px;
margin-bottom:10px;
}

// ansi terminal color and background settings
// DO NOT EDIT
:global {
.ansi-black-fg { color: #151515; }
.ansi-red-fg { color: #fb9fb1; }
.ansi-green-fg { color: #acc267; }
.ansi-yellow-fg { color: #ddb26f;}
.ansi-blue-fg { color: #6fc2ef; }
.ansi-magenta-fg { color: #e1a3ee;}
.ansi-cyan-fg { color: #12cfc0; }
.ansi-white-fg { color: #d0d0d0; }

.ansi-bright-black-fg { color: #505050; }
.ansi-bright-red-fg { color: #fb9fb1; }
.ansi-bright-green-fg {color: #acc267;}
.ansi-bright-yellow-fg {color: #ddb26f;}
.ansi-bright-blue-fg {color: #6fc2ef;}
.ansi-bright-magenta-fg {color: #e1a3ee;}
.ansi-bright-cyan-fg {color: #12cfc0; }
.ansi-bright-white-fg { color: #f5f5f5; }

.ansi-black-bg { background-color: #151515; }
.ansi-red-bg { background-color: #fb9fb1; }
.ansi-green-bg { background-color: #acc267; }
.ansi-yellow-bg { background-color: #ddb26f;}
.ansi-blue-bg { background-color: #6fc2ef; }
.ansi-magenta-bg { background-color: #e1a3ee;}
.ansi-cyan-bg { background-color: #12cfc0; }
.ansi-white-bg { background-color: #d0d0d0; }

.ansi-bright-black-bg { background-color: #505050; }
.ansi-bright-red-bg { background-color: #fb9fb1; }
.ansi-bright-green-bg {background-color: #acc267;}
.ansi-bright-yellow-bg {background-color: #ddb26f;}
.ansi-bright-blue-bg {background-color: #6fc2ef;}
.ansi-bright-magenta-bg {background-color: #e1a3ee;}
.ansi-bright-cyan-bg {background-color: #12cfc0; }
.ansi-bright-white-bg { background-color: #f5f5f5; }
}
@@ -0,0 +1,145 @@
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, proc } = props.match.params;
const slug = repositorySlug(owner, repo);
const number = parseInt(build);
const pid = parseInt(proc || 2);

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)) {
console.log("fetch logs", nextProps.proc.pid);
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)) {
console.log("stream logs", nextProps.proc.pid);
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 } = this.props;

if (loading || !proc) {
return <Term.Loading />;
}

if (error) {
return <Term.Error />;
}

return (
<div>
<Top />
<Term
lines={logs || []}
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>
);
@@ -0,0 +1,104 @@
.loading {
padding: 20px;
border-radius: 2px;
background: #ECEFF1;
font-family: 'Roboto Mono', monospace;
font-size: 12px;
}

.error {
background: #ECEFF1;
color: #fc4758;
padding: 20px;
border-radius:2px;
font-size: 14px;
margin-bottom:10px;
}

.actions {
position: fixed;
bottom: 30px;
right: 30px;
display:flex;
flex-direction: row;

button {
background: #FFF;
border: none;
outline: none;
border: 1px solid #CFD8DC;
margin-left:-1px;
padding:0px;
display:flex;
flex-direction: row;
align-items: center;
padding:2px;
cursor: pointer;
color: #546E7A;
justify-content: center;
min-width: 32px;
min-height: 32px;

&.bottom svg {
transform: rotate(180deg);
}

&.follow svg,
&.unfollow svg {
width: 18px;
height: 18px;
}
}

svg {
fill: #546E7A;
}
}




.logactions {
position: fixed;
bottom: 30px;
right: 30px;
display:flex;

div {
display: flex;
}

button {
background: #FFF;
border: none;
outline: none;
border: 1px solid #CFD8DC;
margin-left:-1px;
padding:0px;
display:flex;
flex-direction: row;
align-items: center;
padding:2px;
cursor: pointer;
color: #546E7A;
justify-content: center;
min-width: 32px;
min-height: 32px;

svg {
fill: #546E7A;
}

&.gotoTop {
transform: rotate(180deg);
}

&.followButton {
svg { width: 18px; height: 18px; }
}

&.unfollowButton {
svg { width: 18px; height: 18px; }
}
}
}
@@ -0,0 +1,81 @@
import React, { Component } from "react";
import { Link } from "react-router-dom";
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, match } = this.props;
const { owner, repo } = this.props.match;
return (
<div>
{!build ? (
undefined
) : (
<section>
<ul>
<li>
{build.status === "peding" || build.status === "running" ? (
<button onclick={this.handleCancel}>
<CloseIcon />
<span>Cancel</span>
</button>
) : (
<button onclick={this.handleRestart}>
<RefreshIcon />
<span>Restart Build</span>
</button>
)}
</li>
</ul>
</section>
)}
<RepoMenu {...this.props} />
</div>
);
}
}
@@ -0,0 +1,3 @@
import { List, Item } from "./list";

export { List, Item };
@@ -0,0 +1,79 @@
import React, { Component } from "react";
import TimeAgo from "react-timeago";

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;

let eventDesc;
let eventDest;

switch (build.event) {
case "push":
eventDesc = "pushed to";
eventDest = build.branch;
break;
case "pull_request":
eventDesc = "updated pull request";
eventDest = build.refspec != "" ? build.refspec : build.branch;
break;
case "tag":
eventDesc = "pushed tag";
eventDest = build.ref;
break;
case "deployment":
eventDesc = "deployed to";
eventDest = build.deploy_to;
break;
}

return (
<div className={styles.root}>
<div className={styles.icon}>
<img src={build.author_avatar} />
</div>
<div className={styles.body}>
<h3>{build.message}</h3>

<div className={styles.description} style={{ display: "none" }}>
<em>{build.author}</em>
<span>{eventDesc}</span>
<em>{eventDest}</em>
</div>
</div>

<div className={styles.meta}>
<BuildMeta
link={build.link_url}
event={build.event}
commit={build.commit}
branch={build.branch}
/>
</div>

<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>
);
}
}
@@ -0,0 +1,122 @@
.list {
a {
display: block;
text-decoration: none;
color: #222;
padding: 20px 0px;
border-bottom: 1px solid #EEE;
box-sizing: border-box;

&:last-child {
border-bottom: none;
}
}
}

.root a {
display: block;
text-decoration: none;
color: #000;
padding:20px 0px;
border-top: 1px solid #ECEFF1;
}

.root {
display:block;
display:flex;
}

.root h3 {
margin: 0;
line-height: 22px;
min-height: 22px;
font-size: 15px;
font-weight:normal;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.root em {
font-style: normal;
font-size: 14px;
}
.root span {
margin: 0 5px;
font-size: 14px;
color: #9e9e9e;
}
.icon {
width: 22px;
min-width: 22px;
max-width: 22px;
margin-right:20px;
margin-left:10px;
}
.icon img {
border-radius: 50%;
width:22px;
height:22px;
}
.status {
// width: 125px;
// min-width: 125px;
// max-width: 125px;
text-align: right;
white-space: nowrap;
display:inline-block;
}
.status span {
/*padding-right:10px;
line-height: 28px;*/
display: inline-block;
color: #4dc89a;
border: 2px solid #4dc89a;
text-align: center;
border-radius: 2px;
line-height: 20px;
min-width:65px;
margin-right:10px;
}
/*.status span:before {
content:'#';
padding-right:7px;
}*/
.status div {
vertical-align: middle;
display:inline-block;

&:last-child {
margin-left: 20px;
}
}
.body {
flex: 1;
}
// .root time {
// font-size: 12px;
// display: block;
// margin: 5px 0;
// color: #9e9e9e;
// }

.meta {
padding-left: 20px;
padding-right: 20px;
margin-left: 20px;
margin-right: 20px;
border-left:1px solid #ECEFF1;
border-right:1px solid #ECEFF1;
box-sizing: border-box;
min-width: 200px;
flex: 0 0 200px;
}

.time {
padding-right: 20px;
margin-right: 20px;
// border-right:1px solid #ECEFF1;
box-sizing: border-box;
min-width: 200px;
flex: 0 0 200px;
}
@@ -0,0 +1,26 @@
import React, { Component } from "react";
import { Link } from "react-router-dom";

import Breadcrumb, {
SEPARATOR,
BACK_BUTTON,
} from "shared/components/breadcrumb";

import style from "./header.less";

export default class Header extends Component {
render() {
const { owner, repo, build } = this.props.match.params;
return (
<div>
<Breadcrumb
elements={[
<Link to={`/${owner}/${repo}`}>
{owner} / {repo}
</Link>,
]}
/>
</div>
);
}
}
Empty file.
@@ -0,0 +1,92 @@
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 {
componentWillMount() {
this.synchronize(this.props);
}

shouldComponentUpdate(nextProps, nextState) {
return (
this.props.repo !== nextProps.repo ||
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);
}

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 (list.length === 0) {
return <div>Build list is empty</div>;
}

return (
<div className={styles.root}>
<List>{list.sort(compareBuild).map(renderBuild)}</List>
</div>
);
}
}
@@ -0,0 +1,3 @@
.root {
padding: 20px;
}
@@ -0,0 +1,28 @@
import React, { Component } from "react";
import { Link } from "react-router-dom";

import styles from "./menu.less";

export default class RepoMenu extends Component {
render() {
const { owner, repo } = this.props.match.params;
return (
<section>
<ul>
<li>
<Link to={`/${owner}/${repo}`}>Builds</Link>
</li>
<li>
<Link to={`/${owner}/${repo}/settings/secrets`}>Secrets</Link>
</li>
<li>
<Link to={`/${owner}/${repo}/settings/registry`}>Registry</Link>
</li>
<li>
<Link to={`/${owner}/${repo}/settings`}>Settings</Link>
</li>
</ul>
</section>
);
}
}
@@ -0,0 +1,14 @@
.root {
border-bottom: 1px solid #EEE;
box-sizing: border-box;
line-height: 45px;
padding: 0px 20px;
height: 45px;

a {
margin-right: 20px;
font-size: 15px;
color: #212121;
text-decoration: none;
}
}
@@ -0,0 +1,45 @@
import React, { Component } from "react";
import styles from "./form.less";

export class Form extends Component {
constructor(props) {
super(props);
this.clear = this.clear.bind(this);
this._handleSubmit = this._handleSubmit.bind(this);
}

_handleSubmit() {
const { onsubmit } = this.props;
const detail = {
address: this.refs.address.value,
username: this.refs.username.value,
password: this.refs.password.value,
};
onsubmit({ detail });
this.clear();
}

clear() {
this.refs.address.value = "";
this.refs.username.value = "";
this.refs.password.value = "";
}

render() {
const { onsubmit } = this.props;
return (
<div className={styles.form}>
<input
type="text"
ref="address"
placeholder="Registry Address (e.g. docker.io)"
/>
<input type="text" ref="username" placeholder="Registry Username" />
<textarea rows="1" ref="password" placeholder="Registry Password" />
<div className={styles.actions}>
<button onClick={this._handleSubmit}>Save</button>
</div>
</div>
);
}
}
@@ -0,0 +1,63 @@
.form {
input {
border: 1px solid #ECEFF1;
display: block;
width: 100%;
box-sizing: border-box;
outline: none;
padding: 10px;
margin-bottom:20px;

&:focus {
border: 1px solid #212121;
}
}

textarea {
box-sizing: border-box;
border: 1px solid #ECEFF1;
display: block;
width: 100%;
outline: none;
padding: 10px;
margin-bottom:20px;
height: 100px;

&:focus {
border: 1px solid #212121;
}
}

.actions {
text-align: right;
}

button {
outline: none;
color: #212121;
border: 1px solid #212121;
background: #FFF;
line-height: 28px;
padding:0px 20px;
font-family: "Roboto";
user-select: none;
font-size: 14px;
text-transform: uppercase;
border-radius: 2px;
cursor: pointer;
}

::-moz-input-placeholder {
color: #CFD8DC;
font-weight:300;
font-size:15px;
user-select: none;
}

::-webkit-input-placeholder {
color: #CFD8DC;
font-weight:300;
font-size:15px;
user-select: none;
}
}
@@ -0,0 +1,4 @@
import { Form } from "./form";
import { List, Item } from "./list";

export { Form, List, Item };
@@ -0,0 +1,15 @@
import React, { Component } 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>
);
@@ -0,0 +1,51 @@
@delete-button-color: #fc4758;
@save-button-color: #212121;

.list {

}

.item {
display: flex;
border-bottom: 1px solid #ECEFF1;
padding:10px 10px;
padding-bottom:20px;

&:last-child {
border-bottom: none;
}

&:first-child {
padding-top:0px;
}

&> div:first-child {
flex: 1 1 auto;
line-height:24px;
font-size:15px;
text-transform: lowercase;
line-height: 32px;
}

&> div:last-child {
text-align: right;
display: flex;
align-content: stretch;
justify-content: center;
flex-direction: column;
}

button {
background: #fff;
color: @delete-button-color;
border: 1px solid @delete-button-color;
text-decoration: none;
text-align: center;
border-radius:2px;
text-transform: uppercase;
font-size: 13px;
padding: 2px 10px;
display: block;
cursor: pointer;
}
}
@@ -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>
);
@@ -0,0 +1,30 @@
.root {
padding: 20px;
display: flex;
}

.left {
flex: 1;
margin-right:20px;
}

.right {
flex: 1;
border-left: 1px solid #ECEFF1;
padding-left:20px;
padding-top:10px;
}

@media (max-width: 960px) {
.root {
flex-direction: column;
}
.list {
margin-right: 0px;
}
.right {
border-left: none;
padding-left:0px;
padding-top:20px;
}
}
@@ -0,0 +1,38 @@
import React, { Component } from "react";
import styles from "./form.less";

export class Form extends Component {
constructor(props) {
super(props);
this.clear = this.clear.bind(this);
this._handleSubmit = this._handleSubmit.bind(this);
}

_handleSubmit() {
const { onsubmit } = this.props;
const detail = {
name: this.refs.name.value,
value: this.refs.value.value,
};
onsubmit({ detail });
this.clear();
}

clear() {
this.refs.name.value = "";
this.refs.value.value = "";
}

render() {
const { onsubmit } = this.props;
return (
<div className={styles.form}>
<input type="text" ref="name" placeholder="Secret Name" />
<textarea rows="1" ref="value" placeholder="Secret Value" />
<div className={styles.actions}>
<button onClick={this._handleSubmit}>Save</button>
</div>
</div>
);
}
}
@@ -0,0 +1,63 @@
.form {
input {
border: 1px solid #ECEFF1;
display: block;
width: 100%;
box-sizing: border-box;
outline: none;
padding: 10px;
margin-bottom:20px;

&:focus {
border: 1px solid #212121;
}
}

textarea {
box-sizing: border-box;
border: 1px solid #ECEFF1;
display: block;
width: 100%;
outline: none;
padding: 10px;
margin-bottom:20px;
height: 100px;

&:focus {
border: 1px solid #212121;
}
}

.actions {
text-align: right;
}

button {
outline: none;
color: #212121;
border: 1px solid #212121;
background: #FFF;
line-height: 28px;
padding:0px 20px;
font-family: "Roboto";
user-select: none;
font-size: 14px;
text-transform: uppercase;
border-radius: 2px;
cursor: pointer;
}

::-moz-input-placeholder {
color: #CFD8DC;
font-weight:300;
font-size:15px;
user-select: none;
}

::-webkit-input-placeholder {
color: #CFD8DC;
font-weight:300;
font-size:15px;
user-select: none;
}
}
@@ -0,0 +1,4 @@
import { Form } from "./form";
import { List, Item } from "./list";

export { Form, List, Item };
@@ -0,0 +1,22 @@
import React, { Component } 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}
<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>;
};
@@ -0,0 +1,71 @@
@delete-button-color: #fc4758;
@save-button-color: #212121;

.list {

}

.item {
display: flex;
border-bottom: 1px solid #ECEFF1;
padding:10px 10px;
padding-bottom:20px;

&:last-child {
border-bottom: none;
}

&:first-child {
padding-top:0px;
}

&> div:first-child {
flex: 1 1 auto;
line-height:24px;
font-size:15px;
text-transform: lowercase;
line-height: 32px;
}

&> div:last-child {
text-align: right;
display: flex;
align-content: stretch;
justify-content: center;
flex-direction: column;
}

button {
background: #fff;
color: @delete-button-color;
border: 1px solid @delete-button-color;
text-decoration: none;
text-align: center;
border-radius:2px;
text-transform: uppercase;
font-size: 13px;
padding: 2px 10px;
display: block;
cursor: pointer;
}

ul {
padding: 0px;
margin: 0px;
list-style: none;
line-height: 0px;
}

li {
display:inline-block;
background: #f5f5f5;
color: #212121;
padding: 0px 10px;
border-radius: 2px;
margin-right:2px;
font-size: 12px;
line-height: 20px;
text-transform: uppercase;
margin-bottom: 2px;
}
}
@@ -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: ["push", "tag", "deployment"],
};

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>
);
@@ -0,0 +1,30 @@
.root {
padding: 20px;
display: flex;
}

.left {
flex: 1;
margin-right:20px;
}

.right {
flex: 1;
border-left: 1px solid #ECEFF1;
padding-left:20px;
padding-top:10px;
}

@media (max-width: 960px) {
.root {
flex-direction: column;
}
.list {
margin-right: 0px;
}
.right {
border-left: none;
padding-left:0px;
padding-top:20px;
}
}
@@ -0,0 +1,217 @@
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.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;
const { user } = this.props;

if (!repo) {
return undefined;
}

return (
<div className={styles.root}>
<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));
}

handleChange(prop, value) {
const { dispatch, drone, repo } = this.props;
let data = {};
data[prop] = value;
dispatch(updateRepository, drone, repo.owner, repo.name, data);
}
}