瀏覽代碼

Merge pull request #246 from czhen-zilliz/plugin-enable-client

Enable client plugin
nameczz 3 年之前
父節點
當前提交
fa857bd4fa

+ 7 - 5
Dockerfile

@@ -8,7 +8,7 @@ WORKDIR /app/client
 RUN yarn install
 RUN yarn build
 # # => Building Server
-WORKDIR /app/server
+WORKDIR /app/express
 RUN yarn install
 ENV NODE_ENV production
 ENV PORT 80
@@ -17,11 +17,11 @@ RUN yarn build
 # => Copy to Final container
 FROM mhart/alpine-node:14
 WORKDIR /app
-COPY --from=builder /app/server/dist /app/dist
-COPY --from=builder /app/server/build /app/build
+COPY --from=builder /app/express/dist /app/dist
+COPY --from=builder /app/client/build /app/build
 # COPY --from=builder /app/server/node_modules /app/node_modules
-COPY --from=builder /app/server/package.json /app/package.json
-COPY --from=builder /app/server/yarn.lock /app/yarn.lock
+COPY --from=builder /app/express/package.json /app/package.json
+COPY --from=builder /app/express/yarn.lock /app/yarn.lock
 
 # => Reinstall production dependencies and clean cache
 RUN yarn install --production && yarn cache clean
@@ -35,5 +35,7 @@ RUN chmod +x /app/build/env.sh
 RUN chgrp -R 0 /app && \
     chmod -R g=u /app
 
+EXPOSE 3000
+
 # RUN echo -e window.__version="{\"version\":\""$VERSION"\"}" > /app/build/version.js
 CMD [ "/bin/bash", "-c", "/app/build/env.sh && yarn start:prod" ]

+ 16 - 0
client/README.md

@@ -22,6 +22,22 @@
       ├── types                   # Global types
       └── utils                   # The common functoins
 
+### Fixed pacakge version
+
+Temporarily we specify 3 packages' version for ts build.
+
+```
+"@material-ui/core": "4.11.4",
+"@material-ui/lab": "4.0.0-alpha.58",
+"react-i18next": "11.10.0",
+```
+
+`react-i18next`'s `useTranslation(<name>)` will return a `t` function, which used to return `string` and `string{}` type. But in latest version it only return `string` and cause typecheck error.
+
+`@material-ui/core` change `TablePagination`(from '@material-ui/core/TablePagination') type in latest version. We specified a former version to prevent error here.
+
+In future we will fix all type issues and remove specified package version usage.
+
 ### How to name the file
 
 We use Camel-Case to name the file.

+ 6 - 11
client/config-overrides.js

@@ -1,12 +1,7 @@
-const path = require('path');
-// const darkTheme = require("@ant-design/dark-theme").default;
+// const path = require('path');
+const { configPaths } = require('react-app-rewire-alias');
+const { aliasDangerous } = require('react-app-rewire-alias/lib/aliasDangerous');
 
-module.exports = {
-  // The paths config to use when compiling your react app
-  //  for development or production.
-  paths: function (paths, env) {
-    // ...add your paths config
-    paths.appBuild = path.resolve(__dirname, '../server/build');
-    return paths;
-  },
-};
+const aliasMap = configPaths('./tsconfig.paths.json');
+
+module.exports = aliasDangerous(aliasMap);

+ 8 - 3
client/package.json

@@ -6,9 +6,10 @@
   "bugs": "https://github.com/milvus-io/milvus-insight/issues",
   "private": true,
   "dependencies": {
-    "@material-ui/core": "^4.11.4",
+    "@loadable/component": "^5.15.0",
+    "@material-ui/core": "4.11.4",
     "@material-ui/icons": "^4.11.2",
-    "@material-ui/lab": "^4.0.0-alpha.58",
+    "@material-ui/lab": "4.0.0-alpha.58",
     "@mui/x-data-grid": "^4.0.0",
     "@testing-library/jest-dom": "^5.11.4",
     "@testing-library/react": "^11.1.0",
@@ -26,10 +27,11 @@
     "i18next": "^20.3.1",
     "papaparse": "^5.3.1",
     "react": "^17.0.2",
+    "react-app-rewire-alias": "^1.1.4",
     "react-app-rewired": "^2.1.8",
     "react-dom": "^17.0.2",
     "react-highlight-words": "^0.17.0",
-    "react-i18next": "^11.10.0",
+    "react-i18next": "11.10.0",
     "react-router-dom": "^5.2.0",
     "react-scripts": "4.0.3",
     "react-syntax-highlighter": "^15.4.4",
@@ -43,6 +45,7 @@
   },
   "scripts": {
     "start": "react-app-rewired start -FAST_REFRESH=true",
+    "start:plugin": "REACT_APP_PLUGIN_DEV=true react-app-rewired start -FAST_REFRESH=true",
     "build": "react-app-rewired build",
     "test": "react-app-rewired test",
     "test:watch": "react-app-rewired test --watch",
@@ -71,6 +74,8 @@
   },
   "devDependencies": {
     "@testing-library/react-hooks": "^7.0.1",
+    "@types/loadable__component": "^5.13.4",
+    "@types/webpack-env": "^1.16.3",
     "prettier": "2.3.2"
   }
 }

+ 29 - 0
client/src/components/layout/Layout.tsx

@@ -8,6 +8,9 @@ import icons from '../icons/Icons';
 import { useTranslation } from 'react-i18next';
 import { useHistory, useLocation } from 'react-router-dom';
 import { authContext } from '../../context/Auth';
