前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >KubeSphere Console 二次开发源码阅读

KubeSphere Console 二次开发源码阅读

作者头像
我是阳明
发布2021-08-20 17:29:33
1.6K0
发布2021-08-20 17:29:33
举报
文章被收录于专栏:k8s技术圈k8s技术圈

前言

安装了 KubeSphere 几个月了,但是总是知其然不知其所以然,想添点东西也不知从何着手,于是决定好好看一下他的源码,如果有对 console 二开感兴趣的小伙伴,可以看看呦~

开工之前建议先了解一下:

  • ES6 语法
  • React 基础

console 代码结构

如何尽快上手

作为一个开源前端项目,代码量很大的情况下,除了借助官方文档了解代码结构,还可以从浏览界面入手先理顺一条完整的调用链,这样其他的照葫芦画瓢就会比较容易

一个例子

举一个具体的简单例子,比如首页的蜘蛛图(集群资源使用情况)数据获取,根据一个具体的比较好理解 console 用到的组件、路由、请求封装。

我刚开始接触 console 的时候,最困惑的事情是三样:

  • 不知道怎样在代码里找到它
  • 不知道搜索出来的一堆里面哪一个是需要的
  • 不知道这个数据从哪里来的

所以我们从最基础的捞代码开始,打开浏览器,在选择目标附近的文本进行搜索。

结合浏览器检查元素,可知搜索的文本是一个 Panel。

所以确定是这个不是上面两个,最上面四个 js 是国际化。

进入文件,查看 render 里面,是哪个组件渲染了数据。

找到 getResourceOptions 函数。

代码语言:javascript
复制
  getResourceOptions = () => {
    const data = getLastMonitoringData(this.metrics)
    return [
      {
        name: 'CPU',
        unitType: 'cpu',
        used: this.getValue(data[MetricTypes.cpu_usage]),
        total: this.getValue(data[MetricTypes.cpu_total]),
      },
      {
        name: 'Memory',
        unitType: 'memory',
        used: this.getValue(data[MetricTypes.memory_usage]),
        total: this.getValue(data[MetricTypes.memory_total]),
      },
      {
        name: 'Pod',
        unitType: '',
        used: this.getValue(data[MetricTypes.pod_count]),
        total: this.getValue(data[MetricTypes.pod_capacity]),
      },
      {
        name: 'Local Storage',
        unitType: 'disk',
        used: this.getValue(data[MetricTypes.disk_size_usage]),
        total: this.getValue(data[MetricTypes.disk_size_capacity]),
      },
    ]
  }

可知 getResourceOptions 的数据来源于 getLastMonitoringData(this.metrics),这里解释一下为什么集群资源首页的数据会名字与 Monitoring 相关,实际上是因为这里用的就是监控的数据,互通的。

代码语言:javascript
复制
export const getLastMonitoringData = data => {
  const result = {}

  Object.entries(data).forEach(([key, value]) => {
    const values = get(value, 'data.result[0].values', []) || []
    const _value = isEmpty(values)
      ? get(value, 'data.result[0].value', []) || []
      : last(values)
    set(result, `[${key}].value`, _value)
  })

  return result
}

由此可知,getLastMonitoringData 只是做了一下数据转换处理,并不是真正的数据来源,那么数据来源就是被 getLastMonitoringData 处理的 this.metrics,回到数据渲染文件。

代码语言:javascript
复制
  monitorStore = new ClusterMonitorStore({ cluster: this.props.cluster })

  componentDidMount() {
    this.fetchData()
  }

  get metrics() {
    return this.monitorStore.data
  }

可知 this.metrics 来源于 ClusterMonitorStore,此处也可以进行本地调试打印辅助确认。

依照路径找到 ClusterMonitorStore 类(react 项目,引用路径默认是 src,所以有很多 stores 也不要疑惑,指的就是 src 下的那个)。

代码语言:javascript
复制
import { action, observable } from 'mobx'
import { get } from 'lodash'

import { to } from 'utils'

