From f9b0adb2f8c308669548d60b8865c95062802d8b Mon Sep 17 00:00:00 2001 From: Sam Beckett Date: Fri, 4 Feb 2022 13:33:05 -0700 Subject: [PATCH 1/3] fix some prop errors --- src/components/Formations/DynoType.jsx | 23 +++++++---------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/src/components/Formations/DynoType.jsx b/src/components/Formations/DynoType.jsx index 6a0f097..b530eef 100644 --- a/src/components/Formations/DynoType.jsx +++ b/src/components/Formations/DynoType.jsx @@ -411,7 +411,6 @@ export default class DynoType extends BaseComponent { if (this.props.formation.type === 'web') { webUrl = this.props.app.web_url.replace(/\/+$/, ''); } - return (
} > - 0 - 1 - 2 - 3 - 4 - 5 - 6 - 7 - 8 - 9 - 10 + {Array.from(Array(11).keys()).map(k => ( + {k} + ))}
@@ -471,7 +462,7 @@ export default class DynoType extends BaseComponent { disabled={!this.state.edit} type="numeric" label="Port" - value={port} + value={port || ''} onChange={this.handleChange('port')} helperText={this.state.errorText} error={this.state.errorText.length > 0} @@ -480,7 +471,7 @@ export default class DynoType extends BaseComponent { className="command" disabled={!this.state.edit} label="Command" - value={this.state.command} + value={this.state.command || ''} onChange={this.handleChange('command')} helperText={this.state.errorText} error={this.state.errorText.length > 0} @@ -494,7 +485,7 @@ export default class DynoType extends BaseComponent { className="command" disabled={!this.state.edit} label="Command" - value={this.state.command} + value={this.state.command || ''} onChange={this.handleChange('command')} helperText={this.state.errorText} error={this.state.errorText.length > 0} @@ -549,7 +540,7 @@ export default class DynoType extends BaseComponent { type="text" label="Healthcheck" placeholder="healthcheck_endpoint" - value={healthcheck} + value={healthcheck || ''} onChange={this.handleChange('healthcheck')} helperText={this.state.errorText} error={this.state.errorText.length > 0} From c471b692224e9a2e86ae231a6595190ec6e43d7b Mon Sep 17 00:00:00 2001 From: Sam Beckett Date: Fri, 4 Feb 2022 13:33:42 -0700 Subject: [PATCH 2/3] filter out one off formations in dynos tab --- src/components/Formations/index.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Formations/index.jsx b/src/components/Formations/index.jsx index 90d3689..e2ff35d 100644 --- a/src/components/Formations/index.jsx +++ b/src/components/Formations/index.jsx @@ -84,7 +84,7 @@ export default class Formations extends BaseComponent { this.api.getFormationSizes(), this.api.getDynos(this.props.app.name), ]); - const formations = r1.data.sort((a, b) => (a.type < b.type ? -1 : 1)); + const formations = r1.data.filter(a => !a.oneoff).sort((a, b) => (a.type < b.type ? -1 : 1)); const dynos = r3.data; let sizes = []; r2.data.forEach((size) => { From 1c39c72cabad51b5dfb127bf974cd60c572f35f3 Mon Sep 17 00:00:00 2001 From: Sam Beckett Date: Wed, 14 Dec 2022 21:53:08 -0700 Subject: [PATCH 3/3] add actions details, create new action --- package-lock.json | 107 ++- package.json | 4 +- src/components/Actions/NewAction.jsx | 667 ++++++++++++++ src/components/Actions/index.jsx | 1265 ++++++++++++++++++++++++++ src/components/CustomSelect.jsx | 22 +- src/components/Icons/ActionsIcon.jsx | 12 + src/config/GlobalTheme.jsx | 2 +- src/scenes/Apps/AppInfo.jsx | 19 +- src/services/api/index.js | 54 +- 9 files changed, 2133 insertions(+), 19 deletions(-) create mode 100644 src/components/Actions/NewAction.jsx create mode 100644 src/components/Actions/index.jsx create mode 100644 src/components/Icons/ActionsIcon.jsx diff --git a/package-lock.json b/package-lock.json index d4ba459..58ee6ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,10 @@ { "name": "akkeris-ui", - "version": "0.1.1", + "version": "0.2.0", "lockfileVersion": 2, "requires": true, "packages": { "": { - "name": "akkeris-ui", "version": "0.1.1", "dependencies": { "@auth0/s3": "^1.0.0", @@ -31,7 +30,7 @@ "react-dom": "^16.6.3", "react-event-listener": "^0.6.4", "react-ga": "^2.6.0", - "react-lazylog": "^3.1.4", + "react-lazylog": "^4.5.3", "react-loadable": "5.5.0", "react-router-dom": "^4.3.1", "react-select": "^2.3.0", @@ -8035,7 +8034,71 @@ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.9.tgz", "integrity": "sha512-oeyj2H3EjjonWcFjD5NvZNE9Rqe4UW+nQBU2HNeKw0koVLEFIhtyETyAakeAM3de7Z/SW5kcA+fZUait9EApnw==", "bundleDependencies": [ - "node-pre-gyp" + "node-pre-gyp", + "abbrev", + "ansi-regex", + "aproba", + "are-we-there-yet", + "balanced-match", + "brace-expansion", + "chownr", + "code-point-at", + "concat-map", + "console-control-strings", + "core-util-is", + "debug", + "deep-extend", + "delegates", + "detect-libc", + "fs-minipass", + "fs.realpath", + "gauge", + "glob", + "has-unicode", + "iconv-lite", + "ignore-walk", + "inflight", + "inherits", + "ini", + "is-fullwidth-code-point", + "isarray", + "minimatch", + "minimist", + "minipass", + "minizlib", + "mkdirp", + "ms", + "needle", + "nopt", + "npm-bundled", + "npm-packlist", + "npmlog", + "number-is-nan", + "object-assign", + "once", + "os-homedir", + "os-tmpdir", + "osenv", + "path-is-absolute", + "process-nextick-args", + "rc", + "readable-stream", + "rimraf", + "safe-buffer", + "safer-buffer", + "sax", + "semver", + "set-blocking", + "signal-exit", + "string_decoder", + "string-width", + "strip-ansi", + "strip-json-comments", + "tar", + "util-deprecate", + "wide-align", + "wrappy", + "yallist" ], "hasInstallScript": true, "optional": true, @@ -12116,18 +12179,22 @@ "integrity": "sha512-aUk3bHfZ2bRSVFFbbeVS4i+lNPZr3/WM5jT2J5omUVV1zzcs1nAaf3l51ctA5FFvCRbhrH0bdAsRRQddFJZPtA==" }, "node_modules/react-lazylog": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/react-lazylog/-/react-lazylog-3.2.0.tgz", - "integrity": "sha512-JkqkWfkAQ+ptc0jlSsLfejukCrPJld43OxNDoHWnMCb30Uv68HBul93slrU3y+EQr9gyTpN9SAv3+eBlUfY3Kg==", + "version": "4.5.3", + "resolved": "https://registry.npmjs.org/react-lazylog/-/react-lazylog-4.5.3.tgz", + "integrity": "sha512-lyov32A/4BqihgXgtNXTHCajXSXkYHPlIEmV8RbYjHIMxCFSnmtdg4kDCI3vATz7dURtiFTvrw5yonHnrS+NNg==", "dependencies": { "@mattiasbuelens/web-streams-polyfill": "^0.2.0", "fetch-readablestream": "^0.2.0", "immutable": "^3.8.2", "mitt": "^1.1.2", "prop-types": "^15.6.1", + "react-string-replace": "^0.4.1", "react-virtualized": "^9.21.0", "text-encoding-utf-8": "^1.0.1", "whatwg-fetch": "^2.0.4" + }, + "peerDependencies": { + "react": ">=16.3.0" } }, "node_modules/react-lazylog/node_modules/whatwg-fetch": { @@ -12224,6 +12291,17 @@ "react-transition-group": "^2.5.0" } }, + "node_modules/react-string-replace": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/react-string-replace/-/react-string-replace-0.4.4.tgz", + "integrity": "sha512-FAMkhxmDpCsGTwTZg7p/2v+/GTmxAp73so3fbSvlAcBBX36ujiGRNEaM/1u+jiYQrArhns+7eE92g2pi5E5FUA==", + "dependencies": { + "lodash": "^4.17.4" + }, + "engines": { + "node": ">=0.12.0" + } + }, "node_modules/react-test-renderer": { "version": "16.6.3", "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-16.6.3.tgz", @@ -27095,15 +27173,16 @@ "integrity": "sha512-aUk3bHfZ2bRSVFFbbeVS4i+lNPZr3/WM5jT2J5omUVV1zzcs1nAaf3l51ctA5FFvCRbhrH0bdAsRRQddFJZPtA==" }, "react-lazylog": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/react-lazylog/-/react-lazylog-3.2.0.tgz", - "integrity": "sha512-JkqkWfkAQ+ptc0jlSsLfejukCrPJld43OxNDoHWnMCb30Uv68HBul93slrU3y+EQr9gyTpN9SAv3+eBlUfY3Kg==", + "version": "4.5.3", + "resolved": "https://registry.npmjs.org/react-lazylog/-/react-lazylog-4.5.3.tgz", + "integrity": "sha512-lyov32A/4BqihgXgtNXTHCajXSXkYHPlIEmV8RbYjHIMxCFSnmtdg4kDCI3vATz7dURtiFTvrw5yonHnrS+NNg==", "requires": { "@mattiasbuelens/web-streams-polyfill": "^0.2.0", "fetch-readablestream": "^0.2.0", "immutable": "^3.8.2", "mitt": "^1.1.2", "prop-types": "^15.6.1", + "react-string-replace": "^0.4.1", "react-virtualized": "^9.21.0", "text-encoding-utf-8": "^1.0.1", "whatwg-fetch": "^2.0.4" @@ -27207,6 +27286,14 @@ "react-transition-group": "^2.5.0" } }, + "react-string-replace": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/react-string-replace/-/react-string-replace-0.4.4.tgz", + "integrity": "sha512-FAMkhxmDpCsGTwTZg7p/2v+/GTmxAp73so3fbSvlAcBBX36ujiGRNEaM/1u+jiYQrArhns+7eE92g2pi5E5FUA==", + "requires": { + "lodash": "^4.17.4" + } + }, "react-test-renderer": { "version": "16.6.3", "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-16.6.3.tgz", diff --git a/package.json b/package.json index b13093f..e6c9e3f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "akkeris-ui", - "version": "0.1.1", + "version": "0.2.0", "description": "UI for Akkeris", "repository": { "type": "git", @@ -70,7 +70,7 @@ "react-dom": "^16.6.3", "react-event-listener": "^0.6.4", "react-ga": "^2.6.0", - "react-lazylog": "^3.1.4", + "react-lazylog": "^4.5.3", "react-loadable": "5.5.0", "react-router-dom": "^4.3.1", "react-select": "^2.3.0", diff --git a/src/components/Actions/NewAction.jsx b/src/components/Actions/NewAction.jsx new file mode 100644 index 0000000..fe2cfb7 --- /dev/null +++ b/src/components/Actions/NewAction.jsx @@ -0,0 +1,667 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + Step, Stepper, StepLabel, Button, TextField, Typography, CircularProgress, + Tooltip, IconButton, Grid, Checkbox, FormControlLabel, Dialog, + DialogTitle, DialogContent, DialogActions, +} from '@material-ui/core'; +import HelpIcon from '@material-ui/icons/Help'; +import ReactGA from 'react-ga'; +import ConfirmationModal from '../ConfirmationModal'; +import BaseComponent from '../../BaseComponent'; +import Search from '../Search'; + +const style = { + root: { + width: '100%', + maxWidth: 700, + margin: 'auto', + minHeight: 200, + paddingBottom: '12px', + }, + stepper: { + height: 40, + }, + buttons: { + div: { + marginTop: 24, + marginBottom: 12, + }, + back: { + marginRight: 12, + }, + }, + stepDescription: { + marginTop: '24px', + }, + h6: { + marginBottom: '12px', + }, + bold: { + fontWeight: 'bold', + }, + refresh: { + div: { + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + flexGrow: 1, + }, + indicator: { + display: 'inline-block', + position: 'relative', + }, + }, + stepContent: { + inputStep: { + root: { + height: '200px', + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + alignItems: 'flex-start', + flexGrow: 1, + }, + }, + summaryStep: { + root: { + height: '200px', + display: 'flex', + wordWrap: 'anywhere', + overflowY: 'auto', + }, + wrapper: { + height: 'min-content', + margin: 'auto 0', + }, + }, + }, + contentContainer: { + margin: '0 32px', height: '440px', display: 'flex', flexDirection: 'column', + }, + stepContainer: { + flexGrow: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', + }, + buttonContainer: { + paddingTop: '12px', + }, + events: { + header: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + width: '70px', + }, + label: { + fontSize: '14px', + paddingRight: '8px', + }, + checkAllContainer: { + borderTop: '1px solid black', + marginTop: '5px', + paddingTop: '5px', + }, + gridContainer: { + width: '550px', + }, + checkbox: { + marginRight: '6px', + padding: '6px', + }, + }, +}; + +export default class NewBuild extends BaseComponent { + constructor(props, context) { + super(props, context); + this.state = { + loading: false, + finished: false, + stepIndex: 0, + submitFail: false, + submitMessage: '', + errorText: null, + name: '', + description: '', + sizes: [], + size: {}, + image: '', + command: '', + configvars: '', + eventsDialogOpen: false, + checkedAll: false, + events: [], + availableHooks: [], + }; + } + + componentDidMount() { + super.componentDidMount(); + this.getSizes(); + } + + // Copy of the getSizes function in NewFormation + // Also gets available hooks + getSizes = async () => { + try { + const { data: formationSizes } = await this.api.getFormationSizes(); + let { data: availableHooks } = (await this.api.getAvailableHooks()); + // Filter out "destroy" hook, which we can't use for actions + availableHooks = availableHooks.filter(hook => hook.type !== 'destroy'); + + const sizes = []; + formationSizes.forEach((size) => { + if (size.name.indexOf('prod') === -1) { + sizes.push(size); + } + }); + + const smallestSize = (sizes.sort((a, b) => a.price - b.price))[0]; + + const groupedSizes = sizes.reduce((acc, size) => { + const idx = acc.findIndex(e => e.label.toLowerCase() === size.type.toLowerCase()); + if (idx === -1) { + acc.push({ + label: size.type.charAt(0).toUpperCase() + size.type.slice(1), + options: [{ + value: size.name, + label: `${size.name}: ${size.description}`, + }], + }); + } else { + acc[idx].options.push({ + value: size.name, + label: `${size.name}: ${size.description}`, + memory: parseInt(size.resources.limits.memory, 10), + }); + } + return acc; + }, []); + + groupedSizes.forEach(size => size.options.sort((a, b) => a.memory - b.memory)); + groupedSizes.sort((a, b) => { + const la = a.label.toLowerCase(); + const lb = b.label.toLowerCase(); + if (la < lb) return -1; + if (la > lb) return 1; + return 0; + }); + + this.setState({ + availableHooks, + sizes: groupedSizes, + loading: false, + size: { + label: `${smallestSize.name}: ${smallestSize.description}`, + value: smallestSize.name, + }, + }); + } catch (err) { + if (!this.isCancel(err)) { + console.error(err); // eslint-disable-line no-console + } + } + } + + handleCheckAll = (event, checked) => { + let currEvents = []; + if (checked) { + this.state.availableHooks.forEach(e => currEvents.push(e.type)); + this.setState({ checkedAll: true }); + } else { + currEvents = []; + this.setState({ checkedAll: false }); + } + this.setState({ events: currEvents }); + } + + handleCheck = (event, checked) => { + const currEvents = this.state.events; + if (checked) { + currEvents.push(event.target.value); + } else { + currEvents.splice(currEvents.indexOf(event.target.value), 1); + } + this.setState({ + events: currEvents, + checkedAll: currEvents.length === this.state.availableHooks.length, + }); + } + + handleNext = () => { + const { stepIndex, name, image, command, configvars } = this.state; + + if (stepIndex === 0) { + // Maximum length of description and name + // Name must not be null + if (!name || name === '' || name.length < 1) { + this.setState({ errorText: 'Required' }); + return; + } else if (!(/^[A-Za-z0-9]+$/i.test(name))) { + this.setState({ errorText: 'Name must be an alphanumeric string' }); + return; + } + } else if (stepIndex === 2 && (image && image !== '')) { + // Make sure image has tag + const imgArr = image.split(':'); + if (imgArr.length < 2 || imgArr[0].length < 1 || imgArr[1].length < 1) { + this.setState({ errorText: 'Docker image must contain tag' }); + return; + } + } else if (stepIndex === 3) { + if (!command || command === '' || command.length < 1) { + this.setState({ errorText: 'Required' }); + return; + } + } else if (stepIndex === 4 && configvars && configvars !== '' && configvars.length > 0) { + // Make sure the JSON is valid + try { + const config = JSON.parse(configvars); + if (typeof config !== 'object' || Array.isArray(config)) { + throw new Error(); + } + } catch (err) { + this.setState({ errorText: 'Must be a valid JSON string' }); + return; + } + } else if (stepIndex === 6) { + // Submit + this.submitAction(); + return; + } + + this.setState({ + stepIndex: stepIndex + 1, + errorText: null, + }); + } + + handlePrev = () => { + const { stepIndex } = this.state; + this.setState({ + stepIndex: stepIndex - 1, + errorText: null, + }); + } + + // Handles most changes + handleChange = name => (event) => { + this.setState({ [name]: event.target.value }); + } + + handleSizeChange = (event) => { + this.setState({ errorText: '', size: event }); + } + + submitAction = async () => { + const { + name, description, size, image, configvars, command, events, + } = this.state; + const handleError = (error) => { + if (!this.isCancel(error)) { + this.setState({ + submitMessage: error.response.data, + submitFail: true, + finished: false, + stepIndex: 0, + loading: false, + errorText: null, + eventsDialogOpen: false, + }); + } + }; + try { + this.setState({ + loading: true, + }, async () => { + try { + await this.api.createAction( + this.props.app, + name, + description.trim() !== '' ? description.trim() : null, + size.value, + image.trim() !== '' ? image.trim() : null, + command.trim(), + configvars ? JSON.parse(configvars) : null, + events.join(','), + ); + + ReactGA.event({ + category: 'ACTIONS', + action: 'Created new action', + }); + + // Add a pleasing amount of loading instead of flashing the indicator + // for a variable amount of time + setTimeout(() => this.props.onComplete('New Action Created'), 1000); + } catch (err) { + handleError(err); + } + }); + } catch (error) { + handleError(error); + } + } + + renderEventsInfoDialog() { + return ( + + Description of Events + +
+ {this.state.availableHooks.map(event => ( +

{event.type}
{event.description}

+ ))} +
+
+ + + +
+ ); + } + + renderEventCheckboxes() { + const { events, availableHooks } = this.state; + return availableHooks.map(event => ( + + + } + label={event.type} + /> + + )); + } + + renderStepContent(stepIndex) { + const { + name, description, size, errorText, image, configvars, sizes, command, checkedAll, events, + } = this.state; + + switch (stepIndex) { + // Name and Description + case 0: + return ( +
+
+ 0} + onKeyPress={(e) => { if (e.key === 'Enter') this.handleNext(); }} + autoFocus + fullWidth + /> + { if (e.key === 'Enter') this.handleNext(); }} + /> +
+ + {'Enter a name, and optionally a short description, for the new action.'} + +
+ ); + // Size + case 1: + return ( +
+ + + {'Choose a dyno size that the action will use when it runs. Please choose as small of a size as possible.'} + +
+ ); + // Image + case 2: + return ( +
+ 0} + onKeyPress={(e) => { if (e.key === 'Enter') this.handleNext(); }} + autoFocus + fullWidth + /> + + {'The image to use when the action runs.'} + +
    + This is optional. If not provided, the app's image will be used. + Make sure this is a valid Docker image! (e.g. repository.url/organization/image:tag) +
+
+ ); + // Command + case 3: + return ( +
+ 0} + onKeyPress={(e) => { if (e.key === 'Enter') this.handleNext(); }} + autoFocus + fullWidth + /> + + {'The command to use when the action runs.'} + +
+ ); + // Config Vars + case 4: + return ( +
+ 0} + autoFocus + fullWidth + multiline + rows={5} + rowsMax={5} + variant="outlined" + /> + + {'Config vars you wish to use to override the app\'s existing config vars'} + +
    + This should be a valid JSON object consisting of "key": value" pairs. + This UX is a work in progress. Stay tuned :) +
