Просмотр исходного кода

Merge branch 'dev' of https://github.com/porter-dev/porter into dev

Alexander Belanger 3 лет назад
Родитель
Сommit
035d37470a

+ 80 - 121
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",
@@ -365,11 +366,11 @@
       }
     },
     "node_modules/@babel/helper-module-imports": {
-      "version": "7.16.0",
-      "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.16.0.tgz",
-      "integrity": "sha512-kkH7sWzKPq0xt3H1n+ghb4xEMP8k0U7XV3kkB+ZGy69kDk2ySFW1qPi06sjKzFY3t1j6XbJSqr4mF9L7CYVyhg==",
+      "version": "7.18.6",
+      "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.18.6.tgz",
+      "integrity": "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==",
       "dependencies": {
-        "@babel/types": "^7.16.0"
+        "@babel/types": "^7.18.6"
       },
       "engines": {
         "node": ">=6.9.0"
@@ -407,9 +408,9 @@
       }
     },
     "node_modules/@babel/helper-plugin-utils": {
-      "version": "7.14.5",
-      "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.14.5.tgz",
-      "integrity": "sha512-/37qQCE3K0vvZKwoK4XU/irIJQdIfCJuhU5eKnNxpFDsOkgFaUAwbv+RYw6eYgsC0E4hS7r5KqGULUogqui0fQ==",
+      "version": "7.19.0",
+      "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.19.0.tgz",
+      "integrity": "sha512-40Ryx7I8mT+0gaNxm8JGTZFUITNqdLAgdg0hXzeVZxVD6nFsdhQvip6v8dqkRHzsz1VFpFAaOCHNn0vKBL7Czw==",
       "dev": true,
       "engines": {
         "node": ">=6.9.0"
@@ -479,10 +480,18 @@
         "node": ">=6.9.0"
       }
     },
+    "node_modules/@babel/helper-string-parser": {
+      "version": "7.19.4",
+      "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.19.4.tgz",
+      "integrity": "sha512-nHtDoQcuqFmwYNYPz3Rah5ph2p8PFeFCsZk9A/48dPc/rGocJ5J3hAAZ7pb76VWX3fZKu+uEr/FhH5jLx7umrw==",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
     "node_modules/@babel/helper-validator-identifier": {
-      "version": "7.15.7",
-      "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.15.7.tgz",
-      "integrity": "sha512-K4JvCtQqad9OY2+yTU8w+E82ywk/fe+ELNlt1G8z3bVGlZfn/hOcQQsUhGhW/N+tb3fxK800wLtKOE/aM0m72w==",
+      "version": "7.19.1",
+      "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.19.1.tgz",
+      "integrity": "sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w==",
       "engines": {
         "node": ">=6.9.0"
       }
@@ -905,12 +914,12 @@
       }
     },
     "node_modules/@babel/plugin-syntax-jsx": {
-      "version": "7.16.0",
-      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.16.0.tgz",
-      "integrity": "sha512-8zv2+xiPHwly31RK4RmnEYY5zziuF3O7W2kIDW+07ewWDh6Oi0dRq8kwvulRkFgt6DB97RlKs5c1y068iPlCUg==",
+      "version": "7.18.6",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.18.6.tgz",
+      "integrity": "sha512-6mmljtAedFGTWu2p/8WIORGwy+61PLgOMPOdazc7YoJ9ZCWUyFy3A6CpPkRKLKD1ToAesxX8KGEViAiLo9N+7Q==",
       "dev": true,
       "dependencies": {
-        "@babel/helper-plugin-utils": "^7.14.5"
+        "@babel/helper-plugin-utils": "^7.18.6"
       },
       "engines": {
         "node": ">=6.9.0"
@@ -1773,9 +1782,9 @@
       }
     },
     "node_modules/@babel/runtime": {
-      "version": "7.16.3",
-      "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.16.3.tgz",
-      "integrity": "sha512-WBwekcqacdY2e9AF/Q7WLFUWmdJGJTkbjqTjoMDgXkVZ3ZRUvOPsLb5KdwISoQVsbP+DQzVZW4Zhci0DvpbNTQ==",
+      "version": "7.19.4",
+      "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.19.4.tgz",
+      "integrity": "sha512-EXpLCrk55f+cYqmHsSR+yD/0gAIMxxA9QK9lnQWzhMCvt+YmoBN7Zx94s++Kv0+unHk39vxNO8t+CMA2WSS3wA==",
       "dependencies": {
         "regenerator-runtime": "^0.13.4"
       },
@@ -1829,11 +1838,12 @@
       }
     },
     "node_modules/@babel/types": {
-      "version": "7.16.0",
-      "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.16.0.tgz",
-      "integrity": "sha512-PJgg/k3SdLsGb3hhisFvtLOw5ts113klrpLuIPtCJIU+BB24fqq6lf8RWqKJEjzqXR9AEH1rIb5XTqwBHB+kQg==",
+      "version": "7.19.4",
+      "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.19.4.tgz",
+      "integrity": "sha512-M5LK7nAeS6+9j7hAq+b3fQs+pNfUtTGq+yFFfHnauFA8zQtLRfmuipmsKDKKLuyG+wC8ABW43A153YNawNTEtw==",
       "dependencies": {
-        "@babel/helper-validator-identifier": "^7.15.7",
+        "@babel/helper-string-parser": "^7.19.4",
+        "@babel/helper-validator-identifier": "^7.19.1",
         "to-fast-properties": "^2.0.0"
       },
       "engines": {
@@ -4049,37 +4059,6 @@
         "styled-components": ">= 2"
       }
     },
