西河刘卡车医 发表于 2024-5-13 04:45:51

让运维无忧,实战解析巡检报告功能实现方案

随着大数据技能的演进和信息安全性需求的提升,数据规模的连续扩张为数据运维工作带来了严峻磨练。面对海量数据所形成的繁重管理压力,运维人员面临效率瓶颈,而不断攀升的人力成本也使得单纯依赖扩充运维团队来办理题目变得不再实际可行。
由此可见,智能化与高效便捷是运维发展的必然方向。袋鼠云所推出的巡检报告功能,正是为了顺应这一目的,致力于提供优化的办理方案。
什么是巡检报告?
巡检报告是指对某一个体系或设备举行全面查抄,并把查抄结果及发起整理成报告的过程。巡检报告通常用于评估体系或设备的运行状况与性能,为发现题目、优化体系、提高效率、低落故障率等方面提供参考。
https://img2024.cnblogs.com/other/2317299/202403/2317299-20240306174051565-839012251.png
本文将详细阐述巡检报告的各项功能特性和其实现方案,为有此类需求的用户提供实用的参考依据。
巡检报告实现功能

● 自定义布局
· 报告中的面板可举行拖拽改变布局
· 在拖拽的过程中限定拖拽区域,只答应在同一父级内举行拖拽,不答应跨目录移动,不答应改变目录的级别,比如把一级目录移动到另一个一级目录内,变成二级目录
● 目录可收缩展开
· 目录支持收缩展开,收缩时隐藏全部子面板,展开时表现全部子面板
· 移动目录时,子面板跟随移动
· 改变目录后,同步更新右侧的目录面板
· 生成目录编号
https://img2024.cnblogs.com/other/2317299/202403/2317299-20240306174051796-1934751803.png
● 右侧目录树
· 生成目录编号
· 支持锚点滚动
· 支持展开收缩
· 与左侧报告联动
https://img2024.cnblogs.com/other/2317299/202403/2317299-20240306174052032-2076499952.png
● 数据面板
· 根据日期范围获取指标数据
· 通过图表的形式展示指标信息
· 查看详情,删除
· 各面板的哀求设计,支持刷新哀求
https://img2024.cnblogs.com/other/2317299/202403/2317299-20240306174052222-771587140.png
https://img2024.cnblogs.com/other/2317299/202403/2317299-20240306174052459-1392738363.png
● 面板导入
· 统计目录下选择的面板数量
· 导入新面板时,不能粉碎已有布局,新面板只能跟在旧面板后
· 导入已有面板时,需要举行数据比较,有数据变更需要重新获取最新的数据
https://img2024.cnblogs.com/other/2317299/202403/2317299-20240306174052749-931624269.png
● 保存
在保存前,全部影响布局相关的利用,都是临时的,包括导入面板。只有在点击保存后,才会把当前数据提交给后端举行保存。
● 支持 pdf 和 word 导出
https://img2024.cnblogs.com/other/2317299/202403/2317299-20240306174053022-1962822395.png
巡检报告实现方案

那么,这一套巡检报告功能毕竟是如何实现的呢?下面将从数据布局设计、组件设计、目录、面板等方面举行逐一先容。
数据布局设计

先看看使用扁平布局下的图示:
https://img2024.cnblogs.com/other/2317299/202403/2317299-20240306174053276-1326494862.png
在扁平布局下,确定子项只需要找到下一个 row 面板,对于多级目录下也是同理,只是对一级目录需要额外处置惩罚。
扁平布局固然实现起来较为简单,但为了满足特定需求,即限定目录的拖拽。限定目录需要一个比较清楚的面板层级关系,很显然,树状数据布局能够非常贴切且清楚地描述一个数据的层级布局。
https://img2024.cnblogs.com/other/2317299/202403/2317299-20240306174053529-1615007387.png
组件设计

