diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/01-bug-report.md similarity index 64% rename from .github/ISSUE_TEMPLATE/bug_report.md rename to .github/ISSUE_TEMPLATE/01-bug-report.md index ea1ab2c92..c79ef8abc 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/01-bug-report.md @@ -1,10 +1,8 @@ --- name: Bug report -about: Create a bug report to help us improve -title: "[Bug] " -labels: bug -projects: "aws/112" -assignees: "" +about: Something isn't working as expected +labels: bug, triage +type: Bug --- ## Description @@ -37,13 +35,8 @@ assignees: "" --- - > [!IMPORTANT] -> If you are interested in working on this issue or have submitted -> a pull request, please leave a comment. +> If you are interested in working on this issue, please leave a comment. - > [!TIP] -> Please use a πŸ‘ reaction to provide a +1/vote. -> -> This helps the community and maintainers prioritize this request. +> Please use a πŸ‘ reaction to provide a +1/vote. This helps the community and maintainers prioritize this request. diff --git a/.github/ISSUE_TEMPLATE/02-feature-request.md b/.github/ISSUE_TEMPLATE/02-feature-request.md new file mode 100644 index 000000000..790f359cb --- /dev/null +++ b/.github/ISSUE_TEMPLATE/02-feature-request.md @@ -0,0 +1,35 @@ +--- +name: Feature request +about: Suggest a new feature or improvement +labels: enhancement, triage +type: Feature +--- + +## Description + + + +## Preferred Solution + + + +## Related Issues + + + +--- + +> [!IMPORTANT] +> If you are interested in working on this issue, please leave a comment. + +> [!TIP] +> Please use a πŸ‘ reaction to provide a +1/vote. This helps the community and maintainers prioritize this request. diff --git a/.github/ISSUE_TEMPLATE/03-epic.md b/.github/ISSUE_TEMPLATE/03-epic.md new file mode 100644 index 000000000..1852789f6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/03-epic.md @@ -0,0 +1,35 @@ +--- +name: Epic +about: A large feature broken into smaller tasks +labels: enhancement, triage +type: Epic +--- + +## Description + + + +## High-Level Plan + + + +## Related Issues + + + +--- + +> [!IMPORTANT] +> If you are interested in working on this issue, please leave a comment. + +> [!TIP] +> Please use a πŸ‘ reaction to provide a +1/vote. This helps the community and maintainers prioritize this request. diff --git a/.github/ISSUE_TEMPLATE/04-task.md b/.github/ISSUE_TEMPLATE/04-task.md new file mode 100644 index 000000000..9a385b606 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/04-task.md @@ -0,0 +1,15 @@ +--- +name: Task +about: A small, focused unit of work +type: Task +--- + + + +--- + +> [!IMPORTANT] +> If you are interested in working on this issue, please leave a comment. + +> [!TIP] +> Please use a πŸ‘ reaction to provide a +1/vote. This helps the community and maintainers prioritize this request. diff --git a/.github/ISSUE_TEMPLATE/05-spike.md b/.github/ISSUE_TEMPLATE/05-spike.md new file mode 100644 index 000000000..4c2f11ac5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/05-spike.md @@ -0,0 +1,31 @@ +--- +name: Spike +about: Exploratory work to produce estimates or inform planning +type: Spike +--- + +## Goal + + + +## Expected Outcome + + + +## Related Issues + + + +--- + +> [!IMPORTANT] +> If you are interested in working on this issue, please leave a comment. + +> [!TIP] +> Please use a πŸ‘ reaction to provide a +1/vote. This helps the community and maintainers prioritize this request. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..3ba13e0ce --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index ac1772f32..000000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -name: Feature request -about: Suggest an idea for improving this project -title: "[Feature Request] " -labels: enhancement -projects: "aws/112" -assignees: "" ---- - -## Description - - - -## Preferred Solution - - - -## Additional Context - - - -## Related Issues - - - -## Tasks - - - ---- - - -> [!IMPORTANT] -> If you are interested in working on this issue or have submitted -> a pull request, please leave a comment. - - -> [!TIP] -> Please use a πŸ‘ reaction to provide a +1/vote. -> -> This helps the community and maintainers prioritize this request. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 9169f0dfd..7fe3bb117 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -26,7 +26,7 @@ Please read the [Code of Conduct](https://github.com/aws/graph-explorer/blob/mai --> - [ ] I confirm that my contribution is made under the terms of the Apache 2.0 license. -- [ ] I have run `pnpm checks` to ensure code compiles and meets standards. -- [ ] I have run `pnpm test` to check if all tests are passing. +- [ ] I have verified `pnpm checks` passes with no errors. +- [ ] I have verified `pnpm test` passes with no failures. - [ ] I have covered new added functionality with unit tests if necessary. -- [ ] I have added an entry in the `Changelog.md`. +- [ ] I have updated documentation if necessary. diff --git a/.github/workflows/build_docker.yml b/.github/workflows/build_docker.yml index 51496e09c..270a358d4 100644 --- a/.github/workflows/build_docker.yml +++ b/.github/workflows/build_docker.yml @@ -15,37 +15,39 @@ on: branches: - main +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: build-and-push-image: runs-on: ubuntu-latest - permissions: - contents: read steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Get package version - uses: tyankatsu0105/read-package-version-actions@v1 - with: - path: "./" id: package-version + run: echo "version=$(node -p 'require("./package.json").version')" >> $GITHUB_OUTPUT - name: Get image tag id: get-image-tag + env: + IS_RELEASE: ${{ github.event_name == 'workflow_dispatch' && inputs.image_tag == 'release' }} + VERSION: ${{ steps.package-version.outputs.version }} run: | - if ${{ github.event_name == 'workflow_dispatch' }} ; then - if ${{ inputs.image_tag == 'release'}}; then - echo "image_tag=${{ steps.package-version.outputs.version }}" >> $GITHUB_OUTPUT - else - echo "image_tag=latest-SNAPSHOT" >> $GITHUB_OUTPUT - fi + if [ "$IS_RELEASE" = "true" ]; then + echo "image_tag=$VERSION" >> $GITHUB_OUTPUT else echo "image_tag=latest-SNAPSHOT" >> $GITHUB_OUTPUT fi - name: Configure AWS Credentials - uses: aws-actions/configure-aws-credentials@v4 + uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708 # v5.1.1 with: aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID_ECR }} aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY_ECR }} @@ -56,7 +58,7 @@ jobs: - name: Login to Amazon ECR id: login-ecr-public - uses: aws-actions/amazon-ecr-login@v2 + uses: aws-actions/amazon-ecr-login@376925c9d111252e87ae59691e5a442dd100ef6a # v2.1.3 with: registry-type: public diff --git a/.github/workflows/security-audit.yml b/.github/workflows/security-audit.yml new file mode 100644 index 000000000..439c1ec6a --- /dev/null +++ b/.github/workflows/security-audit.yml @@ -0,0 +1,61 @@ +name: Security Audit + +on: + workflow_dispatch: + push: + branches: + - main + schedule: + # Every day at 9:00 UTC + - cron: "0 9 * * *" + +permissions: + contents: read + +jobs: + dependency-audit: + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + steps: + - name: Checkout repository + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + + - name: Run Trivy filesystem scan + uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0 + with: + scan-type: fs + scan-ref: . + vuln-type: library + format: sarif + output: trivy-fs-results.sarif + + - name: Upload Trivy filesystem results to GitHub Security tab + uses: github/codeql-action/upload-sarif@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2 + with: + sarif_file: trivy-fs-results.sarif + category: dependency-scan + + docker-image-audit: + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + steps: + - name: Checkout repository + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + + - name: Scan Docker image for vulnerabilities + uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0 + with: + image-ref: public.ecr.aws/neptune/graph-explorer:latest-SNAPSHOT + vuln-type: os + format: sarif + output: trivy-image-results.sarif + + - name: Upload Trivy image results to GitHub Security tab + uses: github/codeql-action/upload-sarif@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2 + with: + sarif_file: trivy-image-results.sarif + category: docker-image-scan diff --git a/.github/workflows/test_build_docker.yml b/.github/workflows/test_build_docker.yml index e9fe5f23b..a41d37674 100644 --- a/.github/workflows/test_build_docker.yml +++ b/.github/workflows/test_build_docker.yml @@ -6,35 +6,76 @@ on: branches: - main +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: test-build-docker-image: runs-on: ubuntu-latest - permissions: - contents: read steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Build Docker image run: | docker build -t test-image . docker build -t test-image-neptune --build-arg NEPTUNE_NOTEBOOK=true . + - name: Scan Docker image for vulnerabilities + uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0 + with: + image-ref: test-image + severity: HIGH,CRITICAL + exit-code: 1 + - name: Ensure openSSL is installed run: | docker run --rm --entrypoint="" test-image openssl --version docker run --rm --entrypoint="" test-image-neptune openssl --version - - name: Verify package managers are removed + - name: Verify unnecessary packages are removed run: | docker run --rm --entrypoint="" test-image sh -c ' (command -v npm && echo "FAIL: npm found" && exit 1) || echo "βœ“ npm removed" (command -v pnpm && echo "FAIL: pnpm found" && exit 1) || echo "βœ“ pnpm removed" (command -v corepack && echo "FAIL: corepack found" && exit 1) || echo "βœ“ corepack removed" + (command -v python3 && echo "FAIL: python3 found" && exit 1) || echo "βœ“ python3 removed" + (command -v yum && echo "FAIL: yum found" && exit 1) || echo "βœ“ yum removed" + (command -v dnf && echo "FAIL: dnf found" && exit 1) || echo "βœ“ dnf removed" ' docker run --rm --entrypoint="" test-image-neptune sh -c ' (command -v npm && echo "FAIL: npm found" && exit 1) || echo "βœ“ npm removed" (command -v pnpm && echo "FAIL: pnpm found" && exit 1) || echo "βœ“ pnpm removed" (command -v corepack && echo "FAIL: corepack found" && exit 1) || echo "βœ“ corepack removed" + (command -v python3 && echo "FAIL: python3 found" && exit 1) || echo "βœ“ python3 removed" + (command -v yum && echo "FAIL: yum found" && exit 1) || echo "βœ“ yum removed" + (command -v dnf && echo "FAIL: dnf found" && exit 1) || echo "βœ“ dnf removed" ' + + - name: Verify server starts and responds + run: | + docker run -d --name test-server \ + -p 8080:80 \ + -e PROXY_SERVER_HTTPS_CONNECTION=false \ + test-image + trap 'docker rm -f test-server 2>/dev/null' EXIT + + # Wait for the server to be ready + for i in $(seq 1 30); do + if curl -sf http://localhost:8080/status; then + echo "" + echo "βœ“ Server responded on /status" + break + fi + if [ "$i" -eq 30 ]; then + echo "FAIL: Server did not respond within 30 seconds" + docker logs test-server + exit 1 + fi + sleep 1 + done diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index 9463054a2..24465aa6e 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -9,31 +9,43 @@ on: branches: - main +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: + dependency-review: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + + - name: Review dependencies for vulnerabilities + uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4.9.0 + install-and-test: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Set up pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 - name: Set up Node.js - uses: actions/setup-node@v5 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: node-version-file: "package.json" - name: Install package and dependencies - run: pnpm install + run: pnpm install --frozen-lockfile - name: Run type, lint, and format checks run: pnpm checks - name: Run package tests with code coverage run: pnpm coverage - - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v5 - with: - flags: unittests diff --git a/.gitignore b/.gitignore index e656afe21..38fd6570d 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,10 @@ **/.env.local # TypeScript build cache manifests -*.tsbuildinfo \ No newline at end of file +*.tsbuildinfo + +# Local planning docs +plans/ + +# Claude Code local files +.claude/ \ No newline at end of file diff --git a/.kiro/skills/git/SKILL.md b/.kiro/skills/git/SKILL.md new file mode 100644 index 000000000..ddcc719e1 --- /dev/null +++ b/.kiro/skills/git/SKILL.md @@ -0,0 +1,9 @@ +--- +name: git +description: Git workflow conventions for Graph Explorer. Use when making commits, creating branches, or discussing version control practices. +--- + +- Single trunk branch `main`, always releasable +- No branch or commit message prefixes (no `chore:`, `fix:`, `feature/`, etc.) +- Each commit is self-contained and cohesive β€” one logical change per commit +- Keep commit messages brief and descriptive diff --git a/.kiro/skills/github/SKILL.md b/.kiro/skills/github/SKILL.md index e2a5e4227..9a33f3776 100644 --- a/.kiro/skills/github/SKILL.md +++ b/.kiro/skills/github/SKILL.md @@ -14,10 +14,21 @@ tools: ["git", "gh", "fs_read", "grep", "glob", "web_search", "web_fetch"] ## Issues -- Always assign an issue type when creating issues: `Task`, `Bug`, `Feature`, `Epic`, or `Spike` +- Always assign an issue type when creating issues: `Bug`, `Feature`, `Epic`, `Task`, or `Spike` +- Set the issue type via the REST API after creation: + ```bash + gh api -X PATCH repos/{owner}/{repo}/issues/{number} --field type={type_name} + ``` +- Use the corresponding issue template for each type: + - `Bug` β†’ `.github/ISSUE_TEMPLATE/01-bug-report.md` + - `Feature` β†’ `.github/ISSUE_TEMPLATE/02-feature-request.md` + - `Epic` β†’ `.github/ISSUE_TEMPLATE/03-epic.md` + - `Task` β†’ `.github/ISSUE_TEMPLATE/04-task.md` + - `Spike` β†’ `.github/ISSUE_TEMPLATE/05-spike.md` - Use context from the codebase or the web to fill in additional detail when creating issues - Only apply labels from the existing set; do not create new labels - Do not add or remove labels unless explicitly requested +- For tasks and spikes, always assign a parent issue with the type bug, feature, or epic ## Sub-Issues @@ -64,3 +75,4 @@ Example: - Link to the corresponding issue when one exists (e.g., `Fixes #123`) - Keep descriptions concise and focused - Include a bulleted list of changes at the conceptual level with reasons for each change so reviewers can scan quickly +- When filling out the PR template checklist, only check an item if the action was actually performed during the current session. For example, only check `pnpm checks` or `pnpm test` if those commands were run and passed. Leave items unchecked when unsure. diff --git a/.kiro/skills/testing/SKILL.md b/.kiro/skills/testing/SKILL.md index 16db14b6a..540e4b111 100644 --- a/.kiro/skills/testing/SKILL.md +++ b/.kiro/skills/testing/SKILL.md @@ -137,8 +137,28 @@ All tests automatically inherit the configuration from `setupTests.ts`, which pr - **Cleanup**: Automatic cleanup after each test - **Global Mocks**: Intl, environment variables +The vitest config handles mock cleanup automatically between tests via `clearMocks`, `resetMocks`, `restoreMocks`, `unstubEnvs`, and `unstubGlobals`. You do not need to call these manually. + Most tests require minimal additional setup beyond what's provided automatically. +### Using `vi.doMock` with Dynamic Imports + +If a test needs `vi.doMock()` with dynamic `import()` to swap module implementations between tests, it must call `vi.resetModules()` in its own `beforeEach`. This is not done globally because it invalidates the module cache and is expensive. + +```typescript +describe("my module", () => { + beforeEach(() => { + vi.resetModules(); + }); + + test("with mock A", async () => { + vi.doMock("./dep", () => ({ value: "A" })); + const mod = await import("./myModule"); + expect(mod.result).toBe("A"); + }); +}); +``` + ### Environment Variables Tests run with `DEV=true` and `PROD=false` by default. Override when needed: diff --git a/.oxfmtrc.jsonc b/.oxfmtrc.jsonc new file mode 100644 index 000000000..330162cbc --- /dev/null +++ b/.oxfmtrc.jsonc @@ -0,0 +1,20 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "printWidth": 80, + "arrowParens": "avoid", + "ignorePatterns": ["pnpm-lock.yaml", ".claude/"], + "sortImports": { + "groups": [ + "type-import", + ["value-builtin", "value-external"], + "type-internal", + "value-internal", + ["type-parent", "type-sibling", "type-index"], + ["value-parent", "value-sibling", "value-index"], + "unknown", + ], + }, + "sortTailwindcss": { + "functions": ["cva", "cn"], + }, +} diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 000000000..316c65f83 --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,156 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": [ + "eslint", + "oxc", + "typescript", + "unicorn", + "import", + "vitest", + "promise" + ], + "options": { + "typeAware": true + }, + "categories": { + "correctness": "error" + }, + "env": { + "builtin": true + }, + "ignorePatterns": [ + "**/*.config.{js,ts,mjs}", + "**/vitest.workspace.ts", + ".claude/" + ], + "rules": { + "vitest/require-mock-type-parameters": "off", + "jest/require-to-throw-message": "off", + "jest/valid-expect": "off", + "typescript/unbound-method": "off", + "typescript/no-floating-promises": "off", + "no-unused-vars": [ + "error", + { + "argsIgnorePattern": "^_", + "varsIgnorePattern": "^_", + "caughtErrorsIgnorePattern": "^_" + } + ], + "no-console": ["error", { "allow": ["warn", "error"] }], + "typescript/consistent-type-imports": [ + "error", + { "fixStyle": "inline-type-imports" } + ], + "typescript/no-import-type-side-effects": "error", + "typescript/switch-exhaustiveness-check": "error", + "no-case-declarations": "error", + "no-empty": "error", + "no-fallthrough": "error", + "no-prototype-builtins": "error", + "no-redeclare": "error", + "no-regex-spaces": "error", + "preserve-caught-error": "error", + "no-array-constructor": "error", + "typescript/ban-ts-comment": "error", + "typescript/no-empty-object-type": "error", + "typescript/no-namespace": "error", + "typescript/no-require-imports": "error", + "typescript/no-unnecessary-type-constraint": "error", + "typescript/no-unsafe-function-type": "error", + "typescript/no-unnecessary-type-assertion": "error", + "typescript/no-unsafe-enum-comparison": "error", + "typescript/only-throw-error": "error", + "typescript/prefer-promise-reject-errors": "error", + "typescript/require-await": "error", + "typescript/restrict-plus-operands": "error" + }, + "overrides": [ + { + "files": ["**/*.ts", "**/*.tsx", "**/*.mts", "**/*.cts"], + "rules": { + "constructor-super": "off", + "no-class-assign": "off", + "no-const-assign": "off", + "no-dupe-class-members": "off", + "no-dupe-keys": "off", + "no-func-assign": "off", + "no-import-assign": "off", + "no-new-native-nonconstructor": "off", + "no-obj-calls": "off", + "no-redeclare": "off", + "no-setter-return": "off", + "no-this-before-super": "off", + "no-unsafe-negation": "off", + "no-var": "error", + "no-with": "off", + "prefer-const": "error", + "prefer-rest-params": "error", + "prefer-spread": "error" + } + }, + { + "files": ["packages/graph-explorer/**/*.{ts,tsx}"], + "plugins": ["react", "react-perf", "jsx-a11y"], + "jsPlugins": [ + "@tanstack/eslint-plugin-query", + { "name": "react-compiler", "specifier": "eslint-plugin-react-hooks" } + ], + "env": { "browser": true }, + "rules": { + "react/display-name": "off", + "react/no-unsafe": "error", + "react/react-in-jsx-scope": "off", + "jsx-a11y/no-autofocus": "off", + "jsx-a11y/click-events-have-key-events": "off", + "jsx-a11y/no-static-element-interactions": "off", + "jsx-a11y/mouse-events-have-key-events": "off", + "react-compiler/rules-of-hooks": "off", + "react-compiler/exhaustive-deps": "off", + "react-compiler/config": "error", + "react-compiler/error-boundaries": "error", + "react-compiler/gating": "error", + "react-compiler/globals": "error", + "react-compiler/immutability": "error", + "react-compiler/incompatible-library": "warn", + "react-compiler/preserve-manual-memoization": "error", + "react-compiler/purity": "error", + "react-compiler/refs": "error", + "react-compiler/set-state-in-effect": "error", + "react-compiler/set-state-in-render": "error", + "react-compiler/static-components": "error", + "react-compiler/unsupported-syntax": "warn", + "react-compiler/use-memo": "error", + "react/jsx-key": "error", + "react/jsx-no-duplicate-props": "error", + "react/jsx-no-undef": "error", + "react/no-children-prop": "error", + "react/no-danger-with-children": "error", + "react/no-direct-mutation-state": "error", + "react/no-find-dom-node": "error", + "react/no-is-mounted": "error", + "react/no-render-return-value": "error", + "react/no-string-refs": "error", + "react/jsx-no-comment-textnodes": "error", + "react/jsx-no-target-blank": "error", + "react/no-unescaped-entities": "error", + "react/no-unknown-property": "error", + "react/jsx-curly-brace-presence": "error", + "react/rules-of-hooks": "error", + "react/exhaustive-deps": "warn", + "@tanstack/query/exhaustive-deps": "error", + "@tanstack/query/no-rest-destructuring": "warn", + "@tanstack/query/stable-query-client": "error", + "@tanstack/query/no-unstable-deps": "error", + "@tanstack/query/infinite-query-property-order": "error", + "@tanstack/query/no-void-query-fn": "error", + "@tanstack/query/mutation-property-order": "error" + } + }, + { + "files": ["packages/graph-explorer-proxy-server/**/*.{ts,js}"], + "plugins": ["node"], + "env": { "node": true } + } + ] +} diff --git a/.prettierignore b/.prettierignore deleted file mode 100644 index 10d264ab8..000000000 --- a/.prettierignore +++ /dev/null @@ -1,4 +0,0 @@ -# Don't allow Prettier to change the PNPM lock file -pnpm-lock.yaml - -**/dist/ diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index 4032d79f0..000000000 --- a/.prettierrc +++ /dev/null @@ -1,6 +0,0 @@ -{ - "arrowParens": "avoid", - "proseWrap": "preserve", - "plugins": ["prettier-plugin-tailwindcss"], - "tailwindFunctions": ["cva", "cn"] -} diff --git a/.vscode/extensions.json b/.vscode/extensions.json index f3f8d0581..3c5498b9f 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -2,7 +2,7 @@ "recommendations": [ "bradlc.vscode-tailwindcss", "dbaeumer.vscode-eslint", - "esbenp.prettier-vscode", + "oxc.oxc-vscode", "vitest.explorer" ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 151b32163..ed86689be 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,5 @@ { - "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.defaultFormatter": "oxc.oxc-vscode", "editor.formatOnSave": true, "editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit" diff --git a/.zed/settings.json b/.zed/settings.json index ca9a2b2f8..ea5fc75d7 100644 --- a/.zed/settings.json +++ b/.zed/settings.json @@ -1,5 +1,15 @@ { + "format_on_save": "on", + "prettier": { "allowed": false }, + "formatter": [{ "language_server": { "name": "oxfmt" } }], "lsp": { + "oxfmt": { + "initialization_options": { + "settings": { + "fmt.configPath": null + } + } + }, "tailwindcss-language-server": { "settings": { "rootFontSize": 14, diff --git a/AGENTS.md b/AGENTS.md index 21be75eff..0d8215ab0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,14 +28,29 @@ # All checks (lint, format, types) pnpm run checks -# All tests -pnpm test +# All tests with coverage threshold enforcement +pnpm coverage # Single test file pnpm test packages/graph-explorer/src/path/to/file.test.ts # Test name pattern match pnpm test -- -t "pattern" + +# Format all files +pnpm format + +# Format specific files or globs (pass paths after --) +pnpm format -- src/foo.ts 'packages/graph-explorer/src/**/*.tsx' + +# Check formatting on specific files +pnpm check:format -- src/foo.ts + +# Lint and auto-fix all files +pnpm lint + +# Lint specific files or globs +pnpm lint -- 'packages/graph-explorer/src/**/*.ts' ``` # Project Organization @@ -62,3 +77,17 @@ pnpm test -- -t "pattern" - Visualization settings - Layout preferences - **External Data**: Graph data is queried directly from the connected graph databases and is not owned or persisted by Graph Explorer + +## Agent skills + +### Issue tracker + +Issues are tracked in GitHub Issues on aws/graph-explorer. See `docs/agents/issue-tracker.md`. + +### Triage labels + +Default label vocabulary (needs-triage, needs-info, ready-for-agent, ready-for-human, wontfix). See `docs/agents/triage-labels.md`. + +### Domain docs + +Single-context layout β€” one `CONTEXT.md` + `docs/adr/` at the repo root. See `docs/agents/domain.md`. diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 000000000..1b4287dee --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,97 @@ +# Graph Explorer + +A React-based web application that lets users visually explore graph databases without writing queries. Connects to property graph and RDF databases over HTTP. + +## Language + +**Vertex**: +A node in the graph β€” an entity with an ID, one or more types, and attributes. Called "node" in the UI to match user expectations; called "vertex" in code to avoid collision with Node.js global types. +_Avoid_: Node (in code) + +**Edge**: +A directed relationship between two vertices (source β†’ target), with a type and optional attributes. Always directed in the data model regardless of graph type. +_Avoid_: Relationship, link + +**Connection**: +A saved database profile β€” the URL, query engine, authentication settings, and proxy routing needed to reach a graph database. Users create and manage these in the UI. +_Avoid_: Configuration (legacy term being phased out β€” previously bundled connection + schema + user preferences into one object) + +**Query Language**: +The graph query protocol a Connection uses β€” one of Gremlin, openCypher, or SPARQL. Determines which Explorer is instantiated and implies the graph type (Gremlin/openCypher β†’ property graph, SPARQL β†’ RDF). +_Avoid_: Query engine (internal code name `queryEngine`, but UI says "Query Language") + +**Vertex Type**: +A classification of vertices. The schema, styling, filtering, and exploration all operate at the type level. UI label varies by query language: "Node Label" (Gremlin/openCypher), "Class" (SPARQL). + +**Edge Type**: +A classification of edges. UI label varies by query language: "Edge Label" (Gremlin), "Relationship Type" (openCypher), "Predicate" (SPARQL). + +**Neighbors**: +Vertices directly connected to a given vertex (one hop away). Users "expand neighbors" to progressively discover the graph. Neighbor counts track total vs. unfetched to indicate how much remains unexplored. +_Avoid_: Connections (ambiguous with Connection) + +**Session**: +The set of vertices and edges a user has loaded through exploration for a given Connection. Persisted to IndexedDB so users can close the browser and restore where they left off. +_Avoid_: State, workspace + +**Graph View**: +The interactive canvas where vertices and edges are visualized using Cytoscape.js. Users explore the graph here by expanding neighbors and applying layouts. Nav label: "Graph". +_Avoid_: Graph Explorer (ambiguous with the product name) + +**Data Table View**: +Tabular view of all vertices and edges currently in the Session, filterable by type. Complements the Graph View for structured browsing. +_Avoid_: Data Explorer (legacy route name) + +**Schema View**: +Visual representation of the Schema β€” shows vertex types and their edge connections as a graph. +_Avoid_: Schema Explorer (legacy route name) + +**Edge Connection**: +A schema-level pattern describing how two vertex types can be related via an edge type: sourceVertexType --[edgeType]--> targetVertexType. What the Schema View visualizes. Not an actual edge instance. +_Avoid_: Relationship (Gremlin UI term), Object Property (SPARQL UI term) + +**Property**: +A key-value pair on a Vertex or Edge. UI label varies by query language: "Property" (Gremlin/openCypher), "Datatype Property" (SPARQL). +_Avoid_: Attribute (legacy code term being phased out) + +**User Preferences**: +Display customizations per Vertex Type and Edge Type β€” shape, color, icon, line style, and display labels. Persisted and merged with Schema-discovered metadata to produce the final rendering. +_Avoid_: User styling, user settings + +**Schema Sync**: +The process that queries the database to discover vertex types, edge types, and their attributes. Required before a user can explore a new Connection. +_Avoid_: Fetch, load + +**Schema**: +The discovered structure of a connected graph database β€” vertex types, edge types, their attributes, and how they connect. Populated by Schema Sync when a Connection is first used; not user-defined. +_Avoid_: Model, structure + +## Relationships + +- A **Connection** has exactly one **Query Language** +- A **Connection** has one **Schema**, populated by **Schema Sync** +- A **Schema** contains **Vertex Types**, **Edge Types**, and **Edge Connections** +- A **Vertex** has one or more **Vertex Types** and zero or more **Properties** +- An **Edge** connects exactly two **Vertices** (source β†’ target), has one **Edge Type**, and zero or more **Properties** +- An **Edge Connection** links a source **Vertex Type** to a target **Vertex Type** via an **Edge Type** +- A **Session** belongs to a **Connection** and contains **Vertices** and **Edges** +- **Neighbors** are **Vertices** one hop away from a given **Vertex** +- **User Preferences** are scoped per **Vertex Type** and **Edge Type** +- The **Graph View**, **Data Table View**, and **Schema View** all render from the same **Session** and **Schema** + +## Example dialogue + +> **Dev:** "When a user creates a new **Connection** and opens the **Graph View**, can they start exploring immediately?" +> **Domain expert:** "No β€” a **Schema Sync** has to complete first. Until the **Schema** is populated, we don't know what **Vertex Types** or **Edge Types** exist, so there's nothing to search or filter by." + +> **Dev:** "If a user expands **Neighbors** on a vertex, do we fetch all connected vertices at once?" +> **Domain expert:** "No, it's progressive. We fetch one page at a time and track unfetched **Neighbor** counts so the user knows how much is left to explore." + +> **Dev:** "Are **User Preferences** shared across **Connections**?" +> **Domain expert:** "No β€” well, actually right now they're per **Vertex Type** and **Edge Type** globally, not scoped to a specific **Connection**. So if two databases happen to have the same type name, they'd share styling." + +## Flagged ambiguities + +- "Configuration" was used to mean both **Connection** and the bundled object (connection + schema + user preferences) β€” resolved: **Connection** is canonical, "Configuration" is legacy. +- "Node" means **Vertex** in code but is the preferred UI term for property graphs β€” resolved: use **Vertex** in code, "node" in UI copy. +- "Attribute" vs "Property" β€” resolved: **Property** is canonical, "attribute" is legacy code term being phased out. diff --git a/Changelog.md b/Changelog.md index f0652c10b..2152ece42 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,5 +1,143 @@ # Graph Explorer Change Log +## Unreleased + +- Add defaultStyling.json support for persistent per-type vertex and edge + styling (#1265, #112, #173, #573, #689) +- Add Lucide icon picker to node styling dialog + +## Release 3.0.3 + +This patch release blocks cross-origin requests by default, improves performance for larger schemas, adds inferred edge connections in search and expansion, and adds a diagnostic logging setting. + +- **Schema Explorer** β€” edge connections are now inferred from graph exploration, so the Schema Explorer shows relationships discovered through search and neighbor expansion without waiting for the database summary API +- **Performance** β€” schema operations are significantly faster for graphs with many vertex or edge types, reducing unnecessary re-renders when the schema is already up to date +- **New feature** β€” a diagnostic logging toggle in settings enables verbose console logging at runtime, even in production builds, for easier troubleshooting +- **Documentation** β€” getting started restructured as a hands-on tutorial, new configuration reference, new Neptune public endpoints guide, new architecture documentation +- **Infrastructure** β€” hardened Docker image and CI workflows, migrated to oxfmt and oxlint for faster formatting and linting, TypeScript upgraded to 6.0, proxy server runs natively without a build step + +### Cross-Origin Request Blocking + +The proxy server now blocks cross-origin requests by default instead of allowing all origins. Since the proxy server serves both the API and the UI from the same origin in all standard deployments (Docker, SageMaker, ECS Fargate), CORS is not needed. + +If you serve the UI from a different origin than the proxy server, set the `PROXY_SERVER_CORS_ORIGIN` environment variable to the UI origin. See the [Security documentation](https://github.com/aws/graph-explorer/blob/v3.0.3/docs/references/security.md#cors) for details. + +### All Changes + +- Fix bug description in v3.0.2 changelog by @kmcginnes in https://github.com/aws/graph-explorer/pull/1685 +- Harden CI workflows with SHA pinning and version bumps by @kmcginnes in https://github.com/aws/graph-explorer/pull/1686 +- Scope Trivy scans to prevent duplicate security findings by @kmcginnes in https://github.com/aws/graph-explorer/pull/1687 +- Move EC2 setup to deployment guides by @kmcginnes in https://github.com/aws/graph-explorer/pull/1689 +- Move Gremlin Server connection details to guides by @kmcginnes in https://github.com/aws/graph-explorer/pull/1690 +- Move local dev setup to development docs by @kmcginnes in https://github.com/aws/graph-explorer/pull/1691 +- Add Try It Out section and update intro for new users by @kmcginnes in https://github.com/aws/graph-explorer/pull/1692 +- Fix smart quotes and placeholder URLs in docs by @kmcginnes in https://github.com/aws/graph-explorer/pull/1693 +- Clean up tsconfig structure across monorepo by @kmcginnes in https://github.com/aws/graph-explorer/pull/1694 +- Fix broken links, invalid JSON, and unclear terminology in docs by @kmcginnes in https://github.com/aws/graph-explorer/pull/1697 +- Consolidate certificate trust instructions into security reference by @kmcginnes in https://github.com/aws/graph-explorer/pull/1698 +- Split features into separate pages and improve docs index by @kmcginnes in https://github.com/aws/graph-explorer/pull/1700 +- Add navigation back-links to all documentation leaf pages by @kmcginnes in https://github.com/aws/graph-explorer/pull/1701 +- Update TypeScript to 6.0 and eslint plugins by @kmcginnes in https://github.com/aws/graph-explorer/pull/1703 +- Fix outdated instructions and align terminology with UI by @kmcginnes in https://github.com/aws/graph-explorer/pull/1706 +- Update all dependencies to latest versions by @kmcginnes in https://github.com/aws/graph-explorer/pull/1707 +- Replace Codecov with Vitest built-in coverage thresholds by @kmcginnes in https://github.com/aws/graph-explorer/pull/1708 +- Add diagnostic logging user setting by @kmcginnes in https://github.com/aws/graph-explorer/pull/1709 +- Add Neptune public endpoints connection guide by @kmcginnes in https://github.com/aws/graph-explorer/pull/1710 +- Add Docker restart policy to deployment guides by @kmcginnes in https://github.com/aws/graph-explorer/pull/1711 +- Harden Docker image by @kmcginnes in https://github.com/aws/graph-explorer/pull/1712 +- Speed up test suite (~97s to ~55s) by @kmcginnes in https://github.com/aws/graph-explorer/pull/1717 +- Migrate proxy server to Node native TypeScript type stripping by @kmcginnes in https://github.com/aws/graph-explorer/pull/1721 +- Move Local Docker Setup to a deployment guide by @kmcginnes in https://github.com/aws/graph-explorer/pull/1723 +- Create consolidated configuration reference by @kmcginnes in https://github.com/aws/graph-explorer/pull/1724 +- Restructure getting-started page into a hands-on tutorial by @kmcginnes in https://github.com/aws/graph-explorer/pull/1732 +- Add feature highlights to the features page by @kmcginnes in https://github.com/aws/graph-explorer/pull/1733 +- Rewrite schema merge for efficiency and referential equality by @kmcginnes in https://github.com/aws/graph-explorer/pull/1735 +- Add architecture documentation and fix inaccuracies by @kmcginnes in https://github.com/aws/graph-explorer/pull/1737 +- Block cross-origin requests by default by @kmcginnes in https://github.com/aws/graph-explorer/pull/1738 +- Break circular dependency in storageAtoms by @kmcginnes in https://github.com/aws/graph-explorer/pull/1739 +- Restructure root README with clear user paths by @kmcginnes in https://github.com/aws/graph-explorer/pull/1740 +- Bump version to 3.0.3 by @kmcginnes in https://github.com/aws/graph-explorer/pull/1741 +- Update air routes sample to TinkerPop 3.8.1 by @kmcginnes in https://github.com/aws/graph-explorer/pull/1743 +- Replace O(n^2) lookups with Map-based atoms in schema state layer by @kmcginnes in https://github.com/aws/graph-explorer/pull/1744 +- Replace Prettier with oxfmt by @kmcginnes in https://github.com/aws/graph-explorer/pull/1745 +- Migrate from ESLint to oxlint by @kmcginnes in https://github.com/aws/graph-explorer/pull/1746 +- Infer edge connections from graph exploration by @kmcginnes in https://github.com/aws/graph-explorer/pull/1742 +- Bump AL2023 releasever to pick up latest glibc by @kmcginnes in https://github.com/aws/graph-explorer/pull/1749 + +## Release 3.0.2 + +This patch release fixes a bug where schema sync did not automatically trigger when switching connections and adds a new configuration option for controlling allowed origins. + +- **Bug fix** β€” schema sync now automatically triggers when switching to a connection that has no cached schema +- **Configuration** β€” new `PROXY_SERVER_CORS_ORIGIN` environment variable lets you explicitly control which origins are allowed to connect to the proxy server. See the [Security documentation](https://github.com/aws/graph-explorer/blob/v3.0.2/docs/references/security.md#cors) for details. + +### All Changes + +- Set User-Agent header on all outbound proxy requests by @kmcginnes in https://github.com/aws/graph-explorer/pull/1656 +- Add PROXY_SERVER_CORS_ORIGIN env var to configure allowed CORS origin by @kmcginnes in https://github.com/aws/graph-explorer/pull/1669 +- Update dompurify to latest version by @kmcginnes in https://github.com/aws/graph-explorer/pull/1676 +- Include connection ID in schema sync query key by @kmcginnes in https://github.com/aws/graph-explorer/pull/1682 + +## Release 3.0.1 + +This patch release improves error handling, refactors the proxy server for testability, and hardens the application protection mechanisms. Error messages surface richer diagnostics β€” status codes, response bodies, and cause chains β€” so troubleshooting is more useful. We also upgraded to Vite 8, cutting build time by 60% and bundle size by 5%. + +- **Bug fixes** β€” new empty state when no connections are configured instead of misleading "No Schema Available", Docker entrypoint now respects custom config directories (thanks @theneelshah!), Podman container permissions fix +- **Error handling** β€” the error details dialog now surfaces status codes, response bodies, and cause chains so you can diagnose issues without digging through logs. Invalid proxy requests return proper 400 errors instead of cryptic 500s. Connection failures and CORS mismatches get targeted error messages. +- **Tooling & docs** β€” Vite 8 upgrade cuts build time by 60% and bundle size by 5%, reorganized docs into guides and references, updated README +- **Application hardening** β€” tighter CORS defaults, supply chain hardening, automated vulnerability scanning, least-privilege CI permissions, and a new security policy for reporting vulnerabilities +- **Proxy server** β€” previously untestable parts of the proxy server now have 170+ tests (up from 56), making future changes safer and more reliable + +### HTTPS Configuration + +Previous versions had a bug where the Docker entrypoint ignored custom config directory paths, which could cause your HTTPS settings to be silently skipped. While fixing this, we also discovered that when HTTPS was enabled but certificate generation or discovery failed, the server would silently fall back to HTTP instead of reporting the problem. The server now exits with a clear error in this scenario. If you were unknowingly relying on this fallback behavior, ensure your certificates are in place before upgrading or explicitly disable HTTPS. See the [HTTPS Connections](https://github.com/aws/graph-explorer/blob/v3.0.1/docs/references/security.md#https-connections) documentation for details. + +### New Contributors + +Welcome and thank you to our first-time contributor! + +- @theneelshah made their first contribution in https://github.com/aws/graph-explorer/pull/1598 + +### All Changes + +- Update readme intro by @kmcginnes in https://github.com/aws/graph-explorer/pull/1567 +- Switch to proseWrap preserve by @kmcginnes in https://github.com/aws/graph-explorer/pull/1568 +- Use permalinks in the changelog by @kmcginnes in https://github.com/aws/graph-explorer/pull/1569 +- Rename additionaldocs directory to docs by @kmcginnes in https://github.com/aws/graph-explorer/pull/1570 +- Reorganize documentation into guides structure by @kmcginnes in https://github.com/aws/graph-explorer/pull/1571 +- Move reference documentation to docs/references/ by @kmcginnes in https://github.com/aws/graph-explorer/pull/1572 +- Set explicit minimum permissions on GitHub Actions workflows by @kmcginnes in https://github.com/aws/graph-explorer/pull/1573 +- Update dependencies and remove stale overrides by @kmcginnes in https://github.com/aws/graph-explorer/pull/1574 +- Bump version to 3.1.0 by @kmcginnes in https://github.com/aws/graph-explorer/pull/1587 +- Fix air routes sample permission error on Podman by @kmcginnes in https://github.com/aws/graph-explorer/pull/1588 +- Rename issue templates for consistent ordering by @kmcginnes in https://github.com/aws/graph-explorer/pull/1591 +- Update GitHub issue and PR templates by @kmcginnes in https://github.com/aws/graph-explorer/pull/1592 +- Add issue type REST API instructions to GitHub skill by @kmcginnes in https://github.com/aws/graph-explorer/pull/1597 +- Fix: use CONFIGURATION_FOLDER_PATH in docker-entrypoint.sh for custom config directory by @theneelshah in https://github.com/aws/graph-explorer/pull/1598 +- Update dependencies and remove stale fast-xml-parser override by @kmcginnes in https://github.com/aws/graph-explorer/pull/1603 +- Add git conventions skill by @kmcginnes in https://github.com/aws/graph-explorer/pull/1606 +- Change version to 3.0.1 by @kmcginnes in https://github.com/aws/graph-explorer/pull/1607 +- Clean up Docker image and add Trivy scan to CI by @kmcginnes in https://github.com/aws/graph-explorer/pull/1609 +- Show empty connection state instead of misleading 'No Schema Available' by @kmcginnes in https://github.com/aws/graph-explorer/pull/1611 +- Disable schema sync queries when no connection exists by @kmcginnes in https://github.com/aws/graph-explorer/pull/1612 +- Add GRAPH_EXP_DEV_PORT and .env.local documentation by @kmcginnes in https://github.com/aws/graph-explorer/pull/1613 +- Update dependencies to latest compatible versions by @kmcginnes in https://github.com/aws/graph-explorer/pull/1615 +- Decouple proxy server startup from module-level side effects by @kmcginnes in https://github.com/aws/graph-explorer/pull/1628 +- Add tests for process-environment.sh and fix POSIX compliance by @kmcginnes in https://github.com/aws/graph-explorer/pull/1629 +- Update lodash to latest version by @kmcginnes in https://github.com/aws/graph-explorer/pull/1631 +- Extract proxy server into testable modules by @kmcginnes in https://github.com/aws/graph-explorer/pull/1632 +- Extract SSL cert logic into testable setup-ssl.sh script by @kmcginnes in https://github.com/aws/graph-explorer/pull/1637 +- Validate graph database connection URL with Zod schema by @kmcginnes in https://github.com/aws/graph-explorer/pull/1639 +- Fail fast when HTTPS is requested but certificates are missing by @kmcginnes in https://github.com/aws/graph-explorer/pull/1640 +- Pin @tanstack/eslint-plugin-query to 5.96.2 by @kmcginnes in https://github.com/aws/graph-explorer/pull/1641 +- Improve error details with richer diagnostic information by @kmcginnes in https://github.com/aws/graph-explorer/pull/1644 +- Add security policy and security audit workflow by @kmcginnes in https://github.com/aws/graph-explorer/pull/1648 +- Remove ExplorerInjector component by @kmcginnes in https://github.com/aws/graph-explorer/pull/1649 +- Improve error details and handle proxy connection errors by @kmcginnes in https://github.com/aws/graph-explorer/pull/1650 +- Update Vite to version 8 by @kmcginnes in https://github.com/aws/graph-explorer/pull/1651 +- Harden supply chain security settings by @kmcginnes in https://github.com/aws/graph-explorer/pull/1652 +- Improve CORS defaults and upstream header forwarding by @kmcginnes in https://github.com/aws/graph-explorer/pull/1653 + ## Release 3.0.0 Graph Explorer 3.0 is here! This release brings one of the most requested features β€” the ability to visualize your graph database schema β€” along with a fresh navigation experience and a handful of quality-of-life improvements. diff --git a/Dockerfile b/Dockerfile index 065c839e0..3a0c0546f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,9 @@ FROM amazonlinux:2023 AS base ENV NODE_VERSION=24.13.0 -RUN yum update -y && \ +# Install Node.js and openssl, then remove everything not needed at runtime +# (package managers, python3, build tools) to minimize potential issues. +RUN yum update -y --releasever 2023.11.20260427 && \ yum install -y tar xz openssl && \ ARCH=$(uname -m) && \ if [ "$ARCH" = "x86_64" ]; then NODE_ARCH="x64"; \ @@ -13,7 +15,9 @@ RUN yum update -y && \ corepack enable && \ yum remove -y tar xz && \ yum clean all && \ - rm -rf /var/cache/yum + rpm -qa 'python3*' | while read pkg; do rpm -e --nodeps "$pkg"; done && \ + rpm -qa 'dnf*' 'yum*' 'libdnf*' | while read pkg; do rpm -e --nodeps "$pkg"; done && \ + rm -rf /var/cache/yum /var/cache/dnf FROM base ARG NEPTUNE_NOTEBOOK @@ -46,15 +50,17 @@ WORKDIR / COPY . /graph-explorer/ WORKDIR /graph-explorer -RUN pnpm install && \ +RUN pnpm install --frozen-lockfile && \ pnpm build && \ pnpm clean:dep && \ - pnpm install --prod --ignore-scripts && \ + pnpm install --prod --frozen-lockfile --ignore-scripts && \ npm uninstall -g npm && \ corepack disable && \ rm -rf /usr/local/bin/pnpm* /usr/local/bin/corepack && \ rm -rf $HOME/.local && \ + rm -rf $HOME/.cache && \ chmod a+x ./process-environment.sh && \ + chmod a+x ./setup-ssl.sh && \ chmod a+x ./docker-entrypoint.sh EXPOSE 443 diff --git a/README.md b/README.md index 68ae905e0..881e7cf24 100644 --- a/README.md +++ b/README.md @@ -26,188 +26,24 @@ Understand your data model at a glance. See node types, their relationships, and ![Schema Explorer showing the relationships between airport, country, continent, and version node types](./images/schema-explorer.png) -### Flexible Deployment +## Get Started -Run Graph Explorer wherever it fits your workflow: as a Docker container, on an Amazon EC2 instance, through Amazon SageMaker, or locally from source during development. Configure multiple connections to different databases and switch between them seamlessly. See [Getting Started](#getting-started) for setup guides. - -## Learn More - -- [Getting Started](./docs/getting-started/README.md) - Set up Graph Explorer with Docker, EC2, or from source -- [Features Overview](./docs/features/README.md) - Detailed guide to all features and functionality -- [Roadmap](./ROADMAP.md) - See what's planned for future releases -- [Discussions](https://github.com/aws/graph-explorer/discussions) - Ask questions, share ideas, and connect with the community -- [Submit an Issue](https://github.com/aws/graph-explorer/issues/new/choose) - Report bugs or request new features -- [Contributing](./CONTRIBUTING.md) - Learn how to contribute to Graph Explorer -- [Changelog](./Changelog.md) - See what's changed in recent releases - -## Getting Started - -There are many ways to deploy and run Graph Explorer. If you are new to graph databases and Graph Explorer, we recommend that you check out the [Getting Started](./docs/getting-started/README.md) guide. +- [Hands-on tutorial](./docs/getting-started/README.md) β€” Try Graph Explorer with sample data using Docker Compose +- [Deployment & connection guides](./docs/guides) β€” Run with Docker, EC2, ECS Fargate, or SageMaker and connect to your database +- [Development](./docs/development.md) β€” Build from source for local development -- [Local Docker Setup](./docs/getting-started/README.md#local-docker-setup) - A quick start guide to deploying Graph Explorer locally using the official Docker image. -- [Amazon EC2 Setup](./docs/getting-started/README.md#amazon-ec2-setup) - A quick start guide to setting up Graph Explorer on Amazon EC2 with Neptune. -- [Local Development](./docs/getting-started/README.md#local-development-setup) - A quick start guide building the Docker image from source code. -- [Troubleshooting](./docs/troubleshooting.md) - A collection of helpful tips if you run in to issues while setting up Graph Explorer. -- [Samples](./samples) - A collection of Docker Compose files that show various ways to configure and use Graph Explorer. +## Documentation -### Minimum Recommended Versions +See the [full documentation](./docs) for features, guides, references, and more. -Graph Explorer does not block any particular versions of graph databases, but the queries used may or may not succeed based on the version of the query engine. +## Community -For Neptune databases, we recommend [version 1.2.1.0](https://docs.aws.amazon.com/neptune/latest/userguide/engine-releases-1.2.1.0.html) or above, which include the summary API and TinkerPop 3.6.2. +- [Roadmap](./ROADMAP.md) β€” See what's planned +- [Changelog](./Changelog.md) β€” Recent releases +- [Discussions](https://github.com/aws/graph-explorer/discussions) β€” Ask questions and share ideas +- [Submit an Issue](https://github.com/aws/graph-explorer/issues/new/choose) β€” Report bugs or request features -For non-Neptune databases, we recommend at least TinkerPop 3.6. - -## Connections - -Graph Explorer supports visualizing both **property graphs** and **RDF graphs**. You can connect to Amazon Neptune or you can also connect to open graph databases that implement an Apache TinkerPop Gremlin endpoint or the SPARQL 1.1 protocol, such as Blazegraph. For additional details on connecting to different graph databases, see [Connections](./docs/connections.md). - -### Providing a Default Connection - -To provide a default connection such that initial loads of Graph Explorer always result with the same starting connection, modify the `docker run ...` command to either take in a JSON configuration or runtime environment variables. If you provide both a JSON configuration and environmental variables, the JSON will be prioritized. - -#### Environment Variables - -These are the valid environment variables used for the default connection, their defaults, and their descriptions. - -- Required: - - `PUBLIC_OR_PROXY_ENDPOINT` - `None` - See [Add a New Connection](#connections-ui) -- Optional - - `GRAPH_TYPE` - `None` - If not specified, multiple connections will be created for every available query language. See [Add a New Connection](#connections-ui) - - `USING_PROXY_SERVER` - `False` - See [Add a New Connection](#connections-ui) - - `IAM` - `False` - See [Add a New Connection](#connections-ui) - - `GRAPH_EXP_HTTPS_CONNECTION` - `True` - Controls whether Graph Explorer uses SSL or not - - `PROXY_SERVER_HTTPS_CONNECTION` - `True` - Controls whether the server uses SSL or not - - `GRAPH_EXP_FETCH_REQUEST_TIMEOUT` - `240000` - Controls the timeout for the fetch request. Measured in milliseconds (i.e. 240000 is 240 seconds or 4 minutes). - - `GRAPH_EXP_NODE_EXPANSION_LIMIT` - `None` - Controls the limit for node counts and expansion queries. -- Conditionally Required: - - Required if `USING_PROXY_SERVER=True` - - `GRAPH_CONNECTION_URL` - `None` - See [Add a New Connection](#connections-ui) - - Required if `USING_PROXY_SERVER=True` and `IAM=True` - - `AWS_REGION` - `None` - See [Add a New Connection](#connections-ui) - - `SERVICE_TYPE` - `neptune-db`, Set this as `neptune-db` for Neptune database or `neptune-graph` for Neptune Analytics. - -#### JSON Configuration Approach - -First, create a `config.json` file containing values for the connection attributes: - -```js -{ - "PUBLIC_OR_PROXY_ENDPOINT": "https://public-endpoint", - "GRAPH_CONNECTION_URL": "https://cluster-cqmizgqgrsbf.us-west-2.neptune.amazonaws.com:8182", - "USING_PROXY_SERVER": true, - "IAM": true, - "SERVICE_TYPE": "neptune-db", - "AWS_REGION": "us-west-2", - // Possible Values are "gremlin", "sparql", "openCypher" - "GRAPH_TYPE": "gremlin", - "GRAPH_EXP_HTTPS_CONNECTION": true, - "PROXY_SERVER_HTTPS_CONNECTION": true, - // Measured in milliseconds (i.e. 240000 is 240 seconds or 4 minutes) - "GRAPH_EXP_FETCH_REQUEST_TIMEOUT": 240000, - "GRAPH_EXP_NODE_EXPANSION_LIMIT": 500, -} -``` - -Pass the `config.json` file path to the `docker run` command. - -```bash -docker run -p 80:80 -p 443:443 \ - --env HOST={hostname-or-ip-address} \ - -v /path/to/config.json:/graph-explorer/config.json \ - public.ecr.aws/neptune/graph-explorer -``` - -#### Environment Variable Approach - -Provide the desired connection variables directly to the `docker run` command, as follows: - -```bash -docker run -p 80:80 -p 443:443 \ - --env HOST={hostname-or-ip-address} \ - --env PUBLIC_OR_PROXY_ENDPOINT=https://public-endpoint \ - --env GRAPH_TYPE=gremlin \ - --env USING_PROXY_SERVER=true \ - --env IAM=false \ - --env GRAPH_CONNECTION_URL=https://cluster-cqmizgqgrsbf.us-west-2.neptune.amazonaws.com:8182 \ - --env AWS_REGION=us-west-2 \ - --env SERVICE_TYPE=neptune-db \ - --env PROXY_SERVER_HTTPS_CONNECTION=true \ - --env GRAPH_EXP_FETCH_REQUEST_TIMEOUT=240000 \ - --env GRAPH_EXP_NODE_EXPANSION_LIMIT=500 \ - public.ecr.aws/neptune/graph-explorer -``` - -## Development - -For development guidance, see [Development](./docs/development.md). - -## Security - -You can use Graph Explorer to connect to a publicly accessible graph database endpoint, or connect to a proxy endpoint that redirects to a private graph database endpoint. - -Graph Explorer supports the HTTPS protocol by default and provides a self-signed certificate as part of the Docker image. You can choose to use HTTP instead by changing the [environment variable default settings](./docs/development.md#environment-variables). - -### HTTPS Connections - -If either Graph Explorer or the proxy-server are served over an HTTPS connection (which it is by default), you will have to bypass the warning message from the browser due to the included certificate being a self-signed certificate. You can bypass by manually ignoring them from the browser or downloading the correct certificate and configuring them to be trusted. Alternatively, you can provide your own certificate. The following instructions can be used as an example to bypass the warnings for Chrome, but note that different browsers and operating systems will have slightly different steps. - -1. Download the certificate directly from the browser. For example, if using Google Chrome, click the β€œNot Secure” section on the left of the URL bar and select β€œCertificate is not valid” to show the certificate. Then click Details tab and click Export at the bottom. -2. Once you have the certificate, you will need to trust it on your machine. For MacOS, you can open the Keychain Access app. Select System under System Keychains. Then go to File > Import Items... and import the certificate you downloaded in the previous step. -3. Once imported, select the certificate and right-click to select "Get Info". Expand the Trust section, and change the value of "When using this certificate" to "Always Trust". -4. You should now refresh the browser and see that you can proceed to open the application. For Chrome, the application will remain β€œNot Secure” due to the fact that this is a self-signed certificate. If you have trouble accessing Graph Explorer after completing the previous step and reloading the browser, consider running a docker restart command and refreshing the browser again. - - -> [!TIP] -> -> To get rid of the β€œNot Secure” warning, see [Using self-signed certificates on Chrome](./docs/development.md#using-self-signed-certificates-on-chrome). - -## Permissions - -Graph Explorer does not provide any mechanisms for controlling user permissions. If you are using Graph Explorer with AWS, Neptune permissions can be controlled through IAM roles. - -For information about what permissions Graph Explorer requires check out the documentation on [SageMaker configuration](./docs/sagemaker/README.md#minimum-database-permissions). - - -> [!CAUTION] -> -> By default, a Neptune Notebook will have full read & write access to Neptune data. - -## Authentication - -Authentication for Amazon Neptune connections is enabled using the [SigV4 signing protocol](https://docs.aws.amazon.com/general/latest/gr/signature-version-4.html). - -To use AWS IAM authentication, you must run requests through a proxy endpoint, such as an EC2 instance, where credentials are resolved and where requests are signed. - -To set up a connection in Graph Explorer UI with AWS IAM auth enabled on Neptune, check Using Proxy-Server, then check AWS IAM Auth Enabled and type in the AWS Region where the Neptune cluster is hosted (e.g., us-east-1). - -For further information on how AWS credentials are resolved in Graph Explorer, refer to this [documentation](https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/CredentialProviderChain.html). - -## Health Check Status - -The `graph-explorer-proxy-server` provides a `/status` endpoint for monitoring its health and readiness. This endpoint is crucial for ensuring reliable service operation and can be utilized in various deployment scenarios. - -**Key Features:** - -- **Health Check:** The `/status` endpoint serves as a basic health check, confirming that the Express server is running and responding. This is essential for load balancers (like AWS ALB) to determine if the server is operational and should receive traffic. -- **Readiness Probe:** It also functions as a readiness probe in container orchestration systems (like Kubernetes). This allows the orchestrator to know when the server is ready to accept requests, preventing traffic from being routed to instances that are still starting up or experiencing issues. -- **Expected Response:** A successful health check or readiness probe will result in an HTTP `200 OK` response with the body containing `OK`. - -## Logging - -Logs are, by default, sent to the console and will be visible as output to the docker logs. If you want to access the full set of logs, you can run `docker logs {container name or id}`. - -The log level will be set via the `LOG_LEVEL` env variable at `/packages/graph-explorer/.env` where the possible options, from highest to lowest, are `fatal`, `error`, `warn`, `info`, `debug`, `trace`, and `silent` such that `fatal` is the highest level and will only include logs labeled as fatal and `trace` the lowest and will include any type of log. The `silent` level disables all logging. - -By default, the log level is set to `info` and the only type of logs generated are those of `error`, `info`, or `debug`. If you need more detailed logs, you can change the log level from `info` in the default .env file to `debug` and the logs will begin printing the error's stack trace. - -The proxy server logging is split across a few key modules: - -1. `logging.ts` - Contains the `logger` instance (using pino) that is responsible for actually recording the logs. -2. `error-handler.ts` - Contains `errorHandlingMiddleware` which catches errors thrown within Express routes, logs whitelisted request headers, and sends appropriate error responses. It also contains a `handleError` function used for global error handling. -3. An endpoint called `/logger` in `node-server.ts` - This is how you would log things from the browser. It needs a log level and message header passed and you can then expect to see the message logged at the provided log level. - -## Contributing Guidelines +## Contributing See [CONTRIBUTING](./CONTRIBUTING.md) for more information. diff --git a/ROADMAP.md b/ROADMAP.md index 87fe33e9e..d5a3a83b9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,7 +4,6 @@ This document describes the future roadmap for Graph Explorer. We encourage you to participate in the discussion within the individual roadmap issues linked below. - > [!IMPORTANT] > These items are subject to change. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..7789af16f --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,28 @@ +# Security Policy + +## Reporting a Vulnerability + +If you discover a security vulnerability in Graph Explorer, please report it through [GitHub's Private Vulnerability Reporting][pvr]. This ensures the issue stays private while we work on a fix. + +### What to Include + +- A description of the vulnerability +- Steps to reproduce the issue +- The potential impact +- Any suggested fix (optional) + +## Response Process + +- We will acknowledge your report. +- We will provide an initial assessment. +- We will work with you on coordinated disclosure once a fix is available. + +## Supported Versions + +We provide security fixes for the latest release only. + +## Scope + +This policy applies to the Graph Explorer application itself. For vulnerabilities in third-party dependencies, please report them to the respective upstream projects. + +[pvr]: https://github.com/aws/graph-explorer/security/advisories/new diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 01c50911f..f4a9c69c8 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -1,33 +1,25 @@ #!/bin/sh +set -e ./process-environment.sh -PROXY_SERVER_HTTPS_CONNECTION_VALUE=$(grep -e 'PROXY_SERVER_HTTPS_CONNECTION' ./packages/graph-explorer/.env | cut -d "=" -f 2) +CONFIGURATION_FOLDER_PATH=${CONFIGURATION_FOLDER_PATH:-"./packages/graph-explorer/"} -if [ -n "$PROXY_SERVER_HTTPS_CONNECTION_VALUE" ] && [ "$PROXY_SERVER_HTTPS_CONNECTION_VALUE" == "true" ]; then +if [ ! -f "$CONFIGURATION_FOLDER_PATH/.env" ]; then + echo "Expected .env file not found at $CONFIGURATION_FOLDER_PATH/.env" >&2 + exit 1 +fi - if [ $HOST ]; then - echo "Generating new self-signed SSL cert using $HOST..." - cd /graph-explorer/packages/graph-explorer-proxy-server/cert-info/ - sed -i "21s/$/ $HOST:*/" csr.conf - sed -i "8s/$/ $HOST:*/" cert.conf - openssl req -x509 -sha256 -days 356 -nodes -newkey rsa:2048 -subj "/CN=Graph Explorer/C=US/L=Seattle" -keyout rootCA.key -out rootCA.crt - openssl genrsa -out ./server.key 2048 - openssl req -new -key ./server.key -out ./server.csr -config ./csr.conf - openssl x509 -req -in ./server.csr -CA ./rootCA.crt -CAkey ./rootCA.key -CAcreateserial -out ./server.crt -days 365 -sha256 -extfile ./cert.conf - else - echo "No HOST environment variable specified." - if [ -f "./rootCA.key" ] && [ -f "./rootCA.crt" ] && [ -f "./rootCA.crt" ] && [ -f "./server.csr"] && [ -f "./server.crt"]; then - echo "Found existing self-signed SSL certificate. Re-using existing cert." - else - echo "No existing self-signed SSL certificate found. Please specify --env HOST= during docker run command to create SSL cert." - exit 1 - fi - fi +PROXY_SERVER_HTTPS_CONNECTION_VALUE=$(grep -e '^PROXY_SERVER_HTTPS_CONNECTION=' "$CONFIGURATION_FOLDER_PATH/.env" | cut -d "=" -f 2 || true) +if [ -n "$PROXY_SERVER_HTTPS_CONNECTION_VALUE" ] && [ "$PROXY_SERVER_HTTPS_CONNECTION_VALUE" = "true" ]; then + CERT_DIR=/graph-explorer/packages/graph-explorer-proxy-server/cert-info \ + HOST="$HOST" \ + ./setup-ssl.sh else echo "SSL disabled. Skipping self-signed certificate generation." fi echo "Starting graph explorer..." -cd /graph-explorer/packages/graph-explorer-proxy-server && NODE_ENV=production node dist/node-server.js +# Stubbed in tests β€” update docker-entrypoint.test.ts if changing +cd /graph-explorer/packages/graph-explorer-proxy-server && NODE_ENV=production node src/node-server.ts diff --git a/docs/README.md b/docs/README.md index dee5fe59b..7e16792e5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,9 +1,8 @@ -## Table of Contents +# Graph Explorer Documentation -- [Getting Started](./getting-started) -- [Features](./features) -- [Connections](./connections.md) -- [Development](./development.md) -- [Troubleshooting](./troubleshooting.md) -- [SageMaker](./sagemaker) -- [ECS](./ecs) +- [Getting Started](./getting-started) β€” A hands-on tutorial using the air routes sample dataset. +- [Features](./features) β€” Key capabilities and detailed guides for each part of the application. +- [Guides](./guides) β€” Connect to databases, deploy to AWS, and troubleshoot common issues. +- [References](./references) β€” Security, logging, health checks, and configuration details. +- [Development](./development.md) β€” Build from source and contribute to Graph Explorer. +- [Architecture](./architecture.md) β€” System design, key libraries, and the connector pattern. diff --git a/docs/agents/domain.md b/docs/agents/domain.md new file mode 100644 index 000000000..16af79946 --- /dev/null +++ b/docs/agents/domain.md @@ -0,0 +1,36 @@ +# Domain Docs + +How the engineering skills should consume this repo's domain documentation when exploring the codebase. + +## Before exploring, read these + +- **`CONTEXT.md`** at the repo root, or +- **`CONTEXT-MAP.md`** at the repo root if it exists β€” it points at one `CONTEXT.md` per context. Read each one relevant to the topic. +- **`docs/adr/`** β€” read ADRs that touch the area you're about to work in. In multi-context repos, also check `src//docs/adr/` for context-scoped decisions. + +If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The producer skill (`/grill-with-docs`) creates them lazily when terms or decisions actually get resolved. + +## File structure + +Single-context repo (most repos): + +``` +/ +β”œβ”€β”€ CONTEXT.md +β”œβ”€β”€ docs/adr/ +β”‚ β”œβ”€β”€ 0001-event-sourced-orders.md +β”‚ └── 0002-postgres-for-write-model.md +└── src/ +``` + +## Use the glossary's vocabulary + +When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids. + +If the concept you need isn't in the glossary yet, that's a signal β€” either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/grill-with-docs`). + +## Flag ADR conflicts + +If your output contradicts an existing ADR, surface it explicitly rather than silently overriding: + +> _Contradicts ADR-0007 (event-sourced orders) β€” but worth reopening because…_ diff --git a/docs/agents/issue-tracker.md b/docs/agents/issue-tracker.md new file mode 100644 index 000000000..cce77ecbb --- /dev/null +++ b/docs/agents/issue-tracker.md @@ -0,0 +1,22 @@ +# Issue tracker: GitHub + +Issues and PRDs for this repo live as GitHub issues. Use the `gh` CLI for all operations. + +## Conventions + +- **Create an issue**: `gh issue create --title "..." --body "..."`. Use a heredoc for multi-line bodies. +- **Read an issue**: `gh issue view --comments`, filtering comments by `jq` and also fetching labels. +- **List issues**: `gh issue list --state open --json number,title,body,labels,comments --jq '[.[] | {number, title, body, labels: [.labels[].name], comments: [.comments[].body]}]'` with appropriate `--label` and `--state` filters. +- **Comment on an issue**: `gh issue comment --body "..."` +- **Apply / remove labels**: `gh issue edit --add-label "..."` / `--remove-label "..."` +- **Close**: `gh issue close --comment "..."` + +Infer the repo from `git remote -v` β€” `gh` does this automatically when run inside a clone. + +## When a skill says "publish to the issue tracker" + +Create a GitHub issue. + +## When a skill says "fetch the relevant ticket" + +Run `gh issue view --comments`. diff --git a/docs/agents/triage-labels.md b/docs/agents/triage-labels.md new file mode 100644 index 000000000..b716855d4 --- /dev/null +++ b/docs/agents/triage-labels.md @@ -0,0 +1,15 @@ +# Triage Labels + +The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's issue tracker. + +| Label in mattpocock/skills | Label in our tracker | Meaning | +| -------------------------- | -------------------- | ---------------------------------------- | +| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue | +| `needs-info` | `needs-info` | Waiting on reporter for more information | +| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent | +| `ready-for-human` | `ready-for-human` | Requires human implementation | +| `wontfix` | `wontfix` | Will not be actioned | + +When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding label string from this table. + +Edit the right-hand column to match whatever vocabulary you actually use. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 000000000..e107ea4ec --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,46 @@ +# Architecture + +Graph Explorer is a client-heavy web application with a thin backend proxy. The browser does most of the work β€” constructing queries, managing state, and rendering the graph β€” while the server handles request forwarding and signing. + +## Supported Graph Data Models and Query Languages + +- Labelled Property Graph (PG) using Gremlin or openCypher +- Resource Description Framework (RDF) using SPARQL + +## System Overview + +```mermaid +graph LR + Browser["Browser\n(React)"] -- HTTP --> Proxy["Proxy Server\n(Express)"] + Proxy -- HTTP --> DB["Graph Database\n(Neptune, etc.)"] + Browser -. direct .-> DB + Browser -- persistence --> IDB["IndexedDB\n(localforage)"] +``` + +The React client constructs queries and sends them through the proxy server, which forwards requests to the graph database. When connecting to Amazon Neptune, the proxy signs requests with AWS SigV4 credentials. For non-Neptune databases, the proxy is optional β€” the client can connect directly to a publicly accessible endpoint (shown as the dotted line above). + +The proxy does not store any user data β€” all preferences, connections, and query history live in the browser's IndexedDB. + +This split exists because browsers cannot perform SigV4 signing directly (it requires AWS credentials that should not be exposed to the client), and because the proxy can run inside a VPC alongside the database while the browser runs outside it. + +## Monorepo Structure + +The repository uses pnpm workspaces with two main packages: + +- **`packages/graph-explorer`** β€” The React client. Contains all UI components, state management, and database query logic. +- **`packages/graph-explorer-proxy-server`** β€” The Express server. Handles request proxying, SigV4 signing, HTTPS termination, and serving the built client assets. + +## Key Libraries + +| Library | Role | Why | +| --------------------------------------------------------- | ------------------- | ----------------------------------------------------------------------------- | +| [Cytoscape.js](https://js.cytoscape.org/) | Graph rendering | Mature canvas-based graph library with layout plugins and interaction support | +| [Jotai](https://jotai.org/) | Client state | Atom-based model that avoids unnecessary re-renders in a component-heavy UI | +| [TanStack Query](https://tanstack.com/query) | Remote data caching | Handles caching, deduplication, and background refresh for database queries | +| [localforage](https://localforage.github.io/localForage/) | Persistence | Provides an async IndexedDB API for storing user data client-side | + +## Connector & Explorer Pattern + +Graph Explorer supports three query languages (Gremlin, openCypher, SPARQL) through a connector abstraction. Each query language has an "explorer" that implements a common interface for operations like searching nodes, fetching neighbors, and discovering schema. + +The UI code calls the explorer interface without knowing which query language is active. The active connection's query language determines which explorer handles the request. This keeps query-language-specific logic isolated from the rest of the application. diff --git a/docs/connections.md b/docs/connections.md deleted file mode 100644 index 805002596..000000000 --- a/docs/connections.md +++ /dev/null @@ -1,44 +0,0 @@ -## Connections - -This section contains detailed instructions that help when configuring Graph Explorer with different graph database engines. - -### Connecting to Neptune - -- Ensure that Graph Explorer has access to the Neptune instance by being in the same VPC or VPC peering. -- If authentication is enabled, read query privileges are needed (See [ReadDataViaQuery managed policy](https://docs.aws.amazon.com/neptune/latest/userguide/iam-data-access-examples.html#iam-auth-data-policy-example-read-query)). - -### Connecting to Gremlin-Server - -If you are using the default Gremlin Server docker image, you can get the server running with the following commands: - -``` -docker pull tinkerpop/gremlin-server:latest -docker run -p 8182:8182 \ - tinkerpop/gremlin-server:latest \ - conf/gremlin-server-rest-modern.yaml -``` - -#### Enable REST - -Graph Explorer only supports HTTP(S) connections. When connecting to Gremlin-Server, ensure it is configured with a channelizer that supports HTTP(S) (i.e. [Channelizer Documentation](https://tinkerpop.apache.org/javadocs/current/full/org/apache/tinkerpop/gremlin/server/Channelizer.html)). - - -> [!TIP] -> The Gremlin Server configuration can be usually found at: -> -> ``` -> /conf/gremlin-server.yaml -> ``` - -#### Versions Prior to 3.7 - -If you have a version of Gremlin Server prior to 3.7, you will need to make the following changes: - -- **Enable property returns** - Remove β€œ.withStrategies(ReferenceElementStrategy)” from `/scripts/generate-modern.groovy` so that properties are returned. -- **Enable string IDs** - Change `gremlin.tinkergraph.vertexIdManager` and `gremlin.tinkergraph.edgeIdManager` in `/conf/tinkergraph-empty.properties` to support string ids. You can use `ANY`. -- Build and run the Docker container as normal. - -### Connecting to BlazeGraph - -- Build and run the Docker container as normal and connect the proxy-server to BlazeGraph and your workbench to the proxy-server. -- If using Docker, ensure that the container running the workbench can properly access the container running BlazeGraph. You can find documentation on how to connect containers via [Docker networks](https://docs.docker.com/network/). diff --git a/docs/development.md b/docs/development.md index 52e6c6a67..76bdaef5b 100644 --- a/docs/development.md +++ b/docs/development.md @@ -1,13 +1,13 @@ -## Development +# Development -This developer README details instructions for building on top of the graph-explorer application, or for configuring advanced settings, like using environment variables to switch to HTTP. +Build instructions and development setup for contributing to Graph Explorer. For system design and key libraries, see [Architecture](./architecture.md). -### Requirements +## Requirements - pnpm >=10.28.1 - node >=24.13.0 -#### Node Version +### Node Version Ensure you are running the correct Node version. If you are using [NVM](https://github.com/nvm-sh/nvm), you can simply do: @@ -17,7 +17,7 @@ nvm use Otherwise, use whatever method you use to install [Node v24.13.0](https://nodejs.org/en/download). -#### Node Corepack +### Node Corepack [Corepack](https://nodejs.org/api/corepack.html) is used to ensure the package manager used for the project is consistent. @@ -25,12 +25,9 @@ Otherwise, use whatever method you use to install [Node v24.13.0](https://nodejs corepack enable ``` -### Supported Graph Data Models and Query Languages +If `corepack` is not found, install it first with `npm install -g corepack@latest`. -- Labelled Property Graph (PG) using Gremlin or openCypher -- Resource Description Framework (RDF) using SPARQL - -### Run in development mode +## Run in development mode Install any missing or updated dependencies. @@ -52,29 +49,42 @@ http://localhost:5173 At this point, Graph Explorer should be successfully running and it is asking you for connection details. This part is specific to your personal setup. -### Build for production - -Building Graph Explorer is simple. +## Build for production ```bash pnpm install pnpm build ``` -This will run the build across the both the client code and the proxy server code. You'll end up with two `dist` folders: +This builds the React client into static assets at `packages/graph-explorer/dist/`. The proxy server has no build step β€” Node runs its TypeScript source directly using [native type stripping](https://nodejs.org/en/learn/typescript/run-natively#native-type-stripping). + +Start the proxy server, which also serves the built client assets: +```bash +pnpm start ``` -{ROOT_PATH}/packages/graph-explorer/dist/ -{ROOT_PATH}/packages/graph-explorer-proxy-server/dist/ + +## Build and run with Docker + +You can also build and run Graph Explorer as a Docker image from source. + +```bash +docker build -t graph-explorer . ``` -The recommended way to serve Graph Explorer is using the proxy server. +Run the container with HTTPS disabled for local use: ```bash -pnpm start +docker run -p 80:80 \ + --name graph-explorer \ + --env PROXY_SERVER_HTTPS_CONNECTION=false \ + --env GRAPH_EXP_HTTPS_CONNECTION=false \ + graph-explorer ``` -### Managing dependencies +Then open [http://localhost/explorer](http://localhost/explorer) in your browser. + +## Managing dependencies If you need to add, remove, or update a dependency you can easily do so from the root folder in the CLI: @@ -86,93 +96,69 @@ pnpm add react --filter graph-explorer pnpm add -D vitest --filter graph-explorer-proxy-server ``` -#### Preparation of a release +### Preparation of a release -This repository is composed by 3 packages and a mono-repository structure itself. Then, you need to take into account 4 different `package.json` files: +This repository is composed of 3 packages and a mono-repository structure itself. Then, you need to take into account 4 different `package.json` files: - `/package.json` is intended to keep the dependencies for managing the repository. It has utilities like linter, code formatter, or git checks. - `/packages/graph-explorer/package.json` is the package file that describes the UI client package. - `/packages/graph-explorer-proxy-server/package.json` is the package file for the node server which is in charge of authentication and redirection of requests. - `/packages/shared/package.json` is the package file for shared code between the client and server packages. -Each of these `package.json` files has an independent `version` property. However, in this project we should keep them correlated. Therefore, when a new release version is being prepared, the version number should be increased in all 4 files. Regarding the version number displayed in the user interface, it is specifically extracted from the `/packages/graph-explorer/package.json`. file - -### Environment variables +Each of these `package.json` files has an independent `version` property. However, in this project we should keep them correlated. Therefore, when a new release version is being prepared, the version number should be increased in all 4 files. Regarding the version number displayed in the user interface, it is specifically extracted from the `/packages/graph-explorer/package.json` file. -#### `GRAPH_EXP_ENV_ROOT_FOLDER` +## Supply chain security -Base path used to serve the `graph-explorer` front end application. +The `pnpm-workspace.yaml` file includes several settings that harden the project against supply chain attacks. These may cause `pnpm install` to fail when adding new dependencies, which is intentional. -Example: `/explorer` +- **`minimumReleaseAge`** β€” Newly published package versions are blocked for 24 hours, giving the community time to discover and report compromised releases. +- **`strictDepBuilds`** β€” Any dependency that tries to run a build script (e.g. `postinstall`) will cause installation to fail unless it is explicitly listed in `onlyBuiltDependencies` or `ignoredBuiltDependencies`. +- **`blockExoticSubdeps`** β€” Transitive dependencies cannot resolve to git repositories or raw tarball URLs. Only direct dependencies in `package.json` may use exotic sources. +- **`trustPolicy`** β€” Refuses to install a package version whose publish-time trust evidence (provenance, signatures) is weaker than a previously published version of that package. -- Optional -- Default: `/` -- Type: `string` - -#### `HOST` +If `pnpm install` fails due to one of these checks, evaluate whether the dependency is safe and update `pnpm-workspace.yaml` accordingly. -The public hostname of the server. This is used to generate the SSL certificate during the Docker build. +## Local environment overrides -Example: `localhost` +Create a `.env.local` file in `packages/graph-explorer/` to override environment variables without modifying tracked files. This file is gitignored and will not be committed. -- Required when using HTTPS connections -- Default is `localhost` -- Type: `string` +Example `packages/graph-explorer/.env.local`: -#### `GRAPH_EXP_HTTPS_CONNECTION` +``` +GRAPH_EXP_DEV_PORT=5174 +PROXY_SERVER_HTTP_PORT=8082 +``` -Uses the self-signed certificate to serve Graph Explorer over https if true. Only used in Docker via the entrypoint script. +## Environment variables -- Optional -- Default `true` in Docker, not set otherwise -- Type: `boolean` +See the [Configuration Reference](./references/configuration.md) for all available environment variables including application settings and default connection options. -#### `PROXY_SERVER_HTTPS_PORT` +## Development-only environment variables -The port to use for the HTTPS server. +These variables only affect the local development server (`pnpm dev`) and have no effect on production builds or Docker. -- Optional -- Default `443` -- Type: `number` +### `GRAPH_EXP_DEV_PORT` -#### `PROXY_SERVER_HTTP_PORT` +Sets a fixed port for the Vite development server. When set, `strictPort` is enabled β€” Vite will fail with an error if the port is already in use rather than silently selecting another port. This ensures the dev server runs on the exact port you intend. -The port to use for the HTTP server. +Example: `5174` - Optional -- Default `80` +- Default: Vite's default behavior (auto-selects an available port starting at 5173) - Type: `number` -#### `PROXY_SERVER_HTTPS_CONNECTION` - -Uses the self-signed certificate to serve the proxy-server over https if true. - -- Optional -- Default `false` in code, `true` in Docker via the entrypoint script -- Type: `boolean` - -### Using self-signed certificates with Docker +## Using self-signed certificates with Docker - Self-signed certificates will use the hostname provided in the `docker run` command, so unless you have specific requirements, there are no extra steps here besides providing the hostname. - If you would like to modify the certificate files, be aware that the Dockerfile will make automatic modifications on run in the [entrypoint script](https://github.com/aws/graph-explorer/blob/main/docker-entrypoint.sh), so you will need to remove these lines. - If you only serve one of either the proxy server or Graph Explorer UI over an HTTPS connection and wish to download from the browser, you should navigate to the one served over HTTPS to download the certificate. - The other certificate files can also be found at /packages/graph-explorer-proxy-server/cert-info/ on the Docker container that is created. -### Using Self-signed certificates on Chrome - -For browsers like Safari and Firefox, [trusting the certificate from the browser](../README.md/#https-connections) is enough to bypass the β€œNot Secure” warning. However, Chrome treats self-signed certificates differently. If you want to use a self-signed certificate on Chrome **without** the β€œNot Secure” warning and you do not have your own certificate, or one provided by Let’s Encrypt, you can use the following instructions to add the root certificate and remove the warning. These instructions assume you’re using an EC2 instance to run the Docker container for Graph Explorer. +## Using self-signed certificates on Chrome -1. After the Docker container is built and running, open a terminal prompt and SSH into your proxy server instance (e.g., EC2). -2. Get the container ID by running `sudo docker ps` -3. Copy the root certificate file (rootCA.crt) from the container to EC2: `sudo docker cp {container_id}:~/graph-explorer/packages/graph-explorer-proxy-server/cert-info/rootCA.crt ~/rootCA.crt` -4. Exit SSH session -5. Copy the root certificate file from EC2 to your local machine (where you would run Graph Explorer on Chrome): `scp -i {path_to_pem_file} {EC2_login}:~/rootCA.crt {path_on_local_to_place_file}` For example, `scp -i /Users/user1/EC2.pem ec2-user@XXX.XXX.XXX.XXX:~/rootCA.crt /Users/user1/downloads` -6. After copying the certificate from the container to your local machine’s file system, you can delete the rootCA.crt file from the EC2 file store with `rm -rf ~/rootCA.crt` -7. Once you have the certificate, you will need to trust it on your machine. For MacOS, you can open the Keychain Access app. Select System under System Keychains. Then go to File > Import Items... and import the certificate you downloaded in the previous step. -8. Once imported, select the certificate and right-click to select "Get Info". Expand the Trust section, and change the value of "When using this certificate" to "Always Trust". -9. You should now refresh the browser and see that you can proceed to open the application. For Chrome, the application will remain β€œNot Secure” due to the fact that this is a self-signed certificate. If you have trouble accessing Graph Explorer after completing the previous step and reloading the browser, consider running a docker restart command and refreshing the browser again. +See [Removing the "Not Secure" warning on Chrome](./references/security.md#removing-the-not-secure-warning-on-chrome) in the security reference. -### Troubleshooting +## Troubleshooting - If you need more detailed logs, you can change the log level from `info` in the default .env file to `debug`. The logs will begin printing the error's stack trace. - If Graph Explorer crashes, you can recreate the container or run `pnpm start` diff --git a/docs/features/README.md b/docs/features/README.md index e48b0b4c9..eb154199b 100644 --- a/docs/features/README.md +++ b/docs/features/README.md @@ -1,189 +1,41 @@ # Features -If you are interested in where Graph Explorer is headed in the future then check out our [roadmap](../../ROADMAP.md) and [participate in the discussions](https://github.com/aws/graph-explorer/discussions). +Graph Explorer provides a visual interface for exploring and analyzing graph data without writing queries. -## Settings UI +## Feature Highlights -### General Settings +### Multiple Query Languages -- **Default Neighbor Expansion Limit:** This setting will allow you to enable or disable the default limit applied during neighbor expansion. This applies to both double click expansion and the expand sidebar. This setting can be overridden by a similar setting on the connection itself. -- **Save Configuration:** This action will export all the configuration data within the Graph Explorer local database. This will not store any data from the connected graph databases. However, the export may contain the shape of the schema for your databases and the connection URL. -- **Load Configuration:** This action will replace all the Graph Explorer configuration data you currently have with the data in the provided configuration file. This is a destructive act and can not be undone. It is **strongly** suggested that you perform a **Save Configuration** action before performing a **Load Configuration** action to preserve any existing configuration data. +Graph Explorer supports property graphs via [Gremlin](https://tinkerpop.apache.org/gremlin.html) and [openCypher](https://opencypher.org), as well as RDF graphs via [SPARQL](https://www.w3.org/TR/sparql11-overview/). Connect to [Amazon Neptune](https://aws.amazon.com/neptune/), [Apache TinkerPop Gremlin Server](https://tinkerpop.apache.org/), [JanusGraph](https://janusgraph.org/), or any database that supports these protocols over HTTP. -### About +### No Centralized Database -In the _About_ page you can see the version number and submit any feedback. +All user data β€” connections, preferences, layout settings, and query history β€” is stored client-side in the browser using IndexedDB. The proxy server handles SigV4 request signing and request routing but does not store any user data. There is no external database to set up or manage. -## Connections UI +### Interactive Graph Exploration -You can create and manage connections to graph databases using this feature. Connections is accessible as the first screen after deploying the application or by clicking `Connections` in the navigation bar. Click `+` on the top-right to add a new connection. You can also edit and delete connections. +Double-click any node to expand its first-order neighbors directly on the canvas. Use the Expand sidebar panel for more control: filter by neighbor type, narrow by attribute value, or limit the number of results returned. As the graph grows, use the Entities Filter to show or hide specific node and edge types without removing them from the graph. -- **Add a new connection:** - - **Name:** Enter a name for your connection (e.g., `MyNeptuneCluster`). - - **Query Language:** Choose a query language that corresponds to your graph database. - - **Public or proxy endpoint:** Provide the publicly accessible endpoint URL for a graph database, e.g., Gremlin Server. If connecting to Amazon Neptune, then provide a proxy endpoint URL that is accessible from outside the VPC, e.g., EC2. - - **Note:** For connecting to Amazon Neptune, ensure that the graph connection URL is in the format `https://[NEPTUNE_ENDPOINT]:8182`, and that the proxy endpoint URL is either `https://[EC2_PUBLIC_HOSTNAME]:443` or `http://[EC2_PUBLIC_HOSTNAME]:80`, depending on the protocol used. Ensure that you don't end either of the URLs with `/`. - - **Using proxy server:** Check this box if using a proxy endpoint. - - **Graph connection URL:** Provide the endpoint for the graph database - - **AWS IAM Auth Enabled:** Check this box if connecting to Amazon Neptune using IAM Auth and SigV4 signed requests - - **Service Type:** Choose the service type - - **AWS Region:** Specify the AWS region where the Neptune cluster is hosted (e.g., us-east-1) - - **Fetch Timeout:** Specify the timeout for the fetch request - - **Neighbor Expansion Limit:** Specify the default limit for neighbor expansion. This will override the app setting for neighbor expansion. +### Query Editor -- **Available Connections:** Once a connection is created, this section will appear as a left-hand pane. When you create more than one connection to a graph database, you can only connect to and visualize from one graph database endpoint at a time. To select the active database, toggle the "Active" switch. +Execute raw Gremlin, openCypher, or SPARQL queries directly from the Search panel's Query tab. Results are displayed inline and can be added to the graph individually or all at once. -- **Connection Details:** Once a connection is created, this section will appear as a right-hand information pane for a selected connection. It shows details such as the connection name, query language, endpoint and a summary of the graph data, such as the count of nodes, edges, and a list of node types. -- **Last Synchronization:** When a connection is created, Graph Explorer will perform a scan of the graph to provide summary data. To re-synchronize after data has changed on your graph, select a connection, and then click the "refresh" button next to "Last Synchronization" text. -- **Data Table:** Under a listed node type, you can click on the ">" arrow to get to the "Data Table" view. This allows you to see a sample list of nodes under this type and choose one or more nodes to "Send to Explorer" for getting started quickly if you are new to the data. You can also navigate directly to the Data Table view using the "Data Table" link in the navigation bar. +### Customizable Styles -## Graph View +Personalize how each node and edge type appears on the canvas. Change colors, shapes, borders, icons, and which property is displayed as the label. You can also rename how type names are displayed throughout the application β€” for example, show the `airport` label as "Airport" or a `route` edge as "Flies To" β€” without modifying the underlying data. -You can search, browse, expand, customize views of your graph data using the Graph view, which is the main view of this application. Once you create a connection, you can click "Graph" in the navigation bar to navigate here. There are several key features on this UI: +### Save & Load Graph -- **Toggle view visibility** The graph and table views can be hidden to allow the other to expand. +Export the current graph as a JSON file to save your work, share it with a colleague, or reload it later. Anyone with the same database connection can import the file and pick up exactly where you left off. -### Graph View UI +## Feature Details -The graph visualization canvas that you can interact with. Double-click to expand the first-order neighbors of a node. +For detailed documentation on each part of the application: -- **Layout drop-down & reset:** You can display graph data using standard graph layouts in the Graph View. You can use the circular arrow to reset the physics of a layout. -- **Screenshot:** Download a picture of the current window in Graph View. -- **Save Graph:** Save the current rendered graph as a JSON file that can be shared with others having the same connection or reloaded at a later time. -- **Load Graph:** Load a previously saved graph from a JSON file. -- **Zoom In/Out & Clear:** To help users quickly zoom in/out or clear the whole canvas in the Graph View. -- **Legend (i):** This displays an informational list of icons, colors, and display names available. +- [Connections](./connections.md) β€” Create and manage connections to graph databases. +- [Graph View](./graph-view.md) β€” Search, browse, expand, and customize views of your graph data. +- [Data Table](./data-table.md) β€” View tabular data for specific node types. +- [Schema View](./schema-view.md) β€” Visualize the schema of your graph database. +- [Settings](./settings.md) β€” Configure application-wide preferences. -### Sidebar Panel UI - -The panel on the right of the graph provides various actions, configuration, and details about the open graph. - -- [**Search panel**](#search-panel) allows you to search for specific nodes by filtering on node types & attributes or executing a database query then adding nodes & edges to the graph panel. -- [**Details panel**](#details-panel) shows details about a selected node/edge such as properties etc. -- [**Entities filter panel**](#entities-filter-panel) is used to control the display of nodes and edges that are already expanded in the Graph View; click to hide or show nodes/edges. -- [**Expand panel**](#expand-panel) provides controls and filters to help focus large neighbor expansions. -- [**Node styling panel**](#node-styling-panel) of node display options (e.g., color, icon, the property to use for the displayed name). -- [**Edge styling panel**](#edge-styling-panel) of edge display options (e.g., color, icon, the property to use for the displayed name). -- [**Namespaces panel (RDF only)**](#namespace-panel) allows you to shorten the display of Resource URIs within the app based on auto-generated prefixes, commonly-used prefix libraries, or custom prefixes set by the user. Order of priority is set to Custom > Common > Auto-generated. - -#### Search Panel - -The Search UI provides two powerful ways to search and interact with your graph database: - -##### Filter Search - -- Enables faceted filtering of nodes based on: - - Node labels (or rdf:type for RDF databases) - - Node attribute values -- Supports partial text matching -- Search results can be added to the graph individually or all at once -- Supports cancellation of long-running queries - -##### Query Search - -- Allows execution of any valid database query, including mutations -- When adding an edge, its connected nodes are automatically included -- Displays scalar values in results (though these cannot be added to the graph) -- Paginates large result sets -- Results can be added to the graph individually or all at once -- Supports cancellation of long-running queries -- Some limitations exist for SPARQL queries - - No support for `INSERT` and `DELETE` queries - - No support for blank node results - - `SELECT` queries only returns scalar values, even for resource URIs - -#### Details Panel - -- Displays attributes and values for node or edge selections -- Displays neighbor counts by label for node selections -- Displays relationship information for edge selections - -#### Expand Panel - -Provides fine grained control over neighbor expansions - -- Filter by node label (or rdf:type for RDF databases) -- Filter by attribute value -- Limit results to a maximum size - -#### Entities Filter Panel - -Provides the ability to filter nodes or edges from the visualization by label (or rdf:type for RDF databases) - -#### Node Styling Panel - -Each node type can be customized in a variety of ways. - -- **Display label** allows you to change how the node label (or rdf:type) is represented -- **Display name attribute** allows you to choose the attribute on the node that is used to uniquely label the node in the graph visualization and search -- **Display description attribute** allows you to choose the attribute on the node that is used to describe the node in search -- **Custom symbol** can be uploaded in the form of an SVG icon -- **Colors and borders** can be customized to visually distinguish from other node types - -#### Edge Styling Panel - -Each edge type can be customized in a variety of ways. - -- **Display label** allows you to change how the edge label (or predicate in RDF databases) is represented -- **Display name attribute** allows you to choose the attribute on the edge that is used to uniquely label the edge in the graph visualization and search -- **Arrow symbol** can be chosen for both source and target variations -- **Colors and borders** can be customized for the edge label and the line -- **Line style** can be solid, dotted, or dashed - -#### Namespace Panel - -- Only visible in RDF connections -- Displays any automatically generated namespace prefixes for the connection -- Displays all the common prefixes that are built-in -- Allows creation and management of any custom namespace prefixes - -### Table View UI - -This collapsible view shows a row-column display of the data in the Graph View. You can use filters in the Table to show/hide elements in the Graph View, and you can export the table view into a CSV or JSON file. - -The following columns are available for filtering on property graphs (RDF graphs in parentheses): - -- Node ID (Resource URI) -- Node Type (Class) -- Edge Type (Predicate) -- Source ID (Source URI) -- Source Type (Source Class) -- Target ID (Target URI) -- Target Type (Target Class) -- Display Name - Set in the Node/Edge Styling panes -- Display Description - Set in the Node/Edge Styling panes -- Total Neighbors - Enter an integer to be used as the >= limit - -#### Additional Table View UI Features - -- **Visibility** - manually show or hide nodes or edges -- **All Nodes / All Edges (or All Resources / All Predicates) dropdown** - allows you to display a list of either nodes or edges and control display/filter on them -- **Download** - You can download the current Table View as a CSV or JSON file with additional customization options -- **Default columns** - You can set which columns you want to display -- Paging of rows - -## Data Table View - -You can use the Data Table view to view the data for the selected node type. You can open the Data Table view by clicking "Data Table" in the navigation bar or by clicking the node type row in the connection details pane. - -- Select a node type from the dropdown to view its data -- View tabular data for the selected node type -- Set the node type display name and description attributes -- Export the current table data to a CSV or JSON file -- Send a specific node to the graph view - -## Schema View - -The Schema view visualizes the schema of your graph database as an interactive graph. Node types are displayed as nodes and their relationships (edge connections) are displayed as edges between them. - -You can open the Schema view by clicking "Schema" in the navigation bar. - -- View all node types and their relationships as a graph -- Select a node type to view its properties and data types -- Select an edge connection to view its source type, edge type, and target type -- Choose from multiple graph layout algorithms -- Take a screenshot of the schema graph - -> [!NOTE] -> -> The schema and data types shown in the Schema view are inferred from samples of nodes and edges returned by queries. They may not be 100% accurate or complete, especially for large or diverse datasets. As you explore more data, the schema will grow more complete over time. +If you are interested in where Graph Explorer is headed in the future, check out our [roadmap](../../ROADMAP.md) and [participate in the discussions](https://github.com/aws/graph-explorer/discussions). diff --git a/docs/features/connections.md b/docs/features/connections.md new file mode 100644 index 000000000..5c891db5b --- /dev/null +++ b/docs/features/connections.md @@ -0,0 +1,37 @@ +[← Features](./) + +# Connections + +You can create and manage connections to graph databases using this feature. Connections is accessible as the first screen after deploying the application or by clicking `Connections` in the navigation bar. Click `+` on the top-right to add a new connection. You can also edit and delete connections. + +For guides on connecting to specific databases, see [Connecting to databases](../guides#connecting-to-databases). + +## Add a New Connection + +- **Name:** Enter a name for your connection (e.g., `MyNeptuneCluster`). +- **Query Language:** Choose a query language that corresponds to your graph database. +- **Public or proxy endpoint:** Provide the publicly accessible endpoint URL for a graph database, e.g., Gremlin Server. If connecting to Amazon Neptune, then provide a proxy endpoint URL that is accessible from outside the VPC, e.g., EC2. + - **Note:** For connecting to Amazon Neptune, ensure that the graph connection URL is in the format `https://[NEPTUNE_ENDPOINT]:8182`, and that the proxy endpoint URL is either `https://[EC2_PUBLIC_HOSTNAME]:443` or `http://[EC2_PUBLIC_HOSTNAME]:80`, depending on the protocol used. Ensure that you don't end either of the URLs with `/`. +- **Using proxy server:** Check this box if using a proxy endpoint. +- **Graph connection URL:** Provide the endpoint for the graph database +- **AWS IAM Auth Enabled:** Check this box if connecting to Amazon Neptune using IAM Auth and SigV4 signed requests +- **Service Type:** Choose the service type +- **AWS Region:** Specify the AWS region where the Neptune cluster is hosted (e.g., us-east-1) +- **Fetch Timeout:** Specify the timeout for the fetch request +- **Neighbor Expansion Limit:** Specify the default limit for neighbor expansion. This will override the app setting for neighbor expansion. + +## Available Connections + +Once a connection is created, this section will appear as a left-hand pane. When you create more than one connection to a graph database, you can only connect to and visualize from one graph database endpoint at a time. To select the active database, toggle the "Active" switch. + +## Connection Details + +Once a connection is created, this section will appear as a right-hand information pane for a selected connection. It shows details such as the connection name, query language, endpoint and a summary of the graph data, such as the count of nodes, edges, and a list of node types. + +### Last Synchronization + +When a connection is created, Graph Explorer will perform a scan of the graph to provide summary data. To re-synchronize after data has changed on your graph, select a connection, and then click the "refresh" button next to "Last Synchronization" text. + +### Data Table + +Under a listed node type, you can click on the ">" arrow to get to the [Data Table](./data-table.md) view. This allows you to see a sample list of nodes under this type and choose one or more nodes to "Send to Explorer" for getting started quickly if you are new to the data. You can also navigate directly to the Data Table view using the "Data Table" link in the navigation bar. diff --git a/docs/features/data-table.md b/docs/features/data-table.md new file mode 100644 index 000000000..b8fc926c4 --- /dev/null +++ b/docs/features/data-table.md @@ -0,0 +1,11 @@ +[← Features](./) + +# Data Table + +You can use the Data Table view to view the data for the selected node type. You can open the Data Table view by clicking "Data Table" in the navigation bar or by clicking the node type row in the [connection details](./connections.md#connection-details) pane. + +- Select a node type from the dropdown to view its data +- View tabular data for the selected node type +- Set the node type display name and description attributes +- Export the current table data to a CSV or JSON file +- Send a specific node to the [Graph View](./graph-view.md) diff --git a/docs/features/graph-view.md b/docs/features/graph-view.md new file mode 100644 index 000000000..ee09b9e9c --- /dev/null +++ b/docs/features/graph-view.md @@ -0,0 +1,126 @@ +[← Features](./) + +# Graph View + +You can search, browse, expand, customize views of your graph data using the Graph view, which is the main view of this application. Once you create a connection, you can click "Graph" in the navigation bar to navigate here. + +- **Toggle view visibility** The graph and table views can be hidden to allow the other to expand. + +## Graph Canvas + +The graph visualization canvas that you can interact with. Double-click to expand the first-order neighbors of a node. + +- **Layout drop-down & reset:** You can display graph data using standard graph layouts in the Graph View. You can use the circular arrow to reset the physics of a layout. +- **Screenshot:** Download a picture of the current window in Graph View. +- **Save Graph:** Save the current rendered graph as a JSON file that can be shared with others having the same connection or reloaded at a later time. +- **Load Graph:** Load a previously saved graph from a JSON file. +- **Zoom In/Out & Clear:** To help users quickly zoom in/out or clear the whole canvas in the Graph View. +- **Legend (i):** This displays an informational list of icons, colors, and display names available. + +## Sidebar Panels + +The panel on the right of the graph provides various actions, configuration, and details about the open graph. + +- [**Search panel**](#search-panel) allows you to search for specific nodes by filtering on node types & attributes or executing a database query then adding nodes & edges to the graph panel. +- [**Details panel**](#details-panel) shows details about a selected node/edge such as properties etc. +- [**Entities filter panel**](#entities-filter-panel) is used to control the display of nodes and edges that are already expanded in the Graph View; click to hide or show nodes/edges. +- [**Expand panel**](#expand-panel) provides controls and filters to help focus large neighbor expansions. +- [**Node styling panel**](#node-styling-panel) of node display options (e.g., color, icon, the property to use for the displayed name). +- [**Edge styling panel**](#edge-styling-panel) of edge display options (e.g., color, icon, the property to use for the displayed name). +- [**Namespaces panel (RDF only)**](#namespace-panel) allows you to shorten the display of Resource URIs within the app based on auto-generated prefixes, commonly-used prefix libraries, or custom prefixes set by the user. Order of priority is set to Custom > Common > Auto-generated. + +### Search Panel + +The Search UI provides two powerful ways to search and interact with your graph database: + +#### Filter Search + +- Enables faceted filtering of nodes based on: + - Node labels (or rdf:type for RDF databases) + - Node attribute values +- Supports partial text matching +- Search results can be added to the graph individually or all at once +- Supports cancellation of long-running queries + +#### Query Search + +- Allows execution of any valid database query, including mutations +- When adding an edge, its connected nodes are automatically included +- Displays scalar values in results (though these cannot be added to the graph) +- Paginates large result sets +- Results can be added to the graph individually or all at once +- Supports cancellation of long-running queries +- Some limitations exist for SPARQL queries + - No support for `INSERT` and `DELETE` queries + - No support for blank node results + - `SELECT` queries only returns scalar values, even for resource URIs + +### Details Panel + +- Displays attributes and values for node or edge selections +- Displays neighbor counts by label for node selections +- Displays relationship information for edge selections + +### Expand Panel + +Provides fine grained control over neighbor expansions + +- Filter by node label (or rdf:type for RDF databases) +- Filter by attribute value +- Limit results to a maximum size + +### Entities Filter Panel + +Provides the ability to filter nodes or edges from the visualization by label (or rdf:type for RDF databases) + +### Node Styling Panel + +Each node type can be customized in a variety of ways. + +- **Display label** allows you to change how the node label (or rdf:type) is represented +- **Display name attribute** allows you to choose the attribute on the node that is used to uniquely label the node in the graph visualization and search +- **Display description attribute** allows you to choose the attribute on the node that is used to describe the node in search +- **Custom symbol** can be uploaded in the form of an SVG icon +- **Colors and borders** can be customized to visually distinguish from other node types + +### Edge Styling Panel + +Each edge type can be customized in a variety of ways. + +- **Display label** allows you to change how the edge label (or predicate in RDF databases) is represented +- **Display name attribute** allows you to choose the attribute on the edge that is used to uniquely label the edge in the graph visualization and search +- **Arrow symbol** can be chosen for both source and target variations +- **Colors and borders** can be customized for the edge label and the line +- **Line style** can be solid, dotted, or dashed + +### Namespace Panel + +- Only visible in RDF connections +- Displays any automatically generated namespace prefixes for the connection +- Displays all the common prefixes that are built-in +- Allows creation and management of any custom namespace prefixes + +## Table View + +This collapsible view shows a row-column display of the data in the Graph View. You can use filters in the Table to show/hide elements in the Graph View, and you can export the table view into a CSV or JSON file. + +The following columns are available for filtering on property graphs (RDF graphs in parentheses): + +- Node ID (Resource URI) +- Node Type (Class) +- Edge Type (Predicate) +- Source ID (Source URI) +- Source Type (Source Class) +- Target ID (Target URI) +- Target Type (Target Class) +- Display Name - Set in the Node/Edge Styling panes +- Display Description - Set in the Node/Edge Styling panes +- Total Neighbors - Enter an integer to be used as the >= limit + +### Additional Table View Features + +- **Visibility** - manually show or hide nodes or edges +- **All Nodes / All Edges (or All Resources / All Predicates) dropdown** - allows you to display a list of either nodes or edges and control display/filter on them +- **Download** - You can download the current Table View as a CSV or JSON file with additional customization options +- **Default columns** - You can set which columns you want to display +- Paging of rows diff --git a/docs/features/schema-view.md b/docs/features/schema-view.md new file mode 100644 index 000000000..ac4f542b7 --- /dev/null +++ b/docs/features/schema-view.md @@ -0,0 +1,17 @@ +[← Features](./) + +# Schema View + +The Schema view visualizes the schema of your graph database as an interactive graph. Node types are displayed as nodes and their relationships (edge connections) are displayed as edges between them. + +You can open the Schema view by clicking "Schema" in the navigation bar. + +- View all node types and their relationships as a graph +- Select a node type to view its properties and data types +- Select an edge connection to view its source type, edge type, and target type +- Choose from multiple graph layout algorithms +- Take a screenshot of the schema graph + +> [!NOTE] +> +> The schema and data types shown in the Schema view are inferred from samples of nodes and edges returned by queries. They may not be 100% accurate or complete, especially for large or diverse datasets. As you explore more data, the schema will grow more complete over time. diff --git a/docs/features/settings.md b/docs/features/settings.md new file mode 100644 index 000000000..511245f85 --- /dev/null +++ b/docs/features/settings.md @@ -0,0 +1,13 @@ +[← Features](./) + +# Settings + +## General Settings + +- **Default Neighbor Expansion Limit:** This setting will allow you to enable or disable the default limit applied during neighbor expansion. This applies to both double click expansion and the expand sidebar. This setting can be overridden by a similar setting on the connection itself. +- **Save Configuration:** This action will export all the configuration data within the Graph Explorer local database. This will not store any data from the connected graph databases. However, the export may contain the shape of the schema for your databases and the connection URL. +- **Load Configuration:** This action will replace all the Graph Explorer configuration data you currently have with the data in the provided configuration file. This is a destructive act and can not be undone. It is **strongly** suggested that you perform a **Save Configuration** action before performing a **Load Configuration** action to preserve any existing configuration data. + +## About + +In the _About_ page you can see the version number and submit any feedback. diff --git a/docs/getting-started/README.md b/docs/getting-started/README.md index dd262501a..632f89050 100644 --- a/docs/getting-started/README.md +++ b/docs/getting-started/README.md @@ -1,172 +1,116 @@ # Getting Started -This project contains the code needed to create a Docker image of the Graph Explorer. The image will create the Graph Explorer application and proxy server that will be served over the standard HTTP or HTTPS ports (HTTPS by default). +A hands-on tutorial that walks you through Graph Explorer using the air routes sample dataset. By the end, you will have searched for airports, explored connections between them, filtered the graph, styled nodes, and viewed data in a table. -The proxy server will be created automatically, but will only be necessary if you are connecting to Neptune. Gremlin-Server and BlazeGraph can be connected to directly. Additionally, the image will create a self-signed certificate that can be optionally used. - -## Examples - -### Local Docker Setup - -The quickest way to get started with Graph Explorer is to use the official Docker image. You can find the latest version of the image on -[Amazon's ECR Public Registry](https://gallery.ecr.aws/neptune/graph-explorer). - - -> [!NOTE] -> -> Make sure to use the version of the image that does not include `sagemaker` in the tag. - -#### Prerequisites +## Prerequisites - [Docker](https://docs.docker.com/get-docker/) installed on your machine -- [AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/install-cliv2.html) installed on your machine -#### Steps +## Launch Graph Explorer -1. Authenticate with the Amazon ECR Public Registry. [More information](https://docs.aws.amazon.com/AmazonECR/latest/public/public-registries.html#public-registry-auth) +The fastest way to try Graph Explorer is with the [Air Routes sample](../../samples/air_routes/README.md). It launches Graph Explorer and a Gremlin Server pre-loaded with sample data using Docker Compose β€” no database setup or AWS account required. +1. Clone the repository ``` - aws ecr-public get-login-password --region us-east-1 | docker login --username AWS --password-stdin public.ecr.aws + git clone https://github.com/aws/graph-explorer.git ``` - -2. Pull down the docker image - +2. Navigate to the sample directory and start the containers ``` - docker pull public.ecr.aws/neptune/graph-explorer + cd graph-explorer/samples/air_routes + docker compose up ``` +3. Open your browser and navigate to [http://localhost:8080/explorer](http://localhost:8080/explorer) -3. Create and run a docker container using the image +Graph Explorer opens to the **Graph** page with a default connection already configured and connected to the Gremlin Server with the air routes data. The canvas is empty because no nodes have been added yet. - ``` - docker run -p 80:80 -p 443:443 \ - --env HOST=localhost \ - --name graph-explorer \ - public.ecr.aws/neptune/graph-explorer - ``` +## Tour the UI - The `HOST` environment variable is used for SSL certificates generation since HTTPS is the default. If you are hosting this on a public domain, you should replace `HOST=localhost` with your domain name. +Graph Explorer has four main pages, accessible from the navigation bar at the top: -4. Open a browser and type in the URL of the Graph Explorer server instance +- **Graph** β€” The main visualization canvas where you explore nodes and edges interactively. +- **Data Table** β€” A paginated table view of all nodes in the database, organized by type. +- **Schema** β€” A visual overview of the schema showing how node types and edge types relate to each other. +- **Connections** β€” Where you manage database connections. - ``` - https://localhost/explorer - ``` +Click **Connections** in the navigation bar to verify the "Default Connection" is active, then click **Graph** to return to the graph view. -5. You will receive a warning as the SSL certificate used is self-signed. Since the application is set to use HTTPS by default and contains a self-signed certificate, you will need to add the Graph Explorer certificates to the trusted certificates directory and manually trust them. See the [HTTPS Connections](../troubleshooting.md#https-connections) section. -6. After completing the trusted certification step and refreshing the browser, you should now see the Connections UI. See below description on Connections UI to configure your first connection to Amazon Neptune. +On the right side of the graph view, you will see a vertical strip of sidebar icons. These open panels for **Search**, **Details**, **Expand**, **Filters**, and **Node Label Styling**. You will use each of these as you work through the tutorial. -#### Gremlin Server Database +## Search for a Node -Gremlin Server is an easy way to get started with graph databases. This example will configure a simple Gremlin Server instance to be used with Graph Explorer. It comes with a very small graph dataset. +1. Click the **Search** icon (magnifying glass) in the right sidebar to open the Search panel. +2. In the **Node Label** dropdown, select **airport**. +3. In the **Property** dropdown, select **code**. +4. In the search text field, type `AUS`. +5. Click the result for Austin to expand it, then click the **βŠ•** button to add it to the graph canvas. -1. Pull the latest Gremlin Server image from Docker Hub. - ``` - docker pull tinkerpop/gremlin-server:latest - ``` -2. Create and run the Gremlin Server container using the HTTP REST modern configuration. - ``` - docker run -p 8182:8182 \ - --name gremlin-server \ - tinkerpop/gremlin-server:latest \ - conf/gremlin-server-rest-modern.yaml - ``` -3. Open Graph Explorer and add a new connection - - Name: `Gremlin Server` - - Query Language: `Gremlin` - - Public or Proxy Endpoint: `https://localhost` - - Using Proxy Server: `true` - - Graph Connection URL: `http://localhost:8182` +The node appears on the canvas. Click it to select it β€” the **Details** panel opens automatically in the right sidebar, showing its properties like city, country, and coordinates. You can zoom with the scroll wheel and pan by clicking and dragging the background. -## Amazon EC2 Setup +## Expand Neighbors -The following instructions detail how to deploy graph-explorer onto an Amazon EC2 instance and use it as a proxy server with SSH tunneling to connect to Amazon Neptune. +With the Austin airport node on the canvas, let's discover what it connects to. - -> [!NOTE] -> -> This documentation is not an official recommendation on -network setups as there are many ways to connect to Amazon Neptune from outside -of the VPC, such as setting up a load balancer or VPC peering. +1. **Double-click** the AUS node on the canvas. -### Prerequisites +Graph Explorer fetches up to 10 neighbors and adds them to the graph. The number on top of a node shows how many unexpanded neighbors remain. Double-click again to fetch the next batch. -- Provision an Amazon EC2 instance that will be used to host the application and connect to Neptune as a proxy server. For more details, see instructions [here](https://github.com/aws/graph-notebook/tree/main/additional-databases/neptune). -- Ensure the Amazon EC2 instance can send and receive on ports `22` (SSH), `8182` (Neptune), and `443` or `80` depending on protocol used (graph-explorer). +> [!TIP] +> +> You can also right-click a node and select **Expand node** from the context +> menu, or use the **Expand** sidebar panel for more control over which neighbor +> types to fetch. -### Steps +## Filter the Graph -These steps describe how to install Graph Explorer on your Amazon EC2 instance. +As you expand nodes, the graph can get crowded. The Filters panel lets you focus on specific types. -1. Open an SSH client and connect to the EC2 instance. -2. Download and install the necessary command line tools such as [Git](https://git-scm.com/downloads) and [Docker](https://docs.docker.com/get-docker/). -3. Clone the repository - ``` - git clone https://github.com/aws/graph-explorer.git - ``` -4. Navigate to the repository - ``` - cd graph-explorer - ``` -5. Build the image - ``` - docker build -t graph-explorer . - ``` +1. Click the **Filters** icon in the right sidebar to open the Entities Filter panel. +2. You will see two tabs: **Node Labels** and **Edge Labels**. Each tab lists the types currently in the graph with checkboxes. +3. Try unchecking a node label to hide those nodes from the canvas. Check it again to bring them back. - -> [!TIP] -> -> If you receive an error relating to the docker service not running, run -> `service docker start`. +This does not remove nodes from the graph β€” it only controls visibility. You can use this to temporarily focus on a subset of the data. -4. Run the container substituting the `{hostname-or-ip-address}` with the hostname or IP address of the EC2 instance. - ``` - docker run -p 80:80 -p 443:443 \ - --env HOST={hostname-or-ip-address} \ - graph-explorer - ``` -5. Navigate to the public URL of your EC2 instance accessing the `/explorer` endpoint. You will receive a warning as the SSL certificate used is self-signed. The URL will look like this: - ``` - https://ec2-1-2-3-4.us-east-1.compute.amazonaws.com/explorer - ``` -6. Since the application is set to use HTTPS by default and contains a self-signed certificate, you will need to add the Graph Explorer certificates to the trusted certificates directory and manually trust them. See [HTTPS Connections](../troubleshooting.md#https-connections) section. -7. After completing the trusted certification step and refreshing the browser, you should now see the Connections UI. +## Table View -## Local Development Setup +You can view the nodes and edges currently on the canvas in a table format without leaving the Graph page. -You can build the Docker image locally by following the steps below. +1. Click the grid icon in the navigation bar to toggle the Table View open. +2. Use the dropdown to switch between **All Nodes** and **All Edges**. +3. You can sort, filter, and export the table data to CSV or JSON. -### Prerequisites +This table only shows what is on the canvas β€” it is a different view of the same data you have been exploring. -- [Docker](https://docs.docker.com/get-docker/) installed on your machine -- [Git](https://git-scm.com/downloads) installed on your machine +## Style Nodes -### Steps +You can customize how each node type looks on the canvas. -1. Clone the repository - ``` - git clone https://github.com/aws/graph-explorer.git - ``` -2. Navigate to the repository - ``` - cd graph-explorer - ``` -3. Build the image - ``` - docker build -t graph-explorer . - ``` -4. Run the container (HTTPS disabled) - ``` - docker run -p 80:80 \ - --name graph-explorer \ - --env PROXY_SERVER_HTTPS_CONNECTION=false \ - --env GRAPH_EXP_HTTPS_CONNECTION=false \ - graph-explorer - ``` -5. Connect to the Graph Explorer UI - ``` - http://localhost/explorer - ``` +1. Click the **Node Label Styling** icon in the right sidebar. +2. Find the **airport** type in the list. +3. Click **Customize** to open the style dialog. +4. Change the **Display Name Property** to **code** so each airport shows its IATA code. +5. Change the **Display Description Property** to **city** to see the city name underneath. +6. Change the **Node Color** to a color of your choice using the color picker. +7. Click **Done** to apply. + +All airport nodes on the canvas update with the new labels and color. You can also change the shape, border, and icon. + +## Switch to the Data Table + +The Data Table page lets you browse all nodes in the database without adding them to the graph first. + +1. Click **Data Table** in the navigation bar. +2. The **Node Label** dropdown at the top left is pre-selected to **airport**. Use it to switch to other types like **country** or **continent**. +3. Browse the paginated table of all airports in the dataset. +4. To send a specific airport to the graph view, click the **Send to Explorer** button on its row. + +## Next Steps -## Troubleshooting +Now that you have explored the basics, here are some directions to go next: -If the instructions above do not work for you, please see the [Troubleshooting](../troubleshooting.md) page for more information. It contains workarounds for common issues and information on how to diagnose other issues. +- [Features](../features) β€” Explore all capabilities in depth +- [Connecting to databases](../guides#connecting-to-databases) β€” Connect to Neptune, Gremlin Server, or BlazeGraph +- [Deployment guides](../guides#deployment) β€” Deploy with Docker, EC2, ECS Fargate, or SageMaker +- [Configuration](../references/configuration.md) β€” Environment variables for application settings and default connections +- [Development](../development.md) β€” Build from source for local development +- [Troubleshooting](../guides/troubleshooting.md) β€” Common issues and workarounds +- [Samples](../../samples) β€” More Docker Compose examples for different configurations diff --git a/docs/guides/README.md b/docs/guides/README.md new file mode 100644 index 000000000..446e62932 --- /dev/null +++ b/docs/guides/README.md @@ -0,0 +1,20 @@ +# Guides + +Guides for connecting to graph databases, deploying Graph Explorer, and troubleshooting common issues. + +## Connecting to Databases + +- [Connecting to Neptune](./connecting-to-neptune.md) +- [Connecting to Gremlin-Server](./connecting-to-gremlin-server.md) +- [Connecting to BlazeGraph](./connecting-to-blazegraph.md) + +## Deployment + +- [Deploy with Docker](./deploy-with-docker.md) +- [Deploy to Amazon EC2](./deploy-to-ec2.md) +- [Deploy to ECS Fargate](./deploy-to-ecs-fargate.md) +- [Deploy to SageMaker](./deploy-to-sagemaker.md) + +## Troubleshooting + +- [Troubleshooting](./troubleshooting.md) diff --git a/docs/guides/connecting-to-blazegraph.md b/docs/guides/connecting-to-blazegraph.md new file mode 100644 index 000000000..5caeba8ed --- /dev/null +++ b/docs/guides/connecting-to-blazegraph.md @@ -0,0 +1,6 @@ +[← Guides](./) + +# Connecting to BlazeGraph + +- Build and run the Docker container as normal and connect Graph Explorer to BlazeGraph through the proxy server. +- If using Docker, ensure that the container running Graph Explorer can properly access the container running BlazeGraph. You can find documentation on how to connect containers via [Docker networks](https://docs.docker.com/network/). diff --git a/docs/guides/connecting-to-gremlin-server.md b/docs/guides/connecting-to-gremlin-server.md new file mode 100644 index 000000000..9d03582a9 --- /dev/null +++ b/docs/guides/connecting-to-gremlin-server.md @@ -0,0 +1,39 @@ +[← Guides](./) + +# Connecting to Gremlin-Server + +If you are using the default Gremlin Server docker image, you can get the server running with the following commands: + +``` +docker pull tinkerpop/gremlin-server:latest +docker run -p 8182:8182 \ + tinkerpop/gremlin-server:latest \ + conf/gremlin-server-rest-modern.yaml +``` + +Then open Graph Explorer and add a new connection with the following settings: + +- Name: `Gremlin Server` +- Query Language: `Gremlin` +- Public or Proxy Endpoint: `https://localhost` +- Using Proxy Server: `true` +- Graph Connection URL: `http://localhost:8182` + +## Enable REST + +Graph Explorer only supports HTTP(S) connections. When connecting to Gremlin-Server, ensure it is configured with a channelizer that supports HTTP(S) (i.e. [Channelizer Documentation](https://tinkerpop.apache.org/javadocs/current/full/org/apache/tinkerpop/gremlin/server/Channelizer.html)). + +> [!TIP] +> The Gremlin Server configuration can be usually found at: +> +> ``` +> /conf/gremlin-server.yaml +> ``` + +## Versions Prior to 3.7 + +If you have a version of Gremlin Server prior to 3.7, you will need to make the following changes: + +- **Enable property returns** - Remove ".withStrategies(ReferenceElementStrategy)" from `/scripts/generate-modern.groovy` so that properties are returned. +- **Enable string IDs** - Change `gremlin.tinkergraph.vertexIdManager` and `gremlin.tinkergraph.edgeIdManager` in `/conf/tinkergraph-empty.properties` to support string ids. You can use `ANY`. +- Build and run the Docker container as normal. diff --git a/docs/guides/connecting-to-neptune.md b/docs/guides/connecting-to-neptune.md new file mode 100644 index 000000000..ae19a8099 --- /dev/null +++ b/docs/guides/connecting-to-neptune.md @@ -0,0 +1,39 @@ +[← Guides](./) + +# Connecting to Neptune + +Graph Explorer connects to Amazon Neptune through its proxy server, which forwards requests to the database and handles SigV4 signing when IAM authentication is enabled. + +## Connection Settings + +- Name: `My Neptune Cluster` +- Query Language: Choose the query language for your graph +- Public or Proxy Endpoint: `https://localhost` (or wherever Graph Explorer is hosted) +- Using Proxy Server: `true` +- Graph Connection URL: `https://{your-cluster-endpoint}:8182` +- AWS IAM Auth Enabled: `true` if IAM authentication is enabled on your cluster +- Service Type: `neptune-db` (or `neptune-graph` for Neptune Analytics) +- AWS Region: your cluster's region (e.g., `us-east-1`) + +When IAM authentication is enabled, AWS credentials must be available where Graph Explorer's proxy server runs (environment variables, credential file, or IAM role). Read query privileges are needed (see [ReadDataViaQuery managed policy](https://docs.aws.amazon.com/neptune/latest/userguide/iam-data-access-examples.html#iam-auth-data-policy-example-read-query)). + +For details on how credentials are resolved, see the [AWS credential provider chain documentation](https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/Package/-aws-sdk-credential-providers/). + +## Network Access + +### Public Endpoints + +Neptune [public endpoints](https://docs.aws.amazon.com/neptune/latest/userguide/neptune-public-endpoints.html) allow Graph Explorer to connect from outside the VPC β€” including from your local machine. This requires: + +- Neptune engine version 1.4.6.x or later +- Public accessibility enabled on the Neptune instance +- IAM database authentication enabled on the cluster (required for public endpoints) +- Security group allowing inbound traffic on port 8182 from Graph Explorer's IP + +### VPC Access + +For Neptune instances without public endpoints, Graph Explorer must be deployed within the same VPC or connected via VPC peering. See the deployment guides: + +- [Deploy to Amazon EC2](./deploy-to-ec2.md) +- [Deploy to ECS Fargate](./deploy-to-ecs-fargate.md) +- [Deploy to SageMaker](./deploy-to-sagemaker.md) diff --git a/docs/guides/deploy-to-ec2.md b/docs/guides/deploy-to-ec2.md new file mode 100644 index 000000000..9da90b7fc --- /dev/null +++ b/docs/guides/deploy-to-ec2.md @@ -0,0 +1,54 @@ +[← Guides](./) + +# Deploy to Amazon EC2 + +Deploy Graph Explorer onto an Amazon EC2 instance and use it as a proxy server with SSH tunneling to connect to Amazon Neptune. + +> [!NOTE] +> +> This documentation is not an official recommendation on network setups as there are many ways to connect to Amazon Neptune from outside of the VPC, such as setting up a load balancer or VPC peering. + +## Prerequisites + +- Provision an Amazon EC2 instance that will be used to host the application and connect to Neptune as a proxy server. For more details, see instructions [here](https://github.com/aws/graph-notebook/tree/main/additional-databases/neptune). +- Ensure the Amazon EC2 instance can send and receive on ports `22` (SSH), `8182` (Neptune), and `443` or `80` depending on protocol used (graph-explorer). +- [Docker](https://docs.docker.com/get-docker/) installed on the EC2 instance +- [AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html) installed on the EC2 instance + +## Steps + +1. Open an SSH client and connect to the EC2 instance +2. Authenticate with the [Amazon ECR Public Registry](https://docs.aws.amazon.com/AmazonECR/latest/public/public-registries.html#public-registry-auth) + ``` + aws ecr-public get-login-password --region us-east-1 | docker login --username AWS --password-stdin public.ecr.aws + ``` +3. Pull the Docker image + ``` + docker pull public.ecr.aws/neptune/graph-explorer + ``` + +> [!TIP] +> +> If you receive an error relating to the docker service not running, run +> `service docker start`. + +4. Run the container substituting the `{hostname-or-ip-address}` with the hostname or IP address of the EC2 instance + ``` + docker run -p 80:80 -p 443:443 \ + --restart unless-stopped \ + --env HOST={hostname-or-ip-address} \ + public.ecr.aws/neptune/graph-explorer + ``` + +> [!TIP] +> +> The `--restart unless-stopped` flag ensures the container automatically restarts after a host reboot or if the container crashes. See Docker's [restart policy documentation](https://docs.docker.com/engine/containers/start-containers-automatically/) for other options. + +5. Navigate to the public URL of your EC2 instance accessing the `/explorer` endpoint. You will receive a warning as the SSL certificate used is self-signed. The URL will look like this: + +``` +https://ec2-1-2-3-4.us-east-1.compute.amazonaws.com/explorer +``` + +6. Since the application is set to use HTTPS by default and contains a self-signed certificate, you will need to add the Graph Explorer certificates to the trusted certificates directory and manually trust them. See [HTTPS Connections](./troubleshooting.md#https-connections) section. +7. After completing the trusted certification step and refreshing the browser, you should now see the Connections UI. diff --git a/docs/ecs/ECS_FARGATE_DEPLOYMENT.md b/docs/guides/deploy-to-ecs-fargate.md similarity index 93% rename from docs/ecs/ECS_FARGATE_DEPLOYMENT.md rename to docs/guides/deploy-to-ecs-fargate.md index c3fc1a5de..6d8158583 100644 --- a/docs/ecs/ECS_FARGATE_DEPLOYMENT.md +++ b/docs/guides/deploy-to-ecs-fargate.md @@ -1,8 +1,10 @@ +[← Guides](./) + # Running Graph Explorer on AWS Fargate + Amazon ECS -The following steps will allow you set up Graph Explorer on AWS Fargate in Amazon ECS, and connect to a running Neptune database. +The following steps will allow you to set up Graph Explorer on AWS Fargate in Amazon ECS, and connect to a running Neptune database. -### Create a new IAM role and permission policies +## Create a new IAM role and permission policies 1. Open the IAM console at https://console.aws.amazon.com/iam/. 2. In the navigation pane, click **Roles**, and then click **Create role**. @@ -14,9 +16,9 @@ The following steps will allow you set up Graph Explorer on AWS Fargate in Amazo 8. For **Add tags (optional)**, enter any custom tags to associate with the policy. 9. Click **Create role** to finish, and keep the role name handy to use in the next steps. -![image](./images/iam-role.png) +![image](./deploy-to-ecs-fargate/iam-role.png) -### Create an Amazon ECS Cluster +## Create an Amazon ECS Cluster 1. Open the ECS console at https://console.aws.amazon.com/ecs/v2. 2. In the left hand navigation pane, click **Clusters**. @@ -27,16 +29,16 @@ The following steps will allow you set up Graph Explorer on AWS Fargate in Amazo 5. Under **Infrastructure**, select only **AWS Fargate**. 6. (Optional) To turn on Container Insights, expand **Monitoring**, and then turn on **Use Container Insights**. 7. (Optional) To help identify your cluster, expand **Tags**, and then configure your tags. - - [Add a tag] Choose **Add tag and** do the following: + - [Add a tag] Choose **Add tag** and do the following: - For **Key**, enter the key name. - For **Value**, enter the key value. 8. Click **Create**. After the cluster has finished creation, you can create task definitions for your applications, which can then be run as standalone tasks, or as part of a service. -![image](./images/ecs-cluster.png) +![image](./deploy-to-ecs-fargate/ecs-cluster.png) -### Request an ACM Public Certificate +## Request an ACM Public Certificate 1. Open the ACM console at https://console.aws.amazon.com/acm/home. 2. In the left hand navigation pane, click **Request certificate**. @@ -57,7 +59,7 @@ After the cluster has finished creation, you can create task definitions for you After the request is processed, the console will return you to your certificate list, where information about the certificate will be displayed. The newly requested certificate will initially display the status `Pending validation`. Once the verification is successful, ACM will issue the SSL/TLS certificate for the specified domain names. -### Creating an ECS Task Definition +## Creating an ECS Task Definition 1. Open the ECS console at https://console.aws.amazon.com/ecs/v2. 2. In the left hand navigation pane, choose **Task definitions**. @@ -157,7 +159,7 @@ After the request is processed, the console will return you to your certificate ``` 5. In the JSON template, update the following fields: - `taskRoleArn` and `executionRoleArn`: The ARN of the IAM role created in step "Create a new IAM role and permission policies". - - `environment` variables section (see [Default Connections](https://github.com/aws/graph-explorer#providing-a-default-connection) for more details): + - `environment` variables section (see [Configuration](../references/configuration.md#default-connection) for more details): - `AWS_REGION`: The AWS region in which your Neptune cluster is located. - `GRAPH_TYPE`: The query language for your initial connection. - `IAM`: Set this to `true` to use SigV4 signed requests, if your Neptune cluster has IAM db authentication enabled. @@ -166,7 +168,7 @@ After the request is processed, the console will return you to your certificate - `SERVICE_TYPE`: Set this as `neptune-db` for Neptune database or `neptune-graph` for Neptune Analytics. 6. Click **Create**. -### Create a Fargate Service +## Create a Fargate Service 1. Open the ECS console at https://console.aws.amazon.com/ecs/v2. 2. In the left hand navigation pane, choose **Clusters**. @@ -199,7 +201,7 @@ After the request is processed, the console will return you to your certificate - **Target group name**: `tg-graphexplorer-demo` with **Protocol** as `HTTPS`. - **Health check path**: `/explorer/` with **Health check protocol** as `HTTPS`. - image + image 10. (Optional) Under section **Service auto scaling**, specify the desired scaling configuration. 11. (Optional) To help identify your service and tasks, expand the **Tags** section, then configure your desired tags. @@ -208,9 +210,9 @@ After the request is processed, the console will return you to your certificate After few minutes, the Fargate service will be created and ready. -image +image -### Create an Amazon Route53 Entry +## Create an Amazon Route53 Entry 1. Open the Route 53 console at https://console.aws.amazon.com/route53/. 2. In the left hand navigation pane, click **Hosted zones**. @@ -227,6 +229,6 @@ After few minutes, the Fargate service will be created and ready. Changes generally propagate to all Route 53 servers within 60 seconds. When propagation is done, you'll be able to route traffic to your load balancer by using the name of the alias record that you created in the above step. -### Accessing Graph Explorer +## Accessing Graph Explorer Enter the URL you created in the Route53 section into a browser to access the endpoint for Graph Explorer (ex.`https://graphexplorer.example.com/explorer`). You should now be connected. diff --git a/docs/ecs/images/ecs-cluster.png b/docs/guides/deploy-to-ecs-fargate/ecs-cluster.png similarity index 100% rename from docs/ecs/images/ecs-cluster.png rename to docs/guides/deploy-to-ecs-fargate/ecs-cluster.png diff --git a/docs/ecs/images/fargate-service.png b/docs/guides/deploy-to-ecs-fargate/fargate-service.png similarity index 100% rename from docs/ecs/images/fargate-service.png rename to docs/guides/deploy-to-ecs-fargate/fargate-service.png diff --git a/docs/ecs/images/iam-role.png b/docs/guides/deploy-to-ecs-fargate/iam-role.png similarity index 100% rename from docs/ecs/images/iam-role.png rename to docs/guides/deploy-to-ecs-fargate/iam-role.png diff --git a/docs/ecs/images/target-group.png b/docs/guides/deploy-to-ecs-fargate/target-group.png similarity index 100% rename from docs/ecs/images/target-group.png rename to docs/guides/deploy-to-ecs-fargate/target-group.png diff --git a/docs/sagemaker/README.md b/docs/guides/deploy-to-sagemaker.md similarity index 78% rename from docs/sagemaker/README.md rename to docs/guides/deploy-to-sagemaker.md index 5535d67f1..7898df176 100644 --- a/docs/sagemaker/README.md +++ b/docs/guides/deploy-to-sagemaker.md @@ -1,8 +1,10 @@ +[← Guides](./) + # Launching Graph Explorer using Amazon SageMaker Graph Explorer can be hosted and launched on Amazon SageMaker Notebooks via a lifecycle configuration script. To learn more about lifecycle configurations and how to create one, see the [documentation](https://docs.aws.amazon.com/sagemaker/latest/dg/notebook-lifecycle-config.html). -You can use the provided sample lifecycle configuration, [`install-graph-explorer-lc.sh`](install-graph-explorer-lc.sh), or create your own shell script. If using the sample lifecycle, you should also create an IAM role with a policy containing the permissions described in either [`graph-explorer-neptune-db-policy.json`](graph-explorer-neptune-db-policy.json) or [`graph-explorer-neptune-analytics-policy.json`](graph-explorer-neptune-analytics-policy.json), depending on the service used. +You can use the provided sample lifecycle configuration, [`./deploy-to-sagemaker/install-graph-explorer-lc.sh`](./deploy-to-sagemaker/install-graph-explorer-lc.sh), or create your own shell script. If using the sample lifecycle, you should also create an IAM role with a policy containing the permissions described in either [`./deploy-to-sagemaker/graph-explorer-neptune-db-policy.json`](./deploy-to-sagemaker/graph-explorer-neptune-db-policy.json) or [`./deploy-to-sagemaker/graph-explorer-neptune-analytics-policy.json`](./deploy-to-sagemaker/graph-explorer-neptune-analytics-policy.json), depending on the service used. After you have created the lifecycle configuration and IAM role, you can attach them to a new or existing SageMaker notebook instance, under `Notebook instance settings` -> `Additional configuration` -> `Lifecycle configuration` and `Permission and encryption` -> `IAM role`, respectively. @@ -22,9 +24,8 @@ To restrict Graph Explorer access for its most basic functionality you can use t - Get the graph summary information (used for schema sync) - Cancel query - > [!CAUTION] -> +> > If you are using the standard notebook setup, these policies will apply to both the Jupyter graph notebooks as well as Graph Explorer. If a user attempts to execute a mutation query inside of Graph Explorer, they will be presented with an error that informs them they are not authorized for that request. diff --git a/docs/sagemaker/graph-explorer-neptune-analytics-policy.json b/docs/guides/deploy-to-sagemaker/graph-explorer-neptune-analytics-policy.json similarity index 100% rename from docs/sagemaker/graph-explorer-neptune-analytics-policy.json rename to docs/guides/deploy-to-sagemaker/graph-explorer-neptune-analytics-policy.json diff --git a/docs/sagemaker/graph-explorer-neptune-db-policy.json b/docs/guides/deploy-to-sagemaker/graph-explorer-neptune-db-policy.json similarity index 100% rename from docs/sagemaker/graph-explorer-neptune-db-policy.json rename to docs/guides/deploy-to-sagemaker/graph-explorer-neptune-db-policy.json diff --git a/docs/sagemaker/install-graph-explorer-lc.sh b/docs/guides/deploy-to-sagemaker/install-graph-explorer-lc.sh similarity index 100% rename from docs/sagemaker/install-graph-explorer-lc.sh rename to docs/guides/deploy-to-sagemaker/install-graph-explorer-lc.sh diff --git a/docs/guides/deploy-with-docker.md b/docs/guides/deploy-with-docker.md new file mode 100644 index 000000000..cee4c4d24 --- /dev/null +++ b/docs/guides/deploy-with-docker.md @@ -0,0 +1,52 @@ +[← Guides](./) + +# Deploy with Docker + +Deploy Graph Explorer locally using the official Docker image from Amazon's ECR Public Registry. + +You can find the latest version of the image on +[Amazon's ECR Public Registry](https://gallery.ecr.aws/neptune/graph-explorer). + +> [!NOTE] +> +> Make sure to use the version of the image that does not include `sagemaker` in the tag. + +## Prerequisites + +- [Docker](https://docs.docker.com/get-docker/) installed on your machine +- [AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html) installed on your machine + +## Steps + +1. Authenticate with the [Amazon ECR Public Registry](https://docs.aws.amazon.com/AmazonECR/latest/public/public-registries.html#public-registry-auth) + + ``` + aws ecr-public get-login-password --region us-east-1 | docker login --username AWS --password-stdin public.ecr.aws + ``` + +2. Pull down the docker image + + ``` + docker pull public.ecr.aws/neptune/graph-explorer + ``` + +3. Create and run a docker container using the image + + ``` + docker run -p 80:80 -p 443:443 \ + --restart unless-stopped \ + --env HOST=localhost \ + --name graph-explorer \ + public.ecr.aws/neptune/graph-explorer + ``` + + The `HOST` environment variable is used for SSL certificates generation since HTTPS is the default. If you are hosting this on a public domain, you should replace `HOST=localhost` with your domain name. + +4. Open a browser and type in the URL of the Graph Explorer server instance + + ``` + https://localhost/explorer + ``` + +5. You will receive a warning as the SSL certificate used is self-signed. Since the application is set to use HTTPS by default and contains a self-signed certificate, you will need to add the Graph Explorer certificates to the trusted certificates directory and manually trust them. See the [HTTPS Connections](./troubleshooting.md#https-connections) section. +6. After completing the trusted certification step and refreshing the browser, you should now see the Connections UI. See [Connections](../features/connections.md) for details on configuring your first connection. diff --git a/docs/troubleshooting.md b/docs/guides/troubleshooting.md similarity index 61% rename from docs/troubleshooting.md rename to docs/guides/troubleshooting.md index 0fa27c114..893e18e75 100644 --- a/docs/troubleshooting.md +++ b/docs/guides/troubleshooting.md @@ -1,16 +1,18 @@ +[← Guides](./) + # Troubleshooting This page contains workarounds for common issues and information on how to diagnose other issues. - [Docker Container Issues](#docker-container-issues) - [Schema Sync Fails](#schema-sync-fails) -- [Backup Graph Explorer Data](#backup-graph-explorer-data) +- [Save & Load Configuration](#save--load-configuration) - [Gathering SageMaker Logs](#gathering-sagemaker-logs) ## Docker Container Issues 1. If the container does not start, or immediately stops, use `docker logs graph-explorer` to check the container console logs for any related error messages that might provide guidance on why graph-explorer did not start. -2. If you are having issues connecting graph-explorer to your graph database, use your browser's Developer Tools feature to monitor both the browser console and network calls to determine if here are any errors related to connectivity. +2. If you are having issues connecting graph-explorer to your graph database, use your browser's Developer Tools feature to monitor both the browser console and network calls to determine if there are any errors related to connectivity. ### Ports in Use @@ -32,7 +34,7 @@ Which will result in the following URLs: ### HTTP Only -If you do not want to use SSL and HTTPS, you can disable it by setting the following [environment variables](/docs/development.md#environment-variables): +If you do not want to use SSL and HTTPS, you can disable it by setting the following [environment variables](../references/configuration.md#application-configuration): ``` PROXY_SERVER_HTTPS_CONNECTION=false @@ -51,21 +53,9 @@ docker run -p 80:80 \ ### HTTPS Connections -If either of the Graph Explorer or the proxy-server are served over an HTTPS connection (which it is by default), you will have to bypass the warning message from the browser due to the included certificate being a self-signed certificate. - -You can bypass by manually ignoring them from the browser or downloading the correct certificate and configuring them to be trusted. Alternatively, you can provide your own certificate. - -The following instructions can be used as an example to bypass the warnings for Chrome, but note that different browsers and operating systems will have slightly different steps. - -1. Download the certificate directly from the browser. For example, if using Google Chrome, click the β€œNot Secure” section on the left of the URL bar and select β€œCertificate is not valid” to show the certificate. Then click Details tab and click Export at the bottom. -2. Once you have the certificate, you will need to trust it on your machine. For MacOS, you can open the Keychain Access app. Select System under System Keychains. Then go to File > Import Items... and import the certificate you downloaded in the previous step. -3. Once imported, select the certificate and right-click to select "Get Info". Expand the Trust section, and change the value of "When using this certificate" to "Always Trust". -4. You should now refresh the browser and see that you can proceed to open the application. For Chrome, the application will remain β€œNot Secure” due to the fact that this is a self-signed certificate. If you have trouble accessing Graph Explorer after completing the previous step and reloading the browser, consider running a docker restart command and refreshing the browser again. +If either Graph Explorer or the proxy server are served over an HTTPS connection (which is the default), your browser will show a security warning due to the self-signed certificate. - -> [!TIP] -> To get rid of the β€œNot Secure” warning, see -[Using self-signed certificates on Chrome](/docs/development.md#using-self-signed-certificates-on-chrome). +See [Trusting the self-signed certificate](../references/security.md#trusting-the-self-signed-certificate) for step-by-step instructions, or [Removing the "Not Secure" warning on Chrome](../references/security.md#removing-the-not-secure-warning-on-chrome) for Chrome-specific instructions. ## Schema Sync Fails @@ -126,20 +116,20 @@ This can manifest as different types of errors depending on the root cause. You > [!IMPORTANT] > The paths listed here could always change in the future. If they do change, we will note that in the release notes. -## Backup Graph Explorer Data +## Save & Load Configuration -Inside of Graph Explorer there is an option to export all the configuration data that Graph Explorer uses. This data is local to the user’s browser and does not exist on the server. +Inside of Graph Explorer there is an option to save all the configuration data that Graph Explorer uses. This data is local to the user's browser and does not exist on the server. -To gather the config data: +To save the configuration data: 1. Launch Graph Explorer 2. Navigate to the connections screen -3. Press the β€œSettings” button in the navigation bar -4. Select the β€œGeneral” page within settings -5. Press the β€œSave Configuration” button -6. Choose where to save the exported file +3. Press the "Settings" button in the navigation bar +4. Select the "General" page within settings +5. Press the "Save Configuration" button +6. Choose where to save the configuration file -This backup can be restored using the β€œLoad Configuration” button in the same settings page. +This configuration can be restored using the "Load Configuration" button in the same settings page. ## Gathering SageMaker Logs @@ -149,21 +139,21 @@ To gather these logs: 1. Open the AWS Console 2. Navigate to the Neptune page -3. Select β€œNotebook” from the sidebar +3. Select "Notebook" from the sidebar 4. Find the Notebook hosting Graph Explorer 5. Open the details screen for that Notebook -6. In the β€œActions” menu, choose β€œView Details in SageMaker” -7. Press the β€œView Logs” link in the SageMaker details screen under the field titled β€œLifecycle configuration” -8. Scroll down to the β€œLog Streams” panel in the CloudWatch details where you should find multiple log streams +6. In the "Actions" menu, choose "View Details in SageMaker" +7. Press the "View Logs" link in the SageMaker details screen under the field titled "Lifecycle configuration" +8. Scroll down to the "Log Streams" panel in the CloudWatch details where you should find multiple log streams 9. For each log stream related to Graph Explorer (LifecycleConfigOnStart.log, graph-explorer.log) 1. Open the log stream - 2. In the β€œActions” menu, choose β€œDownload search results (CSV)” + 2. In the "Actions" menu, choose "Download search results (CSV)" ### Graph Explorer Log Stream Does Not Exist -New Neptune Notebooks automatically apply the correct IAM permissions to write to CloudWatch. If your Notebook does not automatically create a graph-explorer.log in the CloudWatch Log Streams, then it is possible that the Neptune Notebook was created before those IAM permissions were added. You’ll need to add those permissions manually. +New Neptune Notebooks automatically apply the correct IAM permissions to write to CloudWatch. If your Notebook does not automatically create a graph-explorer.log in the CloudWatch Log Streams, then it is possible that the Neptune Notebook was created before those IAM permissions were added. You'll need to add those permissions manually. Below are examples of which IAM permissions you need for Graph Explorer. -- [IAM permissions for Neptune DB](/docs/sagemaker/graph-explorer-neptune-db-policy.json) -- [IAM permissions for Neptune Analytics](/docs/sagemaker/graph-explorer-neptune-analytics-policy.json) +- [IAM permissions for Neptune DB](./deploy-to-sagemaker/graph-explorer-neptune-db-policy.json) +- [IAM permissions for Neptune Analytics](./deploy-to-sagemaker/graph-explorer-neptune-analytics-policy.json) diff --git a/docs/references/README.md b/docs/references/README.md new file mode 100644 index 000000000..e6bb029c1 --- /dev/null +++ b/docs/references/README.md @@ -0,0 +1,11 @@ +# References + +Technical reference documentation for Graph Explorer covering configuration, security, and operational details. + +- [Configuration](./configuration.md) - Environment variables for application settings and default connections +- [Minimum Requirements](./minimum-requirements.md) - Recommended database and query language versions +- [Security](./security.md) - HTTPS connections, certificates, and permissions +- [Health Check](./health-check.md) - Proxy server health and readiness endpoint +- [Logging](./logging.md) - Log levels, output, and proxy server logging modules +- [Default Connection](./default-connection.md) - Configure a default connection via environment variables or JSON +- [Default Styling](./default-styling.md) - Configure default vertex and edge styling via a JSON file diff --git a/docs/references/configuration.md b/docs/references/configuration.md new file mode 100644 index 000000000..c4b8ef1f8 --- /dev/null +++ b/docs/references/configuration.md @@ -0,0 +1,165 @@ +[← References](./) + +# Configuration + +All environment variables for configuring Graph Explorer, organized by concern. + +## Application Configuration + +These variables control server behavior, networking, and security. + +### `GRAPH_EXP_ENV_ROOT_FOLDER` + +Base path used to serve the `graph-explorer` front end application. + +Example: `/explorer` + +- Optional +- Default: `/` +- Type: `string` + +### `HOST` + +The public hostname of the server. This is used to generate the self-signed SSL certificate at container startup. + +Example: `localhost` + +- Required when using HTTPS connections +- Default is `localhost` +- Type: `string` + +### `GRAPH_EXP_HTTPS_CONNECTION` + +Uses the self-signed certificate to serve Graph Explorer over https if true. Only used in Docker via the entrypoint script. + +- Optional +- Default `true` in Docker, not set otherwise +- Type: `boolean` + +### `PROXY_SERVER_HTTPS_PORT` + +The port to use for the HTTPS server. + +- Optional +- Default `443` +- Type: `number` + +### `PROXY_SERVER_HTTP_PORT` + +The port to use for the HTTP server. + +- Optional +- Default `80` +- Type: `number` + +### `PROXY_SERVER_HTTPS_CONNECTION` + +Uses the self-signed certificate to serve the proxy-server over https if true. + +- Optional +- Default `false` in code, `true` in Docker via the entrypoint script +- Type: `boolean` + +### `PROXY_SERVER_CORS_ORIGIN` + +Restricts which origins are allowed to make cross-origin requests to the proxy server. When set, only requests from these exact origins will receive CORS headers. When not set, cross-origin requests are blocked. Each origin must include the scheme and must not have a trailing slash or path. + +Example: `https://my-app.example.com` or `https://app-a.example.com,https://app-b.example.com` + +- Optional +- Default: cross-origin requests blocked +- Type: `string` (comma-separated for multiple origins) + +### `LOG_STYLE` + +Controls the log output format. + +- Optional +- Default: `default` +- Type: `"cloudwatch" | "default"` +- `cloudwatch` omits timestamps and hostname/pid (these are provided by CloudWatch) +- `default` uses the standard log format + +### `CONFIGURATION_FOLDER_PATH` + +Override path for the folder containing `.env` and `defaultConnection.json`. When set, replaces the default path entirely. + +- Optional +- Default: `` (`packages/graph-explorer`) +- Type: `string` + +## Default Connection + +To provide a default connection such that initial loads of Graph Explorer always result with the same starting connection, modify the `docker run ...` command to either take in a JSON configuration or runtime environment variables. If you provide both a JSON configuration and environmental variables, the JSON will be prioritized. + +### Environment Variables + +These are the valid environment variables used for the default connection, their defaults, and their descriptions. + +- Required: + - `PUBLIC_OR_PROXY_ENDPOINT` - `None` +- Optional + - `GRAPH_TYPE` - `None` - If not specified, multiple connections will be created for every available query language. + - `USING_PROXY_SERVER` - `False` + - `IAM` - `False` + - `GRAPH_EXP_HTTPS_CONNECTION` - `True` - Controls whether Graph Explorer uses SSL or not + - `PROXY_SERVER_HTTPS_CONNECTION` - `True` - Controls whether the server uses SSL or not + - `GRAPH_EXP_FETCH_REQUEST_TIMEOUT` - `240000` - Controls the timeout for the fetch request. Measured in milliseconds (i.e. 240000 is 240 seconds or 4 minutes). + - `GRAPH_EXP_NODE_EXPANSION_LIMIT` - `None` - Controls the limit for node counts and expansion queries. +- Conditionally Required: + - Required if `USING_PROXY_SERVER=True` + - `GRAPH_CONNECTION_URL` - `None` + - Required if `USING_PROXY_SERVER=True` and `IAM=True` + - `AWS_REGION` - `None` + - `SERVICE_TYPE` - `neptune-db`, Set this as `neptune-db` for Neptune database or `neptune-graph` for Neptune Analytics. + +### JSON Configuration Approach + +First, create a `config.json` file containing values for the connection attributes: + +```json +{ + "PUBLIC_OR_PROXY_ENDPOINT": "https://public-endpoint", + "GRAPH_CONNECTION_URL": "https://{your-cluster-id}.us-west-2.neptune.amazonaws.com:8182", + "USING_PROXY_SERVER": true, + "IAM": true, + "SERVICE_TYPE": "neptune-db", + "AWS_REGION": "us-west-2", + "GRAPH_TYPE": "gremlin", + "GRAPH_EXP_HTTPS_CONNECTION": true, + "PROXY_SERVER_HTTPS_CONNECTION": true, + "GRAPH_EXP_FETCH_REQUEST_TIMEOUT": 240000, + "GRAPH_EXP_NODE_EXPANSION_LIMIT": 500 +} +``` + +`GRAPH_TYPE` accepts `"gremlin"`, `"sparql"`, or `"openCypher"`. `GRAPH_EXP_FETCH_REQUEST_TIMEOUT` is measured in milliseconds (e.g., 240000 is 4 minutes). + +Pass the `config.json` file path to the `docker run` command. + +```bash +docker run -p 80:80 -p 443:443 \ + --env HOST={hostname-or-ip-address} \ + -v /path/to/config.json:/graph-explorer/config.json \ + public.ecr.aws/neptune/graph-explorer +``` + +### Environment Variable Approach + +Provide the desired connection variables directly to the `docker run` command, as follows: + +```bash +docker run -p 80:80 -p 443:443 \ + --env HOST={hostname-or-ip-address} \ + --env PUBLIC_OR_PROXY_ENDPOINT=https://public-endpoint \ + --env GRAPH_TYPE=gremlin \ + --env USING_PROXY_SERVER=true \ + --env IAM=false \ + --env GRAPH_CONNECTION_URL=https://{your-cluster-id}.us-west-2.neptune.amazonaws.com:8182 \ + --env AWS_REGION=us-west-2 \ + --env SERVICE_TYPE=neptune-db \ + --env PROXY_SERVER_HTTPS_CONNECTION=true \ + --env GRAPH_EXP_FETCH_REQUEST_TIMEOUT=240000 \ + --env GRAPH_EXP_NODE_EXPANSION_LIMIT=500 \ + public.ecr.aws/neptune/graph-explorer +``` diff --git a/docs/references/default-styling.md b/docs/references/default-styling.md new file mode 100644 index 000000000..ebcce86b8 --- /dev/null +++ b/docs/references/default-styling.md @@ -0,0 +1,165 @@ +# Default Styling Configuration + +Graph Explorer supports an optional `defaultStyling.json` file that provides +default vertex and edge styling for all users. This is useful for: + +- Non-persistent browser environments (e.g., AWS WorkSpaces Web) where IndexedDB + is cleared between sessions +- Pre-configuring a shared visual style for teams +- Providing a consistent starting point for new users + +## How It Works + +On startup, Graph Explorer fetches `defaultStyling.json` and merges its values +into the user's per-type styling preferences (stored in IndexedDB). Default +values fill in any properties the user hasn't explicitly set β€” existing user +overrides are preserved. + +When no `defaultStyling.json` is mounted, behavior is identical to the default +Graph Explorer experience. When mounted, it populates the per-type styling that +users can then customize. Resetting a vertex or edge style in the UI will revert +to the `defaultStyling.json` values (or the hardcoded application defaults if no +entry exists for that type). + +## Setup + +### Docker + +Mount the file into the container's configuration folder: + +```bash +docker run \ + -v /path/to/defaultStyling.json:/graph-explorer/packages/graph-explorer/defaultStyling.json \ + public.ecr.aws/neptune/graph-explorer +``` + +### Custom Icons + +To serve custom icon files (referenced by URL in the config), mount an icons +directory: + +```bash +docker run \ + -v /path/to/defaultStyling.json:/graph-explorer/packages/graph-explorer/defaultStyling.json \ + -v /path/to/icons:/graph-explorer/packages/graph-explorer/custom-icons \ + public.ecr.aws/neptune/graph-explorer +``` + +Icons in the `custom-icons` directory are served at `/custom-icons/`. + +## JSON Schema + +```json +{ + "vertices": { + "": { + "color": "#hex", + "icon": "lucide-icon-name", + "iconUrl": "url-or-base64", + "iconImageType": "image/svg+xml", + "shape": "ellipse", + "displayLabel": "Custom Label", + "displayNameAttribute": "name", + "longDisplayNameAttribute": "description", + "backgroundOpacity": 0.4, + "borderWidth": 0, + "borderColor": "#hex", + "borderStyle": "solid" + } + }, + "edges": { + "": { + "displayLabel": "Custom Label", + "displayNameAttribute": "name", + "labelColor": "#hex", + "labelBackgroundOpacity": 0.7, + "labelBorderColor": "#hex", + "labelBorderStyle": "solid", + "labelBorderWidth": 0, + "lineColor": "#hex", + "lineThickness": 2, + "lineStyle": "solid", + "sourceArrowStyle": "none", + "targetArrowStyle": "triangle" + } + } +} +``` + +All properties are optional β€” only specify what you want to override. Type +labels must exactly match the vertex/edge type names in your graph database. + +### Icons + +There are three ways to set vertex icons: + +- **Icon Picker (UI)** β€” In the Node Style dialog, click **Browse** to search + and select from the full Lucide icon library (~1,900 icons). +- **`icon`** β€” A [Lucide](https://lucide.dev/icons) icon name in kebab-case + (e.g., `"user"`, `"log-in"`, `"landmark"`). Resolved to an SVG at runtime. No + additional files needed. +- **`iconUrl`** β€” A URL or base64 data URI for a custom icon. Use this for + non-Lucide icons. If both `icon` and `iconUrl` are specified, `iconUrl` takes + precedence. + +### Vertex Shapes + +Available shapes: `ellipse`, `rectangle`, `diamond`, `triangle`, `pentagon`, +`hexagon`, `heptagon`, `octagon`, `star`, `barrel`, `vee`, `rhomboid`, `tag`, +`round-rectangle`, `round-triangle`, `round-diamond`, `round-pentagon`, +`round-hexagon`, `round-heptagon`, `round-octagon`, `round-tag`, +`cut-rectangle`, `concave-hexagon`. + +### Line Styles + +Available for edges and borders: `solid`, `dashed`, `dotted`. + +### Arrow Styles + +Available for `sourceArrowStyle` and `targetArrowStyle`: `triangle`, +`triangle-tee`, `circle-triangle`, `triangle-cross`, `triangle-backcurve`, +`tee`, `vee`, `square`, `circle`, `diamond`, `none`. + +## Common Lucide Icons for Graph Use Cases + +| Use Case | Icon Name | +| -------------------- | --------------------------------- | +| Person / User | `user` | +| Account / Bank | `landmark` | +| Email | `mail` | +| Phone | `phone` | +| Login / Auth | `log-in` | +| Device | `monitor`, `laptop`, `smartphone` | +| IP Address / Network | `globe`, `network` | +| Transaction | `arrow-left-right` | +| Location | `map-pin` | +| Organization | `building` | +| Alert | `shield-alert`, `triangle-alert` | +| Document | `file-text` | +| Calendar / Date | `calendar` | +| Lock / Security | `lock`, `shield` | +| Database | `database` | +| Server | `server` | +| Link / Relationship | `link` | + +See the full list at [lucide.dev/icons](https://lucide.dev/icons). + +## Import / Export / Reset + +The Settings page provides styling management: + +- **Export Styling** β€” exports the current per-type styling as a + `defaultStyling.json` file, for sharing or Docker-mounting as team defaults. +- **Import Styling** β€” imports a `defaultStyling.json` file. This is an + alternative to mounting the file in Docker. +- **Reset All Styling** β€” resets all styling to defaults. If a + `defaultStyling.json` is mounted, those values are restored; otherwise, + styling reverts to the application defaults. + +Per-type reset is also available in the Node/Edge Style dialogs via the "Reset +to Default" button. + +## Example + +See [`example/defaultStyling.json`](../example/defaultStyling.json) for a +complete example with banking-oriented vertex and edge types. diff --git a/docs/references/health-check.md b/docs/references/health-check.md new file mode 100644 index 000000000..6aeee1f8b --- /dev/null +++ b/docs/references/health-check.md @@ -0,0 +1,11 @@ +[← References](./) + +# Health Check Status + +The `graph-explorer-proxy-server` provides a `/status` endpoint for monitoring its health and readiness. This endpoint is crucial for ensuring reliable service operation and can be utilized in various deployment scenarios. + +**Key Features:** + +- **Health Check:** The `/status` endpoint serves as a basic health check, confirming that the Express server is running and responding. This is essential for load balancers (like AWS ALB) to determine if the server is operational and should receive traffic. +- **Readiness Probe:** It also functions as a readiness probe in container orchestration systems (like Kubernetes). This allows the orchestrator to know when the server is ready to accept requests, preventing traffic from being routed to instances that are still starting up or experiencing issues. +- **Expected Response:** A successful health check or readiness probe will result in an HTTP `200 OK` response with the body containing `OK`. diff --git a/docs/references/logging.md b/docs/references/logging.md new file mode 100644 index 000000000..2bd7b6ff0 --- /dev/null +++ b/docs/references/logging.md @@ -0,0 +1,15 @@ +[← References](./) + +# Logging + +Logs are, by default, sent to the console and will be visible as output to the docker logs. If you want to access the full set of logs, you can run `docker logs {container name or id}`. + +The log level will be set via the `LOG_LEVEL` env variable at `/packages/graph-explorer/.env` where the possible options, from highest to lowest, are `fatal`, `error`, `warn`, `info`, `debug`, `trace`, and `silent` such that `fatal` is the highest level and will only include logs labeled as fatal and `trace` the lowest and will include any type of log. The `silent` level disables all logging. + +By default, the log level is set to `info` and the only type of logs generated are those of `error`, `info`, or `debug`. If you need more detailed logs, you can change the log level from `info` in the default .env file to `debug` and the logs will begin printing the error's stack trace. + +The proxy server logging is split across a few key modules: + +1. `logging.ts` - Contains the `logger` instance (using pino) that is responsible for actually recording the logs. +2. `error-handler.ts` - Contains `errorHandlingMiddleware` which catches errors thrown within Express routes, logs whitelisted request headers, and sends appropriate error responses. It also contains a `handleError` function used for global error handling. +3. An endpoint called `/logger` in `node-server.ts` - This is how you would log things from the browser. It needs a log level and message header passed and you can then expect to see the message logged at the provided log level. diff --git a/docs/references/minimum-requirements.md b/docs/references/minimum-requirements.md new file mode 100644 index 000000000..b9de2b62e --- /dev/null +++ b/docs/references/minimum-requirements.md @@ -0,0 +1,9 @@ +[← References](./) + +# Minimum Requirements + +Graph Explorer does not block any particular versions of graph databases, but the queries used may or may not succeed based on the version of the query engine. + +For Neptune databases, we recommend [version 1.2.1.0](https://docs.aws.amazon.com/neptune/latest/userguide/engine-releases-1.2.1.0.html) or above, which include the summary API and TinkerPop 3.6.2. + +For non-Neptune databases, we recommend at least TinkerPop 3.6. diff --git a/docs/references/security.md b/docs/references/security.md new file mode 100644 index 000000000..7b825b026 --- /dev/null +++ b/docs/references/security.md @@ -0,0 +1,104 @@ +[← References](./) + +# Security + +You can use Graph Explorer to connect to a publicly accessible graph database endpoint, or connect to a proxy endpoint that redirects to a private graph database endpoint. + +Graph Explorer supports the HTTPS protocol by default and provides a self-signed certificate as part of the Docker image. You can choose to use HTTP instead by changing the [environment variable default settings](./configuration.md#application-configuration). + +## HTTPS Connections + +Graph Explorer serves over HTTPS by default using a self-signed certificate. The `HOST` environment variable controls the hostname used in the certificate's Subject Alternative Name (SAN). When `HOST` is set, the entrypoint script generates a fresh self-signed certificate on container startup. When `HOST` is not set, the server expects to find existing certificate files. + +### Certificate files + +The proxy server reads certificates from the following location inside the container: + +``` +/graph-explorer/packages/graph-explorer-proxy-server/cert-info/ +β”œβ”€β”€ rootCA.key # Root CA private key +β”œβ”€β”€ rootCA.crt # Root CA certificate +β”œβ”€β”€ server.key # Server private key +β”œβ”€β”€ server.csr # Server certificate signing request +β”œβ”€β”€ server.crt # Server certificate +β”œβ”€β”€ csr.conf # CSR configuration template +└── cert.conf # Certificate extensions configuration +``` + +If HTTPS is enabled and any of these files are missing, the server will exit with an error listing the missing files. + +### Using your own certificates + +To use your own certificates instead of the self-signed ones, mount your certificate files into the `cert-info` directory. All five certificate files must be present (`rootCA.key`, `rootCA.crt`, `server.key`, `server.csr`, `server.crt`). + +> [!IMPORTANT] +> +> Do not set the `HOST` environment variable, otherwise the entrypoint will overwrite your files with a new self-signed certificate. + +```bash +docker run -p 443:443 \ + -v /path/to/your/server.key:/graph-explorer/packages/graph-explorer-proxy-server/cert-info/server.key \ + -v /path/to/your/server.crt:/graph-explorer/packages/graph-explorer-proxy-server/cert-info/server.crt \ + -v /path/to/your/rootCA.crt:/graph-explorer/packages/graph-explorer-proxy-server/cert-info/rootCA.crt \ + -v /path/to/your/rootCA.key:/graph-explorer/packages/graph-explorer-proxy-server/cert-info/rootCA.key \ + -v /path/to/your/server.csr:/graph-explorer/packages/graph-explorer-proxy-server/cert-info/server.csr \ + public.ecr.aws/neptune/graph-explorer +``` + +### Disabling HTTPS + +To serve over HTTP instead, set `PROXY_SERVER_HTTPS_CONNECTION=false` in your environment or `.env` file. + +### Trusting the self-signed certificate + +When using the default self-signed certificate, your browser will show a security warning. You can bypass this by trusting the certificate: + +1. Download the certificate directly from the browser. For example, if using Google Chrome, click the "Not Secure" section on the left of the URL bar and select "Certificate is not valid" to show the certificate. Then click Details tab and click Export at the bottom. +2. Once you have the certificate, you will need to trust it on your machine. For MacOS, you can open the Keychain Access app. Select System under System Keychains. Then go to File > Import Items... and import the certificate you downloaded in the previous step. +3. Once imported, select the certificate and right-click to select "Get Info". Expand the Trust section, and change the value of "When using this certificate" to "Always Trust". +4. You should now refresh the browser and see that you can proceed to open the application. For Chrome, the application will remain "Not Secure" due to the fact that this is a self-signed certificate. If you have trouble accessing Graph Explorer after completing the previous step and reloading the browser, consider running a docker restart command and refreshing the browser again. + +### Removing the "Not Secure" warning on Chrome + +For browsers like Safari and Firefox, trusting the certificate from the browser (steps above) is enough to bypass the "Not Secure" warning. However, Chrome treats self-signed certificates differently. To remove the warning on Chrome, you need to trust the **root CA certificate** rather than the server certificate. See the [Chrome Root Store FAQ](https://chromium.googlesource.com/chromium/src/+/main/net/data/ssl/chrome_root_store/faq.md#how-does-the-chrome-certificate-verifier-integrate-with-platform-trust-stores-for-local-trust-decisions) for details on how Chrome integrates with platform trust stores. + +1. Copy the root certificate from the running container to your local machine: + ``` + docker cp graph-explorer:/graph-explorer/packages/graph-explorer-proxy-server/cert-info/rootCA.crt ./rootCA.crt + ``` + If Graph Explorer is running on a remote host (e.g., EC2), copy the file to the remote host first, then use `scp` to transfer it to your local machine. +2. Trust the root certificate on your machine. For macOS, open the Keychain Access app, select System under System Keychains, then go to File > Import Items... and import `rootCA.crt`. +3. Once imported, select the certificate and right-click to select "Get Info". Expand the Trust section, and change the value of "When using this certificate" to "Always Trust". +4. Refresh the browser. The "Not Secure" warning should be gone. + +## CORS + +By default, the proxy server does not allow cross-origin requests. Since the proxy server serves both the API and the UI from the same origin, CORS is not needed in standard deployments. In development mode, the Vite dev server proxies API requests to the Express server to maintain same-origin behavior. + +If you serve the UI from a different origin than the proxy server, set the `PROXY_SERVER_CORS_ORIGIN` environment variable to the origin you want to allow. + +```bash +PROXY_SERVER_CORS_ORIGIN=https://my-app.example.com +``` + +To allow multiple origins, separate them with commas: + +```bash +PROXY_SERVER_CORS_ORIGIN=https://my-app.example.com,https://other-app.example.com +``` + +When set, browsers will block cross-origin requests from any other origin. This prevents malicious pages from making requests to the proxy server using a visitor's browser session. + +> [!NOTE] +> +> CORS headers only affect browser-initiated requests β€” direct API calls from scripts or other servers are not restricted by CORS. CORS is a defense-in-depth layer, not a substitute for authentication or network-level access controls. Ensure the proxy server is not exposed to untrusted networks. + +## Permissions + +Graph Explorer does not provide any mechanisms for controlling user permissions. If you are using Graph Explorer with AWS, Neptune permissions can be controlled through IAM roles. + +For information about what permissions Graph Explorer requires check out the documentation on [SageMaker configuration](../guides/deploy-to-sagemaker.md#minimum-database-permissions). + +> [!CAUTION] +> +> By default, a Neptune Notebook will have full read & write access to Neptune data. diff --git a/eslint.config.mjs b/eslint.config.mjs deleted file mode 100644 index 1fb095125..000000000 --- a/eslint.config.mjs +++ /dev/null @@ -1,126 +0,0 @@ -import { defineConfig } from "eslint/config"; -import globals from "globals"; -import pluginJs from "@eslint/js"; -import tseslint from "typescript-eslint"; -import reactLint from "eslint-plugin-react"; -import reactHooks from "eslint-plugin-react-hooks"; -import tanstackQueryLint from "@tanstack/eslint-plugin-query"; -import perfectionist from "eslint-plugin-perfectionist"; -import eslintConfigPrettier from "eslint-config-prettier"; -import { includeIgnoreFile } from "@eslint/compat"; -import { fileURLToPath } from "node:url"; - -const gitignorePath = fileURLToPath(new URL(".gitignore", import.meta.url)); - -export default defineConfig( - // Ignored files - includeIgnoreFile(gitignorePath, "Imported .gitignore patterns"), - { - ignores: ["**/*.config.{js,ts,mjs}", "**/vitest.workspace.ts"], - }, - - // JavaScript files - pluginJs.configs.recommended, - tseslint.configs.recommendedTypeChecked, - - // TypeScript files - all packages - { - languageOptions: { - parserOptions: { - projectService: true, - }, - }, - rules: { - // Disallow use of console.log - "no-console": ["error", { allow: ["warn", "error"] }], - - // Force all switches to be exhaustive - "@typescript-eslint/switch-exhaustiveness-check": "error", - - // Ensure imports are marked with type when appropriate - "@typescript-eslint/consistent-type-imports": [ - "error", - { fixStyle: "inline-type-imports" }, - ], - - // Allow unused vars with modifier - "@typescript-eslint/no-unused-vars": [ - "error", - { - argsIgnorePattern: "^_", - varsIgnorePattern: "^_", - caughtErrorsIgnorePattern: "^_", - }, - ], - - // Ensure no type imports contain side effects - "@typescript-eslint/no-import-type-side-effects": "error", - - // Disable overly strict rules (we should eliminate these over time) - "@typescript-eslint/no-explicit-any": "off", - "@typescript-eslint/no-unsafe-member-access": "off", - "@typescript-eslint/no-unsafe-assignment": "off", - "@typescript-eslint/no-unsafe-call": "off", - "@typescript-eslint/no-unsafe-return": "off", - "@typescript-eslint/no-unsafe-argument": "off", - "@typescript-eslint/no-unused-expressions": "off", - "@typescript-eslint/unbound-method": "off", - "@typescript-eslint/no-floating-promises": "off", - "@typescript-eslint/no-misused-promises": "off", - }, - }, - - // React files - frontend only - { - files: ["packages/graph-explorer/**/*.{ts,tsx}"], - plugins: { - react: reactLint, - "react-hooks": reactHooks, - "@tanstack/query": tanstackQueryLint, - }, - languageOptions: { - globals: globals.browser, - parserOptions: { ecmaFeatures: { jsx: true } }, - }, - settings: { - react: { version: "19" }, - }, - rules: { - ...reactLint.configs.flat.recommended.rules, - ...reactHooks.configs.flat.recommended.rules, - ...tanstackQueryLint.configs.recommended.rules, - - // React optimizations - "react/react-in-jsx-scope": "off", - "react/prop-types": "off", - "react/display-name": "off", - "react/jsx-curly-brace-presence": "error", - - // TanStack Query - "@tanstack/query/exhaustive-deps": "error", - "@tanstack/query/no-rest-destructuring": "warn", - "@tanstack/query/stable-query-client": "error", - }, - }, - - // Node.js backend files - { - files: ["packages/graph-explorer-proxy-server/**/*.{ts,js}"], - languageOptions: { - globals: globals.node, - }, - }, - - { - plugins: { - perfectionist, - }, - rules: { - "perfectionist/sort-imports": ["error", { type: "natural" }], - "perfectionist/sort-named-imports": ["error", { type: "natural" }], - }, - }, - - // Prettier must be last - eslintConfigPrettier, -); diff --git a/example/defaultStyling.json b/example/defaultStyling.json new file mode 100644 index 000000000..122a8856b --- /dev/null +++ b/example/defaultStyling.json @@ -0,0 +1,86 @@ +{ + "vertices": { + "User": { + "color": "#1565C0", + "icon": "user", + "shape": "ellipse" + }, + "Account": { + "color": "#2E7D32", + "icon": "landmark", + "shape": "ellipse" + }, + "Login": { + "color": "#EF6C00", + "icon": "log-in", + "shape": "ellipse" + }, + "EmailAddress": { + "color": "#C62828", + "icon": "mail", + "shape": "ellipse" + }, + "PhoneNumber": { + "color": "#6A1B9A", + "icon": "phone", + "shape": "ellipse" + }, + "Device": { + "color": "#00838F", + "icon": "monitor", + "shape": "rectangle" + }, + "IPAddress": { + "color": "#4E342E", + "icon": "globe", + "shape": "diamond" + }, + "Transaction": { + "color": "#1B5E20", + "icon": "arrow-left-right", + "shape": "ellipse" + }, + "Location": { + "color": "#E65100", + "icon": "map-pin", + "shape": "ellipse" + }, + "Organization": { + "color": "#283593", + "icon": "building", + "shape": "rectangle" + } + }, + "edges": { + "OWNS": { + "lineColor": "#2E7D32", + "lineThickness": 2, + "lineStyle": "solid" + }, + "TRANSFERRED_TO": { + "lineColor": "#1B5E20", + "lineThickness": 3, + "lineStyle": "solid" + }, + "HAS_EMAIL": { + "lineColor": "#9E9E9E", + "lineThickness": 1, + "lineStyle": "dashed" + }, + "HAS_PHONE": { + "lineColor": "#9E9E9E", + "lineThickness": 1, + "lineStyle": "dashed" + }, + "LOGGED_IN_FROM": { + "lineColor": "#EF6C00", + "lineThickness": 2, + "lineStyle": "solid" + }, + "LOCATED_AT": { + "lineColor": "#E65100", + "lineThickness": 1, + "lineStyle": "dotted" + } + } +} diff --git a/package.json b/package.json index d4accc3c8..299dee31d 100644 --- a/package.json +++ b/package.json @@ -1,34 +1,17 @@ { "name": "graph-explorer", - "version": "3.0.0", + "version": "3.0.3", + "private": true, "description": "Graph Explorer", - "author": "amazon", "license": "Apache-2.0", + "author": "amazon", "type": "module", - "packageManager": "pnpm@10.28.1+sha512.7d7dbbca9e99447b7c3bf7a73286afaaf6be99251eb9498baefa7d406892f67b879adb3a1d7e687fc4ccc1a388c7175fbaae567a26ab44d1067b54fcb0d6a316", - "devEngines": { - "runtime": { - "name": "node", - "version": ">=24.13.0", - "onFail": "error" - }, - "packageManager": { - "name": "pnpm", - "version": ">=10.28.1", - "onFail": "error" - } - }, - "engines": { - "node": ">=24.13.0", - "pnpm": ">=10.28.1" - }, - "private": true, "scripts": { "prepare": "husky", - "lint": "eslint --fix --concurrency=auto", - "check:lint": "eslint --concurrency=auto", - "format": "prettier --write .", - "check:format": "prettier --check .", + "lint": "oxlint --fix", + "check:lint": "oxlint", + "format": "oxfmt", + "check:format": "oxfmt --check", "test": "vitest run", "test:watch": "vitest", "coverage": "vitest run --coverage", @@ -42,54 +25,56 @@ "dev": "pnpm --stream -r run dev" }, "devDependencies": { - "@eslint/compat": "^2.0.2", - "@eslint/js": "^10.0.1", - "@tanstack/eslint-plugin-query": "^5.91.4", - "@vitest/coverage-v8": "4.0.10", - "babel-plugin-react-compiler": "19.1.0-rc.3", - "eslint": "^10.0.2", - "eslint-config-prettier": "^10.1.8", - "eslint-plugin-perfectionist": "^5.6.0", - "eslint-plugin-react": "^7.37.5", - "eslint-plugin-react-hooks": "7.0.1", - "globals": "^16.5.0", + "@tanstack/eslint-plugin-query": "5.100.6", + "@types/node": "^24.10.9", + "@vitest/coverage-v8": "4.1.5", + "babel-plugin-react-compiler": "^1.0.0", + "eslint-plugin-react-hooks": "7.1.1", "husky": "^9.1.7", - "lint-staged": "^16.2.7", - "prettier": "^3.8.1", - "prettier-plugin-tailwindcss": "^0.7.2", - "typescript": "^5.9.3", - "typescript-eslint": "^8.56.0", - "vitest": "4.0.10" + "lint-staged": "^16.4.0", + "oxfmt": "^0.47.0", + "oxlint": "1.62.0", + "oxlint-tsgolint": "0.22.1", + "typescript": "^6.0.3", + "vitest": "4.1.5" }, "lint-staged": { - "!(**/*.{js,ts,tsx})": "prettier --ignore-unknown --write", + "!(**/*.{js,ts,tsx})": "oxfmt --no-error-on-unmatched-pattern", "**/*.{js,ts,tsx}": [ - "eslint --fix", - "prettier --ignore-unknown --write" + "oxlint --fix", + "oxfmt" ] }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": ">=10.28.1", + "onFail": "error" + }, + "runtime": { + "name": "node", + "version": ">=24.13.0", + "onFail": "error" + } + }, + "engines": { + "node": ">=24.13.0", + "pnpm": ">=10.28.1" + }, + "packageManager": "pnpm@10.28.1+sha512.7d7dbbca9e99447b7c3bf7a73286afaaf6be99251eb9498baefa7d406892f67b879adb3a1d7e687fc4ccc1a388c7175fbaae567a26ab44d1067b54fcb0d6a316", "pnpm": { "overrides": { - "json5@>=2.0.0 <2.2.2": ">=2.2.2", - "minimatch@<10.2.1": ">=10.2.1", - "loader-utils@>=2.0.0 <2.0.4": ">=2.0.4", - "webpack@>=5.0.0 <5.76.0": ">=5.76.0", - "decode-uri-component@<0.2.1": ">=0.2.1", - "yaml@>=2.0.0-5 <2.2.2": ">=2.2.2", - "vite@>=4.5.0 <4.5.3": ">=4.5.3", - "xml2js@<0.5.0": ">=0.5.0", - "semver@<7.5.2": ">=7.5.2", - "postcss@<8.4.31": ">=8.4.31", - "@babel/traverse@<7.23.2": ">=7.23.2", - "crypto-js@<4.2.0": ">=4.2.0", - "@adobe/css-tools@<4.3.2": ">=4.3.2", - "undici@<5.28.3": ">=5.28.3", - "ws@<8.17.1": ">=8.17.1", - "@eslint/plugin-kit@<0.3.3": ">=0.3.3", - "fast-xml-parser@<5.3.5": ">=5.3.5", - "@isaacs/brace-expansion@<5.0.1": ">=5.0.1", - "qs@>=6.7.0 <6.14.2": ">=6.14.2", - "rollup@>=4.0.0 <4.59.0": ">=4.59.0" + "semver@<7.0.0": ">=7.7.3", + "brace-expansion@>=5.0.0 <5.0.5": ">=5.0.5", + "dompurify@<=3.3.3": ">=3.4.0", + "fast-xml-parser@<5.7.0": ">=5.7.0", + "flatted@<3.4.2": ">=3.4.2", + "minimatch@<10.2.3": ">=10.2.3", + "path-to-regexp@>=8.0.0 <8.4.0": ">=8.4.0", + "picomatch@<2.3.2": ">=2.3.2", + "picomatch@>=4.0.0 <4.0.4": ">=4.0.4", + "yaml@>=1.0.0 <1.10.3": ">=1.10.3", + "yaml@>=2.0.0 <2.8.3": ">=2.8.3" } } } diff --git a/packages/graph-explorer-proxy-server/cert-info/csr.conf b/packages/graph-explorer-proxy-server/cert-info/csr.conf index d51943669..d5a4c2846 100644 --- a/packages/graph-explorer-proxy-server/cert-info/csr.conf +++ b/packages/graph-explorer-proxy-server/cert-info/csr.conf @@ -17,4 +17,4 @@ subjectAltName = @alt_names [ alt_names ] # Filled in by Dockerfile -DNS.1 = \ No newline at end of file +DNS.1 = \ No newline at end of file diff --git a/packages/graph-explorer-proxy-server/package.json b/packages/graph-explorer-proxy-server/package.json index 1ac1d1d19..bb1e9bc54 100644 --- a/packages/graph-explorer-proxy-server/package.json +++ b/packages/graph-explorer-proxy-server/package.json @@ -1,20 +1,17 @@ { "name": "graph-explorer-proxy-server", - "version": "3.0.0", + "version": "3.0.3", "description": "Server to facilitate communication between the browser and the supported graph database.", - "main": "dist/node-server.js", + "license": "Apache-2.0", + "author": "amazon", "type": "module", "scripts": { "typecheck": "tsc --noEmit", - "clean": "rimraf dist coverage", - "build": "tsc --build tsconfig.build.json", - "dev": "tsx watch src/node-server.ts", - "start": "NODE_ENV=production node dist/node-server.js" + "dev": "node --watch src/node-server.ts", + "start": "NODE_ENV=production node src/node-server.ts" }, - "author": "amazon", - "license": "Apache-2.0", "dependencies": { - "@aws-sdk/credential-providers": "^3.993.0", + "@aws-sdk/credential-providers": "^3.1045.0", "@graph-explorer/shared": "workspace:*", "aws4": "^1.13.2", "body-parser": "^2.2.2", @@ -22,11 +19,11 @@ "cors": "^2.8.6", "crypto-js": "^4.2.0", "date-fns": "^4.1.0", - "dotenv": "^17.2.3", + "dotenv": "^17.4.2", "express": "^5.2.1", "https": "^1.0.0", "node-fetch": "^3.3.2", - "pino": "^10.3.0", + "pino": "^10.3.1", "pino-pretty": "^13.1.3", "zod": "^4.3.6" }, @@ -36,11 +33,10 @@ "@types/compression": "^1.8.1", "@types/cors": "^2.8.19", "@types/express": "^5.0.6", - "@types/node": "^24.10.9", - "@vitest/coverage-v8": "4.0.10", - "memfs": "^4.56.10", - "rimraf": "^6.1.2", - "tsx": "^4.21.0", - "vitest": "4.0.10" + "@types/node": "^24.12.2", + "@types/supertest": "^7.2.0", + "@vitest/coverage-v8": "4.1.5", + "supertest": "^7.2.2", + "vitest": "4.1.5" } } diff --git a/packages/graph-explorer-proxy-server/src/app.test.ts b/packages/graph-explorer-proxy-server/src/app.test.ts new file mode 100644 index 000000000..03b4f5d72 --- /dev/null +++ b/packages/graph-explorer-proxy-server/src/app.test.ts @@ -0,0 +1,931 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; +import { Readable } from "stream"; +import request from "supertest"; + +import { createApp } from "./app.ts"; +import { createLogger } from "./logging.ts"; + +// node-fetch is globally mocked in test-setup.ts +const { default: fetch } = await import("node-fetch"); +const mockFetch = vi.mocked(fetch); + +const testVersion = "1.2.3"; + +function createTestApp(configPath = ".", corsOrigin?: string[]) { + const app = createApp({ + configPath, + staticFilesVirtualPath: "/explorer", + staticFilesPath: ".", + version: testVersion, + corsOrigin, + }); + app.locals.logger = createLogger({ + HOST: "localhost", + PROXY_SERVER_HTTPS_CONNECTION: false, + PROXY_SERVER_HTTPS_PORT: 443, + PROXY_SERVER_HTTP_PORT: 80, + LOG_LEVEL: "silent", + LOG_STYLE: "default", + }); + return app; +} + +const graphDbUrl = "https://my-graph-db.example.com:8182"; + +function dbHeaders(overrides: Record = {}) { + return { + "graph-db-connection-url": graphDbUrl, + ...overrides, + }; +} + +/** Creates a minimal node-fetch Response-like object that fetchData can pipe. */ +function createMockFetchResponse( + body: string, + status = 200, + headers: Record = {}, +) { + const readable = Readable.from([body]); + return { + ok: status >= 200 && status < 300, + status, + headers: new Map(Object.entries(headers)), + body: readable, + }; +} + +function mockFetchOnce(body = "ok", status = 200, headers = {}) { + mockFetch.mockResolvedValueOnce( + createMockFetchResponse(body, status, headers) as any, + ); +} + +describe("createApp", () => { + beforeEach(() => { + mockFetch.mockReset(); + }); + + // ── CORS ──────────────────────────────────────────────────────────── + + describe("CORS", () => { + it("does not allow cross-origin requests by default", async () => { + const app = createTestApp(); + const response = await request(app) + .get("/status") + .set("Origin", "http://example.com"); + + expect(response.headers["access-control-allow-origin"]).toBeUndefined(); + }); + + it("does not set CORS headers on preflight by default", async () => { + const app = createTestApp(); + const response = await request(app) + .options("/status") + .set("Origin", "http://example.com") + .set("Access-Control-Request-Method", "POST"); + + expect(response.headers["access-control-allow-origin"]).toBeUndefined(); + }); + + it("does not set origin header when request has no Origin", async () => { + const app = createTestApp(); + const response = await request(app).get("/status"); + + expect(response.headers["access-control-allow-origin"]).toBeUndefined(); + }); + + it("sets the configured corsOrigin when provided", async () => { + const app = createTestApp(".", ["https://my-app.example.com"]); + const response = await request(app) + .get("/status") + .set("Origin", "https://my-app.example.com"); + + expect(response.headers["access-control-allow-origin"]).toBe( + "https://my-app.example.com", + ); + }); + + it("sets the configured origin as a fixed header for single-origin config", async () => { + const app = createTestApp(".", ["https://my-app.example.com"]); + const response = await request(app) + .get("/status") + .set("Origin", "https://evil.example.com"); + + // The cors library sets a fixed Access-Control-Allow-Origin header + // when origin is a string. The browser enforces the mismatch by + // blocking the response when the header doesn't match the page origin. + expect(response.headers["access-control-allow-origin"]).toBe( + "https://my-app.example.com", + ); + }); + + it("returns the configured origin on preflight regardless of the requesting origin", async () => { + const app = createTestApp(".", ["https://my-app.example.com"]); + const response = await request(app) + .options("/status") + .set("Origin", "https://evil.example.com") + .set("Access-Control-Request-Method", "POST"); + + expect(response.headers["access-control-allow-origin"]).toBe( + "https://my-app.example.com", + ); + }); + + it("reflects the matching origin when multiple origins are configured", async () => { + const app = createTestApp(".", [ + "https://app-a.example.com", + "https://app-b.example.com", + ]); + const response = await request(app) + .get("/status") + .set("Origin", "https://app-b.example.com"); + + expect(response.headers["access-control-allow-origin"]).toBe( + "https://app-b.example.com", + ); + }); + + it("does not reflect a non-matching origin when multiple origins are configured", async () => { + const app = createTestApp(".", [ + "https://app-a.example.com", + "https://app-b.example.com", + ]); + const response = await request(app) + .get("/status") + .set("Origin", "https://evil.example.com"); + + expect(response.headers["access-control-allow-origin"]).toBeUndefined(); + }); + + it("only allows GET and POST methods when corsOrigin is configured", async () => { + const app = createTestApp(".", ["http://example.com"]); + const response = await request(app) + .options("/status") + .set("Origin", "http://example.com") + .set("Access-Control-Request-Method", "DELETE"); + + expect(response.headers["access-control-allow-methods"]).toBe("GET,POST"); + }); + + it("sets preflight max-age cache header when corsOrigin is configured", async () => { + const app = createTestApp(".", ["http://example.com"]); + const response = await request(app) + .options("/status") + .set("Origin", "http://example.com") + .set("Access-Control-Request-Method", "POST"); + + expect(response.headers["access-control-max-age"]).toBe("86400"); + }); + + it("only forwards content-type from upstream responses", async () => { + mockFetchOnce(JSON.stringify({ results: [] }), 200, { + "content-type": "application/json", + "access-control-allow-origin": "https://upstream.example.com", + "transfer-encoding": "chunked", + server: "Neptune/1.0", + "x-request-id": "abc-123", + }); + + const app = createTestApp(); + const response = await request(app) + .post("/sparql") + .set(dbHeaders()) + .send({ query: "SELECT 1" }); + + expect(response.headers["content-type"]).toContain("application/json"); + expect(response.headers["access-control-allow-origin"]).toBeUndefined(); + expect(response.headers["server"]).toBeUndefined(); + expect(response.headers["x-request-id"]).toBeUndefined(); + }); + }); + + // ── Static routes ────────────────────────────────────────────────── + + it("GET /status returns 200 OK", async () => { + const app = createTestApp(); + const response = await request(app).get("/status"); + expect(response.status).toBe(200); + expect(response.text).toBe("OK"); + }); + + it("unknown routes return 404", async () => { + const app = createTestApp(); + const response = await request(app).get("/nonexistent"); + expect(response.status).toBe(404); + }); + + it("GET /defaultConnection serves defaultConnection.json from configPath", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ge-app-test-")); + try { + const connectionData = { endpoint: "https://example:8182" }; + fs.writeFileSync( + path.join(tmpDir, "defaultConnection.json"), + JSON.stringify(connectionData), + ); + const app = createTestApp(tmpDir); + const response = await request(app).get("/defaultConnection"); + expect(response.status).toBe(200); + expect(response.body).toEqual(connectionData); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + // ── Logger route ─────────────────────────────────────────────────── + + it("POST /logger returns error when level header is missing", async () => { + const app = createTestApp(); + const response = await request(app) + .post("/logger") + .set("message", JSON.stringify("test message")); + expect(response.status).toBe(500); + }); + + it("POST /logger returns error when message header is missing", async () => { + const app = createTestApp(); + const response = await request(app).post("/logger").set("level", "info"); + expect(response.status).toBe(500); + }); + + it("POST /logger succeeds with valid level and message", async () => { + const app = createTestApp(); + const response = await request(app) + .post("/logger") + .set("level", "info") + .set("message", JSON.stringify("test message")); + expect(response.status).toBe(200); + expect(response.text).toBe("Log received."); + }); + + it.each(["error", "warn", "info", "debug", "trace"])( + "POST /logger accepts %s level", + async level => { + const app = createTestApp(); + const response = await request(app) + .post("/logger") + .set("level", level) + .set("message", JSON.stringify("msg")); + expect(response.status).toBe(200); + }, + ); + + it("POST /logger returns error for unknown log level", async () => { + const app = createTestApp(); + const response = await request(app) + .post("/logger") + .set("level", "verbose") + .set("message", JSON.stringify("msg")); + expect(response.status).toBe(500); + }); + + // ── Error handling middleware ────────────────────────────────────── + + it("error handling middleware sends structured error response", async () => { + const app = createTestApp(); + const response = await request(app) + .post("/logger") + .set("level", "info") + .set("message", "not valid json"); + expect(response.status).toBe(500); + expect(response.body.error).toBeDefined(); + }); + + // ── Query validation (400 cases) ────────────────────────────────── + + describe.each(["sparql", "gremlin", "openCypher"])( + "POST /%s input validation", + route => { + it("returns 400 when query is missing but headers are present", async () => { + const app = createTestApp(); + const response = await request(app) + .post(`/${route}`) + .set(dbHeaders()) + .send({}); + expect(response.status).toBe(400); + }); + + it("returns 400 when neither query nor db headers are present", async () => { + const app = createTestApp(); + const response = await request(app).post(`/${route}`).send({}); + expect(response.status).toBe(400); + }); + + it("returns 400 when query is present but db connection header is missing", async () => { + mockFetchOnce(); + + const app = createTestApp(); + const response = await request(app) + .post(`/${route}`) + .send({ query: "test query" }); + expect(response.status).toBe(400); + }); + + it("returns 400 when graph-db-connection-url is not a valid HTTP URL", async () => { + const app = createTestApp(); + const response = await request(app) + .post(`/${route}`) + .set({ "graph-db-connection-url": "ftp://not-http.example.com" }) + .send({ query: "test query" }); + expect(response.status).toBe(400); + }); + }, + ); + + // ── SPARQL happy path ───────────────────────────────────────────── + + describe("POST /sparql", () => { + it("proxies query to the graph database", async () => { + mockFetchOnce(JSON.stringify({ results: [] }), 200, { + "content-type": "application/sparql-results+json", + }); + + const app = createTestApp(); + const response = await request(app) + .post("/sparql") + .set(dbHeaders()) + .send({ query: "SELECT * WHERE { ?s ?p ?o }" }); + + expect(response.status).toBe(200); + expect(mockFetch).toHaveBeenCalledWith( + `${graphDbUrl}/sparql`, + expect.objectContaining({ method: "POST" }), + ); + }); + + it("sends query as url-encoded form body", async () => { + mockFetchOnce(); + + const app = createTestApp(); + const query = "SELECT * WHERE { ?s ?p ?o }"; + await request(app).post("/sparql").set(dbHeaders()).send({ query }); + + const fetchOptions = mockFetch.mock.calls[0][1] as any; + expect(fetchOptions.headers["content-type"]).toBe( + "application/x-www-form-urlencoded", + ); + expect(fetchOptions.body).toContain(`query=${encodeURIComponent(query)}`); + }); + + it("includes queryId in the body when provided", async () => { + mockFetchOnce(); + + const app = createTestApp(); + await request(app) + .post("/sparql") + .set(dbHeaders({ queryid: "q-123" })) + .send({ query: "SELECT 1" }); + + const fetchOptions = mockFetch.mock.calls[0][1] as any; + expect(fetchOptions.body).toContain( + `queryId=${encodeURIComponent("q-123")}`, + ); + }); + + it("pipes the upstream response status and body back to the client", async () => { + const upstreamBody = JSON.stringify({ + results: { bindings: [{ x: "1" }] }, + }); + mockFetchOnce(upstreamBody, 200, { + "content-type": "application/sparql-results+json", + }); + + const app = createTestApp(); + const response = await request(app) + .post("/sparql") + .set(dbHeaders()) + .send({ query: "SELECT 1" }); + + expect(response.status).toBe(200); + expect(response.text).toBe(upstreamBody); + }); + + it("forwards non-200 status from the upstream database", async () => { + mockFetchOnce("Bad Request", 400); + + const app = createTestApp(); + const response = await request(app) + .post("/sparql") + .set(dbHeaders()) + .send({ query: "INVALID" }); + + expect(response.status).toBe(400); + }); + }); + + // ── Gremlin happy path ──────────────────────────────────────────── + + describe("POST /gremlin", () => { + it("proxies query to the graph database", async () => { + mockFetchOnce(JSON.stringify({ result: {} }), 200, { + "content-type": "application/vnd.gremlin-v3.0+json", + }); + + const app = createTestApp(); + const response = await request(app) + .post("/gremlin") + .set(dbHeaders()) + .send({ query: "g.V().limit(1)" }); + + expect(response.status).toBe(200); + expect(mockFetch).toHaveBeenCalledWith( + `${graphDbUrl}/gremlin`, + expect.objectContaining({ method: "POST" }), + ); + }); + + it("sends query as JSON with gremlin key", async () => { + mockFetchOnce(); + + const app = createTestApp(); + const query = "g.V().limit(1)"; + await request(app).post("/gremlin").set(dbHeaders()).send({ query }); + + const fetchOptions = mockFetch.mock.calls[0][1] as any; + const body = JSON.parse(fetchOptions.body); + expect(body.gremlin).toBe(query); + }); + + it("includes queryId in the JSON body when provided", async () => { + mockFetchOnce(); + + const app = createTestApp(); + await request(app) + .post("/gremlin") + .set(dbHeaders({ queryid: "q-456" })) + .send({ query: "g.V()" }); + + const fetchOptions = mockFetch.mock.calls[0][1] as any; + const body = JSON.parse(fetchOptions.body); + expect(body.queryId).toBe("q-456"); + }); + + it("forwards non-200 status from the upstream database", async () => { + mockFetchOnce("Server Error", 500); + + const app = createTestApp(); + const response = await request(app) + .post("/gremlin") + .set(dbHeaders()) + .send({ query: "g.V()" }); + + expect(response.status).toBe(500); + }); + }); + + // ── openCypher happy path ───────────────────────────────────────── + + describe("POST /openCypher", () => { + it("proxies query to the graph database", async () => { + mockFetchOnce(JSON.stringify({ results: [] }), 200, { + "content-type": "application/json", + }); + + const app = createTestApp(); + const response = await request(app) + .post("/openCypher") + .set(dbHeaders()) + .send({ query: "MATCH (n) RETURN n LIMIT 1" }); + + expect(response.status).toBe(200); + expect(mockFetch).toHaveBeenCalledWith( + `${graphDbUrl}/openCypher`, + expect.objectContaining({ method: "POST" }), + ); + }); + + it("sends query as url-encoded form body", async () => { + mockFetchOnce(); + + const app = createTestApp(); + const query = "MATCH (n) RETURN n"; + await request(app).post("/openCypher").set(dbHeaders()).send({ query }); + + const fetchOptions = mockFetch.mock.calls[0][1] as any; + expect(fetchOptions.headers["content-type"]).toBe( + "application/x-www-form-urlencoded", + ); + expect(fetchOptions.body).toBe(`query=${encodeURIComponent(query)}`); + }); + + it("forwards non-200 status from the upstream database", async () => { + mockFetchOnce("Not Found", 404); + + const app = createTestApp(); + const response = await request(app) + .post("/openCypher") + .set(dbHeaders()) + .send({ query: "MATCH (n) RETURN n" }); + + expect(response.status).toBe(404); + }); + }); + + // ── Summary routes ──────────────────────────────────────────────── + + describe("GET /summary", () => { + it("proxies to the graph database summary endpoint", async () => { + mockFetchOnce(JSON.stringify({ graphSummary: {} }), 200, { + "content-type": "application/json", + }); + + const app = createTestApp(); + const response = await request(app).get("/summary").set(dbHeaders()); + + expect(response.status).toBe(200); + expect(mockFetch).toHaveBeenCalledWith( + `${graphDbUrl}/summary?mode=detailed`, + expect.objectContaining({ method: "GET" }), + ); + }); + }); + + describe("GET /pg/statistics/summary", () => { + it("proxies to the PG statistics summary endpoint", async () => { + mockFetchOnce(JSON.stringify({ stats: {} }), 200, { + "content-type": "application/json", + }); + + const app = createTestApp(); + const response = await request(app) + .get("/pg/statistics/summary") + .set(dbHeaders()); + + expect(response.status).toBe(200); + expect(mockFetch).toHaveBeenCalledWith( + `${graphDbUrl}/pg/statistics/summary?mode=detailed`, + expect.objectContaining({ method: "GET" }), + ); + }); + }); + + describe("GET /rdf/statistics/summary", () => { + it("proxies to the RDF statistics summary endpoint", async () => { + mockFetchOnce(JSON.stringify({ stats: {} }), 200, { + "content-type": "application/json", + }); + + const app = createTestApp(); + const response = await request(app) + .get("/rdf/statistics/summary") + .set(dbHeaders()); + + expect(response.status).toBe(200); + expect(mockFetch).toHaveBeenCalledWith( + `${graphDbUrl}/rdf/statistics/summary?mode=detailed`, + expect.objectContaining({ method: "GET" }), + ); + }); + }); + + // ── IAM signing ─────────────────────────────────────────────────── + + describe("IAM signing", () => { + it("signs the request when aws-neptune-region header is present", async () => { + mockFetchOnce(); + + const app = createTestApp(); + await request(app) + .post("/sparql") + .set( + dbHeaders({ + "aws-neptune-region": "us-east-1", + "service-type": "neptune-db", + }), + ) + .send({ query: "SELECT 1" }); + + // aws4 signing adds Authorization and X-Amz headers to the options + const fetchOptions = mockFetch.mock.calls[0][1] as any; + expect(fetchOptions.headers).toHaveProperty("Authorization"); + }); + + it("uses only the mocked credentials, never real ones", async () => { + mockFetchOnce(); + + const app = createTestApp(); + await request(app) + .post("/sparql") + .set( + dbHeaders({ + "aws-neptune-region": "us-east-1", + "service-type": "neptune-db", + }), + ) + .send({ query: "SELECT 1" }); + + const fetchOptions = mockFetch.mock.calls[0][1] as any; + const authHeader: string = fetchOptions.headers["Authorization"]; + // The Authorization header must reference our fake credential, proving + // the mock intercepted the credential provider chain. + expect( + authHeader.includes("Credential=test-key/"), + "Authorization header should contain the mock credential", + ).toBe(true); + expect( + authHeader.startsWith("AWS4-HMAC-SHA256"), + "Authorization header should not contain a real AWS key", + ).toBe(true); + }); + + it("does not sign the request when aws-neptune-region is absent", async () => { + mockFetchOnce(); + + const app = createTestApp(); + await request(app) + .post("/sparql") + .set(dbHeaders()) + .send({ query: "SELECT 1" }); + + const fetchOptions = mockFetch.mock.calls[0][1] as any; + expect(fetchOptions.headers).not.toHaveProperty("Authorization"); + }); + }); + + // ── Query logging header ─────────────────────────────────────────── + + describe("db-query-logging-enabled header", () => { + it.each(["sparql", "gremlin", "openCypher"])( + "POST /%s logs the query when db-query-logging-enabled is true", + async route => { + mockFetchOnce(); + + const app = createTestApp(); + const logger = app.locals.logger; + const debugSpy = vi.spyOn(logger, "debug"); + + await request(app) + .post(`/${route}`) + .set(dbHeaders({ "db-query-logging-enabled": "true" })) + .send({ query: "test query" }); + + expect(debugSpy).toHaveBeenCalledWith( + expect.stringContaining("Received database query"), + "test query", + ); + }, + ); + + it.each(["sparql", "gremlin", "openCypher"])( + "POST /%s does not log the query when db-query-logging-enabled is absent", + async route => { + mockFetchOnce(); + + const app = createTestApp(); + const logger = app.locals.logger; + const debugSpy = vi.spyOn(logger, "debug"); + + await request(app) + .post(`/${route}`) + .set(dbHeaders()) + .send({ query: "test query" }); + + expect(debugSpy).not.toHaveBeenCalledWith( + expect.stringContaining("Received database query"), + expect.anything(), + ); + }, + ); + }); + + // ── Service type default ────────────────────────────────────────── + + describe("service-type header default", () => { + it("defaults service to neptune-db when IAM is enabled but service-type is absent", async () => { + mockFetchOnce(); + + const app = createTestApp(); + await request(app) + .post("/sparql") + .set( + dbHeaders({ + "aws-neptune-region": "us-east-1", + }), + ) + .send({ query: "SELECT 1" }); + + const fetchOptions = mockFetch.mock.calls[0][1] as any; + expect(fetchOptions.service).toBe("neptune-db"); + }); + + it("uses provided service-type when IAM is enabled", async () => { + mockFetchOnce(); + + const app = createTestApp(); + await request(app) + .post("/sparql") + .set( + dbHeaders({ + "aws-neptune-region": "us-east-1", + "service-type": "neptune-graph", + }), + ) + .send({ query: "SELECT 1" }); + + const fetchOptions = mockFetch.mock.calls[0][1] as any; + expect(fetchOptions.service).toBe("neptune-graph"); + }); + }); + + // ── User-Agent header ─────────────────────────────────────────── + + describe("User-Agent header", () => { + it("sets User-Agent on outbound requests", async () => { + mockFetchOnce(); + + const app = createTestApp(); + await request(app) + .post("/sparql") + .set(dbHeaders()) + .send({ query: "SELECT 1" }); + + const fetchOptions = mockFetch.mock.calls[0][1] as any; + expect(fetchOptions.headers["User-Agent"]).toBe( + `graph-explorer/${testVersion}`, + ); + }); + + it("falls back to 'graph-explorer' when version is not provided", async () => { + mockFetchOnce(); + + const app = createApp({ + configPath: ".", + staticFilesVirtualPath: "/explorer", + staticFilesPath: ".", + }); + app.locals.logger = createLogger({ + HOST: "localhost", + PROXY_SERVER_HTTPS_CONNECTION: false, + PROXY_SERVER_HTTPS_PORT: 443, + PROXY_SERVER_HTTP_PORT: 80, + LOG_LEVEL: "silent", + LOG_STYLE: "default", + }); + + await request(app) + .post("/sparql") + .set(dbHeaders()) + .send({ query: "SELECT 1" }); + + const fetchOptions = mockFetch.mock.calls[0][1] as any; + expect(fetchOptions.headers["User-Agent"]).toBe("graph-explorer"); + }); + + it("preserves User-Agent after IAM signing", async () => { + mockFetchOnce(); + + const app = createTestApp(); + await request(app) + .post("/sparql") + .set( + dbHeaders({ + "aws-neptune-region": "us-east-1", + "service-type": "neptune-db", + }), + ) + .send({ query: "SELECT 1" }); + + const fetchOptions = mockFetch.mock.calls[0][1] as any; + expect(fetchOptions.headers["User-Agent"]).toBe( + `graph-explorer/${testVersion}`, + ); + }); + + it("preserves request-specific headers after IAM signing", async () => { + mockFetchOnce(); + + const app = createTestApp(); + await request(app) + .post("/sparql") + .set( + dbHeaders({ + "aws-neptune-region": "us-east-1", + "service-type": "neptune-db", + }), + ) + .send({ query: "SELECT 1" }); + + const fetchOptions = mockFetch.mock.calls[0][1] as any; + expect(fetchOptions.headers["content-type"]).toBe( + "application/x-www-form-urlencoded", + ); + expect(fetchOptions.headers["accept"]).toBe( + "application/sparql-results+json", + ); + expect(fetchOptions.headers["Authorization"]).toBeDefined(); + }); + }); + + // ── URL path preservation ──────────────────────────────────────── + + describe("preserves base URL path for non-root endpoints", () => { + const blazegraphUrl = "http://blazegraph:9999/blazegraph/namespace/kb"; + + function blazegraphHeaders(overrides: Record = {}) { + return { + "graph-db-connection-url": blazegraphUrl, + ...overrides, + }; + } + + it("POST /sparql appends to the base URL path", async () => { + mockFetchOnce(); + + const app = createTestApp(); + await request(app) + .post("/sparql") + .set(blazegraphHeaders()) + .send({ query: "SELECT 1" }); + + expect(mockFetch).toHaveBeenCalledWith( + `${blazegraphUrl}/sparql`, + expect.anything(), + ); + }); + + it("POST /gremlin appends to the base URL path", async () => { + mockFetchOnce(); + + const app = createTestApp(); + await request(app) + .post("/gremlin") + .set(blazegraphHeaders()) + .send({ query: "g.V()" }); + + expect(mockFetch).toHaveBeenCalledWith( + `${blazegraphUrl}/gremlin`, + expect.anything(), + ); + }); + + it("POST /openCypher appends to the base URL path", async () => { + mockFetchOnce(); + + const app = createTestApp(); + await request(app) + .post("/openCypher") + .set(blazegraphHeaders()) + .send({ query: "MATCH (n) RETURN n" }); + + expect(mockFetch).toHaveBeenCalledWith( + `${blazegraphUrl}/openCypher`, + expect.anything(), + ); + }); + + it("GET /summary appends to the base URL path", async () => { + mockFetchOnce(); + + const app = createTestApp(); + await request(app).get("/summary").set(blazegraphHeaders()); + + expect(mockFetch).toHaveBeenCalledWith( + `${blazegraphUrl}/summary?mode=detailed`, + expect.anything(), + ); + }); + + it("GET /pg/statistics/summary appends to the base URL path", async () => { + mockFetchOnce(); + + const app = createTestApp(); + await request(app).get("/pg/statistics/summary").set(blazegraphHeaders()); + + expect(mockFetch).toHaveBeenCalledWith( + `${blazegraphUrl}/pg/statistics/summary?mode=detailed`, + expect.anything(), + ); + }); + + it("GET /rdf/statistics/summary appends to the base URL path", async () => { + mockFetchOnce(); + + const app = createTestApp(); + await request(app) + .get("/rdf/statistics/summary") + .set(blazegraphHeaders()); + + expect(mockFetch).toHaveBeenCalledWith( + `${blazegraphUrl}/rdf/statistics/summary?mode=detailed`, + expect.anything(), + ); + }); + }); + + // ── Fetch error handling ────────────────────────────────────────── + + describe("fetch error handling", () => { + it("returns 500 when the upstream fetch throws", async () => { + mockFetch.mockRejectedValueOnce(new Error("Connection refused")); + + const app = createTestApp(); + const response = await request(app) + .post("/sparql") + .set(dbHeaders()) + .send({ query: "SELECT 1" }); + + expect(response.status).toBe(500); + }); + }); +}); diff --git a/packages/graph-explorer-proxy-server/src/app.ts b/packages/graph-explorer-proxy-server/src/app.ts new file mode 100644 index 000000000..83bed0610 --- /dev/null +++ b/packages/graph-explorer-proxy-server/src/app.ts @@ -0,0 +1,604 @@ +import type { IncomingHttpHeaders } from "http"; + +import { fromNodeProviderChain } from "@aws-sdk/credential-providers"; +import aws4 from "aws4"; +import bodyParser from "body-parser"; +import compression from "compression"; +import cors from "cors"; +import express, { type NextFunction, type Response } from "express"; +import fetch, { type RequestInit } from "node-fetch"; +import path from "path"; +import { pipeline } from "stream"; +import { z } from "zod"; + +import { errorHandlingMiddleware } from "./error-handler.ts"; +import { RequestValidationError } from "./errors.ts"; +import { type AppLogger, requestLoggingMiddleware } from "./logging.ts"; + +const DEFAULT_SERVICE_TYPE = "neptune-db"; + +/** + * Resolves a relative endpoint path against a base URL, preserving the base + * path. Forces a trailing slash on the base so that `new URL` appends rather + * than replaces the path. + */ +function resolveEndpointUrl( + base: string, + endpoint: T extends `/${string}` ? never : T, +): URL { + return new URL(endpoint, base.replace(/\/?$/, "/")); +} + +/** Zod schema for the custom headers expected on database query requests. */ +const DbQueryHeadersSchema = z.object({ + queryid: z.string().optional(), + "graph-db-connection-url": z.url({ protocol: /^https?$/ }), + "aws-neptune-region": z.string().optional(), + "service-type": z + .enum(["neptune-db", "neptune-graph"]) + .optional() + .default(DEFAULT_SERVICE_TYPE), + "db-query-logging-enabled": z.stringbool().optional().default(false), +}); + +/** Validates and extracts database query headers. Throws {@link RequestValidationError} on failure. */ +function parseDbQueryHeaders(headers: IncomingHttpHeaders) { + const result = DbQueryHeadersSchema.safeParse(headers); + if (!result.success) { + throw new RequestValidationError(result.error); + } + const parsed = result.data; + + const authOptions = parsed["aws-neptune-region"] + ? { + isIamEnabled: true, + region: parsed["aws-neptune-region"], + serviceType: parsed["service-type"], + } + : { + isIamEnabled: false, + region: "", + serviceType: "", + }; + + return { + queryId: parsed.queryid, + graphDbConnectionUrl: parsed["graph-db-connection-url"], + shouldLogDbQuery: parsed["db-query-logging-enabled"], + ...authOptions, + }; +} + +interface LoggerIncomingHttpHeaders extends IncomingHttpHeaders { + level?: string; + message?: string; +} + +interface CreateAppOptions { + configPath: string; + staticFilesVirtualPath: string; + staticFilesPath: string; + version?: string; + corsOrigin?: string[]; +} + +export function createApp({ + configPath, + staticFilesVirtualPath, + staticFilesPath, + version, + corsOrigin, +}: CreateAppOptions): express.Express { + const app = express(); + + app.use(requestLoggingMiddleware()); + app.use(compression()); + if (corsOrigin) { + app.use( + cors({ + origin: corsOrigin.length === 1 ? corsOrigin[0] : corsOrigin, + methods: ["GET", "POST"], + maxAge: 86400, + }), + ); + } + app.use(bodyParser.json({ limit: "50mb" })); + app.use(bodyParser.urlencoded({ extended: true, limit: "50mb" })); + app.use( + "/defaultConnection", + express.static(path.join(configPath, "defaultConnection.json")), + ); + app.use( + "/defaultStyling", + express.static(path.join(configPath, "defaultStyling.json")), + ); + app.use( + "/custom-icons", + express.static(path.join(configPath, "custom-icons")), + ); + + // Host the Graph Explorer UI static files + app.use(staticFilesVirtualPath, express.static(staticFilesPath)); + + function getLogger(): AppLogger { + return app.locals.logger; + } + + // Function to get IAM headers for AWS4 signing process. + async function getIAMHeaders(options: string | aws4.Request) { + const credentialProvider = fromNodeProviderChain(); + const creds = await credentialProvider(); + if (creds === undefined) { + throw new Error( + "IAM is enabled but credentials cannot be found on the credential provider chain.", + ); + } + + const headers = aws4.sign(options, { + accessKeyId: creds.accessKeyId, + secretAccessKey: creds.secretAccessKey, + ...(creds.sessionToken && { sessionToken: creds.sessionToken }), + }); + + return headers; + } + + const userAgent = version ? `graph-explorer/${version}` : "graph-explorer"; + + // Function to retry fetch requests with exponential backoff. + const retryFetch = async ( + url: URL, + options: any, + isIamEnabled: boolean, + region: string | undefined, + serviceType: string, + retryDelay = 10000, + refetchMaxRetries = 1, + ) => { + const logger = getLogger(); + for (let i = 0; i < refetchMaxRetries; i++) { + if (isIamEnabled) { + const data = await getIAMHeaders({ + host: url.hostname, + port: url.port, + path: url.pathname + url.search, + service: serviceType, + region, + method: options.method, + body: options.body ?? undefined, + headers: options.headers, + }); + + options = { + host: url.hostname, + port: url.port, + path: url.pathname + url.search, + service: serviceType, + region, + method: options.method, + body: options.body ?? undefined, + headers: data.headers, + }; + } + options = { + host: url.hostname, + port: url.port, + path: url.pathname + url.search, + service: serviceType, + method: options.method, + body: options.body ?? undefined, + headers: options.headers, + compress: false, // prevent automatic decompression + }; + + try { + const res = await fetch(url.href, options); + if (!res.ok) { + logger.error("!!Request failure!!"); + return res; + } else { + return res; + } + } catch (err) { + if (refetchMaxRetries === 1) { + // Don't log about retries if retrying is not used + throw err; + } else if (i === refetchMaxRetries - 1) { + logger.error(err, "!!Proxy Retry Fetch Reached Maximum Tries!!"); + throw err; + } else { + logger.debug("Proxy Retry Fetch Count::: " + i); + await new Promise(resolve => setTimeout(resolve, retryDelay)); + } + } + } + // Should never reach this code + throw new Error("retryFetch failed to complete retry logic"); + }; + + // Function to fetch data from the given URL and send it as a response. + async function fetchData( + res: Response, + next: NextFunction, + url: string, + options: RequestInit, + isIamEnabled: boolean, + region: string | undefined, + serviceType: string, + ) { + const logger = getLogger(); + try { + const response = await retryFetch( + new URL(url), + { + ...options, + headers: { + "User-Agent": userAgent, + ...Object.fromEntries(new Headers(options.headers as HeadersInit)), + }, + }, + isIamEnabled, + region, + serviceType, + ); + + // Only forward content headers from the upstream response. + // All other headers (CORS, hop-by-hop, server info) are dropped + // to avoid conflicts with the proxy's own response handling. + res.status(response.status); + for (const header of ["content-type", "content-encoding"]) { + const value = response.headers.get(header); + if (value) { + res.setHeader(header, value); + } + } + + // Pipe the raw fetch response body directly to the client response + if (response.body) { + pipeline(response.body, res, err => { + if (err) { + // Log the error as a warning, but otherwise ignore it + logger.warn("Pipeline error %o", err); + } + }); + } else { + res.end(); + } + } catch (error) { + next(error); + } + } + + // POST endpoint for SPARQL queries. + app.post("/sparql", async (req, res, next) => { + const logger = getLogger(); + const { + queryId, + graphDbConnectionUrl, + shouldLogDbQuery, + isIamEnabled, + region, + serviceType, + } = parseDbQueryHeaders(req.headers); + + /// Function to cancel long running queries if the client disappears before completion + async function cancelQuery() { + if (!queryId) { + return; + } + logger.debug(`Cancelling request ${queryId}...`); + try { + const statusUrl = resolveEndpointUrl( + graphDbConnectionUrl, + "sparql/status", + ); + await retryFetch( + statusUrl, + { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: `cancelQuery&queryId=${encodeURIComponent(queryId)}&silent=true`, + }, + isIamEnabled, + region, + serviceType, + ); + } catch (err) { + // Not really an error + logger.warn(err, "Failed to cancel the query"); + } + } + + // Watch for a cancelled or aborted connection + req.on("close", async () => { + if (req.complete) { + return; + } + + await cancelQuery(); + }); + res.on("close", async () => { + if (res.writableFinished) { + return; + } + await cancelQuery(); + }); + + // Validate the input before making any external calls. + const queryString = req.body.query; + if (!queryString) { + res.status(400).send({ error: "[Proxy]SPARQL: Query not provided" }); + return; + } + + if (shouldLogDbQuery) { + logger.debug("[SPARQL] Received database query:\n%s", queryString); + } + + const rawUrl = resolveEndpointUrl(graphDbConnectionUrl, "sparql").href; + let body = `query=${encodeURIComponent(queryString)}`; + if (queryId) { + body += `&queryId=${encodeURIComponent(queryId)}`; + } + const requestOptions = { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/sparql-results+json", + }, + body, + }; + + await fetchData( + res, + next, + rawUrl, + requestOptions, + isIamEnabled, + region, + serviceType, + ); + }); + + // POST endpoint for Gremlin queries. + app.post("/gremlin", async (req, res, next) => { + const logger = getLogger(); + const { + queryId, + graphDbConnectionUrl, + shouldLogDbQuery, + isIamEnabled, + region, + serviceType, + } = parseDbQueryHeaders(req.headers); + + // Validate the input before making any external calls. + const queryString = req.body.query; + if (!queryString) { + res.status(400).send({ error: "[Proxy] Gremlin: query not provided" }); + return; + } + + if (shouldLogDbQuery) { + logger.debug("[Gremlin] Received database query:\n%s", queryString); + } + + /// Function to cancel long running queries if the client disappears before completion + async function cancelQuery() { + if (!queryId) { + return; + } + logger.debug(`Cancelling request ${queryId}...`); + try { + const cancelUrl = resolveEndpointUrl( + graphDbConnectionUrl, + "gremlin/status", + ); + cancelUrl.searchParams.set("cancelQuery", ""); + cancelUrl.searchParams.set("queryId", queryId); + await retryFetch( + cancelUrl, + { method: "GET" }, + isIamEnabled, + region, + serviceType, + ); + } catch (err) { + // Not really an error + logger.warn(err, "Failed to cancel the query"); + } + } + + // Watch for a cancelled or aborted connection + req.on("close", async () => { + if (req.complete) { + return; + } + await cancelQuery(); + }); + res.on("close", async () => { + if (res.writableFinished) { + return; + } + await cancelQuery(); + }); + + const body = { gremlin: queryString, queryId }; + const rawUrl = resolveEndpointUrl(graphDbConnectionUrl, "gremlin").href; + const requestOptions = { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/vnd.gremlin-v3.0+json", + }, + body: JSON.stringify(body), + }; + + await fetchData( + res, + next, + rawUrl, + requestOptions, + isIamEnabled, + region, + serviceType, + ); + }); + + // POST endpoint for openCypher queries. + app.post("/openCypher", async (req, res, next) => { + const logger = getLogger(); + const { + graphDbConnectionUrl, + shouldLogDbQuery, + isIamEnabled, + region, + serviceType, + } = parseDbQueryHeaders(req.headers); + + const queryString = req.body.query; + // Validate the input before making any external calls. + if (!queryString) { + res.status(400).send({ error: "[Proxy]OpenCypher: query not provided" }); + return; + } + + if (shouldLogDbQuery) { + logger.debug("[openCypher] Received database query:\n%s", queryString); + } + + const openCypherUrl = resolveEndpointUrl( + graphDbConnectionUrl, + "openCypher", + ).href; + const requestOptions = { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: `query=${encodeURIComponent(queryString)}`, + }; + + await fetchData( + res, + next, + openCypherUrl, + requestOptions, + isIamEnabled, + region, + serviceType, + ); + }); + + // GET endpoint to retrieve PropertyGraph statistics summary for Neptune Analytics. + app.get("/summary", async (req, res, next) => { + const { graphDbConnectionUrl, isIamEnabled, region, serviceType } = + parseDbQueryHeaders(req.headers); + const rawUrl = resolveEndpointUrl( + graphDbConnectionUrl, + "summary?mode=detailed", + ).href; + + await fetchData( + res, + next, + rawUrl, + { method: "GET" }, + isIamEnabled, + region, + serviceType, + ); + }); + + // GET endpoint to retrieve PropertyGraph statistics summary for Neptune DB. + app.get("/pg/statistics/summary", async (req, res, next) => { + const { graphDbConnectionUrl, isIamEnabled, region, serviceType } = + parseDbQueryHeaders(req.headers); + const rawUrl = resolveEndpointUrl( + graphDbConnectionUrl, + "pg/statistics/summary?mode=detailed", + ).href; + + await fetchData( + res, + next, + rawUrl, + { method: "GET" }, + isIamEnabled, + region, + serviceType, + ); + }); + + // GET endpoint to retrieve RDF statistics summary. + app.get("/rdf/statistics/summary", async (req, res, next) => { + const { graphDbConnectionUrl, isIamEnabled, region, serviceType } = + parseDbQueryHeaders(req.headers); + const rawUrl = resolveEndpointUrl( + graphDbConnectionUrl, + "rdf/statistics/summary?mode=detailed", + ).href; + + await fetchData( + res, + next, + rawUrl, + { method: "GET" }, + isIamEnabled, + region, + serviceType, + ); + }); + + app.get("/status", (_req, res) => { + res.send("OK"); + }); + + app.post("/logger", (req, res, next) => { + const logger = getLogger(); + const headers = req.headers as LoggerIncomingHttpHeaders; + let message; + let level; + try { + if (headers["level"] === undefined) { + throw new Error("No log level passed."); + } else { + level = headers["level"]; + } + if (headers["message"] === undefined) { + throw new Error("No log message passed."); + } else { + message = JSON.parse(headers["message"]).replaceAll("\\", ""); + } + if (level.toLowerCase() === "error") { + logger.error(message); + } else if (level.toLowerCase() === "warn") { + logger.warn(message); + } else if (level.toLowerCase() === "info") { + logger.info(message); + } else if (level.toLowerCase() === "debug") { + logger.debug(message); + } else if (level.toLowerCase() === "trace") { + logger.trace(message); + } else { + throw new Error("Tried to log to an unknown level."); + } + res.send("Log received."); + } catch (error) { + next(error); + } + }); + + // Error handler middleware to log errors and send appropriate response. + app.use(errorHandlingMiddleware()); + + app.use((_req, res) => { + res.status(404).send("The requested resource was not available"); + }); + + return app; +} diff --git a/packages/graph-explorer-proxy-server/src/config-pipeline.test.ts b/packages/graph-explorer-proxy-server/src/config-pipeline.test.ts new file mode 100644 index 000000000..474024f80 --- /dev/null +++ b/packages/graph-explorer-proxy-server/src/config-pipeline.test.ts @@ -0,0 +1,106 @@ +import { execFileSync } from "child_process"; +import dotenv from "dotenv"; +import fs from "fs"; +import os from "os"; +import path from "path"; + +import { parseEnvironmentValues } from "./env.ts"; +import { proxyServerRoot } from "./paths.ts"; +import { resolveServerConfig, ServerConfigError } from "./server-config.ts"; + +const expectedKeyPath = path.join(proxyServerRoot, "cert-info/server.key"); +const expectedCertPath = path.join(proxyServerRoot, "cert-info/server.crt"); + +const processEnvScriptPath = path.resolve( + import.meta.dirname, + "../../../process-environment.sh", +); + +function runPipeline( + workDir: string, + env: Record = {}, + presetEnvVars: Record = {}, +) { + const configFolder = + env.CONFIGURATION_FOLDER_PATH ?? "./packages/graph-explorer/"; + const resolvedConfigFolder = path.resolve(workDir, configFolder); + fs.mkdirSync(resolvedConfigFolder, { recursive: true }); + + // Pre-seed .env with vars the shell script doesn't write + if (Object.keys(presetEnvVars).length > 0) { + const lines = Object.entries(presetEnvVars) + .map(([k, v]) => `${k}=${v}`) + .join("\n"); + fs.writeFileSync(path.join(resolvedConfigFolder, ".env"), lines + "\n"); + } + + execFileSync("sh", [processEnvScriptPath], { + cwd: workDir, + env: { ...env, PATH: process.env.PATH }, + }); + + const envFilePath = path.join(resolvedConfigFolder, ".env"); + const envFileContent = fs.readFileSync(envFilePath, "utf-8"); + const parsed = dotenv.parse(envFileContent); + return parseEnvironmentValues(parsed); +} + +describe("config pipeline: shell β†’ dotenv β†’ Zod β†’ server config", () => { + let workDir: string; + + beforeEach(() => { + workDir = fs.mkdtempSync(path.join(os.tmpdir(), "ge-pipeline-test-")); + }); + + afterEach(() => { + fs.rmSync(workDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + it("HTTPS enabled by default flows through to server config", () => { + const env = runPipeline(workDir); + expect(env.PROXY_SERVER_HTTPS_CONNECTION).toBe(true); + + vi.spyOn(fs, "existsSync").mockImplementation( + p => p === expectedKeyPath || p === expectedCertPath, + ); + const config = resolveServerConfig(env); + expect(config.useHttps).toBe(true); + expect(config.port).toBe(443); + }); + + it("HTTPS disabled flows through to HTTP config", () => { + const env = runPipeline(workDir, { + PROXY_SERVER_HTTPS_CONNECTION: "false", + }); + + const config = resolveServerConfig(env); + expect(config.useHttps).toBe(false); + expect(config.port).toBe(80); + }); + + it("Neptune Notebook forces HTTPS off", () => { + const env = runPipeline(workDir, { NEPTUNE_NOTEBOOK: "true" }); + + const config = resolveServerConfig(env); + expect(config.useHttps).toBe(false); + }); + + it("HTTPS enabled but certs missing throws ServerConfigError", () => { + const env = runPipeline(workDir); + expect(env.PROXY_SERVER_HTTPS_CONNECTION).toBe(true); + + expect(() => resolveServerConfig(env)).toThrow(ServerConfigError); + }); + + it("custom HTTP port flows through", () => { + const env = runPipeline( + workDir, + { PROXY_SERVER_HTTPS_CONNECTION: "false" }, + { PROXY_SERVER_HTTP_PORT: "8080" }, + ); + + const config = resolveServerConfig(env); + expect(config.port).toBe(8080); + }); +}); diff --git a/packages/graph-explorer-proxy-server/src/docker-entrypoint.test.ts b/packages/graph-explorer-proxy-server/src/docker-entrypoint.test.ts new file mode 100644 index 000000000..261a47bb6 --- /dev/null +++ b/packages/graph-explorer-proxy-server/src/docker-entrypoint.test.ts @@ -0,0 +1,194 @@ +import { execFileSync } from "child_process"; +import fs from "fs"; +import os from "os"; +import path from "path"; + +const originalScriptPath = path.resolve( + import.meta.dirname, + "../../../docker-entrypoint.sh", +); + +function setupWorkDir() { + const workDir = fs.mkdtempSync(path.join(os.tmpdir(), "ge-entrypoint-test-")); + const configDir = path.join(workDir, "packages", "graph-explorer"); + fs.mkdirSync(configDir, { recursive: true }); + + // Create modified entrypoint with stubbed last line + const script = fs.readFileSync(originalScriptPath, "utf-8"); + const serverStartLine = + "cd /graph-explorer/packages/graph-explorer-proxy-server && NODE_ENV=production node src/node-server.ts"; + if (!script.includes(serverStartLine)) { + throw new Error( + "docker-entrypoint.sh no longer contains the expected server start line. Update the test stub.", + ); + } + const modifiedScript = script.replace( + serverStartLine, + 'echo "SERVER_STARTED"', + ); + const scriptPath = path.join(workDir, "docker-entrypoint.sh"); + fs.writeFileSync(scriptPath, modifiedScript, { mode: 0o755 }); + + // Default stubs + fs.writeFileSync( + path.join(workDir, "process-environment.sh"), + "#!/bin/sh\nexit 0\n", + { mode: 0o755 }, + ); + fs.writeFileSync( + path.join(workDir, "setup-ssl.sh"), + '#!/bin/sh\ntouch ./ssl-called\necho "$HOST" > ./host-value\n', + { mode: 0o755 }, + ); + + return { workDir, configDir, scriptPath }; +} + +function writeEnv(configDir: string, content: string) { + fs.writeFileSync(path.join(configDir, ".env"), content); +} + +function runScript( + workDir: string, + scriptPath: string, + env: Record = {}, +): { exitCode: number; stdout: string; stderr: string } { + try { + const stdout = execFileSync("sh", [scriptPath], { + cwd: workDir, + env: { PATH: process.env.PATH, ...env }, + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }); + return { exitCode: 0, stdout, stderr: "" }; + } catch (error: unknown) { + const e = error as { status: number; stdout: string; stderr: string }; + return { + exitCode: e.status, + stdout: e.stdout ?? "", + stderr: e.stderr ?? "", + }; + } +} + +describe("docker-entrypoint.sh", () => { + let workDir: string; + let configDir: string; + let scriptPath: string; + + beforeEach(() => { + ({ workDir, configDir, scriptPath } = setupWorkDir()); + }); + + afterEach(() => { + fs.rmSync(workDir, { recursive: true, force: true }); + }); + + it("fails when .env file is missing", () => { + // Don't write any .env file + + const { exitCode, stderr } = runScript(workDir, scriptPath); + expect(exitCode).not.toBe(0); + expect(stderr).toContain(".env"); + }); + + it("set -e propagates process-environment.sh failure", () => { + fs.writeFileSync( + path.join(workDir, "process-environment.sh"), + "#!/bin/sh\nexit 1\n", + { mode: 0o755 }, + ); + + const { exitCode } = runScript(workDir, scriptPath); + expect(exitCode).not.toBe(0); + }); + + it("set -e propagates setup-ssl.sh failure", () => { + writeEnv(configDir, "PROXY_SERVER_HTTPS_CONNECTION=true\n"); + fs.writeFileSync( + path.join(workDir, "setup-ssl.sh"), + "#!/bin/sh\nexit 1\n", + { mode: 0o755 }, + ); + + const { exitCode, stdout } = runScript(workDir, scriptPath); + expect(exitCode).not.toBe(0); + expect(stdout).not.toContain("Starting graph explorer"); + }); + + it("calls setup-ssl.sh when HTTPS is true", () => { + writeEnv(configDir, "PROXY_SERVER_HTTPS_CONNECTION=true\n"); + + const { exitCode } = runScript(workDir, scriptPath, { + HOST: "localhost", + }); + + expect(exitCode).toBe(0); + expect(fs.existsSync(path.join(workDir, "ssl-called"))).toBe(true); + }); + + it("skips setup-ssl.sh when HTTPS is false", () => { + writeEnv(configDir, "PROXY_SERVER_HTTPS_CONNECTION=false\n"); + + const { exitCode, stdout } = runScript(workDir, scriptPath); + + expect(exitCode).toBe(0); + expect(fs.existsSync(path.join(workDir, "ssl-called"))).toBe(false); + expect(stdout).toContain("SSL disabled"); + }); + + it("skips setup-ssl.sh when PROXY_SERVER_HTTPS_CONNECTION is absent", () => { + writeEnv(configDir, "LOG_LEVEL=info\n"); + + const { exitCode, stdout } = runScript(workDir, scriptPath); + + expect(exitCode).toBe(0); + expect(fs.existsSync(path.join(workDir, "ssl-called"))).toBe(false); + expect(stdout).toContain("SSL disabled"); + }); + + it("grep ignores commented-out lines", () => { + writeEnv(configDir, "# PROXY_SERVER_HTTPS_CONNECTION=true\n"); + + const { exitCode } = runScript(workDir, scriptPath); + + expect(exitCode).toBe(0); + expect(fs.existsSync(path.join(workDir, "ssl-called"))).toBe(false); + }); + + it("grep ignores similarly-named variables", () => { + writeEnv(configDir, "GRAPH_EXP_PROXY_SERVER_HTTPS_CONNECTION=true\n"); + + const { exitCode } = runScript(workDir, scriptPath); + + expect(exitCode).toBe(0); + expect(fs.existsSync(path.join(workDir, "ssl-called"))).toBe(false); + }); + + it("passes HOST to setup-ssl.sh", () => { + writeEnv(configDir, "PROXY_SERVER_HTTPS_CONNECTION=true\n"); + + runScript(workDir, scriptPath, { HOST: "my-test-host" }); + + const hostValue = fs + .readFileSync(path.join(workDir, "host-value"), "utf-8") + .trim(); + expect(hostValue).toBe("my-test-host"); + }); + + it("uses custom CONFIGURATION_FOLDER_PATH", () => { + const customDir = path.join(workDir, "custom-config"); + fs.mkdirSync(customDir, { recursive: true }); + fs.writeFileSync( + path.join(customDir, ".env"), + "PROXY_SERVER_HTTPS_CONNECTION=false\n", + ); + + const { exitCode, stdout } = runScript(workDir, scriptPath, { + CONFIGURATION_FOLDER_PATH: customDir, + }); + + expect(exitCode).toBe(0); + expect(stdout).toContain("SSL disabled"); + }); +}); diff --git a/packages/graph-explorer-proxy-server/src/env.test.ts b/packages/graph-explorer-proxy-server/src/env.test.ts new file mode 100644 index 000000000..61b132f30 --- /dev/null +++ b/packages/graph-explorer-proxy-server/src/env.test.ts @@ -0,0 +1,193 @@ +import { parseEnvironmentValues } from "./env.ts"; + +describe("parseEnvironmentValues", () => { + it("returns defaults when env is empty", () => { + const result = parseEnvironmentValues({}); + + expect(result.HOST).toBe("localhost"); + expect(result.PROXY_SERVER_HTTPS_CONNECTION).toBe(false); + expect(result.PROXY_SERVER_HTTPS_PORT).toBe(443); + expect(result.PROXY_SERVER_HTTP_PORT).toBe(80); + expect(result.LOG_LEVEL).toBe("debug"); + expect(result.LOG_STYLE).toBe("default"); + }); + + it("parses provided values", () => { + const result = parseEnvironmentValues({ + HOST: "my-server", + PROXY_SERVER_HTTP_PORT: "8080", + LOG_LEVEL: "error", + }); + + expect(result.HOST).toBe("my-server"); + expect(result.PROXY_SERVER_HTTP_PORT).toBe(8080); + expect(result.LOG_LEVEL).toBe("error"); + }); + + it("overrides all defaults", () => { + const result = parseEnvironmentValues({ + HOST: "my-server.example.com", + PROXY_SERVER_HTTPS_CONNECTION: "true", + PROXY_SERVER_HTTPS_PORT: "8443", + PROXY_SERVER_HTTP_PORT: "8080", + LOG_LEVEL: "error", + LOG_STYLE: "cloudwatch", + }); + + expect(result.HOST).toBe("my-server.example.com"); + expect(result.PROXY_SERVER_HTTPS_CONNECTION).toBe(true); + expect(result.PROXY_SERVER_HTTPS_PORT).toBe(8443); + expect(result.PROXY_SERVER_HTTP_PORT).toBe(8080); + expect(result.LOG_LEVEL).toBe("error"); + expect(result.LOG_STYLE).toBe("cloudwatch"); + }); + + it("handles boolean values case-insensitively", () => { + expect( + parseEnvironmentValues({ PROXY_SERVER_HTTPS_CONNECTION: "TRUE" }) + .PROXY_SERVER_HTTPS_CONNECTION, + ).toBe(true); + + expect( + parseEnvironmentValues({ PROXY_SERVER_HTTPS_CONNECTION: "False" }) + .PROXY_SERVER_HTTPS_CONNECTION, + ).toBe(false); + }); + + it("coerces port strings to numbers", () => { + const result = parseEnvironmentValues({ + PROXY_SERVER_HTTP_PORT: "3000", + PROXY_SERVER_HTTPS_PORT: "3443", + }); + + expect(result.PROXY_SERVER_HTTP_PORT).toBe(3000); + expect(result.PROXY_SERVER_HTTPS_PORT).toBe(3443); + }); + + describe("validation failures", () => { + beforeEach(() => { + vi.spyOn(process, "exit").mockImplementation(() => undefined as never); + vi.spyOn(console, "error").mockImplementation(() => undefined); + }); + + it("exits process on invalid PROXY_SERVER_HTTPS_CONNECTION", () => { + parseEnvironmentValues({ PROXY_SERVER_HTTPS_CONNECTION: "yes" }); + expect(process.exit).toHaveBeenCalledWith(1); + }); + + it("exits process on invalid LOG_LEVEL", () => { + parseEnvironmentValues({ LOG_LEVEL: "verbose" }); + expect(process.exit).toHaveBeenCalledWith(1); + }); + + it("exits process on invalid LOG_STYLE", () => { + parseEnvironmentValues({ LOG_STYLE: "json" }); + expect(process.exit).toHaveBeenCalledWith(1); + }); + + it("exits process when PROXY_SERVER_CORS_ORIGIN is missing the scheme", () => { + parseEnvironmentValues({ + PROXY_SERVER_CORS_ORIGIN: "my-app.example.com", + }); + expect(process.exit).toHaveBeenCalledWith(1); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("Must be an HTTP or HTTPS URL"), + ); + }); + + it("exits process when PROXY_SERVER_CORS_ORIGIN is a wildcard", () => { + parseEnvironmentValues({ PROXY_SERVER_CORS_ORIGIN: "*" }); + expect(process.exit).toHaveBeenCalledWith(1); + }); + + it("exits process when PROXY_SERVER_CORS_ORIGIN has a trailing comma", () => { + parseEnvironmentValues({ + PROXY_SERVER_CORS_ORIGIN: "https://example.com,", + }); + expect(process.exit).toHaveBeenCalledWith(1); + }); + + it("logs error details on validation failure", () => { + parseEnvironmentValues({ LOG_LEVEL: "verbose" }); + expect(console.error).toHaveBeenCalledWith( + "Failed to parse environment values", + ); + }); + }); + + it("ignores unknown env vars", () => { + const result = parseEnvironmentValues({ + HOST: "my-server", + SOME_OTHER_VAR: "ignored", + }); + + expect(result.HOST).toBe("my-server"); + expect(result).not.toHaveProperty("SOME_OTHER_VAR"); + }); + + it("parses PROXY_SERVER_CORS_ORIGIN when provided", () => { + const result = parseEnvironmentValues({ + PROXY_SERVER_CORS_ORIGIN: "https://my-app.example.com", + }); + + expect(result.PROXY_SERVER_CORS_ORIGIN).toStrictEqual([ + "https://my-app.example.com", + ]); + }); + + it("parses comma-separated PROXY_SERVER_CORS_ORIGIN values", () => { + const result = parseEnvironmentValues({ + PROXY_SERVER_CORS_ORIGIN: + "https://app-a.example.com,https://app-b.example.com", + }); + + expect(result.PROXY_SERVER_CORS_ORIGIN).toStrictEqual([ + "https://app-a.example.com", + "https://app-b.example.com", + ]); + }); + + it("trims whitespace around comma-separated PROXY_SERVER_CORS_ORIGIN values", () => { + const result = parseEnvironmentValues({ + PROXY_SERVER_CORS_ORIGIN: + "https://app-a.example.com , https://app-b.example.com", + }); + + expect(result.PROXY_SERVER_CORS_ORIGIN).toStrictEqual([ + "https://app-a.example.com", + "https://app-b.example.com", + ]); + }); + + it("defaults PROXY_SERVER_CORS_ORIGIN to undefined when not provided", () => { + const result = parseEnvironmentValues({}); + + expect(result.PROXY_SERVER_CORS_ORIGIN).toBeUndefined(); + }); + + it("treats empty PROXY_SERVER_CORS_ORIGIN as unset", () => { + const result = parseEnvironmentValues({ PROXY_SERVER_CORS_ORIGIN: "" }); + + expect(result.PROXY_SERVER_CORS_ORIGIN).toBeUndefined(); + }); + + it("strips trailing slash from PROXY_SERVER_CORS_ORIGIN", () => { + const result = parseEnvironmentValues({ + PROXY_SERVER_CORS_ORIGIN: "https://my-app.example.com/", + }); + + expect(result.PROXY_SERVER_CORS_ORIGIN).toStrictEqual([ + "https://my-app.example.com", + ]); + }); + + it("strips path from PROXY_SERVER_CORS_ORIGIN", () => { + const result = parseEnvironmentValues({ + PROXY_SERVER_CORS_ORIGIN: "https://my-app.example.com/app", + }); + + expect(result.PROXY_SERVER_CORS_ORIGIN).toStrictEqual([ + "https://my-app.example.com", + ]); + }); +}); diff --git a/packages/graph-explorer-proxy-server/src/env.ts b/packages/graph-explorer-proxy-server/src/env.ts index 25c68ad90..35c07a9de 100644 --- a/packages/graph-explorer-proxy-server/src/env.ts +++ b/packages/graph-explorer-proxy-server/src/env.ts @@ -1,17 +1,13 @@ -import dotenv from "dotenv"; -import path from "path"; import { z } from "zod"; -import { clientRoot } from "./paths.js"; - /** Coerces a string to a boolean value in a case insensitive way. */ -export const BooleanStringSchema = z +const BooleanStringSchema = z .string() .refine(s => s.toLowerCase() === "true" || s.toLowerCase() === "false") .transform(s => s.toLowerCase() === "true"); -// Define a required schema for the values we expect along with their defaults -const EnvironmentValuesSchema = z.object({ +/** Schema for the environment values we expect along with their defaults. */ +export const EnvironmentValuesSchema = z.object({ HOST: z.string().default("localhost"), PROXY_SERVER_HTTPS_CONNECTION: BooleanStringSchema.default(false), PROXY_SERVER_HTTPS_PORT: z.coerce.number().default(443), @@ -20,36 +16,36 @@ const EnvironmentValuesSchema = z.object({ .enum(["fatal", "error", "warn", "info", "debug", "trace", "silent"]) .default("debug"), LOG_STYLE: z.enum(["cloudwatch", "default"]).default("default"), - CONFIGURATION_FOLDER_PATH: z.coerce.string().default(clientRoot), -}); - -const defaultConnectionFolderPath = process.env.CONFIGURATION_FOLDER_PATH - ? process.env.CONFIGURATION_FOLDER_PATH - : clientRoot; - -// Load environment variables from .env file. -dotenv.config({ - path: [ - path.join(clientRoot, ".env.local"), - path.join(clientRoot, ".env"), - path.join(defaultConnectionFolderPath, ".env"), - ], + PROXY_SERVER_CORS_ORIGIN: z + .string() + .optional() + .transform(value => value || undefined) + .transform(value => value?.split(",").map(v => v.trim())) + .pipe( + z + .array( + z + .httpUrl({ + message: + "Must be an HTTP or HTTPS URL (e.g. https://example.com)", + }) + .transform(value => new URL(value).origin), + ) + .optional(), + ), }); -// Parse the environment values from the process -const parsedEnvironmentValues = EnvironmentValuesSchema.safeParse(process.env); +export type EnvironmentValues = z.infer; -if (!parsedEnvironmentValues.success) { +/** Parses and validates environment values, exiting the process on failure. */ +export function parseEnvironmentValues( + env: Record, +): EnvironmentValues { + const result = EnvironmentValuesSchema.safeParse(env); + if (result.success) { + return result.data; + } console.error("Failed to parse environment values"); - const flattenedErrors = parsedEnvironmentValues.error.flatten(); - console.error(flattenedErrors.fieldErrors); - process.exit(1); + console.error(z.prettifyError(result.error)); + return process.exit(1); } - -// eslint-disable-next-line no-console -console.log("Parsed environment values:", parsedEnvironmentValues.data); - -// Adds all environment values to local object -export const env = { - ...parsedEnvironmentValues.data, -}; diff --git a/packages/graph-explorer-proxy-server/src/error-handler.ts b/packages/graph-explorer-proxy-server/src/error-handler.ts index 71822dff7..c0a0cde46 100644 --- a/packages/graph-explorer-proxy-server/src/error-handler.ts +++ b/packages/graph-explorer-proxy-server/src/error-handler.ts @@ -1,13 +1,13 @@ import type { NextFunction, Request, Response } from "express"; -import { getRequestLoggerPrefix, logger } from "./logging.js"; +import { RequestValidationError } from "./errors.ts"; +import { type AppLogger, getRequestLoggerPrefix } from "./logging.ts"; /** * Global error handler * @param error The error to handle. */ -export function handleError(error: unknown) { - // Log the error itself +export function handleError(error: unknown, logger: AppLogger) { logger.error(error); } @@ -30,6 +30,7 @@ export function errorHandlingMiddleware() { response: Response, _next: NextFunction, ) => { + const logger = request.app.locals.logger; const errorInfo = extractErrorInfo(error); response.status(errorInfo.status); @@ -49,27 +50,34 @@ export function errorHandlingMiddleware() { .join(""), ); - handleError(error); + handleError(error, logger); }; } function extractErrorInfo(error: unknown) { - const statusCode = getStatusFromError(error); const defaultErrorMessage = "Internal Server Error"; + if (error instanceof RequestValidationError) { + return { + status: 400, + message: error.message, + }; + } + if (error instanceof Error) { return { + // oxlint-disable-next-line typescript/no-misused-spread -- Intentionally extracting Error properties for serialization ...error, - status: statusCode, + status: getStatusFromError(error), message: error.message || defaultErrorMessage, }; - } else { - return { - status: statusCode, - message: defaultErrorMessage, - name: "Error", - }; } + + return { + status: 500, + message: defaultErrorMessage, + name: "Error", + }; } function getStatusFromError(error: unknown) { diff --git a/packages/graph-explorer-proxy-server/src/errors.ts b/packages/graph-explorer-proxy-server/src/errors.ts new file mode 100644 index 000000000..8bae3f1b5 --- /dev/null +++ b/packages/graph-explorer-proxy-server/src/errors.ts @@ -0,0 +1,12 @@ +import { z } from "zod"; + +/** Thrown when request validation via Zod fails. Carries the original ZodError. */ +export class RequestValidationError extends Error { + readonly zodError: z.core.$ZodError; + + constructor(zodError: z.core.$ZodError) { + super(z.prettifyError(zodError)); + this.name = "RequestValidationError"; + this.zodError = zodError; + } +} diff --git a/packages/graph-explorer-proxy-server/src/express.d.ts b/packages/graph-explorer-proxy-server/src/express.d.ts new file mode 100644 index 000000000..e364bd043 --- /dev/null +++ b/packages/graph-explorer-proxy-server/src/express.d.ts @@ -0,0 +1,11 @@ +import type { EnvironmentValues } from "./env.ts"; +import type { AppLogger } from "./logging.ts"; + +declare module "express-serve-static-core" { + interface Application { + locals: { + env: EnvironmentValues; + logger: AppLogger; + } & Record; + } +} diff --git a/packages/graph-explorer-proxy-server/src/logging.test.ts b/packages/graph-explorer-proxy-server/src/logging.test.ts new file mode 100644 index 000000000..983045937 --- /dev/null +++ b/packages/graph-explorer-proxy-server/src/logging.test.ts @@ -0,0 +1,202 @@ +import type { Request, Response } from "express"; + +import type { EnvironmentValues } from "./env.ts"; + +import { + createLogger, + getRequestLoggerPrefix, + logRequestAndResponse, + requestLoggingMiddleware, +} from "./logging.ts"; + +function createMockEnv( + overrides: Partial = {}, +): EnvironmentValues { + return { + HOST: "localhost", + PROXY_SERVER_HTTPS_CONNECTION: false, + PROXY_SERVER_HTTPS_PORT: 443, + PROXY_SERVER_HTTP_PORT: 80, + LOG_LEVEL: "silent", + LOG_STYLE: "default", + ...overrides, + }; +} + +const sharedLogger = createLogger(createMockEnv()); + +function createMockRequest(overrides: Partial = {}) { + return { + method: "GET", + path: "/test", + app: { + locals: { + logger: sharedLogger, + }, + }, + ...overrides, + } as unknown as Request; +} + +function createMockResponse(statusCode: number) { + return { + statusCode, + statusMessage: "OK", + on: vi.fn(), + writableFinished: true, + } as unknown as Response; +} + +describe("createLogger", () => { + it("creates a logger with the configured log level", () => { + const logger = createLogger(createMockEnv({ LOG_LEVEL: "warn" })); + expect(logger.level).toBe("warn"); + }); + + it("creates a logger with debug level by default", () => { + const logger = createLogger(createMockEnv({ LOG_LEVEL: "debug" })); + expect(logger.level).toBe("debug"); + }); + + it("supports all valid log levels", () => { + const levels = [ + "fatal", + "error", + "warn", + "info", + "debug", + "trace", + "silent", + ] as const; + for (const level of levels) { + const logger = createLogger(createMockEnv({ LOG_LEVEL: level })); + expect(logger.level).toBe(level); + } + }); +}); + +describe("getRequestLoggerPrefix", () => { + it("formats GET request", () => { + const req = createMockRequest({ method: "GET", path: "/sparql" }); + expect(getRequestLoggerPrefix(req)).toBe("GET /sparql"); + }); + + it("formats POST request", () => { + const req = createMockRequest({ method: "POST", path: "/gremlin" }); + expect(getRequestLoggerPrefix(req)).toBe("POST /gremlin"); + }); +}); + +describe("logRequestAndResponse", () => { + it("logs 2xx responses at debug level", () => { + const req = createMockRequest(); + const logger = req.app.locals.logger; + const debugSpy = vi.spyOn(logger, "debug"); + + logRequestAndResponse(req, createMockResponse(200)); + + expect(debugSpy).toHaveBeenCalledWith( + expect.stringContaining("Response 200"), + ); + }); + + it("logs 3xx responses at debug level", () => { + const req = createMockRequest(); + const logger = req.app.locals.logger; + const debugSpy = vi.spyOn(logger, "debug"); + + logRequestAndResponse(req, createMockResponse(301)); + + expect(debugSpy).toHaveBeenCalledWith( + expect.stringContaining("Response 301"), + ); + }); + + it("logs 4xx responses at warn level", () => { + const req = createMockRequest(); + const logger = req.app.locals.logger; + const warnSpy = vi.spyOn(logger, "warn"); + + logRequestAndResponse(req, createMockResponse(404)); + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("Response 404"), + ); + }); + + it("logs 5xx responses at error level", () => { + const req = createMockRequest(); + const logger = req.app.locals.logger; + const errorSpy = vi.spyOn(logger, "error"); + + logRequestAndResponse(req, createMockResponse(500)); + + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining("Response 500"), + ); + }); + + it("includes request method and path in log message", () => { + const req = createMockRequest({ method: "POST", path: "/sparql" }); + const logger = req.app.locals.logger; + const debugSpy = vi.spyOn(logger, "debug"); + + logRequestAndResponse(req, createMockResponse(200)); + + expect(debugSpy).toHaveBeenCalledWith( + expect.stringContaining("[POST /sparql]"), + ); + }); +}); + +describe("requestLoggingMiddleware", () => { + it("calls next for normal requests", () => { + const middleware = requestLoggingMiddleware(); + const req = createMockRequest({ method: "GET", path: "/sparql" }); + const res = createMockResponse(200); + const next = vi.fn(); + + middleware(req, res, next); + + expect(next).toHaveBeenCalled(); + }); + + it("skips logging for /logger endpoint", () => { + const middleware = requestLoggingMiddleware(); + const req = createMockRequest({ method: "POST", path: "/logger" }); + const logger = req.app.locals.logger; + const traceSpy = vi.spyOn(logger, "trace"); + const res = createMockResponse(200); + const next = vi.fn(); + + middleware(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(traceSpy).not.toHaveBeenCalled(); + }); + + it("skips logging for OPTIONS requests", () => { + const middleware = requestLoggingMiddleware(); + const req = createMockRequest({ method: "OPTIONS", path: "/sparql" }); + const logger = req.app.locals.logger; + const traceSpy = vi.spyOn(logger, "trace"); + const res = createMockResponse(200); + const next = vi.fn(); + + middleware(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(traceSpy).not.toHaveBeenCalled(); + }); + + it("registers a finish listener on the response", () => { + const middleware = requestLoggingMiddleware(); + const req = createMockRequest({ method: "GET", path: "/sparql" }); + const res = createMockResponse(200); + const next = vi.fn(); + + middleware(req, res, next); + + expect(res.on).toHaveBeenCalledWith("finish", expect.any(Function)); + }); +}); diff --git a/packages/graph-explorer-proxy-server/src/logging.ts b/packages/graph-explorer-proxy-server/src/logging.ts index 08a3727d8..b82fc94ae 100644 --- a/packages/graph-explorer-proxy-server/src/logging.ts +++ b/packages/graph-explorer-proxy-server/src/logging.ts @@ -3,14 +3,12 @@ import type { PrettyOptions } from "pino-pretty"; import { type LevelWithSilent, pino } from "pino"; -import { env } from "./env.js"; +import type { EnvironmentValues } from "./env.ts"; export type LogLevel = LevelWithSilent; -export const logger = createLogger(); - /** Create a logger instance with pino. */ -function createLogger() { +export function createLogger(env: EnvironmentValues) { // Check whether we are configured with CloudWatch style const loggingInCloudWatch = env.LOG_STYLE === "cloudwatch"; const options: PrettyOptions = loggingInCloudWatch @@ -24,10 +22,9 @@ function createLogger() { colorize: true, translateTime: true, }; - const level = env.LOG_LEVEL; return pino({ - level, + level: env.LOG_LEVEL, transport: { target: "pino-pretty", options, @@ -35,6 +32,8 @@ function createLogger() { }); } +export type AppLogger = ReturnType; + /** Chooses an log level appropriate for the given status code. */ function logLevelFromStatusCode(statusCode: number) { if (statusCode >= 400 && statusCode < 500) { @@ -53,6 +52,7 @@ export function getRequestLoggerPrefix(req: Request) { /** Logs the request path and response status using the given logger. */ export function logRequestAndResponse(req: Request, res: Response) { + const logger = req.app.locals.logger; const logLevel = logLevelFromStatusCode(res.statusCode); const requestMessage = `[${getRequestLoggerPrefix(req)}] Response ${res.statusCode} ${res.statusMessage}`; @@ -73,6 +73,8 @@ export function logRequestAndResponse(req: Request, res: Response) { /** Creates the pino-http middleware with the given logger and appropriate options. */ export function requestLoggingMiddleware() { return (req: Request, res: Response, next: NextFunction) => { + const logger = req.app.locals.logger; + // Ignore requests to logger endpoint if (req.path.includes("/logger")) { next(); diff --git a/packages/graph-explorer-proxy-server/src/node-server.ts b/packages/graph-explorer-proxy-server/src/node-server.ts index 8d96bcd78..037a35029 100644 --- a/packages/graph-explorer-proxy-server/src/node-server.ts +++ b/packages/graph-explorer-proxy-server/src/node-server.ts @@ -1,612 +1,97 @@ -import type { IncomingHttpHeaders } from "http"; - -import { fromNodeProviderChain } from "@aws-sdk/credential-providers"; -import aws4 from "aws4"; -import bodyParser from "body-parser"; -import compression from "compression"; -import cors from "cors"; -import express, { type NextFunction, type Response } from "express"; -import fs from "fs"; -import https from "https"; -import fetch, { type RequestInit } from "node-fetch"; +import dotenv from "dotenv"; import path from "path"; -import { pipeline } from "stream"; - -import { BooleanStringSchema, env } from "./env.js"; -import { errorHandlingMiddleware, handleError } from "./error-handler.js"; -import { logger as proxyLogger, requestLoggingMiddleware } from "./logging.js"; -import { clientRoot, proxyServerRoot } from "./paths.js"; - -const app = express(); - -const DEFAULT_SERVICE_TYPE = "neptune-db"; - -interface DbQueryIncomingHttpHeaders extends IncomingHttpHeaders { - queryid?: string; - "graph-db-connection-url"?: string; - "aws-neptune-region"?: string; - "service-type"?: string; - "db-query-logging-enabled"?: string; -} - -interface LoggerIncomingHttpHeaders extends IncomingHttpHeaders { - level?: string; - message?: string; -} - -app.use(requestLoggingMiddleware()); - -// Function to get IAM headers for AWS4 signing process. -async function getIAMHeaders(options: string | aws4.Request) { - const credentialProvider = fromNodeProviderChain(); - const creds = await credentialProvider(); - if (creds === undefined) { - throw new Error( - "IAM is enabled but credentials cannot be found on the credential provider chain.", - ); - } - - const headers = aws4.sign(options, { - accessKeyId: creds.accessKeyId, - secretAccessKey: creds.secretAccessKey, - ...(creds.sessionToken && { sessionToken: creds.sessionToken }), - }); - return headers; +import packageJson from "../package.json" with { type: "json" }; +import { createApp } from "./app.ts"; +import { parseEnvironmentValues } from "./env.ts"; +import { handleError } from "./error-handler.ts"; +import { createLogger } from "./logging.ts"; +import { clientRoot, isDirectory } from "./paths.ts"; +import { resolveServerConfig, ServerConfigError } from "./server-config.ts"; +import { createServer } from "./server.ts"; + +// Load .env files into process.env before parsing +const configPath = process.env.CONFIGURATION_FOLDER_PATH ?? clientRoot; +if (!isDirectory(configPath)) { + const source = process.env.CONFIGURATION_FOLDER_PATH + ? `CONFIGURATION_FOLDER_PATH="${configPath}"` + : `default config path "${configPath}"`; + console.error(`Configuration folder does not exist: ${source}`); + process.exit(1); } - -// Function to retry fetch requests with exponential backoff. -const retryFetch = async ( - url: URL, - options: any, - isIamEnabled: boolean, - region: string | undefined, - serviceType: string, - retryDelay = 10000, - refetchMaxRetries = 1, -) => { - for (let i = 0; i < refetchMaxRetries; i++) { - if (isIamEnabled) { - const data = await getIAMHeaders({ - host: url.hostname, - port: url.port, - path: url.pathname + url.search, - service: serviceType, - region, - method: options.method, - body: options.body ?? undefined, - headers: options.headers, - }); - - options = { - host: url.hostname, - port: url.port, - path: url.pathname + url.search, - service: serviceType, - region, - method: options.method, - body: options.body ?? undefined, - headers: data.headers, - }; - } - options = { - host: url.hostname, - port: url.port, - path: url.pathname + url.search, - service: serviceType, - method: options.method, - body: options.body ?? undefined, - headers: options.headers, - compress: false, // prevent automatic decompression - }; - - try { - const res = await fetch(url.href, options); - if (!res.ok) { - proxyLogger.error("!!Request failure!!"); - return res; - } else { - return res; - } - } catch (err) { - if (refetchMaxRetries === 1) { - // Don't log about retries if retrying is not used - throw err; - } else if (i === refetchMaxRetries - 1) { - proxyLogger.error(err, "!!Proxy Retry Fetch Reached Maximum Tries!!"); - throw err; - } else { - proxyLogger.debug("Proxy Retry Fetch Count::: " + i); - await new Promise(resolve => setTimeout(resolve, retryDelay)); - } - } - } - // Should never reach this code - throw new Error("retryFetch failed to complete retry logic"); -}; - -// Function to fetch data from the given URL and send it as a response. -async function fetchData( - res: Response, - next: NextFunction, - url: string, - options: RequestInit, - isIamEnabled: boolean, - region: string | undefined, - serviceType: string, -) { - try { - const response = await retryFetch( - new URL(url), - options, - isIamEnabled, - region, - serviceType, - ); - - // Set the headers from the fetch response to the client response - res.status(response.status); - for (const [key, value] of response.headers.entries()) { - res.setHeader(key, value); - } - - // Pipe the raw fetch response body directly to the client response - if (response.body) { - pipeline(response.body, res, err => { - if (err) { - // Log the error as a warning, but otherwise ignore it - proxyLogger.warn("Pipeline error %o", err); - } - }); - } else { - res.end(); - } - } catch (error) { - next(error); - } -} - -const defaultConnectionFolderPath = env.CONFIGURATION_FOLDER_PATH - ? env.CONFIGURATION_FOLDER_PATH - : clientRoot; - -app.use(compression()); // Use compression middleware -app.use(cors()); -app.use(bodyParser.json({ limit: "50mb" })); -app.use(bodyParser.urlencoded({ extended: true, limit: "50mb" })); -app.use( - "/defaultConnection", - express.static( - path.join(defaultConnectionFolderPath, "defaultConnection.json"), - ), -); - -// Host the Graph Explorer UI static files -const staticFilesVirtualPath = "/explorer"; -const staticFilesPath = path.join(clientRoot, "dist"); - -proxyLogger.info("Hosting client side static files from: %s", staticFilesPath); -proxyLogger.info( - "Hosting client side static files at: %s", - staticFilesVirtualPath ?? "/", -); -if (staticFilesVirtualPath) { - app.use(staticFilesVirtualPath, express.static(staticFilesPath)); -} else { - app.use(express.static(staticFilesPath)); -} - -// POST endpoint for SPARQL queries. -app.post("/sparql", async (req, res, next) => { - // Gather info from the headers - const headers = req.headers as DbQueryIncomingHttpHeaders; - const queryId = headers["queryid"]; - const graphDbConnectionUrl = headers["graph-db-connection-url"]; - const shouldLogDbQuery = BooleanStringSchema.default(false).parse( - headers["db-query-logging-enabled"], - ); - const isIamEnabled = !!headers["aws-neptune-region"]; - const region = isIamEnabled ? headers["aws-neptune-region"] : ""; - const serviceType = isIamEnabled - ? (headers["service-type"] ?? DEFAULT_SERVICE_TYPE) - : ""; - - /// Function to cancel long running queries if the client disappears before completion - async function cancelQuery() { - if (!queryId) { - return; - } - proxyLogger.debug(`Cancelling request ${queryId}...`); - try { - await retryFetch( - new URL(`${graphDbConnectionUrl}/sparql/status`), - { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - }, - body: `cancelQuery&queryId=${encodeURIComponent(queryId)}&silent=true`, - }, - isIamEnabled, - region, - serviceType, - ); - } catch (err) { - // Not really an error - proxyLogger.warn(err, "Failed to cancel the query"); - } - } - - // Watch for a cancelled or aborted connection - req.on("close", async () => { - if (req.complete) { - return; - } - - await cancelQuery(); - }); - res.on("close", async () => { - if (res.writableFinished) { - return; - } - await cancelQuery(); - }); - - // Validate the input before making any external calls. - const queryString = req.body.query; - if (!queryString) { - res.status(400).send({ error: "[Proxy]SPARQL: Query not provided" }); - return; - } - - if (shouldLogDbQuery) { - proxyLogger.debug("[SPARQL] Received database query:\n%s", queryString); - } - - const rawUrl = `${graphDbConnectionUrl}/sparql`; - let body = `query=${encodeURIComponent(queryString)}`; - if (queryId) { - body += `&queryId=${encodeURIComponent(queryId)}`; - } - const requestOptions = { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/sparql-results+json", - }, - body, - }; - - await fetchData( - res, - next, - rawUrl, - requestOptions, - isIamEnabled, - region, - serviceType, - ); +dotenv.config({ + path: [path.join(configPath, ".env.local"), path.join(configPath, ".env")], }); -// POST endpoint for Gremlin queries. -app.post("/gremlin", async (req, res, next) => { - // Gather info from the headers - const headers = req.headers as DbQueryIncomingHttpHeaders; - const queryId = headers["queryid"]; - const graphDbConnectionUrl = headers["graph-db-connection-url"]; - const shouldLogDbQuery = BooleanStringSchema.default(false).parse( - headers["db-query-logging-enabled"], - ); - const isIamEnabled = !!headers["aws-neptune-region"]; - const region = isIamEnabled ? headers["aws-neptune-region"] : ""; - const serviceType = isIamEnabled - ? (headers["service-type"] ?? DEFAULT_SERVICE_TYPE) - : ""; - - // Validate the input before making any external calls. - const queryString = req.body.query; - if (!queryString) { - res.status(400).send({ error: "[Proxy] Gremlin: query not provided" }); - return; - } +const env = parseEnvironmentValues(process.env); - if (shouldLogDbQuery) { - proxyLogger.debug("[Gremlin] Received database query:\n%s", queryString); - } +const logger = createLogger(env); +logger.info("Parsed environment values: %o", env); - /// Function to cancel long running queries if the client disappears before completion - async function cancelQuery() { - if (!queryId) { - return; - } - proxyLogger.debug(`Cancelling request ${queryId}...`); - try { - await retryFetch( - new URL( - `${graphDbConnectionUrl}/gremlin/status?cancelQuery&queryId=${encodeURIComponent(queryId)}`, - ), - { method: "GET" }, - isIamEnabled, - region, - serviceType, - ); - } catch (err) { - // Not really an error - proxyLogger.warn(err, "Failed to cancel the query"); - } +let serverConfig; +try { + serverConfig = resolveServerConfig(env); +} catch (error) { + if (error instanceof ServerConfigError) { + logger.fatal(error.message); + process.exit(1); } + throw error; +} - // Watch for a cancelled or aborted connection - req.on("close", async () => { - if (req.complete) { - return; - } - await cancelQuery(); - }); - res.on("close", async () => { - if (res.writableFinished) { - return; - } - await cancelQuery(); - }); - - const body = { gremlin: queryString, queryId }; - const rawUrl = `${graphDbConnectionUrl}/gremlin`; - const requestOptions = { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/vnd.gremlin-v3.0+json", - }, - body: JSON.stringify(body), - }; - - await fetchData( - res, - next, - rawUrl, - requestOptions, - isIamEnabled, - region, - serviceType, - ); -}); - -// POST endpoint for openCypher queries. -app.post("/openCypher", async (req, res, next) => { - const headers = req.headers as DbQueryIncomingHttpHeaders; - const shouldLogDbQuery = BooleanStringSchema.default(false).parse( - headers["db-query-logging-enabled"], - ); - - const queryString = req.body.query; - // Validate the input before making any external calls. - if (!queryString) { - res.status(400).send({ error: "[Proxy]OpenCypher: query not provided" }); - return; - } - - if (shouldLogDbQuery) { - proxyLogger.debug("[openCypher] Received database query:\n%s", queryString); - } - - const rawUrl = `${headers["graph-db-connection-url"]}/openCypher`; - const requestOptions = { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - }, - body: `query=${encodeURIComponent(queryString)}`, - }; - - const isIamEnabled = !!headers["aws-neptune-region"]; - const region = isIamEnabled ? headers["aws-neptune-region"] : ""; - const serviceType = isIamEnabled - ? (headers["service-type"] ?? DEFAULT_SERVICE_TYPE) - : ""; - - await fetchData( - res, - next, - rawUrl, - requestOptions, - isIamEnabled, - region, - serviceType, - ); -}); - -// GET endpoint to retrieve PropertyGraph statistics summary for Neptune Analytics. -app.get("/summary", async (req, res, next) => { - const headers = req.headers as DbQueryIncomingHttpHeaders; - const isIamEnabled = !!headers["aws-neptune-region"]; - const serviceType = isIamEnabled - ? (headers["service-type"] ?? DEFAULT_SERVICE_TYPE) - : ""; - const rawUrl = `${headers["graph-db-connection-url"]}/summary?mode=detailed`; - - const requestOptions = { - method: "GET", - }; - - const region = isIamEnabled ? headers["aws-neptune-region"] : ""; - - await fetchData( - res, - next, - rawUrl, - requestOptions, - isIamEnabled, - region, - serviceType, - ); +const { + port, + baseUrl, + certificateKeyFilePath, + certificateFilePath, + staticFilesVirtualPath, + staticFilesPath, + useHttps, +} = serverConfig; + +const app = createApp({ + configPath, + staticFilesVirtualPath, + staticFilesPath, + version: packageJson.version, + corsOrigin: env.PROXY_SERVER_CORS_ORIGIN, }); -// GET endpoint to retrieve PropertyGraph statistics summary for Neptune DB. -app.get("/pg/statistics/summary", async (req, res, next) => { - const headers = req.headers as DbQueryIncomingHttpHeaders; - const isIamEnabled = !!headers["aws-neptune-region"]; - const serviceType = isIamEnabled - ? (headers["service-type"] ?? DEFAULT_SERVICE_TYPE) - : ""; - const rawUrl = `${headers["graph-db-connection-url"]}/pg/statistics/summary?mode=detailed`; +// Store logger on app.locals for access in middleware and routes +app.locals.logger = logger; - const requestOptions = { - method: "GET", - }; +// Log static file hosting info +logger.info("Hosting client side static files from: %s", staticFilesPath); +logger.info("Hosting client side static files at: %s", staticFilesVirtualPath); - const region = isIamEnabled ? headers["aws-neptune-region"] : ""; - - await fetchData( - res, - next, - rawUrl, - requestOptions, - isIamEnabled, - region, - serviceType, - ); +const server = createServer(app, { + useHttps, + certKeyPath: certificateKeyFilePath, + certPath: certificateFilePath, }); -// GET endpoint to retrieve RDF statistics summary. -app.get("/rdf/statistics/summary", async (req, res, next) => { - const headers = req.headers as DbQueryIncomingHttpHeaders; - const isIamEnabled = !!headers["aws-neptune-region"]; - const serviceType = isIamEnabled - ? (headers["service-type"] ?? DEFAULT_SERVICE_TYPE) - : ""; - const rawUrl = `${headers["graph-db-connection-url"]}/rdf/statistics/summary?mode=detailed`; - - const requestOptions = { - method: "GET", - }; - - const region = isIamEnabled ? headers["aws-neptune-region"] : ""; - - await fetchData( - res, - next, - rawUrl, - requestOptions, - isIamEnabled, - region, - serviceType, +// Start the server +server.listen(port, () => { + logger.info(`Proxy server located at ${baseUrl}`); + logger.info( + `Graph Explorer UI located at: ${baseUrl}${staticFilesVirtualPath}`, ); }); -app.get("/status", (_req, res) => { - res.send("OK"); -}); - -app.post("/logger", (req, res, next) => { - const headers = req.headers as LoggerIncomingHttpHeaders; - let message; - let level; - try { - if (headers["level"] === undefined) { - throw new Error("No log level passed."); - } else { - level = headers["level"]; - } - if (headers["message"] === undefined) { - throw new Error("No log message passed."); - } else { - message = JSON.parse(headers["message"]).replaceAll("\\", ""); - } - if (level.toLowerCase() === "error") { - proxyLogger.error(message); - } else if (level.toLowerCase() === "warn") { - proxyLogger.warn(message); - } else if (level.toLowerCase() === "info") { - proxyLogger.info(message); - } else if (level.toLowerCase() === "debug") { - proxyLogger.debug(message); - } else if (level.toLowerCase() === "trace") { - proxyLogger.trace(message); - } else { - throw new Error("Tried to log to an unknown level."); - } - res.send("Log received."); - } catch (error) { - next(error); - } -}); - -// Error handler middleware to log errors and send appropriate response. -app.use(errorHandlingMiddleware()); - -app.use((_req, res) => { - res.status(404).send("The requested resource was not available"); -}); - -// Relative paths to certificate files -const certificateKeyFilePath = path.join( - proxyServerRoot, - "cert-info/server.key", -); -const certificateFilePath = path.join(proxyServerRoot, "cert-info/server.crt"); - -// Get the port numbers to listen on -const host = env.HOST; -const httpPort = env.PROXY_SERVER_HTTP_PORT; -const httpsPort = env.PROXY_SERVER_HTTPS_PORT; -const useHttps = - env.PROXY_SERVER_HTTPS_CONNECTION && - fs.existsSync(certificateKeyFilePath) && - fs.existsSync(certificateFilePath); - -// Log the server locations based on the configuration. -function logServerLocations() { - const scheme = useHttps ? "https" : "http"; - let port = ""; - - // Only show the port if it is not one of the defaults - if (useHttps && httpsPort !== 443) { - port = `:${httpsPort}`; - } else if (!useHttps && httpPort !== 80) { - port = `:${httpPort}`; - } - - const baseUrl = `${scheme}://${host}${port}`; - proxyLogger.info(`Proxy server located at ${baseUrl}`); - proxyLogger.info( - `Graph Explorer UI located at: ${baseUrl}${staticFilesVirtualPath ?? ""}`, - ); -} - -// Start the server on port 80 or 443 (if HTTPS is enabled) -function startServer() { - if (useHttps) { - const options = { - key: fs.readFileSync(certificateKeyFilePath), - cert: fs.readFileSync(certificateFilePath), - }; - return https.createServer(options, app).listen(httpsPort, () => { - logServerLocations(); - }); - } else { - return app.listen(httpPort, () => { - logServerLocations(); - }); - } -} - -const server = startServer(); - process.on("uncaughtException", (error: Error) => { - handleError(error); + handleError(error, logger); }); process.on("unhandledRejection", reason => { - handleError(reason); + handleError(reason, logger); }); -// Watch for shutdown event and close gracefully. -process.on("SIGTERM", () => { - proxyLogger.info("SIGTERM signal received: closing HTTP server"); +// Watch for shutdown events and close gracefully. +function gracefulShutdown(signal: string) { + logger.info(`${signal} signal received: closing HTTP server`); server.close(() => { - proxyLogger.info("HTTP server closed"); + logger.info("HTTP server closed"); }); -}); +} +process.on("SIGTERM", () => gracefulShutdown("SIGTERM")); +process.on("SIGINT", () => gracefulShutdown("SIGINT")); diff --git a/packages/graph-explorer-proxy-server/src/paths.test.ts b/packages/graph-explorer-proxy-server/src/paths.test.ts index c16af043f..aa70393f4 100644 --- a/packages/graph-explorer-proxy-server/src/paths.test.ts +++ b/packages/graph-explorer-proxy-server/src/paths.test.ts @@ -1,4 +1,8 @@ -import { clientRoot, proxyServerRoot } from "./paths.js"; +import fs from "fs"; +import path from "path"; +import { vi } from "vitest"; + +import { clientRoot, isDirectory, proxyServerRoot } from "./paths.ts"; test("clientRoot is points to graph-explorer", () => { expect(clientRoot).toMatch("/packages/graph-explorer"); @@ -7,3 +11,25 @@ test("clientRoot is points to graph-explorer", () => { test("proxyServerRoot points to graph-explorer-proxy-server", () => { expect(proxyServerRoot).toMatch("/packages/graph-explorer-proxy-server"); }); + +describe("isDirectory", () => { + test("returns true for a directory", () => { + expect(isDirectory(proxyServerRoot)).toBe(true); + }); + + test("returns false for a file", () => { + expect(isDirectory(path.join(proxyServerRoot, "package.json"))).toBe(false); + }); + + test("returns false for a non-existent path", () => { + expect(isDirectory("/does/not/exist")).toBe(false); + }); + + test("rethrows unexpected errors", () => { + vi.spyOn(fs, "statSync").mockImplementation(() => { + throw new Error("unexpected"); + }); + expect(() => isDirectory("/any/path")).toThrow("unexpected"); + vi.restoreAllMocks(); + }); +}); diff --git a/packages/graph-explorer-proxy-server/src/paths.ts b/packages/graph-explorer-proxy-server/src/paths.ts index 805e9e757..bf6bf8776 100644 --- a/packages/graph-explorer-proxy-server/src/paths.ts +++ b/packages/graph-explorer-proxy-server/src/paths.ts @@ -1,3 +1,4 @@ +import fs from "fs"; import path from "path"; // Construct relative paths @@ -8,3 +9,15 @@ export const proxyServerRoot = path.join( "packages", "graph-explorer-proxy-server", ); + +/** Returns true if the given path exists and is a directory, false if it does not exist, or rethrows on unexpected errors. */ +export function isDirectory(path: string) { + try { + return fs.statSync(path).isDirectory(); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") { + return false; + } + throw error; + } +} diff --git a/packages/graph-explorer-proxy-server/src/process-environment.test.ts b/packages/graph-explorer-proxy-server/src/process-environment.test.ts new file mode 100644 index 000000000..3fb5552a5 --- /dev/null +++ b/packages/graph-explorer-proxy-server/src/process-environment.test.ts @@ -0,0 +1,431 @@ +import { execFileSync } from "child_process"; +import fs from "fs"; +import os from "os"; +import path from "path"; + +const scriptPath = path.resolve( + import.meta.dirname, + "../../../process-environment.sh", +); + +/** Runs process-environment.sh in a temp directory with the given env vars. */ +function runScript(workDir: string, env: Record = {}) { + // Ensure the configuration folder exists + const configFolder = + env.CONFIGURATION_FOLDER_PATH ?? "./packages/graph-explorer/"; + const resolvedConfigFolder = path.resolve(workDir, configFolder); + fs.mkdirSync(resolvedConfigFolder, { recursive: true }); + + execFileSync("sh", [scriptPath], { + cwd: workDir, + env: { ...env, PATH: process.env.PATH }, + }); + + return { + envFile: safeRead(path.join(resolvedConfigFolder, ".env")), + defaultConnection: safeReadJson( + path.join(resolvedConfigFolder, "defaultConnection.json"), + ), + }; +} + +function safeRead(filePath: string): string { + try { + return fs.readFileSync(filePath, "utf-8"); + } catch { + return ""; + } +} + +function safeReadJson(filePath: string): Record | null { + try { + return JSON.parse(fs.readFileSync(filePath, "utf-8")); + } catch { + return null; + } +} + +describe("process-environment.sh", () => { + let workDir: string; + + beforeEach(() => { + workDir = fs.mkdtempSync(path.join(os.tmpdir(), "ge-shell-test-")); + }); + + afterEach(() => { + fs.rmSync(workDir, { recursive: true, force: true }); + }); + + describe("SSL defaults", () => { + it("defaults PROXY_SERVER_HTTPS_CONNECTION to true", () => { + const { envFile } = runScript(workDir); + expect(envFile).toContain("PROXY_SERVER_HTTPS_CONNECTION=true"); + }); + + it("defaults GRAPH_EXP_HTTPS_CONNECTION to true", () => { + const { envFile } = runScript(workDir); + expect(envFile).toContain("GRAPH_EXP_HTTPS_CONNECTION=true"); + }); + }); + + describe("explicit SSL disable", () => { + it("respects PROXY_SERVER_HTTPS_CONNECTION=false", () => { + const { envFile } = runScript(workDir, { + PROXY_SERVER_HTTPS_CONNECTION: "false", + }); + expect(envFile).toContain("PROXY_SERVER_HTTPS_CONNECTION=false"); + }); + + it("respects GRAPH_EXP_HTTPS_CONNECTION=false", () => { + const { envFile } = runScript(workDir, { + GRAPH_EXP_HTTPS_CONNECTION: "false", + }); + expect(envFile).toContain("GRAPH_EXP_HTTPS_CONNECTION=false"); + }); + }); + + describe("NEPTUNE_NOTEBOOK=true forces SSL off", () => { + it("overrides both HTTPS vars to false", () => { + const { envFile } = runScript(workDir, { + NEPTUNE_NOTEBOOK: "true", + }); + expect(envFile).toContain("PROXY_SERVER_HTTPS_CONNECTION=false"); + expect(envFile).toContain("GRAPH_EXP_HTTPS_CONNECTION=false"); + }); + + it("writes NEPTUNE_NOTEBOOK=true to .env", () => { + const { envFile } = runScript(workDir, { + NEPTUNE_NOTEBOOK: "true", + }); + expect(envFile).toContain("NEPTUNE_NOTEBOOK=true"); + }); + }); + + describe("NEPTUNE_NOTEBOOK=false does not force SSL off", () => { + it("does not override HTTPS vars when NEPTUNE_NOTEBOOK is false", () => { + const { envFile } = runScript(workDir, { + NEPTUNE_NOTEBOOK: "false", + }); + expect(envFile).toContain("NEPTUNE_NOTEBOOK=false"); + expect(envFile).toContain("PROXY_SERVER_HTTPS_CONNECTION=true"); + expect(envFile).toContain("GRAPH_EXP_HTTPS_CONNECTION=true"); + }); + }); + + describe("NEPTUNE_NOTEBOOK unset", () => { + it("defaults NEPTUNE_NOTEBOOK to false in .env", () => { + const { envFile } = runScript(workDir); + expect(envFile).toContain("NEPTUNE_NOTEBOOK=false"); + }); + }); + + describe("config.json takes priority over env vars", () => { + it("reads values from config.json", () => { + fs.writeFileSync( + path.join(workDir, "config.json"), + JSON.stringify({ + PUBLIC_OR_PROXY_ENDPOINT: "https://my-endpoint:8182", + GRAPH_TYPE: "sparql", + USING_PROXY_SERVER: true, + IAM: true, + GRAPH_CONNECTION_URL: "https://my-db:8182", + AWS_REGION: "us-west-2", + PROXY_SERVER_HTTPS_CONNECTION: false, + GRAPH_EXP_HTTPS_CONNECTION: false, + }), + ); + + const { envFile, defaultConnection } = runScript(workDir); + + expect(envFile).toContain("PROXY_SERVER_HTTPS_CONNECTION=false"); + expect(envFile).toContain("GRAPH_EXP_HTTPS_CONNECTION=false"); + expect(defaultConnection).toMatchObject({ + GRAPH_EXP_PUBLIC_OR_PROXY_ENDPOINT: "https://my-endpoint:8182", + GRAPH_EXP_GRAPH_TYPE: "sparql", + GRAPH_EXP_USING_PROXY_SERVER: true, + GRAPH_EXP_IAM: true, + GRAPH_EXP_CONNECTION_URL: "https://my-db:8182", + GRAPH_EXP_AWS_REGION: "us-west-2", + }); + }); + + it("config.json overrides conflicting env vars", () => { + fs.writeFileSync( + path.join(workDir, "config.json"), + JSON.stringify({ + PUBLIC_OR_PROXY_ENDPOINT: "https://from-config:8182", + GRAPH_TYPE: "sparql", + }), + ); + + const { defaultConnection } = runScript(workDir, { + PUBLIC_OR_PROXY_ENDPOINT: "https://from-env:8182", + GRAPH_TYPE: "gremlin", + }); + + expect(defaultConnection).toHaveProperty( + "GRAPH_EXP_PUBLIC_OR_PROXY_ENDPOINT", + "https://from-config:8182", + ); + expect(defaultConnection).toHaveProperty( + "GRAPH_EXP_GRAPH_TYPE", + "sparql", + ); + }); + }); + + describe("defaultConnection.json generation", () => { + it("creates defaultConnection.json with GRAPH_EXP_ prefixed fields", () => { + const { defaultConnection } = runScript(workDir, { + PUBLIC_OR_PROXY_ENDPOINT: "https://endpoint:8182", + GRAPH_TYPE: "gremlin", + USING_PROXY_SERVER: "true", + IAM: "false", + GRAPH_CONNECTION_URL: "https://db:8182", + AWS_REGION: "eu-west-1", + }); + + expect(defaultConnection).toMatchObject({ + GRAPH_EXP_PUBLIC_OR_PROXY_ENDPOINT: "https://endpoint:8182", + GRAPH_EXP_GRAPH_TYPE: "gremlin", + GRAPH_EXP_USING_PROXY_SERVER: true, + GRAPH_EXP_IAM: false, + GRAPH_EXP_CONNECTION_URL: "https://db:8182", + GRAPH_EXP_AWS_REGION: "eu-west-1", + }); + }); + + it("does not create defaultConnection.json without PUBLIC_OR_PROXY_ENDPOINT", () => { + const { defaultConnection } = runScript(workDir, { + GRAPH_TYPE: "gremlin", + }); + expect(defaultConnection).toBeNull(); + }); + + it("defaults SERVICE_TYPE to neptune-db", () => { + const { defaultConnection } = runScript(workDir, { + PUBLIC_OR_PROXY_ENDPOINT: "https://endpoint:8182", + }); + expect(defaultConnection).toHaveProperty( + "GRAPH_EXP_SERVICE_TYPE", + "neptune-db", + ); + }); + + it("defaults USING_PROXY_SERVER to false", () => { + const { defaultConnection } = runScript(workDir, { + PUBLIC_OR_PROXY_ENDPOINT: "https://endpoint:8182", + }); + expect(defaultConnection).toHaveProperty( + "GRAPH_EXP_USING_PROXY_SERVER", + false, + ); + }); + + it("defaults IAM to false", () => { + const { defaultConnection } = runScript(workDir, { + PUBLIC_OR_PROXY_ENDPOINT: "https://endpoint:8182", + }); + expect(defaultConnection).toHaveProperty("GRAPH_EXP_IAM", false); + }); + }); + + describe("SERVICE_TYPE=neptune-graph auto-sets openCypher", () => { + it("sets GRAPH_TYPE to openCypher when SERVICE_TYPE is neptune-graph", () => { + const { defaultConnection } = runScript(workDir, { + PUBLIC_OR_PROXY_ENDPOINT: "https://endpoint:8182", + SERVICE_TYPE: "neptune-graph", + }); + expect(defaultConnection).toHaveProperty( + "GRAPH_EXP_GRAPH_TYPE", + "openCypher", + ); + }); + + it("does not set GRAPH_TYPE when SERVICE_TYPE is neptune-db and no GRAPH_TYPE given", () => { + const { defaultConnection } = runScript(workDir, { + PUBLIC_OR_PROXY_ENDPOINT: "https://endpoint:8182", + SERVICE_TYPE: "neptune-db", + }); + expect(defaultConnection).not.toHaveProperty("GRAPH_EXP_GRAPH_TYPE"); + }); + + it("explicit GRAPH_TYPE takes priority over neptune-graph auto-detection", () => { + const { defaultConnection } = runScript(workDir, { + PUBLIC_OR_PROXY_ENDPOINT: "https://endpoint:8182", + SERVICE_TYPE: "neptune-graph", + GRAPH_TYPE: "sparql", + }); + expect(defaultConnection).toHaveProperty( + "GRAPH_EXP_GRAPH_TYPE", + "sparql", + ); + }); + }); + + describe("custom CONFIGURATION_FOLDER_PATH", () => { + it("writes .env and defaultConnection.json to the custom path", () => { + const customFolder = path.join(workDir, "custom-config"); + + runScript(workDir, { + CONFIGURATION_FOLDER_PATH: customFolder, + PUBLIC_OR_PROXY_ENDPOINT: "https://endpoint:8182", + }); + + // Files exist at the custom path + expect(fs.existsSync(path.join(customFolder, ".env"))).toBe(true); + expect( + fs.existsSync(path.join(customFolder, "defaultConnection.json")), + ).toBe(true); + + // Files do not exist at the default path + const defaultFolder = path.join(workDir, "packages", "graph-explorer"); + expect(fs.existsSync(path.join(defaultFolder, ".env"))).toBe(false); + expect( + fs.existsSync(path.join(defaultFolder, "defaultConnection.json")), + ).toBe(false); + }); + }); + + describe("default values for optional fields", () => { + it("defaults GRAPH_CONNECTION_URL to empty string", () => { + const { defaultConnection } = runScript(workDir, { + PUBLIC_OR_PROXY_ENDPOINT: "https://endpoint:8182", + }); + expect(defaultConnection).toHaveProperty("GRAPH_EXP_CONNECTION_URL", ""); + }); + + it("preserves path in GRAPH_CONNECTION_URL", () => { + const { defaultConnection } = runScript(workDir, { + PUBLIC_OR_PROXY_ENDPOINT: "http://localhost:8080", + GRAPH_CONNECTION_URL: "http://blazegraph:9999/blazegraph/namespace/kb", + }); + expect(defaultConnection).toHaveProperty( + "GRAPH_EXP_CONNECTION_URL", + "http://blazegraph:9999/blazegraph/namespace/kb", + ); + }); + + it("preserves trailing slash in GRAPH_CONNECTION_URL", () => { + const { defaultConnection } = runScript(workDir, { + PUBLIC_OR_PROXY_ENDPOINT: "http://localhost:8080", + GRAPH_CONNECTION_URL: "http://blazegraph:9999/blazegraph/namespace/kb/", + }); + expect(defaultConnection).toHaveProperty( + "GRAPH_EXP_CONNECTION_URL", + "http://blazegraph:9999/blazegraph/namespace/kb/", + ); + }); + + it("defaults AWS_REGION to empty string", () => { + const { defaultConnection } = runScript(workDir, { + PUBLIC_OR_PROXY_ENDPOINT: "https://endpoint:8182", + }); + expect(defaultConnection).toHaveProperty("GRAPH_EXP_AWS_REGION", ""); + }); + + it("passes through custom SERVICE_TYPE value", () => { + const { defaultConnection } = runScript(workDir, { + PUBLIC_OR_PROXY_ENDPOINT: "https://endpoint:8182", + SERVICE_TYPE: "neptune-graph", + }); + expect(defaultConnection).toHaveProperty( + "GRAPH_EXP_SERVICE_TYPE", + "neptune-graph", + ); + }); + }); + + describe("file behavior", () => { + it("appends to existing .env file", () => { + const configFolder = path.resolve(workDir, "packages/graph-explorer"); + fs.mkdirSync(configFolder, { recursive: true }); + fs.writeFileSync(path.join(configFolder, ".env"), "EXISTING_VAR=keep\n"); + + const { envFile } = runScript(workDir); + + expect(envFile).toContain("EXISTING_VAR=keep"); + expect(envFile).toContain("PROXY_SERVER_HTTPS_CONNECTION=true"); + }); + + it("overwrites existing defaultConnection.json", () => { + const configFolder = path.resolve(workDir, "packages/graph-explorer"); + fs.mkdirSync(configFolder, { recursive: true }); + fs.writeFileSync( + path.join(configFolder, "defaultConnection.json"), + JSON.stringify({ OLD_KEY: "old-value" }), + ); + + const { defaultConnection } = runScript(workDir, { + PUBLIC_OR_PROXY_ENDPOINT: "https://endpoint:8182", + }); + + expect(defaultConnection).not.toHaveProperty("OLD_KEY"); + expect(defaultConnection).toHaveProperty( + "GRAPH_EXP_PUBLIC_OR_PROXY_ENDPOINT", + "https://endpoint:8182", + ); + }); + }); + + describe("defaultConnection.json has all expected keys", () => { + it("contains exactly the expected keys when all values provided", () => { + const { defaultConnection } = runScript(workDir, { + PUBLIC_OR_PROXY_ENDPOINT: "https://endpoint:8182", + SERVICE_TYPE: "neptune-db", + GRAPH_TYPE: "gremlin", + USING_PROXY_SERVER: "true", + IAM: "true", + GRAPH_CONNECTION_URL: "https://db:8182", + AWS_REGION: "us-east-1", + }); + + expect(Object.keys(defaultConnection!).sort()).toEqual([ + "GRAPH_EXP_AWS_REGION", + "GRAPH_EXP_CONNECTION_URL", + "GRAPH_EXP_GRAPH_TYPE", + "GRAPH_EXP_IAM", + "GRAPH_EXP_PUBLIC_OR_PROXY_ENDPOINT", + "GRAPH_EXP_SERVICE_TYPE", + "GRAPH_EXP_USING_PROXY_SERVER", + ]); + }); + + it("omits GRAPH_EXP_GRAPH_TYPE when neither GRAPH_TYPE nor neptune-graph", () => { + const { defaultConnection } = runScript(workDir, { + PUBLIC_OR_PROXY_ENDPOINT: "https://endpoint:8182", + }); + + expect(Object.keys(defaultConnection!).sort()).toEqual([ + "GRAPH_EXP_AWS_REGION", + "GRAPH_EXP_CONNECTION_URL", + "GRAPH_EXP_IAM", + "GRAPH_EXP_PUBLIC_OR_PROXY_ENDPOINT", + "GRAPH_EXP_SERVICE_TYPE", + "GRAPH_EXP_USING_PROXY_SERVER", + ]); + }); + }); + + describe("grep-safe .env output", () => { + it("PROXY_SERVER_HTTPS_CONNECTION is on its own line", () => { + const { envFile } = runScript(workDir); + expect(envFile).toMatch(/^PROXY_SERVER_HTTPS_CONNECTION=true$/m); + }); + + it("does not produce commented-out PROXY_SERVER_HTTPS_CONNECTION", () => { + const { envFile } = runScript(workDir); + expect(envFile).not.toContain("# PROXY_SERVER_HTTPS_CONNECTION"); + }); + + it("value has no trailing whitespace", () => { + const { envFile } = runScript(workDir, { + PROXY_SERVER_HTTPS_CONNECTION: "false", + }); + const line = envFile + .split("\n") + .find(l => l.startsWith("PROXY_SERVER_HTTPS_CONNECTION")); + expect(line).toBe("PROXY_SERVER_HTTPS_CONNECTION=false"); + }); + }); +}); diff --git a/packages/graph-explorer-proxy-server/src/server-config.test.ts b/packages/graph-explorer-proxy-server/src/server-config.test.ts new file mode 100644 index 000000000..5b619a7f6 --- /dev/null +++ b/packages/graph-explorer-proxy-server/src/server-config.test.ts @@ -0,0 +1,207 @@ +import fs from "fs"; +import path from "path"; + +import type { EnvironmentValues } from "./env.ts"; + +import { clientRoot, proxyServerRoot } from "./paths.ts"; +import { buildBaseUrl, resolveServerConfig } from "./server-config.ts"; + +const expectedKeyPath = path.join(proxyServerRoot, "cert-info/server.key"); +const expectedCertPath = path.join(proxyServerRoot, "cert-info/server.crt"); +const expectedStaticFilesPath = path.join(clientRoot, "dist"); + +function createEnv( + overrides: Partial = {}, +): EnvironmentValues { + return { + HOST: "localhost", + PROXY_SERVER_HTTPS_CONNECTION: false, + PROXY_SERVER_HTTPS_PORT: 443, + PROXY_SERVER_HTTP_PORT: 80, + LOG_LEVEL: "debug", + LOG_STYLE: "default", + ...overrides, + }; +} + +describe("resolveServerConfig", () => { + it("returns certificate paths relative to proxyServerRoot", () => { + const config = resolveServerConfig(createEnv()); + + expect(config.certificateKeyFilePath).toBe(expectedKeyPath); + expect(config.certificateFilePath).toBe(expectedCertPath); + }); + + it("returns static file paths", () => { + const config = resolveServerConfig(createEnv()); + + expect(config.staticFilesVirtualPath).toBe("/explorer"); + expect(config.staticFilesPath).toBe(expectedStaticFilesPath); + }); + + it("passes through host and port values from env", () => { + const config = resolveServerConfig( + createEnv({ + HOST: "my-host", + PROXY_SERVER_HTTP_PORT: 8080, + }), + ); + + expect(config.host).toBe("my-host"); + expect(config.port).toBe(8080); + expect(config.baseUrl).toBe("http://my-host:8080"); + }); + + it("returns HTTPS port and baseUrl when useHttps is true", () => { + vi.spyOn(fs, "existsSync").mockReturnValue(true); + + const config = resolveServerConfig( + createEnv({ + HOST: "my-host", + PROXY_SERVER_HTTPS_CONNECTION: true, + PROXY_SERVER_HTTP_PORT: 8080, + PROXY_SERVER_HTTPS_PORT: 8443, + }), + ); + + expect(config.port).toBe(8443); + expect(config.baseUrl).toBe("https://my-host:8443"); + }); + + it("sets useHttps to false when PROXY_SERVER_HTTPS_CONNECTION is false", () => { + const config = resolveServerConfig( + createEnv({ PROXY_SERVER_HTTPS_CONNECTION: false }), + ); + + expect(config.useHttps).toBe(false); + }); + + it("throws when HTTPS is enabled but cert files do not exist", () => { + vi.spyOn(fs, "existsSync").mockReturnValue(false); + + expect(() => + resolveServerConfig(createEnv({ PROXY_SERVER_HTTPS_CONNECTION: true })), + ).toThrow( + expect.objectContaining({ + name: "ServerConfigError", + message: expect.stringContaining(expectedKeyPath), + }), + ); + }); + + it("throws when HTTPS is enabled but only the key file exists", () => { + vi.spyOn(fs, "existsSync").mockImplementation(p => p === expectedKeyPath); + + expect(() => + resolveServerConfig(createEnv({ PROXY_SERVER_HTTPS_CONNECTION: true })), + ).toThrow( + expect.objectContaining({ + name: "ServerConfigError", + message: expect.stringContaining(expectedCertPath), + }), + ); + }); + + it("throws when HTTPS is enabled but only the cert file exists", () => { + vi.spyOn(fs, "existsSync").mockImplementation(p => p === expectedCertPath); + + expect(() => + resolveServerConfig(createEnv({ PROXY_SERVER_HTTPS_CONNECTION: true })), + ).toThrow( + expect.objectContaining({ + name: "ServerConfigError", + message: expect.stringContaining(expectedKeyPath), + }), + ); + }); + + it("sets useHttps to true when HTTPS is enabled and both cert files exist", () => { + vi.spyOn(fs, "existsSync").mockReturnValue(true); + + const config = resolveServerConfig( + createEnv({ PROXY_SERVER_HTTPS_CONNECTION: true }), + ); + + expect(config.useHttps).toBe(true); + }); + + describe("ServerConfigError message content", () => { + it("both files missing: message contains both paths", () => { + vi.spyOn(fs, "existsSync").mockReturnValue(false); + + let message = ""; + try { + resolveServerConfig(createEnv({ PROXY_SERVER_HTTPS_CONNECTION: true })); + } catch (e) { + message = (e as Error).message; + } + + expect(message).toContain("server.key"); + expect(message).toContain("server.crt"); + }); + + it("only key missing: message contains key path but not cert path", () => { + vi.spyOn(fs, "existsSync").mockImplementation( + p => p === expectedCertPath, + ); + + let message = ""; + try { + resolveServerConfig(createEnv({ PROXY_SERVER_HTTPS_CONNECTION: true })); + } catch (e) { + message = (e as Error).message; + } + + expect(message).toContain("server.key"); + expect(message).not.toContain("server.crt"); + }); + + it("only cert missing: message contains cert path but not key path", () => { + vi.spyOn(fs, "existsSync").mockImplementation(p => p === expectedKeyPath); + + let message = ""; + try { + resolveServerConfig(createEnv({ PROXY_SERVER_HTTPS_CONNECTION: true })); + } catch (e) { + message = (e as Error).message; + } + + expect(message).toContain("server.crt"); + expect(message).not.toContain("server.key"); + }); + + it("message includes the env var name", () => { + vi.spyOn(fs, "existsSync").mockReturnValue(false); + + expect(() => + resolveServerConfig(createEnv({ PROXY_SERVER_HTTPS_CONNECTION: true })), + ).toThrow( + expect.objectContaining({ + message: expect.stringContaining("PROXY_SERVER_HTTPS_CONNECTION"), + }), + ); + }); + }); +}); + +describe("buildBaseUrl", () => { + it("returns http URL with custom port", () => { + expect(buildBaseUrl(false, "localhost", 8080)).toBe( + "http://localhost:8080", + ); + }); + + it("omits port 80 for http", () => { + expect(buildBaseUrl(false, "localhost", 80)).toBe("http://localhost"); + }); + + it("returns https URL with custom port", () => { + expect(buildBaseUrl(true, "localhost", 8443)).toBe( + "https://localhost:8443", + ); + }); + + it("omits port 443 for https", () => { + expect(buildBaseUrl(true, "localhost", 443)).toBe("https://localhost"); + }); +}); diff --git a/packages/graph-explorer-proxy-server/src/server-config.ts b/packages/graph-explorer-proxy-server/src/server-config.ts new file mode 100644 index 000000000..38767bcc4 --- /dev/null +++ b/packages/graph-explorer-proxy-server/src/server-config.ts @@ -0,0 +1,59 @@ +import fs from "fs"; +import path from "path"; + +import type { EnvironmentValues } from "./env.ts"; + +import { clientRoot, proxyServerRoot } from "./paths.ts"; + +export class ServerConfigError extends Error { + constructor(message: string) { + super(message); + this.name = "ServerConfigError"; + } +} + +export function resolveServerConfig(env: EnvironmentValues) { + const certificateKeyFilePath = path.join( + proxyServerRoot, + "cert-info/server.key", + ); + const certificateFilePath = path.join( + proxyServerRoot, + "cert-info/server.crt", + ); + + const useHttps = env.PROXY_SERVER_HTTPS_CONNECTION; + + if (useHttps) { + const missingFiles = [certificateKeyFilePath, certificateFilePath].filter( + f => !fs.existsSync(f), + ); + if (missingFiles.length > 0) { + throw new ServerConfigError( + `PROXY_SERVER_HTTPS_CONNECTION is true but certificate files are missing: ${missingFiles.join(", ")}`, + ); + } + } + + const port = useHttps + ? env.PROXY_SERVER_HTTPS_PORT + : env.PROXY_SERVER_HTTP_PORT; + + return { + host: env.HOST, + port, + useHttps, + baseUrl: buildBaseUrl(useHttps, env.HOST, port), + certificateKeyFilePath, + certificateFilePath, + staticFilesVirtualPath: "/explorer", + staticFilesPath: path.join(clientRoot, "dist"), + }; +} + +export function buildBaseUrl(useHttps: boolean, host: string, port: number) { + const scheme = useHttps ? "https" : "http"; + const defaultPort = useHttps ? 443 : 80; + const portSuffix = port === defaultPort ? "" : `:${port}`; + return `${scheme}://${host}${portSuffix}`; +} diff --git a/packages/graph-explorer-proxy-server/src/server.test.ts b/packages/graph-explorer-proxy-server/src/server.test.ts new file mode 100644 index 000000000..dc02b8857 --- /dev/null +++ b/packages/graph-explorer-proxy-server/src/server.test.ts @@ -0,0 +1,76 @@ +import { execFileSync } from "child_process"; +import express from "express"; +import fs from "fs"; +import https from "https"; +import os from "os"; +import path from "path"; + +import { createServer } from "./server.ts"; + +function createTestExpressApp() { + const app = express(); + app.get("/status", (_req, res) => res.send("OK")); + return app; +} + +function createTempCerts() { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ge-server-test-")); + const keyPath = path.join(tmpDir, "server.key"); + const certPath = path.join(tmpDir, "server.crt"); + + execFileSync("openssl", [ + "req", + "-x509", + "-newkey", + "rsa:2048", + "-keyout", + keyPath, + "-out", + certPath, + "-days", + "1", + "-nodes", + "-subj", + "/CN=localhost", + ]); + + return { tmpDir, keyPath, certPath }; +} + +describe("createServer", () => { + it("returns an HTTP server when useHttps is false", () => { + const app = createTestExpressApp(); + const server = createServer(app, { + useHttps: false, + certKeyPath: "", + certPath: "", + }); + expect(server).not.toBeInstanceOf(https.Server); + }); + + it("returns an HTTPS server when useHttps is true and certs exist", () => { + const { tmpDir, keyPath, certPath } = createTempCerts(); + try { + const app = createTestExpressApp(); + const server = createServer(app, { + useHttps: true, + certKeyPath: keyPath, + certPath: certPath, + }); + expect(server).toBeInstanceOf(https.Server); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("throws when useHttps is true but cert files are missing", () => { + const app = createTestExpressApp(); + expect(() => + createServer(app, { + useHttps: true, + certKeyPath: "/nonexistent/server.key", + certPath: "/nonexistent/server.crt", + }), + ).toThrow(); + }); +}); diff --git a/packages/graph-explorer-proxy-server/src/server.ts b/packages/graph-explorer-proxy-server/src/server.ts new file mode 100644 index 000000000..9474a17ef --- /dev/null +++ b/packages/graph-explorer-proxy-server/src/server.ts @@ -0,0 +1,25 @@ +import type express from "express"; + +import fs from "fs"; +import http from "http"; +import https from "https"; + +export interface CreateServerOptions { + useHttps: boolean; + certKeyPath: string; + certPath: string; +} + +export function createServer( + app: express.Express, + options: CreateServerOptions, +): http.Server { + if (options.useHttps) { + const sslOptions = { + key: fs.readFileSync(options.certKeyPath), + cert: fs.readFileSync(options.certPath), + }; + return https.createServer(sslOptions, app); + } + return http.createServer(app); +} diff --git a/packages/graph-explorer-proxy-server/src/setup-ssl.test.ts b/packages/graph-explorer-proxy-server/src/setup-ssl.test.ts new file mode 100644 index 000000000..6bbf17b89 --- /dev/null +++ b/packages/graph-explorer-proxy-server/src/setup-ssl.test.ts @@ -0,0 +1,173 @@ +import { execFileSync } from "child_process"; +import fs from "fs"; +import os from "os"; +import path from "path"; + +const scriptPath = path.resolve(import.meta.dirname, "../../../setup-ssl.sh"); +const certInfoDir = path.resolve(import.meta.dirname, "../cert-info"); + +/** Read the real config files so tests break if they change. */ +const CERT_CONF_CONTENT = fs.readFileSync( + path.join(certInfoDir, "cert.conf"), + "utf-8", +); +const CSR_CONF_CONTENT = fs.readFileSync( + path.join(certInfoDir, "csr.conf"), + "utf-8", +); + +function runScript( + certDir: string, + env: Record = {}, +): { exitCode: number; stdout: string; stderr: string } { + try { + const stdout = execFileSync("sh", [scriptPath], { + env: { CERT_DIR: certDir, PATH: process.env.PATH, ...env }, + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }); + return { exitCode: 0, stdout, stderr: "" }; + } catch (error: unknown) { + const e = error as { + status: number; + stdout: string; + stderr: string; + }; + return { + exitCode: e.status, + stdout: e.stdout ?? "", + stderr: e.stderr ?? "", + }; + } +} + +/** Creates the cert config template files that openssl needs. */ +function seedCertConfigs(certDir: string) { + fs.writeFileSync(path.join(certDir, "cert.conf"), CERT_CONF_CONTENT); + fs.writeFileSync(path.join(certDir, "csr.conf"), CSR_CONF_CONTENT); +} + +/** Creates placeholder cert files to simulate pre-existing certificates. */ +function seedAllCertFiles(certDir: string) { + for (const f of [ + "rootCA.key", + "rootCA.crt", + "server.key", + "server.csr", + "server.crt", + ]) { + fs.writeFileSync(path.join(certDir, f), "placeholder"); + } +} + +describe("setup-ssl.sh", () => { + let certDir: string; + + beforeEach(() => { + certDir = fs.mkdtempSync(path.join(os.tmpdir(), "ge-ssl-test-")); + }); + + afterEach(() => { + fs.rmSync(certDir, { recursive: true, force: true }); + }); + + it("fails when CERT_DIR is not set", () => { + const { exitCode, stderr } = runScript("", { CERT_DIR: "" }); + expect(exitCode).not.toBe(0); + expect(stderr).toContain("CERT_DIR is required"); + }); + + describe("with HOST set (generate new certs)", () => { + it("generates certificate files", () => { + seedCertConfigs(certDir); + + const { exitCode } = runScript(certDir, { HOST: "localhost" }); + + expect(exitCode).toBe(0); + expect(fs.existsSync(path.join(certDir, "rootCA.key"))).toBe(true); + expect(fs.existsSync(path.join(certDir, "rootCA.crt"))).toBe(true); + expect(fs.existsSync(path.join(certDir, "server.key"))).toBe(true); + expect(fs.existsSync(path.join(certDir, "server.csr"))).toBe(true); + expect(fs.existsSync(path.join(certDir, "server.crt"))).toBe(true); + }); + + it("adds HOST to cert.conf SAN entries", () => { + seedCertConfigs(certDir); + + runScript(certDir, { HOST: "my-host.example.com" }); + + const certConf = fs.readFileSync( + path.join(certDir, "cert.conf"), + "utf-8", + ); + expect(certConf).toContain("my-host.example.com"); + }); + + it("adds HOST to csr.conf SAN entries", () => { + seedCertConfigs(certDir); + + runScript(certDir, { HOST: "my-host.example.com" }); + + const csrConf = fs.readFileSync(path.join(certDir, "csr.conf"), "utf-8"); + expect(csrConf).toContain("my-host.example.com"); + }); + + it("exits immediately when openssl fails due to invalid config", () => { + fs.writeFileSync(path.join(certDir, "cert.conf"), "INVALID"); + fs.writeFileSync(path.join(certDir, "csr.conf"), "INVALID"); + + const { exitCode } = runScript(certDir, { HOST: "localhost" }); + + expect(exitCode).not.toBe(0); + expect(fs.existsSync(path.join(certDir, "server.crt"))).toBe(false); + }); + }); + + describe("without HOST (check existing certs)", () => { + it("succeeds when all cert files exist", () => { + seedAllCertFiles(certDir); + + const { exitCode, stdout } = runScript(certDir); + + expect(exitCode).toBe(0); + expect(stdout).toContain("Re-using existing cert"); + }); + + it("fails when no cert files exist", () => { + const { exitCode, stderr } = runScript(certDir); + + expect(exitCode).not.toBe(0); + expect(stderr).toContain("Missing certificate files"); + }); + + it("fails when server.key is missing", () => { + for (const f of [ + "rootCA.key", + "rootCA.crt", + "server.csr", + "server.crt", + ]) { + fs.writeFileSync(path.join(certDir, f), "placeholder"); + } + + const { exitCode, stderr } = runScript(certDir); + + expect(exitCode).not.toBe(0); + expect(stderr).toContain("server.key"); + }); + + it("reports each missing file individually", () => { + // Only rootCA.key exists + fs.writeFileSync(path.join(certDir, "rootCA.key"), "placeholder"); + + const { exitCode, stderr } = runScript(certDir); + + expect(exitCode).not.toBe(0); + expect(stderr).toContain("rootCA.crt"); + expect(stderr).toContain("server.key"); + expect(stderr).toContain("server.csr"); + expect(stderr).toContain("server.crt"); + expect(stderr).not.toContain("rootCA.key"); + }); + }); +}); diff --git a/packages/graph-explorer-proxy-server/src/test-setup.ts b/packages/graph-explorer-proxy-server/src/test-setup.ts new file mode 100644 index 000000000..f2c3a3a8e --- /dev/null +++ b/packages/graph-explorer-proxy-server/src/test-setup.ts @@ -0,0 +1,31 @@ +/** + * CREDENTIAL SAFETY + * + * These mocks prevent any test from accidentally using real AWS credentials + * or making real HTTP requests. They apply to every test file in this package. + * + * - @aws-sdk/credential-providers: Returns fake credentials so the real + * credential provider chain (env vars, ~/.aws, IMDS) is never consulted. + * - node-fetch: Returns a no-op mock so no HTTP request ever leaves the process. + * + * DO NOT remove these mocks without understanding the security implications. + */ + +vi.mock("@aws-sdk/credential-providers", () => ({ + fromNodeProviderChain: () => () => + Promise.resolve({ + accessKeyId: "test-key", + secretAccessKey: "test-secret", + sessionToken: "test-token", + }), +})); + +vi.mock("node-fetch", () => ({ + __esModule: true, + default: vi.fn(), + Headers: Map, +})); + +// Each test creates a fresh Express app which adds process-level listeners, +// exceeding the default limit of 10 and producing a noisy warning. +process.setMaxListeners(0); diff --git a/packages/graph-explorer-proxy-server/tsconfig.build.json b/packages/graph-explorer-proxy-server/tsconfig.build.json deleted file mode 100644 index 0ee23d1ee..000000000 --- a/packages/graph-explorer-proxy-server/tsconfig.build.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "./tsconfig.json", - "exclude": ["**/*.test.ts", "./src/test-setup.ts"], - "compilerOptions": { - "types": ["node"], - "outDir": "./dist", - "noEmit": false - } -} diff --git a/packages/graph-explorer-proxy-server/tsconfig.json b/packages/graph-explorer-proxy-server/tsconfig.json index de9f722ab..c78d425c0 100644 --- a/packages/graph-explorer-proxy-server/tsconfig.json +++ b/packages/graph-explorer-proxy-server/tsconfig.json @@ -2,23 +2,7 @@ "extends": "../../tsconfig.base.json", "include": ["src"], "compilerOptions": { - /* Modules */ - "lib": ["es2024", "ESNext.Array", "ESNext.Collection", "ESNext.Iterator"], - "module": "nodenext", - "target": "es2022", - "moduleResolution": "node16", - "esModuleInterop": true, - - /* Type Resolution */ - "types": ["node", "vitest/globals"], - "skipLibCheck": true, - - /* Paths */ - "baseUrl": "./src", - "rootDir": "./src", - "paths": { - "@/*": ["./*"], - "@shared/*": ["../../shared/src/*"] - } + "allowImportingTsExtensions": true, + "types": ["node", "vitest/globals"] } } diff --git a/packages/graph-explorer-proxy-server/vitest.config.ts b/packages/graph-explorer-proxy-server/vitest.config.ts index 070c09860..18b5146be 100644 --- a/packages/graph-explorer-proxy-server/vitest.config.ts +++ b/packages/graph-explorer-proxy-server/vitest.config.ts @@ -4,10 +4,10 @@ export default defineConfig({ test: { globals: true, environment: "node", - coverage: { - reportsDirectory: "coverage", - provider: "v8", - reporter: ["lcov", "text", "json", "clover"], - }, + pool: "threads", + restoreMocks: true, + unstubEnvs: true, + unstubGlobals: true, + setupFiles: ["src/test-setup.ts"], }, }); diff --git a/packages/graph-explorer/package.json b/packages/graph-explorer/package.json index 807ae5342..52dbf617d 100644 --- a/packages/graph-explorer/package.json +++ b/packages/graph-explorer/package.json @@ -1,10 +1,9 @@ { "name": "graph-explorer", - "version": "3.0.0", + "version": "3.0.3", "description": "Graph Explorer", - "engines": { - "node": ">=24.13.0" - }, + "license": "Apache-2.0", + "author": "amazon", "type": "module", "scripts": { "typecheck": "tsc --noEmit", @@ -13,131 +12,101 @@ "vite-build": "NODE_OPTIONS=--max_old_space_size=6144 vite build", "build": "pnpm vite-build -- --mode production" }, - "author": "amazon", - "license": "Apache-2.0", "dependencies": { "@graph-explorer/shared": "workspace:*", "@hookform/resolvers": "^5.2.2", "@monaco-editor/react": "^4.7.0", - "@react-aria/textfield": "3.18.2", - "@tanstack/react-query": "^5.90.20", - "babel-plugin-react-compiler": "19.1.0-rc.3", + "@react-aria/textfield": "3.19.0", + "@tanstack/react-query": "^5.99.2", + "babel-plugin-react-compiler": "^1.0.0", "clsx": "^2.1.1", "color": "^5.0.3", "crypto-js": "^4.2.0", "cva": "1.0.0-beta.1", - "cytoscape": "3.33.1", + "cytoscape": "3.33.2", "cytoscape-canvas": "^3.0.1", "cytoscape-d3-force": "^1.1.4", "cytoscape-dagre": "^2.5.0", "cytoscape-fcose": "^2.2.0", "cytoscape-klay": "^3.1.4", "date-fns": "^4.1.0", - "dedent": "^1.7.1", + "dedent": "^1.7.2", "file-saver": "^2.0.5", "flat": "^6.0.1", - "jotai": "^2.17.0", + "jotai": "^2.19.1", "jotai-family": "^1.0.1", "localforage": "^1.10.0", - "lodash": "^4.17.23", - "lucide-react": "^0.554.0", + "lodash": "^4.18.1", + "lucide-react": "^1.8.0", "monaco-editor": "^0.55.1", - "motion": "^12.29.2", + "motion": "^12.38.0", "papaparse": "^5.5.3", "radix-ui": "^1.4.3", "re-resizable": "^6.11.2", - "react": "^19.2.4", + "react": "^19.2.5", "react-colorful": "^5.6.1", - "react-dom": "^19.2.4", - "react-error-boundary": "^6.1.0", - "react-hook-form": "^7.71.1", - "react-inlinesvg": "^4.2.0", + "react-dom": "^19.2.5", + "react-error-boundary": "^6.1.1", + "react-hook-form": "^7.73.1", + "react-inlinesvg": "^4.3.0", "react-laag": "^2.0.5", - "react-router": "^7.13.0", + "react-router": "^7.14.2", "react-table": "^7.8.0", "react-transition-group": "^4.4.5", - "react-virtuoso": "^4.18.1", + "react-virtuoso": "^4.18.5", "sonner": "^2.0.7", - "tailwind-merge": "^3.4.0", + "tailwind-merge": "^3.5.0", "use-deep-compare-effect": "^1.8.1", - "uuid": "^13.0.0", + "uuid": "^14.0.0", "zod": "^4.3.6", "zod-validation-error": "^5.0.0" }, "devDependencies": { "@babel/core": "^7.29.0", - "@babel/preset-env": "^7.29.0", + "@babel/preset-env": "^7.29.5", "@babel/preset-react": "^7.28.5", "@babel/preset-typescript": "^7.28.5", - "@react-stately/radio": "^3.11.3", - "@react-types/button": "^3.14.1", - "@react-types/checkbox": "^3.10.2", - "@react-types/combobox": "^3.13.10", - "@react-types/radio": "^3.9.2", - "@react-types/shared": "^3.32.1", - "@react-types/switch": "^3.5.15", - "@tailwindcss/vite": "^4.1.18", - "@tanstack/react-query-devtools": "^5.91.3", + "@react-stately/radio": "^3.12.0", + "@react-types/button": "^3.16.0", + "@react-types/checkbox": "^3.11.0", + "@react-types/combobox": "^3.15.0", + "@react-types/radio": "^3.10.0", + "@react-types/shared": "^3.34.0", + "@react-types/switch": "^3.6.0", + "@rolldown/plugin-babel": "0.2.3", + "@tailwindcss/vite": "^4.2.4", + "@tanstack/react-query-devtools": "^5.99.2", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", - "@types/color": "^4.2.0", + "@types/color": "^4.2.1", "@types/crypto-js": "^4.2.2", "@types/file-saver": "^2.0.7", "@types/flat": "^5.0.5", - "@types/lodash": "^4.17.23", - "@types/node": "^24.10.9", + "@types/lodash": "^4.17.24", + "@types/node": "^24.12.2", "@types/papaparse": "^5.5.2", - "@types/react": "^19.2.10", + "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@types/react-table": "^7.7.20", "@types/react-transition-group": "^4.4.12", "@types/wicg-file-system-access": "^2023.10.7", - "@vitejs/plugin-react": "^5.1.2", - "@vitest/coverage-v8": "4.0.10", - "core-js": "^3.48.0", - "happy-dom": "^20.4.0", - "jsdom": "^27.4.0", - "lint-staged": "^16.2.7", - "rimraf": "^6.1.2", - "tailwindcss": "^4.1.18", + "@vitejs/plugin-react": "^6.0.0", + "@vitest/coverage-v8": "4.1.5", + "core-js": "^3.49.0", + "happy-dom": "^20.9.0", + "jsdom": "^29.0.2", + "lint-staged": "^16.4.0", + "rimraf": "^6.1.3", + "tailwindcss": "^4.2.4", "tw-animate-css": "^1.4.0", - "type-fest": "^5.4.3", - "vite": "^7.3.1", - "vite-tsconfig-paths": "^5.1.4", - "vitest": "4.0.10" - }, - "overrides": { - "json5@>=2.0.0 <2.2.2": "2.2.2", - "loader-utils@>=2.0.0 <2.0.3": "2.0.4", - "decode-uri-component@<0.2.1": "0.2.2", - "semver@>=6.0.0 <6.3.1": ">=6.3.1", - "webpack@>=5.0.0 <5.76.0": "5.76.0", - "tough-cookie@<4.1.3": "4.1.3", - "postcss@<8.4.31": ">=8.4.31", - "@babel/traverse@<7.23.2": ">=7.23.2", - "@adobe/css-tools@<4.3.2": ">=4.3.2", - "undici@<5.28.3": ">=5.28.3", - "ws@<8.17.1": ">=8.17.1" - }, - "lint-staged": { - "*.{ts,tsx}": [ - "eslint --fix", - "prettier --write" - ] + "type-fest": "^5.6.0", + "vite": "^8.0.9", + "vitest": "4.1.5" }, - "browserslist": { - "production": [ - ">0.2%", - "not dead", - "not op_mini all" - ], - "development": [ - "last 1 chrome version", - "last 1 firefox version", - "last 1 safari version" - ] + "engines": { + "node": ">=24.13.0" }, "msw": { "workerDirectory": "public" diff --git a/packages/graph-explorer/src/@types/react-table-config.d.ts b/packages/graph-explorer/src/@types/react-table-config.d.ts index 531caa693..539a6bc3c 100644 --- a/packages/graph-explorer/src/@types/react-table-config.d.ts +++ b/packages/graph-explorer/src/@types/react-table-config.d.ts @@ -133,7 +133,7 @@ declare module "react-table" { export interface Cell< D extends Record = Record, - // eslint-disable-next-line @typescript-eslint/no-unused-vars + // oxlint-disable-next-line @typescript-eslint/no-unused-vars V = any, > extends UseGroupByCellProps, UseRowStateCellProps {} diff --git a/packages/graph-explorer/src/components/Button/Button.test.tsx b/packages/graph-explorer/src/components/Button/Button.test.tsx index 535e43667..4ae598280 100644 --- a/packages/graph-explorer/src/components/Button/Button.test.tsx +++ b/packages/graph-explorer/src/components/Button/Button.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment happy-dom import type { PropsWithChildren } from "react"; import { render, screen } from "@testing-library/react"; diff --git a/packages/graph-explorer/src/components/Button/NavButton.test.tsx b/packages/graph-explorer/src/components/Button/NavButton.test.tsx index 680086e0c..140e86c03 100644 --- a/packages/graph-explorer/src/components/Button/NavButton.test.tsx +++ b/packages/graph-explorer/src/components/Button/NavButton.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment happy-dom import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { MemoryRouter, useLocation } from "react-router"; diff --git a/packages/graph-explorer/src/components/EmptyState.tsx b/packages/graph-explorer/src/components/EmptyState.tsx index 830bd2e8a..c4c1bef4d 100644 --- a/packages/graph-explorer/src/components/EmptyState.tsx +++ b/packages/graph-explorer/src/components/EmptyState.tsx @@ -69,6 +69,7 @@ EmptyStateContent.displayName = "EmptyStateContent"; function EmptyStateTitle({ className, + children, ...props }: React.ComponentPropsWithRef<"h3">) { return ( @@ -78,7 +79,9 @@ function EmptyStateTitle({ className, )} {...props} - /> + > + {children} + ); } EmptyStateTitle.displayName = "EmptyStateTitle"; diff --git a/packages/graph-explorer/src/components/FileButton.tsx b/packages/graph-explorer/src/components/FileButton.tsx index 5e9cf0744..498bbe373 100644 --- a/packages/graph-explorer/src/components/FileButton.tsx +++ b/packages/graph-explorer/src/components/FileButton.tsx @@ -51,7 +51,9 @@ export const FileButton = ({ const inputRef = React.useRef(null); const handleClick = () => { - !disabled && inputRef.current?.click(); + if (!disabled) { + inputRef.current?.click(); + } }; // Calls onChange with the selected file or files diff --git a/packages/graph-explorer/src/components/Graph/helpers/layout.ts b/packages/graph-explorer/src/components/Graph/helpers/layout.ts index 8cc66c743..9400f0f08 100755 --- a/packages/graph-explorer/src/components/Graph/helpers/layout.ts +++ b/packages/graph-explorer/src/components/Graph/helpers/layout.ts @@ -25,7 +25,7 @@ export const runLayout = ( ...additionalLayoutsConfig[layoutName], }; if (_layout) { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // oxlint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore: Animate is not a cytoscape.LayoutOption. Need to check _layout.animate = useAnimation; if (layoutName === "F_COSE") { diff --git a/packages/graph-explorer/src/components/Graph/hooks/useAddClickEvents.ts b/packages/graph-explorer/src/components/Graph/hooks/useAddClickEvents.ts index a7d536f16..dc5603e49 100755 --- a/packages/graph-explorer/src/components/Graph/hooks/useAddClickEvents.ts +++ b/packages/graph-explorer/src/components/Graph/hooks/useAddClickEvents.ts @@ -117,7 +117,9 @@ const useAddClickEvents = ({ return () => { // Clear the timeout if it exists - tappedTimeout && clearTimeout(tappedTimeout); + if (tappedTimeout) { + clearTimeout(tappedTimeout); + } cy.off("tap", handleDoubleTap); }; }, [cy]); @@ -245,9 +247,12 @@ const useAddClickEvents = ({ cy.on("mouseout", "edge", handleOnEdgeMouseOut); // Group events - onGroupClick && cy.on("tap", "node[?__isGroupNode]", handleOnGroupClick); - onGroupDoubleClick && + if (onGroupClick) { + cy.on("tap", "node[?__isGroupNode]", handleOnGroupClick); + } + if (onGroupDoubleClick) { cy.on("doubleTap", "node[?__isGroupNode]", handleOnGroupDoubleClick); + } return () => { // Graph events cy.off("tap", handleOnGraphClick); @@ -269,9 +274,12 @@ const useAddClickEvents = ({ cy.off("mouseout", "edge", handleOnGroupClick); // Group events - onGroupClick && cy.off("tap", "node[?__isGroupNode]", handleOnGroupClick); - onGroupDoubleClick && + if (onGroupClick) { + cy.off("tap", "node[?__isGroupNode]", handleOnGroupClick); + } + if (onGroupDoubleClick) { cy.off("doubleTap", "node[?__isGroupNode]", handleOnGroupDoubleClick); + } }; }, [ cy, diff --git a/packages/graph-explorer/src/components/Graph/hooks/useInitCytoscape.tsx b/packages/graph-explorer/src/components/Graph/hooks/useInitCytoscape.tsx index ecbb547e2..cf7395901 100644 --- a/packages/graph-explorer/src/components/Graph/hooks/useInitCytoscape.tsx +++ b/packages/graph-explorer/src/components/Graph/hooks/useInitCytoscape.tsx @@ -94,7 +94,7 @@ const useInitCytoscape = ({ }, 100); cy.on("pan", debouncedPan); - // eslint-disable-next-line react-hooks/set-state-in-effect + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: tracking external Cytoscape instance setCy(cy); return () => { @@ -102,6 +102,7 @@ const useInitCytoscape = ({ (cy as any).removeAllListeners(); cy.destroy(); cy.unmount(); + setCy(undefined); }; } diff --git a/packages/graph-explorer/src/components/Graph/hooks/useManageConfigChanges.ts b/packages/graph-explorer/src/components/Graph/hooks/useManageConfigChanges.ts index 301e82e89..1d99b82ac 100755 --- a/packages/graph-explorer/src/components/Graph/hooks/useManageConfigChanges.ts +++ b/packages/graph-explorer/src/components/Graph/hooks/useManageConfigChanges.ts @@ -64,7 +64,11 @@ export const useManageConfigChanges = (config: Config, cy?: CytoscapeType) => { useEffect(() => { if (cy) { //lock nodes instead of using cy.autolock(autolock) to allow layout to run on newly added nodes before locking - autolock ? cy.nodes().lock() : cy.nodes().unlock(); + if (autolock) { + cy.nodes().lock(); + } else { + cy.nodes().unlock(); + } } }, [autolock, cy]); diff --git a/packages/graph-explorer/src/components/Graph/hooks/useManageElementsSelection.ts b/packages/graph-explorer/src/components/Graph/hooks/useManageElementsSelection.ts index 1ca0a9392..96a7b75d8 100755 --- a/packages/graph-explorer/src/components/Graph/hooks/useManageElementsSelection.ts +++ b/packages/graph-explorer/src/components/Graph/hooks/useManageElementsSelection.ts @@ -145,7 +145,7 @@ export default function useManageElementsSelection( }, [selectedNodesIds, selectedEdgesIds, selectedGroupsIds]); // Init cytoscape Select and unselect event handlers - const handlers = useRef({ + const handlers = useRef({ onSelectedElementIdsChange, onSelectedEdgesIdsChange, onSelectedGroupsIdsChange, diff --git a/packages/graph-explorer/src/components/Graph/hooks/useManageStyles.ts b/packages/graph-explorer/src/components/Graph/hooks/useManageStyles.ts index e6e8b30be..d73c43094 100755 --- a/packages/graph-explorer/src/components/Graph/hooks/useManageStyles.ts +++ b/packages/graph-explorer/src/components/Graph/hooks/useManageStyles.ts @@ -29,16 +29,16 @@ export const getStyles = ({ const rootStyles: cytoscape.StylesheetStyle[] = []; if (!hideDefaultNodeLabels) { - rootStyles.push({ + rootStyles.push({ selector: "node[id]", style: { label: "data(id)" }, }); - rootStyles.push({ + rootStyles.push({ selector: "node[label]", style: { label: "data(label)" }, }); if (defaultNodeLabelAttribute) { - rootStyles.push({ + rootStyles.push({ selector: `node[${defaultNodeLabelAttribute}]`, style: { label: `data(${defaultNodeLabelAttribute})` }, }); @@ -46,21 +46,21 @@ export const getStyles = ({ } if (!hideDefaultEdgeLabels) { - rootStyles.push({ + rootStyles.push({ selector: "edge[id]", style: { label: "data(id)" }, }); - rootStyles.push({ + rootStyles.push({ selector: "edge[label]", style: { label: "data(label)" }, }); - rootStyles.push({ + rootStyles.push({ selector: "edge[type]", style: { label: "data(type)" }, }); if (defaultEdgeLabelAttribute) { - rootStyles.push({ + rootStyles.push({ selector: `edge[${defaultEdgeLabelAttribute}]`, style: { label: `data(${defaultEdgeLabelAttribute})` }, }); @@ -71,7 +71,7 @@ export const getStyles = ({ const stylesWithDefault = styles?.[selector] ? { ...style, ...styles?.[selector] } : style; - rootStyles.push({ + rootStyles.push({ selector, style: stylesWithDefault, }); @@ -86,7 +86,7 @@ export const getStyles = ({ } if (badgesEnabled === false) { - rootStyles.push({ + rootStyles.push({ selector: "node", style: { label: "", diff --git a/packages/graph-explorer/src/components/Graph/hooks/useRenderBadges.ts b/packages/graph-explorer/src/components/Graph/hooks/useRenderBadges.ts index 140ce73d6..622e4428d 100755 --- a/packages/graph-explorer/src/components/Graph/hooks/useRenderBadges.ts +++ b/packages/graph-explorer/src/components/Graph/hooks/useRenderBadges.ts @@ -74,7 +74,7 @@ const useRenderBadges = ({ useEffect(() => { if (cy && cy.cyCanvas) { try { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // oxlint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - zIndex is allowed layerRef.current = cy.cyCanvas({ zIndex: 20 }); canvasRef.current = layerRef.current.getCanvas() ?? null; diff --git a/packages/graph-explorer/src/components/Graph/hooks/useUpdateGraphElements.ts b/packages/graph-explorer/src/components/Graph/hooks/useUpdateGraphElements.ts index 3fb056658..f8f6f9964 100644 --- a/packages/graph-explorer/src/components/Graph/hooks/useUpdateGraphElements.ts +++ b/packages/graph-explorer/src/components/Graph/hooks/useUpdateGraphElements.ts @@ -45,7 +45,7 @@ const useUpdateGraphElements = ({ wereElementsAddedOrRemoved(cy.edges(), edges); if (structureChanged) { - // eslint-disable-next-line react-hooks/set-state-in-effect + // oxlint-disable-next-line react-compiler/set-state-in-effect setGraphStructureVersion(v => v + 1); } diff --git a/packages/graph-explorer/src/components/Graph/useGraphGlobalActions.ts b/packages/graph-explorer/src/components/Graph/useGraphGlobalActions.ts index ea6c5aff3..9982eca4b 100644 --- a/packages/graph-explorer/src/components/Graph/useGraphGlobalActions.ts +++ b/packages/graph-explorer/src/components/Graph/useGraphGlobalActions.ts @@ -39,7 +39,8 @@ export function useGraphGlobalActions() { }; } // graphRef is a reference that doesn't change but cytoscape can - //eslint-disable-next-line + // oxlint-disable-next-line react/exhaustive-deps + // oxlint-disable-next-line react-compiler/refs }, [graphRef?.current?.cytoscape]); const onFitSelectionToCanvas = () => { diff --git a/packages/graph-explorer/src/components/IconPicker.test.tsx b/packages/graph-explorer/src/components/IconPicker.test.tsx new file mode 100644 index 000000000..6babd1d37 --- /dev/null +++ b/packages/graph-explorer/src/components/IconPicker.test.tsx @@ -0,0 +1,118 @@ +// @vitest-environment happy-dom +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import { IconPicker } from "./IconPicker"; + +describe("IconPicker", () => { + it("should render Browse button", () => { + render(); + expect(screen.getByRole("button", { name: /browse/i })).toBeInTheDocument(); + }); + + it("should open popover with search input on click", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: /browse/i })); + + expect(screen.getByPlaceholderText("Search icons...")).toBeInTheDocument(); + }); + + it("should show icons in the grid", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: /browse/i })); + + // Wait for at least some icon buttons to appear in the grid + await waitFor(() => { + const iconButtons = screen + .getAllByRole("button") + .filter(btn => btn.title && btn.title !== ""); + expect(iconButtons.length).toBeGreaterThan(0); + }); + }); + + it("should filter icons when searching", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: /browse/i })); + const searchInput = screen.getByPlaceholderText("Search icons..."); + + await user.type(searchInput, "user"); + + await waitFor(() => { + const iconButtons = screen + .getAllByRole("button") + .filter(btn => btn.title && btn.title.includes("user")); + expect(iconButtons.length).toBeGreaterThan(0); + }); + }); + + it("should show no results message for invalid search", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: /browse/i })); + const searchInput = screen.getByPlaceholderText("Search icons..."); + + await user.type(searchInput, "zzzznotanicon"); + + expect(screen.getByText("No icons found")).toBeInTheDocument(); + }); + + it("should call onSelect with data URI when icon is clicked", async () => { + const user = userEvent.setup(); + const onSelect = vi.fn(); + render(); + + await user.click(screen.getByRole("button", { name: /browse/i })); + + // Wait for icons to load then click the first one + await waitFor(() => { + const iconButtons = screen + .getAllByRole("button") + .filter(btn => btn.title && btn.title !== ""); + expect(iconButtons.length).toBeGreaterThan(0); + }); + + const firstIcon = screen + .getAllByRole("button") + .filter(btn => btn.title && btn.title !== "")[0]; + await user.click(firstIcon); + + await waitFor(() => { + expect(onSelect).toHaveBeenCalledWith( + expect.stringMatching(/^data:image\/svg\+xml;base64,/), + "image/svg+xml", + ); + }); + }); + + it("should close popover after selecting an icon", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: /browse/i })); + + await waitFor(() => { + const iconButtons = screen + .getAllByRole("button") + .filter(btn => btn.title && btn.title !== ""); + expect(iconButtons.length).toBeGreaterThan(0); + }); + + const firstIcon = screen + .getAllByRole("button") + .filter(btn => btn.title && btn.title !== "")[0]; + await user.click(firstIcon); + + await waitFor(() => { + expect( + screen.queryByPlaceholderText("Search icons..."), + ).not.toBeInTheDocument(); + }); + }); +}); diff --git a/packages/graph-explorer/src/components/IconPicker.tsx b/packages/graph-explorer/src/components/IconPicker.tsx new file mode 100644 index 000000000..f8b6f852c --- /dev/null +++ b/packages/graph-explorer/src/components/IconPicker.tsx @@ -0,0 +1,135 @@ +import { SearchIcon } from "lucide-react"; +import dynamicIconImports from "lucide-react/dynamicIconImports"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { lucideIconToDataUri } from "@/utils/lucideIconUrl"; + +import { Button, Input, Popover, PopoverContent, PopoverTrigger } from "."; + +const allIconNames = Object.keys(dynamicIconImports).sort(); + +const MAX_VISIBLE = 50; + +export function IconPicker({ + onSelect, +}: { + onSelect: (iconUrl: string, iconImageType: string) => void; +}) { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + const inputRef = useRef(null); + + const filtered = useMemo(() => { + if (!search) return allIconNames.slice(0, MAX_VISIBLE); + const lower = search.toLowerCase(); + const results: string[] = []; + for (const name of allIconNames) { + if (name.includes(lower)) { + results.push(name); + if (results.length >= MAX_VISIBLE) break; + } + } + return results; + }, [search]); + + const handleSelect = useCallback( + async (iconName: string) => { + const dataUri = await lucideIconToDataUri(iconName); + if (dataUri) { + onSelect(dataUri, "image/svg+xml"); + setOpen(false); + setSearch(""); + } + }, + [onSelect], + ); + + // Focus search input when popover opens + useEffect(() => { + if (open) { + // Small delay to allow popover animation + const timer = setTimeout(() => inputRef.current?.focus(), 100); + return () => clearTimeout(timer); + } + }, [open]); + + return ( + + + + + + setSearch(e.target.value)} + className="h-8 text-sm" + /> +
+ {filtered.map(name => ( + + ))} + {filtered.length === 0 && ( +

+ No icons found +

+ )} +
+ {!search && ( +

+ Showing {MAX_VISIBLE} of {allIconNames.length} icons. Type to + search. +

+ )} +
+
+ ); +} + +function IconButton({ + name, + onSelect, +}: { + name: string; + onSelect: (name: string) => void; +}) { + const [src, setSrc] = useState(null); + + useEffect(() => { + let cancelled = false; + lucideIconToDataUri(name).then( + uri => { + if (!cancelled && uri) setSrc(uri); + }, + () => { + // Icon failed to load, leave as placeholder + }, + ); + return () => { + cancelled = true; + }; + }, [name]); + + return ( + + ); +} diff --git a/packages/graph-explorer/src/components/Label.tsx b/packages/graph-explorer/src/components/Label.tsx index c3ac1c7ad..b7c50f114 100644 --- a/packages/graph-explorer/src/components/Label.tsx +++ b/packages/graph-explorer/src/components/Label.tsx @@ -12,7 +12,7 @@ export function Label({ @@ -82,12 +85,32 @@ function ErrorDetailsButton({ error }: { error: unknown }) { {errorName} - - -
- {errorMessage} -
-
+ {errorMessage ? ( + + +
+ {errorMessage} +
+
+ ) : null} + {errorData ? ( + + +
+ +
+
+ ) : null} diff --git a/packages/graph-explorer/src/components/RouteButton.test.tsx b/packages/graph-explorer/src/components/RouteButton.test.tsx index 61a3a1282..7f7ca08b3 100644 --- a/packages/graph-explorer/src/components/RouteButton.test.tsx +++ b/packages/graph-explorer/src/components/RouteButton.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment happy-dom import type { PropsWithChildren } from "react"; import { render, screen, within } from "@testing-library/react"; diff --git a/packages/graph-explorer/src/components/SchemaDiscoveryBoundary.test.tsx b/packages/graph-explorer/src/components/SchemaDiscoveryBoundary.test.tsx index 8b3f40b4f..f246d6267 100644 --- a/packages/graph-explorer/src/components/SchemaDiscoveryBoundary.test.tsx +++ b/packages/graph-explorer/src/components/SchemaDiscoveryBoundary.test.tsx @@ -1,4 +1,6 @@ +// @vitest-environment happy-dom import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router"; import { describe, expect, test, vi } from "vitest"; import { SchemaDiscoveryBoundary } from "./SchemaDiscoveryBoundary"; @@ -9,6 +11,7 @@ vi.mock("@/core", async () => { ...actual, useHasActiveSchema: vi.fn(), useMaybeActiveSchema: vi.fn(), + useConfiguration: vi.fn(), }; }); @@ -25,10 +28,15 @@ vi.mock("@/hooks", async () => { }; }); -import { useHasActiveSchema, useMaybeActiveSchema } from "@/core"; +import { + useConfiguration, + useHasActiveSchema, + useMaybeActiveSchema, +} from "@/core"; import { useSchemaSync } from "@/hooks/useSchemaSync"; import { createRandomEdgeConnection } from "@/utils/testing/randomData"; +const mockedUseConfiguration = vi.mocked(useConfiguration); const mockedUseHasActiveSchema = vi.mocked(useHasActiveSchema); const mockedUseMaybeActiveSchema = vi.mocked(useMaybeActiveSchema); const mockedUseSchemaSync = vi.mocked(useSchemaSync); @@ -57,11 +65,16 @@ function createMockSchemaSync( function mockSchema({ hasSchema = false, + hasConnection = true, edgeConnections, }: { hasSchema?: boolean; + hasConnection?: boolean; edgeConnections?: ReturnType[]; } = {}) { + mockedUseConfiguration.mockReturnValue( + hasConnection ? ({} as ReturnType) : undefined, + ); mockedUseHasActiveSchema.mockReturnValue(hasSchema); mockedUseMaybeActiveSchema.mockReturnValue( hasSchema ? { vertices: [], edges: [], edgeConnections } : undefined, @@ -69,18 +82,47 @@ function mockSchema({ } describe("SchemaDiscoveryBoundary", () => { + function renderBoundary(props: { requireEdgeConnections?: boolean } = {}) { + return render( + + +
Children
+
+
, + ); + } + + describe("no active connection", () => { + test("renders no-connection state when no connection is configured", () => { + mockSchema({ hasConnection: false }); + mockedUseSchemaSync.mockReturnValue(createMockSchemaSync()); + + renderBoundary(); + + expect(screen.getByText("No Connection")).toBeInTheDocument(); + expect(screen.queryByText("No Schema Available")).not.toBeInTheDocument(); + expect(screen.queryByText("Children")).not.toBeInTheDocument(); + }); + + test("renders no-connection state even with requireEdgeConnections", () => { + mockSchema({ hasConnection: false }); + mockedUseSchemaSync.mockReturnValue(createMockSchemaSync()); + + renderBoundary({ requireEdgeConnections: true }); + + expect(screen.getByText("No Connection")).toBeInTheDocument(); + expect(screen.queryByText("Children")).not.toBeInTheDocument(); + }); + }); + describe("schema only (default)", () => { test("renders children when schema is available", () => { mockSchema({ hasSchema: true }); mockedUseSchemaSync.mockReturnValue(createMockSchemaSync()); - render( - -
Schema content
-
, - ); + renderBoundary(); - expect(screen.getByText("Schema content")).toBeInTheDocument(); + expect(screen.getByText("Children")).toBeInTheDocument(); }); test("renders loading state when schema is syncing", () => { @@ -89,14 +131,10 @@ describe("SchemaDiscoveryBoundary", () => { createMockSchemaSync({ isFetching: true }), ); - render( - -
Schema content
-
, - ); + renderBoundary(); expect(screen.getByText("Synchronizing...")).toBeInTheDocument(); - expect(screen.queryByText("Schema content")).not.toBeInTheDocument(); + expect(screen.queryByText("Children")).not.toBeInTheDocument(); }); test("renders loading state when isFetching with existing schema", () => { @@ -105,14 +143,10 @@ describe("SchemaDiscoveryBoundary", () => { createMockSchemaSync({ isFetching: true }), ); - render( - -
Schema content
-
, - ); + renderBoundary(); expect(screen.getByText("Synchronizing...")).toBeInTheDocument(); - expect(screen.queryByText("Schema content")).not.toBeInTheDocument(); + expect(screen.queryByText("Children")).not.toBeInTheDocument(); }); test("renders error state when schema sync fails", () => { @@ -129,27 +163,19 @@ describe("SchemaDiscoveryBoundary", () => { }), ); - render( - -
Schema content
-
, - ); + renderBoundary(); - expect(screen.queryByText("Schema content")).not.toBeInTheDocument(); + expect(screen.queryByText("Children")).not.toBeInTheDocument(); }); test("renders no-schema state when no schema exists", () => { mockSchema(); mockedUseSchemaSync.mockReturnValue(createMockSchemaSync()); - render( - -
Schema content
-
, - ); + renderBoundary(); expect(screen.getByText("No Schema Available")).toBeInTheDocument(); - expect(screen.queryByText("Schema content")).not.toBeInTheDocument(); + expect(screen.queryByText("Children")).not.toBeInTheDocument(); }); }); @@ -161,11 +187,7 @@ describe("SchemaDiscoveryBoundary", () => { }); mockedUseSchemaSync.mockReturnValue(createMockSchemaSync()); - render( - -
Children
-
, - ); + renderBoundary({ requireEdgeConnections: true }); expect(screen.getByText("Children")).toBeInTheDocument(); }); @@ -187,11 +209,7 @@ describe("SchemaDiscoveryBoundary", () => { }), ); - render( - -
Children
-
, - ); + renderBoundary({ requireEdgeConnections: true }); expect(screen.getByText("Children")).toBeInTheDocument(); }); @@ -202,11 +220,7 @@ describe("SchemaDiscoveryBoundary", () => { createMockSchemaSync({ isFetching: true }), ); - render( - -
Children
-
, - ); + renderBoundary({ requireEdgeConnections: true }); expect(screen.getByText("Synchronizing...")).toBeInTheDocument(); expect(screen.queryByText("Children")).not.toBeInTheDocument(); @@ -226,11 +240,7 @@ describe("SchemaDiscoveryBoundary", () => { }), ); - render( - -
Children
-
, - ); + renderBoundary({ requireEdgeConnections: true }); expect(screen.queryByText("Children")).not.toBeInTheDocument(); expect( @@ -252,11 +262,7 @@ describe("SchemaDiscoveryBoundary", () => { }), ); - render( - -
Children
-
, - ); + renderBoundary({ requireEdgeConnections: true }); expect(screen.queryByText("Children")).not.toBeInTheDocument(); expect( @@ -268,11 +274,7 @@ describe("SchemaDiscoveryBoundary", () => { mockSchema({ hasSchema: true, edgeConnections: undefined }); mockedUseSchemaSync.mockReturnValue(createMockSchemaSync()); - render( - -
Children
-
, - ); + renderBoundary({ requireEdgeConnections: true }); expect( screen.getByText("No edge-connections Available"), @@ -284,11 +286,7 @@ describe("SchemaDiscoveryBoundary", () => { mockSchema(); mockedUseSchemaSync.mockReturnValue(createMockSchemaSync()); - render( - -
Children
-
, - ); + renderBoundary({ requireEdgeConnections: true }); expect(screen.getByText("No Schema Available")).toBeInTheDocument(); expect(screen.queryByText("Children")).not.toBeInTheDocument(); diff --git a/packages/graph-explorer/src/components/SchemaDiscoveryBoundary.tsx b/packages/graph-explorer/src/components/SchemaDiscoveryBoundary.tsx index 4bee87917..655b9a539 100644 --- a/packages/graph-explorer/src/components/SchemaDiscoveryBoundary.tsx +++ b/packages/graph-explorer/src/components/SchemaDiscoveryBoundary.tsx @@ -1,6 +1,15 @@ import type { PropsWithChildren } from "react"; +import { ArrowRightIcon, DatabaseIcon } from "lucide-react"; + import { + EmptyState, + EmptyStateActions, + EmptyStateContent, + EmptyStateDescription, + EmptyStateIcon, + EmptyStateTitle, + NavButton, Panel, PanelContent, PanelEmptyState, @@ -10,7 +19,11 @@ import { PanelTitle, SyncIcon, } from "@/components"; -import { useHasActiveSchema, useMaybeActiveSchema } from "@/core"; +import { + useConfiguration, + useHasActiveSchema, + useMaybeActiveSchema, +} from "@/core"; import { useTranslations } from "@/hooks"; import { useCancelSchemaSync, useSchemaSync } from "@/hooks/useSchemaSync"; @@ -28,6 +41,7 @@ export function SchemaDiscoveryBoundary({ children, requireEdgeConnections = false, }: SchemaDiscoveryBoundaryProps) { + const config = useConfiguration(); const { schemaDiscoveryQuery, edgeDiscoveryQuery, @@ -39,6 +53,30 @@ export function SchemaDiscoveryBoundary({ const cancel = useCancelSchemaSync(); const t = useTranslations(); + // 0. If no connection is configured, show no-connection state + if (!config) { + return ( + + + + + + + No Connection + + Add a connection to start exploring your graph data. + + + + Go to Connections + + + + + + ); + } + // 1. If loading/fetching, show loading state if (isFetching) { return ( diff --git a/packages/graph-explorer/src/components/SelectField.tsx b/packages/graph-explorer/src/components/SelectField.tsx index 5ad79bc03..d161a645a 100644 --- a/packages/graph-explorer/src/components/SelectField.tsx +++ b/packages/graph-explorer/src/components/SelectField.tsx @@ -37,7 +37,7 @@ export type SelectFieldProps = { ComponentPropsWithRef; function SelectField({ - options = [], + options, value, onValueChange, label, diff --git a/packages/graph-explorer/src/components/SettingsSection.tsx b/packages/graph-explorer/src/components/SettingsSection.tsx index a80234a52..09fd42cc9 100644 --- a/packages/graph-explorer/src/components/SettingsSection.tsx +++ b/packages/graph-explorer/src/components/SettingsSection.tsx @@ -30,6 +30,7 @@ export function SettingsSectionHeader({ export function SettingsSectionTitle({ className, + children, ...props }: ComponentProps<"h2">) { return ( @@ -39,7 +40,9 @@ export function SettingsSectionTitle({ className, )} {...props} - /> + > + {children} + ); } diff --git a/packages/graph-explorer/src/components/Tabular/Tabular.tsx b/packages/graph-explorer/src/components/Tabular/Tabular.tsx index 7ad4df1e5..3ac63ee92 100644 --- a/packages/graph-explorer/src/components/Tabular/Tabular.tsx +++ b/packages/graph-explorer/src/components/Tabular/Tabular.tsx @@ -148,12 +148,13 @@ const TabularContent = ({ useEffect(() => { if (!headerControlsRef.current || headerControlsPosition !== "sticky") { - // eslint-disable-next-line react-hooks/set-state-in-effect + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: DOM measurement requires setState in effect setStickyHeaderTop(0); return; } const { height } = headerControlsRef.current.getBoundingClientRect(); + setStickyHeaderTop(height); // headerControlsChildren can affect to the container's height }, [headerControlsRef, headerControlsPosition]); diff --git a/packages/graph-explorer/src/components/Tabular/TabularRow.tsx b/packages/graph-explorer/src/components/Tabular/TabularRow.tsx index 5299e7d43..15b76d319 100644 --- a/packages/graph-explorer/src/components/Tabular/TabularRow.tsx +++ b/packages/graph-explorer/src/components/Tabular/TabularRow.tsx @@ -25,7 +25,7 @@ const TabularRow = ({ const [selectable, setSelectable] = useState(true); useEffect(() => { if (tableInstance.state.columnResizing.isResizingColumn) { - // eslint-disable-next-line react-hooks/set-state-in-effect + // oxlint-disable-next-line react-compiler/set-state-in-effect setSelectable(false); return; } @@ -35,7 +35,9 @@ const TabularRow = ({ }, 100); return () => { - timeout && clearTimeout(timeout); + if (timeout) { + clearTimeout(timeout); + } }; }, [tableInstance.state.columnResizing.isResizingColumn]); diff --git a/packages/graph-explorer/src/components/Tabular/filters/TextFilter.tsx b/packages/graph-explorer/src/components/Tabular/filters/TextFilter.tsx index b216ee048..00f6ab33d 100644 --- a/packages/graph-explorer/src/components/Tabular/filters/TextFilter.tsx +++ b/packages/graph-explorer/src/components/Tabular/filters/TextFilter.tsx @@ -28,9 +28,11 @@ export function TextFilter({ onChange={e => { // do not use value || undefined because // if the user types zero, it clears the filter too - e.target.value !== "" && e.target.value !== undefined - ? column.setFilter(e.target.value) - : column.setFilter(undefined); + if (e.target.value !== "" && e.target.value !== undefined) { + column.setFilter(e.target.value); + } else { + column.setFilter(undefined); + } }} />
diff --git a/packages/graph-explorer/src/components/Tabular/useTabular.ts b/packages/graph-explorer/src/components/Tabular/useTabular.ts index 9fee5a1dc..7363fe2be 100644 --- a/packages/graph-explorer/src/components/Tabular/useTabular.ts +++ b/packages/graph-explorer/src/components/Tabular/useTabular.ts @@ -326,6 +326,7 @@ export const useTabular = (options: TabularOptions) => { // Avoid table to delete filter on re-render useEffect(() => { skipPageResetRef.current = true; + // oxlint-disable-next-line react-compiler/set-state-in-effect -- react-table v7 pattern to prevent auto-reset on data changes setUpdatedData(data); }, [data]); @@ -373,16 +374,16 @@ export const useTabular = (options: TabularOptions) => { // this reducer fixes getToggleAllRowsSelectedProps bad behaviour. // When the object contains { [rowId]: false } it marks the Checkbox as indeterminate if (selectedRowIds?.[rowId]) { - ids[rowId as IdType] = true; + ids[rowId] = true; } return ids; }, - {} as UseRowSelectState["selectedRowIds"], + {} as Record, ), }), // selectedRowIds is necessary in deps to listen external updates - // eslint-disable-next-line react-hooks/exhaustive-deps + // oxlint-disable-next-line react/exhaustive-deps [tableState, selectedRowIds], ); }; @@ -423,6 +424,7 @@ export const useTabular = (options: TabularOptions) => { }, stateReducer, useControlledState, + /* oxlint-disable react-compiler/refs -- react-table v7 requires reading refs during render to control auto-reset */ autoResetPage: !skipPageResetRef.current, autoResetExpanded: !skipPageResetRef.current, autoResetGroupBy: !skipPageResetRef.current, diff --git a/packages/graph-explorer/src/components/Typography.tsx b/packages/graph-explorer/src/components/Typography.tsx index c51feaeef..64d0620f8 100644 --- a/packages/graph-explorer/src/components/Typography.tsx +++ b/packages/graph-explorer/src/components/Typography.tsx @@ -6,7 +6,11 @@ import { cn } from "@/utils"; import { Alert, AlertDescription, AlertTitle } from "./Alert"; -export function PageHeading({ className, ...props }: ComponentProps<"h1">) { +export function PageHeading({ + className, + children, + ...props +}: ComponentProps<"h1">) { return (

) { className, )} {...props} - /> + > + {children} +

); } diff --git a/packages/graph-explorer/src/components/VertexIcon.tsx b/packages/graph-explorer/src/components/VertexIcon.tsx index 1b8abb429..8ad4122e2 100644 --- a/packages/graph-explorer/src/components/VertexIcon.tsx +++ b/packages/graph-explorer/src/components/VertexIcon.tsx @@ -14,15 +14,19 @@ import { SearchResultSymbol } from "./SearchResult"; interface Props { vertexStyle: VertexPreferences; className?: string; + alt?: string; } -function VertexIcon({ vertexStyle, className }: Props) { +function VertexIcon({ vertexStyle, className, alt }: Props) { + const altText = alt ?? `${vertexStyle.displayLabel ?? vertexStyle.type} icon`; + if (vertexStyle.iconImageType === "image/svg+xml") { return ( ); } @@ -30,6 +34,7 @@ function VertexIcon({ vertexStyle, className }: Props) { return ( {altText} diff --git a/packages/graph-explorer/src/components/index.ts b/packages/graph-explorer/src/components/index.ts index 2bc9878eb..4343b7aec 100644 --- a/packages/graph-explorer/src/components/index.ts +++ b/packages/graph-explorer/src/components/index.ts @@ -36,6 +36,7 @@ export * from "./Form"; export * from "./numberFormat"; export * from "./icons"; +export * from "./IconPicker"; export * from "./Input"; export { default as InputField } from "./InputField"; diff --git a/packages/graph-explorer/src/connector/LoggerConnector.test.ts b/packages/graph-explorer/src/connector/LoggerConnector.test.ts new file mode 100644 index 000000000..b0fc46e6d --- /dev/null +++ b/packages/graph-explorer/src/connector/LoggerConnector.test.ts @@ -0,0 +1,67 @@ +import { + ClientLoggerConnector, + ServerLoggerConnector, +} from "./LoggerConnector"; + +describe("ClientLoggerConnector", () => { + test("should call logger methods without throwing", () => { + const connector = new ClientLoggerConnector(); + expect(() => connector.error("test error")).not.toThrow(); + expect(() => connector.warn("test warn")).not.toThrow(); + expect(() => connector.info("test info")).not.toThrow(); + expect(() => connector.debug("test debug")).not.toThrow(); + expect(() => connector.trace("test trace")).not.toThrow(); + }); +}); + +describe("ServerLoggerConnector", () => { + test("should send logs to the server", () => { + const mockFetch = vi.fn().mockResolvedValue({}); + vi.stubGlobal("fetch", mockFetch); + + const connector = new ServerLoggerConnector("https://example.com/"); + + connector.error("error msg"); + expect(mockFetch).toHaveBeenCalledWith("https://example.com/logger", { + method: "POST", + headers: { level: "error", message: JSON.stringify("error msg") }, + }); + + connector.warn("warn msg"); + expect(mockFetch).toHaveBeenCalledWith("https://example.com/logger", { + method: "POST", + headers: { level: "warn", message: JSON.stringify("warn msg") }, + }); + + connector.info("info msg"); + expect(mockFetch).toHaveBeenCalledWith("https://example.com/logger", { + method: "POST", + headers: { level: "info", message: JSON.stringify("info msg") }, + }); + + connector.debug("debug msg"); + expect(mockFetch).toHaveBeenCalledWith("https://example.com/logger", { + method: "POST", + headers: { level: "debug", message: JSON.stringify("debug msg") }, + }); + + connector.trace("trace msg"); + expect(mockFetch).toHaveBeenCalledWith("https://example.com/logger", { + method: "POST", + headers: { level: "trace", message: JSON.stringify("trace msg") }, + }); + }); + + test("should strip trailing slash from connection URL", () => { + const mockFetch = vi.fn().mockResolvedValue({}); + vi.stubGlobal("fetch", mockFetch); + + const connector = new ServerLoggerConnector("https://example.com/"); + connector.info("test"); + + expect(mockFetch).toHaveBeenCalledWith( + "https://example.com/logger", + expect.any(Object), + ); + }); +}); diff --git a/packages/graph-explorer/src/connector/emptyExplorer.test.ts b/packages/graph-explorer/src/connector/emptyExplorer.test.ts new file mode 100644 index 000000000..5efbfd95b --- /dev/null +++ b/packages/graph-explorer/src/connector/emptyExplorer.test.ts @@ -0,0 +1,38 @@ +import { emptyExplorer } from "./emptyExplorer"; + +const req = {} as any; + +describe("emptyExplorer", () => { + test("should return empty results for all methods", async () => { + expect(await emptyExplorer.fetchSchema()).toStrictEqual({ + totalVertices: 0, + vertices: [], + totalEdges: 0, + edges: [], + }); + expect(await emptyExplorer.fetchVertexCountsByType(req)).toStrictEqual({ + total: 0, + }); + expect(await emptyExplorer.fetchNeighbors(req)).toStrictEqual({ + vertices: [], + edges: [], + }); + expect(await emptyExplorer.neighborCounts(req)).toStrictEqual({ + counts: [], + }); + expect(await emptyExplorer.keywordSearch(req)).toStrictEqual({ + vertices: [], + }); + expect(await emptyExplorer.vertexDetails(req)).toStrictEqual({ + vertices: [], + }); + expect(await emptyExplorer.edgeDetails(req)).toStrictEqual({ edges: [] }); + expect(await emptyExplorer.rawQuery(req)).toStrictEqual({ + results: [], + rawResponse: null, + }); + expect(await emptyExplorer.fetchEdgeConnections(req)).toStrictEqual({ + edgeConnections: [], + }); + }); +}); diff --git a/packages/graph-explorer/src/connector/emptyExplorer.ts b/packages/graph-explorer/src/connector/emptyExplorer.ts index 423b975f5..fd4a28220 100644 --- a/packages/graph-explorer/src/connector/emptyExplorer.ts +++ b/packages/graph-explorer/src/connector/emptyExplorer.ts @@ -1,4 +1,4 @@ -/* eslint-disable @typescript-eslint/require-await */ +/* oxlint-disable @typescript-eslint/require-await */ import type { Explorer } from "./useGEFetchTypes"; /** diff --git a/packages/graph-explorer/src/connector/entities/scalar.test.ts b/packages/graph-explorer/src/connector/entities/scalar.test.ts index 07ec136c9..673bc564d 100644 --- a/packages/graph-explorer/src/connector/entities/scalar.test.ts +++ b/packages/graph-explorer/src/connector/entities/scalar.test.ts @@ -191,27 +191,27 @@ describe("scalar", () => { expect(getDisplayValueForScalar(1234567.0123456789)).toBe( "1,234,567.0123", ); - // eslint-disable-next-line no-loss-of-precision + // oxlint-disable-next-line no-loss-of-precision expect(getDisplayValueForScalar(12345678.00123456789)).toBe( "12,345,678.0012", ); - // eslint-disable-next-line no-loss-of-precision + // oxlint-disable-next-line no-loss-of-precision expect(getDisplayValueForScalar(123456789.000123456789)).toBe( "123,456,789.0001", ); - // eslint-disable-next-line no-loss-of-precision + // oxlint-disable-next-line no-loss-of-precision expect(getDisplayValueForScalar(1234567890.0000123456789)).toBe( "1.235E9", ); - // eslint-disable-next-line no-loss-of-precision + // oxlint-disable-next-line no-loss-of-precision expect(getDisplayValueForScalar(12345678901.00000123456789)).toBe( "1.235E10", ); - // eslint-disable-next-line no-loss-of-precision + // oxlint-disable-next-line no-loss-of-precision expect(getDisplayValueForScalar(123456789012.000000123456789)).toBe( "1.235E11", ); - // eslint-disable-next-line no-loss-of-precision + // oxlint-disable-next-line no-loss-of-precision expect(getDisplayValueForScalar(1234567890123.0000000123456789)).toBe( "1.235E12", ); diff --git a/packages/graph-explorer/src/connector/fetchDatabaseRequest.test.ts b/packages/graph-explorer/src/connector/fetchDatabaseRequest.test.ts new file mode 100644 index 000000000..4a73383aa --- /dev/null +++ b/packages/graph-explorer/src/connector/fetchDatabaseRequest.test.ts @@ -0,0 +1,495 @@ +import type { FeatureFlags, NormalizedConnection } from "@/core"; + +import { logger, NetworkError, ServerConnectionError } from "@/utils"; + +import { fetchDatabaseRequest } from "./fetchDatabaseRequest"; + +function createConnection( + overrides?: Partial, +): NormalizedConnection { + return { + url: "http://localhost:8182", + queryEngine: "gremlin", + graphDbUrl: "", + proxyConnection: false, + awsAuthEnabled: false, + ...overrides, + }; +} + +function createFeatureFlags(overrides?: Partial): FeatureFlags { + return { + showDebugActions: false, + allowLoggingDbQuery: false, + ...overrides, + }; +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +function textResponse(body: string, status: number): Response { + return new Response(body, { + status, + headers: { "Content-Type": "text/plain" }, + }); +} + +function emptyResponse(status: number): Response { + return new Response("", { status, headers: {} }); +} + +describe("fetchDatabaseRequest", () => { + let mockFetch: ReturnType; + + beforeEach(() => { + mockFetch = vi.fn(); + vi.stubGlobal("fetch", mockFetch); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + const connection = createConnection(); + const featureFlags = createFeatureFlags(); + + describe("successful responses", () => { + it("returns parsed JSON on success", async () => { + mockFetch.mockResolvedValue(jsonResponse({ result: "ok" })); + + const data = await fetchDatabaseRequest( + connection, + featureFlags, + "http://localhost:8182/query", + { method: "POST" }, + ); + + expect(data).toStrictEqual({ result: "ok" }); + }); + + it("passes the URI and method through to fetch", async () => { + mockFetch.mockResolvedValue(jsonResponse({})); + + await fetchDatabaseRequest( + connection, + featureFlags, + "http://localhost:8182/sparql", + { method: "GET" }, + ); + + expect(mockFetch).toHaveBeenCalledWith( + "http://localhost:8182/sparql", + expect.objectContaining({ method: "GET" }), + ); + }); + + it("passes the request body through to fetch", async () => { + mockFetch.mockResolvedValue(jsonResponse({})); + + await fetchDatabaseRequest(connection, featureFlags, "/query", { + method: "POST", + body: "g.V().limit(10)", + }); + + expect(mockFetch).toHaveBeenCalledWith( + "/query", + expect.objectContaining({ body: "g.V().limit(10)" }), + ); + }); + }); + + describe("header construction", () => { + it("sets proxy headers when proxyConnection is true", async () => { + mockFetch.mockResolvedValue(jsonResponse({})); + const conn = createConnection({ + proxyConnection: true, + graphDbUrl: "https://my-neptune:8182", + }); + + await fetchDatabaseRequest(conn, featureFlags, "/query", { + method: "POST", + }); + + const headers = mockFetch.mock.calls[0][1].headers; + expect(headers["graph-db-connection-url"]).toBe( + "https://my-neptune:8182", + ); + expect(headers["db-query-logging-enabled"]).toBe("false"); + }); + + it("sets db-query-logging-enabled based on allowLoggingDbQuery", async () => { + mockFetch.mockResolvedValue(jsonResponse({})); + const conn = createConnection({ proxyConnection: true }); + const flags = createFeatureFlags({ allowLoggingDbQuery: true }); + + await fetchDatabaseRequest(conn, flags, "/query", { method: "POST" }); + + const headers = mockFetch.mock.calls[0][1].headers; + expect(headers["db-query-logging-enabled"]).toBe("true"); + }); + + it("sets AWS headers when awsAuthEnabled is true", async () => { + mockFetch.mockResolvedValue(jsonResponse({})); + const conn = createConnection({ + awsAuthEnabled: true, + awsRegion: "us-west-2", + serviceType: "neptune-graph", + }); + + await fetchDatabaseRequest(conn, featureFlags, "/query", { + method: "POST", + }); + + const headers = mockFetch.mock.calls[0][1].headers; + expect(headers["aws-neptune-region"]).toBe("us-west-2"); + expect(headers["service-type"]).toBe("neptune-graph"); + }); + + it("defaults serviceType to neptune-db", async () => { + mockFetch.mockResolvedValue(jsonResponse({})); + const conn = createConnection({ awsAuthEnabled: true }); + + await fetchDatabaseRequest(conn, featureFlags, "/query", { + method: "POST", + }); + + const headers = mockFetch.mock.calls[0][1].headers; + expect(headers["service-type"]).toBe("neptune-db"); + }); + + it("does not set proxy or AWS headers when both are disabled", async () => { + mockFetch.mockResolvedValue(jsonResponse({})); + + await fetchDatabaseRequest(connection, featureFlags, "/query", { + method: "POST", + }); + + const headers = mockFetch.mock.calls[0][1].headers; + expect(headers).not.toHaveProperty("graph-db-connection-url"); + expect(headers).not.toHaveProperty("aws-neptune-region"); + expect(headers).not.toHaveProperty("service-type"); + }); + + it("merges caller-provided headers with auth headers", async () => { + mockFetch.mockResolvedValue(jsonResponse({})); + const conn = createConnection({ proxyConnection: true }); + + await fetchDatabaseRequest(conn, featureFlags, "/query", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + }); + + const headers = mockFetch.mock.calls[0][1].headers; + expect(headers["content-type"]).toBe("application/x-www-form-urlencoded"); + expect(headers["graph-db-connection-url"]).toBeDefined(); + }); + }); + + describe("timeout and abort signals", () => { + it("does not set a signal when no timeout or abort signal is provided", async () => { + mockFetch.mockResolvedValue(jsonResponse({})); + + await fetchDatabaseRequest(connection, featureFlags, "/query", { + method: "POST", + }); + + const signal = mockFetch.mock.calls[0][1].signal; + expect(signal).toBeUndefined(); + }); + + it("passes through the caller's abort signal", async () => { + mockFetch.mockResolvedValue(jsonResponse({})); + const controller = new AbortController(); + + await fetchDatabaseRequest(connection, featureFlags, "/query", { + method: "POST", + signal: controller.signal, + }); + + const signal = mockFetch.mock.calls[0][1].signal; + expect(signal).toBeDefined(); + expect(signal!.aborted).toBe(false); + }); + + it("creates a signal when fetchTimeoutMs is set", async () => { + mockFetch.mockResolvedValue(jsonResponse({})); + const conn = createConnection({ fetchTimeoutMs: 5000 }); + + await fetchDatabaseRequest(conn, featureFlags, "/query", { + method: "POST", + }); + + const signal = mockFetch.mock.calls[0][1].signal; + expect(signal).toBeDefined(); + }); + + it("does not create a timeout signal when fetchTimeoutMs is 0", async () => { + mockFetch.mockResolvedValue(jsonResponse({})); + const conn = createConnection({ fetchTimeoutMs: 0 }); + + await fetchDatabaseRequest(conn, featureFlags, "/query", { + method: "POST", + }); + + const signal = mockFetch.mock.calls[0][1].signal; + expect(signal).toBeUndefined(); + }); + + it("does not create a timeout signal when fetchTimeoutMs is negative", async () => { + mockFetch.mockResolvedValue(jsonResponse({})); + const conn = createConnection({ fetchTimeoutMs: -1 }); + + await fetchDatabaseRequest(conn, featureFlags, "/query", { + method: "POST", + }); + + const signal = mockFetch.mock.calls[0][1].signal; + expect(signal).toBeUndefined(); + }); + }); + + describe("error responses", () => { + it("throws NetworkError with message from JSON body", async () => { + mockFetch.mockResolvedValue( + jsonResponse({ message: "Query timed out" }, 500), + ); + + await expect( + fetchDatabaseRequest(connection, featureFlags, "/query", { + method: "POST", + }), + ).rejects.toThrow( + expect.objectContaining({ + message: "Query timed out", + statusCode: 500, + }), + ); + }); + + it("throws NetworkError with detailedMessage when present", async () => { + mockFetch.mockResolvedValue( + jsonResponse( + { + code: "MalformedQueryException", + detailedMessage: "Syntax error at line 1", + message: "Bad request", + }, + 400, + ), + ); + + await expect( + fetchDatabaseRequest(connection, featureFlags, "/query", { + method: "POST", + }), + ).rejects.toThrow( + expect.objectContaining({ + message: "Syntax error at line 1", + }), + ); + }); + + it("throws NetworkError with default message when body has no extractable message", async () => { + mockFetch.mockResolvedValue(jsonResponse({ code: "ERR_UNKNOWN" }, 500)); + + await expect( + fetchDatabaseRequest(connection, featureFlags, "/query", { + method: "POST", + }), + ).rejects.toThrow( + expect.objectContaining({ + message: "Network response was not OK", + }), + ); + }); + + it("includes the decoded error body as data on NetworkError", async () => { + const errorBody = { + code: "MalformedQueryException", + requestId: "abc-123", + detailedMessage: "Syntax error", + }; + mockFetch.mockResolvedValue(jsonResponse(errorBody, 400)); + + const error = await fetchDatabaseRequest( + connection, + featureFlags, + "/query", + { method: "POST" }, + ).catch(e => e); + + expect(error).toBeInstanceOf(NetworkError); + expect(error.data).toStrictEqual(errorBody); + }); + + it("preserves the HTTP status code", async () => { + mockFetch.mockResolvedValue(jsonResponse({ message: "nope" }, 403)); + + const error = await fetchDatabaseRequest( + connection, + featureFlags, + "/query", + { method: "POST" }, + ).catch(e => e); + + expect(error.statusCode).toBe(403); + }); + + it("logs the error via logger.error", async () => { + mockFetch.mockResolvedValue( + jsonResponse({ message: "server error" }, 500), + ); + + await fetchDatabaseRequest(connection, featureFlags, "/query", { + method: "POST", + }).catch(() => {}); + + expect(logger.error).toHaveBeenCalledWith( + "Response status 500 received:", + expect.anything(), + ); + }); + }); + + describe("error body decoding", () => { + it("flattens a nested error object", async () => { + const innerError = { code: "ECONNREFUSED", message: "refused" }; + mockFetch.mockResolvedValue(jsonResponse({ error: innerError }, 502)); + + const error = await fetchDatabaseRequest( + connection, + featureFlags, + "/query", + { method: "POST" }, + ).catch(e => e); + + expect(error.data).toStrictEqual(innerError); + }); + + it("returns raw text when JSON parsing fails", async () => { + mockFetch.mockResolvedValue( + new Response("not valid json", { + status: 500, + headers: { "Content-Type": "application/json" }, + }), + ); + + const error = await fetchDatabaseRequest( + connection, + featureFlags, + "/query", + { method: "POST" }, + ).catch(e => e); + + expect(error.data).toBe("not valid json"); + }); + + it("returns raw text for non-JSON content", async () => { + mockFetch.mockResolvedValue(textResponse("Service Unavailable", 503)); + + const error = await fetchDatabaseRequest( + connection, + featureFlags, + "/query", + { method: "POST" }, + ).catch(e => e); + + expect(error.data).toBe("Service Unavailable"); + }); + + it("uses default message when error body is empty", async () => { + mockFetch.mockResolvedValue(emptyResponse(500)); + + await expect( + fetchDatabaseRequest(connection, featureFlags, "/query", { + method: "POST", + }), + ).rejects.toThrow( + expect.objectContaining({ + message: "Network response was not OK", + }), + ); + }); + + it("assumes JSON when Content-Type header is missing", async () => { + const body = JSON.stringify({ message: "no content type" }); + const response = new Response(body, { status: 400 }); + response.headers.delete("Content-Type"); + mockFetch.mockResolvedValue(response); + + const error = await fetchDatabaseRequest( + connection, + featureFlags, + "/query", + { method: "POST" }, + ).catch(e => e); + + expect(error.message).toBe("no content type"); + }); + }); + + describe("fetch failures", () => { + it("wraps failed to fetch in ServerConnectionError with the URL", async () => { + mockFetch.mockRejectedValue(new TypeError("Failed to fetch")); + + const error = await fetchDatabaseRequest( + connection, + featureFlags, + "http://localhost:8182/query", + { method: "POST" }, + ).catch(e => e); + + expect(error).toBeInstanceOf(ServerConnectionError); + expect(error.url).toBe("http://localhost:8182/query"); + expect(error.cause).toBeInstanceOf(TypeError); + }); + + it("propagates abort errors", async () => { + const controller = new AbortController(); + controller.abort(); + + mockFetch.mockRejectedValue(controller.signal.reason); + + await expect( + fetchDatabaseRequest(connection, featureFlags, "/query", { + method: "POST", + signal: controller.signal, + }), + ).rejects.toThrow(); + }); + + it("wraps other TypeErrors as ServerConnectionError", async () => { + const error = new TypeError("Cannot read properties of null"); + mockFetch.mockRejectedValue(error); + + const caught = await fetchDatabaseRequest( + connection, + featureFlags, + "http://localhost:8182/query", + { method: "POST" }, + ).catch(e => e); + + expect(caught).toBeInstanceOf(ServerConnectionError); + expect(caught.cause).toBe(error); + }); + + it("extracts URL from a URL object", async () => { + mockFetch.mockRejectedValue(new TypeError("Failed to fetch")); + + const caught = await fetchDatabaseRequest( + connection, + featureFlags, + new URL("http://localhost:8182/sparql"), + { method: "POST" }, + ).catch(e => e); + + expect(caught).toBeInstanceOf(ServerConnectionError); + expect(caught.url).toBe("http://localhost:8182/sparql"); + }); + }); +}); diff --git a/packages/graph-explorer/src/connector/fetchDatabaseRequest.ts b/packages/graph-explorer/src/connector/fetchDatabaseRequest.ts index bf7f0fa39..80696820f 100644 --- a/packages/graph-explorer/src/connector/fetchDatabaseRequest.ts +++ b/packages/graph-explorer/src/connector/fetchDatabaseRequest.ts @@ -1,22 +1,11 @@ -import { z } from "zod"; - import type { FeatureFlags, NormalizedConnection } from "@/core"; -import { logger, NetworkError } from "@/utils"; +import { logger, NetworkError, ServerConnectionError } from "@/utils"; import { DEFAULT_SERVICE_TYPE } from "@/utils/constants"; +import { extractErrorMessage } from "@/utils/extractErrorMessage"; import { anySignal } from "./utils/anySignal"; -const NeptuneErrorSchema = z.object({ - code: z.string(), - errno: z.string().optional(), - requestId: z.string().optional(), - detailedMessage: z.string().optional(), - message: z.string().optional(), - status: z.number().optional(), - type: z.string().optional(), -}); - /** * Attempts to decode the error response into a JSON object. * @@ -54,7 +43,7 @@ async function decodeErrorSafely(response: Response): Promise { } } - return { message: rawText }; + return rawText; } // Construct the request headers based on the connection settings @@ -63,7 +52,7 @@ function getAuthHeaders( featureFlags: FeatureFlags, typeHeaders: HeadersInit | undefined, ) { - const headers: HeadersInit = {}; + const headers: Record = {}; if (connection.proxyConnection) { headers["graph-db-connection-url"] = connection.graphDbUrl || ""; headers["db-query-logging-enabled"] = String( @@ -75,7 +64,11 @@ function getAuthHeaders( headers["service-type"] = connection.serviceType || DEFAULT_SERVICE_TYPE; } - return { ...headers, ...typeHeaders }; + if (typeHeaders) { + Object.assign(headers, Object.fromEntries(new Headers(typeHeaders))); + } + + return headers; } // Construct an AbortSignal for the fetch timeout if configured @@ -104,7 +97,17 @@ export async function fetchDatabaseRequest( signal: anySignal(getFetchTimeoutSignal(connection), options.signal), }; - const response = await fetch(uri, fetchOptions); + let response: Response; + try { + response = await fetch(uri, fetchOptions); + } catch (error) { + if (error instanceof TypeError) { + const url = + typeof uri === "string" ? uri : uri instanceof URL ? uri.href : uri.url; + throw new ServerConnectionError(url, error); + } + throw error; + } if (!response.ok) { const defaultMessage = "Network response was not OK"; @@ -113,18 +116,9 @@ export async function fetchDatabaseRequest( // Log the error to the console always logger.error(`Response status ${response.status} received:`, error); - // Parse out neptune specific error messages - const parseNeptuneError = NeptuneErrorSchema.safeParse(error); - if (parseNeptuneError.success) { - const message = - parseNeptuneError.data.detailedMessage ?? - parseNeptuneError.data.message ?? - defaultMessage; - - throw new NetworkError(message, response.status, parseNeptuneError.data); - } - // Or just throw a generic error - throw new NetworkError(defaultMessage, response.status, error); + // Extract a message from the error body + const message = extractErrorMessage(error) ?? defaultMessage; + throw new NetworkError(message, response.status, error); } // A successful response is assumed to be JSON diff --git a/packages/graph-explorer/src/connector/queries/edgeConnectionsQuery.ts b/packages/graph-explorer/src/connector/queries/edgeConnectionsQuery.ts index c0d61bc4c..ba2ecea2f 100644 --- a/packages/graph-explorer/src/connector/queries/edgeConnectionsQuery.ts +++ b/packages/graph-explorer/src/connector/queries/edgeConnectionsQuery.ts @@ -11,7 +11,7 @@ import { import { logger } from "@/utils"; import { getExplorer, getStore } from "./helpers"; -import { schemaSyncQuery } from "./schemaSyncQuery"; +import { schemaSyncQueryKey } from "./schemaSyncQuery"; /** * Fetches edge connections for the edge types in the active schema and persists @@ -35,6 +35,7 @@ export function edgeConnectionsQuery( return queryOptions({ queryKey: ["schema", "edgeConnections", sortedEdgeTypes], staleTime: Infinity, + retryOnMount: false, enabled: activeSchema != null && !activeSchema.lastEdgeConnectionSyncFail, initialData: activeSchema?.edgeConnections, queryFn: async ({ signal, meta, client }) => { @@ -65,7 +66,8 @@ export function edgeConnectionsQuery( // Update the cached schema for the schema sync query const newSchema = store.get(activeSchemaAtom); - client.setQueryData(schemaSyncQuery(activeSchema).queryKey, newSchema); + const connectionId = store.get(activeConfigurationAtom); + client.setQueryData(schemaSyncQueryKey(connectionId), newSchema); return results.edgeConnections; } catch (error) { diff --git a/packages/graph-explorer/src/connector/queries/executeUserQuery.ts b/packages/graph-explorer/src/connector/queries/executeUserQuery.ts index d7024d2db..657d823db 100644 --- a/packages/graph-explorer/src/connector/queries/executeUserQuery.ts +++ b/packages/graph-explorer/src/connector/queries/executeUserQuery.ts @@ -16,13 +16,8 @@ export function executeUserQuery( query: string, updateSchema: UpdateSchemaHandler, ) { + // oxlint-disable-next-line @tanstack/query/exhaustive-deps -- intentionally static key so repeated fetchQuery calls replace the same cache entry return queryOptions({ - /* - * DEV NOTE: I'm intentionally leaving the query string out of the query - * key. This ensures the same cache entry is replaced when a new - * `fetchQuery` is executed. - */ - // eslint-disable-next-line @tanstack/query/exhaustive-deps queryKey: ["execute-user-query"], enabled: false, // Don't mark cached values as stale diff --git a/packages/graph-explorer/src/connector/queries/schemaSyncQuery.test.ts b/packages/graph-explorer/src/connector/queries/schemaSyncQuery.test.ts index a219c828d..94dd2c7a9 100644 --- a/packages/graph-explorer/src/connector/queries/schemaSyncQuery.test.ts +++ b/packages/graph-explorer/src/connector/queries/schemaSyncQuery.test.ts @@ -42,6 +42,17 @@ describe("schemaSyncQuery", () => { store.set(explorerForTestingAtom, explorer); }); + function defaultOptions( + overrides?: Partial[0]>, + ) { + return schemaSyncQuery({ + connectionId: store.get(activeConfigurationAtom), + activeSchema: undefined, + hasConnection: true, + ...overrides, + }); + } + it("should fetch schema and update the store", async () => { const vertex = createTestableVertex().with({ types: [createVertexType("Person")], @@ -50,7 +61,7 @@ describe("schemaSyncQuery", () => { const queryClient = createQueryClient(); - const result = await queryClient.fetchQuery(schemaSyncQuery(undefined)); + const result = await queryClient.fetchQuery(defaultOptions()); expect(result.vertices).toHaveLength(1); expect(result.vertices[0].type).toBe("Person"); @@ -69,7 +80,7 @@ describe("schemaSyncQuery", () => { const queryClient = createQueryClient(); - await queryClient.fetchQuery(schemaSyncQuery(undefined)); + await queryClient.fetchQuery(defaultOptions()); const activeConfigId = store.get(activeConfigurationAtom); const storedSchema = store.get(schemaAtom).get(activeConfigId!); @@ -101,7 +112,7 @@ describe("schemaSyncQuery", () => { const queryClient = createQueryClient(); - await queryClient.fetchQuery(schemaSyncQuery(undefined)); + await queryClient.fetchQuery(defaultOptions()); // Verify old schema was replaced const storedSchema = store.get(schemaAtom).get(activeConfigId); @@ -112,25 +123,36 @@ describe("schemaSyncQuery", () => { it("should return empty schema when no data exists", async () => { const queryClient = createQueryClient(); - const result = await queryClient.fetchQuery(schemaSyncQuery(undefined)); + const result = await queryClient.fetchQuery(defaultOptions()); expect(result.vertices).toStrictEqual([]); expect(result.edges).toStrictEqual([]); }); + it("should be disabled when hasConnection is false", () => { + const options = defaultOptions({ hasConnection: false }); + + expect(options.enabled).toBe(false); + }); + + it("should be disabled when lastSyncFail is true", () => { + const options = defaultOptions({ + activeSchema: { vertices: [], edges: [], lastSyncFail: true }, + }); + + expect(options.enabled).toBe(false); + }); + it("should set lastSyncFail when fetch fails", async () => { - const fetchSchemaSpy = vi.spyOn(explorer, "fetchSchema"); - fetchSchemaSpy.mockRejectedValue(new Error("Network error")); + vi.spyOn(explorer, "fetchSchema").mockRejectedValue( + new Error("Network error"), + ); const queryClient = createQueryClient(); - queryClient.setDefaultOptions({ - ...queryClient.getDefaultOptions(), - queries: { ...queryClient.getDefaultOptions().queries, retry: false }, - }); - await expect( - queryClient.fetchQuery(schemaSyncQuery(undefined)), - ).rejects.toThrow("Network error"); + await expect(queryClient.fetchQuery(defaultOptions())).rejects.toThrow( + "Network error", + ); const activeConfigId = store.get(activeConfigurationAtom); const storedSchema = store.get(schemaAtom).get(activeConfigId!); @@ -151,7 +173,7 @@ describe("schemaSyncQuery", () => { }); const queryClient = createQueryClient(); - await queryClient.fetchQuery(schemaSyncQuery(undefined)); + await queryClient.fetchQuery(defaultOptions()); const storedSchema = store.get(schemaAtom).get(activeConfigId); expect(storedSchema?.edgeConnections).toBeUndefined(); @@ -171,7 +193,7 @@ describe("schemaSyncQuery", () => { }); const queryClient = createQueryClient(); - await queryClient.fetchQuery(schemaSyncQuery(undefined)); + await queryClient.fetchQuery(defaultOptions()); const storedSchema = store.get(schemaAtom).get(activeConfigId); expect(storedSchema?.lastSyncFail).toBe(false); @@ -190,18 +212,15 @@ describe("schemaSyncQuery", () => { return updated; }); - const fetchSchemaSpy = vi.spyOn(explorer, "fetchSchema"); - fetchSchemaSpy.mockRejectedValue(new Error("Network error")); + vi.spyOn(explorer, "fetchSchema").mockRejectedValue( + new Error("Network error"), + ); const queryClient = createQueryClient(); - queryClient.setDefaultOptions({ - ...queryClient.getDefaultOptions(), - queries: { ...queryClient.getDefaultOptions().queries, retry: false }, - }); - await expect( - queryClient.fetchQuery(schemaSyncQuery(undefined)), - ).rejects.toThrow("Network error"); + await expect(queryClient.fetchQuery(defaultOptions())).rejects.toThrow( + "Network error", + ); // Verify existing data was preserved const storedSchema = store.get(schemaAtom).get(activeConfigId); @@ -226,7 +245,7 @@ describe("schemaSyncQuery", () => { const queryClient = createQueryClient(); - await queryClient.fetchQuery(schemaSyncQuery(undefined)); + await queryClient.fetchQuery(defaultOptions()); const activeConfigId = store.get(activeConfigurationAtom); const storedSchema = store.get(schemaAtom).get(activeConfigId!); @@ -241,7 +260,7 @@ describe("schemaSyncQuery", () => { const queryClient = createQueryClient(); - await queryClient.fetchQuery(schemaSyncQuery(undefined)); + await queryClient.fetchQuery(defaultOptions()); expect(fetchSchemaSpy).toHaveBeenCalledWith( expect.objectContaining({ signal: expect.any(AbortSignal) }), @@ -266,12 +285,8 @@ describe("schemaSyncQuery", () => { ); const queryClient = createQueryClient(); - queryClient.setDefaultOptions({ - ...queryClient.getDefaultOptions(), - queries: { ...queryClient.getDefaultOptions().queries, retry: false }, - }); - const queryPromise = queryClient.fetchQuery(schemaSyncQuery(undefined)); + const queryPromise = queryClient.fetchQuery(defaultOptions()); // Cancel the query await queryClient.cancelQueries({ queryKey: ["schema"] }); @@ -287,7 +302,7 @@ describe("schemaSyncQuery", () => { const queryClient = createQueryClient(); - const result = await queryClient.fetchQuery(schemaSyncQuery(undefined)); + const result = await queryClient.fetchQuery(defaultOptions()); expect(result.vertices.length).toBeGreaterThanOrEqual(2); expect(result.totalVertices).toBe(2); @@ -307,7 +322,7 @@ describe("schemaSyncQuery", () => { const queryClient = createQueryClient(); - const result = await queryClient.fetchQuery(schemaSyncQuery(undefined)); + const result = await queryClient.fetchQuery(defaultOptions()); expect(result.totalVertices).toBe(100); expect(result.totalEdges).toBe(50); @@ -326,18 +341,15 @@ describe("schemaSyncQuery", () => { return updated; }); - const fetchSchemaSpy = vi.spyOn(explorer, "fetchSchema"); - fetchSchemaSpy.mockRejectedValue(new Error("Network error")); + vi.spyOn(explorer, "fetchSchema").mockRejectedValue( + new Error("Network error"), + ); const queryClient = createQueryClient(); - queryClient.setDefaultOptions({ - ...queryClient.getDefaultOptions(), - queries: { ...queryClient.getDefaultOptions().queries, retry: false }, - }); - await expect( - queryClient.fetchQuery(schemaSyncQuery(undefined)), - ).rejects.toThrow("Network error"); + await expect(queryClient.fetchQuery(defaultOptions())).rejects.toThrow( + "Network error", + ); const storedSchema = store.get(schemaAtom).get(activeConfigId); expect(storedSchema?.lastSyncFail).toBe(true); diff --git a/packages/graph-explorer/src/connector/queries/schemaSyncQuery.ts b/packages/graph-explorer/src/connector/queries/schemaSyncQuery.ts index 702446ca5..f5f10928e 100644 --- a/packages/graph-explorer/src/connector/queries/schemaSyncQuery.ts +++ b/packages/graph-explorer/src/connector/queries/schemaSyncQuery.ts @@ -3,6 +3,7 @@ import { atom } from "jotai"; import { activeConfigurationAtom, + type ConfigurationId, type PrefixTypeConfig, schemaAtom, } from "@/core"; @@ -18,6 +19,11 @@ import type { SchemaResponse } from "../useGEFetchTypes"; import { getExplorer, getStore } from "./helpers"; +/** Returns the query key for the schema sync query for the given connection. */ +export function schemaSyncQueryKey(connectionId: ConfigurationId | null) { + return ["schema", "discovery", connectionId] as const; +} + /** * Fetches the schema from the given explorer and persists it to the local cache on success. * @@ -27,13 +33,21 @@ import { getExplorer, getStore } from "./helpers"; * On failure, persists `lastSyncFail` so the UI can show the failure after * a browser refresh and automatic retry is suppressed. */ -export function schemaSyncQuery(activeSchema: SchemaStorageModel | undefined) { +export function schemaSyncQuery({ + connectionId, + activeSchema, + hasConnection, +}: { + connectionId: ConfigurationId | null; + activeSchema: SchemaStorageModel | undefined; + hasConnection: boolean; +}) { return queryOptions({ - queryKey: ["schema", "discovery"], + queryKey: schemaSyncQueryKey(connectionId), staleTime: Infinity, + retryOnMount: false, initialData: activeSchema, - // Don't automatically retry if the last sync failed (persisted across sessions) - enabled: !activeSchema?.lastSyncFail, + enabled: hasConnection && !activeSchema?.lastSyncFail, queryFn: async ({ signal, meta }) => { const explorer = getExplorer(meta); const store = getStore(meta); diff --git a/packages/graph-explorer/src/connector/queries/searchQuery.ts b/packages/graph-explorer/src/connector/queries/searchQuery.ts index 943098c64..c6fafa5dc 100644 --- a/packages/graph-explorer/src/connector/queries/searchQuery.ts +++ b/packages/graph-explorer/src/connector/queries/searchQuery.ts @@ -22,6 +22,7 @@ export function searchQuery( request: KeywordSearchRequest, updateSchema: UpdateSchemaHandler, ) { + // oxlint-disable-next-line @tanstack/query/exhaustive-deps -- updateSchema is a callback, not query data return queryOptions({ queryKey: ["keyword-search", request], queryFn: async ({ signal, meta, client }) => { diff --git a/packages/graph-explorer/src/connector/sparql/neighborCounts.test.ts b/packages/graph-explorer/src/connector/sparql/neighborCounts.test.ts index 77777cb3c..7c6faccb1 100644 --- a/packages/graph-explorer/src/connector/sparql/neighborCounts.test.ts +++ b/packages/graph-explorer/src/connector/sparql/neighborCounts.test.ts @@ -101,7 +101,6 @@ describe("neighborCounts", () => { it("should return blank node neighbor counts", async () => { const vertex = createRandomVertexForRdf(); - vertex.isBlankNode; const blankNodes: BlankNodesMap = new Map(); const expected: NeighborCount = { vertexId: vertex.id, diff --git a/packages/graph-explorer/src/connector/sparql/parseAndMapQuads.test.ts b/packages/graph-explorer/src/connector/sparql/parseAndMapQuads.test.ts index 004127aa7..0a66f7c5b 100644 --- a/packages/graph-explorer/src/connector/sparql/parseAndMapQuads.test.ts +++ b/packages/graph-explorer/src/connector/sparql/parseAndMapQuads.test.ts @@ -109,6 +109,24 @@ describe("parseAndMapQuads", () => { expect(() => parseAndMapQuads(invalidData)).toThrow(); expect(logger.error).toHaveBeenCalled(); }); + + it("should throw validation error for partial shorthand matches (e.g., s, predicate, object)", () => { + const invalidData = { + head: { vars: ["s", "predicate", "object"] }, + results: { + bindings: [ + { + s: { type: "uri", value: "http://example.com/resource" }, + predicate: { type: "uri", value: "http://example.com/predicate" }, + object: { type: "literal", value: "test value" }, + }, + ], + }, + }; + + expect(() => parseAndMapQuads(invalidData)).toThrow(); + expect(logger.error).toHaveBeenCalled(); + }); }); describe("successful parsing", () => { @@ -138,6 +156,31 @@ describe("parseAndMapQuads", () => { expect(logger.error).not.toHaveBeenCalled(); }); + it("should parse shorthand variables (?s ?p ?o) correctly", () => { + const vertex = createTestableVertex().withRdfValues(); + const bindings = createQuadBindingsForEntities([vertex], []); + + // Convert standard bindings to shorthand variables + const shorthandBindings = bindings.map(b => ({ + s: b.subject, + p: b.predicate, + o: b.object, + })); + + const data = { + head: { vars: ["s", "p", "o"] }, + results: { bindings: shorthandBindings }, + }; + + const result = parseAndMapQuads(data); + + expect(result).toEqual({ + vertices: [vertex.asResult()], + edges: [], + }); + expect(logger.error).not.toHaveBeenCalled(); + }); + it("should parse single vertex with blank node", () => { const vertex = createTestableVertex().withRdfValues({ isBlankNode: true, diff --git a/packages/graph-explorer/src/connector/sparql/rawQuery.test.ts b/packages/graph-explorer/src/connector/sparql/rawQuery.test.ts index 28bdd43b2..38d26b141 100644 --- a/packages/graph-explorer/src/connector/sparql/rawQuery.test.ts +++ b/packages/graph-explorer/src/connector/sparql/rawQuery.test.ts @@ -221,6 +221,35 @@ describe("rawQuery", () => { expect(result.rawResponse).toEqual(mockResponse); }); + it("should treat SELECT queries with exactly s/p/o variables as CONSTRUCT", async () => { + const vertex1 = createTestableVertex().withRdfValues(); + const vertex2 = createTestableVertex().withRdfValues(); + + const bindings = createQuadBindingsForEntities([vertex1, vertex2], []); + const shorthandBindings = bindings.map(b => ({ + s: b.subject, + p: b.predicate, + o: b.object, + })); + + const mockResponse = { + head: { vars: ["s", "p", "o"] }, + results: { bindings: shorthandBindings }, + }; + + const mockFetch = vi.fn().mockResolvedValue(mockResponse); + const result = await rawQuery(mockFetch, { + query: "SELECT ?s ?p ?o WHERE { ?s ?p ?o }", + }); + + // It should map them to fragments as if it was a CONSTRUCT response + expect(result.results).toStrictEqual([ + vertex1.asFragmentResult(), + vertex2.asFragmentResult(), + ]); + expect(result.rawResponse).toEqual(mockResponse); + }); + it("should not treat SELECT queries with subject/predicate/object variables as CONSTRUCT", async () => { const mockResponse = { head: { vars: ["subject", "predicate", "object", "extra"] }, @@ -262,6 +291,44 @@ describe("rawQuery", () => { expect(result.rawResponse).toEqual(mockResponse); }); + it("should not treat SELECT queries with partial shorthand variables (e.g., s/predicate/object) as CONSTRUCT", async () => { + const mockResponse = { + head: { vars: ["s", "predicate", "object"] }, + results: { + bindings: [ + { + s: createUriValue("http://example.org/person1"), + predicate: createUriValue("http://example.org/name"), + object: createLiteralValue("John Doe"), + }, + ], + }, + }; + + const mockFetch = vi.fn().mockResolvedValue(mockResponse); + const result = await rawQuery(mockFetch, { + query: "SELECT ?s ?predicate ?object WHERE { ?s ?predicate ?object }", + }); + + // Should be treated as SELECT query (bundle with 3 scalars), not CONSTRUCT + expect(result.results).toStrictEqual([ + createResultBundle({ + values: [ + createResultScalar({ + name: "?s", + value: "http://example.org/person1", + }), + createResultScalar({ + name: "?predicate", + value: "http://example.org/name", + }), + createResultScalar({ name: "?object", value: "John Doe" }), + ], + }), + ]); + expect(result.rawResponse).toEqual(mockResponse); + }); + it("should not treat SELECT queries with non-URI subjects as CONSTRUCT", async () => { const mockResponse = { head: { vars: ["subject", "predicate", "object"] }, diff --git a/packages/graph-explorer/src/connector/sparql/types.ts b/packages/graph-explorer/src/connector/sparql/types.ts index 73706992b..a3062709d 100644 --- a/packages/graph-explorer/src/connector/sparql/types.ts +++ b/packages/graph-explorer/src/connector/sparql/types.ts @@ -206,7 +206,7 @@ export const sparqlAskResponseSchema = z.object({ boolean: z.boolean(), }); -export const sparqlQuadBindingSchema = z +const sparqlQuadBindingLongSchema = z .object({ subject: sparqlResourceValueSchema, predicate: sparqlUriValueSchema, @@ -214,4 +214,25 @@ export const sparqlQuadBindingSchema = z graph: sparqlValueSchema.optional(), }) .strict(); + +const sparqlQuadBindingShortSchema = z + .object({ + s: sparqlResourceValueSchema, + p: sparqlUriValueSchema, + o: sparqlValueSchema, + g: sparqlValueSchema.optional(), + c: sparqlValueSchema.optional(), + }) + .strict() + .transform(val => ({ + subject: val.s, + predicate: val.p, + object: val.o, + graph: val.g ?? val.c, + })); + +export const sparqlQuadBindingSchema = z.union([ + sparqlQuadBindingLongSchema, + sparqlQuadBindingShortSchema, +]); export type SparqlQuadBinding = z.infer; diff --git a/packages/graph-explorer/src/core/AppStatusLoader.tsx b/packages/graph-explorer/src/core/AppStatusLoader.tsx index 36535788f..a100d356c 100644 --- a/packages/graph-explorer/src/core/AppStatusLoader.tsx +++ b/packages/graph-explorer/src/core/AppStatusLoader.tsx @@ -1,5 +1,5 @@ import { useQuery } from "@tanstack/react-query"; -import { useAtom } from "jotai"; +import { useAtom, useSetAtom } from "jotai"; import { type PropsWithChildren, startTransition, @@ -11,7 +11,14 @@ import { PanelEmptyState, Spinner } from "@/components"; import { logger } from "@/utils"; import { fetchDefaultConnection } from "./defaultConnection"; -import { activeConfigurationAtom, configurationAtom } from "./StateProvider"; +import { fetchDefaultStyling, resolveDefaultStyling } from "./defaultStyling"; +import { + activeConfigurationAtom, + configurationAtom, + defaultStylingAtom, + mergeDefaultsIntoUserStyling, + userStylingAtom, +} from "./StateProvider"; function AppStatusLoader({ children }: PropsWithChildren) { return ( @@ -24,6 +31,8 @@ function AppStatusLoader({ children }: PropsWithChildren) { function LoadDefaultConfig({ children }: PropsWithChildren) { const [activeConfig, setActiveConfig] = useAtom(activeConfigurationAtom); const [configuration, setConfiguration] = useAtom(configurationAtom); + const setDefaultStyling = useSetAtom(defaultStylingAtom); + const setUserStyling = useSetAtom(userStylingAtom); const defaultConfigQuery = useQuery({ queryKey: ["default-connection"], @@ -33,6 +42,39 @@ function LoadDefaultConfig({ children }: PropsWithChildren) { enabled: configuration.size === 0, }); + // Fetch default styling on every session start + const defaultStylingQuery = useQuery({ + queryKey: ["default-styling"], + queryFn: fetchDefaultStyling, + staleTime: Infinity, + }); + + useEffect(() => { + const data = defaultStylingQuery.data; + if (!data) { + return; + } + let cancelled = false; + logger.debug("Applying default styling", data); + resolveDefaultStyling(data) + .then(resolved => { + if (!cancelled) { + // Store reference copy for per-type "Reset to Default" + setDefaultStyling(resolved); + + // Merge file values into user styling. Default values fill in + // properties the user hasn't explicitly set; user overrides win. + setUserStyling(prev => mergeDefaultsIntoUserStyling(prev, resolved)); + } + }) + .catch(err => { + logger.warn("Failed to resolve default styling", err); + }); + return () => { + cancelled = true; + }; + }, [defaultStylingQuery.data, setDefaultStyling, setUserStyling]); + const defaultConnectionConfigs = defaultConfigQuery.data; useEffect(() => { diff --git a/packages/graph-explorer/src/core/ConfigurationProvider/types.ts b/packages/graph-explorer/src/core/ConfigurationProvider/types.ts index 2c940d88e..1bea2321d 100644 --- a/packages/graph-explorer/src/core/ConfigurationProvider/types.ts +++ b/packages/graph-explorer/src/core/ConfigurationProvider/types.ts @@ -9,9 +9,15 @@ import type { import type { Branded } from "@/utils"; import type { IriNamespace, RdfPrefix } from "@/utils/rdf"; -import type { EdgeType, VertexType } from "../entities"; import type { SchemaStorageModel } from "../StateProvider"; +import { + createEdgeType, + createVertexType, + type EdgeType, + type VertexType, +} from "../entities"; + export type ConfigurationId = Branded; export function createNewConfigurationId() { @@ -98,6 +104,19 @@ export type PrefixTypeConfig = { __inferred?: boolean; }; +/** Creates a PrefixTypeConfig from plain strings. */ +export function createPrefixTypeConfig(options: { + prefix: string; + uri: string; + inferred?: boolean; +}): PrefixTypeConfig { + return { + prefix: options.prefix as RdfPrefix, + uri: options.uri as IriNamespace, + __inferred: options.inferred, + }; +} + /** * Represents a connection between node labels via an edge type. * Used by Schema Explorer to visualize relationships between node types. @@ -121,6 +140,21 @@ export type EdgeConnection = { count?: number; }; +/** Creates an EdgeConnection with branded types from plain strings. */ +export function createEdgeConnection(options: { + source: string; + edge: string; + target: string; + count?: number; +}): EdgeConnection { + return { + sourceVertexType: createVertexType(options.source), + edgeType: createEdgeType(options.edge), + targetVertexType: createVertexType(options.target), + ...(options.count != null && { count: options.count }), + }; +} + export type RawConfiguration = { /** * Unique identifier for this config diff --git a/packages/graph-explorer/src/core/ExplorerInjector.test.tsx b/packages/graph-explorer/src/core/ExplorerInjector.test.tsx deleted file mode 100644 index db66bb2cc..000000000 --- a/packages/graph-explorer/src/core/ExplorerInjector.test.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { render } from "@testing-library/react"; -import { Provider } from "jotai"; -import { beforeEach, describe, expect, test, vi } from "vitest"; - -import { createMockExplorer } from "@/utils/testing"; - -import { explorerForTestingAtom } from "./connector"; -import { ExplorerInjector } from "./ExplorerInjector"; -import { getAppStore } from "./StateProvider/appStore"; - -function renderExplorerInjector(queryClient: QueryClient) { - const store = getAppStore(); - return render( - - - - - , - ); -} - -describe("ExplorerInjector", () => { - let queryClient: QueryClient; - - beforeEach(() => { - queryClient = new QueryClient(); - }); - - test("should render null", () => { - const { container } = renderExplorerInjector(queryClient); - expect(container.firstChild).toBeNull(); - }); - - test("should set default options on query client", () => { - const store = getAppStore(); - const explorer = createMockExplorer(); - store.set(explorerForTestingAtom, explorer); - - renderExplorerInjector(queryClient); - - const defaultOptions = queryClient.getDefaultOptions(); - expect(defaultOptions.queries?.meta?.store).toBe(store); - expect(defaultOptions.mutations?.meta?.store).toBe(store); - }); - - test("should clear cache when explorer changes", () => { - const store = getAppStore(); - const explorer1 = createMockExplorer(); - store.set(explorerForTestingAtom, explorer1); - - const clearSpy = vi.spyOn(queryClient, "clear"); - const setDefaultOptionsSpy = vi.spyOn(queryClient, "setDefaultOptions"); - - render( - - - - - , - ); - - expect(clearSpy).toHaveBeenCalledTimes(1); - expect(setDefaultOptionsSpy).toHaveBeenCalledTimes(1); - }); - - test("should not clear cache when explorer remains the same", () => { - const store = getAppStore(); - const explorer = createMockExplorer(); - store.set(explorerForTestingAtom, explorer); - - const clearSpy = vi.spyOn(queryClient, "clear"); - - const { rerender } = render( - - - - - , - ); - - expect(clearSpy).toHaveBeenCalledTimes(1); - - // Rerender without changing explorer - rerender( - - - - - , - ); - - // Should still be 1 since explorer didn't change - expect(clearSpy).toHaveBeenCalledTimes(1); - }); -}); diff --git a/packages/graph-explorer/src/core/ExplorerInjector.tsx b/packages/graph-explorer/src/core/ExplorerInjector.tsx deleted file mode 100644 index 45bef2460..000000000 --- a/packages/graph-explorer/src/core/ExplorerInjector.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { type DefaultOptions, useQueryClient } from "@tanstack/react-query"; -import { useMemo, useState } from "react"; - -import { logger } from "@/utils"; - -import { createDefaultOptions } from "./queryClient"; -import { getAppStore } from "./StateProvider/appStore"; - -/** - * Ensures the query client has the correct explorer for the connection and - * Jotai store injected in to the `meta` object. It also clears the cache when - * the explorer changes. - */ -export function ExplorerInjector() { - const queryClient = useQueryClient(); - const store = getAppStore(); - - // Only crate a new defaultOptions when explorer or store changes - const defaultOptions = useMemo(() => createDefaultOptions(store), [store]); - - // Start with null to ensure first render sets the default options properly - const [prevDefaultOptions, setPrevDefaultOptions] = - useState | null>(null); - - if (prevDefaultOptions !== defaultOptions) { - setPrevDefaultOptions(defaultOptions); - logger.log("Clearing cache and updating query default options"); - queryClient.clear(); - queryClient.setDefaultOptions(defaultOptions); - } - - return null; -} diff --git a/packages/graph-explorer/src/core/StateProvider/configuration.test.ts b/packages/graph-explorer/src/core/StateProvider/configuration.test.ts index a972d7cdb..e7271caea 100644 --- a/packages/graph-explorer/src/core/StateProvider/configuration.test.ts +++ b/packages/graph-explorer/src/core/StateProvider/configuration.test.ts @@ -1,5 +1,6 @@ import { createRandomName } from "@shared/utils/testing"; +import { createEdgeType, createVertexType } from "@/core/entities"; import { RESERVED_TYPES_PROPERTY } from "@/utils"; import { createRandomEdgePreferencesStorageModel, @@ -20,7 +21,10 @@ import type { UserStyling } from "./userPreferences"; import { defaultEdgeTypeConfig, defaultVertexTypeConfig, + getDefaultEdgeTypeConfig, + getDefaultVertexTypeConfig, mergeConfiguration, + normalizeConnection, type NormalizedConnection, patchToRemoveDisplayLabel, } from "./configuration"; @@ -257,6 +261,35 @@ describe("mergedConfiguration", () => { expect(actualEtConfig?.displayLabel).toEqual(customDisplayLabel); }); + it("should apply styling from userStyling (which includes merged defaults)", () => { + const config = createRandomRawConfiguration(); + const schema = createRandomSchema(); + // In the new architecture, defaults from defaultStyling.json are + // merged into userStyling at load time + const styling: UserStyling = { + vertices: schema.vertices.map(v => ({ + type: v.type, + color: "#FF0000", + })), + edges: schema.edges.map(e => ({ + type: e.type, + lineColor: "#00FF00", + })), + }; + const result = mergeConfiguration(schema, config, styling); + + for (const v of result.schema?.vertices ?? []) { + const style = styling.vertices?.find(s => s.type === v.type); + assert(style); + expect(v.color).toBe("#FF0000"); + } + for (const e of result.schema?.edges ?? []) { + const style = styling.edges?.find(s => s.type === e.type); + assert(style); + expect(e.lineColor).toBe("#00FF00"); + } + }); + it("should patch displayNameAttribute to be 'types' when it was 'type'", () => { const etConfig = createRandomEdgeTypeConfig(); @@ -303,4 +336,107 @@ describe("patchToRemoveDisplayLabel", () => { expect(attr).not.toHaveProperty("displayLabel"); } }); + + it("should not mutate the original config", () => { + const config = createRandomVertexTypeConfig(); + config.displayLabel = createRandomName("displayLabel"); + config.attributes.forEach( + a => ((a as any).displayLabel = createRandomName("displayLabel")), + ); + const originalDisplayLabel = config.displayLabel; + const originalAttrDisplayLabels = config.attributes.map( + a => (a as any).displayLabel, + ); + + patchToRemoveDisplayLabel(config); + + expect(config.displayLabel).toBe(originalDisplayLabel); + config.attributes.forEach((a, i) => { + expect((a as any).displayLabel).toBe(originalAttrDisplayLabels[i]); + }); + }); +}); + +describe("normalizeConnection", () => { + test("should remove trailing slash from url", () => { + const result = normalizeConnection({ url: "https://example.com/" }); + expect(result.url).toBe("https://example.com"); + }); + + test("should default queryEngine to gremlin", () => { + const result = normalizeConnection({ url: "https://example.com" }); + expect(result.queryEngine).toBe("gremlin"); + }); + + test("should default proxyConnection to true when graphDbUrl is present", () => { + const result = normalizeConnection({ + url: "https://proxy.com", + graphDbUrl: "https://db.com", + }); + expect(result.proxyConnection).toBe(true); + }); + + test("should default proxyConnection to false when graphDbUrl is absent", () => { + const result = normalizeConnection({ url: "https://example.com" }); + expect(result.proxyConnection).toBe(false); + }); + + test("should default awsAuthEnabled to false", () => { + const result = normalizeConnection({ url: "https://example.com" }); + expect(result.awsAuthEnabled).toBe(false); + }); + + test("should preserve path in url", () => { + const result = normalizeConnection({ + url: "http://localhost:9999/blazegraph/namespace/kb", + }); + expect(result.url).toBe("http://localhost:9999/blazegraph/namespace/kb"); + }); + + test("should remove only trailing slash from url with path", () => { + const result = normalizeConnection({ + url: "http://localhost:9999/blazegraph/namespace/kb/", + }); + expect(result.url).toBe("http://localhost:9999/blazegraph/namespace/kb"); + }); + + test("should preserve path in graphDbUrl", () => { + const result = normalizeConnection({ + url: "http://proxy:8080", + graphDbUrl: "http://blazegraph:9999/blazegraph/namespace/kb", + }); + expect(result.graphDbUrl).toBe( + "http://blazegraph:9999/blazegraph/namespace/kb", + ); + }); + + test("should remove only trailing slash from graphDbUrl with path", () => { + const result = normalizeConnection({ + url: "http://proxy:8080", + graphDbUrl: "http://blazegraph:9999/blazegraph/namespace/kb/", + }); + expect(result.graphDbUrl).toBe( + "http://blazegraph:9999/blazegraph/namespace/kb", + ); + }); +}); + +describe("getDefaultVertexTypeConfig", () => { + test("should return default config with given type", () => { + const result = getDefaultVertexTypeConfig(createVertexType("Person")); + expect(result).toStrictEqual({ + ...defaultVertexTypeConfig, + type: createVertexType("Person"), + }); + }); +}); + +describe("getDefaultEdgeTypeConfig", () => { + test("should return default config with given type", () => { + const result = getDefaultEdgeTypeConfig(createEdgeType("knows")); + expect(result).toStrictEqual({ + ...defaultEdgeTypeConfig, + type: createEdgeType("knows"), + }); + }); }); diff --git a/packages/graph-explorer/src/core/StateProvider/configuration.ts b/packages/graph-explorer/src/core/StateProvider/configuration.ts index f3119f406..0b28eb3eb 100644 --- a/packages/graph-explorer/src/core/StateProvider/configuration.ts +++ b/packages/graph-explorer/src/core/StateProvider/configuration.ts @@ -2,7 +2,7 @@ import type { ConnectionConfig } from "@shared/types"; import { atom } from "jotai"; import { selectAtom } from "jotai/utils"; -import { cloneDeep, isEqual, uniq } from "lodash"; +import { isEqual } from "lodash"; import { activeConfigurationAtom, @@ -63,30 +63,37 @@ export function mergeConfiguration( currentConfig: RawConfiguration, userStyling: UserStyling, ): RawConfiguration { - const configVLabels = currentConfig.schema?.vertices.map(v => v.type) || []; - const schemaVLabels = currentSchema?.vertices?.map(v => v.type) || []; - const allVertexLabels = uniq([...configVLabels, ...schemaVLabels]); + const configVertexMap = toMapByType(currentConfig.schema?.vertices); + const schemaVertexMap = toMapByType(currentSchema?.vertices); + const prefsVertexMap = toMapByType(userStyling.vertices); + + const allVertexLabels = [ + ...new Set([...configVertexMap.keys(), ...schemaVertexMap.keys()]), + ]; const mergedVertices = allVertexLabels - .map(vLabel => { - const configVertex = currentConfig.schema?.vertices.find( - v => v.type === vLabel, - ); - const schemaVertex = currentSchema?.vertices.find(v => v.type === vLabel); - const prefsVertex = userStyling.vertices?.find(v => v.type === vLabel); - - return mergeVertex(configVertex, schemaVertex, prefsVertex); - }) + .map(vLabel => + mergeVertex( + configVertexMap.get(vLabel), + schemaVertexMap.get(vLabel), + prefsVertexMap.get(vLabel), + ), + ) .toSorted((a, b) => a.type.localeCompare(b.type)); - const configELabels = currentConfig.schema?.edges.map(v => v.type) || []; - const schemaELabels = currentSchema?.edges?.map(v => v.type) || []; - const allEdgeLabels = uniq([...configELabels, ...schemaELabels]); - const mergedEdges = allEdgeLabels.map(vLabel => { - const configEdge = currentConfig.schema?.edges.find(v => v.type === vLabel); - const schemaEdge = currentSchema?.edges.find(v => v.type === vLabel); - const prefsEdge = userStyling.edges?.find(v => v.type === vLabel); - return mergeEdge(configEdge, schemaEdge, prefsEdge); - }); + const configEdgeMap = toMapByType(currentConfig.schema?.edges); + const schemaEdgeMap = toMapByType(currentSchema?.edges); + const prefsEdgeMap = toMapByType(userStyling.edges); + + const allEdgeLabels = [ + ...new Set([...configEdgeMap.keys(), ...schemaEdgeMap.keys()]), + ]; + const mergedEdges = allEdgeLabels.map(eLabel => + mergeEdge( + configEdgeMap.get(eLabel), + schemaEdgeMap.get(eLabel), + prefsEdgeMap.get(eLabel), + ), + ); return { id: currentConfig.id, @@ -126,20 +133,21 @@ const mergeAttributes = ( config: VertexTypeConfig | EdgeTypeConfig | null, schema: VertexTypeConfig | EdgeTypeConfig | null, ): AttributeConfig[] => { - const configAttrLabels = config?.attributes.map(attr => attr.name) || []; - const schemaAttrLabels = schema?.attributes.map(attr => attr.name) || []; - const allAttrLabels = uniq([...configAttrLabels, ...schemaAttrLabels]); - - return allAttrLabels.map(attrName => { - const configAttr = config?.attributes.find(attr => attr.name === attrName); - const schemaAttr = schema?.attributes.find(attr => attr.name === attrName); - - return { - name: attrName, - ...(schemaAttr || {}), - ...(configAttr || {}), - }; - }); + const configAttrMap = new Map( + config?.attributes.map(attr => [attr.name, attr]), + ); + const schemaAttrMap = new Map( + schema?.attributes.map(attr => [attr.name, attr]), + ); + const allAttrNames = [ + ...new Set([...configAttrMap.keys(), ...schemaAttrMap.keys()]), + ]; + + return allAttrNames.map(attrName => ({ + name: attrName, + ...schemaAttrMap.get(attrName), + ...configAttrMap.get(attrName), + })); }; const mergeVertex = ( @@ -167,11 +175,11 @@ const mergeVertex = ( // Defaults ...getDefaultVertexTypeConfig(vt), // Automatic schema override - ...(patchedSchema || {}), + ...patchedSchema, // File-based override - ...(patchedConfig || {}), - // User preferences override - ...(preferences || {}), + ...patchedConfig, + // User preferences override (includes defaults from defaultStyling.json) + ...preferences, attributes, }; }; @@ -201,11 +209,11 @@ const mergeEdge = ( // Defaults ...getDefaultEdgeTypeConfig(et), // Automatic schema override - ...(patchedSchema || {}), + ...patchedSchema, // File-based override - ...(patchedConfig || {}), - // User preferences override - ...(preferences || {}), + ...patchedConfig, + // User preferences override (includes defaults from defaultStyling.json) + ...preferences, attributes, }; @@ -276,15 +284,22 @@ export function getDefaultEdgeTypeConfig(edgeType: EdgeType): EdgeTypeConfig { export function patchToRemoveDisplayLabel< TypeConfig extends VertexTypeConfig | EdgeTypeConfig, >(config: TypeConfig): TypeConfig { - const cloned = cloneDeep(config); + const { displayLabel: _, ...rest } = config; - delete cloned.displayLabel; - - // Remove any displayLabel values that were cached in old versions of Graph Explorer - for (const attr of cloned.attributes) { - // Cast to `any` since the type no longer has `displayLabel` defined - delete (attr as any).displayLabel; - } + return { + ...rest, + // Remove any displayLabel values that were cached in old versions of Graph Explorer + attributes: config.attributes.map(attr => { + const { displayLabel: _, ...attrRest } = attr as AttributeConfig & { + displayLabel?: string; + }; + return attrRest; + }), + } as TypeConfig; +} - return cloned; +function toMapByType( + items: T[] | undefined | null, +): Map { + return new Map(items?.map(item => [item.type, item])); } diff --git a/packages/graph-explorer/src/core/StateProvider/defaultStylingAtom.ts b/packages/graph-explorer/src/core/StateProvider/defaultStylingAtom.ts new file mode 100644 index 000000000..a92e8651b --- /dev/null +++ b/packages/graph-explorer/src/core/StateProvider/defaultStylingAtom.ts @@ -0,0 +1,15 @@ +import { atom } from "jotai"; + +import type { UserStyling } from "./userPreferences"; + +/** + * Read-only reference copy of the resolved styling from defaultStyling.json. + * + * On load, the file values are written into userStylingAtom (for types without + * existing overrides). This atom is kept only as a reference so that per-type + * "Reset to Default" can restore the file's original values. + * + * NOT persisted to LocalForage. Re-fetched each session. Remains null when + * no defaultStyling.json is mounted. + */ +export const defaultStylingAtom = atom(null); diff --git a/packages/graph-explorer/src/core/StateProvider/displayEdge.test.ts b/packages/graph-explorer/src/core/StateProvider/displayEdge.test.ts index 6040fafcd..4276d9bfb 100644 --- a/packages/graph-explorer/src/core/StateProvider/displayEdge.test.ts +++ b/packages/graph-explorer/src/core/StateProvider/displayEdge.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment happy-dom import type { QueryEngine } from "@shared/types"; import { createRandomDate, createRandomName } from "@shared/utils/testing"; diff --git a/packages/graph-explorer/src/core/StateProvider/displayTypeConfigs.test.ts b/packages/graph-explorer/src/core/StateProvider/displayTypeConfigs.test.ts index 50da19a4f..7c21963ee 100644 --- a/packages/graph-explorer/src/core/StateProvider/displayTypeConfigs.test.ts +++ b/packages/graph-explorer/src/core/StateProvider/displayTypeConfigs.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment happy-dom import { createRandomName } from "@shared/utils/testing"; import type { TextTransformer } from "@/hooks"; diff --git a/packages/graph-explorer/src/core/StateProvider/displayVertex.test.ts b/packages/graph-explorer/src/core/StateProvider/displayVertex.test.ts index c82e1ff54..020b513fe 100644 --- a/packages/graph-explorer/src/core/StateProvider/displayVertex.test.ts +++ b/packages/graph-explorer/src/core/StateProvider/displayVertex.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment happy-dom import type { QueryEngine } from "@shared/types"; import { createRandomDate, createRandomName } from "@shared/utils/testing"; diff --git a/packages/graph-explorer/src/core/StateProvider/featureFlags.test.ts b/packages/graph-explorer/src/core/StateProvider/featureFlags.test.ts new file mode 100644 index 000000000..05cdbb959 --- /dev/null +++ b/packages/graph-explorer/src/core/StateProvider/featureFlags.test.ts @@ -0,0 +1,14 @@ +// @vitest-environment happy-dom +import { renderHookWithState } from "@/utils/testing"; + +import { useFeatureFlags } from "./featureFlags"; + +describe("useFeatureFlags", () => { + test("should return default feature flags", () => { + const { result } = renderHookWithState(() => useFeatureFlags()); + expect(result.current).toStrictEqual({ + showDebugActions: false, + allowLoggingDbQuery: false, + }); + }); +}); diff --git a/packages/graph-explorer/src/core/StateProvider/index.ts b/packages/graph-explorer/src/core/StateProvider/index.ts index 786eda58d..113fd2342 100644 --- a/packages/graph-explorer/src/core/StateProvider/index.ts +++ b/packages/graph-explorer/src/core/StateProvider/index.ts @@ -1,5 +1,6 @@ export * from "./appStore"; export * from "./configuration"; +export * from "./defaultStylingAtom"; export * from "./displayAttribute"; export * from "./displayEdge"; export * from "./displayTypeConfigs"; @@ -15,3 +16,4 @@ export * from "./schema"; export * from "./storageAtoms"; export * from "./graphSession"; export * from "./userLayout"; +export * from "./userLayoutDefaults"; diff --git a/packages/graph-explorer/src/core/StateProvider/localDb.test.ts b/packages/graph-explorer/src/core/StateProvider/localDb.test.ts index 60abd5bd5..20cbe6383 100644 --- a/packages/graph-explorer/src/core/StateProvider/localDb.test.ts +++ b/packages/graph-explorer/src/core/StateProvider/localDb.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment happy-dom import { createRandomDate, createRandomInteger, @@ -234,30 +235,30 @@ test("Remove prefix from restored entries", async () => { }); /** Fake database using a map as the data source. */ -export function createFakeLocalDb( +function createFakeLocalDb( initialState?: Iterable | null, ): LocalDb { const map = new Map(initialState); return { - // eslint-disable-next-line @typescript-eslint/require-await + // oxlint-disable-next-line @typescript-eslint/require-await async getItem(key: string) { return map.get(key) as T; }, - // eslint-disable-next-line @typescript-eslint/require-await + // oxlint-disable-next-line @typescript-eslint/require-await async setItem(key: string, value: T) { map.set(key, value); return value; }, - // eslint-disable-next-line @typescript-eslint/require-await + // oxlint-disable-next-line @typescript-eslint/require-await async removeItem(key) { map.delete(key); }, - // eslint-disable-next-line @typescript-eslint/require-await + // oxlint-disable-next-line @typescript-eslint/require-await async clear() { map.clear(); }, - // eslint-disable-next-line @typescript-eslint/require-await + // oxlint-disable-next-line @typescript-eslint/require-await async keys() { return [...map.keys()]; }, diff --git a/packages/graph-explorer/src/core/StateProvider/neighbors.test.ts b/packages/graph-explorer/src/core/StateProvider/neighbors.test.ts index a4fcee5e7..f2b620745 100644 --- a/packages/graph-explorer/src/core/StateProvider/neighbors.test.ts +++ b/packages/graph-explorer/src/core/StateProvider/neighbors.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment happy-dom import { waitFor } from "@testing-library/react"; import type { NeighborCount } from "@/connector"; diff --git a/packages/graph-explorer/src/core/StateProvider/renderedEntities.test.ts b/packages/graph-explorer/src/core/StateProvider/renderedEntities.test.ts index 270bcc869..6e9c90e6b 100644 --- a/packages/graph-explorer/src/core/StateProvider/renderedEntities.test.ts +++ b/packages/graph-explorer/src/core/StateProvider/renderedEntities.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment happy-dom import { waitFor } from "@testing-library/react"; import { diff --git a/packages/graph-explorer/src/core/StateProvider/schema.test.ts b/packages/graph-explorer/src/core/StateProvider/schema.test.ts index 872245792..97a9d4e8f 100644 --- a/packages/graph-explorer/src/core/StateProvider/schema.test.ts +++ b/packages/graph-explorer/src/core/StateProvider/schema.test.ts @@ -1,4 +1,6 @@ +// @vitest-environment happy-dom import { createArray, createRandomName } from "@shared/utils/testing"; +import { act } from "@testing-library/react"; import { useAtomValue } from "jotai"; import type { IriNamespace, RdfPrefix } from "@/utils/rdf"; @@ -13,7 +15,6 @@ import { LABELS } from "@/utils"; import { createRandomEdge, createRandomEdgeConnection, - createRandomEntities, createRandomRawConfiguration, createRandomSchema, createRandomVertex, @@ -23,33 +24,39 @@ import { renderHookWithState, } from "@/utils/testing"; -import type { - EdgeTypeConfig, - PrefixTypeConfig, - VertexTypeConfig, +import { + createEdgeConnection, + createPrefixTypeConfig, + type EdgeTypeConfig, + type PrefixTypeConfig, + type VertexTypeConfig, } from "../ConfigurationProvider"; - import { createEdge, createEdgeType, createVertex, + createVertexId, createVertexType, type EntityProperties, } from "../entities"; import { + activeSchemaSelector, + createVertexTypeLookup, generateSchemaPrefixes, mapEdgeToTypeConfig, mapVertexToTypeConfigs, maybeActiveSchemaAtom, type SchemaStorageModel, - shouldUpdateSchemaFromEntities, updateSchemaFromEntities, useActiveSchema, useGraphSchema, useHasActiveSchema, useMaybeActiveSchema, + useUpdateSchemaFromEntities, } from "./schema"; +const EMPTY_VERTEX_TYPE_LOOKUP = createVertexTypeLookup(); + describe("schema", () => { describe("mapVertexToTypeConfigs", () => { it("should work with vertex", () => { @@ -119,12 +126,403 @@ describe("schema", () => { }); }); + describe("createVertexTypeLookup", () => { + it("should resolve vertex types by ID", () => { + const v1 = createVertex({ id: "v1", types: ["Person"], attributes: {} }); + const v2 = createVertex({ + id: "v2", + types: ["Dog", "Pet"], + attributes: {}, + }); + const map = new Map([ + [v1.id, v1], + [v2.id, v2], + ]); + + const lookup = createVertexTypeLookup(map); + + expect(lookup.get(v1.id)).toStrictEqual(v1.types); + expect(lookup.get(v2.id)).toStrictEqual(v2.types); + }); + + it("should chain multiple maps", () => { + const v1 = createVertex({ id: "v1", types: ["Person"], attributes: {} }); + const v2 = createVertex({ id: "v2", types: ["Dog"], attributes: {} }); + + const lookup = createVertexTypeLookup( + new Map([[v1.id, v1]]), + new Map([[v2.id, v2]]), + ); + + expect(lookup.get(v1.id)).toStrictEqual(v1.types); + expect(lookup.get(v2.id)).toStrictEqual(v2.types); + }); + + it("should return types from the first map that contains the vertex", () => { + const v1 = createVertex({ id: "v1", types: ["Person"], attributes: {} }); + const v1Duplicate = createVertex({ + id: "v1", + types: ["Employee"], + attributes: {}, + }); + + const lookup = createVertexTypeLookup( + new Map([[v1.id, v1]]), + new Map([[v1Duplicate.id, v1Duplicate]]), + ); + + expect(lookup.get(v1.id)).toStrictEqual(v1.types); + }); + + it("should return undefined for unknown vertex IDs", () => { + const v1 = createVertex({ id: "v1", types: ["Person"], attributes: {} }); + const lookup = createVertexTypeLookup(new Map([[v1.id, v1]])); + + expect(lookup.get(createVertexId("v99"))).toBeUndefined(); + }); + + it("should report isEmpty when all maps are empty", () => { + const lookup = createVertexTypeLookup(new Map(), new Map()); + + expect(lookup.isEmpty()).toBe(true); + }); + + it("should report not empty when any map has entries", () => { + const v1 = createVertex({ id: "v1", types: ["Person"], attributes: {} }); + const lookup = createVertexTypeLookup(new Map(), new Map([[v1.id, v1]])); + + expect(lookup.isEmpty()).toBe(false); + }); + }); + + describe("updateSchemaFromEntities edge connections", () => { + it("should not change edgeConnections when vertex lookup is empty", () => { + const schema = createRandomSchema(); + const edge = createRandomEdge(); + + const result = updateSchemaFromEntities( + { edges: [edge] }, + schema, + EMPTY_VERTEX_TYPE_LOOKUP, + ); + + expect(result.edgeConnections).toBe(schema.edgeConnections); + }); + + it("should infer connections when schema has no prior edgeConnections", () => { + const source = createVertex({ + id: "v1", + types: ["Person"], + attributes: {}, + }); + const target = createVertex({ id: "v2", types: ["Dog"], attributes: {} }); + const edge = createEdge({ + id: "e1", + type: "owner", + sourceId: "v1", + targetId: "v2", + attributes: {}, + }); + + const schema: SchemaStorageModel = { vertices: [], edges: [] }; + expect(schema.edgeConnections).toBeUndefined(); + + const vertexLookup = createVertexTypeLookup( + new Map([ + [source.id, source], + [target.id, target], + ]), + ); + + const result = updateSchemaFromEntities( + { edges: [edge], vertices: [source, target] }, + schema, + vertexLookup, + ); + + expect(result.edgeConnections).toStrictEqual([ + createEdgeConnection({ + source: "Person", + edge: "owner", + target: "Dog", + }), + ]); + }); + + it("should not change edgeConnections when entities have no edges", () => { + const schema = createRandomSchema(); + const vertex = createRandomVertex(); + + const vertexLookup = createVertexTypeLookup( + new Map([[vertex.id, vertex]]), + ); + + const result = updateSchemaFromEntities( + { vertices: [vertex] }, + schema, + vertexLookup, + ); + + expect(result.edgeConnections).toBe(schema.edgeConnections); + }); + + it("should infer edge connections from edges and vertex lookup", () => { + const source = createVertex({ + id: "v1", + types: ["Person"], + attributes: {}, + }); + const target = createVertex({ id: "v2", types: ["Dog"], attributes: {} }); + const edge = createEdge({ + id: "e1", + type: "owner", + sourceId: "v1", + targetId: "v2", + attributes: {}, + }); + + const schema: SchemaStorageModel = { vertices: [], edges: [] }; + const vertexLookup = createVertexTypeLookup( + new Map([ + [source.id, source], + [target.id, target], + ]), + ); + + const result = updateSchemaFromEntities( + { edges: [edge], vertices: [source, target] }, + schema, + vertexLookup, + ); + + expect(result.edgeConnections).toStrictEqual([ + createEdgeConnection({ + source: "Person", + edge: "owner", + target: "Dog", + }), + ]); + }); + + it("should create connections for all type combinations of multi-label vertices", () => { + const source = createVertex({ + id: "v1", + types: ["Person", "Employee"], + attributes: {}, + }); + const target = createVertex({ + id: "v2", + types: ["Company"], + attributes: {}, + }); + const edge = createEdge({ + id: "e1", + type: "worksAt", + sourceId: "v1", + targetId: "v2", + attributes: {}, + }); + + const schema: SchemaStorageModel = { vertices: [], edges: [] }; + const vertexLookup = createVertexTypeLookup( + new Map([ + [source.id, source], + [target.id, target], + ]), + ); + + const result = updateSchemaFromEntities( + { edges: [edge], vertices: [source, target] }, + schema, + vertexLookup, + ); + + expect(result.edgeConnections).toStrictEqual([ + createEdgeConnection({ + source: "Person", + edge: "worksAt", + target: "Company", + }), + createEdgeConnection({ + source: "Employee", + edge: "worksAt", + target: "Company", + }), + ]); + }); + + it("should skip edges where source or target vertex type cannot be resolved", () => { + const source = createVertex({ + id: "v1", + types: ["Person"], + attributes: {}, + }); + const edge = createEdge({ + id: "e1", + type: "knows", + sourceId: "v1", + targetId: "v2", + attributes: {}, + }); + + const schema: SchemaStorageModel = { + vertices: [], + edges: [], + edgeConnections: [], + }; + const vertexLookup = createVertexTypeLookup( + new Map([[source.id, source]]), + ); + + const result = updateSchemaFromEntities( + { edges: [edge], vertices: [source] }, + schema, + vertexLookup, + ); + + expect(result.edgeConnections).toBe(schema.edgeConnections); + }); + + it("should preserve existing edge connections including count", () => { + const existingConnection = createEdgeConnection({ + source: "Cat", + edge: "chases", + target: "Mouse", + count: 7, + }); + const source = createVertex({ + id: "v1", + types: ["Person"], + attributes: {}, + }); + const target = createVertex({ id: "v2", types: ["Dog"], attributes: {} }); + const edge = createEdge({ + id: "e1", + type: "owner", + sourceId: "v1", + targetId: "v2", + attributes: {}, + }); + + const schema: SchemaStorageModel = { + vertices: [], + edges: [], + edgeConnections: [existingConnection], + }; + const vertexLookup = createVertexTypeLookup( + new Map([ + [source.id, source], + [target.id, target], + ]), + ); + + const result = updateSchemaFromEntities( + { edges: [edge], vertices: [source, target] }, + schema, + vertexLookup, + ); + + expect(result.edgeConnections).toStrictEqual([ + existingConnection, + createEdgeConnection({ + source: "Person", + edge: "owner", + target: "Dog", + }), + ]); + }); + + it("should not add duplicate edge connections", () => { + const source = createVertex({ + id: "v1", + types: ["Person"], + attributes: {}, + }); + const target = createVertex({ id: "v2", types: ["Dog"], attributes: {} }); + const existingConnection = createEdgeConnection({ + source: "Person", + edge: "owner", + target: "Dog", + count: 42, + }); + const edge = createEdge({ + id: "e1", + type: "owner", + sourceId: "v1", + targetId: "v2", + attributes: {}, + }); + + const schema: SchemaStorageModel = { + vertices: [], + edges: [], + edgeConnections: [existingConnection], + }; + const vertexLookup = createVertexTypeLookup( + new Map([ + [source.id, source], + [target.id, target], + ]), + ); + + const result = updateSchemaFromEntities( + { edges: [edge], vertices: [source, target] }, + schema, + vertexLookup, + ); + + expect(result.edgeConnections).toBe(schema.edgeConnections); + }); + + it("should preserve edgeConnections reference when no new connections are discovered", () => { + const source = createVertex({ + id: "v1", + types: ["Person"], + attributes: {}, + }); + const target = createVertex({ id: "v2", types: ["Dog"], attributes: {} }); + const existingConnection = createEdgeConnection({ + source: "Person", + edge: "owner", + target: "Dog", + }); + const edge = createEdge({ + id: "e1", + type: "owner", + sourceId: "v1", + targetId: "v2", + attributes: {}, + }); + + const schema: SchemaStorageModel = { + vertices: [], + edges: [], + edgeConnections: [existingConnection], + }; + const vertexLookup = createVertexTypeLookup( + new Map([ + [source.id, source], + [target.id, target], + ]), + ); + + const result = updateSchemaFromEntities( + { edges: [edge], vertices: [source, target] }, + schema, + vertexLookup, + ); + + expect(result.edgeConnections).toBe(schema.edgeConnections); + }); + }); + describe("updateSchemaFromEntities", () => { it("should do nothing when no entities", () => { const originalSchema = createRandomSchema(); const result = updateSchemaFromEntities( { vertices: [], edges: [] }, originalSchema, + EMPTY_VERTEX_TYPE_LOOKUP, ); expect(result).toEqual(originalSchema); @@ -137,6 +535,7 @@ describe("schema", () => { const result = updateSchemaFromEntities( { vertices: newNodes, edges: newEdges }, originalSchema, + EMPTY_VERTEX_TYPE_LOOKUP, ); expect(result).toEqual({ @@ -164,6 +563,7 @@ describe("schema", () => { const result = updateSchemaFromEntities( { vertices: [newNode1, newNode2], edges: [newEdge1, newEdge2] }, originalSchema, + EMPTY_VERTEX_TYPE_LOOKUP, ); const node1Configs = mapVertexToTypeConfigs(newNode1); @@ -206,6 +606,7 @@ describe("schema", () => { const result = updateSchemaFromEntities( { vertices: [newNode], edges: [newEdge] }, originalSchema, + EMPTY_VERTEX_TYPE_LOOKUP, ); expect(result.vertices.flatMap(v => v.attributes)).toEqual( @@ -235,6 +636,7 @@ describe("schema", () => { const result = updateSchemaFromEntities( { vertices: newNodes, edges: [] }, originalSchema, + EMPTY_VERTEX_TYPE_LOOKUP, ); const emptyTypeVtConfigs = result.vertices.filter( @@ -258,11 +660,161 @@ describe("schema", () => { types: ["http://data.nobelprize.org/class/Country"], }); - const result = updateSchemaFromEntities({ vertices: [vertex] }, schema); + const result = updateSchemaFromEntities( + { vertices: [vertex] }, + schema, + EMPTY_VERTEX_TYPE_LOOKUP, + ); const prefixes = result.prefixes?.map(p => p.prefix); expect(prefixes).toContain("country"); }); + + it("should add all types from a multi-label vertex", () => { + const schema: SchemaStorageModel = { + vertices: [], + edges: [], + }; + + const vertex = createVertex({ + id: "1", + types: ["Person", "Employee"], + attributes: { name: "Alice" }, + }); + + const result = updateSchemaFromEntities( + { vertices: [vertex] }, + schema, + EMPTY_VERTEX_TYPE_LOOKUP, + ); + + expect(result.vertices).toHaveLength(2); + expect(result.vertices.map(v => v.type)).toStrictEqual([ + createVertexType("Person"), + createVertexType("Employee"), + ]); + expect(result.vertices[0].attributes).toStrictEqual([ + { name: "name", dataType: "String" }, + ]); + expect(result.vertices[1].attributes).toStrictEqual([ + { name: "name", dataType: "String" }, + ]); + }); + + it("should not regenerate prefixes for existing schema types", () => { + const schema: SchemaStorageModel = { + vertices: [ + { + type: createVertexType("http://example.com/ontology#Person"), + attributes: [ + { name: "http://example.com/ontology#name", dataType: "String" }, + ], + }, + ], + edges: [], + prefixes: [ + createPrefixTypeConfig({ + prefix: "ontology", + uri: "http://example.com/ontology#", + inferred: true, + }), + ], + }; + + const vertex = createVertex({ + id: "1", + types: ["http://example.com/ontology#Person"], + attributes: { + "http://example.com/ontology#name": "Alice", + }, + }); + + const result = updateSchemaFromEntities( + { vertices: [vertex] }, + schema, + EMPTY_VERTEX_TYPE_LOOKUP, + ); + + expect(result.prefixes).toBe(schema.prefixes); + }); + + it("should generate prefixes for new attribute namespaces on existing types", () => { + const schema: SchemaStorageModel = { + vertices: [ + { + type: createVertexType("http://example.com/ontology#Person"), + attributes: [ + { name: "http://example.com/ontology#name", dataType: "String" }, + ], + }, + ], + edges: [], + prefixes: [ + createPrefixTypeConfig({ + prefix: "ontology", + uri: "http://example.com/ontology#", + inferred: true, + }), + ], + }; + + const vertex = createVertex({ + id: "1", + types: ["http://example.com/ontology#Person"], + attributes: { + "http://new.example.com/props#age": 30, + }, + }); + + const result = updateSchemaFromEntities( + { vertices: [vertex] }, + schema, + EMPTY_VERTEX_TYPE_LOOKUP, + ); + + const newPrefixes = result.prefixes!.filter( + p => !schema.prefixes!.includes(p), + ); + expect(newPrefixes).toHaveLength(1); + expect(newPrefixes[0].prefix).toBe("props"); + }); + + it("should generate prefixes for new types but not existing ones", () => { + const schema: SchemaStorageModel = { + vertices: [ + { + type: createVertexType("http://example.com/ontology#Person"), + attributes: [], + }, + ], + edges: [], + prefixes: [ + createPrefixTypeConfig({ + prefix: "ontology", + uri: "http://example.com/ontology#", + inferred: true, + }), + ], + }; + + const vertex = createVertex({ + id: "http://example.com/resource/1", + types: ["http://example.com/classes#Employee"], + attributes: {}, + }); + + const result = updateSchemaFromEntities( + { vertices: [vertex] }, + schema, + EMPTY_VERTEX_TYPE_LOOKUP, + ); + + const newPrefixes = result.prefixes!.filter( + p => !schema.prefixes!.includes(p), + ); + expect(newPrefixes.length).toBeGreaterThan(0); + expect(newPrefixes.every(p => p.__inferred)).toBe(true); + }); }); describe("generateSchemaPrefixes", () => { @@ -281,24 +833,24 @@ describe("schema", () => { const result = generateSchemaPrefixes(iris, []); expect(result).toEqual([ - { - prefix: "vertex" as RdfPrefix, - uri: "http://abcdefg.com/vertex#" as IriNamespace, - __inferred: true, - }, - { - prefix: "edge" as RdfPrefix, - uri: "http://abcdefg.com/edge#" as IriNamespace, - __inferred: true, - }, + createPrefixTypeConfig({ + prefix: "vertex", + uri: "http://abcdefg.com/vertex#", + inferred: true, + }), + createPrefixTypeConfig({ + prefix: "edge", + uri: "http://abcdefg.com/edge#", + inferred: true, + }), ] satisfies PrefixTypeConfig[]); }); it("should not regenerate prefixes already covered by existing ones", () => { - const existingPrefix: PrefixTypeConfig = { - prefix: "custom" as RdfPrefix, - uri: "http://custom.example.com/" as IriNamespace, - }; + const existingPrefix = createPrefixTypeConfig({ + prefix: "custom", + uri: "http://custom.example.com/", + }); const result = generateSchemaPrefixes( new Set(["http://custom.example.com/Thing"]), @@ -307,73 +859,33 @@ describe("schema", () => { expect(result).toStrictEqual([]); }); - }); - - describe("shouldUpdateSchemaFromEntities", () => { - it("should return false when no entities are provided", () => { - const result = shouldUpdateSchemaFromEntities( - { vertices: [], edges: [] }, - createRandomSchema(), - ); - expect(result).toBeFalsy(); - }); - it("should return true when entities are provided", () => { - const entities = createRandomEntities(); - const result = shouldUpdateSchemaFromEntities( - entities, - createRandomSchema(), - ); - expect(result).toBeTruthy(); - }); + it("should append next numeral when prefix name collides with existing deduplicated prefixes", () => { + const existingPrefixes = [ + createPrefixTypeConfig({ + prefix: "country", + uri: "http://data.example.org/country/", + inferred: true, + }), + createPrefixTypeConfig({ + prefix: "country2", + uri: "http://stats.example.org/country/", + inferred: true, + }), + ]; - it("should return false when the vertex has an existing type", () => { - const schema = createRandomSchema(); - const vertex = createRandomVertex(); - vertex.type = schema.vertices[0].type; - vertex.attributes = schema.vertices[0].attributes.reduce((acc, attr) => { - acc[attr.name] = createRandomName("value"); - return acc; - }, {} as EntityProperties); - const result = shouldUpdateSchemaFromEntities( - { - vertices: [vertex], - edges: [], - }, - schema, + const result = generateSchemaPrefixes( + new Set(["http://geo.example.org/country/France"]), + existingPrefixes, ); - expect(result).toBeFalsy(); - }); - - it("should return false when the edge is an existing type", () => { - const schema = createRandomSchema(); - const source = createRandomVertex(); - const target = createRandomVertex(); - source.type = schema.vertices[0].type; - source.attributes = schema.vertices[0].attributes.reduce((acc, attr) => { - acc[attr.name] = createRandomName("value"); - return acc; - }, {} as EntityProperties); - target.type = schema.vertices[1].type; - target.attributes = schema.vertices[1].attributes.reduce((acc, attr) => { - acc[attr.name] = createRandomName("value"); - return acc; - }, {} as EntityProperties); - const edge = createRandomEdge(source, target); - edge.type = schema.edges[0].type; - edge.attributes = schema.edges[0].attributes.reduce((acc, attr) => { - acc[attr.name] = createRandomName("value"); - return acc; - }, {} as EntityProperties); - const result = shouldUpdateSchemaFromEntities( - { - vertices: [source, target], - edges: [edge], - }, - schema, - ); - expect(result).toBeFalsy(); + expect(result).toStrictEqual([ + createPrefixTypeConfig({ + prefix: "country3", + uri: "http://geo.example.org/country/", + inferred: true, + }), + ]); }); }); }); @@ -389,7 +901,11 @@ describe("referential integrity", () => { return acc; }, {} as EntityProperties); - const result = updateSchemaFromEntities({ vertices: [vertex] }, schema); + const result = updateSchemaFromEntities( + { vertices: [vertex] }, + schema, + EMPTY_VERTEX_TYPE_LOOKUP, + ); expect(result).toBe(schema); }); @@ -404,7 +920,11 @@ describe("referential integrity", () => { return acc; }, {} as EntityProperties); - const result = updateSchemaFromEntities({ vertices: [vertex] }, schema); + const result = updateSchemaFromEntities( + { vertices: [vertex] }, + schema, + EMPTY_VERTEX_TYPE_LOOKUP, + ); expect(result.vertices[0]).toBe(schema.vertices[0]); }); @@ -418,7 +938,11 @@ describe("referential integrity", () => { return acc; }, {} as EntityProperties); - const result = updateSchemaFromEntities({ edges: [edge] }, schema); + const result = updateSchemaFromEntities( + { edges: [edge] }, + schema, + EMPTY_VERTEX_TYPE_LOOKUP, + ); expect(result.edges[0]).toBe(schema.edges[0]); }); @@ -433,7 +957,11 @@ describe("referential integrity", () => { return acc; }, {} as EntityProperties); - const result = updateSchemaFromEntities({ vertices: [vertex] }, schema); + const result = updateSchemaFromEntities( + { vertices: [vertex] }, + schema, + EMPTY_VERTEX_TYPE_LOOKUP, + ); expect(result.vertices[0].attributes).toBe(schema.vertices[0].attributes); }); @@ -444,7 +972,11 @@ describe("referential integrity", () => { vertex.type = schema.vertices[0].type; vertex.types = [schema.vertices[0].type]; - const result = updateSchemaFromEntities({ vertices: [vertex] }, schema); + const result = updateSchemaFromEntities( + { vertices: [vertex] }, + schema, + EMPTY_VERTEX_TYPE_LOOKUP, + ); expect(result.vertices[0]).not.toBe(schema.vertices[0]); expect(result.vertices[0].attributes).not.toBe( @@ -457,11 +989,54 @@ describe("referential integrity", () => { const edge = createRandomEdge(); edge.type = schema.edges[0].type; - const result = updateSchemaFromEntities({ edges: [edge] }, schema); + const result = updateSchemaFromEntities( + { edges: [edge] }, + schema, + EMPTY_VERTEX_TYPE_LOOKUP, + ); expect(result.edges[0]).not.toBe(schema.edges[0]); expect(result.edges[0].attributes).not.toBe(schema.edges[0].attributes); }); + + it("should preserve vertices array reference when only edges change", () => { + const schema = createRandomSchema(); + const edge = createRandomEdge(); + + const result = updateSchemaFromEntities( + { edges: [edge] }, + schema, + EMPTY_VERTEX_TYPE_LOOKUP, + ); + + expect(result.vertices).toBe(schema.vertices); + }); + + it("should preserve edges array reference when only vertices change", () => { + const schema = createRandomSchema(); + const vertex = createRandomVertex(); + + const result = updateSchemaFromEntities( + { vertices: [vertex] }, + schema, + EMPTY_VERTEX_TYPE_LOOKUP, + ); + + expect(result.edges).toBe(schema.edges); + }); + + it("should preserve prefixes reference when no new prefixes needed", () => { + const schema = createRandomSchema(); + const vertex = createRandomVertex(); + + const result = updateSchemaFromEntities( + { vertices: [vertex] }, + schema, + EMPTY_VERTEX_TYPE_LOOKUP, + ); + + expect(result.prefixes).toBe(schema.prefixes); + }); }); describe("useGraphSchema edgeConnections", () => { @@ -559,6 +1134,157 @@ describe("useGraphSchema edgeConnections", () => { }); }); +describe("useUpdateSchemaFromEntities", () => { + it("should not churn schemaAtom when active config has no schema entry", () => { + const state = new DbState().withNoActiveSchema(); + + const { result } = renderHookWithState( + () => ({ + updateSchema: useUpdateSchemaFromEntities(), + schemaMap: useAtomValue(schemaAtom), + }), + state, + ); + + const schemaMapBefore = result.current.schemaMap; + + act(() => { + result.current.updateSchema({ + vertices: [createRandomVertex()], + }); + }); + + expect(result.current.schemaMap).toBe(schemaMapBefore); + }); + + it("should not churn schemaAtom when entities already match schema", () => { + const state = new DbState(); + const existingVertex = createRandomVertex(); + existingVertex.type = state.activeSchema.vertices[0].type; + existingVertex.types = [state.activeSchema.vertices[0].type]; + existingVertex.attributes = + state.activeSchema.vertices[0].attributes.reduce((acc, attr) => { + acc[attr.name] = createRandomName("value"); + return acc; + }, {} as EntityProperties); + + const { result } = renderHookWithState( + () => ({ + updateSchema: useUpdateSchemaFromEntities(), + schemaMap: useAtomValue(schemaAtom), + }), + state, + ); + + const schemaMapBefore = result.current.schemaMap; + + act(() => { + result.current.updateSchema({ + vertices: [existingVertex], + }); + }); + + expect(result.current.schemaMap).toBe(schemaMapBefore); + }); + + it("should infer edge connections from entities and canvas vertices", () => { + const state = new DbState(); + state.activeSchema.edgeConnections = []; + + // Source vertex is on the canvas + const source = createVertex({ + id: "v1", + types: ["Person"], + attributes: {}, + }); + state.addVertexToGraph(source); + + // Target vertex and edge arrive via entities + const target = createVertex({ + id: "v2", + types: ["Dog"], + attributes: {}, + }); + const edge = createEdge({ + id: "e1", + type: "owner", + sourceId: "v1", + targetId: "v2", + attributes: {}, + }); + + const { result } = renderHookWithState( + () => ({ + updateSchema: useUpdateSchemaFromEntities(), + schema: useAtomValue(activeSchemaSelector), + }), + state, + ); + + act(() => { + result.current.updateSchema({ + vertices: [target], + edges: [edge], + }); + }); + + expect(result.current.schema?.edgeConnections).toStrictEqual([ + createEdgeConnection({ source: "Person", edge: "owner", target: "Dog" }), + ]); + }); + + it("should prefer entity vertices over canvas vertices for edge connections", () => { + const state = new DbState(); + state.activeSchema.edgeConnections = []; + + // Canvas has v1 as "Employee" + const canvasSource = createVertex({ + id: "v1", + types: ["Employee"], + attributes: {}, + }); + state.addVertexToGraph(canvasSource); + + // Entities provide v1 as "Person" (should take priority) + const entitySource = createVertex({ + id: "v1", + types: ["Person"], + attributes: {}, + }); + const target = createVertex({ + id: "v2", + types: ["Dog"], + attributes: {}, + }); + const edge = createEdge({ + id: "e1", + type: "owner", + sourceId: "v1", + targetId: "v2", + attributes: {}, + }); + + const { result } = renderHookWithState( + () => ({ + updateSchema: useUpdateSchemaFromEntities(), + schema: useAtomValue(activeSchemaSelector), + }), + state, + ); + + act(() => { + result.current.updateSchema({ + vertices: [entitySource, target], + edges: [edge], + }); + }); + + expect(result.current.schema?.edgeConnections).toStrictEqual([ + createEdgeConnection({ source: "Person", edge: "owner", target: "Dog" }), + ]); + }); +}); + describe("useHasActiveSchema", () => { test("should return false when schema has no lastUpdate", () => { const state = new DbState(); @@ -737,11 +1463,11 @@ describe("backward compatibility: legacy __matches on prefixes", () => { // New prefix should be generated expect(result).toStrictEqual([ - { - prefix: "vertex" as RdfPrefix, - uri: "http://newdomain.com/vertex#" as IriNamespace, - __inferred: true, - }, + createPrefixTypeConfig({ + prefix: "vertex", + uri: "http://newdomain.com/vertex#", + inferred: true, + }), ]); }); @@ -784,16 +1510,22 @@ describe("backward compatibility: legacy __matches on prefixes", () => { attributes: {}, }); - const result = updateSchemaFromEntities({ vertices: [vertex] }, schema); + const result = updateSchemaFromEntities( + { vertices: [vertex] }, + schema, + EMPTY_VERTEX_TYPE_LOOKUP, + ); // Legacy prefix should be preserved expect(result.prefixes?.[0]).toBe(legacyPrefix); // New prefix should be appended for the new namespace expect(result.prefixes).toHaveLength(2); - expect(result.prefixes?.[1]).toStrictEqual({ - prefix: "vertex" as RdfPrefix, - uri: "http://new.example.com/vertex#" as IriNamespace, - __inferred: true, - }); + expect(result.prefixes?.[1]).toStrictEqual( + createPrefixTypeConfig({ + prefix: "vertex", + uri: "http://new.example.com/vertex#", + inferred: true, + }), + ); }); }); diff --git a/packages/graph-explorer/src/core/StateProvider/schema.ts b/packages/graph-explorer/src/core/StateProvider/schema.ts index 2cbd59101..6511bc9a0 100644 --- a/packages/graph-explorer/src/core/StateProvider/schema.ts +++ b/packages/graph-explorer/src/core/StateProvider/schema.ts @@ -26,10 +26,13 @@ import { type EntityProperties, schemaAtom, type Vertex, + type VertexId, type VertexType, } from "@/core"; import { logger } from "@/utils"; -import { generatePrefixes, PrefixLookup, splitIri } from "@/utils/rdf"; +import { generatePrefixes, PrefixLookup } from "@/utils/rdf"; + +import { nodesAtom, toNodeMap } from "./nodes"; /** * Persisted schema state for a database connection. @@ -252,21 +255,23 @@ export const activeSchemaSelector = atom( return; } set(schemaAtom, prevSchemaMap => { - const updatedSchemaMap = new Map(prevSchemaMap); - const prev = updatedSchemaMap.get(schemaId); + const prev = prevSchemaMap.get(schemaId); const newValue = typeof update === "function" ? update(prev) : update; - // Handle reset value or undefined + if (newValue === prev) { + return prevSchemaMap; + } + + const updatedSchemaMap = new Map(prevSchemaMap); + if (newValue === RESET || !newValue) { + if (!prev) { + return prevSchemaMap; + } updatedSchemaMap.delete(schemaId); return updatedSchemaMap; } - if (newValue === prev) { - return prevSchemaMap; - } - - // Update the map updatedSchemaMap.set(schemaId, newValue); return updatedSchemaMap; @@ -274,10 +279,11 @@ export const activeSchemaSelector = atom( }, ); -/** Updates the schema based on the given nodes and edges. */ +/** Updates the schema based on the given nodes and edges. Preserves referential equality at every level when nothing changes. */ export function updateSchemaFromEntities( entities: Partial, schema: SchemaStorageModel, + vertexLookup: VertexTypeLookup, ) { const vertices = entities.vertices ?? []; const edges = entities.edges ?? []; @@ -286,131 +292,299 @@ export function updateSchemaFromEntities( return schema; } - const newVertexConfigs = vertices.flatMap(mapVertexToTypeConfigs); - const newEdgeConfigs = edges.map(mapEdgeToTypeConfig); - - const mergedVertices = merge(schema.vertices, newVertexConfigs); - const mergedEdges = merge(schema.edges, newEdgeConfigs); + const { configs: mergedVertices, newIris: vertexIris } = mergeVertices( + schema.vertices, + vertices, + ); + const { configs: mergedEdges, newIris: edgeIris } = mergeEdges( + schema.edges, + edges, + ); - // Generate new prefixes for the schema changes and resource IRIs const existingPrefixes = schema.prefixes ?? []; - const entityUris = getEntityUris(entities); - const schemaUris = getSchemaUris({ - vertices: mergedVertices, - edges: mergedEdges, - }); - const newPrefixes = generateSchemaPrefixes( - entityUris.union(schemaUris), + const mergedPrefixes = mergePrefixes( existingPrefixes, + entities, + vertexIris, + edgeIris, + ); + + const existingConnections = schema.edgeConnections ?? []; + const mergedConnections = mergeEdgeConnections( + existingConnections, + edges, + vertexLookup, ); if ( mergedVertices === schema.vertices && mergedEdges === schema.edges && - newPrefixes.length === 0 + mergedPrefixes === existingPrefixes && + mergedConnections === existingConnections ) { + logger.debug("Schema already up to date with given entities"); return schema; } - const newSchema = { + const result = { ...schema, vertices: mergedVertices, edges: mergedEdges, prefixes: - newPrefixes.length > 0 - ? [...existingPrefixes, ...newPrefixes] - : schema.prefixes, + mergedPrefixes !== existingPrefixes ? mergedPrefixes : schema.prefixes, + edgeConnections: + mergedConnections !== existingConnections + ? mergedConnections + : schema.edgeConnections, }; - logger.debug("Updated schema:", { newSchema, prevSchema: schema }); - return newSchema; + logger.debug("Updated schema from entities", result); + return result; +} + +/** Resolves a vertex ID to its type labels without copying vertex data. */ +export type VertexTypeLookup = { + get(id: VertexId): VertexType[] | undefined; + isEmpty(): boolean; +}; + +/** Creates a lookup that chains multiple vertex maps, checking each in order. Earlier maps take priority. */ +export function createVertexTypeLookup( + ...sources: ReadonlyMap[] +): VertexTypeLookup { + return { + get(id) { + for (const source of sources) { + const vertex = source.get(id); + if (vertex) { + return vertex.types; + } + } + return undefined; + }, + isEmpty() { + return sources.every(s => s.size === 0); + }, + }; } -/** Merges new node or edge configs in to a set of existing node or edge configs. */ -function merge( - existing: T[], - newConfigs: T[], -): T[] { - const configMap = new Map(existing.map(vt => [vt.type, vt])); +/** Infers and merges new edge connections from edges and a vertex type lookup. Preserves existing entries including their count. */ +function mergeEdgeConnections( + existing: EdgeConnection[], + edges: Edge[], + vertexLookup: VertexTypeLookup, +): EdgeConnection[] { + // Fast-path: skip work when there are no edges or no vertices to resolve against + if (edges.length === 0 || vertexLookup.isEmpty()) { + return existing; + } + + const existingIds = new Set(existing.map(createEdgeConnectionId)); + const newConnections: EdgeConnection[] = []; + + for (const edge of edges) { + const sourceTypes = vertexLookup.get(edge.sourceId); + const targetTypes = vertexLookup.get(edge.targetId); + if (!sourceTypes || !targetTypes) { + continue; + } + + for (const sourceVertexType of sourceTypes) { + for (const targetVertexType of targetTypes) { + const connection: EdgeConnection = { + sourceVertexType, + edgeType: edge.type, + targetVertexType, + }; + const id = createEdgeConnectionId(connection); + if (!existingIds.has(id)) { + existingIds.add(id); + newConnections.push(connection); + } + } + } + } + + if (newConnections.length === 0) { + return existing; + } + + return [...existing, ...newConnections]; +} + +type MergeResult = { + configs: T[]; + newIris: Set; +}; + +/** Merges new vertex entities into existing vertex type configs. */ +function mergeVertices( + existing: VertexTypeConfig[], + vertices: Vertex[], +): MergeResult { + if (!vertices.length) { + return { configs: existing, newIris: new Set() }; + } + + const byType = new Map(existing.map(v => [v.type, v])); + const newIris = new Set(); let hasChanges = false; - for (const newConfig of newConfigs) { - const existingConfig = configMap.get(newConfig.type); + for (const vertex of vertices) { + const attrs = attributesFromProperties(vertex.attributes); + for (const type of vertex.types) { + const existingConfig = byType.get(type); + if (!existingConfig) { + logger.debug("Discovered new vertex type:", type); + byType.set(type, { type, attributes: attrs }); + newIris.add(type); + for (const attr of attrs) { + newIris.add(attr.name); + } + hasChanges = true; + } else { + const mergedAttrs = mergeAttributesFromProperties( + existingConfig.attributes, + vertex.attributes, + ); + if (mergedAttrs !== existingConfig.attributes) { + logger.debug("Discovered new attributes for vertex type:", type); + byType.set(type, { ...existingConfig, attributes: mergedAttrs }); + // Only the newly added attributes need IRI scanning + for ( + let i = existingConfig.attributes.length; + i < mergedAttrs.length; + i++ + ) { + newIris.add(mergedAttrs[i].name); + } + hasChanges = true; + } + } + } + } + + return { + configs: hasChanges ? Array.from(byType.values()) : existing, + newIris, + }; +} + +/** Merges new edge entities into existing edge type configs. */ +function mergeEdges( + existing: EdgeTypeConfig[], + edges: Edge[], +): MergeResult { + if (!edges.length) { + return { configs: existing, newIris: new Set() }; + } + + const byType = new Map(existing.map(e => [e.type, e])); + const newIris = new Set(); + let hasChanges = false; + + for (const edge of edges) { + const existingConfig = byType.get(edge.type); if (!existingConfig) { - configMap.set(newConfig.type, newConfig); + logger.debug("Discovered new edge type:", edge.type); + byType.set(edge.type, { + type: edge.type, + attributes: attributesFromProperties(edge.attributes), + }); + newIris.add(edge.type); hasChanges = true; } else { - const mergedAttributes = mergeAttributes( + const mergedAttrs = mergeAttributesFromProperties( existingConfig.attributes, - newConfig.attributes, + edge.attributes, ); - if (mergedAttributes === existingConfig.attributes) { - continue; + if (mergedAttrs !== existingConfig.attributes) { + logger.debug("Discovered new attributes for edge type:", edge.type); + byType.set(edge.type, { ...existingConfig, attributes: mergedAttrs }); + hasChanges = true; } - configMap.set(newConfig.type, { - ...existingConfig, - attributes: mergedAttributes, - }); - hasChanges = true; } } - // Return original array if nothing changed - return hasChanges ? Array.from(configMap.values()) : existing; + return { + configs: hasChanges ? Array.from(byType.values()) : existing, + newIris, + }; +} + +/** Generates and merges new RDF prefixes from entity IRIs and newly-discovered schema IRIs. */ +function mergePrefixes( + existing: PrefixTypeConfig[], + entities: Partial, + vertexIris: Set, + edgeIris: Set, +): PrefixTypeConfig[] { + const iris = new Set(vertexIris); + + for (const iri of edgeIris) { + iris.add(iri); + } + for (const v of entities.vertices ?? []) { + iris.add(String(v.id)); + } + for (const e of entities.edges ?? []) { + iris.add(String(e.id)); + } + + const newPrefixes = generateSchemaPrefixes(iris, existing); + if (newPrefixes.length === 0) { + return existing; + } + + logger.debug( + "Discovered new prefixes:", + newPrefixes.map(p => p.prefix), + ); + return [...existing, ...newPrefixes]; } -export function mergeAttributes( +/** Merges entity properties into an existing attribute list. Preserves existing dataType on conflicts. */ +function mergeAttributesFromProperties( existing: AttributeConfig[], - newAttributes: AttributeConfig[], + properties: EntityProperties, ): AttributeConfig[] { - const attrMap = new Map(existing.map(attr => [attr.name, attr])); - let hasChanges = false; + const existingNames = new Set(existing.map(a => a.name)); + const newAttrs: AttributeConfig[] = []; - for (const newAttr of newAttributes) { - const existingAttr = attrMap.get(newAttr.name); - if (!existingAttr) { - attrMap.set(newAttr.name, newAttr); - hasChanges = true; - } else if ( - existingAttr.name === newAttr.name && - existingAttr.dataType !== newAttr.dataType - ) { - continue; - } else { - // Check if merge would actually change anything - const merged = { ...existingAttr, ...newAttr }; - if ( - merged.name === existingAttr.name && - merged.dataType === existingAttr.dataType - ) { - continue; - } - attrMap.set(newAttr.name, merged); - hasChanges = true; + for (const name of Object.keys(properties)) { + if (!existingNames.has(name)) { + newAttrs.push({ name, dataType: detectDataType(properties[name]) }); } } - // Return original array if nothing changed - return hasChanges ? Array.from(attrMap.values()) : existing; + if (newAttrs.length === 0) { + return existing; + } + + return [...existing, ...newAttrs]; +} + +/** Converts entity properties to an attribute config array. */ +function attributesFromProperties( + properties: EntityProperties, +): AttributeConfig[] { + return Object.entries(properties).map(([name, value]) => ({ + name, + dataType: detectDataType(value), + })); } export function mapVertexToTypeConfigs(vertex: Vertex): VertexTypeConfig[] { return vertex.types.map(type => ({ type, - attributes: Object.entries(vertex.attributes).map(([name, value]) => ({ - name, - dataType: detectDataType(value), - })), + attributes: attributesFromProperties(vertex.attributes), })); } export function mapEdgeToTypeConfig(edge: Edge): EdgeTypeConfig { return { type: edge.type, - attributes: Object.entries(edge.attributes).map(([name, value]) => ({ - name, - dataType: detectDataType(value), - })), + attributes: attributesFromProperties(edge.attributes), }; } @@ -450,8 +624,6 @@ export function generateSchemaPrefixes( return []; } - logger.debug("Generated new prefixes:", newPrefixes); - return newPrefixes; } @@ -462,28 +634,14 @@ export function getSchemaUris(schema: { }) { const result = new Set(); - schema.vertices.forEach(v => { + for (const v of schema.vertices) { result.add(v.type); - v.attributes.forEach(attr => { + for (const attr of v.attributes) { result.add(attr.name); - }); - }); - schema.edges.forEach(e => { - result.add(e.type); - }); - - return result; -} - -/** Collects IDs from entities. */ -function getEntityUris(entities: Partial) { - const result = new Set(); - - for (const v of entities.vertices ?? []) { - result.add(String(v.id)); + } } - for (const e of entities.edges ?? []) { - result.add(String(e.id)); + for (const e of schema.edges) { + result.add(e.type); } return result; @@ -495,26 +653,21 @@ export function useUpdateSchemaFromEntities() { useCallback((get, set, entities: Partial) => { const vertices = entities.vertices ?? []; const edges = entities.edges ?? []; - const activeSchema = get(activeSchemaSelector); if (vertices.length === 0 && edges.length === 0) { return; } - if (!activeSchema) { - return; - } - if ( - !shouldUpdateSchemaFromEntities(entities, activeSchema) && - !hasNewPrefixNamespaces(entities, get(prefixesAtom)) - ) { - logger.debug("Schema is already up to date with the given entities"); - return; - } - logger.debug("Updating schema from entities"); + + // Incoming entities take priority over canvas vertices + const vertexLookup = createVertexTypeLookup( + toNodeMap(vertices), + get(nodesAtom), + ); + set(activeSchemaSelector, prev => { if (!prev) { return prev; } - return updateSchemaFromEntities(entities, prev); + return updateSchemaFromEntities(entities, prev, vertexLookup); }); }, []), ); @@ -523,85 +676,3 @@ export function useUpdateSchemaFromEntities() { export type UpdateSchemaHandler = ReturnType< typeof useUpdateSchemaFromEntities >; - -/** Attempts to efficiently detect if the schema should be updated. */ -export function shouldUpdateSchemaFromEntities( - entities: Partial, - schema: SchemaStorageModel, -) { - const vertices = entities.vertices ?? []; - const edges = entities.edges ?? []; - if (vertices.length > 0) { - // Check if the vertex types and attributes are the same - const fromEntities = getUniqueTypesAndAttributes(vertices); - const fromSchema = getUniqueTypesAndAttributes(schema.vertices); - - if (!fromSchema.isSupersetOf(fromEntities)) { - logger.debug( - "Found new vertex types or attributes:", - fromEntities.difference(fromSchema), - ); - return true; - } - } - - if (edges.length > 0) { - // Check if the edge types and attributes are the same - const fromEntities = getUniqueTypesAndAttributes(edges); - const fromSchema = getUniqueTypesAndAttributes(schema.edges); - - if (!fromSchema.isSupersetOf(fromEntities)) { - logger.debug( - "Found new edge types or attributes:", - fromEntities.difference(fromSchema), - ); - return true; - } - } - - return false; -} - -/** Checks if any entity IDs have namespaces not yet covered by existing prefixes. */ -function hasNewPrefixNamespaces( - entities: Partial, - prefixes: PrefixLookup, -) { - for (const v of entities.vertices ?? []) { - const parts = splitIri(String(v.id)); - if (parts && !prefixes.findPrefix(parts.namespace)) { - return true; - } - } - for (const e of entities.edges ?? []) { - const parts = splitIri(String(e.id)); - if (parts && !prefixes.findPrefix(parts.namespace)) { - return true; - } - } - return false; -} - -/** - * Creates a set of unique types and attribute names as a set of strings in order to be used for comparisons. - * - * The entries in the set will be in the format of `vertexType.attributeName` or `edgeType.attributeName`. - */ -function getUniqueTypesAndAttributes( - entities: (Vertex | Edge | VertexTypeConfig | EdgeTypeConfig)[], -) { - return new Set( - entities.flatMap(e => { - return [ - e.type, - ...getAttributeNames(e.attributes).map(a => `${e.type}.${a}`), - ]; - }), - ); -} - -function getAttributeNames(attributes: EntityProperties | AttributeConfig[]) { - return Array.isArray(attributes) - ? attributes.map(a => a.name) - : Object.keys(attributes); -} diff --git a/packages/graph-explorer/src/core/StateProvider/storageAtoms.test.ts b/packages/graph-explorer/src/core/StateProvider/storageAtoms.test.ts new file mode 100644 index 000000000..886546b93 --- /dev/null +++ b/packages/graph-explorer/src/core/StateProvider/storageAtoms.test.ts @@ -0,0 +1,64 @@ +import { createStore } from "jotai"; + +import { + activeConfigurationAtom, + allGraphSessionsAtom, + allowLoggingDbQueryAtom, + configurationAtom, + defaultNeighborExpansionLimitAtom, + defaultNeighborExpansionLimitEnabledAtom, + diagnosticLoggingAtom, + schemaAtom, + showDebugActionsAtom, + userLayoutAtom, + userStylingAtom, +} from "./storageAtoms"; +import { defaultUserLayout } from "./userLayoutDefaults"; + +/** + * storageAtoms.ts uses top-level await to preload IndexedDB data into Jotai + * atoms. These tests verify that all atoms are properly initialized and + * readable from a fresh store, which guards against circular dependency + * regressions that would leave atoms as `undefined`. + */ +describe("storageAtoms", () => { + it("should initialize all atoms as defined values", () => { + expect(activeConfigurationAtom).toBeDefined(); + expect(configurationAtom).toBeDefined(); + expect(schemaAtom).toBeDefined(); + expect(userStylingAtom).toBeDefined(); + expect(userLayoutAtom).toBeDefined(); + expect(allGraphSessionsAtom).toBeDefined(); + expect(showDebugActionsAtom).toBeDefined(); + expect(allowLoggingDbQueryAtom).toBeDefined(); + expect(defaultNeighborExpansionLimitEnabledAtom).toBeDefined(); + expect(defaultNeighborExpansionLimitAtom).toBeDefined(); + expect(diagnosticLoggingAtom).toBeDefined(); + }); + + it("should provide correct default values from a fresh store", () => { + const store = createStore(); + + expect(store.get(activeConfigurationAtom)).toBeNull(); + expect(store.get(configurationAtom)).toStrictEqual(new Map()); + expect(store.get(schemaAtom)).toStrictEqual(new Map()); + expect(store.get(userStylingAtom)).toStrictEqual({}); + expect(store.get(userLayoutAtom)).toStrictEqual(defaultUserLayout); + expect(store.get(allGraphSessionsAtom)).toStrictEqual(new Map()); + expect(store.get(showDebugActionsAtom)).toBe(false); + expect(store.get(allowLoggingDbQueryAtom)).toBe(false); + expect(store.get(defaultNeighborExpansionLimitEnabledAtom)).toBe(true); + expect(store.get(defaultNeighborExpansionLimitAtom)).toBe(10); + expect(store.get(diagnosticLoggingAtom)).toBe(false); + }); + + it("should persist a written value on subsequent reads", () => { + const store = createStore(); + + store.set(showDebugActionsAtom, true); + expect(store.get(showDebugActionsAtom)).toBe(true); + + store.set(showDebugActionsAtom, false); + expect(store.get(showDebugActionsAtom)).toBe(false); + }); +}); diff --git a/packages/graph-explorer/src/core/StateProvider/storageAtoms.ts b/packages/graph-explorer/src/core/StateProvider/storageAtoms.ts index a5be64737..0831b0b6c 100644 --- a/packages/graph-explorer/src/core/StateProvider/storageAtoms.ts +++ b/packages/graph-explorer/src/core/StateProvider/storageAtoms.ts @@ -2,14 +2,12 @@ import type { ConfigurationId, RawConfiguration, } from "../ConfigurationProvider"; +import type { GraphSessionStorageModel } from "./graphSession/storage"; +import type { SchemaStorageModel } from "./schema"; +import type { UserStyling } from "./userPreferences"; import { atomWithLocalForage } from "./atomWithLocalForage"; -import { - defaultUserLayout, - type GraphSessionStorageModel, - type SchemaStorageModel, - type UserStyling, -} from "./index"; +import { defaultUserLayout } from "./userLayoutDefaults"; /** DEV NOTE @@ -58,6 +56,7 @@ const [ allowLoggingDbQueryAtom, defaultNeighborExpansionLimitEnabledAtom, defaultNeighborExpansionLimitAtom, + diagnosticLoggingAtom, ] = await Promise.all([ atomWithLocalForage("active-configuration", null), atomWithLocalForage>( @@ -78,12 +77,14 @@ const [ */ /** Shows debug actions in various places around the app. */ atomWithLocalForage("showDebugActions", false), - /** Shows debug actions in various places around the app. */ + /** Enables logging of generated database queries on the proxy server. */ atomWithLocalForage("allowLoggingDbQuery", false), /** Setting that enables/disables the default limit for neighbor expansion. */ atomWithLocalForage("defaultNeighborExpansionLimitEnabled", true), /** Setting that defines the default limit for neighbor expansion. */ atomWithLocalForage("defaultNeighborExpansionLimit", 10), + /** Enables verbose diagnostic logging to the browser console. */ + atomWithLocalForage("diagnosticLogging", false), ]); export { @@ -97,4 +98,5 @@ export { allowLoggingDbQueryAtom, defaultNeighborExpansionLimitEnabledAtom, defaultNeighborExpansionLimitAtom, + diagnosticLoggingAtom, }; diff --git a/packages/graph-explorer/src/core/StateProvider/useResetState.test.ts b/packages/graph-explorer/src/core/StateProvider/useResetState.test.ts new file mode 100644 index 000000000..a047e60d9 --- /dev/null +++ b/packages/graph-explorer/src/core/StateProvider/useResetState.test.ts @@ -0,0 +1,137 @@ +// @vitest-environment happy-dom +import { useQueryClient } from "@tanstack/react-query"; +import { act } from "@testing-library/react"; +import { describe, expect, test } from "vitest"; + +import { getAppStore } from "@/core/StateProvider/appStore"; +import { selectedTabAtom } from "@/modules/SearchSidebar"; +import { queryTextAtom } from "@/modules/SearchSidebar/QuerySearchTabContent"; +import { + partialMatchAtom, + searchTermAtom, + selectedAttributeAtom, + selectedVertexTypeAtom, +} from "@/modules/SearchSidebar/useKeywordSearch"; +import { + createTestableEdge, + createTestableVertex, + DbState, + renderHookWithState, +} from "@/utils/testing"; + +import { + edgesAtom, + edgesFilteredIdsAtom, + edgesOutOfFocusIdsAtom, + edgesSelectedIdsAtom, + edgesTableFiltersAtom, + edgesTableSortsAtom, + edgesTypesFilteredAtom, +} from "./edges"; +import { isRestorePreviousSessionAvailableAtom } from "./graphSession"; +import { + nodesAtom, + nodesFilteredIdsAtom, + nodesOutOfFocusIdsAtom, + nodesSelectedIdsAtom, + nodesTableFiltersAtom, + nodesTableSortsAtom, + nodesTypesFilteredAtom, +} from "./nodes"; +import useResetState from "./useResetState"; + +describe("useResetState", () => { + test("should reset all node atoms", () => { + const state = new DbState(); + const vertex = createTestableVertex(); + state.addTestableVertexToGraph(vertex); + state.filterVertex(vertex.asVertex().id); + state.filterVertexType(vertex.asVertex().types[0]); + + const { result } = renderHookWithState(() => useResetState(), state); + const store = getAppStore(); + + expect(store.get(nodesAtom).size).toBeGreaterThan(0); + + act(() => result.current()); + + expect(store.get(nodesAtom).size).toBe(0); + expect(store.get(nodesSelectedIdsAtom).size).toBe(0); + expect(store.get(nodesOutOfFocusIdsAtom).size).toBe(0); + expect(store.get(nodesFilteredIdsAtom).size).toBe(0); + expect(store.get(nodesTypesFilteredAtom).size).toBe(0); + expect(store.get(nodesTableFiltersAtom)).toStrictEqual([]); + expect(store.get(nodesTableSortsAtom)).toStrictEqual([]); + }); + + test("should reset all edge atoms", () => { + const state = new DbState(); + const edge = createTestableEdge(); + state.addTestableEdgeToGraph(edge); + state.filterEdge(edge.asEdge().id); + state.filterEdgeType(edge.asEdge().type); + + const { result } = renderHookWithState(() => useResetState(), state); + const store = getAppStore(); + + expect(store.get(edgesAtom).size).toBeGreaterThan(0); + + act(() => result.current()); + + expect(store.get(edgesAtom).size).toBe(0); + expect(store.get(edgesSelectedIdsAtom).size).toBe(0); + expect(store.get(edgesOutOfFocusIdsAtom).size).toBe(0); + expect(store.get(edgesFilteredIdsAtom).size).toBe(0); + expect(store.get(edgesTypesFilteredAtom).size).toBe(0); + expect(store.get(edgesTableFiltersAtom)).toStrictEqual([]); + expect(store.get(edgesTableSortsAtom)).toStrictEqual([]); + }); + + test("should reset search and query editor atoms", () => { + const { result } = renderHookWithState(() => useResetState()); + const store = getAppStore(); + + store.set(searchTermAtom, "test search"); + store.set(selectedVertexTypeAtom, "Person"); + store.set(selectedAttributeAtom, "name"); + store.set(partialMatchAtom, true); + store.set(selectedTabAtom, "query"); + store.set(queryTextAtom, "MATCH (n) RETURN n"); + + act(() => result.current()); + + expect(store.get(searchTermAtom)).toBe(""); + expect(store.get(selectedVertexTypeAtom)).toBe("__all"); + expect(store.get(selectedAttributeAtom)).toBe("__id"); + expect(store.get(partialMatchAtom)).toBe(false); + expect(store.get(selectedTabAtom)).toBe("filter"); + expect(store.get(queryTextAtom)).toBe(""); + }); + + test("should reset previous session availability", () => { + const { result } = renderHookWithState(() => useResetState()); + const store = getAppStore(); + + store.set(isRestorePreviousSessionAvailableAtom, false); + + act(() => result.current()); + + expect(store.get(isRestorePreviousSessionAvailableAtom)).toBe(true); + }); + + test("should remove all queries from the query client", () => { + const { result } = renderHookWithState(() => { + const queryClient = useQueryClient(); + const resetState = useResetState(); + return { queryClient, resetState }; + }); + + // Seed a query so there's something to remove + result.current.queryClient.setQueryData(["test-query"], "test-data"); + expect(result.current.queryClient.getQueryCache().getAll()).toHaveLength(1); + + act(() => result.current.resetState()); + + expect(result.current.queryClient.getQueryCache().getAll()).toHaveLength(0); + }); +}); diff --git a/packages/graph-explorer/src/core/StateProvider/userLayout.test.ts b/packages/graph-explorer/src/core/StateProvider/userLayout.test.ts index 7f924b671..021609fdb 100644 --- a/packages/graph-explorer/src/core/StateProvider/userLayout.test.ts +++ b/packages/graph-explorer/src/core/StateProvider/userLayout.test.ts @@ -1,5 +1,4 @@ -import type { ExtractAtomValue } from "jotai"; - +// @vitest-environment happy-dom import { act } from "react"; import { @@ -8,17 +7,22 @@ import { renderHookWithState, } from "@/utils/testing"; -import { userLayoutAtom } from "./storageAtoms"; -import { useSidebar, useViewToggles } from "./userLayout"; +import type { UserLayout } from "./userLayoutDefaults"; -type UserLayout = ExtractAtomValue; +import { userLayoutAtom } from "./storageAtoms"; +import { + useSidebar, + useSidebarSize, + useTableViewSize, + useViewToggles, +} from "./userLayout"; describe("useViewToggles", () => { it("should default to both views open", () => { const { result } = renderHookWithState(() => useViewToggles()); - expect(result.current.isGraphVisible).toBeTruthy(); - expect(result.current.isTableVisible).toBeTruthy(); + expect(result.current.isGraphVisible).toBe(true); + expect(result.current.isTableVisible).toBe(true); }); it("should toggle graph view", () => { @@ -26,13 +30,13 @@ describe("useViewToggles", () => { act(() => result.current.toggleGraphVisibility()); - expect(result.current.isGraphVisible).toBeFalsy(); - expect(result.current.isTableVisible).toBeTruthy(); + expect(result.current.isGraphVisible).toBe(false); + expect(result.current.isTableVisible).toBe(true); act(() => result.current.toggleGraphVisibility()); - expect(result.current.isGraphVisible).toBeTruthy(); - expect(result.current.isTableVisible).toBeTruthy(); + expect(result.current.isGraphVisible).toBe(true); + expect(result.current.isTableVisible).toBe(true); }); it("should toggle table view", () => { @@ -40,13 +44,13 @@ describe("useViewToggles", () => { act(() => result.current.toggleTableVisibility()); - expect(result.current.isGraphVisible).toBeTruthy(); - expect(result.current.isTableVisible).toBeFalsy(); + expect(result.current.isGraphVisible).toBe(true); + expect(result.current.isTableVisible).toBe(false); act(() => result.current.toggleTableVisibility()); - expect(result.current.isGraphVisible).toBeTruthy(); - expect(result.current.isTableVisible).toBeTruthy(); + expect(result.current.isGraphVisible).toBe(true); + expect(result.current.isTableVisible).toBe(true); }); }); @@ -57,7 +61,7 @@ describe("useSidebar", () => { expect(result.current.isSidebarOpen).toBe(true); }); - it("should change to the give sidebar item", () => { + it("should change to the given sidebar item", () => { const { result } = renderHookWithJotai(() => useSidebar()); act(() => result.current.toggleSidebar("details")); @@ -133,3 +137,54 @@ describe("useSidebar", () => { expect(result.current.shouldShowNamespaces).toBe(false); }); }); + +describe("useTableViewSize", () => { + it("should default to DEFAULT_TABLE_VIEW_HEIGHT", () => { + const { result } = renderHookWithState(() => useTableViewSize()); + + expect(result.current[0]).toBe(300); + }); + + it("should return 100% when graph viewer is hidden", () => { + const { result } = renderHookWithState(() => ({ + tableView: useTableViewSize(), + toggles: useViewToggles(), + })); + + act(() => result.current.toggles.toggleGraphVisibility()); + + expect(result.current.tableView[0]).toBe("100%"); + }); + + it("should adjust height by delta", () => { + const { result } = renderHookWithState(() => useTableViewSize()); + + act(() => result.current[1](50)); + + expect(result.current[0]).toBe(350); + + act(() => result.current[1](-100)); + + expect(result.current[0]).toBe(250); + }); +}); + +describe("useSidebarSize", () => { + it("should default to 400", () => { + const { result } = renderHookWithState(() => useSidebarSize()); + + expect(result.current[0]).toBe(400); + }); + + it("should adjust width by delta", () => { + const { result } = renderHookWithState(() => useSidebarSize()); + + act(() => result.current[1](100)); + + expect(result.current[0]).toBe(500); + + act(() => result.current[1](-200)); + + expect(result.current[0]).toBe(300); + }); +}); diff --git a/packages/graph-explorer/src/core/StateProvider/userLayout.ts b/packages/graph-explorer/src/core/StateProvider/userLayout.ts index 5035df1d2..e9a0d0f8c 100644 --- a/packages/graph-explorer/src/core/StateProvider/userLayout.ts +++ b/packages/graph-explorer/src/core/StateProvider/userLayout.ts @@ -2,41 +2,12 @@ import { useAtom } from "jotai"; import { useQueryEngine } from "../connector"; import { userLayoutAtom } from "./storageAtoms"; - -export type ToggleableView = "graph-viewer" | "table-view"; - -type UserLayout = { - activeToggles: Set; - activeSidebarItem: - | "search" - | "details" - | "filters" - | "expand" - | "nodes-styling" - | "edges-styling" - | "namespaces" - | null; - tableView?: { - height: number; - }; - sidebar?: { - width: number; - }; - detailsAutoOpenOnSelection?: boolean; -}; - -export type SidebarItems = UserLayout["activeSidebarItem"]; - -export const DEFAULT_TABLE_VIEW_HEIGHT = 300; - -export const defaultUserLayout: UserLayout = { - activeToggles: new Set(["graph-viewer", "table-view"]), - activeSidebarItem: "search", - detailsAutoOpenOnSelection: true, - tableView: { - height: DEFAULT_TABLE_VIEW_HEIGHT, - }, -}; +import { + DEFAULT_SIDEBAR_WIDTH, + DEFAULT_TABLE_VIEW_HEIGHT, + type SidebarItems, + type ToggleableView, +} from "./userLayoutDefaults"; export function useViewToggles() { const [userLayout, setUserLayout] = useAtom(userLayoutAtom); @@ -75,7 +46,7 @@ export function useTableViewSize() { const tableViewHeight = !userLayout.activeToggles.has("graph-viewer") ? "100%" - : userLayout.tableView?.height || DEFAULT_TABLE_VIEW_HEIGHT; + : (userLayout.tableView?.height ?? DEFAULT_TABLE_VIEW_HEIGHT); /** Sets the table view height to the current height + the given delta */ const setTableViewHeight = (deltaHeight: number) => @@ -84,7 +55,7 @@ export function useTableViewSize() { return { ...prev, tableView: { - ...(prev.tableView || {}), + ...prev.tableView, height: prevHeight + deltaHeight, }, }; @@ -138,15 +109,12 @@ export function useSidebar() { }; } -export const DEFAULT_SIDEBAR_WIDTH = 400; -export const CLOSED_SIDEBAR_WIDTH = 50; - export function useSidebarSize() { const [userLayout, setUserLayout] = useAtom(userLayoutAtom); const sidebarWidth = userLayout.sidebar?.width ?? DEFAULT_SIDEBAR_WIDTH; - /** Sets the sidebar width to the current with + the given delta */ + /** Sets the sidebar width to the current width + the given delta */ const setSidebarWidth = (deltaWidth: number) => { setUserLayout(prev => { const prevWidth = prev.sidebar?.width ?? DEFAULT_SIDEBAR_WIDTH; diff --git a/packages/graph-explorer/src/core/StateProvider/userLayoutDefaults.ts b/packages/graph-explorer/src/core/StateProvider/userLayoutDefaults.ts new file mode 100644 index 000000000..0213859d6 --- /dev/null +++ b/packages/graph-explorer/src/core/StateProvider/userLayoutDefaults.ts @@ -0,0 +1,45 @@ +/** The two main content views that can be toggled on or off. */ +export type ToggleableView = "graph-viewer" | "table-view"; + +/** Identifiers for the sidebar panels, or null when the sidebar is closed. */ +export type SidebarItems = + | "search" + | "details" + | "filters" + | "expand" + | "nodes-styling" + | "edges-styling" + | "namespaces" + | null; + +/** Persisted layout preferences for the main application shell. */ +export type UserLayout = { + activeToggles: Set; + activeSidebarItem: SidebarItems; + tableView?: { + height: number; + }; + sidebar?: { + width: number; + }; + detailsAutoOpenOnSelection?: boolean; +}; + +/** Default height for the table view panel in pixels. */ +export const DEFAULT_TABLE_VIEW_HEIGHT = 300; + +/** Default width for the sidebar panel in pixels. */ +export const DEFAULT_SIDEBAR_WIDTH = 400; + +/** Width of the sidebar when collapsed. */ +export const CLOSED_SIDEBAR_WIDTH = 50; + +/** Initial layout state used when no persisted layout exists. */ +export const defaultUserLayout: UserLayout = { + activeToggles: new Set(["graph-viewer", "table-view"]), + activeSidebarItem: "search", + detailsAutoOpenOnSelection: true, + tableView: { + height: DEFAULT_TABLE_VIEW_HEIGHT, + }, +}; diff --git a/packages/graph-explorer/src/core/StateProvider/userPreferences.test.ts b/packages/graph-explorer/src/core/StateProvider/userPreferences.test.ts index 00ba721d6..8bec96347 100644 --- a/packages/graph-explorer/src/core/StateProvider/userPreferences.test.ts +++ b/packages/graph-explorer/src/core/StateProvider/userPreferences.test.ts @@ -1,3 +1,5 @@ +// @vitest-environment happy-dom +import { useAtomValue } from "jotai"; import { act } from "react"; import { createEdgeType, createVertexType } from "@/core"; @@ -6,9 +8,12 @@ import { DbState, renderHookWithState } from "@/utils/testing"; import { defaultEdgePreferences, defaultVertexPreferences, + edgePreferencesAtom, type EdgePreferencesStorageModel, + mergeDefaultsIntoUserStyling, useEdgeStyling, useVertexStyling, + vertexPreferencesAtom, type VertexPreferencesStorageModel, } from "./userPreferences"; @@ -339,6 +344,172 @@ describe("useEdgeStyling", () => { }); }); +describe("default styling", () => { + it("should apply default styling when no user pref exists", () => { + const dbState = new DbState(); + dbState.setDefaultStyling({ + vertices: [ + { type: createVertexType("test"), color: "red", shape: "diamond" }, + ], + }); + + const { result } = renderHookWithState( + () => useVertexStyling(createVertexType("test")), + dbState, + ); + + expect(result.current.vertexStyle.color).toBe("red"); + expect(result.current.vertexStyle.shape).toBe("diamond"); + }); + + it("should let user prefs override default styling", () => { + const dbState = new DbState(); + dbState.setDefaultStyling({ + vertices: [ + { type: createVertexType("test"), color: "red", shape: "diamond" }, + ], + }); + dbState.addVertexStyle(createVertexType("test"), { color: "blue" }); + + const { result } = renderHookWithState( + () => useVertexStyling(createVertexType("test")), + dbState, + ); + + // User pref overrides default styling color + expect(result.current.vertexStyle.color).toBe("blue"); + // Default styling shape still applies since user didn't override it + expect(result.current.vertexStyle.shape).toBe("diamond"); + }); + + it("should reveal default styling after reset", () => { + const dbState = new DbState(); + dbState.setDefaultStyling({ + vertices: [{ type: createVertexType("test"), color: "red" }], + }); + dbState.addVertexStyle(createVertexType("test"), { color: "blue" }); + + const { result } = renderHookWithState( + () => useVertexStyling(createVertexType("test")), + dbState, + ); + + expect(result.current.vertexStyle.color).toBe("blue"); + + act(() => result.current.resetVertexStyle()); + + // After reset, default styling color should be visible + expect(result.current.vertexStyle.color).toBe("red"); + }); + + it("should fall through to hardcoded defaults when no default styling", () => { + const dbState = new DbState(); + // No default styling set + + const { result } = renderHookWithState( + () => useVertexStyling(createVertexType("test")), + dbState, + ); + + expect(result.current.vertexStyle).toStrictEqual( + createExpectedVertex({ type: createVertexType("test") }), + ); + }); + + it("should apply default edge styling", () => { + const dbState = new DbState(); + dbState.setDefaultStyling({ + edges: [ + { type: createEdgeType("test"), lineColor: "green", lineThickness: 5 }, + ], + }); + + const { result } = renderHookWithState( + () => useEdgeStyling(createEdgeType("test")), + dbState, + ); + + expect(result.current.edgeStyle.lineColor).toBe("green"); + expect(result.current.edgeStyle.lineThickness).toBe(5); + }); + + it("should let user edge prefs override default edge styling", () => { + const dbState = new DbState(); + dbState.setDefaultStyling({ + edges: [ + { type: createEdgeType("test"), lineColor: "green", lineThickness: 5 }, + ], + }); + dbState.addEdgeStyle(createEdgeType("test"), { lineColor: "red" }); + + const { result } = renderHookWithState( + () => useEdgeStyling(createEdgeType("test")), + dbState, + ); + + expect(result.current.edgeStyle.lineColor).toBe("red"); + expect(result.current.edgeStyle.lineThickness).toBe(5); + }); +}); + +describe("mergeDefaultsIntoUserStyling", () => { + it("should add default types when user has none", () => { + const result = mergeDefaultsIntoUserStyling( + {}, + { + vertices: [{ type: createVertexType("A"), color: "red" }], + edges: [{ type: createEdgeType("B"), lineColor: "green" }], + }, + ); + expect(result.vertices).toHaveLength(1); + expect(result.vertices![0].color).toBe("red"); + expect(result.edges).toHaveLength(1); + expect(result.edges![0].lineColor).toBe("green"); + }); + + it("should merge properties when user has partial override", () => { + const result = mergeDefaultsIntoUserStyling( + { + vertices: [{ type: createVertexType("A"), color: "blue" }], + }, + { + vertices: [ + { type: createVertexType("A"), color: "red", shape: "diamond" }, + ], + }, + ); + expect(result.vertices).toHaveLength(1); + expect(result.vertices![0].color).toBe("blue"); // user wins + expect(result.vertices![0].shape).toBe("diamond"); // default fills in + }); + + it("should not modify types not in defaults", () => { + const result = mergeDefaultsIntoUserStyling( + { + vertices: [{ type: createVertexType("A"), color: "blue" }], + edges: [{ type: createEdgeType("X"), lineColor: "red" }], + }, + { + vertices: [{ type: createVertexType("B"), color: "green" }], + }, + ); + expect(result.vertices).toHaveLength(2); + expect(result.vertices![0].color).toBe("blue"); + expect(result.vertices![1].color).toBe("green"); + expect(result.edges).toHaveLength(1); + expect(result.edges![0].lineColor).toBe("red"); + }); + + it("should handle empty defaults", () => { + const input = { + vertices: [{ type: createVertexType("A"), color: "blue" }], + }; + const result = mergeDefaultsIntoUserStyling(input, {}); + expect(result.vertices).toHaveLength(1); + expect(result.edges).toHaveLength(0); + }); +}); + describe("useDeferredAtom integration", () => { it("should handle multiple rapid updates correctly", () => { const dbState = new DbState(); @@ -394,3 +565,65 @@ describe("useDeferredAtom integration", () => { ); }); }); + +describe("vertexPreferencesAtom", () => { + it("should return stored preferences for a known type", () => { + const dbState = new DbState(); + const vertexType = createVertexType("Person"); + dbState.addVertexStyle(vertexType, { color: "#ff0000" }); + + const { result } = renderHookWithState( + () => useAtomValue(vertexPreferencesAtom), + dbState, + ); + + expect(result.current.get(vertexType)).toStrictEqual( + createExpectedVertex({ type: vertexType, color: "#ff0000" }), + ); + }); + + it("should return defaults for an unknown type", () => { + const dbState = new DbState(); + const vertexType = createVertexType("Unknown"); + + const { result } = renderHookWithState( + () => useAtomValue(vertexPreferencesAtom), + dbState, + ); + + expect(result.current.get(vertexType)).toStrictEqual( + createExpectedVertex({ type: vertexType }), + ); + }); +}); + +describe("edgePreferencesAtom", () => { + it("should return stored preferences for a known type", () => { + const dbState = new DbState(); + const edgeType = createEdgeType("KNOWS"); + dbState.addEdgeStyle(edgeType, { lineColor: "#00ff00" }); + + const { result } = renderHookWithState( + () => useAtomValue(edgePreferencesAtom), + dbState, + ); + + expect(result.current.get(edgeType)).toStrictEqual( + createExpectedEdge({ type: edgeType, lineColor: "#00ff00" }), + ); + }); + + it("should return defaults for an unknown type", () => { + const dbState = new DbState(); + const edgeType = createEdgeType("Unknown"); + + const { result } = renderHookWithState( + () => useAtomValue(edgePreferencesAtom), + dbState, + ); + + expect(result.current.get(edgeType)).toStrictEqual( + createExpectedEdge({ type: edgeType }), + ); + }); +}); diff --git a/packages/graph-explorer/src/core/StateProvider/userPreferences.ts b/packages/graph-explorer/src/core/StateProvider/userPreferences.ts index 54e386bdf..10f24ff9c 100644 --- a/packages/graph-explorer/src/core/StateProvider/userPreferences.ts +++ b/packages/graph-explorer/src/core/StateProvider/userPreferences.ts @@ -9,6 +9,7 @@ import DEFAULT_ICON_URL from "@/utils/defaultIconUrl"; import type { EdgeType, VertexType } from "../entities"; +import { defaultStylingAtom } from "./defaultStylingAtom"; import { useActiveSchema } from "./schema"; import { userStylingAtom } from "./storageAtoms"; @@ -158,19 +159,72 @@ export type UserStyling = { edges?: Array; }; -/** Get the stored user preferences for vertices and edges in a fast lookup Map. */ -function useStoredGraphPreferences() { - const graphPreferences = useAtomValue(userStylingAtom); - const vertices = new Map( - graphPreferences.vertices?.map(v => [v.type, v]) ?? [], - ); - const edges = new Map(graphPreferences.edges?.map(e => [e.type, e]) ?? []); - const result = { vertices, edges }; - const deferredResult = useDeferredValue(result); - return deferredResult; +/** + * Merges default styling from defaultStyling.json into user styling. + * Default values fill in properties the user hasn't explicitly set; + * existing user overrides win via spread order. + */ +export function mergeDefaultsIntoUserStyling( + userStyling: UserStyling, + defaults: UserStyling, +): UserStyling { + const vertices = [...(userStyling.vertices ?? [])]; + for (const v of defaults.vertices ?? []) { + const existingIndex = vertices.findIndex(e => e.type === v.type); + if (existingIndex >= 0) { + vertices[existingIndex] = { ...v, ...vertices[existingIndex] }; + } else { + vertices.push(v); + } + } + + const edges = [...(userStyling.edges ?? [])]; + for (const e of defaults.edges ?? []) { + const existingIndex = edges.findIndex(x => x.type === e.type); + if (existingIndex >= 0) { + edges[existingIndex] = { ...e, ...edges[existingIndex] }; + } else { + edges.push(e); + } + } + + return { vertices, edges }; } -/** Combines the stored user preferences with the defined default values. */ +/** Vertex preferences indexed by type for O(1) lookup with default fallback. */ +export const vertexPreferencesAtom = atom(get => { + const userStyling = get(userStylingAtom); + const lookup = new Map( + userStyling.vertices?.map(v => [ + v.type, + createVertexPreference(v.type, v), + ]) ?? [], + ); + return { + get(type: VertexType) { + return lookup.get(type) ?? createVertexPreference(type); + }, + }; +}); + +/** Edge preferences indexed by type for O(1) lookup with default fallback. */ +export const edgePreferencesAtom = atom(get => { + const userStyling = get(userStylingAtom); + const lookup = new Map( + userStyling.edges?.map(e => [e.type, createEdgePreference(e.type, e)]) ?? + [], + ); + return { + get(type: EdgeType) { + return lookup.get(type) ?? createEdgePreference(type); + }, + }; +}); + +/** + * Combines hardcoded defaults with user preferences. + * User preferences include values populated from defaultStyling.json on load. + */ export function createVertexPreference( type: VertexType, stored?: VertexPreferencesStorageModel, @@ -182,7 +236,10 @@ export function createVertexPreference( } as const; } -/** Combines the stored user preferences with the defined default values. */ +/** + * Combines hardcoded defaults with user preferences. + * User preferences include values populated from defaultStyling.json on load. + */ export function createEdgePreference( type: EdgeType, stored?: EdgePreferencesStorageModel, @@ -196,22 +253,16 @@ export function createEdgePreference( /** Returns an array of vertex preferences based on the known vertex types in the schema. */ export function useAllVertexPreferences(): VertexPreferences[] { - const { vertices: allPreferences } = useStoredGraphPreferences(); + const prefs = useAtomValue(vertexPreferencesAtom); const { vertices: allSchemas } = useActiveSchema(); - - return allSchemas.map(({ type }) => - createVertexPreference(type, allPreferences.get(type)), - ); + return allSchemas.map(({ type }) => prefs.get(type)); } /** Returns an array of edge preferences based on the known edge types in the schema. */ export function useAllEdgePreferences(): EdgePreferences[] { - const { edges: allPreferences } = useStoredGraphPreferences(); + const prefs = useAtomValue(edgePreferencesAtom); const { edges: allSchemas } = useActiveSchema(); - - return allSchemas.map(({ type }) => - createEdgePreference(type, allPreferences.get(type)), - ); + return allSchemas.map(({ type }) => prefs.get(type)); } /** Returns the user preferences for the specified vertex type. */ @@ -228,22 +279,14 @@ export function useEdgePreferences(type: EdgeType): EdgePreferences { * Returns the user preferences for the specified vertex type. */ export const vertexPreferenceByTypeAtom = atomFamily((type: VertexType) => - atom(get => { - const userStyling = get(userStylingAtom); - const stored = userStyling.vertices?.find(v => v.type === type); - return createVertexPreference(type, stored); - }), + atom(get => get(vertexPreferencesAtom).get(type)), ); /** * Returns the user preferences for the specified edge type. */ export const edgePreferenceByTypeAtom = atomFamily((type: EdgeType) => - atom(get => { - const userStyling = get(userStylingAtom); - const stored = userStyling.edges?.find(e => e.type === type); - return createEdgePreference(type, stored); - }), + atom(get => get(edgePreferencesAtom).get(type)), ); type UpdatedVertexStyle = Partial>; @@ -256,6 +299,7 @@ type UpdatedVertexStyle = Partial>; */ export function useVertexStyling(type: VertexType) { const setAllStyling = useSetAtom(userStylingAtom); + const defaultStyling = useAtomValue(defaultStylingAtom); const vertexStyle = useVertexPreferences(type); const setVertexStyle = (updatedStyle: UpdatedVertexStyle) => @@ -279,9 +323,17 @@ export function useVertexStyling(type: VertexType) { const resetVertexStyle = () => setAllStyling(prev => { + // Restore from defaultStyling.json if available, otherwise remove entirely + // (which falls back to hardcoded defaults) + const defaultForType = defaultStyling?.vertices?.find( + v => v.type === type, + ); + const withoutCurrent = prev.vertices?.filter(v => v.type !== type) ?? []; return { ...prev, - vertices: prev.vertices?.filter(v => v.type !== type), + vertices: defaultForType + ? [...withoutCurrent, defaultForType] + : withoutCurrent, }; }); @@ -302,6 +354,7 @@ type UpdatedEdgeStyle = Omit; */ export function useEdgeStyling(type: EdgeType) { const setAllStyling = useSetAtom(userStylingAtom); + const defaultStyling = useAtomValue(defaultStylingAtom); const edgeStyle = useEdgePreferences(type); const setEdgeStyle = (updatedStyle: UpdatedEdgeStyle) => @@ -325,9 +378,15 @@ export function useEdgeStyling(type: EdgeType) { const resetEdgeStyle = () => setAllStyling(prev => { + // Restore from defaultStyling.json if available, otherwise remove entirely + // (which falls back to hardcoded defaults) + const defaultForType = defaultStyling?.edges?.find(e => e.type === type); + const withoutCurrent = prev.edges?.filter(e => e.type !== type) ?? []; return { ...prev, - edges: prev.edges?.filter(v => v.type !== type), + edges: defaultForType + ? [...withoutCurrent, defaultForType] + : withoutCurrent, }; }); diff --git a/packages/graph-explorer/src/core/defaultConnection.test.ts b/packages/graph-explorer/src/core/defaultConnection.test.ts index 2342eeb4c..30f637b54 100644 --- a/packages/graph-explorer/src/core/defaultConnection.test.ts +++ b/packages/graph-explorer/src/core/defaultConnection.test.ts @@ -82,6 +82,30 @@ describe("DefaultConnectionDataSchema", () => { GRAPH_EXP_PUBLIC_OR_PROXY_ENDPOINT: "", }); }); + + test("should preserve path in GRAPH_EXP_CONNECTION_URL", () => { + const data = { + ...createRandomDefaultConnectionData(), + GRAPH_EXP_CONNECTION_URL: + "http://blazegraph:9999/blazegraph/namespace/kb", + }; + const actual = DefaultConnectionDataSchema.parse(data); + expect(actual.GRAPH_EXP_CONNECTION_URL).toBe( + "http://blazegraph:9999/blazegraph/namespace/kb", + ); + }); + + test("should preserve path in GRAPH_EXP_PUBLIC_OR_PROXY_ENDPOINT", () => { + const data = { + ...createRandomDefaultConnectionData(), + GRAPH_EXP_PUBLIC_OR_PROXY_ENDPOINT: + "http://localhost:8080/proxy/explorer", + }; + const actual = DefaultConnectionDataSchema.parse(data); + expect(actual.GRAPH_EXP_PUBLIC_OR_PROXY_ENDPOINT).toBe( + "http://localhost:8080/proxy/explorer", + ); + }); }); function createRandomDefaultConnectionData() { diff --git a/packages/graph-explorer/src/core/defaultConnection.ts b/packages/graph-explorer/src/core/defaultConnection.ts index 93125a636..775f8246f 100644 --- a/packages/graph-explorer/src/core/defaultConnection.ts +++ b/packages/graph-explorer/src/core/defaultConnection.ts @@ -51,14 +51,14 @@ export async function fetchDefaultConnection() { // No query engine was specified, so return all the possible ones const configs = queryEngineOptions.map(queryEngine => { - return { + return { ...config, id: `${config.id}-${queryEngine}` as ConfigurationId, connection: { ...config.connection, queryEngine: queryEngine, }, - }; + } as RawConfiguration; }); return configs; diff --git a/packages/graph-explorer/src/core/defaultStyling.test.ts b/packages/graph-explorer/src/core/defaultStyling.test.ts new file mode 100644 index 000000000..58f09c7b0 --- /dev/null +++ b/packages/graph-explorer/src/core/defaultStyling.test.ts @@ -0,0 +1,418 @@ +// @vitest-environment happy-dom +import { + DefaultStylingSchema, + fetchDefaultStyling, + parseDefaultStyling, + resolveDefaultStyling, + userStylingToExportFormat, +} from "./defaultStyling"; +import { createEdgeType, createVertexType } from "./entities"; + +describe("DefaultStylingSchema", () => { + it("should accept a valid complete config", () => { + const data = { + vertices: { + User: { + color: "#1565C0", + icon: "user", + shape: "ellipse", + backgroundOpacity: 0.4, + borderWidth: 2, + borderColor: "#000000", + borderStyle: "solid", + }, + }, + edges: { + OWNS: { + lineColor: "#2E7D32", + lineThickness: 3, + lineStyle: "dashed", + sourceArrowStyle: "none", + targetArrowStyle: "triangle", + }, + }, + }; + + const result = DefaultStylingSchema.safeParse(data); + expect(result.success).toBe(true); + }); + + it("should accept an empty object", () => { + const result = DefaultStylingSchema.safeParse({}); + expect(result.success).toBe(true); + }); + + it("should accept partial vertex entries", () => { + const data = { + vertices: { + User: { color: "#1565C0" }, + }, + }; + const result = DefaultStylingSchema.safeParse(data); + expect(result.success).toBe(true); + }); + + it("should accept partial edge entries", () => { + const data = { + edges: { + OWNS: { lineColor: "#2E7D32" }, + }, + }; + const result = DefaultStylingSchema.safeParse(data); + expect(result.success).toBe(true); + }); + + it("should accept vertex entries with zero values", () => { + const data = { + vertices: { + User: { backgroundOpacity: 0, borderWidth: 0 }, + }, + }; + const result = DefaultStylingSchema.safeParse(data); + assert(result.success); + expect(result.data.vertices?.User.backgroundOpacity).toBe(0); + expect(result.data.vertices?.User.borderWidth).toBe(0); + }); + + it("should reject unknown top-level properties", () => { + const data = { unknownProp: "value" }; + const result = DefaultStylingSchema.safeParse(data); + expect(result.success).toBe(false); + }); + + it("should reject unknown vertex properties", () => { + const data = { + vertices: { + User: { unknownProp: "value" }, + }, + }; + const result = DefaultStylingSchema.safeParse(data); + expect(result.success).toBe(false); + }); + + it("should reject invalid backgroundOpacity", () => { + const data = { + vertices: { + User: { backgroundOpacity: 2 }, + }, + }; + const result = DefaultStylingSchema.safeParse(data); + expect(result.success).toBe(false); + }); + + it("should reject invalid borderStyle", () => { + const data = { + vertices: { + User: { borderStyle: "wavy" }, + }, + }; + const result = DefaultStylingSchema.safeParse(data); + expect(result.success).toBe(false); + }); + + it("should reject invalid shape", () => { + const data = { + vertices: { + User: { shape: "banana" }, + }, + }; + const result = DefaultStylingSchema.safeParse(data); + expect(result.success).toBe(false); + }); + + it("should accept valid shape values", () => { + const shapes = [ + "ellipse", + "rectangle", + "round-rectangle", + "diamond", + "star", + ]; + for (const shape of shapes) { + const data = { vertices: { User: { shape } } }; + const result = DefaultStylingSchema.safeParse(data); + expect(result.success).toBe(true); + } + }); + + it("should reject invalid arrow style", () => { + const data = { + edges: { + OWNS: { targetArrowStyle: "star" }, + }, + }; + const result = DefaultStylingSchema.safeParse(data); + expect(result.success).toBe(false); + }); +}); + +describe("fetchDefaultStyling", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("should return parsed data on successful fetch", async () => { + const mockData = { vertices: { User: { color: "#1565C0" } } }; + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify(mockData), { status: 200 }), + ); + const result = await fetchDefaultStyling(); + expect(result).not.toBeNull(); + expect(result?.vertices?.User.color).toBe("#1565C0"); + }); + + it("should return null on 404", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response("Not Found", { status: 404 }), + ); + const result = await fetchDefaultStyling(); + expect(result).toBeNull(); + }); + + it("should return null on non-404 error status", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response("Server Error", { status: 500 }), + ); + const result = await fetchDefaultStyling(); + expect(result).toBeNull(); + }); + + it("should return null on fetch exception", async () => { + vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("Network error")); + const result = await fetchDefaultStyling(); + expect(result).toBeNull(); + }); + + it("should return null for invalid JSON response", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response('{"vertices": "invalid"}', { status: 200 }), + ); + const result = await fetchDefaultStyling(); + expect(result).toBeNull(); + }); +}); + +describe("parseDefaultStyling", () => { + it("should return parsed data for valid input", () => { + const data = { + vertices: { + User: { color: "#1565C0", icon: "user" }, + }, + }; + const result = parseDefaultStyling(data); + expect(result).not.toBeNull(); + expect(result?.vertices?.User.color).toBe("#1565C0"); + }); + + it("should return null for invalid input", () => { + const result = parseDefaultStyling({ vertices: "invalid" }); + expect(result).toBeNull(); + }); + + it("should return null for non-object input", () => { + const result = parseDefaultStyling("not an object"); + expect(result).toBeNull(); + }); + + it("should return null for null input", () => { + const result = parseDefaultStyling(null); + expect(result).toBeNull(); + }); +}); + +describe("resolveDefaultStyling", () => { + it("should resolve lucide icon names to data URIs", async () => { + const data = { + vertices: { + User: { icon: "user", color: "#1565C0" }, + }, + }; + const result = await resolveDefaultStyling(data); + + expect(result.vertices).toHaveLength(1); + expect(result.vertices?.[0].type).toBe(createVertexType("User")); + expect(result.vertices?.[0].iconUrl).toMatch( + /^data:image\/svg\+xml;base64,/, + ); + expect(result.vertices?.[0].iconImageType).toBe("image/svg+xml"); + expect(result.vertices?.[0].color).toBe("#1565C0"); + }); + + it("should prefer explicit iconUrl over lucide icon name", async () => { + const data = { + vertices: { + User: { + icon: "user", + iconUrl: "https://example.com/icon.svg", + iconImageType: "image/svg+xml", + }, + }, + }; + const result = await resolveDefaultStyling(data); + + expect(result.vertices?.[0].iconUrl).toBe("https://example.com/icon.svg"); + }); + + it("should handle unknown lucide icon names gracefully", async () => { + const data = { + vertices: { + User: { icon: "not-a-real-icon", color: "#1565C0" }, + }, + }; + const result = await resolveDefaultStyling(data); + + expect(result.vertices?.[0].iconUrl).toBeUndefined(); + expect(result.vertices?.[0].color).toBe("#1565C0"); + }); + + it("should resolve edge styles", async () => { + const data = { + edges: { + OWNS: { lineColor: "#2E7D32", lineThickness: 3 }, + }, + }; + const result = await resolveDefaultStyling(data); + + expect(result.edges).toHaveLength(1); + expect(result.edges?.[0].type).toBe(createEdgeType("OWNS")); + expect(result.edges?.[0].lineColor).toBe("#2E7D32"); + expect(result.edges?.[0].lineThickness).toBe(3); + }); + + it("should handle empty data", async () => { + const result = await resolveDefaultStyling({}); + expect(result.vertices).toHaveLength(0); + expect(result.edges).toHaveLength(0); + }); + + it("should handle multiple vertex types", async () => { + const data = { + vertices: { + User: { color: "#1565C0" }, + Account: { color: "#2E7D32" }, + }, + }; + const result = await resolveDefaultStyling(data); + + expect(result.vertices).toHaveLength(2); + const types = result.vertices?.map(v => v.type); + expect(types).toContain(createVertexType("User")); + expect(types).toContain(createVertexType("Account")); + }); + + it("should resolve all vertex style properties", async () => { + const data = { + vertices: { + User: { + color: "#1565C0", + displayLabel: "Person", + displayNameAttribute: "name", + longDisplayNameAttribute: "fullName", + shape: "round-rectangle" as const, + backgroundOpacity: 0.5, + borderWidth: 2, + borderColor: "#000000", + borderStyle: "dashed" as const, + }, + }, + }; + const result = await resolveDefaultStyling(data); + const v = result.vertices?.[0]; + + expect(v?.color).toBe("#1565C0"); + expect(v?.displayLabel).toBe("Person"); + expect(v?.displayNameAttribute).toBe("name"); + expect(v?.longDisplayNameAttribute).toBe("fullName"); + expect(v?.shape).toBe("round-rectangle"); + expect(v?.backgroundOpacity).toBe(0.5); + expect(v?.borderWidth).toBe(2); + expect(v?.borderColor).toBe("#000000"); + expect(v?.borderStyle).toBe("dashed"); + }); + + it("should resolve iconUrl and iconImageType when explicitly provided", async () => { + const data = { + vertices: { + User: { + iconUrl: "https://example.com/icon.png", + iconImageType: "image/png", + }, + }, + }; + const result = await resolveDefaultStyling(data); + + expect(result.vertices?.[0].iconUrl).toBe("https://example.com/icon.png"); + expect(result.vertices?.[0].iconImageType).toBe("image/png"); + }); + + it("should preserve zero values for backgroundOpacity and borderWidth", async () => { + const data = { + vertices: { + User: { backgroundOpacity: 0, borderWidth: 0 }, + }, + }; + const result = await resolveDefaultStyling(data); + + expect(result.vertices?.[0].backgroundOpacity).toBe(0); + expect(result.vertices?.[0].borderWidth).toBe(0); + }); +}); + +describe("userStylingToExportFormat", () => { + it("should convert vertex styling to export format", () => { + const styling = { + vertices: [ + { + type: createVertexType("User"), + color: "#1565C0", + iconUrl: "data:image/svg+xml;base64,abc", + }, + ], + }; + const result = userStylingToExportFormat(styling); + + expect(result.vertices).toBeDefined(); + expect(result.vertices?.User).toEqual({ + color: "#1565C0", + iconUrl: "data:image/svg+xml;base64,abc", + }); + }); + + it("should convert edge styling to export format", () => { + const styling = { + edges: [ + { + type: createEdgeType("OWNS"), + lineColor: "#2E7D32", + lineThickness: 3, + }, + ], + }; + const result = userStylingToExportFormat(styling); + + expect(result.edges).toBeDefined(); + expect(result.edges?.OWNS).toEqual({ + lineColor: "#2E7D32", + lineThickness: 3, + }); + }); + + it("should return empty object for empty styling", () => { + const result = userStylingToExportFormat({}); + expect(result).toEqual({}); + }); + + it("should not include type in the exported entry", () => { + const styling = { + vertices: [ + { + type: createVertexType("User"), + color: "#1565C0", + }, + ], + }; + const result = userStylingToExportFormat(styling); + + expect(result.vertices?.User).not.toHaveProperty("type"); + }); +}); diff --git a/packages/graph-explorer/src/core/defaultStyling.ts b/packages/graph-explorer/src/core/defaultStyling.ts new file mode 100644 index 000000000..22489623a --- /dev/null +++ b/packages/graph-explorer/src/core/defaultStyling.ts @@ -0,0 +1,284 @@ +import { z } from "zod"; + +import { logger } from "@/utils"; +import { lucideIconToDataUri } from "@/utils/lucideIconUrl"; + +import type { + EdgePreferencesStorageModel, + UserStyling, + VertexPreferencesStorageModel, +} from "./StateProvider/userPreferences"; + +import { createEdgeType, createVertexType } from "./entities"; + +/** Zod schema for a single vertex style entry in defaultStyling.json. */ +const VertexStyleSchema = z + .object({ + /** Lucide icon name (kebab-case). Resolved to an SVG data URI at runtime. */ + icon: z.string().optional(), + /** Custom icon URL or base64 data URI. Takes precedence over `icon`. */ + iconUrl: z.string().optional(), + /** MIME type for custom icon (e.g., "image/svg+xml", "image/png"). */ + iconImageType: z.string().optional(), + /** Hex color for the vertex (e.g., "#1565C0"). */ + color: z.string().optional(), + /** Display label override. */ + displayLabel: z.string().optional(), + /** Which vertex attribute to use as the display name. */ + displayNameAttribute: z.string().optional(), + /** Which vertex attribute to use as the description. */ + longDisplayNameAttribute: z.string().optional(), + /** Node shape. */ + shape: z + .enum([ + "rectangle", + "roundrectangle", + "ellipse", + "triangle", + "pentagon", + "hexagon", + "heptagon", + "octagon", + "star", + "barrel", + "diamond", + "vee", + "rhomboid", + "tag", + "round-rectangle", + "round-triangle", + "round-diamond", + "round-pentagon", + "round-hexagon", + "round-heptagon", + "round-octagon", + "round-tag", + "cut-rectangle", + "concave-hexagon", + ]) + .optional(), + /** Background opacity (0-1). */ + backgroundOpacity: z.number().min(0).max(1).optional(), + /** Border width in pixels. */ + borderWidth: z.number().min(0).optional(), + /** Hex color for the border. */ + borderColor: z.string().optional(), + /** Border line style. */ + borderStyle: z.enum(["solid", "dashed", "dotted"]).optional(), + }) + .strict(); + +/** Zod schema for a single edge style entry in defaultStyling.json. */ +const EdgeStyleSchema = z + .object({ + /** Display label override. */ + displayLabel: z.string().optional(), + /** Which edge attribute to use as the display name. */ + displayNameAttribute: z.string().optional(), + /** Hex color for edge label background. */ + labelColor: z.string().optional(), + /** Label background opacity (0-1). */ + labelBackgroundOpacity: z.number().min(0).max(1).optional(), + /** Hex color for label border. */ + labelBorderColor: z.string().optional(), + /** Label border style. */ + labelBorderStyle: z.enum(["solid", "dashed", "dotted"]).optional(), + /** Label border width in pixels. */ + labelBorderWidth: z.number().min(0).optional(), + /** Hex color for the edge line. */ + lineColor: z.string().optional(), + /** Edge line thickness in pixels. */ + lineThickness: z.number().min(0).optional(), + /** Edge line style. */ + lineStyle: z.enum(["solid", "dashed", "dotted"]).optional(), + /** Arrow style at the source end. */ + sourceArrowStyle: z + .enum([ + "triangle", + "triangle-tee", + "circle-triangle", + "triangle-cross", + "triangle-backcurve", + "tee", + "vee", + "square", + "circle", + "diamond", + "none", + ]) + .optional(), + /** Arrow style at the target end. */ + targetArrowStyle: z + .enum([ + "triangle", + "triangle-tee", + "circle-triangle", + "triangle-cross", + "triangle-backcurve", + "tee", + "vee", + "square", + "circle", + "diamond", + "none", + ]) + .optional(), + }) + .strict(); + +/** Zod schema for the complete defaultStyling.json file. */ +export const DefaultStylingSchema = z + .object({ + vertices: z.record(z.string(), VertexStyleSchema).optional(), + edges: z.record(z.string(), EdgeStyleSchema).optional(), + }) + .strict(); + +export type DefaultStylingData = z.infer; + +/** + * Fetches the default styling configuration from the server. + * Returns null if no file is mounted (404) or if the data is invalid. + */ +export async function fetchDefaultStyling(): Promise { + const defaultStylingPath = `${location.origin}/defaultStyling`; + + try { + logger.debug("Fetching default styling from", defaultStylingPath); + const response = await fetch(defaultStylingPath); + + if (!response.ok) { + if (response.status === 404) { + logger.debug("No default styling file found"); + } else { + logger.warn( + `Response status ${response.status} for default styling`, + await response.text(), + ); + } + return null; + } + + const data = await response.json(); + return parseDefaultStyling(data); + } catch (error) { + logger.warn("Failed to fetch default styling", error); + return null; + } +} + +/** + * Parses and validates default styling data from any source (server fetch or file import). + * Returns null if the data is invalid. + */ +export function parseDefaultStyling(data: unknown): DefaultStylingData | null { + const result = DefaultStylingSchema.safeParse(data); + + if (!result.success) { + logger.warn("Failed to parse default styling data", result.error.flatten()); + return null; + } + + return result.data; +} + +/** + * Resolves a DefaultStylingData object into a UserStyling object. + * + * This converts lucide icon names to data URIs and maps the record-based + * format (keyed by type name) into the array-based format used by UserStyling. + */ +export async function resolveDefaultStyling( + data: DefaultStylingData, +): Promise { + const vertices: VertexPreferencesStorageModel[] = []; + const edges: EdgePreferencesStorageModel[] = []; + + if (data.vertices) { + for (const [typeName, style] of Object.entries(data.vertices)) { + const resolved: VertexPreferencesStorageModel = { + type: createVertexType(typeName), + }; + + // Resolve lucide icon name to data URI if no explicit iconUrl is provided + if (style.icon && !style.iconUrl) { + const dataUri = await lucideIconToDataUri(style.icon); + if (dataUri) { + resolved.iconUrl = dataUri; + resolved.iconImageType = "image/svg+xml"; + } else { + logger.warn( + `Unknown lucide icon name "${style.icon}" for vertex type "${typeName}"`, + ); + } + } + + // Copy over all other defined properties (using !== undefined to + // preserve valid falsy values like 0 for borderWidth) + if (style.iconUrl !== undefined) resolved.iconUrl = style.iconUrl; + if (style.iconImageType !== undefined) + resolved.iconImageType = style.iconImageType; + if (style.color !== undefined) resolved.color = style.color; + if (style.displayLabel !== undefined) + resolved.displayLabel = style.displayLabel; + if (style.displayNameAttribute !== undefined) + resolved.displayNameAttribute = style.displayNameAttribute; + if (style.longDisplayNameAttribute !== undefined) + resolved.longDisplayNameAttribute = style.longDisplayNameAttribute; + if (style.shape !== undefined) resolved.shape = style.shape; + if (style.backgroundOpacity !== undefined) + resolved.backgroundOpacity = style.backgroundOpacity; + if (style.borderWidth !== undefined) + resolved.borderWidth = style.borderWidth; + if (style.borderColor !== undefined) + resolved.borderColor = style.borderColor; + if (style.borderStyle !== undefined) + resolved.borderStyle = style.borderStyle; + + vertices.push(resolved); + } + } + + if (data.edges) { + for (const [typeName, style] of Object.entries(data.edges)) { + const resolved: EdgePreferencesStorageModel = { + type: createEdgeType(typeName), + ...style, + }; + edges.push(resolved); + } + } + + return { vertices, edges }; +} + +/** + * Converts a UserStyling object back to the DefaultStylingData format + * suitable for export to a JSON file. + * + * Note: Lucide icon names cannot be reverse-resolved from data URIs, + * so exported files will contain iconUrl data URIs instead of icon names. + */ +export function userStylingToExportFormat( + styling: UserStyling, +): DefaultStylingData { + const result: DefaultStylingData = {}; + + if (styling.vertices?.length) { + result.vertices = {}; + for (const vertex of styling.vertices) { + const { type, ...rest } = vertex; + result.vertices[type] = rest; + } + } + + if (styling.edges?.length) { + result.edges = {}; + for (const edge of styling.edges) { + const { type, ...rest } = edge; + result.edges[type] = rest; + } + } + + return result; +} diff --git a/packages/graph-explorer/src/core/queryClient.test.ts b/packages/graph-explorer/src/core/queryClient.test.ts new file mode 100644 index 000000000..b9d200c83 --- /dev/null +++ b/packages/graph-explorer/src/core/queryClient.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from "vitest"; + +import { createQueryClient } from "./queryClient"; +import { getAppStore } from "./StateProvider/appStore"; + +describe("createQueryClient", () => { + test("should inject the Jotai store into query and mutation meta", () => { + const store = getAppStore(); + const queryClient = createQueryClient(); + + const defaultOptions = queryClient.getDefaultOptions(); + expect(defaultOptions.queries?.meta?.store).toBe(store); + expect(defaultOptions.mutations?.meta?.store).toBe(store); + }); + + test("should disable refetch on window focus", () => { + const queryClient = createQueryClient(); + + const defaultOptions = queryClient.getDefaultOptions(); + expect(defaultOptions.queries?.refetchOnWindowFocus).toBe(false); + }); + + test("should set a 5 minute stale time", () => { + const queryClient = createQueryClient(); + + const defaultOptions = queryClient.getDefaultOptions(); + expect(defaultOptions.queries?.staleTime).toBe(1000 * 60 * 5); + }); +}); diff --git a/packages/graph-explorer/src/core/queryClient.ts b/packages/graph-explorer/src/core/queryClient.ts index 9761e806e..7192d1776 100644 --- a/packages/graph-explorer/src/core/queryClient.ts +++ b/packages/graph-explorer/src/core/queryClient.ts @@ -51,7 +51,7 @@ export function createQueryClient() { * @param store The Jotai store to use for the default options. * @returns The query client default options */ -export function createDefaultOptions(store: Store): DefaultOptions { +function createDefaultOptions(store: Store): DefaultOptions { const meta: GraphExplorerMeta = { store }; return { queries: { diff --git a/packages/graph-explorer/src/globalSetup.ts b/packages/graph-explorer/src/globalSetup.ts new file mode 100644 index 000000000..816c56ea6 --- /dev/null +++ b/packages/graph-explorer/src/globalSetup.ts @@ -0,0 +1,6 @@ +export function setup() { + process.env.TZ = "UTC"; + process.env.LC_ALL = "en-US.UTF-8"; + process.env.LANG = "en-US.UTF-8"; + process.env.LANGUAGE = "en-US"; +} diff --git a/packages/graph-explorer/src/hooks/useAddToGraph.test.ts b/packages/graph-explorer/src/hooks/useAddToGraph.test.ts index 36c543fa8..413277a44 100644 --- a/packages/graph-explorer/src/hooks/useAddToGraph.test.ts +++ b/packages/graph-explorer/src/hooks/useAddToGraph.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment happy-dom import { waitFor } from "@testing-library/react"; import { useAtomValue } from "jotai"; import { act } from "react"; @@ -5,6 +6,7 @@ import { act } from "react"; import { activeGraphSessionAtom, activeSchemaSelector, + createEdgeConnection, createVertexType, edgesAtom, type EdgeTypeConfig, @@ -19,6 +21,8 @@ import { createRandomEntities, createRandomVertex, createRandomVertexForRdf, + createTestableEdge, + createTestableVertex, DbState, renderHookWithState, } from "@/utils/testing"; @@ -318,6 +322,102 @@ test("should update graph storage when adding an edge", async () => { expect(result.current.graph).toStrictEqual(expectedGraph); }); +test("should infer edge connections when adding edges with vertices", async () => { + const dbState = new DbState(); + dbState.activeSchema.edgeConnections = []; + + const source = createTestableVertex().with({ types: ["Person"] }); + const target = createTestableVertex().with({ types: ["Dog"] }); + const edge = createTestableEdge() + .with({ type: "owner" }) + .withSource(source) + .withTarget(target); + + const { result } = renderHookWithState(() => { + const callback = useAddToGraph(); + const schema = useAtomValue(activeSchemaSelector); + return { callback, schema }; + }, dbState); + + await act(() => + result.current.callback({ + vertices: [source.asVertex(), target.asVertex()], + edges: [edge.asEdge()], + }), + ); + + expect(result.current.schema?.edgeConnections).toStrictEqual([ + createEdgeConnection({ source: "Person", edge: "owner", target: "Dog" }), + ]); +}); + +test("should use batch vertex types over canvas vertex types for edge connections", async () => { + const dbState = new DbState(); + dbState.activeSchema.edgeConnections = []; + + // Canvas has the source as "Employee" + const canvasSource = createTestableVertex().with({ types: ["Employee"] }); + dbState.addVertexToGraph(canvasSource.asVertex()); + + // Batch provides the same vertex as "Person" (should take priority) + const batchSource = canvasSource.with({ types: ["Person"] }); + const target = createTestableVertex().with({ types: ["Dog"] }); + const edge = createTestableEdge() + .with({ type: "owner" }) + .withSource(batchSource) + .withTarget(target); + + const { result } = renderHookWithState(() => { + const callback = useAddToGraph(); + const schema = useAtomValue(activeSchemaSelector); + return { callback, schema }; + }, dbState); + + await act(() => + result.current.callback({ + vertices: [batchSource.asVertex(), target.asVertex()], + edges: [edge.asEdge()], + }), + ); + + expect(result.current.schema?.edgeConnections).toStrictEqual([ + createEdgeConnection({ source: "Person", edge: "owner", target: "Dog" }), + ]); +}); + +test("should resolve edge endpoints from canvas when not in batch", async () => { + const dbState = new DbState(); + dbState.activeSchema.edgeConnections = []; + + // Source is already on the canvas + const source = createTestableVertex().with({ types: ["Person"] }); + dbState.addVertexToGraph(source.asVertex()); + + // Batch only has the target and the edge + const target = createTestableVertex().with({ types: ["Dog"] }); + const edge = createTestableEdge() + .with({ type: "owner" }) + .withSource(source) + .withTarget(target); + + const { result } = renderHookWithState(() => { + const callback = useAddToGraph(); + const schema = useAtomValue(activeSchemaSelector); + return { callback, schema }; + }, dbState); + + await act(() => + result.current.callback({ + vertices: [target.asVertex()], + edges: [edge.asEdge()], + }), + ); + + expect(result.current.schema?.edgeConnections).toStrictEqual([ + createEdgeConnection({ source: "Person", edge: "owner", target: "Dog" }), + ]); +}); + test("should ignore blank nodes when updating graph storage", async () => { const dbState = new DbState(); diff --git a/packages/graph-explorer/src/hooks/useAddToGraph.ts b/packages/graph-explorer/src/hooks/useAddToGraph.ts index a2cb20864..7952963e9 100644 --- a/packages/graph-explorer/src/hooks/useAddToGraph.ts +++ b/packages/graph-explorer/src/hooks/useAddToGraph.ts @@ -1,9 +1,12 @@ import { useMutation } from "@tanstack/react-query"; import { useSetAtom } from "jotai"; +import { useAtomCallback } from "jotai/utils"; +import { useCallback } from "react"; import { toast } from "sonner"; import { activeSchemaSelector, + createVertexTypeLookup, type Edge, edgesAtom, type Entities, @@ -24,7 +27,12 @@ export function useAddToGraph() { const setActiveSchema = useSetAtom(activeSchemaSelector); const updateGraphStorage = useUpdateGraphSession(); - // eslint-disable-next-line @typescript-eslint/require-await + const getCanvasVertices = useAtomCallback( + useCallback(get => get(nodesAtom), []), + ); + + // async is required because useMutation expects a Promise return type + // oxlint-disable-next-line @typescript-eslint/require-await return async (entities: Partial) => { const newVerticesMap = toNodeMap(entities.vertices ?? []); const newEdgesMap = toEdgeMap(entities.edges ?? []); @@ -34,6 +42,13 @@ export function useAddToGraph() { return; } + // Build vertex lookup from batch + canvas before modifying state + // Batch vertices take priority over canvas vertices + const vertexLookup = createVertexTypeLookup( + newVerticesMap, + getCanvasVertices(), + ); + // Add new vertices to the graph if (newVerticesMap.size > 0) { logger.debug("Adding vertices to graph", newVerticesMap); @@ -57,6 +72,7 @@ export function useAddToGraph() { edges: newEdgesMap.values().toArray(), }, prev, + vertexLookup, ); }); @@ -64,13 +80,13 @@ export function useAddToGraph() { }; } -/** Returns a callback the given vertex to the graph. */ +/** Returns a callback that adds the given vertex to the graph. */ export function useAddVertexToGraph(vertex: Vertex) { const callback = useAddToGraph(); return () => callback({ vertices: [vertex] }); } -/** Returns a callback the given edge to the graph. */ +/** Returns a callback that adds the given edge to the graph. */ export function useAddEdgeToGraph(edge: Edge) { const callback = useAddToGraph(); return () => callback({ edges: [edge] }); diff --git a/packages/graph-explorer/src/hooks/useContextMenuTarget.test.ts b/packages/graph-explorer/src/hooks/useContextMenuTarget.test.ts index 1fec917ac..0d8733793 100644 --- a/packages/graph-explorer/src/hooks/useContextMenuTarget.test.ts +++ b/packages/graph-explorer/src/hooks/useContextMenuTarget.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment happy-dom import { beforeEach, describe, expect, it } from "vitest"; import type { EdgeId, VertexId } from "@/core"; diff --git a/packages/graph-explorer/src/hooks/useContextMenuTarget.ts b/packages/graph-explorer/src/hooks/useContextMenuTarget.ts index 1ee7f29b8..091a4383f 100644 --- a/packages/graph-explorer/src/hooks/useContextMenuTarget.ts +++ b/packages/graph-explorer/src/hooks/useContextMenuTarget.ts @@ -79,10 +79,8 @@ export function useContextMenuTarget({ // Check if affected is within selection (short-circuits on first match) const hasAffectedInSelection = - (affectedVertexIds.length > 0 && - affectedVertexIds.some(id => graphSelection.isVertexSelected(id))) || - (affectedEdgeIds.length > 0 && - affectedEdgeIds.some(id => graphSelection.isEdgeSelected(id))); + affectedVertexIds.some(id => graphSelection.isVertexSelected(id)) || + affectedEdgeIds.some(id => graphSelection.isEdgeSelected(id)); // Use selection when affected is within it if (hasAffectedInSelection) { diff --git a/packages/graph-explorer/src/hooks/useDeepMemo.ts b/packages/graph-explorer/src/hooks/useDeepMemo.ts index 0b65c9d09..8c7f4f60c 100644 --- a/packages/graph-explorer/src/hooks/useDeepMemo.ts +++ b/packages/graph-explorer/src/hooks/useDeepMemo.ts @@ -17,12 +17,12 @@ export const useDeepMemo = ( ): TValue => { const ref = useRef<{ key: TKey; value: TValue } | null>(null); - // eslint-disable-next-line react-hooks/refs + // oxlint-disable-next-line react-compiler/refs if (!ref.current || !isEqual(key, ref.current.key)) { ref.current = { key, value: memoFn() }; } - // eslint-disable-next-line react-hooks/refs + // oxlint-disable-next-line react-compiler/refs return ref.current.value; }; diff --git a/packages/graph-explorer/src/hooks/useDeleteConfig.test.ts b/packages/graph-explorer/src/hooks/useDeleteConfig.test.ts index 6ee2b40bf..e08dcd58f 100644 --- a/packages/graph-explorer/src/hooks/useDeleteConfig.test.ts +++ b/packages/graph-explorer/src/hooks/useDeleteConfig.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment happy-dom import { waitFor } from "@testing-library/react"; import { useAtomValue } from "jotai"; import { act } from "react"; diff --git a/packages/graph-explorer/src/hooks/useEntitiesCounts.test.ts b/packages/graph-explorer/src/hooks/useEntitiesCounts.test.ts index 107672c96..daa7a6c8b 100644 --- a/packages/graph-explorer/src/hooks/useEntitiesCounts.test.ts +++ b/packages/graph-explorer/src/hooks/useEntitiesCounts.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment happy-dom import { createRandomInteger } from "@shared/utils/testing"; import { vi } from "vitest"; diff --git a/packages/graph-explorer/src/hooks/useEntityCountFormatter.test.ts b/packages/graph-explorer/src/hooks/useEntityCountFormatter.test.ts index 407efb5c4..dc5e07646 100644 --- a/packages/graph-explorer/src/hooks/useEntityCountFormatter.test.ts +++ b/packages/graph-explorer/src/hooks/useEntityCountFormatter.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment happy-dom import { vi } from "vitest"; import { renderHookWithJotai } from "@/utils/testing"; diff --git a/packages/graph-explorer/src/hooks/useExpandNode.test.ts b/packages/graph-explorer/src/hooks/useExpandNode.test.ts index 726f8f366..c15134deb 100644 --- a/packages/graph-explorer/src/hooks/useExpandNode.test.ts +++ b/packages/graph-explorer/src/hooks/useExpandNode.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment happy-dom import { act, waitFor } from "@testing-library/react"; import { diff --git a/packages/graph-explorer/src/hooks/useHasVertexBeenAddedToGraph.test.ts b/packages/graph-explorer/src/hooks/useHasVertexBeenAddedToGraph.test.ts index f6289d891..a645f4338 100644 --- a/packages/graph-explorer/src/hooks/useHasVertexBeenAddedToGraph.test.ts +++ b/packages/graph-explorer/src/hooks/useHasVertexBeenAddedToGraph.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment happy-dom import { nodesAtom, nodesFilteredIdsAtom, diff --git a/packages/graph-explorer/src/hooks/useNeighborsOptions.test.ts b/packages/graph-explorer/src/hooks/useNeighborsOptions.test.ts index 6b02a3662..b071cc4cb 100644 --- a/packages/graph-explorer/src/hooks/useNeighborsOptions.test.ts +++ b/packages/graph-explorer/src/hooks/useNeighborsOptions.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment happy-dom import { waitFor } from "@testing-library/react"; import type { NeighborCount } from "@/connector"; diff --git a/packages/graph-explorer/src/hooks/useNeighborsOptions.ts b/packages/graph-explorer/src/hooks/useNeighborsOptions.ts index db603e70b..696e36e3d 100644 --- a/packages/graph-explorer/src/hooks/useNeighborsOptions.ts +++ b/packages/graph-explorer/src/hooks/useNeighborsOptions.ts @@ -26,7 +26,7 @@ export default function useNeighborsOptions( .map(([type, neighbors]) => { const vtConfig = getVtConfig(type); - return { + return { label: vtConfig.displayLabel, value: vtConfig.type, isDisabled: neighbors.unfetched === 0, diff --git a/packages/graph-explorer/src/hooks/usePrevious.test.ts b/packages/graph-explorer/src/hooks/usePrevious.test.ts index 544d98319..035415ace 100644 --- a/packages/graph-explorer/src/hooks/usePrevious.test.ts +++ b/packages/graph-explorer/src/hooks/usePrevious.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment happy-dom import { renderHook } from "@testing-library/react"; import usePrevious from "./usePrevious"; diff --git a/packages/graph-explorer/src/hooks/useRefreshEntities.test.ts b/packages/graph-explorer/src/hooks/useRefreshEntities.test.ts index 1e28eb234..26d3d5214 100644 --- a/packages/graph-explorer/src/hooks/useRefreshEntities.test.ts +++ b/packages/graph-explorer/src/hooks/useRefreshEntities.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment happy-dom import { useQueryClient } from "@tanstack/react-query"; import { act, waitFor } from "@testing-library/react"; diff --git a/packages/graph-explorer/src/hooks/useRemoveFromGraph.test.ts b/packages/graph-explorer/src/hooks/useRemoveFromGraph.test.ts index 35e7339d4..07bc51429 100644 --- a/packages/graph-explorer/src/hooks/useRemoveFromGraph.test.ts +++ b/packages/graph-explorer/src/hooks/useRemoveFromGraph.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment happy-dom import { createArray } from "@shared/utils/testing"; import { waitFor } from "@testing-library/react"; import { useAtomValue } from "jotai"; diff --git a/packages/graph-explorer/src/hooks/useSchemaSync.test.ts b/packages/graph-explorer/src/hooks/useSchemaSync.test.ts index 6321b1c93..51b2b7651 100644 --- a/packages/graph-explorer/src/hooks/useSchemaSync.test.ts +++ b/packages/graph-explorer/src/hooks/useSchemaSync.test.ts @@ -1,22 +1,32 @@ +// @vitest-environment happy-dom import { act, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { + activeConfigurationAtom, + configurationAtom, createEdgeType, createVertexType, type EdgeConnection, + explorerForTestingAtom, schemaAtom, } from "@/core"; import { getAppStore } from "@/core/StateProvider/appStore"; import { createRandomEdgeTypeConfig, + createRandomRawConfiguration, createRandomVertexTypeConfig, DbState, FakeExplorer, + renderHookWithJotai, renderHookWithState, } from "@/utils/testing"; -import { useSchemaSync } from "./useSchemaSync"; +import { + useCancelSchemaSync, + useIsSyncing, + useSchemaSync, +} from "./useSchemaSync"; describe("useSchemaSync", () => { let explorer: FakeExplorer; @@ -44,6 +54,47 @@ describe("useSchemaSync", () => { return state; } + describe("no active connection", () => { + function renderWithNoConnection() { + return renderHookWithJotai( + () => useSchemaSync(), + store => { + store.set(activeConfigurationAtom, null); + store.set(configurationAtom, new Map()); + store.set(schemaAtom, new Map()); + store.set(explorerForTestingAtom, explorer); + }, + ); + } + + it("should not fetch schema when no connection exists", () => { + const fetchSchemaSpy = vi.spyOn(explorer, "fetchSchema"); + + const { result } = renderWithNoConnection(); + + expect(fetchSchemaSpy).not.toHaveBeenCalled(); + expect(result.current.schemaDiscoveryQuery.fetchStatus).toBe("idle"); + }); + + it("should not fetch edge connections when no connection exists", () => { + const fetchEdgeConnectionsSpy = vi.spyOn( + explorer, + "fetchEdgeConnections", + ); + + const { result } = renderWithNoConnection(); + + expect(fetchEdgeConnectionsSpy).not.toHaveBeenCalled(); + expect(result.current.edgeDiscoveryQuery.fetchStatus).toBe("idle"); + }); + + it("should report isFetching as false when no connection exists", () => { + const { result } = renderWithNoConnection(); + + expect(result.current.isFetching).toBe(false); + }); + }); + describe("schemaDiscoveryQuery", () => { it("should use initialData from active schema without fetching", () => { const vertexType = createVertexType("Person"); @@ -359,4 +410,155 @@ describe("useSchemaSync", () => { expect(fetchSchemaSpy).toHaveBeenCalledTimes(1); }); }); + + describe("remount behavior", () => { + it("should not auto-fetch when lastSyncFail is true after remount", () => { + // Simulate: sync failed, user navigated away, then came back. + // Start with lastSyncFail already set (as if a previous sync failed). + const state = new DbState(explorer); + state.activeSchema.lastSyncFail = true; + + const fetchSchemaSpy = vi + .spyOn(explorer, "fetchSchema") + .mockRejectedValue(new Error("Network error")); + + // First mount β€” sync should not fire due to lastSyncFail + const { unmount } = renderHookWithState(() => useSchemaSync(), state); + expect(fetchSchemaSpy).not.toHaveBeenCalled(); + + // User navigates away + unmount(); + + // User navigates back β€” re-render with same state + renderHookWithState(() => useSchemaSync(), state); + + // Should still not auto-fetch because lastSyncFail is true + expect(fetchSchemaSpy).not.toHaveBeenCalled(); + }); + }); + + describe("connection switching", () => { + it("should fetch schema when switching to a connection with no schema", async () => { + const state = new DbState(explorer); + state.activeSchema.vertices = [createRandomVertexTypeConfig()]; + + const fetchSchemaSpy = vi.spyOn(explorer, "fetchSchema"); + + const { result, rerender } = renderHookWithState( + () => useSchemaSync(), + state, + ); + + // Should not fetch because initialData exists + expect(fetchSchemaSpy).not.toHaveBeenCalled(); + expect(result.current.schemaDiscoveryQuery.data).toBeDefined(); + + // Switch to a new connection with no schema + const store = getAppStore(); + const newConfig = createRandomRawConfiguration(); + store.set(configurationAtom, prev => { + const updated = new Map(prev); + updated.set(newConfig.id, newConfig); + return updated; + }); + store.set(activeConfigurationAtom, newConfig.id); + + rerender(); + + await waitFor(() => { + expect(fetchSchemaSpy).toHaveBeenCalled(); + }); + }); + }); + + describe("lastEdgeConnectionSyncFail", () => { + it("should not auto-fetch edge connections when lastEdgeConnectionSyncFail is true", () => { + const edgeType = createEdgeType("knows"); + const state = createStateWithSchema([], [edgeType]); + state.activeSchema.lastEdgeConnectionSyncFail = true; + state.activeSchema.edgeConnections = undefined; + + const fetchEdgeConnectionsSpy = vi.spyOn( + explorer, + "fetchEdgeConnections", + ); + + const { result } = renderHookWithState(() => useSchemaSync(), state); + + expect(result.current.edgeDiscoveryQuery.fetchStatus).toBe("idle"); + expect(fetchEdgeConnectionsSpy).not.toHaveBeenCalled(); + }); + }); +}); + +describe("useIsSyncing", () => { + let explorer: FakeExplorer; + + beforeEach(() => { + explorer = new FakeExplorer(); + }); + + it("should return false when no schema queries are running", () => { + const state = new DbState(explorer); + + const { result } = renderHookWithState(() => useIsSyncing(), state); + + expect(result.current).toBe(false); + }); + + it("should return true when a schema query is fetching", () => { + const state = new DbState(explorer).withNoActiveSchema(); + + vi.spyOn(explorer, "fetchSchema").mockImplementation( + () => new Promise(() => {}), + ); + + // Both hooks must share the same query client to observe fetching state + const { result } = renderHookWithState( + () => ({ syncing: useIsSyncing(), schema: useSchemaSync() }), + state, + ); + + expect(result.current.syncing).toBe(true); + }); +}); + +describe("useCancelSchemaSync", () => { + let explorer: FakeExplorer; + + beforeEach(() => { + explorer = new FakeExplorer(); + }); + + it("should return a function", () => { + const state = new DbState(explorer); + + const { result } = renderHookWithState(() => useCancelSchemaSync(), state); + + expect(typeof result.current).toBe("function"); + }); + + it("should cancel in-flight schema queries", async () => { + const state = new DbState(explorer).withNoActiveSchema(); + + const fetchSchemaSpy = vi + .spyOn(explorer, "fetchSchema") + .mockImplementation(() => new Promise(() => {})); + + const { result } = renderHookWithState( + () => ({ + cancel: useCancelSchemaSync(), + schema: useSchemaSync(), + }), + state, + ); + + // Schema fetch should have been triggered + expect(fetchSchemaSpy).toHaveBeenCalled(); + + // Cancel should not throw + await act(async () => { + await result.current.cancel(); + }); + }); }); diff --git a/packages/graph-explorer/src/hooks/useSchemaSync.ts b/packages/graph-explorer/src/hooks/useSchemaSync.ts index 990e89f7b..63aed8540 100644 --- a/packages/graph-explorer/src/hooks/useSchemaSync.ts +++ b/packages/graph-explorer/src/hooks/useSchemaSync.ts @@ -1,7 +1,12 @@ import { useIsFetching, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useAtomValue } from "jotai"; import { edgeConnectionsQuery, schemaSyncQuery } from "@/connector"; -import { useMaybeActiveSchema } from "@/core"; +import { + activeConfigurationAtom, + maybeActiveSchemaAtom, + useConfiguration, +} from "@/core"; import { logger } from "@/utils"; /** Returns true if any schema sync query is running. Will not trigger the query to run. */ @@ -30,9 +35,21 @@ export function useCancelSchemaSync() { * - On refetch failure, TanStack Query preserves the previous successful data. */ export function useSchemaSync() { - const activeSchema = useMaybeActiveSchema(); + const config = useConfiguration(); + // Read the atom directly instead of useMaybeActiveSchema() because that hook + // wraps the value in useDeferredValue, which delays the update by one render. + // The schema and connectionId must update in the same render so the query + // options stay consistent when switching connections. + const activeSchema = useAtomValue(maybeActiveSchemaAtom); + const connectionId = useAtomValue(activeConfigurationAtom); - const schemaDiscoveryQuery = useQuery(schemaSyncQuery(activeSchema)); + const schemaDiscoveryQuery = useQuery( + schemaSyncQuery({ + connectionId, + activeSchema, + hasConnection: config != null, + }), + ); const edgeDiscoveryQuery = useQuery( edgeConnectionsQuery(schemaDiscoveryQuery.data), ); diff --git a/packages/graph-explorer/src/hooks/useTextTransform.test.ts b/packages/graph-explorer/src/hooks/useTextTransform.test.ts index 8f005fb82..b5215b68b 100644 --- a/packages/graph-explorer/src/hooks/useTextTransform.test.ts +++ b/packages/graph-explorer/src/hooks/useTextTransform.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment happy-dom import { vi } from "vitest"; import type { IriNamespace, RdfPrefix } from "@/utils/rdf"; diff --git a/packages/graph-explorer/src/modules/AvailableConnections/AvailableConnections.test.tsx b/packages/graph-explorer/src/modules/AvailableConnections/AvailableConnections.test.tsx new file mode 100644 index 000000000..2139f19f6 --- /dev/null +++ b/packages/graph-explorer/src/modules/AvailableConnections/AvailableConnections.test.tsx @@ -0,0 +1,32 @@ +// @vitest-environment happy-dom +import { render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; + +import { TooltipProvider } from "@/components"; +import { configurationAtom, getAppStore } from "@/core"; +import { createQueryClient } from "@/core/queryClient"; +import { TestProvider } from "@/utils/testing"; + +import AvailableConnections from "./AvailableConnections"; + +vi.mock("@/modules/CreateConnection", () => ({ + default: () =>
CreateConnection
, +})); + +describe("AvailableConnections", () => { + test("renders empty state when there are no connections", () => { + const store = getAppStore(); + store.set(configurationAtom, new Map()); + const queryClient = createQueryClient(); + + render( + + + + + , + ); + + expect(screen.getByText("No Connections")).toBeInTheDocument(); + }); +}); diff --git a/packages/graph-explorer/src/modules/AvailableConnections/AvailableConnections.tsx b/packages/graph-explorer/src/modules/AvailableConnections/AvailableConnections.tsx index 3cb4339f5..12ba07c0e 100644 --- a/packages/graph-explorer/src/modules/AvailableConnections/AvailableConnections.tsx +++ b/packages/graph-explorer/src/modules/AvailableConnections/AvailableConnections.tsx @@ -1,9 +1,17 @@ import { useAtomValue } from "jotai"; +import { DatabaseIcon } from "lucide-react"; +import { useState } from "react"; import { Virtuoso } from "react-virtuoso"; import { AddIcon, Button, + EmptyState, + EmptyStateActions, + EmptyStateContent, + EmptyStateDescription, + EmptyStateIcon, + EmptyStateTitle, FileButton, Panel, PanelContent, @@ -20,6 +28,7 @@ import { DialogDescription, DialogHeader, DialogTitle, + DialogTrigger, } from "@/components/Dialog"; import { activeConfigurationAtom, configurationAtom } from "@/core"; import CreateConnection from "@/modules/CreateConnection"; @@ -28,91 +37,112 @@ import { cn } from "@/utils"; import { ConnectionRow } from "./ConnectionRow"; import { useImportConnectionFile } from "./useImportConnectionFile"; -export type ConnectionDetailProps = { +export type AvailableConnectionsProps = { isSync: boolean; - isModalOpen: boolean; - onModalChange(isOpen: boolean): void; }; -const AvailableConnections = ({ - isSync, - isModalOpen, - onModalChange, -}: ConnectionDetailProps) => { +const AvailableConnections = ({ isSync }: AvailableConnectionsProps) => { const activeConnectionId = useAtomValue(activeConfigurationAtom); const allConnections = useAllConnections(); const importConnectionFile = useImportConnectionFile(); + const [isDialogOpen, setDialogOpen] = useState(false); return ( - - - Available connections - - payload && importConnectionFile(payload)} - accept="application/json" - asChild - > - - - - - - + + + + + + + + - - ( -
+ {allConnections.length === 0 ? ( + + + + + + No Connections + + Get started by adding or importing a connection. + + + + + + + payload && importConnectionFile(payload) + } + accept="application/json" + asChild + > + + + + + + ) : ( + ( +
+
+ +
+
)} - > -
- -
-
+ /> )} - /> - - - - Add New Connection - - Enter the details of the new connection. - - - - onModalChange(false)} /> - - - -
-
+ + + + + + Add New Connection + + Enter the details of the new connection. + + + + setDialogOpen(false)} /> + + + ); }; diff --git a/packages/graph-explorer/src/modules/AvailableConnections/index.ts b/packages/graph-explorer/src/modules/AvailableConnections/index.ts index b21b99f25..82a15600a 100644 --- a/packages/graph-explorer/src/modules/AvailableConnections/index.ts +++ b/packages/graph-explorer/src/modules/AvailableConnections/index.ts @@ -1,2 +1 @@ export { default } from "./AvailableConnections"; -export type { ConnectionDetailProps } from "./AvailableConnections"; diff --git a/packages/graph-explorer/src/modules/AvailableConnections/useImportConnectionFile.test.tsx b/packages/graph-explorer/src/modules/AvailableConnections/useImportConnectionFile.test.tsx index ef3c1930a..d525d6984 100644 --- a/packages/graph-explorer/src/modules/AvailableConnections/useImportConnectionFile.test.tsx +++ b/packages/graph-explorer/src/modules/AvailableConnections/useImportConnectionFile.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment happy-dom import { createRandomName, createRandomUrlString } from "@shared/utils/testing"; import { act } from "@testing-library/react"; import { toast } from "sonner"; diff --git a/packages/graph-explorer/src/modules/ConnectionDetail/ConnectionDetail.tsx b/packages/graph-explorer/src/modules/ConnectionDetail/ConnectionDetail.tsx index 564bd013a..481cfc215 100644 --- a/packages/graph-explorer/src/modules/ConnectionDetail/ConnectionDetail.tsx +++ b/packages/graph-explorer/src/modules/ConnectionDetail/ConnectionDetail.tsx @@ -343,7 +343,7 @@ function DebugActions() { const deleteSchema = () => { logger.log("Deleting schema"); setActiveSchema(RESET); - void queryClient.removeQueries({ + queryClient.removeQueries({ queryKey: ["schema"], }); }; diff --git a/packages/graph-explorer/src/modules/CreateConnection/CreateConnection.tsx b/packages/graph-explorer/src/modules/CreateConnection/CreateConnection.tsx index b5f7b6e96..637a0dc26 100644 --- a/packages/graph-explorer/src/modules/CreateConnection/CreateConnection.tsx +++ b/packages/graph-explorer/src/modules/CreateConnection/CreateConnection.tsx @@ -87,7 +87,7 @@ function mapToConnectionForm( } const result: ConnectionForm = { - ...(existingConfig.connection ?? {}), + ...existingConfig.connection, name: existingConfig.displayLabel ?? existingConfig.id, fetchTimeoutEnabled: Boolean(existingConfig.connection?.fetchTimeoutMs), nodeExpansionLimitEnabled: Boolean( @@ -130,7 +130,7 @@ const CreateConnection = ({ const updated = new Map(prev); const currentConfig = updated.get(configId); const updatedConfig: RawConfiguration = { - ...(currentConfig || {}), + ...currentConfig, id: configId, displayLabel: data.name, connection: mapToConnection(data), @@ -171,7 +171,7 @@ const CreateConnection = ({ // Reseting all query state. Using `removeQueries()` to ensure initial data is recalculated. // This ensures dependent queries execute in the right order - void queryClient.removeQueries(); + queryClient.removeQueries(); } }, [configId, initialData, queryClient], diff --git a/packages/graph-explorer/src/modules/EdgesStyling/SingleEdgeStyling.tsx b/packages/graph-explorer/src/modules/EdgesStyling/SingleEdgeStyling.tsx index 36f80f44b..486b5f3b2 100644 --- a/packages/graph-explorer/src/modules/EdgesStyling/SingleEdgeStyling.tsx +++ b/packages/graph-explorer/src/modules/EdgesStyling/SingleEdgeStyling.tsx @@ -30,7 +30,7 @@ export default function SingleEdgeStyling({ if (prevDisplayAs === null || prevDisplayAs === debouncedDisplayAs) { return; } - void setEdgeStyle({ displayLabel: debouncedDisplayAs }); + setEdgeStyle({ displayLabel: debouncedDisplayAs }); }, [debouncedDisplayAs, prevDisplayAs, setEdgeStyle]); return ( diff --git a/packages/graph-explorer/src/modules/EntitiesFilter/useFiltersConfig.test.ts b/packages/graph-explorer/src/modules/EntitiesFilter/useFiltersConfig.test.ts index 3d755f0b4..7b4da2276 100644 --- a/packages/graph-explorer/src/modules/EntitiesFilter/useFiltersConfig.test.ts +++ b/packages/graph-explorer/src/modules/EntitiesFilter/useFiltersConfig.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment happy-dom import { sample } from "lodash"; import { act } from "react"; import { vi } from "vitest"; @@ -59,13 +60,14 @@ describe("useFiltersConfig", () => { ]; const { result } = renderFilterConfigHook(); - expect(result.current.vertexTypes.map(vt => vt.text as string)).toEqual([ + expect(result.current.vertexTypes.map(vt => vt.text)).toEqual([ "Movie", "Person", ]); - expect(result.current.connectionTypes.map(vt => vt.text as string)).toEqual( - ["ACTED_IN", "DIRECTED"], - ); + expect(result.current.connectionTypes.map(vt => vt.text)).toEqual([ + "ACTED_IN", + "DIRECTED", + ]); }); it("should have all edges in checkboxes", () => { diff --git a/packages/graph-explorer/src/modules/EntitiesFilter/useFiltersConfig.tsx b/packages/graph-explorer/src/modules/EntitiesFilter/useFiltersConfig.tsx index 17b23a730..7fc2d0bb7 100644 --- a/packages/graph-explorer/src/modules/EntitiesFilter/useFiltersConfig.tsx +++ b/packages/graph-explorer/src/modules/EntitiesFilter/useFiltersConfig.tsx @@ -1,6 +1,6 @@ import { atom, useAtomValue, useSetAtom } from "jotai"; -import { type CheckboxListItemProps, VertexIconByType } from "@/components"; +import { VertexIconByType } from "@/components"; import { EdgeIcon } from "@/components/icons"; import { edgesTypesFilteredAtom, @@ -68,7 +68,11 @@ const useFiltersConfig = () => { }; const onChangeVertexTypes = (vertexId: string, isSelected: boolean): void => { - isSelected ? deleteVertex(vertexId) : addVertex(vertexId); + if (isSelected) { + deleteVertex(vertexId); + } else { + addVertex(vertexId); + } }; const onChangeAllVertexTypes = (isSelected: boolean): void => { @@ -79,7 +83,11 @@ const useFiltersConfig = () => { connectionId: string, isSelected: boolean, ): void => { - isSelected ? deleteConnection(connectionId) : addConnection(connectionId); + if (isSelected) { + deleteConnection(connectionId); + } else { + addConnection(connectionId); + } }; const onChangeAllConnectionTypes = (isSelected: boolean): void => { @@ -93,7 +101,7 @@ const useFiltersConfig = () => { id: vertexConfig.type, text: vertexConfig.displayLabel, endAdornment: , - } as CheckboxListItemProps; + }; }) .toArray(); @@ -104,7 +112,7 @@ const useFiltersConfig = () => { id: edgeConfig.type, text: edgeConfig.displayLabel, endAdornment: , - } as CheckboxListItemProps; + }; }) .toArray(); diff --git a/packages/graph-explorer/src/modules/EntitiesTabular/EntitiesTabular.tsx b/packages/graph-explorer/src/modules/EntitiesTabular/EntitiesTabular.tsx index f2cf56b45..822a95810 100644 --- a/packages/graph-explorer/src/modules/EntitiesTabular/EntitiesTabular.tsx +++ b/packages/graph-explorer/src/modules/EntitiesTabular/EntitiesTabular.tsx @@ -26,10 +26,11 @@ import useTranslations from "@/hooks/useTranslations"; import { EdgesTabular, NodesTabular } from "./components"; -enum TableId { - edges = "edges", - nodes = "nodes", -} +const TableId = { + edges: "edges", + nodes: "nodes", +} as const; +type TableId = (typeof TableId)[keyof typeof TableId]; function EntitiesTabular() { const t = useTranslations(); diff --git a/packages/graph-explorer/src/modules/GraphViewer/ImportGraphButton.test.tsx b/packages/graph-explorer/src/modules/GraphViewer/ImportGraphButton.test.tsx index 703ce0274..e6aeab7ef 100644 --- a/packages/graph-explorer/src/modules/GraphViewer/ImportGraphButton.test.tsx +++ b/packages/graph-explorer/src/modules/GraphViewer/ImportGraphButton.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment happy-dom import { createArray } from "@shared/utils/testing"; import { ZodError } from "zod"; diff --git a/packages/graph-explorer/src/modules/GraphViewer/useAutoOpenDetailsSidebar.test.ts b/packages/graph-explorer/src/modules/GraphViewer/useAutoOpenDetailsSidebar.test.ts index 480fb310e..3d337e57f 100644 --- a/packages/graph-explorer/src/modules/GraphViewer/useAutoOpenDetailsSidebar.test.ts +++ b/packages/graph-explorer/src/modules/GraphViewer/useAutoOpenDetailsSidebar.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment happy-dom import { act } from "react"; import { type ToggleableView, userLayoutAtom, useSidebar } from "@/core"; diff --git a/packages/graph-explorer/src/modules/GraphViewer/useBackgroundImageMap.test.ts b/packages/graph-explorer/src/modules/GraphViewer/useBackgroundImageMap.test.ts index d5a272e97..c5f7c10dd 100644 --- a/packages/graph-explorer/src/modules/GraphViewer/useBackgroundImageMap.test.ts +++ b/packages/graph-explorer/src/modules/GraphViewer/useBackgroundImageMap.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment happy-dom import { waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, type Mock, vi } from "vitest"; diff --git a/packages/graph-explorer/src/modules/GraphViewer/useContextMenu.ts b/packages/graph-explorer/src/modules/GraphViewer/useContextMenu.ts index 22412bc84..84f8a1a0b 100644 --- a/packages/graph-explorer/src/modules/GraphViewer/useContextMenu.ts +++ b/packages/graph-explorer/src/modules/GraphViewer/useContextMenu.ts @@ -66,9 +66,10 @@ function useContextMenu() { top: 0, left: 0, }; - // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // oxlint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore handleMouseEvent({ + // oxlint-disable-next-line typescript/no-misused-spread -- Intentionally extracting MouseEvent properties for synthetic event ...event.originalEvent, // Override the event position to node bounds and parent offsets clientY: parentBounds.top + bounds.top + bounds.height / 2, @@ -85,9 +86,10 @@ function useContextMenu() { top: 0, left: 0, }; - // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // oxlint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore handleMouseEvent({ + // oxlint-disable-next-line typescript/no-misused-spread -- Intentionally extracting MouseEvent properties for synthetic event ...event.originalEvent, // Override the event position to event position and parent offsets clientY: event.renderedPosition.y + parentBounds.top, @@ -105,9 +107,10 @@ function useContextMenu() { event.stopPropagation(); clearAllLayers(); - // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // oxlint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore handleMouseEvent({ + // oxlint-disable-next-line typescript/no-misused-spread -- Intentionally extracting MouseEvent properties for synthetic event ...event.originalEvent, clientY: position.top, clientX: position.left, diff --git a/packages/graph-explorer/src/modules/GraphViewer/useGraphSelection.test.ts b/packages/graph-explorer/src/modules/GraphViewer/useGraphSelection.test.ts index eeaef31ac..fdd17bcf4 100644 --- a/packages/graph-explorer/src/modules/GraphViewer/useGraphSelection.test.ts +++ b/packages/graph-explorer/src/modules/GraphViewer/useGraphSelection.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment happy-dom import { act } from "react"; import { renderHookWithState } from "@/utils/testing"; diff --git a/packages/graph-explorer/src/modules/GraphViewer/useGraphStyles.test.tsx b/packages/graph-explorer/src/modules/GraphViewer/useGraphStyles.test.tsx index 17c617620..3002a999d 100644 --- a/packages/graph-explorer/src/modules/GraphViewer/useGraphStyles.test.tsx +++ b/packages/graph-explorer/src/modules/GraphViewer/useGraphStyles.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment happy-dom import { waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, type Mock, vi } from "vitest"; diff --git a/packages/graph-explorer/src/modules/Namespaces/UserPrefixes.tsx b/packages/graph-explorer/src/modules/Namespaces/UserPrefixes.tsx index 670a5d67b..0a4495902 100644 --- a/packages/graph-explorer/src/modules/Namespaces/UserPrefixes.tsx +++ b/packages/graph-explorer/src/modules/Namespaces/UserPrefixes.tsx @@ -176,7 +176,7 @@ function useDeletePrefixCallback(prefix: string) { const activeSchema = updatedSchemas.get(activeConfigId); updatedSchemas.set(activeConfigId, { - ...(activeSchema || {}), + ...activeSchema, vertices: activeSchema?.vertices || [], edges: activeSchema?.edges || [], prefixes: (activeSchema?.prefixes || []).filter( @@ -227,7 +227,7 @@ function EditPrefixModal({ const activeSchema = updatedSchemas.get(configId); updatedSchemas.set(configId, { - ...(activeSchema || {}), + ...activeSchema, vertices: activeSchema?.vertices || [], edges: activeSchema?.edges || [], prefixes: [ diff --git a/packages/graph-explorer/src/modules/NodesStyling/NodeStyleDialog.tsx b/packages/graph-explorer/src/modules/NodesStyling/NodeStyleDialog.tsx index 4dd74e54e..81fda77b6 100644 --- a/packages/graph-explorer/src/modules/NodesStyling/NodeStyleDialog.tsx +++ b/packages/graph-explorer/src/modules/NodesStyling/NodeStyleDialog.tsx @@ -10,6 +10,7 @@ import { FieldLabel, FieldSet, FileButton, + IconPicker, Input, Select, SelectContent, @@ -208,10 +209,17 @@ function Content({ vertexType }: { vertexType: VertexType }) { Icon
+ + setVertexStyle({ iconUrl, iconImageType }) + } + /> { - file && convertImageToBase64AndSetNewIcon(file); + if (file) { + convertImageToBase64AndSetNewIcon(file); + } }} variant="outline" className="rounded-full" diff --git a/packages/graph-explorer/src/modules/NodesStyling/SingleNodeStyling.tsx b/packages/graph-explorer/src/modules/NodesStyling/SingleNodeStyling.tsx index 4b8eff9b5..a84c9eb44 100644 --- a/packages/graph-explorer/src/modules/NodesStyling/SingleNodeStyling.tsx +++ b/packages/graph-explorer/src/modules/NodesStyling/SingleNodeStyling.tsx @@ -31,7 +31,7 @@ export default function SingleNodeStyling({ if (prevDisplayAs === null || prevDisplayAs === debouncedDisplayAs) { return; } - void setVertexStyle({ displayLabel: debouncedDisplayAs }); + setVertexStyle({ displayLabel: debouncedDisplayAs }); }, [debouncedDisplayAs, prevDisplayAs, setVertexStyle]); return ( diff --git a/packages/graph-explorer/src/modules/SchemaGraph/Sidebar/Details.test.tsx b/packages/graph-explorer/src/modules/SchemaGraph/Sidebar/Details.test.tsx index ac83eee5b..8211da824 100644 --- a/packages/graph-explorer/src/modules/SchemaGraph/Sidebar/Details.test.tsx +++ b/packages/graph-explorer/src/modules/SchemaGraph/Sidebar/Details.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment happy-dom import { render, screen } from "@testing-library/react"; import { describe, expect, test } from "vitest"; diff --git a/packages/graph-explorer/src/modules/SchemaGraph/Sidebar/Details.tsx b/packages/graph-explorer/src/modules/SchemaGraph/Sidebar/Details.tsx index 8d83e27d6..c37d549f9 100644 --- a/packages/graph-explorer/src/modules/SchemaGraph/Sidebar/Details.tsx +++ b/packages/graph-explorer/src/modules/SchemaGraph/Sidebar/Details.tsx @@ -39,13 +39,16 @@ export function DetailsHeader({ /** Title text for detail sections */ export function DetailsTitle({ className, + children, ...props }: ComponentPropsWithRef<"h2">) { return (

+ > + {children} +

); } diff --git a/packages/graph-explorer/src/modules/SchemaGraph/Sidebar/SchemaExplorerSidebar.test.tsx b/packages/graph-explorer/src/modules/SchemaGraph/Sidebar/SchemaExplorerSidebar.test.tsx index 496410221..2dac05b7e 100644 --- a/packages/graph-explorer/src/modules/SchemaGraph/Sidebar/SchemaExplorerSidebar.test.tsx +++ b/packages/graph-explorer/src/modules/SchemaGraph/Sidebar/SchemaExplorerSidebar.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment happy-dom import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; diff --git a/packages/graph-explorer/src/modules/SearchSidebar/useEdgeAttributesAsScalars.test.ts b/packages/graph-explorer/src/modules/SearchSidebar/useEdgeAttributesAsScalars.test.ts index 6f4171f0b..acddc5233 100644 --- a/packages/graph-explorer/src/modules/SearchSidebar/useEdgeAttributesAsScalars.test.ts +++ b/packages/graph-explorer/src/modules/SearchSidebar/useEdgeAttributesAsScalars.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment happy-dom import { describe, expect, it } from "vitest"; import { createResultScalar } from "@/connector/entities"; diff --git a/packages/graph-explorer/src/modules/SearchSidebar/useKeywordSearch.test.ts b/packages/graph-explorer/src/modules/SearchSidebar/useKeywordSearch.test.ts index 285b020d1..a09964963 100644 --- a/packages/graph-explorer/src/modules/SearchSidebar/useKeywordSearch.test.ts +++ b/packages/graph-explorer/src/modules/SearchSidebar/useKeywordSearch.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment happy-dom import type { QueryEngine } from "@shared/types"; import { vi } from "vitest"; diff --git a/packages/graph-explorer/src/modules/SearchSidebar/useVertexAttributesAsScalars.test.ts b/packages/graph-explorer/src/modules/SearchSidebar/useVertexAttributesAsScalars.test.ts index 0ba2804c7..bfc180923 100644 --- a/packages/graph-explorer/src/modules/SearchSidebar/useVertexAttributesAsScalars.test.ts +++ b/packages/graph-explorer/src/modules/SearchSidebar/useVertexAttributesAsScalars.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment happy-dom import { createResultScalar } from "@/connector/entities"; import { createVertexId, diff --git a/packages/graph-explorer/src/routes/Connections/Connections.tsx b/packages/graph-explorer/src/routes/Connections/Connections.tsx index d23a1ec50..16e3ec26a 100644 --- a/packages/graph-explorer/src/routes/Connections/Connections.tsx +++ b/packages/graph-explorer/src/routes/Connections/Connections.tsx @@ -1,6 +1,3 @@ -import { useAtomValue } from "jotai"; -import { useState } from "react"; - import { NavBar, NavBarContent, @@ -14,15 +11,13 @@ import { WorkspaceContent, } from "@/components"; import GraphExplorerIcon from "@/components/icons/GraphExplorerIcon"; -import { configurationAtom, useConfiguration } from "@/core"; +import { useConfiguration } from "@/core"; import { useIsSyncing } from "@/hooks/useSchemaSync"; import AvailableConnections from "@/modules/AvailableConnections"; import ConnectionDetail from "@/modules/ConnectionDetail"; export default function Connections() { const config = useConfiguration(); - const configuration = useAtomValue(configurationAtom); - const [isModalOpen, setModal] = useState(configuration.size === 0); const isSyncing = useIsSyncing(); return ( @@ -39,11 +34,7 @@ export default function Connections() {
- +
{config ? (
diff --git a/packages/graph-explorer/src/routes/DataExplorer/DataExplorer.test.tsx b/packages/graph-explorer/src/routes/DataExplorer/DataExplorer.test.tsx index b4f03e143..206aa91b6 100644 --- a/packages/graph-explorer/src/routes/DataExplorer/DataExplorer.test.tsx +++ b/packages/graph-explorer/src/routes/DataExplorer/DataExplorer.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment happy-dom import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { render, screen, waitFor } from "@testing-library/react"; import { Provider } from "jotai"; diff --git a/packages/graph-explorer/src/routes/DefaultLayout.tsx b/packages/graph-explorer/src/routes/DefaultLayout.tsx index a904e509d..0b2d07086 100644 --- a/packages/graph-explorer/src/routes/DefaultLayout.tsx +++ b/packages/graph-explorer/src/routes/DefaultLayout.tsx @@ -1,26 +1,39 @@ import { QueryClientProvider } from "@tanstack/react-query"; +import { useAtomValue } from "jotai"; +import { useEffect } from "react"; import { ErrorBoundary } from "react-error-boundary"; import { Outlet } from "react-router"; import { TooltipProvider } from "@/components"; import { Toaster } from "@/components/Toaster"; +import { diagnosticLoggingAtom } from "@/core"; import AppErrorPage from "@/core/AppErrorPage"; import AppStatusLoader from "@/core/AppStatusLoader"; +import { setDiagnosticLogging } from "@/utils/logger"; -import { ExplorerInjector } from "../core/ExplorerInjector"; import { createQueryClient } from "../core/queryClient"; const queryClient = createQueryClient(); +/** Bridges the diagnosticLogging Jotai atom to the logger's module-level flag. */ +function useSyncDiagnosticLogging() { + const enabled = useAtomValue(diagnosticLoggingAtom); + useEffect(() => { + setDiagnosticLogging(enabled); + return () => setDiagnosticLogging(false); + }, [enabled]); +} + /** * The default layout for the app, which sets up the query client, a global * error boundary, and other app wide services. */ export default function DefaultLayout() { + useSyncDiagnosticLogging(); + return ( - diff --git a/packages/graph-explorer/src/routes/Settings/SettingsGeneral.tsx b/packages/graph-explorer/src/routes/Settings/SettingsGeneral.tsx index 78b47951d..3322835a8 100644 --- a/packages/graph-explorer/src/routes/Settings/SettingsGeneral.tsx +++ b/packages/graph-explorer/src/routes/Settings/SettingsGeneral.tsx @@ -1,10 +1,18 @@ -import { useAtom } from "jotai"; +import { useAtom, useAtomValue, useSetAtom } from "jotai"; import localforage from "localforage"; -import { SaveAllIcon } from "lucide-react"; +import { + DownloadIcon, + RotateCcwIcon, + SaveAllIcon, + UploadIcon, +} from "lucide-react"; import { AnimatePresence, motion } from "motion/react"; +import { useCallback } from "react"; +import { toast } from "sonner"; import { Button, + FileButton, FormItem, ImportantBlock, Input, @@ -21,11 +29,16 @@ import { allowLoggingDbQueryAtom, defaultNeighborExpansionLimitAtom, defaultNeighborExpansionLimitEnabledAtom, + defaultStylingAtom, + diagnosticLoggingAtom, showDebugActionsAtom, + userStylingAtom, } from "@/core"; import { saveLocalForageToFile } from "@/core/StateProvider/localDb"; import LoadConfigButton from "./LoadConfigButton"; +import { useExportStylingFile } from "./useExportStylingFile"; +import { useImportStylingFile } from "./useImportStylingFile"; export default function SettingsGeneral() { const [isDebugOptionsEnabled, setIsDebugOptionsEnabled] = @@ -35,6 +48,10 @@ export default function SettingsGeneral() { allowLoggingDbQueryAtom, ); + const [diagnosticLogging, setDiagnosticLogging] = useAtom( + diagnosticLoggingAtom, + ); + const [defaultNeighborExpansionLimit, setDefaultNeighborExpansionLimit] = useAtom(defaultNeighborExpansionLimitAtom); @@ -43,6 +60,22 @@ export default function SettingsGeneral() { setDefaultNeighborExpansionLimitEnabled, ] = useAtom(defaultNeighborExpansionLimitEnabledAtom); + const exportStyling = useExportStylingFile(); + const importStyling = useImportStylingFile(); + const defaultStyling = useAtomValue(defaultStylingAtom); + const setUserStyling = useSetAtom(userStylingAtom); + + const resetAllStyling = useCallback(() => { + if (defaultStyling) { + setUserStyling(defaultStyling); + } else { + setUserStyling({}); + } + toast.success("Styling Reset", { + description: "All styling has been reset to defaults", + }); + }, [defaultStyling, setUserStyling]); + return ( General Settings @@ -106,6 +139,63 @@ export default function SettingsGeneral() {

+ + + + + { + if (file) { + void importStyling(file); + } + }} + > + + Import + + + + + + +

+ Importing styling will replace your current node and edge styling. + Resetting will revert all types to their default appearance. +

+
+ + { + setDiagnosticLogging(Boolean(isSelected)); + }} + label="Diagnostic logging" + description="Enables verbose logging to the browser console for troubleshooting." + /> + +