wanghongkai 5 éve
commit
8e32b11d31

+ 16 - 0
.editorconfig

@@ -0,0 +1,16 @@
+# http://editorconfig.org
+root = true
+
+[*]
+indent_style = space
+indent_size = 2
+end_of_line = lf
+charset = utf-8
+trim_trailing_whitespace = true
+insert_final_newline = true
+
+[*.md]
+trim_trailing_whitespace = false
+
+[Makefile]
+indent_style = tab

+ 20 - 0
.gitignore

@@ -0,0 +1,20 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+/npm-debug.log*
+/yarn-error.log
+/yarn.lock
+/package-lock.json
+
+# production
+/dist
+
+# misc
+.DS_Store
+
+# umi
+/src/.umi
+/src/.umi-production
+/src/.umi-test
+/.env.local

+ 8 - 0
.prettierignore

@@ -0,0 +1,8 @@
+**/*.md
+**/*.svg
+**/*.ejs
+**/*.html
+package.json
+.umi
+.umi-production
+.umi-test

+ 11 - 0
.prettierrc

@@ -0,0 +1,11 @@
+{
+  "singleQuote": true,
+  "trailingComma": "all",
+  "printWidth": 80,
+  "overrides": [
+    {
+      "files": ".prettierrc",
+      "options": { "parser": "json" }
+    }
+  ]
+}

+ 17 - 0
.umirc.ts

@@ -0,0 +1,17 @@
+import { defineConfig } from 'umi';
+
+export default defineConfig({
+  proxy: {
+    '/api': {
+      'target': 'http://172.19.240.15:8080/',
+      'changeOrigin': true,
+    },
+  },
+  nodeModulesTransform: {
+    type: 'none',
+  },
+  // routes: [
+  //   { path: '/', component: '@/pages/index' },
+  //   { path: '/apps', component: '@/pages/apps'},
+  // ],
+});

+ 11 - 0
Dockerfile

@@ -0,0 +1,11 @@
+FROM node:10-alpine as builder  
+WORKDIR /code
+RUN yarn config set registry https://registry.npm.taobao.org/
+ADD package.json /code
+RUN yarn install --production  
+ADD . /code  
+RUN yarn build
+
+FROM nginx:10-alpine
+COPY seecoder-paas-ui.conf /etc/nginx/site-enabled/seecoder-paas-ui.conf
+COPY --from=builder /code/dist/ /var/www/html/dist/

+ 15 - 0
README.md

@@ -0,0 +1,15 @@
+# umi project
+
+## Getting Started
+
+Install dependencies,
+
+```bash
+$ yarn
+```
+
+Start the dev server,
+
+```bash
+$ yarn start
+```

+ 0 - 0
mock/.gitkeep


+ 37 - 0
package.json

@@ -0,0 +1,37 @@
+{
+  "private": true,
+  "scripts": {
+    "start": "umi dev",
+    "build": "umi build",
+    "postinstall": "umi generate tmp",
+    "prettier": "prettier --write '**/*.{js,jsx,tsx,ts,less,md,json}'",
+    "test": "umi-test",
+    "test:coverage": "umi-test --coverage"
+  },
+  "gitHooks": {
+    "pre-commit": "lint-staged"
+  },
+  "lint-staged": {
+    "*.{js,jsx,less,md,json}": [
+      "prettier --write"
+    ],
+    "*.ts?(x)": [
+      "prettier --parser=typescript --write"
+    ]
+  },
+  "dependencies": {
+    "@ant-design/pro-layout": "^5.0.12",
+    "@uiw/react-codemirror": "^3.0.1",
+    "@umijs/hooks": "^1.9.3",
+    "@umijs/preset-react": "1.x",
+    "@umijs/test": "^3.2.24",
+    "lint-staged": "^10.0.7",
+    "prettier": "^1.19.1",
+    "react": "^16.12.0",
+    "react-dom": "^16.12.0",
+    "umi": "^3.2.24",
+    "xterm": "^4.9.0",
+    "xterm-addon-fit": "^0.4.0",
+    "yorkie": "^2.0.0"
+  }
+}

+ 31 - 0
seecoder-paas-ui.conf

@@ -0,0 +1,31 @@
+map $http_upgrade $connection_upgrade {
+  default upgrade;
+  '' close;
+}
+upstream websocket {
+    server environment-9-3.seec.svc.cluster.local:8080;
+}
+server {
+  listen 80 default_server;
+  listen [::]:80 default_server;
+  root /var/www/html/dist;
+  location ~ /ws {
+      proxy_pass http://websocket;
+      proxy_read_timeout 3h;
+      proxy_send_timeout 3h;
+      
+      proxy_set_header Host $host;
+      proxy_set_header X-Real-IP $remote_addr;
+      proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+      
+      proxy_http_version 1.1;
+      proxy_set_header Upgrade $http_upgrade;
+      proxy_set_header Connection $connection_upgrade;
+  }
+  location ~ / {
+    if ($request_uri ~* (api)) {
+        proxy_pass http://environment-9-3.seec.svc.cluster.local:8080;
+    }
+    try_files $uri $uri/ /index.html;
+  }
+}

+ 46 - 0
src/app.ts

@@ -0,0 +1,46 @@
+import { RequestConfig, history } from 'umi';
+import { useSessionStorageState } from '@umijs/hooks';
+import {message} from 'antd';
+export const request: RequestConfig = {
+  timeout: 10000,
+  dataField: 'result',
+  errorHandler: (error) => {
+    if (error.data && error.data.msg) {
+      message.error(error.data.msg);
+    }
+    return error.data
+  },
+  errorConfig: {
+    adaptor: (response) => {
+      return {
+        ...response,
+        data: response,
+        success: response.code == 0,
+        showType: 2,
+        errorMessage: response.message || response.msg,
+      }
+    }
+  },
+  middlewares: [],
+  requestInterceptors: [(url, options) => {
+    const token = sessionStorage.getItem('token');
+    return {
+      url,
+      options: {
+        ...options,
+        headers: {
+          ...(options.headers || {}),
+          Authorization: token,
+        }
+      }
+    }
+  }],
+  responseInterceptors: [(response, options) => {
+    if (response.status == 403) {
+      message.error('未登录或登录超时!');
+      sessionStorage.removeItem('token');
+      history.push('/login');
+    }
+    return response;
+  }],
+};

+ 273 - 0
src/components/ConfigModal.js

