From a0a2f852c4f61d0482078b932d4707c959984d86 Mon Sep 17 00:00:00 2001 From: ashrafchowdury Date: Thu, 31 Oct 2024 13:47:56 +0600 Subject: [PATCH 1/6] feat(frontend): implemented global observability page --- agenta-web/src/components/Sidebar/config.tsx | 7 +++++++ agenta-web/src/contexts/observability.context.tsx | 4 +--- .../src/pages/apps/[app_id]/observability/index.tsx | 9 ++++++--- agenta-web/src/pages/apps/observability/index.tsx | 8 ++++++++ agenta-web/src/services/observability/core/index.ts | 6 ++++-- 5 files changed, 26 insertions(+), 8 deletions(-) create mode 100644 agenta-web/src/pages/apps/observability/index.tsx diff --git a/agenta-web/src/components/Sidebar/config.tsx b/agenta-web/src/components/Sidebar/config.tsx index 957c6f7394..81b4b75fc0 100644 --- a/agenta-web/src/components/Sidebar/config.tsx +++ b/agenta-web/src/components/Sidebar/config.tsx @@ -67,6 +67,13 @@ export const useSidebarConfig = () => { tooltip: "Create and manage testsets for evaluation purposes.", link: `/apps/testsets`, icon: , + isHidden: apps.length === 0, + }, + { + key: "global-observability-link", + title: "Global Observability", + link: `/apps/observability`, + icon: , divider: true, isHidden: apps.length === 0, }, diff --git a/agenta-web/src/contexts/observability.context.tsx b/agenta-web/src/contexts/observability.context.tsx index a01a5ff8b3..4e34b4527a 100644 --- a/agenta-web/src/contexts/observability.context.tsx +++ b/agenta-web/src/contexts/observability.context.tsx @@ -78,9 +78,7 @@ const ObservabilityContextProvider: React.FC = ({children}) = } useEffect(() => { - if (appId) { - fetchTraces("&focus=tree&size=10&page=1") - } + fetchTraces("?focus=tree&size=10&page=1") }, [appId]) observabilityContextValues.traces = traces diff --git a/agenta-web/src/pages/apps/[app_id]/observability/index.tsx b/agenta-web/src/pages/apps/[app_id]/observability/index.tsx index 8259fbd9e8..921a76f523 100644 --- a/agenta-web/src/pages/apps/[app_id]/observability/index.tsx +++ b/agenta-web/src/pages/apps/[app_id]/observability/index.tsx @@ -471,7 +471,7 @@ const ObservabilityDashboard = ({}: Props) => { : "" } - const data = await fetchTraces(`&${focusPoint}${paginationQuery}${sortQuery}${filterQuery}`) + const data = await fetchTraces(`?${focusPoint}${paginationQuery}${sortQuery}${filterQuery}`) return data } @@ -568,8 +568,11 @@ const ObservabilityDashboard = ({}: Props) => { } description="Monitor the performance and results of your LLM applications here." primaryCta={{ - text: "Go to Playground", - onClick: () => router.push(`/apps/${appId}/playground`), + text: appId ? "Go to Playground" : "Create an Application", + onClick: () => + router.push( + appId ? `/apps/${appId}/playground` : "/apps", + ), tooltip: "Run your LLM app in the playground to generate and view insights.", }} diff --git a/agenta-web/src/pages/apps/observability/index.tsx b/agenta-web/src/pages/apps/observability/index.tsx new file mode 100644 index 0000000000..570d76d051 --- /dev/null +++ b/agenta-web/src/pages/apps/observability/index.tsx @@ -0,0 +1,8 @@ +import React from "react" +import ObservabilityDashboard from "../[app_id]/observability" + +const index = () => { + return +} + +export default index diff --git a/agenta-web/src/services/observability/core/index.ts b/agenta-web/src/services/observability/core/index.ts index 2b2a7a6294..0655a375be 100644 --- a/agenta-web/src/services/observability/core/index.ts +++ b/agenta-web/src/services/observability/core/index.ts @@ -10,10 +10,12 @@ import axios from "@/lib/helpers/axiosConfig" // - delete: DELETE data from server export const fetchAllTraces = async ({appId, queries}: {appId: string; queries?: string}) => { - const filterByAppId = `filtering={"conditions":[{"key":"refs.application.id","operator":"is","value":"${appId}"}]}` + const filterByAppId = appId + ? `&filtering={"conditions":[{"key":"refs.application.id","operator":"is","value":"${appId}"}]}` + : "" const response = await axios.get( - `${getAgentaApiUrl()}/api/observability/v1/traces/search?${filterByAppId}${queries}`, + `${getAgentaApiUrl()}/api/observability/v1/traces/search${queries}${filterByAppId}`, ) return response.data } From bbd8c957476f54e4625252ad5afacd967485131c Mon Sep 17 00:00:00 2001 From: ashrafchowdury Date: Thu, 31 Oct 2024 16:02:48 +0600 Subject: [PATCH 2/6] fix(frontend): converted observability page into component --- agenta-web/src/components/Sidebar/config.tsx | 2 +- .../observability/ObservabilityDashboard.tsx | 634 +++++++++++++++++ .../src/contexts/observability.context.tsx | 2 +- .../apps/[app_id]/observability/index.tsx | 637 +----------------- .../src/pages/apps/observability/index.tsx | 8 - agenta-web/src/pages/observability/index.tsx | 12 + .../src/services/observability/core/index.ts | 2 +- 7 files changed, 653 insertions(+), 644 deletions(-) create mode 100644 agenta-web/src/components/pages/observability/ObservabilityDashboard.tsx delete mode 100644 agenta-web/src/pages/apps/observability/index.tsx create mode 100644 agenta-web/src/pages/observability/index.tsx diff --git a/agenta-web/src/components/Sidebar/config.tsx b/agenta-web/src/components/Sidebar/config.tsx index 81b4b75fc0..de4794a42d 100644 --- a/agenta-web/src/components/Sidebar/config.tsx +++ b/agenta-web/src/components/Sidebar/config.tsx @@ -72,7 +72,7 @@ export const useSidebarConfig = () => { { key: "global-observability-link", title: "Global Observability", - link: `/apps/observability`, + link: `/observability`, icon: , divider: true, isHidden: apps.length === 0, diff --git a/agenta-web/src/components/pages/observability/ObservabilityDashboard.tsx b/agenta-web/src/components/pages/observability/ObservabilityDashboard.tsx new file mode 100644 index 0000000000..297a5f9424 --- /dev/null +++ b/agenta-web/src/components/pages/observability/ObservabilityDashboard.tsx @@ -0,0 +1,634 @@ +import EmptyComponent from "@/components/EmptyComponent" +import GenericDrawer from "@/components/GenericDrawer" +import {nodeTypeStyles} from "./components/AvatarTreeContent" +import StatusRenderer from "./components/StatusRenderer" +import TraceContent from "./drawer/TraceContent" +import TraceHeader from "./drawer/TraceHeader" +import TraceTree from "./drawer/TraceTree" +import Filters from "@/components/Filters/Filters" +import Sort, {SortResult} from "@/components/Filters/Sort" +import EditColumns from "@/components/Filters/EditColumns" +import ResultTag from "@/components/ResultTag/ResultTag" +import {ResizableTitle} from "@/components/ServerTable/components" +import {useAppId} from "@/hooks/useAppId" +import {useQueryParam} from "@/hooks/useQuery" +import {formatCurrency, formatLatency, formatTokenUsage} from "@/lib/helpers/formatters" +import {getNodeById} from "@/lib/helpers/observability_helpers" +import {Filter, FilterConditions, JSSTheme} from "@/lib/Types" +import {_AgentaRootsResponse} from "@/services/observability/types" +import {SwapOutlined} from "@ant-design/icons" +import { + Button, + Input, + Pagination, + Radio, + RadioChangeEvent, + Space, + Table, + TableColumnType, + Tag, + Typography, +} from "antd" +import {ColumnsType} from "antd/es/table" +import dayjs from "dayjs" +import {useRouter} from "next/router" +import React, {useCallback, useEffect, useMemo, useState} from "react" +import {createUseStyles} from "react-jss" +import {Export} from "@phosphor-icons/react" +import {getAppValues} from "@/contexts/app.context" +import {convertToCsv, downloadCsv} from "@/lib/helpers/fileManipulations" +import {useUpdateEffect} from "usehooks-ts" +import {getStringOrJson} from "@/lib/helpers/utils" +import ObservabilityContextProvider, {useObservabilityData} from "@/contexts/observability.context" + +const useStyles = createUseStyles((theme: JSSTheme) => ({ + title: { + fontSize: theme.fontSizeHeading4, + fontWeight: theme.fontWeightMedium, + lineHeight: theme.lineHeightHeading4, + }, + pagination: { + "& > .ant-pagination-options": { + order: -1, + marginRight: 8, + }, + }, +})) + +type TraceTabTypes = "tree" | "node" | "chat" + +const ObservabilityDashboard = () => { + const {traces, isLoading, count, fetchTraces} = useObservabilityData() + const appId = useAppId() + const router = useRouter() + const classes = useStyles() + const [selectedTraceId, setSelectedTraceId] = useQueryParam("trace", "") + const [searchQuery, setSearchQuery] = useState("") + const [traceTabs, setTraceTabs] = useState("tree") + const [editColumns, setEditColumns] = useState(["span_type"]) + const [filters, setFilters] = useState([]) + const [sort, setSort] = useState({} as SortResult) + const [isFilterColsDropdownOpen, setIsFilterColsDropdownOpen] = useState(false) + const [pagination, setPagination] = useState({current: 1, page: 10}) + const [columns, setColumns] = useState>([ + { + title: "ID", + dataIndex: ["key"], + key: "key", + width: 200, + onHeaderCell: () => ({ + style: {minWidth: 200}, + }), + fixed: "left", + render: (_, record) => { + const {icon: Icon} = nodeTypeStyles[record.node.type ?? "default"] + + return !record.parent ? ( + + ) : ( + +
+ +
+ {record.node.name} +
+ ) + }, + }, + { + title: "Span type", + key: "span_type", + dataIndex: ["node", "type"], + width: 200, + onHeaderCell: () => ({ + style: {minWidth: 200}, + }), + render: (_, record) => { + return
{record.node.type}
+ }, + }, + { + title: "Timestamp", + key: "timestamp", + dataIndex: ["time", "start"], + width: 200, + onHeaderCell: () => ({ + style: {minWidth: 200}, + }), + render: (_, record) => { + return
{dayjs(record.time.start).format("HH:mm:ss DD MMM YYYY")}
+ }, + }, + { + title: "Inputs", + key: "inputs", + width: 350, + render: (_, record) => { + return ( + + {getStringOrJson(record?.data?.inputs)} + + ) + }, + }, + { + title: "Outputs", + key: "outputs", + width: 350, + render: (_, record) => { + return ( + + {getStringOrJson(record?.data?.outputs)} + + ) + }, + }, + { + title: "Status", + key: "status", + dataIndex: ["status", "code"], + width: 160, + onHeaderCell: () => ({ + style: {minWidth: 160}, + }), + render: (_, record) => StatusRenderer({status: record.status, showMore: true}), + }, + { + title: "Latency", + key: "latency", + dataIndex: ["time", "span"], + width: 80, + onHeaderCell: () => ({ + style: {minWidth: 80}, + }), + render: (_, record) =>
{formatLatency(record?.metrics?.acc?.duration.total)}
, + }, + { + title: "Usage", + key: "usage", + dataIndex: ["metrics", "acc", "tokens", "total"], + width: 80, + onHeaderCell: () => ({ + style: {minWidth: 80}, + }), + render: (_, record) => ( +
{formatTokenUsage(record.metrics?.acc?.tokens?.total)}
+ ), + }, + { + title: "Total cost", + key: "total_cost", + dataIndex: ["metrics", "acc", "costs", "total"], + width: 80, + onHeaderCell: () => ({ + style: {minWidth: 80}, + }), + render: (_, record) =>
{formatCurrency(record.metrics?.acc?.costs?.total)}
, + }, + ]) + + const activeTraceIndex = useMemo( + () => + traces?.findIndex((item) => + traceTabs === "node" + ? item.node.id === selectedTraceId + : item.root.id === selectedTraceId, + ), + [selectedTraceId, traces, traceTabs], + ) + + const activeTrace = useMemo(() => traces[activeTraceIndex] ?? null, [activeTraceIndex, traces]) + + const [selected, setSelected] = useState("") + + useEffect(() => { + if (!selected) { + setSelected(activeTrace?.node.id) + } + }, [activeTrace, selected]) + + const selectedItem = useMemo( + () => (traces?.length ? getNodeById(traces, selected) : null), + [selected, traces], + ) + + const handleNextTrace = useCallback(() => { + if (activeTraceIndex !== undefined && activeTraceIndex < traces.length - 1) { + const nextTrace = traces[activeTraceIndex + 1] + if (traceTabs === "node") { + setSelectedTraceId(nextTrace.node.id) + } else { + setSelectedTraceId(nextTrace.root.id) + } + setSelected(nextTrace.node.id) + } + }, [activeTraceIndex, traces, traceTabs, setSelectedTraceId]) + + const handlePrevTrace = useCallback(() => { + if (activeTraceIndex !== undefined && activeTraceIndex > 0) { + const prevTrace = traces[activeTraceIndex - 1] + if (traceTabs === "node") { + setSelectedTraceId(prevTrace.node.id) + } else { + setSelectedTraceId(prevTrace.root.id) + } + setSelected(prevTrace.node.id) + } + }, [activeTraceIndex, traces, traceTabs, setSelectedTraceId]) + + const handleResize = + (key: string) => + (_: any, {size}: {size: {width: number}}) => { + setColumns((cols) => { + return cols.map((col) => ({ + ...col, + width: col.key === key ? size.width : col.width, + })) + }) + } + + const mergedColumns = useMemo(() => { + return columns.map((col) => ({ + ...col, + width: col.width || 200, + hidden: editColumns.includes(col.key as string), + onHeaderCell: (column: TableColumnType<_AgentaRootsResponse>) => ({ + width: column.width, + onResize: handleResize(column.key?.toString()!), + }), + })) + }, [columns, editColumns]) + + const filterColumns = [ + {type: "exists", value: "root.id", label: "root.id"}, + {type: "exists", value: "tree.id", label: "tree.id"}, + {type: "exists", value: "tree.type", label: "tree.type"}, + {type: "exists", value: "node.id", label: "node.id"}, + {type: "exists", value: "node.type", label: "node.type"}, + {type: "exists", value: "node.name", label: "node.name"}, + {type: "exists", value: "parent.id", label: "parent.id"}, + {type: "exists", value: "status.code", label: "status.code"}, + {type: "exists", value: "status.message", label: "status.message"}, + {type: "exists", value: "exception.type", label: "exception.type"}, + {type: "exists", value: "exception.message", label: "exception.message"}, + {type: "exists", value: "exception.stacktrace", label: "exception.stacktrace"}, + {type: "string", value: "data", label: "data"}, + {type: "number", value: "metrics.acc.duration.total", label: "metrics.acc.duration.total"}, + {type: "number", value: "metrics.acc.costs.total", label: "metrics.acc.costs.total"}, + {type: "number", value: "metrics.unit.costs.total", label: "metrics.unit.costs.total"}, + {type: "number", value: "metrics.acc.tokens.prompt", label: "metrics.acc.tokens.prompt"}, + { + type: "number", + value: "metrics.acc.tokens.completion", + label: "metrics.acc.tokens.completion", + }, + {type: "number", value: "metrics.acc.tokens.total", label: "metrics.acc.tokens.total"}, + {type: "number", value: "metrics.unit.tokens.prompt", label: "metrics.unit.tokens.prompt"}, + { + type: "number", + value: "metrics.unit.tokens.completion", + label: "metrics.unit.tokens.completion", + }, + {type: "number", value: "metrics.unit.tokens.total", label: "metrics.unit.tokens.total"}, + {type: "exists", value: "refs.variant.id", label: "refs.variant.id"}, + {type: "exists", value: "refs.variant.slug", label: "refs.variant.slug"}, + {type: "exists", value: "refs.variant.version", label: "refs.variant.version"}, + {type: "exists", value: "refs.environment.id", label: "refs.environment.id"}, + {type: "exists", value: "refs.environment.slug", label: "refs.environment.slug"}, + {type: "exists", value: "refs.environment.version", label: "refs.environment.version"}, + {type: "exists", value: "refs.application.id", label: "refs.application.id"}, + {type: "exists", value: "refs.application.slug", label: "refs.application.slug"}, + {type: "exists", value: "link.type", label: "link.type"}, + {type: "exists", value: "link.node.id", label: "link.node.id"}, + {type: "exists", value: "otel.kind", label: "otel.kind"}, + ] + + const onExport = async () => { + try { + if (traces.length) { + const {currentApp} = getAppValues() + const filename = `${currentApp?.app_name}_observability.csv` + + const convertToStringOrJson = (value: any) => { + return typeof value === "string" ? value : JSON.stringify(value) + } + + // Helper function to create a trace object + const createTraceObject = (trace: any) => ({ + "Trace ID": trace.key, + Timestamp: dayjs(trace.time.start).format("HH:mm:ss DD MMM YYYY"), + Inputs: trace?.data?.inputs?.topic || "N/A", + Outputs: convertToStringOrJson(trace?.data?.outputs) || "N/A", + Status: trace.status.code, + Latency: formatLatency(trace.metrics?.acc?.duration.total), + Usage: formatTokenUsage(trace.metrics?.acc?.tokens?.total || 0), + "Total Cost": formatCurrency(trace.metrics?.acc?.costs?.total || 0), + "Span Type": trace.node.type || "N/A", + "Span ID": trace.node.id, + }) + + const csvData = convertToCsv( + traces.flatMap((trace) => { + const parentTrace = createTraceObject(trace) + return trace.children && Array.isArray(trace.children) + ? [parentTrace, ...trace.children.map(createTraceObject)] + : [parentTrace] + }), + [ + ...columns.map((col) => + col.title === "ID" ? "Trace ID" : (col.title as string), + ), + "Span ID", + "Span Type", + ], + ) + + downloadCsv(csvData, filename) + } + } catch (error) { + console.error("Export error:", error) + } + } + + const handleToggleColumnVisibility = useCallback((key: string) => { + setEditColumns((prev) => + prev.includes(key) ? prev.filter((item) => item !== key) : [...prev, key], + ) + }, []) + + const updateFilter = ({ + key, + operator, + value, + }: { + key: string + operator: FilterConditions + value: string + }) => { + setFilters((prevFilters) => { + const otherFilters = prevFilters.filter((f) => f.key !== key) + return value ? [...otherFilters, {key, operator, value}] : otherFilters + }) + } + + const onPaginationChange = (current: number, pageSize: number) => { + setPagination({current, page: pageSize}) + } + + const onSearchChange = (e: React.ChangeEvent) => { + const query = e.target.value + setSearchQuery(query) + + if (!query) { + setFilters((prevFilters) => prevFilters.filter((f) => f.key !== "data")) + } + } + + const onSearchQueryApply = () => { + if (searchQuery) { + updateFilter({key: "data", operator: "contains", value: searchQuery}) + } + } + + const onSearchClear = () => { + const isSearchFilterExist = filters.some((item) => item.key === "data") + + if (isSearchFilterExist) { + setFilters((prevFilters) => prevFilters.filter((f) => f.key !== "data")) + } + } + // Sync searchQuery with filters state + useUpdateEffect(() => { + const dataFilter = filters.find((f) => f.key === "data") + setSearchQuery(dataFilter ? dataFilter.value : "") + }, [filters]) + + const onApplyFilter = useCallback((newFilters: Filter[]) => { + setFilters(newFilters) + }, []) + + const onClearFilter = useCallback(() => { + setFilters([]) + setSearchQuery("") + if (traceTabs === "chat") { + setTraceTabs("tree") + } + }, []) + + const onTraceTabChange = async (e: RadioChangeEvent) => { + const selectedTab = e.target.value + setTraceTabs(selectedTab) + + if (selectedTab === "chat") { + updateFilter({key: "node.type", operator: "is", value: selectedTab}) + } else { + const isNodeTypeFilterExist = filters.some( + (item) => item.key === "node.type" && item.value === "chat", + ) + + if (isNodeTypeFilterExist) { + setFilters((prevFilters) => prevFilters.filter((f) => f.key !== "node.type")) + } + } + if (pagination.current > 1) { + setPagination({...pagination, current: 1}) + } + } + // Sync traceTabs with filters state + useUpdateEffect(() => { + const nodeTypeFilter = filters.find((f) => f.key === "node.type")?.value + setTraceTabs((prev) => + nodeTypeFilter === "chat" ? "chat" : prev == "chat" ? "tree" : prev, + ) + }, [filters]) + + const onSortApply = useCallback(({type, sorted, customRange}: SortResult) => { + setSort({type, sorted, customRange}) + }, []) + + const fetchFilterdTrace = async () => { + const focusPoint = traceTabs == "tree" || traceTabs == "node" ? `focus=${traceTabs}` : "" + const filterQuery = filters[0]?.operator + ? `&filtering={"conditions":${JSON.stringify(filters)}}` + : "" + const paginationQuery = `&size=${pagination.page}&page=${pagination.current}` + + let sortQuery = "" + if (sort) { + sortQuery = + sort.type === "standard" + ? `&oldest=${sort.sorted}` + : sort.type === "custom" && sort.customRange?.startTime + ? `&oldest=${sort.customRange.startTime}&newest=${sort.customRange.endTime}` + : "" + } + + const data = await fetchTraces(`${focusPoint}${paginationQuery}${sortQuery}${filterQuery}`) + + return data + } + + useUpdateEffect(() => { + fetchFilterdTrace() + }, [filters, traceTabs, sort, pagination]) + + return ( +
+ Observability + +
+ + + + + +
+ + + Root + LLM + All + + + + + + + +
+
+ +
+ []} + dataSource={traces} + bordered + style={{cursor: "pointer"}} + onRow={(record) => ({ + onClick: () => { + setSelected(record.node.id) + if (traceTabs === "node") { + setSelectedTraceId(record.node.id) + } else { + setSelectedTraceId(record.root.id) + } + }, + })} + components={{ + header: { + cell: ResizableTitle, + }, + }} + pagination={false} + scroll={{x: "max-content"}} + locale={{ + emptyText: ( +
+ + } + description="Monitor the performance and results of your LLM applications here." + primaryCta={{ + text: appId ? "Go to Playground" : "Create an Application", + onClick: () => + router.push( + appId ? `/apps/${appId}/playground` : "/apps", + ), + tooltip: + "Run your LLM app in the playground to generate and view insights.", + }} + secondaryCta={{ + text: "Learn More", + onClick: () => + router.push( + "https://docs.agenta.ai/observability/quickstart", + ), + tooltip: + "Explore more about tracking and analyzing your app's observability data.", + }} + /> +
+ ), + }} + /> + + + + {activeTrace && !!traces?.length && ( + setSelectedTraceId("")} + expandable + headerExtra={ + + } + mainContent={selectedItem ? : null} + sideContent={ + + } + /> + )} + + ) +} + +export default () => ( + + + +) diff --git a/agenta-web/src/contexts/observability.context.tsx b/agenta-web/src/contexts/observability.context.tsx index 4e34b4527a..ac923a2a24 100644 --- a/agenta-web/src/contexts/observability.context.tsx +++ b/agenta-web/src/contexts/observability.context.tsx @@ -78,7 +78,7 @@ const ObservabilityContextProvider: React.FC = ({children}) = } useEffect(() => { - fetchTraces("?focus=tree&size=10&page=1") + fetchTraces("focus=tree&size=10&page=1") }, [appId]) observabilityContextValues.traces = traces diff --git a/agenta-web/src/pages/apps/[app_id]/observability/index.tsx b/agenta-web/src/pages/apps/[app_id]/observability/index.tsx index 921a76f523..a5b2574727 100644 --- a/agenta-web/src/pages/apps/[app_id]/observability/index.tsx +++ b/agenta-web/src/pages/apps/[app_id]/observability/index.tsx @@ -1,636 +1,7 @@ -import EmptyComponent from "@/components/EmptyComponent" -import GenericDrawer from "@/components/GenericDrawer" -import {nodeTypeStyles} from "@/components/pages/observability/components/AvatarTreeContent" -import StatusRenderer from "@/components/pages/observability/components/StatusRenderer" -import TraceContent from "@/components/pages/observability/drawer/TraceContent" -import TraceHeader from "@/components/pages/observability/drawer/TraceHeader" -import TraceTree from "@/components/pages/observability/drawer/TraceTree" -import Filters from "@/components/Filters/Filters" -import Sort, {SortResult} from "@/components/Filters/Sort" -import EditColumns from "@/components/Filters/EditColumns" -import ResultTag from "@/components/ResultTag/ResultTag" -import {ResizableTitle} from "@/components/ServerTable/components" -import {useAppId} from "@/hooks/useAppId" -import {useQueryParam} from "@/hooks/useQuery" -import {formatCurrency, formatLatency, formatTokenUsage} from "@/lib/helpers/formatters" -import {getNodeById} from "@/lib/helpers/observability_helpers" -import {Filter, FilterConditions, JSSTheme} from "@/lib/Types" -import {_AgentaRootsResponse} from "@/services/observability/types" -import {SwapOutlined} from "@ant-design/icons" -import { - Button, - Input, - Pagination, - Radio, - RadioChangeEvent, - Space, - Table, - TableColumnType, - Tag, - Typography, -} from "antd" -import {ColumnsType} from "antd/es/table" -import dayjs from "dayjs" -import {useRouter} from "next/router" -import React, {useCallback, useEffect, useMemo, useState} from "react" -import {createUseStyles} from "react-jss" -import {Export} from "@phosphor-icons/react" -import {getAppValues} from "@/contexts/app.context" -import {convertToCsv, downloadCsv} from "@/lib/helpers/fileManipulations" -import {useUpdateEffect} from "usehooks-ts" -import {getStringOrJson} from "@/lib/helpers/utils" -import ObservabilityContextProvider, {useObservabilityData} from "@/contexts/observability.context" +import ObservabilityDashboard from "@/components/pages/observability/ObservabilityDashboard" -const useStyles = createUseStyles((theme: JSSTheme) => ({ - title: { - fontSize: theme.fontSizeHeading4, - fontWeight: theme.fontWeightMedium, - lineHeight: theme.lineHeightHeading4, - }, - pagination: { - "& > .ant-pagination-options": { - order: -1, - marginRight: 8, - }, - }, -})) - -interface Props {} - -type TraceTabTypes = "tree" | "node" | "chat" - -const ObservabilityDashboard = ({}: Props) => { - const {traces, isLoading, count, fetchTraces} = useObservabilityData() - const appId = useAppId() - const router = useRouter() - const classes = useStyles() - const [selectedTraceId, setSelectedTraceId] = useQueryParam("trace", "") - const [searchQuery, setSearchQuery] = useState("") - const [traceTabs, setTraceTabs] = useState("tree") - const [editColumns, setEditColumns] = useState(["span_type"]) - const [filters, setFilters] = useState([]) - const [sort, setSort] = useState({} as SortResult) - const [isFilterColsDropdownOpen, setIsFilterColsDropdownOpen] = useState(false) - const [pagination, setPagination] = useState({current: 1, page: 10}) - const [columns, setColumns] = useState>([ - { - title: "ID", - dataIndex: ["key"], - key: "key", - width: 200, - onHeaderCell: () => ({ - style: {minWidth: 200}, - }), - fixed: "left", - render: (_, record) => { - const {icon: Icon} = nodeTypeStyles[record.node.type ?? "default"] - - return !record.parent ? ( - - ) : ( - -
- -
- {record.node.name} -
- ) - }, - }, - { - title: "Span type", - key: "span_type", - dataIndex: ["node", "type"], - width: 200, - onHeaderCell: () => ({ - style: {minWidth: 200}, - }), - render: (_, record) => { - return
{record.node.type}
- }, - }, - { - title: "Timestamp", - key: "timestamp", - dataIndex: ["time", "start"], - width: 200, - onHeaderCell: () => ({ - style: {minWidth: 200}, - }), - render: (_, record) => { - return
{dayjs(record.time.start).format("HH:mm:ss DD MMM YYYY")}
- }, - }, - { - title: "Inputs", - key: "inputs", - width: 350, - render: (_, record) => { - return ( - - {getStringOrJson(record?.data?.inputs)} - - ) - }, - }, - { - title: "Outputs", - key: "outputs", - width: 350, - render: (_, record) => { - return ( - - {getStringOrJson(record?.data?.outputs)} - - ) - }, - }, - { - title: "Status", - key: "status", - dataIndex: ["status", "code"], - width: 160, - onHeaderCell: () => ({ - style: {minWidth: 160}, - }), - render: (_, record) => StatusRenderer({status: record.status, showMore: true}), - }, - { - title: "Latency", - key: "latency", - dataIndex: ["time", "span"], - width: 80, - onHeaderCell: () => ({ - style: {minWidth: 80}, - }), - render: (_, record) =>
{formatLatency(record?.metrics?.acc?.duration.total)}
, - }, - { - title: "Usage", - key: "usage", - dataIndex: ["metrics", "acc", "tokens", "total"], - width: 80, - onHeaderCell: () => ({ - style: {minWidth: 80}, - }), - render: (_, record) => ( -
{formatTokenUsage(record.metrics?.acc?.tokens?.total)}
- ), - }, - { - title: "Total cost", - key: "total_cost", - dataIndex: ["metrics", "acc", "costs", "total"], - width: 80, - onHeaderCell: () => ({ - style: {minWidth: 80}, - }), - render: (_, record) =>
{formatCurrency(record.metrics?.acc?.costs?.total)}
, - }, - ]) - - const activeTraceIndex = useMemo( - () => - traces?.findIndex((item) => - traceTabs === "node" - ? item.node.id === selectedTraceId - : item.root.id === selectedTraceId, - ), - [selectedTraceId, traces, traceTabs], - ) - - const activeTrace = useMemo(() => traces[activeTraceIndex] ?? null, [activeTraceIndex, traces]) - - const [selected, setSelected] = useState("") - - useEffect(() => { - if (!selected) { - setSelected(activeTrace?.node.id) - } - }, [activeTrace, selected]) - - const selectedItem = useMemo( - () => (traces?.length ? getNodeById(traces, selected) : null), - [selected, traces], - ) - - const handleNextTrace = useCallback(() => { - if (activeTraceIndex !== undefined && activeTraceIndex < traces.length - 1) { - const nextTrace = traces[activeTraceIndex + 1] - if (traceTabs === "node") { - setSelectedTraceId(nextTrace.node.id) - } else { - setSelectedTraceId(nextTrace.root.id) - } - setSelected(nextTrace.node.id) - } - }, [activeTraceIndex, traces, traceTabs, setSelectedTraceId]) - - const handlePrevTrace = useCallback(() => { - if (activeTraceIndex !== undefined && activeTraceIndex > 0) { - const prevTrace = traces[activeTraceIndex - 1] - if (traceTabs === "node") { - setSelectedTraceId(prevTrace.node.id) - } else { - setSelectedTraceId(prevTrace.root.id) - } - setSelected(prevTrace.node.id) - } - }, [activeTraceIndex, traces, traceTabs, setSelectedTraceId]) - - const handleResize = - (key: string) => - (_: any, {size}: {size: {width: number}}) => { - setColumns((cols) => { - return cols.map((col) => ({ - ...col, - width: col.key === key ? size.width : col.width, - })) - }) - } - - const mergedColumns = useMemo(() => { - return columns.map((col) => ({ - ...col, - width: col.width || 200, - hidden: editColumns.includes(col.key as string), - onHeaderCell: (column: TableColumnType<_AgentaRootsResponse>) => ({ - width: column.width, - onResize: handleResize(column.key?.toString()!), - }), - })) - }, [columns, editColumns]) - - const filterColumns = [ - {type: "exists", value: "root.id", label: "root.id"}, - {type: "exists", value: "tree.id", label: "tree.id"}, - {type: "exists", value: "tree.type", label: "tree.type"}, - {type: "exists", value: "node.id", label: "node.id"}, - {type: "exists", value: "node.type", label: "node.type"}, - {type: "exists", value: "node.name", label: "node.name"}, - {type: "exists", value: "parent.id", label: "parent.id"}, - {type: "exists", value: "status.code", label: "status.code"}, - {type: "exists", value: "status.message", label: "status.message"}, - {type: "exists", value: "exception.type", label: "exception.type"}, - {type: "exists", value: "exception.message", label: "exception.message"}, - {type: "exists", value: "exception.stacktrace", label: "exception.stacktrace"}, - {type: "string", value: "data", label: "data"}, - {type: "number", value: "metrics.acc.duration.total", label: "metrics.acc.duration.total"}, - {type: "number", value: "metrics.acc.costs.total", label: "metrics.acc.costs.total"}, - {type: "number", value: "metrics.unit.costs.total", label: "metrics.unit.costs.total"}, - {type: "number", value: "metrics.acc.tokens.prompt", label: "metrics.acc.tokens.prompt"}, - { - type: "number", - value: "metrics.acc.tokens.completion", - label: "metrics.acc.tokens.completion", - }, - {type: "number", value: "metrics.acc.tokens.total", label: "metrics.acc.tokens.total"}, - {type: "number", value: "metrics.unit.tokens.prompt", label: "metrics.unit.tokens.prompt"}, - { - type: "number", - value: "metrics.unit.tokens.completion", - label: "metrics.unit.tokens.completion", - }, - {type: "number", value: "metrics.unit.tokens.total", label: "metrics.unit.tokens.total"}, - {type: "exists", value: "refs.variant.id", label: "refs.variant.id"}, - {type: "exists", value: "refs.variant.slug", label: "refs.variant.slug"}, - {type: "exists", value: "refs.variant.version", label: "refs.variant.version"}, - {type: "exists", value: "refs.environment.id", label: "refs.environment.id"}, - {type: "exists", value: "refs.environment.slug", label: "refs.environment.slug"}, - {type: "exists", value: "refs.environment.version", label: "refs.environment.version"}, - {type: "exists", value: "refs.application.id", label: "refs.application.id"}, - {type: "exists", value: "refs.application.slug", label: "refs.application.slug"}, - {type: "exists", value: "link.type", label: "link.type"}, - {type: "exists", value: "link.node.id", label: "link.node.id"}, - {type: "exists", value: "otel.kind", label: "otel.kind"}, - ] - - const onExport = async () => { - try { - if (traces.length) { - const {currentApp} = getAppValues() - const filename = `${currentApp?.app_name}_observability.csv` - - const convertToStringOrJson = (value: any) => { - return typeof value === "string" ? value : JSON.stringify(value) - } - - // Helper function to create a trace object - const createTraceObject = (trace: any) => ({ - "Trace ID": trace.key, - Timestamp: dayjs(trace.time.start).format("HH:mm:ss DD MMM YYYY"), - Inputs: trace?.data?.inputs?.topic || "N/A", - Outputs: convertToStringOrJson(trace?.data?.outputs) || "N/A", - Status: trace.status.code, - Latency: formatLatency(trace.metrics?.acc?.duration.total), - Usage: formatTokenUsage(trace.metrics?.acc?.tokens?.total || 0), - "Total Cost": formatCurrency(trace.metrics?.acc?.costs?.total || 0), - "Span Type": trace.node.type || "N/A", - "Span ID": trace.node.id, - }) - - const csvData = convertToCsv( - traces.flatMap((trace) => { - const parentTrace = createTraceObject(trace) - return trace.children && Array.isArray(trace.children) - ? [parentTrace, ...trace.children.map(createTraceObject)] - : [parentTrace] - }), - [ - ...columns.map((col) => - col.title === "ID" ? "Trace ID" : (col.title as string), - ), - "Span ID", - "Span Type", - ], - ) - - downloadCsv(csvData, filename) - } - } catch (error) { - console.error("Export error:", error) - } - } - - const handleToggleColumnVisibility = useCallback((key: string) => { - setEditColumns((prev) => - prev.includes(key) ? prev.filter((item) => item !== key) : [...prev, key], - ) - }, []) - - const updateFilter = ({ - key, - operator, - value, - }: { - key: string - operator: FilterConditions - value: string - }) => { - setFilters((prevFilters) => { - const otherFilters = prevFilters.filter((f) => f.key !== key) - return value ? [...otherFilters, {key, operator, value}] : otherFilters - }) - } - - const onPaginationChange = (current: number, pageSize: number) => { - setPagination({current, page: pageSize}) - } - - const onSearchChange = (e: React.ChangeEvent) => { - const query = e.target.value - setSearchQuery(query) - - if (!query) { - setFilters((prevFilters) => prevFilters.filter((f) => f.key !== "data")) - } - } - - const onSearchQueryApply = () => { - if (searchQuery) { - updateFilter({key: "data", operator: "contains", value: searchQuery}) - } - } - - const onSearchClear = () => { - const isSearchFilterExist = filters.some((item) => item.key === "data") - - if (isSearchFilterExist) { - setFilters((prevFilters) => prevFilters.filter((f) => f.key !== "data")) - } - } - // Sync searchQuery with filters state - useUpdateEffect(() => { - const dataFilter = filters.find((f) => f.key === "data") - setSearchQuery(dataFilter ? dataFilter.value : "") - }, [filters]) - - const onApplyFilter = useCallback((newFilters: Filter[]) => { - setFilters(newFilters) - }, []) - - const onClearFilter = useCallback(() => { - setFilters([]) - setSearchQuery("") - if (traceTabs === "chat") { - setTraceTabs("tree") - } - }, []) - - const onTraceTabChange = async (e: RadioChangeEvent) => { - const selectedTab = e.target.value - setTraceTabs(selectedTab) - - if (selectedTab === "chat") { - updateFilter({key: "node.type", operator: "is", value: selectedTab}) - } else { - const isNodeTypeFilterExist = filters.some( - (item) => item.key === "node.type" && item.value === "chat", - ) - - if (isNodeTypeFilterExist) { - setFilters((prevFilters) => prevFilters.filter((f) => f.key !== "node.type")) - } - } - if (pagination.current > 1) { - setPagination({...pagination, current: 1}) - } - } - // Sync traceTabs with filters state - useUpdateEffect(() => { - const nodeTypeFilter = filters.find((f) => f.key === "node.type")?.value - setTraceTabs((prev) => - nodeTypeFilter === "chat" ? "chat" : prev == "chat" ? "tree" : prev, - ) - }, [filters]) - - const onSortApply = useCallback(({type, sorted, customRange}: SortResult) => { - setSort({type, sorted, customRange}) - }, []) - - const fetchFilterdTrace = async () => { - const focusPoint = traceTabs == "tree" || traceTabs == "node" ? `focus=${traceTabs}` : "" - const filterQuery = filters[0]?.operator - ? `&filtering={"conditions":${JSON.stringify(filters)}}` - : "" - const paginationQuery = `&size=${pagination.page}&page=${pagination.current}` - - let sortQuery = "" - if (sort) { - sortQuery = - sort.type === "standard" - ? `&oldest=${sort.sorted}` - : sort.type === "custom" && sort.customRange?.startTime - ? `&oldest=${sort.customRange.startTime}&newest=${sort.customRange.endTime}` - : "" - } - - const data = await fetchTraces(`?${focusPoint}${paginationQuery}${sortQuery}${filterQuery}`) - - return data - } - - useUpdateEffect(() => { - fetchFilterdTrace() - }, [filters, traceTabs, sort, pagination]) - - return ( -
- Observability - -
- - - - - -
- - - Root - LLM - All - - - - - - - -
-
- -
-
[]} - dataSource={traces} - bordered - style={{cursor: "pointer"}} - onRow={(record) => ({ - onClick: () => { - setSelected(record.node.id) - if (traceTabs === "node") { - setSelectedTraceId(record.node.id) - } else { - setSelectedTraceId(record.root.id) - } - }, - })} - components={{ - header: { - cell: ResizableTitle, - }, - }} - pagination={false} - scroll={{x: "max-content"}} - locale={{ - emptyText: ( -
- - } - description="Monitor the performance and results of your LLM applications here." - primaryCta={{ - text: appId ? "Go to Playground" : "Create an Application", - onClick: () => - router.push( - appId ? `/apps/${appId}/playground` : "/apps", - ), - tooltip: - "Run your LLM app in the playground to generate and view insights.", - }} - secondaryCta={{ - text: "Learn More", - onClick: () => - router.push( - "https://docs.agenta.ai/observability/quickstart", - ), - tooltip: - "Explore more about tracking and analyzing your app's observability data.", - }} - /> -
- ), - }} - /> - - - - {activeTrace && !!traces?.length && ( - setSelectedTraceId("")} - expandable - headerExtra={ - - } - mainContent={selectedItem ? : null} - sideContent={ - - } - /> - )} - - ) +const Observability = () => { + return } -export default () => ( - - - -) +export default Observability diff --git a/agenta-web/src/pages/apps/observability/index.tsx b/agenta-web/src/pages/apps/observability/index.tsx deleted file mode 100644 index 570d76d051..0000000000 --- a/agenta-web/src/pages/apps/observability/index.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import React from "react" -import ObservabilityDashboard from "../[app_id]/observability" - -const index = () => { - return -} - -export default index diff --git a/agenta-web/src/pages/observability/index.tsx b/agenta-web/src/pages/observability/index.tsx new file mode 100644 index 0000000000..977a415277 --- /dev/null +++ b/agenta-web/src/pages/observability/index.tsx @@ -0,0 +1,12 @@ +import ProtectedRoute from "@/components/ProtectedRoute/ProtectedRoute" +import ObservabilityDashboard from "@/components/pages/observability/ObservabilityDashboard" + +const GlobalObservability = () => { + return +} + +export default () => ( + + + +) diff --git a/agenta-web/src/services/observability/core/index.ts b/agenta-web/src/services/observability/core/index.ts index abc5b46ee2..41d3146e8f 100644 --- a/agenta-web/src/services/observability/core/index.ts +++ b/agenta-web/src/services/observability/core/index.ts @@ -11,7 +11,7 @@ import axios from "@/lib/helpers/axiosConfig" export const fetchAllTraces = async ({appId, queries}: {appId: string; queries?: string}) => { const filterByAppId = appId - ? `filtering={"conditions":[{"key":"refs.application_id","operator":"is","value":"${appId}"}]}&` + ? `filtering={"conditions":[{"key":"refs.application.id","operator":"is","value":"${appId}"}]}&` : "" const response = await axios.get( From 755826a084b782e5e24d590cef59a5d11ce8edc0 Mon Sep 17 00:00:00 2001 From: ashrafchowdury Date: Fri, 1 Nov 2024 14:51:27 +0600 Subject: [PATCH 3/6] fix(frontend): renamed app observability into traces --- agenta-web/src/components/Sidebar/config.tsx | 14 +++++------ .../observability/ObservabilityDashboard.tsx | 25 +++++++++++-------- .../{observability => traces}/index.tsx | 4 +-- 3 files changed, 24 insertions(+), 19 deletions(-) rename agenta-web/src/pages/apps/[app_id]/{observability => traces}/index.tsx (69%) diff --git a/agenta-web/src/components/Sidebar/config.tsx b/agenta-web/src/components/Sidebar/config.tsx index de4794a42d..eaea606695 100644 --- a/agenta-web/src/components/Sidebar/config.tsx +++ b/agenta-web/src/components/Sidebar/config.tsx @@ -16,6 +16,7 @@ import { SlackLogo, Gear, Dot, + TreeView, } from "@phosphor-icons/react" import {useAppsData} from "@/contexts/app.context" @@ -70,8 +71,8 @@ export const useSidebarConfig = () => { isHidden: apps.length === 0, }, { - key: "global-observability-link", - title: "Global Observability", + key: "app-observability-link", + title: "Observability", link: `/observability`, icon: , divider: true, @@ -107,12 +108,11 @@ export const useSidebarConfig = () => { icon: , }, { - key: "app-observability-link", - title: "Observability", - icon: , + key: "app-traces-link", + title: "Traces", + icon: , isHidden: !appId && !recentlyVisitedAppId, - link: `/apps/${appId || recentlyVisitedAppId}/observability`, - cloudFeatureTooltip: "Observability available in Cloud/Enterprise editions only", + link: `/apps/${appId || recentlyVisitedAppId}/traces`, }, { key: "invite-teammate-link", diff --git a/agenta-web/src/components/pages/observability/ObservabilityDashboard.tsx b/agenta-web/src/components/pages/observability/ObservabilityDashboard.tsx index 297a5f9424..8ce64f8155 100644 --- a/agenta-web/src/components/pages/observability/ObservabilityDashboard.tsx +++ b/agenta-web/src/components/pages/observability/ObservabilityDashboard.tsx @@ -27,6 +27,7 @@ import { Table, TableColumnType, Tag, + Tooltip, Typography, } from "antd" import {ColumnsType} from "antd/es/table" @@ -122,30 +123,34 @@ const ObservabilityDashboard = () => { { title: "Inputs", key: "inputs", - width: 350, + width: 400, render: (_, record) => { return ( - - {getStringOrJson(record?.data?.inputs)} - + {getStringOrJson(record?.data?.inputs)} + ) }, }, { title: "Outputs", key: "outputs", - width: 350, + width: 400, render: (_, record) => { return ( - - {getStringOrJson(record?.data?.outputs)} - + {getStringOrJson(record?.data?.outputs)} + ) }, }, @@ -453,7 +458,7 @@ const ObservabilityDashboard = () => { }, []) const fetchFilterdTrace = async () => { - const focusPoint = traceTabs == "tree" || traceTabs == "node" ? `focus=${traceTabs}` : "" + const focusPoint = traceTabs == "chat" ? "focus=node" : `focus=${traceTabs}` const filterQuery = filters[0]?.operator ? `&filtering={"conditions":${JSON.stringify(filters)}}` : "" @@ -469,7 +474,7 @@ const ObservabilityDashboard = () => { : "" } - const data = await fetchTraces(`${focusPoint}${paginationQuery}${sortQuery}${filterQuery}`) + const data = await fetchTraces(`&${focusPoint}${paginationQuery}${sortQuery}${filterQuery}`) return data } diff --git a/agenta-web/src/pages/apps/[app_id]/observability/index.tsx b/agenta-web/src/pages/apps/[app_id]/traces/index.tsx similarity index 69% rename from agenta-web/src/pages/apps/[app_id]/observability/index.tsx rename to agenta-web/src/pages/apps/[app_id]/traces/index.tsx index a5b2574727..9865379fcf 100644 --- a/agenta-web/src/pages/apps/[app_id]/observability/index.tsx +++ b/agenta-web/src/pages/apps/[app_id]/traces/index.tsx @@ -1,7 +1,7 @@ import ObservabilityDashboard from "@/components/pages/observability/ObservabilityDashboard" -const Observability = () => { +const Traces = () => { return } -export default Observability +export default Traces From f574ea3b94a7262ad4ec06f41e963584f0d59611 Mon Sep 17 00:00:00 2001 From: ashrafchowdury Date: Fri, 1 Nov 2024 15:57:22 +0600 Subject: [PATCH 4/6] fix(frontend): endpoint query --- .../components/pages/observability/ObservabilityDashboard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agenta-web/src/components/pages/observability/ObservabilityDashboard.tsx b/agenta-web/src/components/pages/observability/ObservabilityDashboard.tsx index 8ce64f8155..d8582890f9 100644 --- a/agenta-web/src/components/pages/observability/ObservabilityDashboard.tsx +++ b/agenta-web/src/components/pages/observability/ObservabilityDashboard.tsx @@ -474,7 +474,7 @@ const ObservabilityDashboard = () => { : "" } - const data = await fetchTraces(`&${focusPoint}${paginationQuery}${sortQuery}${filterQuery}`) + const data = await fetchTraces(`${focusPoint}${paginationQuery}${sortQuery}${filterQuery}`) return data } From df64d61a5d0e6248cdedc61f0589cc390b6cac44 Mon Sep 17 00:00:00 2001 From: ashrafchowdury Date: Mon, 4 Nov 2024 16:53:36 +0600 Subject: [PATCH 5/6] fix(frontend): application default filter visible to users --- agenta-web/src/components/Filters/Filters.tsx | 25 +++++++++++++------ .../observability/ObservabilityDashboard.tsx | 8 +++--- .../src/contexts/observability.context.tsx | 18 ++++++------- agenta-web/src/lib/Types.ts | 1 + 4 files changed, 29 insertions(+), 23 deletions(-) diff --git a/agenta-web/src/components/Filters/Filters.tsx b/agenta-web/src/components/Filters/Filters.tsx index 391bdf2090..faea7c2379 100644 --- a/agenta-web/src/components/Filters/Filters.tsx +++ b/agenta-web/src/components/Filters/Filters.tsx @@ -37,9 +37,9 @@ type Props = { const Filters: React.FC = ({filterData, columns, onApplyFilter, onClearFilter}) => { const classes = useStyles() - const emptyFilter = [{key: "", operator: "", value: ""}] as Filter[] + const emptyFilter = [{key: "", operator: "", value: "", isPermanent: false}] as Filter[] - const [filter, setFilter] = useState(emptyFilter) + const [filter, setFilter] = useState(filterData || emptyFilter) const [isFilterOpen, setIsFilterOpen] = useState(false) useUpdateEffect(() => { @@ -80,7 +80,7 @@ const Filters: React.FC = ({filterData, columns, onApplyFilter, onClearFi value, idx, }: { - columnName: keyof Filter + columnName: Exclude value: any idx: number }) => { @@ -94,17 +94,24 @@ const Filters: React.FC = ({filterData, columns, onApplyFilter, onClearFi } const addNestedFilter = () => { - setFilter([...filter, {key: "", operator: "", value: ""}]) + setFilter([...filter, {key: "", operator: "", value: "", isPermanent: false}]) } const clearFilter = () => { - setFilter(emptyFilter) - onClearFilter(emptyFilter) + const clearedFilters = filter.filter((f) => f.isPermanent) + + if (JSON.stringify(clearedFilters) !== JSON.stringify(filter)) { + setFilter(clearedFilters) + onClearFilter(clearedFilters) + } } const applyFilter = () => { const sanitizedFilters = filter.filter(({key, operator}) => key && operator) - onApplyFilter(sanitizedFilters) + + if (JSON.stringify(sanitizedFilters) !== JSON.stringify(filterData)) { + onApplyFilter(sanitizedFilters) + } setIsFilterOpen(false) } @@ -153,6 +160,7 @@ const Filters: React.FC = ({filterData, columns, onApplyFilter, onClearFi } value={item.key} options={filteredColumns} + disabled={item.isPermanent} /> {item.key && ( @@ -173,12 +181,14 @@ const Filters: React.FC = ({filterData, columns, onApplyFilter, onClearFi popupMatchSelectWidth={100} value={item.operator} options={filteredOperators} + disabled={item.isPermanent} /> onFilterChange({ columnName: "value", @@ -193,6 +203,7 @@ const Filters: React.FC = ({filterData, columns, onApplyFilter, onClearFi