iT邦幫忙

2024 iThome 鐵人賽

DAY 29
2
Modern Web

論前端工程師如何靠 Grafana 吃飯:從 Grafana App 到前端可觀測性系列 第 29

靠 Grafana 吃飯的第二十九天 - 克隆 Grafana Cloud 前端可觀測性平台 - 挑戰篇

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20241013/20152073artLgxqCmh.png

前言

在上一篇文章中,我們了解了一個可觀測性平台的單一頁面是如何建置的。然而,我們在 Layout 中觀察到除了 Overview 頁面外,還存在 Errors、Sessions 頁面,或是其他因團隊需求而新增的頁面。這些頁面通常都是總覽性的。如果希望針對特定的 Page、Session ID 或 Error 進行檢視,就需要透過動態頁面來實現。

在實作過程中,我們發現跨頁同步是克隆應用程式時需要克服的一個挑戰。因此,本篇文章將針對單一頁面的建置進行實作,並分享一些實作過程中的挑戰及解決方案,供有相同需求的讀者參考。

跨頁同步挑戰

如果說單個總覽頁面最具挑戰性的部分是客製化的圖表 Panel 和數據資料的轉換,那麼跨頁面和動態頁面的挑戰就是資料同步,例如時間或是 id 等其他變數的同步。在了解這個難題及解決方法之前,我們可以先羅列哪些情況會出現資料同步的需求:

  1. 在整個應用程式的 Layout 中(下圖紅框範圍),可以發現右上角的 Logs 選單及時間選單,這兩個選單的資料會在不同頁面間共用,例如一載入應用程式選擇了這兩個參數後,在 Overview 切換到 Errors 和 Sessions 頁面都會同步顯示相同的參數。

    https://ithelp.ithome.com.tw/upload/images/20241013/20152073XPjhZ39lXM.png

  2. 在 Page Performance Panel 中,選擇特定的 Page ID 後,會跳出一個使用者體驗良好的 Drawer,依據 Page ID、時間及 data source 預覽該頁面的詳細資訊。

    https://ithelp.ithome.com.tw/upload/images/20241013/20152073UZERhlAcS3.png

  3. 在上圖點下藍色 Drilldown 按鈕後,會跳轉到新的頁面,一個更明確的動態頁面,URL 和 Breadcrumb 會跟著改變,並且會將原本的 Drawer 的內容同步到新的頁面中。

    https://ithelp.ithome.com.tw/upload/images/20241013/20152073v7EyEbGxnX.png

上述的三個情況,第一個情況由於三個頁面是經由 tabs 切換,所以 tabs 中的元件會自動繼承 Layout 的選單參數,因此不會有資料同步的問題。Grafana Scenes 使用一個層次結構來管理 SceneObject,每個 SceneObject 可以有自己的 $timeRange,如果沒有定義會從 parent 繼承。所以第二和第三個情況都是額外開啟的頁面,而內容呈現的 SceneObject 可能不在原本的 Scene 系統中,尤其是 drilldown 的頁面,以動態路徑來渲染頁面可能會造成上下文間產生隔離的問題,例如下面範例的呈現:

drilldowns: [
  {
    routePath: prefixRoute(`${ROUTES.Faro}/route`),
    getPage(routeMatch, parent) {
      const pageId = locationService.getSearchObject()['var-page_id'];
      return new SceneAppPage({
        url: prefixRoute(`${ROUTES.Faro}/route`),
        title: `Route Details: ${pageId}`,
        controls: [
          new SceneTimePicker({}),
        ],
        getParentPage: () => parent,
        getScene: () => {
          return getPageIdScene(pageId?.toString() || '', parent);
        },
      });
    },
  },
],

解決方法

Layout 中有 data source 和 time range 兩個值需要同步,data source 是以 variable 建立,我們只需要知道 SceneObject 以及 name 值,可以使用 sceneGraph.interpolate 取得 variable 的變數,再於 SceneQueryRunner 中帶入變數進行 query:

const data = new SceneQueryRunner({
  datasource: {
    type: 'loki',
    uid: sceneGraph.interpolate(model, '$loki'), // model 是 SceneObject
  },
  queries: [...],
});

但是 timeRange 如果使用 sceneGraph.getTimeRange() 取得會是層級最近的不為 undefined 的值,但這不會是使用者選擇的值,因此有兩個解決方法:

  1. Grafana Scenes 的變數會同步於 url 中,因此可以透過 url 取得 timeRange 的值( from 和 to )。
  2. 在 drilldown 中定義相同來源的 timeRange。