+import { IconsType } from '../icons/Types';
+
+const PLUGIN_DEV = process.env?.REACT_APP_PLUGIN_DEV;
 
 const useStyles = makeStyles((theme: Theme) =>
   createStyles({
@@ -87,6 +90,32 @@ const Layout = (props: any) => {
     },
   ];
 
+  function importAll(r: any) {
+    r.keys().forEach((key: any) => {
+      const content = r(key);
+      const pathName = content.client?.path;
+      if (!pathName) return;
+      const result: NavMenuItem = {
+        icon: icons.navOverview,
+        label: content.client?.label || 'PLGUIN',
+      };
+      result.onClick = () => history.push(`${pathName}`);
+      const iconName: IconsType = content.client?.iconName;
+      if (iconName) {
+        // TODO: support custom icon
+        result.icon = icons[iconName];
+      }
+      content.client?.iconActiveClass &&
+        (result.iconActiveClass = content.client?.iconActiveClass);
+      content.client?.iconNormalClass &&
+        (result.iconNormalClass = content.client?.iconNormalClass);
+      menuItems.push(result);
+    });
+  }
+  importAll(require.context('../../plugins', true, /config\.json$/));
+  PLUGIN_DEV &&
+    importAll(require.context('all_plugins/', true, /config\.json$/));
+
   return (
     <div className={classes.root}>
       <GlobalEffect>

+ 1 - 1
client/src/pages/collections/Collections.tsx

@@ -171,7 +171,7 @@ const Collections = () => {
       // update collections
       fetchData();
       return { result: true, msg: '' };
-    } catch (err) {
+    } catch (err: any) {
       const {
         response: {
           data: { message },

+ 1 - 1
client/src/pages/partitions/Partitions.tsx

@@ -205,7 +205,7 @@ const Partitions: FC<{
       fetchPartitions(collectionName);
 
       return { result: true, msg: '' };
-    } catch (err) {
+    } catch (err: any) {
       const {
         response: {
           data: { message },

+ 78 - 0
client/src/plugins/system/BaseCard.tsx

@@ -0,0 +1,78 @@
+
+import { FC } from 'react';
+import { makeStyles } from '@material-ui/core';
+import { SvgIcon } from '@material-ui/core';
+import { BaseCardProps } from './Types';
+import { ReactComponent } from '../../assets/imgs/pic.svg'
+
+const getStyles = makeStyles(() => ({
+  root: {
+    backgroundColor: 'white',
+    borderRadius: '8px',
+    boxShadow: '3px 3px 10px rgba(0, 0, 0, 0.05)',
+    boxSizing: 'border-box',
+    height: '150px',
+    padding: '16px',
+  },
+  title: {
+    color: '#82838E',
+    fontSize: '14px',
+    marginBottom: '5px',
+    textTransform: 'capitalize',
+  },
+  content: {
+    color: '#010E29',
+    fontSize: '20px',
+    fontWeight: 600,
+    lineHeight: '36px',
+  },
+  desc: {
+    color: '#82838E',
+    fontSize: '14px',
+    lineHeight: '36px',
+    marginLeft: "8px",
+  },
+  emptyRoot: {
+    alignItems: 'center',
+    display: 'flex',
+    flexDirection: 'column',
+    justifyContent: 'flex-start',
+
+    '& > svg': {
+      marginTop: '10px',
+      width: '100%',
+    }
+  },
+  emptyTitle: {
+    fontSize: '14px',
+    marginTop: '14px',
+    textTransform: 'capitalize',
+  },
+  emptyDesc: {
+    fontSize: '10px',
+    color: '#82838E',
+    marginTop: '8px',
+  },
+}));
+
+const BaseCard: FC<BaseCardProps> = (props) => {
+  const classes = getStyles();
+  const { children, title, content, desc } = props;
+  return (
+    <div className={classes.root}>
+      <div className={classes.title}>{title}</div>
+      {content && <span className={classes.content}>{content}</span>}
+      {desc && <span className={classes.desc}>{desc}</span>}
+      {!content && !desc && (
+        <div className={classes.emptyRoot}>
+          <SvgIcon viewBox="0 0 101 26" component={ReactComponent} {...props} />
+          <span className={classes.emptyTitle}>no data available</span>
+          <span className={classes.emptyDesc}>There is no data to show you right now.</span>
+        </div>
+      )}
+      {children}
+    </div>
+  );
+};
+
+export default BaseCard;

+ 195 - 0
client/src/plugins/system/DataCard.tsx

@@ -0,0 +1,195 @@
+
+import { FC } from 'react';
+import { useTranslation } from 'react-i18next';
+import { makeStyles } from '@material-ui/core';
+import Progress from './Progress';
+import { getByteString } from '../../utils/Format';
+import { DataProgressProps, DataSectionProps, DataCardProps } from './Types';
+
+const getStyles = makeStyles(() => ({
+  root: {
+    backgroundColor: '#F6F6F6',
+    borderTopRightRadius: '8px',
+    borderBottomRightRadius: '8px',
+    height: '100%',
+    padding: '20px 16px',
+    boxSizing: 'border-box',
+  },
+
+  title: {
+    display: 'flex',
+    justifyContent: 'space-between',
+  },
+
+  content: {
+    color: '#010E29',
+    fontSize: '20px',
+    fontWeight: 600,
+    lineHeight: '36px',
+  },
+
+  desc: {
+    color: '#82838E',
+    fontSize: '14px',
+    lineHeight: '36px',
+    marginLeft: "8px",
+  },
+
+  rootName: {
+    color: '#82838E',
+    fontSize: '20px',
+    lineHeight: '24px',
+  },
+
+  childName: {
+    color: '#06AFF2',
+    fontSize: '20px',
+    lineHeight: '24px',
+  },
+
+  ip: {
+    color: '#010E29',
+    fontSize: '16px',
+    lineHeight: '24px',
+  },
+
+  sectionRoot: {
+    borderSpacing: '0 1px',
+    display: 'table',
+    marginTop: '24px',
+    width: '100%'
+  },
+
+  sectionRow: {
+    display: 'table-row',
+  },
+
+  sectionHeaderCell: {
+    display: 'table-cell',
+    color: '#82838E',
+    fontSize: '12px',
+    lineHeight: '24px',
+    padding: '8px 16px',
+    textTransform: 'uppercase',
+    width: '50%',
+  },
+
+  sectionCell: {
+    backgroundColor: 'white',
+    color: '#010E29',
+    display: 'table-cell',
+    fontSize: '14px',
+    lineHeight: '24px',
+    padding: '12px 16px',
+    textTransform: 'capitalize',
+    verticalAlign: 'middle',
+    width: '50%',
+  },
+  progressTitle: {
+    fontSize: '14px',
+    color: '#010E29',
+    lineHeight: '24px',
+    display: 'flex',
+    justifyContent: 'space-between',
+  }
+}));
+
+const DataSection: FC<DataSectionProps> = (props) => {
+  const classes = getStyles();
+  const { titles, contents } = props;
+
+  return (
+    <div className={classes.sectionRoot}>
+      <div className={classes.sectionRow}>
+        {titles.map((titleEntry) => <div key={titleEntry} className={classes.sectionHeaderCell}>{titleEntry}</div>)}
+      </div>
+      {contents.map((contentEntry) => {
+        return (
+          <div key={contentEntry.label} className={classes.sectionRow}>
+            <div className={classes.sectionCell}>
+              {contentEntry.label}
+            </div>
+            <div className={classes.sectionCell}>
+              {contentEntry.value}
+            </div>
+          </div>)
+      })}
+    </div>
+  );
+}
+
+const DataProgress: FC<DataProgressProps> = ({ percent = 0, desc = '' }) => {
+  const classes = getStyles();
+  return (
+    <div>
+      <div className={classes.progressTitle}>
+        <span>{`${Number(percent * 100).toFixed(2)}%`}</span>
+        <span>{desc}</span>
+      </div>
+      <Progress percent={percent} color='#06AFF2' />
+    </div>
+  )
+};
+
+const DataCard: FC<DataCardProps & React.HTMLAttributes<HTMLDivElement>> = (props) => {
+  const classes = getStyles();
+  const { t } = useTranslation('systemView');
+  const { t: commonTrans } = useTranslation();
+  const capacityTrans: { [key in string]: string } = commonTrans('capacity');
+  const { node, extend } = props;
+  const hardwareTitle = [t('hardwareTitle'), t('valueTitle')];
+  const hardwareContent = [];
+  const infos = node?.infos?.hardware_infos || {};
+
+  const {
+    cpu_core_count: cpu = 0,
+    cpu_core_usage: cpuUsage = 0,
+    memory = 1,
+    memory_usage: memoryUsage = 0,
+    disk = 1,
+    disk_usage: diskUsage = 0,
+  } = infos;
+
+  if (extend) {
+    hardwareContent.push({ label: t('thCPUCount'), value: cpu });
+    hardwareContent.push({
+      label: t('thCPUUsage'), value: <DataProgress percent={cpuUsage / 100} />
+    });
+    hardwareContent.push({
+      label: t('thMemUsage'), value: <DataProgress percent={memoryUsage / memory} desc={getByteString(memoryUsage, memory, capacityTrans)} />
+    });
+    hardwareContent.push({
+      label: t('thDiskUsage'), value: <DataProgress percent={diskUsage / disk} desc={getByteString(diskUsage, disk, capacityTrans)} />
+    });
+  }
+
+  const systemTitle = [t('systemTitle'), t('valueTitle')];
+  const systemContent = [];
+  const sysInfos = node?.infos?.system_info || {};
+  const {
+    system_version: version,
+    deploy_mode: mode = '',
+    created_time: create = '',
+    updated_time: update = '',
+  } = sysInfos;
+  systemContent.push({ label: t('thVersion'), value: version });
+  systemContent.push({ label: t('thDeployMode'), value: mode });
+  systemContent.push({ label: t('thCreateTime'), value: create });
+  systemContent.push({ label: t('thUpdateTime'), value: update });
+
+  return (
+    <div className={classes.root}>
+      <div className={classes.title}>
+        <div>
+          <span className={classes.rootName}>Milvus / </span>
+          <span className={classes.childName}>{node?.infos?.name}</span>
+        </div>
+        <div className={classes.ip}>{`${t('thIP')}:${infos?.ip || ''}`}</div>
+      </div>
+      {extend && <DataSection titles={hardwareTitle} contents={hardwareContent} />}
+      <DataSection titles={systemTitle} contents={systemContent} />
+    </div>
+  );
+};
+
+export default DataCard;

+ 125 - 0
client/src/plugins/system/LineChartCard.tsx

@@ -0,0 +1,125 @@
+
+import { FC, useState, useEffect, useRef } from 'react';
+import { makeStyles } from '@material-ui/core';
+import BaseCard from './BaseCard';
+import { LineChartCardProps, LinceChartNode } from './Types';
+
+const getStyles = makeStyles(() => ({
+  root: {
+    transform: 'scaleY(-1)',
+    maxWidth: '90%',
+  },
+  ycoord: {
+    cursor: 'pointer',
+    "&:hover, &:focus": {
+      "& line": {
+        transition: 'all .25s',
+        opacity: 1,
+      },
+    },
+
+    "&:hover": {
+      "& circle": {
+        fill: '#06AFF2',
+      },
+    },
+
+    "&:focus": {
+      outline: 'none',
+
+      "& circle": {
+        fill: '#06F3AF',
+      },
+    },
+  }
+}));
+
+const LineChartCard: FC<LineChartCardProps> = (props) => {
+
+  const FULL_HEIGHT = 60;
+  const FULL_WIDTH = 300;
+  const ROUND = 5;
+  const STEP = 25;
+
+  const classes = getStyles();
+  const { title, value } = props;
+  const [displayNodes, setDisplayNodes] = useState<LinceChartNode[]>([]);
+  const [currentNode, setCurrentNode] = useState<LinceChartNode>({
+    percent: 0,
+    value: 0,
+    timestamp: Date.now(),
+  });
+
+  const max = useRef(1);
+  const isHover = useRef(false);
+  const nodes = useRef<LinceChartNode[]>([]);
+
+  useEffect(() => {
+    // show at most 10 nodes. so remove the earliest node when nodes exceed 10
+    if (nodes.current.length > 9) {
+      nodes.current.shift();
+    }
+
+    if (value && max.current) {
+      // calculate the y-axis max scale
+      let currentMax = max.current;
+      if (value > max.current) {
+        const pow = Math.ceil(Math.log10(value));
+        currentMax = Math.pow(10, pow);
+        max.current = currentMax;
+      }
+
+      // generate a new node and save in ref
+      if (nodes.current) {
+        const newNodes = nodes.current.slice(0);
+        const newNode = {
+          percent: value / currentMax * 100,
+          value,
+          timestamp: Date.now(),
+        }
+        newNodes.push(newNode);
+        nodes.current = newNodes;
+
+        // refresh nodes for display when mouse is not hovering on the chart
+        if (!isHover.current) {
+          setDisplayNodes(newNodes);
+          setCurrentNode(newNode);
+        }
+      }
+    }
+  }, [value]);
+
+  return (
+    nodes.current.length ? (
+      <BaseCard title={title} content={`${Math.round(currentNode.value)}ms`} desc={new Date(currentNode.timestamp).toLocaleString()}>
+        <svg className={classes.root} onMouseEnter={() => isHover.current = true} onMouseLeave={() => isHover.current = false} width={FULL_WIDTH} height={FULL_HEIGHT} viewBox={`0 5 ${FULL_WIDTH} ${FULL_HEIGHT}`} fill="white" xmlns="http://www.w3.org/2000/svg">
+          {
+            displayNodes.map((node, index) => {
+              const x1 = FULL_WIDTH - (displayNodes.length - index + 1) * STEP;
+              const y1 = node.percent * .5 + ROUND * 2;
+
+              let line = null;
+              if (index < displayNodes.length - 1) {
+                const x2 = FULL_WIDTH - (displayNodes.length - index) * STEP;
+                const y2 = displayNodes[index + 1]['percent'] * .5 + ROUND * 2;
+                line = <line x1={x1} y1={y1} x2={x2} y2={y2} stroke="#06AFF2" />;
+              }
+              return (
+                <g key={`${node.value}${index}`}>
+                  {line}
+                  <g className={classes.ycoord} onMouseOver={() => { setCurrentNode(node) }}>
+                    <circle cx={x1} cy={y1} r={ROUND} fill="white" stroke="#06AFF2" />
+                    <rect opacity="0" x={x1 - ROUND} y={0} width={ROUND * 2} height={FULL_HEIGHT} fill="#E9E9ED" />
+                    <line opacity="0" x1={x1} y1={0} x2={x1} y2={FULL_WIDTH} strokeWidth="2" stroke="#06AFF2" strokeDasharray="2.5" />
+                  </g>
+                </g>
+              )
+            })
+          }
+        </svg>
+      </BaseCard >
+    ) : <BaseCard title={title} />
+  );
+};
+
+export default LineChartCard;

+ 86 - 0
client/src/plugins/system/MiniTopology.tsx

@@ -0,0 +1,86 @@
+import { FC } from 'react';
+import { makeStyles, Theme } from '@material-ui/core';
+import { MiniTopoProps } from './Types';
+
+const getStyles = makeStyles((theme: Theme) => ({
+  container: {
+    height: '100%',
+    width: 'auto',
+  },
+  childNode: {
+    transition: 'all .25s',
+    cursor: 'pointer',
+    transformOrigin: '50% 50%',
+    transformBox: 'fill-box',
+
+    '& circle': {
+      transition: 'all .25s',
+    },
+
+    '& text': {
+      transition: 'all .25s',
+    },
+
+    '&:hover, &:focus': {
+      transform: 'scale(1.1)',
+      filter: 'drop-shadow(3px 3px 5px rgba(0, 0, 0, .2))',
+    },
+
+    '&:focus': {
+      outline: 'none',
+
+      '& svg path': {
+        fill: 'white',
+      },
+
+      '& circle': {
+        fill: '#06AFF2',
+        stroke: '#06AFF2',
+      },
+
+      '& text': {
+        fill: 'white',
+      }
+    }
+  },
+}));
+
+const capitalize = (s: string) => {
+  return s.charAt(0).toUpperCase() + s.slice(1);
+}
+
+const MiniTopo: FC<MiniTopoProps> = (props) => {
+  const classes = getStyles();
+  const { selectedCord, selectedChildNode, setCord } = props;
+
+  const WIDTH = 400;                // width for svg
+  const HEIGHT = 400;               // height for svg
+  const LINE = 80;                // line lenght from lv2 node
+  const ANGLE = 10;                // angle offset for lv2 node
+  const R1 = 45;                    // root node radius
+  const R2 = 30;                    // lv1 node radius
+  const W3 = 20;                    // width of child rect
+
+  const childNodeCenterX = WIDTH / 2 + LINE * Math.cos(ANGLE * Math.PI / 180);
+  const childNodeCenterY = HEIGHT / 2 + LINE * Math.sin(ANGLE * Math.PI / 180);
+
+  return (
+    <svg className={classes.container} width={WIDTH} height={HEIGHT} viewBox={`0 0 ${WIDTH} ${HEIGHT}`} xmlns="http://www.w3.org/2000/svg">
+      <rect width="100%" height="100%" fill="white" />
+      <line x1={`${WIDTH / 3}`} y1={`${HEIGHT / 3}`} x2={childNodeCenterX} y2={childNodeCenterY} stroke="#06AFF2" />
+      <g className={classes.childNode} onClick={() => { setCord(null) }}>
+        <circle cx={`${WIDTH / 3}`} cy={`${HEIGHT / 3}`} r={R1} fill="white" stroke="#06AFF2" />
+        <text fontFamily="Roboto" alignmentBaseline="middle" textAnchor="middle" fill="#06AFF2" fontWeight="700" fontSize="12" x={`${WIDTH / 3}`} y={`${HEIGHT / 3}`}>{selectedCord ? capitalize(selectedCord.infos?.name) : ''}</text>
+      </g>
+      <g>
+        <svg width="60" height="60" viewBox="0 0 60 60" x={childNodeCenterX - 30} y={childNodeCenterY - 30}>
+          <circle cx={R2} cy={R2} r={R2} fill="#06AFF2" stroke="white" />
+          <rect className="selected" x={R2 - W3 / 2} y={R2 - W3 / 2} width={W3} height={W3} fill="white" />
+        </svg>
+        <text fontFamily="Roboto" textAnchor="middle" fill="#82838E" fontSize="12" x={childNodeCenterX} y={childNodeCenterY + 50}>{`${selectedChildNode ? selectedChildNode.infos?.name : ''}`}</text>
+      </g>
+    </svg >
+  );
+};
+
+export default MiniTopo;

+ 151 - 0
client/src/plugins/system/NodeListView.tsx

@@ -0,0 +1,151 @@
+import { FC, useState, useEffect } from 'react';
+import { useTranslation } from 'react-i18next';
+import { makeStyles, Theme } from '@material-ui/core';
+import KeyboardArrowDown from '@material-ui/icons/KeyboardArrowDown';
+import { DataGrid } from '@mui/x-data-grid';
+import { useNavigationHook } from '../../hooks/Navigation';
+import { ALL_ROUTER_TYPES } from '../../router/Types';
+import MiniTopo from './MiniTopology';
+import { getByteString } from '../../utils/Format';
+import DataCard from './DataCard';
+import { NodeListViewProps, Node } from './Types';
+
+const getStyles = makeStyles((theme: Theme) => ({
+  root: {
+    fontFamily: 'Roboto',
+    margin: '14px 40px',
+    display: 'grid',
+    gridTemplateColumns: 'auto 400px',
+    gridTemplateRows: '40px 400px auto',
+    gridTemplateAreas:
+      `"a a"
+       "b ."
+       "b d"`,
+    height: 'calc(100% - 28px)',
+  },
+  cardContainer: {
+    display: 'grid',
+    gap: '16px',
+    gridTemplateColumns: 'repeat(4, minmax(300px, 1fr))',
+  },
+  contentContainer: {
+    borderRadius: '8px',
+    boxShadow: '3px 3px 10px rgba(0, 0, 0, 0.05)',
+    display: 'grid',
+    marginTop: '14px',
+  },
+  childView: {
+    height: '100%',
+    width: '100%',
+    transition: 'all .25s',
+    position: 'absolute',
+    zIndex: 1000,
+    backgroundColor: 'white',
+  },
+  childCloseBtn: {
+    border: 0,
+    backgroundColor: 'white',
+    gridArea: 'a',
+    cursor: 'pointer',
+    width: '100%',
+  },
+  gridContainer: {
+    gridArea: 'b',
+    display: 'flex',
+  },
+  dataCard: {
+    gridArea: 'd',
+  }
+}));
+
+const NodeListView: FC<NodeListViewProps> = (props) => {
+  useNavigationHook(ALL_ROUTER_TYPES.SYSTEM);
+  const { t } = useTranslation('systemView');
+  const { t: commonTrans } = useTranslation();
+  const capacityTrans: { [key in string]: string } = commonTrans('capacity');
+
+  const classes = getStyles();
+  const [selectedChildNode, setSelectedChildNode] = useState<Node | undefined>();
+  const [rows, setRows] = useState<any[]>([]);
+  const { selectedCord, childNodes, setCord } = props;
+
+  let columns: any[] = [
+    {
+      field: 'name',
+      headerName: t('thName'),
+      flex: 1,
+    },
+    {
+      field: 'ip',
+      headerName: t('thIP'),
+      flex: 1,
+    },
+    {
+      field: 'cpuCore',
+      headerName: t('thCPUCount'),
+      flex: 1,
+    },
+    {
+      field: 'cpuUsage',
+      headerName: t('thCPUUsage'),
+      flex: 1,
+    },
+    {
+      field: 'diskUsage',
+      headerName: t('thDiskUsage'),
+      flex: 1,
+    },
+    {
+      field: 'memUsage',
+      headerName: t('thMemUsage'),
+      flex: 1,
+    },
+  ];
+
+  useEffect(() => {
+    if (selectedCord) {
+      const connectedIds = selectedCord.connected.map(node => node.connected_identifier);
+      const newRows: any[] = [];
+      childNodes.forEach(node => {
+        if (connectedIds.includes(node.identifier)) {
+          const dataRow = {
+            id: node?.identifier,
+            ip: node?.infos?.hardware_infos.ip,
+            cpuCore: node?.infos?.hardware_infos.cpu_core_count,
+            cpuUsage: node?.infos?.hardware_infos.cpu_core_usage,
+            diskUsage: getByteString(node?.infos?.hardware_infos.disk_usage, node?.infos?.hardware_infos.disk, capacityTrans),
+            memUsage: getByteString(node?.infos?.hardware_infos.memory_usage, node?.infos?.hardware_infos.memory, capacityTrans),
+            name: node?.infos?.name,
+          }
+          newRows.push(dataRow);
+        }
+      })
+      setRows(newRows);
+    }
+  }, [selectedCord, childNodes, capacityTrans]);
+
+  return (
+    <div className={classes.root}>
+      <button className={classes.childCloseBtn} onClick={() => setCord(null)}>
+        <KeyboardArrowDown />
+      </button>
+      <div className={classes.gridContainer}>
+        <DataGrid
+          rows={rows}
+          columns={columns}
+          hideFooterPagination
+          hideFooterSelectedRowCount
+          onRowClick={(rowData) => {
+            const selectedNode = childNodes.find(node => rowData.row.id === node.identifier);
+            setSelectedChildNode(selectedNode);
+          }}
+        />
+      </div>
+      <MiniTopo selectedCord={selectedCord} setCord={setCord} selectedChildNode={selectedChildNode} />
+      <DataCard className={classes.dataCard} node={selectedChildNode} />
+    </div>
+
+  );
+};
+
+export default NodeListView;

+ 30 - 0
client/src/plugins/system/Progress.tsx

@@ -0,0 +1,30 @@
+
+import { FC } from 'react';
+import { makeStyles } from '@material-ui/core';
+import { ProgressProps } from './Types';
+
+const getStyles = makeStyles(() => ({
+  root: {
+    height: 'auto',
+    transform: 'scaleY(-1)',
+    width: '100%',
+
+    "& line": {
+      transformOrigin: '10px 15px',
+    },
+  },
+}));
+
+const Progress: FC<ProgressProps> = (props) => {
+  const classes = getStyles();
+  const { percent = 0, color = '#06F3AF' } = props;
+
+  return (
+    <svg className={classes.root} width="300" height="30" viewBox="0 0 300 30" fill="none" xmlns="http://www.w3.org/2000/svg">
+      <line x1={10} y1={15} x2={290} y2={15} vectorEffect="non-scaling-stroke" strokeWidth="12" stroke="#AEAEBB" strokeLinecap="round" />
+      <line x1={10} y1={15} x2={290} y2={15} vectorEffect="non-scaling-stroke" transform={`scale(${percent}, 1)`} strokeWidth="12" stroke={color} strokeLinecap="round" />
+    </svg >
+  );
+};
+
+export default Progress;

+ 28 - 0
client/src/plugins/system/ProgressCard.tsx

@@ -0,0 +1,28 @@
+
+import { FC } from 'react';
+import { useTranslation } from 'react-i18next';
+import BaseCard from './BaseCard';
+import Progress from './Progress';
+import { getByteString } from '../../utils/Format';
+import { ProgressCardProps } from './Types';
+
+const color1 = '#06F3AF';
+const color2 = '#635DCE';
+
+const ProgressCard: FC<ProgressCardProps> = (props) => {
+  const { title, total, usage } = props;
+  const { t } = useTranslation('systemView');
+  const { t: commonTrans } = useTranslation();
+  const capacityTrans: { [key in string]: string } = commonTrans('capacity');
+
+  const color = title === t('diskTitle') ? color1 : color2;
+  const percent = (usage && total) ? (usage / total) : 0;
+
+  return (
+    <BaseCard title={title} content={`${getByteString(usage, total, capacityTrans)} (${Math.floor(percent * 100)}%)`}>
+      <Progress percent={percent} color={color} />
+    </BaseCard>
+  );
+};
+
+export default ProgressCard;

+ 154 - 0
client/src/plugins/system/SystemView.tsx

@@ -0,0 +1,154 @@
+import { useState, useEffect, useRef } from 'react';
+import { useTranslation } from 'react-i18next';
+import { makeStyles, Theme } from '@material-ui/core';
+import clsx from 'clsx';
+import { useNavigationHook } from '../../hooks/Navigation';
+import { ALL_ROUTER_TYPES } from '../../router/Types';
+import { MilvusHttp } from '../../http/Milvus';
+import { useInterval } from '../../hooks/SystemView';
+import Topo from './Topology';
+import NodeListView from './NodeListView';
+import LineChartCard from './LineChartCard';
+import ProgressCard from './ProgressCard';
+import DataCard from './DataCard';
+
+const getStyles = makeStyles((theme: Theme) => ({
+  root: {
+    fontFamily: 'Roboto',
+    margin: '14px 40px',
+    position: 'relative',
+    height: 'calc(100vh - 80px)',
+    display: 'flex',
+    flexDirection: 'column',
+  },
+  cardContainer: {
+    display: 'grid',
+    gap: '16px',
+    gridTemplateColumns: 'repeat(4, minmax(300px, 1fr))',
+  },
+  transparent: {
+    opacity: 0,
+    transition: 'opacity .5s',
+  },
+  contentContainer: {
+    borderRadius: '8px',
+    boxShadow: '3px 3px 10px rgba(0, 0, 0, 0.05)',
+    display: 'grid',
+    gridTemplateColumns: '1fr auto',
+    marginTop: '14px',
+    height: '100%',
+  },
+  childView: {
+    height: '100%',
+    width: '100%',
+    transition: 'all .25s',
+    position: 'absolute',
+    zIndex: 1000,
+    backgroundColor: 'white',
+  },
+  showChildView: {
+    top: 0,
+    maxHeight: 'auto',
+  },
+  hideChildView: {
+    top: '1000px',
+    maxHeight: 0,
+  },
+  childCloseBtn: {
+    border: 0,
+    backgroundColor: 'white',
+    width: '100%',
+  }
+}));
+
+
+const parseJson = (jsonData: any) => {
+  const nodes: any[] = [];
+  const childNodes: any[] = [];
+
+  const system = {
+    // qps: Math.random() * 1000,
+    letency: Math.random() * 1000,
+    disk: 0,
+    diskUsage: 0,
+    memory: 0,
+    memoryUsage: 0,
+  }
+
+  jsonData?.response?.nodes_info.forEach((node: any) => {
+    const type = node?.infos?.type;
+    // coordinator node
+    if (type.includes("Coord")) {
+      nodes.push(node);
+      // other nodes
+    } else {
+      childNodes.push(node);
+    }
+
+    const info = node.infos.hardware_infos;
+    system.memory += info.memory;
+    system.memoryUsage += info.memory_usage;
+    system.disk += info.disk;
+    system.diskUsage += info.disk_usage;
+  });
+  return { nodes, childNodes, system };
+}
+
+
+const SystemView: any = () => {
+  useNavigationHook(ALL_ROUTER_TYPES.SYSTEM);
+  const { t } = useTranslation('systemView');
+
+  const classes = getStyles();
+  const INTERVAL = 10000;
+
+  const [data, setData] = useState<{ nodes: any, childNodes: any, system: any }>({ nodes: [], childNodes: [], system: {} });
+  const [selectedNode, setNode] = useState<any>();
+  const [selectedCord, setCord] = useState<any>();
+  const { nodes, childNodes, system } = data;
+
+  useInterval(async () => {
+    if (!selectedCord) {
+      const res = await MilvusHttp.getMetrics();
+      setData(parseJson(res));
+    }
+  }, INTERVAL);
+
+  useEffect(() => {
+    async function fetchData() {
+      const res = await MilvusHttp.getMetrics();
+      setData(parseJson(res));
+    }
+    fetchData();
+  }, []);
+
+  let qps = system?.qps || 0;
+  const letency = system?.letency || 0;
+  const childView = useRef<HTMLInputElement>(null);
+
+  return (
+    <div className={classes.root}>
+      <div className={clsx(classes.cardContainer, selectedCord && classes.transparent)}>
+        <ProgressCard title={t('diskTitle')} usage={system.diskUsage} total={system.disk} />
+        <ProgressCard title={t('memoryTitle')} usage={system.memoryUsage} total={system.memory} />
+        <LineChartCard title={t('qpsTitle')} value={qps} />
+        <LineChartCard title={t('letencyTitle')} value={letency} />
+      </div>
+      <div className={classes.contentContainer}>
+        <Topo nodes={nodes} setNode={setNode} setCord={setCord} />
+        <DataCard node={selectedNode} extend />
+      </div>
+
+      <div
+        ref={childView}
+        className={clsx(classes.childView,
+          selectedCord ? classes.showChildView : classes.hideChildView)}
+      >
+        {selectedCord && (<NodeListView selectedCord={selectedCord} childNodes={childNodes} setCord={setCord} />)}
+      </div>
+    </div >
+
+  );
+};
+
+export default SystemView;

文件差異過大導致無法顯示
+ 178 - 0
client/src/plugins/system/Topology.tsx


+ 71 - 0
client/src/plugins/system/Types.ts

@@ -0,0 +1,71 @@
+import { ReactNode } from "react";
+
+export interface Node {
+  infos: {
+    hardware_infos: any,
+    system_info: any,
+    name: string,
+  },
+  connected: {
+    connected_identifier: number,
+  }[],
+  identifier: number,
+}
+
+export interface ProgressProps {
+  percent: number,
+  color: string,
+}
+
+export interface ProgressCardProps {
+  title: string,
+  total: number,
+  usage: number,
+}
+
+export interface BaseCardProps {
+  children?: ReactNode,
+  title: string,
+  content?: string,
+  desc?: string,
+}
+
+export interface LineChartCardProps {
+  title: string,
+  value: number,
+}
+
+export interface DataProgressProps {
+  percent: number,
+  desc?: string,
+}
+
+export interface DataSectionProps {
+  titles: string[],
+  contents: { label: string, value: string }[],
+}
+
+export interface DataCardProps {
+  node?: Node,
+  extend?: boolean
+}
+
+export interface LinceChartNode {
+  percent: number,
+  value: number,
+  timestamp: number,
+}
+
+type SetCord = (arg1: Node | null) => void;
+
+export interface MiniTopoProps {
+  selectedCord: Node,
+  selectedChildNode: Node | undefined,
+  setCord: SetCord,
+}
+
+export interface NodeListViewProps {
+  selectedCord: Node,
+  childNodes: Node[],
+  setCord: SetCord,
+}

+ 10 - 0
client/src/plugins/system/config.json

@@ -0,0 +1,10 @@
+{
+  "name": "system-view",
+  "version": "0.1.0",
+  "client": {
+    "path": "system",
+    "entry": "SystemView.tsx",
+    "label": "System View",
+    "iconName": "navSystem"
+  }
+}

+ 26 - 6
client/src/router/Config.ts

@@ -3,8 +3,10 @@ import Collections from '../pages/collections/Collections';
 import Connect from '../pages/connect/Connect';
 import Overview from '../pages/overview/Overview';
 import VectorSearch from '../pages/seach/VectorSearch';
-import SystemView from '../pages/system/SystemView';
 import { RouterConfigType } from './Types';
+import loadable from '@loadable/component';
+
+const PLUGIN_DEV = process.env.REACT_APP_PLUGIN_DEV;
 
 const RouterConfig: RouterConfigType[] = [
   {
@@ -32,11 +34,29 @@ const RouterConfig: RouterConfigType[] = [
     component: VectorSearch,
     auth: true,
   },
-  {
-    path: '/system',
-    component: SystemView,
-    auth: true,
-  },
 ];
 
+function importAll(r: any, outOfRoot = false) {
+  r.keys().forEach((key: any) => {
+    const content = r(key);
+    const dirName = key.split('/config.json').shift().split('/')[1];
+    const pathName = content.client?.path;
+    const fileEntry = content.client?.entry;
+    if (!pathName || !fileEntry) return;
+    // console.log(content);
+    const auth = content.client?.auth || false;
+    const OtherComponent = outOfRoot
+      ? loadable(() => import(`all_plugins/${dirName}/client/${fileEntry}`))
+      : loadable(() => import(`../plugins/${dirName}/${fileEntry}`));
+    RouterConfig.push({
+      path: `/${pathName}`,
+      component: OtherComponent,
+      auth,
+    });
+  });
+}
+importAll(require.context('../plugins/', true, /config\.json$/));
+PLUGIN_DEV &&
+  importAll(require.context('all_plugins/', true, /config\.json$/), true);
+
 export default RouterConfig;

+ 1 - 1
client/src/router/Types.ts

@@ -18,6 +18,6 @@ export type NavInfo = {
 
 export type RouterConfigType = {
   path: string;
-  component: () => JSX.Element;
+  component: any;
   auth: boolean;
 };

+ 11 - 2
client/tsconfig.json

@@ -1,7 +1,11 @@
 {
   "compilerOptions": {
     "target": "es5",
-    "lib": ["dom", "dom.iterable", "esnext"],
+    "lib": [
+      "dom",
+      "dom.iterable",
+      "esnext"
+    ],
     "allowJs": true,
     "skipLibCheck": true,
     "esModuleInterop": true,
@@ -16,5 +20,10 @@
     "noEmit": true,
     "jsx": "react-jsx"
   },
-  "include": ["src"]
+  "include": [
+    "src",
+    "../../src",
+    "all_plugins"
+  ],
+  "extends": "./tsconfig.paths.json"
 }

+ 8 - 0
client/tsconfig.paths.json

@@ -0,0 +1,8 @@
+{
+  "compilerOptions": {
+    "baseUrl": ".",
+    "paths": {
+      "all_plugins/*": ["src/plugins"]
+    }
+  }
+}

文件差異過大導致無法顯示
+ 479 - 473
client/yarn.lock


+ 3 - 2
express/package.json

@@ -30,8 +30,9 @@
     "build": "yarn clean && tsc",
     "prestart": "yarn build",
     "start": "node .",
+    "start:plugin": "yarn build && cross-env PLUGIN_DEV=1 node dist/milvus-insight/express/src/app.js",
+    "start:prod": "node dist/app.js",
     "test": "echo \"Error: no test specified\" && exit 1",
-    "clean": "rimraf dist",
-    "start:plugin": "yarn build && cross-env PLUGIN_DEV=1 node dist/milvus-insight/express/src/app.js"
+    "clean": "rimraf dist"
   }
 }

+ 10 - 1
express/src/app.ts

@@ -1,4 +1,4 @@
-import express  from "express";
+import express from "express";
 import cors from "cors";
 import helmet from "helmet";
 import * as http from "http";
@@ -15,6 +15,7 @@ import {
   ErrorInterceptor,
 } from "./interceptors";
 import { getDirectories, generateCfgs } from "./utils";
+import * as path from "path";
 
 const PLUGIN_DEV = process.env?.PLUGIN_DEV;
 const SRC_PLUGIN_DIR = "src/plugins";
@@ -108,6 +109,14 @@ getDirectories(SRC_PLUGIN_DIR, async (dirErr: Error, dirRes: [string]) => {
   app.use("/api/v1", router);
   app.use("/api/plugins", pluginsRouter);
 
+  // Return client build files
+  app.use(express.static("build"));
+  // handle every other route with index.html, which will contain
+  // a script tag to your application's JavaScript file(s).
+  app.get("*", (request, response) => {
+    response.sendFile(path.join(__dirname, "../build/index.html"));
+  });
+
   // ErrorInterceptor
   app.use(ErrorInterceptor);
   // start server

部分文件因文件數量過多而無法顯示