Puberty commit

This commit is contained in:
2017-10-29 23:06:41 +01:00
commit 34034b5223
63 changed files with 16011 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
# See https://help.github.com/ignore-files/ for more about ignoring files.
# dependencies
/node_modules
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
+2164
View File
File diff suppressed because it is too large Load Diff
+10354
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
{
"name": "cmdui",
"version": "0.1.0",
"private": true,
"dependencies": {
"react": "^16.0.0",
"react-dom": "^16.0.0",
"react-router-dom": "^4.2.2",
"react-scripts": "1.0.14",
"source-map-explorer": "^1.5.0"
},
"scripts": {
"analyze": "source-map-explorer build/static/js/main.*",
"start": "BROWSER=none react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test --env=jsdom",
"eject": "react-scripts eject"
}
}
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html>
<head>
<title>go-api | commands</title>
<!-- <link rel="stylesheet" type="text/css" href="/styles.css"> -->
<link href="https://fonts.googleapis.com/css?family=Source+Sans+Pro:200,400,600" rel="stylesheet">
<meta charset="utf-8">
</head>
<body>
<div id="root"></div>
<!-- <script src="http://localhost:8097"></script> -->
</body>
</html>
+21
View File
@@ -0,0 +1,21 @@
body {
font-family: "Source Sans Pro", "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 16px;
font-weight: 400;
color: #222;
}
.container {
display: grid;
grid-template-columns: 280px 100%;
max-width: 1400px;
}
main {
max-width: 1400px;
width: calc(100% - 300px);
}
a {
color: #a11122;
}
+118
View File
@@ -0,0 +1,118 @@
import React, { Component } from 'react';
import { BrowserRouter as Router, Route } from 'react-router-dom'
import Loading from './pages/loading.js';
import Login from './pages/login.js';
import SelectCommand from './pages/select_command.js';
import Command from './pages/command.js';
import History from './pages/history.js';
import Job from './pages/job.js';
import Header from './blocks/header.js';
import { api, httpGET } from './http.js';
import './app.css';
export default class App extends Component {
constructor(props) {
super(props);
this.state = {
authorized: null,
user: null,
commands: null,
commands_query: ""
};
this.loadAuth();
this.loadCommands();
}
loadAuth() {
httpGET(api("/auth/session"),
(status, body) => {
if (status === 200) {
this.setState({
authorized: true,
user: JSON.parse(body)
});
} else {
this.setState({authorized: false});
}
},
(error) => {
console.log("Failed to load auth details:", error);
this.setState({authorized: false});
}
);
}
loadCommands() {
httpGET(api("/commands"),
(status, body) => {
if (status === 200) {
let list = JSON.parse(body);
var hash = {};
for (var cmd of list) {
hash[cmd.name] = cmd;
}
this.setState({commands: hash});
}
},
(error) => {
this.setState({commands: {}});
console.log("Failed to load commands:", error);
}
);
}
render() {
if (this.state.authorized === null || this.state.commands === null) {
return <Loading />;
}
if (this.state.authorized === false) {
return <Login />;
}
return (
<Router>
<div className="router-container">
<Header user={this.state.user} />
<Route exact path="/" render={props => (
<SelectCommand commands={this.state.commands}
onQueryChange={this.queryHandler.bind(this)} query={this.state.commands_query} />
)}/>
{/* Command */}
<Route exact path={"/cmd/:name"} render={props => (
<Command
cmd={props.match.params.name} commands={this.state.commands}
query={this.state.commands_query} onQueryChange={this.queryHandler.bind(this)} />
)}/>
{/* Logs */}
<Route exact path="/cmd/:name/jobs/:jobID" render={props => (
<Job jobID={props.match.params.jobID}
cmd={props.match.params.name} commands={this.state.commands}
query={this.state.commands_query} onQueryChange={this.queryHandler.bind(this)} />
)}/>
{/* History */}
<Route exact path="/cmd/:name/jobs" render={props => (
<History cmd={props.match.params.name} commands={this.state.commands}
query={this.state.commands_query} onQueryChange={this.queryHandler.bind(this)} />
)}/>
<Route exact path="/users/:id/jobs" render={props => (
<History userID={props.match.params.id}
commands={this.state.commands}
query={this.state.commands_query} onQueryChange={this.queryHandler.bind(this)} />
)}/>
<Route exact path="/jobs" render={props => (
<History
commands={this.state.commands}
query={this.state.commands_query} onQueryChange={this.queryHandler.bind(this)} />
)}/>
</div>
</Router>
);
}
queryHandler(event) {
this.setState({
commands_query: event.target.value,
});
}
}
+45
View File
@@ -0,0 +1,45 @@
header {
width: 100%;
background-color: #891826;
color: #fff;
}
.header-container {
display: grid;
grid-template-columns: auto auto;
margin: 0 0 20px 0;
max-width: 1400px;
}
header .go-api {
grid-column: 1;
margin: 0 0 2px 20px;
font-weight: 600;
white-space: nowrap;
font-size: 24px;
line-height: 38px;
}
header .go-api span {
font-weight: 200;
}
header .go-api span:before {
content: ' | ';
}
header .auth {
display: grid;
grid-column: 2;
grid-template-columns: auto 55px;
}
header .auth .name {
grid-column: 1;
text-align: right;
line-height: 40px;
}
header .auth img {
grid-column: 2;
margin: 5px 20px 5px 5px;
width: 30px;
height: 30px;
border-radius: 4px;
}
+21
View File
@@ -0,0 +1,21 @@
import React, { Component } from 'react';
import './header.css';
class Header extends Component {
render() {
return (
<header>
<div className="header-container">
<div className="go-api">go-api<span>commands</span></div>
<div className="auth">
<div className="name">{this.props.user.name}</div>
<img src={this.props.user.picture} alt="" />
</div>
</div>
</header>
);
}
}
export default Header;
+35
View File
@@ -0,0 +1,35 @@
.job-details.short {
display: grid;
grid-template-columns: 20px 100px 20% auto 200px 120px;
line-height: 32px;
}
.job-details.short:nth-child(even) {
background-color: #f5f5f5;
}
.job-details.short.legend {
font-weight: 600;
border-bottom: #aaa 1px solid;
}
.job-details.short a {
color: #222;
text-decoration: none;
}
.job-details.short .dot {
margin: 13px 5px 0 5px;
width: 8px;
height: 8px;
border-radius: 10px;
}
.job-details.short .dot.finished {
background-color: #0c8; /* People can't tell green from blue, right? */
}
.job-details.short .dot.failed {
background-color: #d03;
}
.job-details.short .id {
font-family: monospace;
}
+42
View File
@@ -0,0 +1,42 @@
import React, { Component } from 'react';
import { Link } from 'react-router-dom';
import Timestamp from './timestamp.js';
import './job_list.css';
export default class JobList extends Component {
render() {
if (this.props.jobs === undefined) {
return null;
}
return (
<div className="history-page">
<div className={"job-details short legend"}>
<div></div>
<div>ID</div>
<div>Command</div>
<div>User</div>
<div>Started</div>
<div>Took</div>
</div>
{this.props.jobs.map(this.renderJob)}
</div>
)
}
renderJob(job) {
let shortID = job.id.substring(0, 8);
return (
<div key={job.id} className={"job-details short"}>
<div className={"dot "+ job.state}></div>
<div className="id"><Link to={"/cmd/"+ job.command +"/jobs/"+ job.id}>{shortID}</Link></div>
<div className="command"><Link to={"/cmd/"+ job.command +"/jobs"}>{job.command}</Link></div>
<div className="user"><Link to={"/users/"+ job.user_id +"/jobs"}>{job.user.name}</Link></div>
<div className="started"><Timestamp date={job.started_at} /></div>
<div className="took"><Timestamp from={job.started_at} until={job.finished_at} /></div>
</div>
);
}
}
+33
View File
@@ -0,0 +1,33 @@
nav {
grid-column: 1;
margin: 0 20px 20px 10px;
width: 230px;
}
input.search {
width: 100%;
height: 22px;
line-height: 22px;
margin: 0 0 10px 10px;
padding: 3px 10px;
}
nav a {
display: block;
width: 100%;
padding: 5px 10px;
color: #666;
border-radius: 4px;
text-decoration: none;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
nav a:hover {
background-color: #f0f0f0;
color: #000;
}
nav a.active {
background-color: #a11122;
color: #fff;
}
+40
View File
@@ -0,0 +1,40 @@
import React, { Component } from 'react';
import { Link } from 'react-router-dom';
import './nav.css';
export default class Nav extends Component {
constructor(props) {
super(props);
this.state = {
query: "",
};
}
render() {
return (
<nav>
<input className="search" type="search" placeholder="Search for commands"
value={this.props.query} onChange={this.props.onQueryChange} results="0" />
<ul>
{Object.keys(this.props.commands).map((name) => {
let cmd = this.props.commands[name];
if (cmd.name.indexOf(this.props.query) === -1) {
return null;
}
var className = "";
if (this.props.active && cmd.name === this.props.active) {
className = "active";
}
return (
<li key={cmd.name}>
<Link to={"/cmd/" + cmd.name} className={className}>{cmd.name}</Link>
</li>
);
})}
</ul>
</nav>
);
}
}
+58
View File
@@ -0,0 +1,58 @@
.output {
overflow: auto;
margin: 0 0 20px 0;
background-color: #fafafa;
border: #f4f4f4 1px solid;
border-radius: 4px;
padding: 10px;
font-family: monospace;
visibility: hidden;
}
.output.visible {
visibility: visible;
}
.job-details.full {
display: grid;
grid-template-rows: auto;
margin: 10px 0 10px 0;
padding: 10px;
background-color: #f4f4f4;
border: #eaeaea 1px solid;
border-radius: 4px;
}
.job-details.full .item > * {
line-height: 24px;
}
.job-details.full .item .name {
grid-column: 1;
display: inline-block;
font-weight: 600;
margin: 0 5px 0 0;
}
.job-details.full .item .name:after {
content: ':';
}
.job-details.full .item .val {
grid-column: 2;
display: inline-block;
}
.job-details.full .item.command {
font-family: monospace;
}
.job-details.full .item.command .command {
font-weight: 600;
margin: 0 10px 0 0;
}
.job-details.full .item.command .args {
margin: 0 10px 0 0;
}
.job-details.full .item.state .val.finished {
color: #3e0;
font-weight: 600;
}
.job-details.full .item.state .val.failed {
color: #e30;
font-weight: 600;
}
+159
View File
@@ -0,0 +1,159 @@
import React, { Component } from 'react';
import { Link } from 'react-router-dom';
import Timestamp from './timestamp.js';
import { api, httpGET, httpStreamGET } from '../http.js';
import './output.css';
export default class Output extends Component {
constructor(props) {
super(props);
this.state = {
job: null,
xhr: null
};
}
componentDidMount() {
let jobID = this.props.jobID;
if (jobID !== null) {
this.loadCommandLog(jobID);
this.loadJobDetails(jobID);
}
}
componentWillUnmount() {
if (this.state.xhr !== null) {
this.state.xhr.abort();
}
}
loadJobDetails(id) {
if (id === null) {
return;
}
httpGET(api("/jobs/" + id),
(status, body) => {
this.setState({job: JSON.parse(body)});
},
(error) => {
console.log("Failed to load job details:", error);
}
);
}
loadCommandLog(id) {
if (id === null || this.state.xhr !== null) {
return;
}
let xhr = httpStreamGET(api("/jobs/" + id + "/log"),
(chunk) => { // Progress
let target = this.refs["output"];
target.innerHTML += chunk.replace(/\n/g, "<br/>");
this.autoScroll();
},
(status) => { // Complete
// Request cancelled
if (status === 0) {
return;
}
// Reload job details
this.setState({xhr: null});
this.loadJobDetails(id);
},
(error) => {
let target = this.refs["output"];
target.innerHTML = "Failed to fetch command log: "+ error;
}
);
this.setState({xhr: xhr});
}
autoScroll() {
// TODO: Figure out how to make it convinient
}
render() {
var outputClass = "output";
if (this.state.job) {
outputClass += " visible";
}
return (
<div>
{this.renderJobDetails()}
<div ref="output" className={outputClass}></div>
</div>
);
}
renderJobDetails() {
let details = this.state.job;
if (!details) {
return (<div></div>);
}
// let shortID = details.id.substring(0, 8);
var state = details.state;
state = state.charAt(0).toUpperCase() + state.substr(1);
var args;
if (details.args !== "") {
args = <span className="args">{details.args}</span>;
}
return (
<div className="job-details full">
<div className="item command">
<span className="command"><Link to={"/cmd/"+ details.command + "/jobs"}>{details.command}</Link></span>
{args}
<span className="flags">{details.flags}</span>
</div>
<div className="item id">
<div className="name">ID</div>
<div className="val"><Link to={"/cmd/"+ details.command + "/jobs/"+ details.id}>{details.id}</Link></div>
</div>
<div className="item state">
<div className="name">Status</div>
<div className={"val "+details.state}>{state}</div>
</div>
<div className="item user">
<div className="name">User</div>
<div className="val"><Link to={"/users/"+ details.user.id +"/jobs"}>{details.user.name}</Link></div>
</div>
{this.renderStarted()}
{this.renderFinished()}
</div>
);
}
renderStarted() {
let details = this.state.job;
return (
<div className="item started_at">
<div className="name">Started</div>
<div className="val"><Timestamp date={details.started_at} relative={details.finished_at === null} /></div>
</div>
);
}
renderFinished() {
let details = this.state.job;
if (details.finished_at !== null) {
return [
<div className="item finished_at" key="finished_at">
<div className="name">Finished</div>
<div className="val"><Timestamp date={details.finished_at} /></div>
</div>,
<div className="item took" key="took">
<div className="name">Took</div>
<div className="val"><Timestamp from={details.started_at} until={details.finished_at} /></div>
</div>
];
} else {
return null;
}
}
}
+93
View File
@@ -0,0 +1,93 @@
import React, { Component } from 'react';
export default class Timestamp extends Component {
constructor(props) {
super(props);
this.state = {timer: null, text: null};
}
componentDidMount() {
if (this.props.relative) {
this.setState({
text: this.relativeDate(),
timer: setInterval(this.setRelativeDate.bind(this), 1000)
});
} else {
this.setState({
text: null,
timer: null
});
}
}
componentWillUnmount() {
if (this.state.timer !== null) {
clearInterval(this.state.timer);
this.setState({timer: null, text: null});
}
}
relativeDate(date = this.props.date) {
return this.timeSince(new Date(date)) + " ago";
}
setRelativeDate(date = this.props.date) {
this.setState({text: this.relativeDate()});
}
render() {
var text;
if (this.props.date !== undefined) {
if (this.props.relative) {
text = this.state.text;
} else {
text = this.formatDate(new Date(this.props.date));
}
} else if (this.props.from !== undefined && this.props.until !== undefined) {
text = this.timeSince(new Date(this.props.from), new Date(this.props.until));
} else {
text = "&mdash;";
}
var title = "";
if (this.props.relative) {
title = this.formatDate(new Date(this.props.date));
} else if (this.props.until !== undefined) {
title = this.formatDate(new Date(this.props.until));
}
return (
<span title={title}>{text}</span>
);
}
formatDate(date) {
let leftPad = (n) => n > 9 ? n : "0"+ n; // I wish I was a package
let monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
let y = date.getFullYear(),
m = date.getMonth(),
d = date.getDate(),
h = leftPad(date.getHours()),
i = leftPad(date.getMinutes()),
s = leftPad(date.getSeconds());
return [d, monthNames[m], y, "at", [h, i, s].join(":")].join(" ");
}
timeSince(timeStamp, until = new Date()) {
let secondsPast = (until.getTime() - timeStamp.getTime())/1000;
if (secondsPast < 60) {
return parseInt(secondsPast, 10) + 's';
} else if (secondsPast < 3600) {
return parseInt(secondsPast/60, 10) + 'm';
} else if (secondsPast <= 86400) {
return parseInt(secondsPast/3600, 10) + 'h';
} else {
let day = timeStamp.getDate();
let month = timeStamp.toDateString().match(/ [a-zA-Z]*/)[0].replace(" ","");
let year = timeStamp.getFullYear() === until.getFullYear()
? ""
: " "+timeStamp.getFullYear();
return day + " " + month + year;
}
}
}
+16
View File
@@ -0,0 +1,16 @@
.user-details {
height: 50px;
margin: 0 0 10px 0;
}
.user-details img {
float: left;
width: 50px;
height: 50px;
margin: 0 10px 0 0;
border-radius: 5px;
}
.user-details .name {
font-size: 24px;
line-height: 50px;
}
+46
View File
@@ -0,0 +1,46 @@
import React, { Component } from 'react';
import { api, httpGET } from '../http.js';
import './user.css';
export default class User extends Component {
constructor(props) {
super(props);
this.state = {user: undefined};
}
componentDidMount() {
if (this.props.id === undefined || this.props.id === null) {
return;
}
httpGET(api("/users/"+ this.props.id),
(status, body) => {
this.setState({user: JSON.parse(body)});
},
(error) => {
console.log("Failed to load user details:", error);
}
);
}
render() {
if (this.props.id === undefined || this.props.id === null || this.state.user === undefined) {
return null;
} else if (this.state.user === null) {
let shortID = this.props.id.substring(0, 8);
return (
<div className="user-details">
User
<div className="user-id">{shortID}</div>
</div>
);
} else {
let details = this.state.user;
return (
<div className="user-details">
<img src={details.picture} alt={details.name +" picture"} />
<div className="name">{details.name}</div>
</div>
);
}
}
}
+93
View File
@@ -0,0 +1,93 @@
export function api(path) {
let proto = window.location.protocol,
host = window.location.host;
return proto + "//" + host + "/api" + path;
}
export function httpGET(url, success, error) {
let xhr = new XMLHttpRequest();
xhr.addEventListener("load", (e) => {
if (xhr.status >= 400) {
error("Request failed: " + xhr.statusText);
} else {
success(xhr.status, xhr.responseText);
}
});
xhr.addEventListener("error", (e) => {
error("Request failed");
});
xhr.addEventListener("abort", (e) => {
error("Connection closed");
});
let async = true;
xhr.open("GET", url, async);
xhr.send(null);
return xhr;
}
export function httpStreamGET(url, progress, complete, error) {
let xhr = new XMLHttpRequest();
xhr.responseType = "text";
var lastIndex = 0;
xhr.onreadystatechange = () => {
let state = xhr.readyState;
if (state === xhr.LOADING) {
let curIndex = xhr.responseText.length;
if (curIndex === lastIndex) {
// No progress was made
return;
}
let text = xhr.responseText.slice(lastIndex, curIndex);
lastIndex = curIndex;
progress(text);
} else if (state === xhr.DONE) {
if (xhr.status >= 400) {
error("Request failed: " + xhr.statusText);
} else {
complete(xhr.status);
}
}
// Ignoring states: UNSENT, OPENED, HEADERS_RECEIVED
};
xhr.onerror = (e) => {
error("Request failed");
};
xhr.onabort = (e) => {
error("Connection closed");
};
let async = true;
xhr.open("GET", url, async);
xhr.send(null);
return xhr;
}
export function httpPOST(url, form, success, error) {
let xhr = new XMLHttpRequest();
xhr.responseType = "text";
xhr.onreadystatechange = () => {
let state = xhr.readyState;
if (state === xhr.DONE) {
if (xhr.status >= 400) {
error("Request failed: " + xhr.statusText);
} else {
success(xhr.responseText);
}
}
// Ignoring states: UNSENT, OPENED, HEADERS_RECEIVED, PROGRESS
};
xhr.onerror = (e) => {
error("Request failed");
};
xhr.onabort = (e) => {
error("Connection closed");
};
let async = true;
xhr.open("POST", url, async);
xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
xhr.send(form);
return xhr;
}
+15
View File
@@ -0,0 +1,15 @@
/*
* Reset
*/
html, body {
margin: 0;
padding: 0;
}
ul, li {
margin: 0;
padding: 0;
}
li {
list-style-type: none;
}
+8
View File
@@ -0,0 +1,8 @@
import React from 'react';
import ReactDOM from 'react-dom';
import registerServiceWorker from './registerServiceWorker';
import App from './app.js';
import './index.css';
ReactDOM.render(<App />, document.getElementById('root'));
registerServiceWorker();
+86
View File
@@ -0,0 +1,86 @@
form {
width: 100%;
margin: 0 0 10px 0;
}
form > .command {
width: 100%;
margin: 0 0 10px 0;
}
.command .name {
font-size: 24px;
line-height: 36px;
}
.command .descr {
color: #666;
}
.fields li {
margin: 0 0 10px;
}
.fields label {
display: inline-block;
margin: 0 5px 0 0;
line-height: 18px;
font-size: 14px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.fields .descr {
font-size: 12px;
line-height: 18px;
color: #666;
display: inline-block;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
input {
display: block;
width: calc(100% - 10px);
margin: 0;
padding: 0 5px;
line-height: 30px;
font-size: 16px;
border: #ddd 1px solid;
border-radius: 4px;
}
input[type=checkbox] {
display: inline-block;
width: auto;
margin: 0 5px 0 0;
}
input[type=submit] {
width: auto;
padding: 5px 25px;
background-color: #891826;
color: #fff;
cursor: pointer;
float: left;
margin: 0 10px 0 0;
}
a.history {
line-height: 42px;
}
.select-command {
display: block;
width: calc(100% - 20px);
line-height: 60px;
background-color: #eee;
text-align: center;
border-radius: 4px;
}
.error {
overflow: auto;
margin: 0 0 20px 0;
background-color: #f55;
color: #fff;
border-radius: 4px;
padding: 10px;
}
+169
View File
@@ -0,0 +1,169 @@
import React, { Component } from 'react';
import { Link } from 'react-router-dom';
import Nav from '../blocks/nav.js';
import Output from '../blocks/output.js';
import { api, httpPOST } from '../http.js';
import './command.css';
export default class Command extends Component {
constructor(props) {
super(props);
this.state = {
form: this.defaultForm(props.commands[props.cmd]),
jobID: null,
error: null,
};
}
componentWillReceiveProps(props) {
this.setState({form: this.defaultForm(props.commands[props.cmd])});
}
defaultForm(cmd) {
if (!cmd || Object.keys(cmd).length === 0) {
return {};
}
var form = {
command: cmd.name,
args: "",
flags: {}
}
cmd.flags.forEach((flag) => {
form.flags[flag.name] = flag.default;
});
return form;
}
render() {
let cmd = this.props.commands[this.props.cmd];
if (!cmd) {
return null;
}
return (
<div className="container">
<Nav commands={this.props.commands} active={this.props.cmd}
query={this.props.query} onQueryChange={this.props.onQueryChange} />
<main>
<form method="post" action="/api/exec" onSubmit={this.submitHandler.bind(this)} ref="form">
<div className="command">
<div className="name">{cmd.name}</div>
<div className="descr">{cmd.description}</div>
<input type="hidden" name="command" defaultValue={cmd.name} />
</div>
<ul className="fields">
<li>
<div className="descr">Command arguments</div>
<input type="text" name="args" placeholder={cmd.args_placeholder}
onChange={this.changeHandler.bind(this)} />
</li>
{cmd.flags.map((flag) => { return this.input(flag); })}
</ul>
<input type="submit" value="Execute" id="submit-button"/>
<Link to={"/cmd/"+ cmd.name +"/jobs"} className="history">History</Link>
</form>
{this.renderError()}
<Output cmd={cmd} jobID={this.state.jobID} key={this.state.jobID} />
</main>
</div>
);
}
renderError() {
if (this.state.error === null) {
return null;
}
return (
<div className="error">{this.state.error}</div>
);
}
submitHandler(event) {
event.preventDefault();
let f = this.state.form;
var args = [];
args.push(["command", f.command]);
args.push(["args", f.args]);
for (let name of Object.keys(f.flags)) {
args.push(["flags[" + name + "]", f.flags[name]]);
}
let formQuery = args.map((pair) => {
return pair[0] + "=" + encodeURIComponent(pair[1]);
}).join("&");
httpPOST(api("/exec"), formQuery,
(response) => {
let details = JSON.parse(response);
this.setState({jobID: details.id, error: null});
},
(error) => {
this.setState({jobID: null, error: error});
},
);
}
changeHandler(event) {
if (event.target.id.indexOf("flag-") === 0) {
var val = event.target.value;
if (event.target.type === "checkbox") {
val = JSON.stringify(event.target.checked);
}
var flags = this.state.form.flags;
let name = event.target.id.substring(5);
flags[name] = val;
this.setState({
form: {
command: this.state.form.command,
args: this.state.form.args,
flags: flags
}
});
} else if (event.target.name === "args") {
this.setState({
form: {
command: this.state.form.command,
args: event.target.value,
flags: this.state.form.flags
}
});
}
}
input(flag) {
let flagName = (name) => { return "flags[" + name + "]"; }
if (flag.type === "bool") {
return (
<li key={flag.name}>
<input type="checkbox" name={flagName(flag.name)} id={"flag-"+flag.name}
defaultChecked={(flag.default === "true")} value="true"
onClick={this.changeHandler.bind(this)} />
<label htmlFor={"flag-"+flag.name}>{flag.name}</label>
<span className="descr">{flag.description}</span>
</li>
)
} else {
var inputType = "string";
let numericTypes = [
"int", "int8", "int16", "int32", "int64",
"uint", "uint8", "uint16", "uint32", "uint64"
];
if (numericTypes.includes(flag.type)) {
inputType = "number";
}
return (
<li key={flag.name}>
<label htmlFor={"flags-"+flag.name}>{flag.name}</label>
<span className="descr">{flag.description}</span>
<input type={inputType} name={flagName(flag.name)} id={"flags-"+flag.name}
defaultValue={flag.default} onChange={this.changeHandler.bind(this)} />
</li>
)
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+14
View File
@@ -0,0 +1,14 @@
.command-details {
font-size: 24px;
line-height: 50px;
margin: 0 0 10px 0;
}
.command-details span {
display: inline-block;
margin-right: 5px;
}
.command-details .cmd-name {
display: inline-block;
font-weight: 400;
font-family: monospace;
}
+61
View File
@@ -0,0 +1,61 @@
import React, { Component } from 'react';
import { Link } from 'react-router-dom';
import Nav from '../blocks/nav.js';
import JobList from '../blocks/job_list.js';
import User from '../blocks/user.js';
import { api, httpGET } from '../http.js';
import './history.css';
export default class History extends Component {
constructor(props) {
super(props);
this.state = {};
}
componentDidMount() {
var url;
if (this.props.cmd !== undefined) {
url = api("/commands/" + this.props.cmd + "/jobs")
} else if (this.props.userID !== undefined) {
url = api("/users/" + this.props.userID + "/jobs")
} else {
url = api("/jobs")
}
httpGET(url,
(status, body) => {
let jobs = JSON.parse(body);
this.setState({jobs: jobs});
},
(error) => {
console.log("Failed to load jobs:", error);
},
);
}
render() {
let details;
if (this.props.cmd !== undefined) {
details = (
<div className="command-details">
<span>Command</span>
<div className="cmd-name"><Link to={"/cmd/"+ this.props.cmd}>{this.props.cmd}</Link></div>
</div>
);
} else if (this.props.userID !== undefined) {
details = (
<User id={this.props.userID} />
);
}
return (
<div className="container">
<Nav commands={this.props.commands} active={this.props.cmd}
query={this.props.query} onQueryChange={this.props.onQueryChange} />
<main>
{details}
<JobList jobs={this.state.jobs} />
</main>
</div>
);
}
}
+18
View File
@@ -0,0 +1,18 @@
import React, { Component } from 'react';
import Nav from '../blocks/nav.js';
import Output from '../blocks/output.js';
export default class Job extends Component {
render() {
return (
<div className="container">
<Nav commands={this.props.commands} active={this.props.cmd}
query={this.props.query} onQueryChange={this.props.onQueryChange} />
<main>
<Output cmd={this.props.cmd} jobID={this.props.jobID} />
</main>
</div>
);
}
}
+5
View File
@@ -0,0 +1,5 @@
.loading-page {
width: 100%;
text-align: center;
line-height: 200px;
}
+12
View File
@@ -0,0 +1,12 @@
import React, { Component } from 'react';
import './loading.css';
export default class Loading extends Component {
render() {
return (
<div className="loading-page">
Loading...
</div>
);
}
}
+19
View File
@@ -0,0 +1,19 @@
.login-page {
width: 100%;
text-align: center;
}
.login-page .button {
display: block;
margin-top: 100px;
text-decoration: none;
}
.login-page img {
width: 150px;
height: 150px;
}
.login-page div {
line-height: 30px;
font-size: 18px;
font-weight: 600;
color: #222;
}
+21
View File
@@ -0,0 +1,21 @@
import React, { Component } from 'react';
import './login.css';
import githubLogo from './github_logo.png';
export default class Login extends Component {
componentDidMount() {
let page = document.getElementsByClassName("login-page")[0];
page.style.height = window.innerHeight + "px";
}
render() {
return (
<div className="login-page">
<a className="button" href="/api/auth/login">
<img src={githubLogo} alt="Login with GitHub" />
<div>Login with GitHub</div>
</a>
</div>
);
}
}
+20
View File
@@ -0,0 +1,20 @@
import React, { Component } from 'react';
import Nav from '../blocks/nav.js';
class SelectCommand extends Component {
render() {
return (
<div className="container">
<Nav commands={this.props.commands} onQueryChange={this.props.onQueryChange} query={this.props.query} />
<main>
<div className="select-command">
Please select a command
</div>
</main>
</div>
);
}
}
export default SelectCommand;
+108
View File
@@ -0,0 +1,108 @@
// In production, we register a service worker to serve assets from local cache.
// This lets the app load faster on subsequent visits in production, and gives
// it offline capabilities. However, it also means that developers (and users)
// will only see deployed updates on the "N+1" visit to a page, since previously
// cached resources are updated in the background.
// To learn more about the benefits of this model, read https://goo.gl/KwvDNy.
// This link also includes instructions on opting out of this behavior.
const isLocalhost = Boolean(
window.location.hostname === 'localhost' ||
// [::1] is the IPv6 localhost address.
window.location.hostname === '[::1]' ||
// 127.0.0.1/8 is considered localhost for IPv4.
window.location.hostname.match(
/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
)
);
export default function register() {
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
// The URL constructor is available in all browsers that support SW.
const publicUrl = new URL(process.env.PUBLIC_URL, window.location);
if (publicUrl.origin !== window.location.origin) {
// Our service worker won't work if PUBLIC_URL is on a different origin
// from what our page is served on. This might happen if a CDN is used to
// serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374
return;
}
window.addEventListener('load', () => {
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
if (!isLocalhost) {
// Is not local host. Just register service worker
registerValidSW(swUrl);
} else {
// This is running on localhost. Lets check if a service worker still exists or not.
checkValidServiceWorker(swUrl);
}
});
}
}
function registerValidSW(swUrl) {
navigator.serviceWorker
.register(swUrl)
.then(registration => {
registration.onupdatefound = () => {
const installingWorker = registration.installing;
installingWorker.onstatechange = () => {
if (installingWorker.state === 'installed') {
if (navigator.serviceWorker.controller) {
// At this point, the old content will have been purged and
// the fresh content will have been added to the cache.
// It's the perfect time to display a "New content is
// available; please refresh." message in your web app.
console.log('New content is available; please refresh.');
} else {
// At this point, everything has been precached.
// It's the perfect time to display a
// "Content is cached for offline use." message.
console.log('Content is cached for offline use.');
}
}
};
};
})
.catch(error => {
console.error('Error during service worker registration:', error);
});
}
function checkValidServiceWorker(swUrl) {
// Check if the service worker can be found. If it can't reload the page.
fetch(swUrl)
.then(response => {
// Ensure service worker exists, and that we really are getting a JS file.
if (
response.status === 404 ||
response.headers.get('content-type').indexOf('javascript') === -1
) {
// No service worker found. Probably a different app. Reload the page.
navigator.serviceWorker.ready.then(registration => {
registration.unregister().then(() => {
window.location.reload();
});
});
} else {
// Service worker found. Proceed as normal.
registerValidSW(swUrl);
}
})
.catch(() => {
console.log(
'No internet connection found. App is running in offline mode.'
);
});
}
export function unregister() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready.then(registration => {
registration.unregister();
});
}
}