在上一篇文章中,我們了解了一個可觀測性平台的單一頁面是如何建置的。然而,我們在 Layout 中觀察到除了 Overview 頁面外,還存在 Errors、Sessions 頁面,或是其他因團隊需求而新增的頁面。這些頁面通常都是總覽性的。如果希望針對特定的 Page、Session ID 或 Error 進行檢視,就需要透過動態頁面來實現。
在實作過程中,我們發現跨頁同步是克隆應用程式時需要克服的一個挑戰。因此,本篇文章將針對單一頁面的建置進行實作,並分享一些實作過程中的挑戰及解決方案,供有相同需求的讀者參考。
如果說單個總覽頁面最具挑戰性的部分是客製化的圖表 Panel 和數據資料的轉換,那麼跨頁面和動態頁面的挑戰就是資料同步,例如時間或是 id 等其他變數的同步。在了解這個難題及解決方法之前,我們可以先羅列哪些情況會出現資料同步的需求:
在整個應用程式的 Layout 中(下圖紅框範圍),可以發現右上角的 Logs 選單及時間選單,這兩個選單的資料會在不同頁面間共用,例如一載入應用程式選擇了這兩個參數後,在 Overview 切換到 Errors 和 Sessions 頁面都會同步顯示相同的參數。
在 Page Performance Panel 中,選擇特定的 Page ID 後,會跳出一個使用者體驗良好的 Drawer,依據 Page ID、時間及 data source 預覽該頁面的詳細資訊。
在上圖點下藍色 Drilldown 按鈕後,會跳轉到新的頁面,一個更明確的動態頁面,URL 和 Breadcrumb 會跟著改變,並且會將原本的 Drawer 的內容同步到新的頁面中。
上述的三個情況,第一個情況由於三個頁面是經由 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 的值,但這不會是使用者選擇的值,因此有兩個解決方法:
從 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 的規則,因此會跳出以下警告。
但警告中也提供了解決的方法 - 使用 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(),
});
},
},
],
});
在開發 Page Performance Panel 時,有一個特殊的需求:可以根據特定 Page ID 展開的 Drawer。這個功能需要在 URL 中反映 Drawer 的開關狀態,以便使用者可以輕鬆分享特定的 Panel 或是重新整理頁面時,也可以保持 Drawer 的開關狀態(例如上一段落跨頁挑戰的圖)。
在實現這個功能時,我遇到了 Grafana Scene 的一些限制:
根據上述的限制,只要開關 Drawer 就會觸發查詢的狀況並不是我們想要的,而且是單純的顯示狀態,因此果斷地放棄使用 CustomVariable 來處理,改為使用手動更新 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());
};
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
const params = window.location.search;
const isDrawerOpen = params.includes("var-show_overview_drawer=true");
setIsOpen(Boolean(isDrawerOpen));
}, []);
在 Page Performance Panel 中,除了顯示 Drawer 外,還需要根據選擇的 Page ID 動態更新頁面,以及在 Drawer 中轉跳至 drilldown 的動態頁面。在這項功能中,由於 Page ID 會需要顯示在 URL 中,以及傳進動態頁面以提供參數進行查詢,因此會需要 CustomVariable 的支援,並且有以下需求:
為了克服這些限制並實現所需的動態行為,採用了一種巧妙的解決方法:
window.history.pushState()
同步 URL,同時 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 等,這些都是值得讀者去探索的。希望未來有讀者遇到相同問題時,能夠從本文獲得一些靈感,或是提供更好的解法。