与传统组件编程有所区别。在实现上对渲染和数据处置惩罚举行了分离,分为两块:
· React 组件:重要负责页面渲染
· Class : 负责数据的处置惩罚
https://img2024.cnblogs.com/other/2317299/202403/2317299-20240306174053745-682839233.png
DashboardModel
class DashboardModel {
    id: string | number;
    panels: PanelModel[]; // 各个面板
    // ...
}PanelModel
class PanelModel {
    key?: string;
    id!: number;
    gridPos!: GridPos; // 位置信息
    title?: string;
    type: string;
    panels: PanelModel[]; // 目录面板需要维护当前目录下的面板信息
    // ...
}每一个 Dashboard 组件对应一个 DashboardModel,每一个 Panel 组件对应一个 PanelModel。
React 组建根据类实例中的数据举行渲染。实例生产后,不会容易的销毁,或者改变引用地址,这让依赖实例数据举行渲染的 React 组件无法触发更新渲染。
需要一个方式,在实例内数据发生改变后,由我们手动触发组件的更新渲染。
● 组件渲染控制
由于我们之前接纳的是 Hooks 组件,不像 Class 组件可以通过调用 forceUpdate 方法触发组件。
而在 react18 中有一个新特性 useSyncExternalStore,可以让我们订阅外部的数据,如果数据发生改变了,会触发组件的渲染。
实际上 useSyncExternalStore 触发组件渲染的原理就是在内部维护了一个 state,当更改了 state 值,则引起了外部组件的渲染。
基于这个思路我们简单的实现了一个能够触发组件渲染的 useForceUpdate 方法。
export function useForceUpdate() {
    const = useState(0);
    return debounce(() => setValue((prevState) => prevState + 1), 0);
}虽说实现了 useForceUpdate,但是在实际使用的过程中,还需要在组件销毁时移除事件。而 useSyncExternalStore 已经内部已经实现了,直接使用即可。
useSyncExternalStore(dashboard?.subscribe ?? (() => {}), dashboard?.getSnapshot ?? (() => 0));

useSyncExternalStore(panel?.subscribe ?? (() => {}), panel?.getSnapshot ?? (() => 0));根据 useSyncExternalStore 使用,分别添加了 subscribe 和 getSnapshot 方法。
class DashboardModel {// PanelModel 一样
    count = 0;

    forceUpdate() {
      this.count += 1;
      eventEmitter.emit(this.key);
    }

    /**
   * useSyncExternalStore 的第一个入参,执行 listener 可以触发组件的重渲染
   * @param listener
   * @returns
   */
    subscribe = (listener: () => void) => {
      eventEmitter.on(this.key, listener);
      return () => {
            eventEmitter.off(this.key, listener);
      };
    };

    /**
   * useSyncExternalStore 的第二个入参,count 在这里改变后触发diff的通过。
   * @param listener
   * @returns
   */
    getSnapshot = () => {
      return this.count;
    };
}当改变数据后,需要触发组件的渲染,只需要执行 forceUpdate 即可。
面板