@@ -0,0 +1,273 @@
+import React, { PureComponent } from 'react';
+import { PageHeader, Button, Descriptions, Modal, Card, Icon, Row, Col, Form, Popconfirm, Input, Space, Divider } from 'antd';
+import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
+import styles from './ConfigModal.less'
+
+export default class ConfigModal extends PureComponent {
+  formRef = React.createRef();
+  state = {
+  }
+
+  setFormValues(config) {
+    if (config) {
+      this.formRef.current.setFieldsValue({
+        ...(config.config || {}),
+        envsArr: Object.keys(config.config.envs || {}).map(key => ({ key, value: config.config.envs[key] })),
+        mountsArr: Object.keys(config.config.mounts || {}).map(key => ({ key, value: config.config.mounts[key] })),
+        ingressAnnotaionsArr: Object.keys(config.config.ingressAnnotaions || {}).map(key => ({ key, value: config.config.ingressAnnotaions[key] })),
+        hostPathArr: Object.keys(config.config.hostPath || {}).map(key => ({ key, value: config.config.hostPath[key] })),
+      });
+    }
+  }
+
+  componentDidMount() {
+    this.setFormValues(this.props.config);
+  }
+
+  componentWillReceiveProps(props) {
+    if (props.config != this.props.config)
+    this.setFormValues(props.config);
+  }
+
+  onOk() {
+    this.formRef.current.validateFields().then(values => {
+      this.props.onOk({
+        ...this.props.config,
+        config: {
+          ...values,
+          envs: (values.envsArr || []).reduce((obj, item) => {
+            obj[item.key] = item.value;
+            return obj;
+          }, {}),
+          mounts: (values.mountsArr || []).reduce((obj, item) => {
+            obj[item.key] = item.value;
+            return obj;
+          }, {}),
+          hostPath: (values.hostPathArr || []).reduce((obj, item) => {
+            obj[item.key] = item.value;
+            return obj;
+          }, {}),
+          ingressAnnotaions: (values.ingressAnnotaionsArr || []).reduce((obj, item) => {
+            obj[item.key] = item.value;
+            return obj;
+          }, {}),
+        }
+      })
+    });
+  }
+
+  render() {
+    return (
+      <Modal
+        visible={this.props.visible}
+        forceRender
+        onCancel={() => this.props.onCancel()}
+        title={"更新配置"}
+        okText="确认"
+        cancelText="取消"
+        onOk={this.onOk.bind(this)}
+        className={styles.modal}
+        width="80%"
+      >
+        <Form
+          ref={this.formRef}
+          labelCol={{ span: 4 }}
+          wrapperCol={{ span: 20 }}
+          labelAlign="right"
+        >
+          <Form.Item 
+            name="hostPrefix"
+            label="域名前缀"
+            tooltip="有域名前缀时会根据端口对服务进行暴露,生成域名规则为{域名前缀}-{端口}.{主域名}"
+          >
+            <Input placeholder="域名前缀"></Input>
+          </Form.Item >
+          <Form.Item 
+            name="servicePort"
+            label="服务端口"
+            tooltip="服务端口,多个用英文逗号隔开"
+          >
+            <Input placeholder='服务端口,多个用英文逗号隔开'></Input>
+          </Form.Item >
+          <Form.Item 
+            name="replicas"
+            label="实例数"
+            tooltip="服务部署的实例数目,即pod数量,默认为1"
+          >
+            <Input placeholder="实例数"></Input>
+          </Form.Item >
+          <Form.Item 
+            name="runArgs"
+            label="运行参数"
+            tooltip="服务运行参数"
+          >
+            <Input placeholder="运行参数"></Input>
+          </Form.Item>
+          <Form.Item 
+            name="runCommands"
+            label="运行命令"
+            tooltip="服务运行命令"
+          >
+            <Input placeholder="运行命令"></Input>
+          </Form.Item >
+          <Divider plain>环境变量配置</Divider>
+          <div className={styles.list}>
+            <Form.List name="envsArr" label="key-value">
+              {(fields, {add, remove}) => (
+                  <>
+                    {fields.map(field => (
+                        <div key={`env-${field.fieldKey}`} field={field} className={styles.field}>
+                          <Form.Item
+                            {...field}
+                            key={`env-key-${field.fieldKey}`}
+                            label="key"
+                            name={[field.name, 'key']}
+                            fieldKey={[field.fieldKey, 'key']}
+                            rules={[{ required: true, message: 'key不能为空' }]}
+                          >
+                            <Input placeholder="环境变量key" />
+                          </Form.Item>
+                          <Form.Item
+                            {...field}
+                            key={`env-value-${field.fieldKey}`}
+                            label="value"
+                            name={[field.name, 'value']}
+                            fieldKey={[field.fieldKey, 'value']}
+                            rules={[{ required: true, message: 'value不能为空' }]}
+                          >
+                            <Input placeholder="环境变量value" />
+                          </Form.Item>
+                          <MinusCircleOutlined style={{fontSize: 18, marginLeft: 10}} onClick={() => remove(field.name)} />
+                        </div>
+                    ))}
+                    <Form.Item className={styles.addBtn}>
+                      <Button type="dashed" onClick={() => add()} block icon={<PlusOutlined />}>
+                        添加
+                      </Button>
+                    </Form.Item>
+                  </>
+              )}
+            </Form.List>
+          </div>
+          <Divider plain>挂载文件配置</Divider>
+          <div className={styles.list}>
+            <Form.List name="mountsArr">
+              {(fields, {add, remove}) => (
+                  <>
+                    {fields.map(field => (
+                        <div key={`mount-${field.fieldKey}`} className={styles.field}>
+                          <Form.Item
+                            {...field}
+                            key={`mount-key-${field.fieldKey}`}
+                            label="文件路径"
+                            name={[field.name, 'key']}
+                            fieldKey={[field.fieldKey, 'key']}
+                            rules={[{ required: true, message: '文件路径不能为空' }]}
+                          >
+                            <Input placeholder="文件路径" />
+                          </Form.Item>
+                          <Form.Item
+                            {...field}
+                            label="文件内容"
+                            key={`mount-value-${field.fieldKey}`}
+                            style={{alignItems: 'center'}}
+                            name={[field.name, 'value']}
+                            fieldKey={[field.fieldKey, 'value']}
+                          >
+                            <Input.TextArea placeholder="文件内容" />
+                          </Form.Item>
+                          <MinusCircleOutlined style={{fontSize: 18, marginLeft: 10}} onClick={() => remove(field.name)} />
+                        </div>
+                    ))}
+                    <Form.Item className={styles.addBtn} >
+                      <Button type="dashed" onClick={() => add()} block icon={<PlusOutlined />}>
+                        添加
+                      </Button>
+                    </Form.Item>
+                  </>
+              )}
+            </Form.List>
+          </div>
+          <Divider plain>节点文件挂载</Divider>
+          <div className={styles.list}>
+            <Form.List name="hostPathArr">
+              {(fields, {add, remove}) => (
+                  <>
+                    {fields.map(field => (
+                        <div key={`hostPath-${field.fieldKey}`} className={styles.field}>
+                          <Form.Item
+                            {...field}
+                            key={`hostPath-key-${field.fieldKey}`}
+                            name={[field.name, 'key']}
+                            label="节点路径"
+                            fieldKey={[field.fieldKey, 'key']}
+                            rules={[{ required: true, message: '节点文件路径不能为空' }]}
+                          >
+                            <Input placeholder="节点文件路径" />
+                          </Form.Item>
+                          <Form.Item
+                            {...field}
+                            key={`hostPath-value-${field.fieldKey}`}
+                            name={[field.name, 'value']}
+                            label="挂载路径"
+                            fieldKey={[field.fieldKey, 'value']}
+                            rules={[{ required: true, message: '挂载文件路径不能为空' }]}
+                          >
+                            <Input placeholder="挂载文件路径" />
+                          </Form.Item>
+                          <MinusCircleOutlined style={{fontSize: 18, marginLeft: 10}} onClick={() => remove(field.name)} />
+                        </div>
+                    ))}
+                    <Form.Item className={styles.addBtn} >
+                      <Button type="dashed" onClick={() => add()} block icon={<PlusOutlined />}>
+                        添加
+                      </Button>
+                    </Form.Item>
+                  </>
+              )}
+            </Form.List>
+          </div>
+          <Divider plain>ingress控制器annotations</Divider>
+          <div className={styles.list}>
+            <Form.List name="ingressAnnotationsArr">
+              {(fields, {add, remove}) => (
+                  <>
+                    {fields.map(field => (
+                        <div key={`ingress-${field.fieldKey}`} className={styles.field}>
+                          <Form.Item
+                            {...field}
+                            label="key"
+                            key={`ingress-key-${field.fieldKey}`}
+                            name={[field.name, 'key']}
+                            fieldKey={[field.fieldKey, 'key']}
+                            rules={[{ required: true, message: 'annotationKey不能为空' }]}
+                          >
+                            <Input placeholder="annotationKey" />
+                          </Form.Item>
+                          <Form.Item
+                            {...field}
+                            key={`ingress-value-${field.fieldKey}`}
+                            name={[field.name, 'value']}
+                            label="value"
+                            fieldKey={[field.fieldKey, 'value']}
+                            rules={[{ required: true, message: 'annotationValue不能为空' }]}
+                          >
+                            <Input placeholder="annotationValue" />
+                          </Form.Item>
+                          <MinusCircleOutlined style={{fontSize: 18, marginLeft: 10}} onClick={() => remove(field.name)} />
+                        </div>
+                    ))}
+                    <Form.Item className={styles.addBtn} >
+                      <Button type="dashed" onClick={() => add()} block icon={<PlusOutlined />}>
+                        添加
+                      </Button>
+                    </Form.Item>
+                  </>
+              )}
+            </Form.List>
+          </div>
+        </Form>
+      </Modal>
+    )
+  }
+}

+ 19 - 0
src/components/ConfigModal.less

@@ -0,0 +1,19 @@
+.modal {
+  min-width: 1050px;
+  .list {
+    .field {
+      display: flex;
+      align-items: center;
+      margin-bottom: 16px;
+      :global(.ant-form-item) {
+        margin-bottom: 0px;
+        flex: 1;
+      }
+    }
+    .addBtn {
+      :global(.ant-col) {
+        max-width: 100%;
+      }
+    }
+  }
+}

