From ab4d394ad2bcf01869cf1ee23024edb6fa5d90a1 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Mon, 9 Mar 2026 17:24:44 +0200 Subject: [PATCH 1/8] feat: add scatter-plot timeline view for config changes Add a Graph/Table toggle to the config changes view using reaviz ScatterPlot. Changes are plotted by time (x-axis) and config name (y-axis) with tooltips showing change details. Toggle state persists in localStorage via jotai atomWithStorage. --- package-lock.json | 451 ++++++++++++++++-- package.json | 1 + .../Configs/Changes/ConfigChangesGraph.tsx | 129 +++++ .../Changes/ConfigChangesViewToggle.tsx | 34 ++ .../FilterBar/ConfigRelatedChangesFilters.tsx | 9 +- .../details/ConfigDetailsChangesPage.tsx | 44 +- 6 files changed, 627 insertions(+), 41 deletions(-) create mode 100644 src/components/Configs/Changes/ConfigChangesGraph.tsx create mode 100644 src/components/Configs/Changes/ConfigChangesViewToggle.tsx diff --git a/package-lock.json b/package-lock.json index 21ab7c45ee..f1e510e274 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,7 @@ "packages": { "": { "name": "@flanksource/flanksource-ui", - "version": "1.4.218", + "version": "1.4.200", "dependencies": { "@ai-sdk/anthropic": "^3.0.1", "@ai-sdk/mcp": "^1.0.1", @@ -129,6 +129,7 @@ "react-use-size": "^3.0.3", "react-windowed-select": "^5.2.0", "reactflow": "^11.11.3", + "reaviz": "^16.1.2", "recharts": "^2.15.4", "shiki": "^3.20.0", "streamdown": "^1.6.10", @@ -3301,27 +3302,24 @@ } }, "node_modules/@floating-ui/core": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.5.0.tgz", - "integrity": "sha512-kK1h4m36DQ0UHGj5Ah4db7R0rHemTqqO0QLvUqi1/mUUp3LuAWbWxdxSIf/XsnH9VS6rRVPLJCncjRzUvyCLXg==", + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", "dependencies": { - "@floating-ui/utils": "^0.1.3" + "@floating-ui/utils": "^0.2.11" } }, "node_modules/@floating-ui/dom": { - "version": "1.6.3", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.3.tgz", - "integrity": "sha512-RnDthu3mzPlQ31Ss/BTwQ1zjzIhr3lk1gZB1OC56h/1vEtaXkESrOqL5fQVMfXpwGtRwX+YsZBdyHtJMQnkArw==", + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", "dependencies": { - "@floating-ui/core": "^1.0.0", - "@floating-ui/utils": "^0.2.0" + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" } }, - "node_modules/@floating-ui/dom/node_modules/@floating-ui/utils": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.1.tgz", - "integrity": "sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==" - }, "node_modules/@floating-ui/react": { "version": "0.26.22", "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.22.tgz", @@ -3337,26 +3335,23 @@ } }, "node_modules/@floating-ui/react-dom": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.1.tgz", - "integrity": "sha512-4h84MJt3CHrtG18mGsXuLCHMrug49d7DFkU0RMIyshRveBeyV2hmV/pDaF2Uxtu8kgq5r46llp5E5FQiR0K2Yg==", + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", + "license": "MIT", "dependencies": { - "@floating-ui/dom": "^1.0.0" + "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, - "node_modules/@floating-ui/react/node_modules/@floating-ui/utils": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.7.tgz", - "integrity": "sha512-X8R8Oj771YRl/w+c1HqAC1szL8zWQRwFvgDwT129k9ACdBoud/+/rX9V0qiMl6LWUdP9voC2nDVZYPMQQsb6eA==" - }, "node_modules/@floating-ui/utils": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.1.6.tgz", - "integrity": "sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A==" + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" }, "node_modules/@headlessui/react": { "version": "2.1.2", @@ -8399,6 +8394,16 @@ "react-dom": ">=17" } }, + "node_modules/@reaviz/react-use-fuzzy": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@reaviz/react-use-fuzzy/-/react-use-fuzzy-1.0.3.tgz", + "integrity": "sha512-ON5RxiI0r9zNpEKHvxqT8zz3iI4kzc37NzB4t8lfXSWwCEkpzxWY9TB2JAdYp9LC3UW2tN9zxdMnaNBLz4atTw==", + "license": "MIT", + "peerDependencies": { + "fuse.js": "^6.6.2", + "react": ">= 16" + } + }, "node_modules/@remix-run/router": { "version": "1.13.0", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.13.0.tgz", @@ -8407,6 +8412,19 @@ "node": ">=14.0.0" } }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.18.0.tgz", + "integrity": "sha512-xuglR2rBVHA5UsI8h8UbX4VJ470PtGCf5Vpswh7p2ukaqBGFTnsfzxUBetoWBWymHMxbIG0Cmx7Y9qDZzr648w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, "node_modules/@rushstack/eslint-patch": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.6.0.tgz", @@ -15338,6 +15356,21 @@ "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==" }, + "node_modules/@types/d3-cloud": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@types/d3-cloud/-/d3-cloud-1.2.9.tgz", + "integrity": "sha512-5EWJvnlCrqTThGp8lYHx+DL00sOjx2HTlXH1WRe93k5pfOIhPQaL63NttaKYIbT7bTXp/USiunjNS/N4ipttIQ==", + "license": "MIT", + "dependencies": { + "@types/d3": "^3" + } + }, + "node_modules/@types/d3-cloud/node_modules/@types/d3": { + "version": "3.5.53", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-3.5.53.tgz", + "integrity": "sha512-8yKQA9cAS6+wGsJpBysmnhlaaxlN42Qizqkw+h2nILSlS+MAG2z4JdO6p+PJrJ+ACvimkmLJL281h157e52psQ==", + "license": "MIT" + }, "node_modules/@types/d3-color": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", @@ -16373,6 +16406,16 @@ "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==" }, + "node_modules/@upsetjs/venn.js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@upsetjs/venn.js/-/venn.js-2.0.0.tgz", + "integrity": "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==", + "license": "MIT", + "optionalDependencies": { + "d3-selection": "^3.0.0", + "d3-transition": "^3.0.1" + } + }, "node_modules/@valibot/to-json-schema": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@valibot/to-json-schema/-/to-json-schema-1.5.0.tgz", @@ -17921,7 +17964,6 @@ "version": "1.6.52", "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", - "dev": true, "engines": { "node": ">=0.6" } @@ -18072,6 +18114,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/body-scroll-lock-upgrade": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/body-scroll-lock-upgrade/-/body-scroll-lock-upgrade-1.1.0.tgz", + "integrity": "sha512-nnfVAS+tB7CS9RaksuHVTpgHWHF7fE/ptIBJnwZrMqImIvWJF1OGcLnMpBhC6qhkx9oelvyxmWXwmIJXCV98Sw==", + "license": "MIT" + }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -18791,6 +18839,12 @@ "node": ">=10" } }, + "node_modules/chroma-js": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/chroma-js/-/chroma-js-3.2.0.tgz", + "integrity": "sha512-os/OippSlX1RlWWr+QDPcGUZs0uoqr32urfxESG9U93lhUfbnlyckte84Q8P1UQY/qth983AS1JONKmLS4T0nw==", + "license": "(BSD-3-Clause AND Apache-2.0)" + }, "node_modules/chromatic": { "version": "6.24.1", "resolved": "https://registry.npmjs.org/chromatic/-/chromatic-6.24.1.tgz", @@ -18857,9 +18911,10 @@ "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==" }, "node_modules/classnames": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.2.tgz", - "integrity": "sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw==" + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" }, "node_modules/clean-css": { "version": "5.3.2", @@ -19666,6 +19721,13 @@ "node": ">= 6" } }, + "node_modules/coverup": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/coverup/-/coverup-0.1.1.tgz", + "integrity": "sha512-Q2Fs0v3M4eMNfj0TGaFb4Oh0yuaQj9EhQbEKtFhD3Tm4HZt1Zn7TrBKX14gVEO9aeWP8KnF2/qrh7fr/rvbcXw==", + "deprecated": "No longer maintained", + "license": "MIT" + }, "node_modules/create-ecdh": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.4.tgz", @@ -19682,6 +19744,12 @@ "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==", "dev": true }, + "node_modules/create-global-state-hook": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/create-global-state-hook/-/create-global-state-hook-0.0.2.tgz", + "integrity": "sha512-+1gRNwtuSQIC9lQQngfcY1VARKs6R32KJjI1bFrsp0W5MbauupRW/uQF0f+ElXx8xMmuEK9wl5zqAslzj6GvCA==", + "license": "MIT" + }, "node_modules/create-hash": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz", @@ -19993,6 +20061,15 @@ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.1.tgz", "integrity": "sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw==" }, + "node_modules/ctrl-keys": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/ctrl-keys/-/ctrl-keys-1.0.6.tgz", + "integrity": "sha512-fENSKrbIfvX83uHxruP3S/9GizirvgT66vHhgKHOCTVHK+22Xpud/vttg5c5IifRl+6Gom/GjE+ZSXJKf0DMTA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/cytoscape": { "version": "3.33.1", "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz", @@ -20127,6 +20204,21 @@ "node": ">=12" } }, + "node_modules/d3-cloud": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/d3-cloud/-/d3-cloud-1.2.9.tgz", + "integrity": "sha512-leL1GLneC9ZQtnV+6TGWrNlGfI1WX7S2arcTv2vae12DaXo5wjm6GBCkskXbrDlyOymd/A75Pyj1H37MW4BZ/Q==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-dispatch": "^1.0.3" + } + }, + "node_modules/d3-cloud/node_modules/d3-dispatch": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-1.0.6.tgz", + "integrity": "sha512-fVjoElzjhCEy+Hbn8KygnmMS7Or0a9sI2UzGwoB7cCtvI1XpVN9GpoYlnb3xt2YV66oXYb1fLJ8GMvP4hdU1RA==", + "license": "BSD-3-Clause" + }, "node_modules/d3-color": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-2.0.0.tgz", @@ -21271,6 +21363,12 @@ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.25.tgz", "integrity": "sha512-kMb204zvK3PsSlgvvwzI3wBIcAw15tRkYk+NQdsjdDtcQWTp2RABbMQ9rUBy8KNEOM+/E6ep+XC3AykiWZld4g==" }, + "node_modules/ellipsize": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/ellipsize/-/ellipsize-0.6.2.tgz", + "integrity": "sha512-zB4m5iEETalVrrP8RzcF0Qzqyw3MkUQ4R43NiczRAp0Hpp0+0bRdwKnoaFXyJoVJCipm2/3xc7Hkg0OOAorUPw==", + "license": "MIT" + }, "node_modules/elliptic": { "version": "6.5.4", "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.4.tgz", @@ -23378,6 +23476,30 @@ "node": ">=0.4.0" } }, + "node_modules/focus-trap": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.8.0.tgz", + "integrity": "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==", + "license": "MIT", + "dependencies": { + "tabbable": "^6.4.0" + } + }, + "node_modules/focus-trap-react": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/focus-trap-react/-/focus-trap-react-10.3.1.tgz", + "integrity": "sha512-PN4Ya9xf9nyj/Nd9VxBNMuD7IrlRbmaG6POAQ8VLqgtc6IY/Ln1tYakow+UIq4fihYYYFM70/2oyidE6bbiPgw==", + "license": "MIT", + "dependencies": { + "focus-trap": "^7.6.1", + "tabbable": "^6.2.0" + }, + "peerDependencies": { + "prop-types": "^15.8.1", + "react": ">=16.3.0", + "react-dom": ">=16.3.0" + } + }, "node_modules/follow-redirects": { "version": "1.15.3", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.3.tgz", @@ -23812,6 +23934,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fuse.js": { + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-6.6.2.tgz", + "integrity": "sha512-cJaJkxCCxC8qIIcPBF9yGxY0W/tVZS3uEISDxhYIdtk8OL93pe+6Zj7LjCqVV4dzbqcriOZ+kQ/NE4RXZHsIGA==", + "license": "Apache-2.0", + "engines": { + "node": ">=10" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -24770,6 +24901,12 @@ "dev": true, "license": "MIT" }, + "node_modules/highlight-words-core": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/highlight-words-core/-/highlight-words-core-1.2.3.tgz", + "integrity": "sha512-m1O9HW3/GNHxzSIXWw1wCNXXsgLlxrP0OI6+ycGUhiUHkikqW3OrwVHz+lxeNBe5yqLESdIcj8PowHQ2zLvUvQ==", + "license": "MIT" + }, "node_modules/hmac-drbg": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", @@ -25053,6 +25190,15 @@ "node": ">= 14" } }, + "node_modules/human-format": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/human-format/-/human-format-1.2.1.tgz", + "integrity": "sha512-o5Ldz62VWR5lYUZ8aVQaLKiN37NsHnmk3xjMoUjza3mGkk8MvMofgZT0T6HKSCKSJIir+AWk9Dx8KhxvZAUgCg==", + "license": "ISC", + "engines": { + "node": ">=4" + } + }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -25374,6 +25520,12 @@ "loose-envify": "^1.0.0" } }, + "node_modules/invert-color": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/invert-color/-/invert-color-2.0.0.tgz", + "integrity": "sha512-9s6IATlhOAr0/0MPUpLdMpk81ixIu8IqwPwORssXBauFT/4ff/iyEOcojd0UYuPwkDbJvL1+blIZGhqVIaAm5Q==", + "license": "MIT" + }, "node_modules/ip": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.1.tgz", @@ -31245,6 +31397,12 @@ "thenify-all": "^1.0.0" } }, + "node_modules/name-initials": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/name-initials/-/name-initials-0.1.3.tgz", + "integrity": "sha512-UJcpCmyftGuZ7I46dqOw776VvH3VqHhAM7ma4eyY0t52FahFv/VhmDqSeekwSZFXyK9HLg9MXmb9udeOJ3YtCA==", + "license": "MIT" + }, "node_modules/nanoclone": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/nanoclone/-/nanoclone-0.2.1.tgz", @@ -35800,6 +35958,15 @@ "pathe": "^2.0.1" } }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/pnp-webpack-plugin": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/pnp-webpack-plugin/-/pnp-webpack-plugin-1.7.0.tgz", @@ -36894,6 +37061,90 @@ "react-dom": "^16.13.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/reablocks": { + "version": "9.4.1", + "resolved": "https://registry.npmjs.org/reablocks/-/reablocks-9.4.1.tgz", + "integrity": "sha512-WiicdjqkgZeJZGQop9fWLafEeaYVaVOse3o7Z+Y3YGDuMcxkmkBS3qQvCDVGNW6DZvSGN3cQuaTE8Wa4G9BTcQ==", + "license": "Apache-2.0", + "dependencies": { + "@floating-ui/react": "^0.27.16", + "@reaviz/react-use-fuzzy": "^1.0.3", + "body-scroll-lock-upgrade": "^1.1.0", + "chroma-js": "^3.1.2", + "classnames": "^2.5.1", + "coverup": "^0.1.1", + "create-global-state-hook": "^0.0.2", + "ctrl-keys": "^1.0.6", + "date-fns": "^4.1.0", + "ellipsize": "^0.6.2", + "focus-trap-react": "^10.3.1", + "fuse.js": "^6.6.2", + "human-format": "^1.2.1", + "motion": "^12.23.12", + "name-initials": "^0.1.3", + "pluralize": "^8.0.0", + "react-fast-compare": "^3.2.2", + "react-highlight-words": "^0.21.0", + "react-textarea-autosize": "^8.5.9", + "tailwind-merge": "^2.6.0" + }, + "engines": { + "node": ">=22", + "npm": ">=10.8.2" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, + "node_modules/reablocks/node_modules/@floating-ui/react": { + "version": "0.27.19", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.19.tgz", + "integrity": "sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.1.8", + "@floating-ui/utils": "^0.2.11", + "tabbable": "^6.0.0" + }, + "peerDependencies": { + "react": ">=17.0.0", + "react-dom": ">=17.0.0" + } + }, + "node_modules/reablocks/node_modules/react-fast-compare": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", + "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==", + "license": "MIT" + }, + "node_modules/reablocks/node_modules/react-textarea-autosize": { + "version": "8.5.9", + "resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.5.9.tgz", + "integrity": "sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.13", + "use-composed-ref": "^1.3.0", + "use-latest": "^1.2.1" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/reablocks/node_modules/tailwind-merge": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz", + "integrity": "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, "node_modules/react": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", @@ -37088,6 +37339,25 @@ "react-dom": "^16.8.2 || ^17.0 || ^18.x || ^19.x" } }, + "node_modules/react-highlight-words": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/react-highlight-words/-/react-highlight-words-0.21.0.tgz", + "integrity": "sha512-SdWEeU9fIINArEPO1rO5OxPyuhdEKZQhHzZZP1ie6UeXQf+CjycT1kWaB+9bwGcVbR0NowuHK3RqgqNg6bgBDQ==", + "license": "MIT", + "dependencies": { + "highlight-words-core": "^1.2.0", + "memoize-one": "^4.0.0" + }, + "peerDependencies": { + "react": "^0.14.0 || ^15.0.0 || ^16.0.0-0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0" + } + }, + "node_modules/react-highlight-words/node_modules/memoize-one": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-4.0.3.tgz", + "integrity": "sha512-QmpUu4KqDmX0plH4u+tf0riMc1KHE1+lw95cMrLlXQAFOx/xnBtwhZ52XJxd9X2O6kwKBqX32kmhbhlobD0cuw==", + "license": "MIT" + }, "node_modules/react-hook-form": { "version": "7.48.2", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.48.2.tgz", @@ -37607,6 +37877,103 @@ "node": ">=8.10.0" } }, + "node_modules/reaviz": { + "version": "16.1.2", + "resolved": "https://registry.npmjs.org/reaviz/-/reaviz-16.1.2.tgz", + "integrity": "sha512-w79c7e1jYKZgY1MQ9RDkF/r5lzAX51nIpX+j926m0G0Gs+v8FkE3HWbN2CqhGF4vBZdbPkmc1tLhg5jqcJcpBw==", + "license": "Apache-2.0", + "dependencies": { + "@floating-ui/dom": "^1.7.4", + "@types/d3-cloud": "^1.2.9", + "@upsetjs/venn.js": "^2.0.0", + "big-integer": "^1.6.52", + "chroma-js": "^3.1.2", + "classnames": "^2.5.1", + "d3-array": "^3.2.4", + "d3-cloud": "^1.2.7", + "d3-format": "^3.1.0", + "d3-geo": "^3.1.1", + "d3-hierarchy": "^3.1.2", + "d3-interpolate": "^3.0.1", + "d3-sankey": "^0.12.3", + "d3-scale": "^4.0.2", + "d3-shape": "^3.2.0", + "d3-time": "^3.1.0", + "ellipsize": "^0.6.2", + "human-format": "^1.2.1", + "invert-color": "^2.0.0", + "motion": "^12.23.12", + "reablocks": "^9.2.2", + "react-fast-compare": "^3.2.2", + "reaviz-data-utils": "^1.0.0", + "safe-identifier": "^0.4.2", + "transformation-matrix": "^3.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, + "node_modules/reaviz-data-utils": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/reaviz-data-utils/-/reaviz-data-utils-1.0.0.tgz", + "integrity": "sha512-I1sUNF8E1fuV4XZwEnh5M9vEUlbyM5rWW3EO53hLUWHTzc43ScTVWbs9LncSTvL93GmwW+sVeotLzDq1WRz0Rw==", + "license": "ISC", + "dependencies": { + "big-integer": "^1.6.52", + "d3-array": "^3.2.4", + "date-fns": "^3.6.0" + }, + "optionalDependencies": { + "@rollup/rollup-linux-x64-gnu": "4.18.0" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/reaviz-data-utils/node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/reaviz-data-utils/node_modules/date-fns": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", + "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/reaviz/node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/reaviz/node_modules/react-fast-compare": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", + "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==", + "license": "MIT" + }, "node_modules/recast": { "version": "0.23.4", "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.4.tgz", @@ -38556,6 +38923,12 @@ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" }, + "node_modules/safe-identifier": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/safe-identifier/-/safe-identifier-0.4.2.tgz", + "integrity": "sha512-6pNbSMW6OhAi9j+N8V+U715yBQsaWJ7eyEUaOrawX+isg5ZxhUlV1NipNtgaKHmFGiABwt+ZF04Ii+3Xjkg+8w==", + "license": "ISC" + }, "node_modules/safe-regex-test": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.0.tgz", @@ -41181,9 +41554,10 @@ "integrity": "sha512-AsS729u2RHUfEra9xJrE39peJcc2stq2+poBXX8bcM08Y6g9j/i/PUzwNQqkaJde7Ntg1TO7bSREbR5sdosQ+g==" }, "node_modules/tabbable": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", - "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==" + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", + "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", + "license": "MIT" }, "node_modules/tagged-tag": { "version": "1.0.0", @@ -41940,6 +42314,15 @@ "node": ">=12" } }, + "node_modules/transformation-matrix": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/transformation-matrix/-/transformation-matrix-3.1.0.tgz", + "integrity": "sha512-oYubRWTi2tYFHAL2J8DLvPIqIYcYZ0fSOi2vmSy042Ho4jBW2ce6VP7QfD44t65WQz6bw5w1Pk22J7lcUpaTKA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/chrvadala" + } + }, "node_modules/traverse": { "version": "0.6.8", "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.6.8.tgz", diff --git a/package.json b/package.json index 38571882d0..6162219160 100644 --- a/package.json +++ b/package.json @@ -129,6 +129,7 @@ "react-use-size": "^3.0.3", "react-windowed-select": "^5.2.0", "reactflow": "^11.11.3", + "reaviz": "^16.1.2", "recharts": "^2.15.4", "shiki": "^3.20.0", "streamdown": "^1.6.10", diff --git a/src/components/Configs/Changes/ConfigChangesGraph.tsx b/src/components/Configs/Changes/ConfigChangesGraph.tsx new file mode 100644 index 0000000000..c7c8ce755e --- /dev/null +++ b/src/components/Configs/Changes/ConfigChangesGraph.tsx @@ -0,0 +1,129 @@ +import { ConfigChange } from "@flanksource-ui/api/types/configs"; +import { Age } from "@flanksource-ui/ui/Age"; +import { ChangeIcon } from "@flanksource-ui/ui/Icons/ChangeIcon"; +import { relativeDateTime } from "@flanksource-ui/utils/date"; +import dayjs from "dayjs"; +import { useMemo } from "react"; +import { + ChartShallowDataShape, + ChartTooltip, + ChartZoomPan, + LinearXAxis, + LinearXAxisTickLabel, + LinearXAxisTickLine, + LinearXAxisTickSeries, + LinearYAxis, + LinearYAxisTickLabel, + LinearYAxisTickSeries, + ScatterPlot, + ScatterPoint, + ScatterSeries +} from "reaviz"; +import ConfigsTypeIcon from "../ConfigsTypeIcon"; + +type ConfigChangesGraphProps = { + changes: ConfigChange[]; + onItemClicked?: (change: ConfigChange) => void; +}; + +export default function ConfigChangesGraph({ + changes, + onItemClicked = () => {} +}: ConfigChangesGraphProps) { + const data: ChartShallowDataShape[] = useMemo(() => { + return changes.map((change) => ({ + key: dayjs(change.first_observed).toDate(), + data: change.config?.name!, + metadata: change + })); + }, [changes]); + + return ( +
+ } + yAxis={ + v} + /> + } + /> + } + /> + } + xAxis={ + } + label={ + relativeDateTime(v)} + /> + } + /> + } + /> + } + series={ + { + const change = data.metadata as ConfigChange; + return ( +
+ + {change.config?.name} + +
+
+ + + {change.change_type} + + + + {(change.count || 1) > 1 && ( + + (x{change.count} over{" "} + ) + + )} + +
+

{change.summary}

+
+
+ ); + }} + /> + } + className={"bg-gray-500"} + size={20} + symbol={(data) => } + onClick={(data) => { + onItemClicked(data.metadata as ConfigChange); + }} + /> + } + /> + } + /> +
+ ); +} diff --git a/src/components/Configs/Changes/ConfigChangesViewToggle.tsx b/src/components/Configs/Changes/ConfigChangesViewToggle.tsx new file mode 100644 index 0000000000..510dad424f --- /dev/null +++ b/src/components/Configs/Changes/ConfigChangesViewToggle.tsx @@ -0,0 +1,34 @@ +import { Switch } from "@flanksource-ui/ui/FormControls/Switch"; +import { useAtom } from "jotai"; +import { atomWithStorage } from "jotai/utils"; + +export type GraphType = "Table" | "Graph"; +export const configChangesViewToggle = atomWithStorage( + "configChangesViewToggleState", + "Table", + undefined, + { + getOnInit: true + } +); + +export function useConfigChangesViewToggleState() { + const [view] = useAtom(configChangesViewToggle); + return view; +} + +export default function ConfigChangesViewToggle() { + const [toggleValue, setToggleValue] = useAtom(configChangesViewToggle); + + return ( +
+ { + setToggleValue(v as GraphType); + }} + value={toggleValue} + /> +
+ ); +} diff --git a/src/components/Configs/Changes/ConfigsRelatedChanges/FilterBar/ConfigRelatedChangesFilters.tsx b/src/components/Configs/Changes/ConfigsRelatedChanges/FilterBar/ConfigRelatedChangesFilters.tsx index 990e163753..66e759482f 100644 --- a/src/components/Configs/Changes/ConfigsRelatedChanges/FilterBar/ConfigRelatedChangesFilters.tsx +++ b/src/components/Configs/Changes/ConfigsRelatedChanges/FilterBar/ConfigRelatedChangesFilters.tsx @@ -7,6 +7,7 @@ import ConfigChangesDateRangeFilter from "../../ConfigChangesFilters/ConfigChang import { FilterBadge } from "../../ConfigChangesFilters/ConfigChangesFilters"; import { ConfigRelatedChangesToggles } from "../../ConfigChangesFilters/ConfigRelatedChangesToggles"; import { ConfigTagsDropdown } from "../../ConfigChangesFilters/ConfigTagsDropdown"; +import ConfigChangesViewToggle from "../../ConfigChangesViewToggle"; import ShowDeletedConfigs from "../../../ConfigsListFilters/ShowDeletedConfigs"; import ConfigTypesTristateDropdown from "../../ConfigChangesFilters/ConfigTypesTristateDropdown"; @@ -27,7 +28,12 @@ export function ConfigRelatedChangesFilters({ paramsToReset={paramsToReset} filterFields={["configTypes", "changeType", "severity", "tags"]} > -
+
@@ -35,6 +41,7 @@ export function ConfigRelatedChangesFilters({ +
diff --git a/src/pages/config/details/ConfigDetailsChangesPage.tsx b/src/pages/config/details/ConfigDetailsChangesPage.tsx index 2b0cfcab1b..1595b8fba3 100644 --- a/src/pages/config/details/ConfigDetailsChangesPage.tsx +++ b/src/pages/config/details/ConfigDetailsChangesPage.tsx @@ -1,12 +1,19 @@ +import { useGetConfigChangesById } from "@flanksource-ui/api/query-hooks/useGetConfigChangesByConfigChangeIdQuery"; import { useGetConfigChangesByIDQuery } from "@flanksource-ui/api/query-hooks/useConfigChangesHooks"; +import { ConfigChange } from "@flanksource-ui/api/types/configs"; import { ConfigChangeTable } from "@flanksource-ui/components/Configs/Changes/ConfigChangeTable"; +import ConfigChangesGraph from "@flanksource-ui/components/Configs/Changes/ConfigChangesGraph"; +import { useConfigChangesViewToggleState } from "@flanksource-ui/components/Configs/Changes/ConfigChangesViewToggle"; +import { ConfigDetailChangeModal } from "@flanksource-ui/components/Configs/Changes/ConfigDetailsChanges/ConfigDetailsChanges"; import { ConfigRelatedChangesFilters } from "@flanksource-ui/components/Configs/Changes/ConfigsRelatedChanges/FilterBar/ConfigRelatedChangesFilters"; import { ConfigDetailsTabs } from "@flanksource-ui/components/Configs/ConfigDetailsTabs"; import { InfoMessage } from "@flanksource-ui/components/InfoMessage"; +import { useState } from "react"; import { useParams, useSearchParams } from "react-router-dom"; export function ConfigDetailsChangesPage() { const { id } = useParams(); + const view = useConfigChangesViewToggleState(); const [params] = useSearchParams({ sortBy: "created_at", @@ -32,6 +39,12 @@ export function ConfigDetailsChangesPage() { const totalChanges = data?.total ?? 0; const totalChangesPages = Math.ceil(totalChanges / parseInt(pageSize)); + const [selectedChange, setSelectedChange] = useState(); + const { data: changeDetails, isLoading: changeLoading } = + useGetConfigChangesById(selectedChange?.id ?? "", { + enabled: !!selectedChange + }); + if (error) { const errorMessage = typeof error === "symbol" @@ -52,12 +65,31 @@ export function ConfigDetailsChangesPage() {
- + {view === "Graph" ? ( + <> + setSelectedChange(change)} + /> + {selectedChange && ( + { + if (!open) setSelectedChange(undefined); + }} + changeDetails={changeDetails ?? selectedChange} + /> + )} + + ) : ( + + )}
From 38de39d81844386d6ee276eb460834d8ec58ff7e Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Mon, 6 Apr 2026 09:09:15 +0300 Subject: [PATCH 2/8] feat(auth): add basic authentication system with login and session handling Introduces basic username/password auth as an alternative to Clerk and Kratos. Adds BasicLogin component, auth context provider, session checker, and API endpoint for login. Includes new middleware for basic auth flow and updates auth system detection to support three authentication backends. --- .gitignore | 6 +- middleware.basic.ts | 38 ++++++ clerk.middleware.ts => middleware.clerk.ts | 0 middleware.ts => middleware.kratos.ts | 0 package.json | 7 +- pages/api/[...paths].ts | 54 ++------ pages/api/auth/login.ts | 33 +++++ pages/login/[[...index]].tsx | 2 + src/api/axios.ts | 6 + .../Authentication/AuthProviderWrapper.tsx | 6 +- .../Authentication/AuthSessionChecker.tsx | 5 + .../Basic/BasicAuthContextProvider.tsx | 62 ++++++++++ .../Basic/BasicAuthSessionChecker.tsx | 20 +++ .../Authentication/Basic/BasicLogin.tsx | 116 ++++++++++++++++++ .../Authentication/useDetermineAuthSystem.tsx | 8 +- .../SkeletonLoader/FullPageSkeletonLoader.tsx | 6 +- 16 files changed, 313 insertions(+), 56 deletions(-) create mode 100644 middleware.basic.ts rename clerk.middleware.ts => middleware.clerk.ts (100%) rename middleware.ts => middleware.kratos.ts (100%) create mode 100644 pages/api/auth/login.ts create mode 100644 src/components/Authentication/Basic/BasicAuthContextProvider.tsx create mode 100644 src/components/Authentication/Basic/BasicAuthSessionChecker.tsx create mode 100644 src/components/Authentication/Basic/BasicLogin.tsx diff --git a/.gitignore b/.gitignore index 24baf480d2..3393618bc4 100644 --- a/.gitignore +++ b/.gitignore @@ -40,7 +40,7 @@ src/components/SpecEditorControl/Untitled-1.json tsconfig.tsbuildinfo # ignore middleware for now, as it is and instead, during build, rename -# clerk.middleware.js to clerk.middleware.js +# middleware.clerk.ts to middleware.ts # middleware.ts .vercel @@ -56,4 +56,6 @@ tsconfig.tsbuildinfo .envrc default.nix -.claude \ No newline at end of file +.claude +.playwright-mcp/ +*.png diff --git a/middleware.basic.ts b/middleware.basic.ts new file mode 100644 index 0000000000..b34a7152c4 --- /dev/null +++ b/middleware.basic.ts @@ -0,0 +1,38 @@ +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; + +const publicPaths = ["/login", "/api/", "/registration"]; + +function isPublicPath(pathname: string): boolean { + return publicPaths.some((p) => pathname.startsWith(p)); +} + +export default function basicMiddleware(request: NextRequest) { + const { pathname } = request.nextUrl; + + if (isPublicPath(pathname)) { + return NextResponse.next(); + } + + const token = request.nextUrl.searchParams.get("token"); + if (token) { + return NextResponse.next(); + } + + const hasSession = + request.cookies.has("ory_kratos_session") || + request.cookies.has("authorization"); + + if (!hasSession) { + const returnTo = encodeURIComponent(request.url); + return NextResponse.redirect( + new URL(`/login?return_to=${returnTo}`, request.url) + ); + } + + return NextResponse.next(); +} + +export const config = { + matcher: ["/((?!.*\\..*|_next).*)"] +}; diff --git a/clerk.middleware.ts b/middleware.clerk.ts similarity index 100% rename from clerk.middleware.ts rename to middleware.clerk.ts diff --git a/middleware.ts b/middleware.kratos.ts similarity index 100% rename from middleware.ts rename to middleware.kratos.ts diff --git a/package.json b/package.json index 6162219160..947d988abc 100644 --- a/package.json +++ b/package.json @@ -163,12 +163,13 @@ ] }, "scripts": { - "dev": "cross-env next dev", + "dev": "dotenv run cross-env next dev", "dev:canary": "cross-env NEXT_PUBLIC_APP_DEPLOYMENT=CANARY_CHECKER next dev", - "dev:clerk": "cp clerk.middleware.ts middleware.ts && cross-env next dev", + "dev:basic": "cp middleware.basic.ts middleware.ts && dotenv run cross-env next dev", + "dev:clerk": "cp middleware.clerk.ts middleware.ts && cross-env next dev", "start": "cross-env next start", "build": "cross-env NODE_OPTIONS=--max-old-space-size=8192 node -v && cross-env NODE_OPTIONS=--max-old-space-size=8192 NODE_ENV=production next build", - "build:clerk": "cross-env NODE_OPTIONS=--max-old-space-size=8192 node -v && cp clerk.middleware.ts middleware.ts && cross-env NODE_OPTIONS=--max-old-space-size=8192 NODE_ENV=production next build", + "build:clerk": "cross-env NODE_OPTIONS=--max-old-space-size=8192 node -v && cp middleware.clerk.ts middleware.ts && cross-env NODE_OPTIONS=--max-old-space-size=8192 NODE_ENV=production next build", "serve-build": "gzip -f --keep build/static/*/*.{js,css} && node scripts/serve-build.js", "test": "jest --watch", "test:ci": "jest --ci", diff --git a/pages/api/[...paths].ts b/pages/api/[...paths].ts index e9c80821c1..fafae688c1 100644 --- a/pages/api/[...paths].ts +++ b/pages/api/[...paths].ts @@ -1,14 +1,6 @@ -import { clerkClient, getAuth } from "@clerk/nextjs/server"; import { NextApiRequest, NextApiResponse } from "next"; import httpProxyMiddleware from "next-http-proxy-middleware"; -const API_URL = process.env.BACKEND_URL; -const isCanary = process.env.NEXT_PUBLIC_APP_DEPLOYMENT === "CANARY_CHECKER"; -const env = process.env.ENV; -const isClerkAuth = process.env.NEXT_PUBLIC_AUTH_IS_CLERK === "true"; - -const canaryPrefix = isCanary ? "" : "/canary"; - export const config = { api: { bodyParser: false @@ -16,48 +8,20 @@ export const config = { }; async function getTargetURL(req: NextApiRequest) { - if (isClerkAuth) { + if (process.env.NEXT_PUBLIC_AUTH_IS_CLERK === "true") { + const { clerkClient, getAuth } = await import("@clerk/nextjs/server"); const user = getAuth(req); const org = await clerkClient.organizations.getOrganization({ organizationId: user.sessionClaims?.org_id! }); - const backendURL = org?.publicMetadata?.backend_url; - // for now, lets fallback to the old way of doing things, if the backend_url - // is not set in the org metadata - const target = backendURL; - return target; + return org?.publicMetadata?.backend_url; } - return API_URL; + return process.env.BACKEND_URL; } -const clerkBackendPathRewrites = [ - { - patternStr: "^/api", - replaceStr: "/" - } -]; - -const kratosBackendPathRewrites = ["localhost", "netlify"].includes(env!) - ? [ - { - patternStr: "^/api", - replaceStr: "/api" - } - ] - : [ - { - patternStr: "^/api/canary", - replaceStr: `${canaryPrefix}` - }, - { - patternStr: "^/api/.ory", - replaceStr: "/kratos/" - }, - { - patternStr: "^/api", - replaceStr: "/" - } - ]; +function getPathRewrites() { + return [{ patternStr: "^/api", replaceStr: "/" }]; +} export default async function handler( req: NextApiRequest, @@ -72,8 +36,6 @@ export default async function handler( return httpProxyMiddleware(req, res, { target: target!, xfwd: true, - pathRewrite: [ - ...(isClerkAuth ? clerkBackendPathRewrites : kratosBackendPathRewrites) - ] + pathRewrite: getPathRewrites() }); } diff --git a/pages/api/auth/login.ts b/pages/api/auth/login.ts new file mode 100644 index 0000000000..ef3fdc7011 --- /dev/null +++ b/pages/api/auth/login.ts @@ -0,0 +1,33 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +const BACKEND_URL = process.env.BACKEND_URL || "http://localhost:8080"; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + if (req.method !== "POST") { + return res.status(405).json({ error: "Method not allowed" }); + } + + const { username, password } = req.body; + const basicAuth = Buffer.from(`${username}:${password}`).toString("base64"); + + const response = await fetch(`${BACKEND_URL}/auth/login`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Basic ${basicAuth}` + }, + body: JSON.stringify(req.body) + }); + + const setCookie = response.headers.get("set-cookie"); + if (setCookie) { + res.setHeader("set-cookie", setCookie); + } + + const text = await response.text(); + res.status(response.status); + res.send(text); +} diff --git a/pages/login/[[...index]].tsx b/pages/login/[[...index]].tsx index 639413a338..057555e61c 100644 --- a/pages/login/[[...index]].tsx +++ b/pages/login/[[...index]].tsx @@ -1,3 +1,4 @@ +import BasicLogin from "../../src/components/Authentication/Basic/BasicLogin"; import ClerkLogin from "../../src/components/Authentication/Clerk/ClerkLogin"; import KratosLogin from "../../src/components/Authentication/Kratos/KratosLogin"; import useDetermineAuthSystem from "../../src/components/Authentication/useDetermineAuthSystem"; @@ -11,6 +12,7 @@ export default function Signin() {
+ {authSystem === "basic" && } {authSystem === "kratos" && } {authSystem === "clerk" && }
diff --git a/src/api/axios.ts b/src/api/axios.ts index 5c5f27f3b9..ecaa2683c7 100644 --- a/src/api/axios.ts +++ b/src/api/axios.ts @@ -4,6 +4,7 @@ import { isCanaryUI } from "@flanksource-ui/context/Environment"; import axios, { AxiosError } from "axios"; const isClerkAuthSystem = !!process.env.NEXT_PUBLIC_AUTH_IS_CLERK === true; +const isBasicAuthSystem = process.env.NEXT_PUBLIC_AUTH_IS_BASIC === "true"; const API_BASE = "/api"; @@ -243,6 +244,11 @@ for (const client of [ export function redirectToLoginPageOnSessionExpiry(error: AxiosError) { if (error?.response?.status === 401) { + if (isBasicAuthSystem) { + window.location.href = `/login?return_to=${window.location.pathname}${window.location.search}`; + return; + } + if (isClerkAuthSystem) { const url = `/auth-state-checker?return_to=${window.location.pathname}${window.location.search}`; window.location.href = url; diff --git a/src/components/Authentication/AuthProviderWrapper.tsx b/src/components/Authentication/AuthProviderWrapper.tsx index 4018bf3992..ca08fb1b40 100644 --- a/src/components/Authentication/AuthProviderWrapper.tsx +++ b/src/components/Authentication/AuthProviderWrapper.tsx @@ -1,3 +1,4 @@ +import BasicAuthContextProvider from "./Basic/BasicAuthContextProvider"; import ClerkAuthContextProvider from "./Clerk/ClerkAuthContextProvider"; import KratosAuthContextProvider from "./Kratos/KratosAuthContextProvider"; import useDetermineAuthSystem from "./useDetermineAuthSystem"; @@ -11,7 +12,10 @@ export default function AuthProviderWrapper({ }: AuthProviderWrapperProps) { const authSystem = useDetermineAuthSystem(); - // we need access to Clerk's organization context + if (authSystem === "basic") { + return {children}; + } + if (authSystem === "clerk") { return {children}; } diff --git a/src/components/Authentication/AuthSessionChecker.tsx b/src/components/Authentication/AuthSessionChecker.tsx index 2e966aef17..381244da6a 100644 --- a/src/components/Authentication/AuthSessionChecker.tsx +++ b/src/components/Authentication/AuthSessionChecker.tsx @@ -1,3 +1,4 @@ +import BasicAuthSessionChecker from "./Basic/BasicAuthSessionChecker"; import ClerkAuthSessionChecker from "./Clerk/ClerkAuthSessionChecker"; import KratosAuthSessionChecker from "./Kratos/KratosAuthSessionChecker"; import useDetermineAuthSystem from "./useDetermineAuthSystem"; @@ -11,6 +12,10 @@ export default function AuthSessionChecker({ }: AuthSessionCheckerProps) { const authSystem = useDetermineAuthSystem(); + if (authSystem === "basic") { + return {children}; + } + if (authSystem === "clerk") { return {children}; } diff --git a/src/components/Authentication/Basic/BasicAuthContextProvider.tsx b/src/components/Authentication/Basic/BasicAuthContextProvider.tsx new file mode 100644 index 0000000000..51346f9cb5 --- /dev/null +++ b/src/components/Authentication/Basic/BasicAuthContextProvider.tsx @@ -0,0 +1,62 @@ +import { WhoamiResponse, whoami } from "@flanksource-ui/api/services/users"; +import { AuthContext, createAuthorizer } from "@flanksource-ui/context"; +import { useFlanksourceUISnippet } from "@flanksource-ui/hooks/useFlanksourceUISnippet"; +import FullPageSkeletonLoader from "@flanksource-ui/ui/SkeletonLoader/FullPageSkeletonLoader"; +import { useQuery } from "@tanstack/react-query"; +import { AxiosError } from "axios"; + +const backendURL = process.env.NEXT_PUBLIC_BACKEND_URL; + +type Props = { + children: React.ReactNode; +}; + +export default function BasicAuthContextProvider({ children }: Props) { + const { + data: payload, + isLoading, + error + } = useQuery( + ["user", "whoami"], + () => whoami(), + { + refetchOnWindowFocus: false, + refetchInterval: 0, + refetchOnReconnect: false + } + ); + + useFlanksourceUISnippet(payload?.user, { + backendURL: backendURL + }); + + if (isLoading && !payload) { + return ; + } + + if (error && !payload) { + if (error.response?.status === 401) { + window.location.href = `/login?return_to=${window.location.pathname}${window.location.search}`; + return ; + } + } + + if (!payload) { + return ; + } + + return ( + + {children} + + ); +} diff --git a/src/components/Authentication/Basic/BasicAuthSessionChecker.tsx b/src/components/Authentication/Basic/BasicAuthSessionChecker.tsx new file mode 100644 index 0000000000..821d88ae31 --- /dev/null +++ b/src/components/Authentication/Basic/BasicAuthSessionChecker.tsx @@ -0,0 +1,20 @@ +import { FeatureFlagsContextProvider } from "@flanksource-ui/context/FeatureFlagsContext"; +import React, { useEffect, useState } from "react"; + +type Props = { + children: React.ReactNode; +}; + +export default function BasicAuthSessionChecker({ children }: Props) { + const [isBrowserEnv, setIsBrowserEnv] = useState(false); + + useEffect(() => { + setIsBrowserEnv(true); + }, []); + + if (!isBrowserEnv) { + return null; + } + + return {children}; +} diff --git a/src/components/Authentication/Basic/BasicLogin.tsx b/src/components/Authentication/Basic/BasicLogin.tsx new file mode 100644 index 0000000000..9b671984fc --- /dev/null +++ b/src/components/Authentication/Basic/BasicLogin.tsx @@ -0,0 +1,116 @@ +import FormSkeletonLoader from "@flanksource-ui/ui/SkeletonLoader/FormSkeletonLoader"; +import Image from "next/image"; +import { useSearchParams } from "next/navigation"; +import { useRouter } from "next/router"; +import { FormEvent, useState } from "react"; + +export default function BasicLogin() { + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(); + const [submitting, setSubmitting] = useState(false); + + const router = useRouter(); + const searchParams = useSearchParams(); + const returnTo = searchParams.get("return_to") || "/"; + + async function handleSubmit(e: FormEvent) { + e.preventDefault(); + setError(undefined); + setSubmitting(true); + + try { + const response = await fetch("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }) + }); + + if (!response.ok) { + const data = await response.json().catch(() => null); + setError( + data?.message || + data?.error || + `${response.status}: ${response.statusText}` + ); + setSubmitting(false); + return; + } + + router.push(returnTo); + } catch (err) { + setError(String(err)); + setSubmitting(false); + } + } + + return ( +
+
+ Mission Control +

+ Sign In to your account +

+
+ {submitting ? ( + + ) : ( +
+ {error && ( +
+ {error} +
+ )} +
+ + setUsername(e.target.value)} + className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 sm:text-sm" + /> +
+
+ + setPassword(e.target.value)} + className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 sm:text-sm" + /> +
+ +
+ )} +
+
+
+ ); +} diff --git a/src/components/Authentication/useDetermineAuthSystem.tsx b/src/components/Authentication/useDetermineAuthSystem.tsx index ee6341a665..5899fb33eb 100644 --- a/src/components/Authentication/useDetermineAuthSystem.tsx +++ b/src/components/Authentication/useDetermineAuthSystem.tsx @@ -4,7 +4,13 @@ export const isClerkSatellite = export const isClerkAuthSystem = !!process.env.NEXT_PUBLIC_AUTH_IS_CLERK === true; -export default function useDetermineAuthSystem() { +export const isBasicAuthSystem = + process.env.NEXT_PUBLIC_AUTH_IS_BASIC === "true"; + +export default function useDetermineAuthSystem(): "clerk" | "kratos" | "basic" { + if (isBasicAuthSystem) { + return "basic"; + } if (isClerkAuthSystem) { return "clerk"; } diff --git a/src/ui/SkeletonLoader/FullPageSkeletonLoader.tsx b/src/ui/SkeletonLoader/FullPageSkeletonLoader.tsx index e59c61c177..2620bec979 100644 --- a/src/ui/SkeletonLoader/FullPageSkeletonLoader.tsx +++ b/src/ui/SkeletonLoader/FullPageSkeletonLoader.tsx @@ -19,9 +19,7 @@ export default function FullPageSkeletonLoader() {
- {authSystem === "kratos" ? ( -
- ) : ( + {authSystem === "clerk" ? (
+ ) : ( +
)}
From 1b6d0b42d01123bbba3a7aac44863bd3716f0bd1 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Mon, 6 Apr 2026 14:55:26 +0300 Subject: [PATCH 3/8] fix(toast): use getErrorMessage for proper API error extraction - Update toastError to accept unknown and delegate to getErrorMessage - Replace all (ex as Error).message casts with toastError(ex) - Dynamically size toast width based on message length --- src/api/query-hooks/useFeatureFlags.tsx | 6 +- .../Configs/Sidebar/ConfigsPanel.tsx | 2 +- .../Forms/Formik/FormikConnectionField.tsx | 2 +- src/components/Toast/toast.ts | 23 +---- src/pages/Settings/ConnectionsPage.tsx | 6 +- src/pages/applications/ApplicationsPage.tsx | 6 +- src/pages/views/ViewsPage.tsx | 6 +- src/ui/ToasterWithCloseButton.tsx | 83 ++++++++++--------- 8 files changed, 63 insertions(+), 71 deletions(-) diff --git a/src/api/query-hooks/useFeatureFlags.tsx b/src/api/query-hooks/useFeatureFlags.tsx index eac3862515..a3b3804aa6 100644 --- a/src/api/query-hooks/useFeatureFlags.tsx +++ b/src/api/query-hooks/useFeatureFlags.tsx @@ -72,7 +72,7 @@ export function useAddFeatureFlag(onSuccess: () => void) { onSuccess(); }, onError: (error) => { - toastError((error as Error).message); + toastError(error); } }); } @@ -95,7 +95,7 @@ export function useUpdateFeatureFlag(onSuccess: () => void) { onSuccess(); }, onError: (error) => { - toastError((error as Error).message); + toastError(error); } }); } @@ -113,7 +113,7 @@ export function useDeleteFeatureFlag(onSuccess: () => void) { onSuccess(); }, onError: (error) => { - toastError((error as Error).message); + toastError(error); } }); } diff --git a/src/components/Configs/Sidebar/ConfigsPanel.tsx b/src/components/Configs/Sidebar/ConfigsPanel.tsx index 58a64d44c1..6a549c7ed3 100644 --- a/src/components/Configs/Sidebar/ConfigsPanel.tsx +++ b/src/components/Configs/Sidebar/ConfigsPanel.tsx @@ -62,7 +62,7 @@ export function ConfigsPanelList({ } toastError(response.error?.message); } catch (ex) { - toastError((ex as Error).message); + toastError(ex); } }; diff --git a/src/components/Forms/Formik/FormikConnectionField.tsx b/src/components/Forms/Formik/FormikConnectionField.tsx index 33a3d66340..573bda69e4 100644 --- a/src/components/Forms/Formik/FormikConnectionField.tsx +++ b/src/components/Forms/Formik/FormikConnectionField.tsx @@ -38,7 +38,7 @@ export default function FormikConnectionField({ }); }, onError: (err: Error) => { - toastError((err as Error).message); + toastError(err); } }); diff --git a/src/components/Toast/toast.ts b/src/components/Toast/toast.ts index e7cc17ef67..839550a597 100644 --- a/src/components/Toast/toast.ts +++ b/src/components/Toast/toast.ts @@ -1,26 +1,11 @@ +import { getErrorMessage } from "@flanksource-ui/api/types/error"; import toast, { ToastOptions } from "react-hot-toast"; -type ErrorMessage = - | { - response?: { - data?: { - error: string; - message: string; - }; - }; - } - | string - | undefined; - -export function toastError(message: ErrorMessage) { - if (typeof message === "string" || !message) { +export function toastError(message: unknown) { + if (typeof message === "string") { toast.error(message || "An error occurred"); } else { - toast.error( - message.response?.data?.error || - message.response?.data?.message || - "An error occurred" - ); + toast.error(getErrorMessage(message) || "An error occurred"); } } diff --git a/src/pages/Settings/ConnectionsPage.tsx b/src/pages/Settings/ConnectionsPage.tsx index 6a3c42c759..52cdbdcd88 100644 --- a/src/pages/Settings/ConnectionsPage.tsx +++ b/src/pages/Settings/ConnectionsPage.tsx @@ -119,7 +119,7 @@ export function ConnectionsPage() { toastSuccess("Connection added successfully"); }, onError: (ex) => { - toastError((ex as Error).message); + toastError(ex); } }); @@ -138,7 +138,7 @@ export function ConnectionsPage() { toastSuccess("Connection updated successfully"); }, onError: (ex) => { - toastError((ex as Error).message); + toastError(ex); } }); @@ -156,7 +156,7 @@ export function ConnectionsPage() { toastSuccess("Connection deleted successfully"); }, onError: (ex) => { - toastError((ex as Error).message); + toastError(ex); } }); diff --git a/src/pages/applications/ApplicationsPage.tsx b/src/pages/applications/ApplicationsPage.tsx index 0b873f5082..312d250fb2 100644 --- a/src/pages/applications/ApplicationsPage.tsx +++ b/src/pages/applications/ApplicationsPage.tsx @@ -86,7 +86,7 @@ export function ApplicationsPage() { toastSuccess("Application added successfully"); }, onError: (ex) => { - toastError((ex as Error).message); + toastError(ex); } }); @@ -105,7 +105,7 @@ export function ApplicationsPage() { toastSuccess("Application updated successfully"); }, onError: (ex) => { - toastError((ex as Error).message); + toastError(ex); } }); @@ -123,7 +123,7 @@ export function ApplicationsPage() { toastSuccess("Application deleted successfully"); }, onError: (ex) => { - toastError((ex as Error).message); + toastError(ex); } }); diff --git a/src/pages/views/ViewsPage.tsx b/src/pages/views/ViewsPage.tsx index 2063ce3fbe..f8da4ed622 100644 --- a/src/pages/views/ViewsPage.tsx +++ b/src/pages/views/ViewsPage.tsx @@ -68,7 +68,7 @@ export function ViewsPage() { toastSuccess("View added successfully"); }, onError: (ex) => { - toastError((ex as Error).message); + toastError(ex); } }); @@ -86,7 +86,7 @@ export function ViewsPage() { toastSuccess("View updated successfully"); }, onError: (ex) => { - toastError((ex as Error).message); + toastError(ex); } }); @@ -101,7 +101,7 @@ export function ViewsPage() { toastSuccess("View deleted successfully"); }, onError: (ex) => { - toastError((ex as Error).message); + toastError(ex); } }); diff --git a/src/ui/ToasterWithCloseButton.tsx b/src/ui/ToasterWithCloseButton.tsx index 2bd2268894..9cdc45a74b 100644 --- a/src/ui/ToasterWithCloseButton.tsx +++ b/src/ui/ToasterWithCloseButton.tsx @@ -13,47 +13,54 @@ export function ToasterWithCloseButton() { duration: 5000 }} > - {(t) => ( - - {({ message }) => ( -
-
-
- {t.type === "success" && ( - - )} - {t.type === "error" && ( - - )} - {t.type === "loading" && ( - - )} -
+ {(t) => { + const resolved = resolveValue(t.message, t); + const textLen = typeof resolved === "string" ? resolved.length : 40; + const widthClass = + textLen > 200 ? "max-w-2xl" : textLen > 80 ? "max-w-lg" : "max-w-xs"; -
-

- {resolveValue(message, t)} -

-
- {t.type !== "loading" && ( -
- + return ( + + {({ message }) => ( +
+
+
+ {t.type === "success" && ( + + )} + {t.type === "error" && ( + + )} + {t.type === "loading" && ( + + )}
- )} + +
+

+ {resolveValue(message, t)} +

+
+ {t.type !== "loading" && ( +
+ +
+ )} +
-
- )} - - )} + )} + + ); + }} ); } From 73baeed40842fa0a1cdf86a2779f00daa0c1d2c1 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Mon, 6 Apr 2026 14:56:12 +0300 Subject: [PATCH 4/8] fix(plugins): pass user ID instead of object and show inline errors - Fix created_by sending full user object instead of UUID string - Show save errors inline in the form dialog with truncation - Type createScrapePlugin to accept string for created_by --- src/api/services/scrapePlugins.ts | 4 +++- src/components/Plugins/PluginsForm.tsx | 19 +++++++++++++++++ src/components/Plugins/PluginsFormModal.tsx | 5 ++++- .../config/settings/ConfigPluginsPage.tsx | 21 +++++++++++-------- 4 files changed, 38 insertions(+), 11 deletions(-) diff --git a/src/api/services/scrapePlugins.ts b/src/api/services/scrapePlugins.ts index 3f0e07395b..ed061874de 100644 --- a/src/api/services/scrapePlugins.ts +++ b/src/api/services/scrapePlugins.ts @@ -45,7 +45,9 @@ export const getAllScrapePlugins = ( ); }; -export const createScrapePlugin = async (plugin: Partial) => { +export const createScrapePlugin = async ( + plugin: Omit, "created_by"> & { created_by?: string } +) => { return ConfigDB.post("/scrape_plugins", plugin); }; diff --git a/src/components/Plugins/PluginsForm.tsx b/src/components/Plugins/PluginsForm.tsx index 1536c02448..04825ecf78 100644 --- a/src/components/Plugins/PluginsForm.tsx +++ b/src/components/Plugins/PluginsForm.tsx @@ -16,6 +16,7 @@ type PluginsFormProps = { handleBack?: () => void; isSubmitting?: boolean; isDeleting?: boolean; + errorMessage?: string; className?: string; }; @@ -26,9 +27,11 @@ export default function PluginsForm({ handleBack = () => {}, isSubmitting = false, isDeleting = false, + errorMessage, className }: PluginsFormProps) { const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false); + const [isErrorExpanded, setIsErrorExpanded] = useState(false); const isReadOnly = formValue?.source === "KubernetesCRD"; return ( @@ -77,6 +80,22 @@ export default function PluginsForm({ />
+ {errorMessage && ( +
+
+ {errorMessage} +
+ {errorMessage.length > 120 && ( + + )} +
+ )}
{!formValue?.id && (
+ )} + + {isLoading && ( +
+
+ Loading... +
+ )} + + {isImage && artifact && downloadURL && ( + {artifact.filename} + )} + + {content && + artifact && + !isImage && + renderContent(artifact.content_type, content)} +
+ + + {downloadURL && ( + +
+ {changeDetails?.artifacts && changeDetails.artifacts.length > 0 && ( +
+ {changeDetails.artifacts.map((artifact) => ( + + + {artifact.filename} + + ({formatBytes(artifact.size)}) + + + + ))} +
+ )} {changeDetails?.details && ( { if (artifactID) { - const downloadURL = `/api/artifacts/download/${artifactID}`; - window.open(downloadURL, "_blank"); + const { artifactDownloadURL } = await import( + "@flanksource-ui/api/services/artifacts" + ); + window.open(artifactDownloadURL(artifactID, fileName), "_blank"); return; } diff --git a/src/components/Playbooks/Runs/Actions/PlaybooksActionsResults.tsx b/src/components/Playbooks/Runs/Actions/PlaybooksActionsResults.tsx index 8f90228e9d..dba791a227 100644 --- a/src/components/Playbooks/Runs/Actions/PlaybooksActionsResults.tsx +++ b/src/components/Playbooks/Runs/Actions/PlaybooksActionsResults.tsx @@ -12,7 +12,10 @@ import TabContentDownloadButton from "./PlaybookResultsDropdownButton"; import { Tab, Tabs } from "../../../../ui/Tabs/Tabs"; import blockKitToMarkdown from "@flanksource-ui/utils/slack"; import { DisplayMarkdown } from "@flanksource-ui/components/Utils/Markdown"; -import { downloadArtifact } from "../../../../api/services/artifacts"; +import { + artifactDownloadURL, + downloadArtifact +} from "../../../../api/services/artifacts"; import { TbAlertCircle, TbFileDescription } from "react-icons/tb"; import { formatBytes } from "@flanksource-ui/utils/common"; import { Button } from "@flanksource-ui/ui/Buttons/Button"; @@ -482,7 +485,7 @@ function ArtifactContent({ } }); - const downloadURL = `/api/artifacts/download/${artifact.id}`; + const downloadURL = artifactDownloadURL(artifact.id, artifact.filename); return (
diff --git a/src/context/UserAccessContext/permissions.ts b/src/context/UserAccessContext/permissions.ts index 747e1b2adb..3df58a3ae2 100644 --- a/src/context/UserAccessContext/permissions.ts +++ b/src/context/UserAccessContext/permissions.ts @@ -23,7 +23,8 @@ export const tables = { scopes: "scopes", views: "views", teams: "teams", - applications: "applications" + applications: "applications", + artifacts: "artifacts" }; const viewerReadObjects = [ diff --git a/src/pages/Settings/ArtifactsPage.tsx b/src/pages/Settings/ArtifactsPage.tsx new file mode 100644 index 0000000000..00ef8c09b7 --- /dev/null +++ b/src/pages/Settings/ArtifactsPage.tsx @@ -0,0 +1,172 @@ +import { fetchArtifacts } from "@flanksource-ui/api/services/artifacts"; +import { Artifact } from "@flanksource-ui/api/types/artifacts"; +import { ArtifactPreviewModal } from "@flanksource-ui/components/Artifacts/ArtifactPreviewModal"; +import { ArtifactsTable } from "@flanksource-ui/components/Artifacts/ArtifactsTable"; +import { ArtifactsSummaryCards } from "@flanksource-ui/components/Artifacts/ArtifactsSummaryCards"; +import { ReactSelectDropdown } from "@flanksource-ui/components/ReactSelectDropdown"; +import { + BreadcrumbNav, + BreadcrumbRoot +} from "@flanksource-ui/ui/BreadcrumbNav"; +import useReactTablePaginationState from "@flanksource-ui/ui/DataTable/Hooks/useReactTablePaginationState"; +import useReactTableSortState from "@flanksource-ui/ui/DataTable/Hooks/useReactTableSortState"; +import { Head } from "@flanksource-ui/ui/Head"; +import { SearchLayout } from "@flanksource-ui/ui/Layout/SearchLayout"; +import { TextInputClearable } from "@flanksource-ui/ui/FormControls/TextInputClearable"; +import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; +import { useSearchParams } from "react-router-dom"; + +const contentTypeOptions = [ + { id: "all", value: "all", label: "All", description: "All" }, + { + id: "application/json", + value: "application/json", + label: "JSON", + description: "JSON" + }, + { + id: "application/yaml", + value: "application/yaml", + label: "YAML", + description: "YAML" + }, + { + id: "text/plain", + value: "text/plain", + label: "Text", + description: "Text" + }, + { + id: "text/markdown", + value: "text/markdown", + label: "Markdown", + description: "Markdown" + }, + { + id: "application/sql", + value: "application/sql", + label: "SQL", + description: "SQL" + }, + { + id: "text/x-shellscript", + value: "text/x-shellscript", + label: "Shell", + description: "Shell" + } +]; + +export function ArtifactsPage() { + const [searchParams, setSearchParams] = useSearchParams(); + const [selectedArtifact, setSelectedArtifact] = useState< + Artifact | undefined + >(); + + const contentType = searchParams.get("content_type") ?? "all"; + const filenameSearch = searchParams.get("search") ?? ""; + + const [sortState] = useReactTableSortState({ + defaultSorting: [{ id: "created_at", desc: true }] + }); + const { pageIndex, pageSize } = useReactTablePaginationState(); + + const { data, isLoading, refetch } = useQuery({ + queryKey: [ + "artifacts", + { pageIndex, pageSize, sortState, contentType, filenameSearch } + ], + queryFn: () => + fetchArtifacts({ + pageIndex, + pageSize, + sortBy: sortState, + contentType, + filenameSearch: filenameSearch || undefined + }), + keepPreviousData: true + }); + + const artifacts = data?.data ?? []; + const totalEntries = data?.totalEntries ?? 0; + const pageCount = Math.ceil(totalEntries / pageSize); + + return ( + <> + + + Artifacts + + ]} + /> + } + onRefresh={() => refetch()} + contentClass="p-0 h-full" + loading={isLoading} + > +
+ +
+ { + const nextParams = new URLSearchParams(searchParams); + if (e.target.value) { + nextParams.set("search", e.target.value); + } else { + nextParams.delete("search"); + } + setSearchParams(nextParams); + }} + onClear={() => { + const nextParams = new URLSearchParams(searchParams); + nextParams.delete("search"); + setSearchParams(nextParams); + }} + /> + { + const nextParams = new URLSearchParams(searchParams); + if (!value || value === "all") { + nextParams.delete("content_type"); + } else { + nextParams.set("content_type", value); + } + setSearchParams(nextParams); + }} + className="min-w-[180px]" + dropDownClassNames="w-[200px] left-0" + hideControlBorder + prefix={ + Content Type: + } + /> +
+
+ +
+
+ setSelectedArtifact(undefined)} + /> +
+ + ); +} diff --git a/src/services/permissions/features.ts b/src/services/permissions/features.ts index 22ca5c9817..e1f74e26b0 100644 --- a/src/services/permissions/features.ts +++ b/src/services/permissions/features.ts @@ -24,7 +24,8 @@ export const features = { "settings.notifications": "settings.notifications", "settings.playbooks": "settings.playbooks", "settings.integrations": "settings.integrations", - "settings.permissions": "settings.permissions" + "settings.permissions": "settings.permissions", + "settings.artifacts": "settings.artifacts" } as const; export const featureToParentMap = { From d7e3e902336e7d6a0f58db31035c874ef32eb478 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Mon, 6 Apr 2026 20:35:22 +0300 Subject: [PATCH 6/8] fix(tests): lazy-load ConfigChangesGraph to fix reaviz module resolution The reaviz library imports @upsetjs/venn.js which Jest cannot resolve, causing 8 test suites to fail. Lazy-importing the graph component prevents reaviz from being eagerly loaded during test module resolution. --- .../details/ConfigDetailsChangesPage.tsx | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src/pages/config/details/ConfigDetailsChangesPage.tsx b/src/pages/config/details/ConfigDetailsChangesPage.tsx index 1595b8fba3..44fd3cabe7 100644 --- a/src/pages/config/details/ConfigDetailsChangesPage.tsx +++ b/src/pages/config/details/ConfigDetailsChangesPage.tsx @@ -2,15 +2,18 @@ import { useGetConfigChangesById } from "@flanksource-ui/api/query-hooks/useGetC import { useGetConfigChangesByIDQuery } from "@flanksource-ui/api/query-hooks/useConfigChangesHooks"; import { ConfigChange } from "@flanksource-ui/api/types/configs"; import { ConfigChangeTable } from "@flanksource-ui/components/Configs/Changes/ConfigChangeTable"; -import ConfigChangesGraph from "@flanksource-ui/components/Configs/Changes/ConfigChangesGraph"; import { useConfigChangesViewToggleState } from "@flanksource-ui/components/Configs/Changes/ConfigChangesViewToggle"; import { ConfigDetailChangeModal } from "@flanksource-ui/components/Configs/Changes/ConfigDetailsChanges/ConfigDetailsChanges"; import { ConfigRelatedChangesFilters } from "@flanksource-ui/components/Configs/Changes/ConfigsRelatedChanges/FilterBar/ConfigRelatedChangesFilters"; import { ConfigDetailsTabs } from "@flanksource-ui/components/Configs/ConfigDetailsTabs"; import { InfoMessage } from "@flanksource-ui/components/InfoMessage"; -import { useState } from "react"; +import { Suspense, lazy, useState } from "react"; import { useParams, useSearchParams } from "react-router-dom"; +const ConfigChangesGraph = lazy( + () => import("@flanksource-ui/components/Configs/Changes/ConfigChangesGraph") +); + export function ConfigDetailsChangesPage() { const { id } = useParams(); const view = useConfigChangesViewToggleState(); @@ -67,10 +70,18 @@ export function ConfigDetailsChangesPage() {
{view === "Graph" ? ( <> - setSelectedChange(change)} - /> + + Loading graph... +
+ } + > + setSelectedChange(change)} + /> + {selectedChange && ( Date: Mon, 6 Apr 2026 22:02:51 +0300 Subject: [PATCH 7/8] fix(auth): add timeout and error handling to login endpoint Implement 10-second timeout on auth service requests and proper error responses for timeouts and connection failures. Also refactors return_to parameter handling to reduce code duplication and normalize severity values in change icons to handle case-insensitive comparisons. --- package-lock.json | 2 +- package.json | 2 +- pages/api/auth/login.ts | 44 ++++++++++++------- src/api/axios.ts | 12 ++--- .../Authentication/Kratos/KratosLogin.tsx | 15 +++---- .../ConfigChangeSeverity.tsx | 2 +- .../Configs/Changes/ConfigChangesGraph.tsx | 27 +++++++----- .../ConfigDetailsChanges.tsx | 2 +- src/pages/config/ConfigChangesPage.tsx | 12 ++--- .../details/ConfigDetailsChangesPage.tsx | 19 +++++--- src/ui/Icons/ChangeIcon.tsx | 10 ++++- 11 files changed, 89 insertions(+), 58 deletions(-) diff --git a/package-lock.json b/package-lock.json index f1e510e274..08de4daf9c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,7 @@ "packages": { "": { "name": "@flanksource/flanksource-ui", - "version": "1.4.200", + "version": "1.4.232", "dependencies": { "@ai-sdk/anthropic": "^3.0.1", "@ai-sdk/mcp": "^1.0.1", diff --git a/package.json b/package.json index 947d988abc..da4c777c27 100644 --- a/package.json +++ b/package.json @@ -129,7 +129,7 @@ "react-use-size": "^3.0.3", "react-windowed-select": "^5.2.0", "reactflow": "^11.11.3", - "reaviz": "^16.1.2", + "reaviz": "^16.1.1", "recharts": "^2.15.4", "shiki": "^3.20.0", "streamdown": "^1.6.10", diff --git a/pages/api/auth/login.ts b/pages/api/auth/login.ts index ef3fdc7011..e6129e7d78 100644 --- a/pages/api/auth/login.ts +++ b/pages/api/auth/login.ts @@ -13,21 +13,35 @@ export default async function handler( const { username, password } = req.body; const basicAuth = Buffer.from(`${username}:${password}`).toString("base64"); - const response = await fetch(`${BACKEND_URL}/auth/login`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Basic ${basicAuth}` - }, - body: JSON.stringify(req.body) - }); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10_000); - const setCookie = response.headers.get("set-cookie"); - if (setCookie) { - res.setHeader("set-cookie", setCookie); - } + try { + const response = await fetch(`${BACKEND_URL}/auth/login`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Basic ${basicAuth}` + }, + body: JSON.stringify(req.body), + signal: controller.signal + }); + + const setCookie = response.headers.get("set-cookie"); + if (setCookie) { + res.setHeader("set-cookie", setCookie); + } - const text = await response.text(); - res.status(response.status); - res.send(text); + const text = await response.text(); + res.status(response.status).send(text); + } catch (error: any) { + if (error.name === "AbortError") { + return res.status(504).json({ error: "Login request timed out" }); + } + return res + .status(502) + .json({ error: "Failed to connect to authentication service" }); + } finally { + clearTimeout(timeout); + } } diff --git a/src/api/axios.ts b/src/api/axios.ts index ecaa2683c7..00ab796163 100644 --- a/src/api/axios.ts +++ b/src/api/axios.ts @@ -244,18 +244,20 @@ for (const client of [ export function redirectToLoginPageOnSessionExpiry(error: AxiosError) { if (error?.response?.status === 401) { + const returnTo = encodeURIComponent( + `${window.location.pathname}${window.location.search}` + ); + if (isBasicAuthSystem) { - window.location.href = `/login?return_to=${window.location.pathname}${window.location.search}`; + window.location.href = `/login?return_to=${returnTo}`; return; } if (isClerkAuthSystem) { - const url = `/auth-state-checker?return_to=${window.location.pathname}${window.location.search}`; - window.location.href = url; + window.location.href = `/auth-state-checker?return_to=${returnTo}`; return; } - const url = `/login?return_to=${window.location.pathname}${window.location.search}`; - window.location.href = url; + window.location.href = `/login?return_to=${returnTo}`; } } diff --git a/src/components/Authentication/Kratos/KratosLogin.tsx b/src/components/Authentication/Kratos/KratosLogin.tsx index 234aa1ebbc..9a891a1314 100644 --- a/src/components/Authentication/Kratos/KratosLogin.tsx +++ b/src/components/Authentication/Kratos/KratosLogin.tsx @@ -33,7 +33,7 @@ const KratosLogin = () => { const searchParams = useSearchParams(); const flowId = searchParams.get("flow") || undefined; - const returnTo = searchParams.get("return_to") || "/"; + const returnTo = searchParams.get("return_to") || undefined; const username = searchParams.get("username"); const password = searchParams.get("password"); @@ -54,7 +54,7 @@ const KratosLogin = () => { const { data } = await ory.getLoginFlow({ id }); setFlow(data); } catch (error) { - handleError(error as AxiosError); + return handleError(error as AxiosError); } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -68,13 +68,13 @@ const KratosLogin = () => { ); const createFlow = useCallback( - async (refresh: boolean, aal: string, returnTo: string = "/") => { + async (refresh: boolean, aal: string, returnTo?: string) => { try { const { data } = await ory.createBrowserLoginFlow({ refresh: refresh, // Check for two-factor authentication aal: aal, - returnTo: returnTo + ...(returnTo ? { returnTo } : {}) }); setFlow(data); if (flowId !== data.id) { @@ -97,13 +97,13 @@ const KratosLogin = () => { if (flowId) { getFlow(flowId).catch(() => { - createFlow(refresh, aal, String(returnTo ?? "/")); + createFlow(refresh, aal, returnTo); }); return; } // Otherwise we initialize it - createFlow(refresh, aal, returnTo ?? "/"); + createFlow(refresh, aal, returnTo); // eslint-disable-next-line react-hooks/exhaustive-deps }, [isReady]); @@ -115,7 +115,7 @@ const KratosLogin = () => { updateLoginFlowBody: values }); setLoginSuccessful(true); - push(String(returnTo || "/")); + push(returnTo ?? "/"); } catch (error) { if ((error as AxiosError).response?.status === 400) { // Yup, it is! @@ -157,7 +157,6 @@ const KratosLogin = () => { } }, [flow, submitFlow, credentials]); - return (
diff --git a/src/components/Configs/Changes/ConfigChangesFilters/ConfigChangeSeverity.tsx b/src/components/Configs/Changes/ConfigChangesFilters/ConfigChangeSeverity.tsx index 0d37f9b9d6..debf986395 100644 --- a/src/components/Configs/Changes/ConfigChangesFilters/ConfigChangeSeverity.tsx +++ b/src/components/Configs/Changes/ConfigChangesFilters/ConfigChangeSeverity.tsx @@ -57,7 +57,7 @@ export const configChangeSeverity = { icon: , name: "Info", description: "Info", - value: "Info", + value: "info", colorClass: "text-gray-500 fill-gray-500" } } as const; diff --git a/src/components/Configs/Changes/ConfigChangesGraph.tsx b/src/components/Configs/Changes/ConfigChangesGraph.tsx index c7c8ce755e..1c3dc9aa0b 100644 --- a/src/components/Configs/Changes/ConfigChangesGraph.tsx +++ b/src/components/Configs/Changes/ConfigChangesGraph.tsx @@ -31,11 +31,13 @@ export default function ConfigChangesGraph({ onItemClicked = () => {} }: ConfigChangesGraphProps) { const data: ChartShallowDataShape[] = useMemo(() => { - return changes.map((change) => ({ - key: dayjs(change.first_observed).toDate(), - data: change.config?.name!, - metadata: change - })); + return changes + .filter((change) => change.first_observed && change.config?.name) + .map((change) => ({ + key: dayjs(change.first_observed).toDate(), + data: change.config!.name!, + metadata: change + })); }, [changes]); return ( @@ -97,13 +99,14 @@ export default function ConfigChangesGraph({ {change.change_type} - - {(change.count || 1) > 1 && ( - - (x{change.count} over{" "} - ) - - )} + + {(change.count || 1) > 1 && + change.first_observed && ( + + (x{change.count} over{" "} + ) + + )}

{change.summary}

diff --git a/src/components/Configs/Changes/ConfigDetailsChanges/ConfigDetailsChanges.tsx b/src/components/Configs/Changes/ConfigDetailsChanges/ConfigDetailsChanges.tsx index 9e2d2f96f6..9737b79948 100644 --- a/src/components/Configs/Changes/ConfigDetailsChanges/ConfigDetailsChanges.tsx +++ b/src/components/Configs/Changes/ConfigDetailsChanges/ConfigDetailsChanges.tsx @@ -72,7 +72,7 @@ export function ConfigDetailsChanges({ } + value={} /> ({ - ...changes, + const changes = (data?.changes ?? []).map((c) => ({ + ...c, config: { - id: changes.config_id!, - type: changes.type!, - name: changes.name!, - deleted_at: changes.deleted_at + id: c.config_id ?? "", + type: c.type ?? c.config?.type ?? "", + name: c.name ?? c.config?.name ?? "", + deleted_at: c.deleted_at } })); diff --git a/src/pages/config/details/ConfigDetailsChangesPage.tsx b/src/pages/config/details/ConfigDetailsChangesPage.tsx index 44fd3cabe7..aa360df2e4 100644 --- a/src/pages/config/details/ConfigDetailsChangesPage.tsx +++ b/src/pages/config/details/ConfigDetailsChangesPage.tsx @@ -7,7 +7,7 @@ import { ConfigDetailChangeModal } from "@flanksource-ui/components/Configs/Chan import { ConfigRelatedChangesFilters } from "@flanksource-ui/components/Configs/Changes/ConfigsRelatedChanges/FilterBar/ConfigRelatedChangesFilters"; import { ConfigDetailsTabs } from "@flanksource-ui/components/Configs/ConfigDetailsTabs"; import { InfoMessage } from "@flanksource-ui/components/InfoMessage"; -import { Suspense, lazy, useState } from "react"; +import { Suspense, lazy, useEffect, useState } from "react"; import { useParams, useSearchParams } from "react-router-dom"; const ConfigChangesGraph = lazy( @@ -30,12 +30,12 @@ export function ConfigDetailsChangesPage() { enabled: !!id }); - const changes = (data?.changes ?? []).map((changes) => ({ - ...changes, + const changes = (data?.changes ?? []).map((c) => ({ + ...c, config: { - id: changes.config_id!, - type: changes.type!, - name: changes.name! + id: c.config_id ?? "", + type: c.type ?? c.config?.type ?? "", + name: c.name ?? c.config?.name ?? "" } })); @@ -43,9 +43,14 @@ export function ConfigDetailsChangesPage() { const totalChangesPages = Math.ceil(totalChanges / parseInt(pageSize)); const [selectedChange, setSelectedChange] = useState(); + + useEffect(() => { + if (view !== "Graph") setSelectedChange(undefined); + }, [view]); + const { data: changeDetails, isLoading: changeLoading } = useGetConfigChangesById(selectedChange?.id ?? "", { - enabled: !!selectedChange + enabled: view === "Graph" && !!selectedChange }); if (error) { diff --git a/src/ui/Icons/ChangeIcon.tsx b/src/ui/Icons/ChangeIcon.tsx index 5c1f034057..d46f6eb174 100644 --- a/src/ui/Icons/ChangeIcon.tsx +++ b/src/ui/Icons/ChangeIcon.tsx @@ -16,8 +16,16 @@ export function ChangeIcon({ change }: ChangeIconProps) { const colorClass = useMemo(() => { + const normalized = change?.severity?.toLowerCase(); + const aliases: Record = { + failure: "critical", + blocker: "critical", + warning: "medium", + success: "low" + }; + const mapped = aliases[normalized ?? ""] ?? normalized; const items = Object.values(configChangeSeverity).find( - (item) => item.value === change?.severity + (item) => item.value === mapped ); return items?.colorClass ?? ""; }, [change?.severity]); From 836784009d22c39c2fc269091341eeafbe81ef3d Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Mon, 6 Apr 2026 22:34:50 +0300 Subject: [PATCH 8/8] feat(ui): add graph view toggle for config changes Adds a view toggle to switch between table and graph representations of config changes. Graph view uses lazy loading and displays change details in a modal when an item is selected. --- .../ConfigChangesFilters.tsx | 2 + src/pages/config/ConfigChangesPage.tsx | 62 ++++++++++++++++--- 2 files changed, 57 insertions(+), 7 deletions(-) diff --git a/src/components/Configs/Changes/ConfigChangesFilters/ConfigChangesFilters.tsx b/src/components/Configs/Changes/ConfigChangesFilters/ConfigChangesFilters.tsx index 9b0b05566e..678c5caa5f 100644 --- a/src/components/Configs/Changes/ConfigChangesFilters/ConfigChangesFilters.tsx +++ b/src/components/Configs/Changes/ConfigChangesFilters/ConfigChangesFilters.tsx @@ -11,6 +11,7 @@ import { ConfigChangeSeverity } from "./ConfigChangeSeverity"; import ConfigChangesDateRangeFilter from "./ConfigChangesDateRangeFIlter"; import { ConfigTagsDropdown } from "./ConfigTagsDropdown"; import ShowDeletedConfigs from "../../ConfigsListFilters/ShowDeletedConfigs"; +import ConfigChangesViewToggle from "../ConfigChangesViewToggle"; import ConfigTypesTristateDropdown from "./ConfigTypesTristateDropdown"; type FilterBadgeProps = { @@ -100,6 +101,7 @@ export function ConfigChangeFilters({ +
diff --git a/src/pages/config/ConfigChangesPage.tsx b/src/pages/config/ConfigChangesPage.tsx index d3f63cac1b..f57fc2bc0a 100644 --- a/src/pages/config/ConfigChangesPage.tsx +++ b/src/pages/config/ConfigChangesPage.tsx @@ -1,6 +1,10 @@ import { useGetAllConfigsChangesQuery } from "@flanksource-ui/api/query-hooks/useConfigChangesHooks"; +import { useGetConfigChangesById } from "@flanksource-ui/api/query-hooks/useGetConfigChangesByConfigChangeIdQuery"; +import { ConfigChange } from "@flanksource-ui/api/types/configs"; import { ConfigChangeTable } from "@flanksource-ui/components/Configs/Changes/ConfigChangeTable"; import { ConfigChangeFilters } from "@flanksource-ui/components/Configs/Changes/ConfigChangesFilters/ConfigChangesFilters"; +import { useConfigChangesViewToggleState } from "@flanksource-ui/components/Configs/Changes/ConfigChangesViewToggle"; +import { ConfigDetailChangeModal } from "@flanksource-ui/components/Configs/Changes/ConfigDetailsChanges/ConfigDetailsChanges"; import ConfigPageTabs from "@flanksource-ui/components/Configs/ConfigPageTabs"; import ConfigsTypeIcon from "@flanksource-ui/components/Configs/ConfigsTypeIcon"; import { InfoMessage } from "@flanksource-ui/components/InfoMessage"; @@ -13,9 +17,15 @@ import { Head } from "@flanksource-ui/ui/Head"; import { SearchLayout } from "@flanksource-ui/ui/Layout/SearchLayout"; import { refreshButtonClickedTrigger } from "@flanksource-ui/ui/SlidingSideBar/SlidingSideBar"; import { useAtom } from "jotai"; +import { Suspense, lazy, useEffect, useState } from "react"; import { useSearchParams } from "react-router-dom"; +const ConfigChangesGraph = lazy( + () => import("@flanksource-ui/components/Configs/Changes/ConfigChangesGraph") +); + export function ConfigChangesPage() { + const view = useConfigChangesViewToggleState(); const [, setRefreshButtonClickedTrigger] = useAtom( refreshButtonClickedTrigger ); @@ -49,6 +59,17 @@ export function ConfigChangesPage() { const totalChanges = data?.total ?? 0; const totalChangesPages = Math.ceil(totalChanges / parseInt(pageSize)); + const [selectedChange, setSelectedChange] = useState(); + + useEffect(() => { + if (view !== "Graph") setSelectedChange(undefined); + }, [view]); + + const { data: changeDetails, isLoading: changeLoading } = + useGetConfigChangesById(selectedChange?.id ?? "", { + enabled: view === "Graph" && !!selectedChange + }); + const errorMessage = typeof error === "string" ? error @@ -100,13 +121,40 @@ export function ConfigChangesPage() { ) : ( <> - + {view === "Graph" ? ( + <> + + Loading graph... +
+ } + > + setSelectedChange(change)} + /> + + {selectedChange && ( + { + if (!open) setSelectedChange(undefined); + }} + changeDetails={changeDetails ?? selectedChange} + /> + )} + + ) : ( + + )} )}