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-lock.json b/package-lock.json index 21ab7c45ee..08de4daf9c 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.232", "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..da4c777c27 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.1", "recharts": "^2.15.4", "shiki": "^3.20.0", "streamdown": "^1.6.10", @@ -162,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..e6129e7d78 --- /dev/null +++ b/pages/api/auth/login.ts @@ -0,0 +1,47 @@ +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 controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10_000); + + 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).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/pages/auth-state-checker.tsx b/pages/auth-state-checker.tsx index e7a8a25d7a..2e5dd69192 100644 --- a/pages/auth-state-checker.tsx +++ b/pages/auth-state-checker.tsx @@ -22,6 +22,8 @@ export function ClerkAuthStateChecker() { return ; } +export const getServerSideProps = async () => ({ props: {} }); + export default function AuthStateCheckerPage() { const authSystem = useDetermineAuthSystem(); 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/App.tsx b/src/App.tsx index cb70bfd0b1..b2179f466c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,7 +7,7 @@ import React, { ReactNode, useEffect, useState, useMemo } from "react"; import { IconType } from "react-icons"; import { AiFillHeart } from "react-icons/ai"; import { BsLink, BsToggles } from "react-icons/bs"; -import { FaBell, FaCrosshairs, FaTasks } from "react-icons/fa"; +import { FaArchive, FaBell, FaCrosshairs, FaTasks } from "react-icons/fa"; import { HiUser } from "react-icons/hi"; import { ImLifebuoy } from "react-icons/im"; import { @@ -46,6 +46,7 @@ import { IncidentPageContextProvider } from "./context/IncidentPageContext"; import { UserAccessStateContextProvider } from "./context/UserAccessContext/UserAccessContext"; import { tables } from "./context/UserAccessContext/permissions"; +import { ArtifactsPage } from "./pages/Settings/ArtifactsPage"; import { PermissionsPage } from "./pages/Settings/PermissionsPage"; import ScopesPage from "./pages/Settings/ScopesPage"; import { features } from "./services/permissions/features"; @@ -381,6 +382,13 @@ const settingsNav: SettingsNavigationItems = { icon: AdjustmentsIcon, checkPath: false, submenu: [ + { + name: "Artifacts", + href: "/settings/artifacts", + icon: FaArchive, + featureName: features["settings.artifacts"], + resourceName: tables.artifacts + }, { name: "Connections", href: "/settings/connections", @@ -718,6 +726,15 @@ export function IncidentManagerRoutes({ sidebar }: { sidebar: ReactNode }) { + , + tables.artifacts, + "read", + true + )} + /> 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/api/services/artifacts.ts b/src/api/services/artifacts.ts index d6751b1a1c..bec2b38c2e 100644 --- a/src/api/services/artifacts.ts +++ b/src/api/services/artifacts.ts @@ -1,4 +1,12 @@ -import { ArtifactAPI } from "../axios"; +import { SortingState } from "@tanstack/react-table"; +import { Artifact, ArtifactSummary } from "../types/artifacts"; +import { ArtifactAPI, IncidentCommander } from "../axios"; +import { resolvePostGrestRequestWithPagination } from "../resolve"; + +export function artifactDownloadURL(artifactId: string, filename?: string) { + const params = filename ? `?filename=${encodeURIComponent(filename)}` : ""; + return `/api/artifacts/download/${artifactId}${params}`; +} export async function downloadArtifact(artifactId: string) { const response = await ArtifactAPI.get(`/download/${artifactId}`, { @@ -6,3 +14,54 @@ export async function downloadArtifact(artifactId: string) { }); return response.data; } + +export async function fetchArtifacts({ + pageIndex = 0, + pageSize = 50, + sortBy, + contentType, + filenameSearch +}: { + pageIndex?: number; + pageSize?: number; + sortBy?: SortingState; + contentType?: string; + filenameSearch?: string; +}) { + const query = new URLSearchParams({ + select: + "*,playbook_run_action:playbook_run_actions(id,name,playbook_run_id),config_change:config_changes(id,config_id,change_type)" + }); + + query.set("limit", pageSize.toString()); + query.set("offset", (pageIndex * pageSize).toString()); + + if (sortBy && sortBy.length > 0) { + query.set("order", `${sortBy[0].id}.${sortBy[0].desc ? "desc" : "asc"}`); + } else { + query.set("order", "created_at.desc"); + } + + if (contentType && contentType !== "all") { + query.set("content_type", `eq.${contentType}`); + } + + if (filenameSearch) { + query.set("filename", `ilike.*${filenameSearch}*`); + } + + return resolvePostGrestRequestWithPagination( + IncidentCommander.get(`/artifacts?${query.toString()}`, { + headers: { + Prefer: "count=exact" + } + }) + ); +} + +export async function fetchArtifactSummary() { + const res = await IncidentCommander.get( + "/artifact_summary?select=*" + ); + return res.data ?? []; +} diff --git a/src/api/services/configs.ts b/src/api/services/configs.ts index 98d0fafab8..31131c3b86 100644 --- a/src/api/services/configs.ts +++ b/src/api/services/configs.ts @@ -377,7 +377,7 @@ export const getConfigListFilteredByType = (types: string[]) => { export const getConfigChangeById = async (id: string) => { const res = await ConfigDB.get( - `/config_changes?id=eq.${id}&select=id,config_id,change_type,created_at,external_created_by,source,diff,details,patches,created_by,config:configs(id,name,type,config_class)` + `/config_changes?id=eq.${id}&select=id,config_id,change_type,created_at,external_created_by,source,diff,details,patches,created_by,config:configs(id,name,type,config_class),artifacts:artifacts(*)::jsonb` ); return res.data?.[0] ?? null; }; 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/api/types/artifacts.ts b/src/api/types/artifacts.ts new file mode 100644 index 0000000000..b998a6dffa --- /dev/null +++ b/src/api/types/artifacts.ts @@ -0,0 +1,25 @@ +export type Artifact = { + id: string; + filename: string; + path: string; + content_type: string; + checksum: string; + size: number; + playbook_run_action_id?: string; + config_change_id?: string; + connection_id?: string; + created_at: string; + // PostgREST embedded joins + playbook_run_action?: { id: string; name: string; playbook_run_id: string }; + config_change?: { id: string; config_id: string; change_type: string }; +}; + +export type ArtifactSummary = { + content_type: string; + storage: "inline" | "external"; + connection_id: string | null; + connection_name: string | null; + connection_type: string | null; + total_count: number; + total_size: number; +}; diff --git a/src/api/types/configs.ts b/src/api/types/configs.ts index a0cfc4cb73..1bc6808f1c 100644 --- a/src/api/types/configs.ts +++ b/src/api/types/configs.ts @@ -1,5 +1,6 @@ import { Agent, Avatar, CreatedAt, Timestamped } from "../traits"; import { HealthCheckSummary } from "./health"; +import { PlaybookArtifact } from "./playbooks"; import { Property } from "./topology"; export interface ConfigChange extends CreatedAt { @@ -23,6 +24,7 @@ export interface ConfigChange extends CreatedAt { tags?: Record; first_observed?: string; count?: number; + artifacts?: PlaybookArtifact[]; } export interface Change { diff --git a/src/components/Artifacts/ArtifactPreviewModal.tsx b/src/components/Artifacts/ArtifactPreviewModal.tsx new file mode 100644 index 0000000000..4ae3a26683 --- /dev/null +++ b/src/components/Artifacts/ArtifactPreviewModal.tsx @@ -0,0 +1,161 @@ +import { Artifact } from "@flanksource-ui/api/types/artifacts"; +import { + artifactDownloadURL, + downloadArtifact +} from "@flanksource-ui/api/services/artifacts"; +import { formatBytes } from "@flanksource-ui/utils/common"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle +} from "@flanksource-ui/components/ui/dialog"; +import { Button } from "@flanksource-ui/ui/Buttons/Button"; +import CodeBlock from "@flanksource-ui/ui/Code/CodeBlock"; +import { darkTheme } from "@flanksource-ui/ui/Code/JSONViewerTheme"; +import { JSONViewer } from "@flanksource-ui/ui/Code/JSONViewer"; +import { DisplayMarkdown } from "@flanksource-ui/components/Utils/Markdown"; +import { useQuery } from "@tanstack/react-query"; +import { IoMdDownload } from "react-icons/io"; + +type ArtifactPreviewModalProps = { + artifact: Artifact | undefined; + onClose: () => void; +}; + +const MAX_PREVIEW_SIZE = 50 * 1024 * 1024; // 50MB + +function isImageContentType(contentType: string) { + return contentType.startsWith("image/"); +} + +export function ArtifactPreviewModal({ + artifact, + onClose +}: ArtifactPreviewModalProps) { + const isSmallFile = artifact ? artifact.size < MAX_PREVIEW_SIZE : false; + + const isImage = artifact ? isImageContentType(artifact.content_type) : false; + + const { data: content, isFetching: isLoading } = useQuery({ + queryKey: ["artifact", artifact?.id], + queryFn: () => downloadArtifact(artifact!.id), + enabled: !!artifact && isSmallFile && !isImage, + staleTime: Infinity, + cacheTime: 1000 * 60 * 60 * 24 + }); + + const downloadURL = artifact + ? artifactDownloadURL(artifact.id, artifact.filename) + : undefined; + + return ( + !open && onClose()}> + + + + {artifact?.filename} + {artifact && ( + + ({formatBytes(artifact.size)}) + + )} + + + +
+ {artifact && !isSmallFile && ( +
+

+ File is too large to preview. Maximum file size is 50MB. +

+ +
+ )} + + {isLoading && ( +
+
+ Loading... +
+ )} + + {isImage && artifact && downloadURL && ( + {artifact.filename} + )} + + {content && + artifact && + !isImage && + renderContent(artifact.content_type, content)} +
+ + + {downloadURL && ( + +
+ ); +} + +function renderContent(contentType: string, content: string) { + switch (contentType) { + case "text/x-shellscript": + return ( + + ); + + case "application/sql": + return ( + + ); + + case "text/markdown": + case "markdown": + return ; + + case "application/yaml": + case "application/json": + return ( +
+          
+        
+ ); + + case "text/plain": + default: + return ( +
+          {content}
+        
+ ); + } +} diff --git a/src/components/Artifacts/ArtifactsSummaryCards.tsx b/src/components/Artifacts/ArtifactsSummaryCards.tsx new file mode 100644 index 0000000000..be3ca585c9 --- /dev/null +++ b/src/components/Artifacts/ArtifactsSummaryCards.tsx @@ -0,0 +1,141 @@ +import { ArtifactSummary } from "@flanksource-ui/api/types/artifacts"; +import { fetchArtifactSummary } from "@flanksource-ui/api/services/artifacts"; +import { formatBytes } from "@flanksource-ui/utils/common"; +import { useQuery } from "@tanstack/react-query"; +import { useMemo } from "react"; + +type StorageBreakdown = { + label: string; + count: number; + size: number; +}; + +type ContentTypeBreakdown = { + contentType: string; + count: number; + size: number; +}; + +function aggregateSummary(rows: ArtifactSummary[]) { + let totalCount = 0; + let totalSize = 0; + + const storageMap = new Map(); + const typeMap = new Map(); + + for (const row of rows) { + totalCount += row.total_count; + totalSize += row.total_size; + + // Storage breakdown + const storageKey = + row.storage === "inline" + ? "inline" + : (row.connection_name ?? row.connection_id ?? "external"); + const existing = storageMap.get(storageKey) ?? { + label: + row.storage === "inline" + ? "Inline (Database)" + : (row.connection_name ?? "External"), + count: 0, + size: 0 + }; + existing.count += row.total_count; + existing.size += row.total_size; + storageMap.set(storageKey, existing); + + // Content type breakdown + const ct = row.content_type || "unknown"; + const existingType = typeMap.get(ct) ?? { + contentType: ct, + count: 0, + size: 0 + }; + existingType.count += row.total_count; + existingType.size += row.total_size; + typeMap.set(ct, existingType); + } + + return { + totalCount, + totalSize, + storage: [...storageMap.values()].sort((a, b) => b.size - a.size), + contentTypes: [...typeMap.values()].sort((a, b) => b.count - a.count) + }; +} + +export function ArtifactsSummaryCards() { + const { data: summaryRows = [] } = useQuery({ + queryKey: ["artifact_summary"], + queryFn: fetchArtifactSummary + }); + + const summary = useMemo(() => aggregateSummary(summaryRows), [summaryRows]); + + if (summary.totalCount === 0) { + return null; + } + + return ( +
+ {/* Total */} + + + {/* Storage breakdown */} + {summary.storage.map((s) => ( + + ))} + + {/* Content type breakdown */} + {summary.contentTypes.map((ct) => ( + + ))} +
+ ); +} + +function SummaryCard({ + title, + value, + subtitle +}: { + title: string; + value: string; + subtitle: string; +}) { + return ( +
+ {title} + {value} + {subtitle} +
+ ); +} + +const CONTENT_TYPE_LABELS: Record = { + "application/json": "JSON", + "application/yaml": "YAML", + "text/plain": "Text", + "text/markdown": "Markdown", + "application/sql": "SQL", + "text/x-shellscript": "Shell", + "application/log+json": "Log JSON" +}; + +function formatContentType(ct: string): string { + return CONTENT_TYPE_LABELS[ct] ?? ct; +} diff --git a/src/components/Artifacts/ArtifactsTable.tsx b/src/components/Artifacts/ArtifactsTable.tsx new file mode 100644 index 0000000000..7cf264f18a --- /dev/null +++ b/src/components/Artifacts/ArtifactsTable.tsx @@ -0,0 +1,105 @@ +import { Artifact } from "@flanksource-ui/api/types/artifacts"; +import { formatBytes } from "@flanksource-ui/utils/common"; +import { MRTDateCell } from "@flanksource-ui/ui/MRTDataTable/Cells/MRTDateCells"; +import { MRTCellProps } from "@flanksource-ui/ui/MRTDataTable/MRTCellProps"; +import MRTDataTable from "@flanksource-ui/ui/MRTDataTable/MRTDataTable"; +import { MRT_ColumnDef } from "mantine-react-table"; +import { Link } from "react-router-dom"; + +function SizeCell({ row }: MRTCellProps) { + return {formatBytes(row.original.size)}; +} + +function SourceCell({ row }: MRTCellProps) { + const { playbook_run_action, config_change } = row.original; + + if (playbook_run_action) { + return ( + e.stopPropagation()} + > + Playbook: {playbook_run_action.name} + + ); + } + + if (config_change) { + return ( + e.stopPropagation()} + > + Config Change: {config_change.change_type} + + ); + } + + return -; +} + +const columns: MRT_ColumnDef[] = [ + { + header: "Filename", + accessorKey: "filename", + minSize: 200, + enableResizing: true + }, + { + header: "Content Type", + accessorKey: "content_type", + maxSize: 120 + }, + { + header: "Size", + accessorKey: "size", + Cell: SizeCell, + maxSize: 80 + }, + { + header: "Source", + accessorKey: "playbook_run_action_id", + Cell: SourceCell, + enableSorting: false, + minSize: 150 + }, + { + header: "Created", + accessorKey: "created_at", + Cell: MRTDateCell, + sortingFn: "datetime", + maxSize: 100 + } +]; + +type ArtifactsTableProps = { + artifacts: Artifact[]; + isLoading?: boolean; + pageCount?: number; + totalRowCount?: number; + onRowClick?: (artifact: Artifact) => void; +}; + +export function ArtifactsTable({ + artifacts, + isLoading, + pageCount, + totalRowCount, + onRowClick +}: ArtifactsTableProps) { + return ( + + ); +} 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/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/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/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/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/components/Configs/Changes/ConfigChangesGraph.tsx b/src/components/Configs/Changes/ConfigChangesGraph.tsx new file mode 100644 index 0000000000..1c3dc9aa0b --- /dev/null +++ b/src/components/Configs/Changes/ConfigChangesGraph.tsx @@ -0,0 +1,132 @@ +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 + .filter((change) => change.first_observed && change.config?.name) + .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 && + change.first_observed && ( + + (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/ConfigDetailsChanges/ConfigDetailsChanges.tsx b/src/components/Configs/Changes/ConfigDetailsChanges/ConfigDetailsChanges.tsx index 3e164ddc4b..9737b79948 100644 --- a/src/components/Configs/Changes/ConfigDetailsChanges/ConfigDetailsChanges.tsx +++ b/src/components/Configs/Changes/ConfigDetailsChanges/ConfigDetailsChanges.tsx @@ -11,10 +11,14 @@ import { JSONViewer } from "@flanksource-ui/ui/Code/JSONViewer"; import { ChangeIcon } from "@flanksource-ui/ui/Icons/ChangeIcon"; import { ConfigIcon } from "@flanksource-ui/ui/Icons/ConfigIcon"; import { Icon } from "@flanksource-ui/ui/Icons/Icon"; +import { artifactDownloadURL } from "@flanksource-ui/api/services/artifacts"; +import { formatBytes } from "@flanksource-ui/utils/common"; import { Loading } from "@flanksource-ui/ui/Loading"; import { Modal } from "@flanksource-ui/ui/Modal"; import ModalTitleListItems from "@flanksource-ui/ui/Modal/ModalTitleListItems"; import { Stat } from "@flanksource-ui/ui/stats/Stat"; +import { TbFileDescription } from "react-icons/tb"; +import { IoMdDownload } from "react-icons/io"; import { useMemo, useState } from "react"; import ConfigLink from "../../ConfigLink/ConfigLink"; import ConfigChangeDetailSection from "./ConfigChangeDetailsSection"; @@ -68,7 +72,7 @@ export function ConfigDetailsChanges({ } + value={} />
+ {changeDetails?.artifacts && changeDetails.artifacts.length > 0 && ( +
+ {changeDetails.artifacts.map((artifact) => ( + + + {artifact.filename} + + ({formatBytes(artifact.size)}) + + + + ))} +
+ )} {changeDetails?.details && ( -
+
@@ -35,6 +41,7 @@ export function ConfigRelatedChangesFilters({ +
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/Playbooks/Runs/Actions/PlaybookResultsDropdownButton.tsx b/src/components/Playbooks/Runs/Actions/PlaybookResultsDropdownButton.tsx index 8715fdebad..507e9ee598 100644 --- a/src/components/Playbooks/Runs/Actions/PlaybookResultsDropdownButton.tsx +++ b/src/components/Playbooks/Runs/Actions/PlaybookResultsDropdownButton.tsx @@ -41,8 +41,10 @@ export default function TabContentDownloadButton({ const onDownloadLogs = useCallback(async () => { 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/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 && (
+ } + > + setSelectedChange(change)} + /> + + {selectedChange && ( + { + if (!open) setSelectedChange(undefined); + }} + changeDetails={changeDetails ?? selectedChange} + /> + )} + + ) : ( + + )} )} diff --git a/src/pages/config/details/ConfigDetailsChangesPage.tsx b/src/pages/config/details/ConfigDetailsChangesPage.tsx index 2b0cfcab1b..aa360df2e4 100644 --- a/src/pages/config/details/ConfigDetailsChangesPage.tsx +++ b/src/pages/config/details/ConfigDetailsChangesPage.tsx @@ -1,12 +1,22 @@ +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 { 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 { Suspense, lazy, useEffect, 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(); const [params] = useSearchParams({ sortBy: "created_at", @@ -20,18 +30,29 @@ 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 ?? "" } })); 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 + }); + if (error) { const errorMessage = typeof error === "symbol" @@ -52,12 +73,39 @@ export function ConfigDetailsChangesPage() {
- + {view === "Graph" ? ( + <> + + Loading graph... +
+ } + > + setSelectedChange(change)} + /> + + {selectedChange && ( + { + if (!open) setSelectedChange(undefined); + }} + changeDetails={changeDetails ?? selectedChange} + /> + )} + + ) : ( + + )}
diff --git a/src/pages/config/settings/ConfigPluginsPage.tsx b/src/pages/config/settings/ConfigPluginsPage.tsx index 15eccbb0c3..4cbea0928d 100644 --- a/src/pages/config/settings/ConfigPluginsPage.tsx +++ b/src/pages/config/settings/ConfigPluginsPage.tsx @@ -8,10 +8,8 @@ import { import ConfigPageTabs from "@flanksource-ui/components/Configs/ConfigPageTabs"; import { AuthorizationAccessCheck } from "@flanksource-ui/components/Permissions/AuthorizationAccessCheck"; import PluginsFormModal from "@flanksource-ui/components/Plugins/PluginsFormModal"; -import { - toastError, - toastSuccess -} from "@flanksource-ui/components/Toast/toast"; +import { toastSuccess } from "@flanksource-ui/components/Toast/toast"; +import { getErrorMessage } from "@flanksource-ui/api/types/error"; import { useUser } from "@flanksource-ui/context"; import { tables } from "@flanksource-ui/context/UserAccessContext/permissions"; import { @@ -27,7 +25,7 @@ import { Head } from "@flanksource-ui/ui/Head"; import { SearchLayout } from "@flanksource-ui/ui/Layout/SearchLayout"; import { useMutation, useQuery } from "@tanstack/react-query"; import { MRT_ColumnDef } from "mantine-react-table"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { AiFillPlusCircle } from "react-icons/ai"; import CRDSource from "@flanksource-ui/components/Settings/CRDSource"; @@ -140,6 +138,9 @@ export default function ConfigPluginsPage() { const user = useUser(); const [isOpen, setIsOpen] = useState(false); const [editedRow, setEditedRow] = useState(); + const [formError, setFormError] = useState(); + + const clearFormError = useCallback(() => setFormError(undefined), []); const [sortState] = useReactTableSortState(); const { pageIndex, pageSize } = useReactTablePaginationState(); @@ -160,7 +161,7 @@ export default function ConfigPluginsPage() { const payload = buildPluginPayload(values.name, values.spec); const response = await createScrapePlugin({ ...payload, - created_by: user.user, + created_by: user.user?.id, source: payload.source || "UI" }); return response?.data; @@ -171,7 +172,7 @@ export default function ConfigPluginsPage() { toastSuccess("Plugin added successfully"); }, onError: (ex) => { - toastError((ex as Error).message); + setFormError(getErrorMessage(ex)); } }); @@ -193,7 +194,7 @@ export default function ConfigPluginsPage() { toastSuccess("Plugin updated successfully"); }, onError: (ex) => { - toastError((ex as Error).message); + setFormError(getErrorMessage(ex)); } }); @@ -208,7 +209,7 @@ export default function ConfigPluginsPage() { toastSuccess("Plugin deleted successfully"); }, onError: (ex) => { - toastError((ex as Error).message); + setFormError(getErrorMessage(ex)); } }); @@ -280,6 +281,7 @@ export default function ConfigPluginsPage() { isOpen={isOpen} setIsOpen={setIsOpen} onSubmit={(values: { name: string; spec: Record }) => { + clearFormError(); if (editedRow?.id) { updatePlugin(values); } else { @@ -290,6 +292,7 @@ export default function ConfigPluginsPage() { isSubmitting={isSubmitting} isDeleting={isDeleting} formValue={editedRow} + errorMessage={formError} key={editedRow?.id || "plugin-form"} /> 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/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 = { 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]); 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" ? (
+ ) : ( +
)}
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" && ( +
+ +
+ )} +
-
- )} - - )} + )} + + ); + }} ); }