import Base from './base'

export default class ClusterMonitoring extends Base {
  @observable
  statistics = {
    data: {},
    isLoading: false,
  }

  @observable
  resourceMetrics = {
    originData: {},
    data: [],
    isLoading: false,
  }

  @action
  async fetchStatistics() {
    this.statistics.isLoading = true

    const params = {
      type: 'statistics',
    }
    const result = await to(request.get(this.getApi(), params))
    const data = this.getResult(result)

    this.statistics = {
      data,
      isLoading: false,
    }

    return data
  }

  @action
  async fetchApplicationResourceMetrics({
    workspace,
    namespace,
    autoRefresh = false,
    ...filters
  }) {
    if (autoRefresh) {
      filters.last = true
      this.resourceMetrics.isRefreshing = true
    } else {
      this.resourceMetrics.isLoading = true
    }

    if (filters.cluster) {
      this.cluster = filters.cluster
    }

    const params = this.getParams(filters)

    // set correct path
    const paramsReg = /^[a-zA-Z]+_/g
    const metricType = get(filters.metrics, '[0]', '').replace(
      paramsReg,
      'cluster_'
    )
    let path = 'cluster'

    if (workspace) {
      path = `workspaces/${workspace}`
      params.metrics_filter = `${metricType.replace(paramsReg, 'workspace_')}$`
    }
    if (namespace && namespace !== 'all') {
      path = `namespaces/${namespace}`
      params.metrics_filter = `${metricType.replace(paramsReg, 'namespace_')}$`
    }

    const result = await to(request.get(`${this.apiVersion}/${path}`, params))

    let data = this.getResult(result)
    if (autoRefresh) {
      data = this.getRefreshResult(data, this.resourceMetrics.originData)
    }

    this.resourceMetrics = {
      originData: data,
      data: get(Object.values(data), '[0].data.result') || [],
      isLoading: false,
      isRefreshing: false,
    }

    return data
  }

  fetchClusterDevopsCount = async () => {
    const result = await request.get(
      'kapis/tenant.kubesphere.io/v1alpha2/devopscount/'
    )

    return get(result, 'count', 0)
  }
}

有意思的事情来了,遍寻 ClusterMonitoring 类也找不到数据来源,经过一番调试,发现 ClusterMonitoring 只是一个桥,数据来源在继承的 Base 类。

代码语言:javascript
复制
  @action
  async fetchMetrics({
    autoRefresh = false,
    more = false,
    fillZero = true,
    ...filters
  }) {
    if (autoRefresh) {
      filters.last = true
      this.isRefreshing = true
    } else {
      this.isLoading = true
    }

    if (filters.cluster) {
      this.cluster = filters.cluster
    }

    const params = this.getParams(filters)
    const api = this.getApi(filters)
    const response = await to(request.get(api, params))

    let result = this.getResult(response)
    if (autoRefresh) {
      result = this.getRefreshResult(result, this.data)
    }
    if (more) {
      result = this.getMoreResult(result, this.data)
    }

    this.data = fillZero ? fillEmptyMetrics(params, result) : result
    this.isLoading = false
    this.isRefreshing = false

    return result
  }

打印 const response = await to(request.get(api, params)) 中的 params 以及 api,对照浏览器的请求头及参数。

去掉编码后的字符以及分页限制等等暂时和主线不相关的,这是完整的数据化获取请求头。

拆解开:

  1. 请求地址: /kapis/monitoring.kubesphere.io/v1alpha3/cluster?
  2. 请求参数: metrics_filter=cluster_cpu_usage|cluster_cpu_total|cluster_memory_usage_wo_cache|cluster_memory_total|cluster_disk_size_usage|cluster_disk_size_capacity|cluster_pod_running_count|cluster_pod_quota$

以上,就是一个简短的数据获取,以此类推,其他的也只是调用链长一点,顺序大致如此,数据怎么来的一般就可以这么找,下面我们来看具体的组件化。

console 的组件

以上面的来讲:

对应界面:

然后我们来看 ResourceItem 的实现,对于 react 来说,函数、类、html 标签等等都是组件,都可以以 <xxx/> 的形式呈现,然后经 render 渲染到界面。

代码语言:javascript
复制
import React from 'react'

import { Text } from 'components/Base'
import { PieChart } from 'components/Charts'

import { getSuitableUnit, getValueByUnit } from 'utils/monitoring'

import styles from './image/index.scss'

export default function ResourceItem(props) {
  const title = t(props.name)
  const unit = getSuitableUnit(props.total, props.unitType) || unit
  const used = getValueByUnit(props.used, unit)
  const total = getValueByUnit(props.total, unit) || used

  return (
    <div className={styles.item}>
      <div className={styles.pie}>

        //这是左边那个小环形图
        <PieChart
          width={48}
          height={48}
          data={[
            {
              name: 'Used',
              itemStyle: {
                fill: '#329dce',
              },
              value: used,
            },
            {
              name: 'Left',
              itemStyle: {
                fill: '#c7deef',
              },
              value: total - used,
            },
          ]}
        />
      </div>
      //文本
      <Text
        title={`${Math.round((used * 100) / total)}%`}
        description={title}
      />
      <Text title={unit ? `${used} ${unit}` : used} description={t('Used')} />
      <Text
        title={unit ? `${total} ${unit}` : total}
        description={t('Total')}
      />
    </div>
  )
}

<PieChart> 的实现:

代码语言:javascript
复制
import React from 'react'
import PropTypes from 'prop-types'

import { PieChart, Pie, Cell } from 'recharts'

export default class Chart extends React.Component {
  static propTypes = {
    width: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
    height: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
  }

  static defaultProps = {
    width: 100,
    height: 100,
    dataKey: 'value',
  }

  render() {
    const { width, height, data, dataKey } = this.props

    return (
      <PieChart width={width} height={height}>
        <Pie
          data={data}
          dataKey={dataKey}
          innerRadius="60%"
          outerRadius="100%"
          animationDuration={1000}
        >
          {data.map(entry => (
            <Cell
              key={`cell-${entry.name}`}
              {...entry.itemStyle}
              strokeWidth={0}
            />
          ))}
        </Pie>
      </PieChart>
    )
  }
}

根据调用处导入的 Text 可知,用的是最顶层的 base 下的 Text 组件,<Text> 的实现。

代码语言:javascript
复制
import React from 'react'
import { Icon } from '@kube-design/components'
import { isUndefined } from 'lodash'
import classNames from 'classnames'

import styles from './image/index.scss'

export default class Text extends React.PureComponent {
  render() {
    const {
      icon,
      title,
      description,
      className,
      ellipsis,
      extra,
      onClick,
    } = this.props

    return (
      <div
        className={classNames(
          styles.wrapper,
          { [styles.clickable]: !!onClick, [styles.ellipsis]: ellipsis },
          className
        )}
        onClick={onClick}
      >
        {icon && <Icon className={styles.icon} name={icon} size={40} />}
        <div className={styles.text}>
          <div>{isUndefined(title) || title === '' ? '-' : title}</div>
          <p>{description}</p>
        </div>
        {extra}
      </div>
    )
  }
}

对于一些复杂的组件,可能里面还会嵌套其他组件,但是原理都是一样的,根据导入路径一级一级找就行了,如果我们对于 UI 有修改的需求,比如要另一个样子的 <Text>,那么可以复写一个 <Text> 组件,引用新写的 <Text> 组件

console 的请求封装

就上面讲到的,数据真正来自于 metrics。

代码语言:javascript
复制
fetchData = () => {
    this.monitorStore.fetchMetrics({
      metrics: Object.values(MetricTypes),
      last: true,
    })
  }

fetchMetrics函数 cluster.js 同级的 base.js,我们去详细看一下这个 base 文件。

