diff --git a/.claude/preview-worktree.sh b/.claude/preview-worktree.sh index 667782552..db3b7bd36 100755 --- a/.claude/preview-worktree.sh +++ b/.claude/preview-worktree.sh @@ -17,6 +17,12 @@ set -e root="${CLAUDE_PROJECT_DIR:-$PWD}" cd "$root" +# The main checkout is the source of the shared node_modules / .env.local. CLAUDE_PROJECT_DIR is +# the *current* checkout, which is the worktree itself when we're launched from one — so derive +# the main checkout from git rather than assuming $root is it. (Assuming $root pointed the +# symlinks below at a worktree's own node_modules → a self-referential link → ELOOP on start.) +main_checkout="$(cd "$(dirname "$(git rev-parse --git-common-dir)")" && pwd)" + # Read the target worktree from .claude/preview-cwd (repo-relative). `read` trims surrounding # whitespace and tolerates a missing trailing newline; fall back to the main checkout when the # file is absent or empty. @@ -35,13 +41,47 @@ fi # Guard with both -e and -L so an existing real dir/file AND a dangling symlink are left alone # (a broken symlink passes -e but `ln -s` over it would fail with "File exists"). if [ ! -e node_modules ] && [ ! -L node_modules ]; then - ln -s "$root/node_modules" node_modules + ln -s "$main_checkout/node_modules" node_modules echo "[preview] Symlinked node_modules from the main checkout." >&2 fi -if [ ! -e .env.local ] && [ ! -L .env.local ] && [ -f "$root/.env.local" ]; then - ln -s "$root/.env.local" .env.local +if [ ! -e .env.local ] && [ ! -L .env.local ] && [ -f "$main_checkout/.env.local" ]; then + ln -s "$main_checkout/.env.local" .env.local echo "[preview] Symlinked .env.local from the main checkout." >&2 fi +# The preview launcher may inherit an older default Node than pnpm 11 needs (>=22.13). Prefer the +# repo's pinned .nvmrc version; otherwise fall back to the newest installed nvm node >=22. +current_major="$(node -v 2>/dev/null | sed 's/^v//; s/\..*//')" +if [ -z "$current_major" ] || [ "$current_major" -lt 22 ]; then + want="" + [ -f "$root/.nvmrc" ] && want="$(tr -d '[:space:]' < "$root/.nvmrc")" + want="${want#v}" # .nvmrc may write the version with or without a leading `v`. + adjusted="" + if [ -n "$want" ] && [ -d "$HOME/.nvm/versions/node/v${want}/bin" ]; then + PATH="$HOME/.nvm/versions/node/v${want}/bin:$PATH" + adjusted=1 + elif [ -d "$HOME/.nvm/versions/node" ]; then + # Newest installed >=22. Portable numeric sort (descending) instead of `sort -V`, which is a + # GNU/newer-BSD extension missing from older `sort` (e.g. stock macOS). + for v in $(ls -1 "$HOME/.nvm/versions/node" | sed 's/^v//' | sort -t. -k1,1rn -k2,2rn -k3,3rn); do + if [ "${v%%.*}" -ge 22 ]; then + PATH="$HOME/.nvm/versions/node/v$v/bin:$PATH" + adjusted=1 + break + fi + done + fi + # Only claim an adjustment when PATH actually changed; otherwise warn (pnpm will likely fail). + if [ -n "$adjusted" ]; then + export PATH + echo "[preview] Adjusted PATH to Node $(node -v 2>/dev/null) for pnpm." >&2 + else + echo "[preview] Warning: Node $(node -v 2>/dev/null) is older than pnpm 11 needs (>=22.13) and no nvm Node >=22 was found — pnpm may fail." >&2 + fi +fi + echo "[preview] Serving $(pwd) on port 5173" >&2 -exec pnpm run dev +# node_modules is symlinked from the main checkout; skip pnpm's pre-run deps check so it can't +# try to reinstall/purge it (the workspace state won't match, and a purge would mutate the +# shared checkout). We just want to run the dev script against the already-installed modules. +exec pnpm --config.verify-deps-before-run=false run dev diff --git a/.github/workflows/deploy-dev-with-restart.yaml b/.github/workflows/deploy-dev-with-restart.yaml index d84abc72f..2c755464e 100644 --- a/.github/workflows/deploy-dev-with-restart.yaml +++ b/.github/workflows/deploy-dev-with-restart.yaml @@ -19,7 +19,7 @@ jobs: - name: Set up pnpm uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - name: Set up Node.js - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: cache: 'pnpm' cache-dependency-path: 'pnpm-lock.yaml' diff --git a/.github/workflows/deploy-dev.yaml b/.github/workflows/deploy-dev.yaml index fd5ffa974..ee00a9852 100644 --- a/.github/workflows/deploy-dev.yaml +++ b/.github/workflows/deploy-dev.yaml @@ -22,7 +22,7 @@ jobs: - name: Set up pnpm uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - name: Set up Node.js - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: cache: 'pnpm' cache-dependency-path: 'pnpm-lock.yaml' diff --git a/.github/workflows/deploy-prod-with-restart.yaml b/.github/workflows/deploy-prod-with-restart.yaml index c55bf0ec1..169b38e33 100644 --- a/.github/workflows/deploy-prod-with-restart.yaml +++ b/.github/workflows/deploy-prod-with-restart.yaml @@ -19,7 +19,7 @@ jobs: - name: Set up pnpm uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - name: Set up Node.js - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: cache: 'pnpm' cache-dependency-path: 'pnpm-lock.yaml' diff --git a/.github/workflows/deploy-prod.yaml b/.github/workflows/deploy-prod.yaml index 61ac26a57..ca539cd56 100644 --- a/.github/workflows/deploy-prod.yaml +++ b/.github/workflows/deploy-prod.yaml @@ -21,7 +21,7 @@ jobs: - name: Set up pnpm uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - name: Set up Node.js - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: cache: 'pnpm' cache-dependency-path: 'pnpm-lock.yaml' diff --git a/.github/workflows/deploy-stage-with-restart.yaml b/.github/workflows/deploy-stage-with-restart.yaml index 00da9b7b4..afa82c945 100644 --- a/.github/workflows/deploy-stage-with-restart.yaml +++ b/.github/workflows/deploy-stage-with-restart.yaml @@ -21,7 +21,7 @@ jobs: - name: Set up pnpm uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - name: Set up Node.js - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: cache: 'pnpm' cache-dependency-path: 'pnpm-lock.yaml' diff --git a/.github/workflows/deploy-stage.yaml b/.github/workflows/deploy-stage.yaml index 54646f649..3ee0161c9 100644 --- a/.github/workflows/deploy-stage.yaml +++ b/.github/workflows/deploy-stage.yaml @@ -27,7 +27,7 @@ jobs: - name: Set up pnpm uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - name: Set up Node.js - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: cache: 'pnpm' cache-dependency-path: 'pnpm-lock.yaml' diff --git a/.github/workflows/verify-pr.yaml b/.github/workflows/verify-pr.yaml index 06d03d158..669d44713 100644 --- a/.github/workflows/verify-pr.yaml +++ b/.github/workflows/verify-pr.yaml @@ -17,7 +17,7 @@ jobs: - name: Set up pnpm uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - name: Set up Node.js - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: cache: 'pnpm' cache-dependency-path: 'pnpm-lock.yaml' @@ -38,7 +38,7 @@ jobs: # Runs on test failure too — coverage is still written thanks to # reportOnFailure in vitest.config.ts. if: always() - uses: davelosert/vitest-coverage-report-action@3c054a2d2e2ca45446417ad5d6d5eb33092af8f1 # v2.12.1 + uses: davelosert/vitest-coverage-report-action@8b157684c6a6b259b97d45e72b44242865c0f6a5 # v2.12.2 - name: Run lint run: pnpm lint - name: Build diff --git a/package.json b/package.json index 05703036b..b04c6ba3e 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "@tailwindcss/vite": "^4.0.9", "@tanstack/react-query": "^5.90.2", "@tanstack/react-query-devtools": "^5.90.2", - "@tanstack/react-router": "1.170.17", + "@tanstack/react-router": "1.170.18", "@tanstack/react-router-devtools": "1.167.0", "@tanstack/react-table": "^8.21.2", "@types/node": "^24.0.0", @@ -73,7 +73,7 @@ "react": "^19.0.0", "react-complex-tree": "^2.6.1", "react-dom": "^19.0.0", - "react-dropzone": "^15.0.0", + "react-dropzone": "^17.0.0", "react-hook-form": "^7.54.2", "react-markdown": "^10.1.0", "recharts": "^3.2.1", @@ -103,7 +103,7 @@ "dotenv-cli": "^11.0.0", "dprint": "^0.55.0", "happy-dom": "^20.9.0", - "harper": "5.1.18", + "harper": "5.1.21", "husky": "^9.1.7", "jsdom": "^29.0.0", "oxlint": "^1.32.0", @@ -112,6 +112,6 @@ "vite": "^8.0.0", "vitest": "^4.0.0" }, - "packageManager": "pnpm@11.11.0", + "packageManager": "pnpm@11.13.1", "license": "Apache-2.0" } diff --git a/patches/@tanstack__router-core@1.171.14.patch b/patches/@tanstack__router-core@1.171.15.patch similarity index 97% rename from patches/@tanstack__router-core@1.171.14.patch rename to patches/@tanstack__router-core@1.171.15.patch index 304a70478..dd58a7757 100644 --- a/patches/@tanstack__router-core@1.171.14.patch +++ b/patches/@tanstack__router-core@1.171.15.patch @@ -1,5 +1,5 @@ diff --git a/dist/cjs/load-matches.cjs b/dist/cjs/load-matches.cjs -index c09976a80c2c112b7dac6f941e643c81eeaf8d52..9858f1f29c91f4e859ad4cf1e586722730c44754 100644 +index c09976a..9858f1f 100644 --- a/dist/cjs/load-matches.cjs +++ b/dist/cjs/load-matches.cjs @@ -5,6 +5,31 @@ const require_root = require("./root.cjs"); @@ -148,10 +148,10 @@ index c09976a80c2c112b7dac6f941e643c81eeaf8d52..9858f1f29c91f4e859ad4cf1e5867227 exports.loadRouteChunk = loadRouteChunk; exports.routeNeedsPreload = routeNeedsPreload; diff --git a/dist/cjs/router.cjs b/dist/cjs/router.cjs -index b928d955ea81a572f2770baf30bfc9001e4a9011..c9f95fdc964f0bb03474c8b4c3914b73feb61395 100644 +index 69080aa..e63a3c7 100644 --- a/dist/cjs/router.cjs +++ b/dist/cjs/router.cjs -@@ -768,6 +768,7 @@ var RouterCore = class { +@@ -766,6 +766,7 @@ var RouterCore = class { }); return matches; } catch (err) { @@ -160,7 +160,7 @@ index b928d955ea81a572f2770baf30bfc9001e4a9011..c9f95fdc964f0bb03474c8b4c3914b73 if (err.options.reloadDocument) return; return await this.preloadRoute({ diff --git a/dist/esm/load-matches.js b/dist/esm/load-matches.js -index bcea2e0d88d037a01b93bf36dd6e643f98028e83..f8253bec33e2b4aba3e412b1195c4cfbb9e68a70 100644 +index bcea2e0..f8253be 100644 --- a/dist/esm/load-matches.js +++ b/dist/esm/load-matches.js @@ -5,6 +5,31 @@ import { rootRouteId } from "./root.js"; @@ -310,7 +310,7 @@ index bcea2e0d88d037a01b93bf36dd6e643f98028e83..f8253bec33e2b4aba3e412b1195c4cfb //# sourceMappingURL=load-matches.js.map \ No newline at end of file diff --git a/dist/esm/router.js b/dist/esm/router.js -index 05cd2d21ab580df0956d03e2b4aabb29649edb40..ba24e4a0ef4e7008e3fec5651e6b85b156a01f47 100644 +index 7cd17f8..3be3ef9 100644 --- a/dist/esm/router.js +++ b/dist/esm/router.js @@ -7,7 +7,7 @@ import { setupScrollRestoration } from "./scroll-restoration.js"; @@ -322,7 +322,7 @@ index 05cd2d21ab580df0956d03e2b4aabb29649edb40..ba24e4a0ef4e7008e3fec5651e6b85b1 import { composeRewrites, executeRewriteInput, executeRewriteOutput, rewriteBasepath } from "./rewrite.js"; import { createRouterStores } from "./stores.js"; import { createBrowserHistory, parseHref } from "@tanstack/history"; -@@ -768,6 +768,7 @@ var RouterCore = class { +@@ -766,6 +766,7 @@ var RouterCore = class { }); return matches; } catch (err) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 40523fb79..1bec25be8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,7 +16,7 @@ overrides: mdast-util-to-hast: ^13.2.1 patchedDependencies: - '@tanstack/router-core@1.171.14': 5c637ef4d89ce19f66b526ed9d261865a43bcdb3bee998cc4f35a8a8b3584442 + '@tanstack/router-core@1.171.15': dae6b41a9293e139e2709614c5beaa12d76eea37a1dd9ee98b69328600e17342 importers: @@ -30,7 +30,7 @@ importers: version: 7.5.0 '@datadog/browser-rum-react': specifier: ^7.0.0 - version: 7.5.0(@datadog/browser-rum@7.5.0)(@tanstack/react-router@1.170.17(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 7.5.0(@datadog/browser-rum@7.5.0)(@tanstack/react-router@1.170.18(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) '@harperfast/agent-tools': specifier: ^1.2.0 version: 1.2.0(@harperfast/skills@1.10.8)(create-harper@1.10.6) @@ -107,11 +107,11 @@ importers: specifier: ^5.90.2 version: 5.101.2(@tanstack/react-query@5.101.2(react@19.2.7))(react@19.2.7) '@tanstack/react-router': - specifier: 1.170.17 - version: 1.170.17(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + specifier: 1.170.18 + version: 1.170.18(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@tanstack/react-router-devtools': specifier: 1.167.0 - version: 1.167.0(@tanstack/react-router@1.170.17(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@tanstack/router-core@1.171.14(patch_hash=5c637ef4d89ce19f66b526ed9d261865a43bcdb3bee998cc4f35a8a8b3584442))(csstype@3.2.3)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 1.167.0(@tanstack/react-router@1.170.18(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@tanstack/router-core@1.171.15(patch_hash=dae6b41a9293e139e2709614c5beaa12d76eea37a1dd9ee98b69328600e17342))(csstype@3.2.3)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@tanstack/react-table': specifier: ^8.21.2 version: 8.21.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -167,8 +167,8 @@ importers: specifier: ^19.0.0 version: 19.2.7(react@19.2.7) react-dropzone: - specifier: ^15.0.0 - version: 15.0.0(react@19.2.7) + specifier: ^17.0.0 + version: 17.0.0(react@19.2.7) react-hook-form: specifier: ^7.54.2 version: 7.81.0(react@19.2.7) @@ -247,13 +247,13 @@ importers: version: 11.0.0 dprint: specifier: ^0.55.0 - version: 0.55.1 + version: 0.55.2 happy-dom: specifier: ^20.9.0 version: 20.10.6(bufferutil@4.1.0)(utf-8-validate@5.0.10) harper: - specifier: 5.1.18 - version: 5.1.18(@types/node@24.13.3)(react-native@0.86.0(@babel/core@7.29.7)(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7)(utf-8-validate@5.0.10)) + specifier: 5.1.21 + version: 5.1.21(@types/node@24.13.3)(react-native@0.86.0(@babel/core@7.29.7)(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7)(utf-8-validate@5.0.10)) husky: specifier: ^9.1.7 version: 9.1.7 @@ -753,87 +753,87 @@ packages: '@datadog/js-core@0.0.5': resolution: {integrity: sha512-9cGazfbindLRCcHh7Pgle68SZrSKMr6ScIvtT7XwxTGp38RaskXA/CG4nUojb46nWnsmV+KyH3C8WymkXdgMJg==} - '@dprint/android-arm64@0.55.1': - resolution: {integrity: sha512-H1UQIcBJEo1dA8AVrkPnw5AYMMGHNe4w0lq9rEDSY3Lds0VUKACu2MK3JLNARpjljh3WKeO109J26Wpq8PTuGQ==} + '@dprint/android-arm64@0.55.2': + resolution: {integrity: sha512-cywqM0E2h8lCA/b5BMFcuascaGuekm2lnyKb5hRH9juKbsqKeR8A7qf2p3nVWRQ8cM7djOnQwhy8ecQ3qd6j9A==} cpu: [arm64] os: [android] - '@dprint/android-x64@0.55.1': - resolution: {integrity: sha512-8dPiGpEo4S4cQhnLaoHtNm4HiqR9DIPr/81z3gWb5qTmJp8bKj+3DO4mAmmldMRBicmpXCss0xudniNTxnWOMg==} + '@dprint/android-x64@0.55.2': + resolution: {integrity: sha512-BvcqquD94dSYQP2FzBuojU/8vITpwz80uAkub2xCCx5g7zYdUZ98tK+N3GKfRS5AUJ1LpRPJgbO09rRLEo8leA==} cpu: [x64] os: [android] - '@dprint/darwin-arm64@0.55.1': - resolution: {integrity: sha512-c65u8f63R/etCKP0CZ/RkbMCRC99wgZDt/vhwjY2QdO0k23dMKXKEV6+ruUEwV2toh+QIRT02dRL7BtxjA0V7w==} + '@dprint/darwin-arm64@0.55.2': + resolution: {integrity: sha512-HjzasuPaC0EBHxEpS3Px/OPcxqZYln37xuAyYE0GFAhDuaotZEUpM8VE05Tzo4E32y7LKgLPT8CZVtEG5Ck7mQ==} cpu: [arm64] os: [darwin] - '@dprint/darwin-x64@0.55.1': - resolution: {integrity: sha512-AsET9+4rMk7ZFEMq3HYl/hYNBMBrR/juiSzclEU0oUCf3koxFbVKL8Srad8Vhyv8jEnOaMn78TCnahmHhp1SCQ==} + '@dprint/darwin-x64@0.55.2': + resolution: {integrity: sha512-7XSF9XERvimg3XakXNlJRpJM9an5HKibWWShwCJr7Dz1Xp7P3pomjx9AtYV5EeD49GXu9FKdFfDiTC4BJtAVjA==} cpu: [x64] os: [darwin] - '@dprint/linux-arm64-glibc@0.55.1': - resolution: {integrity: sha512-vt7w+aL2MjtD3RRfnUTDuK/b7xg1/feBe3WoV0S2rGNmPEJI+K7wwBXkOl/I77V9w3PP2zvN/aRY5dMOZrkbSg==} + '@dprint/linux-arm64-glibc@0.55.2': + resolution: {integrity: sha512-2h5oe4tHGXJOTLU5BxVJLGPP1qQQxxECdjUaRkLxGn2rZIEf/7HwoJ+TjwMUaIVg4QxAFxfiMJJ3MRZjumJpsg==} cpu: [arm64] os: [linux] libc: [glibc] - '@dprint/linux-arm64-musl@0.55.1': - resolution: {integrity: sha512-7shUIOvJcTn4IYEPlb/Up4KLt5kbDuCTdc3DN68i88XT+nRBFQatD7Qok+6Ysra34KJMxF88QVHiimjH1ABy4A==} + '@dprint/linux-arm64-musl@0.55.2': + resolution: {integrity: sha512-Z1X9mNLHWoSI7CHz888H2w27IV2UQ5lL/YbTwgguJM9ApLgvkMer5qr4pLlXF6vEY3+Rm0445eo3uojbjE7PTw==} cpu: [arm64] os: [linux] libc: [musl] - '@dprint/linux-loong64-glibc@0.55.1': - resolution: {integrity: sha512-G2mXiOGpkeC3LP8huSnImxU7hMvRY6rHIW+yBuw1vDDm5r6+H9CVhcXr3TMOk3poZ7IlWwQnDmP0/RuGQUEpYw==} + '@dprint/linux-loong64-glibc@0.55.2': + resolution: {integrity: sha512-qxk4Cg/SjO14DALoG1MFZL+n6pEvCyd7VDHg1cQIpp82mZ+wtRaWkJqSaItWj12x8jihA32wAoQ/8OHz+5FFsQ==} cpu: [loong64] os: [linux] libc: [glibc] - '@dprint/linux-loong64-musl@0.55.1': - resolution: {integrity: sha512-uO2r4QPYawDfQUyATr0opqfMkG2hVpz3yScRKs5ve9PWzqx2f5kJaaLuLVoXwSbWqDkozMGPcldAVSpOwGJ24Q==} + '@dprint/linux-loong64-musl@0.55.2': + resolution: {integrity: sha512-Mr+kRdZnxhUHBmhhADkYOo1g81PYvbh7eBo+lpMStgIvL/5Z93mGAcp/1NFcpeN02pilaI1N59awV08ApJ19cA==} cpu: [loong64] os: [linux] libc: [musl] - '@dprint/linux-ppc64-glibc@0.55.1': - resolution: {integrity: sha512-NVR+BN4kTQd/vJ91YOGSkBotAM2f1hFD+HD5zq5Oc1djxjl1U2Zvc667UWKamxaXdv/ssZxkLuj0lbvbx9E3/A==} + '@dprint/linux-ppc64-glibc@0.55.2': + resolution: {integrity: sha512-C8XEL3f3yg+MWZD4qlot+ibJ0GDPvCT5jq9ErwPdKteyIPpe6IPSqiEApYSQ194Q7audL8rgS0WdJsW4HNVhmw==} cpu: [ppc64] os: [linux] libc: [glibc] - '@dprint/linux-ppc64-musl@0.55.1': - resolution: {integrity: sha512-34Db83XGoKbwmvIY52sokywIMCmmIZM1vzw4ILlOkKugG51TltBOzLCssT9Rkfxz/TjR9dgwtBHEQJZjftuQ6Q==} + '@dprint/linux-ppc64-musl@0.55.2': + resolution: {integrity: sha512-tSGe4bTmL5/EhNH38baCKWBGxOXVB0M5Loxnpls7wsQjuLpzWnzDCw4wiO3C7PrKLbAe4sJUWhbgdDXa3TJ2eQ==} cpu: [ppc64] os: [linux] libc: [musl] - '@dprint/linux-riscv64-glibc@0.55.1': - resolution: {integrity: sha512-MVeYH48GhR3QkfhMhhYbXl9x+iO2sK1nNpr2ByJ4IKogHIlvlFff0eeukqAZZAod+rhAzJnLMBGgIDbIUHWK1g==} + '@dprint/linux-riscv64-glibc@0.55.2': + resolution: {integrity: sha512-gSaoq9e3vGTfYm5jcEd86YysFsp8OJF/CGZgBzNNu3NSjLEs8VVgTObDxQI+U7ex2mA17lbpWyr3diejaaK+bg==} cpu: [riscv64] os: [linux] libc: [glibc] - '@dprint/linux-x64-glibc@0.55.1': - resolution: {integrity: sha512-ILHgXOIIXeZDjt51egaC4kmu744UuNfaauUM1El+dG9QGan4Vv4f0RRDDbQMKN95zTpacECuNKEhuUtS8euNWA==} + '@dprint/linux-x64-glibc@0.55.2': + resolution: {integrity: sha512-GBLWjuqTT5OGbqh2gQENQihhfRn/AjdDu2SVwjmbZcOFR80HMppIfFrIIolBB3FLyrZk+M8rMk8EAo0tQ3PAXw==} cpu: [x64] os: [linux] libc: [glibc] - '@dprint/linux-x64-musl@0.55.1': - resolution: {integrity: sha512-SwWxWd313VkeH63fR4IlQCDfJNJ+mSw50BidP0PQInCNbUHYOIeWTcyzAaVa8GQdLvmhiNcJ0pmTDOL0F98QIQ==} + '@dprint/linux-x64-musl@0.55.2': + resolution: {integrity: sha512-GZEUpmtxCOiauRtqOqT/erAjy2ws8dOxx7oQJFf9g5bypisuapwymvr7fUVVb8nqccFqjx7E5kN4IxKDlzntHA==} cpu: [x64] os: [linux] libc: [musl] - '@dprint/win32-arm64@0.55.1': - resolution: {integrity: sha512-9jiZWZ2AkaaXIkaOpOoiP6v0XxuBFp1Hyi3kUMUAY4NVCmvlSEDQhr+UZgnDhozYygDbAV13iNMLERDeoUa0nA==} + '@dprint/win32-arm64@0.55.2': + resolution: {integrity: sha512-dxDdxU4a6wQ4K0omdAxW7o0mUdOI03z0/Ah3N6O9Ja7wAMQI5sbfzq7DOJ739UVXtHSB4zBTtYuk1MrBOIyVQQ==} cpu: [arm64] os: [win32] - '@dprint/win32-x64@0.55.1': - resolution: {integrity: sha512-+RmeJL8zzlEWTcrvyFN51rz0S98tV43Socs7xeVGE2S/EH5dG2fG2/fBe6RB3kuyZSfGqByApYeicIdshYIAgw==} + '@dprint/win32-x64@0.55.2': + resolution: {integrity: sha512-UTiDSShHbrkx+z719/MffgwGYD8PaypdVmG4vVJVjmF5Hoin6EfugHhSoBf8+G6U9rF+uDIIl0idZq+6Bifzwg==} cpu: [x64] os: [win32] @@ -2363,8 +2363,8 @@ packages: '@tanstack/router-core': optional: true - '@tanstack/react-router@1.170.17': - resolution: {integrity: sha512-ppLkjCfSMaeug9rmFRYzOd4TIqWV+yTE7tzIny7alJsSnM7w4lzEZm6eqCehG0SPetpZ0R3K+UnanSmBgOAVcQ==} + '@tanstack/react-router@1.170.18': + resolution: {integrity: sha512-wpbGYZEp/fmz1q4bn7BD8VZ+/VZ7GBqSJv5V969pU+chP8y7dquWDmKTFMohvUegb9lg12m1uPVvD6kB2wORvQ==} engines: {node: '>=20.19'} peerDependencies: react: '>=18.0.0 || >=19.0.0' @@ -2383,8 +2383,8 @@ packages: react: '>=16.8' react-dom: '>=16.8' - '@tanstack/router-core@1.171.14': - resolution: {integrity: sha512-Mo3hwx0qB0cJsVYGDjG0+Ouf7VV74h/vsoDMGztdlyzDanp4gBA2s7IVvm6hFrmQM6GpD9F0Z7SqD7OldfLE7g==} + '@tanstack/router-core@1.171.15': + resolution: {integrity: sha512-IILCDcLaItMZQ2jEmCABHY1Nhjjn5XUvwpQp3e4Nmu+vfg0BgYFuu/QASz2SwE2ZNbVMrvt8X/wxa+Gg5aErxA==} engines: {node: '>=20.19'} '@tanstack/router-devtools-core@1.168.0': @@ -3831,8 +3831,8 @@ packages: resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==} engines: {node: '>=12'} - dprint@0.55.1: - resolution: {integrity: sha512-tgUCT9gAM7veMvLX5NmRoYVLnKGKrIYRXpNpJDJj5U7/YIIJef5rLJhPZaJXwB7ad2Vo0Kv//nQM1xkrn5j8kQ==} + dprint@0.55.2: + resolution: {integrity: sha512-1d4D4SB9KiD2qFnBWbl3aoYjLvPVpZoth28yxTT00xJv2yXugdz0Xoyv7sGG0w0hzE6hQZMgd6B333GnySDpGQ==} hasBin: true drange@1.1.1: @@ -4292,8 +4292,8 @@ packages: resolution: {integrity: sha512-6QD0ilzDDt93tX44y8tbmZdAcdTRYDhUP+Asgi6pC8Pp5IA3cvaZGyoVN/EGtlq9ziT65iPuBBn3ASLr6hCgVw==} engines: {node: '>=20.0.0'} - harper@5.1.18: - resolution: {integrity: sha512-4XQxwzXGwukAd2q5yHU8zyrBqnsfxZxqxF7UWse/YAUNBLuryij14RLqt/dXlxsVds6EmVPiqfJX/g3BojfKRw==} + harper@5.1.21: + resolution: {integrity: sha512-/m/TIkcBbKh1KmmSWVC3Eu7Nx5BajNBtPW0twnVlKMoTIoHaAg5xuD2PTmh+CrdtMJlshIx0HhfKkUN0ecWI1A==} engines: {node: ^22.18.0 || >=24.0.0} hasBin: true peerDependencies: @@ -5986,11 +5986,11 @@ packages: peerDependencies: react: ^19.2.7 - react-dropzone@15.0.0: - resolution: {integrity: sha512-lGjYV/EoqEjEWPnmiSvH4v5IoIAwQM2W4Z1C0Q/Pw2xD0eVzKPS359BQTUMum+1fa0kH2nrKjuavmTPOGhpLPg==} - engines: {node: '>= 10.13'} + react-dropzone@17.0.0: + resolution: {integrity: sha512-yedj9VzdC/boq15u0XfzWRGX0Mqpb8dxy/TRDqOIFlISl8nhh38R9QKbztyFqxqLMwLdViVtLbXkv6x/b8gP8A==} + engines: {node: '>= 20'} peerDependencies: - react: '>= 16.8 || 18.0.0' + react: '>= 18' react-hook-form@7.81.0: resolution: {integrity: sha512-ocbmr2p5KBMoAfj4WCUvped33lVi1Kd5DuDUvQDnB6VEAacOjPI/jMbtDdbhco4y9ct4xUuCmMY0b/C9L0QHjw==} @@ -7947,14 +7947,14 @@ snapshots: '@datadog/browser-core': 7.5.0 '@datadog/js-core': 0.0.5 - '@datadog/browser-rum-react@7.5.0(@datadog/browser-rum@7.5.0)(@tanstack/react-router@1.170.17(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)': + '@datadog/browser-rum-react@7.5.0(@datadog/browser-rum@7.5.0)(@tanstack/react-router@1.170.18(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)': dependencies: '@datadog/browser-core': 7.5.0 '@datadog/browser-rum-core': 7.5.0 '@datadog/js-core': 0.0.5 optionalDependencies: '@datadog/browser-rum': 7.5.0 - '@tanstack/react-router': 1.170.17(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@tanstack/react-router': 1.170.18(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 '@datadog/browser-rum@7.5.0': @@ -7967,49 +7967,49 @@ snapshots: '@datadog/js-core@0.0.5': {} - '@dprint/android-arm64@0.55.1': + '@dprint/android-arm64@0.55.2': optional: true - '@dprint/android-x64@0.55.1': + '@dprint/android-x64@0.55.2': optional: true - '@dprint/darwin-arm64@0.55.1': + '@dprint/darwin-arm64@0.55.2': optional: true - '@dprint/darwin-x64@0.55.1': + '@dprint/darwin-x64@0.55.2': optional: true - '@dprint/linux-arm64-glibc@0.55.1': + '@dprint/linux-arm64-glibc@0.55.2': optional: true - '@dprint/linux-arm64-musl@0.55.1': + '@dprint/linux-arm64-musl@0.55.2': optional: true - '@dprint/linux-loong64-glibc@0.55.1': + '@dprint/linux-loong64-glibc@0.55.2': optional: true - '@dprint/linux-loong64-musl@0.55.1': + '@dprint/linux-loong64-musl@0.55.2': optional: true - '@dprint/linux-ppc64-glibc@0.55.1': + '@dprint/linux-ppc64-glibc@0.55.2': optional: true - '@dprint/linux-ppc64-musl@0.55.1': + '@dprint/linux-ppc64-musl@0.55.2': optional: true - '@dprint/linux-riscv64-glibc@0.55.1': + '@dprint/linux-riscv64-glibc@0.55.2': optional: true - '@dprint/linux-x64-glibc@0.55.1': + '@dprint/linux-x64-glibc@0.55.2': optional: true - '@dprint/linux-x64-musl@0.55.1': + '@dprint/linux-x64-musl@0.55.2': optional: true - '@dprint/win32-arm64@0.55.1': + '@dprint/win32-arm64@0.55.2': optional: true - '@dprint/win32-x64@0.55.1': + '@dprint/win32-x64@0.55.2': optional: true '@emnapi/core@1.11.1': @@ -9787,22 +9787,22 @@ snapshots: '@tanstack/query-core': 5.101.2 react: 19.2.7 - '@tanstack/react-router-devtools@1.167.0(@tanstack/react-router@1.170.17(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@tanstack/router-core@1.171.14(patch_hash=5c637ef4d89ce19f66b526ed9d261865a43bcdb3bee998cc4f35a8a8b3584442))(csstype@3.2.3)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@tanstack/react-router-devtools@1.167.0(@tanstack/react-router@1.170.18(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@tanstack/router-core@1.171.15(patch_hash=dae6b41a9293e139e2709614c5beaa12d76eea37a1dd9ee98b69328600e17342))(csstype@3.2.3)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@tanstack/react-router': 1.170.17(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@tanstack/router-devtools-core': 1.168.0(@tanstack/router-core@1.171.14(patch_hash=5c637ef4d89ce19f66b526ed9d261865a43bcdb3bee998cc4f35a8a8b3584442))(csstype@3.2.3) + '@tanstack/react-router': 1.170.18(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@tanstack/router-devtools-core': 1.168.0(@tanstack/router-core@1.171.15(patch_hash=dae6b41a9293e139e2709614c5beaa12d76eea37a1dd9ee98b69328600e17342))(csstype@3.2.3) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: - '@tanstack/router-core': 1.171.14(patch_hash=5c637ef4d89ce19f66b526ed9d261865a43bcdb3bee998cc4f35a8a8b3584442) + '@tanstack/router-core': 1.171.15(patch_hash=dae6b41a9293e139e2709614c5beaa12d76eea37a1dd9ee98b69328600e17342) transitivePeerDependencies: - csstype - '@tanstack/react-router@1.170.17(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@tanstack/react-router@1.170.18(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@tanstack/history': 1.162.0 '@tanstack/react-store': 0.9.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@tanstack/router-core': 1.171.14(patch_hash=5c637ef4d89ce19f66b526ed9d261865a43bcdb3bee998cc4f35a8a8b3584442) + '@tanstack/router-core': 1.171.15(patch_hash=dae6b41a9293e139e2709614c5beaa12d76eea37a1dd9ee98b69328600e17342) isbot: 5.1.32 react: 19.2.7 react-dom: 19.2.7(react@19.2.7) @@ -9820,16 +9820,16 @@ snapshots: react: 19.2.7 react-dom: 19.2.7(react@19.2.7) - '@tanstack/router-core@1.171.14(patch_hash=5c637ef4d89ce19f66b526ed9d261865a43bcdb3bee998cc4f35a8a8b3584442)': + '@tanstack/router-core@1.171.15(patch_hash=dae6b41a9293e139e2709614c5beaa12d76eea37a1dd9ee98b69328600e17342)': dependencies: '@tanstack/history': 1.162.0 cookie-es: 3.1.1 seroval: 1.5.4 seroval-plugins: 1.5.4(seroval@1.5.4) - '@tanstack/router-devtools-core@1.168.0(@tanstack/router-core@1.171.14(patch_hash=5c637ef4d89ce19f66b526ed9d261865a43bcdb3bee998cc4f35a8a8b3584442))(csstype@3.2.3)': + '@tanstack/router-devtools-core@1.168.0(@tanstack/router-core@1.171.15(patch_hash=dae6b41a9293e139e2709614c5beaa12d76eea37a1dd9ee98b69328600e17342))(csstype@3.2.3)': dependencies: - '@tanstack/router-core': 1.171.14(patch_hash=5c637ef4d89ce19f66b526ed9d261865a43bcdb3bee998cc4f35a8a8b3584442) + '@tanstack/router-core': 1.171.15(patch_hash=dae6b41a9293e139e2709614c5beaa12d76eea37a1dd9ee98b69328600e17342) clsx: 2.1.1 goober: 2.1.18(csstype@3.2.3) optionalDependencies: @@ -11326,23 +11326,23 @@ snapshots: dotenv@17.2.3: {} - dprint@0.55.1: + dprint@0.55.2: optionalDependencies: - '@dprint/android-arm64': 0.55.1 - '@dprint/android-x64': 0.55.1 - '@dprint/darwin-arm64': 0.55.1 - '@dprint/darwin-x64': 0.55.1 - '@dprint/linux-arm64-glibc': 0.55.1 - '@dprint/linux-arm64-musl': 0.55.1 - '@dprint/linux-loong64-glibc': 0.55.1 - '@dprint/linux-loong64-musl': 0.55.1 - '@dprint/linux-ppc64-glibc': 0.55.1 - '@dprint/linux-ppc64-musl': 0.55.1 - '@dprint/linux-riscv64-glibc': 0.55.1 - '@dprint/linux-x64-glibc': 0.55.1 - '@dprint/linux-x64-musl': 0.55.1 - '@dprint/win32-arm64': 0.55.1 - '@dprint/win32-x64': 0.55.1 + '@dprint/android-arm64': 0.55.2 + '@dprint/android-x64': 0.55.2 + '@dprint/darwin-arm64': 0.55.2 + '@dprint/darwin-x64': 0.55.2 + '@dprint/linux-arm64-glibc': 0.55.2 + '@dprint/linux-arm64-musl': 0.55.2 + '@dprint/linux-loong64-glibc': 0.55.2 + '@dprint/linux-loong64-musl': 0.55.2 + '@dprint/linux-ppc64-glibc': 0.55.2 + '@dprint/linux-ppc64-musl': 0.55.2 + '@dprint/linux-riscv64-glibc': 0.55.2 + '@dprint/linux-x64-glibc': 0.55.2 + '@dprint/linux-x64-musl': 0.55.2 + '@dprint/win32-arm64': 0.55.2 + '@dprint/win32-x64': 0.55.2 drange@1.1.1: {} @@ -11834,7 +11834,7 @@ snapshots: - bufferutil - utf-8-validate - harper@5.1.18(@types/node@24.13.3)(react-native@0.86.0(@babel/core@7.29.7)(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7)(utf-8-validate@5.0.10)): + harper@5.1.21(@types/node@24.13.3)(react-native@0.86.0(@babel/core@7.29.7)(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7)(utf-8-validate@5.0.10)): dependencies: '@aws-sdk/client-s3': 3.1073.0 '@aws-sdk/lib-storage': 3.1075.0(@aws-sdk/client-s3@3.1073.0) @@ -13935,11 +13935,10 @@ snapshots: react: 19.2.7 scheduler: 0.27.0 - react-dropzone@15.0.0(react@19.2.7): + react-dropzone@17.0.0(react@19.2.7): dependencies: attr-accept: 2.2.5 file-selector: 2.1.2 - prop-types: 15.8.1 react: 19.2.7 react-hook-form@7.81.0(react@19.2.7): diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index fe175604f..edfd252f1 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -43,4 +43,4 @@ overrides: js-yaml: '^4.2.0' mdast-util-to-hast: '^13.2.1' patchedDependencies: - '@tanstack/router-core@1.171.14': patches/@tanstack__router-core@1.171.14.patch + '@tanstack/router-core@1.171.15': patches/@tanstack__router-core@1.171.15.patch diff --git a/renovate.json b/renovate.json index e5bf99916..d9a147e42 100644 --- a/renovate.json +++ b/renovate.json @@ -34,6 +34,13 @@ "patch" ], "matchCurrentVersion": "!/^0/" + }, + { + "description": "Treat pre-1.0 dprint/oxfmt (stable formatters) as non-major so bumps group with other deps; regroups normally once they reach 1.0.0+.", + "groupName": "all non-major dependencies", + "groupSlug": "all-minor-patch", + "matchPackageNames": ["dprint", "oxfmt"], + "matchCurrentVersion": "< 1.0.0" } ] } diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 2a6fd760b..d87bcb066 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -9,11 +9,11 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import { Version } from '@/components/Version'; import { defaultInstanceRoute, isLocalStudio } from '@/config/constants'; import { useLogoutMutation } from '@/features/auth/hooks/useLogout'; -import { useOverallAuth } from '@/hooks/useAuth'; +import { isFabricAdmin, useOverallAuth } from '@/hooks/useAuth'; import { excludeFalsy } from '@/lib/arrays/excludeFalsy'; import { getDefaultSignedInCloudRouteForUser } from '@/lib/urls/getDefaultSignedInCloudRouteForUser'; import { Link, useNavigate, useRouter } from '@tanstack/react-router'; -import { BookOpenTextIcon, BugIcon, LogInIcon, LogOutIcon, Menu, UserIcon, X } from 'lucide-react'; +import { BookOpenTextIcon, BugIcon, LogInIcon, LogOutIcon, Menu, ShieldIcon, UserIcon, X } from 'lucide-react'; import { ReactNode, useCallback, useMemo, useState } from 'react'; import { toast } from 'sonner'; @@ -23,6 +23,9 @@ export function Navbar() { const { mutate: signOut } = useLogoutMutation(); const navigate = useNavigate(); const { user } = useOverallAuth(); + // Derived from the existing subscription — useAdminMode() would add a second + // authStore listener per Navbar (review feedback on #1533). + const isAdmin = isFabricAdmin(user); const router = useRouter(); const handleSignOut = useCallback(() => { @@ -52,6 +55,12 @@ export function Navbar() { text: 'Profile', textBreakpoint: 'xl', }, + !isLocalStudio && isAdmin && { + to: '/admin', + icon: , + text: 'Admin', + textBreakpoint: 'xl', + }, { to: 'https://docs.harperdb.io/docs', target: '_blank', @@ -80,7 +89,7 @@ export function Navbar() { textBreakpoint: isLocalStudio ? 'md' : 'xl', }, ].filter(excludeFalsy) satisfies Array, - [handleSignOut], + [handleSignOut, isAdmin], ); if (!user) { diff --git a/src/components/ui/table.tsx b/src/components/ui/table.tsx index 21a1f2460..f39212b03 100644 --- a/src/components/ui/table.tsx +++ b/src/components/ui/table.tsx @@ -5,6 +5,14 @@ import { ArrowDown, ArrowUp, ArrowUpDown, GripVerticalIcon } from 'lucide-react' import * as React from 'react'; import { useCallback } from 'react'; +// Upper bound for double-click auto-fit so a very long value (e.g. a stringified object) can't +// stretch a column across the whole viewport. +const AUTO_FIT_MAX_SIZE = 500; +// Horizontal cell padding (px-2 => 8px each side) added back after measuring bare content. +const CELL_HORIZONTAL_PADDING = 16; +// Width of the right-edge resize handle strip; reserved when auto-fitting so it never overlaps the title. +const RESIZE_HANDLE_WIDTH = 16; + export interface TableProps extends React.ComponentProps<'table'> { containerClassName?: string; containerRef?: React.Ref; @@ -79,58 +87,108 @@ export function TableHeadSortable({ onColumnClick?.(header.column.columnDef.accessorKey, willSortByAscending); }, [header, onColumnClick]); const enableSorting = header.column.columnDef.enableSorting; - const enableResizing = header.column.columnDef.enableResizing; + // Only render resize handles for tables that explicitly opt in via `enableColumnResizing`. + // TableHeadSortable is shared (e.g. SimpleBrowseDataTable), and TanStack's getCanResize() + // defaults to enabled — so gating on getCanResize() alone would sprinkle handles onto every + // table that uses this header, not just the browse table that wires up sizing + persistence. + const enableResizing = header.getContext().table.options.enableColumnResizing === true + && header.column.getCanResize(); const content = header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext()); - const resetSize = useCallback(() => { - header.column.resetSize(); - }, [header]); + const table = header.getContext().table; + const size = header.getSize(); + const minSize = header.column.columnDef.minSize ?? table.options.defaultColumn?.minSize ?? 20; + // Double-click the handle to fit the column to its widest value rather than snapping back to the + // default width. Content width is measured with a Range (the tight bounding box of the actual + // text) so it works even when the cell is wider than its content; capped by AUTO_FIT_MAX_SIZE so + // a huge value can't blow the column out. + const autoFitColumn = useCallback((e: React.MouseEvent) => { + const tableEl = e.currentTarget.closest('table'); + if (!tableEl) { + header.column.resetSize(); + return; + } + const selector = `[data-col-id="${CSS.escape(header.column.id)}"]`; + const range = document.createRange(); + const measure = (el: Element) => { + range.selectNodeContents(el); + return range.getBoundingClientRect().width; + }; + let widest = 0; + tableEl.querySelectorAll(`tbody ${selector}`).forEach((cell) => { + widest = Math.max(widest, measure(cell)); + }); + // The header's flex row fills the cell, so derive its intrinsic width from the title's own + // text width plus the width of the sort/resize controls beside it. `controls` is measured as + // (all children) - (current title box), which is invariant to the title being truncated. + const headerRow = tableEl.querySelector(`thead ${selector} > div`); + const titleEl = headerRow?.querySelector('.truncate'); + if (headerRow && titleEl) { + const childrenWidth = Array.from(headerRow.children).reduce( + (sum, child) => sum + child.getBoundingClientRect().width, + 0, + ); + const controls = childrenWidth - titleEl.getBoundingClientRect().width; + widest = Math.max(widest, measure(titleEl) + controls + RESIZE_HANDLE_WIDTH); + } + const fitted = Math.min(Math.max(Math.ceil(widest) + CELL_HORIZONTAL_PADDING, minSize), AUTO_FIT_MAX_SIZE); + table.setColumnSizing((old) => ({ ...old, [header.column.id]: fitted })); + }, [header, table, minSize]); + // Clamp the resize preview so the handle stops at the column's min width instead of sliding left + // across the title while dragging (the actual resize commits on release). + const previewOffset = header.column.getIsResizing() + ? Math.max(table.getState().columnSizingInfo.deltaOffset ?? 0, minSize - size) + : 0; return ( -
+ {/* pr leaves room for the right-edge resize handle so the title never sits under it. */} +
{enableSorting ? ( ) - : content} - {enableResizing && ( - - )} + : {content}}
+ {enableResizing && ( +
+ +
+ )} ); } diff --git a/src/components/ui/utils/badgeStatus.tsx b/src/components/ui/utils/badgeStatus.tsx index f64ce7e2f..2dc8b87b4 100644 --- a/src/components/ui/utils/badgeStatus.tsx +++ b/src/components/ui/utils/badgeStatus.tsx @@ -27,7 +27,7 @@ export function renderBadgeStatusVariant(value: BadgeStatusVariant): BadgeStatus } switch (value) { case 'STOPPED': - return 'secondary'; + return 'destructive'; case 'TERMINATING': case 'TERMINATED': case 'FAILED': @@ -39,6 +39,22 @@ export function renderBadgeStatusVariant(value: BadgeStatusVariant): BadgeStatus } } +/** + * The instance is stopped or mid container-lifecycle transition, so its ops API is unreachable. + * Callers use this to suppress per-instance status polling (get_status) until it's back up. + */ +export function isStoppedOrTransitioning(value: string | undefined): boolean { + switch (value) { + case 'STOPPED': + case 'STOPPING': + case 'STARTING': + case 'RESTARTING': + return true; + default: + return false; + } +} + export function isRunning(value: string | undefined): value is 'RUNNING' | 'UPDATED' { switch (value) { case 'RUNNING': diff --git a/src/features/admin/apiToken/index.tsx b/src/features/admin/apiToken/index.tsx new file mode 100644 index 000000000..abb78c5a0 --- /dev/null +++ b/src/features/admin/apiToken/index.tsx @@ -0,0 +1,72 @@ +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { useGenerateApiTokenMutation } from '@/features/admin/apiToken/mutations/useGenerateApiToken'; +import { useCopyTextToClipboard } from '@/hooks/useCopyToClipboard'; +import { ApiTokenResult } from '@/integrations/api/api.patch'; +import { CopyIcon, KeyRoundIcon } from 'lucide-react'; +import { useState } from 'react'; + +export function ApiTokenIndex() { + const { mutate, isPending, reset } = useGenerateApiTokenMutation(); + const [token, setToken] = useState(null); + const copyText = useCopyTextToClipboard(); + + const onGenerate = () => { + // The global MutationCache.onError (react-query/queryClient) already surfaces + // failures with a toast, so no local onError. + mutate(undefined, { + onSuccess: (result) => { + // Hold the credential in local component state only, then drop it from + // the shared MutationCache so it can't be read there or outlive the page. + setToken(result); + reset(); + }, + }); + }; + + return ( +
+

API Token

+

+ Generate a short-lived token for programmatic API access. It authenticates as you, with your permissions. Send + it as a bearer token:{' '} + Authorization: Bearer <token> +

+ + + + {token && ( + + + Your API token + + Copy it now — it won't be shown again. Expires {new Date(token.expiresAt).toLocaleString()}. + + + + copyText(token.operationToken)} + /> + + + + )} +
+ ); +} diff --git a/src/features/admin/apiToken/mutations/useGenerateApiToken.test.ts b/src/features/admin/apiToken/mutations/useGenerateApiToken.test.ts new file mode 100644 index 000000000..6278398d2 --- /dev/null +++ b/src/features/admin/apiToken/mutations/useGenerateApiToken.test.ts @@ -0,0 +1,53 @@ +/** @vitest-environment jsdom */ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { createElement, ReactNode } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { post } = vi.hoisted(() => ({ post: vi.fn() })); +vi.mock('@/config/apiClient', () => ({ apiClient: { post } })); + +import { generateApiToken, useGenerateApiTokenMutation } from './useGenerateApiToken'; + +describe('generateApiToken', () => { + beforeEach(() => post.mockReset()); + + it('POSTs to /Admin/ApiToken and returns the token result', async () => { + post.mockResolvedValue({ data: { operationToken: 'op-tok', expiresAt: '2026-01-01T00:00:00.000Z' } }); + + const result = await generateApiToken(); + + expect(post).toHaveBeenCalledWith('/Admin/ApiToken', {}); + expect(result).toEqual({ operationToken: 'op-tok', expiresAt: '2026-01-01T00:00:00.000Z' }); + }); +}); + +describe('useGenerateApiTokenMutation cache hygiene', () => { + beforeEach(() => post.mockReset()); + + const SECRET = 'secret-bearer-token'; + const withClient = (client: QueryClient) => ({ children }: { children: ReactNode }) => + createElement(QueryClientProvider, { client }, children); + + const cacheContainsToken = (client: QueryClient) => + JSON.stringify(client.getMutationCache().getAll().map((m) => m.state)).includes(SECRET); + + it('keeps the bearer token out of the shared MutationCache (reset() clears it; gcTime:0 GCs on unmount)', async () => { + post.mockResolvedValue({ data: { operationToken: SECRET, expiresAt: '2026-01-01T00:00:00.000Z' } }); + const client = new QueryClient(); + const { result, unmount } = renderHook(() => useGenerateApiTokenMutation(), { wrapper: withClient(client) }); + + // Generate, then do what the page does: copy into local state and reset(). + await act(async () => { + await result.current.mutateAsync(); + result.current.reset(); + }); + + // The credential must not remain readable via getMutationCache(). + await waitFor(() => expect(cacheContainsToken(client)).toBe(false)); + + // And nothing lingers after the page unmounts (gcTime:0). + unmount(); + expect(client.getMutationCache().getAll()).toHaveLength(0); + }); +}); diff --git a/src/features/admin/apiToken/mutations/useGenerateApiToken.ts b/src/features/admin/apiToken/mutations/useGenerateApiToken.ts new file mode 100644 index 000000000..ec369c4bb --- /dev/null +++ b/src/features/admin/apiToken/mutations/useGenerateApiToken.ts @@ -0,0 +1,26 @@ +import { apiClient } from '@/config/apiClient'; +import { ApiTokenResult } from '@/integrations/api/api.patch'; +import { useMutation } from '@tanstack/react-query'; + +/** + * POST /Admin/ApiToken → a short-lived Bearer token for the SSO'd fabric admin. + * + * The endpoint isn't in the generated API types yet, so the URL and response are + * cast (same approach as getCurrentUser / updateUserMutation). + */ +export async function generateApiToken(): Promise { + const { data } = await apiClient.post('/Admin/ApiToken' as '/Cluster/', {}); + return data as unknown as ApiTokenResult; +} + +export function useGenerateApiTokenMutation() { + return useMutation({ + mutationFn: generateApiToken, + // The result is a bearer credential. Don't let it linger in the shared + // MutationCache: that's readable via queryClient.getMutationCache(), shows + // in the mutation inspector, and survives logout (logoutOnSuccess clears + // only the QueryCache). gcTime:0 discards it the moment the observer + // unmounts; the page also reset()s right after copying it into local state. + gcTime: 0, + }); +} diff --git a/src/features/admin/components/AdminShell.tsx b/src/features/admin/components/AdminShell.tsx new file mode 100644 index 000000000..181c069ba --- /dev/null +++ b/src/features/admin/components/AdminShell.tsx @@ -0,0 +1,34 @@ +import { SubNavItem, SubNavRail } from '@/components/SubNavRail'; +import { isFabricAdmin, useCloudAuth } from '@/hooks/useAuth'; +import { Navigate, Outlet } from '@tanstack/react-router'; +import { KeyRoundIcon } from 'lucide-react'; + +/** + * Shell for the Admin section: a responsive sub-nav rail (so future admin + * endpoints slot in as new items) plus the active page. Gated to fabric_admin + * (matching the token endpoint's SSO-session contract — see isFabricAdmin); the + * dashboard route guard already handles the unauthenticated redirect. + */ +const items: SubNavItem[] = [ + { to: '/admin', label: 'API Token', icon: KeyRoundIcon, exact: true }, +]; + +export function AdminShell() { + const { isLoading, user } = useCloudAuth(); + + if (isLoading) { return null; } + if (!isFabricAdmin(user)) { return ; } + + return ( +
+
+ +
+ +
+
+
+ ); +} diff --git a/src/features/admin/routes.ts b/src/features/admin/routes.ts new file mode 100644 index 000000000..ae27fbda0 --- /dev/null +++ b/src/features/admin/routes.ts @@ -0,0 +1,19 @@ +import { AdminShell } from '@/features/admin/components/AdminShell'; +import { dashboardLayout } from '@/router/dashboardRoute'; +import { createRoute, lazyRouteComponent } from '@tanstack/react-router'; + +export const adminLayoutRoute = createRoute({ + getParentRoute: () => dashboardLayout, + path: 'admin', + component: AdminShell, +}); + +const apiTokenRoute = createRoute({ + getParentRoute: () => adminLayoutRoute, + path: '/', + head: () => ({ meta: [{ title: 'API Token — Harper Fabric' }] }), + component: lazyRouteComponent(async () => import('@/features/admin/apiToken/index'), 'ApiTokenIndex'), +}); + +// Parent: adminLayoutRoute (keep in lockstep with rootRouteTree's addChildren). +export const adminRoutes = [apiTokenRoute]; diff --git a/src/features/auth/handlers/clearAuthStateLocally.ts b/src/features/auth/handlers/clearAuthStateLocally.ts new file mode 100644 index 000000000..de2abaf58 --- /dev/null +++ b/src/features/auth/handlers/clearAuthStateLocally.ts @@ -0,0 +1,23 @@ +import { authStore } from '@/features/auth/store/authStore'; +import { clearLocalStorage } from '@/lib/storage/clearLocalStorage'; +import { clearSessionStorage } from '@/lib/storage/clearSessionStorage'; +import { queryClient } from '@/react-query/queryClient'; + +/** + * Full LOCAL sign-out for a session already known-dead (e.g. a 401 from CM): + * clears all in-memory auth connections/tokens/flags, persisted storage, and + * BOTH React Query caches — WITHOUT posting a logout to CM or instances (the + * session is gone, so a network logout would only fail). + * + * This closes the cross-user gap where, after A's session expired, A's stale + * in-memory entity connections / Fabric tokens / cached queries survived a + * same-tab re-login as B. Clears the MutationCache too (logoutOnSuccess clears + * only the QueryCache), so no credential lingers there either. + */ +export function clearAuthStateLocally(): void { + authStore.signOutAllLocally(); + queryClient.getMutationCache().clear(); + queryClient.getQueryCache().clear(); + clearLocalStorage(); + clearSessionStorage(); +} diff --git a/src/features/auth/hooks/useCloudSignIn.test.tsx b/src/features/auth/hooks/useCloudSignIn.test.tsx new file mode 100644 index 000000000..b468e085c --- /dev/null +++ b/src/features/auth/hooks/useCloudSignIn.test.tsx @@ -0,0 +1,103 @@ +/** + * @vitest-environment jsdom + */ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { AxiosError } from 'axios'; +import { PropsWithChildren } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { useCloudSignIn } from './useCloudSignIn'; + +// One shared apiClient mock backs both the login POST and the resend POST. +const { post } = vi.hoisted(() => ({ post: vi.fn() })); +vi.mock('@/config/apiClient', () => ({ apiClient: { post } })); + +// The hook only reaches for the router; give it inert doubles we can assert against. +const { navigate } = vi.hoisted(() => ({ navigate: vi.fn() })); +vi.mock('@tanstack/react-router', () => ({ + useNavigate: () => navigate, + useRouter: () => ({ invalidate: vi.fn() }), + useSearch: () => ({}), +})); + +vi.mock('sonner', () => ({ + toast: { info: vi.fn(), error: vi.fn().mockReturnValue({ dismiss: vi.fn() }), dismiss: vi.fn() }, +})); + +// onSuccess-only integrations — never exercised by these error-path tests, mocked to keep imports inert. +vi.mock('@/integrations/datadog/datadog', () => ({ loginSuccessDatadogAction: vi.fn() })); +vi.mock('@/integrations/reo/reo', () => ({ reoClient: { identify: vi.fn() } })); + +import { apiClient } from '@/config/apiClient'; +import { toast } from 'sonner'; + +const EMAIL = 'unverified@example.com'; + +function axiosError(status: number, data: unknown): AxiosError { + return { isAxiosError: true, response: { status, data } } as AxiosError; +} + +function wrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + return ({ children }: PropsWithChildren) => {children} + ; +} + +afterEach(() => vi.clearAllMocks()); + +describe('useCloudSignIn — unverified email', () => { + it('resends verification, redirects to /verifying, and suppresses the generic error toast', async () => { + post.mockImplementation((url: string) => { + if (url === '/Login/') { + return Promise.reject(axiosError(403, { error: 'User has not verified email address' })); + } + if (url === '/ResendVerificationEmail/') { return Promise.resolve({ data: { email: EMAIL } }); } + return Promise.reject(new Error(`unexpected ${url}`)); + }); + + const { result } = renderHook(() => useCloudSignIn(), { wrapper: wrapper() }); + act(() => result.current.submitForm({ email: EMAIL, password: 'correct-horse' })); + + await waitFor(() => expect(navigate).toHaveBeenCalledWith({ to: '/verifying?email=unverified%40example.com' })); + expect(apiClient.post).toHaveBeenCalledWith('/ResendVerificationEmail/', { email: EMAIL }); + // The "we sent a link" toast only fires once the resend actually succeeds. + await waitFor(() => expect(toast.info).toHaveBeenCalled()); + // The dead-end "Error" toast must NOT fire for this case. + expect(toast.error).not.toHaveBeenCalled(); + }); + + it('still redirects (without claiming a link was sent) when the resend fails', async () => { + post.mockImplementation((url: string) => { + if (url === '/Login/') { + return Promise.reject(axiosError(403, { error: 'User has not verified email address' })); + } + // Resend fails (e.g. rate-limited) — the user must still reach /verifying, and we must + // NOT show a "we sent a link" toast that never happened. + return Promise.reject(axiosError(429, { error: 'Too many requests' })); + }); + + const { result } = renderHook(() => useCloudSignIn(), { wrapper: wrapper() }); + act(() => result.current.submitForm({ email: EMAIL, password: 'correct-horse' })); + + await waitFor(() => expect(navigate).toHaveBeenCalledWith({ to: '/verifying?email=unverified%40example.com' })); + await waitFor(() => expect(apiClient.post).toHaveBeenCalledWith('/ResendVerificationEmail/', { email: EMAIL })); + expect(toast.info).not.toHaveBeenCalled(); + }); + + it('shows the standard error toast (no redirect, no resend) for invalid credentials', async () => { + post.mockImplementation((url: string) => + url === '/Login/' + ? Promise.reject(axiosError(401, { error: 'Invalid email or password' })) + : Promise.reject(new Error(`unexpected ${url}`)) + ); + + const { result } = renderHook(() => useCloudSignIn(), { wrapper: wrapper() }); + act(() => result.current.submitForm({ email: EMAIL, password: 'wrong' })); + + await waitFor(() => expect(toast.error).toHaveBeenCalled()); + expect(navigate).not.toHaveBeenCalled(); + expect(apiClient.post).not.toHaveBeenCalledWith('/ResendVerificationEmail/', expect.anything()); + }); +}); diff --git a/src/features/auth/hooks/useCloudSignIn.ts b/src/features/auth/hooks/useCloudSignIn.ts index c42c2f42e..4c5a46891 100644 --- a/src/features/auth/hooks/useCloudSignIn.ts +++ b/src/features/auth/hooks/useCloudSignIn.ts @@ -1,4 +1,5 @@ import { apiClient } from '@/config/apiClient'; +import { isEmailNotVerifiedError } from '@/features/auth/isEmailNotVerifiedError'; import { currentUserQueryKey } from '@/features/auth/queries/getCurrentUser'; import { authStore, OverallAppSignIn } from '@/features/auth/store/authStore'; import { User } from '@/integrations/api/api.patch'; @@ -8,10 +9,13 @@ import { reoClient } from '@/integrations/reo/reo'; import { parseCompanyFromEmail } from '@/lib/string/parseCompanyFromEmail'; import { clearUtmParamsFromUrl } from '@/lib/urls/clearUtmParams'; import { getDefaultSignedInCloudRouteForUser } from '@/lib/urls/getDefaultSignedInCloudRouteForUser'; +import { errorHandler } from '@/react-query/queryClient'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useNavigate, useRouter, useSearch } from '@tanstack/react-router'; import { useCallback } from 'react'; +import { toast } from 'sonner'; import { z } from 'zod'; +import { useResendEmailVerification } from './useResendEmailVerification'; export function useCloudSignIn() { const navigate = useNavigate(); @@ -20,6 +24,7 @@ export function useCloudSignIn() { const { redirect } = useSearch({ strict: false }); const { mutate: submitLoginData, isPending } = useLoginMutation(); + const { mutate: resendEmailVerification } = useResendEmailVerification(); const submitForm = useCallback((formData: z.infer) => { submitLoginData(formData, { @@ -40,8 +45,29 @@ export function useCloudSignIn() { void router.invalidate(); await navigate({ to: redirect?.startsWith('/') ? redirect : defaultCloudRoute }); }, + // The login mutation opts out of the global error toast (meta.skipGlobalErrorToast) so we + // can special-case the unverified-email rejection instead of dead-ending on a red toast. + onError: (error) => { + if (isEmailNotVerifiedError(error)) { + // This rejection only happens after central-manager accepts the password, so the + // credentials are valid — resending a fresh link here can't be abused for spam. Only + // claim "we sent a link" once the resend actually succeeds; navigate regardless so an + // unverified user always lands on /verifying (which offers a manual resend) rather than + // dead-ending on the sign-in page. + resendEmailVerification({ email: formData.email }, { + onSuccess: () => + toast.info('Verify your email to finish signing in', { + description: `We sent a new verification link to ${formData.email}.`, + }), + }); + void navigate({ to: '/verifying?email=' + encodeURIComponent(formData.email) }); + return; + } + // Any other failure keeps the standard error toast. + errorHandler(error); + }, }); - }, [navigate, queryClient, redirect, router, submitLoginData]); + }, [navigate, queryClient, redirect, resendEmailVerification, router, submitLoginData]); return { isPending, @@ -52,6 +78,9 @@ export function useCloudSignIn() { function useLoginMutation() { return useMutation>({ mutationFn: (loginData) => onLoginSubmit(loginData), + // submitForm renders the login-error UX itself (redirecting unverified users into the + // email-verification flow), so suppress the default global error toast for this mutation. + meta: { skipGlobalErrorToast: true }, }); } diff --git a/src/features/auth/isEmailNotVerifiedError.test.ts b/src/features/auth/isEmailNotVerifiedError.test.ts new file mode 100644 index 000000000..1de2d3516 --- /dev/null +++ b/src/features/auth/isEmailNotVerifiedError.test.ts @@ -0,0 +1,46 @@ +import { AxiosError } from 'axios'; +import { describe, expect, it } from 'vitest'; +import { isEmailNotVerifiedError } from './isEmailNotVerifiedError'; + +// Shapes a rejection the way axios surfaces a central-manager error response. +function axiosError(status: number, data: unknown): AxiosError { + return { isAxiosError: true, response: { status, data } } as AxiosError; +} + +describe('isEmailNotVerifiedError', () => { + it('matches the 403 "User has not verified email address" rejection (data.error)', () => { + expect(isEmailNotVerifiedError(axiosError(403, { error: 'User has not verified email address' }))).toBe(true); + }); + + it('matches when the message is under data.message', () => { + expect(isEmailNotVerifiedError(axiosError(403, { message: 'User has not verified email address' }))).toBe(true); + }); + + it('matches when the body is a bare string', () => { + expect(isEmailNotVerifiedError(axiosError(403, 'User has not verified email address'))).toBe(true); + }); + + it('matches a nested structured error object', () => { + expect(isEmailNotVerifiedError(axiosError(403, { error: { message: 'User has not verified email address' } }))) + .toBe(true); + }); + + it('does NOT match a 403 deactivated-account rejection (same status, different reason)', () => { + expect(isEmailNotVerifiedError(axiosError(403, { error: 'User account deactivated' }))).toBe(false); + }); + + it('does NOT match a 401 invalid-credentials rejection', () => { + expect(isEmailNotVerifiedError(axiosError(401, { error: 'Invalid email or password' }))).toBe(false); + }); + + it('does NOT match a 409 conflict', () => { + expect(isEmailNotVerifiedError(axiosError(409, { error: 'Multiple user@example.com records found' }))).toBe(false); + }); + + it('is safe on non-axios / empty errors', () => { + expect(isEmailNotVerifiedError(new Error('Something went wrong'))).toBe(false); + expect(isEmailNotVerifiedError(undefined)).toBe(false); + expect(isEmailNotVerifiedError(null)).toBe(false); + expect(isEmailNotVerifiedError(axiosError(403, undefined))).toBe(false); + }); +}); diff --git a/src/features/auth/isEmailNotVerifiedError.ts b/src/features/auth/isEmailNotVerifiedError.ts new file mode 100644 index 000000000..bd704de46 --- /dev/null +++ b/src/features/auth/isEmailNotVerifiedError.ts @@ -0,0 +1,26 @@ +import { errorText } from '@/lib/errorText'; +import { isAxiosError } from 'axios'; + +/** + * Detects the cloud-login rejection that means "this account exists and the password was + * correct, but the email address hasn't been verified yet". + * + * central-manager's `Login` resource checks verification *after* validating the password + * (so an attacker with a wrong password can't distinguish an unverified account from a bad + * one) and throws HTTP 403 with the message "User has not verified email address". A + * *deactivated* account also comes back as 403, so we additionally match the message — a + * bare status check would wrongly funnel deactivated users into the email-verification flow. + * + * Because this state is only reachable once the password has been accepted, callers can treat + * it as proof of valid credentials (e.g. safe to auto-resend a verification link). + */ +export function isEmailNotVerifiedError(error: unknown): boolean { + if (!isAxiosError(error) || error.response?.status !== 403) { + return false; + } + const data = error.response.data as string | { error?: unknown; message?: unknown } | undefined; + const message = typeof data === 'string' + ? data + : errorText(data?.error) ?? errorText(data?.message) ?? ''; + return /verif/i.test(message); +} diff --git a/src/features/auth/store/authStore.ts b/src/features/auth/store/authStore.ts index dfd022ac2..b863d4463 100644 --- a/src/features/auth/store/authStore.ts +++ b/src/features/auth/store/authStore.ts @@ -421,6 +421,26 @@ class AuthStore { this.updateConnectionIfChanged(id, false, null); } + /** + * Local-only full sign-out: clears every in-memory connection, Fabric token, + * and flag for ALL entities, plus the cloud slot — WITHOUT posting a logout to + * CM or any instance. For a session already known-dead (e.g. a 401 from CM), + * where the in-memory maps would otherwise survive until a page reload and a + * same-tab re-login could inherit the prior user's connections/tokens/cache. + * Distinct from signOutFromPotentiallyAuthenticatedInstances, which also posts + * per-instance logouts. + */ + public signOutAllLocally(): void { + // Snapshot keys first — signOutLocally mutates the flags as it goes. + for (const id of Object.keys(this.potentiallyAuthenticated)) { + this.signOutLocally(id); + } + this.fabricConnectAuth.clear(); + this.fabricConnectInFlight.clear(); + this.operationTokenRefreshInFlight.clear(); + this.setUserForEntity(OverallAppSignIn, null); + } + private calculateKeyFromEntity(entity: EntityTypes): AuthenticatedConnectionKey | undefined { if (isLocalStudio || entity === OverallAppSignIn) { return OverallAppSignIn; diff --git a/src/features/auth/store/signOutAllLocally.test.ts b/src/features/auth/store/signOutAllLocally.test.ts new file mode 100644 index 000000000..5225542f8 --- /dev/null +++ b/src/features/auth/store/signOutAllLocally.test.ts @@ -0,0 +1,35 @@ +/** @vitest-environment jsdom */ +// jsdom: authStore touches localStorage at module load and throughout. +import { beforeEach, describe, expect, it } from 'vitest'; +import { authStore, OverallAppSignIn } from './authStore'; + +type SetArgs = Parameters; + +describe('authStore.signOutAllLocally', () => { + beforeEach(() => { + localStorage.clear(); + sessionStorage.clear(); + }); + + it('clears the cloud slot and every entity connection/flag locally, without a network logout', () => { + // Establish A's cloud session plus an authenticated instance with a Fabric flag. + authStore.setUserForIdAndKey(OverallAppSignIn, OverallAppSignIn, { id: 'usr_a' } as SetArgs[2]); + authStore.setUserForIdAndKey( + 'inst_1' as SetArgs[0], + 'https://inst-1' as SetArgs[1], + { username: 'u' } as SetArgs[2], + ); + authStore.flagForFabricConnect('inst_1' as SetArgs[0], true); + + expect(authStore.getConnectionById(OverallAppSignIn).user).not.toBeNull(); + expect(authStore.getConnectionById('inst_1' as SetArgs[0]).user).not.toBeNull(); + expect(authStore.checkForFabricConnect('inst_1' as SetArgs[0])).toBe(true); + + authStore.signOutAllLocally(); + + // Cross-user leak guard: nothing of A's survives in memory or storage. + expect(authStore.getConnectionById(OverallAppSignIn).user).toBeNull(); + expect(authStore.getConnectionById('inst_1' as SetArgs[0]).user).toBeNull(); + expect(authStore.checkForFabricConnect('inst_1' as SetArgs[0])).toBe(false); + }); +}); diff --git a/src/features/cluster/ClusterHome.tsx b/src/features/cluster/ClusterHome.tsx index 917027510..823157df6 100644 --- a/src/features/cluster/ClusterHome.tsx +++ b/src/features/cluster/ClusterHome.tsx @@ -6,6 +6,7 @@ import { getInstanceClient } from '@/config/getInstanceClient'; import { authStore } from '@/features/auth/store/authStore'; import { ClusterPageLayout } from '@/features/cluster/components/ClusterPageLayout'; import { getClusterInfoQueryOptions } from '@/features/cluster/queries/getClusterInfoQuery'; +import { ClusterStateMenu } from '@/features/clusters/components/ClusterStateMenu'; import { useInstanceAuth } from '@/hooks/useAuth'; import { useCopyToClipboard } from '@/hooks/useCopyToClipboard'; import { useOrganizationClusterPermissions } from '@/hooks/usePermissions'; @@ -17,7 +18,18 @@ import { getOperationsUrlForCluster } from '@/lib/urls/getOperationsUrlForCluste import { getOperationsUrlForInstance } from '@/lib/urls/getOperationsUrlForInstance'; import { useQuery } from '@tanstack/react-query'; import { Link, Navigate, useNavigate, useParams, useRouter } from '@tanstack/react-router'; -import { ArrowRight, CircleCheck, Copy, ExternalLink, KeyRound, Loader2, Rocket, Server, Zap } from 'lucide-react'; +import { + ArrowRight, + CircleCheck, + Copy, + ExternalLink, + KeyRound, + LifeBuoy, + Loader2, + Rocket, + Server, + Zap, +} from 'lucide-react'; import { ComponentType, ReactNode, useCallback, useMemo, useState } from 'react'; import { toast } from 'sonner'; @@ -120,6 +132,14 @@ export function ClusterHome() { .filter((instance) => instance.status && !deletedClusterStatuses.includes(instance.status)) .length; + // The cluster is "fully in safe mode" only when it's up and every instance reports safe mode — a + // stopped/transitioning instance has no safeMode flag, so a partial cluster won't qualify. + const clusterInstances = cluster.instances ?? []; + const allInSafeMode = !!cluster.status + && activeClusterStatuses.includes(cluster.status) + && clusterInstances.length > 0 + && clusterInstances.every((instance) => instance.safeMode); + return (
@@ -130,6 +150,7 @@ export function ClusterHome() {

{cluster.name}

+ {allInSafeMode && }
{instanceCount} {instanceCount === 1 ? 'instance' : 'instances'} @@ -157,6 +178,7 @@ export function ClusterHome() {
+
{isLoading ? : connected @@ -387,18 +409,35 @@ function Spinner() { function StatusPill({ status }: { status?: string }) { const active = status && activeClusterStatuses.includes(status); + const colorClass = active + ? 'text-green bg-green/10' + : status === 'STOPPED' + ? 'text-destructive bg-destructive/10' + : status === 'PARTIAL' + ? 'text-yellow bg-yellow/10' + : 'text-muted-foreground bg-muted'; return ( - + {status ? status.charAt(0) + status.slice(1).toLowerCase() : 'Unknown'} ); } +// Shown beside the StatusPill when every instance is in safe mode (mirrors the per-instance +// "Safe mode" badge on the Instances page, styled as a rounded-full pill to match StatusPill). +function SafeModePill() { + return ( + + + Safe mode + + ); +} + function ConnectOption( { icon: Icon, title, description, pill, children }: { icon: ComponentType<{ className?: string }>; diff --git a/src/features/cluster/InstanceActionsMenu.tsx b/src/features/cluster/InstanceActionsMenu.tsx index 14e1595dd..99b1e1078 100644 --- a/src/features/cluster/InstanceActionsMenu.tsx +++ b/src/features/cluster/InstanceActionsMenu.tsx @@ -13,22 +13,25 @@ import { useInstanceMenuItems } from './useInstanceMenuItems'; */ export function InstanceActionsMenu({ instance, isSelfManaged }: { instance: Instance; isSelfManaged: boolean }) { const [open, setOpen] = useState(false); - const items = useInstanceMenuItems(instance, isSelfManaged, open); + const { items, dialog } = useInstanceMenuItems(instance, isSelfManaged, open); if (!items.length) { return null; } return ( - - - - - - {renderEntityMenuItems(items, 'dropdown')} - - + <> + + + + + + {renderEntityMenuItems(items, 'dropdown')} + + + {dialog} + ); } diff --git a/src/features/cluster/InstanceLogInCell.tsx b/src/features/cluster/InstanceLogInCell.tsx index 0634d99fd..6f48984af 100644 --- a/src/features/cluster/InstanceLogInCell.tsx +++ b/src/features/cluster/InstanceLogInCell.tsx @@ -1,4 +1,5 @@ import { Button } from '@/components/ui/button'; +import { isStoppedOrTransitioning } from '@/components/ui/utils/badgeStatus'; import { defaultInstanceRoute } from '@/config/constants'; import { useInstanceClient } from '@/config/useInstanceClient'; import { authStore } from '@/features/auth/store/authStore'; @@ -24,6 +25,12 @@ export function InstanceLogInCell( await signOutOfInstance({ instance, instanceClient }); }, [instance, instanceClient]); + // A stopped / mid-transition instance isn't reachable — you can't connect or sign in — so show a + // neutral placeholder instead of the perpetual spinner (which is meant for instances coming up). + if (isStoppedOrTransitioning(instance.status)) { + return ; + } + if ( instanceAuthIsLoading || !instance.status || !['CLONE_READY', 'RUNNING', 'UPDATED', 'PENDING_UPGRADE'].includes(instance.status) diff --git a/src/features/cluster/InstanceRowContextMenu.tsx b/src/features/cluster/InstanceRowContextMenu.tsx index 67a28a0ee..46ebe8317 100644 --- a/src/features/cluster/InstanceRowContextMenu.tsx +++ b/src/features/cluster/InstanceRowContextMenu.tsx @@ -18,16 +18,19 @@ export function InstanceRowContextMenu({ children: ReactNode; }) { const [open, setOpen] = useState(false); - const items = useInstanceMenuItems(instance, isSelfManaged, open); + const { items, dialog } = useInstanceMenuItems(instance, isSelfManaged, open); if (!items.length) { return <>{children}; } return ( - - {children} - {renderEntityMenuItems(items, 'context')} - + <> + + {children} + {renderEntityMenuItems(items, 'context')} + + {dialog} + ); } diff --git a/src/features/cluster/InstanceStatusCell.tsx b/src/features/cluster/InstanceStatusCell.tsx index 6e515b1f4..6afb3ffa9 100644 --- a/src/features/cluster/InstanceStatusCell.tsx +++ b/src/features/cluster/InstanceStatusCell.tsx @@ -1,6 +1,7 @@ import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { isStoppedOrTransitioning } from '@/components/ui/utils/badgeStatus'; import { useInstanceClientIdParams } from '@/config/useInstanceClient'; import { useOrganizationClusterInstancePermissions } from '@/hooks/usePermissions'; import { Instance } from '@/integrations/api/api.patch'; @@ -31,8 +32,12 @@ export function InstanceStatusCell( return () => clearTimeout(timer); }, [index]); + // Don't poll get_status while the instance is stopped / mid container-transition — its ops API + // is unreachable, so the request just errors on a 10s loop. + const stopped = isStoppedOrTransitioning(instance.status); + const statusPollEnabled = ready && canManage && !stopped; const { data: statusResponse, isLoading, isFetching } = useQuery( - getStatusQueryOptions(instanceParams, ready && canManage), + getStatusQueryOptions(instanceParams, statusPollEnabled), ); const systemStatus = getSystemStatusById(statusResponse, 'availability') || 'Unknown'; @@ -43,6 +48,21 @@ export function InstanceStatusCell( return null; } + // Stopped / transitioning: availability + rotation are N/A. Show a muted dot rather than a stale + // green "Available" (cached from before it stopped) or an endless spinner. + if (stopped) { + return ( +
+ + + + + Not running + +
+ ); + } + return (
diff --git a/src/features/cluster/Instances.tsx b/src/features/cluster/Instances.tsx index d92ac999a..4730e33a5 100644 --- a/src/features/cluster/Instances.tsx +++ b/src/features/cluster/Instances.tsx @@ -3,7 +3,7 @@ import { SubNavMenu } from '@/components/SubNavMenu'; import { TextLoadingSkeleton } from '@/components/TextLoadingSkeleton'; import { Badge } from '@/components/ui/badge'; import { Card, CardContent } from '@/components/ui/card'; -import { renderBadgeStatusVariant } from '@/components/ui/utils/badgeStatus'; +import { isStoppedOrTransitioning, renderBadgeStatusVariant } from '@/components/ui/utils/badgeStatus'; import { deletedClusterStatuses } from '@/config/clusterStatuses'; import { ClusterPageLayout } from '@/features/cluster/components/ClusterPageLayout'; import { calculateInstanceFQDN } from '@/features/clusters/upsert/lib/calculateInstanceFQDN'; @@ -15,6 +15,7 @@ import { capitalizeWords } from '@/lib/string/capitalizeWords'; import { useQuery } from '@tanstack/react-query'; import { useParams } from '@tanstack/react-router'; import { ColumnDef } from '@tanstack/react-table'; +import { LifeBuoyIcon } from 'lucide-react'; import { useMemo } from 'react'; import { EmptyCluster } from './EmptyCluster'; import { InstanceActionsMenu } from './InstanceActionsMenu'; @@ -73,6 +74,14 @@ export function Instances() {
{status ? {capitalizeWords(status)} : null} + {cell.row.original.safeMode && !isStoppedOrTransitioning(cell.row.original.status) + ? ( + + + Safe mode + + ) + : null}
); }, diff --git a/src/features/cluster/useInstanceMenuItems.tsx b/src/features/cluster/useInstanceMenuItems.tsx index 9d2fea5a9..392061c47 100644 --- a/src/features/cluster/useInstanceMenuItems.tsx +++ b/src/features/cluster/useInstanceMenuItems.tsx @@ -1,11 +1,14 @@ import type { EntityMenuItem } from '@/components/ui/entityMenu'; +import { isStoppedOrTransitioning } from '@/components/ui/utils/badgeStatus'; import { defaultInstanceRoute } from '@/config/constants'; import { useInstanceClient, useInstanceClientIdParams } from '@/config/useInstanceClient'; import { authStore } from '@/features/auth/store/authStore'; import { signOutOfInstance } from '@/features/cluster/signOutOfInstance'; +import { SafeModeConfirmDialog } from '@/features/clusters/components/SafeModeConfirmDialog'; import { calculateInstanceFQDN } from '@/features/clusters/upsert/lib/calculateInstanceFQDN'; import { useInstanceAuth } from '@/hooks/useAuth'; import { useCopyToClipboard } from '@/hooks/useCopyToClipboard'; +import { useInstanceContainerOps } from '@/hooks/useInstanceContainerOps'; import { useOrganizationClusterInstancePermissions } from '@/hooks/usePermissions'; import { Instance } from '@/integrations/api/api.patch'; import { getStatusQueryOptions, getSystemStatusById } from '@/integrations/api/instance/status/getStatus'; @@ -13,8 +16,19 @@ import { useSetStatus } from '@/integrations/api/instance/status/setStatus'; import { excludeFalsy } from '@/lib/arrays/excludeFalsy'; import { getOperationsUrlForInstance } from '@/lib/urls/getOperationsUrlForInstance'; import { useQuery } from '@tanstack/react-query'; -import { ClipboardIcon, LogInIcon, LogOutIcon, ServerIcon, ShieldCheckIcon, ShieldXIcon } from 'lucide-react'; -import { useCallback, useMemo } from 'react'; +import { + ClipboardIcon, + LifeBuoyIcon, + LogInIcon, + LogOutIcon, + PlayIcon, + RotateCwIcon, + ServerIcon, + ShieldCheckIcon, + ShieldXIcon, + SquareIcon, +} from 'lucide-react'; +import { type ReactNode, useCallback, useMemo, useState } from 'react'; const READY_STATUSES = ['CLONE_READY', 'RUNNING', 'UPDATED', 'PENDING_UPGRADE']; @@ -31,7 +45,7 @@ export function useInstanceMenuItems( instance: Instance, isSelfManaged: boolean, enabled: boolean, -): EntityMenuItem[] { +): { items: EntityMenuItem[]; dialog: ReactNode } { const { user: instanceUser } = useInstanceAuth(instance.id); const operationsUrl = useMemo(() => getOperationsUrlForInstance(instance), [instance]); const instanceClient = useInstanceClient({ operationsUrl }); @@ -39,11 +53,17 @@ export function useInstanceMenuItems( const isFabricConnect = authStore.checkForFabricConnect(instance.id); const statusParams = useInstanceClientIdParams({ operationsUrl, instanceId: instance.id, forceFabricConnect: true }); - const { data: statusResponse } = useQuery(getStatusQueryOptions(statusParams, enabled && canManage)); + const { data: statusResponse } = useQuery( + getStatusQueryOptions(statusParams, enabled && canManage && !isStoppedOrTransitioning(instance.status)), + ); const systemStatus = getSystemStatusById(statusResponse, 'availability') || 'Unknown'; const isAvailable = systemStatus === 'Available'; const isUnavailable = systemStatus === 'Unavailable'; const { mutate: setStatus, isPending: isSettingStatus } = useSetStatus(); + const { run: runContainerOp, isPending: isContainerOpPending } = useInstanceContainerOps(instance); + // Safe mode is jargon for a recovery action that drops user apps/components — explain it at the + // point of use, same as the cluster path. Both safe-mode entries route through one dialog. + const [safeModeAction, setSafeModeAction] = useState<'start' | 'restart' | null>(null); const fqdn = instance.instanceFqdn; const apiUrl = calculateInstanceFQDN({ @@ -63,6 +83,14 @@ export function useInstanceMenuItems( const hasCopy = !!fqdn; const hasRotation = canManage && isReady && (isAvailable || isUnavailable); + // Container lifecycle ops (stop/start/restart) — distinct from the proxied Harper "restart". + // Only offered from a settled RUNNING/STOPPED state; hidden mid-transition (the instances poll + // reveals the resting state and the actions reappear). Self-hosted clusters have no managed + // container lifecycle (Harper doesn't control their runtime), so the group is hidden for them. + const isRunning = instance.status === 'RUNNING'; + const isStopped = instance.status === 'STOPPED'; + const hasContainerOps = canManage && !isSelfManaged && (isRunning || isStopped); + const actions: EntityMenuItem[] = [ hasAuth && isDirectlyLoggedIn && { key: 'direct-connect', @@ -110,15 +138,85 @@ export function useInstanceMenuItems( icon: , label: 'Bring back into rotation', }, + + (hasAuth || hasCopy || hasRotation) && hasContainerOps && { type: 'separator' as const, key: 'container-sep' }, + hasContainerOps && { + type: 'label' as const, + key: 'container-label', + className: 'text-gray-600 text-xs', + label: 'Container', + }, + hasContainerOps && isStopped && { + key: 'container-start', + disabled: isContainerOpPending, + onClick: () => void runContainerOp('start', { safeMode: false }), + icon: , + label: 'Start', + }, + hasContainerOps && isStopped && { + key: 'container-start-safe', + disabled: isContainerOpPending, + onClick: () => setSafeModeAction('start'), + icon: , + label: 'Start in safe mode', + }, + hasContainerOps && isRunning && { + key: 'container-restart', + disabled: isContainerOpPending, + onClick: () => void runContainerOp('restart', { safeMode: false }), + icon: , + label: 'Restart', + }, + hasContainerOps && isRunning && { + key: 'container-restart-safe', + disabled: isContainerOpPending, + onClick: () => setSafeModeAction('restart'), + icon: , + label: 'Restart in safe mode', + }, + hasContainerOps && isRunning && { + key: 'container-stop', + variant: 'destructive' as const, + disabled: isContainerOpPending, + onClick: () => void runContainerOp('stop'), + icon: , + label: 'Stop', + }, ].filter(excludeFalsy); + const dialog = ( + { + if (!isOpen) { setSafeModeAction(null); } + }} + action={safeModeAction ?? 'restart'} + targetName={instance.name ?? instance.id} + scope="instance" + isPending={isContainerOpPending} + onConfirm={() => { + const action = safeModeAction; + setSafeModeAction(null); + if (action) { + void runContainerOp(action, { + safeMode: true, + label: action === 'start' ? 'Starting in safe mode' : 'Restarting in safe mode', + }); + } + }} + /> + ); + if (!actions.length) { - return []; + return { items: [], dialog }; } - return [ - { type: 'label', key: 'label', className: 'text-gray-600 text-xs', label: 'Options' }, - { type: 'separator', key: 'label-sep' }, - ...actions, - ]; + return { + items: [ + { type: 'label', key: 'label', className: 'text-gray-600 text-xs', label: 'Options' }, + { type: 'separator', key: 'label-sep' }, + ...actions, + ], + dialog, + }; } diff --git a/src/features/clusters/components/ClusterCard.tsx b/src/features/clusters/components/ClusterCard.tsx index abf587562..391dac3d5 100644 --- a/src/features/clusters/components/ClusterCard.tsx +++ b/src/features/clusters/components/ClusterCard.tsx @@ -10,13 +10,17 @@ import { useInstanceClient } from '@/config/useInstanceClient'; import { authStore } from '@/features/auth/store/authStore'; import { getClusterInfo } from '@/features/cluster/queries/getClusterInfoQuery'; import { ClusterCardAction } from '@/features/clusters/components/ClusterCardAction'; +import { ClusterContainerOpModals } from '@/features/clusters/components/ClusterContainerOpModals'; import { ClusterProgress } from '@/features/clusters/components/ClusterProgress'; +import { SafeModeConfirmDialog } from '@/features/clusters/components/SafeModeConfirmDialog'; import { useTerminateClusterMutation } from '@/features/clusters/mutations/terminateCluster'; import { useInstanceAuth } from '@/hooks/useAuth'; +import { useClusterContainerOps } from '@/hooks/useClusterContainerOps'; import { useCopyToClipboard } from '@/hooks/useCopyToClipboard'; import { useLocalStorage } from '@/hooks/useLocalStorage'; import { useOrganizationClusterPermissions } from '@/hooks/usePermissions'; import { Cluster } from '@/integrations/api/api.patch'; +import { ContainerStrategy } from '@/integrations/api/cluster/containerOperation'; import { clusterIsSelfManaged } from '@/integrations/api/clusterIsSelfManaged'; import { onInstanceLogoutSubmit } from '@/integrations/api/instance/auth/onInstanceLogoutSubmit'; import { excludeFalsy } from '@/lib/arrays/excludeFalsy'; @@ -32,9 +36,14 @@ import { GitGraphIcon, GlobeIcon, KeyIcon, + LifeBuoyIcon, + Loader2, + PlayIcon, RocketIcon, + RotateCwIcon, ScaleIcon, ServerIcon, + SquareIcon, TrashIcon, } from 'lucide-react'; import { useCallback, useMemo, useState } from 'react'; @@ -53,12 +62,33 @@ export function ClusterCard({ cluster }: { cluster: Cluster }) { const [signingOut, setSigningOut] = useState(false); const [isTerminateClusterModalOpen, setIsTerminateClusterModalOpen] = useState(false); + const [stopConfirmOpen, setStopConfirmOpen] = useState(false); + const [restartDialogOpen, setRestartDialogOpen] = useState(false); + const [safeModeAction, setSafeModeAction] = useState<'start' | 'restart' | null>(null); + const { run: runClusterOp, isPending: isClusterOpPending } = useClusterContainerOps(cluster); + + // Container-op availability by cluster state (see the per-instance menu for the same idea): + // RUNNING → Restart, Restart in safe mode, Stop STOPPED → Start, Start in safe mode + // PARTIAL → Start, Stop, Restart (some up, some down) + const isClusterRunning = cluster.status === 'RUNNING'; + const isClusterStopped = cluster.status === 'STOPPED'; + const isClusterPartial = cluster.status === 'PARTIAL'; + + // Temporary status badge on the card for container-op states. Transitional states tell the user + // what's happening (Stopping/Starting/Restarting) and clear on their own as the clusters list + // polls; STOPPED/PARTIAL are resting labels. RUNNING stays clean (no badge) on this route. + const isClusterTransitioning = cluster.status === 'STOPPING' || cluster.status === 'STARTING' + || cluster.status === 'RESTARTING'; + const showContainerOpBadge = isClusterTransitioning || isClusterStopped || isClusterPartial; const isActive = useMemo( () => !!(cluster.status && activeClusterStatuses.includes(cluster.status)), [cluster.status], ); const isSelfManaged = clusterIsSelfManaged(cluster); + // Self-hosted clusters have no managed container lifecycle — Harper doesn't control their + // runtime — so the whole Container action group is hidden for them (matching ClusterStateMenu). + const showContainerActions = update && !isSelfManaged; const isFabricConnect = authStore.checkForFabricConnect(cluster.id); const isDirectConnect = !isFabricConnect && !!auth.user; const isTerminated = useMemo( @@ -137,7 +167,16 @@ export function ClusterCard({ cluster }: { cluster: Cluster }) { // The whole card opens the cluster home (overview) for the normal "Open" case — including // self-hosted clusters, which get their own overview. A managed cluster with no FQDN yet opens its // instances; resetPassword (→ Finish Setup / Pending) keeps its explicit CTA in ClusterCardAction. - const cardHref = !isActive || !view || isTerminated + // Stopped/partial clusters aren't "active" but must still be reachable: a fully-stopped cluster + // opens its instances page (where you start them back up); a partial cluster (some instances still + // running) opens the cluster overview like a normal cluster. + const cardHref = !view || isTerminated + ? undefined + : cluster.status === 'STOPPED' + ? `/${cluster.organizationId}/${cluster.id}/instances` + : cluster.status === 'PARTIAL' + ? `/${cluster.organizationId}/${cluster.id}` + : !isActive ? undefined : isSelfManaged ? `/${cluster.organizationId}/${cluster.id}` @@ -197,7 +236,7 @@ export function ClusterCard({ cluster }: { cluster: Cluster }) { icon: , label: 'Instances', }, - isActive && view && { + isActive && view && !!auth.user && { key: 'deployments', to: `${cluster.id}/config/deployments`, disabled: signingOut, @@ -205,6 +244,47 @@ export function ClusterCard({ cluster }: { cluster: Cluster }) { label: 'Deployments', }, + showContainerActions && (isClusterRunning || isClusterStopped || isClusterPartial) + && { type: 'separator' as const, key: 'container-separator' }, + showContainerActions && (isClusterRunning || isClusterStopped || isClusterPartial) + && { type: 'label' as const, key: 'container-label', className: 'text-gray-600 text-xs', label: 'Container' }, + showContainerActions && (isClusterStopped || isClusterPartial) && { + key: 'container-start', + disabled: isClusterOpPending, + onClick: () => void runClusterOp('start', { safeMode: false, strategy: 'parallel' }), + icon: , + label: 'Start', + }, + showContainerActions && isClusterStopped && { + key: 'container-start-safe', + disabled: isClusterOpPending, + onClick: () => setSafeModeAction('start'), + icon: , + label: 'Start in safe mode', + }, + showContainerActions && (isClusterRunning || isClusterPartial) && { + key: 'container-restart', + disabled: isClusterOpPending, + onClick: () => setRestartDialogOpen(true), + icon: , + label: 'Restart', + }, + showContainerActions && isClusterRunning && { + key: 'container-restart-safe', + disabled: isClusterOpPending, + onClick: () => setSafeModeAction('restart'), + icon: , + label: 'Restart in safe mode', + }, + showContainerActions && (isClusterRunning || isClusterPartial) && { + key: 'container-stop', + variant: 'destructive' as const, + disabled: isClusterOpPending, + onClick: () => setStopConfirmOpen(true), + icon: , + label: 'Stop', + }, + isActive && view && !!cluster.fqdn && { type: 'separator' as const, key: 'copy-separator' }, isActive && view && !!cluster.fqdn && { key: 'copy-host-name', @@ -296,6 +376,12 @@ export function ClusterCard({ cluster }: { cluster: Cluster }) { {isActive && view && } + {showContainerOpBadge && cluster.status && ( + + {isClusterTransitioning && } + {capitalizeWords(cluster.status)} + + )} {clusterHasFailed && cluster.status && ( <> {capitalizeWords(cluster.status)} @@ -315,6 +401,45 @@ export function ClusterCard({ cluster }: { cluster: Cluster }) { deletionConfirmed={handleTerminatedCluster} deletionPending={isTerminateClusterPending} /> + + { + setStopConfirmOpen(false); + void runClusterOp('stop', { strategy: 'parallel' }); + }} + restartOpen={restartDialogOpen} + setRestartOpen={setRestartDialogOpen} + onConfirmRestart={(strategy: ContainerStrategy) => { + setRestartDialogOpen(false); + void runClusterOp('restart', { safeMode: false, strategy }); + }} + /> + + { + if (!isOpen) { setSafeModeAction(null); } + }} + action={safeModeAction ?? 'restart'} + targetName={cluster.name} + scope="cluster" + isPending={isClusterOpPending} + onConfirm={() => { + const action = safeModeAction; + setSafeModeAction(null); + if (action) { + void runClusterOp(action, { + safeMode: true, + strategy: 'parallel', + label: action === 'start' ? 'Starting in safe mode' : 'Restarting in safe mode', + }); + } + }} + /> ); diff --git a/src/features/clusters/components/ClusterContainerOpModals.tsx b/src/features/clusters/components/ClusterContainerOpModals.tsx new file mode 100644 index 000000000..5c72ce3db --- /dev/null +++ b/src/features/clusters/components/ClusterContainerOpModals.tsx @@ -0,0 +1,98 @@ +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { ContainerStrategy } from '@/integrations/api/cluster/containerOperation'; + +/** + * Dialogs for cluster-wide container ops: + * - Stop confirmation (whole cluster goes offline). + * - Restart strategy picker (parallel vs rolling) — clicking a strategy dispatches the restart. + * State lives in the parent (ClusterCard), mirroring how the terminate modal is wired. + */ +export function ClusterContainerOpModals({ + clusterName, + isPending, + stopOpen, + setStopOpen, + onConfirmStop, + restartOpen, + setRestartOpen, + onConfirmRestart, +}: { + clusterName: string; + isPending: boolean; + stopOpen: boolean; + setStopOpen: (open: boolean) => void; + onConfirmStop: () => void; + restartOpen: boolean; + setRestartOpen: (open: boolean) => void; + onConfirmRestart: (strategy: ContainerStrategy) => void; +}) { + return ( + <> + + + + Stop cluster {clusterName}? + + This stops every instance in the cluster. It will be offline and stop serving traffic until you start it + again. + + + +
+ + +
+
+
+
+ + + + + Restart cluster {clusterName} + Choose how to restart the cluster's instances. + +
+ + +
+ + + +
+
+ + ); +} diff --git a/src/features/clusters/components/ClusterStateMenu.tsx b/src/features/clusters/components/ClusterStateMenu.tsx new file mode 100644 index 000000000..26a07d66f --- /dev/null +++ b/src/features/clusters/components/ClusterStateMenu.tsx @@ -0,0 +1,173 @@ +import { ConfirmDeletionModal } from '@/components/ConfirmDeletionModal'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdownMenu'; +import { ClusterContainerOpModals } from '@/features/clusters/components/ClusterContainerOpModals'; +import { SafeModeConfirmDialog } from '@/features/clusters/components/SafeModeConfirmDialog'; +import { useTerminateClusterMutation } from '@/features/clusters/mutations/terminateCluster'; +import { useClusterContainerOps } from '@/hooks/useClusterContainerOps'; +import { useOrganizationClusterPermissions } from '@/hooks/usePermissions'; +import { Cluster } from '@/integrations/api/api.patch'; +import { ContainerStrategy } from '@/integrations/api/cluster/containerOperation'; +import { clusterIsSelfManaged } from '@/integrations/api/clusterIsSelfManaged'; +import { useQueryClient } from '@tanstack/react-query'; +import { useRouter } from '@tanstack/react-router'; +import { ChevronDown, LifeBuoyIcon, PlayIcon, RotateCwIcon, SquareIcon, TrashIcon } from 'lucide-react'; +import { useCallback, useState } from 'react'; +import { toast } from 'sonner'; + +/** + * "Cluster actions" dropdown (AWS EC2 "Instance state" style) for the cluster overview: every + * container lifecycle op plus Terminate in one discoverable, labeled place. Ops that don't apply to + * the current status are shown but disabled, so users can see the feature set exists. Self-hosted + * clusters have no container ops, so the control is hidden for them. + */ +export function ClusterStateMenu({ cluster }: { cluster: Cluster }) { + const router = useRouter(); + const queryClient = useQueryClient(); + const { update, remove } = useOrganizationClusterPermissions(cluster.organizationId, cluster.id); + const { run: runClusterOp, isPending } = useClusterContainerOps(cluster); + const { mutate: terminateCluster, isPending: isTerminatePending } = useTerminateClusterMutation(); + + const [stopOpen, setStopOpen] = useState(false); + const [restartOpen, setRestartOpen] = useState(false); + const [terminateOpen, setTerminateOpen] = useState(false); + // Both safe-mode ops route through one explain-and-confirm dialog; this tracks which. + const [safeModeAction, setSafeModeAction] = useState<'start' | 'restart' | null>(null); + + const isRunning = cluster.status === 'RUNNING'; + const isStopped = cluster.status === 'STOPPED'; + const isPartial = cluster.status === 'PARTIAL'; + const opsDisabled = !update || isPending; + + const onTerminate = useCallback(() => { + terminateCluster(cluster.id, { + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: [cluster.organizationId], refetchType: 'active' }); + setTerminateOpen(false); + toast.success('Success', { + description: 'Cluster successfully terminated.', + action: { label: 'Dismiss', onClick: () => toast.dismiss() }, + }); + // The overview for a terminated cluster is a dead end — return to the clusters list. + void router.navigate({ to: `/${cluster.organizationId}` }); + }, + onError: () => { + // The global MutationCache.onError already toasts the failure (with the server's + // message); just close the modal here to avoid double-toasting. + setTerminateOpen(false); + }, + }); + }, [cluster.id, cluster.organizationId, terminateCluster, queryClient, router]); + + const onConfirmSafeMode = useCallback(() => { + const action = safeModeAction; + setSafeModeAction(null); + if (action) { + void runClusterOp(action, { + safeMode: true, + strategy: 'parallel', + label: action === 'start' ? 'Starting in safe mode' : 'Restarting in safe mode', + }); + } + }, [safeModeAction, runClusterOp]); + + // Container ops are managed-cluster only; a user with neither permission gets no control. + if (clusterIsSelfManaged(cluster) || (!update && !remove)) { return null; } + + return ( + <> + + + + + + Container + void runClusterOp('start', { safeMode: false, strategy: 'parallel' })} + > + Start + + setSafeModeAction('start')}> + Start in safe mode + + setRestartOpen(true)} + > + Restart + + setSafeModeAction('restart')}> + Restart in safe mode + + setStopOpen(true)} + > + Stop + + + setTerminateOpen(true)}> + Terminate + + + + + { + setStopOpen(false); + void runClusterOp('stop', { strategy: 'parallel' }); + }} + restartOpen={restartOpen} + setRestartOpen={setRestartOpen} + onConfirmRestart={(strategy: ContainerStrategy) => { + setRestartOpen(false); + void runClusterOp('restart', { safeMode: false, strategy }); + }} + /> + + { + if (!isOpen) { setSafeModeAction(null); } + }} + action={safeModeAction ?? 'restart'} + targetName={cluster.name} + scope="cluster" + isPending={isPending} + onConfirm={onConfirmSafeMode} + /> + + + + ); +} diff --git a/src/features/clusters/components/SafeModeConfirmDialog.tsx b/src/features/clusters/components/SafeModeConfirmDialog.tsx new file mode 100644 index 000000000..a135d757c --- /dev/null +++ b/src/features/clusters/components/SafeModeConfirmDialog.tsx @@ -0,0 +1,75 @@ +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { LifeBuoyIcon } from 'lucide-react'; + +/** + * Explain-and-confirm dialog for the safe-mode ops. "Safe mode" is jargon and a recovery action, so + * rather than a hover tooltip (hover-only, easy to miss) we explain what it does at the moment of + * use. Shared by the start-in-safe-mode and restart-in-safe-mode entries at both cluster and + * instance scope — the explanation matters equally either way (see #1429 review). + */ +export function SafeModeConfirmDialog({ + open, + setOpen, + action, + targetName, + scope, + isPending, + onConfirm, +}: { + open: boolean; + setOpen: (open: boolean) => void; + action: 'start' | 'restart'; + targetName: string; + scope: 'cluster' | 'instance'; + isPending: boolean; + onConfirm: () => void; +}) { + const verb = action === 'start' ? 'Start' : 'Restart'; + const verbed = action === 'start' ? 'starts' : 'restarts'; + return ( + + + + + + {verb} {targetName} in safe mode? + + + Safe mode boots Harper{' '} + + without loading your applications or components + . Use it to recover {scope === 'cluster' ? 'a cluster' : 'an instance'}{' '} + that a bad app, component, or config is keeping from starting. Then fix or remove what's broken and + {' '} + {action === 'start' ? 'start' : 'restart'} normally to bring everything back. + + +

+ {scope === 'cluster' + ? `All instances are ${action === 'start' ? 'started' : 'restarted'} at once (parallel)${ + action === 'restart' ? ', so expect a brief interruption while they come back.' : '.' + }` + : `The instance ${verbed} in safe mode${ + action === 'restart' ? ', so expect a brief interruption while it comes back.' : '.' + }`} +

+ +
+ + +
+
+
+
+ ); +} diff --git a/src/features/clusters/queries/getHarperVersionsQuery.test.ts b/src/features/clusters/queries/getHarperVersionsQuery.test.ts new file mode 100644 index 000000000..585ba0105 --- /dev/null +++ b/src/features/clusters/queries/getHarperVersionsQuery.test.ts @@ -0,0 +1,156 @@ +import { apiClient } from '@/config/apiClient'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + dedupeHarperVersionsByTag, + getHarperVersionsOptions, + HarperVersion, + HarperVersionsResponse, +} from './getHarperVersionsQuery'; + +vi.mock('@/config/apiClient', () => ({ + apiClient: { + get: vi.fn(), + }, +})); + +const mockedGet = vi.mocked(apiClient.get); + +beforeEach(() => { + mockedGet.mockReset(); +}); + +function tags(versions: HarperVersion[]) { + return versions.map(({ version, name }) => `${version} ${name}`); +} + +describe('dedupeHarperVersionsByTag', () => { + it('keeps the most-preferred tag when a version appears more than once', () => { + const result = dedupeHarperVersionsByTag([ + { name: 'next', version: '5.1.21' }, + { name: 'stable', version: '5.1.21' }, + ]); + expect(tags(result)).toEqual(['5.1.21 stable']); + }); + + it('discards a less-preferred tag that arrives after a more-preferred one', () => { + const result = dedupeHarperVersionsByTag([ + { name: 'stable', version: '5.1.21' }, + { name: 'next', version: '5.1.21' }, + ]); + expect(tags(result)).toEqual(['5.1.21 stable']); + }); + + it('applies the full preference order stable > next > beta > alpha', () => { + const result = dedupeHarperVersionsByTag([ + { name: 'alpha', version: '6.0.0' }, + { name: 'beta', version: '6.0.0' }, + { name: 'next', version: '6.0.0' }, + ]); + expect(tags(result)).toEqual(['6.0.0 next']); + }); + + it('prefers any known tag over an unknown one', () => { + const result = dedupeHarperVersionsByTag([ + { name: 'foobarbaz', version: '5.1.21' }, + { name: 'alpha', version: '5.1.21' }, + ]); + expect(tags(result)).toEqual(['5.1.21 alpha']); + }); + + it('keeps a single unknown tag when there is nothing better', () => { + const result = dedupeHarperVersionsByTag([ + { name: 'foobarbaz', version: '5.1.21' }, + ]); + expect(tags(result)).toEqual(['5.1.21 foobarbaz']); + }); + + it('keeps distinct versions untouched and preserves their order', () => { + const result = dedupeHarperVersionsByTag([ + { name: 'stable', version: '5.1.21' }, + { name: 'next', version: '5.2.0' }, + { name: 'beta', version: '6.0.0' }, + ]); + expect(tags(result)).toEqual(['5.1.21 stable', '5.2.0 next', '6.0.0 beta']); + }); + + it('keeps each version at the position of its first appearance', () => { + const result = dedupeHarperVersionsByTag([ + { name: 'next', version: '5.1.21' }, + { name: 'stable', version: '5.2.0' }, + { name: 'stable', version: '5.1.21' }, + ]); + expect(tags(result)).toEqual(['5.1.21 stable', '5.2.0 stable']); + }); + + it('handles an empty list', () => { + expect(dedupeHarperVersionsByTag([])).toEqual([]); + }); +}); + +describe('getHarperVersionsOptions', () => { + it('scopes the cache key to the organization (org-first) and does not retry', () => { + const options = getHarperVersionsOptions('org_123'); + // Org-first so it participates in org-scoped `invalidateQueries([organizationId])`. + expect(options.queryKey).toEqual(['org_123', 'HarperVersions']); + expect(options.staleTime).toBe(60_000); + expect(options.retry).toBe(false); + }); + + it('fetches the versions and dedupes overlapping tags, keeping the rest of the response', async () => { + mockedGet.mockResolvedValue({ + data: { + name: 'Harper Versions', + description: 'Available Harper versions', + value: [ + { name: 'next', version: '5.1.21' }, + { name: 'stable', version: '5.1.21' }, + { name: 'beta', version: '5.2.0' }, + ], + } satisfies HarperVersionsResponse, + }); + + const options = getHarperVersionsOptions('org_1'); + const result = await (options.queryFn as () => Promise)(); + + expect(mockedGet).toHaveBeenCalledWith('/HarperVersions/', { params: { organizationId: 'org_1' } }); + expect(result).toEqual({ + name: 'Harper Versions', + description: 'Available Harper versions', + value: [ + { name: 'stable', version: '5.1.21' }, + { name: 'beta', version: '5.2.0' }, + ], + }); + }); + + it('passes the organizationId through axios params (axios handles encoding)', async () => { + mockedGet.mockResolvedValue({ + data: { + name: 'Harper Versions', + description: 'Available Harper versions', + value: [ + { name: 'stable', version: '5.1.21' }, + { name: 'deployed on prod-east', version: '5.0.8' }, + ], + } satisfies HarperVersionsResponse, + }); + + const options = getHarperVersionsOptions('org/1'); + const result = await (options.queryFn as () => Promise)(); + + expect(mockedGet).toHaveBeenCalledWith('/HarperVersions/', { params: { organizationId: 'org/1' } }); + expect(tags(result.value)).toEqual(['5.1.21 stable', '5.0.8 deployed on prod-east']); + }); + + it('surfaces (rather than swallows) a malformed response with no value array', async () => { + // The endpoint's OpenAPI description is broken, so the `as HarperVersionsResponse` cast is not + // schema-backed. If it ever returns a body without `value`, the queryFn throws — React Query's + // QueryCache.onError surfaces it and every consumer null-checks `harperVersions?.value`, so it + // fails safely rather than silently rendering an empty picker. + mockedGet.mockResolvedValue({ data: { name: 'Harper Versions', description: '' } }); + + const options = getHarperVersionsOptions('org_1'); + + await expect((options.queryFn as () => Promise)()).rejects.toThrow(); + }); +}); diff --git a/src/features/clusters/queries/getHarperVersionsQuery.ts b/src/features/clusters/queries/getHarperVersionsQuery.ts index ebf7146a0..2bf6bbb26 100644 --- a/src/features/clusters/queries/getHarperVersionsQuery.ts +++ b/src/features/clusters/queries/getHarperVersionsQuery.ts @@ -7,21 +7,65 @@ export interface HarperVersionsResponse { value: HarperVersion[]; } -interface HarperVersion { - name: 'stable' | 'next' | 'current'; +export interface HarperVersion { + /** + * Release tag for this version. `stable`, `next`, `beta`, and `alpha` come from the endpoint (which may + * also return tags we don't know about yet, hence the plain `string`); `current` is synthesized + * client-side for the version a cluster already runs. + */ + name: string; version: string; } -async function getHarperVersions() { +/** + * Known release tags, most-preferred first. When the endpoint returns the same version under more than one + * tag we keep the most-preferred; any unrecognized tag ranks below all of these. + */ +export const HARPER_VERSION_TAG_PREFERENCE = ['stable', 'next', 'beta', 'alpha'] as const; + +function tagRank(name: string): number { + const index = (HARPER_VERSION_TAG_PREFERENCE as readonly string[]).indexOf(name); + return index === -1 ? HARPER_VERSION_TAG_PREFERENCE.length : index; +} + +/** + * Collapse versions that share the same version string down to a single entry, keeping the one with the + * most-preferred tag (stable > next > beta > alpha > anything else). The endpoint can return, e.g., + * `5.1.21` tagged both `stable` and `next`; the picker should only offer `stable`. + */ +export function dedupeHarperVersionsByTag(versions: HarperVersion[]): HarperVersion[] { + const bestByVersion = new Map(); + for (const version of versions) { + const existing = bestByVersion.get(version.version); + if (!existing || tagRank(version.name) < tagRank(existing.name)) { + // Map preserves first-insertion order, so replacing the value keeps the version's original position. + bestByVersion.set(version.version, version); + } + } + return [...bestByVersion.values()]; +} + +async function getHarperVersions(organizationId: string) { // TODO: OpenAPI from CM is erroring, so this new endpoint isn't described. - const { data } = await apiClient.get(`/HarperVersions/` as any); - return data as HarperVersionsResponse; + // The list is org-scoped: enterprise orgs also get the versions currently deployed on their + // clusters (labeled with the cluster) merged in server-side. + const { data } = await apiClient.get(`/HarperVersions/` as any, { + params: { organizationId }, + }); + const response = data as HarperVersionsResponse; + return { + ...response, + value: dedupeHarperVersionsByTag(response.value), + } satisfies HarperVersionsResponse; } -export function getHarperVersionsOptions() { +export function getHarperVersionsOptions(organizationId: string) { return queryOptions({ - queryKey: ['HarperVersions'], - queryFn: getHarperVersions, + // Org-first, matching the sibling queries (getPlanTypesQuery, getRegionLocationsQuery, …) so + // org-scoped invalidations — `queryClient.invalidateQueries({ queryKey: [organizationId] })`, + // used after cluster ops that can change deployed versions — also refresh this list. + queryKey: [organizationId, 'HarperVersions'], + queryFn: () => getHarperVersions(organizationId), staleTime: 60_000, retry: false, }); diff --git a/src/features/clusters/upsert/index.tsx b/src/features/clusters/upsert/index.tsx index f9b6ae651..ca30713a3 100644 --- a/src/features/clusters/upsert/index.tsx +++ b/src/features/clusters/upsert/index.tsx @@ -73,12 +73,14 @@ export function UpsertCluster() { })); const { data: regionLocationsDedicated } = useQuery(getRegionLocationsOptions({ organizationId })); - const { data: newHarperVersions } = useQuery(getHarperVersionsOptions()); + const { data: newHarperVersions } = useQuery(getHarperVersionsOptions(organizationId)); const harperVersions = useMemo(() => { if (cluster) { const clusterVersions = cluster.instances?.map(i => i.version).filter(excludeFalsy); if (newHarperVersions && clusterVersions) { - const latestClusterVersion = clusterVersions.sort(compareVersions).pop(); + // Copy before sort — sort mutates in place, and we reuse the full set below. + const latestClusterVersion = [...clusterVersions].sort(compareVersions).pop(); + const clusterVersionSet = new Set(clusterVersions); return { ...newHarperVersions, value: [ @@ -87,13 +89,12 @@ export function UpsertCluster() { version: latestClusterVersion, } as const, ...(newHarperVersions?.value || []).filter(v => { - // Is our version unique from the latest cluster version? - return latestClusterVersion !== v.version - // Do we have a cluster version? - && (!latestClusterVersion - // Or if we do, have we updated to a higher version already? - // This can prevent upgrading to, say, "next" v5, and then downgrading to the "latest" v4. - || wasAReleasedBeforeB(latestClusterVersion, v.version)); + // Drop any version this cluster already runs: the current version is shown once as + // "current" above, and the backend also returns it (and any co-tenant instance's + // version, mid-upgrade) as a "deployed on " entry we don't want to duplicate. + return !clusterVersionSet.has(v.version) + // Only offer newer releases — no downgrades (e.g. don't drop from "next" v5 to "stable" v4). + && (!latestClusterVersion || wasAReleasedBeforeB(latestClusterVersion, v.version)); }), ].filter(excludeFalsy), } satisfies HarperVersionsResponse; diff --git a/src/features/instance/applications/components/TextEditorView/DegradedIntelliSenseBanner.test.tsx b/src/features/instance/applications/components/TextEditorView/DegradedIntelliSenseBanner.test.tsx new file mode 100644 index 000000000..75fa35727 --- /dev/null +++ b/src/features/instance/applications/components/TextEditorView/DegradedIntelliSenseBanner.test.tsx @@ -0,0 +1,34 @@ +/** + * @vitest-environment jsdom + */ +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { DegradedIntelliSenseBanner } from './DegradedIntelliSenseBanner'; + +afterEach(cleanup); + +describe('DegradedIntelliSenseBanner (HarperFast/studio#1504)', () => { + it('announces politely rather than as an alert, so it is non-blocking', () => { + render(); + expect(screen.getByRole('status').getAttribute('aria-live')).toBe('polite'); + }); + + it('explains the budget degradation and its cause', () => { + render(); + const text = screen.getByRole('status').textContent ?? ''; + expect(text).toMatch(/cannot find module/i); + expect(text).toMatch(/references more packages/i); + }); + + it('explains the oversized-file degradation', () => { + render(); + expect(screen.getByRole('status').textContent ?? '').toMatch(/plain text/i); + }); + + it('invokes onDismiss when the dismiss control is clicked', () => { + const onDismiss = vi.fn(); + render(); + fireEvent.click(screen.getByRole('button', { name: /dismiss notice/i })); + expect(onDismiss).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/features/instance/applications/components/TextEditorView/DegradedIntelliSenseBanner.tsx b/src/features/instance/applications/components/TextEditorView/DegradedIntelliSenseBanner.tsx new file mode 100644 index 000000000..a34f53192 --- /dev/null +++ b/src/features/instance/applications/components/TextEditorView/DegradedIntelliSenseBanner.tsx @@ -0,0 +1,56 @@ +/** + * A thin, dismissible notice shown above the code editor when the editor has + * silently degraded its language features to stay responsive + * (HarperFast/studio#1504). Two modes exist, both otherwise invisible: + * + * - `budget`: the session-wide automatic type-acquisition budget is spent, so + * further packages report a spurious "cannot find module" + * (see `typeAcquisition.ts` / `ExtraLibBudget`). + * - `oversized`: the open file is over `MAX_WORKER_MODEL_CHARS`, so it renders + * as plaintext with no highlighting or IntelliSense. + * + * Non-blocking and dismissible (the caller owns the dismissed state so it can + * reset per file/mode); announced via `role="status"` / `aria-live="polite"` + * rather than a purely visual cue. + */ +import { InfoIcon, TriangleAlertIcon, XIcon } from 'lucide-react'; + +export type IntelliSenseDegradation = 'budget' | 'oversized'; + +const MESSAGES: Record = { + budget: + 'Type information is limited to keep the editor responsive — this application references more packages than the editor can load, so some imports may show “cannot find module.”', + oversized: 'This file is large, so it’s shown as plain text without language features.', +}; + +export interface DegradedIntelliSenseBannerProps { + variant: IntelliSenseDegradation; + onDismiss: () => void; +} + +export function DegradedIntelliSenseBanner({ + variant, + onDismiss, +}: DegradedIntelliSenseBannerProps) { + const Icon = variant === 'oversized' ? InfoIcon : TriangleAlertIcon; + return ( +
+ + {MESSAGES[variant]} + +
+ ); +} diff --git a/src/features/instance/applications/components/TextEditorView/harper-language/typeAcquisition.ts b/src/features/instance/applications/components/TextEditorView/harper-language/typeAcquisition.ts index a62fef921..6dd97821e 100644 --- a/src/features/instance/applications/components/TextEditorView/harper-language/typeAcquisition.ts +++ b/src/features/instance/applications/components/TextEditorView/harper-language/typeAcquisition.ts @@ -16,6 +16,7 @@ * blocked CDN, unknown package — is swallowed so it can never break editing. */ import { typescript } from '@/lib/monaco/languageServices'; +import { ExtraLibBudget } from '@/lib/monaco/workerLimits'; /** node builtins are not on npm; their types come from `@types/node` (not acquired here). */ const NODE_BUILTINS = new Set([ @@ -107,6 +108,12 @@ function createImportScannerShim(): unknown { let runnerPromise: Promise<(source: string) => Promise> | undefined; const acquiredPaths = new Set(); +/** + * Bounds the chars handed to the worker as extra libs. Session-lifetime and + * never reclaimed — the backstop against unbounded cross-project accumulation + * that would otherwise OOM the language worker (HarperFast/studio#1499). + */ +const extraLibBudget = new ExtraLibBudget(); function getRunner(): Promise<(source: string) => Promise> { if (!runnerPromise) { @@ -120,7 +127,28 @@ function getRunner(): Promise<(source: string) => Promise> { if (acquiredPaths.has(path)) { return; } + // Mark it seen even when rejected, so a skipped lib isn't + // reconsidered on every subsequent acquisition pass. acquiredPaths.add(path); + // Extra libs are eagerly cloned to the language worker + // (setEagerModelSync) and never swept; bound the total so a + // large or deep dependency graph — or a long multi-project + // session — can't OOM the worker's clone buffer (#1499). + const { admitted, justExhausted, oversize } = extraLibBudget.admit(code.length); + if (!admitted) { + // Leave a breadcrumb: without one, a user whose IntelliSense + // quietly stops resolving a package has no signal why. + if (justExhausted) { + console.warn( + '[harper] type acquisition: extra-lib budget exhausted; further packages will report "cannot find module" in this tab', + ); + } else if (oversize) { + console.debug( + `[harper] type acquisition: skipped oversized declaration ${path} (${code.length} chars)`, + ); + } + return; + } const uri = `file://${path}`; typescriptDefaults.addExtraLib(code, uri); javascriptDefaults.addExtraLib(code, uri); @@ -132,6 +160,17 @@ function getRunner(): Promise<(source: string) => Promise> { return runnerPromise; } +/** + * Whether this session's automatic type-acquisition budget is exhausted. Once + * true it stays true — the budget is never reclaimed — so further packages are + * no longer acquired and their imports report a spurious "cannot find module" + * until the tab is reopened. Surfaced (rather than only `console.warn`'d) so the + * editor can show a user-facing degradation notice (HarperFast/studio#1504). + */ +export function isTypeAcquisitionBudgetSpent(): boolean { + return extraLibBudget.isSpent; +} + /** * Acquire npm `@types` for every package imported across the given source files. * Best-effort and idempotent; safe to call on each project load. @@ -140,6 +179,13 @@ export async function acquireApplicationTypes(sources: string[]): Promise if (sources.length === 0) { return; } + // Once the aggregate budget is spent it is never reclaimed, so the + // acquisition engine would walk the CDN and parse declarations only for + // `receivedFile` to discard every one. Skip the whole pass — including its + // network requests — rather than pay for work that can't land. + if (extraLibBudget.isSpent) { + return; + } try { const run = await getRunner(); // One pass: the shim scans the combined source for every import at once. diff --git a/src/features/instance/applications/components/TextEditorView/index.tsx b/src/features/instance/applications/components/TextEditorView/index.tsx index 9d00c0496..24fac4593 100644 --- a/src/features/instance/applications/components/TextEditorView/index.tsx +++ b/src/features/instance/applications/components/TextEditorView/index.tsx @@ -14,6 +14,7 @@ import { MAX_WORKER_MODEL_CHARS } from '@/lib/monaco/workerLimits'; import { parseFileExtension } from '@/lib/string/parseFileExtension'; import type { EditorProps, OnMount } from '@monaco-editor/react'; import { useCallback, useEffect, useState } from 'react'; +import { DegradedIntelliSenseBanner, type IntelliSenseDegradation } from './DegradedIntelliSenseBanner'; import { configureHarperLanguageSupport } from './harper-language'; import './monaco-customizations.css'; @@ -64,7 +65,7 @@ export function TextEditorView() { const instanceParams = useInstanceClientIdParams(); const { openedEntryContents, openedEntry, restrictPackageModification, isSavingFile, saveFile, rootEntries } = useEditorView(); - useApplicationTypeIntelligence(openedEntry, rootEntries); + const { typeAcquisitionBudgetExhausted } = useApplicationTypeIntelligence(openedEntry, rootEntries); const { content: updatedFileContent, setContent, @@ -79,6 +80,11 @@ export function TextEditorView() { const [mounted, setMounted] = useState | null>(null); useCodeNavigation(mounted?.[0]); + // Which degradation notice the user has dismissed, keyed by `:` + // so it re-shows for a different file or a different degradation, but stays + // dismissed while the same file/mode is open. + const [dismissedBanner, setDismissedBanner] = useState(null); + const extension = parseFileExtension(openedEntry?.path); const fileContent = updatedFileContent ?? openedEntryContents; // A huge open file would feed its full text to the language worker the same @@ -165,23 +171,52 @@ export function TextEditorView() { const readOnly = isSavingFile || !!openedEntry.package || !canManageBrowseInstance; + // Surface either silent editor degradation as a dismissible notice. Oversized + // wins: the file is already plaintext, so the "cannot find module" wording + // wouldn't apply. The budget notice only fits a real (editable) script file, + // where a missing package's import would actually error. + const isScriptLanguage = language === 'javascript' || language === 'typescript'; + const degradation: IntelliSenseDegradation | null = oversized + ? 'oversized' + : (typeAcquisitionBudgetExhausted && isScriptLanguage && !openedEntry.package) + ? 'budget' + : null; + const bannerKey = degradation ? `${openedEntry.path}:${degradation}` : null; + const showBanner = bannerKey !== null && dismissedBanner !== bannerKey; + return ( - + // `h-full` (not `min-h-full`) gives this column a *definite* height, so the + // editor's `height:100%` resolves; the `flex-1 min-h-0` wrapper then hands + // Monaco a concrete, banner-adjusted height it can lay out against. +
+ {showBanner && degradation && ( + setDismissedBanner(bannerKey)} + /> + )} +
+ +
+
); } diff --git a/src/features/instance/applications/hooks/useApplicationTypeIntelligence.ts b/src/features/instance/applications/hooks/useApplicationTypeIntelligence.ts index c4f89432d..29e68b3c9 100644 --- a/src/features/instance/applications/hooks/useApplicationTypeIntelligence.ts +++ b/src/features/instance/applications/hooks/useApplicationTypeIntelligence.ts @@ -32,7 +32,10 @@ * worker (HarperFast/studio#1407). */ import { useInstanceClientIdParams } from '@/config/useInstanceClient'; -import { acquireApplicationTypes } from '@/features/instance/applications/components/TextEditorView/harper-language/typeAcquisition'; +import { + acquireApplicationTypes, + isTypeAcquisitionBudgetSpent, +} from '@/features/instance/applications/components/TextEditorView/harper-language/typeAcquisition'; import { DirectoryEntry } from '@/features/instance/applications/context/directoryEntry'; import { FileEntry } from '@/features/instance/applications/context/fileEntry'; import { isDirectory } from '@/features/instance/applications/context/isDirectory'; @@ -47,7 +50,16 @@ import { typescript } from '@/lib/monaco/languageServices'; import { MAX_WORKER_MODEL_CHARS } from '@/lib/monaco/workerLimits'; import { useQueryClient } from '@tanstack/react-query'; import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js'; -import { useEffect, useMemo, useRef } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; + +/** Degradation status the editor surfaces to the user (HarperFast/studio#1504). */ +export interface ApplicationTypeIntelligenceStatus { + /** + * The session-wide `@types` budget is spent, so further packages are no longer + * acquired and their imports report a spurious "cannot find module". + */ + typeAcquisitionBudgetExhausted: boolean; +} /** Source files worth registering as models (everything the worker can parse). */ const LOADABLE_SOURCE = /\.(tsx?|jsx?|mjs|cjs|mts|cts|json)$/i; @@ -173,10 +185,18 @@ function projectUriPrefix(project: string | undefined): string | undefined { return project === undefined ? undefined : monaco.Uri.parse(`file:///${project}/`).toString(); } -export function useApplicationTypeIntelligence(openedEntry: AnyEntry | undefined, rootEntries: AnyEntry[]): void { +export function useApplicationTypeIntelligence( + openedEntry: AnyEntry | undefined, + rootEntries: AnyEntry[], +): ApplicationTypeIntelligenceStatus { const instanceParams = useInstanceClientIdParams(); const queryClient = useQueryClient(); + // The budget is module-level and monotonic, so seed from it: a file opened + // after a prior project already exhausted the budget shows the notice at once, + // without waiting for another (short-circuited) acquisition pass. + const [typeAcquisitionBudgetExhausted, setTypeAcquisitionBudgetExhausted] = useState(isTypeAcquisitionBudgetSpent); + // Only intelligence-load the user's own applications. Installed packages can // be large dependency trees and are read-only. const project = openedEntry && !openedEntry.package ? openedEntry.project : undefined; @@ -309,7 +329,12 @@ export function useApplicationTypeIntelligence(openedEntry: AnyEntry | undefined && file.content.length <= MAX_WORKER_MODEL_CHARS ) .map(file => file.content); - void acquireApplicationTypes(scriptSources); + await acquireApplicationTypes(scriptSources); + // The pass may have just sealed the budget; surface the current state + // so the editor can show (or keep) the degradation notice. + if (!cancelled) { + setTypeAcquisitionBudgetExhausted(isTypeAcquisitionBudgetSpent()); + } })(); } @@ -327,4 +352,6 @@ export function useApplicationTypeIntelligence(openedEntry: AnyEntry | undefined }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [project, context, filesKey]); + + return { typeAcquisitionBudgetExhausted }; } diff --git a/src/features/instance/config/secrets/SecretGrantsEditor.tsx b/src/features/instance/config/secrets/SecretGrantsEditor.tsx index 48f011273..4490e8274 100644 --- a/src/features/instance/config/secrets/SecretGrantsEditor.tsx +++ b/src/features/instance/config/secrets/SecretGrantsEditor.tsx @@ -2,24 +2,27 @@ * Grants editor for one secret, shown inside the edit dialog. A secret is only materialized into * the environment of components listed in its grants, so this is where a stored secret actually * gets scoped to applications. Grant/revoke apply immediately (they are their own operations, not - * part of the value form). + * part of the value form). The target application is chosen through the shared + * ComponentGrantCombobox, seeded with the components the cluster reports. */ import { Badge } from '@/components/ui/badge'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; import { useInstanceClientIdParams } from '@/config/useInstanceClient'; +import { ComponentGrantCombobox } from '@/features/instance/secrets/ComponentGrantCombobox'; import { useGrantSecret, useRevokeSecret } from '@/integrations/api/instance/secrets/secrets'; -import { PlusIcon, XIcon } from 'lucide-react'; -import { KeyboardEvent, useCallback, useState } from 'react'; +import { XIcon } from 'lucide-react'; +import { useCallback, useState } from 'react'; import { toast } from 'sonner'; export function SecretGrantsEditor({ name, initialGrants, + components, onChanged, }: { name: string; initialGrants: string[]; + /** Component names the cluster reports, offered as picker suggestions (empty → free text). */ + components: string[]; /** Called after a successful grant/revoke so the list view can refresh its metadata. */ onChanged?: () => void; }) { @@ -30,22 +33,18 @@ export function SecretGrantsEditor({ // The mutation responses carry the resulting grants, so the chips track server truth. const [grants, setGrants] = useState(initialGrants); - const [component, setComponent] = useState(''); - const onGrantClick = useCallback(async () => { - const target = component.trim(); - if (!target) { - return; - } + const onGrant = useCallback(async (target: string) => { try { const response = await grantSecret({ ...instanceParams, name, component: target }); setGrants(response.grants); - setComponent(''); onChanged?.(); } catch (error) { toast.error(String(error)); + // Rethrow so the combobox keeps the typed text for a retry instead of clearing it. + throw error; } - }, [component, grantSecret, instanceParams, name, onChanged]); + }, [grantSecret, instanceParams, name, onChanged]); const onRevokeClick = useCallback(async (target: string) => { try { @@ -57,14 +56,6 @@ export function SecretGrantsEditor({ } }, [revokeSecret, instanceParams, name, onChanged]); - // This editor lives inside the value form — Enter must grant, not submit a value replacement. - const onComponentKeyDown = useCallback((event: KeyboardEvent) => { - if (event.key === 'Enter') { - event.preventDefault(); - void onGrantClick(); - } - }, [onGrantClick]); - return (
Granted applications @@ -91,26 +82,13 @@ export function SecretGrantsEditor({ ))}
)} -
- setComponent(event.target.value)} - onKeyDown={onComponentKeyDown} - disabled={busy} - /> - -
+
); } diff --git a/src/features/instance/config/secrets/grantableComponents.test.ts b/src/features/instance/config/secrets/grantableComponents.test.ts new file mode 100644 index 000000000..77bb69ec2 --- /dev/null +++ b/src/features/instance/config/secrets/grantableComponents.test.ts @@ -0,0 +1,44 @@ +import { APIDirectoryEntry } from '@/integrations/api/instance/applications/getComponents'; +import { describe, expect, it } from 'vitest'; +import { grantableComponentNames } from './grantableComponents'; + +function tree(entries: APIDirectoryEntry['entries']): APIDirectoryEntry { + return { name: 'root', entries }; +} + +describe('grantableComponentNames', () => { + it('returns [] for an undefined tree', () => { + expect(grantableComponentNames(undefined)).toEqual([]); + }); + + it('returns the top-level directory (component) names, sorted', () => { + const result = grantableComponentNames( + tree([ + { name: 'web-app', entries: [] }, + { name: 'auth-service', entries: [{ name: 'index.js' }] }, + { name: 'billing', entries: [], package: '@acme/billing' }, + ]), + ); + expect(result).toEqual(['auth-service', 'billing', 'web-app']); + }); + + it('drops top-level files that are not components (no entries)', () => { + const result = grantableComponentNames( + tree([ + { name: 'my-app', entries: [] }, + { name: 'harperdb-config.yaml' } as APIDirectoryEntry, + ]), + ); + expect(result).toEqual(['my-app']); + }); + + it('de-duplicates repeated component names', () => { + const result = grantableComponentNames( + tree([ + { name: 'dup', entries: [] }, + { name: 'dup', entries: [] }, + ]), + ); + expect(result).toEqual(['dup']); + }); +}); diff --git a/src/features/instance/config/secrets/grantableComponents.ts b/src/features/instance/config/secrets/grantableComponents.ts new file mode 100644 index 000000000..d2e368ea4 --- /dev/null +++ b/src/features/instance/config/secrets/grantableComponents.ts @@ -0,0 +1,22 @@ +import { isDirectory } from '@/features/instance/applications/context/isDirectory'; +import { APIDirectoryEntry } from '@/integrations/api/instance/applications/getComponents'; + +/** + * The component names a secret can be scoped to, derived from a `get_components` tree. A grant + * targets a component by name, and every deployed component (local application or installed + * package) is a top-level directory entry in the tree — so those directory names are exactly the + * grantable targets. Stray top-level files (if any) aren't components and are dropped. Sorted for + * a stable picker order; the tree can list the same name twice across reads, so it's de-duped. + */ +export function grantableComponentNames(tree: APIDirectoryEntry | undefined): string[] { + if (!tree?.entries) { + return []; + } + const names = new Set(); + for (const entry of tree.entries) { + if (isDirectory(entry)) { + names.add(entry.name); + } + } + return [...names].sort((a, b) => a.localeCompare(b)); +} diff --git a/src/features/instance/config/secrets/index.tsx b/src/features/instance/config/secrets/index.tsx index f4d1f8fc3..fdb7f2637 100644 --- a/src/features/instance/config/secrets/index.tsx +++ b/src/features/instance/config/secrets/index.tsx @@ -1,8 +1,11 @@ import { useInstanceClientIdParams } from '@/config/useInstanceClient'; import { getClusterInfoQueryOptions } from '@/features/cluster/queries/getClusterInfoQuery'; +import { grantableComponentNames } from '@/features/instance/config/secrets/grantableComponents'; import { SecretGrantsEditor } from '@/features/instance/config/secrets/SecretGrantsEditor'; import { SecretRow, SecretsManager } from '@/features/instance/secrets/SecretsManager'; +import { useInstanceManagePermission } from '@/hooks/usePermissions'; import { clusterIsSelfManaged } from '@/integrations/api/clusterIsSelfManaged'; +import { getComponentsQueryOptions } from '@/integrations/api/instance/applications/getComponents'; import { listSecretsQueryOptions, SecretMetadata, @@ -26,6 +29,13 @@ export function ConfigSecretsIndex() { const instanceParams = useInstanceClientIdParams(); const { data, refetch, isFetching } = useQuery(listSecretsQueryOptions(instanceParams)); + // The components the cluster reports feed the grants picker so scoping a secret is a pick, not a + // retype. Gated on manage permission (same as the overview page); it degrades to a free-text + // field when unavailable (older Harper, no permission), so a failure here never blocks scoping. + const canManage = useInstanceManagePermission(); + const { data: componentsTree } = useQuery(getComponentsQueryOptions({ ...instanceParams, enabled: canManage })); + const grantableComponents = useMemo(() => grantableComponentNames(componentsTree), [componentsTree]); + // Fabric-managed clusters get their public key from central-manager (the custodian — it mints // the keypair on first use, central-manager#409); self-hosted/local nodes serve their own. const clusterQuery = useQuery(getClusterInfoQueryOptions(clusterId, false)); @@ -123,6 +133,17 @@ export function ConfigSecretsIndex() { onSelectName={onSelectName} nameHeader="Secret" delivery={true} + docsLink={ + + Secrets Docs + + } + grantableComponents={grantableComponents} addDescription="The value is encrypted in your browser against the cluster's secrets key — plaintext never reaches the API, the operation log, or disk. It can be replaced or deleted, but never read back." editDescription="The current value can't be shown — it's stored encrypted. Enter a new value to replace it, adjust how applications read it, or delete the secret." valueDescription="Encrypted client-side before it leaves this page." @@ -136,6 +157,7 @@ export function ConfigSecretsIndex() { key={secret.name} name={secret.name} initialGrants={secret.grants} + components={grantableComponents} onChanged={() => void refetch()} /> ) diff --git a/src/features/instance/databases/components/DatabaseTableView.tsx b/src/features/instance/databases/components/DatabaseTableView.tsx index f9fa9e5d4..7e718c835 100644 --- a/src/features/instance/databases/components/DatabaseTableView.tsx +++ b/src/features/instance/databases/components/DatabaseTableView.tsx @@ -36,7 +36,7 @@ import { onClickStopPropagation } from '@/lib/onClickStopPropagation'; import { zodResolver } from '@hookform/resolvers/zod'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { Link, useParams, useSearch } from '@tanstack/react-router'; -import { Row, VisibilityState } from '@tanstack/react-table'; +import { ColumnSizingState, Row, VisibilityState } from '@tanstack/react-table'; import { BrushCleaningIcon, CircleCheckBigIcon, @@ -117,6 +117,11 @@ export function DatabaseTableView({ instanceDatabaseMap, databaseName, tableName ); const [selectedIds, setSelectedIds] = useEffectedState(null, allParams); const [isEditModalOpen, setIsEditModalOpen] = useState(false); + // The row the user clicked, straight from the list query. We keep it so the edit modal can fall + // back to showing it read-only when the record can't be fetched by its declared primary key -- + // either the row has no value for that key, or it has one but nothing is stored under it (both + // happen when a table's primary key was changed after rows existed; see #1199). + const [clickedRow, setClickedRow] = useEffectedState | null>(null, allParams); const isLastTableInDatabase = useMemo(() => { const tableNames = databaseName ? Object.keys(instanceDatabaseMap?.[databaseName] || []).sort() : []; @@ -288,13 +293,28 @@ export function DatabaseTableView({ instanceDatabaseMap, databaseName, tableName const isFetching = tableDataFetching || tableConditionsDataFetching; // One by id - const { data: searchByIdData } = useQuery(getSearchByIdOptions({ - ...instanceParams, - enabled: isEditModalOpen, - databaseName: databaseName, - tableName: tableName, - ids: selectedIds, - })); + const { data: searchByIdData, isFetching: isSearchByIdFetching, isError: isSearchByIdError } = useQuery( + getSearchByIdOptions({ + ...instanceParams, + enabled: isEditModalOpen, + databaseName: databaseName, + tableName: tableName, + ids: selectedIds, + }), + ); + + // The clicked row can't be shown from the server in two cases, both stemming from a table whose + // declared primary key doesn't match how its rows are actually stored (see #1199): + // - missingPrimaryKey: the row has no value for the declared primary key at all. + // - recordUnavailable: it has a value, we looked it up, but nothing is stored under that key + // (Harper kept keying rows by the original attribute), so the fetch comes back empty. + // In both cases we show the row the list already gave us, read-only, with an explanation. + const missingPrimaryKey = isEditModalOpen && !!clickedRow && (primaryKey ? clickedRow[primaryKey] == null : true); + const fetchedRecord = searchByIdData?.data; + const recordUnavailable = !missingPrimaryKey + && !!selectedIds?.length + && !isSearchByIdFetching + && (isSearchByIdError || (Array.isArray(fetchedRecord) && fetchedRecord.length === 0)); const { mutate: updateTableRecords, isPending: isUpdateTableRecordsPending } = useUpdateTableRecords(); const { mutate: deleteTableRecords, isPending: isDeleteTableRecordsPending } = useDeleteTableRecords(); @@ -376,8 +396,12 @@ export function DatabaseTableView({ instanceDatabaseMap, databaseName, tableName }, [deleteTableRecords, instanceParams, databaseName, tableName, refreshTable]); const onRowClick = (rowData: Row>) => { - setSelectedIds([rowData.original[primaryKey]]); - setIsEditModalOpen(!isEditModalOpen); + const primaryKeyValue = primaryKey ? rowData.original[primaryKey] : undefined; + setClickedRow(rowData.original); + // With no usable primary key there's nothing to look up, so skip the (doomed) fetch; otherwise + // fetch the fresh record. Either way the modal can fall back to `clickedRow`. + setSelectedIds(primaryKeyValue == null ? null : [primaryKeyValue]); + setIsEditModalOpen(true); }; const onColumnClick = (accessorKey: string, isAscending: boolean) => { setSort({ @@ -407,6 +431,11 @@ export function DatabaseTableView({ instanceDatabaseMap, databaseName, tableName ...storedColumnVisibility, }), [relationshipInfoMap, storedColumnVisibility]); + const [columnSizing, setColumnSizing] = useSessionStorage( + `ColumnSizing/${databaseName}/${tableName}` as 'ColumnSizing/{database}/{table}', + {} satisfies ColumnSizingState, + ); + return ( <>
@@ -557,6 +586,8 @@ export function DatabaseTableView({ instanceDatabaseMap, databaseName, tableName filtersToggled={filtersToggled} columns={dataTableColumns} columnVisibility={columnVisibility} + columnSizing={columnSizing} + setColumnSizing={setColumnSizing} onRowClick={onRowClick} onColumnClick={onColumnClick} totalPages={totalPages} @@ -579,8 +610,12 @@ export function DatabaseTableView({ instanceDatabaseMap, databaseName, tableName setIsModalOpen={setIsEditModalOpen} isModalOpen={isEditModalOpen} primaryKey={primaryKey} + missingPrimaryKey={missingPrimaryKey} + recordUnavailable={recordUnavailable} syntheticAttributes={syntheticAttributes} - data={searchByIdData?.data} + data={missingPrimaryKey || recordUnavailable + ? (clickedRow ? [clickedRow] : undefined) + : searchByIdData?.data} onSaveChanges={onRecordUpdate} onDeleteRecord={onDeleteRecord} isUpdateTableRecordsPending={isUpdateTableRecordsPending} diff --git a/src/features/instance/databases/components/TableView.test.tsx b/src/features/instance/databases/components/TableView.test.tsx index 8d3714043..5be3e5682 100644 --- a/src/features/instance/databases/components/TableView.test.tsx +++ b/src/features/instance/databases/components/TableView.test.tsx @@ -1,8 +1,9 @@ /** * @vitest-environment jsdom */ -import { ColumnDef, VisibilityState } from '@tanstack/react-table'; +import { ColumnDef, ColumnSizingState, VisibilityState } from '@tanstack/react-table'; import { cleanup, render, screen } from '@testing-library/react'; +import { useState } from 'react'; import { useForm } from 'react-hook-form'; import { afterEach, beforeAll, describe, expect, it } from 'vitest'; import { z } from 'zod'; @@ -31,12 +32,15 @@ const data: Record[] = [{ id: 'abc-123', type: 'demo' }]; function Harness({ columnVisibility }: { columnVisibility: VisibilityState }) { const columnFiltersForm = useForm>({ defaultValues: {} }); + const [columnSizing, setColumnSizing] = useState({}); return ( , unknown> applyFilters={() => undefined} columnFiltersForm={columnFiltersForm} columns={columns} columnVisibility={columnVisibility} + columnSizing={columnSizing} + setColumnSizing={setColumnSizing} data={data} pageIndex={0} pageSize={20} @@ -66,3 +70,13 @@ describe('TableView column visibility', () => { expect(screen.getByText('demo')).toBeTruthy(); }); }); + +describe('TableView column resizing', () => { + it('renders a resize handle for each column header', () => { + // Regression: the handle used to be gated on columnDef.enableResizing (never set), so it + // never rendered. It is now gated on getCanResize(), driven by the table-level flag. + const { container } = render(); + const handles = container.querySelectorAll('svg.lucide-grip-vertical'); + expect(handles.length).toBe(columns.length); + }); +}); diff --git a/src/features/instance/databases/components/TableView.tsx b/src/features/instance/databases/components/TableView.tsx index 330884d9b..5e0e71dd3 100644 --- a/src/features/instance/databases/components/TableView.tsx +++ b/src/features/instance/databases/components/TableView.tsx @@ -1,14 +1,24 @@ 'use client'; import { LoadingSubtle } from '@/components/LoadingSubtle'; -import { Table, TableBody, TableCell, TableHeader, TableHeadSortable, TableRow } from '@/components/ui/table'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableHeadSortable, + TableRow, +} from '@/components/ui/table'; import { cn } from '@/lib/cn'; import { Cell, ColumnDef, + ColumnSizingState, flexRender, getCoreRowModel, getPaginationRowModel, + OnChangeFn, Row, useReactTable, VisibilityState, @@ -24,6 +34,8 @@ interface BrowseDataTableProps { columnFiltersForm: UseFormReturn>; columns: ColumnDef[]; columnVisibility: VisibilityState; + columnSizing: ColumnSizingState; + setColumnSizing: OnChangeFn; data?: TData[]; isFetching?: boolean; onColumnClick?: (accessorKey: string, isDescending: boolean) => void; @@ -48,6 +60,8 @@ export function TableView({ columnFiltersForm, columns, columnVisibility, + columnSizing, + setColumnSizing, data, isFetching, onColumnClick, @@ -72,63 +86,106 @@ export function TableView({ manualPagination: true, enableColumnResizing: true, columnResizeMode: 'onEnd', + onColumnSizingChange: setColumnSizing, pageCount: totalPages, defaultColumn: { - minSize: 1, + // Wide enough that the header title + sort/resize controls never collide when shrinking. + minSize: 80, }, state: { columnVisibility, + columnSizing, }, rowCount: totalRecords, getCoreRowModel: getCoreRowModel(), getPaginationRowModel: getPaginationRowModel(), }); + // During a column resize, preview where the new right edge will land with a full-height guide line. + // columnResizeMode is 'onEnd', so the column width doesn't change until release -- the guide is the + // live feedback. Its x is the sum of column widths up to the resizing one, plus the (clamped) drag delta. + const columnSizingInfo = table.getState().columnSizingInfo; + const resizingColumnId = columnSizingInfo.isResizingColumn; + let resizeGuideLeft: number | null = null; + if (resizingColumnId) { + const minSize = table.options.defaultColumn?.minSize ?? 20; + const startSize = table.getColumn(resizingColumnId)?.getSize() ?? 0; + let edge = 0; + for (const leafColumn of table.getVisibleLeafColumns()) { + edge += leafColumn.getSize(); + if (leafColumn.id === resizingColumnId) { + break; + } + } + // Clamp to match the handle's own preview: the column can't shrink below minSize. + resizeGuideLeft = edge + Math.max(columnSizingInfo.deltaOffset ?? 0, minSize - startSize); + } + return ( <> - - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - +
+ + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + {/* Filler column: takes the remaining width so real columns stay tight. */} + - ))} - - ))} - - {filtersToggled && ( - + ))} + + {filtersToggled && ( + + )} + + {table.getRowModel().rows?.length + ? (table.getRowModel().rows.map((row) => ( + + ))) + : ( + + + {isFetching || data === undefined + ? + : No results.} + + + )} + +
+ {resizeGuideLeft !== null && ( +
)} - - {table.getRowModel().rows?.length - ? (table.getRowModel().rows.map((row) => ( - - ))) - : ( - - - {isFetching || data === undefined - ? - : No results.} - - - )} - - +
( className={cn('hover:bg-muted/10 data-[state=selected]:bg-muted', onRowClick && 'cursor-pointer')} > {cells} + {/* Filler cell matching the header's filler column. */} + ); } function TableBodyRowCell({ cell }: { cell: Cell }) { + const size = cell.column.getSize(); return ( {/* Object/array stringification lives in the column defs (renderPlainCell / RelationshipCell). */} {flexRender(cell.column.columnDef.cell, cell.getContext())} diff --git a/src/features/instance/databases/functions/formatBrowseDataTableHeader.ts b/src/features/instance/databases/functions/formatBrowseDataTableHeader.ts index 6bbd2d6c6..6a35ca0b4 100644 --- a/src/features/instance/databases/functions/formatBrowseDataTableHeader.ts +++ b/src/features/instance/databases/functions/formatBrowseDataTableHeader.ts @@ -52,7 +52,6 @@ export function formatBrowseDataTableHeader( // Relationship columns are filterable via sub-properties (`.name value`), which the // server executes as a join against the related table. enableColumnFilter: Boolean(is_primary_key || indexed || relationshipInfo), - // enableResizing: true, size: sizeByAttributeType(type), cell: relationshipInfo ? relationshipCell(relationshipInfo) : renderPlainCell, meta: relationshipInfo ? { relationshipInfo } : undefined, @@ -108,21 +107,24 @@ function renderPlainCell(context: CellContext, unknown>) return String(value); } +// Default column widths (px). These are just starting points -- every column is resizable, and a +// user's adjustments are persisted per table, so these only need to be reasonable defaults. function sizeByAttributeType(type: InstanceAttribute['type']) { switch (type) { case 'Id': case 'ID': - return 1; + return 220; case 'Boolean': - return 1; + return 90; case 'Int': case 'Long': case 'Float': case 'BigInt': - return 1; + return 120; case 'Date': + return 190; case 'String': default: - return Math.round(window.innerWidth * 0.1); + return 200; } } diff --git a/src/features/instance/databases/hooks/useResizableDatabasesSidebar.test.ts b/src/features/instance/databases/hooks/useResizableDatabasesSidebar.test.ts new file mode 100644 index 000000000..820c28b3e --- /dev/null +++ b/src/features/instance/databases/hooks/useResizableDatabasesSidebar.test.ts @@ -0,0 +1,89 @@ +/** @vitest-environment jsdom */ +import { act, renderHook } from '@testing-library/react'; +import type { KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent } from 'react'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + clampWidth, + DEFAULT_DATABASES_SIDEBAR_WIDTH, + maxSidebarWidth, + MIN_SIDEBAR_WIDTH, + useResizableDatabasesSidebar, +} from './useResizableDatabasesSidebar'; + +function setViewportWidth(width: number) { + (window as unknown as { innerWidth: number }).innerWidth = width; +} + +afterEach(() => { + localStorage.clear(); + setViewportWidth(1024); // jsdom default +}); + +describe('clampWidth', () => { + it('raises a too-small width up to the minimum', () => { + expect(clampWidth(50, 1024)).toBe(MIN_SIDEBAR_WIDTH); + }); + it('caps a too-large width at half the viewport', () => { + expect(clampWidth(9999, 1000)).toBe(500); + }); + it('passes a mid-range width through, rounded', () => { + expect(clampWidth(321.6, 1024)).toBe(322); + }); + it('never lets the max fall below the minimum on a tiny viewport', () => { + expect(maxSidebarWidth(100)).toBe(MIN_SIDEBAR_WIDTH); + expect(clampWidth(10, 100)).toBe(MIN_SIDEBAR_WIDTH); + }); +}); + +describe('useResizableDatabasesSidebar', () => { + it('sizes the rendered width from the drag delta and persists it on release', () => { + const { result } = renderHook(() => useResizableDatabasesSidebar()); + expect(result.current.width).toBe(DEFAULT_DATABASES_SIDEBAR_WIDTH); + + act(() => { + result.current.startResizing({ clientX: 300, preventDefault() {} } as unknown as ReactMouseEvent); + }); + act(() => { + window.dispatchEvent(new MouseEvent('mousemove', { clientX: 400 })); + }); + expect(result.current.width).toBe(DEFAULT_DATABASES_SIDEBAR_WIDTH + 100); + + act(() => { + window.dispatchEvent(new MouseEvent('mouseup')); + }); + // Persisted preference survives a remount. + const { result: remounted } = renderHook(() => useResizableDatabasesSidebar()); + expect(remounted.current.width).toBe(DEFAULT_DATABASES_SIDEBAR_WIDTH + 100); + }); + + it('re-clamps the rendered width on viewport resize without clobbering the saved preference', () => { + const { result } = renderHook(() => useResizableDatabasesSidebar()); + expect(result.current.width).toBe(320); + + // Shrink the window so 320 no longer fits (half of 500 is 250). + act(() => { + setViewportWidth(500); + window.dispatchEvent(new Event('resize')); + }); + expect(result.current.width).toBe(250); + + // Grow it back: the preferred 320 is restored (proving resize never overwrote it). + act(() => { + setViewportWidth(1024); + window.dispatchEvent(new Event('resize')); + }); + expect(result.current.width).toBe(320); + }); + + it('nudges the width in 10px steps with Arrow keys', () => { + const { result } = renderHook(() => useResizableDatabasesSidebar()); + act(() => { + result.current.handleKeyDown({ key: 'ArrowRight', preventDefault() {} } as unknown as ReactKeyboardEvent); + }); + expect(result.current.width).toBe(330); + act(() => { + result.current.handleKeyDown({ key: 'ArrowLeft', preventDefault() {} } as unknown as ReactKeyboardEvent); + }); + expect(result.current.width).toBe(320); + }); +}); diff --git a/src/features/instance/databases/hooks/useResizableDatabasesSidebar.ts b/src/features/instance/databases/hooks/useResizableDatabasesSidebar.ts new file mode 100644 index 000000000..2934101db --- /dev/null +++ b/src/features/instance/databases/hooks/useResizableDatabasesSidebar.ts @@ -0,0 +1,111 @@ +import { useLocalStorage } from '@/hooks/useLocalStorage'; +import { LocalStorageKeys } from '@/lib/storage/localStorageKeys'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +/** Roughly the old fixed `lg:col-span-3` feel, so first load is close to unchanged. */ +export const DEFAULT_DATABASES_SIDEBAR_WIDTH = 320; +/** Narrowest the sidebar may be dragged — below this the tree labels are unusable. */ +export const MIN_SIDEBAR_WIDTH = 200; +/** Width nudge per Arrow key press when the separator is focused. */ +const KEYBOARD_STEP = 10; + +/** Widest the sidebar may be — half the viewport, so the content pane keeps the larger share. */ +export function maxSidebarWidth(viewportWidth: number): number { + return Math.max(MIN_SIDEBAR_WIDTH, Math.floor(viewportWidth / 2)); +} + +/** Clamp to a usable range: never below the minimum, never past half the viewport. */ +export function clampWidth(width: number, viewportWidth: number): number { + return Math.round(Math.min(Math.max(width, MIN_SIDEBAR_WIDTH), maxSidebarWidth(viewportWidth))); +} + +/** + * Drag-to-resize state for the databases sidebar. Mirrors the applications file tray + * (see `ApplicationsSidebar/SidebarResizeHandle.tsx`): mousedown arms the gesture, then window + * `mousemove`/`mouseup` listeners track it so the cursor can leave the handle. Unlike that tray + * (anchored at the viewport's left edge, so its width is just the cursor X), this sidebar sits + * in-flow with page padding to its left, so we track the delta from where the drag started. + * + * Width updates once per render during the drag and commits to local storage once on release — + * writing localStorage on every `mousemove` would stutter. + */ +export function useResizableDatabasesSidebar() { + const [persistedWidth, setPersistedWidth] = useLocalStorage( + LocalStorageKeys.DatabasesSidebarWidth, + DEFAULT_DATABASES_SIDEBAR_WIDTH, + ); + const [isResizing, setIsResizing] = useState(false); + const [width, setWidth] = useState(() => clampWidth(persistedWidth, window.innerWidth)); + const widthRef = useRef(width); + widthRef.current = width; + // The cursor X and sidebar width captured at mousedown, so mousemove can size from the delta. + const gestureRef = useRef<{ startX: number; startWidth: number } | null>(null); + + // When not actively dragging, keep the rendered width in sync with the persisted value + // (e.g. after a viewport-resize clamp below). + useEffect(() => { + if (!isResizing) { + setWidth(clampWidth(persistedWidth, window.innerWidth)); + } + }, [persistedWidth, isResizing]); + + // On viewport resize, re-clamp only the *rendered* width — never the persisted preference. Writing + // localStorage on every resize event stutters, and clamping the stored value down would permanently + // lose the user's preferred width once they shrink then re-widen the window. + useEffect(() => { + const onResize = () => { + if (!isResizing) { + setWidth(clampWidth(persistedWidth, window.innerWidth)); + } + }; + window.addEventListener('resize', onResize); + return () => window.removeEventListener('resize', onResize); + }, [persistedWidth, isResizing]); + + const startResizing = useCallback((event: React.MouseEvent) => { + event.preventDefault(); + gestureRef.current = { startX: event.clientX, startWidth: widthRef.current }; + setIsResizing(true); + }, []); + + useEffect(() => { + if (!isResizing) { + return; + } + const onMouseMove = (event: MouseEvent) => { + const gesture = gestureRef.current; + if (!gesture) { + return; + } + setWidth(clampWidth(gesture.startWidth + (event.clientX - gesture.startX), window.innerWidth)); + }; + const onMouseUp = () => { + setIsResizing(false); + // Persist the final width once, instead of on every mousemove. + setPersistedWidth(widthRef.current); + }; + // Suppress text selection across the page while dragging. + const previousUserSelect = document.body.style.userSelect; + document.body.style.userSelect = 'none'; + window.addEventListener('mousemove', onMouseMove); + window.addEventListener('mouseup', onMouseUp); + return () => { + document.body.style.userSelect = previousUserSelect; + window.removeEventListener('mousemove', onMouseMove); + window.removeEventListener('mouseup', onMouseUp); + }; + }, [isResizing, setPersistedWidth]); + + // Keyboard resize for the focused separator: Arrow keys nudge the persisted width in fixed steps. + const handleKeyDown = useCallback((event: React.KeyboardEvent) => { + if (event.key === 'ArrowLeft') { + event.preventDefault(); + setPersistedWidth((current) => clampWidth(current - KEYBOARD_STEP, window.innerWidth)); + } else if (event.key === 'ArrowRight') { + event.preventDefault(); + setPersistedWidth((current) => clampWidth(current + KEYBOARD_STEP, window.innerWidth)); + } + }, [setPersistedWidth]); + + return { width, isResizing, startResizing, handleKeyDown }; +} diff --git a/src/features/instance/databases/index.tsx b/src/features/instance/databases/index.tsx index 97b96787a..f290e6b1f 100644 --- a/src/features/instance/databases/index.tsx +++ b/src/features/instance/databases/index.tsx @@ -1,13 +1,16 @@ import { useInstanceClientIdParams } from '@/config/useInstanceClient'; import { getDescribeAllQueryOptions } from '@/integrations/api/instance/database/getDescribeAll'; +import { cn } from '@/lib/cn'; import { buildAbsoluteLinkToDatabasePage } from '@/lib/urls/buildAbsoluteLinkToDatabasePage'; import { useQuery } from '@tanstack/react-query'; import { Navigate, useParams } from '@tanstack/react-router'; +import { CSSProperties } from 'react'; import { DatabaseActionModals } from './components/DatabaseActionModals'; import { DatabaseOverview } from './components/DatabaseOverview'; import { DatabasesSidebar } from './components/DatabasesSidebar'; import { DatabaseTableView } from './components/DatabaseTableView'; import { resolveDatabasesRedirect } from './functions/resolveDatabasesRedirect'; +import { maxSidebarWidth, MIN_SIDEBAR_WIDTH, useResizableDatabasesSidebar } from './hooks/useResizableDatabasesSidebar'; export function Databases() { const params: { @@ -25,6 +28,10 @@ export function Databases() { getDescribeAllQueryOptions({ ...instanceParams, skipRecordCount: true }), ); + const { width: sidebarWidth, isResizing, startResizing, handleKeyDown } = useResizableDatabasesSidebar(); + // Drive the width through a CSS variable so it only applies at md+ (mobile stays full-width, stacked). + const sidebarWidthVar = { '--db-sidebar-width': `${sidebarWidth}px` } as CSSProperties; + // Land on the first database's overview when nothing is selected, and recover from stale links to a // dropped database/table (see resolveDatabasesRedirect for the exact rules + loop-freedom). const redirect = resolveDatabasesRedirect(instanceDatabaseMap, params); @@ -39,11 +46,41 @@ export function Databases() { return ( <> -
-
+
+
+ { + /* Drag (or focus + Arrow keys) to resize the sidebar (md+ only; mobile stacks full-width). + The grab zone straddles the edge into the inter-pane gap so it doesn't fight the tree's + scrollbar; only the thin centered line is visible (on hover / drag / focus). */ + } +
+
+
-
+
{params.databaseName && params.tableName ? ( ({ + Editor: ({ value, options }: { value?: string; options?: { readOnly?: boolean } }) => ( +
{value}
+ ), +})); +vi.mock('@/hooks/useMonacoTheme', () => ({ useMonacoTheme: () => 'light' })); + +beforeAll(() => { + // Radix Dialog relies on DOM APIs jsdom doesn't implement. + Element.prototype.hasPointerCapture ??= () => false; + Element.prototype.setPointerCapture ??= () => undefined; + Element.prototype.releasePointerCapture ??= () => undefined; + Element.prototype.scrollIntoView ??= () => undefined; + window.matchMedia ??= ((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener() {}, + removeEventListener() {}, + addListener() {}, + removeListener() {}, + dispatchEvent() { + return false; + }, + })) as unknown as typeof window.matchMedia; + if (typeof window.PointerEvent === 'undefined') { + window.PointerEvent = class extends MouseEvent {} as typeof PointerEvent; + } +}); + +afterEach(() => cleanup()); + +const noop = () => {}; +const row = { name: 'Ada Lovelace', city: 'London', id: 'abc-123' }; + +function renderModal(overrides: Record = {}) { + return render( + , + ); +} + +describe('EditTableRowModal', () => { + it('warns, goes read-only, and hides write actions when the primary key is missing', () => { + renderModal({ missingPrimaryKey: true }); + + expect(screen.getByText('This row has no primary key value')).toBeTruthy(); + // The banner names the missing primary-key attribute. + expect(screen.getByText('email')).toBeTruthy(); + expect(screen.getByRole('heading').textContent).toContain('View'); + expect(screen.getByTestId('editor').getAttribute('data-readonly')).toBe('true'); + // No way to save or delete a row that can't be addressed. + expect(screen.queryByRole('button', { name: /Save Changes/i })).toBeNull(); + expect(screen.queryByRole('button', { name: /Delete Row/i })).toBeNull(); + }); + + it("warns and hides write actions when the record can't be loaded by its primary key", () => { + renderModal({ recordUnavailable: true }); + + expect(screen.getByText("This row couldn't be loaded")).toBeTruthy(); + expect(screen.getByText('email')).toBeTruthy(); + expect(screen.getByRole('heading').textContent).toContain('View'); + expect(screen.getByTestId('editor').getAttribute('data-readonly')).toBe('true'); + expect(screen.queryByRole('button', { name: /Save Changes/i })).toBeNull(); + expect(screen.queryByRole('button', { name: /Delete Row/i })).toBeNull(); + }); + + it('lets a normal row be edited and deleted', () => { + renderModal(); + + expect(screen.queryByText('This row has no primary key value')).toBeNull(); + expect(screen.getByRole('heading').textContent).toContain('Edit'); + expect(screen.getByTestId('editor').getAttribute('data-readonly')).toBe('false'); + expect(screen.getByRole('button', { name: /Save Changes/i })).toBeTruthy(); + expect(screen.getByRole('button', { name: /Delete Row/i })).toBeTruthy(); + }); +}); diff --git a/src/features/instance/databases/modals/EditTableRowModal.tsx b/src/features/instance/databases/modals/EditTableRowModal.tsx index ac22a93ce..8bf6198c4 100644 --- a/src/features/instance/databases/modals/EditTableRowModal.tsx +++ b/src/features/instance/databases/modals/EditTableRowModal.tsx @@ -1,9 +1,10 @@ import { Loading } from '@/components/Loading'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { useMonacoTheme } from '@/hooks/useMonacoTheme'; import { Editor } from '@/lib/monaco/MonacoEditor'; -import { Save, Trash } from 'lucide-react'; +import { Save, Trash, TriangleAlert } from 'lucide-react'; import { useCallback, useMemo, useState } from 'react'; export function EditTableRowModal({ @@ -12,6 +13,8 @@ export function EditTableRowModal({ setIsModalOpen, isModalOpen, primaryKey, + missingPrimaryKey, + recordUnavailable, syntheticAttributes, data, onSaveChanges, @@ -24,6 +27,13 @@ export function EditTableRowModal({ setIsModalOpen: (open: boolean) => void; isModalOpen: boolean; primaryKey: string; + /** The clicked row has no value for the declared primary key, so it can't be looked up, edited, + * or deleted by id (see #1199). We still show its contents read-only, with an explanation. */ + missingPrimaryKey?: boolean; + /** The row has a primary-key value, but looking it up returned no record — nothing is stored + * under that key (the table isn't actually keyed by the declared primary key; see #1199). Shown + * read-only with an explanation, since it can't be edited or deleted by that key. */ + recordUnavailable?: boolean; /** Relationship/computed attribute names — read-only, so they are hidden from the editable JSON * (saving a record that assigns one fails, even with null). */ syntheticAttributes?: string[]; @@ -34,6 +44,11 @@ export function EditTableRowModal({ isDeleteTableRecordsPending: boolean; }) { const monacoTheme = useMonacoTheme(); + // A row that can't be addressed by its declared primary key can't be saved or deleted + // individually, so force the editor read-only and hide the write actions regardless of the + // user's permissions. + const unaddressable = Boolean(missingPrimaryKey) || Boolean(recordUnavailable); + const isReadOnly = !canEditRecords || unaddressable; const [isValidJSON, setIsValidJSON] = useState(true); const [madeChanges, setMadeChanges] = useState(false); const [updatedTableRecordData, setUpdatedTableRecordData] = useState(); @@ -58,8 +73,8 @@ export function EditTableRowModal({ { if (madeChanges) { event.preventDefault(); @@ -68,8 +83,42 @@ export function EditTableRowModal({ : undefined} > - {canEditRecords ? 'Edit' : 'View'} Row + {isReadOnly ? 'View' : 'Edit'} Row + {unaddressable && ( + + + + {missingPrimaryKey ? 'This row has no primary key value' : "This row couldn't be loaded"} + + +

+ {missingPrimaryKey + ? (primaryKey + ? ( + <> + It has no value for the primary key{' '} + {primaryKey}, so it can't be looked up, edited, or deleted individually. + + ) + : `It has no primary key value, so it can't be looked up, edited, or deleted individually.`) + : (primaryKey + ? ( + <> + Nothing is stored under its primary key{' '} + {primaryKey}, so it can't be edited or deleted individually. + + ) + : `Nothing is stored under its primary key, so it can't be edited or deleted individually.`)} +

+

+ This usually means the table's primary key was changed after the row was created, so the value shown + here isn't the key the record is actually stored under. To remove it, recreate the table or restore the + original primary key attribute. +

+
+
+ )} {data ? ( // Wrapper owns the flex sizing: @monaco-editor/react applies `className` to its inner @@ -80,7 +129,7 @@ export function EditTableRowModal({ className="w-full h-full" language="json" theme={monacoTheme} - options={{ readOnly: !canEditRecords, automaticLayout: true }} + options={{ readOnly: isReadOnly, automaticLayout: true }} value={value} onValidate={onValidate} onChange={(updatedValue) => { @@ -92,14 +141,14 @@ export function EditTableRowModal({ : }
- {canDeleteRecords && ( + {canDeleteRecords && !unaddressable && ( )} - {canEditRecords && ( + {canEditRecords && !unaddressable && ( +
+ {showDropdown && ( +
    + {options.map((option, idx) => { + const isActive = idx === clampedActiveIdx; + return ( +
  • setActiveIdx(idx)} + // onMouseDown (not onClick) so we commit before the input's blur fires; preventDefault + // keeps focus in the input. The ref flag stops the blur handler closing us mid-click. + onMouseDown={(event) => { + event.preventDefault(); + clickingOption.current = true; + void commit(option.value).finally(() => { + clickingOption.current = false; + }); + }} + className={cn( + 'flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-sm', + isActive && 'bg-accent text-accent-foreground', + )} + > + {option.custom + ? ( + + Use “{option.value}” — not a deployed application + + ) + : {option.value}} +
  • + ); + })} +
+ )} +
+ ); +} diff --git a/src/features/instance/secrets/PendingGrantsInput.tsx b/src/features/instance/secrets/PendingGrantsInput.tsx index 95a9379d6..d57c47824 100644 --- a/src/features/instance/secrets/PendingGrantsInput.tsx +++ b/src/features/instance/secrets/PendingGrantsInput.tsx @@ -2,48 +2,36 @@ * A local, API-free grants collector for the Add-secret flow: the secret doesn't exist yet, so * grants can't be persisted with grant_secret (as the edit flow's live SecretGrantsEditor does) — * they're gathered here and submitted in the initial set_secret call. Chip UI mirrors - * SecretGrantsEditor so the two read the same. + * SecretGrantsEditor so the two read the same, and both pick the target application through the + * shared ComponentGrantCombobox. */ import { Badge } from '@/components/ui/badge'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { PlusIcon, XIcon } from 'lucide-react'; -import { KeyboardEvent, useCallback, useState } from 'react'; +import { XIcon } from 'lucide-react'; +import { useCallback } from 'react'; +import { ComponentGrantCombobox } from './ComponentGrantCombobox'; export function PendingGrantsInput({ grants, onChange, + components, disabled, }: { grants: string[]; onChange: (next: string[]) => void; + /** Component names the cluster reports, offered as picker suggestions (empty → free text). */ + components: string[]; disabled?: boolean; }) { - const [component, setComponent] = useState(''); - - const add = useCallback(() => { - const target = component.trim(); - if (!target) { - return; - } + const add = useCallback((target: string) => { if (!grants.includes(target)) { onChange([...grants, target]); } - setComponent(''); - }, [component, grants, onChange]); + }, [grants, onChange]); const remove = useCallback((target: string) => { onChange(grants.filter((granted) => granted !== target)); }, [grants, onChange]); - // This input lives inside the add form — Enter must add a grant, not submit the secret. - const onKeyDown = useCallback((event: KeyboardEvent) => { - if (event.key === 'Enter') { - event.preventDefault(); - add(); - } - }, [add]); - return (
Granted applications @@ -70,29 +58,16 @@ export function PendingGrantsInput({ ))}
)} -
- setComponent(event.target.value)} - onKeyDown={onKeyDown} - // Commit a typed-but-not-added name on blur too, so submitting the Add-secret form (which - // blurs this field first) doesn't silently drop it. add() no-ops on empty/duplicate. - onBlur={add} - disabled={disabled} - /> - -
+ ); } diff --git a/src/features/instance/secrets/SecretDeliveryPicker.tsx b/src/features/instance/secrets/SecretDeliveryPicker.tsx index 69d550e57..974f483dd 100644 --- a/src/features/instance/secrets/SecretDeliveryPicker.tsx +++ b/src/features/instance/secrets/SecretDeliveryPicker.tsx @@ -9,6 +9,9 @@ import { ReactNode } from 'react'; import { SecretTier } from './accessExample'; import { SecretAccessExample } from './SecretAccessExample'; +/** The Harper docs hub for the secrets store — the `secrets` accessor, tiers, and change subscriptions. */ +const SECRETS_DOCS_URL = 'https://docs.harperdb.io/reference/v5/security/secrets'; + export function SecretDeliveryPicker({ name, tier, @@ -61,6 +64,14 @@ export function SecretDeliveryPicker({ How to read it in your component
+ + Learn more about secrets in the Harper docs + ); diff --git a/src/features/instance/secrets/SecretModals.tsx b/src/features/instance/secrets/SecretModals.tsx index 390ea1d7e..e3bb0f93c 100644 --- a/src/features/instance/secrets/SecretModals.tsx +++ b/src/features/instance/secrets/SecretModals.tsx @@ -57,6 +57,7 @@ export function AddSecretModal({ setIsModalOpen, delivery = false, defaultTier = 'scoped', + grantableComponents = [], }: { description: ReactNode; valueDescription?: ReactNode; @@ -70,6 +71,8 @@ export function AddSecretModal({ delivery?: boolean; /** Tier pre-selected when the dialog opens (defaults to the safer scoped tier). */ defaultTier?: SecretTier; + /** Component names the cluster reports, offered as suggestions in the grants picker. */ + grantableComponents?: string[]; }) { const schema = useMemo( () => @@ -173,7 +176,14 @@ export function AddSecretModal({ tier={tier} onTierChange={setTier} disabled={isPending} - grantsSlot={} + grantsSlot={ + + } /> )} diff --git a/src/features/instance/secrets/SecretsManager.delivery.test.tsx b/src/features/instance/secrets/SecretsManager.delivery.test.tsx index d55b00001..a1d4d5f8c 100644 --- a/src/features/instance/secrets/SecretsManager.delivery.test.tsx +++ b/src/features/instance/secrets/SecretsManager.delivery.test.tsx @@ -47,6 +47,23 @@ function exampleText(): string { return document.querySelector('pre code')?.textContent ?? ''; } +describe('SecretsManager toolbar', () => { + it('renders docsLink at the start of the toolbar, before Refresh/Add', () => { + renderManager({ + onRefresh: vi.fn().mockResolvedValue(undefined), + docsLink: ( + + Secrets Docs + + ), + }); + const docs = screen.getByRole('link', { name: /secrets docs/i }); + const refresh = screen.getByRole('button', { name: /refresh/i }); + // docsLink precedes Refresh in DOM order — it sits at the left, matching the sshKeys/certificates pages. + expect(docs.compareDocumentPosition(refresh) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); +}); + describe('SecretsManager delivery tier — Add', () => { async function openAddWithKeyValue() { fireEvent.click(screen.getByRole('button', { name: /add/i })); @@ -58,20 +75,27 @@ describe('SecretsManager delivery tier — Add', () => { renderManager(); await openAddWithKeyValue(); - // Scoped is the default: the example destructures the accessor, never touches process.env. + // Scoped is the default: the example reads the live accessor and subscribes for rotations, + // never touching process.env. expect(exampleText()).toContain("import { secrets } from 'harper';"); - expect(exampleText()).toContain('const { NEW_KEY } = secrets;'); + expect(exampleText()).toContain('let value = secrets.NEW_KEY;'); + expect(exampleText()).toContain("secrets.subscribe('NEW_KEY')"); expect(exampleText()).not.toContain('process.env'); + + // The example links out to the Harper secrets docs for deeper reading. + const docsLink = screen.getByRole('link', { name: /learn more about secrets/i }); + expect(docsLink.getAttribute('href')).toBe('https://docs.harperdb.io/reference/v5/security/secrets'); }); it('submits a scoped secret with its pending grants', async () => { const { onSet } = renderManager(); await openAddWithKeyValue(); - // Add one grant (the PendingGrantsInput "Add" button, distinct from "Add Secret"). + // Add one grant through the component picker. No components are supplied here, so it's a + // free-text field; Enter commits the typed name (the chip's Remove button confirms it landed). fireEvent.change(screen.getByPlaceholderText('application name'), { target: { value: 'my-app' } }); - fireEvent.click(screen.getByRole('button', { name: /^add$/i })); - expect(screen.getByText('my-app')).toBeTruthy(); + fireEvent.keyDown(screen.getByRole('combobox'), { key: 'Enter' }); + await screen.findByRole('button', { name: /remove my-app/i }); const submit = screen.getByRole('button', { name: /add secret/i }); await waitFor(() => expect(submit.hasAttribute('disabled')).toBe(false)); @@ -125,7 +149,7 @@ describe('SecretsManager delivery tier — Edit', () => { selectedName: 'DB_PASSWORD', renderEditExtras: () =>
grants
, }); - await waitFor(() => expect(exampleText()).toContain('const { DB_PASSWORD } = secrets;')); + await waitFor(() => expect(exampleText()).toContain('let value = secrets.DB_PASSWORD;')); // Tier matches what's stored (scoped, unchanged), so the live grant_secret editor is safe. expect(screen.getByTestId('live-grants')).toBeTruthy(); }); @@ -143,7 +167,7 @@ describe('SecretsManager delivery tier — Edit', () => { // Flip to scoped without saving: the secret is still processEnv server-side, so a live // grant_secret would be rejected — show the "save first" hint instead of the editor. fireEvent.click(screen.getAllByRole('radio')[0]); - await waitFor(() => expect(exampleText()).toContain('const { TOKEN } = secrets;')); + await waitFor(() => expect(exampleText()).toContain('let value = secrets.TOKEN;')); expect(screen.queryByTestId('live-grants')).toBeNull(); expect(screen.getByText(/save this as a scoped secret first/i)).toBeTruthy(); }); diff --git a/src/features/instance/secrets/SecretsManager.tsx b/src/features/instance/secrets/SecretsManager.tsx index 11d464ad4..ed7020606 100644 --- a/src/features/instance/secrets/SecretsManager.tsx +++ b/src/features/instance/secrets/SecretsManager.tsx @@ -46,9 +46,11 @@ export function SecretsManager({ onSet, onDelete, renderEditExtras, + docsLink, children, delivery = false, deliveryDefaultTier = 'scoped', + grantableComponents, }: { rows: SecretRow[]; isFetching?: boolean; @@ -72,12 +74,16 @@ export function SecretsManager({ onDelete?: (key: string) => Promise; /** Extra per-secret content for the edit dialog (e.g. a live grants editor). */ renderEditExtras?: (name: string) => ReactNode; + /** A docs link rendered at the START of the toolbar (before Refresh/Add), matching the sshKeys/certificates config pages. */ + docsLink?: ReactNode; /** Extra toolbar actions, rendered after Refresh/Add. */ children?: ReactNode; /** Enable the delivery-tier chooser (process.env vs scoped) + access examples in the dialogs. */ delivery?: boolean; /** Tier pre-selected in the Add dialog (defaults to the safer scoped tier). */ deliveryDefaultTier?: SecretTier; + /** Component names the cluster reports, offered as suggestions in the Add dialog's grants picker. */ + grantableComponents?: string[]; }) { const columns = useMemo>>(() => [ { @@ -112,6 +118,7 @@ export function SecretsManager({ isFetching={isFetching} onRowClick={canManage ? onRowClick : undefined} > + {docsLink} {onRefresh && ( + + ); + } + + return ( +
+

+ + {copy.warning} +

+
+ + {/* Not autoFocused: focus on the destructive button would let a stray Enter or double-click confirm. */} + +
+
+ ); +} diff --git a/src/features/organization/users/components/ResendInviteButton.tsx b/src/features/organization/users/components/ResendInviteButton.tsx new file mode 100644 index 000000000..e67b5034f --- /dev/null +++ b/src/features/organization/users/components/ResendInviteButton.tsx @@ -0,0 +1,59 @@ +import { Button } from '@/components/ui/button'; +import { useInviteUserToOrganizationRole } from '@/features/organization/mutations/inviteUserToOrganizationRole'; +import { useOrganizationRolePermissions } from '@/hooks/usePermissions'; +import { SchemaUser } from '@/integrations/api/api.gen'; +import { MailIcon } from 'lucide-react'; +import { MouseEvent, useCallback } from 'react'; +import { toast } from 'sonner'; + +/** + * Resend a pending user's org invite. The backend treats a repeat POST /UserInvite for an + * already-invited (still pending) user as a resend, so this reuses the same invite mutation and + * payload ({ email, roleId }) — we just target the user's existing role. + * + * Rendered only for PENDING rows (see the users table definition). Stops row-click propagation so + * clicking it doesn't also open the edit modal. + */ +export function ResendInviteButton({ user }: { user: SchemaUser }) { + // Reads the org from the current route (same as the page's other permission checks). + const { update } = useOrganizationRolePermissions(); + const { mutate: inviteUser, isPending } = useInviteUserToOrganizationRole(); + const roleId = user.roles?.[0]?.id; + + const onClick = useCallback( + (event: MouseEvent) => { + event.stopPropagation(); + if (!user.email || !roleId) { + toast.error('Cannot resend invite: this user has no email or role.'); + return; + } + // The global MutationCache.onError (react-query/queryClient) already surfaces failures with + // the server's message, so only the success toast is local here — avoids double-toasting. + inviteUser( + { email: user.email, roleId }, + { + onSuccess: () => toast.success(`Invitation resent to ${user.email}.`), + }, + ); + }, + [inviteUser, roleId, user.email], + ); + + // Resending an invite is a write; gate it on the same org-role update permission as the page's + // other write affordances (Add user, edit) so view-only users don't see a clickable button. + if (!update) { + return null; + } + + return ( + + ); +} diff --git a/src/features/organization/users/constants/tableDefinition.ts b/src/features/organization/users/constants/tableDefinition.tsx similarity index 74% rename from src/features/organization/users/constants/tableDefinition.ts rename to src/features/organization/users/constants/tableDefinition.tsx index 2b1d3e35c..fa97f0ba0 100644 --- a/src/features/organization/users/constants/tableDefinition.ts +++ b/src/features/organization/users/constants/tableDefinition.tsx @@ -1,3 +1,4 @@ +import { ResendInviteButton } from '@/features/organization/users/components/ResendInviteButton'; import { SchemaUser } from '@/integrations/api/api.gen'; import { ColumnDef, createColumnHelper } from '@tanstack/react-table'; @@ -40,4 +41,10 @@ export const dataTableColumns: Array> = [ accessorKey: 'isVerified', enableSorting: false, }, + columnHelper.display({ + header: '', + enableSorting: false, + id: 'actions', + cell: (props) => props.row.original.status === 'PENDING' ? : null, + }), ]; diff --git a/src/features/organization/users/index.tsx b/src/features/organization/users/index.tsx index b1d5d2562..bf08b472c 100644 --- a/src/features/organization/users/index.tsx +++ b/src/features/organization/users/index.tsx @@ -7,6 +7,7 @@ import { getOrganizationRolesQueryOptions } from '@/features/organization/querie import { dataTableColumns } from '@/features/organization/users/constants/tableDefinition'; import { AddUserModal } from '@/features/organization/users/modals/AddUserModal'; import { EditUserModal } from '@/features/organization/users/modals/EditUserModal'; +import { isAdminRoleName } from '@/features/organization/users/orgUserRemovalPolicy'; import { useOrganizationRolePermissions } from '@/hooks/usePermissions'; import { useRefreshClick } from '@/hooks/useRefreshClick'; import { SchemaUser } from '@/integrations/api/api.gen'; @@ -42,6 +43,20 @@ export function OrgConfigUsersIndex() { return Object.values(users).sort(sortByEmail); }, [organizationRoles]); + // Distinct members holding an admin role — used to keep the last admin from removing their own + // admin role or leaving (which would leave the org with no one able to manage it). + const orgAdminCount = useMemo(() => { + const adminUserIds = new Set(); + for (const organizationRole of organizationRoles) { + if (isAdminRoleName(organizationRole.roleName)) { + for (const user of organizationRole.users ?? []) { + adminUserIds.add(user.id); + } + } + } + return adminUserIds.size; + }, [organizationRoles]); + const selectedUser = useMemo(() => cloudUsers?.find((user) => user.id === orgUserId), [cloudUsers, orgUserId]); const onSelectUser = useCallback( @@ -139,6 +154,8 @@ export function OrgConfigUsersIndex() { data={selectedUser} isModalOpen={isEditModalOpen} onUserUpdated={onUserUpdated} + orgUserCount={cloudUsers.length} + orgAdminCount={orgAdminCount} /> )} diff --git a/src/features/organization/users/modals/EditUserModal.tsx b/src/features/organization/users/modals/EditUserModal.tsx index d6233fdb8..22e71c4ff 100644 --- a/src/features/organization/users/modals/EditUserModal.tsx +++ b/src/features/organization/users/modals/EditUserModal.tsx @@ -1,25 +1,36 @@ import { Loading } from '@/components/Loading'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { useRemoveUserFromOrganizationRole } from '@/features/organization/mutations/removeUserFromOrganizationRole'; import { getOrganizationRolesQueryOptions } from '@/features/organization/queries/getOrganizationRoles'; import { OrgUserRoleCheckbox } from '@/features/organization/users/components/OrgUserRoleCheckbox'; +import { RemoveUserFromOrgButton } from '@/features/organization/users/components/RemoveUserFromOrgButton'; +import { ResendInviteButton } from '@/features/organization/users/components/ResendInviteButton'; +import { getOrgUserRemovalPolicy, isAdminRoleName } from '@/features/organization/users/orgUserRemovalPolicy'; import { useCloudAuth } from '@/hooks/useAuth'; import { useOrganizationRolePermissions } from '@/hooks/usePermissions'; import { SchemaUser } from '@/integrations/api/api.gen'; import { keyBy } from '@/lib/keyBy'; import { useSuspenseQuery } from '@tanstack/react-query'; import { useParams } from '@tanstack/react-router'; -import { Suspense, useMemo, useState } from 'react'; +import { AxiosError } from 'axios'; +import { TriangleAlertIcon } from 'lucide-react'; +import { Suspense, useCallback, useMemo, useState } from 'react'; +import { toast } from 'sonner'; export function EditUserModal({ closeModal, data, isModalOpen, onUserUpdated, + orgUserCount, + orgAdminCount, }: { closeModal: () => void; data: SchemaUser; isModalOpen: boolean; onUserUpdated: () => void; + orgUserCount: number; + orgAdminCount: number; }) { const { organizationId }: { organizationId: string } = useParams({ strict: false }); const auth = useCloudAuth(); @@ -27,6 +38,48 @@ export function EditUserModal({ const isSelf = auth.user?.email === data.email; const [changesMade, setChangesMade] = useState(false); + const { canRemoveRoles, showRemovalAction, blockedReason } = getOrgUserRemovalPolicy({ + canDelete: remove, + isSelf, + orgUserCount, + adminCount: orgAdminCount, + isAdmin: (data.roles ?? []).some((role) => isAdminRoleName(role.roleName)), + roleCount: data.roles?.length ?? 0, + }); + + const { mutateAsync: removeUserFromRole } = useRemoveUserFromOrganizationRole(); + const [isRemoving, setIsRemoving] = useState(false); + + // Removing a user from the org means dropping every role they hold in it. We fire one removal + // per role; a role that's already gone (404) is treated as success so a partially-completed + // attempt can be retried cleanly. + const onRemoveFromOrg = useCallback(async () => { + const roles = data.roles ?? []; + setIsRemoving(true); + const results = await Promise.allSettled( + roles.map((role) => removeUserFromRole({ userId: data.id, roleId: role.id })), + ); + const failed = results.some( + (result) => result.status === 'rejected' && (result.reason as AxiosError)?.response?.status !== 404, + ); + if (failed) { + toast.error( + isSelf + ? 'Something went wrong leaving the organization. Please try again.' + : `Couldn’t remove ${data.email} from the organization. Please try again.`, + ); + } else { + toast.success( + isSelf + ? 'You’ve left the organization.' + : `${data.email} was removed from the organization.`, + ); + } + // Refetch either way so the list reflects whatever actually changed, then close. + setIsRemoving(false); + onUserUpdated(); + }, [data.roles, data.id, data.email, isSelf, removeUserFromRole, onUserUpdated]); + // TODO: Cancel invite return ( @@ -37,7 +90,11 @@ export function EditUserModal({ {update && ( - To remove {isSelf ? 'your self' : 'this user'} from the organization, uncheck all of the boxes below. + Use the checkboxes to change which roles {isSelf ? 'you have' : 'this user has'}. + {showRemovalAction + && (isSelf + ? ' To leave the organization entirely, use the button below.' + : ' To remove this user from the organization entirely, use the button below.')} )} @@ -47,10 +104,38 @@ export function EditUserModal({ data={data} organizationId={organizationId} update={update} - remove={remove} + canRemove={canRemoveRoles} setChangesMade={setChangesMade} /> + + {/* Pending users haven't accepted their invite yet — offer the same resend action as the users table row. */} + {data.status === 'PENDING' && ( +
+ +
+ )} + + {showRemovalAction && ( +
+ +
+ )} + + {blockedReason && ( +

+ + + {blockedReason === 'sole-member' + ? `You’re the only member of this organization, so you can’t remove your roles or leave — it would be ` + + `left with no one to manage it. If you no longer need this organization, terminate its clusters, then ` + + `delete the organization from the ⋯ menu on the Organizations page.` + : `You’re the only admin of this organization, so you can’t remove your roles or leave — it would be left ` + + `with no one who can manage its members, roles, or clusters. Give another member the admin role first, ` + + `then you can step back.`} + +

+ )}
); @@ -60,13 +145,13 @@ function EditUserRolesList({ data, organizationId, update, - remove, + canRemove, setChangesMade, }: { data: SchemaUser; organizationId: string; update: boolean; - remove: boolean; + canRemove: boolean; setChangesMade: (value: boolean) => void; }) { const { data: orgRoles } = useSuspenseQuery(getOrganizationRolesQueryOptions(organizationId)); @@ -78,7 +163,7 @@ function EditUserRolesList({ { + it('matches "admin" case-insensitively and trims whitespace', () => { + expect(isAdminRoleName('admin')).toBe(true); + expect(isAdminRoleName('Admin')).toBe(true); + expect(isAdminRoleName(' ADMIN ')).toBe(true); + }); + + it('does not match other role names', () => { + expect(isAdminRoleName('administrator')).toBe(false); + expect(isAdminRoleName('new_role')).toBe(false); + expect(isAdminRoleName('')).toBe(false); + expect(isAdminRoleName(undefined)).toBe(false); + }); +}); + +describe('getOrgUserRemovalPolicy', () => { + it('lets an admin remove another user who holds roles', () => { + expect(getOrgUserRemovalPolicy({ ...base, isSelf: false })) + .toEqual({ canRemoveRoles: true, showRemovalAction: true, blockedReason: null }); + }); + + it('lets you leave when other members and other admins remain', () => { + expect(getOrgUserRemovalPolicy({ ...base, isSelf: true, isAdmin: true, orgUserCount: 3, adminCount: 2 })) + .toEqual({ canRemoveRoles: true, showRemovalAction: true, blockedReason: null }); + }); + + it('blocks the sole member with the sole-member notice', () => { + expect(getOrgUserRemovalPolicy({ ...base, isSelf: true, isAdmin: true, orgUserCount: 1, adminCount: 1 })) + .toEqual({ canRemoveRoles: false, showRemovalAction: false, blockedReason: 'sole-member' }); + }); + + it('blocks the last admin even when other (non-admin) members remain', () => { + expect(getOrgUserRemovalPolicy({ ...base, isSelf: true, isAdmin: true, orgUserCount: 4, adminCount: 1 })) + .toEqual({ canRemoveRoles: false, showRemovalAction: false, blockedReason: 'last-admin' }); + }); + + it('prefers the sole-member reason when you are both the only member and the only admin', () => { + expect( + getOrgUserRemovalPolicy({ ...base, isSelf: true, isAdmin: true, orgUserCount: 1, adminCount: 1 }).blockedReason, + ) + .toBe('sole-member'); + }); + + it('does not block a non-admin self when other members remain', () => { + expect(getOrgUserRemovalPolicy({ ...base, isSelf: true, isAdmin: false, orgUserCount: 3, adminCount: 2 })) + .toEqual({ canRemoveRoles: true, showRemovalAction: true, blockedReason: null }); + }); + + it('does not block a self admin when another admin exists', () => { + expect( + getOrgUserRemovalPolicy({ ...base, isSelf: true, isAdmin: true, orgUserCount: 3, adminCount: 2 }).blockedReason, + ) + .toBe(null); + }); + + it('never blocks when acting on another user, even in a one-member org', () => { + // A fabric admin cleaning up someone else's solo org is never the "leave yourself" case. + expect(getOrgUserRemovalPolicy({ ...base, isSelf: false, orgUserCount: 1, adminCount: 1 })) + .toEqual({ canRemoveRoles: true, showRemovalAction: true, blockedReason: null }); + }); + + it('hides everything (no notice) when the actor lacks delete permission', () => { + expect( + getOrgUserRemovalPolicy({ + ...base, + canDelete: false, + isSelf: true, + orgUserCount: 1, + adminCount: 1, + isAdmin: true, + }), + ) + .toEqual({ canRemoveRoles: false, showRemovalAction: false, blockedReason: null }); + }); + + it('allows removal in principle but hides the action when the user holds no roles', () => { + expect(getOrgUserRemovalPolicy({ ...base, isSelf: false, roleCount: 0 })) + .toEqual({ canRemoveRoles: true, showRemovalAction: false, blockedReason: null }); + }); +}); diff --git a/src/features/organization/users/orgUserRemovalPolicy.ts b/src/features/organization/users/orgUserRemovalPolicy.ts new file mode 100644 index 000000000..f08fdb0b8 --- /dev/null +++ b/src/features/organization/users/orgUserRemovalPolicy.ts @@ -0,0 +1,61 @@ +/** + * Decides what an admin (or the user themselves) may do to an org membership in the Edit User + * modal. A user belongs to an org only by holding org roles, so removing their last role — or + * their admin role — is equivalent to removing them from the org. + * + * Two edge cases would orphan the organization when acting on yourself, so both block role + * removal / leaving and show a notice with the real way out: + * - sole member: no one would be left in the org at all. + * - last admin: members would remain, but no one could manage them (or the clusters). + * + * "Admin" is matched by role name (see `isAdminRoleName`) — a deliberately simple heuristic that + * needs no per-role permission lookup. It can miss a custom-named admin role; the server remains + * the source of truth, this is just a guard rail. + */ +export interface OrgUserRemovalPolicy { + /** Whether individual role checkboxes may be unchecked (i.e. roles removed). */ + canRemoveRoles: boolean; + /** Whether to show the explicit "Remove from organization" / "Leave organization" action. */ + showRemovalAction: boolean; + /** Why removal is blocked for the acting user, if it is — drives which notice to show. */ + blockedReason: 'sole-member' | 'last-admin' | null; +} + +/** A role grants org administration if it is literally named "admin" (case-insensitive). */ +export function isAdminRoleName(roleName: string | undefined): boolean { + return roleName?.trim().toLowerCase() === 'admin'; +} + +export function getOrgUserRemovalPolicy({ + canDelete, + isSelf, + orgUserCount, + adminCount, + isAdmin, + roleCount, +}: { + /** The acting user has the org-role delete permission. */ + canDelete: boolean; + /** The membership being edited is the acting user's own. */ + isSelf: boolean; + /** Number of distinct members in the organization. */ + orgUserCount: number; + /** Number of distinct members holding an admin role. */ + adminCount: number; + /** The edited user holds an admin role. */ + isAdmin: boolean; + /** Number of roles the edited user currently holds in the org. */ + roleCount: number; +}): OrgUserRemovalPolicy { + const isSoleMember = isSelf && orgUserCount <= 1; + const isLastAdmin = isSelf && isAdmin && adminCount <= 1; + // Sole-member takes priority: with no one else in the org, promoting another admin isn't an + // option, so we point them at deleting the org rather than at handing off admin. + const blockedReason = !canDelete ? null : isSoleMember ? 'sole-member' : isLastAdmin ? 'last-admin' : null; + const canRemoveRoles = canDelete && blockedReason === null; + return { + canRemoveRoles, + showRemovalAction: canRemoveRoles && roleCount > 0, + blockedReason, + }; +} diff --git a/src/hooks/useAuth.test.ts b/src/hooks/useAuth.test.ts new file mode 100644 index 000000000..d2b9ff8b2 --- /dev/null +++ b/src/hooks/useAuth.test.ts @@ -0,0 +1,25 @@ +/** @vitest-environment jsdom */ +// jsdom: importing useAuth pulls in authStore, which touches localStorage at module load. +import type { LocalUser, User } from '@/integrations/api/api.patch'; +import { describe, expect, it } from 'vitest'; +import { isFabricAdmin } from './useAuth'; + +const cloudUser = (fabricRole: User['fabricRole']) => ({ fabricRole }) as User; +// A local-instance user has no fabricRole at all. +const localUser = { username: 'admin', role: { role: 'super_user' } } as unknown as LocalUser; + +describe('isFabricAdmin', () => { + // Pins the role boundary the Admin section relies on: only fabric_admin can + // satisfy the token endpoint's SSO-session contract. super_user must NOT see + // the section (it may password-login and would only get a 403 from the mint). + it('is true only for a fabric_admin cloud user', () => { + expect(isFabricAdmin(cloudUser('fabric_admin'))).toBe(true); + }); + + it('is false for super_user, least_privileged, a local user, and null', () => { + expect(isFabricAdmin(cloudUser('super_user'))).toBe(false); + expect(isFabricAdmin(cloudUser('least_privileged'))).toBe(false); + expect(isFabricAdmin(localUser)).toBe(false); + expect(isFabricAdmin(null)).toBe(false); + }); +}); diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts index aa01e6118..c06506dc0 100644 --- a/src/hooks/useAuth.ts +++ b/src/hooks/useAuth.ts @@ -7,7 +7,7 @@ import { EntityIds, OverallAppSignIn, } from '@/features/auth/store/authStore'; -import { User } from '@/integrations/api/api.patch'; +import { LocalUser, User } from '@/integrations/api/api.patch'; import { useParams } from '@tanstack/react-router'; import { useEffect, useState } from 'react'; @@ -46,6 +46,14 @@ export function isAdminMode(user: User | null): boolean { return user?.fabricRole === 'fabric_admin' || user?.fabricRole === 'super_user'; } +// Narrower than isAdminMode: the Fabric Admin section is fabric_admin-only +// because its API token endpoint requires a Google SSO session, which only +// fabric_admin accounts have (super_user may password-login and would 403). +// Accepts the cloud/local union so callers don't need a type guard. +export function isFabricAdmin(user: User | LocalUser | null): boolean { + return user !== null && 'fabricRole' in user && user.fabricRole === 'fabric_admin'; +} + export function useInstanceAuth(entityId?: EntityIds): AuthenticatedInstanceConnection { const key = isLocalStudio ? OverallAppSignIn : entityId; const { clusterId, instanceId }: { instanceId?: string; clusterId: string } = useParams({ strict: false }); diff --git a/src/hooks/useClusterContainerOps.ts b/src/hooks/useClusterContainerOps.ts new file mode 100644 index 000000000..a87d8d103 --- /dev/null +++ b/src/hooks/useClusterContainerOps.ts @@ -0,0 +1,57 @@ +import { Cluster } from '@/integrations/api/api.patch'; +import { ContainerStrategy, useClusterContainerOperation } from '@/integrations/api/cluster/containerOperation'; +import { ContainerAction } from '@/integrations/api/instance/containerOperation'; +import { useQueryClient } from '@tanstack/react-query'; +import { useCallback } from 'react'; +import { toast } from 'sonner'; + +const GERUND: Record = { + stop: 'Stopping', + start: 'Starting', + restart: 'Restarting', +}; + +export interface RunClusterOpOptions { + safeMode?: boolean; + strategy?: ContainerStrategy; + /** Override the display label, e.g. "Restarting in safe mode". */ + label?: string; +} + +/** + * Fires a cluster-wide container op with the standard loading → success/error toast + cache + * invalidation (mirrors {@link useInstanceContainerOps}). The op is async, so "accepted" means the + * fan-out started; the clusters list / overview poll reflect the resting state shortly after. + */ +export function useClusterContainerOps(cluster: Cluster) { + const { mutateAsync, isPending } = useClusterContainerOperation(); + const queryClient = useQueryClient(); + + const run = useCallback( + async (action: ContainerAction, opts?: RunClusterOpOptions) => { + const gerund = opts?.label ?? GERUND[action]; + const toastId = toast.loading(gerund, { + description: `${gerund} cluster ${cluster.name}. Status will update automatically.`, + duration: 30_000, + action: { label: 'Dismiss', onClick: () => toast.dismiss() }, + }); + try { + await mutateAsync({ clusterId: cluster.id, action, safeMode: opts?.safeMode, strategy: opts?.strategy }); + void queryClient.invalidateQueries({ queryKey: [cluster.organizationId] }); + void queryClient.invalidateQueries({ queryKey: [cluster.id] }); + toast.dismiss(toastId); + toast.success('Request accepted', { + description: `${cluster.name} is ${gerund.toLowerCase()}. The status will update automatically.`, + action: { label: 'Dismiss', onClick: () => toast.dismiss() }, + }); + } catch { + // Just drop the loading toast; the global MutationCache.onError already shows the error + // (with the server's message), so a local error toast here would double up. + toast.dismiss(toastId); + } + }, + [cluster.id, cluster.name, cluster.organizationId, mutateAsync, queryClient], + ); + + return { run, isPending }; +} diff --git a/src/hooks/useInstanceContainerOps.ts b/src/hooks/useInstanceContainerOps.ts new file mode 100644 index 000000000..2a6a072a6 --- /dev/null +++ b/src/hooks/useInstanceContainerOps.ts @@ -0,0 +1,63 @@ +import { Instance } from '@/integrations/api/api.patch'; +import { ContainerAction, useInstanceContainerOperation } from '@/integrations/api/instance/containerOperation'; +import { useQueryClient } from '@tanstack/react-query'; +import { useParams } from '@tanstack/react-router'; +import { useCallback } from 'react'; +import { toast } from 'sonner'; + +const GERUND: Record = { + stop: 'Stopping', + start: 'Starting', + restart: 'Restarting', +}; + +export interface RunContainerOpOptions { + /** start/restart only; omit for stop. Explicit false exits safe mode. */ + safeMode?: boolean; + /** Override the display label, e.g. "Restarting in safe mode". */ + label?: string; +} + +/** + * Fires an instance container op with the standard loading → success/error toast and cache + * invalidation, mirroring {@link useRestartInstanceClick}. The op is async backend-side, so + * "accepted" means the instance entered its transitional state; the instances-page poll reflects + * the resting state shortly after. + */ +export function useInstanceContainerOps(instance: Instance) { + const { clusterId }: { clusterId?: string } = useParams({ strict: false }); + const { mutateAsync, isPending } = useInstanceContainerOperation(); + const queryClient = useQueryClient(); + + const run = useCallback( + async (action: ContainerAction, opts?: RunContainerOpOptions) => { + const gerund = opts?.label ?? GERUND[action]; + const target = instance.name ?? instance.id; + const toastId = toast.loading(gerund, { + description: `${gerund} ${target}. Status will update shortly.`, + duration: 30_000, + action: { label: 'Dismiss', onClick: () => toast.dismiss() }, + }); + try { + await mutateAsync({ instanceId: instance.id, action, safeMode: opts?.safeMode }); + // clusterId is optional (useParams strict:false); guard so we don't invalidate [undefined]. + if (clusterId) { + void queryClient.invalidateQueries({ queryKey: [clusterId] }); + } + void queryClient.invalidateQueries({ queryKey: [instance.id] }); + toast.dismiss(toastId); + toast.success('Request accepted', { + description: `${target} is ${gerund.toLowerCase()}. The status will update automatically.`, + action: { label: 'Dismiss', onClick: () => toast.dismiss() }, + }); + } catch { + // Just drop the loading toast; the global MutationCache.onError already shows the error + // (with the server's message), so a local error toast here would double up. + toast.dismiss(toastId); + } + }, + [clusterId, instance.id, instance.name, mutateAsync, queryClient], + ); + + return { run, isPending }; +} diff --git a/src/hooks/useResolvedTheme.ts b/src/hooks/useResolvedTheme.ts new file mode 100644 index 000000000..eb0796750 --- /dev/null +++ b/src/hooks/useResolvedTheme.ts @@ -0,0 +1,29 @@ +import { useEffect, useState } from 'react'; + +type ResolvedTheme = 'light' | 'dark'; + +/** Resolved app theme ('light' | 'dark') for the few code paths that + * genuinely branch in JS (e.g. chart color-stop interpolation, area fill + * opacity). The ThemeProvider (src/hooks/useTheme) already resolves the + * user's explicit choice vs. OS preference by toggling the `.dark` class + * on — reading that class keeps consumers in lockstep with every + * other themed surface without re-deriving preference + media-query state. + * Everything color-related that CAN be a CSS token should use CSS vars + * instead and never touch this hook. */ +export function useResolvedTheme(): ResolvedTheme { + const [dark, setDark] = useState(() => + typeof document !== 'undefined' && document.documentElement.classList.contains('dark') + ); + useEffect(() => { + const root = document.documentElement; + // Re-sync on mount: a theme toggle between the lazy initializer and + // the observer attaching would otherwise be missed. + setDark(root.classList.contains('dark')); + const observer = new MutationObserver(() => { + setDark(root.classList.contains('dark')); + }); + observer.observe(root, { attributes: true, attributeFilter: ['class'] }); + return () => observer.disconnect(); + }, []); + return dark ? 'dark' : 'light'; +} diff --git a/src/index.css b/src/index.css index 0f071985d..113dcdd23 100644 --- a/src/index.css +++ b/src/index.css @@ -274,6 +274,7 @@ --color-error: var(--destructive); --color-success: var(--green); --color-warning: var(--yellow); + --color-info: var(--blue); --shadow-deep: 0 2px 12px 0 rgba(0,0,0,0.06), 0 1px 3px 0 rgba(0,0,0,0.04); --color-sidebar-ring: var(--sidebar-ring); diff --git a/src/integrations/api/api.patch.d.ts b/src/integrations/api/api.patch.d.ts index bacf9e731..7b125dfa8 100644 --- a/src/integrations/api/api.patch.d.ts +++ b/src/integrations/api/api.patch.d.ts @@ -41,6 +41,12 @@ export interface User extends Omit { oauthConfigId: string | null; } +/** Response of POST /Admin/ApiToken — a short-lived Bearer token for programmatic API access. */ +export interface ApiTokenResult { + operationToken: string; + expiresAt: string; +} + export interface Organization extends SchemaOrganization { type: ENTERPRISE | SELF_SERVICE | string | undefined; settings?: { @@ -123,11 +129,16 @@ export type LocalRoleAttributePermissionAction = keyof Omit { // TODO: Can we return enums from the server to make this easier? status?: string | 'PROVISIONING' | 'UPDATING' | 'RUNNING' | 'TERMINATED' | 'FAILED'; + // Use the patched Instance (adds status + safeMode) rather than the raw generated shape. + instances?: Instance[]; } export interface ClusterUpsert extends SchemaClusterUpsert { diff --git a/src/integrations/api/cluster/containerOperation.ts b/src/integrations/api/cluster/containerOperation.ts new file mode 100644 index 000000000..4012903fd --- /dev/null +++ b/src/integrations/api/cluster/containerOperation.ts @@ -0,0 +1,48 @@ +import { apiClient } from '@/config/apiClient'; +import { ContainerAction } from '@/integrations/api/instance/containerOperation'; +import { useMutation } from '@tanstack/react-query'; + +export type ContainerStrategy = 'parallel' | 'rolling'; + +export interface ClusterContainerOperationParams { + clusterId: string; + action: ContainerAction; + /** start/restart only. Explicit false exits safe mode. Backend rejects safeMode + rolling. */ + safeMode?: boolean; + /** parallel (all at once) or rolling (one at a time, waiting for each to rejoin). */ + strategy?: ContainerStrategy; +} + +export interface ClusterContainerOperationResponse { + clusterId: string; + action: ContainerAction; + strategy: ContainerStrategy; + /** Transitional cluster status returned immediately (STOPPING / STARTING / RESTARTING). */ + status: string; + instanceIds: string[]; + message: string; +} + +/** + * Dispatch a cluster-wide container lifecycle op via the central manager: + * POST /Cluster/{id}/container/{action}. Fans the action out to every instance. + * + * Async: the endpoint returns a transitional status immediately; the resting status lands as the + * fan-out completes (the clusters list / overview poll reflect it). + * + * NOTE: hand-typed past the generated SDK (the /container/{action} path + safeMode/strategy body + * aren't in the CM OpenAPI yet) — same pattern as terminateCluster / the instance op. + */ +export async function clusterContainerOperation( + { clusterId, action, safeMode, strategy }: ClusterContainerOperationParams, +): Promise { + const body: { safeMode?: boolean; strategy?: ContainerStrategy } = {}; + if (safeMode !== undefined) { body.safeMode = safeMode; } + if (strategy !== undefined) { body.strategy = strategy; } + const { data } = await apiClient.post(`/Cluster/${clusterId}/container/${action}` as '/Cluster/{id}', body); + return data as ClusterContainerOperationResponse; +} + +export function useClusterContainerOperation() { + return useMutation({ mutationFn: clusterContainerOperation }); +} diff --git a/src/integrations/api/instance/containerOperation.ts b/src/integrations/api/instance/containerOperation.ts new file mode 100644 index 000000000..cfd0ca9a3 --- /dev/null +++ b/src/integrations/api/instance/containerOperation.ts @@ -0,0 +1,48 @@ +import { apiClient } from '@/config/apiClient'; +import { useMutation } from '@tanstack/react-query'; + +export type ContainerAction = 'stop' | 'start' | 'restart'; + +export interface InstanceContainerOperationParams { + instanceId: string; + action: ContainerAction; + /** Only meaningful for start/restart; omit for stop. Explicit `false` exits safe mode. */ + safeMode?: boolean; +} + +export interface InstanceContainerOperationResponse { + instanceId: string; + action: ContainerAction; + /** Transitional status returned immediately (STOPPING / STARTING / RESTARTING). */ + status: string; + message: string; +} + +/** + * Dispatch a container lifecycle op (stop/start/restart) to an instance via the central manager: + * POST /HDBInstance/{id}/container/{action}. + * + * This is a DIFFERENT class from the proxied Harper `restart` operation (which goes through the + * instance ops API and needs Harper to be up). Container ops are handled host-side and support + * `safeMode` (boot without loading user apps/components). The op is async: the endpoint returns a + * transitional status immediately and the resting status lands later — the instances-page poll + * reflects it. + * + * NOTE: these endpoints aren't in the generated OpenAPI/SDK yet, so the URL is cast past the + * typed-path check (same pattern as terminateCluster). Replace the cast with the generated path + * once the CM OpenAPI exposes /HDBInstance/{id}/container/{action}. + */ +export async function instanceContainerOperation( + { instanceId, action, safeMode }: InstanceContainerOperationParams, +): Promise { + const body = safeMode === undefined ? {} : { safeMode }; + const { data } = await apiClient.post( + `/HDBInstance/${instanceId}/container/${action}` as '/HDBInstance/{id}', + body, + ); + return data as InstanceContainerOperationResponse; +} + +export function useInstanceContainerOperation() { + return useMutation({ mutationFn: instanceContainerOperation }); +} diff --git a/src/integrations/api/instance/database/getSearchById.test.ts b/src/integrations/api/instance/database/getSearchById.test.ts new file mode 100644 index 000000000..543d907aa --- /dev/null +++ b/src/integrations/api/instance/database/getSearchById.test.ts @@ -0,0 +1,52 @@ +import type { EntityIds } from '@/features/auth/store/authStore'; +import { getSearchByIdOptions } from '@/integrations/api/instance/database/getSearchById'; +import type { AxiosInstance } from 'axios'; +import { describe, expect, it, vi } from 'vitest'; + +// A row whose declared primary key has no value surfaces here as `null`/`undefined`. If it were +// sent, JSON.stringify turns it into `[null]`, which Harper rejects (500 'hash_values' must be +// strings or numbers) -- see #1199. These guard that it is filtered out before any request. +function makeClient(post = vi.fn()) { + return { post } as unknown as AxiosInstance; +} + +const base = { + entityId: 'e1' as unknown as EntityIds, + databaseName: 'data', + tableName: 'Thing', +}; + +describe('getSearchByIdOptions', () => { + it('disables the query when the only id is null (broken primary key)', () => { + const opts = getSearchByIdOptions({ ...base, instanceClient: makeClient(), enabled: true, ids: [null] }); + expect(opts.enabled).toBe(false); + }); + + it('disables the query when ids is null', () => { + const opts = getSearchByIdOptions({ ...base, instanceClient: makeClient(), enabled: true, ids: null }); + expect(opts.enabled).toBe(false); + }); + + it('sends only the valid ids, never null/undefined', () => { + const post = vi.fn().mockResolvedValue({ data: [] }); + const opts = getSearchByIdOptions({ + ...base, + instanceClient: makeClient(post), + enabled: true, + ids: [null, 'abc', undefined, 5], + }); + expect(opts.enabled).toBe(true); + (opts.queryFn as () => unknown)(); + expect(post).toHaveBeenCalledWith( + '/', + // onlyIfCached must be false: the edit fetch needs the real record, not a cache-only hit + // that answers "Entry is not cached" on a miss (#1199). + expect.objectContaining({ ids: ['abc', 5], operation: 'search_by_id', onlyIfCached: false }), + ); + }); + + it('honors a false enabled flag even with valid ids', () => { + const opts = getSearchByIdOptions({ ...base, instanceClient: makeClient(), enabled: false, ids: ['abc'] }); + expect(opts.enabled).toBe(false); + }); +}); diff --git a/src/integrations/api/instance/database/getSearchById.ts b/src/integrations/api/instance/database/getSearchById.ts index 0f685ff7b..0df22c24d 100644 --- a/src/integrations/api/instance/database/getSearchById.ts +++ b/src/integrations/api/instance/database/getSearchById.ts @@ -11,19 +11,29 @@ interface SearchByIdParams extends InstanceClientIdConfig { export function getSearchByIdOptions( { enabled, entityId, instanceClient, databaseName, tableName, ids }: SearchByIdParams, ) { + // A record whose declared primary key has no value surfaces here as `null`/`undefined`. Sending + // it would JSON-serialize to `[null]`, which Harper rejects with a 500 ('hash_values' must be + // strings or numbers) -- see #1199. Drop those so we never look a record up by a null key; the + // caller (DatabaseTableView) detects the missing key up front and shows the row read-only instead. + const validIds = ids?.filter((id) => id != null) ?? null; return queryOptions({ - queryKey: [entityId, 'search_by_id', databaseName, tableName, ids] as const, + queryKey: [entityId, 'search_by_id', databaseName, tableName, validIds] as const, queryFn: () => instanceClient.post('/', { get_attributes: ['*'], - ids, + ids: validIds, noCacheStore: true, - onlyIfCached: true, + // Fetch the actual record, not just a cached copy. Opening a row to view/edit it is a + // deliberate lookup of one record, so `onlyIfCached: true` is wrong here -- on a cache + // miss the server answers `{message: "Entry is not cached"}`, which the modal then renders + // as the "record". With this off, a key that resolves to nothing returns an empty result + // the caller can surface cleanly (see #1199). + onlyIfCached: false, operation: 'search_by_id', database: databaseName, table: tableName, }), - enabled: enabled && !!ids?.length, + enabled: enabled && !!validIds?.length, retry: false, }); } diff --git a/src/integrations/api/instance/status/getAnalytics.ts b/src/integrations/api/instance/status/getAnalytics.ts index 4f62f9908..a2122e65f 100644 --- a/src/integrations/api/instance/status/getAnalytics.ts +++ b/src/integrations/api/instance/status/getAnalytics.ts @@ -1,5 +1,5 @@ -import type { InstanceClientIdConfig, InstanceTypeConfig } from '@/config/instanceClientConfig.ts'; -import type { AnalyticsDataPoint } from '@/features/instance/status/analytics/types/analytics.ts'; +import type { InstanceClientIdConfig, InstanceTypeConfig } from '@/config/instanceClientConfig'; +import type { AnalyticsDataPoint } from '@/features/instance/status/analytics/types/analytics'; import { queryOptions } from '@tanstack/react-query'; // Analytics query feeding the spec-driven pipeline. Returns rows verbatim diff --git a/src/lib/humanFileSize.ts b/src/lib/humanFileSize.ts index 66427a3d5..300d7d288 100644 --- a/src/lib/humanFileSize.ts +++ b/src/lib/humanFileSize.ts @@ -1,4 +1,4 @@ -import { determineUnits, scaleValueToUnits } from '@/lib/units.ts'; +import { determineUnits, scaleValueToUnits } from '@/lib/units'; export function humanFileSize(input: number, multiplierFromBytes: number = 1) { const initialValue = input * multiplierFromBytes; diff --git a/src/lib/installApiUnauthorizedRedirect.ts b/src/lib/installApiUnauthorizedRedirect.ts new file mode 100644 index 000000000..26a7604b0 --- /dev/null +++ b/src/lib/installApiUnauthorizedRedirect.ts @@ -0,0 +1,59 @@ +import { apiClient } from '@/config/apiClient'; +import { clearAuthStateLocally } from '@/features/auth/handlers/clearAuthStateLocally'; +import { authStore, OverallAppSignIn } from '@/features/auth/store/authStore'; +import { makeUnauthorizedResponseHandler } from '@/lib/unauthorizedResponseHandler'; + +// The unauthenticated auth flows 401 for a bad credential or a stale +// reset/verify token (CM's verifyToken), not for a lost session — a stale +// email link opened in a second tab must not sign out a live session. +const AUTH_FLOW_PATHS = [ + '/Login/', + '/ForgotPassword/', + '/ResetPassword/', + '/VerifyEmail/', + '/ResendVerificationEmail/', +]; + +// Config key stamped by the request interceptor with the cloud identity at send +// time, so the response handler can tell whether the session has changed since. +const AUTH_STAMP = 'authUserAtSend'; + +// The cloud user's stable id, or null when signed out. LocalUser has no id. +function currentCloudUserId(): string | null { + const user = authStore.getConnectionById(OverallAppSignIn).user; + return user && 'id' in user ? user.id : null; +} + +let installed = false; + +/** + * Install the 401 -> clear-auth interceptor on the CM api client. Call once at + * app startup, before React mounts; re-installs (HMR) are no-ops so duplicate + * interceptors can't stack. On a lost session the cached auth is fully cleared + * (locally), which re-runs the route guards and redirects to /sign-in. + */ +export function installApiUnauthorizedRedirect(): void { + if (installed) { + return; + } + installed = true; + + // Stamp each request with the cloud identity at send time (see AUTH_STAMP). + apiClient.interceptors.request.use((config) => { + (config as typeof config & { [AUTH_STAMP]?: string | null })[AUTH_STAMP] = currentCloudUserId(); + return config; + }); + + apiClient.interceptors.response.use( + (response) => response, + makeUnauthorizedResponseHandler( + clearAuthStateLocally, + (url) => AUTH_FLOW_PATHS.some((path) => url.startsWith(path)), + // Only clear if the identity hasn't changed since the request was sent — + // don't let a slow 401 from the old session clear a fresh re-login. + (config) => + (config as (typeof config & { [AUTH_STAMP]?: string | null }) | undefined)?.[AUTH_STAMP] + === currentCloudUserId(), + ), + ); +} diff --git a/src/lib/monaco/workerLimits.test.ts b/src/lib/monaco/workerLimits.test.ts new file mode 100644 index 000000000..33ae328b1 --- /dev/null +++ b/src/lib/monaco/workerLimits.test.ts @@ -0,0 +1,65 @@ +import { ExtraLibBudget, MAX_EXTRA_LIB_CHARS_TOTAL, MAX_WORKER_MODEL_CHARS } from '@/lib/monaco/workerLimits'; +import { describe, expect, it } from 'vitest'; + +/** + * Admit declarations up to exactly the aggregate cap, using the largest single + * admissible file (`MAX_WORKER_MODEL_CHARS`) so it takes as few calls as + * possible. Asserts every one lands and returns the budget at the brink — one + * more char will overflow it. + */ +function fillToCap(budget: ExtraLibBudget): void { + let total = 0; + while (total + MAX_WORKER_MODEL_CHARS <= MAX_EXTRA_LIB_CHARS_TOTAL) { + expect(budget.admit(MAX_WORKER_MODEL_CHARS).admitted).toBe(true); + total += MAX_WORKER_MODEL_CHARS; + } + const remainder = MAX_EXTRA_LIB_CHARS_TOTAL - total; + if (remainder > 0) { + expect(budget.admit(remainder).admitted).toBe(true); + } +} + +describe('ExtraLibBudget (HarperFast/studio#1499)', () => { + it('admits normal declarations and accumulates their size without sealing', () => { + const budget = new ExtraLibBudget(); + expect(budget.admit(10_000)).toEqual({ admitted: true, justExhausted: false, oversize: false }); + expect(budget.admit(10_000)).toEqual({ admitted: true, justExhausted: false, oversize: false }); + expect(budget.isSpent).toBe(false); + }); + + it('admits declarations up to exactly the aggregate cap without sealing', () => { + const budget = new ExtraLibBudget(); + fillToCap(budget); + // The cap is a ceiling that can be reached, not crossed — reaching it exactly + // does not spend the budget; only an overflow does. + expect(budget.isSpent).toBe(false); + }); + + it('rejects a single oversized declaration without spending the budget', () => { + const budget = new ExtraLibBudget(); + expect(budget.admit(MAX_WORKER_MODEL_CHARS + 1)).toEqual({ + admitted: false, + justExhausted: false, + oversize: true, + }); + expect(budget.isSpent).toBe(false); + // A file within the per-file limit still fits — an oversize file is not the aggregate cap. + expect(budget.admit(10_000).admitted).toBe(true); + }); + + it('seals the budget on the first aggregate overflow and flags the transition once', () => { + const budget = new ExtraLibBudget(); + fillToCap(budget); + expect(budget.admit(1)).toEqual({ admitted: false, justExhausted: true, oversize: false }); + expect(budget.isSpent).toBe(true); + }); + + it('rejects everything once sealed — even a declaration that would have fit — without re-flagging', () => { + const budget = new ExtraLibBudget(); + fillToCap(budget); + budget.admit(1); // overflows and seals (justExhausted: true) + // A tiny file is now turned away and the exhaustion transition is not re-reported. + expect(budget.admit(1)).toEqual({ admitted: false, justExhausted: false, oversize: false }); + expect(budget.isSpent).toBe(true); + }); +}); diff --git a/src/lib/monaco/workerLimits.ts b/src/lib/monaco/workerLimits.ts index ee1d84164..c70016768 100644 --- a/src/lib/monaco/workerLimits.ts +++ b/src/lib/monaco/workerLimits.ts @@ -16,3 +16,86 @@ * payloads that add nothing to highlighting or IntelliSense. */ export const MAX_WORKER_MODEL_CHARS = 512 * 1024; + +/** + * Total-character budget for automatically-acquired `@types` declarations + * (`addExtraLib`), across the whole session. + * + * `setEagerModelSync(true)` clones *both* models *and* every extra lib to the + * language worker over `postMessage`. The model budget + * (`MAX_APPLICATION_MODEL_CHARS_TOTAL`) only sees `file:///` models — extra libs + * are invisible to it — and, unlike models, extra libs are never swept when the + * open project changes, so Automatic Type Acquisition accumulates them + * monotonically across every project opened in a session. Left unbounded, that + * volume eventually overflows the clone buffer and crashes the worker with + * "DataCloneError: Data cannot be cloned, out of memory." (HarperFast/studio#1499, + * a recurrence of #1370 through the extra-lib path the model guards don't cover). + * + * 8 MB comfortably fits a typical app's real dependency `@types` (react, + * react-dom, and friends) while still capping the pathological accumulation. + */ +export const MAX_EXTRA_LIB_CHARS_TOTAL = 8 * 1024 * 1024; + +/** The outcome of offering one acquired declaration to {@link ExtraLibBudget}. */ +export interface ExtraLibAdmission { + /** Whether the declaration was admitted (and the running total advanced). */ + admitted: boolean; + /** + * Set only on the single call that first exhausts the aggregate budget, so + * the caller can log the transition once and short-circuit later passes. + */ + justExhausted: boolean; + /** + * Set when the declaration was rejected solely because it exceeds the + * per-file limit — smaller declarations can still be admitted afterward, so + * this does not spend the budget. + */ + oversize: boolean; +} + +/** + * Session-lifetime accounting for the `@types` declarations handed to the + * language worker as extra libs (`addExtraLib`). `setEagerModelSync(true)` + * clones every extra lib to the worker, the model budget can't see them, and + * they are never swept on project switch — so this is the sole bound on their + * unbounded, cross-project accumulation (HarperFast/studio#1499). + * + * Kept free of any Monaco dependency so the accumulation logic is unit-testable + * on its own. A rejected declaration degrades its package to "cannot find + * module" — the same tradeoff `selectFilesWithinModelBudget` makes for skipped + * sibling models — which beats crashing the worker for the whole tab. + */ +export class ExtraLibBudget { + private totalChars = 0; + private spent = false; + + /** + * Whether the aggregate budget is exhausted. Once true it stays true: the + * budget is never reclaimed, so a whole acquisition pass can be skipped + * rather than woken to walk the CDN only for every file to be rejected. + */ + get isSpent(): boolean { + return this.spent; + } + + /** + * Offer a declaration of `chars` characters. Admits it (advancing the running + * total) when it fits both the per-file and aggregate limits; otherwise + * reports why it was turned away. The first aggregate overflow seals the + * budget so every later offer is rejected without further arithmetic. + */ + admit(chars: number): ExtraLibAdmission { + if (this.spent) { + return { admitted: false, justExhausted: false, oversize: false }; + } + if (chars > MAX_WORKER_MODEL_CHARS) { + return { admitted: false, justExhausted: false, oversize: true }; + } + if (this.totalChars + chars > MAX_EXTRA_LIB_CHARS_TOTAL) { + this.spent = true; + return { admitted: false, justExhausted: true, oversize: false }; + } + this.totalChars += chars; + return { admitted: true, justExhausted: false, oversize: false }; + } +} diff --git a/src/lib/storage/localStorageKeys.ts b/src/lib/storage/localStorageKeys.ts index 3d93f434e..f5dcdb330 100644 --- a/src/lib/storage/localStorageKeys.ts +++ b/src/lib/storage/localStorageKeys.ts @@ -3,6 +3,7 @@ export const enum LocalStorageKeys { 'ApplicationChatWidth' = 'ApplicationChatWidth', 'ApplicationsSidebarWidth' = 'ApplicationsSidebarWidth', 'ChatAlwaysApprovedTools' = 'ChatAlwaysApprovedTools', + 'DatabasesSidebarWidth' = 'DatabasesSidebarWidth', 'LastUsedSignInMethod' = 'LastUsedSignInMethod', 'LogsOrderReversed' = 'LogsOrderReversed', 'RememberSignInMethod' = 'RememberSignInMethod', diff --git a/src/lib/storage/sessionStorageKeys.ts b/src/lib/storage/sessionStorageKeys.ts index 50363f52f..13831848e 100644 --- a/src/lib/storage/sessionStorageKeys.ts +++ b/src/lib/storage/sessionStorageKeys.ts @@ -10,5 +10,6 @@ export interface SessionStorageKeys { 'FileSelected/{entityId}': true; 'DatabaseTreeExpanded/{entityId}': true; 'ColumnDisplayed/{database}/{table}': true; + 'ColumnSizing/{database}/{table}': true; 'ShowAllOrganizations': true; } diff --git a/src/lib/unauthorizedResponseHandler.test.ts b/src/lib/unauthorizedResponseHandler.test.ts new file mode 100644 index 000000000..bd4a51873 --- /dev/null +++ b/src/lib/unauthorizedResponseHandler.test.ts @@ -0,0 +1,97 @@ +import type { AxiosError } from 'axios'; +import { describe, expect, it, vi } from 'vitest'; +import { makeUnauthorizedResponseHandler } from './unauthorizedResponseHandler'; + +const errorWithStatus = (status?: number, url?: string, config?: Record) => + ({ + response: status === undefined ? undefined : { status }, + config: config ?? (url === undefined ? undefined : { url }), + }) as AxiosError; + +describe('makeUnauthorizedResponseHandler', () => { + it('clears auth on 401 and still rejects with the original error', async () => { + const clearAuth = vi.fn(); + const error = errorWithStatus(401); + await expect(makeUnauthorizedResponseHandler(clearAuth)(error)).rejects.toBe(error); + expect(clearAuth).toHaveBeenCalledTimes(1); + }); + + it('does not clear auth on 403 (a legitimate permission denial)', async () => { + const clearAuth = vi.fn(); + const error = errorWithStatus(403); + await expect(makeUnauthorizedResponseHandler(clearAuth)(error)).rejects.toBe(error); + expect(clearAuth).not.toHaveBeenCalled(); + }); + + it('does not clear auth on a network error with no response', async () => { + const clearAuth = vi.fn(); + const error = errorWithStatus(undefined); + await expect(makeUnauthorizedResponseHandler(clearAuth)(error)).rejects.toBe(error); + expect(clearAuth).not.toHaveBeenCalled(); + }); + + it('does not clear auth on a 401 from an exempt URL (unauthenticated auth flows)', async () => { + // A stale reset/verify-email link opened in a second tab 401s with "bad + // token" — that must not sign out a live session. + const clearAuth = vi.fn(); + const isExempt = (url: string) => url.startsWith('/ResetPassword/'); + const error = errorWithStatus(401, '/ResetPassword/'); + await expect(makeUnauthorizedResponseHandler(clearAuth, isExempt)(error)).rejects.toBe(error); + expect(clearAuth).not.toHaveBeenCalled(); + }); + + it('clears auth on a 401 from a non-exempt URL', async () => { + const clearAuth = vi.fn(); + const isExempt = (url: string) => url.startsWith('/ResetPassword/'); + const error = errorWithStatus(401, '/Organization/'); + await expect(makeUnauthorizedResponseHandler(clearAuth, isExempt)(error)).rejects.toBe(error); + expect(clearAuth).toHaveBeenCalledTimes(1); + }); + + it('treats a 401 with no request config as non-exempt (fail toward sign-out)', async () => { + const clearAuth = vi.fn(); + const isExempt = (url: string) => url.startsWith('/ResetPassword/'); + const error = errorWithStatus(401); // no config + await expect(makeUnauthorizedResponseHandler(clearAuth, isExempt)(error)).rejects.toBe(error); + expect(clearAuth).toHaveBeenCalledTimes(1); + }); + + describe('auth-generation guard (isStillCurrent)', () => { + // Simulates the identity stamp: the request carries the user it was sent + // under; the handler compares against whoever is current at response time. + const handlerFor = (currentUserId: () => string | null, clearAuth = vi.fn()) => ({ + clearAuth, + handler: makeUnauthorizedResponseHandler( + clearAuth, + () => false, + (config) => (config as { authUserAtSend?: string | null } | undefined)?.authUserAtSend === currentUserId(), + ), + }); + + it('clears when the identity is unchanged since the request was sent (normal expiry)', async () => { + const { clearAuth, handler } = handlerFor(() => 'userA'); + const error = errorWithStatus(401, undefined, { url: '/Organization/', authUserAtSend: 'userA' }); + await expect(handler(error)).rejects.toBe(error); + expect(clearAuth).toHaveBeenCalledTimes(1); + }); + + it('does NOT clear when a re-login changed the identity (slow 401 from the old session)', async () => { + // A expired → 401 handled → re-login as B. B's earlier in-flight request + // (sent as A) now 401s; current identity is B, so it must be ignored. + const { clearAuth, handler } = handlerFor(() => 'userB'); + const staleError = errorWithStatus(401, undefined, { url: '/Organization/', authUserAtSend: 'userA' }); + await expect(handler(staleError)).rejects.toBe(staleError); + expect(clearAuth).not.toHaveBeenCalled(); + }); + }); + + it('passes through an undefined rejection reason without throwing', async () => { + // An upstream interceptor could reject with a non-AxiosError (even undefined); + // the handler must re-reject with it as-is, not with its own TypeError. + const clearAuth = vi.fn(); + await expect(makeUnauthorizedResponseHandler(clearAuth)(undefined as unknown as AxiosError)).rejects.toBe( + undefined, + ); + expect(clearAuth).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/unauthorizedResponseHandler.ts b/src/lib/unauthorizedResponseHandler.ts new file mode 100644 index 000000000..74c280fbe --- /dev/null +++ b/src/lib/unauthorizedResponseHandler.ts @@ -0,0 +1,42 @@ +import type { AxiosError, InternalAxiosRequestConfig } from 'axios'; + +/** + * Build an axios response-error handler that runs `clearAuth` on a 401. + * + * A 401 from the CM API means the cloud session is gone — expired, revoked, or + * (for fabric admins) past its configured max age. Clearing the cached auth lets + * the route guards re-run and redirect to /sign-in, instead of leaving the SPA + * on a stale user while every data call fails until a manual refresh. + * + * Only 401 (unauthenticated) is treated as a lost session. 403 is deliberately + * left alone: it is a legitimate "authenticated but not permitted" response and + * must not sign the user out. `isExemptUrl` lets the installer skip endpoints + * whose 401s mean "bad credentials/token" rather than a lost session (the + * unauthenticated auth flows). `isStillCurrent` guards the auth-generation race: + * a slow 401 from a request sent under the OLD session must not clear a session + * the user has since re-established (A expires → 401 → re-login as B → B's + * earlier in-flight request 401s and would otherwise sign B out). + * + * Kept free of app imports (auth store, api client) so it unit-tests in the + * default node env without pulling in browser-only globals. + */ +export function makeUnauthorizedResponseHandler( + clearAuth: () => void, + isExemptUrl: (url: string) => boolean = () => false, + isStillCurrent: (config: InternalAxiosRequestConfig | undefined) => boolean = () => true, +) { + return (error: AxiosError): Promise => { + // `error?.`: axios itself always rejects with an AxiosError, but an upstream + // interceptor added later could reject with anything (even undefined) — don't + // let this handler replace the rejection reason with its own TypeError. + if ( + error?.response?.status === 401 + && !isExemptUrl(error.config?.url ?? '') + && isStillCurrent(error.config) + ) { + clearAuth(); + } + // Re-reject so individual callers still see (and can handle) the error. + return Promise.reject(error); + }; +} diff --git a/src/lib/units.test.ts b/src/lib/units.test.ts index fa0260539..a84fa24e4 100644 --- a/src/lib/units.test.ts +++ b/src/lib/units.test.ts @@ -1,4 +1,4 @@ -import { determineUnits, scaleValueToUnits } from '@/lib/units.ts'; +import { determineUnits, scaleValueToUnits } from '@/lib/units'; import { describe, expect, it } from 'vitest'; describe('determineUnits', () => { diff --git a/src/main.tsx b/src/main.tsx index 97496b993..36f0ef4f0 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,4 +1,5 @@ import { App } from '@/App'; +import { installApiUnauthorizedRedirect } from '@/lib/installApiUnauthorizedRedirect'; import { installBrowserTranslationDomGuard } from '@/lib/installBrowserTranslationDomGuard'; import { installStaleDeployReload } from '@/lib/installStaleDeployReload'; import { addReactError } from '@datadog/browser-rum-react'; @@ -12,6 +13,9 @@ installBrowserTranslationDomGuard(); // Reload once when a redeploy invalidates this tab's hashed chunks, instead of // leaving routes and Monaco language workers broken for the session (issue #1406). installStaleDeployReload(); +// Redirect to /sign-in when a CM call returns 401 (lost/expired session), instead +// of leaving the SPA on a stale user while every data call fails. +installApiUnauthorizedRedirect(); createRoot( document.getElementById('root')!, diff --git a/src/react-query/queryClient.test.ts b/src/react-query/queryClient.test.ts index 9d9f8a6d4..dc6882516 100644 --- a/src/react-query/queryClient.test.ts +++ b/src/react-query/queryClient.test.ts @@ -23,7 +23,11 @@ describe('errorHandler', () => { }); afterEach(() => { - consoleMock.mockReset(); + // mockRestore (not mockReset) un-installs the spy so the global + // render-phase-update tripwire's console.error wrapper is back in place + // at teardown — mockReset leaves a swallowing no-op spy installed, which + // the tripwire self-check (failOnRenderPhaseUpdate #1520) flags. + consoleMock.mockRestore(); }); it('should display default error message when no specific error info is available', () => { @@ -115,6 +119,17 @@ describe('errorHandler', () => { ); }); + // A mutation can opt out of the global toast to render its own error UX (e.g. the cloud login + // flow redirects an unverified user instead of dead-ending on a red toast). + it('skips the global toast when the mutation sets meta.skipGlobalErrorToast', async () => { + const observer = new MutationObserver(queryClient, { + mutationFn: () => Promise.reject(new Error('handled elsewhere')), + meta: { skipGlobalErrorToast: true }, + }); + await expect(observer.mutate()).rejects.toThrow('handled elsewhere'); + expect(toast.error).not.toHaveBeenCalled(); + }); + it('should display error message from generic error.message', () => { // Create a generic error with message property const genericError = { diff --git a/src/react-query/queryClient.ts b/src/react-query/queryClient.ts index bc39c33f6..0bbe45e7e 100644 --- a/src/react-query/queryClient.ts +++ b/src/react-query/queryClient.ts @@ -50,6 +50,14 @@ export const queryClient = new QueryClient({ onError: errorHandler, }), mutationCache: new MutationCache({ - onError: errorHandler, + // Every mutation error routes through the shared toast by default. A mutation can opt out + // — to render its own inline UI or redirect instead — with `meta: { skipGlobalErrorToast: true }` + // (e.g. the cloud login flow redirects an unverified user to the email-verification page). + onError: (error, _variables, _context, mutation) => { + if (mutation.meta?.skipGlobalErrorToast) { + return; + } + errorHandler(error); + }, }), }); diff --git a/src/router/rootRouteTree.ts b/src/router/rootRouteTree.ts index 750b4e9d2..59486305e 100644 --- a/src/router/rootRouteTree.ts +++ b/src/router/rootRouteTree.ts @@ -1,4 +1,5 @@ import { isLocalStudio } from '@/config/constants'; +import { adminLayoutRoute, adminRoutes } from '@/features/admin/routes'; import { authRouteTree, localAuthRoutes } from '@/features/auth/routes'; import { clusterLayoutRoute } from '@/features/cluster/clusterLayoutRoute'; import { clusterRoutes } from '@/features/cluster/routes'; @@ -21,6 +22,7 @@ export const rootRouteTree = isLocalStudio authRouteTree, dashboardLayout.addChildren([ ...profileRoutes, + adminLayoutRoute.addChildren([...adminRoutes]), orgsLayoutRoute.addChildren([ ...orgsRoutes, orgLayoutRoute.addChildren([ diff --git a/src/testSetup/failOnRenderPhaseUpdate.test.ts b/src/testSetup/failOnRenderPhaseUpdate.test.ts new file mode 100644 index 000000000..ce95a97d8 --- /dev/null +++ b/src/testSetup/failOnRenderPhaseUpdate.test.ts @@ -0,0 +1,36 @@ +// Unit coverage for the render-phase-update tripwire's self-check (#1520): the +// afterEach net-disabled detection must fire when a test replaces console.error +// and leaves it replaced, and must stay quiet when the wrapper is intact. +import { describe, expect, it } from 'vitest'; +import { detectDisabledNet, installTripwire } from './failOnRenderPhaseUpdate'; + +describe('failOnRenderPhaseUpdate — tripwire net self-check', () => { + it('installTripwire installs a forwarding wrapper and captures the original', () => { + const before = console.error; + const { wrapper, original } = installTripwire(); + try { + expect(original).toBe(before); + expect(console.error).toBe(wrapper); + expect(wrapper).not.toBe(before); + } finally { + // Put the setup's wrapper back so this test's own afterEach detection + // (which compares against that wrapper) does not flag a false disable. + console.error = before; + } + }); + + it('detectDisabledNet returns null while the installed wrapper is still in place', () => { + const wrapper = (() => {}) as typeof console.error; + expect(detectDisabledNet(wrapper, wrapper, 'some test')).toBeNull(); + }); + + it('detectDisabledNet flags a warning when console.error was replaced and not restored', () => { + const wrapper = (() => {}) as typeof console.error; + const replacement = (() => {}) as typeof console.error; // e.g. a vi.spyOn mock + const message = detectDisabledNet(replacement, wrapper, 'a test that mocks console.error'); + + expect(message).not.toBeNull(); + expect(message).toContain('DISABLED'); + expect(message).toContain('a test that mocks console.error'); + }); +}); diff --git a/src/testSetup/failOnRenderPhaseUpdate.ts b/src/testSetup/failOnRenderPhaseUpdate.ts new file mode 100644 index 000000000..6d15cc524 --- /dev/null +++ b/src/testSetup/failOnRenderPhaseUpdate.ts @@ -0,0 +1,127 @@ +// Global tripwire: fail any test during which React logs a render-phase +// cross-component update ("Cannot update a component (`X`) while rendering a +// different component (`Y`)"). This defect class ships silently — the suite +// stays green while every affected render pollutes the browser console (and +// Datadog RUM) in production. It has now bitten twice: the router-rebuild +// Transitioner warning (see CLAUDE.md, Jul 2026) and the analytics freshness +// watcher setState-ing from a QueryCache subscription (PR #1510) — both were +// only caught by manually watching a real browser console. +// +// The match is deliberately narrow (this one React message), NOT a blanket +// console.error ban: intentional error paths (PanelErrorBoundary's +// `[panel:x] render failed`, the query-cache toast handler) log through +// console.error legitimately. +// +// A test that MUST assert this warning fires (e.g. proving a repro against a +// known-bad implementation) can capture console.error itself; replacing the +// function inside the test body takes this interceptor out of the chain for +// matching calls it swallows. +// +// DEV-MODE DEPENDENCY: this tripwire only works because React emits the +// "Cannot update a component while rendering a different component" warning +// from its DEV build (via console.error). A production React build strips that +// warning entirely, so if the test runtime is ever switched to a production +// React bundle this net silently goes green — it stops catching anything +// rather than failing loudly. Keep tests on the development build of React. +// +// TRIPWIRE-NET SELF-CHECK (#1520): a test that replaces console.error +// (`vi.spyOn(console, 'error').mockImplementation(...)`) swaps this wrapper out +// of the chain, so render-phase warnings emitted while that mock is installed +// are never seen — the net is disabled for that test. Two such tests live in +// the exact subsystems this guard protects (tabs/OverviewTab, router +// preloadEvictionRepro). We can't retroactively see a warning a mock already +// swallowed, but we CAN detect at teardown that the wrapper is no longer +// installed (the test replaced console.error and didn't restore it). A hard +// failure there would break the ~4 tests that legitimately mock console.error, +// so the chosen severity is a loud `console.warn` (through the restored real +// console.error) naming the test, plus a defensive restore so the leak doesn't +// bleed into the next test. Warn, don't throw. +import { afterEach, beforeEach } from 'vitest'; + +const RENDER_PHASE_UPDATE = 'Cannot update a component'; + +interface TripwireState { + /** Render-phase-update messages captured during the current test. */ + offenders: string[]; + /** The wrapper we installed onto console.error in beforeEach — the identity + * we compare against in afterEach to tell whether the net is still armed. */ + wrapper: typeof console.error; + /** The real console.error captured before wrapping, restored in afterEach. */ + original: typeof console.error; +} + +let state: TripwireState | undefined; + +/** Wrap console.error so render-phase-update warnings are captured while still + * forwarding every call through to the real console.error. Returns the state + * needed to inspect and unwind the interception. Exported for unit testing. */ +export function installTripwire(): TripwireState { + const offenders: string[] = []; + const original = console.error; + const wrapper: typeof console.error = (...args: unknown[]) => { + if (typeof args[0] === 'string' && args[0].includes(RENDER_PHASE_UPDATE)) { + // React logs printf-style: substitute %s args so the failure + // message names the actual components. + let i = 1; + offenders.push(args[0].replace(/%s/g, () => String(args[i++] ?? '%s'))); + } + original.apply(console, args as Parameters); + }; + console.error = wrapper; + return { offenders, wrapper, original }; +} + +/** Detect whether the tripwire net was disabled during a test: at teardown, + * `current` (console.error as the test left it) should still be the `wrapper` + * we installed. When it isn't, a test replaced console.error and never put our + * wrapper back — any render-phase warning it logged went unseen. Returns the + * warning to surface, or null when the net is still armed. Exported so the + * detection is unit-testable without driving the global hooks. */ +export function detectDisabledNet( + current: typeof console.error, + wrapper: typeof console.error, + testName: string, +): string | null { + if (current === wrapper) { return null; } + return `[failOnRenderPhaseUpdate] The render-phase-update tripwire was DISABLED during "${testName}" — ` + + `console.error was replaced (e.g. vi.spyOn(console, 'error').mockImplementation) and not restored to this ` + + `setup's wrapper, so any render-phase warning logged in that test went unseen. Restore with ` + + `mockRestore() / vi.restoreAllMocks() (not mockReset, which leaves the spy installed) to keep the net armed.`; +} + +beforeEach(() => { + state = installTripwire(); +}); + +afterEach((context) => { + const s = state; + state = undefined; + if (!s) { return; } + + // Snapshot console.error as the test left it, then restore the real one + // unconditionally — even if a test leaked a mock, the next test starts clean. + const current = console.error; + console.error = s.original; + + // Surface (but don't fail on) a test that left the tripwire net disabled. + const disabledMessage = detectDisabledNet(current, s.wrapper, context.task.name); + if (disabledMessage) { + console.warn(disabledMessage); + } + + if (s.offenders.length > 0) { + const details = s.offenders.join('\n '); + const message = + `React render-phase update detected — a component setState'd (or notified a store subscriber) while another component was rendering. ` + + `Defer the notification (see useAnalyticsFreshness / notifyManager.batchCalls for the pattern):\n ${details}`; + // If the test body already failed, don't throw from the hook — a hook + // error would shadow the primary assertion failure in the report. The + // offender detail still reaches the log (console.error was restored just + // above, so call it directly); the test is red either way. + if (context.task.result?.state === 'fail') { + console.error(`[failOnRenderPhaseUpdate] ${message}`); + return; + } + throw new Error(message); + } +}); diff --git a/tsconfig.app.json b/tsconfig.app.json index cd05ded00..3b2292c96 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -17,7 +17,6 @@ ], /* Bundler mode */ "moduleResolution": "bundler", - "allowImportingTsExtensions": true, "isolatedModules": true, "moduleDetection": "force", "noEmit": true, diff --git a/tsconfig.node.json b/tsconfig.node.json index 8335873fc..b0cf4fa87 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -14,7 +14,6 @@ ], /* Bundler mode */ "moduleResolution": "bundler", - "allowImportingTsExtensions": true, "isolatedModules": true, "moduleDetection": "force", "noEmit": true, diff --git a/vitest.config.ts b/vitest.config.ts index c8ecc3cdf..ef396723e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -18,6 +18,7 @@ export default defineConfig({ '**/.claude/worktrees/**', ], setupFiles: [ + './src/testSetup/failOnRenderPhaseUpdate.ts', './src/features/instance/status/analytics/__tests__/setup.ts', './src/lib/monaco/__tests__/setup.ts', ],