diff --git a/extralit-frontend/package-lock.json b/extralit-frontend/package-lock.json index e2ab4f7e5..abc9d26fb 100644 --- a/extralit-frontend/package-lock.json +++ b/extralit-frontend/package-lock.json @@ -3018,6 +3018,128 @@ "deprecated": "Use @eslint/object-schema instead", "dev": true }, + "node_modules/@inquirer/ansi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", + "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/confirm": { + "version": "5.1.21", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz", + "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "10.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz", + "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "cli-width": "^4.1.0", + "mute-stream": "^2.0.0", + "signal-exit": "^4.1.0", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core/node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "engines": { + "node": ">= 12" + } + }, + "node_modules/@inquirer/core/node_modules/mute-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", + "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@inquirer/figures": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", + "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/type": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz", + "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/@interactjs/types": { "version": "1.10.27", "resolved": "https://registry.npmjs.org/@interactjs/types/-/types-1.10.27.tgz", @@ -6057,6 +6179,28 @@ "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==", "dev": true }, + "node_modules/@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, "node_modules/@open-draft/until": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-1.0.3.tgz", @@ -6813,6 +6957,15 @@ "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", "dev": true }, + "node_modules/@types/statuses": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", + "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/@types/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@types/strip-bom/-/strip-bom-3.0.0.tgz", @@ -25475,6 +25628,15 @@ "node": ">= 4" } }, + "node_modules/rettime": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.7.0.tgz", + "integrity": "sha512-LPRKoHnLKd/r3dVxcwO7vhCW+orkOGj9ViueosEBK6ie89CijnfRlhaDhHq/3Hxu4CkWQtxwlBG0mzTQY6uQjw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -27567,6 +27729,30 @@ "@popperjs/core": "^2.9.0" } }, + "node_modules/tldts": { + "version": "7.0.18", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.18.tgz", + "integrity": "sha512-lCcgTAgMxQ1JKOWrVGo6E69Ukbnx4Gc1wiYLRf6J5NN4HRYJtCby1rPF8rkQ4a6qqoFBK5dvjJ1zJ0F7VfDSvw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tldts-core": "^7.0.18" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.18", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.18.tgz", + "integrity": "sha512-jqJC13oP4FFAahv4JT/0WTDrCF9Okv7lpKtOZUGPLiAnNbACcSg8Y8T+Z9xthOmRBqi/Sob4yi0TE0miRCvF7Q==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/tmp": { "version": "0.0.33", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", @@ -28377,6 +28563,18 @@ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" }, + "node_modules/until-async": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz", + "integrity": "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "funding": { + "url": "https://github.com/sponsors/kettanaito" + } + }, "node_modules/upath": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/upath/-/upath-2.0.1.tgz", @@ -29529,6 +29727,15 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/vite-node/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/vite-node/node_modules/fdir": { "version": "6.4.6", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", @@ -29564,6 +29771,40 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/vite-node/node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/vite-node/node_modules/terser": { + "version": "5.44.1", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.44.1.tgz", + "integrity": "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "peer": true, + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/vite-node/node_modules/vite": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/vite/-/vite-7.0.0.tgz", @@ -29639,6 +29880,21 @@ } } }, + "node_modules/vite-node/node_modules/yaml": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", + "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + } + }, "node_modules/vitest": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", @@ -29725,6 +29981,35 @@ "vitest": ">=2.0.0" } }, + "node_modules/vitest/node_modules/@mswjs/interceptors": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.40.0.tgz", + "integrity": "sha512-EFd6cVbHsgLa6wa4RljGj6Wk75qoHxUSyc5asLyyPSyuhIcdS2Q3Phw6ImS1q+CkALthJRShiYfKANcQMuMqsQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "strict-event-emitter": "^0.5.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/vitest/node_modules/@vitest/mocker": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", @@ -29752,6 +30037,86 @@ } } }, + "node_modules/vitest/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/vitest/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/vitest/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/vitest/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/vitest/node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=18" + } + }, "node_modules/vitest/node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -29777,6 +30142,62 @@ } } }, + "node_modules/vitest/node_modules/headers-polyfill": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz", + "integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/vitest/node_modules/msw": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.12.2.tgz", + "integrity": "sha512-Fsr8AR5Yu6C0thoWa1Z8qGBFQLDvLsWlAn/v3CNLiUizoRqBYArK3Ex3thXpMWRr1Li5/MKLOEZ5mLygUmWi1A==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@inquirer/confirm": "^5.0.0", + "@mswjs/interceptors": "^0.40.0", + "@open-draft/deferred-promise": "^2.2.0", + "@types/statuses": "^2.0.4", + "cookie": "^1.0.2", + "graphql": "^16.8.1", + "headers-polyfill": "^4.0.2", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "path-to-regexp": "^6.3.0", + "picocolors": "^1.1.1", + "rettime": "^0.7.0", + "statuses": "^2.0.2", + "strict-event-emitter": "^0.5.1", + "tough-cookie": "^6.0.0", + "type-fest": "^4.26.1", + "until-async": "^3.0.2", + "yargs": "^17.7.2" + }, + "bin": { + "msw": "cli/index.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mswjs" + }, + "peerDependencies": { + "typescript": ">= 4.8.x" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/vitest/node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -29797,6 +30218,91 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/vitest/node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/vitest/node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vitest/node_modules/strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/vitest/node_modules/terser": { + "version": "5.44.1", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.44.1.tgz", + "integrity": "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "peer": true, + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/vitest/node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "peer": true, + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/vitest/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "optional": true, + "peer": true, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/vitest/node_modules/vite": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/vite/-/vite-7.0.0.tgz", @@ -29872,6 +30378,86 @@ } } }, + "node_modules/vitest/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/vitest/node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/vitest/node_modules/yaml": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", + "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + } + }, + "node_modules/vitest/node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "engines": { + "node": ">=12" + } + }, "node_modules/vm-browserify": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-1.1.2.tgz", @@ -32474,10 +33060,25 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yoctocolors-cjs": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", + "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zip-js-esm": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/zip-js-esm/-/zip-js-esm-1.1.1.tgz", "integrity": "sha512-8fSLIpssGjX8mqTduIjlUStH1uBmAFWCmkiAo7/G8uDAY+rEnwE6nll5Cyvu+Ytqmw5FIQ7XAhohNrrZL2PheQ==" } } -} +} \ No newline at end of file diff --git a/extralit-server/src/extralit_server/api/handlers/v1/workspaces.py b/extralit-server/src/extralit_server/api/handlers/v1/workspaces.py index c51b9830c..f2adbdf0f 100644 --- a/extralit-server/src/extralit_server/api/handlers/v1/workspaces.py +++ b/extralit-server/src/extralit_server/api/handlers/v1/workspaces.py @@ -26,6 +26,8 @@ ) from extralit_server.api.schemas.v1.workspaces import ( WorkspaceCreate, + WorkspaceDoctorCheckResult, + WorkspaceDoctorResponse, Workspaces, WorkspaceUserCreate, ) @@ -33,7 +35,8 @@ from extralit_server.database import get_async_db from extralit_server.errors import GenericServerError from extralit_server.errors.future import NotFoundError, NotUniqueError, UnprocessableEntityError -from extralit_server.models import User, Workspace, WorkspaceUser +from extralit_server.models import Dataset, User, Workspace, WorkspaceUser +from extralit_server.search_engine import get_search_engine from extralit_server.security import auth router = APIRouter(tags=["workspaces"]) @@ -178,3 +181,201 @@ async def delete_workspace_user( await accounts.delete_workspace_user(db, workspace_user) return await workspace_user.awaitable_attrs.user + + +@router.post("/workspaces/{workspace_id}/doctor", response_model=WorkspaceDoctorResponse) +async def workspace_doctor( + *, + db: Annotated[AsyncSession, Depends(get_async_db)], + workspace_id: UUID, + current_user: Annotated[User, Security(auth.get_current_user)], + s3_client=Depends(files.get_s3_client), + autofix: bool = True, +): + """ + Run diagnostics on a workspace and optionally auto-fix issues. + + Checks: + - S3 bucket exists (can auto-fix) + - Bucket has proper versioning policy (informational) + - RQ worker pool connectivity (informational) + """ + await authorize(current_user, WorkspacePolicy.get(workspace_id)) + + workspace = await Workspace.get_or_raise(db, workspace_id) + checks = [] + + # Check 1: S3 bucket exists + bucket_exists = await files.bucket_exists(s3_client, workspace.name) + if bucket_exists: + checks.append( + WorkspaceDoctorCheckResult( + check_name="s3_bucket", + status="ok", + message=f"S3 bucket '{workspace.name}' exists", + fixed=False, + ) + ) + else: + if autofix: + try: + await files.create_bucket(s3_client, workspace.name) + checks.append( + WorkspaceDoctorCheckResult( + check_name="s3_bucket", + status="ok", + message=f"S3 bucket '{workspace.name}' was missing and has been created", + fixed=True, + ) + ) + except Exception as e: + checks.append( + WorkspaceDoctorCheckResult( + check_name="s3_bucket", + status="error", + message=f"S3 bucket '{workspace.name}' does not exist and failed to create: {e!s}", + fixed=False, + ) + ) + else: + checks.append( + WorkspaceDoctorCheckResult( + check_name="s3_bucket", + status="error", + message=f"S3 bucket '{workspace.name}' does not exist (autofix disabled)", + fixed=False, + ) + ) + + # Check 2: Bucket versioning policy + if bucket_exists or any(check.check_name == "s3_bucket" and check.fixed for check in checks): + versioning = await files.get_bucket_versioning(s3_client, workspace.name) + if versioning: + if versioning["status"] == "Enabled": + checks.append( + WorkspaceDoctorCheckResult( + check_name="bucket_versioning", + status="ok", + message=f"Bucket versioning is enabled (Status: {versioning['status']})", + fixed=False, + ) + ) + else: + checks.append( + WorkspaceDoctorCheckResult( + check_name="bucket_versioning", + status="warning", + message=f"Bucket versioning is not enabled (Status: {versioning['status']})", + fixed=False, + ) + ) + else: + checks.append( + WorkspaceDoctorCheckResult( + check_name="bucket_versioning", + status="warning", + message="Could not retrieve bucket versioning configuration", + fixed=False, + ) + ) + + # Check 3: RQ worker pool connectivity + try: + from extralit_server.jobs.queues import DEFAULT_QUEUE + + # Try to ping Redis through the queue connection + connection = DEFAULT_QUEUE.connection + connection.ping() + + checks.append( + WorkspaceDoctorCheckResult( + check_name="rq_worker_pool", + status="ok", + message="Redis Queue worker pool is reachable", + fixed=False, + ) + ) + except Exception as e: + checks.append( + WorkspaceDoctorCheckResult( + check_name="rq_worker_pool", + status="warning", + message=f"Could not connect to RQ worker pool: {e!s}", + fixed=False, + ) + ) + + # Check 4: Elasticsearch indexes for datasets (informational only) + try: + # Get datasets for this workspace + from sqlalchemy import select + + result = await db.execute(select(Dataset).where(Dataset.workspace_id == workspace.id)) + datasets = result.scalars().all() + + if datasets: + async with get_search_engine() as search_engine: + missing_indexes = [] + for dataset in datasets: + index_name = f"ex.{dataset.id}" + index_exists = await search_engine._index_exists_request(index_name) + if not index_exists: + missing_indexes.append(dataset.name) + + if missing_indexes: + checks.append( + WorkspaceDoctorCheckResult( + check_name="elasticsearch_indexes", + status="warning", + message=f"Missing Elasticsearch indexes for {len(missing_indexes)} dataset(s): {', '.join(missing_indexes[:3])}{'...' if len(missing_indexes) > 3 else ''}", + fixed=False, + ) + ) + else: + checks.append( + WorkspaceDoctorCheckResult( + check_name="elasticsearch_indexes", + status="ok", + message=f"All {len(datasets)} dataset(s) have Elasticsearch indexes", + fixed=False, + ) + ) + else: + checks.append( + WorkspaceDoctorCheckResult( + check_name="elasticsearch_indexes", + status="ok", + message="No datasets found for this workspace", + fixed=False, + ) + ) + except Exception as e: + checks.append( + WorkspaceDoctorCheckResult( + check_name="elasticsearch_indexes", + status="warning", + message=f"Could not check Elasticsearch indexes: {e!s}", + fixed=False, + ) + ) + + # Determine overall status + has_errors = any(check.status == "error" for check in checks) + has_fixed = any(check.fixed for check in checks) + has_warnings = any(check.status == "warning" for check in checks) + + if has_errors: + overall_status = "issues_found" + elif has_fixed: + overall_status = "issues_fixed" + elif has_warnings: + overall_status = "issues_found" + else: + overall_status = "healthy" + + return WorkspaceDoctorResponse( + workspace_id=workspace.id, + workspace_name=workspace.name, + checks=checks, + overall_status=overall_status, + ) diff --git a/extralit-server/src/extralit_server/api/schemas/v1/workspaces.py b/extralit-server/src/extralit_server/api/schemas/v1/workspaces.py index e78cf9165..ef5b4e7e6 100644 --- a/extralit-server/src/extralit_server/api/schemas/v1/workspaces.py +++ b/extralit-server/src/extralit_server/api/schemas/v1/workspaces.py @@ -38,3 +38,17 @@ class Workspaces(BaseModel): class WorkspaceUserCreate(BaseModel): user_id: UUID + + +class WorkspaceDoctorCheckResult(BaseModel): + check_name: str + status: str # "ok", "warning", "error" + message: str + fixed: bool = False + + +class WorkspaceDoctorResponse(BaseModel): + workspace_id: UUID + workspace_name: str + checks: list[WorkspaceDoctorCheckResult] + overall_status: str # "healthy", "issues_found", "issues_fixed" diff --git a/extralit-server/src/extralit_server/contexts/files.py b/extralit-server/src/extralit_server/contexts/files.py index 7318c0f6e..e1a1a7759 100644 --- a/extralit-server/src/extralit_server/contexts/files.py +++ b/extralit-server/src/extralit_server/contexts/files.py @@ -357,6 +357,38 @@ async def delete_object(s3_client, bucket: str, object: str, version_id: str | N raise HTTPException(status_code=500, detail=f"Internal server error: {e!s}") +async def bucket_exists(s3_client: "S3Client", bucket_name: str) -> bool: + """Check if S3 bucket exists.""" + try: + await s3_client.head_bucket(Bucket=bucket_name) + return True + except ClientError as e: + if e.response["Error"]["Code"] in ["404", "NoSuchBucket"]: + return False + # For other errors (like permissions), log and return False + _LOGGER.warning(f"Error checking bucket {bucket_name}: {e}") + return False + except Exception as e: + _LOGGER.warning(f"Unexpected error checking bucket {bucket_name}: {e}") + return False + + +async def get_bucket_versioning(s3_client: "S3Client", bucket_name: str) -> dict[str, str] | None: + """Get bucket versioning configuration.""" + try: + response = await s3_client.get_bucket_versioning(Bucket=bucket_name) + return { + "status": response.get("Status", "Disabled"), + "mfa_delete": response.get("MFADelete", "Disabled"), + } + except ClientError as e: + _LOGGER.error(f"Error getting bucket versioning for {bucket_name}: {e}") + return None + except Exception as e: + _LOGGER.error(f"Unexpected error getting bucket versioning for {bucket_name}: {e}") + return None + + async def create_bucket( s3_client: "S3Client", workspace_name: str, diff --git a/extralit-server/tests/unit/api/handlers/v1/workspaces/test_workspace_doctor.py b/extralit-server/tests/unit/api/handlers/v1/workspaces/test_workspace_doctor.py new file mode 100644 index 000000000..5809c5436 --- /dev/null +++ b/extralit-server/tests/unit/api/handlers/v1/workspaces/test_workspace_doctor.py @@ -0,0 +1,156 @@ +# Copyright 2024-present, Extralit Labs, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from unittest.mock import patch + +import pytest +from httpx import AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession + +from tests.factories import WorkspaceFactory + + +@pytest.mark.asyncio +class TestWorkspaceDoctor: + def url(self, workspace_id: str) -> str: + return f"/api/v1/workspaces/{workspace_id}/doctor" + + async def test_workspace_doctor_healthy(self, db: AsyncSession, async_client: AsyncClient, owner_auth_header: dict): + workspace = await WorkspaceFactory.create() + + # Mock S3 client and Redis connection + with ( + patch("extralit_server.contexts.files.bucket_exists") as mock_bucket_exists, + patch("extralit_server.contexts.files.get_bucket_versioning") as mock_get_versioning, + patch("extralit_server.jobs.queues.DEFAULT_QUEUE") as mock_queue, + ): + mock_bucket_exists.return_value = True + mock_get_versioning.return_value = {"status": "Enabled", "mfa_delete": "Disabled"} + mock_queue.connection.ping.return_value = True + + response = await async_client.post( + self.url(str(workspace.id)), + headers=owner_auth_header, + params={"autofix": True}, + ) + + assert response.status_code == 200 + data = response.json() + + assert data["workspace_id"] == str(workspace.id) + assert data["workspace_name"] == workspace.name + assert data["overall_status"] == "healthy" + assert len(data["checks"]) >= 3 # At least bucket, versioning, and RQ checks + + # Check that bucket check passed + bucket_check = next((c for c in data["checks"] if c["check_name"] == "s3_bucket"), None) + assert bucket_check is not None + assert bucket_check["status"] == "ok" + assert bucket_check["fixed"] is False + + async def test_workspace_doctor_missing_bucket_with_autofix( + self, db: AsyncSession, async_client: AsyncClient, owner_auth_header: dict + ): + workspace = await WorkspaceFactory.create() + + with ( + patch("extralit_server.contexts.files.bucket_exists") as mock_bucket_exists, + patch("extralit_server.contexts.files.create_bucket") as mock_create_bucket, + patch("extralit_server.contexts.files.get_bucket_versioning") as mock_get_versioning, + patch("extralit_server.jobs.queues.DEFAULT_QUEUE") as mock_queue, + ): + mock_bucket_exists.return_value = False + mock_create_bucket.return_value = None + mock_get_versioning.return_value = {"status": "Enabled", "mfa_delete": "Disabled"} + mock_queue.connection.ping.return_value = True + + response = await async_client.post( + self.url(str(workspace.id)), + headers=owner_auth_header, + params={"autofix": True}, + ) + + assert response.status_code == 200 + data = response.json() + + assert data["overall_status"] == "issues_fixed" + + # Check that bucket was created + bucket_check = next((c for c in data["checks"] if c["check_name"] == "s3_bucket"), None) + assert bucket_check is not None + assert bucket_check["status"] == "ok" + assert bucket_check["fixed"] is True + assert "created" in bucket_check["message"].lower() + + # Verify create_bucket was called + mock_create_bucket.assert_called_once() + + async def test_workspace_doctor_missing_bucket_without_autofix( + self, db: AsyncSession, async_client: AsyncClient, owner_auth_header: dict + ): + workspace = await WorkspaceFactory.create() + + with ( + patch("extralit_server.contexts.files.bucket_exists") as mock_bucket_exists, + patch("extralit_server.contexts.files.create_bucket") as mock_create_bucket, + patch("extralit_server.contexts.files.get_bucket_versioning") as mock_get_versioning, + patch("extralit_server.jobs.queues.DEFAULT_QUEUE") as mock_queue, + ): + mock_bucket_exists.return_value = False + mock_get_versioning.return_value = None + mock_queue.connection.ping.return_value = True + + response = await async_client.post( + self.url(str(workspace.id)), + headers=owner_auth_header, + params={"autofix": False}, + ) + + assert response.status_code == 200 + data = response.json() + + assert data["overall_status"] == "issues_found" + + # Check that bucket error is reported + bucket_check = next((c for c in data["checks"] if c["check_name"] == "s3_bucket"), None) + assert bucket_check is not None + assert bucket_check["status"] == "error" + assert bucket_check["fixed"] is False + + # Verify create_bucket was NOT called + mock_create_bucket.assert_not_called() + + async def test_workspace_doctor_without_authentication(self, db: AsyncSession, async_client: AsyncClient): + workspace = await WorkspaceFactory.create() + + response = await async_client.post( + self.url(str(workspace.id)), + params={"autofix": True}, + ) + + assert response.status_code == 401 + + async def test_workspace_doctor_nonexistent_workspace( + self, db: AsyncSession, async_client: AsyncClient, owner_auth_header: dict + ): + from uuid import uuid4 + + fake_id = uuid4() + response = await async_client.post( + self.url(str(fake_id)), + headers=owner_auth_header, + params={"autofix": True}, + ) + + assert response.status_code == 404 diff --git a/extralit/docs/user_guide/command_line_interface.md b/extralit/docs/user_guide/command_line_interface.md index fcf742cfb..2b5b19466 100644 --- a/extralit/docs/user_guide/command_line_interface.md +++ b/extralit/docs/user_guide/command_line_interface.md @@ -87,6 +87,30 @@ List all workspaces you have access to: extralit workspaces list ``` +### Diagnosing Workspace Issues + +Run health checks on a workspace to identify and fix common issues: + +```bash +# Run diagnostics with automatic fixes +extralit workspaces --name my-workspace doctor --autofix + +# Run diagnostics without fixing issues (informational only) +extralit workspaces --name my-workspace doctor --no-autofix +``` + +The doctor command checks: +- **S3 bucket existence**: Automatically creates the bucket if missing (with autofix) +- **Bucket versioning**: Verifies proper file versioning policy is enabled (informational) +- **RQ worker pool**: Tests connectivity to the background job queue (informational) +- **Elasticsearch indexes**: Checks if dataset indexes exist (informational) + +Use this command when: +- Setting up a new workspace to verify configuration +- Troubleshooting upload or storage issues +- After environment changes or migrations +- Regular health monitoring + ## Document Management ### Importing Documents diff --git a/extralit/docs/user_guide/workspace.md b/extralit/docs/user_guide/workspace.md index 836b77356..8a8d45bbe 100644 --- a/extralit/docs/user_guide/workspace.md +++ b/extralit/docs/user_guide/workspace.md @@ -154,6 +154,24 @@ workspace = client.workspaces("my_workspace") removed_user = workspace.remove_user("my_username") ``` +## Diagnose workspace health + + +You can run health diagnostics on a workspace to check for common issues like missing S3 buckets, connectivity problems, or configuration issues. The `doctor` method checks various aspects of the workspace setup and can automatically fix certain issues. + +!!! tip "CLI Usage" + You can run workspace diagnostics from the command line: + ```bash + extralit workspaces --name my_workspace doctor + ``` + +The doctor checks: +- **S3 bucket existence**: Creates the bucket if missing (when autofix=True) +- **Bucket versioning**: Verifies file versioning policy (informational) +- **RQ worker pool**: Tests background job queue connectivity (informational) +- **Elasticsearch indexes**: Checks dataset index availability (informational) + + ## Delete a workspace To delete a workspace, **no dataset can be associated with it**. If the workspace contains any dataset, deletion will fail. You can delete a workspace by calling the `delete` method on the `Workspace` class. diff --git a/extralit/src/extralit/_api/_workspaces.py b/extralit/src/extralit/_api/_workspaces.py index fa03ac474..0d8ede6ba 100644 --- a/extralit/src/extralit/_api/_workspaces.py +++ b/extralit/src/extralit/_api/_workspaces.py @@ -25,7 +25,7 @@ from extralit._constants import _DEFAULT_SCHEMA_S3_PATH from extralit._exceptions._api import ExtralitAPIError, api_error_handler from extralit._models._files import FileObjectResponse, ListObjectsResponse, ObjectMetadata -from extralit._models._workspace import WorkspaceModel +from extralit._models._workspace import WorkspaceDoctorResponse, WorkspaceModel if TYPE_CHECKING: from extralit._models._schema import SchemaStructure @@ -116,6 +116,31 @@ def add_user(self, workspace_id: "UUID", user_id: "UUID") -> None: response.raise_for_status() self._log_message(message=f"Added user {user_id} to workspace {workspace_id}") + @api_error_handler + def doctor(self, workspace_id: "UUID", autofix: bool = True) -> "WorkspaceDoctorResponse": + """ + Run diagnostics on a workspace and optionally auto-fix issues. + + Args: + workspace_id: The ID of the workspace to diagnose. + autofix: Whether to automatically fix issues (default: True). + + Returns: + WorkspaceDoctorResponse with diagnostic results. + + Raises: + ExtralitAPIError: If the API request fails. + """ + logger.info(f"Running doctor diagnostics on workspace {workspace_id}") + response = self.http_client.post(url=f"{self.url_stub}/{workspace_id}/doctor", params={"autofix": autofix}) + response.raise_for_status() + response_json = response.json() + doctor_response = WorkspaceDoctorResponse(**response_json) + self._log_message( + message=f"Doctor check completed for workspace {workspace_id}: {doctor_response.overall_status}" + ) + return doctor_response + #################### # File methods # #################### diff --git a/extralit/src/extralit/_models/_workspace.py b/extralit/src/extralit/_models/_workspace.py index cac81ab34..8982e162e 100644 --- a/extralit/src/extralit/_models/_workspace.py +++ b/extralit/src/extralit/_models/_workspace.py @@ -13,12 +13,13 @@ # limitations under the License. import re +from uuid import UUID -from pydantic import ConfigDict, field_validator +from pydantic import BaseModel, ConfigDict, field_validator from extralit._models import ResourceModel -__all__ = ["WorkspaceModel"] +__all__ = ["WorkspaceDoctorCheckResult", "WorkspaceDoctorResponse", "WorkspaceModel"] class WorkspaceModel(ResourceModel): @@ -36,3 +37,21 @@ def validate_name(cls, value): if not re.match(r"^[a-zA-Z0-9.-]+$", value): raise ValueError("Workspace name must be url safe and cannot contain underscores") return value + + +class WorkspaceDoctorCheckResult(BaseModel): + """Result of a single doctor check.""" + + check_name: str + status: str + message: str + fixed: bool = False + + +class WorkspaceDoctorResponse(BaseModel): + """Response from workspace doctor diagnostic.""" + + workspace_id: UUID + workspace_name: str + checks: list[WorkspaceDoctorCheckResult] + overall_status: str diff --git a/extralit/src/extralit/cli/workspaces/__main__.py b/extralit/src/extralit/cli/workspaces/__main__.py index e44192de5..68834a9e3 100644 --- a/extralit/src/extralit/cli/workspaces/__main__.py +++ b/extralit/src/extralit/cli/workspaces/__main__.py @@ -22,7 +22,7 @@ from extralit.cli.callback import init_callback from extralit.cli.rich import get_themed_panel, print_rich_table -_COMMANDS_REQUIRING_WORKSPACE = ["add-user", "delete-user"] +_COMMANDS_REQUIRING_WORKSPACE = ["add-user", "delete-user", "doctor"] def callback( @@ -44,16 +44,19 @@ def callback( try: workspace = client.workspaces(name) + + if workspace is None: + panel = get_themed_panel( + f"Workspace with name={name} does not exist.", + title="Workspace not found", + title_align="left", + success=False, + ) + Console().print(panel) + raise typer.Exit(code=1) + ctx.obj = workspace - except ValueError: - panel = get_themed_panel( - f"Workspace with name={name} does not exist.", - title="Workspace not found", - title_align="left", - success=False, - ) - Console().print(panel) - raise typer.Exit(code=1) + except RuntimeError: panel = get_themed_panel( "An unexpected error occurred when trying to get the workspace from the Extralit server", @@ -157,9 +160,9 @@ def add_user( Console().print(panel) raise typer.Exit(code=1) - workspace_obj = client.workspaces(name=workspace["name"]) + workspace_obj = client.workspaces(name=workspace.name) if not workspace_obj: - raise ValueError(f"Workspace with name={workspace['name']} not found.") + raise ValueError(f"Workspace with name={workspace.name} not found.") user_obj = client.users(username=username) if not user_obj: @@ -171,7 +174,7 @@ def add_user( # Display success message panel = get_themed_panel( - f"User with username={username} has been added to workspace={workspace['name']}", + f"User with username={username} has been added to workspace={workspace.name}", title="User added", title_align="left", ) @@ -220,9 +223,9 @@ def delete_user( Console().print(panel) raise typer.Exit(code=1) - workspace_obj = client.workspaces(name=workspace["name"]) + workspace_obj = client.workspaces(name=workspace.name) if not workspace_obj: - raise ValueError(f"Workspace with name={workspace['name']} not found.") + raise ValueError(f"Workspace with name={workspace.name} not found.") user_obj = client.users(username=username) if not user_obj: @@ -231,7 +234,7 @@ def delete_user( workspace_obj.remove_user(user=user_obj) panel = get_themed_panel( - f"User with username={username} has been removed from workspace={workspace['name']}", + f"User with username={username} has been removed from workspace={workspace.name}", title="User removed", title_align="left", ) @@ -256,5 +259,94 @@ def delete_user( raise typer.Exit(code=1) +@app.command(name="doctor", help="Run diagnostics on a workspace and auto-fix issues") +def workspace_doctor( + ctx: typer.Context, + autofix: bool = typer.Option(False, "--autofix/--no-autofix", help="Automatically fix issues if possible"), +) -> None: + """Run diagnostics on a workspace to check S3 bucket, versioning, RQ worker pool, etc.""" + workspace = ctx.obj + console = Console() + + try: + client = init_callback() + + api = client.api.workspaces + + # Look up workspace by name + workspace_obj = api.get_by_name(workspace.name) + if not workspace_obj: + raise ValueError(f"Workspace with name={workspace.name} not found.") + + console.print(f"\n[bold]Running diagnostics on workspace: {workspace.name}[/bold]\n") + + # Run doctor diagnostics + doctor_response = api.doctor(workspace_obj.id, autofix=autofix) + + # Display results + from rich.table import Table + + table = Table(title=f"Workspace Health Check: {doctor_response.workspace_name}") + table.add_column("Check", style="cyan", no_wrap=True) + table.add_column("Status", style="magenta") + table.add_column("Message", style="white") + table.add_column("Fixed", style="green") + + for check in doctor_response.checks: + fixed_text = "✓" if check.fixed else "" + + table.add_row( + check.check_name, + check.status, + check.message, + fixed_text, + ) + + console.print(table) + console.print() + + # Overall status + if doctor_response.overall_status == "healthy": + panel = get_themed_panel( + "All checks passed. Workspace is healthy.", + title="Workspace Health: ✅ Healthy", + title_align="left", + ) + elif doctor_response.overall_status == "issues_fixed": + panel = get_themed_panel( + "Some issues were found and have been automatically fixed.", + title="Workspace Health: ✅ Issues Fixed", + title_align="left", + ) + else: + panel = get_themed_panel( + "Some issues were found. Review the details above.", + title="Workspace Health: ⚠️ Issues Found", + title_align="left", + success=False, + ) + + console.print(panel) + + except ValueError as e: + panel = get_themed_panel( + str(e), + title="Workspace not found", + title_align="left", + success=False, + ) + Console().print(panel) + raise typer.Exit(code=1) + except RuntimeError as e: + panel = get_themed_panel( + f"An unexpected error occurred: {e!s}", + title="Unexpected error", + title_align="left", + success=False, + ) + Console().print(panel) + raise typer.Exit(code=1) + + if __name__ == "__main__": app()