代码语言:javascript
复制
  @action
  async fetchMetrics({
    autoRefresh = false,
    more = false,
    fillZero = true,
    ...filters
  }) {
    if (autoRefresh) {
      filters.last = true
      this.isRefreshing = true
    } else {
      this.isLoading = true
    }

    if (filters.cluster) {
      this.cluster = filters.cluster
    }
    
    //获取参数列表
    const params = this.getParams(filters)
    //获取请求头的API
    const api = this.getApi(filters)
    //发起请求获取response
    const response = await to(request.get(api, params))

    let result = this.getResult(response)
    if (autoRefresh) {
      result = this.getRefreshResult(result, this.data)
    }
    if (more) {
      result = this.getMoreResult(result, this.data)
    }

    this.data = fillZero ? fillEmptyMetrics(params, result) : result
    this.isLoading = false
    this.isRefreshing = false

    return result
  }

我们在浏览器打印一下看看。

console 直接用的就是 nodejs 的 request.get 请求,同时支持 http 和 https,这个例子里面就是缺省回调的写法,完整的长这样:

代码语言:javascript
复制
request.get(url, (error, response, body) => {
  //需要xxx回调执行的代码放这里
});

我们再看一眼请求头

kapis/monitoring.kubesphere.io/v1alpha3/cluster

其中monitoring.kubesphere.io是代表这个请求组属于 monitoring ,所以如果这个请求出现了服务器错误,就应该去看监控的 pod 是不是出问题了。

对照官方的 API 文档可以看到还有很多这样的 apiGroup。

代码语言:javascript
复制
//集群相关的cluster.kubesphere.io
/kapis/cluster.kubesphere.io/v1alpha1/clusters/{cluster}/agent/deployment

//devops相关的devops.kubesphere.io
/kapis/devops.kubesphere.io/v1alpha2/crumbissuer

//资源相关的resources.kubesphere.io
/kapis/resources.kubesphere.io/v1alpha3/{resources}

普通用法一般就这样,咱们再看一个比较特别的。

代码语言:javascript
复制

import { withProjectList, ListPage } from 'components/HOCs/withList'
import Table from 'components/Tables/List'
import OpAppStore from 'stores/openpitrix/application'


@withProjectList({
  store: new OpAppStore(),
  module: 'applications',
  name: 'Application',
})

export default class OPApps extends React.Component {
  
  // 多余的代码省略……

  getColumns = () => {
    const { getSortOrder } = this.props
    //getColumns 的数据来自于 record,表面上并没有任何地方传入了 record (OPApps已经是根组件没有调用他的组件了)
    //record由浏览器打印可以看到是一条完整的table数据
    return [
      {
        title: t('Name'),
        dataIndex: 'name',
        render: (name, record) => (
          <Avatar
            isApp
            to={`${this.prefix}/${record.cluster_id}`}
            avatar={get(record, 'app.icon')}
            iconLetter={name}
            iconSize={40}
            title={name}
            desc={record.description}
          />
        ),
      },
      {
        title: t('Status'),
        dataIndex: 'status',
        isHideable: true,
        width: '16%',
        render: (status, record) => (
          <Status
            name={t(record.transition_status || status)}
            type={record.transition_status || status}
          />
        ),
      },
      {
        title: t('Application'),
        dataIndex: 'app.name',
        isHideable: true,
        width: '16%',
        render: (name, record) => (
          <Link to={`/apps/${get(record, 'version.app_id')}`}>{name}</Link>
        ),
      },
      {
        title: t('Version'),
        dataIndex: 'version.name',
        isHideable: true,
        width: '16%',
      },
      {
        title: t('Last Updated Time'),
        dataIndex: 'status_time',
        sorter: true,
        sortOrder: getSortOrder('status_time'),
        isHideable: true,
        width: 180,
        render: (time, record) =>
          getLocalTime(record.update_time || record.status_time).format(
            'YYYY-MM-DD HH:mm:ss'
          ),
      },
    ]
  }