方法一

從 url 取得值可以透過 window.location.search 取得,或是 Grafana runtime 封裝好的 locationService.getSearchObject() 取得。

const { from, to } = locationService.getSearchObject();
const timeRange = new SceneTimeRange({
  from: from?.toString() || "now-1h",
  to: to?.toString() || "now",
});

return new EmbeddedScene({
  $timeRange: timeRange,
  // 其他設定
});

方法二

方法一雖然可行,但有點繞遠路的感覺,因此我們可以透過定義相同來源的 timeRange 來解決。所以首先要將 SceneTimeRange 定義在較高層級的 SceneObject 中,例如 Layout 或是更高層級的 SceneObject,接著在 drilldown 的頁面中定義相同的 timeRange:

const timeRange = new SceneTimeRange({ from: "now-1h", to: "now" });

return new SceneAppPage({
  $timeRange: timeRange,
  // 其他設定
  drilldowns: [
    {
      getPage: (routeMatch, parent) =>
        new SceneAppPage({
          $timeRange: timeRange,
          // 其他設定
        }),
    },
  ],
});

這樣的設計可以保證取得相同的 timeRange,不會因為層級不同而有所差異,但同時也因為在不同層級中定義相同的 SceneObject,違反了 Grafana Scenes 一個 SceneObject 只能有一個 parent 的規則,因此會跳出以下警告。

https://ithelp.ithome.com.tw/upload/images/20241013/20152073ZxLYIVExrk.png

但警告中也提供了解決的方法 - 使用 clone() 方法,因此我們可以將原本的 timeRange 複製到新的 SceneObject 中,這是 SceneObject 提供的淺拷貝方法,保留了對原始物件的某些引用,因此 TimeRangePicker 更新時,複製的 timeRange 也會跟著更新。

const timeRange = new SceneTimeRange({ from: "now-1h", to: "now" });

return new SceneAppPage({
  $timeRange: timeRange,
  drilldowns: [
    {
      getPage: (routeMatch, parent) => {
        return new SceneAppPage({
          $timeRange: timeRange.clone(),
        });
      },
    },
  ],
});

Panel 中針對特定 ID 展開新的 Drawer

在開發 Page Performance Panel 時,有一個特殊的需求:可以根據特定 Page ID 展開的 Drawer。這個功能需要在 URL 中反映 Drawer 的開關狀態,以便使用者可以輕鬆分享特定的 Panel 或是重新整理頁面時,也可以保持 Drawer 的開關狀態(例如上一段落跨頁挑戰的圖)。

Grafana Scene 限制

在實現這個功能時,我遇到了 Grafana Scene 的一些限制:

  1. 變數處理機制:當以 CustomVariable 建立變數時,自動處理 URL 參數會對布林值有特殊的處理方式。尤其是當值為 "true" 或 "false" 時,可能會被自動去除或轉換,導致狀態無法正確保存或恢復。
  2. 查詢觸發:查詢的觸發通常是由 SceneQueryRunner 控制的,它會監聽相關變數的變化,所以每次 Drawer 開關狀態改變時,都會觸發查詢請求。

解決方法

根據上述的限制,只要開關 Drawer 就會觸發查詢的狀況並不是我們想要的,而且是單純的顯示狀態,因此果斷地放棄使用 CustomVariable 來處理,改為使用手動更新 URL 的方式來實現。

  1. 使用字串表示布林值:選擇使用字串 "true" 和 "false" 來表示 Drawer 的開關狀態,而不是使用實際的布林值。這避免了 Grafana Scene 對布林值的特殊處理。
  2. 自定義 URL 更新邏輯:實現了 openOverviewDrawer 和 closeOverviewDrawer 方法來手動管理 URL 的更新。這些方法使用 window.history.pushState() 來更新 URL,而不觸發頁面刷新或不必要的查詢。
  public openOverviewDrawer = (pageId: string) => {
    const url = new URL(window.location.href);
    url.searchParams.set('var-show_overview_drawer', 'true');
    window.history.pushState({}, '', url.toString());
  };

  public closeOverviewDrawer = () => {
    const url = new URL(window.location.href);
    url.searchParams.set('var-show_overview_drawer', 'false');
    window.history.pushState({}, '', url.toString());
  };
  1. 在控制開關的狀態則是使用 React 的 useState 來控制,並依據 URL 的參數更新狀態。
