隨著大數據技術的演進和信息安全性需求的提升,數據規模的持續擴張為數據運維工作帶來了嚴峻考驗。面對海量數據所形成的繁重管理壓力,運維人員面臨效率瓶頸,而不斷攀升的人力成本也使得單純依賴擴充運維團隊來解決問題變得不再實際可行。 由此可見,智能化與高效便捷是運維發展的必然方向。袋鼠雲所推出的巡檢報告功能, ...
隨著大數據技術的演進和信息安全性需求的提升,數據規模的持續擴張為數據運維工作帶來了嚴峻考驗。面對海量數據所形成的繁重管理壓力,運維人員面臨效率瓶頸,而不斷攀升的人力成本也使得單純依賴擴充運維團隊來解決問題變得不再實際可行。
由此可見,智能化與高效便捷是運維發展的必然方向。袋鼠雲所推出的巡檢報告功能,正是為了順應這一目標,致力於提供優化的解決方案。
什麼是巡檢報告?
巡檢報告是指對某一個系統或設備進行全面檢查,並把檢查結果及建議整理成報告的過程。巡檢報告通常用於評估系統或設備的運行狀況與性能,為發現問題、優化系統、提高效率、降低故障率等方面提供參考。
本文將詳細闡述巡檢報告的各項功能特性和其實現方案,為有此類需求的用戶提供實用的參考依據。
巡檢報告實現功能
● 自定義佈局
· 報告中的面板可進行拖拽改變佈局
· 在拖拽的過程中限制拖拽區域,只允許在同一父級內進行拖拽,不允許跨目錄移動,不允許改變目錄的級別,比如把一級目錄移動到另一個一級目錄內,變成二級目錄
● 目錄可收縮展開
· 目錄支持收縮展開,收縮時隱藏所有子面板,展開時顯示所有子面板
· 移動目錄時,子面板跟隨移動
· 改變目錄後,同步更新右側的目錄面板
· 生成目錄編號
● 右側目錄樹
· 生成目錄編號
· 支持錨點滾動
· 支持展開收縮
· 與左側報告聯動
● 數據面板
· 根據日期範圍獲取指標數據
· 通過圖表的形式展示指標信息
· 查看詳情,刪除
· 各面板的請求設計,支持刷新請求
● 面板導入
· 統計目錄下選擇的面板數量
· 導入新面板時,不能破壞已有佈局,新面板只能跟在舊面板後
· 導入已有面板時,需要進行數據比較,有數據變更需要重新獲取最新的數據
● 保存
在保存前,所有影響佈局相關的操作,都是臨時的,包括導入面板。只有在點擊保存後,才會把當前數據提交給後端進行保存。
● 支持 pdf 和 word 導出
巡檢報告實現方案
那麼,這一套巡檢報告功能究竟是如何實現的呢?下麵將從數據結構設計、組件設計、目錄、面板等方面進行逐一介紹。
數據結構設計
先看看使用扁平結構下的圖示:
在扁平結構下,確定子項只需要找到下一個 row 面板,對於多級目錄下也是同理,只是對一級目錄需要額外處理。
扁平結構雖然實現起來較為簡單,但為了滿足特定需求,即限制目錄的拖拽。限制目錄需要一個比較清晰的面板層級關係,很顯然,樹狀數據結構能夠非常貼切且清晰地描述一個數據的層級結構。
組件設計
與傳統組件編程有所區別。在實現上對渲染和數據處理進行了分離,分為兩塊:
· React 組件:主要負責頁面渲染
· Class : 負責數據的處理
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 [_, setValue] = 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[newPos.i!].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;
}
});
}
// ...
}
● 面板拖動範圍
目前的拖動範圍是整個儀錶盤,可隨意拖動,綠色是儀錶盤可拖拽區域,灰色為面板。如下:
如果需要限制就需要改成如下圖的結構:
在原本的基礎上,以目錄為單位區分,綠色為整體的可移動區域,黃色為一級目錄塊,可在綠色區域拖動,拖動時以整個黃色塊進行拖動,紫色為二級目錄塊,可在當前黃色區域內拖動,不可脫離當前黃色塊,灰色的面板只能在當前目錄下拖動。
需要在原先數據結構基礎上進行改造:
class PanelModel {
dashboard?: DashboardModel; // 當前目錄下的 dashboard
// ...
}
● 面板的導入設計
後端返回的數據是一顆有著三級層級的樹,我們拿到後,在數據上維護成 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;
● 面板刷新
從根節點向下查找,找到葉子節點,在觸發對應的請求。
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([panel]);
// 冒泡父容器,減少的高度
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: { [str: string]: 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[property] && this.hasOwnProperty(property)) {
model[property] = cloneDeep(this[property]);
}
}
model.panels = this.dashboard?.getSaveModel() ?? [];
return model;
}
// ...
}
● 面板詳情展示
對面板進行查看時,可修改時間等,這些操作會影響到實例中的數據,需要對原數據與詳情中的數據進行區分。
通過對原面板數據的重新生成一個 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} />}
對目錄進行收縮展開時,會改變自身的高度,現在還需要把這個改變的高度同步給上一級的儀錶盤。
上一級需要做的就是類似我們控制目錄的處理。如下,控制第一個二級目錄收縮:
當面板發生變更時,需要通知上級面板,進行對應的操作。
增加一個 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[panelIndex].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。展開收縮同理。
● 右側目錄渲染
錨點/序號
· 錨點採用 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