-    "node_modules/babel-plugin-styled-components/node_modules/@babel/helper-module-imports": {
-      "version": "7.16.0",
-      "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.16.0.tgz",
-      "integrity": "sha512-kkH7sWzKPq0xt3H1n+ghb4xEMP8k0U7XV3kkB+ZGy69kDk2ySFW1qPi06sjKzFY3t1j6XbJSqr4mF9L7CYVyhg==",
-      "dependencies": {
-        "@babel/types": "^7.16.0"
-      },
-      "engines": {
-        "node": ">=6.9.0"
-      }
-    },
-    "node_modules/babel-plugin-styled-components/node_modules/@babel/helper-validator-identifier": {
-      "version": "7.15.7",
-      "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.15.7.tgz",
-      "integrity": "sha512-K4JvCtQqad9OY2+yTU8w+E82ywk/fe+ELNlt1G8z3bVGlZfn/hOcQQsUhGhW/N+tb3fxK800wLtKOE/aM0m72w==",
-      "engines": {
-        "node": ">=6.9.0"
-      }
-    },
-    "node_modules/babel-plugin-styled-components/node_modules/@babel/types": {
-      "version": "7.16.0",
-      "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.16.0.tgz",
-      "integrity": "sha512-PJgg/k3SdLsGb3hhisFvtLOw5ts113klrpLuIPtCJIU+BB24fqq6lf8RWqKJEjzqXR9AEH1rIb5XTqwBHB+kQg==",
-      "dependencies": {
-        "@babel/helper-validator-identifier": "^7.15.7",
-        "to-fast-properties": "^2.0.0"
-      },
-      "engines": {
-        "node": ">=6.9.0"
-      }
-    },
     "node_modules/babel-plugin-syntax-jsx": {
       "version": "6.18.0",
       "resolved": "https://registry.npmjs.org/babel-plugin-syntax-jsx/-/babel-plugin-syntax-jsx-6.18.0.tgz",
@@ -5527,6 +5506,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",
@@ -14751,11 +14735,11 @@
       }
     },
     "@babel/helper-module-imports": {
-      "version": "7.16.0",
-      "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.16.0.tgz",
-      "integrity": "sha512-kkH7sWzKPq0xt3H1n+ghb4xEMP8k0U7XV3kkB+ZGy69kDk2ySFW1qPi06sjKzFY3t1j6XbJSqr4mF9L7CYVyhg==",
+      "version": "7.18.6",
+      "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.18.6.tgz",
+      "integrity": "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==",
       "requires": {
-        "@babel/types": "^7.16.0"
+        "@babel/types": "^7.18.6"
       }
     },
     "@babel/helper-module-transforms": {
@@ -14784,9 +14768,9 @@
       }
     },
     "@babel/helper-plugin-utils": {
-      "version": "7.14.5",
-      "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.14.5.tgz",
-      "integrity": "sha512-/37qQCE3K0vvZKwoK4XU/irIJQdIfCJuhU5eKnNxpFDsOkgFaUAwbv+RYw6eYgsC0E4hS7r5KqGULUogqui0fQ==",
+      "version": "7.19.0",
+      "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.19.0.tgz",
+      "integrity": "sha512-40Ryx7I8mT+0gaNxm8JGTZFUITNqdLAgdg0hXzeVZxVD6nFsdhQvip6v8dqkRHzsz1VFpFAaOCHNn0vKBL7Czw==",
       "dev": true
     },
     "@babel/helper-remap-async-to-generator": {
@@ -14838,10 +14822,15 @@
         "@babel/types": "^7.16.0"
       }
     },