● 面板拖拽
市面上比较大众的拖拽插件有以下几个:
· react-beautiful-dnd
· react-dnd
· react-grid-layout
经过比较后,发现 react-grid-layout 非常适实用来做面板的拖拽功能。react-grid-layout 自己使用简单,根本无上手门槛,最终决定使用 react-grid-layout。详细说明可以查看该链接:https://github.com/react-grid-layout/react-grid-layout
在面板布局改变后触发 react-grid-layout 的 onLayoutChange 方法,可以拿到布局后的全部面板最新的位置数据。
const onLayoutChange = (newLayout: ReactGridLayout.Layout[]) => {
    for (const newPos of newLayout) {
      panelMap.updateGridPos(newPos);
    }
    dashboard!.sortPanelsByGridPos();
};PanelMap 是一个 map,key 为 Panel.key, value 为面板,是在我们组件渲染时就已经准备好了。
const panelMap: Record<PanelModel['key'], PanelModel> = {};要更新面板布局数据,可通过 PanelMap 准确定位到相应的面板,并进一步调用其 updateGridPos 方法执行布局更新利用。
到这,我们只是完成了面板自己数据更新,还需要执行仪表盘的 sortPanelsByGridPos 方法,对全部的面板举行排序。
class DashboardModel {
    sortPanelsByGridPos() {
      this.panels.sort((panelA, panelB) => {
            if (panelA.gridPos.y === panelB.gridPos.y) {
                return panelA.gridPos.x - panelB.gridPos.x;
            } else {
                return panelA.gridPos.y - panelB.gridPos.y;
            }
      });
    }
    // ...
}● 面板拖动范围
现在的拖动范围是整个仪表盘,可随意拖动,绿色是仪表盘可拖拽区域,灰色为面板。如下:
https://img2024.cnblogs.com/other/2317299/202403/2317299-20240306174053946-936046679.png
如果需要限定就需要改成如下图的布局:
https://img2024.cnblogs.com/other/2317299/202403/2317299-20240306174054149-298919925.png
在本来的基础上,以目录为单位区分,绿色为整体的可移动区域,黄色为一级目录块,可在绿色区域拖动,拖动时以整个黄色块举行拖动,紫色为二级目录块,可在当前黄色区域内拖动,不可脱离当前黄色块,灰色的面板只能在当前目录下拖动。
需要在原先数据布局基础上举行改造:
https://img2024.cnblogs.com/other/2317299/202403/2317299-20240306174054366-617333074.png
class PanelModel {
    dashboard?: DashboardModel; // 当前目录下的 dashboard
    // ...
}● 面板的导入设计
https://img2024.cnblogs.com/other/2317299/202403/2317299-20240306174054612-1056111457.png
后端返回的数据是一颗有着三级层级的树,我们拿到后,在数据上维护成 ModuleMap, DashboardMap 和 PanelMap 3个Map。
import { createContext } from 'react';

export interface Module { // 一级目录
    key: string;
    label: string;
    dashboards?: string[];
    sub_module?: Dashboard[];
}

export interface Dashboard { // 二级目录
    key: string;
    dashboard_key: string;
    label: string;
    panels?: number[];
    selectPanels?: number[];
    metrics?: Panel[];
}

export interface Panel {
    expr: Expr[]; // 数据源语句信息
    label: string;
    panel_id: number;
}

type Expr = {
    expr: string;
    legendFormat: string;
};

export const DashboardContext = createContext({
    moduleMap: new Map<string, Module>(),
    dashboardMap: new Map<string, Dashboard>(),
    panelMap: new Map<number, Panel>(),
});我们在渲染模块时,遍历 ModuleMap ,并通过 Module 内的 dashboards 信息找到二级目录。
在交互上设置一级目录不可选中,当选中二级目录时,通过二级目录 Dashboard 的 panels 找到相关的面板渲染到右侧区域。
对于这3个 Map 的利用,维护在 useHandleData 中,导出:
{
    ...map, // moduleMap、dashboardMap、panelMap
    getData, // 生成巡检报告的数据结构
    init: initData, // 初始化 Map
}● 面板选中回填
在进入面板管理时,需要回填已选中的面板,我们可以通过 getSaveModel获取到当前巡检报告的信息,把对应的选中信息存放到 selectPanels 中。
现在我们只需要改变 selectPanels 中的值,就可以做到对应面板的选中。
● 面板选中重置
直接遍历 DashboardMap,并把每个 selectPanels 重置。
dashboardMap.forEach((dashboard) => {
    dashboard.selectPanels = [];
});● 面板插入
在我们选中面板后,对选中面板举行插入时,有几种情况:
· 巡检报告本来存在的面板,这次也选中,在插入时会比较数据,如果数据发生改变,需要根据最新的数据源信息举行哀求,并渲染
· 巡检报告本来存在的面板,这次未选中,在插入时,需要删除掉未选中的面板
· 新选中的面板,在插入时,在对应目录的末尾举行插入
添加新面板需要,与目录收缩雷同,不同的是:
· 目录收缩针对的只有一个目录,而插入在针对的是整体
· 目录收缩是直接从子节点开始向上冒泡,而插入是先从根节点开始向下插入,插入完成后在根据最新的目录数据,更新一遍布局
class DashboardModel {
    update(panels: PanelData[]) {
      this.updatePanels(panels); // 更新面板
      this.resetDashboardGridPos(); // 重新布局
      this.forceUpdate();
    }