+ 105 - 0
src/components/EnvironmentModal.js

@@ -0,0 +1,105 @@
+import React, { PureComponent } from 'react';
+import { Modal, Form, Input, Select } from 'antd';
+import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
+import styles from './EnvironmentModal.less'
+
+const { Option } = Select;
+const buildTypes = ['FROM_BRANCH_OR_COMMIT', 'FROM_IMAGE'];
+const buildTypeOptionName = {
+  FROM_BRANCH_OR_COMMIT: '从分支或commit构建',
+  FROM_IMAGE: '从现有镜像构建',
+}
+const buildTypeValueLabels = {
+  FROM_BRANCH_OR_COMMIT: '分支名/commit',
+  FROM_IMAGE: '镜像名',
+}
+export default class EnvironmentModal extends PureComponent {
+  formRef = React.createRef();
+  state = {
+  }
+
+  setFormValues(environment) {
+    if (environment) {
+      this.formRef.current.setFieldsValue({
+        ...environment,
+      });
+    } else {
+      this.formRef.current.setFieldsValue({
+        name: null,
+        description: null,
+        buildType: buildTypes[0],
+        buildTypeValue: '',
+      });
+    }
+  }
+
+  componentDidMount() {
+    this.setFormValues(this.props.environment);
+  }
+
+  componentWillReceiveProps(props) {
+    if (props.environment != this.props.environment || props.environment == null)
+      this.setFormValues(props.config);
+  }
+
+  onOk() {
+    this.formRef.current.validateFields().then(values => {
+      this.props.onOk({
+        // ...(this.props.environment || {}),
+        ...values,
+      });
+    });
+  }
+
+  render() {
+    const buildType = this.formRef.current ? this.formRef.current.getFieldValue('buildType') : null;
+    return (
+      <Modal
+        visible={this.props.visible}
+        forceRender
+        onCancel={() => this.props.onCancel()}
+        title={"新建/更新环境"}
+        okText="确认"
+        cancelText="取消"
+        onOk={this.onOk.bind(this)}
+        className={styles.modal}
+        width="80%"
+      >
+        <Form
+          ref={this.formRef}
+          labelCol={{ span: 4 }}
+          wrapperCol={{ span: 20 }}
+          labelAlign="right"
+        >
+          <Form.Item 
+            name="name"
+            label="名称"
+            rules={[{required: true, message: '名称不能为空'}]}
+          >
+            <Input placeholder="名称"></Input>
+          </Form.Item >
+          <Form.Item 
+            name="buildType"
+            label="构建类型"
+            rules={[{required: true, message: '构建类型不能为空'}]}
+          >
+            <Select>
+              {buildTypes.map(type => {
+                return (
+                  <Option key={type} value={type}>{buildTypeOptionName[type]}</Option>
+                )
+              })}
+            </Select>
+          </Form.Item>
+          <Form.Item 
+            name="buildTypeValue"
+            label={buildTypeValueLabels[buildType]}
+            rules={[{required: true, message: `${buildTypeValueLabels[buildType]}不能为空`}]}
+          >
+            <Input placeholder={buildTypeValueLabels[buildType]}></Input>
+          </Form.Item >
+        </Form>
+      </Modal>
+    )
+  }
+}

+ 0 - 0
src/components/EnvironmentModal.less


+ 3 - 0
src/global.css

@@ -0,0 +1,3 @@
+#root {
+  height: 100%;
+}

+ 34 - 0
src/layouts/index.less

@@ -0,0 +1,34 @@
+.layout {
+  background-color: #fff;
+  height: 100%;
+  .header {
+    background: #fff;
+    -webkit-box-shadow: 0 2px 8px #f0f1f2;
+    box-shadow: 0 2px 8px #f0f1f2;
+    .headerContent {
+      display: flex;
+      .logo {
+        cursor: pointer;
+        height: 64px;
+        overflow: hidden;
+        color: rgba(0,0,0,.85);
+        font-size: 18px;
+        font-family: Avenir,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;
+        line-height: 64px;
+        white-space: nowrap;
+        text-decoration: none;
+        font-weight: 500;
+      }
+      .appSelector {
+        flex: 1;
+        margin-left: 16px;
+      }
+    }
+  }
+  .content {
+    margin-top: 10px;
+    padding: 10px 20px 20px 20px;
+    background-color: #fff;
+    height: 100%;
+  }
+}

+ 52 - 0
src/layouts/index.tsx

@@ -0,0 +1,52 @@
+import { IRouteComponentProps, connect, history } from 'umi'
+import React, { PureComponent } from 'react';
+import { Layout, Select } from 'antd'
+import Login from '@/pages/login';
+import styles from './index.less'
+import Terminal from '@/pages/terminal'
+const { Option } = Select;
+const { Header, Content } = Layout
+@connect(({app}) => ({app}))
+export default class GlobalLayout extends PureComponent {
+  toIndex() {
+    history.push('/apps');
+  }
+  render() {
+    const { children, location, route, history, match } = this.props;
+    if (location.pathname === '/login') {
+      return <Login></Login>
+    } else if (location.pathname === '/terminal') {
+      return <Terminal location={location}></Terminal>
+    } else {
+      return (
+        <Layout className={styles.layout}>
+          <Header className={styles.header}>
+            <div className={styles.headerContent}>
+              <div onClick={this.toIndex.bind(this)} className={styles.logo}>Seecoder-PaaS</div>
+              <div className={styles.appSelector}>
+                <Select 
+                  value={this.props.app.currentApp?.id}
+                  bordered={false} 
+                  onChange={(value) => {
+                    history.push(`/app/${value}`)
+                  }}
+                  placeholder="选择应用"
+                >
+                  {(this.props.app.apps || []).map(app => {
+                    return (
+                      <Option key={app.id} value={app.id}>{app.name}</Option>
+                    );
+                  })}
+                </Select>
+              </div>
+              <div className={styles.user}></div>
+            </div>
+          </Header>
+          <Content className={styles.content}>
+            {children}
+          </Content>
+        </Layout>
+      )
+    }
+  }
+}

+ 74 - 0
src/models/app.js

@@ -0,0 +1,74 @@
+import {
+  getAll,
+  get,
+  create,
+  update,
+  deleteApp,
+} from '@/services/app'
+import { message } from 'antd'
+export default {
+  namespace: 'app',
+  state: {
+    currentApp: null,
+    apps: [],
+  },
+  effects: {
+    *getAll({payload}, {call, put}) {
+      const response = yield call(getAll);
+      yield put({type: 'putApps', payload: response.result})
+      if (payload && payload.callback) {
+        yield call(payload.callback, response);
+      }
+    },
+    *get({payload}, {call, put}) {
+      const response = yield call(get, payload);
+      if (payload && payload.callback) {
+        yield call(payload.callback, response);
+      }
+    },
+    *create({payload}, {call, put}) {
+      const response = yield call(create, payload);
+      if (response && response.code == 0) {
+        message.success('创建成功!');
+      }
+      yield put({type: 'getAll'})
+      if (payload && payload.callback) {
+        yield call(payload.callback, response);
+      }
+    },
+    *update({payload}, {call, put}) {
+      const response = yield call(update, payload);
+      if (response && response.code == 0) {
+        message.success('更新成功!');
+      }
+      yield put({type: 'getAll'})
+      if (payload && payload.callback) {
+        yield call(payload.callback, response);
+      }
+    },
+    *deleteApp({payload}, {call, put}) {
+      const response = yield call(deleteApp, payload);
+      if (response && response.code == 0) {
+        message.success('删除成功!');
+      }
+      yield put({type: 'getAll'})
+      if (payload && payload.callback) {
+        yield call(payload.callback, response);
+      }
+    }
+  },
+  reducers: {
+    putApps(state, action) {
+      return {
+        ...state,
+        apps: action.payload,
+      };
+    },
+    setCurrentApp(state, action) {
+      return {
+        ...state,
+        currentApp: action.payload ? state.apps.filter(app => app.id == action.payload?.id)[0] : null,
+      }
+    },
+  }
+}

+ 22 - 0
src/models/config.js

@@ -0,0 +1,22 @@
+import {
+  update,
+} from '@/services/config'
+import { message } from 'antd'
+export default {
+  namespace: 'config',
+  state: {
+  },
+  effects: {
+    *update({payload}, {call, put}) {
+      const response = yield call(update, payload);
+      if (response && response.code == 0) {
+        message.success('更新成功!');
+      }
+      if (payload && payload.callback) {
+        yield call(payload.callback, response);
+      }
+    },
+  },
+  reducers: {
+  }
+}