  render() {
    const { bannerProps, tableProps, match } = this.props
    // table的Columns数据来自于getColumns
    return (
      <ListPage {...this.props}>
        <Banner {...bannerProps} match={match} type={this.type} />
        <Table
          //真正的数据传入是tableProps
          {...tableProps}
          {...this.getTableProps()}
          itemActions={this.itemActions}
          columns={this.getColumns()}
        />
      </ListPage>
    )
  }
}

record 打印:

实际上这里的参数只是起到占位及展示的作用,叫什么都可以,即使改变名字也是获取到同样的数据,因为实际向 Table 传递参数的是 tablePropsthis.getColumns() 的结果也作为 columns 参数传递过去,为了便于理解拆解开的,可以人为拆解开为三个回合,实际上反映到执行过程中只是参数的变化过程。

第一回合(即 render 之前):参数是 tableProps 以及 this.getColumns(),此时 this.getColumns() 中有效参数是 titledataIndex,此时 render 是一个正在准备的回调方法。

代码语言:javascript
复制
      {
        title: t('Name'),
        dataIndex: 'name',
        render: (name, record) => (
          <Avatar
            isApp
            to={`${this.prefix}/${record.cluster_id}`}
            avatar={get(record, 'app.icon')}
            iconLetter={name}
            iconSize={40}
            title={name}
            desc={record.description}
          />
        ),
      },

第二回合(render):参数是 tableProps 以及 this.getColumns() 此时 this.getColumns() 中有效参数是 title dataIndexrender,此时 render 是根据 dataIndex 识别出的要装载那些参数的已经完成回调的一个对象。

第三回合(render 结束):this.getColumns() 装载内部渲染完成,以 columns 形式作为 Table 的一个参数进行 Table 渲染(此时 render 已经是 dom 元素了)。

console 的路由

继续回归到我们这个简单的例子,路由我们也以此为例先讲普遍简单的 路由的找法不推荐由界面==>代码,因为有多级路由逆向并不方便,还是老老实实从项目根路由找起,一般 react 项目的根路由都在 src 下名字与 route 有关(这里仅仅指单页面应用,多页面应用路由后面讲)。

由此可以找到 /src/core/routes.js

代码语言:javascript
复制
import { lazy } from 'react'
//懒加载代码省略……
export default [
  {
    component: BaseLayout,
    routes: [
      {
        path: '/clusters',
        component: Clusters,
      },
      {
        path: '/access',
        component: AccessControl,
      },
      {
        path: '/:workspace/clusters/:cluster/projects/:namespace',
        component: Projects,
      },
      {
        path: '/:workspace/clusters/:cluster/devops/:devops',
        component: DevOps,
      },
      {
        path: '/:workspace/federatedprojects/:namespace',
        component: FederatedProjects,
      },
      {
        path: '/workspaces/:workspace',
        component: Workspaces,
      },
      {
        path: '/apps',
        component: AppStore,
      },
      {
        path: '/apps-manage',
        component: ManageApp,
      },
      {
        path: '/settings',
        component: Settings,
      },
      {
        path: '*',
        component: Console,
      },
    ],
  },
]

怎么确定是不是根路由?

  • 看路由的目录层级
  • 与开头介绍的项目代码功能结构进行对比

然后根据浏览器的访问路径 /clusters/default/overview 可知,我们的这个例子是第一个 clusters 的路由,所以如果要添加一个与集群管理同级页面路由在那里添就不言而喻了吧。

然后直接去找 overview 文件夹,我们本例的代码也确实在 overview 下(这里就和上面看到的代码无缝对接了,经过一串组件的封装之后渲染到了界面)。

ps:这里解释一下 default,这是我的集群的名字叫 default。kubesphere 3.0 是支持多集群的,一级路由是 clusters 要进入二级路由当然需要指定是那个集群,选择你要进入哪一个集群的概览。

组件封装调用链:

代码语言:javascript
复制
// 红框1
export default class Overview extends React.Component {
  get cluster() {
    return this.props.clusterStore
  }