+    "@babel/helper-string-parser": {
+      "version": "7.19.4",
+      "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.19.4.tgz",
+      "integrity": "sha512-nHtDoQcuqFmwYNYPz3Rah5ph2p8PFeFCsZk9A/48dPc/rGocJ5J3hAAZ7pb76VWX3fZKu+uEr/FhH5jLx7umrw=="
+    },
     "@babel/helper-validator-identifier": {
-      "version": "7.15.7",
-      "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.15.7.tgz",
-      "integrity": "sha512-K4JvCtQqad9OY2+yTU8w+E82ywk/fe+ELNlt1G8z3bVGlZfn/hOcQQsUhGhW/N+tb3fxK800wLtKOE/aM0m72w=="
+      "version": "7.19.1",
+      "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.19.1.tgz",
+      "integrity": "sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w=="
     },
     "@babel/helper-validator-option": {
       "version": "7.14.5",
@@ -15120,12 +15109,12 @@
       }
     },
     "@babel/plugin-syntax-jsx": {
-      "version": "7.16.0",
-      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.16.0.tgz",
-      "integrity": "sha512-8zv2+xiPHwly31RK4RmnEYY5zziuF3O7W2kIDW+07ewWDh6Oi0dRq8kwvulRkFgt6DB97RlKs5c1y068iPlCUg==",
+      "version": "7.18.6",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.18.6.tgz",
+      "integrity": "sha512-6mmljtAedFGTWu2p/8WIORGwy+61PLgOMPOdazc7YoJ9ZCWUyFy3A6CpPkRKLKD1ToAesxX8KGEViAiLo9N+7Q==",
       "dev": true,
       "requires": {
-        "@babel/helper-plugin-utils": "^7.14.5"
+        "@babel/helper-plugin-utils": "^7.18.6"
       }
     },
     "@babel/plugin-syntax-logical-assignment-operators": {
@@ -15702,9 +15691,9 @@
       }
     },
     "@babel/runtime": {
-      "version": "7.16.3",
-      "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.16.3.tgz",
-      "integrity": "sha512-WBwekcqacdY2e9AF/Q7WLFUWmdJGJTkbjqTjoMDgXkVZ3ZRUvOPsLb5KdwISoQVsbP+DQzVZW4Zhci0DvpbNTQ==",
+      "version": "7.19.4",
+      "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.19.4.tgz",
+      "integrity": "sha512-EXpLCrk55f+cYqmHsSR+yD/0gAIMxxA9QK9lnQWzhMCvt+YmoBN7Zx94s++Kv0+unHk39vxNO8t+CMA2WSS3wA==",
       "requires": {
         "regenerator-runtime": "^0.13.4"
       }
@@ -15746,11 +15735,12 @@
       }
     },
     "@babel/types": {
-      "version": "7.16.0",
-      "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.16.0.tgz",
-      "integrity": "sha512-PJgg/k3SdLsGb3hhisFvtLOw5ts113klrpLuIPtCJIU+BB24fqq6lf8RWqKJEjzqXR9AEH1rIb5XTqwBHB+kQg==",
+      "version": "7.19.4",
+      "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.19.4.tgz",
+      "integrity": "sha512-M5LK7nAeS6+9j7hAq+b3fQs+pNfUtTGq+yFFfHnauFA8zQtLRfmuipmsKDKKLuyG+wC8ABW43A153YNawNTEtw==",
       "requires": {
-        "@babel/helper-validator-identifier": "^7.15.7",
+        "@babel/helper-string-parser": "^7.19.4",
+        "@babel/helper-validator-identifier": "^7.19.1",
         "to-fast-properties": "^2.0.0"
       }
     },