+ 113 - 0
src/models/environment.js

@@ -0,0 +1,113 @@
+import {
+  getAll,
+  get,
+  create,
+  update,
+  deleteEnvironment,
+  deploy,
+  restart,
+  getLog,
+} from '@/services/environment'
+import { message } from 'antd'
+export default {
+  namespace: 'environment',
+  state: {
+    currentEnvironment: null,
+    appEnvironments: {},
+  },
+  effects: {
+    *getAll({payload}, {call, put}) {
+      const response = yield call(getAll, payload);
+      yield put({type: 'putEnvironments', payload: {
+        appId: payload.appId,
+        environments: response.result,
+      }});
+      if (payload && payload.callback) {
+        yield call(payload.callback, response);
+      }
+    },
+    *get({payload}, {call, put}) {
+      const response = yield call(get, payload);
+      if (payload && payload.callback) {
+        yield call(payload.callback, response);
+      }
+    },
+    *create({payload}, {call, put}) {
+      const response = yield call(create, payload);
+      if (response && response.code == 0) {
+        message.success('创建成功!');
+      }
+      yield put({type: 'getAll', payload: {
+        appId: payload.appId,
+      }})
+      if (payload && payload.callback) {
+        yield call(payload.callback, response);
+      }
+    },
+    *update({payload}, {call, put}) {
+      const response = yield call(update, payload);
+      if (response && response.code == 0) {
+        message.success('更新成功!');
+      }
+      yield put({type: 'getAll', payload: {
+        appId: payload.appId,
+      }})
+      if (payload && payload.callback) {
+        yield call(payload.callback, response);
+      }
+    },
+    *deleteEnvironment({payload}, {call, put}) {
+      const response = yield call(deleteEnvironment, payload);
+      if (response && response.code == 0) {
+        message.success('删除成功!');
+      }
+      yield put({type: 'getAll', payload: {
+        appId: payload.appId,
+      }})
+      if (payload && payload.callback) {
+        yield call(payload.callback, response);
+      }
+    },
+    *deploy({payload}, {call, put}) {
+      const response = yield call(deploy, payload);
+      if (response && response.code == 0) {
+        message.success('触发成功!');
+      }
+      if (payload && payload.callback) {
+        yield call(payload.callback, response);
+      }
+    },
+    *restart({payload}, {call, put}) {
+      const response = yield call(restart, payload);
+      if (response && response.code == 0) {
+        message.success('触发成功!');
+      }
+      if (payload && payload.callback) {
+        yield call(payload.callback, response);
+      }
+    },
+    *getLog({payload}, {call, put}) {
+      const response = yield call(getLog, payload);
+      if (payload && payload.callback) {
+        yield call(payload.callback, response);
+      }
+    },
+  },
+  reducers: {
+    putEnvironments(state, action) {
+      return {
+        ...state,
+        appEnvironments: {
+          ...state.appEnvironments,
+          [action.payload.appId]: action.payload.environments,
+        },
+      };
+    },
+    setCurrentEnvironment(state, action) {
+      return {
+        ...state,
+        currentEnvironment: action.payload ? (Object.keys(state.appEnvironments).flatMap(key => (state.appEnvironments[key] || [])).filter(env => env.id == action.payload.id)[0] || action.payload) : null,
+      }
+    },
+  }
+}

+ 18 - 0
src/models/user.js

@@ -0,0 +1,18 @@
+import { login } from '@/services/user'
+
+export default {
+  namespace: 'user',
+  state: {
+  },
+  effects: {
+    *login({ payload }, { call }) {
+      const response = yield call(login, payload);
+      if (response && response.code == 0) {
+        sessionStorage.setItem('token', response.result);
+      }
+      if (payload && payload.callback) {
+        yield call(payload.callback, response);
+      }
+    }
+  },
+}

+ 40 - 0
src/pages/app/[id]/_layout.less

@@ -0,0 +1,40 @@
+.appHeader {
+  box-shadow: 0 2px 8px #bbccde;
+  -webkit-box-shadow: 0 2px 8px #bbccde;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  :global(.ant-page-header-content) {
+    flex: 1;
+    display: flex;
+    flex-direction: column;
+    overflow: hidden;
+  }
+  .pageContent {
+    flex: 1;
+    display: flex;
+    flex-direction: column;
+    overflow: hidden;
+    .environments {
+      flex: 1;
+      display: flex;
+      overflow: hidden;
+    }
+  }
+}
+:global(.ant-modal) {
+  height: calc(100% - 100px);
+  :global(.ant-modal-content) {
+    max-height: 80%;
+    display: flex;
+    flex-direction: column;
+    overflow: hidden;
+    :global(.ant-modal-body) {
+      max-height: calc(100% - 105px);
+      overflow-y: scroll;
+    }
+    :global(.ant-modal-footer) {
+      height: 50px;
+    }
+  }
+}

+ 206 - 0
src/pages/app/[id]/_layout.tsx

@@ -0,0 +1,206 @@
+import React, { PureComponent } from 'react';
+import { connect, history } from 'umi';
+import { Empty, PageHeader, Button, Descriptions, Menu, Divider } from 'antd';
+import ConfigModal from '@/components/ConfigModal'
+import EnvironmentModal from '@/components/EnvironmentModal'
+import styles from './_layout.less'
+import {
+  LoadingOutlined,
+} from '@ant-design/icons';
+@connect(({ app, environment, loading }) => ({
+  app, loading, environment
+}))
+export default class AppDetailPage extends PureComponent {
+  state = {
+    showConfigModal: false,
+    showEnvironmentModal: false,
+    currentApp: null,
+  }
+  componentDidMount() {
+    this.fetchData(this.props.match.params.id);
+  }
+  componentWillReceiveProps(props) {
+    if (this.props.match.params.id != props.match.params.id ) {
+      this.fetchData(props.match.params.id);
+    }
+  }
+  fetchData(id) {
+    this.props.dispatch({
+      type: 'app/get',
+      payload: {
+        id,
+        callback: (response) => {
+          if (response && response.code == 0 && response.result) {
+            let needRequest = false;
+            if (this.props.app.apps.length) {
+              const currentApp = this.props.app.apps.filter(app => app.id == id)[0];
+              if (currentApp == null) {
+                needRequest = true;
+              }
+            } else {
+              needRequest = true;
+            }
+            if (needRequest) {
+              this.props.dispatch({
+                type: 'app/getAll',
+                payload: {
+                  callback: () => {
+                    this.props.dispatch({
+                      type: 'app/setCurrentApp',
+                      payload: {
+                        id,
+                      }
+                    });
+
+                  }
+                }
+              });
+            } else {
+              this.props.dispatch({
+                type: 'app/setCurrentApp',
+                payload: {
+                  id,
+                }
+              });
+            }
+            this.setState({
+              currentApp: response.result,
+            });
+            this.props.dispatch({
+              type: 'environment/getAll',
+              payload: {
+                appId: response.result.id,
+              },
+            });
+          } else {
+            if (!this.props.app.apps.length) {
+              this.props.dispatch({
+                type: 'app/getAll',
+              });
+            }
+          }
+        }
+      }
+    });
+  }
+  showAppConfig() {
+    this.setState({
+      showConfigModal: true,
+    });
+  }
+  updateAppConfig(values) {
+    this.props.dispatch({
+      type: 'config/update',
+      payload: {
+        ...values,
+        callback: (response) => {
+          if (response && response.code == 0) {
+            this.setState({
+              showConfigModal: false,
+            });
+            this.props.dispatch({
+              type: 'app/get',
+              payload: {
+                id: this.props.match.params.id,
+                callback: (response) => {
+                  this.setState({
+                    currentApp: response.result,
+                  });
+                }
+              }
+            });
+          }
+        }
+      }
+    });
+  }
+  hideAppConfig() {
+    this.setState({
+      showConfigModal: false,
+    });
+  }
+  showEnvCreate() {
+    this.setState({
+      showEnvironmentModal: true,
+    });
+  }
+  hideEnvCreate() {
+    this.setState({
+      showEnvironmentModal: false,
+    });
+  }
+  createEnv(values) {
+    this.props.dispatch({
+      type: 'environment/create',
+      payload: {
+        ...values,
+        appId: this.state.currentApp.id,
+        callback: (response) => {
+          if (response && response.code == 0) {
+            this.setState({
+              showEnvironmentModal: false,
+            });
+          }
+        }
+      },
+    });
+  }
+  selectEnvironment({ item, key, keyPath, domEvent }) {
+    history.push(`/app/${this.state.currentApp.id}/environment/${key}`)
+  }
+  render() {
+    return (
+      <PageHeader
+        backIcon={false}
+        title={this.props.app.currentApp?.name ? this.props.app.currentApp.name : '应用'}
+        subTitle={this.state.currentApp ? this.state.currentApp.description : null}
+        className={styles.appHeader}
+        extra={this.props.app.currentApp ? (
+          <div>
+            <Button type="primary" onClick={this.showEnvCreate.bind(this)} style={{marginRight: 10, background: '#74de37', borderColor: '#74de37'}}>创建环境</Button>
+            <Button type="primary" onClick={this.showAppConfig.bind(this)}>应用配置</Button>
+          </div>
+        ) : null}
+      >
+        {(this.props.loading.effects['app/getAll'] || this.props.loading.effects['app/get']) ? 
+          <div style={{textAlign: 'center', marginTop: 30}}><LoadingOutlined style={{ fontSize: '48px'}} /></div> :
+          (this.props.app.currentApp ? 
+            <div className={styles.pageContent}>
+              <Descriptions size="small"
+              >
+                <Descriptions.Item label="Git URL"><a target="_blank" href={this.state.currentApp?.gitUrl}>{this.state.currentApp?.gitUrl}</a></Descriptions.Item>
+              </Descriptions>
+              <div><Divider orientation="left" style={{fontWeight: 600}}>环境列表</Divider></div>
+              <div className={styles.environments}>
+                <Menu
+                  style={{ width: 256, height: '100%' }} mode="vertical"
+                  onClick={(values) => this.selectEnvironment(values)}
+                  selectedKeys={[this.props.environment?.currentEnvironment?.id + '']}
+                >
+                  {(this.props.environment.appEnvironments[this.state.currentApp?.id] || []).map(env => {
+                    return <Menu.Item key={env.id}>{env.name}</Menu.Item>
+                  })}
+                </Menu>
+                <div style={{flex: 1, overflowY: 'auto'}}>
+                  {this.props.children}
+                </div>
+              </div>
+              <ConfigModal
+                visible={this.state.showConfigModal}
+                onOk={this.updateAppConfig.bind(this)}
+                onCancel={this.hideAppConfig.bind(this)}
+                config={this.state.currentApp?.config}
+              ></ConfigModal>
+              <EnvironmentModal
+                visible={this.state.showEnvironmentModal}
+                onOk={this.createEnv.bind(this)}
+                onCancel={this.hideEnvCreate.bind(this)}
+              >
+              </EnvironmentModal>
+            </div> : <Empty />   
+          )
+        }
+      </PageHeader>
+    )
+  }
+}