  render() {
    const { isReady } = this.cluster.detail

    if (!isReady) {
      return <Initializing store={this.cluster} />
    }
    //加载<Dashboard/>
    return <Dashboard match={this.props.match} />
  }
}
//黄框2

<div>
  <NewClusterTitle
    className="margin-b12"
    cluster={detail}
    size="large"
    noStatus
  />
  <Columns>
    <Column>
      {globals.app.isMultiCluster && (
        <ClusterInfo cluster={detail} version={this.cluster.version} />
      )}
      <ServiceComponents cluster={match.params.cluster} />
      //加载<ResourcesUsage/>
      <ResourcesUsage cluster={match.params.cluster} />
      {globals.app.isPlatformAdmin && (
        <Tools cluster={match.params.cluster} />
      )}
    </Column>
    <Column className="is-narrow is-4">
      <KubernetesStatus cluster={match.params.cluster} />
      <ClusterNodes cluster={match.params.cluster} />
    </Column>
  </Columns>
</div>

//篮筐3

<Panel title={t('Cluster Resources Usage')}>
<Loading spinning={this.monitorStore.isLoading}>
  <div className={styles.wrapper}>
    <div className={styles.chart}>
      //加载资源使用情况,具体渲染在RadarChart组件中,上面讲过了
      <RadarChart
        cx={180}
        cy={158}
        width={360}
        height={316}
        data={radarOptions}
      >
        <PolarGrid gridType="circle" />
        <PolarAngleAxis dataKey="name" />
        <PolarRadiusAxis domain={[0, 100]} />
        <Radar dataKey="usage" stroke="#345681" fill="#1c2d4267" />
      </RadarChart>
    </div>
    <div className={styles.list}>
      {options.map(option => (
        <ResourceItem key={option.name} {...option} />
      ))}
    </div>
  </div>
</Loading>
</Panel>

最简单的路由就这样就完了,再说一个复杂一点的多页面应用的路由。

先解释一下为什么需要多页面应用,因为作为单页面应用所有的页面都受同一个跟路由管控,当我们需要不受跟路由管控的页面时,就需要多页面应用 比如登录,和帮助手册。

多页面应用是需要在 webpack 下配置的,通常在 webpack.config.js 中,但是一搜会找到很多 webpack.config.js 文件,分不出来是哪一个?没关系我们可以去抄登录,这是现成的多页面引用的例子。

于是我们找到登录的注册路由 server/routes.js

代码语言:javascript
复制
const Router = require('koa-router')
//多余代码省略……
const {
  handleLogin,
  handleLogout,
  handleOAuthLogin,
} = require('./controllers/session')

const {
  renderView,
  renderLogin,
  renderDocument,//渲染同级目录/controllers/view下的document
  renderMarkdown,
  renderCaptcha,
} = require('./controllers/view')

const parseBody = convert(
  bodyParser({
    formLimit: '200kb',
    jsonLimit: '200kb',
    bufferLimit: '4mb',
  })
)

const router = new Router()

router
  //多余代码省略……
  // session
  .post('/login', parseBody, handleLogin)
  .post('/logout', handleLogout)
  .get('/login', renderLogin)

  .get('/oauth/redirect', handleOAuthLogin)

  // markdown template
  .get('/blank_md', renderMarkdown)

  // 注册一个document
  .get('/document', renderDocument)

  // page entry
  .all('*', renderView)

module.exports = router

document 是一个简单的静态页面,但他的路由级别与整个 console 内部是同级的互不干扰的,也就是说我们也可以基于它构建和 console 一样复杂的页面。

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2021-08-18,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 k8s技术圈 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 前言
  • console 代码结构
  • 如何尽快上手
  • 一个例子
  • console 的组件
  • console 的请求封装
  • console 的路由
相关产品与服务
CODING DevOps
CODING DevOps 一站式研发管理平台,包括代码托管、项目管理、测试管理、持续集成、制品库等多款产品和服务,涵盖软件开发从构想到交付的一切所需,使研发团队在云端高效协同,实践敏捷开发与 DevOps,提升软件交付质量与速度。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档