@@ -15791,8 +15781,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 +15910,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 +16214,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 +17300,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",
@@ -17602,30 +17587,6 @@
         "@babel/helper-module-imports": "^7.15.4",
         "babel-plugin-syntax-jsx": "^6.18.0",
         "lodash": "^4.17.11"
-      },
-      "dependencies": {
-        "@babel/helper-module-imports": {
-          "version": "7.16.0",
-          "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.16.0.tgz",
-          "integrity": "sha512-kkH7sWzKPq0xt3H1n+ghb4xEMP8k0U7XV3kkB+ZGy69kDk2ySFW1qPi06sjKzFY3t1j6XbJSqr4mF9L7CYVyhg==",
-          "requires": {
-            "@babel/types": "^7.16.0"
-          }
-        },
-        "@babel/helper-validator-identifier": {
-          "version": "7.15.7",
-          "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.15.7.tgz",
-          "integrity": "sha512-K4JvCtQqad9OY2+yTU8w+E82ywk/fe+ELNlt1G8z3bVGlZfn/hOcQQsUhGhW/N+tb3fxK800wLtKOE/aM0m72w=="
-        },
-        "@babel/types": {
-          "version": "7.16.0",
-          "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.16.0.tgz",
-          "integrity": "sha512-PJgg/k3SdLsGb3hhisFvtLOw5ts113klrpLuIPtCJIU+BB24fqq6lf8RWqKJEjzqXR9AEH1rIb5XTqwBHB+kQg==",
-          "requires": {
-            "@babel/helper-validator-identifier": "^7.15.7",
-            "to-fast-properties": "^2.0.0"
-          }
-        }
       }
     },
     "babel-plugin-syntax-jsx": {
@@ -18280,8 +18241,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 +18803,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 +20463,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 +21246,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 +22295,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 +22687,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 +22745,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 +25904,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"
   },

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

@@ -1,16 +1,19 @@
-import React, { useContext, useEffect, useState } from "react";
+import React, {
+  useCallback,
+  useContext,
+  useEffect,
+  useRef,
+  useState,
+} from "react";
 
 import styled from "styled-components";
 import RadioFilter from "components/RadioFilter";
 
 import filterOutline from "assets/filter-outline.svg";
-import downArrow from "assets/down-arrow.svg";
 import { Context } from "shared/Context";
 import api from "shared/api";
-import { useLogs } from "./useAgentLogs";
+import { Direction, useLogs } from "./useAgentLogs";
 import Anser from "anser";