+ 493 - 0
src/pages/app/[id]/environment/[environmentId].js

@@ -0,0 +1,493 @@
+import React, { PureComponent } from 'react';
+import { connect, history } from 'umi';
+import { Empty, PageHeader, Button, Popconfirm, Descriptions, Steps, Divider, Modal, Table, Input } from 'antd';
+import moment from 'moment';
+import {
+  LoadingOutlined,
+} from '@ant-design/icons';
+import styles from './[environmentId].less'
+import ConfigModal from '@/components/ConfigModal'
+import EnvironmentModal from '@/components/EnvironmentModal'
+const { Step } = Steps;
+const buildTypes = ['FROM_BRANCH_OR_COMMIT', 'FROM_IMAGE'];
+const buildTypeOptionName = {
+  FROM_BRANCH_OR_COMMIT: '从分支或commit构建',
+  FROM_IMAGE: '从现有镜像构建',
+}
+const buildTypeValueLabels = {
+  FROM_BRANCH_OR_COMMIT: '分支名/commit',
+  FROM_IMAGE: '镜像名',
+}
+const statusText = {
+  NOT_STARTED: '未开始',
+  SUCCESS: '成功',
+  FAIL: '失败',
+  BUILDING: '正在构建',
+  RESTARTING: '正在重启',
+  DEPLOYING: '正在部署',
+}
+
+const statusColor = {
+  NOT_STARTED: '#cfcfcf',
+  SUCCESS: '#91e12e',
+  FAIL: '#f81d22',
+  BUILDING: '#16d0ff',
+  RESTARTING: '#f89d58',
+  DEPLOYING: '#16d0ff',
+}
+
+const statusStepStatus = {
+  NOT_STARTED: 'wait',
+  SUCCESS: 'finish',
+  FAIL: 'error',
+  BUILDING: 'process',
+  RESTARTING: 'process',
+  DEPLOYING: 'process',
+}
+
+
+@connect(({ environment, loading }) => ({
+  environment, loading
+}))
+export default class AppEnvironment extends PureComponent {
+  state = {
+    currentEnvironment: null,
+    showConfigModal: false,
+    showEnvironmentModal: false,
+    showLogModal: false,
+    currentStep: 0,
+    buildTimeOffset: null,
+    deployTimeOffset: null,
+    log: '',
+    requestFromInitial: false,
+  }
+  fetchData(id) {
+    this.setState({
+      requestFromInitial: true,
+    });
+    this.props.dispatch({
+      type: 'environment/get',
+      payload: {
+        id,
+        callback: (response) => {
+          if (response && response.code == 0 && response.result) {
+            this.props.dispatch({
+              type: 'environment/setCurrentEnvironment',
+              payload: {
+                ...response.result,
+              },
+            });
+            this.setState({
+              currentEnvironment: response.result,
+              currentStep: response.result.buildStatus != 'SUCCESS' ? 0 : 1,
+            });
+            if (response.result.buildStatus.endsWith('ING') || response.result.deployStatus.endsWith('ING')) {
+              this.requestInterval();
+            }
+          }
+        }
+      }
+    });
+  }
+  requestAndSetStatus() {
+    this.props.dispatch({
+      type: 'environment/get',
+      payload: {
+        id: this.state.currentEnvironment.id,
+        callback: (response) => {
+          if (response && response.code == 0) {
+            let buildFinished = !response.result.buildStatus.endsWith('ING');
+            let deployFinished = !response.result.deployStatus.endsWith('ING');
+            let buildTimeOffset = !buildFinished ? this.getDuration(this.state.currentEnvironment.buildStartTime) : null;
+            let deployTimeOffset = !deployFinished ? this.getDuration(this.state.currentEnvironment.deployTimeOffset) : null;
+            this.setState({
+              currentEnvironment: response.result,
+              currentStep: response.result.buildStatus != 'SUCCESS' ? 0 : 1,
+              buildTimeOffset,
+              deployTimeOffset,
+            });
+            if (buildFinished && deployFinished && this.interval) {
+              clearInterval(this.interval);
+              this.interval = null;
+            }
+          }
+        }
+      }
+    });
+  }
+  requestInterval() {
+    if (!this.interval) {
+      let count = 0;
+      this.setState({
+        requestFromInitial: false,
+      });
+      this.requestAndSetStatus();
+      this.interval = setInterval(() => {
+        if (this.state.currentEnvironment) {
+          count++;
+          if (count % 5 == 0) {
+            this.requestAndSetStatus();
+          } else {
+            let buildTimeOffset = this.state.currentEnvironment.buildStatus.endsWith('ING') ? this.getDuration(this.state.currentEnvironment.buildStartTime) : null;
+            let deployTimeOffset = this.state.currentEnvironment.deployStatus.endsWith('ING') ? this.getDuration(this.state.currentEnvironment.deployTimeOffset) : null;
+            this.setState({
+              buildTimeOffset,
+              deployTimeOffset,
+            });
+          }
+        }
+      }, 1000);
+    }
+  }
+  getDuration(time) {
+    if (time) {
+      return moment.utc(moment().diff(moment(time))).format('HH:mm:ss');
+    }
+    return null;
+  }
+  componentDidMount() {
+    this.fetchData(this.props.match.params.environmentId);
+  }
+  componentWillReceiveProps(props) {
+    if (this.props.match.params.environmentId != props.match.params.environmentId ) {
+      this.fetchData(props.match.params.environmentId);
+    }
+  }
+  showEnvConfig() {
+    this.setState({
+      showConfigModal: true,
+    });
+  }
+  updateEnvConfig(values) {
+    this.props.dispatch({
+      type: 'config/update',
+      payload: {
+        ...values,
+        callback: (response) => {
+          if (response && response.code == 0) {
+            this.setState({
+              showConfigModal: false,
+            });
+            this.props.dispatch({
+              type: 'environment/get',
+              payload: {
+                id: this.props.match.params.environmentId,
+                callback: (response) => {
+                  this.setState({
+                    currentEnvironment: response.result,
+                  });
+                }
+              }
+            });
+          }
+        }
+      }
+    });
+  }
+  hideEnvConfig() {
+    this.setState({
+      showConfigModal: false,
+    });
+  }
+  showEnvUpdate() {
+    this.setState({
+      showEnvironmentModal: true,
+    });
+  }
+  hideEnvUpdate() {
+    this.setState({
+      showEnvironmentModal: false,
+    });
+  }
+  updateEnv(values) {
+    this.props.dispatch({
+      type: 'environment/update',
+      payload: {
+        ...values,
+        id: this.state.currentEnvironment.id,
+        appId: this.state.currentEnvironment.appId,
+        callback: (response) => {
+          if (response && response.code == 0) {
+            this.setState({
+              showEnvironmentModal: false,
+            });
+            this.props.dispatch({
+              type: 'environment/get',
+              payload: {
+                id: this.props.match.params.environmentId,
+                callback: (response) => {
+                  this.setState({
+                    currentEnvironment: response.result,
+                  });
+                }
+              }
+            });
+          }
+        }
+      },
+    });
+  }
+  deleteEnv() {
+    this.props.dispatch({
+      type: 'environment/deleteEnvironment',
+      payload: {
+        id: this.state.currentEnvironment.id,
+        appId: this.props.match.params.id,
+        callback: (response) => {
+          if (response && response.code == 0) {
+            this.props.dispatch({
+              type: 'environment/setCurrentEnvironment',
+            });
+            history.push(`/app/${this.props.match.params.id}`);
+          }
+        }
+      }
+    })
+  }
+  onChangeStep(current) {
+    this.setState({
+      currentStep: current,
+    });
+  }
+  invokeDeploy() {
+    this.props.dispatch({
+      type: 'environment/deploy',
+      payload: {
+        id: this.state.currentEnvironment.id,
+        callback: (response) => {
+          if (response && response.code == 0) {
+            this.requestInterval();
+          }
+        }
+      }
+    })
+  }
+  requestAndSetLog(logType, id, record) {
+    const type = logType == 'build' || logType == 'deploy' ? 'environment/get' : 'environment/getLog';
+    this.props.dispatch({
+      type,
+      payload: {
+        id,
+        fetchAll: true,
+        podName: record?.name,
+        callback: (response) => {
+          if (response && response.code == 0) {
+            let log = '';
+            switch(logType) {
+              case 'build':
+                log = response.result.buildOutput;
+                break;
+              case 'deploy':
+                log = response.result.deployOutput;
+                break;
+              case 'pod':
+                log = response.result[record.labels[`paas.seecoder.cn/resource`]];
+                break;
+            }
+            this.setState({
+              log,
+            });
+            if (this.elem) {
+              this.elem.scrollIntoView(true);
+            }
+          }
+        }
+      }
+    });
+  }
+  showLogModal(logType, record) {
+    this.setState({
+      requestFromInitial: false,
+      showLogModal: true,
+    });
+    this.requestAndSetLog(logType, this.state.currentEnvironment.id, record);
+    if (!this.logInterval) {
+      this.logInterval = setInterval(() => {
+        this.requestAndSetLog(logType, this.state.currentEnvironment.id, record);
+      }, 5000);
+    }
+  }
+  hideLogModal() {
+    if (this.logInterval) {
+      clearInterval(this.logInterval);
+      this.logInterval = null;
+    }
+    this.setState({
+      showLogModal: false,
+    });
+  }
+  componentWillUnmount() {
+    if (this.interval) {
+      clearInterval(this.interval);
+      this.interval = null;
+    }
+    if (this.logInterval) {
+      clearInterval(this.logInterval);
+      this.logInterval = null;
+    }
+  }
+  restartPod(record) {
+    this.setState({
+      requestFromInitial: false,
+    });
+    this.props.dispatch({
+      type: 'environment/restart',
+      payload: {
+        id: this.state.currentEnvironment.id,
+        podName: record.name,
+        callback: (response) => {
+          if (response && response.code == 0) {
+            this.requestAndSetStatus();
+            if (!this.interval) {
+              let count = 0;
+              this.interval = setInterval(() => {
+                if (this.state.currentEnvironment) {
+                  count++;
+                  if (count % 5 == 0) {
+                    this.requestAndSetStatus();
+                  } else {
+                    let buildTimeOffset = this.state.currentEnvironment.buildStatus.endsWith('ING') ? this.getDuration(this.state.currentEnvironment.buildStartTime) : null;
+                    let deployTimeOffset = this.state.currentEnvironment.deployStatus.endsWith('ING') ? this.getDuration(this.state.currentEnvironment.deployTimeOffset) : null;
+                    this.setState({
+                      buildTimeOffset,
+                      deployTimeOffset,
+                    });
+                  }
+                }
+              }, 1000);
+            }
+          }
+        }
+      }
+    })
+  }
+  render() {
+    const buildType = this.state.currentEnvironment?.buildType;
+    const stepText = this.state.currentStep == 0 ? '构建' : '部署';
+    const stepStatus = this.state.currentStep == 0 ? this.state.currentEnvironment?.buildStatus : this.state.currentEnvironment?.deployStatus;
+    const stepStartTime = this.state.currentStep == 0 ? this.state.currentEnvironment?.buildStartTime : this.state.currentEnvironment?.deployStartTime;
+    const logType = this.state.currentStep == 0 ? 'build' : 'deploy';
+    const columns = [{
+      title: 'pod名称',
+      dataIndex: 'name',
+    }, {
+      title: '状态',
+      dataIndex: ['podStatus', 'phase'],
+    }, {
+      title: 'podIP',
+      dataIndex: ['podStatus', 'podIP'],
+    }, {
+      title: '宿主机IP',
+      dataIndex: ['podStatus', 'hostIP'],
+    }, {
+      title: '操作',
+      render: (text, record, index) => {
+        return (<div key={`action-${record.name}`}>
+          <a key={`action-podlog-${record.name}`} style={{marginRight: 5}} onClick={() => this.showLogModal('pod', record)}>查看日志</a>
+          <Popconfirm title="确认要重启吗?" onConfirm={() => this.restartPod(record)} okText="确定" cancelText="取消">
+            <a key={`action-restart-${record.name}`} style={{marginRight: 5}}>重新启动</a>
+          </Popconfirm>
+          <a key={`action-terminal-${record.name}`} style={{marginRight: 5}} target="_blank" href={`/terminal?namespace=${record.namespace}&podName=${record.name}&token=${sessionStorage.getItem('token')}`}>打开终端</a>
+        </div>)
+      }
+    }];
+    const dataSource = (this.state.currentEnvironment?.instance || []).map(item => ({...item, key: item.name }));
+    const namespace = this.state.currentEnvironment?.instance?.length ? this.state.currentEnvironment.instance[0].namespace : null;
+    const svc = this.state.currentEnvironment?.instance?.length ? this.state.currentEnvironment.instance[0].labels[`paas.seecoder.cn/resource`] : null;
+    return (
+      <div>
+        {this.props.loading.effects['environment/get'] && this.state.requestFromInitial ?
+          <div style={{textAlign: 'center', marginTop: 30}}><LoadingOutlined style={{ fontSize: '36px'}} /></div> :
+          (this.state.currentEnvironment ?
+            <PageHeader
+              backIcon={false}
+              title={this.state.currentEnvironment?.name}
+              extra={this.state.currentEnvironment ? (
+                <div>
+                  <Popconfirm title="确认要删除吗?" onConfirm={this.deleteEnv.bind(this)} okText="确定" cancelText="取消">
+                    <Button type="danger">删除环境</Button>
+                  </Popconfirm>
+                  <Button style={{marginLeft: 10}} type="primary" onClick={this.showEnvUpdate.bind(this)} >更新环境</Button>
+                  <Button style={{marginLeft: 10}} type="primary" onClick={this.showEnvConfig.bind(this)}>环境配置</Button>
+                  <Popconfirm title="确认要触发吗?" onConfirm={this.invokeDeploy.bind(this)} okText="确定" cancelText="取消">
+                    <Button style={{marginLeft: 10}} type="primary">触发部署</Button>
+                  </Popconfirm>
+                </div>
+              ) : null}
+            >
+              <Descriptions size="small"
+                column={2}
+              >
+                <Descriptions.Item label="构建类型">{buildTypeOptionName[buildType]}</Descriptions.Item>
+                <Descriptions.Item label={buildTypeValueLabels[buildType]}>{this.state.currentEnvironment?.buildTypeValue}</Descriptions.Item>
+                <Descriptions.Item label={"内部服务地址"}>{`${svc}.${namespace}.svc.cluster.local`}</Descriptions.Item>
+              </Descriptions>
+              <Divider style={{fontSize: 14, color: '#aaaaaad9', fontWeight: 600}} >部署构建</Divider>
+              <Steps current={this.state.currentStep} onChange={(current) => this.onChangeStep(current)}>
+                <Step
+                  title="构建"
+                  status={statusStepStatus[this.state.currentEnvironment?.buildStatus]}
+                  subTitle={this.state.buildTimeOffset}
+                ></Step>
+                <Step
+                  title="部署"
+                  status={this.state.currentEnvironment?.buildStatus == 'SUCCESS' ? statusStepStatus[this.state.currentEnvironment?.deployStatus] : 'wait'}
+                  subTitle={this.state.deployTimeOffset}
+                ></Step>
+              </Steps>
+              <Descriptions size="small"
+                column={2}
+                style={{marginTop: 10}}
+              >
+                <Descriptions.Item label={`当前${stepText}状态`}>
+                  <div><span className={styles.status} style={{background: `${statusColor[stepStatus]}`}}></span>{statusText[stepStatus]}</div>
+                </Descriptions.Item>
+                <Descriptions.Item label={`${stepText}开始时间`}>{stepStartTime}</Descriptions.Item>
+                <Descriptions.Item label={`镜像`}>{this.state.currentEnvironment?.image}</Descriptions.Item>
+                <Descriptions.Item ><Button type="primary" onClick={() => this.showLogModal(logType)}>查看日志输出</Button></Descriptions.Item>
+              </Descriptions>
+              <Divider style={{fontSize: 14, color: '#aaaaaad9', fontWeight: 600}}>实例维护</Divider>
+              <Table dataSource={dataSource} columns={columns}></Table>
+              <Modal
+                visible={this.state.showLogModal}
+                onOk={this.hideLogModal.bind(this)}
+                onCancel={this.hideLogModal.bind(this)}
+                okText="确认"
+                cancelText="取消"
+                title="查看日志"
+                id="logModal"
+                width="90%"
+                className={styles.logModal}
+              >
+                <div>
+                  <Input.TextArea
+                    value={this.state.log}
+                    bordered={false}
+                    readOnly={true}
+                    autoSize={true}
+                  >
+                  </Input.TextArea>
+                  <div ref={(elem) => {this.elem = elem;}}></div>
+                </div>
+              </Modal>
+              <ConfigModal
+                visible={this.state.showConfigModal}
+                onOk={this.updateEnvConfig.bind(this)}
+                onCancel={this.hideEnvConfig.bind(this)}
+                config={this.state.currentEnvironment?.config}
+              ></ConfigModal>
+              <EnvironmentModal
+                visible={this.state.showEnvironmentModal}
+                onOk={this.updateEnv.bind(this)}
+                onCancel={this.hideEnvUpdate.bind(this)}
+                environment={this.state.currentEnvironment}
+              >
+              </EnvironmentModal>
+            </PageHeader> :
+            <Empty></Empty>
+          )
+        }
+      </div>
+    )
+  }
+}

