Jelajahi Sumber

rfac: use dayjs, implement logs buffer

Soham Parekh 3 tahun lalu
induk
melakukan
66fc8ebba7

+ 23 - 24
dashboard/package-lock.json

@@ -38,6 +38,7 @@
         "cronstrue": "^2.2.0",
         "d3-array": "^2.11.0",
         "d3-time-format": "^3.0.0",
+        "dayjs": "^1.11.5",
         "dotenv": "^8.2.0",
         "highlight.run": "^1.4.5",
         "ini": ">=1.3.6",
@@ -5527,6 +5528,11 @@
         "url": "https://opencollective.com/date-fns"
       }
     },
+    "node_modules/dayjs": {
+      "version": "1.11.5",
+      "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.5.tgz",
+      "integrity": "sha512-CAdX5Q3YW3Gclyo5Vpqkgpj8fSdLQcRuzfX6mC6Phy0nfJ0eGYOeS7m4mt2plDWLAtA4TqTakvbboHvUxfe4iA=="
+    },
     "node_modules/debounce": {
       "version": "1.2.1",
       "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz",
@@ -15791,8 +15797,7 @@
     "@icons/material": {
       "version": "0.2.4",
       "resolved": "https://registry.npmjs.org/@icons/material/-/material-0.2.4.tgz",
-      "integrity": "sha512-QPcGmICAPbGLGb6F/yNf/KzKqvFx8z5qx3D1yFqVAjoFmXK35EgyW+cJ57Te3CNsmzblwtzakLGFqHPqrfb4Tw==",
-      "requires": {}
+      "integrity": "sha512-QPcGmICAPbGLGb6F/yNf/KzKqvFx8z5qx3D1yFqVAjoFmXK35EgyW+cJ57Te3CNsmzblwtzakLGFqHPqrfb4Tw=="
     },
     "@ironplans/api": {
       "version": "0.4.1",
@@ -15921,8 +15926,7 @@
     "@material-ui/types": {
       "version": "5.1.0",
       "resolved": "https://registry.npmjs.org/@material-ui/types/-/types-5.1.0.tgz",
-      "integrity": "sha512-7cqRjrY50b8QzRSYyhSpx4WRw2YuO0KKIGQEVk5J8uoz2BanawykgZGoWEqKm7pVIbzFDN0SpPcVV4IhOFkl8A==",
-      "requires": {}
+      "integrity": "sha512-7cqRjrY50b8QzRSYyhSpx4WRw2YuO0KKIGQEVk5J8uoz2BanawykgZGoWEqKm7pVIbzFDN0SpPcVV4IhOFkl8A=="
     },
     "@material-ui/utils": {
       "version": "4.11.3",
@@ -16226,8 +16230,7 @@
       "version": "7.2.1",
       "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-7.2.1.tgz",
       "integrity": "sha512-oZ0Ib5I4Z2pUEcoo95cT1cr6slco9WY7yiPpG+RGNkj8YcYgJnM7pXmYmorNOReh8MIGcKSqXyeGjxnr8YiZbA==",
-      "dev": true,
-      "requires": {}
+      "dev": true
     },
     "@types/body-parser": {
       "version": "1.19.2",
@@ -17313,15 +17316,13 @@
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/ajv-errors/-/ajv-errors-1.0.1.tgz",
       "integrity": "sha512-DCRfO/4nQ+89p/RK43i8Ezd41EqdGIU4ld7nGF8OQ14oc/we5rEntLCUa7+jrn3nn83BosfwZA0wb4pon2o8iQ==",
-      "dev": true,
-      "requires": {}
+      "dev": true
     },
     "ajv-keywords": {
       "version": "3.5.2",
       "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz",
       "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==",
-      "dev": true,
-      "requires": {}
+      "dev": true
     },
     "anser": {
       "version": "2.1.0",
@@ -18280,8 +18281,7 @@
     "cohere-sentry": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/cohere-sentry/-/cohere-sentry-1.0.1.tgz",
-      "integrity": "sha512-OHdKcc8LED8X/JQKlMD0Zapb4rcOkPu0m11+okHouMDep1/MvyOG4JXcK4Mo3sabJT65yozc9Uo+nJfSWzaFcg==",
-      "requires": {}
+      "integrity": "sha512-OHdKcc8LED8X/JQKlMD0Zapb4rcOkPu0m11+okHouMDep1/MvyOG4JXcK4Mo3sabJT65yozc9Uo+nJfSWzaFcg=="
     },
     "collection-visit": {
       "version": "1.0.0",
@@ -18843,6 +18843,11 @@
       "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.29.3.tgz",
       "integrity": "sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA=="
     },
+    "dayjs": {
+      "version": "1.11.5",
+      "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.5.tgz",
+      "integrity": "sha512-CAdX5Q3YW3Gclyo5Vpqkgpj8fSdLQcRuzfX6mC6Phy0nfJ0eGYOeS7m4mt2plDWLAtA4TqTakvbboHvUxfe4iA=="
+    },
     "debounce": {
       "version": "1.2.1",
       "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz",
@@ -20498,8 +20503,7 @@
       "version": "5.1.0",
       "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz",
       "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==",
-      "dev": true,
-      "requires": {}
+      "dev": true
     },
     "ieee754": {
       "version": "1.2.1",
@@ -21282,8 +21286,7 @@
     "markdown-to-jsx": {
       "version": "7.1.3",
       "resolved": "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-7.1.3.tgz",
-      "integrity": "sha512-jtQ6VyT7rMT5tPV0g2EJakEnXLiPksnvlYtwQsVVZ611JsWGN8bQ1tVSDX4s6JllfEH6wmsYxNjTUAMrPmNA8w==",
-      "requires": {}
+      "integrity": "sha512-jtQ6VyT7rMT5tPV0g2EJakEnXLiPksnvlYtwQsVVZ611JsWGN8bQ1tVSDX4s6JllfEH6wmsYxNjTUAMrPmNA8w=="
     },
     "material-colors": {
       "version": "1.2.6",
@@ -22332,8 +22335,7 @@
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.0.0.tgz",
       "integrity": "sha512-bdHleFnP3kZ4NYDhuGlVK+CMrQ/pqUm8bx/oGL93K6gVwiclvX5x0n76fYMKuIGKzlABOy13zsvqjb0f92TEXw==",
-      "dev": true,
-      "requires": {}
+      "dev": true
     },
     "postcss-modules-local-by-default": {
       "version": "4.0.0",
@@ -22725,8 +22727,7 @@
     "react-onclickoutside": {
       "version": "6.12.2",
       "resolved": "https://registry.npmjs.org/react-onclickoutside/-/react-onclickoutside-6.12.2.tgz",
-      "integrity": "sha512-NMXGa223OnsrGVp5dJHkuKxQ4czdLmXSp5jSV9OqiCky9LOpPATn3vLldc+q5fK3gKbEHvr7J1u0yhBh/xYkpA==",
-      "requires": {}
+      "integrity": "sha512-NMXGa223OnsrGVp5dJHkuKxQ4czdLmXSp5jSV9OqiCky9LOpPATn3vLldc+q5fK3gKbEHvr7J1u0yhBh/xYkpA=="
     },
     "react-popper": {
       "version": "2.3.0",
@@ -22784,8 +22785,7 @@
     "react-table": {
       "version": "7.7.0",
       "resolved": "https://registry.npmjs.org/react-table/-/react-table-7.7.0.tgz",
-      "integrity": "sha512-jBlj70iBwOTvvImsU9t01LjFjy4sXEtclBovl3mTiqjz23Reu0DKnRza4zlLtOPACx6j2/7MrQIthIK1Wi+LIA==",
-      "requires": {}
+      "integrity": "sha512-jBlj70iBwOTvvImsU9t01LjFjy4sXEtclBovl3mTiqjz23Reu0DKnRza4zlLtOPACx6j2/7MrQIthIK1Wi+LIA=="
     },
     "react-transition-group": {
       "version": "4.4.2",
@@ -25944,8 +25944,7 @@
       "version": "7.5.5",
       "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.5.tgz",
       "integrity": "sha512-BAkMFcAzl8as1G/hArkxOxq3G7pjUqQ3gzYbLL0/5zNkph70e+lCoxBGnm6AW1+/aiNeV4fnKqZ8m4GZewmH2w==",
-      "dev": true,
-      "requires": {}
+      "dev": true
     },
     "xtend": {
       "version": "4.0.2",

+ 2 - 1
dashboard/package.json

@@ -33,6 +33,7 @@
     "cronstrue": "^2.2.0",
     "d3-array": "^2.11.0",
     "d3-time-format": "^3.0.0",
+    "dayjs": "^1.11.5",
     "dotenv": "^8.2.0",
     "highlight.run": "^1.4.5",
     "ini": ">=1.3.6",
@@ -61,7 +62,7 @@
   },
   "scripts": {
     "test": "echo \"Error: no test specified\" && exit 1",
-    "start": "webpack-dev-server",
+    "start": "npx webpack-dev-server",
     "build": "NODE_ENV=\"production\" webpack",
     "build-and-analyze": "ENABLE_ANALYZER=true NODE_ENV=\"production\" webpack"
   },

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/expanded-chart/logs-section/LogsSection.tsx

@@ -34,7 +34,7 @@ const LogsSection: React.FC<Props> = ({
   const [scrollToBottom, setScrollToBottom] = useState(true);
   const [searchText, setSearchText] = useState("");
   const [enteredSearchText, setEnteredSearchText] = useState("");
-  const [selectedDate, setSelectedDate] = useState<Date>(null);
+  const [selectedDate, setSelectedDate] = useState<Date | undefined>(undefined);
 
   const { logs, refresh, moveCursor } = useLogs(
     podFilter,

+ 139 - 72
dashboard/src/main/home/cluster-dashboard/expanded-chart/logs-section/useAgentLogs.ts

@@ -1,27 +1,49 @@
-import Anser from "anser";
-import { flatMap } from "lodash";
-import { useContext, useEffect, useMemo, useRef, useState } from "react";
+import Anser, { AnserJsonEntry } from "anser";
+import dayjs from "dayjs";
+import _ from "lodash";
+import { useContext, useEffect, useRef, useState } from "react";
 import api from "shared/api";
 import { Context } from "shared/Context";
 import { useWebsockets, NewWebsocketOptions } from "shared/hooks/useWebsockets";
 
-const MAX_LOGS = 250;
+const MAX_LOGS = 5000;
+const MAX_BUFFER_LOGS = 1000;
+
+export enum Direction {
+  forward = "forward",
+  backward = "backward",
+}
+
+interface Log {
+  line: AnserJsonEntry[];
+  lineNumber: number;
+  timestamp: string;
+}
+
+const parseLogs = (logs: string[] = []): AnserJsonEntry[][] => {
+  return logs.filter(Boolean).map((logLine: string) => {
+    const parsedLine = JSON.parse(logLine);
+    // TODO Move log parsing to the render method
+    const ansiLog = Anser.ansiToJson(parsedLine.log);
+    return ansiLog;
+  });
+};
 
 export const useLogs = (
   currentPod: string,
   namespace: string,
   searchParam: string,
   // if setDate is set, results are not live
-  setDate: Date
+  setDate?: Date
 ) => {
-  var d = new Date();
-  d.setDate(d.getDate() - 14);
+  const d = dayjs().subtract(14, "days");
 
   const isLive = !setDate;
+  const logsBufferRef = useRef<AnserJsonEntry[][]>([]);
   const { currentCluster, currentProject } = useContext(Context);
-  const [logs, setLogs] = useState<Anser.AnserJsonEntry[][]>([]);
-  const [startDate, setStartDate] = useState<Date>(d);
-  const [endDate, setEndDate] = useState<Date>(setDate || new Date());
+  const [logs, setLogs] = useState<AnserJsonEntry[][]>([]);
+  const [startDate, setStartDate] = useState<dayjs.Dayjs>(d);
+  const [endDate, setEndDate] = useState<dayjs.Dayjs>(dayjs(setDate));
 
   // if we are live:
   // - start date is initially set to 2 weeks ago
@@ -41,16 +63,40 @@ export const useLogs = (
     closeAllWebsockets,
   } = useWebsockets();
 
-  useEffect(() => {
-    return refresh();
-  }, [currentPod, namespace, searchParam, setDate]);
+  const updateLogs = (newLogs: AnserJsonEntry[][]) => {
+    setLogs((logs) => {
+      let currentLogs = logs;
+      currentLogs.push(...newLogs);
+      if (logs.length > MAX_LOGS) {
+        const logsToBeRemoved =
+          newLogs.length < MAX_BUFFER_LOGS ? newLogs.length : MAX_BUFFER_LOGS;
+        currentLogs = currentLogs.slice(logsToBeRemoved);
+      }
 
-  useEffect(() => {
-    // if the streaming is no longer live, close all websockets
-    if (!isLive) {
-      closeAllWebsockets();
+      return logs;
+    });
+  };
+
+  /**
+   * Flushes the logs buffer. If `discard` is true,
+   * it will update `current logs` before executing
+   * the flush operation
+   */
+  const flushLogsBuffer = (discard: boolean = false) => {
+    if (!discard) {
+      updateLogs(logsBufferRef.current ?? []);
     }
-  }, [isLive]);
+
+    logsBufferRef.current = [];
+  };
+
+  const pushLogs = (newLogs: AnserJsonEntry[][]) => {
+    logsBufferRef.current.push(...newLogs);
+
+    if (logsBufferRef.current.length > MAX_BUFFER_LOGS) {
+      flushLogsBuffer();
+    }
+  };
 
   const setupWebsocket = (websocketKey: string) => {
     const endpoint = `/api/projects/${currentProject.id}/clusters/${currentCluster.id}/namespaces/${namespace}/logs/loki?pod_selector=${currentPod}&namespace=${namespace}&search_param=${searchParam}`;
@@ -60,20 +106,9 @@ export const useLogs = (
         console.log("Opened websocket:", websocketKey);
       },
       onmessage: (evt: MessageEvent) => {
-        let newLogs: Anser.AnserJsonEntry[][] = [];
-
-        evt?.data?.split("\n").forEach((logLine: string) => {
-          if (logLine) {
-            var parsedLine = JSON.parse(logLine);
-
-            let ansiLog = Anser.ansiToJson(parsedLine.log);
-            newLogs.push(ansiLog);
-          }
-        });
+        const newLogs = parseLogs(evt?.data?.split("\n"));
 
-        setLogs((prevLogs) => {
-          return prevLogs.concat(newLogs);
-        });
+        pushLogs(newLogs);
       },
       onclose: () => {
         console.log("Closed websocket:", websocketKey);
@@ -85,10 +120,9 @@ export const useLogs = (
   };
 
   const queryLogs = (
-    initLogs: Anser.AnserJsonEntry[][],
     startDate: Date,
     endDate: Date,
-    direction: string,
+    direction: Direction,
     cb?: () => void
   ) => {
     api
@@ -109,25 +143,13 @@ export const useLogs = (
         }
       )
       .then((res) => {
-        var newLogs: Anser.AnserJsonEntry[][] = [];
-        res.data.logs?.forEach((logLine: any) => {
-          if (logLine) {
-            var parsedLine = JSON.parse(logLine.line);
-
-            let ansiLog = Anser.ansiToJson(parsedLine.log);
-            newLogs.push(ansiLog);
-          }
-        });
-
-        var modifiedLogs: Anser.AnserJsonEntry[][] = initLogs;
-
-        if (direction == "forward") {
-          modifiedLogs.push(...newLogs);
-        } else if (direction == "backward") {
-          modifiedLogs.push(...newLogs.reverse());
-        }
+        const newLogs = parseLogs(
+          res.data.logs?.filter(Boolean).map((logLine: any) => logLine.line)
+        );
 
-        setLogs([...modifiedLogs]);
+        pushLogs(
+          direction === Direction.backward ? newLogs.reverse() : newLogs
+        );
         cb && cb();
       });
   };
@@ -137,33 +159,43 @@ export const useLogs = (
       return;
     }
 
+    flushLogsBuffer(true);
     const websocketKey = `${currentPod}-${namespace}-websocket`;
-    var newEndDate = setDate || new Date();
-
-    queryLogs([], startDate, newEndDate, "backward", () => {
-      setEndDate(newEndDate);
-      closeWebsocket(websocketKey);
-
-      if (isLive) {
-        setupWebsocket(websocketKey);
-        return () => {
-          closeWebsocket(websocketKey);
-        };
+    const newEndDate = dayjs(setDate);
+
+    queryLogs(
+      startDate.toDate(),
+      newEndDate.toDate(),
+      Direction.backward,
+      () => {
+        setEndDate(newEndDate);
+        closeWebsocket(websocketKey);
+
+        if (isLive) {
+          setupWebsocket(websocketKey);
+          return () => {
+            closeWebsocket(websocketKey);
+          };
+        }
       }
-    });
+    );
   };
 
   const moveCursor = (direction: number) => {
     if (direction < 0) {
       // we query by setting the endDate equal to the previous startDate, and setting the direction
       // to "backward"
-      var twoWeeksAgo = new Date();
-      twoWeeksAgo.setDate(twoWeeksAgo.getDate() - 14);
-
-      queryLogs(logs, twoWeeksAgo, startDate, "backward", () => {
-        setEndDate(startDate);
-        setStartDate(twoWeeksAgo);
-      });
+      const twoWeeksAgo = dayjs().subtract(14, "days");
+
+      queryLogs(
+        twoWeeksAgo.toDate(),
+        startDate.toDate(),
+        Direction.backward,
+        () => {
+          setEndDate(startDate);
+          setStartDate(twoWeeksAgo);
+        }
+      );
     } else {
       if (isLive) {
         return;
@@ -171,14 +203,49 @@ export const useLogs = (
 
       // we query by setting the startDate equal to the previous endDate, setting the endDate equal to the
       // current time, and setting the direction to "forward"
-      var currDate = new Date();
-      queryLogs(logs, endDate, currDate, "forward", () => {
+      const currDate = dayjs();
+      queryLogs(endDate.toDate(), currDate.toDate(), Direction.forward, () => {
         setStartDate(endDate);
         setEndDate(currDate);
       });
     }
   };
 
+  useEffect(() => {
+    flushLogsBuffer(true);
+  }, []);
+
+  /**
+   * In some situations, we might never hit the limit for the max buffer size.
+   * An example is if the total logs for the pod < MAX_BUFFER_LOGS.
+   *
+   * For handling situations like this, we would want to force a flush operation
+   * on the buffer so that we dont have any stale logs
+   */
+  useEffect(() => {
+    /**
+     * We don't want users to wait for too long for the initial
+     * logs to appear. So we use a setTimeout for 1s to force-flush
+     * logs after 1s of load
+     */
+    setTimeout(flushLogsBuffer, 500);
+
+    const flushLogsBufferInterval = setInterval(flushLogsBuffer, 3000);
+
+    return () => clearInterval(flushLogsBufferInterval);
+  }, []);
+
+  useEffect(() => {
+    refresh();
+  }, [currentPod, namespace, searchParam, setDate]);
+
+  useEffect(() => {
+    // if the streaming is no longer live, close all websockets
+    if (!isLive) {
+      closeAllWebsockets();
+    }
+  }, [isLive]);
+
   return {
     logs,
     refresh,

+ 16 - 16
dashboard/src/main/home/dashboard/ClusterList.tsx

@@ -75,47 +75,47 @@ class Templates extends Component<PropsType, StateType> {
           <path
             d="M15.207 12.4403C16.8094 12.4403 18.1092 11.1414 18.1092 9.53907C18.1092 7.93673 16.8094 6.63782 15.207 6.63782"
             stroke="white"
-            stroke-width="1.5"
-            stroke-linecap="round"
+            strokeWidth="1.5"
+            strokeLinecap="round"
             stroke-linejoin="round"
           />
           <path
             d="M3.90217 12.4403C2.29983 12.4403 1 11.1414 1 9.53907C1 7.93673 2.29983 6.63782 3.90217 6.63782"
             stroke="white"
-            stroke-width="1.5"
-            stroke-linecap="round"
+            strokeWidth="1.5"
+            strokeLinecap="round"
             stroke-linejoin="round"
           />
           <path
-            fill-rule="evenodd"
-            clip-rule="evenodd"
+            fillRule="evenodd"
+            clipRule="evenodd"
             d="M9.54993 13.4133C7.4086 13.4133 5.69168 11.6964 5.69168 9.55417C5.69168 7.41284 7.4086 5.69592 9.54993 5.69592C11.6913 5.69592 13.4082 7.41284 13.4082 9.55417C13.4082 11.6964 11.6913 13.4133 9.54993 13.4133Z"
             stroke="white"
-            stroke-width="1.5"
-            stroke-linecap="round"
+            strokeWidth="1.5"
+            strokeLinecap="round"
             stroke-linejoin="round"
           />
           <path
             d="M6.66895 15.207C6.66895 16.8094 7.96787 18.1092 9.5702 18.1092C11.1725 18.1092 12.4715 16.8094 12.4715 15.207"
             stroke="white"
-            stroke-width="1.5"
-            stroke-linecap="round"
+            strokeWidth="1.5"
+            strokeLinecap="round"
             stroke-linejoin="round"
           />
           <path
             d="M6.66895 3.90217C6.66895 2.29983 7.96787 1 9.5702 1C11.1725 1 12.4715 2.29983 12.4715 3.90217"
             stroke="white"
-            stroke-width="1.5"
-            stroke-linecap="round"
+            strokeWidth="1.5"
+            strokeLinecap="round"
             stroke-linejoin="round"
           />
           <path
-            fill-rule="evenodd"
-            clip-rule="evenodd"
+            fillRule="evenodd"
+            clipRule="evenodd"
             d="M5.69591 9.54996C5.69591 7.40863 7.41283 5.69171 9.55508 5.69171C11.6964 5.69171 13.4133 7.40863 13.4133 9.54996C13.4133 11.6913 11.6964 13.4082 9.55508 13.4082C7.41283 13.4082 5.69591 11.6913 5.69591 9.54996Z"
             stroke="white"
-            stroke-width="1.5"
-            stroke-linecap="round"
+            strokeWidth="1.5"
+            strokeLinecap="round"
             stroke-linejoin="round"
           />
         </svg>

+ 22 - 22
dashboard/src/main/home/sidebar/ClusterSection.tsx

@@ -170,48 +170,48 @@ export const ClusterSection: React.FC<Props> = ({
               <path
                 d="M15.207 12.4403C16.8094 12.4403 18.1092 11.1414 18.1092 9.53907C18.1092 7.93673 16.8094 6.63782 15.207 6.63782"
                 stroke="white"
-                stroke-width="1.5"
-                stroke-linecap="round"
-                stroke-linejoin="round"
+                strokeWidth="1.5"
+                strokeLinecap="round"
+                strokeLinejoin="round"
               />
               <path
                 d="M3.90217 12.4403C2.29983 12.4403 1 11.1414 1 9.53907C1 7.93673 2.29983 6.63782 3.90217 6.63782"
                 stroke="white"
-                stroke-width="1.5"
-                stroke-linecap="round"
-                stroke-linejoin="round"
+                strokeWidth="1.5"
+                strokeLinecap="round"
+                strokeLinejoin="round"
               />
               <path
-                fill-rule="evenodd"
-                clip-rule="evenodd"
+                fillRule="evenodd"
+                clipRule="evenodd"
                 d="M9.54993 13.4133C7.4086 13.4133 5.69168 11.6964 5.69168 9.55417C5.69168 7.41284 7.4086 5.69592 9.54993 5.69592C11.6913 5.69592 13.4082 7.41284 13.4082 9.55417C13.4082 11.6964 11.6913 13.4133 9.54993 13.4133Z"
                 stroke="white"
-                stroke-width="1.5"
-                stroke-linecap="round"
-                stroke-linejoin="round"
+                strokeWidth="1.5"
+                strokeLinecap="round"
+                strokeLinejoin="round"
               />
               <path
                 d="M6.66895 15.207C6.66895 16.8094 7.96787 18.1092 9.5702 18.1092C11.1725 18.1092 12.4715 16.8094 12.4715 15.207"
                 stroke="white"
-                stroke-width="1.5"
-                stroke-linecap="round"
-                stroke-linejoin="round"
+                strokeWidth="1.5"
+                strokeLinecap="round"
+                strokeLinejoin="round"
               />
               <path
                 d="M6.66895 3.90217C6.66895 2.29983 7.96787 1 9.5702 1C11.1725 1 12.4715 2.29983 12.4715 3.90217"
                 stroke="white"
-                stroke-width="1.5"
-                stroke-linecap="round"
-                stroke-linejoin="round"
+                strokeWidth="1.5"
+                strokeLinecap="round"
+                strokeLinejoin="round"
               />
               <path
-                fill-rule="evenodd"
-                clip-rule="evenodd"
+                fillRule="evenodd"
+                clipRule="evenodd"
                 d="M5.69591 9.54996C5.69591 7.40863 7.41283 5.69171 9.55508 5.69171C11.6964 5.69171 13.4133 7.40863 13.4133 9.54996C13.4133 11.6913 11.6964 13.4082 9.55508 13.4082C7.41283 13.4082 5.69591 11.6913 5.69591 9.54996Z"
                 stroke="white"
-                stroke-width="1.5"
-                stroke-linecap="round"
-                stroke-linejoin="round"
+                strokeWidth="1.5"
+                strokeLinecap="round"
+                strokeLinejoin="round"
               />
             </svg>
           </ClusterIcon>