-import { flatMap } from "lodash";
-import time from "assets/time.svg";
 import DateTimePicker from "components/date-time-picker/DateTimePicker";
 
 type Props = {
@@ -28,13 +31,14 @@ const LogsSection: React.FC<Props> = ({
   isFullscreen,
   setIsFullscreen,
 }) => {
+  const scrollToBottomRef = useRef<HTMLDivElement | undefined>(undefined);
   const { currentProject, currentCluster } = useContext(Context);
   const [podFilter, setPodFilter] = useState();
   const [podFilterOpts, setPodFilterOpts] = useState<string[]>();
-  const [scrollToBottom, setScrollToBottom] = useState(true);
+  const [scrollToBottomEnabled, setScrollToBottomEnabled] = 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,
@@ -56,25 +60,32 @@ const LogsSection: React.FC<Props> = ({
         }
       )
       .then((res) => {
-        console.log(res.data);
         setPodFilterOpts(res.data);
         setPodFilter(res.data[0]);
       });
-
-    console.log(currentChart);
   }, []);
 
+  useEffect(() => {
+    if (scrollToBottomRef.current && scrollToBottomEnabled) {
+      scrollToBottomRef.current.scrollIntoView({
+        behavior: "smooth",
+        block: "end",
+      });
+    }
+  }, [logs, scrollToBottomRef, scrollToBottomEnabled]);
+
   const renderLogs = () => {
     return logs?.map((log, i) => {
       return (
-        <Log key={i}>
-          {log.map((ansi, j) => {
+        <Log key={[log.lineNumber, i].join(".")}>
+          <span className="line-number">{log.lineNumber}.</span>
+          {log.line.map((ansi, j) => {
             if (ansi.clearLine) {
               return null;
             }
 
             return (
-              <LogSpan key={i + "." + j} ansi={ansi}>
+              <LogSpan key={[log.lineNumber, i, j].join(".")} ansi={ansi}>
                 {ansi.content.replace(/ /g, "\u00a0")}
               </LogSpan>
             );
@@ -124,8 +135,8 @@ const LogsSection: React.FC<Props> = ({
             />
           </Flex>
           <Flex>
-            <Button onClick={() => setScrollToBottom(!scrollToBottom)}>
-              <Checkbox checked={scrollToBottom}>
+            <Button onClick={() => setScrollToBottomEnabled((s) => !s)}>
+              <Checkbox checked={scrollToBottomEnabled}>
                 <i className="material-icons">done</i>
               </Checkbox>
               Scroll to bottom
@@ -146,6 +157,14 @@ const LogsSection: React.FC<Props> = ({
           </Flex>
         </FlexRow>
         <StyledLogsSection isFullscreen={isFullscreen}>
+          <LoadMoreButton
+            active={selectedDate && logs.length !== 0}
+            role="button"
+            onClick={() => moveCursor(Direction.backward)}
+            ref={scrollToBottomRef}
+          >
+            Load Previous
+          </LoadMoreButton>
           {renderLogs()}
           {/* <Message>
             
@@ -155,6 +174,14 @@ const LogsSection: React.FC<Props> = ({
               Refresh
             </Highlight>
           </Message> */}
+          <LoadMoreButton
+            active={selectedDate && logs.length !== 0}
+            role="button"
+            onClick={() => moveCursor(Direction.forward)}
+            ref={scrollToBottomRef}
+          >
+            Load more
+          </LoadMoreButton>
         </StyledLogsSection>
       </>
     );
@@ -382,8 +409,7 @@ const StyledLogsSection = styled.div<{ isFullscreen: boolean }>`
   border-radius: ${(props) => (props.isFullscreen ? "" : "8px")};
   border: ${(props) => (props.isFullscreen ? "" : "1px solid #ffffff33")};
   border-top: ${(props) => (props.isFullscreen ? "1px solid #ffffff33" : "")};
-  padding: 18px 22px;
-  background: #121318;
+  background: #101420;
   animation: floatIn 0.3s;
   animation-timing-function: ease-out;
   animation-fill-mode: forwards;
@@ -404,6 +430,22 @@ const StyledLogsSection = styled.div<{ isFullscreen: boolean }>`
 const Log = styled.div`
   font-family: monospace;
   user-select: text;
+  display: flex;
+  align-items: flex-end;
+  gap: 8px;
+  & > * {
+    padding-block: 5px;
+  }
+  & > .line-number {
+    height: 100%;
+    background: #202538;
+    display: inline-block;
+    text-align: right;
+    min-width: 45px;
+    padding-inline-end: 5px;
+    opacity: 0.3;
+    font-family: monospace;
+  }
 `;
 
 const LogSpan = styled.span`
@@ -416,3 +458,14 @@ const LogSpan = styled.span`
   background-color: ${(props: { ansi: Anser.AnserJsonEntry }) =>
     props.ansi?.bg ? `rgb(${props.ansi?.bg})` : "transparent"};
 `;
+
+const LoadMoreButton = styled.div<{ active: boolean }>`
+  width: 100%;
+  display: ${(props) => (props.active ? "flex" : "none")};
+  justify-content: center;
+  align-items: center;
+  padding-block: 10px;
+  background: #1f2023;
+  cursor: pointer;
+  font-family: monospace;
+`;

+ 197 - 84
dashboard/src/main/home/cluster-dashboard/expanded-chart/logs-section/useAgentLogs.ts

@@ -1,27 +1,55 @@
-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;
+}
+
+interface LogLine {
+  log: string;
+  stream: string;
+  time: string;
+}
+
+const parseLogs = (logs: string[] = []): Log[] => {
+  return logs.filter(Boolean).map((logLine: string, idx) => {
+    const parsedLine: LogLine = JSON.parse(logLine);
+    // TODO Move log parsing to the render method
+    const ansiLog = Anser.ansiToJson(parsedLine.log);
+    return {
+      line: ansiLog,
+      lineNumber: idx + 1,
+      timestamp: parsedLine.time,
+    };
+  });
+};
 
 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 isLive = !setDate;
+  const logsBufferRef = useRef<Log[]>([]);
   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<Log[]>([]);
 
   // if we are live:
   // - start date is initially set to 2 weeks ago
@@ -41,16 +69,80 @@ export const useLogs = (
     closeAllWebsockets,
   } = useWebsockets();
 
-  useEffect(() => {
-    return refresh();
-  }, [currentPod, namespace, searchParam, setDate]);
+  const updateLogs = (
+    newLogs: Log[],
+    direction: Direction = Direction.forward
+  ) => {
+    // Nothing to update here
+    if (!newLogs.length) {
+      return;
+    }
 
-  useEffect(() => {
-    // if the streaming is no longer live, close all websockets
-    if (!isLive) {
-      closeAllWebsockets();
+    setLogs((logs) => {
+      let updatedLogs = _.cloneDeep(logs);
+
+      /**
+       * If direction = Direction.forward, we want to append the new logs
+       * at the end of the current logs, else we want to append before the current logs
+       *
+       */
+      if (direction === Direction.forward) {
+        const lastLineNumber = updatedLogs.at(-1)?.lineNumber ?? 0;
+
+        updatedLogs.push(
+          ...newLogs.map((log) => ({
+            ...log,
+            lineNumber: lastLineNumber + log.lineNumber,
+          }))
+        );
+
+        // For direction = Direction.forward, remove logs from the front
+        if (updatedLogs.length > MAX_LOGS) {
+          const logsToBeRemoved =
+            newLogs.length < MAX_BUFFER_LOGS ? newLogs.length : MAX_BUFFER_LOGS;
+          updatedLogs = updatedLogs.slice(logsToBeRemoved);
+        }
+      } else {
+        updatedLogs = newLogs.concat(
+          updatedLogs.map((log) => ({
+            ...log,
+            lineNumber: log.lineNumber + newLogs.length,
+          }))
+        );
+
+        // For direction = Direction.backward, remove logs from the back
+        if (updatedLogs.length > MAX_LOGS) {
+          const logsToBeRemoved =
+            newLogs.length < MAX_BUFFER_LOGS ? newLogs.length : MAX_BUFFER_LOGS;
+
+          updatedLogs = updatedLogs.slice(0, logsToBeRemoved);
+        }
+      }
+
+      return updatedLogs;
+    });
+  };
+
+  /**
+   * 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: Log[]) => {
+    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 +152,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);
+        const newLogs = parseLogs(evt?.data?.split("\n"));
 
-            let ansiLog = Anser.ansiToJson(parsedLine.log);
-            newLogs.push(ansiLog);
-          }
-        });
-
-        setLogs((prevLogs) => {
-          return prevLogs.concat(newLogs);
-        });
+        pushLogs(newLogs);
       },
       onclose: () => {
         console.log("Closed websocket:", websocketKey);
@@ -84,14 +165,8 @@ export const useLogs = (
     openWebsocket(websocketKey);
   };
 
-  const queryLogs = (
-    initLogs: Anser.AnserJsonEntry[][],
-    startDate: Date,
-    endDate: Date,
-    direction: string,
-    cb?: () => void
-  ) => {
-    api
+  const queryLogs = (startDate: Date, endDate: Date, direction: Direction) => {
+    return api
       .getLogs(
         "<token>",
         {
@@ -109,61 +184,57 @@ 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]);
-        cb && cb();
+        return newLogs;
       });
   };
 
-  const refresh = () => {
+  const refresh = async () => {
     if (!currentPod) {
       return;
     }
 
+    setLogs([]);
+    flushLogsBuffer(true);
     const websocketKey = `${currentPod}-${namespace}-websocket`;
-    var newEndDate = setDate || new Date();
+    const endDate = dayjs(setDate);
+    const twoWeeksAgo = endDate.subtract(14, "days");
 
-    queryLogs([], startDate, newEndDate, "backward", () => {
-      setEndDate(newEndDate);
-      closeWebsocket(websocketKey);
+    const initialLogs = await queryLogs(
+      twoWeeksAgo.toDate(),
+      endDate.toDate(),
+      Direction.forward
+    );
 
-      if (isLive) {
-        setupWebsocket(websocketKey);
-        return () => {
-          closeWebsocket(websocketKey);
-        };
-      }
-    });
+    updateLogs(initialLogs);
+
+    closeWebsocket(websocketKey);
+
+    if (isLive) {
+      setupWebsocket(websocketKey);
+    }
+
+    return () => isLive && closeWebsocket(websocketKey);
   };
 
-  const moveCursor = (direction: number) => {
-    if (direction < 0) {
+  const moveCursor = async (direction: Direction) => {
+    if (direction === Direction.backward) {
       // 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);
+      const refDate = dayjs(logs[0]?.timestamp);
+      const twoWeeksAgo = refDate.subtract(14, "days");
 
-      queryLogs(logs, twoWeeksAgo, startDate, "backward", () => {
-        setEndDate(startDate);
-        setStartDate(twoWeeksAgo);
-      });
+      const newLogs = await queryLogs(
+        twoWeeksAgo.toDate(),
+        refDate.toDate(),
+        Direction.backward
+      );
+
+      // TODO For backwards query, we want to add to the front of the current logs rather than append at the end
+      updateLogs(newLogs);
     } else {
       if (isLive) {
         return;
@@ -171,14 +242,56 @@ 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", () => {
-        setStartDate(endDate);
-        setEndDate(currDate);
-      });
+      const refDate = logs.length
+        ? dayjs(logs.at(-1).timestamp)
+        : dayjs(setDate);
+      const currDate = dayjs();
+      const newLogs = await queryLogs(
+        refDate.toDate(),
+        currDate.toDate(),
+        Direction.forward
+      );
+
+      updateLogs(newLogs);
     }
   };
 
+  useEffect(() => {
+    setLogs([]);
+    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>