    /**
   * 以当前与传入的进行对比,以传入的数据为准,并在当前的顺序上进行修改
   * @param panels
   */
    updatePanels(panels: PanelData[]) {
      const panelMap = new Map();
      panels.forEach((panel) => panelMap.set(panel.id, panel));

      this.panels = this.panels.filter((panel) => {
            if (panelMap.has(panel.id)) {
                panel.update(panelMap.get(panel.id));
                panelMap.delete(panel.id);
                return true;
            }
            return false;
      });

      panelMap.forEach((panel) => {
            this.addPanel(panel);
      });
    }

    addPanel(panelData: any) {
      this.panels = [...this.panels, new PanelModel({ ...panelData, top: this })];
    }

    resetDashboardGridPos(panels: PanelModel[] = this.panels) {
      let sumH = 0;
      panels?.forEach((panel: any | PanelModel) => {
            let h = ROW_HEIGHT;
            if (isRowPanel(panel)) {
                h += this.resetDashboardGridPos(panel.dashboard.panels);
            } else {
                h = panel.getHeight();
            }

            const gridPos = {
                ...panel.gridPos,
                y: sumH,
                h,
            };
            panel.updateGridPos({ ...gridPos });
            sumH += h;
      });

      return sumH;
    }
}

class PanelModel {
    /**
   * 更新
   * @param panel
   */
    update(panel: PanelData) {
      // 数据源语句发生变化需要重新获取数据
      if (this.target !== panel.target) {
            this.needRequest = true;
      }

      this.restoreModel(panel);

      if (this.dashboard) {
            this.dashboard.updatePanels(panel.panels ?? []);
      }

      this.needRequest && this.forceUpdate();
    }
}● 面板哀求
needRequest 控制面板是否需要举行哀求,如果为 true 在面板下一次举行渲染时,会举行哀求,哀求的处置惩罚也放在了 PanelModel 中。
import { Params, params as fetchParams } from '../../components/useParams';

class PanelModel {
    target: string; // 数据源信息

    getParams() {
      return {
            targets: this.target,
            ...fetchParams,
      } as Params;
    }

    request = () => {
      if (!this.needRequest) return;
      this.fetchData(this.getParams());
    };

    fetchData = async (params: Params) => {
      const data = await this.fetch(params);
      this.data = data;
      this.needRequest = false;
      this.forceUpdate();
    };
   
    fetch = async (params: Params) => { /* ... */ }
}我们数据渲染组件一般层级较深,而哀求时会需要时间区间等外部参数,对于这部门参数接纳全局变量的方式,用 useParams 举行维护。上层组件使用 change 修改参数,数据渲染组件根据抛出的 params 举行哀求。
export let params: Params = {
    decimal: 1,
    unit: null,
};

function useParams() {
    const change = (next: (() => Params) | Params) => {
      if (typeof next === 'function') params = next();
      params = { ...params, ...next } as Params;
    };

    return { params, change };
}

export default useParams;● 面板刷新
从根节点向下查找,找到叶子节点,在触发对应的哀求。
https://img2024.cnblogs.com/other/2317299/202403/2317299-20240306174054852-393828142.png
class DashboardModel {
    /**
   * 刷新子面板
   */
    reloadPanels() {
      this.panels.forEach((panel) => {
            panel.reload();
      });
    }
}

class PanelModel {
    /**
   * 刷新
   */
    reload() {
      if (isRowPanel(this)) {
            this.dashboard.reloadPanels();
      } else {
            this.reRequest();
      }
    }