+
+ ); + // Events + case 5: + return ( +
+
+

Events

+ + + + + +
+ {this.renderEventsInfoDialog()} +
+ + {this.renderEventCheckboxes()} + + + } + label="Check All" + /> + + +
+ {errorText && ( +
+ {errorText} +
+ )} +
+ ); + // Confirm + case 6: + return ( +
+
+ Summary + + {'A new action, '} + {name} + {', will be created using the '} + {image && image !== '' ? `image: ${image}` : 'default app image'} + {'. It will run using a '} + {size.value} + {' dyno, and the run command will be '} + {command} + {'.'} + {events.length > 0 && ( + + {' The action will be triggered on the following events: '} + {events.join(', ')} + + )} + +
+
+ ); + default: + return 'You\'re a long way from home sonny jim!'; + } + } + + renderContent() { + const { stepIndex, loading } = this.state; + return ( +
+ {!loading ? ( +
+ {this.renderStepContent(stepIndex)} +
+ ) : ( +
+ +
+ )} +
+ + +
+
+ ); + } + + render() { + const { + stepIndex, submitFail, submitMessage, + } = this.state; + return ( +
+ + + Name + + + Size + + + Image + + + Command + + + Config Vars + + + Events + + + Confirm + + + {this.renderContent()} + this.setState({ submitFail: false })} + message={submitMessage} + title="Error" + className="new-build-error" + /> +
+ ); + } +} + +NewBuild.propTypes = { + app: PropTypes.string.isRequired, + onComplete: PropTypes.func.isRequired, +}; diff --git a/src/components/Actions/index.jsx b/src/components/Actions/index.jsx new file mode 100644 index 0000000..485c6f8 --- /dev/null +++ b/src/components/Actions/index.jsx @@ -0,0 +1,1265 @@ +import React from 'react'; +import deepmerge from 'deepmerge'; +import { + CircularProgress, Divider, Typography, IconButton, Tooltip, + List, ListItem, ListItemText, ListItemIcon, DialogTitle, + LinearProgress, Dialog, DialogActions, DialogContent, Button, + Table, TableRow, TableCell, TableBody, Tabs, Tab, Collapse, TextField, Grid, TableHead, + FormControl, InputLabel, Select, MenuItem, +} from '@material-ui/core'; +import { + Add as AddIcon, CheckCircle as SuccessIcon, Lens as UnknownIcon, + Clear as RemoveIcon, Edit as EditIcon, + Event as EventIcon, EventAvailable as TimeFinishedSuccess, + EventBusy as TimeFinishedFailure, DateRange as TimeStarted, + Refresh as ReloadIcon, PlayArrow as TriggerIcon, Delete as DeleteIcon, + Stop as StopIcon, +} from '@material-ui/icons'; +import { grey, yellow } from '@material-ui/core/colors'; +import FailureIcon from '@material-ui/icons/Cancel'; +import { MuiThemeProvider } from '@material-ui/core/styles'; +import { LazyLog } from 'react-lazylog'; +import Ansi from 'ansi-to-react'; +import BaseComponent from '../../BaseComponent'; +import { getDateDiff } from '../../services/util'; +import ConfirmationModal from '../ConfirmationModal'; +import NewAction from './NewAction'; +import Search from '../Search'; + +function highlight(data) { + return {data.replace(/^([A-z0-9\:\-\+\.]+Z) ([A-z\-0-9]+) ([A-z\.0-9\/\[\]\-]+)\: /gm, '\u001b[36m$1\u001b[0m $2 \u001b[38;5;104m$3:\u001b[0m ')}; // eslint-disable-line +} + +const style = { + noActions: { + display: 'flex', justifyContent: 'center', alignItems: 'center', width: '100%', height: '100%', + }, + header: { + container: { + display: 'flex', flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', padding: '6px 0px', + }, + title: { + paddingLeft: '24px', + paddingRight: '24px', + }, + details: { + paddingLeft: '24px', + }, + right: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + flex: '2 0 0', + }, + left: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + flex: '1 0 0', + }, + actions: { + container: { + display: 'flex', justifyContent: 'space-between', alignItems: 'center', paddingRight: '24px', + }, + button: { + width: '50px', + }, + }, + }, + refresh: { + div: { + height: '450px', + display: 'flex', + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + }, + indicator: { + display: 'inline-block', + position: 'relative', + }, + column: { + flex: '1 0 0', + alignItems: 'center', + justifyContent: 'center', + }, + }, + content: { + rootContainer: { + height: '550px', + position: 'relative', + }, + subContainer: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + height: '100%', + }, + // noActions: { + // position: 'absolute', + // zIndex: 100, + // left: '50%', + // top: '50%', + // transform: 'translate(-50%,-50%)', + // }, + // blur: { + // filter: 'blur(1.5px)', + // pointerEvents: 'none', + // userSelect: 'none', + // }, + }, + columns: { + leftColumn: { + flex: '1 0 0', + borderRight: '3px double rgba(0, 0, 0, 0.12)', + height: 'inherit', + display: 'flex', + }, + rightColumn: { + flex: '2 0 0', + height: 'inherit', + overflow: 'hidden', + }, + }, + collapse: { + container: { + display: 'flex', flexDirection: 'column', + }, + header: { + container: { + display: 'flex', alignItems: 'center', padding: '6px 26px 0px', + }, + title: { + flex: 1, + }, + }, + }, + actions: { + listContainer: { padding: 0, flexGrow: 1 }, + listItem: { paddingLeft: '24px' }, + listItemActions: { width: '24px', minWidth: 'unset', display: 'block', paddingRight: '21px' }, + listItemTextContainer: { display: 'flex', alignItems: 'center' }, + listItemTextEventIcon: { width: '0.7em', height: '0.7em', paddingRight: '0.3em' }, + runs: { + listItem: { padding: '8px 24px' }, + statusIcon: { minWidth: '24px' }, + listItemTextContainer: { + display: 'flex', flexDirection: 'row', justifyContent: 'left', alignItems: 'center', maxWidth: '75%', + }, + statusContainer: { display: 'flex', alignItems: 'center', flex: '1 0 0' }, + timeIcon: { width: '0.7em', height: '0.7em', paddingRight: '0.3em' }, + }, + }, + actionDetails: { + list: { overflowY: 'auto', padding: '0' }, + noRuns: { paddingLeft: '8px' }, + container: { display: 'flex', flexDirection: 'column', height: 'inherit' }, + header: { + container: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + padding: '6px 24px', + }, + actions: { + container: { + paddingRight: 'unset', + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + }, + triggerContainer: { width: '50px' }, + }, + icon: { width: '50px' }, + }, + }, + runDetails: { + tabs: { backgroundColor: 'unset', color: '#424242', flexGrow: '1' }, + }, + dialogHeight: { minHeight: '430px' }, + dialogWidth: { minWidth: '300px' }, +}; + + +function getIconFromStatus(status) { + let Icon = UnknownIcon; + let iconColor = grey[500]; + if (!status || typeof status !== 'string') { + return { Icon, iconColor }; + } + switch (status.toLowerCase()) { + case 'success': + Icon = SuccessIcon; + iconColor = 'rgb(40, 167, 69)'; + break; + case 'failure': + Icon = FailureIcon; + iconColor = 'rgb(203, 36, 49)'; + break; + case 'starting': + case 'started': + case 'running': + Icon = LinearProgress; + iconColor = yellow[800]; + break; + default: + break; + } + return { Icon, iconColor }; +} + + +export default class Actions extends BaseComponent { + constructor(props, context) { + super(props, context); + this.state = { + collapse: true, + loading: true, + loadingConfig: false, + submitFail: false, + submitMessage: '', + actions: [], + selectedAction: 0, + runDetailsOpen: false, + actionConfigOpen: false, + selectedRun: null, + runDetailsTab: 0, + new: false, + startRunConfirmationOpen: false, + editActionName: '', + editActionDescription: '', + editActionSize: null, + editActionImage: '', + editActionCommand: '', + editActionEnv: '', + editActionEvents: [], + confirmActionSave: false, + sizes: [], + ungroupedSizes: [], + editActionEnvError: false, + editActionEnvErrorText: '', + availableHooks: [], + availableHooksSimple: [], + confirmActionDelete: false, + confirmActionDeleteMessage: '', + loadingLogs: true, + logs: '', + tailingLogs: true, + }; + } + + theme = parentTheme => deepmerge(parentTheme, { + overrides: { + MuiLinearProgress: { + barColorPrimary: { + backgroundColor: yellow[800], + }, + colorPrimary: { + backgroundColor: 'rgb(255, 245, 228)', + }, + }, + }, + }); + + componentDidMount() { + super.componentDidMount(); + this.getActions(); + } + + reload = async () => { + this.setState({ + loading: true, + collapse: true, + startRunConfirmationOpen: false, + confirmActionDelete: false, + loadingLogs: true, + logs: '', + }); + this.setState({ actions: [] }); + await this.getActions(); + } + + tailLogInterval = null + + fetchLogs = async () => { + const { runDetailsTab, selectedAction, actions, selectedRun } = this.state; + if (runDetailsTab !== 1) { + clearInterval(this.interval); + return; + } + const action = actions[selectedAction]; + const { data: run } = await this.api.getActionRun( + this.props.app.name, action.action, selectedRun.action_run, + ); + this.setState({ + logs: (run.logs && run.logs !== '') ? run.logs : '', + loadingLogs: false, + }); + } + + tailLogs = async (start) => { + if (!start) { + this.tailLogInterval = clearInterval(this.tailLogInterval); + this.setState({ tailingLogs: false }); + return; + } else if (this.tailLogInterval) { + return; + } + this.setState({ tailingLogs: true }); + + this.fetchLogs(); + this.tailLogInterval = setInterval(this.fetchLogs, 5000); + } + + handleStartRun = async () => { + const { app } = this.props; + const { actions, selectedAction } = this.state; + const action = actions[selectedAction]; + try { + await this.api.triggerActionRun(app.name, action.name); + await this.reload(); + } catch (err) { + this.setState({ + startRunConfirmationOpen: false, + submitMessage: err.response.data, + submitFail: true, + }); + } + } + + handleNewAction() { + this.setState({ collapse: false, new: true }); + } + + handleNewActionCancel() { + this.setState({ collapse: true }); + } + + handleActionConfigSave = async () => { + const { + selectedAction, + actions, + editActionName, + editActionDescription, + editActionSize, + editActionImage, + editActionCommand, + editActionEvents, + } = this.state; + let { editActionEnv } = this.state; + const action = actions[selectedAction]; + // Make sure that editActionEnv is a valid JSON string + try { + if (!editActionEnv || editActionEnv === '') { + // ignore validation + } else if (typeof editActionEnv === 'string') { + editActionEnv = JSON.parse(editActionEnv); + // Make sure it's formatted the same as the original object + editActionEnv = JSON.stringify(editActionEnv); + } else { + editActionEnv = JSON.stringify(editActionEnv); + } + } catch (err) { + this.setState({ editActionEnvError: true, editActionEnvErrorText: 'Env must be a valid JSON object' }); + return; + } + // If nothing changed, close. Otherwise, switch to confirmation message + if ( + (editActionName !== '' && action.name !== editActionName) || + (action.description !== editActionDescription) || + (editActionSize !== '' && action.formation.size !== editActionSize.value) || + (action.formation.options.image !== editActionImage) || + (action.formation.command !== editActionCommand) || + (JSON.stringify(action.formation.options.env) !== editActionEnv) || + (editActionEvents && action.events !== editActionEvents.join(',')) + ) { + this.setState({ confirmActionSave: true, editActionEnvError: false, editActionEnvErrorText: '' }); + } else { + this.setState({ actionConfigOpen: false, editActionEnvError: false, editActionEnvErrorText: '' }); + } + } + + handleActionConfigSaveConfirm = async () => { + const { + selectedAction, + actions, + editActionName, + editActionDescription, + editActionSize, + editActionImage, + editActionCommand, + editActionEnv, + editActionEvents, + } = this.state; + let env; + if (editActionEnv) { + try { + env = JSON.parse(editActionEnv); + } catch (err) { + env = actions[selectedAction].formation.options.env; + } + } else { + env = {}; + } + const editedAction = { + name: editActionName, + description: editActionDescription, + size: editActionSize.value, + events: editActionEvents.join(','), + command: editActionCommand, + options: { + image: editActionImage, + env, + }, + }; + // Display loading + this.setState({ confirmActionLoading: true }, async () => { + try { + await this.api.patchAction( + this.props.app.name, + actions[selectedAction].action, + editedAction, + ); + this.setState({ + actionConfigOpen: false, + confirmActionSave: false, + confirmActionLoading: false, + }); + this.reload('Action successfully updated'); + } catch (err) { + const msg = err.response ? `${err.message}: ${err.response.data}` : err.message; + this.setState({ + actionConfigOpen: false, + confirmActionSave: false, + confirmActionLoading: false, + submitFail: true, + submitMessage: msg, + }); + } + }); + } + + handleDeleteAction = async () => { + const { actions, selectedAction } = this.state; + try { + this.setState({ confirmActionDelete: false, loading: true }); + await this.api.deleteAction(this.props.app.name, actions[selectedAction].action); + this.reload('Action successfully deleted'); + } catch (err) { + const msg = err.response ? `${err.message}: ${err.response.data}` : err.message; + this.setState({ submitFail: true, submitMessage: msg }); + this.reload(); + } + } + + getActions = async () => { + try { + const { data: actions } = await this.api.getActions(this.props.app.name); + await Promise.all(actions.map(async (action, idx) => { + const { data: runs } = await this.api.getActionRuns(this.props.app.name, action.action); + actions[idx].runs = runs; + })); + this.setState({ + actions, + loading: false, + selectedAction: this.state.selectedAction < actions.length ? this.state.selectedAction : 0, + }); + } catch (err) { + if (!this.isCancel(err)) { + console.error(err); // eslint-disable-line no-console + } + } + } + + // Copy of the getSizes function in NewFormation + // Also gets available hooks + getSizes = async () => { + try { + const { data: formationSizes } = await this.api.getFormationSizes(); + let { data: availableHooks } = (await this.api.getAvailableHooks()); + // Filter out "destroy" hook, which we can't use for actions + availableHooks = availableHooks.filter(hook => hook.type !== 'destroy'); + const availableHooksSimple = availableHooks.map(h => h.type); + + const sizes = []; + formationSizes.forEach((size) => { + if (size.name.indexOf('prod') === -1) { + sizes.push(size); + } + }); + + const smallestSize = (sizes.sort((a, b) => a.price - b.price))[0]; + + const groupedSizes = sizes.reduce((acc, size) => { + const idx = acc.findIndex(e => e.label.toLowerCase() === size.type.toLowerCase()); + if (idx === -1) { + acc.push({ + label: size.type.charAt(0).toUpperCase() + size.type.slice(1), + options: [{ + value: size.name, + label: `${size.name}: ${size.description}`, + }], + }); + } else { + acc[idx].options.push({ + value: size.name, + label: `${size.name}: ${size.description}`, + memory: parseInt(size.resources.limits.memory, 10), + }); + } + return acc; + }, []); + + groupedSizes.forEach(size => size.options.sort((a, b) => a.memory - b.memory)); + groupedSizes.sort((a, b) => { + const la = a.label.toLowerCase(); + const lb = b.label.toLowerCase(); + if (la < lb) return -1; + if (la > lb) return 1; + return 0; + }); + + this.setState({ + availableHooks, + availableHooksSimple, + ungroupedSizes: sizes, + sizes: groupedSizes, + editActionSize: { + label: `${smallestSize.name}: ${smallestSize.description}`, + value: smallestSize.name, + }, + }); + } catch (err) { + if (!this.isCancel(err)) { + console.error(err); // eslint-disable-line no-console + } + } + } + + renderActions() { + const { actions, selectedAction } = this.state; + if (!actions || actions.length <= 0) { + return ( +
+ + No actions + +
+ ); + } + return ( + + {actions.map((action, index) => { + const selected = (index === selectedAction); + const latestRun = ( + action.runs && + Array.isArray(action.runs) && + action.runs.length > 0 + ) ? action.runs[0] : null; + const { + Icon: StatusIcon, iconColor, + } = getIconFromStatus(latestRun ? latestRun.status : null); + return ( + + { this.setState({ selectedAction: index }); }} + > + + + + + + + {latestRun ? getDateDiff(latestRun.finished || latestRun.started_at || latestRun.created) : 'Never'} + + + } + /> + + + + ); + })} + + ); + } + + renderActionRuns = action => + action.runs.map((run) => { + const { Icon: StatusIcon, iconColor } = getIconFromStatus(run.status); + let FinishedIcon; + if (run.finished_at && run.status === 'success') FinishedIcon = TimeFinishedSuccess; + else if (run.finished_at && run.status === 'failure') FinishedIcon = TimeFinishedFailure; + else FinishedIcon = EventIcon; + + return ( + + { this.setState({ selectedRun: run, runDetailsOpen: true }); }} + style={style.actions.runs.listItem} + > + + + + + + + + + {getDateDiff(run.started_at || run.created)} + + + + {run.finished_at ? ( + + + + + {getDateDiff(run.finished_at)} + + + + ) : ( + + + + {run.status} + + + )} + + } + /> + + + + ); + }); + + renderActionDetails() { + const { actions, selectedAction } = this.state; + if (!actions || actions.length <= 0) { + return null; + } + const action = actions[selectedAction]; + return ( +
+
+
+ {action.name} + {action.action} +
+
+
+ + { this.setState({ startRunConfirmationOpen: true }); }} + > + + + +
+
+ + { + this.setState({ loadingConfig: true, actionConfigOpen: true }, async () => { + if (!this.state.sizes || this.state.sizes.length === 0) { + await this.getSizes(); + } + const size = this.state.ungroupedSizes.find( + x => x.name === action.formation.size, + ); + this.setState({ + editActionSize: { + label: `${size.name}: ${size.description}`, + value: size.name, + }, + editActionName: action.name, + editActionDescription: action.description, + editActionImage: action.formation.options.image, + editActionCommand: action.formation.command, + editActionEnv: JSON.stringify(action.formation.options.env), + editActionEvents: action.events.split(','), + loadingConfig: false, + }); + }); + }} + > + + + +
+
+
+ + + {action.runs.length > 0 ? (this.renderActionRuns(action)) : ( + + + + )} + +
+ ); + } + + renderRunDetails() { + /* + action_run uuid + status 'success', 'failure', 'starting', 'running' + exit_code 0, 1, etc + source manual_trigger, ...events + started_at timestamptz + finished_at timestamptz + created_by username, unknown, system + run_number 1, 2, etc + */ + const { selectedRun: run, runDetailsTab, loadingLogs, logs, tailingLogs } = this.state; + return ( +
+
+ { + if (v === 1) { this.tailLogs(true); } + this.setState({ runDetailsTab: v }); + }} + style={style.runDetails.tabs} + > + + + + {runDetailsTab === 1 && ( +
+ {!tailingLogs ? ( + + { this.tailLogs(true); }}> + + + + ) : ( + + { this.tailLogs(false); }}> + + + + )} +
+ )} +
+
+ {runDetailsTab === 0 && ( +
+ + + + ID + {run.action_run} + + + Status + {run.status} + + + Exit Code + {run.exit_code === 0 ? 0 : (run.exit_code || '--')} + + + Source + {run.source} + + + Started At + {run.started_at || '--'} + + + Finished At + {run.finished_at || '--'} + + + Created By + {run.created_by} + + +
+
+ )} + {runDetailsTab === 1 && ( +
+ {loadingLogs ? ( +
+ +
+ ) : ( + highlight(data)} + extraLines={1} + /> + )} +
+ )} +
+
+ ); + } + + renderActionConfirm() { + const { + actions, + selectedAction, + editActionName, + editActionDescription, + editActionSize, + editActionImage, + editActionCommand, + editActionEvents, + confirmActionLoading, + } = this.state; + const action = actions[selectedAction]; + // Make sure formatting matches + let { editActionEnv } = this.state; + if (editActionEnv && editActionEnv !== '') { + editActionEnv = JSON.parse(editActionEnv); + editActionEnv = JSON.stringify(editActionEnv); + } + + if (confirmActionLoading) { + return ( +
+
+ +
+
+ ); + } + return ( +
+ + + + + + + + + Option + Old Value + New Value + + + + {editActionName && action.name !== editActionName && ( + + Name + + {action.name} + + + {editActionName} + + + )} + {editActionDescription && action.description !== editActionDescription && ( + + Description + + {action.description} + + + {editActionDescription} + + + )} + {editActionSize && action.formation.size !== editActionSize.value && ( + + Size + + {action.formation.size} + + + {editActionSize.value} + + + )} + {editActionImage && action.formation.options.image !== editActionImage && ( + + Image + + {action.formation.options.image} + + + {editActionImage} + + + )} + {editActionCommand && action.formation.command !== editActionCommand && ( + + Command + + {action.formation.command} + + + {editActionCommand} + + + )} + {JSON.stringify(action.formation.options.env) !== editActionEnv && ( + + Env Vars + + {JSON.stringify(action.formation.options.env)} + + + {editActionEnv} + + + )} + {editActionEvents && action.events !== editActionEvents.join(',') && ( + + Events + + {action.events} + + + {editActionEvents.join(',')} + + + )} + +
+
+ ); + } + + renderActionConfig() { + const { sizes, editActionSize, availableHooksSimple } = this.state; + return ( +
+ + + this.setState({ editActionName: e.target.value })} + /> + + + this.setState({ editActionDescription: e.target.value })} + /> + + + { this.setState({ editActionSize: event }); }} + placeholder="Select a Size" + label="Size" + style={{ width: '600px' }} + /> + + + this.setState({ editActionImage: e.target.value })} + /> + + + this.setState({ editActionCommand: e.target.value })} + /> + + + this.setState({ editActionEnv: e.target.value })} + /> + + + + Events + + + + +
+ ); + } + + renderRunDetailsDialog() { + const { actions, selectedAction, selectedRun, runDetailsOpen } = this.state; + return ( + { this.setState({ selectedRun: null }); }} + maxWidth="md" + fullWidth + > + + {actions[selectedAction].name} #{selectedRun && selectedRun.run_number} Details + + +
+ {this.renderRunDetails()} +
+
+ + + +
+ ); + } + + renderActionConfigDialog() { + const { + actions, selectedAction, actionConfigOpen, loadingConfig, + confirmActionSave, confirmActionDelete, + } = this.state; + return ( + + {actions[selectedAction].name} Configuration{confirmActionSave && ' Confirmation'} + +
+ {loadingConfig && ( +
+ +
+ )} + {confirmActionSave && this.renderActionConfirm()} + { + !loadingConfig && + !confirmActionSave && + !confirmActionDelete && + this.renderActionConfig() + } +
+
+ + + + { confirmActionSave && ( + + )} + { confirmActionSave ? ( + + ) : ( + + )} + + +
+ ); + } + + render() { + const { + collapse, loading, runDetailsOpen, actionConfigOpen, submitFail, submitMessage, + startRunConfirmationOpen, confirmActionDeleteMessage, confirmActionDelete, + } = this.state; + return ( + +
+ this.setState({ new: false })} + in={!collapse} + > +
+
+ {this.state.new && 'New Build'} +
+ {this.state.new && ( + { this.handleNewActionCancel(); }}> + )} +
+
+
+ {this.state.new && ( + this.reload(message)} + /> + )} +
+
+
+
+
+ Actions +
+
+ Details + {collapse && ( +
+
+ + this.reload()}> + +
+
+ + { this.handleNewAction(); }}> + +
+
+ )} +
+
+ + {loading ? ( +
+
+ +
+
+
+ ) : ( +
+
+
+ {this.renderActions()} +
+
+ {this.renderActionDetails()} +
+
+
+ )} +
+ {runDetailsOpen && this.renderRunDetailsDialog()} + {actionConfigOpen && this.renderActionConfigDialog()} + this.setState({ startRunConfirmationOpen: false })} + title="Confirm Trigger" + message="Are you sure you want to start a new run?" + /> + this.setState({ submitFail: false })} + message={submitMessage} + title="Error" + className="submit-action-error" + /> + this.setState({ confirmActionDelete: false })} + message={confirmActionDeleteMessage} + title="Confirm Deletion" + className="delete-action-confirm" + /> + + ); + } +} diff --git a/src/components/CustomSelect.jsx b/src/components/CustomSelect.jsx index 33a3e10..2fdc956 100644 --- a/src/components/CustomSelect.jsx +++ b/src/components/CustomSelect.jsx @@ -2,7 +2,7 @@ import React, { PureComponent } from 'react'; import deepmerge from 'deepmerge'; import PropTypes from 'prop-types'; import { MuiThemeProvider } from '@material-ui/core/styles'; -import { Select, InputLabel, FormControl } from '@material-ui/core'; +import { Select, InputLabel, FormControl, MenuItem } from '@material-ui/core'; class CustomSelect extends PureComponent { theme = parentTheme => deepmerge(parentTheme, { @@ -44,7 +44,7 @@ class CustomSelect extends PureComponent { render() { - const { children, value, onChange, name, label, style } = this.props; + const { children, value, onChange, name, label, style, multiple, options, fullWidth } = this.props; return ( @@ -53,12 +53,20 @@ class CustomSelect extends PureComponent { className={`${name}-dropdown`} value={value} onChange={onChange} + multiple={multiple} + fullWidth={fullWidth} inputProps={{ name, id: `${name}-select`, }} > - {children} + {multiple ? ( + options.map(option => ( + + {option} + + )) + ) : children} @@ -68,12 +76,15 @@ class CustomSelect extends PureComponent { CustomSelect.propTypes = { children: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.node), PropTypes.node]), - value: PropTypes.oneOfType([PropTypes.object, PropTypes.string]).isRequired, // eslint-disable-line + value: PropTypes.oneOfType([PropTypes.object, PropTypes.string, PropTypes.arrayOf(PropTypes.string)]).isRequired, // eslint-disable-line style: PropTypes.object, // eslint-disable-line react/forbid-prop-types onChange: PropTypes.func.isRequired, name: PropTypes.string.isRequired, color: PropTypes.string, label: PropTypes.string, + multiple: PropTypes.bool, + options: PropTypes.arrayOf(PropTypes.string), + fullWidth: PropTypes.bool, }; CustomSelect.defaultProps = { @@ -81,6 +92,9 @@ CustomSelect.defaultProps = { color: 'white', label: 'Select', style: {}, + multiple: false, + options: [], + fullWidth: false, }; export default CustomSelect; diff --git a/src/components/Icons/ActionsIcon.jsx b/src/components/Icons/ActionsIcon.jsx new file mode 100644 index 0000000..67267c5 --- /dev/null +++ b/src/components/Icons/ActionsIcon.jsx @@ -0,0 +1,12 @@ +import React from 'react'; +import SvgIcon from '@material-ui/core/SvgIcon'; + +const ActionsIcon = props => ( + + + + + +); + +export default ActionsIcon; diff --git a/src/config/GlobalTheme.jsx b/src/config/GlobalTheme.jsx index 253571b..388864e 100644 --- a/src/config/GlobalTheme.jsx +++ b/src/config/GlobalTheme.jsx @@ -69,7 +69,7 @@ const GlobalTheme = createMuiTheme({ }, MuiTab: { root: { - minWidth: '120px !important', + minWidth: '114px !important', }, }, MuiCard: { diff --git a/src/scenes/Apps/AppInfo.jsx b/src/scenes/Apps/AppInfo.jsx index 1285fd1..537dc4d 100644 --- a/src/scenes/Apps/AppInfo.jsx +++ b/src/scenes/Apps/AppInfo.jsx @@ -24,6 +24,7 @@ import WarningIcon from '@material-ui/icons/Warning'; import ReactGA from 'react-ga'; import AutoBuildIcon from '../../components/Icons/CircuitBoard'; +import ActionsIcon from '../../components/Icons/ActionsIcon'; import GitIcon from '../../components/Icons/GitIcon'; import WebhookIcon from '../../components/Icons/WebhookIcon'; import Formations from '../../components/Formations'; @@ -33,6 +34,7 @@ import Config from '../../components/ConfigVars'; import Metrics from '../../components/Metrics'; import Addons from '../../components/Addons'; import Logs from '../../components/Logs'; +import Actions from '../../components/Actions'; import AppOverview from '../../components/Apps/AppOverview'; import { updateHistory } from '../../services/util'; import History from '../../config/History'; @@ -88,7 +90,7 @@ function addRestrictedTooltip(title, children) { ); } -const tabs = ['info', 'dynos', 'releases', 'addons', 'config', 'logs', 'metrics', 'webhooks']; +const tabs = ['info', 'dynos', 'actions', 'releases', 'addons', 'config', 'logs', 'metrics', 'webhooks']; export default class AppInfo extends BaseComponent { constructor(props) { @@ -645,6 +647,16 @@ export default class AppInfo extends BaseComponent { label="Dynos" value="dynos" /> + } + label="Actions" + value="actions" + /> )} + {currentTab === 'actions' && ( + + )} {currentTab === 'releases' && (