+ 13 - 0
src/pages/app/[id]/environment/[environmentId].less

@@ -0,0 +1,13 @@
+.status {
+  margin-right: 5px;
+  display: inline-block;
+  width: 8px;
+  height: 8px;
+  border-radius: 50%;
+}
+.logModal {
+  :global(.ant-modal-body) {
+    overflow-y: hidden;
+    padding: 0;
+  }
+}

+ 14 - 0
src/pages/app/[id]/index.js

@@ -0,0 +1,14 @@
+import React, { PureComponent } from 'react';
+import { connect, history } from 'umi';
+import { Empty } from 'antd';
+export default class EmptyAppEnvironments extends PureComponent {
+  state = {
+  }
+  render() {
+    return (
+      <div style={{flex: 1 }}>
+        <Empty></Empty>
+      </div>
+    )
+  }
+}

+ 161 - 0
src/pages/apps.js

@@ -0,0 +1,161 @@
+import React, { PureComponent } from 'react';
+import styles from './apps.less';
+import { connect, history } from 'umi';
+import { PageHeader, Button, Descriptions, Modal, Card, Icon, Row, Col, Form, Popconfirm, Input } from 'antd';
+import {
+  SaveTwoTone,
+  DeleteTwoTone,
+} from '@ant-design/icons';
+
+@connect(({ app }) => ({
+  app
+}))
+export default class Apps extends PureComponent {
+  formRef = React.createRef();
+  state = {
+    showModal: false,
+    currentApp: null,
+  }
+  componentDidMount() {
+    this.props.dispatch({
+      type: 'app/getAll',
+    })
+  }
+  createApp() {
+    this.setState({
+      showModal: true,
+      currentApp: null,
+    });
+    this.formRef.current.setFieldsValue({
+      name: '',
+      description: '',
+      gitUrl: '',
+    });
+  }
+  updateApp(app) {
+    this.setState({
+      showModal: true,
+      currentApp: app.id,
+    });
+    this.formRef.current.setFieldsValue({
+      ...app,
+    });
+  }
+  deleteApp(app) {
+    this.props.dispatch({
+      type: 'app/deleteApp',
+      payload: {
+        id: app.id,
+      },
+    });
+  }
+  onFinish() {
+    this.formRef.current.validateFields().then(
+      values => {
+        this.props.dispatch({
+          type: this.state.currentApp ? 'app/update' : 'app/create',
+          payload: {
+            id: this.state.currentApp,
+            ...values,
+            callback: (response) => {
+              if (response && response.code == 0) {
+                this.setState({
+                  showModal: false,
+                });
+              }
+            }
+          },
+        });
+      }
+    )
+  }
+  selectApp(app) {
+    history.push(`/app/${app.id}`);
+  }
+  render() {
+    return (
+      <div>    
+        <PageHeader
+          ghost={false}
+          backIcon={false}
+          title="应用"
+          extra={[
+            <Button key="1" type="primary" onClick={this.createApp.bind(this)}>
+              新增
+            </Button>
+          ]}
+        >
+          <Row gutter={16}>
+            {(this.props.app.apps || []).map(app => {
+              return (
+                <Col span={8} xs={24} sm={12} md={8} lg={6}
+                  key={app.id} className={styles.cardCol}>
+                  <Card
+                    className={styles.card}
+                    title={<div className={styles.cardTitle} onClick={() => this.selectApp(app)}>{app.name}</div>}
+                    hoverable
+                    extra={
+                      <div>
+                        <SaveTwoTone style={{marginRight: 4}} onClick={() => this.updateApp(app)}/>
+                        <Popconfirm title="确认要删除吗?" onConfirm={() => this.deleteApp(app)} okText="确定" cancelText="取消">
+                          <DeleteTwoTone/>
+                        </Popconfirm>
+                      </div>
+                    }
+                    size="small"
+                  >
+                    <Descriptions size="small"
+                    >
+                      <Descriptions.Item label="Git URL"><a target="_blank" href={app.gitUrl}>{app.gitUrl}</a></Descriptions.Item>
+                    </Descriptions>
+                  </Card>
+                </Col>
+              )
+            })}
+          </Row>
+          <Modal
+            visible={this.state.showModal}
+            forceRender
+            onCancel={() => {
+              this.setState({
+                showModal: false,
+              });
+            }}
+            title={"新增/更新应用"}
+            okText="确认"
+            cancelText="取消"
+            onOk={() => this.onFinish()}
+          >
+            <Form
+              ref={this.formRef}
+              labelCol={{ span: 4 }}
+              wrapperCol={{ span: 20 }}
+              labelAlign="right"
+            >
+              <Form.Item
+                label="应用名"
+                name="name"
+                rules={[{ required: true, message: '应用名不能为空!' }]}
+              >
+                <Input></Input>
+              </Form.Item>
+              <Form.Item
+                label="描述"
+                name="description"
+              >
+                <Input.TextArea></Input.TextArea>
+              </Form.Item>
+              <Form.Item
+                label="gitUrl"
+                name="gitUrl"
+                rules={[{ required: true, message: 'gitUrl不能为空!' }, { pattern: '^(http|https)://', message: 'gitUrl格式错误!' }]}
+              >
+                <Input></Input>
+              </Form.Item>
+            </Form>
+          </Modal>
+        </PageHeader>
+      </div>
+    );
+  }
+}