    reRequest() {
      this.needRequest = true;
      this.request();
    }
}● 面板的删除
对于面板的删除,我们只需要在对应的 Dashboard 下举行移除,删除后会改变当前 Dashboard 高度,这块的处置惩罚与下文的目录收缩一致。
class DashboardModel {
    /**
   * @param panel 删除的面板
   */
    removePanel(panel: PanelModel) {
      this.panels = this.filterPanelsByPanels();

      // 冒泡父容器,减少的高度
      const h = -panel.gridPos.h;
      this.top?.changeHeight(h);

      this.forceUpdate();
    }

    /**
   * 根据传入的面板进行过滤
   * @param panels 需要过滤的面板数组
   * @returns 过滤后的面板
   */
    filterPanelsByPanels(panels: PanelModel[]) {
      return this.panels.filter((panel) => !panels.includes(panel));
    }
    // ...
}● 面板的保存
与后端沟通后,当前巡检报告数据布局由前端自主维护,最终给后端一个字符串就好。获取到现在的面板数据,用 JSON 举行转换即可。
面板的信息获取过程,先从根节点出发,遍历至叶子结点,再从叶子结点开始,一层层向上举行返回,也就是回溯的过程。
class DashboardModel {
    /**
   * 获取所有面板数据
   * @returns
   */
    getSaveModel() {
      const panels: PanelData[] = this.panels.map((panel) => panel.getSaveModel());
      return panels;
    }
    // ...
}

// 最终保存时所需要的属性,其他的都不需要
const persistedProperties: { : boolean } = {
    id: true,
    title: true,
    type: true,
    gridPos: true,
    collapsed: true,
    target: true,
};

class PanelModel {
    /**
   * 获取所有面板数据
   * @returns
   */
    getSaveModel() {
      const model: any = {};

      for (const property in this) {
            if (persistedProperties && this.hasOwnProperty(property)) {
                model = cloneDeep(this);
            }
      }
      model.panels = this.dashboard?.getSaveModel() ?? [];

      return model;
    }
    // ...
}● 面板详情展示
https://img2024.cnblogs.com/other/2317299/202403/2317299-20240306174055130-151535235.png
对面板举行查看时,可修改时间等,这些利用会影响到实例中的数据,需要对原数据与详情中的数据举行区分。
通过对原面板数据的重新生成一个 PanelModel 实例,对这个实例举行任意利用,都不会影响到原数据。
const model = panel.getSaveModel();
const newPanel = new PanelModel({ ...model, top: panel.top }); // 创建一个新的实例
setEditPanel(newPanel); // 设置为详情在 dom 上,详情页面是接纳绝对定位,覆盖着巡检报告。
目录

● 目录收缩展开
为目录面板维护一个 collapsed 属性用来控制面板的隐藏表现。
class PanelModel {
    collapsed?: boolean; // type = row
    // ...
}

// 组件渲染
{!collapsed && <DashBoard dashboard={panel.dashboard} serialNumber={serialNumber} />}对目录举行收缩展开时,会改变自身的高度,现在还需要把这个改变的高度同步给上一级的仪表盘。
上一级需要做的就是雷同我们控制目录的处置惩罚。如下,控制第一个二级目录收缩:
https://img2024.cnblogs.com/other/2317299/202403/2317299-20240306174055375-364276042.png
当面板发生变更时,需要关照上级面板,举行对应的利用。
https://img2024.cnblogs.com/other/2317299/202403/2317299-20240306174055611-772520785.png
增加一个 top 用来获取到父级实例。
class DashboardModel {
    top?: null | PanelModel; // 最近的 panel 面板

    /**
   * 面板高度变更,同步修改其他面板进行对应高度 Y 轴的变更
   * @param row 变更高度的 row 面板
   * @param h 变更高度
   */
    togglePanelHeight(row: PanelModel, h: number) {
      const rowIndex = this.getIndexById(row.id);

      for (let panelIndex = rowIndex + 1; panelIndex < this.panels.length; panelIndex++) {
            this.panels.gridPos.y += h;
      }
      this.panels = [...this.panels];

      // 顶级 dashBoard 容器没有 top
      this.top?.changeHeight(h);
      this.forceUpdate();
    }
    // ...
}

