Puberty commit
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 = "—";
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/*
|
||||
* Reset
|
||||
*/
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
ul, li {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
li {
|
||||
list-style-type: none;
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 |
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
.loading-page {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
line-height: 200px;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user