const [isOpen, setIsOpen] = useState(false);

useEffect(() => {
  const params = window.location.search;
  const isDrawerOpen = params.includes("var-show_overview_drawer=true");
  setIsOpen(Boolean(isDrawerOpen));
}, []);

動態頁面

Imgur

在 Page Performance Panel 中,除了顯示 Drawer 外,還需要根據選擇的 Page ID 動態更新頁面,以及在 Drawer 中轉跳至 drilldown 的動態頁面。在這項功能中,由於 Page ID 會需要顯示在 URL 中,以及傳進動態頁面以提供參數進行查詢,因此會需要 CustomVariable 的支援,並且有以下需求:

  1. 使用 CustomVariable 來管理 page_id。
  2. 希望 page_id 的值能夠在 URL 中反映,以便於分享和保存狀態。
  3. 不希望 page_id 的變化觸發當前頁面的數據查詢。
  4. 其他 variable(如 timeRange)的變化應該正常觸發查詢。

Grafana Scene 限制

  1. Scene 的 CustomVariable 和 SceneQueryRunner 元件預設會在 variable 變化時觸發新的數據查詢。這代表每次 page_id 更改時,都會執行不必要的請求,甚至導致頁面因請求而閃爍。
  2. 雖然 CustomVariable 提供了 skipUrlSync 參數來防止 URL 同步以及查詢觸發,但這與我們希望在 URL 中保有 page_id 的需求相衝突。

解決方法

為了克服這些限制並實現所需的動態行為,採用了一種巧妙的解決方法:

  1. 將 CustomVariable 的 skipUrlSync 設置為 true,以防止自動的 URL 同步和查詢觸發。
  2. 與 Drawer 的開關狀態一樣,實現了自定義方法 openOverviewDrawer 和 closeOverviewDrawer,在點擊 Drawer 的開關按鈕同時,帶入 page_id 的值,手動管理 URL 的更新,並且使用 window.history.pushState() 同步 URL,同時 variable 的值也會更新。
  3. 當頁面重新載入時由於 skipUrlSync 設置為 true,因此不會自動將 URL 的 page_id 同步到 variable 中,因此會需要使用 locationService.getSearchObject() 方法取得 page_id 的值。
const pageIdVariable = new CustomVariable({
    key: 'page_id',
    name: 'page_id',
    skipUrlSync: true,
});

// 更新 URL 中的 page_id 變數
public openOverviewDrawer = (pageId: string) => {
  const url = new URL(window.location.href);
  const pageIdVariable = sceneGraph.findByKey(this, 'page_id') as CustomVariable;
  pageIdVariable.setState({ value: pageId }); // 依據選擇的 page_id 更新 variable
  url.searchParams.set('var-page_id', pageId); // 更新 URL 中的 page_id 變數
  window.history.pushState({}, '', url.toString()); // 更新瀏覽器 URL
};

public closeOverviewDrawer = () => {
  const url = new URL(window.location.href);
  const pageIdVariable = sceneGraph.findByKey(this, 'page_id') as CustomVariable;
  const pageId = pageIdVariable.getValue(); // 取得 variable 的值
  const urlPageId = locationService.getSearchObject()['var-page_id']; // 取得 URL 中的 page_id 變數
  url.searchParams.set('var-page_id', pageId.toString() || urlPageId?.toString() || ''); // 手動更新 URL 中的 page_id 變數
  window.history.pushState({}, '', url.toString()); // 更新瀏覽器 URL
};

筆者語錄
在實作的過程中遇到了許多挑戰,目前以上的方法雖然順利解決了問題,但或許還有更好的解法,也因為 Grafana Scenes 是一個 React 的框架,因此許多功能上都可以沿用 React 的邏輯,例如使用 useState 控制變數及狀態,或是使用 useEffect 監聽變數的變化。且 Grafana 也有一些封裝的功能,可以讓我們更容易地實現這些需求,例如 locationService 和 sceneGraph 等,這些都是值得讀者去探索的。希望未來有讀者遇到相同問題時,能夠從本文獲得一些靈感,或是提供更好的解法。


上一篇
靠 Grafana 吃飯的第二十八天 - 克隆 Grafana Cloud 前端可觀測性平台 - 基礎篇
下一篇
靠 Grafana 吃飯的第三十天 - 前端可以靠 Grafana 吃飯了嗎?
系列文
論前端工程師如何靠 Grafana 吃飯:從 Grafana App 到前端可觀測性30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言