class PanelModel {
    top: DashboardModel; // 最近的 dashboard 面板

    /**
   * @returns h 展开收起影响的高度
   */
    toggleRow() {
      this.collapsed = !this.collapsed;
      let h = this.dashboard?.getHeight();
      h = this.collapsed ? -h : h;
      this.changeHeight(h);
    }

    /**
   *
   * @param h 变更的高度
   */
    changeHeight(h: number) {
      this.updateGridPos({ ...this.gridPos, h: this.gridPos.h + h }); // 更改自身面板的高度
      this.top.togglePanelHeight(this, h); // 触发父级变更
      this.forceUpdate();
    }
    // ...
}整理流程与冒泡类型,不停到最顶级的 Dashboard。展开收缩同理。
https://img2024.cnblogs.com/other/2317299/202403/2317299-20240306174055828-550899070.png
● 右侧目录渲染
锚点/序号
· 锚点接纳 Anchor + id 选中组件
· 序号根据每次渲染举行生成
接纳发布订阅管理渲染
每当仪表盘改变布局的动作时,右侧目录就需要举行同步更新,而任意一个面板都有可能需要触发右侧目录的更新。
如果我们接纳实例内维护对应组件的渲染事件,有两个题目:
· 需要举行区分,比如刷新面板时,不需要触发右侧目录的渲染
· 每个面板如何订阅右侧目录的渲染事件
最终接纳了发布订阅者模式,对事件举行管理。
class EventEmitter {
    list: Record<string, any[]> = {};

    /**
   * 订阅
   * @param event 订阅事件
   * @param fn 订阅事件回调
   * @returns
   */
    on(event: string, fn: () => void) {}

    /**
   * 取消订阅
   * @param event 订阅事件
   * @param fn 订阅事件回调
   * @returns
   */
    off(event: string, fn: () => void) {}

    /**
   * 发布
   * @param event 订阅事件
   * @param arg 额外参数
   * @returns
   */
    emit(event: string, ...arg: any[]) {
}
eventEmitter.emit(this.key); // 触发面板的订阅事件

eventEmitter.emit(GLOBAL); // 触发顶级订阅事件,就包括右侧目录的更新pdf/word 导出

pdf 导出由 html2Canvas + jsPDF 实现。需要注意的是,当图片过长 pdf 会对图片举行切分,有可能出现切分的是内容区域的情况。需要手动计算面板的高度,是否超出当前文档,如果超出需要我们提前举行分割,添加到下一页中,尽可能把目录面板和数据面板一块切分。
word 导出由 html-docx-js 实现, 需要保留目录的布局,并可以在面板下添加总结,这就需要我们分别对每一个面板举行图片的转换。
实现的思路是根据 panels 遍历,找到目录面板就是用 h1、h2 标签插入,如果是数据面板,在数据面板中维护一个 ref 的属性,能让我们拿到当前面板的 dom 信息,根据这个举行图片转换,并为 base64 的格式(word 只支持 base64 的图片插入)。
写在最后

当前版本的巡检报告尚处于初级阶段,并非最终形态,随着后续的迭代升级,我们将逐步添加包括总结说明在内的多项功能。
接纳现在方式实现后,未来若需举行 UI 界面调解时,只需针对性地修改相关 UI 组件即可,比方新增饼图、表格等内容。而在数据交互层面的改动,则仅需进入 DashboardModel 和 PanelModel 中举行必要的更新。别的,针对特定场景,我们还可以灵活抽离出专用类来举行处置惩罚,以确保整个迭代过程更加模块化和高效化。
《数栈产品白皮书》下载地址:https://www.dtstack.com/resources/1004?src=szsm
《数据管理行业实践白皮书》下载地址:https://www.dtstack.com/resources/1001?src=szsm
想了解或咨询更多有关大数据产品、行业办理方案、客户案例的朋友,浏览袋鼠云官网:https://www.dtstack.com/?src=szbky

免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。
页: [1]
查看完整版本: 让运维无忧,实战解析巡检报告功能实现方案