+ 14 - 0
src/pages/apps.less

@@ -0,0 +1,14 @@
+.cardCol {
+  :global(.ant-card-hoverable) {
+    cursor: default;
+  }
+  .card {
+    margin-top: 8px;
+    height: calc(100% - 8px);
+    .cardTitle {
+      cursor: pointer;
+      font-weight: 500;
+      font-size: 18px;
+    }
+  }
+}

+ 7 - 0
src/pages/index.less

@@ -0,0 +1,7 @@
+
+.normal {
+}
+
+.title {
+  background: rgb(121, 242, 157);
+}

+ 10 - 0
src/pages/index.tsx

@@ -0,0 +1,10 @@
+import React from 'react';
+import styles from './index.less';
+import { Redirect } from 'umi'
+export default () => {
+  return (
+    <div>
+      <Redirect to="/apps" />
+    </div>
+  );
+}

+ 62 - 0
src/pages/login.js

@@ -0,0 +1,62 @@
+import React, { PureComponent } from 'react';
+import styles from './login.less';
+import { connect, history } from 'umi';
+import { Form, Input, Button } from 'antd';
+
+@connect(({ user }) => ({
+  user
+}))
+export default class Login extends PureComponent {
+  componentDidMount() {
+    const token = sessionStorage.getItem('token')
+    if (token) {
+      history.push('/apps')
+    }
+  }
+  onFinish(values) {
+    this.props.dispatch({
+      type: 'user/login',
+      payload: {
+        ...values,
+        callback: (response) => {
+          if (response && response.code == 0) {
+            history.push('/apps');
+          }
+        }
+      },
+    });
+  }
+  render() {
+    return (
+      <div className={styles.loginContainer}>
+        <h1 className={styles.title}>Seecoder PaaS</h1>
+        <Form
+          name="basic"
+          labelAlign="right"
+          onFinish={this.onFinish.bind(this)}
+        >
+          <Form.Item
+            label="用户名"
+            name="username"
+            labelAlign="right"
+            rules={[{ required: true, message: '请输入用户名' }]}
+          >
+            <Input style={{width: 180}} />
+          </Form.Item>
+          <Form.Item
+            label="密码"
+            name="password"
+            rules={[{ required: true, message: '请输入密码' }]}
+          >
+            <Input.Password style={{width: 180}} />
+          </Form.Item>
+          <Form.Item  >
+            <Button type="primary" htmlType="submit" className={styles.loginButton}>
+              登录
+            </Button>
+          </Form.Item>
+        </Form>
+      </div>
+    );
+  }
+}

