diff --git a/ui/.dockerignore b/ui/.dockerignore new file mode 100644 index 000000000000..eab1ac9e81a6 --- /dev/null +++ b/ui/.dockerignore @@ -0,0 +1,3 @@ +node_modules +.git +Dockerfile diff --git a/ui/.gitignore b/ui/.gitignore new file mode 100644 index 000000000000..5c0dd18e851c --- /dev/null +++ b/ui/.gitignore @@ -0,0 +1,5 @@ +node_modules +dist +bundle +.vscode + diff --git a/ui/Dockerfile b/ui/Dockerfile new file mode 100644 index 000000000000..864bcbd61a9c --- /dev/null +++ b/ui/Dockerfile @@ -0,0 +1,21 @@ +FROM node:11.15.0 as build + +WORKDIR /src +ADD ["package.json", "yarn.lock", "./"] + +RUN yarn install + +ADD [".", "."] + +ARG ARGO_VERSION=latest +ENV ARGO_VERSION=$ARGO_VERSION +RUN NODE_ENV='production' yarn build && yarn cache clean && yarn install --production + +FROM node:11.15.0-alpine + +COPY --from=build ./src/dist /app +COPY --from=build ./src/node_modules /app/node_modules +WORKDIR /app + +EXPOSE 8001 +CMD node api/api/main.js --uiDist /app/app --inCluster ${IN_CLUSTER} --namespace ${ARGO_NAMESPACE} --force-namespace-isolation ${FORCE_NAMESPACE_ISOLATION} --instanceId ${INSTANCE_ID:-''} --enableWebConsole ${ENABLE_WEB_CONSOLE:-'false'} --uiBaseHref ${BASE_HREF:-'/'} --ip ${IP:-'0.0.0.0'} --port ${PORT:-'8001'} diff --git a/ui/Procfile b/ui/Procfile new file mode 100644 index 000000000000..0ab8e8d118af --- /dev/null +++ b/ui/Procfile @@ -0,0 +1,2 @@ +ui: yarn start:ui +api: yarn start:api diff --git a/ui/README.md b/ui/README.md index 1a4cc30425f9..cdafdfe47b58 100644 --- a/ui/README.md +++ b/ui/README.md @@ -1,3 +1,19 @@ # Argo UI -Moved to https://github.com/argoproj/argo-ui +![Argo Image](https://github.com/argoproj/argo/blob/master/argo.png?raw=true) + +A web-based UI for the Argo Workflow engine. + +The UI has the following features: +* View live Argo Workflows running in the cluster +* View completed Argo Workflows +* View container logs + + +## Build, Run, & Release + +1. Install Toolset: [NodeJS](https://nodejs.org/en/download/) and [Yarn](https://yarnpkg.com) +2. Install Dependencies: From your command line, navigate to the argo-ui directory and run `yarn install` to install dependencies. +3. Run: `yarn start` - starts API server and webpack dev UI server. API server uses current `kubectl` context to access workflow CRDs. +4. Build: `yarn build` - builds static resources into `./dist` directory. +5. Release: `IMAGE_NAMESPACE=argoproj IMAGE_TAG=latest DOCKER_PUSH=true yarn docker` - builds docker image and optionally push to docker registry. diff --git a/ui/VERSION b/ui/VERSION new file mode 100644 index 000000000000..7fe52d367f85 --- /dev/null +++ b/ui/VERSION @@ -0,0 +1 @@ +v2.2.1 diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 000000000000..1e0bd4243a78 --- /dev/null +++ b/ui/package.json @@ -0,0 +1,109 @@ +{ + "name": "argo-ui", + "version": "1.0.0", + "license": "MIT", + "files": [ + "src" + ], + "scripts": { + "docker": "./scripts/build_docker.sh", + "build": "yarn build:ui && yarn build:api", + "build:ui": "webpack --config ./src/app/webpack.config.js", + "build:api": "tsc -p ./src/api/tsconfig.json", + "build:storybook": "build-storybook -o ./dist/storybook", + "start": "nf start", + "start:ui": "webpack-dev-server --config ./src/app/webpack.config.js", + "start:api": "TS_NODE_PROJECT=./src/api/tsconfig.json nodemon --nolazy --inspect -r ts-node/register ./src/api/main.ts", + "lint": "yarn lint:ui && yarn lint:api", + "lint:ui": "tslint -p ./src/app", + "lint:api": "tslint -p ./src/api", + "test": "mocha --require ts-node/register ./src/app/**/*.spec.ts" + }, + "dependencies": { + "@fortawesome/fontawesome-free": "^5.8.1", + "@tippy.js/react": "^2.1.2", + "@types/react-form": "^2.16.1", + "@types/react-helmet": "^5.0.8", + "argo-ui": "https://github.com/argoproj/argo-ui.git", + "aws-sdk": "^2.188.0", + "body-parser": "^1.18.2", + "classnames": "^2.2.5", + "dagre": "^0.8.2", + "deep-equal": "^1.0.1", + "express": "^4.16.2", + "express-winston": "^3.0.0", + "foundation-sites": "^6.4.3", + "history": "^4.7.2", + "json-stream": "^1.0.0", + "kubernetes-client": "3.17.1", + "moment": "^2.20.1", + "prop-types": "^15.6.0", + "react": "^16.8.3", + "react-dom": "^16.8.3", + "react-form": "2.16.0", + "react-helmet": "^5.2.0", + "react-router-dom": "^4.2.2", + "react-toastify": "^5.0.1", + "rxjs": "^5.5.6", + "superagent": "^3.8.2", + "superagent-promise": "^1.1.0", + "ts-loader": "^6.0.4", + "typescript": "^2.8.3", + "util.promisify": "^1.0.0", + "webpack-cli": "^3.3.5", + "winston": "^3.1.0", + "ws": "^4.0.0", + "xterm": "2.4.0", + "yamljs": "^0.3.0", + "yargs": "^11.0.0" + }, + "devDependencies": { + "@dump247/storybook-state": "^1.5.0", + "@storybook/addon-actions": "^5.1.9", + "@storybook/addon-links": "^5.1.9", + "@storybook/addons": "^5.1.9", + "@storybook/react": "^5.1.9", + "@types/aws-sdk": "^2.7.0", + "@types/chai": "^4.1.2", + "@types/classnames": "^2.2.3", + "@types/dagre": "^0.7.39", + "@types/deep-equal": "^1.0.1", + "@types/history": "^4.6.2", + "@types/mocha": "^2.2.48", + "@types/prop-types": "^15.5.2", + "@types/react": "^16.8.5", + "@types/react-dom": "^16.8.2", + "@types/react-router-dom": "^4.2.3", + "@types/storybook__addon-actions": "^3.0.2", + "@types/storybook__addon-links": "^3.3.0", + "@types/storybook__react": "^3.0.7", + "@types/superagent": "^3.5.7", + "@types/ws": "^4.0.0", + "@types/yamljs": "^0.2.30", + "babel-core": "^6.26.0", + "chai": "^4.1.2", + "copy-webpack-plugin": "^4.3.1", + "copyfiles": "^1.2.0", + "foreman": "^3.0.1", + "glob": "^7.1.2", + "html-webpack-plugin": "^3.2.0", + "jscs": "^3.0.7", + "mocha": "^5.0.0", + "node-sass": "^4.12.0", + "nodemon": "^1.14.11", + "raw-loader": "^0.5.1", + "react-hot-loader": "^3.1.3", + "sass-loader": "^6.0.6", + "source-map-loader": "^0.2.3", + "style-loader": "^0.20.1", + "ts-node": "^4.1.0", + "tslint": "^5.9.1", + "tslint-react": "^3.4.0", + "webfonts-generator": "^0.4.0", + "webpack": "^4.35.0", + "webpack-dev-server": "^3.7.2" + }, + "resolutions": { + "@types/react": "16.8.5" + } +} diff --git a/ui/scripts/build_docker.sh b/ui/scripts/build_docker.sh new file mode 100755 index 000000000000..b666c6d82ec4 --- /dev/null +++ b/ui/scripts/build_docker.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +CURRENT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )" +GIT_COMMIT=`git rev-parse --short HEAD` +VERSION=`cat ${CURRENT_DIR}/../VERSION` + +set -e + +TAG=${IMAGE_TAG:-"$VERSION-$GIT_COMMIT"} + +docker build --build-arg ARGO_VERSION=${TAG} -t ${IMAGE_NAMESPACE:-`whoami`}/argoui:${TAG} . + +if [ "$DOCKER_PUSH" == "true" ] +then + docker push ${IMAGE_NAMESPACE:-`whoami`}/argoui:${TAG} +fi diff --git a/ui/src/api/app.ts b/ui/src/api/app.ts new file mode 100644 index 000000000000..352afca3a484 --- /dev/null +++ b/ui/src/api/app.ts @@ -0,0 +1,235 @@ +import * as aws from 'aws-sdk'; +import * as bodyParser from 'body-parser'; +import * as express from 'express'; +import * as expressWinston from 'express-winston'; +import * as fs from 'fs'; +import * as http from 'http'; +import * as JSONStream from 'json-stream'; +import * as Api from 'kubernetes-client'; +import * as path from 'path'; +import { Observable, Observer } from 'rxjs'; +import * as nodeStream from 'stream'; +import * as promisify from 'util.promisify'; +import * as winston from 'winston'; + +import * as zlib from 'zlib'; +import * as models from '../models/workflows'; +import * as consoleProxy from './console-proxy'; + +import { decodeBase64, reactifyStringStream, streamServerEvents } from './utils'; + +const winstonTransport = new winston.transports.Console({ + format: winston.format.combine( + winston.format.timestamp(), + winston.format.simple(), + ), +}); + +const logger = winston.createLogger({ + transports: [winstonTransport], +}); + +function serve(res: express.Response, action: () => Promise) { + action().then((val) => res.send(val)).catch((err) => { + if (err instanceof Error) { + err = {...err, message: err.message}; + } + res.status(500).send(err); + logger.error(err); + }); +} + +function fileToString(filePath: string): Promise { + return new Promise((resolve, reject) => { + fs.readFile(filePath, 'utf-8', (err, content) => { + if (err) { + reject(err); + } else { + resolve(content); + } + }); + }); +} + +export function create( + uiDist: string, + uiBaseHref: string, + inCluster: boolean, + namespace: string, + forceNamespaceIsolation: boolean, + instanceId: string, + version, + group = 'argoproj.io') { + const config = Object.assign( + {}, inCluster ? Api.config.getInCluster() : Api.config.fromKubeconfig(), {namespace, promises: true }); + const core = new Api.Core(config); + const crd = new Api.CustomResourceDefinitions(Object.assign(config, {version, group})); + crd.addResource('workflows'); + const app = express(); + app.use(bodyParser.json({type: () => true})); + + app.use(expressWinston.logger({ + transports: [winstonTransport], + meta: false, + msg: '{{res.statusCode}} {{req.method}} {{res.responseTime}}ms {{req.url}}', + })); + + function getWorkflowLabelSelector(req) { + const labelSelector: string[] = []; + if (instanceId) { + labelSelector.push(`workflows.argoproj.io/controller-instanceid = ${instanceId}`); + } + if (req.query.phase) { + const phases = req.query.phase instanceof Array ? req.query.phase : [req.query.phase]; + if (phases.length > 0) { + labelSelector.push(`workflows.argoproj.io/phase in (${phases.join(',')})`); + } + } + return labelSelector; + } + + app.get('/api/workflows', (req, res) => serve(res, async () => { + const labelSelector = getWorkflowLabelSelector(req); + + const workflowList = await (forceNamespaceIsolation ? crd.ns(namespace) : crd).workflows.get({ + qs: { labelSelector: labelSelector.join(',') }, + }) as models.WorkflowList; + + workflowList.items.sort(models.compareWorkflows); + workflowList.items = await Promise.all(workflowList.items.map(deCompressNodes)); + return workflowList; + })); + + app.get('/api/workflows/:namespace/:name', + async (req, res) => serve(res, () => (forceNamespaceIsolation ? crd.ns(namespace) : crd.ns(req.params.namespace)).workflows.get(req.params.name).then((deCompressNodes)))); + + app.get('/api/workflows/live', async (req, res) => { + const ns = getNamespace(req); + let updatesSource = new Observable((observer: Observer) => { + const labelSelector = getWorkflowLabelSelector(req); + let stream = (ns ? crd.ns(ns) : crd).workflows.getStream({ qs: { watch: true, labelSelector: labelSelector.join(',') } }); + stream.on('end', () => observer.complete()); + stream.on('error', (e) => observer.error(e)); + stream.on('close', () => observer.complete()); + stream = stream.pipe(new JSONStream()); + stream.on('data', (data) => data && observer.next(data)); + }).flatMap((change) => Observable.fromPromise(deCompressNodes(change.object).then((workflow) => ({...change, object: workflow})))); + if (ns) { + updatesSource = updatesSource.filter((change) => { + return change.object.metadata.namespace === ns; + }); + } + if (req.query.name) { + updatesSource = updatesSource.filter((change) => change.object.metadata.name === req.query.name); + } + streamServerEvents(req, res, updatesSource, (item) => JSON.stringify(item)); + }); + + function getNamespace(req: express.Request) { + return forceNamespaceIsolation ? namespace : (req.query.namespace || req.params.namespace); + } + + function getWorkflow(ns: string, name: string): Promise { + return crd.ns(ns).workflows.get(name).then(deCompressNodes); + } + + async function deCompressNodes(workFlow: models.Workflow): Promise { + if (workFlow.status.compressedNodes !== undefined && workFlow.status.compressedNodes !== '') { + const buffer = Buffer.from(workFlow.status.compressedNodes, 'base64'); + const unCompressedBuffer = await promisify(zlib.unzip)(buffer); + workFlow.status.nodes = JSON.parse(unCompressedBuffer.toString()); + delete workFlow.status.compressedNodes; + return workFlow; + } else { + return workFlow; + } + } + + function loadNodeArtifact(wf: models.Workflow, nodeId: string, artifactName: string): Promise<{ data: Buffer, fileName: string }> { + return new Promise(async (resolve, reject) => { + const node = wf.status.nodes[nodeId]; + const artifact = node.outputs.artifacts.find((item) => item.name === artifactName); + if (artifact.s3) { + try { + const secretAccessKey = decodeBase64((await core.ns( + wf.metadata.namespace).secrets.get(artifact.s3.secretKeySecret.name)).data[artifact.s3.secretKeySecret.key]).trim(); + const accessKeyId = decodeBase64((await core.ns( + wf.metadata.namespace).secrets.get(artifact.s3.accessKeySecret.name)).data[artifact.s3.accessKeySecret.key]).trim(); + const s3 = new aws.S3({ + region: artifact.s3.region, secretAccessKey, accessKeyId, endpoint: `http://${artifact.s3.endpoint}`, s3ForcePathStyle: true, signatureVersion: 'v4' }); + s3.getObject({ Bucket: artifact.s3.bucket, Key: artifact.s3.key }, (err, data) => { + if (err) { + reject(err); + } else { + resolve({ data: data.Body as Buffer, fileName: path.basename(artifact.s3.key) }); + } + }); + } catch (e) { + reject(e); + } + } else { + reject({ code: 'INTERNAL_ERROR', message: 'Artifact source is not supported' }); + } + }); + } + + app.get('/api/workflows/:namespace/:name/artifacts/:nodeId/:artifactName', async (req, res) => { + try { + const wf = await getWorkflow(getNamespace(req), req.params.name); + const artifact = await loadNodeArtifact(wf, req.params.nodeId, req.params.artifactName); + const readStream = new nodeStream.PassThrough(); + readStream.end(artifact.data); + res.set('Content-disposition', 'attachment; filename=' + artifact.fileName); + readStream.pipe(res); + } catch (err) { + res.status(500).send(err); + logger.error(err); + } + }); + + app.get('/api/logs/:namespace/:name/:nodeId/:container', async (req: express.Request, res: express.Response) => { + try { + const wf = await getWorkflow(getNamespace(req), req.params.name); + try { + await core.ns(wf.metadata.namespace).pods.get(req.params.nodeId); + const logsSource = reactifyStringStream( + core.ns(wf.metadata.namespace).po(req.params.nodeId).log.getStream({ qs: { container: req.params.container, follow: true } })); + streamServerEvents(req, res, logsSource, (item) => item.toString()); + } catch (e) { + if (e.code === 404) { + // Try load logs from S3 if pod already deleted + const artifact = await loadNodeArtifact(wf, req.params.nodeId, 'main-logs'); + streamServerEvents(req, res, Observable.from(artifact.data.toString('utf8').split('\n')), (line) => line); + } else { + throw e; + } + } + } catch (e) { + logger.error(e); + res.send(e); + } + }); + + const serveIndex = (req: express.Request, res: express.Response) => { + fileToString(`${uiDist}/index.html`).then((content) => { + return content.replace(``, ``); + }) + .then((indexContent) => res.send(indexContent)) + .catch((err) => res.send(err)); + }; + + app.get('/index.html', serveIndex); + app.use(express.static(uiDist, {index: false})); + app.use(async (req: express.Request, res: express.Response, next: express.NextFunction) => { + if ((req.method === 'GET' || req.method === 'HEAD') && req.accepts('html')) { + serveIndex(req, res); + } else { + next(); + } + }); + + const server = http.createServer(app); + consoleProxy.create(server, core); + + return server; +} diff --git a/ui/src/api/console-proxy.ts b/ui/src/api/console-proxy.ts new file mode 100644 index 000000000000..c8794f445612 --- /dev/null +++ b/ui/src/api/console-proxy.ts @@ -0,0 +1,64 @@ +import * as http from 'http'; +import * as url from 'url'; +import * as WebSocket from 'ws'; + +import * as utils from './utils'; + +function safeCallback(callback) { + const self = this; + // tslint:disable-next-line:only-arrow-functions + return function() { + try { + return callback.apply(self, arguments); + } catch (e) { + // tslint:disable-next-line:no-console + console.error(e); + } + }; +} + +export function create(server: http.Server, core) { + const wss = new WebSocket.Server({server}); + + wss.on('connection', safeCallback((ws, req) => { + const location = url.parse(req.url, true); + const match = location + .path + .match(/\/api\/steps\/([^/]*)\/([^/]*)\/exec/); + if (match) { + const cmd = [location.query.cmd]; + const [, ns, pod] = match; + const apiUri = url + .parse(core.url) + .host; + let uri = `wss://${apiUri}/api/v1/namespaces/${ns}/pods/${pod}/exec?stdout=1&stdin=1&stderr=1&tty=1&container=main`; + cmd.forEach((subCmd) => uri += `&command=${encodeURIComponent(subCmd as string)}`); + + const kubeClient = new WebSocket(uri, 'base64.channel.k8s.io', { + headers: { + Authorization: `Bearer ${core.requestOptions.auth.bearer}`, + }, + }); + + kubeClient.on('message', safeCallback((data) => { + if (data[0].match(/^[0-3]$/)) { + ws.send(utils.decodeBase64(data.slice(1))); + } + })); + kubeClient.on('close', safeCallback(() => { + ws.terminate(); + })); + kubeClient.on('error', safeCallback((err) => { + ws.send(err.message); + ws.terminate(); + })); + + ws.on('message', safeCallback((message) => { + kubeClient.send('0' + utils.encodeBase64(message)); + })); + } else { + ws.close(1002, 'Invalid URL'); + } + })); + +} diff --git a/ui/src/api/main.ts b/ui/src/api/main.ts new file mode 100644 index 000000000000..cf3a4a232514 --- /dev/null +++ b/ui/src/api/main.ts @@ -0,0 +1,22 @@ +process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; + +import * as path from 'path'; +import * as yargs from 'yargs'; +import * as app from './app'; + +const argv = yargs.argv; + +const ip = argv.ip || '0.0.0.0'; +const port = argv.port || '8001'; +// tslint:disable-next-line +console.log(`start argo-ui on ${argv.ip}:${argv.port}`); + +app.create( + argv.uiDist || path.join(__dirname, '..', '..', 'dist', 'app'), + argv.uiBaseHref || '/', + argv.inCluster === 'true', + argv.namespace || 'default', + argv.forceNamespaceIsolation === 'true', + argv.instanceId || undefined, + argv.crdVersion || 'v1alpha1', +).listen(port, ip); diff --git a/ui/src/api/tsconfig.json b/ui/src/api/tsconfig.json new file mode 100644 index 000000000000..f7e70ac1fb61 --- /dev/null +++ b/ui/src/api/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compileOnSave": false, + "compilerOptions": { + "outDir": "../../dist/api", + "sourceMap": true, + "declaration": false, + "moduleResolution": "node", + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "noUnusedLocals": true, + "target": "es5", + "typeRoots": [ + "node_modules/@types" + ], + "lib": [ + "es2017", + "dom" + ] + } +} diff --git a/ui/src/api/utils.ts b/ui/src/api/utils.ts new file mode 100644 index 000000000000..86ac11bf2b3f --- /dev/null +++ b/ui/src/api/utils.ts @@ -0,0 +1,37 @@ +import * as express from 'express'; +import {Observable, Observer} from 'rxjs'; + +export function reactifyStream(stream, converter = (item) => item) { + return new Observable((observer: Observer < any >) => { + stream.on('data', (d) => observer.next(converter(d))); + stream.on('end', () => observer.complete()); + stream.on('error', (e) => observer.error(e)); + }); +} + +export function reactifyStringStream(stream) { + return reactifyStream(stream, (item) => item.toString()); +} + +export function streamServerEvents (req: express.Request, res: express.Response, source: Observable , formatter: (input: T) => string) { + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Transfer-Encoding', 'chunked'); + res.setHeader('X-Content-Type-Options', 'nosniff'); + + const subscription = source.subscribe((info) => res.write(`data:${formatter(info)}\n\n`), (err) => { + res.set(200); + res.end(); + }, () => { + res.set(200); + res.end(); + }); + req.on('close', () => subscription.unsubscribe()); +} + +export function decodeBase64(input: string) { + return new Buffer(input, 'base64').toString('ascii'); +} + +export function encodeBase64(input: string) { + return new Buffer(input).toString('base64'); +} diff --git a/ui/src/app/app.tsx b/ui/src/app/app.tsx new file mode 100644 index 000000000000..dabaf675a366 --- /dev/null +++ b/ui/src/app/app.tsx @@ -0,0 +1,83 @@ +import { AppContext, Layout, Notifications, NotificationsManager, Popup, PopupManager, PopupProps } from 'argo-ui'; +import createHistory from 'history/createBrowserHistory'; +import * as PropTypes from 'prop-types'; +import * as React from 'react'; +import { Redirect, Route, RouteComponentProps, Router, Switch } from 'react-router'; + +import { uiUrl } from './shared/base'; + +export const history = createHistory(); + +import help from './help'; +import workflows from './workflows'; + +const workflowsUrl = uiUrl('workflows'); +const helpUrl = uiUrl('help'); +const timelineUrl = uiUrl('timeline'); +const routes: {[path: string]: { component: React.ComponentType> } } = { + [workflowsUrl]: { component: workflows.component }, + [helpUrl]: { component: help.component }, +}; + +const navItems = [{ + title: 'Timeline', + path: workflowsUrl, + iconClassName: 'argo-icon-timeline', +}, { + title: 'Help', + path: helpUrl, + iconClassName: 'argo-icon-docs', +}]; + +export class App extends React.Component<{}, { popupProps: PopupProps }> { + public static childContextTypes = { + history: PropTypes.object, + apis: PropTypes.object, + }; + + private popupManager: PopupManager; + private notificationsManager: NotificationsManager; + + constructor(props: {}) { + super(props); + this.state = { popupProps: null }; + this.popupManager = new PopupManager(); + this.notificationsManager = new NotificationsManager(); + } + + public componentDidMount() { + this.popupManager.popupProps.subscribe((popupProps) => this.setState({ popupProps })); + } + + public render() { + return ( +
+ {this.state.popupProps && } + + + + ; } + public componentWillMount() { + const router = (this.context as AppContext).router; + router.history.push(router.route.location.pathname.replace(timelineUrl, workflowsUrl)); + } + } }/> + + + {Object.keys(routes).map((path) => { + const route = routes[path]; + return ; + })} + + + +
+ ); + } + + public getChildContext() { + return { history, apis: { popup: this.popupManager, notifications: this.notificationsManager } }; + } +} diff --git a/ui/src/app/help/components/help.scss b/ui/src/app/help/components/help.scss new file mode 100644 index 000000000000..b2bda18e7ad8 --- /dev/null +++ b/ui/src/app/help/components/help.scss @@ -0,0 +1,57 @@ +@import 'node_modules/argo-ui/src/styles/config'; + +.help-box { + min-height: 530px; + margin: 30px 10px; + padding: 80px 40px; + text-align: center; + background-color: #fff; + box-shadow: 0 0 3px rgba(#000,.3); + + &__ico { + width: 180px; + height: 180px; + margin: 0 auto; + background-size: 100%; + opacity: .5; + + &--manual { + background-image: url('assets/images/User-Manual-200.png'); + } + + &--email { + background-image: url('assets/images/Message-200.png'); + } + + &--download { + background-image: url('assets/images/Download-200.png'); + } + } + + &__docker-logo { + width: 23px; + height: 23px; + vertical-align: baseline; + } + + h3 { + margin: 40px 0 60px; + font-size: 22px; + } + + a { + font-size: 18px; + font-weight: bold; + } + + &__link { + display: block; + } + + &__download { + i { + width: 30px; + font-size: 24px; + } + } +} diff --git a/ui/src/app/help/components/help.tsx b/ui/src/app/help/components/help.tsx new file mode 100644 index 000000000000..18647e240264 --- /dev/null +++ b/ui/src/app/help/components/help.tsx @@ -0,0 +1,49 @@ +import { Page } from 'argo-ui'; +import * as React from 'react'; + +require('./help.scss'); + +export const Help = () => ( + +
+
+
+
+

Documentation

+ Argo Project +
+
+
+
+
+

Contact

+ Argo Community + Slack Channel +
+
+
+
+
+

Argo CLI

+