+ 12 - 0
src/pages/login.less

@@ -0,0 +1,12 @@
+.loginContainer {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  margin-top: 150px;
+  :global(.ant-form-item-label) {
+    flex: 1;
+  }
+  .loginButton {
+    width: 100%;
+  }
+}

+ 33 - 0
src/pages/terminal.js

@@ -0,0 +1,33 @@
+import React, { PureComponent } from 'react';
+import { Terminal as XTerm } from 'xterm';
+import {message} from 'antd'
+import styles from './terminal.less'
+import 'xterm/css/xterm.css'
+import { FitAddon } from 'xterm-addon-fit';
+
+export default class Terminal extends PureComponent {
+  componentDidMount() {
+    this.term = new XTerm();
+    const fitAddon = new FitAddon();
+    this.term.loadAddon(fitAddon);
+    this.term.open(document.getElementById('terminal'), true)
+    fitAddon.fit();
+    const isDev = process.env.NODE_ENV === 'development';
+    this.socket = new WebSocket(`ws://${isDev ? '172.19.240.15:8080' : window.location.host}/ws/terminal${this.props.location?.search}`);
+    this.socket.onerror = (error) => {
+      message.error('连接错误!请尝试重新登陆或重新打开终端!');
+    }
+    this.socket.onmessage = (event) => {
+      this.term.write(event.data);
+    }
+    this.term.onData((data) => {
+      this.socket.send(data);
+    })
+  }
+  render() {
+    return (
+      <div id="terminal" style={{height: '100%', width: '100%'}}>
+      </div>
+    );
+  }
+}

+ 0 - 0
src/pages/terminal.less


+ 34 - 0
src/services/app.js

@@ -0,0 +1,34 @@
+import { request } from 'umi';
+
+export async function getAll() {
+  return request('/api/application', {
+    method: 'get'
+  });
+}
+
+export async function create(params) {
+  return request('/api/application', {
+    method: 'post',
+    data: params,
+  });
+}
+
+export async function update(params) {
+  return request(`/api/application`, {
+    method: 'put',
+    data: params,
+  });
+}
+
+export async function get(params) {
+  return request(`/api/application/${params.id}`, {
+    method: 'get',
+  });
+}
+
+export async function deleteApp(params) {
+  return request(`/api/application/${params.id}`, {
+    method: 'delete',
+  });
+}
+

+ 8 - 0
src/services/config.js

@@ -0,0 +1,8 @@
+import { request } from 'umi';
+
+export async function update(params) {
+  return request(`/api/config`, {
+    method: 'put',
+    data: params,
+  });
+}

+ 51 - 0
src/services/environment.js

@@ -0,0 +1,51 @@
+import { request } from 'umi';
+
+export async function getAll(params) {
+  return request(`/api/environment?appId=${params.appId}`, {
+    method: 'get'
+  });
+}
+
+export async function create(params) {
+  return request('/api/environment', {
+    method: 'post',
+    data: params,
+  });
+}
+
+export async function update(params) {
+  return request(`/api/environment`, {
+    method: 'put',
+    data: params,
+  });
+}
+
+export async function get(params) {
+  return request(`/api/environment/${params.id}?fetchAll=${params.fetchAll || ''}`, {
+    method: 'get',
+  });
+}
+
+export async function deleteEnvironment(params) {
+  return request(`/api/environment/${params.id}`, {
+    method: 'delete',
+  });
+}
+
+export async function restart(params) {
+  return request(`/api/environment/restart?id=${params.id}&podName=${params.podName}`, {
+    method: 'post',
+  });
+}
+
+export async function deploy(params) {
+  return request(`/api/environment/deploy?id=${params.id}`, {
+    method: 'post',
+  });
+}
+
+export async function getLog(params) {
+  return request(`/api/environment/log?id=${params.id}&podName=${params.podName}`, {
+    method: 'get',
+  });
+}

+ 8 - 0
src/services/user.js

@@ -0,0 +1,8 @@
+import { request } from 'umi';
+
+export async function login(params) {
+  return request('/api/auth', {
+    method: 'post',
+    data: params,
+  });
+}

+ 25 - 0
tsconfig.json

@@ -0,0 +1,25 @@
+{
+  "compilerOptions": {
+    "target": "esnext",
+    "module": "esnext",
+    "moduleResolution": "node",
+    "importHelpers": true,
+    "jsx": "react",
+    "esModuleInterop": true,
+    "sourceMap": true,
+    "baseUrl": "./",
+    "strict": true,
+    "paths": {
+      "@/*": ["src/*"],
+      "@@/*": ["src/.umi/*"]
+    },
+    "allowSyntheticDefaultImports": true
+  },
+  "include": [
+    "mock/**/*",
+    "src/**/*",
+    "config/**/*",
+    ".umirc.ts",
+    "typings.d.ts"
+  ]
+}

+ 8 - 0
typings.d.ts

@@ -0,0 +1,8 @@
+declare module '*.css';
+declare module '*.less';
+declare module "*.png";
+declare module '*.svg' {
+  export function ReactComponent(props: React.SVGProps<SVGSVGElement>): React.ReactElement
+  const url: string
+  export default url
+}