mirror of
https://github.com/reconurge/flowsint.git
synced 2026-08-04 02:33:48 -05:00
Compare commits
101
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d1d5c1416 | ||
|
|
9d1991f45f | ||
|
|
eef214bdfa | ||
|
|
1db16ec13a | ||
|
|
2032aeee0b | ||
|
|
92e5a7097a | ||
|
|
b9fa1bbde9 | ||
|
|
dcce008b8d | ||
|
|
b6f2f8be68 | ||
|
|
ad6a31a7ab | ||
|
|
6293c90511 | ||
|
|
ae070c14c7 | ||
|
|
599fc8353d | ||
|
|
11df9e6fde | ||
|
|
af590675ec | ||
|
|
89f32e2182 | ||
|
|
3f252ea6ab | ||
|
|
a764704fbe | ||
|
|
19ee40c46d | ||
|
|
137d16e1fc | ||
|
|
38cee3759e | ||
|
|
07f32c983a | ||
|
|
fbb9666c53 | ||
|
|
a461f9997d | ||
|
|
e4cf6ac518 | ||
|
|
ac397f6714 | ||
|
|
53a03575cd | ||
|
|
5ff6ba2b1c | ||
|
|
327123a729 | ||
|
|
2bb1fdbfc1 | ||
|
|
1bf3dae185 | ||
|
|
8a01f353b8 | ||
|
|
9e07429e7b | ||
|
|
2bac67e2a8 | ||
|
|
ad85a5c419 | ||
|
|
177953b216 | ||
|
|
dd0c53f089 | ||
|
|
1ba3c0bd6c | ||
|
|
2fec5b82e9 | ||
|
|
1cc88d1033 | ||
|
|
2372678bda | ||
|
|
fd20468c79 | ||
|
|
427035fd41 | ||
|
|
152be112d0 | ||
|
|
01908e682b | ||
|
|
20a3d35909 | ||
|
|
16c9497268 | ||
|
|
12ef177127 | ||
|
|
14f79fecc5 | ||
|
|
73bb4a0e7a | ||
|
|
51e2f87643 | ||
|
|
ce2fcb49bd | ||
|
|
a178ef1bcd | ||
|
|
7d33b813de | ||
|
|
ab1b7f5c24 | ||
|
|
f1ef843616 | ||
|
|
6766c940bc | ||
|
|
308172a8f9 | ||
|
|
138bbb0fc6 | ||
|
|
f1c3cf1017 | ||
|
|
154cc74e80 | ||
|
|
2f5443b074 | ||
|
|
4b82d3749e | ||
|
|
b18733d158 | ||
|
|
c8085c6400 | ||
|
|
1ce2cbd7a5 | ||
|
|
531bcf970e | ||
|
|
cbea4c09a8 | ||
|
|
3c6a23ebfa | ||
|
|
d0343cba50 | ||
|
|
5977832a1a | ||
|
|
ffe2203410 | ||
|
|
21376168ef | ||
|
|
3c39f9aeb7 | ||
|
|
aa1e39b8af | ||
|
|
93b628480b | ||
|
|
6544b72213 | ||
|
|
9d61be4d04 | ||
|
|
1608f4ca87 | ||
|
|
1b57f8da5c | ||
|
|
feefdd5321 | ||
|
|
f53269641e | ||
|
|
5d81106c15 | ||
|
|
b81274ae0b | ||
|
|
be0880e77d | ||
|
|
6491134b73 | ||
|
|
e76b1ecb2e | ||
|
|
6265b4af10 | ||
|
|
70347690a2 | ||
|
|
baa344f364 | ||
|
|
27c9e91c92 | ||
|
|
c1fe5d2307 | ||
|
|
939008713d | ||
|
|
31f5b200ee | ||
|
|
1ea0296bd8 | ||
|
|
716ed0bc55 | ||
|
|
c17a542e07 | ||
|
|
2c9699542e | ||
|
|
a06da56c23 | ||
|
|
1d184bae3b | ||
|
|
1fc7181a93 |
+16
-23
@@ -1,22 +1,17 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
*.egg
|
||||
.eggs/
|
||||
|
||||
# Virtual environments
|
||||
.venv/
|
||||
venv/
|
||||
ENV/
|
||||
env/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
@@ -24,50 +19,48 @@ env/
|
||||
*~
|
||||
.DS_Store
|
||||
|
||||
# Git
|
||||
.git/
|
||||
.gitignore
|
||||
.gitattributes
|
||||
|
||||
# Documentation
|
||||
*.md
|
||||
!README.md
|
||||
docs/
|
||||
|
||||
# Testing
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.tox/
|
||||
.mypy_cache/
|
||||
.hypothesis/
|
||||
.ruff_cache/
|
||||
|
||||
# Node (for frontend)
|
||||
flowsint-app/node_modules/
|
||||
flowsint-app/dist/
|
||||
flowsint-app/.next/
|
||||
flowsint-app/out/
|
||||
flowsint-app/build/
|
||||
flowsint-app/.vite/
|
||||
flowsint-app/coverage/
|
||||
|
||||
# Environment files
|
||||
.env.local
|
||||
.env.*.local
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.local
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Docker
|
||||
docker-compose*.yml
|
||||
Dockerfile.dev
|
||||
Dockerfile.optimized
|
||||
.dockerignore
|
||||
|
||||
# CI/CD
|
||||
.github/
|
||||
.gitlab-ci.yml
|
||||
|
||||
# Misc
|
||||
*.md
|
||||
!README.md
|
||||
docs/
|
||||
|
||||
dist/
|
||||
build/
|
||||
|
||||
*.bak
|
||||
*.tmp
|
||||
temp/
|
||||
tmp/
|
||||
Makefile
|
||||
|
||||
+138
-21
@@ -1,18 +1,34 @@
|
||||
name: images
|
||||
name: Build and Push Docker Images
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
env:
|
||||
REGISTRY_DOCKERHUB: docker.io
|
||||
REGISTRY_GHCR: ghcr.io
|
||||
|
||||
jobs:
|
||||
build:
|
||||
environment: production
|
||||
build-frontend:
|
||||
name: Build Frontend
|
||||
runs-on: ubuntu-latest
|
||||
environment: production
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
security-events: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
@@ -26,33 +42,134 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ github.repository_owner }}/flowsint-app
|
||||
ghcr.io/${{ github.repository_owner }}/flowsint-app
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./flowsint-app
|
||||
file: ./flowsint-app/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
provenance: true
|
||||
sbom: true
|
||||
|
||||
- name: Run Trivy vulnerability scanner
|
||||
id: trivy
|
||||
uses: aquasecurity/trivy-action@master
|
||||
with:
|
||||
image-ref: ghcr.io/${{ github.repository_owner }}/flowsint-app:${{ github.ref_name }}
|
||||
format: "sarif"
|
||||
output: "trivy-frontend.sarif"
|
||||
severity: "CRITICAL,HIGH"
|
||||
|
||||
- name: Upload Trivy scan results
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
if: always() && steps.trivy.outcome == 'success'
|
||||
with:
|
||||
sarif_file: "trivy-frontend.sarif"
|
||||
|
||||
build-backend:
|
||||
name: Build Backend
|
||||
runs-on: ubuntu-latest
|
||||
environment: production
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
security-events: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build and push frontend
|
||||
uses: docker/build-push-action@v6
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
context: "{{defaultContext}}:flowsint-app"
|
||||
tags: |
|
||||
${{ github.repository_owner }}/flowsint-app:${{ github.ref_name }}
|
||||
${{ github.repository_owner }}/flowsint-app:latest
|
||||
ghcr.io/${{ github.repository_owner }}/flowsint-app:${{ github.ref_name }}
|
||||
ghcr.io/${{ github.repository_owner }}/flowsint-app:latest
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push backend
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ github.repository_owner }}/flowsint-api
|
||||
ghcr.io/${{ github.repository_owner }}/flowsint-api
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./flowsint-api/Dockerfile
|
||||
target: production
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
target: prod
|
||||
file: ./flowsint-api/Dockerfile
|
||||
tags: |
|
||||
${{ github.repository_owner }}/flowsint-api:${{ github.ref_name }}
|
||||
${{ github.repository_owner }}/flowsint-api:latest
|
||||
ghcr.io/${{ github.repository_owner }}/flowsint-api:${{ github.ref_name }}
|
||||
ghcr.io/${{ github.repository_owner }}/flowsint-api:latest
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
provenance: true
|
||||
sbom: true
|
||||
|
||||
- name: Run Trivy vulnerability scanner
|
||||
id: trivy
|
||||
uses: aquasecurity/trivy-action@master
|
||||
with:
|
||||
image-ref: ghcr.io/${{ github.repository_owner }}/flowsint-api:${{ github.ref_name }}
|
||||
format: "sarif"
|
||||
output: "trivy-backend.sarif"
|
||||
severity: "CRITICAL,HIGH"
|
||||
|
||||
- name: Upload Trivy scan results
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
if: always() && steps.trivy.outcome == 'success'
|
||||
with:
|
||||
sarif_file: "trivy-backend.sarif"
|
||||
|
||||
security-summary:
|
||||
name: Security Summary
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-frontend, build-backend]
|
||||
if: always()
|
||||
steps:
|
||||
- name: Summary
|
||||
run: |
|
||||
echo "## Build Summary" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Image | Status |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|-------|--------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Frontend | ${{ needs.build-frontend.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Backend | ${{ needs.build-backend.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Security scans uploaded to GitHub Security tab." >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
## License
|
||||
|
||||
Flowsint is distributed under the **AGPL-3.0** (GNU Affero General Public License v3.0).
|
||||
Flowsint is distributed under the **Apache License 2.0** (effective January 25, 2026).
|
||||
|
||||
Code contributed prior to January 25, 2026 was originally licensed under AGPL-3.0
|
||||
and has been relicensed to Apache 2.0 with the written consent of all contributors.
|
||||
|
||||
## Ethical Foundation
|
||||
|
||||
@@ -48,4 +51,4 @@ If you become aware of any use that contradicts these principles, please report
|
||||
## Responsibility Statement
|
||||
|
||||
The developers and maintainers of Flowsint are committed to upholding these ethical principles throughout the project’s life cycle.
|
||||
However, in accordance with the AGPL-3.0 license, **Flowsint is provided “as is,” without warranty**, and its authors **cannot be held liable for misuse or unlawful activity conducted by third parties**.
|
||||
However, in accordance with the Apache License 2.0, **Flowsint is provided "as is," without warranty**, and its authors **cannot be held liable for misuse or unlawful activity conducted by third parties**.
|
||||
|
||||
@@ -1,660 +1,201 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
A modern platform for visual, flexible, and extensible graph-based investigations. Built with modern technologies for seamless data exploration, enrichment, and connection.
|
||||
Copyright (C) 2025 reconurge
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to the Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2025-2026 Reconurge
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
@@ -1,126 +1,239 @@
|
||||
PROJECT_ROOT := $(shell pwd)
|
||||
|
||||
.PHONY: install run stop stop-dev stop-prod infra api frontend celery clean dev prod check-env build-dev build-prod open-browser test
|
||||
COMPOSE_DEV := docker compose -f docker-compose.dev.yml
|
||||
COMPOSE_PROD := docker compose -f docker-compose.prod.yml
|
||||
COMPOSE_DEPLOY := docker compose -f docker-compose.deploy.yml
|
||||
|
||||
.PHONY: \
|
||||
dev prod deploy \
|
||||
build-dev build-prod \
|
||||
up-dev up-prod up-deploy down \
|
||||
infra-dev infra-prod infra-stop-dev infra-stop-prod \
|
||||
migrate-dev migrate-prod \
|
||||
alembic-upgrade alembic-downgrade alembic-revision \
|
||||
api frontend celery \
|
||||
test install clean check-env open-browser-dev open-browser-prod \
|
||||
logs-dev logs-prod logs-deploy status \
|
||||
regenerate-router
|
||||
|
||||
ENV_DIRS := . flowsint-api flowsint-core flowsint-app
|
||||
|
||||
open-browser:
|
||||
@echo "⏳ Waiting for frontend to be ready..."
|
||||
@bash -c 'until curl -s http://localhost:5173 > /dev/null 2>&1; do sleep 1; done'
|
||||
@echo "🌐 Opening browser..."
|
||||
@open http://localhost:5173 2>/dev/null || xdg-open http://localhost:5173 2>/dev/null || echo "✅ Flowsint ready at http://localhost:5173"
|
||||
|
||||
dev:
|
||||
@echo "🐙 Starting Flowsint in DEVELOPMENT mode..."
|
||||
$(MAKE) check-env
|
||||
docker compose -f docker-compose.dev.yml up --build -d
|
||||
$(MAKE) open-browser
|
||||
docker compose -f docker-compose.dev.yml logs -f
|
||||
|
||||
prod:
|
||||
@echo "🐙 Starting Flowsint in PRODUCTION mode..."
|
||||
$(MAKE) check-env
|
||||
docker compose -f docker-compose.prod.yml up --build -d
|
||||
$(MAKE) open-browser
|
||||
|
||||
build-dev:
|
||||
@echo "🔨 Building development images..."
|
||||
docker compose -f docker-compose.dev.yml build
|
||||
|
||||
build-prod:
|
||||
@echo "🔨 Building production images..."
|
||||
docker compose -f docker-compose.prod.yml build
|
||||
|
||||
check-env:
|
||||
@echo "🔎 Checking .env files..."
|
||||
@echo "Checking .env files..."
|
||||
@for dir in $(ENV_DIRS); do \
|
||||
env_file="$$dir/.env"; \
|
||||
env_example="$(PROJECT_ROOT)/.env.example"; \
|
||||
if [ -f "$$env_file" ]; then \
|
||||
echo "✅ Using existing .env in $$dir"; \
|
||||
else \
|
||||
echo "⚠️ .env missing in $$dir, copying from .env.example"; \
|
||||
if [ ! -f "$$env_file" ]; then \
|
||||
cp "$$env_example" "$$env_file"; \
|
||||
echo "Created $$env_file"; \
|
||||
fi; \
|
||||
done
|
||||
|
||||
test:
|
||||
@echo "🔎 Running tests..."
|
||||
cd $(PROJECT_ROOT)/flowsint-types && poetry run pytest
|
||||
cd $(PROJECT_ROOT)/flowsint-core && poetry run pytest
|
||||
cd $(PROJECT_ROOT)/flowsint-enrichers && poetry run pytest
|
||||
dev:
|
||||
@echo "Starting DEV environment..."
|
||||
$(MAKE) check-env
|
||||
$(MAKE) build-dev
|
||||
$(MAKE) up-dev
|
||||
$(MAKE) open-browser-dev
|
||||
$(COMPOSE_DEV) logs -f
|
||||
|
||||
install:
|
||||
@echo "🐙 Installing Flowsint project modules..."
|
||||
@if ! command -v poetry >/dev/null 2>&1; then \
|
||||
echo "⚠️ Poetry is not installed. Please install it:"; \
|
||||
echo "pipx install poetry"; \
|
||||
echo "or"; \
|
||||
echo "curl -sSL https://install.python-poetry.org | python3 -"; \
|
||||
exit 1; \
|
||||
build-dev:
|
||||
@echo "Building DEV images..."
|
||||
$(COMPOSE_DEV) build
|
||||
|
||||
up-dev:
|
||||
$(COMPOSE_DEV) up -d
|
||||
|
||||
infra-dev:
|
||||
@echo "Starting DEV infra (postgres / redis / neo4j)..."
|
||||
$(COMPOSE_DEV) up -d postgres redis neo4j
|
||||
|
||||
infra-stop-dev:
|
||||
@echo "Stopping DEV infra..."
|
||||
$(COMPOSE_DEV) stop postgres redis neo4j
|
||||
|
||||
logs-dev:
|
||||
$(COMPOSE_DEV) logs -f
|
||||
|
||||
open-browser-dev:
|
||||
@echo "Waiting for frontend on port 5173..."
|
||||
@bash -c 'until curl -s http://localhost:5173 > /dev/null 2>&1; do sleep 1; done'
|
||||
@open http://localhost:5173 2>/dev/null || \
|
||||
xdg-open http://localhost:5173 2>/dev/null || \
|
||||
echo "Frontend ready at http://localhost:5173"
|
||||
|
||||
prod:
|
||||
@echo "Starting PROD environment..."
|
||||
$(MAKE) check-env
|
||||
$(MAKE) build-prod
|
||||
$(MAKE) up-prod
|
||||
@echo ""
|
||||
@echo "Production started!"
|
||||
@echo " Frontend: http://localhost:5173"
|
||||
@echo " API: http://localhost:5001"
|
||||
|
||||
build-prod:
|
||||
@echo "Building PROD images..."
|
||||
$(COMPOSE_PROD) build
|
||||
|
||||
up-prod:
|
||||
$(COMPOSE_PROD) up -d
|
||||
|
||||
infra-prod:
|
||||
@echo "Starting PROD infra (postgres / redis / neo4j)..."
|
||||
$(COMPOSE_PROD) up -d postgres redis neo4j
|
||||
|
||||
infra-stop-prod:
|
||||
@echo "Stopping PROD infra..."
|
||||
$(COMPOSE_PROD) stop postgres redis neo4j
|
||||
|
||||
logs-prod:
|
||||
$(COMPOSE_PROD) logs -f
|
||||
|
||||
open-browser-prod:
|
||||
@echo "Waiting for frontend on port 80 (Traefik)..."
|
||||
@bash -c 'until curl -s http://localhost > /dev/null 2>&1; do sleep 2; done'
|
||||
@open http://localhost 2>/dev/null || \
|
||||
xdg-open http://localhost 2>/dev/null || \
|
||||
echo "Frontend ready at http://localhost"
|
||||
|
||||
deploy:
|
||||
@echo "Starting DEPLOY environment (GHCR images)..."
|
||||
$(MAKE) check-env
|
||||
$(COMPOSE_DEPLOY) pull
|
||||
$(COMPOSE_DEPLOY) up -d
|
||||
@echo ""
|
||||
@echo "Deploy started!"
|
||||
@echo " Frontend: http://localhost (via Traefik)"
|
||||
@echo " API: http://localhost/api"
|
||||
|
||||
up-deploy:
|
||||
$(COMPOSE_DEPLOY) up -d
|
||||
|
||||
logs-deploy:
|
||||
$(COMPOSE_DEPLOY) logs -f
|
||||
|
||||
migrate-dev:
|
||||
@echo "Running DEV migrations..."
|
||||
@if ! $(COMPOSE_DEV) ps -q neo4j | grep -q .; then \
|
||||
echo "Neo4j not running → starting DEV infra"; \
|
||||
$(COMPOSE_DEV) up -d --wait neo4j; \
|
||||
fi
|
||||
poetry config virtualenvs.in-project true --local
|
||||
docker compose up -d postgres redis neo4j
|
||||
poetry install
|
||||
cd $(PROJECT_ROOT)/flowsint-core && poetry install
|
||||
cd $(PROJECT_ROOT)/flowsint-enrichers && poetry install
|
||||
cd $(PROJECT_ROOT)/flowsint-api && poetry install && poetry run alembic upgrade head
|
||||
@echo "✅ All modules installed successfully!"
|
||||
yarn migrate
|
||||
|
||||
infra:
|
||||
docker compose up -d
|
||||
migrate-prod:
|
||||
@echo "⚠️ Running PROD migrations"
|
||||
@echo "This will ALTER production data."
|
||||
@read -p "Type 'prod' to continue: " confirm; \
|
||||
if [ "$$confirm" != "prod" ]; then \
|
||||
echo "Aborted."; exit 1; \
|
||||
fi
|
||||
yarn migrate
|
||||
|
||||
alembic-upgrade:
|
||||
@echo "Running Alembic migrations (upgrade head)..."
|
||||
cd $(PROJECT_ROOT)/flowsint-api && poetry run alembic upgrade head
|
||||
|
||||
alembic-downgrade:
|
||||
@echo "Rolling back last Alembic migration..."
|
||||
cd $(PROJECT_ROOT)/flowsint-api && poetry run alembic downgrade -1
|
||||
|
||||
alembic-revision:
|
||||
@if [ -z "$(m)" ]; then \
|
||||
echo "Usage: make alembic-revision m=\"your migration message\""; exit 1; \
|
||||
fi
|
||||
@echo "Creating new Alembic migration: $(m)"
|
||||
cd $(PROJECT_ROOT)/flowsint-api && poetry run alembic revision --autogenerate -m "$(m)"
|
||||
|
||||
api:
|
||||
cd $(PROJECT_ROOT)/flowsint-api && poetry run uvicorn app.main:app --host 0.0.0.0 --port 5001 --reload
|
||||
cd $(PROJECT_ROOT)/flowsint-api && \
|
||||
poetry run uvicorn app.main:app --host 0.0.0.0 --port 5001 --reload
|
||||
|
||||
frontend:
|
||||
@echo "🐙 Starting frontend and opening browser..."
|
||||
@docker compose up -d flowsint-app
|
||||
@bash -c 'until curl -s http://localhost:5173 > /dev/null 2>&1; do sleep 1; done; open http://localhost:5173 2>/dev/null || xdg-open http://localhost:5173 2>/dev/null || echo "✅ Frontend ready at http://localhost:5173"'
|
||||
|
||||
frontend_prod:
|
||||
cd $(PROJECT_ROOT)/flowsint-app && npm run build
|
||||
cd $(PROJECT_ROOT)/flowsint-app && yarn dev
|
||||
|
||||
celery:
|
||||
cd $(PROJECT_ROOT)/flowsint-core && poetry run celery -A flowsint_core.core.celery worker --loglevel=info --pool=threads --concurrency=10
|
||||
cd $(PROJECT_ROOT)/flowsint-api && \
|
||||
poetry run celery -A flowsint_core.core.celery \
|
||||
worker --loglevel=info --pool=threads --concurrency=10
|
||||
|
||||
run:
|
||||
@echo "🐙 Starting all services..."
|
||||
docker compose up -d
|
||||
@echo "⏳ Waiting for frontend to be ready..."
|
||||
@bash -c 'until curl -s http://localhost:5173 > /dev/null 2>&1; do sleep 1; done'
|
||||
@echo "🌐 Opening browser..."
|
||||
@open http://localhost:5173 2>/dev/null || xdg-open http://localhost:5173 2>/dev/null || echo "✅ All services ready! Flowsint at http://localhost:5173"
|
||||
$(MAKE) -j2 api celery
|
||||
test:
|
||||
cd flowsint-types && poetry run pytest
|
||||
cd flowsint-core && poetry run pytest
|
||||
cd flowsint-enrichers && poetry run pytest
|
||||
|
||||
stop:
|
||||
@echo "🛑 Stopping all services..."
|
||||
-docker compose -f docker-compose.dev.yml down
|
||||
-docker compose -f docker-compose.prod.yml down
|
||||
-docker compose down
|
||||
install:
|
||||
poetry config virtualenvs.in-project true --local
|
||||
$(MAKE) infra-dev
|
||||
poetry install
|
||||
cd flowsint-core && poetry install
|
||||
cd flowsint-enrichers && poetry install
|
||||
cd flowsint-api && poetry install && poetry run alembic upgrade head
|
||||
|
||||
stop-dev:
|
||||
@echo "🛑 Stopping development services..."
|
||||
docker compose -f docker-compose.dev.yml down
|
||||
status:
|
||||
@echo "=== DEV Containers ==="
|
||||
@$(COMPOSE_DEV) ps 2>/dev/null || echo "No DEV containers"
|
||||
@echo ""
|
||||
@echo "=== PROD Containers ==="
|
||||
@$(COMPOSE_PROD) ps 2>/dev/null || echo "No PROD containers"
|
||||
|
||||
stop-prod:
|
||||
@echo "🛑 Stopping production services..."
|
||||
docker compose -f docker-compose.prod.yml down
|
||||
down:
|
||||
-$(COMPOSE_DEV) down
|
||||
-$(COMPOSE_PROD) down
|
||||
-$(COMPOSE_DEPLOY) down
|
||||
|
||||
clean:
|
||||
@echo "⚠️ WARNING: This will remove ALL Docker containers, images, volumes, and virtual environments."
|
||||
@echo "⚠️ ALL DATA in databases and volumes will be permanently deleted!"
|
||||
@echo "This will remove ALL Docker data. Continue? [y/N]"
|
||||
@read confirm; \
|
||||
if [ "$$confirm" != "y" ]; then exit 1; fi
|
||||
-$(COMPOSE_DEV) down -v --rmi all --remove-orphans
|
||||
-$(COMPOSE_PROD) down -v --rmi all --remove-orphans
|
||||
-$(COMPOSE_DEPLOY) down -v --rmi all --remove-orphans
|
||||
rm -rf flowsint-app/node_modules
|
||||
rm -rf flowsint-core/.venv
|
||||
rm -rf flowsint-enrichers/.venv
|
||||
rm -rf flowsint-api/.venv
|
||||
|
||||
regenerate-router:
|
||||
@echo "Regenerating flowsint-app/src/routeTree.gen.ts"
|
||||
cd $(PROJECT_ROOT)/flowsint-app && npx tsr generate
|
||||
|
||||
help:
|
||||
@echo "Flowsint Makefile"
|
||||
@echo ""
|
||||
@read -p "Are you sure you want to continue? [y/N]: " confirm; \
|
||||
if [ "$$confirm" != "y" ] && [ "$$confirm" != "Y" ]; then \
|
||||
echo "❌ Cleanup cancelled."; \
|
||||
exit 1; \
|
||||
fi
|
||||
@echo "🧹 Removing containers, images, volumes and venvs..."
|
||||
-docker compose -f docker-compose.dev.yml down -v --rmi all --remove-orphans
|
||||
-docker compose -f docker-compose.prod.yml down -v --rmi all --remove-orphans
|
||||
-docker compose down -v --rmi all --remove-orphans
|
||||
rm -rf $(PROJECT_ROOT)/flowsint-app/node_modules
|
||||
rm -rf $(PROJECT_ROOT)/flowsint-core/.venv
|
||||
rm -rf $(PROJECT_ROOT)/flowsint-enrichers/.venv
|
||||
rm -rf $(PROJECT_ROOT)/flowsint-api/.venv
|
||||
@echo "✅ Cleanup complete!"
|
||||
@echo "Development:"
|
||||
@echo " make dev - Start DEV environment (local build, hot-reload)"
|
||||
@echo " make build-dev - Build DEV images"
|
||||
@echo " make up-dev - Start DEV containers"
|
||||
@echo " make logs-dev - Follow DEV logs"
|
||||
@echo " make infra-dev - Start only infra (postgres/redis/neo4j)"
|
||||
@echo ""
|
||||
@echo "Production (local build):"
|
||||
@echo " make prod - Start PROD environment (local build + Traefik)"
|
||||
@echo " make build-prod - Build PROD images"
|
||||
@echo " make up-prod - Start PROD containers"
|
||||
@echo " make logs-prod - Follow PROD logs"
|
||||
@echo ""
|
||||
@echo "Deploy (GHCR images):"
|
||||
@echo " make deploy - Start with GHCR images (no build)"
|
||||
@echo " make up-deploy - Start DEPLOY containers"
|
||||
@echo " make logs-deploy - Follow DEPLOY logs"
|
||||
@echo ""
|
||||
@echo "Local (no Docker):"
|
||||
@echo " make api - Run API locally"
|
||||
@echo " make frontend - Run frontend locally"
|
||||
@echo " make celery - Run Celery worker locally"
|
||||
@echo ""
|
||||
@echo "Migrations:"
|
||||
@echo " make migrate-dev - Run Neo4j DEV migrations"
|
||||
@echo " make migrate-prod - Run Neo4j PROD migrations"
|
||||
@echo " make alembic-upgrade - Run Alembic migrations (upgrade head)"
|
||||
@echo " make alembic-downgrade - Rollback last Alembic migration"
|
||||
@echo " make alembic-revision m=.. - Create new Alembic migration"
|
||||
@echo ""
|
||||
@echo "Utilities:"
|
||||
@echo " make status - Show container status"
|
||||
@echo " make down - Stop all containers"
|
||||
@echo " make clean - Remove all Docker data"
|
||||
@echo " make install - Install dependencies locally"
|
||||
@echo " make test - Run tests"
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
Flowsint
|
||||
Copyright 2025-2026 Reconurge
|
||||
|
||||
This product includes software developed by Reconurge.
|
||||
https://github.com/reconurge/flowsint
|
||||
|
||||
Licensed under the Apache License, Version 2.0.
|
||||
|
||||
===============================================================================
|
||||
LICENSING HISTORY
|
||||
===============================================================================
|
||||
|
||||
Effective January 25, 2026, Flowsint is licensed under the Apache License 2.0.
|
||||
|
||||
All code contributed prior to January 25, 2026 was originally released under
|
||||
the GNU Affero General Public License v3.0 (AGPL-3.0-or-later). This code has
|
||||
been relicensed to Apache License 2.0 with the explicit written consent of all
|
||||
contributors.
|
||||
|
||||
The historical Git commits retain the original AGPL-3.0 license references for
|
||||
archival purposes. Users obtaining code from Git history prior to commit
|
||||
[LICENSE_MIGRATION_COMMIT] should note that such code was contributed under
|
||||
AGPL-3.0 terms, but has since been relicensed.
|
||||
|
||||
For the avoidance of doubt:
|
||||
- Code in releases from January 25, 2026 onward: Apache License 2.0
|
||||
- Code in Git history prior to January 25, 2026: Originally AGPL-3.0,
|
||||
relicensed to Apache License 2.0 with contributor consent
|
||||
|
||||
===============================================================================
|
||||
@@ -1,6 +1,6 @@
|
||||
# Flowsint
|
||||
|
||||
[](./LICENSE)
|
||||
[](./LICENSE)
|
||||
[](./ETHICS.md)
|
||||
|
||||
Flowsint is an open-source OSINT graph exploration tool designed for ethical investigation, transparency, and verification.
|
||||
@@ -8,6 +8,8 @@ Flowsint is an open-source OSINT graph exploration tool designed for ethical inv
|
||||
**Ethics:** Please read [ETHICS.md](./ETHICS.md) for responsible use guidelines.
|
||||
|
||||
<img width="1439" height="899" alt="hero-dark" src="https://github.com/user-attachments/assets/01eb128e-bef4-486e-9276-c4da58f829ae" />
|
||||
<img width="1511" height="946" alt="Capture d’écran 2026-01-13 à 09 15 58" src="https://github.com/user-attachments/assets/d1a9eca6-9ec4-4402-93f4-303c3dc30de1" />
|
||||
<img width="1511" height="948" alt="Capture d’écran 2026-01-13 à 09 19 45" src="https://github.com/user-attachments/assets/6d9e9e6d-d8c7-4ed2-8b8c-53a945b28d05" />
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
+9
-13
@@ -1,7 +1,6 @@
|
||||
name: flowsint-dev
|
||||
|
||||
services:
|
||||
# PostgreSQL database
|
||||
postgres:
|
||||
image: postgres:15
|
||||
container_name: flowsint-postgres-dev
|
||||
@@ -22,7 +21,6 @@ services:
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# Redis for Celery & cache
|
||||
redis:
|
||||
image: redis:alpine
|
||||
container_name: flowsint-redis-dev
|
||||
@@ -36,13 +34,12 @@ services:
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# Neo4j graph database
|
||||
neo4j:
|
||||
image: neo4j:5
|
||||
container_name: flowsint-neo4j-dev
|
||||
ports:
|
||||
- "7474:7474" # Web UI
|
||||
- "7687:7687" # Bolt
|
||||
- "7474:7474" # Web UI
|
||||
- "7687:7687" # Bolt
|
||||
environment:
|
||||
- NEO4J_AUTH=${NEO4J_USERNAME}/${NEO4J_PASSWORD}
|
||||
- NEO4J_PLUGINS=["apoc"]
|
||||
@@ -58,12 +55,11 @@ services:
|
||||
networks:
|
||||
- flowsint_network
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:7474 || exit 1"]
|
||||
interval: 10s
|
||||
test: cypher-shell -u ${NEO4J_USERNAME} -p ${NEO4J_PASSWORD} "RETURN 1"
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
retries: 10
|
||||
|
||||
# FastAPI Backend
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
@@ -75,10 +71,11 @@ services:
|
||||
- "5001:5001"
|
||||
volumes:
|
||||
- ./flowsint-api:/app/flowsint-api
|
||||
- /app/flowsint-api/.venv
|
||||
- ./flowsint-core:/app/flowsint-core
|
||||
- ./flowsint-types:/app/flowsint-types
|
||||
- ./flowsint-enrichers:/app/flowsint-enrichers
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://flowsint:flowsint@postgres:5432/flowsint
|
||||
- NEO4J_URI_BOLT=bolt://neo4j:7687
|
||||
@@ -102,7 +99,6 @@ services:
|
||||
networks:
|
||||
- flowsint_network
|
||||
|
||||
# Celery Worker
|
||||
celery:
|
||||
build:
|
||||
context: .
|
||||
@@ -113,10 +109,11 @@ services:
|
||||
command: celery -A flowsint_core.core.celery worker --loglevel=info --pool=threads --concurrency=10
|
||||
volumes:
|
||||
- ./flowsint-api:/app/flowsint-api
|
||||
- /app/flowsint-api/.venv
|
||||
- ./flowsint-core:/app/flowsint-core
|
||||
- ./flowsint-types:/app/flowsint-types
|
||||
- ./flowsint-enrichers:/app/flowsint-enrichers
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://flowsint:flowsint@postgres:5432/flowsint
|
||||
- NEO4J_URI_BOLT=bolt://neo4j:7687
|
||||
@@ -137,7 +134,6 @@ services:
|
||||
networks:
|
||||
- flowsint_network
|
||||
|
||||
# Frontend
|
||||
app:
|
||||
build:
|
||||
context: ./flowsint-app
|
||||
|
||||
+22
-19
@@ -1,9 +1,8 @@
|
||||
name: flowsint-prod
|
||||
|
||||
services:
|
||||
# PostgreSQL database
|
||||
postgres:
|
||||
image: postgres:15
|
||||
image: postgres:15-alpine
|
||||
container_name: flowsint-postgres-prod
|
||||
restart: always
|
||||
environment:
|
||||
@@ -22,9 +21,8 @@ services:
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# Redis for Celery & cache
|
||||
redis:
|
||||
image: redis:alpine
|
||||
image: redis:7-alpine
|
||||
container_name: flowsint-redis-prod
|
||||
restart: always
|
||||
ports:
|
||||
@@ -37,14 +35,13 @@ services:
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# Neo4j graph database
|
||||
neo4j:
|
||||
image: neo4j:5
|
||||
container_name: flowsint-neo4j-prod
|
||||
restart: always
|
||||
ports:
|
||||
- "7474:7474" # Web UI
|
||||
- "7687:7687" # Bolt
|
||||
- "7474:7474"
|
||||
- "7687:7687"
|
||||
environment:
|
||||
- NEO4J_AUTH=${NEO4J_USERNAME}/${NEO4J_PASSWORD}
|
||||
- NEO4J_PLUGINS=["apoc"]
|
||||
@@ -59,23 +56,22 @@ services:
|
||||
networks:
|
||||
- flowsint_network
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:7474 || exit 1"]
|
||||
interval: 10s
|
||||
test: cypher-shell -u ${NEO4J_USERNAME} -p ${NEO4J_PASSWORD} "RETURN 1"
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
retries: 10
|
||||
|
||||
# FastAPI Backend (Production)
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: flowsint-api/Dockerfile
|
||||
target: prod
|
||||
target: production
|
||||
container_name: flowsint-api-prod
|
||||
restart: always
|
||||
ports:
|
||||
- "5001:5001"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://${POSTGRES_USER:-flowsint}:${POSTGRES_PASSWORD:-flowsint}@postgres:5432/${POSTGRES_DB:-flowsint}
|
||||
- NEO4J_URI_BOLT=bolt://neo4j:7687
|
||||
@@ -99,17 +95,25 @@ services:
|
||||
networks:
|
||||
- flowsint_network
|
||||
|
||||
# Celery Worker (Production)
|
||||
celery:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: flowsint-api/Dockerfile
|
||||
target: prod
|
||||
target: production
|
||||
container_name: flowsint-celery-prod
|
||||
restart: always
|
||||
command: celery -A flowsint_core.core.celery worker --loglevel=info --pool=threads --concurrency=10
|
||||
command:
|
||||
[
|
||||
"celery",
|
||||
"-A",
|
||||
"flowsint_core.core.celery",
|
||||
"worker",
|
||||
"--loglevel=info",
|
||||
"--pool=threads",
|
||||
"--concurrency=10",
|
||||
]
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://${POSTGRES_USER:-flowsint}:${POSTGRES_PASSWORD:-flowsint}@postgres:5432/${POSTGRES_DB:-flowsint}
|
||||
- NEO4J_URI_BOLT=bolt://neo4j:7687
|
||||
@@ -130,7 +134,6 @@ services:
|
||||
networks:
|
||||
- flowsint_network
|
||||
|
||||
# Frontend (Production)
|
||||
app:
|
||||
build:
|
||||
context: ./flowsint-app
|
||||
@@ -140,7 +143,7 @@ services:
|
||||
container_name: flowsint-app-prod
|
||||
restart: always
|
||||
ports:
|
||||
- "5173:5173"
|
||||
- "5173:8080"
|
||||
networks:
|
||||
- flowsint_network
|
||||
depends_on:
|
||||
|
||||
@@ -44,6 +44,11 @@ services:
|
||||
- neo4j_logs:/logs
|
||||
- neo4j_import:/var/lib/neo4j/import
|
||||
- neo4j_plugins:/plugins
|
||||
healthcheck:
|
||||
test: cypher-shell -u ${NEO4J_USERNAME} -p ${NEO4J_PASSWORD} "RETURN 1"
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- flowsint_network
|
||||
|
||||
+98
-25
@@ -1,9 +1,19 @@
|
||||
FROM python:3.12-slim AS base
|
||||
FROM python:3.12-slim AS builder
|
||||
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV POETRY_VIRTUALENVS_CREATE=false
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||
POETRY_VIRTUALENVS_IN_PROJECT=true \
|
||||
POETRY_NO_INTERACTION=1 \
|
||||
POETRY_HOME="/opt/poetry"
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
ENV PATH="$POETRY_HOME/bin:$PATH"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# build deps
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
curl \
|
||||
git \
|
||||
@@ -12,46 +22,109 @@ RUN apt-get update && apt-get install -y \
|
||||
libcairo2-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN curl -sSL https://install.python-poetry.org | python3 - \
|
||||
&& ln -s /root/.local/bin/poetry /usr/local/bin/poetry
|
||||
# poetry
|
||||
RUN curl -sSL https://install.python-poetry.org | python3 -
|
||||
|
||||
COPY flowsint-core/pyproject.toml flowsint-core/poetry.lock* ./flowsint-core/
|
||||
COPY flowsint-types/pyproject.toml flowsint-types/poetry.lock* ./flowsint-types/
|
||||
COPY flowsint-enrichers/pyproject.toml flowsint-enrichers/poetry.lock* ./flowsint-enrichers/
|
||||
COPY flowsint-api/pyproject.toml flowsint-api/poetry.lock* ./flowsint-api/
|
||||
|
||||
COPY flowsint-core ./flowsint-core
|
||||
COPY flowsint-types ./flowsint-types
|
||||
COPY flowsint-enrichers ./flowsint-enrichers
|
||||
COPY flowsint-api ./flowsint-api
|
||||
|
||||
WORKDIR /app/flowsint-api
|
||||
RUN poetry install --no-root
|
||||
|
||||
# DEV
|
||||
FROM python:3.12-slim AS dev
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
APP_ENV=development \
|
||||
PATH="/app/flowsint-api/.venv/bin:$PATH"
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libpq5 \
|
||||
libcairo2 \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
FROM base AS dev
|
||||
# Copy virtual environment from builder
|
||||
COPY --from=builder /app/flowsint-api/.venv ./flowsint-api/.venv
|
||||
|
||||
ENV APP_ENV=development
|
||||
|
||||
COPY flowsint-core /app/flowsint-core
|
||||
COPY flowsint-types /app/flowsint-types
|
||||
COPY flowsint-enrichers /app/flowsint-enrichers
|
||||
|
||||
COPY flowsint-api /app/flowsint-api
|
||||
# Copy application code
|
||||
COPY flowsint-core ./flowsint-core
|
||||
COPY flowsint-types ./flowsint-types
|
||||
COPY flowsint-enrichers ./flowsint-enrichers
|
||||
COPY flowsint-api ./flowsint-api
|
||||
|
||||
WORKDIR /app/flowsint-api
|
||||
|
||||
RUN poetry install --no-root
|
||||
# Make entrypoint executable
|
||||
RUN chmod +x entrypoint.sh
|
||||
|
||||
EXPOSE 5001
|
||||
|
||||
ENTRYPOINT ["/app/flowsint-api/entrypoint.sh"]
|
||||
ENTRYPOINT ["./entrypoint.sh"]
|
||||
|
||||
# Dev command with hot-reload
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "5001", "--reload"]
|
||||
|
||||
# Production stage
|
||||
FROM base AS prod
|
||||
# PROD
|
||||
FROM python:3.12-slim AS production
|
||||
|
||||
ENV APP_ENV=production
|
||||
LABEL org.opencontainers.image.source="https://github.com/reconurge/flowsint"
|
||||
LABEL org.opencontainers.image.description="Flowsint API & Worker"
|
||||
LABEL org.opencontainers.image.licenses="Apache-2.0"
|
||||
|
||||
COPY flowsint-core /app/flowsint-core
|
||||
COPY flowsint-types /app/flowsint-types
|
||||
COPY flowsint-enrichers /app/flowsint-enrichers
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
APP_ENV=production \
|
||||
PATH="/app/flowsint-api/.venv/bin:$PATH"
|
||||
|
||||
COPY flowsint-api /app/flowsint-api
|
||||
# Install runtime dependencies only
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libpq5 \
|
||||
libcairo2 \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& apt-get clean
|
||||
|
||||
# Create non-root user
|
||||
RUN groupadd -g 1001 flowsint && \
|
||||
useradd -u 1001 -g flowsint -s /bin/bash -m flowsint
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy virtual environment from builder (production deps only would require separate install)
|
||||
COPY --from=builder --chown=flowsint:flowsint /app/flowsint-api/.venv ./flowsint-api/.venv
|
||||
|
||||
# Copy application code
|
||||
COPY --chown=flowsint:flowsint flowsint-core ./flowsint-core
|
||||
COPY --chown=flowsint:flowsint flowsint-types ./flowsint-types
|
||||
COPY --chown=flowsint:flowsint flowsint-enrichers ./flowsint-enrichers
|
||||
COPY --chown=flowsint:flowsint flowsint-api ./flowsint-api
|
||||
|
||||
WORKDIR /app/flowsint-api
|
||||
|
||||
RUN poetry install
|
||||
# Make entrypoint executable
|
||||
RUN chmod +x entrypoint.sh
|
||||
|
||||
# Switch to non-root user
|
||||
# USER flowsint
|
||||
|
||||
EXPOSE 5001
|
||||
|
||||
ENTRYPOINT ["/app/flowsint-api/entrypoint.sh"]
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD curl -f http://localhost:5001/health || exit 1
|
||||
|
||||
ENTRYPOINT ["./entrypoint.sh"]
|
||||
|
||||
# Production command (no reload)
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "5001"]
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
"""make column types portable (JSONB->JSON, ARRAY->JSON/TEXT)
|
||||
|
||||
Revision ID: a1b2c3d4e5f6
|
||||
Revises: 8173aba964e7
|
||||
Create Date: 2026-02-07 00:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "a1b2c3d4e5f6"
|
||||
down_revision: Union[str, None] = "8173aba964e7"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1. logs.content: JSONB -> JSON
|
||||
op.execute("ALTER TABLE logs ALTER COLUMN content TYPE JSON USING content::text::json")
|
||||
|
||||
# 2. custom_types.schema: JSONB -> JSON
|
||||
op.execute(
|
||||
'ALTER TABLE custom_types ALTER COLUMN "schema" TYPE JSON USING "schema"::text::json'
|
||||
)
|
||||
|
||||
# 3. flows.category: ARRAY(Text) -> JSON
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE flows ALTER COLUMN category TYPE JSON
|
||||
USING CASE
|
||||
WHEN category IS NULL THEN NULL
|
||||
ELSE array_to_json(category)
|
||||
END
|
||||
"""
|
||||
)
|
||||
|
||||
# 4. investigation_user_roles.roles: ARRAY(role_enum) -> TEXT (JSON string)
|
||||
# Convert PostgreSQL enum array like {OWNER,EDITOR} to JSON string like '["owner","editor"]'
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE investigation_user_roles ALTER COLUMN roles TYPE TEXT
|
||||
USING CASE
|
||||
WHEN roles IS NULL THEN '[]'
|
||||
ELSE lower(array_to_json(roles::text[])::text)
|
||||
END
|
||||
"""
|
||||
)
|
||||
|
||||
# Remove the server_default that used PostgreSQL array literal '{}'
|
||||
op.alter_column("investigation_user_roles", "roles", server_default=None)
|
||||
|
||||
# Drop the role_enum type (no longer needed)
|
||||
op.execute("DROP TYPE IF EXISTS role_enum")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Recreate the role_enum type
|
||||
op.execute("CREATE TYPE role_enum AS ENUM ('OWNER', 'EDITOR', 'VIEWER')")
|
||||
|
||||
# 4. investigation_user_roles.roles: TEXT -> ARRAY(role_enum)
|
||||
# Use a temp column to avoid subquery restriction in USING
|
||||
op.execute("ALTER TABLE investigation_user_roles ADD COLUMN roles_tmp role_enum[]")
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE investigation_user_roles SET roles_tmp = CASE
|
||||
WHEN roles IS NULL OR roles = '[]' THEN '{}'::role_enum[]
|
||||
ELSE (
|
||||
SELECT array_agg(upper(elem)::role_enum)
|
||||
FROM json_array_elements_text(roles::json) AS elem
|
||||
)
|
||||
END
|
||||
"""
|
||||
)
|
||||
op.execute("ALTER TABLE investigation_user_roles DROP COLUMN roles")
|
||||
op.execute("ALTER TABLE investigation_user_roles RENAME COLUMN roles_tmp TO roles")
|
||||
op.alter_column(
|
||||
"investigation_user_roles", "roles", server_default=sa.text("'{}'")
|
||||
)
|
||||
|
||||
# 3. flows.category: JSON -> ARRAY(Text)
|
||||
# Use a temp column to avoid subquery restriction in USING
|
||||
op.execute("ALTER TABLE flows ADD COLUMN category_tmp TEXT[]")
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE flows SET category_tmp = CASE
|
||||
WHEN category IS NULL THEN NULL
|
||||
ELSE (
|
||||
SELECT array_agg(elem::text)
|
||||
FROM json_array_elements_text(category::json) AS elem
|
||||
)
|
||||
END
|
||||
"""
|
||||
)
|
||||
op.execute("ALTER TABLE flows DROP COLUMN category")
|
||||
op.execute("ALTER TABLE flows RENAME COLUMN category_tmp TO category")
|
||||
|
||||
# 2. custom_types.schema: JSON -> JSONB
|
||||
op.execute(
|
||||
'ALTER TABLE custom_types ALTER COLUMN "schema" TYPE JSONB USING "schema"::jsonb'
|
||||
)
|
||||
|
||||
# 1. logs.content: JSON -> JSONB
|
||||
op.execute("ALTER TABLE logs ALTER COLUMN content TYPE JSONB USING content::jsonb")
|
||||
@@ -0,0 +1,28 @@
|
||||
"""add description to enricher_templates
|
||||
|
||||
Revision ID: b2c3d4e5f6a7
|
||||
Revises: a1b2c3d4e5f6
|
||||
Create Date: 2025-01-31
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'b2c3d4e5f6a7'
|
||||
down_revision: Union[str, None] = 'a1b2c3d4e5f6'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Add description column to enricher_templates table."""
|
||||
op.add_column('enricher_templates', sa.Column('description', sa.Text(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Remove description column from enricher_templates table."""
|
||||
op.drop_column('enricher_templates', 'description')
|
||||
@@ -0,0 +1,42 @@
|
||||
"""add icon and color to custom types
|
||||
|
||||
Revision ID: bac5764d4496
|
||||
Revises: f5fae279ec04
|
||||
Create Date: 2026-02-14 12:10:26.415916
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'bac5764d4496'
|
||||
down_revision: Union[str, None] = 'f5fae279ec04'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('custom_types', sa.Column('icon', sa.String(), nullable=True))
|
||||
op.add_column('custom_types', sa.Column('color', sa.String(), nullable=True))
|
||||
op.alter_column('enricher_templates', 'content',
|
||||
existing_type=postgresql.JSONB(astext_type=sa.Text()),
|
||||
type_=sa.JSON(),
|
||||
existing_nullable=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.alter_column('enricher_templates', 'content',
|
||||
existing_type=sa.JSON(),
|
||||
type_=postgresql.JSONB(astext_type=sa.Text()),
|
||||
existing_nullable=False)
|
||||
op.drop_column('custom_types', 'color')
|
||||
op.drop_column('custom_types', 'icon')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,80 @@
|
||||
"""add enricher_templates table
|
||||
|
||||
Revision ID: a1b2c3d4e5f6
|
||||
Revises: 8173aba964e7
|
||||
Create Date: 2025-01-31
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "c9d8e7f6a5b4"
|
||||
down_revision: Union[str, None] = "a1b2c3d4e5f6"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
op.create_table(
|
||||
"enricher_templates",
|
||||
sa.Column("id", sa.UUID(), nullable=False),
|
||||
sa.Column("name", sa.Text(), nullable=False),
|
||||
sa.Column("category", sa.Text(), nullable=False),
|
||||
sa.Column("version", sa.Float(), nullable=False, server_default="1.0"),
|
||||
sa.Column("content", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||
sa.Column("is_public", sa.Boolean(), nullable=False, server_default="false"),
|
||||
sa.Column("owner_id", sa.UUID(), nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["owner_id"], ["profiles.id"], onupdate="CASCADE", ondelete="CASCADE"
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_enricher_templates_owner_id",
|
||||
"enricher_templates",
|
||||
["owner_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"idx_enricher_templates_name", "enricher_templates", ["name"], unique=False
|
||||
)
|
||||
op.create_index(
|
||||
"idx_enricher_templates_category",
|
||||
"enricher_templates",
|
||||
["category"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"idx_enricher_templates_is_public",
|
||||
"enricher_templates",
|
||||
["is_public"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
op.drop_index("idx_enricher_templates_is_public", table_name="enricher_templates")
|
||||
op.drop_index("idx_enricher_templates_category", table_name="enricher_templates")
|
||||
op.drop_index("idx_enricher_templates_name", table_name="enricher_templates")
|
||||
op.drop_index("idx_enricher_templates_owner_id", table_name="enricher_templates")
|
||||
op.drop_table("enricher_templates")
|
||||
@@ -0,0 +1,28 @@
|
||||
"""merge portable column types and enricher templates
|
||||
|
||||
Revision ID: f5fae279ec04
|
||||
Revises: b2c3d4e5f6a7, c9d8e7f6a5b4
|
||||
Create Date: 2026-02-07 18:00:18.801891
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'f5fae279ec04'
|
||||
down_revision: Union[str, None] = ('b2c3d4e5f6a7', 'c9d8e7f6a5b4')
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
pass
|
||||
@@ -1,42 +1,30 @@
|
||||
from uuid import UUID, uuid4
|
||||
from app.security.permissions import check_investigation_permission
|
||||
from uuid import UUID
|
||||
from fastapi import APIRouter, HTTPException, Depends, status
|
||||
from typing import List
|
||||
from datetime import datetime
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from flowsint_core.core.postgre_db import get_db
|
||||
from flowsint_core.core.models import Analysis, Profile, InvestigationUserRole
|
||||
from flowsint_core.core.types import Role
|
||||
from flowsint_core.core.models import Profile
|
||||
from flowsint_core.core.services import (
|
||||
create_analysis_service,
|
||||
NotFoundError,
|
||||
PermissionDeniedError,
|
||||
)
|
||||
from app.api.deps import get_current_user
|
||||
from app.api.schemas.analysis import AnalysisRead, AnalysisCreate, AnalysisUpdate
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# Get the list of all analyses for the current user
|
||||
@router.get("", response_model=List[AnalysisRead])
|
||||
def get_analyses(
|
||||
db: Session = Depends(get_db), current_user: Profile = Depends(get_current_user)
|
||||
):
|
||||
# Get all analyses from investigations where user has at least VIEWER role
|
||||
allowed_roles_for_read = [Role.OWNER, Role.EDITOR, Role.VIEWER]
|
||||
|
||||
query = db.query(Analysis).join(
|
||||
InvestigationUserRole,
|
||||
InvestigationUserRole.investigation_id == Analysis.investigation_id,
|
||||
)
|
||||
|
||||
query = query.filter(InvestigationUserRole.user_id == current_user.id)
|
||||
|
||||
# Filter by allowed roles
|
||||
conditions = [InvestigationUserRole.roles.any(role) for role in allowed_roles_for_read]
|
||||
query = query.filter(or_(*conditions))
|
||||
|
||||
return query.distinct().all()
|
||||
"""Get all analyses accessible to the current user."""
|
||||
service = create_analysis_service(db)
|
||||
return service.get_accessible_analyses(current_user.id)
|
||||
|
||||
|
||||
# Create a New analysis
|
||||
@router.post(
|
||||
"/create", response_model=AnalysisRead, status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
@@ -45,64 +33,47 @@ def create_analysis(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Profile = Depends(get_current_user),
|
||||
):
|
||||
check_investigation_permission(
|
||||
current_user.id, payload.investigation_id, actions=["create"], db=db
|
||||
)
|
||||
new_analysis = Analysis(
|
||||
id=uuid4(),
|
||||
title=payload.title,
|
||||
description=payload.description,
|
||||
content=payload.content,
|
||||
owner_id=current_user.id,
|
||||
investigation_id=payload.investigation_id,
|
||||
created_at=datetime.utcnow(),
|
||||
last_updated_at=datetime.utcnow(),
|
||||
)
|
||||
db.add(new_analysis)
|
||||
db.commit()
|
||||
db.refresh(new_analysis)
|
||||
return new_analysis
|
||||
service = create_analysis_service(db)
|
||||
try:
|
||||
return service.create(
|
||||
title=payload.title,
|
||||
description=payload.description,
|
||||
content=payload.content,
|
||||
investigation_id=payload.investigation_id,
|
||||
owner_id=current_user.id,
|
||||
)
|
||||
except PermissionDeniedError:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
|
||||
# Get an analysis by ID
|
||||
@router.get("/{analysis_id}", response_model=AnalysisRead)
|
||||
def get_analysis_by_id(
|
||||
analysis_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Profile = Depends(get_current_user),
|
||||
):
|
||||
analysis = (
|
||||
db.query(Analysis)
|
||||
.filter(Analysis.id == analysis_id)
|
||||
.first()
|
||||
)
|
||||
if not analysis:
|
||||
service = create_analysis_service(db)
|
||||
try:
|
||||
return service.get_by_id(analysis_id, current_user.id)
|
||||
except NotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Analysis not found")
|
||||
check_investigation_permission(
|
||||
current_user.id, analysis.investigation_id, actions=["read"], db=db
|
||||
)
|
||||
return analysis
|
||||
except PermissionDeniedError:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
|
||||
# Get analyses by investigation ID
|
||||
@router.get("/investigation/{investigation_id}", response_model=List[AnalysisRead])
|
||||
def get_analyses_by_investigation(
|
||||
investigation_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Profile = Depends(get_current_user),
|
||||
):
|
||||
check_investigation_permission(
|
||||
current_user.id, investigation_id, actions=["read"], db=db
|
||||
)
|
||||
analyses = (
|
||||
db.query(Analysis)
|
||||
.filter(Analysis.investigation_id == investigation_id)
|
||||
.all()
|
||||
)
|
||||
return analyses
|
||||
service = create_analysis_service(db)
|
||||
try:
|
||||
return service.get_by_investigation(investigation_id, current_user.id)
|
||||
except PermissionDeniedError:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
|
||||
# Update an analysis by ID
|
||||
@router.put("/{analysis_id}", response_model=AnalysisRead)
|
||||
def update_analysis(
|
||||
analysis_id: UUID,
|
||||
@@ -110,51 +81,33 @@ def update_analysis(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Profile = Depends(get_current_user),
|
||||
):
|
||||
analysis = (
|
||||
db.query(Analysis)
|
||||
.filter(Analysis.id == analysis_id)
|
||||
.first()
|
||||
)
|
||||
if not analysis:
|
||||
raise HTTPException(status_code=404, detail="Analysis not found")
|
||||
check_investigation_permission(
|
||||
current_user.id, analysis.investigation_id, actions=["update"], db=db
|
||||
)
|
||||
if payload.title is not None:
|
||||
analysis.title = payload.title
|
||||
if payload.description is not None:
|
||||
analysis.description = payload.description
|
||||
if payload.content is not None:
|
||||
analysis.content = payload.content
|
||||
if payload.investigation_id is not None:
|
||||
# Check permission for the new investigation as well
|
||||
check_investigation_permission(
|
||||
current_user.id, payload.investigation_id, actions=["update"], db=db
|
||||
service = create_analysis_service(db)
|
||||
try:
|
||||
return service.update(
|
||||
analysis_id=analysis_id,
|
||||
user_id=current_user.id,
|
||||
title=payload.title,
|
||||
description=payload.description,
|
||||
content=payload.content,
|
||||
investigation_id=payload.investigation_id,
|
||||
)
|
||||
analysis.investigation_id = payload.investigation_id
|
||||
analysis.last_updated_at = datetime.utcnow()
|
||||
db.commit()
|
||||
db.refresh(analysis)
|
||||
return analysis
|
||||
except NotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Analysis not found")
|
||||
except PermissionDeniedError:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
|
||||
# Delete an analysis by ID
|
||||
@router.delete("/{analysis_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_analysis(
|
||||
analysis_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Profile = Depends(get_current_user),
|
||||
):
|
||||
analysis = (
|
||||
db.query(Analysis)
|
||||
.filter(Analysis.id == analysis_id)
|
||||
.first()
|
||||
)
|
||||
if not analysis:
|
||||
service = create_analysis_service(db)
|
||||
try:
|
||||
service.delete(analysis_id, current_user.id)
|
||||
return None
|
||||
except NotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Analysis not found")
|
||||
check_investigation_permission(
|
||||
current_user.id, analysis.investigation_id, actions=["delete"], db=db
|
||||
)
|
||||
db.delete(analysis)
|
||||
db.commit()
|
||||
return None
|
||||
except PermissionDeniedError:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.exc import SQLAlchemyError, IntegrityError
|
||||
from flowsint_core.core.auth import (
|
||||
verify_password,
|
||||
create_access_token,
|
||||
get_password_hash,
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from flowsint_core.core.services import (
|
||||
create_auth_service,
|
||||
AuthenticationError,
|
||||
ConflictError,
|
||||
DatabaseError,
|
||||
)
|
||||
from app.api.schemas.profile import ProfileCreate
|
||||
from flowsint_core.core.models import Profile
|
||||
from flowsint_core.core.postgre_db import get_db
|
||||
|
||||
router = APIRouter()
|
||||
@@ -18,50 +19,23 @@ router = APIRouter()
|
||||
def login_for_access_token(
|
||||
form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)
|
||||
):
|
||||
service = create_auth_service(db)
|
||||
try:
|
||||
user = db.query(Profile).filter(Profile.email == form_data.username).first()
|
||||
|
||||
if not user or not verify_password(form_data.password, user.hashed_password):
|
||||
raise HTTPException(status_code=400, detail="Incorrect email or password")
|
||||
|
||||
access_token = create_access_token(data={"sub": user.email})
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"user_id": user.id,
|
||||
"token_type": "bearer",
|
||||
}
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
# Log optionnel
|
||||
return service.authenticate(form_data.username, form_data.password)
|
||||
except AuthenticationError:
|
||||
raise HTTPException(status_code=400, detail="Incorrect email or password")
|
||||
except (DatabaseError, SQLAlchemyError) as e:
|
||||
print(f"[ERROR] DB error during login: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.post("/register", status_code=201)
|
||||
def register(user: ProfileCreate, db: Session = Depends(get_db)):
|
||||
service = create_auth_service(db)
|
||||
try:
|
||||
existing_user = db.query(Profile).filter(Profile.email == user.email).first()
|
||||
|
||||
if existing_user:
|
||||
raise HTTPException(status_code=400, detail="Email already registered")
|
||||
|
||||
hashed_password = get_password_hash(user.password)
|
||||
new_user = Profile(email=user.email, hashed_password=hashed_password)
|
||||
|
||||
db.add(new_user)
|
||||
db.commit()
|
||||
db.refresh(new_user)
|
||||
|
||||
return {
|
||||
"message": "User registered successfully",
|
||||
"email": new_user.email,
|
||||
}
|
||||
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
return service.register(user.email, user.password)
|
||||
except ConflictError:
|
||||
raise HTTPException(status_code=400, detail="Email already registered")
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
db.rollback()
|
||||
except (DatabaseError, SQLAlchemyError) as e:
|
||||
print(f"[ERROR] DB error during registration: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
@@ -1,95 +1,44 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
import os
|
||||
from mistralai import Mistral
|
||||
from mistralai.models import UserMessage, AssistantMessage, SystemMessage
|
||||
import json
|
||||
from uuid import UUID, uuid4
|
||||
from fastapi import APIRouter, HTTPException, Depends, status
|
||||
from typing import Dict, List, Optional
|
||||
from datetime import datetime
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
from flowsint_core.core.models import Profile
|
||||
from flowsint_core.core.postgre_db import get_db
|
||||
from flowsint_core.core.models import Chat, ChatMessage, Profile, InvestigationUserRole
|
||||
from flowsint_core.core.types import Role
|
||||
from flowsint_core.core.services import (
|
||||
NotFoundError,
|
||||
create_chat_service,
|
||||
)
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.api.schemas.chat import ChatCreate, ChatRead
|
||||
from app.security.permissions import check_investigation_permission
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def clean_context(context: List[Dict]) -> List[Dict]:
|
||||
print(context)
|
||||
"""Remove unnecessary keys from context data."""
|
||||
cleaned = []
|
||||
for item in context:
|
||||
if isinstance(item, dict):
|
||||
# Create a copy and remove unwanted keys
|
||||
cleaned_item = item["data"].copy()
|
||||
# Remove top-level keys
|
||||
cleaned_item.pop("id", None)
|
||||
cleaned_item.pop("sketch_id", None)
|
||||
# Remove from data if it exists
|
||||
if "data" in cleaned_item and isinstance(cleaned_item["data"], dict):
|
||||
cleaned_item["data"].pop("sketch_id", None)
|
||||
# Remove measured/dimensions
|
||||
cleaned_item.pop("measured", None)
|
||||
cleaned.append(cleaned_item)
|
||||
return cleaned
|
||||
|
||||
|
||||
class ChatRequest(BaseModel):
|
||||
prompt: str
|
||||
context: Optional[List[Dict]] = None
|
||||
context: Optional[List[str]] = None
|
||||
|
||||
|
||||
# Get all chats
|
||||
@router.get("/", response_model=List[ChatRead])
|
||||
def get_chats(
|
||||
db: Session = Depends(get_db), current_user: Profile = Depends(get_current_user)
|
||||
):
|
||||
chats = db.query(Chat).filter(Chat.owner_id == current_user.id).all()
|
||||
|
||||
# Sort messages for each chat by created_at in ascending order
|
||||
for chat in chats:
|
||||
chat.messages.sort(key=lambda x: x.created_at)
|
||||
|
||||
return chats
|
||||
service = create_chat_service(db)
|
||||
return service.get_chats_for_user(current_user.id)
|
||||
|
||||
|
||||
# @router.get("/delete-all", status_code=status.HTTP_204_NO_CONTENT)
|
||||
# def delete_all_chat(db: Session = Depends(get_db)):
|
||||
# chats = db.query(Chat).all()
|
||||
# for chat in chats:
|
||||
# db.delete(chat)
|
||||
# db.commit()
|
||||
# return {"result": "done"}
|
||||
|
||||
|
||||
# Get analyses by investigation ID
|
||||
@router.get("/investigation/{investigation_id}", response_model=List[ChatRead])
|
||||
def get_chats_by_investigation(
|
||||
investigation_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Profile = Depends(get_current_user),
|
||||
):
|
||||
chats = (
|
||||
db.query(Chat)
|
||||
.filter(
|
||||
Chat.investigation_id == investigation_id, Chat.owner_id == current_user.id
|
||||
)
|
||||
.order_by(Chat.created_at.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
# Sort messages for each chat by created_at in ascending order
|
||||
for chat in chats:
|
||||
chat.messages.sort(key=lambda x: x.created_at)
|
||||
|
||||
return chats
|
||||
service = create_chat_service(db)
|
||||
return service.get_by_investigation(investigation_id, current_user.id)
|
||||
|
||||
|
||||
@router.post("/stream/{chat_id}")
|
||||
@@ -99,171 +48,67 @@ async def stream_chat(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Profile = Depends(get_current_user),
|
||||
):
|
||||
# Check if Chat exists
|
||||
chat = (
|
||||
db.query(Chat)
|
||||
.filter(Chat.id == chat_id, Chat.owner_id == current_user.id)
|
||||
.first()
|
||||
)
|
||||
if not chat:
|
||||
raise HTTPException(status_code=404, detail="Chat not found")
|
||||
|
||||
# Update chat's last_updated_at
|
||||
chat.last_updated_at = datetime.utcnow()
|
||||
db.commit()
|
||||
# A new message is created
|
||||
user_message = ChatMessage(
|
||||
id=uuid4(),
|
||||
content=payload.prompt,
|
||||
context=payload.context,
|
||||
chat_id=chat_id,
|
||||
is_bot=False,
|
||||
created_at=datetime.utcnow(),
|
||||
)
|
||||
db.add(user_message)
|
||||
db.commit()
|
||||
db.refresh(user_message)
|
||||
service = create_chat_service(db)
|
||||
|
||||
try:
|
||||
api_key = os.environ.get("MISTRAL_API_KEY")
|
||||
if not api_key:
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Mistral API key not configured"
|
||||
)
|
||||
chat = service.get_by_id(chat_id, current_user.id)
|
||||
except NotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Chat not found")
|
||||
|
||||
client = Mistral(api_key=api_key)
|
||||
model = "mistral-small-latest"
|
||||
accumulated_content = []
|
||||
context_message = None
|
||||
# Convert database messages to Mistral format
|
||||
messages = [
|
||||
SystemMessage(
|
||||
content="You are a CTI/OSINT investigator and you are trying to investigate on a variety of real life cases. Use your knowledge and analytics capabilities to analyse the context and answer the question the best you can. If you need to reference some items (an IP, a domain or something particular) please use the code brackets, like : `12.23.34.54` to reference it."
|
||||
)
|
||||
]
|
||||
service.add_user_message(chat_id, current_user.id, payload.prompt, payload.context)
|
||||
|
||||
# Add context as a single system message if provided
|
||||
if payload.context:
|
||||
try:
|
||||
# Clean context by removing unnecessary keys
|
||||
cleaned_context = clean_context(payload.context)
|
||||
if cleaned_context:
|
||||
context_str = json.dumps(cleaned_context, indent=2, default=str)
|
||||
context_message = f"Context: {context_str}"
|
||||
# Limit context message length to avoid token limits
|
||||
if len(context_message) > 2000:
|
||||
context_message = context_message[:2000] + "..."
|
||||
except Exception as e:
|
||||
# If context processing fails, skip it
|
||||
print(f"Context processing error: {e}")
|
||||
ai_context = service.prepare_ai_context(chat, payload.prompt, payload.context)
|
||||
llm_messages = service.build_llm_messages(ai_context)
|
||||
|
||||
# Sort messages by created_at in ascending order and get recent messages
|
||||
sorted_messages = sorted(chat.messages, key=lambda x: x.created_at)
|
||||
recent_messages = (
|
||||
sorted_messages[-5:] if len(sorted_messages) > 5 else sorted_messages
|
||||
)
|
||||
for message in recent_messages:
|
||||
if message.is_bot:
|
||||
messages.append(
|
||||
AssistantMessage(content=json.dumps(message.content, default=str))
|
||||
)
|
||||
else:
|
||||
messages.append(
|
||||
UserMessage(content=json.dumps(message.content, default=str))
|
||||
)
|
||||
|
||||
# Add the current context
|
||||
if context_message:
|
||||
messages.append(SystemMessage(content=context_message))
|
||||
# Add the current user message
|
||||
messages.append(UserMessage(content=payload.prompt))
|
||||
|
||||
async def generate():
|
||||
response = await client.chat.stream_async(model=model, messages=messages)
|
||||
|
||||
async for chunk in response:
|
||||
if chunk.data.choices[0].delta.content is not None:
|
||||
content_chunk = chunk.data.choices[0].delta.content
|
||||
accumulated_content.append(content_chunk)
|
||||
yield f"data: {json.dumps({'content': content_chunk})}\n\n"
|
||||
|
||||
# Create the bot message after all chunks have been processed
|
||||
chat_message = ChatMessage(
|
||||
id=uuid4(),
|
||||
content="".join(accumulated_content),
|
||||
chat_id=chat_id,
|
||||
is_bot=True,
|
||||
created_at=datetime.utcnow(),
|
||||
)
|
||||
|
||||
db.add(chat_message)
|
||||
db.commit()
|
||||
db.refresh(chat_message)
|
||||
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
return StreamingResponse(generate(), media_type="text/event-stream")
|
||||
except Exception as e:
|
||||
try:
|
||||
provider = service.get_llm_provider(current_user.id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
return StreamingResponse(
|
||||
service.stream_response(chat_id, llm_messages, provider),
|
||||
media_type="text/event-stream",
|
||||
headers={"x-vercel-ai-ui-message-stream": "v1"},
|
||||
)
|
||||
|
||||
|
||||
# Create a new chat
|
||||
@router.post("/create", response_model=ChatRead, status_code=status.HTTP_201_CREATED)
|
||||
def create_chat(
|
||||
payload: ChatCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Profile = Depends(get_current_user),
|
||||
):
|
||||
new_chat = Chat(
|
||||
id=uuid4(),
|
||||
service = create_chat_service(db)
|
||||
return service.create(
|
||||
title=payload.title,
|
||||
description=payload.description,
|
||||
owner_id=current_user.id,
|
||||
investigation_id=payload.investigation_id,
|
||||
created_at=datetime.utcnow(),
|
||||
last_updated_at=datetime.utcnow(),
|
||||
owner_id=current_user.id,
|
||||
)
|
||||
db.add(new_chat)
|
||||
db.commit()
|
||||
db.refresh(new_chat)
|
||||
return new_chat
|
||||
|
||||
|
||||
# Get a chat by ID
|
||||
@router.get("/{chat_id}", response_model=ChatRead)
|
||||
def get_chat_by_id(
|
||||
chat_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Profile = Depends(get_current_user),
|
||||
):
|
||||
chat = (
|
||||
db.query(Chat)
|
||||
.filter(Chat.id == chat_id, Chat.owner_id == current_user.id)
|
||||
.first()
|
||||
)
|
||||
if not chat:
|
||||
service = create_chat_service(db)
|
||||
try:
|
||||
return service.get_by_id(chat_id, current_user.id)
|
||||
except NotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Chat not found")
|
||||
|
||||
# Sort messages by created_at in ascending order
|
||||
chat.messages.sort(key=lambda x: x.created_at)
|
||||
|
||||
return chat
|
||||
|
||||
|
||||
# Delete an chat by ID
|
||||
@router.delete("/{chat_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_chat(
|
||||
chat_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Profile = Depends(get_current_user),
|
||||
):
|
||||
chat = (
|
||||
db.query(Chat)
|
||||
.filter(Chat.id == chat_id, Chat.owner_id == current_user.id)
|
||||
.first()
|
||||
)
|
||||
if not chat:
|
||||
service = create_chat_service(db)
|
||||
try:
|
||||
service.delete(chat_id, current_user.id)
|
||||
return None
|
||||
except NotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Chat not found")
|
||||
db.delete(chat)
|
||||
db.commit()
|
||||
return None
|
||||
|
||||
@@ -1,25 +1,33 @@
|
||||
"""API routes for custom types management."""
|
||||
from uuid import UUID
|
||||
|
||||
from typing import List
|
||||
from fastapi import APIRouter, HTTPException, Depends, status
|
||||
from sqlalchemy.orm import Session
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from flowsint_core.core.models import Profile
|
||||
from flowsint_core.core.postgre_db import get_db
|
||||
from flowsint_core.core.models import CustomType, Profile
|
||||
from flowsint_core.core.services import (
|
||||
ConflictError,
|
||||
NotFoundError,
|
||||
ValidationError,
|
||||
create_custom_type_service,
|
||||
)
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.api.schemas.custom_type import (
|
||||
CustomTypeCreate,
|
||||
CustomTypeUpdate,
|
||||
CustomTypeRead,
|
||||
CustomTypeUpdate,
|
||||
CustomTypeValidatePayload,
|
||||
CustomTypeValidateResponse,
|
||||
)
|
||||
from app.utils.custom_types import (
|
||||
calculate_schema_checksum,
|
||||
validate_json_schema,
|
||||
validate_payload_against_schema,
|
||||
calculate_schema_checksum,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@@ -29,47 +37,22 @@ def create_custom_type(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Profile = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Create a new custom type.
|
||||
|
||||
Validates the JSON Schema and stores it in the database.
|
||||
"""
|
||||
# Validate the JSON Schema
|
||||
validate_json_schema(custom_type.json_schema)
|
||||
|
||||
# Calculate checksum
|
||||
checksum = calculate_schema_checksum(custom_type.json_schema)
|
||||
|
||||
# Check for duplicate name for this user
|
||||
existing = (
|
||||
db.query(CustomType)
|
||||
.filter(
|
||||
CustomType.owner_id == current_user.id,
|
||||
CustomType.name == custom_type.name
|
||||
"""Create a new custom type."""
|
||||
service = create_custom_type_service(db)
|
||||
try:
|
||||
return service.create(
|
||||
name=custom_type.name,
|
||||
json_schema=custom_type.json_schema,
|
||||
user_id=current_user.id,
|
||||
description=custom_type.description,
|
||||
status=custom_type.status,
|
||||
validate_schema_func=validate_json_schema,
|
||||
calculate_checksum_func=calculate_schema_checksum,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Custom type with name '{custom_type.name}' already exists"
|
||||
)
|
||||
|
||||
# Create the custom type
|
||||
db_custom_type = CustomType(
|
||||
name=custom_type.name,
|
||||
owner_id=current_user.id,
|
||||
schema=custom_type.json_schema,
|
||||
description=custom_type.description,
|
||||
status=custom_type.status,
|
||||
checksum=checksum,
|
||||
)
|
||||
|
||||
db.add(db_custom_type)
|
||||
db.commit()
|
||||
db.refresh(db_custom_type)
|
||||
|
||||
return db_custom_type
|
||||
except ValidationError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except ConflictError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.get("", response_model=List[CustomTypeRead])
|
||||
@@ -78,23 +61,12 @@ def list_custom_types(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Profile = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
List all custom types for the current user.
|
||||
|
||||
Can be filtered by status (draft, published, archived).
|
||||
"""
|
||||
query = db.query(CustomType).filter(CustomType.owner_id == current_user.id)
|
||||
|
||||
if status:
|
||||
if status not in ["draft", "published", "archived"]:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Status must be one of: draft, published, archived"
|
||||
)
|
||||
query = query.filter(CustomType.status == status)
|
||||
|
||||
custom_types = query.order_by(CustomType.created_at.desc()).all()
|
||||
return custom_types
|
||||
"""List all custom types for the current user."""
|
||||
service = create_custom_type_service(db)
|
||||
try:
|
||||
return service.list_custom_types(current_user.id, status)
|
||||
except ValidationError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/{id}", response_model=CustomTypeRead)
|
||||
@@ -104,19 +76,11 @@ def get_custom_type(
|
||||
current_user: Profile = Depends(get_current_user),
|
||||
):
|
||||
"""Get a specific custom type by ID."""
|
||||
custom_type = (
|
||||
db.query(CustomType)
|
||||
.filter(CustomType.id == id, CustomType.owner_id == current_user.id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not custom_type:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Custom type not found"
|
||||
)
|
||||
|
||||
return custom_type
|
||||
service = create_custom_type_service(db)
|
||||
try:
|
||||
return service.get_by_id(id, current_user.id)
|
||||
except NotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Custom type not found")
|
||||
|
||||
|
||||
@router.get("/{id}/schema")
|
||||
@@ -126,19 +90,11 @@ def get_custom_type_schema(
|
||||
current_user: Profile = Depends(get_current_user),
|
||||
):
|
||||
"""Get the raw JSON Schema for a custom type."""
|
||||
custom_type = (
|
||||
db.query(CustomType)
|
||||
.filter(CustomType.id == id, CustomType.owner_id == current_user.id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not custom_type:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Custom type not found"
|
||||
)
|
||||
|
||||
return custom_type.schema
|
||||
service = create_custom_type_service(db)
|
||||
try:
|
||||
return service.get_schema(id, current_user.id)
|
||||
except NotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Custom type not found")
|
||||
|
||||
|
||||
@router.put("/{id}", response_model=CustomTypeRead)
|
||||
@@ -148,58 +104,27 @@ def update_custom_type(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Profile = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Update a custom type.
|
||||
|
||||
If the schema is changed, a new checksum is calculated.
|
||||
"""
|
||||
custom_type = (
|
||||
db.query(CustomType)
|
||||
.filter(CustomType.id == id, CustomType.owner_id == current_user.id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not custom_type:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Custom type not found"
|
||||
"""Update a custom type."""
|
||||
service = create_custom_type_service(db)
|
||||
try:
|
||||
return service.update(
|
||||
custom_type_id=id,
|
||||
user_id=current_user.id,
|
||||
name=update_data.name,
|
||||
icon=update_data.icon,
|
||||
color=update_data.color,
|
||||
json_schema=update_data.json_schema,
|
||||
description=update_data.description,
|
||||
status=update_data.status,
|
||||
validate_schema_func=validate_json_schema,
|
||||
calculate_checksum_func=calculate_schema_checksum,
|
||||
)
|
||||
|
||||
# Update fields
|
||||
if update_data.name is not None:
|
||||
# Check for duplicate name
|
||||
existing = (
|
||||
db.query(CustomType)
|
||||
.filter(
|
||||
CustomType.owner_id == current_user.id,
|
||||
CustomType.name == update_data.name,
|
||||
CustomType.id != id
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Custom type with name '{update_data.name}' already exists"
|
||||
)
|
||||
custom_type.name = update_data.name
|
||||
|
||||
if update_data.json_schema is not None:
|
||||
# Validate the new schema
|
||||
validate_json_schema(update_data.json_schema)
|
||||
custom_type.schema = update_data.json_schema
|
||||
custom_type.checksum = calculate_schema_checksum(update_data.json_schema)
|
||||
|
||||
if update_data.description is not None:
|
||||
custom_type.description = update_data.description
|
||||
|
||||
if update_data.status is not None:
|
||||
custom_type.status = update_data.status
|
||||
|
||||
db.commit()
|
||||
db.refresh(custom_type)
|
||||
|
||||
return custom_type
|
||||
except NotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Custom type not found")
|
||||
except ValidationError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except ConflictError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
@@ -209,22 +134,12 @@ def delete_custom_type(
|
||||
current_user: Profile = Depends(get_current_user),
|
||||
):
|
||||
"""Delete a custom type."""
|
||||
custom_type = (
|
||||
db.query(CustomType)
|
||||
.filter(CustomType.id == id, CustomType.owner_id == current_user.id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not custom_type:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Custom type not found"
|
||||
)
|
||||
|
||||
db.delete(custom_type)
|
||||
db.commit()
|
||||
|
||||
return None
|
||||
service = create_custom_type_service(db)
|
||||
try:
|
||||
service.delete(id, current_user.id)
|
||||
return None
|
||||
except NotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Custom type not found")
|
||||
|
||||
|
||||
@router.post("/{id}/validate", response_model=CustomTypeValidateResponse)
|
||||
@@ -234,30 +149,18 @@ def validate_payload(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Profile = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Validate a payload against a custom type's schema.
|
||||
|
||||
This is useful for testing before publishing a type.
|
||||
"""
|
||||
custom_type = (
|
||||
db.query(CustomType)
|
||||
.filter(CustomType.id == id, CustomType.owner_id == current_user.id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not custom_type:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Custom type not found"
|
||||
"""Validate a payload against a custom type's schema."""
|
||||
service = create_custom_type_service(db)
|
||||
try:
|
||||
is_valid, errors = service.validate_payload(
|
||||
id,
|
||||
current_user.id,
|
||||
payload_data.payload,
|
||||
validate_payload_func=validate_payload_against_schema,
|
||||
)
|
||||
|
||||
# Validate the payload against the schema
|
||||
is_valid, errors = validate_payload_against_schema(
|
||||
payload_data.payload,
|
||||
custom_type.schema
|
||||
)
|
||||
|
||||
return CustomTypeValidateResponse(
|
||||
valid=is_valid,
|
||||
errors=errors if not is_valid else None
|
||||
)
|
||||
return CustomTypeValidateResponse(
|
||||
valid=is_valid,
|
||||
errors=errors if not is_valid else None,
|
||||
)
|
||||
except NotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Custom type not found")
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
"""API routes for enricher templates management."""
|
||||
|
||||
from typing import List
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from flowsint_core.core.models import Profile
|
||||
from flowsint_core.core.postgre_db import get_db
|
||||
from flowsint_core.core.services import (
|
||||
ConflictError,
|
||||
NotFoundError,
|
||||
ValidationError,
|
||||
create_enricher_template_service,
|
||||
create_template_generator_service,
|
||||
)
|
||||
from flowsint_core.core.template_enricher import TemplateEnricher
|
||||
from flowsint_core.core.vault import Vault
|
||||
from flowsint_core.templates.types import Template
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.api.schemas.enricher_template import (
|
||||
EnricherTemplateCreate,
|
||||
EnricherTemplateGenerateRequest,
|
||||
EnricherTemplateGenerateResponse,
|
||||
EnricherTemplateList,
|
||||
EnricherTemplateRead,
|
||||
EnricherTemplateTestRequest,
|
||||
EnricherTemplateTestResponse,
|
||||
EnricherTemplateUpdate,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post(
|
||||
"", response_model=EnricherTemplateRead, status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
def create_template(
|
||||
template: EnricherTemplateCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Profile = Depends(get_current_user),
|
||||
):
|
||||
"""Create a new enricher template."""
|
||||
content = template.content
|
||||
name = content.get("name", template.name)
|
||||
description = content.get("description", template.description)
|
||||
category = content.get("category", template.category)
|
||||
version = float(content.get("version", template.version))
|
||||
|
||||
service = create_enricher_template_service(db)
|
||||
try:
|
||||
return service.create_template(
|
||||
name=name,
|
||||
description=description,
|
||||
category=category,
|
||||
version=version,
|
||||
content=content,
|
||||
is_public=template.is_public,
|
||||
owner_id=current_user.id,
|
||||
)
|
||||
except ConflictError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.get("", response_model=List[EnricherTemplateList])
|
||||
def list_templates(
|
||||
category: str = Query(None, description="Filter by category"),
|
||||
include_public: bool = Query(
|
||||
True, description="Include public templates from other users"
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Profile = Depends(get_current_user),
|
||||
):
|
||||
"""List enricher templates."""
|
||||
service = create_enricher_template_service(db)
|
||||
return service.list_templates(current_user.id, category, include_public)
|
||||
|
||||
|
||||
@router.post("/generate", response_model=EnricherTemplateGenerateResponse)
|
||||
async def generate_template(
|
||||
request: EnricherTemplateGenerateRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Profile = Depends(get_current_user),
|
||||
):
|
||||
"""Generate an enricher template from a free-text description using AI."""
|
||||
service = create_template_generator_service(db)
|
||||
try:
|
||||
yaml_content = await service.generate(request.prompt, current_user.id)
|
||||
except ValidationError as e:
|
||||
raise HTTPException(status_code=422, detail=str(e))
|
||||
return EnricherTemplateGenerateResponse(yaml_content=yaml_content)
|
||||
|
||||
|
||||
@router.post("/{template_id}/test", response_model=EnricherTemplateTestResponse)
|
||||
async def test_template(
|
||||
template_id: UUID,
|
||||
test_request: EnricherTemplateTestRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Profile = Depends(get_current_user),
|
||||
):
|
||||
"""Test an enricher template with a sample input value."""
|
||||
service = create_enricher_template_service(db)
|
||||
try:
|
||||
db_template = service.get_template(template_id, current_user.id)
|
||||
except NotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Template not found")
|
||||
|
||||
try:
|
||||
content = db_template.content
|
||||
template = Template(**content)
|
||||
vault = Vault(db=db, owner_id=current_user.id)
|
||||
enricher = TemplateEnricher(
|
||||
sketch_id="123", scan_id="123", template=template, vault=vault
|
||||
)
|
||||
await enricher.async_init()
|
||||
pre = enricher.preprocess([test_request.input_value])
|
||||
results = await enricher.scan(pre)
|
||||
data = {"results": results, "raw_results": enricher.get_raw_response()}
|
||||
return EnricherTemplateTestResponse(
|
||||
success=True, data=data, status_code=200, url=template.request.url
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"An error occured : {e}")
|
||||
|
||||
|
||||
@router.get("/{template_id}", response_model=EnricherTemplateRead)
|
||||
def get_template(
|
||||
template_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Profile = Depends(get_current_user),
|
||||
):
|
||||
"""Get a specific enricher template by ID."""
|
||||
service = create_enricher_template_service(db)
|
||||
try:
|
||||
return service.get_template(template_id, current_user.id)
|
||||
except NotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Template not found")
|
||||
|
||||
|
||||
@router.put("/{template_id}", response_model=EnricherTemplateRead)
|
||||
def update_template(
|
||||
template_id: UUID,
|
||||
update_data: EnricherTemplateUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Profile = Depends(get_current_user),
|
||||
):
|
||||
"""Update an enricher template. Only the owner can update."""
|
||||
service = create_enricher_template_service(db)
|
||||
try:
|
||||
return service.update_template(
|
||||
template_id=template_id,
|
||||
owner_id=current_user.id,
|
||||
update_data=update_data.model_dump(exclude_unset=True),
|
||||
)
|
||||
except NotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Template not found")
|
||||
except ConflictError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_template(
|
||||
template_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Profile = Depends(get_current_user),
|
||||
):
|
||||
"""Delete an enricher template. Only the owner can delete."""
|
||||
service = create_enricher_template_service(db)
|
||||
try:
|
||||
service.delete_template(template_id, current_user.id)
|
||||
except NotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Template not found")
|
||||
return None
|
||||
@@ -1,37 +1,24 @@
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query
|
||||
from typing import List, Any, Optional
|
||||
from pydantic import BaseModel
|
||||
from flowsint_enrichers import ENRICHER_REGISTRY, load_all_enrichers
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from flowsint_core.core.celery import celery
|
||||
from flowsint_core.core.types import Node, Edge, FlowBranch
|
||||
from flowsint_core.core.models import CustomType, Profile
|
||||
from flowsint_core.core.graph_repository import GraphRepository
|
||||
from flowsint_types import clean_neo4j_node_data
|
||||
from app.api.deps import get_current_user
|
||||
from flowsint_core.core.graph import create_graph_service
|
||||
from flowsint_core.core.models import Profile
|
||||
from flowsint_core.core.postgre_db import get_db
|
||||
from flowsint_core.core.services import (
|
||||
create_enricher_service,
|
||||
create_enricher_template_service,
|
||||
)
|
||||
from flowsint_core.core.services.type_registry_service import create_type_registry_service
|
||||
from flowsint_enrichers import ENRICHER_REGISTRY, load_all_enrichers
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
|
||||
# Auto-discover and register all enrichers
|
||||
from app.api.deps import get_current_user
|
||||
|
||||
load_all_enrichers()
|
||||
|
||||
|
||||
class FlowComputationRequest(BaseModel):
|
||||
nodes: List[Node]
|
||||
edges: List[Edge]
|
||||
inputType: Optional[str] = None
|
||||
|
||||
|
||||
class FlowComputationResponse(BaseModel):
|
||||
flowBranches: List[FlowBranch]
|
||||
initialData: Any
|
||||
|
||||
|
||||
class StepSimulationRequest(BaseModel):
|
||||
flowBranches: List[FlowBranch]
|
||||
currentStepIndex: int
|
||||
|
||||
|
||||
class launchEnricherPayload(BaseModel):
|
||||
node_ids: List[str]
|
||||
sketch_id: str
|
||||
@@ -40,55 +27,60 @@ class launchEnricherPayload(BaseModel):
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# Get the list of all enrichers
|
||||
@router.get("/")
|
||||
def get_enrichers(
|
||||
category: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Profile = Depends(get_current_user),
|
||||
):
|
||||
if not category or category.lower() == "undefined":
|
||||
return ENRICHER_REGISTRY.list(exclude=["n8n_connector"])
|
||||
# Si catégorie custom
|
||||
custom_type = (
|
||||
db.query(CustomType)
|
||||
.filter(
|
||||
CustomType.owner_id == current_user.id,
|
||||
CustomType.status == "published",
|
||||
func.lower(CustomType.name) == category.lower(),
|
||||
)
|
||||
.first()
|
||||
"""Get all enrichers, optionally filtered by category."""
|
||||
enricher_service = create_enricher_service(db)
|
||||
return enricher_service.get_all_enrichers(
|
||||
category, current_user.id, ENRICHER_REGISTRY
|
||||
)
|
||||
|
||||
if custom_type:
|
||||
return ENRICHER_REGISTRY.list(exclude=["n8n_connector"], wobbly_type=True)
|
||||
|
||||
return ENRICHER_REGISTRY.list_by_input_type(category, exclude=["n8n_connector"])
|
||||
|
||||
|
||||
@router.post("/{enricher_name}/launch")
|
||||
async def launch_enricher(
|
||||
enricher_name: str,
|
||||
payload: launchEnricherPayload,
|
||||
current_user: Profile = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
# Retrieve nodes from Neo4J by their element IDs
|
||||
graph_repo = GraphRepository()
|
||||
nodes_data = graph_repo.get_nodes_by_ids(payload.node_ids, payload.sketch_id)
|
||||
type_registry = create_type_registry_service(db)
|
||||
resolver = type_registry.build_type_resolver(current_user.id)
|
||||
graph_service = create_graph_service(sketch_id=payload.sketch_id, type_resolver=resolver)
|
||||
entities = graph_service.get_nodes_by_ids_for_task(payload.node_ids)
|
||||
|
||||
if not nodes_data:
|
||||
raise HTTPException(status_code=404, detail="No nodes found with provided IDs")
|
||||
# Send deserialized nodes
|
||||
entities = [
|
||||
entity.model_dump(mode="json", serialize_as_any=True) for entity in entities
|
||||
]
|
||||
if not entities:
|
||||
raise HTTPException(
|
||||
status_code=404, detail="No entities found with provided IDs"
|
||||
)
|
||||
|
||||
# Clean Neo4J-specific fields from node data
|
||||
# The enricher's preprocess() will handle Pydantic validation
|
||||
cleaned_nodes = [clean_neo4j_node_data(node_data) for node_data in nodes_data]
|
||||
is_template = False
|
||||
enricher_in_registry = ENRICHER_REGISTRY.enricher_exists(enricher_name)
|
||||
if not enricher_in_registry:
|
||||
template_service = create_enricher_template_service(db)
|
||||
template = template_service.find_by_name(enricher_name, current_user.id)
|
||||
if not template:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Enricher '{enricher_name}' not found",
|
||||
)
|
||||
is_template = True
|
||||
|
||||
task_name = "run_template_enricher" if is_template else "run_enricher"
|
||||
task = celery.send_task(
|
||||
"run_enricher",
|
||||
task_name,
|
||||
args=[
|
||||
enricher_name,
|
||||
cleaned_nodes,
|
||||
entities,
|
||||
payload.sketch_id,
|
||||
str(current_user.id),
|
||||
],
|
||||
@@ -98,4 +90,7 @@ async def launch_enricher(
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Error launching enricher: {str(e)}")
|
||||
print(e)
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Error launching enricher: {str(e)}"
|
||||
)
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy.orm import Session
|
||||
from flowsint_core.core.postgre_db import get_db
|
||||
from flowsint_core.core.models import Log, Sketch, Scan
|
||||
from flowsint_core.core.events import event_emitter
|
||||
from sse_starlette.sse import EventSourceResponse
|
||||
from flowsint_core.core.types import Event
|
||||
from app.api.deps import get_current_user, get_current_user_sse
|
||||
from flowsint_core.core.models import Profile, Sketch
|
||||
from app.security.permissions import check_investigation_permission
|
||||
import json
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime
|
||||
|
||||
from flowsint_core.core.postgre_db import get_db
|
||||
from flowsint_core.core.events import event_emitter
|
||||
from flowsint_core.core.models import Profile
|
||||
from flowsint_core.core.services import (
|
||||
create_log_service,
|
||||
NotFoundError,
|
||||
PermissionDeniedError,
|
||||
DatabaseError,
|
||||
)
|
||||
from app.api.deps import get_current_user, get_current_user_sse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -21,62 +25,16 @@ def get_logs_by_sketch(
|
||||
limit: int = 100,
|
||||
since: datetime | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Profile = Depends(get_current_user)
|
||||
current_user: Profile = Depends(get_current_user),
|
||||
):
|
||||
"""Get historical logs for a specific sketch with optional filtering"""
|
||||
# Check if sketch exists
|
||||
sketch = db.query(Sketch).filter(Sketch.id == sketch_id).first()
|
||||
if not sketch:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Sketch with id {sketch_id} not found"
|
||||
)
|
||||
|
||||
check_investigation_permission(
|
||||
current_user.id, sketch.investigation_id, actions=["read"], db=db
|
||||
)
|
||||
|
||||
print(
|
||||
f"[EventEmitter] Fetching logs for sketch {sketch_id} (limit: {limit}, since: {since})"
|
||||
)
|
||||
query = (
|
||||
db.query(Log).filter(Log.sketch_id == sketch_id).order_by(Log.created_at.desc())
|
||||
)
|
||||
|
||||
if since:
|
||||
query = query.filter(Log.created_at > since)
|
||||
else:
|
||||
# Default to last 24 hours if no since parameter
|
||||
query = query.filter(Log.created_at > datetime.utcnow() - timedelta(days=1))
|
||||
|
||||
logs = query.limit(limit).all()
|
||||
|
||||
# Reverse to show chronologically (oldest to newest)
|
||||
logs = list(reversed(logs))
|
||||
|
||||
results = []
|
||||
for log in logs:
|
||||
# Ensure payload is always a dictionary
|
||||
if isinstance(log.content, dict):
|
||||
payload = log.content
|
||||
elif isinstance(log.content, str):
|
||||
payload = {"message": log.content}
|
||||
elif log.content is None:
|
||||
payload = {}
|
||||
else:
|
||||
# Handle other types by converting to string and wrapping
|
||||
payload = {"content": str(log.content)}
|
||||
|
||||
results.append(
|
||||
Event(
|
||||
id=str(log.id),
|
||||
sketch_id=str(log.sketch_id) if log.sketch_id else None,
|
||||
type=log.type,
|
||||
payload=payload,
|
||||
created_at=log.created_at
|
||||
)
|
||||
)
|
||||
|
||||
return results
|
||||
"""Get historical logs for a specific sketch with optional filtering."""
|
||||
service = create_log_service(db)
|
||||
try:
|
||||
return service.get_logs_by_sketch(sketch_id, current_user.id, limit, since)
|
||||
except NotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except PermissionDeniedError:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
|
||||
@router.get("/sketch/{sketch_id}/stream")
|
||||
@@ -84,26 +42,22 @@ async def stream_events(
|
||||
request: Request,
|
||||
sketch_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Profile = Depends(get_current_user_sse)
|
||||
current_user: Profile = Depends(get_current_user_sse),
|
||||
):
|
||||
"""Stream events for a specific scan in real-time"""
|
||||
|
||||
# Check if sketch exists
|
||||
sketch = db.query(Sketch).filter(Sketch.id == sketch_id).first()
|
||||
if not sketch:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Sketch with id {sketch_id} not found"
|
||||
)
|
||||
|
||||
check_investigation_permission(
|
||||
current_user.id, sketch.investigation_id, actions=["read"], db=db
|
||||
)
|
||||
"""Stream events for a specific sketch in real-time."""
|
||||
service = create_log_service(db)
|
||||
try:
|
||||
# Verify permission
|
||||
service._get_sketch_with_permission(sketch_id, current_user.id, ["read"])
|
||||
except NotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except PermissionDeniedError:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
async def event_generator():
|
||||
channel = sketch_id
|
||||
await event_emitter.subscribe(channel)
|
||||
try:
|
||||
# Initial connection message
|
||||
yield 'data: {"event": "connected", "data": "Connected to log stream"}\n\n'
|
||||
while True:
|
||||
if await request.is_disconnected():
|
||||
@@ -111,15 +65,12 @@ async def stream_events(
|
||||
|
||||
data = await event_emitter.get_message(channel)
|
||||
if data is None:
|
||||
await asyncio.sleep(0.1) # avoid tight loop on None
|
||||
await asyncio.sleep(0.1)
|
||||
continue
|
||||
|
||||
# Handle different types of events
|
||||
if isinstance(data, dict) and data.get("type") == "enricher_complete":
|
||||
# Send enricher completion event
|
||||
yield json.dumps({"event": "enricher_complete", "data": data})
|
||||
else:
|
||||
# Send regular log event
|
||||
yield json.dumps({"event": "log", "data": data})
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
@@ -147,25 +98,16 @@ def delete_scan_logs(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Profile = Depends(get_current_user),
|
||||
):
|
||||
"""Delete all logs for a specific scan"""
|
||||
# Check if sketch exists and user has permission
|
||||
sketch = db.query(Sketch).filter(Sketch.id == sketch_id).first()
|
||||
if not sketch:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Sketch with id {sketch_id} not found"
|
||||
)
|
||||
|
||||
check_investigation_permission(
|
||||
current_user.id, sketch.investigation_id, actions=["delete"], db=db
|
||||
)
|
||||
|
||||
"""Delete all logs for a specific sketch."""
|
||||
service = create_log_service(db)
|
||||
try:
|
||||
db.query(Log).filter(Log.sketch_id == sketch_id).delete()
|
||||
db.commit()
|
||||
return {"message": f"All logs have been deleted successfully"}
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=f"Failed to delete logs: {str(e)}")
|
||||
return service.delete_logs_by_sketch(sketch_id, current_user.id)
|
||||
except NotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except PermissionDeniedError:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
except DatabaseError as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/sketch/{sketch_id}/status/stream")
|
||||
@@ -173,26 +115,21 @@ async def stream_sketch_status(
|
||||
request: Request,
|
||||
sketch_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Profile = Depends(get_current_user_sse)
|
||||
current_user: Profile = Depends(get_current_user_sse),
|
||||
):
|
||||
"""Stream COMPLETED events for a specific sketch (for graph refresh)"""
|
||||
|
||||
# Check if sketch exists
|
||||
sketch = db.query(Sketch).filter(Sketch.id == sketch_id).first()
|
||||
if not sketch:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Sketch with id {sketch_id} not found"
|
||||
)
|
||||
|
||||
check_investigation_permission(
|
||||
current_user.id, sketch.investigation_id, actions=["read"], db=db
|
||||
)
|
||||
"""Stream COMPLETED events for a specific sketch (for graph refresh)."""
|
||||
service = create_log_service(db)
|
||||
try:
|
||||
service._get_sketch_with_permission(sketch_id, current_user.id, ["read"])
|
||||
except NotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except PermissionDeniedError:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
async def status_generator():
|
||||
channel = f"{sketch_id}_status"
|
||||
await event_emitter.subscribe(channel)
|
||||
try:
|
||||
# Initial connection message
|
||||
yield json.dumps({"event": "connected", "data": "Connected to status stream"})
|
||||
|
||||
while True:
|
||||
@@ -204,7 +141,6 @@ async def stream_sketch_status(
|
||||
await asyncio.sleep(0.1)
|
||||
continue
|
||||
|
||||
# Send status event
|
||||
yield json.dumps({"event": "status", "data": data})
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
@@ -231,29 +167,21 @@ async def stream_status(
|
||||
request: Request,
|
||||
scan_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Profile = Depends(get_current_user_sse)
|
||||
current_user: Profile = Depends(get_current_user_sse),
|
||||
):
|
||||
"""Stream status updates for a specific scan in real-time"""
|
||||
|
||||
# Check if scan exists and user has permission
|
||||
scan = db.query(Scan).filter(Scan.id == scan_id).first()
|
||||
if not scan:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Scan with id {scan_id} not found"
|
||||
)
|
||||
|
||||
# Check investigation permission via sketch
|
||||
sketch = db.query(Sketch).filter(Sketch.id == scan.sketch_id).first()
|
||||
if sketch:
|
||||
check_investigation_permission(
|
||||
current_user.id, sketch.investigation_id, actions=["read"], db=db
|
||||
)
|
||||
"""Stream status updates for a specific scan in real-time."""
|
||||
service = create_log_service(db)
|
||||
try:
|
||||
service.get_scan_with_permission(scan_id, current_user.id)
|
||||
except NotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except PermissionDeniedError:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
async def status_generator():
|
||||
print("[EventEmitter] Start status generator")
|
||||
await event_emitter.subscribe(f"scan_{scan_id}_status")
|
||||
try:
|
||||
# Initial connection message
|
||||
yield 'data: {"event": "connected", "data": "Connected to status stream"}\n\n'
|
||||
|
||||
while True:
|
||||
@@ -265,9 +193,7 @@ async def stream_status(
|
||||
yield f"data: {data}\n\n"
|
||||
|
||||
except asyncio.CancelledError:
|
||||
print(
|
||||
f"[EventEmitter] Client disconnected from status stream for scan_id: {scan_id}"
|
||||
)
|
||||
print(f"[EventEmitter] Client disconnected from status stream for scan_id: {scan_id}")
|
||||
finally:
|
||||
await event_emitter.unsubscribe(f"scan_{scan_id}_status")
|
||||
|
||||
|
||||
@@ -1,50 +1,53 @@
|
||||
from uuid import UUID, uuid4
|
||||
from fastapi import APIRouter, HTTPException, Depends, status, Query
|
||||
from typing import Dict, List, Any, Optional
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from flowsint_core.core.celery import celery
|
||||
from flowsint_core.core.graph import create_graph_service
|
||||
from flowsint_core.core.models import Profile
|
||||
from flowsint_core.core.postgre_db import get_db
|
||||
from flowsint_core.core.services import (
|
||||
NotFoundError,
|
||||
PermissionDeniedError,
|
||||
create_flow_service,
|
||||
)
|
||||
from flowsint_core.core.services.type_registry_service import (
|
||||
create_type_registry_service,
|
||||
)
|
||||
from flowsint_core.core.types import FlowBranch, FlowEdge, FlowNode, FlowStep
|
||||
from flowsint_core.utils import extract_input_schema_flow
|
||||
from flowsint_enrichers import ENRICHER_REGISTRY, load_all_enrichers
|
||||
|
||||
# Auto-discover and register all enrichers
|
||||
load_all_enrichers()
|
||||
from flowsint_core.core.celery import celery
|
||||
from flowsint_core.core.graph_repository import GraphRepository
|
||||
from flowsint_types import (
|
||||
Domain,
|
||||
Phrase,
|
||||
Ip,
|
||||
SocialAccount,
|
||||
Organization,
|
||||
Email,
|
||||
Phone,
|
||||
Username,
|
||||
clean_neo4j_node_data,
|
||||
)
|
||||
from flowsint_core.core.types import Node, Edge, FlowStep, FlowBranch
|
||||
from sqlalchemy.orm import Session
|
||||
from flowsint_core.core.postgre_db import get_db
|
||||
from flowsint_core.core.models import Flow, Profile, CustomType, Sketch
|
||||
from app.api.deps import get_current_user
|
||||
from sqlalchemy import func
|
||||
from app.api.schemas.flow import FlowRead, FlowCreate, FlowUpdate
|
||||
from app.security.permissions import check_investigation_permission
|
||||
from flowsint_types import (
|
||||
ASN,
|
||||
CIDR,
|
||||
CryptoNFT,
|
||||
CryptoWallet,
|
||||
CryptoWalletTransaction,
|
||||
CryptoNFT,
|
||||
Website,
|
||||
DNSRecord,
|
||||
Domain,
|
||||
Email,
|
||||
Individual,
|
||||
Ip,
|
||||
Organization,
|
||||
Phone,
|
||||
Phrase,
|
||||
Port,
|
||||
DNSRecord
|
||||
SocialAccount,
|
||||
Username,
|
||||
Website,
|
||||
)
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.api.schemas.flow import FlowCreate, FlowRead, FlowUpdate
|
||||
|
||||
load_all_enrichers()
|
||||
|
||||
|
||||
class FlowComputationRequest(BaseModel):
|
||||
nodes: List[Node]
|
||||
edges: List[Edge]
|
||||
nodes: List[FlowNode]
|
||||
edges: List[FlowEdge]
|
||||
inputType: Optional[str] = None
|
||||
|
||||
|
||||
@@ -72,37 +75,10 @@ def get_flows(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Profile = Depends(get_current_user),
|
||||
):
|
||||
if not category or category.lower() == "undefined":
|
||||
return db.query(Flow).order_by(Flow.last_updated_at.desc()).all()
|
||||
|
||||
custom_type = (
|
||||
db.query(CustomType)
|
||||
.filter(
|
||||
CustomType.owner_id == current_user.id,
|
||||
CustomType.status == "published",
|
||||
func.lower(CustomType.name) == category.lower(),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if custom_type:
|
||||
flows = db.query(Flow).order_by(Flow.last_updated_at.desc()).all()
|
||||
return [
|
||||
{
|
||||
**(flow.to_dict() if hasattr(flow, "to_dict") else flow.__dict__),
|
||||
"wobblyType": True,
|
||||
}
|
||||
for flow in flows
|
||||
]
|
||||
|
||||
flows = db.query(Flow).order_by(Flow.last_updated_at.desc()).all()
|
||||
return [
|
||||
flow
|
||||
for flow in flows
|
||||
if any(cat.lower() == category.lower() for cat in flow.category)
|
||||
]
|
||||
service = create_flow_service(db)
|
||||
return service.get_all_flows(category, current_user.id)
|
||||
|
||||
|
||||
# Returns the "raw_materials" for the flow editor
|
||||
@router.get("/raw_materials")
|
||||
async def get_material_list():
|
||||
enrichers = ENRICHER_REGISTRY.list_by_categories()
|
||||
@@ -148,57 +124,46 @@ async def get_material_list():
|
||||
extract_input_schema_flow(CryptoNFT),
|
||||
]
|
||||
|
||||
# Put types first, then add all enricher categories
|
||||
flattened_enrichers = {"types": object_inputs}
|
||||
flattened_enrichers.update(enricher_categories)
|
||||
|
||||
return {"items": flattened_enrichers}
|
||||
|
||||
|
||||
# Returns the "raw_materials" for the flow editor
|
||||
@router.get("/input_type/{input_type}")
|
||||
async def get_material_list(input_type: str):
|
||||
async def get_material_by_input_type(input_type: str):
|
||||
enrichers = ENRICHER_REGISTRY.list_by_input_type(input_type)
|
||||
return {"items": enrichers}
|
||||
|
||||
|
||||
# Create a new flow
|
||||
@router.post("/create", response_model=FlowRead, status_code=status.HTTP_201_CREATED)
|
||||
def create_flow(
|
||||
payload: FlowCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Profile = Depends(get_current_user),
|
||||
):
|
||||
|
||||
new_flow = Flow(
|
||||
id=uuid4(),
|
||||
service = create_flow_service(db)
|
||||
return service.create(
|
||||
name=payload.name,
|
||||
description=payload.description,
|
||||
category=payload.category,
|
||||
flow_schema=payload.flow_schema,
|
||||
created_at=datetime.utcnow(),
|
||||
last_updated_at=datetime.utcnow(),
|
||||
)
|
||||
db.add(new_flow)
|
||||
db.commit()
|
||||
db.refresh(new_flow)
|
||||
return new_flow
|
||||
|
||||
|
||||
# Get a flow by ID
|
||||
@router.get("/{flow_id}", response_model=FlowRead)
|
||||
def get_flow_by_id(
|
||||
flow_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Profile = Depends(get_current_user),
|
||||
):
|
||||
flow = db.query(Flow).filter(Flow.id == flow_id).first()
|
||||
if not flow:
|
||||
raise HTTPException(status_code=404, detail="flow not found")
|
||||
return flow
|
||||
service = create_flow_service(db)
|
||||
try:
|
||||
return service.get_by_id(flow_id)
|
||||
except NotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Flow not found")
|
||||
|
||||
|
||||
# Update a flow by ID
|
||||
@router.put("/{flow_id}", response_model=FlowRead)
|
||||
def update_flow(
|
||||
flow_id: UUID,
|
||||
@@ -206,37 +171,25 @@ def update_flow(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Profile = Depends(get_current_user),
|
||||
):
|
||||
flow = db.query(Flow).filter(Flow.id == flow_id).first()
|
||||
if not flow:
|
||||
raise HTTPException(status_code=404, detail="flow not found")
|
||||
update_data = payload.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
print(f"only update {key}")
|
||||
if key == "category":
|
||||
if "SocialAccount" in value:
|
||||
value.append("Username")
|
||||
setattr(flow, key, value)
|
||||
|
||||
flow.last_updated_at = datetime.utcnow()
|
||||
|
||||
db.commit()
|
||||
db.refresh(flow)
|
||||
return flow
|
||||
service = create_flow_service(db)
|
||||
try:
|
||||
return service.update(flow_id, payload.model_dump(exclude_unset=True))
|
||||
except NotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Flow not found")
|
||||
|
||||
|
||||
# Delete a flow by ID
|
||||
@router.delete("/{flow_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_flow(
|
||||
flow_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Profile = Depends(get_current_user),
|
||||
):
|
||||
flow = db.query(Flow).filter(Flow.id == flow_id).first()
|
||||
if not flow:
|
||||
raise HTTPException(status_code=404, detail="flow not found")
|
||||
db.delete(flow)
|
||||
db.commit()
|
||||
return None
|
||||
service = create_flow_service(db)
|
||||
try:
|
||||
service.delete(flow_id)
|
||||
return None
|
||||
except NotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Flow not found")
|
||||
|
||||
|
||||
@router.post("/{flow_id}/launch")
|
||||
@@ -246,38 +199,32 @@ async def launch_flow(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Profile = Depends(get_current_user),
|
||||
):
|
||||
service = create_flow_service(db)
|
||||
try:
|
||||
flow = db.query(Flow).filter(Flow.id == flow_id).first()
|
||||
if flow is None:
|
||||
raise HTTPException(status_code=404, detail="flow not found")
|
||||
flow = service.get_by_id(UUID(flow_id))
|
||||
service.get_sketch_for_launch(payload.sketch_id, current_user.id)
|
||||
|
||||
# Check investigation permission via sketch
|
||||
sketch = db.query(Sketch).filter(Sketch.id == payload.sketch_id).first()
|
||||
if not sketch:
|
||||
raise HTTPException(status_code=404, detail="Sketch not found")
|
||||
|
||||
check_investigation_permission(
|
||||
current_user.id, sketch.investigation_id, actions=["update"], db=db
|
||||
# Retrieve entities from Neo4J by their element IDs
|
||||
type_registry = create_type_registry_service(db)
|
||||
resolver = type_registry.build_type_resolver(current_user.id)
|
||||
graph_service = create_graph_service(
|
||||
sketch_id=payload.sketch_id, type_resolver=resolver
|
||||
)
|
||||
|
||||
# Retrieve nodes from Neo4J by their element IDs
|
||||
graph_repo = GraphRepository()
|
||||
nodes_data = graph_repo.get_nodes_by_ids(payload.node_ids, payload.sketch_id)
|
||||
|
||||
if not nodes_data:
|
||||
raise HTTPException(status_code=404, detail="No nodes found with provided IDs")
|
||||
|
||||
# Clean Neo4J-specific fields from node data
|
||||
# The enricher's preprocess() will handle Pydantic validation
|
||||
cleaned_nodes = [clean_neo4j_node_data(node_data) for node_data in nodes_data]
|
||||
entities = graph_service.get_nodes_by_ids_for_task(payload.node_ids)
|
||||
|
||||
# Compute flow branches
|
||||
nodes = [Node(**node) for node in flow.flow_schema["nodes"]]
|
||||
edges = [Edge(**edge) for edge in flow.flow_schema["edges"]]
|
||||
nodes = [FlowNode(**node) for node in flow.flow_schema["nodes"]]
|
||||
edges = [FlowEdge(**edge) for edge in flow.flow_schema["edges"]]
|
||||
|
||||
# For flow computation, we still need a sample value
|
||||
# Use the label from the first node data
|
||||
sample_value = nodes_data[0].get('label', 'sample_value') if nodes_data else 'sample_value'
|
||||
entities = [
|
||||
entity.model_dump(mode="json", serialize_as_any=True) for entity in entities
|
||||
]
|
||||
|
||||
sample_value = (
|
||||
entities[0].get("nodeLabel", "sample_value")
|
||||
if len(entities)
|
||||
else "sample_value"
|
||||
)
|
||||
flow_branches = compute_flow_branches(sample_value, nodes, edges)
|
||||
serializable_branches = [branch.model_dump() for branch in flow_branches]
|
||||
|
||||
@@ -285,16 +232,19 @@ async def launch_flow(
|
||||
"run_flow",
|
||||
args=[
|
||||
serializable_branches,
|
||||
cleaned_nodes,
|
||||
entities,
|
||||
payload.sketch_id,
|
||||
str(current_user.id),
|
||||
],
|
||||
)
|
||||
return {"id": task.id}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except NotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except PermissionDeniedError:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
except Exception as e:
|
||||
print(e)
|
||||
raise HTTPException(status_code=500, detail=f"Error launching flow: {str(e)}")
|
||||
|
||||
|
||||
@@ -332,10 +282,9 @@ def generate_sample_data(type_str: str) -> Any:
|
||||
|
||||
|
||||
def compute_flow_branches(
|
||||
initial_value: Any, nodes: List[Node], edges: List[Edge]
|
||||
initial_value: Any, nodes: List[FlowNode], edges: List[FlowEdge]
|
||||
) -> List[FlowBranch]:
|
||||
"""Computes flow branches based on nodes and edges with proper DFS traversal"""
|
||||
# Find input nodes (starting points)
|
||||
input_nodes = [node for node in nodes if node.data.get("type") == "type"]
|
||||
|
||||
if not input_nodes:
|
||||
@@ -347,6 +296,7 @@ def compute_flow_branches(
|
||||
FlowStep(
|
||||
nodeId="error",
|
||||
inputs={},
|
||||
params={},
|
||||
type="error",
|
||||
outputs={},
|
||||
status="error",
|
||||
@@ -360,34 +310,25 @@ def compute_flow_branches(
|
||||
node_map = {node.id: node for node in nodes}
|
||||
branches = []
|
||||
branch_counter = 0
|
||||
# Track enricher outputs across all branches
|
||||
enricher_outputs = {}
|
||||
|
||||
def calculate_path_length(start_node: str, visited: set = None) -> int:
|
||||
"""Calculate the shortest possible path length from a node to any leaf"""
|
||||
if visited is None:
|
||||
visited = set()
|
||||
|
||||
if start_node in visited:
|
||||
return float("inf")
|
||||
|
||||
visited.add(start_node)
|
||||
out_edges = [edge for edge in edges if edge.source == start_node]
|
||||
|
||||
if not out_edges:
|
||||
return 1
|
||||
|
||||
min_length = float("inf")
|
||||
for edge in out_edges:
|
||||
length = calculate_path_length(edge.target, visited.copy())
|
||||
min_length = min(min_length, length)
|
||||
|
||||
return 1 + min_length
|
||||
|
||||
def get_outgoing_edges(node_id: str) -> List[Edge]:
|
||||
"""Get outgoing edges sorted by the shortest possible path length"""
|
||||
def get_outgoing_edges(node_id: str) -> List[FlowEdge]:
|
||||
out_edges = [edge for edge in edges if edge.source == node_id]
|
||||
# Sort edges by the length of the shortest possible path from their target
|
||||
return sorted(out_edges, key=lambda e: calculate_path_length(e.target))
|
||||
|
||||
def create_step(
|
||||
@@ -423,7 +364,6 @@ def compute_flow_branches(
|
||||
) -> None:
|
||||
nonlocal branch_counter
|
||||
|
||||
# Skip if node is already in current path (cycle detection)
|
||||
if current_node_id in path:
|
||||
return
|
||||
|
||||
@@ -431,7 +371,6 @@ def compute_flow_branches(
|
||||
if not current_node:
|
||||
return
|
||||
|
||||
# Process node outputs
|
||||
is_input_node = current_node.data.get("type") == "type"
|
||||
if is_input_node:
|
||||
outputs_array = current_node.data["outputs"].get("properties", [])
|
||||
@@ -440,18 +379,14 @@ def compute_flow_branches(
|
||||
)
|
||||
current_outputs = {first_output_name: initial_value}
|
||||
else:
|
||||
# Check if we already have outputs for this enricher
|
||||
if current_node_id in enricher_outputs:
|
||||
current_outputs = enricher_outputs[current_node_id]
|
||||
else:
|
||||
current_outputs = process_node_data(current_node, input_data)
|
||||
# Store the outputs for future use
|
||||
enricher_outputs[current_node_id] = current_outputs
|
||||
|
||||
# Extract node parameters
|
||||
node_params = current_node.data.get("params", {})
|
||||
|
||||
# Create and add current step
|
||||
current_step = create_step(
|
||||
current_node_id,
|
||||
branch_id,
|
||||
@@ -465,19 +400,15 @@ def compute_flow_branches(
|
||||
path.append(current_node_id)
|
||||
branch_visited.add(current_node_id)
|
||||
|
||||
# Get all outgoing edges sorted by path length
|
||||
out_edges = get_outgoing_edges(current_node_id)
|
||||
|
||||
if not out_edges:
|
||||
# Leaf node reached, save the branch
|
||||
branches.append(FlowBranch(id=branch_id, name=branch_name, steps=steps[:]))
|
||||
else:
|
||||
# Process each outgoing edge in order of shortest path
|
||||
for i, edge in enumerate(out_edges):
|
||||
if edge.target in path: # Skip if would create cycle
|
||||
if edge.target in path:
|
||||
continue
|
||||
|
||||
# Prepare next node's input
|
||||
output_key = edge.sourceHandle
|
||||
if not output_key and current_outputs:
|
||||
output_key = list(current_outputs.keys())[0]
|
||||
@@ -491,7 +422,6 @@ def compute_flow_branches(
|
||||
next_input = {edge.targetHandle or "input": output_value}
|
||||
|
||||
if i == 0:
|
||||
# Continue in same branch (will be shortest path)
|
||||
explore_branch(
|
||||
edge.target,
|
||||
branch_id,
|
||||
@@ -504,31 +434,26 @@ def compute_flow_branches(
|
||||
current_outputs,
|
||||
)
|
||||
else:
|
||||
# Create new branch starting from current node
|
||||
branch_counter += 1
|
||||
new_branch_id = f"{branch_id}-{branch_counter}"
|
||||
new_branch_name = f"{branch_name} (Branch {branch_counter})"
|
||||
new_steps = steps[: len(steps)] # Copy steps up to current node
|
||||
new_branch_visited = (
|
||||
branch_visited.copy()
|
||||
) # Create new visited set for the branch
|
||||
new_steps = steps[: len(steps)]
|
||||
new_branch_visited = branch_visited.copy()
|
||||
explore_branch(
|
||||
edge.target,
|
||||
new_branch_id,
|
||||
new_branch_name,
|
||||
depth + 1,
|
||||
next_input,
|
||||
path[:], # Create new path copy for branch
|
||||
path[:],
|
||||
new_branch_visited,
|
||||
new_steps,
|
||||
current_outputs,
|
||||
)
|
||||
|
||||
# Backtrack: remove current node from path and remove its step
|
||||
path.pop()
|
||||
steps.pop()
|
||||
|
||||
# Start exploration from each input node
|
||||
for index, input_node in enumerate(input_nodes):
|
||||
branch_id = f"branch-{index}"
|
||||
branch_name = f"Flow {index + 1}" if len(input_nodes) > 1 else "Main Flow"
|
||||
@@ -538,76 +463,61 @@ def compute_flow_branches(
|
||||
branch_name,
|
||||
0,
|
||||
{},
|
||||
[], # Use list for path to maintain order
|
||||
set(), # Use set for visited to check membership
|
||||
[],
|
||||
set(),
|
||||
[],
|
||||
None,
|
||||
)
|
||||
|
||||
# Sort branches by length (number of steps)
|
||||
branches.sort(key=lambda branch: len(branch.steps))
|
||||
return branches
|
||||
|
||||
|
||||
def process_node_data(node: Node, inputs: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Traite les données de nœud en fonction du type de nœud et des entrées"""
|
||||
def process_node_data(node: FlowNode, inputs: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Process node data based on node type and inputs"""
|
||||
outputs = {}
|
||||
output_types = node.data["outputs"].get("properties", [])
|
||||
|
||||
for output in output_types:
|
||||
output_name = output.get("name", "output")
|
||||
class_name = node.data.get("class_name", "")
|
||||
# For simulation purposes, we'll return a placeholder value based on the enricher type
|
||||
|
||||
if class_name in ["ReverseResolveEnricher", "ResolveEnricher"]:
|
||||
# IP/Domain resolution enrichers
|
||||
outputs[output_name] = (
|
||||
"192.168.1.1" if "ip" in output_name.lower() else "example.com"
|
||||
)
|
||||
elif class_name == "SubdomainEnricher":
|
||||
# Subdomain enricher
|
||||
outputs[output_name] = f"sub.{inputs.get('input', 'example.com')}"
|
||||
|
||||
elif class_name == "WhoisEnricher":
|
||||
# WHOIS enricher
|
||||
outputs[output_name] = {
|
||||
"domain": inputs.get("input", "example.com"),
|
||||
"registrar": "Example Registrar",
|
||||
"creation_date": "2020-01-01",
|
||||
}
|
||||
|
||||
elif class_name == "IpToInfosEnricher":
|
||||
# Geolocation enricher
|
||||
outputs[output_name] = {
|
||||
"country": "France",
|
||||
"city": "Paris",
|
||||
"coordinates": {"lat": 48.8566, "lon": 2.3522},
|
||||
}
|
||||
|
||||
elif class_name == "MaigretEnricher":
|
||||
# Social media enricher
|
||||
outputs[output_name] = {
|
||||
"username": inputs.get("input", "user123"),
|
||||
"platforms": ["twitter", "github", "linkedin"],
|
||||
}
|
||||
|
||||
elif class_name == "HoleheEnricher":
|
||||
# Email verification enricher
|
||||
outputs[output_name] = {
|
||||
"email": inputs.get("input", "user@example.com"),
|
||||
"exists": True,
|
||||
"platforms": ["gmail", "github"],
|
||||
}
|
||||
|
||||
elif class_name == "SireneEnricher":
|
||||
# Organization enricher
|
||||
outputs[output_name] = {
|
||||
"name": inputs.get("input", "Example Corp"),
|
||||
"siret": "12345678901234",
|
||||
"address": "1 Example Street",
|
||||
}
|
||||
|
||||
else:
|
||||
# For unknown enrichers, pass through the input
|
||||
outputs[output_name] = inputs.get("input") or f"flowed_{output_name}"
|
||||
|
||||
return outputs
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
from uuid import UUID, uuid4
|
||||
from app.security.permissions import check_investigation_permission
|
||||
from uuid import UUID
|
||||
from fastapi import APIRouter, HTTPException, Depends, status
|
||||
from typing import List
|
||||
from datetime import datetime
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from flowsint_core.core.types import Role
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
from flowsint_core.core.postgre_db import get_db
|
||||
from flowsint_core.core.models import (
|
||||
Analysis,
|
||||
Investigation,
|
||||
InvestigationUserRole,
|
||||
Profile,
|
||||
Sketch,
|
||||
from flowsint_core.core.models import Profile
|
||||
from flowsint_core.core.services import (
|
||||
create_investigation_service,
|
||||
NotFoundError,
|
||||
PermissionDeniedError,
|
||||
DatabaseError,
|
||||
)
|
||||
from app.api.deps import get_current_user
|
||||
from app.api.schemas.investigation import (
|
||||
@@ -21,62 +19,23 @@ from app.api.schemas.investigation import (
|
||||
InvestigationUpdate,
|
||||
)
|
||||
from app.api.schemas.sketch import SketchRead
|
||||
from flowsint_core.core.graph_db import neo4j_connection
|
||||
from flowsint_core.core.graph_repository import GraphRepository
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def get_user_accessible_investigations(
|
||||
user_id: str, db: Session, allowed_roles: list[Role] = None
|
||||
) -> list[Investigation]:
|
||||
"""
|
||||
Returns all investigations accessible to user depending on its roles
|
||||
"""
|
||||
query = db.query(Investigation).join(
|
||||
InvestigationUserRole,
|
||||
InvestigationUserRole.investigation_id == Investigation.id,
|
||||
)
|
||||
|
||||
query = query.filter(InvestigationUserRole.user_id == user_id)
|
||||
|
||||
if allowed_roles:
|
||||
# ARRAY(Role) contains any of allowed_roles
|
||||
conditions = [InvestigationUserRole.roles.any(role) for role in allowed_roles]
|
||||
# Inclut également le propriétaire de l’investigation
|
||||
query = query.filter(or_(*conditions, Investigation.owner_id == user_id))
|
||||
|
||||
return (
|
||||
query.options(
|
||||
selectinload(Investigation.sketches),
|
||||
selectinload(Investigation.analyses),
|
||||
selectinload(Investigation.owner),
|
||||
)
|
||||
.distinct()
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
# Get the list of all investigations
|
||||
@router.get("", response_model=List[InvestigationRead])
|
||||
def get_investigations(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Profile = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Récupère toutes les investigations accessibles à l'utilisateur
|
||||
selon ses rôles (OWNER, EDITOR, VIEWER).
|
||||
"""
|
||||
allowed_roles_for_read = [Role.OWNER, Role.EDITOR, Role.VIEWER]
|
||||
|
||||
investigations = get_user_accessible_investigations(
|
||||
user_id=current_user.id, db=db, allowed_roles=allowed_roles_for_read
|
||||
"""Get all investigations accessible to the user based on their roles."""
|
||||
service = create_investigation_service(db)
|
||||
allowed_roles = [Role.OWNER, Role.EDITOR, Role.VIEWER]
|
||||
return service.get_accessible_investigations(
|
||||
user_id=current_user.id, allowed_roles=allowed_roles
|
||||
)
|
||||
|
||||
return investigations
|
||||
|
||||
|
||||
# Create a new investigation
|
||||
@router.post(
|
||||
"/create", response_model=InvestigationRead, status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
@@ -85,73 +44,46 @@ def create_investigation(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Profile = Depends(get_current_user),
|
||||
):
|
||||
new_investigation = Investigation(
|
||||
id=uuid4(),
|
||||
service = create_investigation_service(db)
|
||||
return service.create(
|
||||
name=payload.name,
|
||||
description=payload.description or payload.name,
|
||||
description=payload.description,
|
||||
owner_id=current_user.id,
|
||||
status="active",
|
||||
)
|
||||
db.add(new_investigation)
|
||||
|
||||
new_roles = InvestigationUserRole(
|
||||
id=uuid4(),
|
||||
user_id=current_user.id,
|
||||
investigation_id=new_investigation.id,
|
||||
roles=[Role.OWNER],
|
||||
)
|
||||
db.add(new_roles)
|
||||
|
||||
db.commit()
|
||||
db.refresh(new_investigation)
|
||||
db.refresh(new_roles)
|
||||
|
||||
return new_investigation
|
||||
|
||||
|
||||
# Get a investigation by ID
|
||||
@router.get("/{investigation_id}", response_model=InvestigationRead)
|
||||
def get_investigation_by_id(
|
||||
investigation_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Profile = Depends(get_current_user),
|
||||
):
|
||||
check_investigation_permission(current_user.id, investigation_id, actions=["read"], db=db)
|
||||
investigation = (
|
||||
db.query(Investigation)
|
||||
.options(
|
||||
selectinload(Investigation.sketches),
|
||||
selectinload(Investigation.analyses),
|
||||
selectinload(Investigation.owner),
|
||||
)
|
||||
.filter(Investigation.id == investigation_id)
|
||||
.filter(Investigation.owner_id == current_user.id)
|
||||
.first()
|
||||
)
|
||||
if not investigation:
|
||||
service = create_investigation_service(db)
|
||||
try:
|
||||
return service.get_by_id(investigation_id, current_user.id)
|
||||
except NotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Investigation not found")
|
||||
return investigation
|
||||
except PermissionDeniedError:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
|
||||
# Get a investigation by ID
|
||||
@router.get("/{investigation_id}/sketches", response_model=List[SketchRead])
|
||||
def get_sketches_by_investigation(
|
||||
investigation_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Profile = Depends(get_current_user),
|
||||
):
|
||||
check_investigation_permission(current_user.id, investigation_id, actions=["read"], db=db)
|
||||
sketches = (
|
||||
db.query(Sketch).filter(Sketch.investigation_id == investigation_id).all()
|
||||
)
|
||||
if not sketches:
|
||||
service = create_investigation_service(db)
|
||||
try:
|
||||
return service.get_sketches(investigation_id, current_user.id)
|
||||
except NotFoundError:
|
||||
raise HTTPException(
|
||||
status_code=404, detail="No sketches found for this investigation"
|
||||
)
|
||||
return sketches
|
||||
except PermissionDeniedError:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
|
||||
# Update a investigation by ID
|
||||
@router.put("/{investigation_id}", response_model=InvestigationRead)
|
||||
def update_investigation(
|
||||
investigation_id: UUID,
|
||||
@@ -159,66 +91,34 @@ def update_investigation(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Profile = Depends(get_current_user),
|
||||
):
|
||||
check_investigation_permission(current_user.id, investigation_id, actions=["write"], db=db)
|
||||
investigation = (
|
||||
db.query(Investigation).filter(Investigation.id == investigation_id).first()
|
||||
)
|
||||
if not investigation:
|
||||
service = create_investigation_service(db)
|
||||
try:
|
||||
return service.update(
|
||||
investigation_id=investigation_id,
|
||||
user_id=current_user.id,
|
||||
name=payload.name,
|
||||
description=payload.description,
|
||||
status=payload.status,
|
||||
)
|
||||
except NotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Investigation not found")
|
||||
|
||||
investigation.name = payload.name
|
||||
investigation.description = payload.description
|
||||
investigation.status = payload.status
|
||||
investigation.last_updated_at = datetime.utcnow()
|
||||
|
||||
db.commit()
|
||||
db.refresh(investigation)
|
||||
return investigation
|
||||
except PermissionDeniedError:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
|
||||
# Delete a investigation by ID
|
||||
@router.delete("/{investigation_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_investigation(
|
||||
investigation_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Profile = Depends(get_current_user),
|
||||
):
|
||||
check_investigation_permission(current_user.id, investigation_id, actions=["delete"], db=db)
|
||||
investigation = (
|
||||
db.query(Investigation)
|
||||
.filter(
|
||||
Investigation.id == investigation_id,
|
||||
Investigation.owner_id == current_user.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not investigation:
|
||||
service = create_investigation_service(db)
|
||||
try:
|
||||
service.delete(investigation_id, current_user.id)
|
||||
return None
|
||||
except NotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Investigation not found")
|
||||
|
||||
# Get all sketches related to this investigation
|
||||
sketches = (
|
||||
db.query(Sketch).filter(Sketch.investigation_id == investigation_id).all()
|
||||
)
|
||||
analyses = (
|
||||
db.query(Analysis).filter(Sketch.investigation_id == investigation_id).all()
|
||||
)
|
||||
|
||||
# Delete all nodes and relationships for each sketch in Neo4j using GraphRepository
|
||||
graph_repo = GraphRepository(neo4j_connection)
|
||||
for sketch in sketches:
|
||||
try:
|
||||
graph_repo.delete_all_sketch_nodes(str(sketch.id))
|
||||
except Exception as e:
|
||||
print(f"Neo4j cleanup error for sketch {sketch.id}: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to clean up graph data")
|
||||
|
||||
# Delete all sketches from PostgreSQL
|
||||
for sketch in sketches:
|
||||
db.delete(sketch)
|
||||
for analysis in analyses:
|
||||
db.delete(analysis)
|
||||
|
||||
# Finally delete the investigation
|
||||
db.delete(investigation)
|
||||
db.commit()
|
||||
return None
|
||||
except PermissionDeniedError:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
except DatabaseError:
|
||||
raise HTTPException(status_code=500, detail="Failed to clean up graph data")
|
||||
|
||||
@@ -1,23 +1,28 @@
|
||||
from uuid import UUID, uuid4
|
||||
from uuid import UUID
|
||||
from fastapi import APIRouter, HTTPException, Depends, status
|
||||
from typing import List
|
||||
from flowsint_core.core.vault import Vault
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from flowsint_core.core.services import (
|
||||
create_key_service,
|
||||
NotFoundError,
|
||||
DatabaseError,
|
||||
)
|
||||
from flowsint_core.core.postgre_db import get_db
|
||||
from flowsint_core.core.models import Profile, Key
|
||||
from flowsint_core.core.models import Profile
|
||||
from app.api.deps import get_current_user
|
||||
from app.api.schemas.key import KeyRead, KeyCreate
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# Get the list of all keys for a user, just the public method for viewing
|
||||
@router.get("", response_model=List[KeyRead])
|
||||
def get_keys(
|
||||
db: Session = Depends(get_db), current_user: Profile = Depends(get_current_user)
|
||||
):
|
||||
keys = db.query(Key).filter(Key.owner_id == current_user.id).all()
|
||||
response_data = [
|
||||
service = create_key_service(db)
|
||||
keys = service.get_keys_for_user(current_user.id)
|
||||
return [
|
||||
KeyRead(
|
||||
id=key.id,
|
||||
owner_id=key.owner_id,
|
||||
@@ -26,61 +31,57 @@ def get_keys(
|
||||
)
|
||||
for key in keys
|
||||
]
|
||||
return response_data
|
||||
|
||||
|
||||
# Get a key by ID, just the public method for viewing
|
||||
@router.get("/{id}", response_model=KeyRead)
|
||||
def get_key_by_id(
|
||||
id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Profile = Depends(get_current_user),
|
||||
):
|
||||
key = db.query(Key).filter(Key.id == id, Key.owner_id == current_user.id).first()
|
||||
if not key:
|
||||
service = create_key_service(db)
|
||||
try:
|
||||
key = service.get_key_by_id(id, current_user.id)
|
||||
return KeyRead(
|
||||
id=key.id,
|
||||
owner_id=key.owner_id,
|
||||
name=key.name,
|
||||
created_at=key.created_at,
|
||||
)
|
||||
except NotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Key not found")
|
||||
|
||||
# Create a response with obfuscated key
|
||||
response_data = KeyRead(
|
||||
id=key.id,
|
||||
owner_id=key.owner_id,
|
||||
name=key.name,
|
||||
created_at=key.created_at,
|
||||
)
|
||||
return response_data
|
||||
|
||||
|
||||
# Create a new key
|
||||
@router.post("/create", response_model=KeyRead, status_code=status.HTTP_201_CREATED)
|
||||
def create_key(
|
||||
payload: KeyCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Profile = Depends(get_current_user),
|
||||
):
|
||||
service = create_key_service(db)
|
||||
try:
|
||||
vault = Vault(db=db, owner_id=current_user.id)
|
||||
key = vault.set_secret(vault_ref=payload.name, plain_key=payload.key)
|
||||
if not key:
|
||||
raise HTTPException(
|
||||
status_code=500, detail="An error occured creating the key."
|
||||
)
|
||||
return key
|
||||
except Exception as e:
|
||||
key = service.create_key(payload.name, payload.key, current_user.id)
|
||||
return KeyRead(
|
||||
id=key.id,
|
||||
owner_id=key.owner_id,
|
||||
name=key.name,
|
||||
created_at=key.created_at,
|
||||
)
|
||||
except DatabaseError:
|
||||
raise HTTPException(
|
||||
status_code=500, detail="An error occured creating the key."
|
||||
status_code=500, detail="An error occurred creating the key."
|
||||
)
|
||||
|
||||
|
||||
# Delete a key by ID
|
||||
@router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_key(
|
||||
id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Profile = Depends(get_current_user),
|
||||
):
|
||||
key = db.query(Key).filter(Key.id == id, Key.owner_id == current_user.id).first()
|
||||
if not key:
|
||||
service = create_key_service(db)
|
||||
try:
|
||||
service.delete_key(id, current_user.id)
|
||||
return None
|
||||
except NotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Key not found")
|
||||
db.delete(key)
|
||||
db.commit()
|
||||
return None
|
||||
|
||||
@@ -1,84 +1,56 @@
|
||||
from uuid import UUID
|
||||
from fastapi import APIRouter, HTTPException, Depends, status
|
||||
from typing import List
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from flowsint_core.core.postgre_db import get_db
|
||||
from flowsint_core.core.models import Scan, Profile, Sketch, InvestigationUserRole
|
||||
from flowsint_core.core.types import Role
|
||||
from flowsint_core.core.models import Profile
|
||||
from flowsint_core.core.services import (
|
||||
create_scan_service,
|
||||
NotFoundError,
|
||||
PermissionDeniedError,
|
||||
)
|
||||
from app.api.deps import get_current_user
|
||||
from app.api.schemas.scan import ScanRead
|
||||
from app.security.permissions import check_investigation_permission
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# Get the list of all scans
|
||||
@router.get(
|
||||
"",
|
||||
response_model=List[ScanRead],
|
||||
)
|
||||
@router.get("", response_model=List[ScanRead])
|
||||
def get_scans(
|
||||
db: Session = Depends(get_db), current_user: Profile = Depends(get_current_user)
|
||||
):
|
||||
# Get all scans from sketches in investigations where user has at least VIEWER role
|
||||
allowed_roles_for_read = [Role.OWNER, Role.EDITOR, Role.VIEWER]
|
||||
|
||||
query = db.query(Scan).join(
|
||||
Sketch, Sketch.id == Scan.sketch_id
|
||||
).join(
|
||||
InvestigationUserRole,
|
||||
InvestigationUserRole.investigation_id == Sketch.investigation_id,
|
||||
)
|
||||
|
||||
query = query.filter(InvestigationUserRole.user_id == current_user.id)
|
||||
|
||||
# Filter by allowed roles
|
||||
conditions = [InvestigationUserRole.roles.any(role) for role in allowed_roles_for_read]
|
||||
query = query.filter(or_(*conditions))
|
||||
|
||||
return query.distinct().all()
|
||||
"""Get all scans accessible to the current user."""
|
||||
service = create_scan_service(db)
|
||||
return service.get_accessible_scans(current_user.id)
|
||||
|
||||
|
||||
# Get a scan by ID
|
||||
@router.get("/{id}", response_model=ScanRead)
|
||||
def get_scan_by_id(
|
||||
id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Profile = Depends(get_current_user),
|
||||
):
|
||||
scan = db.query(Scan).filter(Scan.id == id).first()
|
||||
if not scan:
|
||||
service = create_scan_service(db)
|
||||
try:
|
||||
return service.get_by_id(id, current_user.id)
|
||||
except NotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Scan not found")
|
||||
|
||||
# Check investigation permission via sketch
|
||||
sketch = db.query(Sketch).filter(Sketch.id == scan.sketch_id).first()
|
||||
if sketch:
|
||||
check_investigation_permission(
|
||||
current_user.id, sketch.investigation_id, actions=["read"], db=db
|
||||
)
|
||||
|
||||
return scan
|
||||
except PermissionDeniedError:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
|
||||
# Delete a scan by ID
|
||||
@router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_scan_by_id(
|
||||
id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Profile = Depends(get_current_user),
|
||||
):
|
||||
scan = db.query(Scan).filter(Scan.id == id).first()
|
||||
if not scan:
|
||||
service = create_scan_service(db)
|
||||
try:
|
||||
service.delete(id, current_user.id)
|
||||
return None
|
||||
except NotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Scan not found")
|
||||
|
||||
# Check investigation permission via sketch
|
||||
sketch = db.query(Sketch).filter(Sketch.id == scan.sketch_id).first()
|
||||
if sketch:
|
||||
check_investigation_permission(
|
||||
current_user.id, sketch.investigation_id, actions=["delete"], db=db
|
||||
)
|
||||
|
||||
db.delete(scan)
|
||||
db.commit()
|
||||
return None
|
||||
except PermissionDeniedError:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,315 +1,18 @@
|
||||
from typing import Any, Dict, Optional, Type
|
||||
from uuid import uuid4
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, TypeAdapter
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from flowsint_core.core.models import Profile
|
||||
from flowsint_core.core.postgre_db import get_db
|
||||
from flowsint_core.core.models import CustomType, Profile
|
||||
from flowsint_core.core.services import create_type_registry_service
|
||||
from app.api.deps import get_current_user
|
||||
from flowsint_types.registry import get_type
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# Helper function to get a type by name from the registry
|
||||
def get_type_from_registry(type_name: str) -> Optional[Type[BaseModel]]:
|
||||
"""Get a type from the TYPE_REGISTRY by name."""
|
||||
return get_type(type_name, case_sensitive=True)
|
||||
|
||||
|
||||
# Returns the "types" for the sketches
|
||||
@router.get("/")
|
||||
async def get_types_list(
|
||||
db: Session = Depends(get_db), current_user: Profile = Depends(get_current_user)
|
||||
):
|
||||
# Define categories with type names to look up in TYPE_REGISTRY
|
||||
# Format: (type_name, label_key, optional_icon)
|
||||
category_definitions = [
|
||||
{
|
||||
"id": uuid4(),
|
||||
"type": "global",
|
||||
"key": "global_category",
|
||||
"icon": "phrase",
|
||||
"label": "Global",
|
||||
"fields": [],
|
||||
"children": [
|
||||
("Phrase", "text", None),
|
||||
("Location", "address", None),
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": uuid4(),
|
||||
"type": "person",
|
||||
"key": "person_category",
|
||||
"icon": "individual",
|
||||
"label": "Identities & Entities",
|
||||
"fields": [],
|
||||
"children": [
|
||||
("Individual", "full_name", None),
|
||||
("Username", "value", "username"),
|
||||
("Organization", "name", None),
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": uuid4(),
|
||||
"type": "organization",
|
||||
"key": "organization_category",
|
||||
"icon": "organization",
|
||||
"label": "Organization",
|
||||
"fields": [],
|
||||
"children": [
|
||||
("Organization", "name", None),
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": uuid4(),
|
||||
"type": "contact_category",
|
||||
"key": "contact",
|
||||
"icon": "phone",
|
||||
"label": "Communication & Contact",
|
||||
"fields": [],
|
||||
"children": [
|
||||
("Phone", "number", None),
|
||||
("Email", "email", None),
|
||||
("Username", "value", None),
|
||||
("SocialAccount", "username", "socialaccount"),
|
||||
("Message", "content", "message"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": uuid4(),
|
||||
"type": "network_category",
|
||||
"key": "network",
|
||||
"icon": "domain",
|
||||
"label": "Network",
|
||||
"fields": [],
|
||||
"children": [
|
||||
("ASN", "number", None),
|
||||
("CIDR", "network", None),
|
||||
("Domain", "domain", None),
|
||||
("Website", "url", None),
|
||||
("Ip", "address", None),
|
||||
("Port", "number", None),
|
||||
("DNSRecord", "name", "dns"),
|
||||
("SSLCertificate", "subject", "ssl"),
|
||||
("WebTracker", "name", "webtracker"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": uuid4(),
|
||||
"type": "security_category",
|
||||
"key": "security",
|
||||
"icon": "credential",
|
||||
"label": "Security & Access",
|
||||
"fields": [],
|
||||
"children": [
|
||||
("Credential", "username", "credential"),
|
||||
("Session", "session_id", "session"),
|
||||
("Device", "device_id", "device"),
|
||||
("Malware", "name", "malware"),
|
||||
("Weapon", "name", "weapon"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": uuid4(),
|
||||
"type": "files_category",
|
||||
"key": "files",
|
||||
"icon": "file",
|
||||
"label": "Files & Documents",
|
||||
"fields": [],
|
||||
"children": [
|
||||
("Document", "title", "document"),
|
||||
("File", "filename", "file"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": uuid4(),
|
||||
"type": "financial_category",
|
||||
"key": "financial",
|
||||
"icon": "creditcard",
|
||||
"label": "Financial Data",
|
||||
"fields": [],
|
||||
"children": [
|
||||
("BankAccount", "account_number", "creditcard"),
|
||||
("CreditCard", "card_number", "creditcard"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": uuid4(),
|
||||
"type": "leak_category",
|
||||
"key": "leaks",
|
||||
"icon": "breach",
|
||||
"label": "Leaks",
|
||||
"fields": [],
|
||||
"children": [
|
||||
("Leak", "name", "breach"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": uuid4(),
|
||||
"type": "crypto_category",
|
||||
"key": "crypto",
|
||||
"icon": "cryptowallet",
|
||||
"label": "Crypto",
|
||||
"fields": [],
|
||||
"children": [
|
||||
("CryptoWallet", "address", "cryptowallet"),
|
||||
("CryptoWalletTransaction", "hash", "cryptowallet"),
|
||||
("CryptoNFT", "name", "cryptowallet"),
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
# Build the types list by looking up each type in TYPE_REGISTRY
|
||||
types = []
|
||||
for category in category_definitions:
|
||||
category_copy = category.copy()
|
||||
children_schemas = []
|
||||
|
||||
for child_def in category["children"]:
|
||||
type_name, label_key, icon = child_def
|
||||
model = get_type_from_registry(type_name)
|
||||
|
||||
if model:
|
||||
children_schemas.append(
|
||||
extract_input_schema(model, label_key=label_key, icon=icon)
|
||||
)
|
||||
else:
|
||||
# Log warning but continue - type might not be available
|
||||
print(f"Warning: Type {type_name} not found in TYPE_REGISTRY")
|
||||
|
||||
category_copy["children"] = children_schemas
|
||||
types.append(category_copy)
|
||||
|
||||
# Add custom types
|
||||
custom_types = (
|
||||
db.query(CustomType)
|
||||
.filter(
|
||||
CustomType.owner_id == current_user.id,
|
||||
CustomType.status == "published", # Only show published custom types
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
if custom_types:
|
||||
custom_types_children = []
|
||||
for custom_type in custom_types:
|
||||
# Extract the label_key from the schema (use first required field or first property)
|
||||
schema = custom_type.schema
|
||||
properties = schema.get("properties", {})
|
||||
required = schema.get("required", [])
|
||||
|
||||
# Try to use the first required field, or the first property
|
||||
label_key = (
|
||||
required[0]
|
||||
if required
|
||||
else list(properties.keys())[0] if properties else "value"
|
||||
)
|
||||
|
||||
custom_types_children.append(
|
||||
{
|
||||
"id": custom_type.id,
|
||||
"type": custom_type.name,
|
||||
"key": custom_type.name.lower(),
|
||||
"label_key": label_key,
|
||||
"icon": "custom",
|
||||
"label": custom_type.name,
|
||||
"description": custom_type.description or "",
|
||||
"fields": [
|
||||
{
|
||||
"name": prop,
|
||||
"label": info.get("title", prop),
|
||||
"description": info.get("description", ""),
|
||||
"type": "text",
|
||||
"required": prop in required,
|
||||
}
|
||||
for prop, info in properties.items()
|
||||
],
|
||||
"custom": True, # Mark as custom type
|
||||
}
|
||||
)
|
||||
|
||||
types.append(
|
||||
{
|
||||
"id": uuid4(),
|
||||
"type": "custom_types_category",
|
||||
"key": "custom_types",
|
||||
"icon": "custom",
|
||||
"label": "Custom types",
|
||||
"fields": [],
|
||||
"children": custom_types_children,
|
||||
}
|
||||
)
|
||||
|
||||
return types
|
||||
|
||||
|
||||
def extract_input_schema(
|
||||
model: Type[BaseModel], label_key: str, icon: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
|
||||
adapter = TypeAdapter(model)
|
||||
schema = adapter.json_schema()
|
||||
# Use the main schema properties, not the $defs
|
||||
type_name = model.__name__
|
||||
details = schema
|
||||
return {
|
||||
"id": uuid4(),
|
||||
"type": type_name,
|
||||
"key": type_name.lower(),
|
||||
"label_key": label_key,
|
||||
"icon": icon or type_name.lower(),
|
||||
"label": type_name,
|
||||
"description": details.get("description", ""),
|
||||
"fields": [
|
||||
resolve_field(prop, details=info, schema=schema)
|
||||
for prop, info in details.get("properties", {}).items()
|
||||
# exclude label from properties to fill
|
||||
if prop != "label"
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def resolve_field(prop: str, details: dict, schema: dict = None) -> Dict:
|
||||
"""_summary_
|
||||
The fields can sometimes contain nested complex objects, like:
|
||||
- Organization having Individual[] as dirigeants, so we want to skip those.
|
||||
Args:
|
||||
details (dict): _description_
|
||||
schema_context (dict, optional): _description_. Defaults to None.
|
||||
|
||||
Returns:
|
||||
str: _description_
|
||||
"""
|
||||
field = {
|
||||
"name": prop,
|
||||
"label": details.get("title", prop),
|
||||
"description": details.get("description", ""),
|
||||
"type": "text",
|
||||
}
|
||||
if has_enum(details):
|
||||
field["type"] = "select"
|
||||
field["options"] = [
|
||||
{"label": label, "value": label} for label in get_enum_values(details)
|
||||
]
|
||||
field["required"] = is_required(details)
|
||||
|
||||
return field
|
||||
|
||||
|
||||
def has_enum(schema: dict) -> bool:
|
||||
any_of = schema.get("anyOf", [])
|
||||
return any(isinstance(entry, dict) and "enum" in entry for entry in any_of)
|
||||
|
||||
|
||||
def is_required(schema: dict) -> bool:
|
||||
any_of = schema.get("anyOf", [])
|
||||
return not any(entry == {"type": "null"} for entry in any_of)
|
||||
|
||||
|
||||
def get_enum_values(schema: dict) -> list:
|
||||
enum_values = []
|
||||
for entry in schema.get("anyOf", []):
|
||||
if isinstance(entry, dict) and "enum" in entry:
|
||||
enum_values.extend(entry["enum"])
|
||||
return enum_values
|
||||
"""Get the complete types list for sketches."""
|
||||
service = create_type_registry_service(db)
|
||||
return service.get_types_list(current_user.id)
|
||||
|
||||
@@ -1,20 +1,31 @@
|
||||
from typing import Optional, Dict, Any
|
||||
from pydantic import BaseModel, UUID4, Field, field_validator
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from pydantic import UUID4, BaseModel, Field, field_validator
|
||||
|
||||
from .base import ORMBase
|
||||
|
||||
|
||||
class CustomTypeCreate(BaseModel):
|
||||
"""Schema for creating a new custom type."""
|
||||
name: str = Field(..., min_length=1, max_length=255, description="Name of the custom type")
|
||||
json_schema: Dict[str, Any] = Field(..., description="JSON Schema definition", alias="schema")
|
||||
description: Optional[str] = Field(None, description="Optional description of the custom type")
|
||||
status: Optional[str] = Field("draft", description="Status: draft or published")
|
||||
|
||||
name: str = Field(
|
||||
..., min_length=1, max_length=255, description="Name of the custom type"
|
||||
)
|
||||
json_schema: Dict[str, Any] = Field(
|
||||
..., description="JSON Schema definition", alias="schema"
|
||||
)
|
||||
description: Optional[str] = Field(
|
||||
None, description="Optional description of the custom type"
|
||||
)
|
||||
status: str = Field("draft", description="Status of the custom type")
|
||||
color: str = Field("#8E9E8C", description="Default color")
|
||||
icon: str = Field("Minus", description="Default icon")
|
||||
|
||||
class Config:
|
||||
populate_by_name = True
|
||||
|
||||
@field_validator('status')
|
||||
@field_validator("status")
|
||||
@classmethod
|
||||
def validate_status(cls, v: str) -> str:
|
||||
if v not in ["draft", "published", "archived"]:
|
||||
@@ -24,15 +35,18 @@ class CustomTypeCreate(BaseModel):
|
||||
|
||||
class CustomTypeUpdate(BaseModel):
|
||||
"""Schema for updating an existing custom type."""
|
||||
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
json_schema: Optional[Dict[str, Any]] = Field(None, alias="schema")
|
||||
description: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
icon: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
populate_by_name = True
|
||||
|
||||
@field_validator('status')
|
||||
@field_validator("status")
|
||||
@classmethod
|
||||
def validate_status(cls, v: Optional[str]) -> Optional[str]:
|
||||
if v is not None and v not in ["draft", "published", "archived"]:
|
||||
@@ -42,9 +56,12 @@ class CustomTypeUpdate(BaseModel):
|
||||
|
||||
class CustomTypeRead(ORMBase):
|
||||
"""Schema for reading a custom type."""
|
||||
|
||||
id: UUID4
|
||||
name: str
|
||||
owner_id: UUID4
|
||||
color: Optional[str]
|
||||
icon: Optional[str]
|
||||
json_schema: Dict[str, Any] = Field(..., alias="schema")
|
||||
status: str
|
||||
checksum: Optional[str]
|
||||
@@ -58,10 +75,14 @@ class CustomTypeRead(ORMBase):
|
||||
|
||||
class CustomTypeValidatePayload(BaseModel):
|
||||
"""Schema for validating a payload against a custom type schema."""
|
||||
payload: Dict[str, Any] = Field(..., description="Data to validate against the schema")
|
||||
|
||||
payload: Dict[str, Any] = Field(
|
||||
..., description="Data to validate against the schema"
|
||||
)
|
||||
|
||||
|
||||
class CustomTypeValidateResponse(BaseModel):
|
||||
"""Response schema for validation."""
|
||||
|
||||
valid: bool
|
||||
errors: Optional[list[str]] = None
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from .base import ORMBase
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import UUID4, BaseModel
|
||||
from typing import Optional
|
||||
from typing import List, Optional, Dict, Any
|
||||
|
||||
from .base import ORMBase
|
||||
|
||||
|
||||
class EnricherCreate(BaseModel):
|
||||
@@ -21,6 +22,6 @@ class EnricherRead(ORMBase):
|
||||
|
||||
class EnricherUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
class_name: str = None
|
||||
class_name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
category: Optional[List[str]] = None
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Pydantic schemas for enricher templates."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from pydantic import UUID4, BaseModel, Field, field_validator
|
||||
|
||||
from .base import ORMBase
|
||||
|
||||
|
||||
class EnricherTemplateCreate(BaseModel):
|
||||
"""Schema for creating a new enricher template."""
|
||||
|
||||
name: str = Field(
|
||||
..., min_length=1, max_length=255, description="Name of the template"
|
||||
)
|
||||
description: Optional[str] = Field(
|
||||
None, max_length=1000, description="Description of the template"
|
||||
)
|
||||
category: str = Field(
|
||||
..., min_length=1, max_length=100, description="Category (e.g., Ip, Domain)"
|
||||
)
|
||||
version: float = Field(default=1.0, ge=0, description="Template version")
|
||||
content: Dict[str, Any] = Field(
|
||||
..., description="Template content as parsed YAML/JSON"
|
||||
)
|
||||
is_public: bool = Field(
|
||||
default=False, description="Whether the template is publicly visible"
|
||||
)
|
||||
|
||||
@field_validator("content")
|
||||
@classmethod
|
||||
def validate_content(cls, v: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Validate that content has required template fields."""
|
||||
required_fields = [
|
||||
"name",
|
||||
"category",
|
||||
"version",
|
||||
"input",
|
||||
"request",
|
||||
"output",
|
||||
"response",
|
||||
]
|
||||
missing = [f for f in required_fields if f not in v]
|
||||
if missing:
|
||||
raise ValueError(
|
||||
f"Missing required fields in content: {', '.join(missing)}"
|
||||
)
|
||||
|
||||
# Validate input
|
||||
if "input" in v and "type" not in v.get("input", {}):
|
||||
raise ValueError("input.type is required")
|
||||
|
||||
# Validate request
|
||||
request = v.get("request", {})
|
||||
if "method" not in request:
|
||||
raise ValueError("request.method is required")
|
||||
if request.get("method") not in ["GET", "POST"]:
|
||||
raise ValueError("request.method must be GET or POST")
|
||||
if "url" not in request:
|
||||
raise ValueError("request.url is required")
|
||||
|
||||
# Validate output
|
||||
if "output" in v and "type" not in v.get("output", {}):
|
||||
raise ValueError("output.type is required")
|
||||
|
||||
# Validate response
|
||||
response = v.get("response", {})
|
||||
if "expect" not in response:
|
||||
raise ValueError("response.expect is required")
|
||||
if response.get("expect") not in ["json", "xml", "text"]:
|
||||
raise ValueError("response.expect must be json, xml, or text")
|
||||
|
||||
return v
|
||||
|
||||
|
||||
class EnricherTemplateUpdate(BaseModel):
|
||||
"""Schema for updating an existing enricher template."""
|
||||
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
description: Optional[str] = Field(None, max_length=1000)
|
||||
category: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||
version: Optional[float] = Field(None, ge=0)
|
||||
content: Optional[Dict[str, Any]] = None
|
||||
is_public: Optional[bool] = None
|
||||
|
||||
@field_validator("content")
|
||||
@classmethod
|
||||
def validate_content(cls, v: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
||||
"""Validate content if provided."""
|
||||
if v is None:
|
||||
return v
|
||||
|
||||
required_fields = [
|
||||
"name",
|
||||
"category",
|
||||
"version",
|
||||
"input",
|
||||
"request",
|
||||
"output",
|
||||
"response",
|
||||
]
|
||||
missing = [f for f in required_fields if f not in v]
|
||||
if missing:
|
||||
raise ValueError(
|
||||
f"Missing required fields in content: {', '.join(missing)}"
|
||||
)
|
||||
|
||||
return v
|
||||
|
||||
|
||||
class EnricherTemplateRead(ORMBase):
|
||||
"""Schema for reading an enricher template."""
|
||||
|
||||
id: UUID4
|
||||
name: str
|
||||
description: Optional[str]
|
||||
category: str
|
||||
version: float
|
||||
content: Dict[str, Any]
|
||||
is_public: bool
|
||||
owner_id: UUID4
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class EnricherTemplateList(ORMBase):
|
||||
"""Schema for listing enricher templates (minimal fields)."""
|
||||
|
||||
id: UUID4
|
||||
name: str
|
||||
description: Optional[str]
|
||||
category: str
|
||||
version: float
|
||||
is_public: bool
|
||||
owner_id: UUID4
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class EnricherTemplateTestRequest(BaseModel):
|
||||
"""Schema for testing an enricher template by ID."""
|
||||
|
||||
input_value: str = Field(
|
||||
..., min_length=1, description="The value to test the template with"
|
||||
)
|
||||
|
||||
|
||||
class EnricherTemplateTestContentRequest(BaseModel):
|
||||
"""Schema for testing template content directly (without saving)."""
|
||||
|
||||
input_value: str = Field(
|
||||
..., min_length=1, description="The value to test the template with"
|
||||
)
|
||||
content: Dict[str, Any] = Field(..., description="Template content to test")
|
||||
|
||||
|
||||
class EnricherTemplateTestResponse(BaseModel):
|
||||
"""Schema for test response."""
|
||||
|
||||
success: bool
|
||||
data: Optional[Dict[str, Any]] = None
|
||||
error: Optional[str] = None
|
||||
status_code: Optional[int] = None
|
||||
url: str
|
||||
|
||||
|
||||
class EnricherTemplateGenerateRequest(BaseModel):
|
||||
"""Schema for AI-assisted template generation."""
|
||||
|
||||
prompt: str = Field(
|
||||
...,
|
||||
min_length=10,
|
||||
max_length=16000,
|
||||
description="Free-text description of the desired enricher template",
|
||||
)
|
||||
|
||||
|
||||
class EnricherTemplateGenerateResponse(BaseModel):
|
||||
"""Schema for the generated template response."""
|
||||
|
||||
yaml_content: str = Field(
|
||||
..., description="Raw YAML string of the generated template"
|
||||
)
|
||||
@@ -1,7 +1,4 @@
|
||||
from fastapi import FastAPI
|
||||
from flowsint_core.core.graph_db import Neo4jConnection
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
# Routes to be included
|
||||
@@ -17,12 +14,7 @@ from app.api.routes import scan
|
||||
from app.api.routes import keys
|
||||
from app.api.routes import types
|
||||
from app.api.routes import custom_types
|
||||
|
||||
load_dotenv()
|
||||
|
||||
URI = os.getenv("NEO4J_URI_BOLT")
|
||||
USERNAME = os.getenv("NEO4J_USERNAME")
|
||||
PASSWORD = os.getenv("NEO4J_PASSWORD")
|
||||
from app.api.routes import enricher_templates
|
||||
|
||||
origins = [
|
||||
"*",
|
||||
@@ -30,7 +22,6 @@ origins = [
|
||||
|
||||
|
||||
app = FastAPI()
|
||||
neo4j_connection = Neo4jConnection(URI, USERNAME, PASSWORD)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
@@ -61,3 +52,4 @@ app.include_router(scan.router, prefix="/api/scans", tags=["scans"])
|
||||
app.include_router(keys.router, prefix="/api/keys", tags=["keys"])
|
||||
app.include_router(types.router, prefix="/api/types", tags=["types"])
|
||||
app.include_router(custom_types.router, prefix="/api/custom-types", tags=["custom-types"])
|
||||
app.include_router(enricher_templates.router, prefix="/api/enrichers/templates", tags=["enricher-templates"])
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import HTTPException
|
||||
from flowsint_core.core.models import InvestigationUserRole
|
||||
from flowsint_core.core.types import Role
|
||||
@@ -21,7 +23,9 @@ def can_user(roles: list[Role], actions: list[str]) -> bool:
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
||||
def check_investigation_permission(user_id: str, investigation_id: str, actions: list[str], db):
|
||||
def check_investigation_permission(
|
||||
user_id: UUID, investigation_id: str, actions: list[str], db
|
||||
):
|
||||
role_entry = (
|
||||
db.query(InvestigationUserRole)
|
||||
.filter_by(user_id=user_id, investigation_id=investigation_id)
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Ensure virtualenv binaries are in PATH
|
||||
export PATH="/app/flowsint-api/.venv/bin:$PATH"
|
||||
|
||||
if [ "$SKIP_MIGRATIONS" != "true" ]; then
|
||||
echo "Running database migrations..."
|
||||
alembic upgrade head
|
||||
|
||||
Generated
+1138
-2325
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
[tool.poetry]
|
||||
name = "flowsint-api"
|
||||
version = "1.0.0"
|
||||
license = "AGPL-3.0-or-later"
|
||||
license = "Apache-2.0"
|
||||
description = "API server for flowsint"
|
||||
authors = ["dextmorgn <contact@flowsint.io>"]
|
||||
packages = [{ include = "app" }]
|
||||
|
||||
Vendored
BIN
Binary file not shown.
@@ -1,8 +1,67 @@
|
||||
# Dependencies (will be installed in container)
|
||||
node_modules
|
||||
npm-debug.log
|
||||
.DS_Store
|
||||
|
||||
# Build output (will be built in container)
|
||||
dist
|
||||
.env.local
|
||||
.env.*.local
|
||||
build
|
||||
|
||||
# Development files
|
||||
*.dev
|
||||
Dockerfile.dev
|
||||
Dockerfile.optimized
|
||||
|
||||
# Environment files (secrets - NEVER include)
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# IDE and editor files
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
.gitattributes
|
||||
|
||||
# Testing
|
||||
coverage
|
||||
.nyc_output
|
||||
*.test.ts
|
||||
*.test.tsx
|
||||
*.spec.ts
|
||||
*.spec.tsx
|
||||
__tests__
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Cache
|
||||
.cache
|
||||
.vite
|
||||
.eslintcache
|
||||
.parcel-cache
|
||||
|
||||
*.md
|
||||
!README.md
|
||||
docs
|
||||
|
||||
# CI/CD
|
||||
.github
|
||||
.gitlab-ci.yml
|
||||
.circleci
|
||||
|
||||
# Misc
|
||||
*.bak
|
||||
*.tmp
|
||||
.husky
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
module.exports = {
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
ecmaFeatures: {
|
||||
jsx: true
|
||||
}
|
||||
},
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:react/recommended',
|
||||
|
||||
+36
-6
@@ -1,13 +1,43 @@
|
||||
FROM node:24
|
||||
FROM node:24-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN yarn install
|
||||
RUN apk add --no-cache python3 make g++
|
||||
|
||||
COPY package.json yarn.lock ./
|
||||
|
||||
RUN yarn install --network-timeout 100000
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN yarn build
|
||||
EXPOSE 5173
|
||||
# Build argument for API URL (injected at build time)
|
||||
ARG VITE_API_URL
|
||||
ENV VITE_API_URL=${VITE_API_URL}
|
||||
|
||||
CMD ["yarn", "preview", "--", "--port", "5173"]
|
||||
RUN yarn build
|
||||
|
||||
FROM nginx:1.27-alpine AS production
|
||||
|
||||
LABEL org.opencontainers.image.source="https://github.com/reconurge/flowsint"
|
||||
LABEL org.opencontainers.image.description="Flowsint Frontend"
|
||||
LABEL org.opencontainers.image.licenses="Apache-2.0"
|
||||
|
||||
RUN addgroup -g 1001 -S flowsint && \
|
||||
adduser -u 1001 -S flowsint -G flowsint && \
|
||||
# Set ownership for nginx html directory
|
||||
chown -R flowsint:flowsint /usr/share/nginx/html && \
|
||||
# Remove default config
|
||||
rm -f /etc/nginx/conf.d/default.conf
|
||||
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
|
||||
COPY --from=builder --chown=flowsint:flowsint /app/dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:8080/health || exit 1
|
||||
|
||||
USER flowsint
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
FROM node:24
|
||||
FROM node:24-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN yarn install
|
||||
RUN apk add --no-cache python3 make g++
|
||||
|
||||
# Copy dependency files
|
||||
COPY package.json yarn.lock ./
|
||||
|
||||
RUN yarn install --network-timeout 100000
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 5173
|
||||
|
||||
# Vite dev server with hot-reload
|
||||
CMD ["yarn", "dev", "--host", "0.0.0.0"]
|
||||
|
||||
+11
-11
@@ -1,4 +1,4 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en" theme="dark">
|
||||
<head>
|
||||
<!-- uncomment for react scan -->
|
||||
@@ -9,21 +9,21 @@
|
||||
</script> -->
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
|
||||
<!-- Google Fonts - Oxanium -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Oxanium:wght@200;300;400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Oxanium:wght@200;300;400;500;600;700;800&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" href="/icon.ico" />
|
||||
<link rel="apple-touch-icon" href="/icon.png" />
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Graph based investigation manager."
|
||||
/>
|
||||
<meta name="description" content="Graph based investigation manager." />
|
||||
<title>Flowsint</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
+83
-29
@@ -1,38 +1,92 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
worker_processes auto;
|
||||
error_log /tmp/nginx_error.log warn;
|
||||
pid /tmp/nginx.pid;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
use epoll;
|
||||
multi_accept on;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
# Temp directories for non-root
|
||||
client_body_temp_path /tmp/client_temp;
|
||||
proxy_temp_path /tmp/proxy_temp;
|
||||
fastcgi_temp_path /tmp/fastcgi_temp;
|
||||
uwsgi_temp_path /tmp/uwsgi_temp;
|
||||
scgi_temp_path /tmp/scgi_temp;
|
||||
|
||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'$status $body_bytes_sent "$http_referer" '
|
||||
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||
|
||||
access_log /tmp/nginx_access.log main;
|
||||
|
||||
# Performance
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
tcp_nodelay on;
|
||||
keepalive_timeout 65;
|
||||
types_hash_max_size 2048;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 10240;
|
||||
gzip_proxied expired no-cache no-store private auth;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/javascript application/json;
|
||||
gzip_disable "MSIE [1-6]\.";
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/xml text/javascript
|
||||
application/json application/javascript application/xml
|
||||
application/rss+xml application/atom+xml image/svg+xml;
|
||||
|
||||
# Proxy API requests to backend
|
||||
location /api {
|
||||
proxy_pass http://flowsint_api:5001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
# Security
|
||||
server_tokens off;
|
||||
|
||||
# Handle SPA routing - serve index.html for all routes
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
server {
|
||||
listen 8080;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
# Health check endpoint
|
||||
location /health {
|
||||
access_log off;
|
||||
return 200 "healthy\n";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
|
||||
# Static assets with aggressive caching
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
access_log off;
|
||||
}
|
||||
|
||||
# SPA fallback - serve index.html for all routes
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# No cache for index.html
|
||||
location = /index.html {
|
||||
expires -1;
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate";
|
||||
}
|
||||
|
||||
# Deny access to hidden files
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
access_log off;
|
||||
log_not_found off;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "flowsint-app",
|
||||
"productName": "Flowsint",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"license": "Apache-2.0",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"description": "Graph analysis and intelligence platform to help you discover hidden relationships and insights.",
|
||||
@@ -16,8 +16,10 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/react": "^3.0.79",
|
||||
"@dagrejs/dagre": "^1.1.4",
|
||||
"@hookform/resolvers": "^5.0.1",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@radix-ui/react-accordion": "^1.2.11",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.14",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.7",
|
||||
@@ -31,7 +33,7 @@
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-menubar": "^1.1.15",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.13",
|
||||
"@radix-ui/react-popover": "^1.1.14",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.7",
|
||||
"@radix-ui/react-radio-group": "^1.3.7",
|
||||
"@radix-ui/react-scroll-area": "^1.2.9",
|
||||
@@ -57,6 +59,7 @@
|
||||
"@tiptap/extension-horizontal-rule": "^2.12.0",
|
||||
"@tiptap/extension-image": "^2.12.0",
|
||||
"@tiptap/extension-link": "^2.12.0",
|
||||
"@tiptap/extension-mention": "2.12.0",
|
||||
"@tiptap/extension-placeholder": "^2.12.0",
|
||||
"@tiptap/extension-subscript": "^2.12.0",
|
||||
"@tiptap/extension-superscript": "^2.12.0",
|
||||
@@ -70,10 +73,12 @@
|
||||
"@tiptap/pm": "^2.12.0",
|
||||
"@tiptap/react": "^2.12.0",
|
||||
"@tiptap/starter-kit": "^2.12.0",
|
||||
"@tiptap/suggestion": "^3.15.3",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-webgl": "^0.18.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"@xyflow/react": "^12.6.4",
|
||||
"ai": "^6.0.77",
|
||||
"babel-plugin-react-compiler": "^19.1.0-rc.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -85,16 +90,16 @@
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"framer-motion": "^12.0.6",
|
||||
"input-otp": "^1.4.2",
|
||||
"leaflet": "^1.9.4",
|
||||
"lowlight": "^3.3.0",
|
||||
"lucide-react": "^0.511.0",
|
||||
"maplibre-gl": "^5.1.1",
|
||||
"marked": "^15.0.12",
|
||||
"next-themes": "^0.4.6",
|
||||
"pixi.js": "^8.14.1",
|
||||
"react-day-picker": "8.10.1",
|
||||
"react-force-graph-2d": "^1.27.1",
|
||||
"react-hook-form": "^7.56.4",
|
||||
"react-joyride": "^2.9.3",
|
||||
"react-map-gl": "^8.1.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-medium-image-zoom": "^5.2.14",
|
||||
"react-resizable-panels": "^3.0.2",
|
||||
@@ -108,6 +113,7 @@
|
||||
"usehooks-ts": "^3.1.1",
|
||||
"uuid": "^13.0.0",
|
||||
"vaul": "^1.1.2",
|
||||
"yaml": "^2.8.2",
|
||||
"zod": "^3.25.42",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
@@ -126,6 +132,7 @@
|
||||
"eslint-plugin-react": "^7.34.3",
|
||||
"graphology-types": "^0.24.8",
|
||||
"husky": "^9.1.7",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"postcss": "^8.4.35",
|
||||
"prettier": "^3.3.2",
|
||||
"react": "^19.2.0",
|
||||
|
||||
@@ -1,98 +1,4 @@
|
||||
import { fetchWithAuth } from './api'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
|
||||
export interface ChatResponse {
|
||||
content: string
|
||||
}
|
||||
|
||||
export class ChatService {
|
||||
private static instance: ChatService
|
||||
|
||||
public static getInstance(): ChatService {
|
||||
if (!ChatService.instance) {
|
||||
ChatService.instance = new ChatService()
|
||||
}
|
||||
return ChatService.instance
|
||||
}
|
||||
|
||||
async streamChat(
|
||||
chatId: string,
|
||||
prompt: string,
|
||||
onChunk: (content: string) => void,
|
||||
context?: any
|
||||
): Promise<string> {
|
||||
const token = useAuthStore.getState().token
|
||||
const API_URL = import.meta.env.VITE_API_URL
|
||||
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_URL}/api/chats/stream/${chatId}`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
prompt,
|
||||
context
|
||||
})
|
||||
})
|
||||
|
||||
if (response.status === 401) {
|
||||
useAuthStore.getState().logout()
|
||||
window.location.href = '/login'
|
||||
throw new Error('Session expired, login again.')
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
throw new Error(errorData.detail || `Error ${response.status}`)
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader()
|
||||
if (!reader) {
|
||||
console.error('No reader available')
|
||||
throw new Error('No reader available')
|
||||
}
|
||||
|
||||
let accumulatedContent = ''
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) {
|
||||
break
|
||||
}
|
||||
|
||||
const text = new TextDecoder().decode(value)
|
||||
const lines = text.split('\n')
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const data = line.slice(6)
|
||||
if (data === '[DONE]') {
|
||||
return accumulatedContent
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(data)
|
||||
if (parsed.content) {
|
||||
accumulatedContent += parsed.content
|
||||
onChunk(accumulatedContent)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error parsing SSE data:', data, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return accumulatedContent
|
||||
}
|
||||
}
|
||||
|
||||
export const chatService = ChatService.getInstance()
|
||||
|
||||
export const chatCRUDService = {
|
||||
get: async (): Promise<any> => {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { DefaultChatTransport } from 'ai'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL
|
||||
|
||||
export function createChatTransport(chatId: string) {
|
||||
return new DefaultChatTransport({
|
||||
api: `${API_URL}/api/chats/stream/${chatId}`,
|
||||
headers: (): Record<string, string> => {
|
||||
const token = useAuthStore.getState().token
|
||||
if (token) return { Authorization: `Bearer ${token}` }
|
||||
return {}
|
||||
},
|
||||
prepareSendMessagesRequest: ({ messages, body }) => {
|
||||
const lastUserMessage = [...messages].reverse().find((m) => m.role === 'user')
|
||||
const prompt = lastUserMessage?.parts
|
||||
?.filter((p): p is { type: 'text'; text: string } => p.type === 'text')
|
||||
.map((p) => p.text)
|
||||
.join('') ?? ''
|
||||
|
||||
const context = (body as Record<string, unknown> | undefined)?.context
|
||||
return {
|
||||
body: { prompt, ...(context ? { context } : {}) },
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -8,6 +8,8 @@ export interface CustomType {
|
||||
status: 'draft' | 'published' | 'archived'
|
||||
checksum?: string
|
||||
description?: string
|
||||
icon?: string
|
||||
color?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
@@ -16,6 +18,8 @@ export interface CustomTypeCreate {
|
||||
name: string
|
||||
schema: Record<string, any>
|
||||
description?: string
|
||||
icon?: string
|
||||
color?: string
|
||||
status?: 'draft' | 'published'
|
||||
}
|
||||
|
||||
@@ -23,6 +27,8 @@ export interface CustomTypeUpdate {
|
||||
name?: string
|
||||
schema?: Record<string, any>
|
||||
description?: string
|
||||
icon?: string
|
||||
color?: string
|
||||
status?: 'draft' | 'published' | 'archived'
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,18 @@ export const enricherService = {
|
||||
method: 'GET'
|
||||
})
|
||||
},
|
||||
getTemplates: async (): Promise<any> => {
|
||||
const url = '/api/enrichers/templates'
|
||||
return fetchWithAuth(url, {
|
||||
method: 'GET'
|
||||
})
|
||||
},
|
||||
getTemplateById: async (templateId: string): Promise<any> => {
|
||||
const url = `/api/enrichers/templates/${templateId}`
|
||||
return fetchWithAuth(url, {
|
||||
method: 'GET'
|
||||
})
|
||||
},
|
||||
launch: async (enricherName: string, body: BodyInit): Promise<any> => {
|
||||
return fetchWithAuth(`/api/enrichers/${enricherName}/launch`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -96,10 +96,18 @@ export const sketchService = {
|
||||
},
|
||||
executeImport: async (
|
||||
sketchId: string,
|
||||
entityMappings: Array<{ id: string; entity_type: string; include: boolean; label: string; data: Record<string, any> }>,
|
||||
entityMappings: Array<{
|
||||
id: string
|
||||
entity_type: string
|
||||
include: boolean
|
||||
nodeLabel: string
|
||||
node_id?: string
|
||||
data: Record<string, any>
|
||||
}>,
|
||||
edges: any
|
||||
): Promise<any> => {
|
||||
const formData = new FormData()
|
||||
formData.append('entity_mappings_json', JSON.stringify(entityMappings))
|
||||
formData.append('entity_mappings_json', JSON.stringify({ nodes: entityMappings, edges: edges }))
|
||||
|
||||
return fetchWithAuth(`/api/sketches/${sketchId}/import/execute`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { fetchWithAuth } from './api'
|
||||
import type { TemplateData } from '@/components/templates/template-schema'
|
||||
|
||||
export interface Template {
|
||||
id: string
|
||||
name: string
|
||||
category: string
|
||||
version: number
|
||||
content: TemplateData
|
||||
is_public: boolean
|
||||
owner_id: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface CreateTemplatePayload {
|
||||
name: string
|
||||
category: string
|
||||
version?: number
|
||||
content: TemplateData
|
||||
is_public?: boolean
|
||||
}
|
||||
|
||||
export interface UpdateTemplatePayload {
|
||||
name?: string
|
||||
category?: string
|
||||
version?: number
|
||||
content?: TemplateData
|
||||
is_public?: boolean
|
||||
}
|
||||
|
||||
export interface TestTemplateResponse {
|
||||
success: boolean
|
||||
data?: Record<string, unknown>
|
||||
error?: string
|
||||
duration_ms: number
|
||||
status_code?: number
|
||||
url: string
|
||||
raw_results?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface GenerateTemplateResponse {
|
||||
yaml_content: string
|
||||
}
|
||||
|
||||
export const templateService = {
|
||||
getAll: async (): Promise<Template[]> => {
|
||||
return fetchWithAuth('/api/enrichers/templates', {
|
||||
method: 'GET'
|
||||
})
|
||||
},
|
||||
|
||||
getById: async (templateId: string): Promise<Template> => {
|
||||
return fetchWithAuth(`/api/enrichers/templates/${templateId}`, {
|
||||
method: 'GET'
|
||||
})
|
||||
},
|
||||
|
||||
create: async (payload: CreateTemplatePayload): Promise<Template> => {
|
||||
return fetchWithAuth('/api/enrichers/templates', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
},
|
||||
|
||||
update: async (templateId: string, payload: UpdateTemplatePayload): Promise<Template> => {
|
||||
return fetchWithAuth(`/api/enrichers/templates/${templateId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
},
|
||||
|
||||
delete: async (templateId: string): Promise<void> => {
|
||||
return fetchWithAuth(`/api/enrichers/templates/${templateId}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
},
|
||||
|
||||
test: async (templateId: string, inputValue: string): Promise<TestTemplateResponse> => {
|
||||
return fetchWithAuth(`/api/enrichers/templates/${templateId}/test`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ input_value: inputValue })
|
||||
})
|
||||
},
|
||||
|
||||
testContent: async (inputValue: string, content: TemplateData): Promise<TestTemplateResponse> => {
|
||||
return fetchWithAuth('/api/enrichers/templates/test', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ input_value: inputValue, content })
|
||||
})
|
||||
},
|
||||
|
||||
generate: async (prompt: string): Promise<GenerateTemplateResponse> => {
|
||||
return fetchWithAuth('/api/enrichers/templates/generate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ prompt })
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
PlusIcon,
|
||||
Trash2,
|
||||
Save,
|
||||
ChevronDown,
|
||||
ChevronsRight,
|
||||
ExternalLink,
|
||||
@@ -30,11 +29,13 @@ import {
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { queryKeys } from '@/api/query-keys'
|
||||
import { useCreateAnalysis } from '@/hooks/use-create-analysis'
|
||||
import { SaveStatusBadge } from '@/components/shared/save-status-badge'
|
||||
|
||||
interface AnalysisEditorProps {
|
||||
// Core data
|
||||
analysis: Analysis | null
|
||||
investigationId: string
|
||||
ownerId?: string
|
||||
|
||||
// Callbacks
|
||||
onAnalysisUpdate?: (analysis: Analysis) => void
|
||||
@@ -83,7 +84,6 @@ export const AnalysisEditor = ({
|
||||
type: string
|
||||
}
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// State/refs for editor
|
||||
const editorContentRef = useRef<any>('')
|
||||
const [titleValue, setTitleValue] = useState('')
|
||||
@@ -267,13 +267,13 @@ export const AnalysisEditor = ({
|
||||
<div className={`flex flex-col h-full w-full overflow-y-auto bg-card ${className}`}>
|
||||
{/* Header */}
|
||||
{showHeader && (
|
||||
<div className="bg-card/70 sticky top-0 z-10 backdrop-blur-sm h-11 w-full flex items-center justify-between">
|
||||
<div className="bg-card/70 sticky top-0 z-10 backdrop-blur-sm h-10 border-b w-full flex items-center justify-between">
|
||||
<div className="flex items-center justify-between p-3 w-full">
|
||||
{/* Left section with navigation and title */}
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
{showNavigation && type !== 'analysis' && (
|
||||
<Button className="h-8 w-8" variant="ghost" onClick={toggleAnalysis}>
|
||||
<ChevronsRight />
|
||||
<ChevronsRight className="opacity-70" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -283,7 +283,7 @@ export const AnalysisEditor = ({
|
||||
<PopoverTrigger asChild>
|
||||
<div>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
<ChevronDown className="h-4 w-4 opacity-70" />
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
@@ -340,7 +340,7 @@ export const AnalysisEditor = ({
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="text-md font-medium cursor-pointer hover:text-primary truncate min-w-0 flex-1"
|
||||
className={`text-md font-medium truncate min-w-0 flex-1 ${'cursor-pointer hover:text-primary'}`}
|
||||
onClick={() => setIsEditingTitle(true)}
|
||||
>
|
||||
{titleValue || 'Untitled Analysis'}
|
||||
@@ -352,37 +352,12 @@ export const AnalysisEditor = ({
|
||||
{/* Action buttons */}
|
||||
{showActions && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={handleManualSave}
|
||||
disabled={!analysis || saveMutation.isPending}
|
||||
title={
|
||||
saveStatus === 'saved'
|
||||
? 'Saved'
|
||||
: saveStatus === 'saving'
|
||||
? 'Saving...'
|
||||
: 'Save'
|
||||
}
|
||||
className="h-8 w-8 relative"
|
||||
>
|
||||
<Save className="w-4 h-4" strokeWidth={1.5} />
|
||||
{saveStatus === 'saved' && (
|
||||
<div className="absolute top-1 right-1 w-2 h-2 bg-green-500 rounded-full" />
|
||||
)}
|
||||
{saveStatus === 'saving' && (
|
||||
<div className="absolute top-1 right-1 w-2 h-2 bg-yellow-500 rounded-full animate-pulse" />
|
||||
)}
|
||||
{saveStatus === 'unsaved' && (
|
||||
<div className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<SaveStatusBadge status={saveStatus} />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div>
|
||||
<Button size="icon" variant="ghost" className="h-8 w-8">
|
||||
<MoreVertical className="w-4 h-4" strokeWidth={1.5} />
|
||||
<MoreVertical className="w-4 h-4 opacity-70" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -402,6 +377,7 @@ export const AnalysisEditor = ({
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{type !== 'analysis' && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => createMutation.mutate()}
|
||||
@@ -454,10 +430,9 @@ export const AnalysisEditor = ({
|
||||
className="w-full h-full"
|
||||
editorContentClassName="p-5 min-h-[300px]"
|
||||
output="json"
|
||||
placeholder="Enter your analysis..."
|
||||
placeholder={'Enter your analysis...'}
|
||||
autofocus={true}
|
||||
showToolbar={showToolbar}
|
||||
editable={true}
|
||||
editorClassName="focus:outline-hidden"
|
||||
onEditorReady={setEditor}
|
||||
/>
|
||||
|
||||
@@ -7,3 +7,4 @@ export * from './selection'
|
||||
export * from './unset-all-marks'
|
||||
export * from './reset-marks-on-enter'
|
||||
export * from './file-handler'
|
||||
export * from './mention'
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from './mention'
|
||||
@@ -0,0 +1,120 @@
|
||||
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ItemType } from '@/stores/node-display-settings'
|
||||
import { useIcon } from '@/hooks/use-icon'
|
||||
|
||||
export interface MentionItem {
|
||||
nodeLabel: string
|
||||
nodeType: ItemType
|
||||
nodeImage: string | null
|
||||
nodeIcon: string | any | null
|
||||
nodeId: string
|
||||
}
|
||||
|
||||
export interface MentionListProps {
|
||||
items: MentionItem[]
|
||||
command: (item: MentionItem) => void
|
||||
}
|
||||
|
||||
export interface MentionListRef {
|
||||
onKeyDown: (props: { event: KeyboardEvent }) => boolean
|
||||
}
|
||||
|
||||
const MentionList = forwardRef<MentionListRef, MentionListProps>((props, ref) => {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
|
||||
const selectItem = (index: number) => {
|
||||
const item = props.items[index]
|
||||
|
||||
if (item) {
|
||||
props.command(item)
|
||||
}
|
||||
}
|
||||
|
||||
const upHandler = () => {
|
||||
setSelectedIndex((selectedIndex + props.items.length - 1) % props.items.length)
|
||||
}
|
||||
|
||||
const downHandler = () => {
|
||||
setSelectedIndex((selectedIndex + 1) % props.items.length)
|
||||
}
|
||||
|
||||
const enterHandler = () => {
|
||||
selectItem(selectedIndex)
|
||||
}
|
||||
|
||||
useEffect(() => setSelectedIndex(0), [props.items])
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
onKeyDown: ({ event }: { event: KeyboardEvent }) => {
|
||||
if (event.key === 'ArrowUp') {
|
||||
upHandler()
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
downHandler()
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
enterHandler()
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="z-50 min-w-32 max-w-[400px] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md">
|
||||
{props.items.length ? (
|
||||
<div className="overflow-y-auto max-h-[300px]">
|
||||
{props.items.map((item, index) => (
|
||||
<MentionListItem
|
||||
item={item}
|
||||
index={index}
|
||||
selectedIndex={selectedIndex}
|
||||
selectItem={selectItem}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-2 py-1.5 text-sm text-muted-foreground">No results</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
type MentionItemProps = {
|
||||
item: MentionItem
|
||||
index: number
|
||||
selectedIndex: number
|
||||
selectItem: (index: number) => void
|
||||
}
|
||||
const MentionListItem = ({ item, index, selectedIndex, selectItem }: MentionItemProps) => {
|
||||
const SourceIcon = useIcon(item.nodeType, {
|
||||
// @ts-ignore
|
||||
nodeIcon: item.nodeIcon,
|
||||
nodeImage: item.nodeImage
|
||||
})
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'relative flex w-full justify-start cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors',
|
||||
'hover:bg-accent hover:text-accent-foreground',
|
||||
'focus:bg-accent focus:text-accent-foreground',
|
||||
index === selectedIndex && 'bg-accent text-accent-foreground'
|
||||
)}
|
||||
key={index}
|
||||
onClick={() => selectItem(index)}
|
||||
type="button"
|
||||
>
|
||||
{SourceIcon && <SourceIcon size={14} />}
|
||||
<span className="flex-1 text-left truncate text-ellipsis">{item.nodeLabel}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
MentionList.displayName = 'MentionList'
|
||||
|
||||
export default MentionList
|
||||
@@ -0,0 +1,256 @@
|
||||
import { computePosition, flip, shift } from '@floating-ui/dom'
|
||||
import { posToDOMRect, ReactRenderer } from '@tiptap/react'
|
||||
import type { Editor } from '@tiptap/react'
|
||||
import TiptapMention from '@tiptap/extension-mention'
|
||||
import type { SuggestionOptions, SuggestionProps } from '@tiptap/suggestion'
|
||||
import { mergeAttributes } from '@tiptap/core'
|
||||
import { ReactNodeViewRenderer } from '@tiptap/react'
|
||||
import { NodeViewWrapper } from '@tiptap/react'
|
||||
import MentionList from './mention-list'
|
||||
import type { MentionListRef, MentionItem } from './mention-list'
|
||||
import { memo, useCallback } from 'react'
|
||||
import { useIcon } from '@/hooks/use-icon'
|
||||
import { useNodesDisplaySettings } from '@/stores/node-display-settings'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { GRAPH_COLORS } from '@/components/sketches/graph'
|
||||
import { useGraphStore } from '@/stores/graph-store'
|
||||
import { useGraphControls } from '@/stores/graph-controls-store'
|
||||
import { GraphNode } from '@/types'
|
||||
|
||||
function hexWithOpacity(hex: string, opacity: number) {
|
||||
hex = hex.replace('#', '')
|
||||
|
||||
if (hex.length === 3) {
|
||||
hex = hex
|
||||
.split('')
|
||||
.map((c) => c + c)
|
||||
.join('')
|
||||
}
|
||||
|
||||
const alpha = Math.round(opacity * 255)
|
||||
.toString(16)
|
||||
.padStart(2, '0')
|
||||
.toUpperCase()
|
||||
|
||||
return `#${hex}${alpha}`
|
||||
}
|
||||
|
||||
const getMentionItemsFromNodes = (): MentionItem[] => {
|
||||
const nodes = useGraphStore.getState().nodes
|
||||
return nodes
|
||||
.map((node) => {
|
||||
return {
|
||||
nodeLabel: node.nodeLabel,
|
||||
nodeType: node.nodeType,
|
||||
nodeId: node.id,
|
||||
nodeImage: node.nodeImage,
|
||||
nodeIcon: node.nodeIcon
|
||||
}
|
||||
})
|
||||
.filter((item): item is MentionItem => item !== null)
|
||||
}
|
||||
|
||||
const updatePosition = (editor: Editor, element: HTMLElement) => {
|
||||
const virtualElement = {
|
||||
getBoundingClientRect: () =>
|
||||
posToDOMRect(editor.view, editor.state.selection.from, editor.state.selection.to)
|
||||
}
|
||||
|
||||
computePosition(virtualElement, element, {
|
||||
placement: 'bottom-start',
|
||||
strategy: 'absolute',
|
||||
middleware: [shift(), flip()]
|
||||
}).then(({ x, y, strategy }) => {
|
||||
element.style.width = 'max-content'
|
||||
element.style.position = strategy
|
||||
element.style.left = `${x}px`
|
||||
element.style.top = `${y}px`
|
||||
})
|
||||
}
|
||||
|
||||
// Composant React custom pour le rendu de la mention
|
||||
const MentionComponent = memo((props: any) => {
|
||||
const attrs = props.node.attrs
|
||||
const { nodeId, nodeType, nodeIcon, nodeImage, nodeLabel } = attrs
|
||||
|
||||
const SourceIcon = useIcon(nodeType, {
|
||||
nodeIcon,
|
||||
nodeImage
|
||||
})
|
||||
const colors = useNodesDisplaySettings((s) => s.colors)
|
||||
const color = nodeType
|
||||
? // @ts-ignore
|
||||
(colors[nodeType] ?? GRAPH_COLORS.NODE_DEFAULT)
|
||||
: GRAPH_COLORS.NODE_DEFAULT
|
||||
const centerOnNode = useGraphControls((state) => state.centerOnNode)
|
||||
const setCurrentNodeFromId = useGraphStore((state) => state.setCurrentNodeFromId)
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
const currentNode = setCurrentNodeFromId(nodeId)
|
||||
if (currentNode) {
|
||||
const { x, y } = currentNode
|
||||
// Auto-zoom if enabled and node has coordinates
|
||||
if (x !== undefined && y !== undefined) {
|
||||
setTimeout(() => {
|
||||
centerOnNode(x, y)
|
||||
}, 200)
|
||||
}
|
||||
}
|
||||
}, [nodeId, centerOnNode, setCurrentNodeFromId])
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="inline-flex">
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className="px-.5 gap-1 pr-1 py-.5 flex items-center cursor-pointer text-xs rounded-full text-foreground"
|
||||
style={{
|
||||
backgroundColor: hexWithOpacity(color, 0.2),
|
||||
//@ts-ignore
|
||||
border: `solid 1px ${hexWithOpacity(color, 0.5)}`
|
||||
}}
|
||||
>
|
||||
{SourceIcon && <SourceIcon size={12} className="opacity-60" />}
|
||||
{nodeLabel}
|
||||
</button>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
})
|
||||
|
||||
export const Mention = TiptapMention.extend({
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(MentionComponent)
|
||||
},
|
||||
addAttributes() {
|
||||
return {
|
||||
nodeLabel: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute('data-label'),
|
||||
renderHTML: (attributes) => {
|
||||
if (!attributes.label) {
|
||||
return {}
|
||||
}
|
||||
return {
|
||||
'data-label': attributes.nodeLabel
|
||||
}
|
||||
}
|
||||
},
|
||||
nodeType: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute('data-type'),
|
||||
renderHTML: (attributes) => {
|
||||
if (!attributes.nodeType) {
|
||||
return {}
|
||||
}
|
||||
return {
|
||||
'data-type': attributes.nodeType
|
||||
}
|
||||
}
|
||||
},
|
||||
nodeImage: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute('data-image'),
|
||||
renderHTML: (attributes) => {
|
||||
if (!attributes.nodeImage) {
|
||||
return {}
|
||||
}
|
||||
return {
|
||||
'data-image': attributes.nodeImage
|
||||
}
|
||||
}
|
||||
},
|
||||
nodeIcon: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute('data-icon'),
|
||||
renderHTML: (attributes) => {
|
||||
if (!attributes.nodeIcon) {
|
||||
return {}
|
||||
}
|
||||
return {
|
||||
'data-icon': attributes.nodeIcon
|
||||
}
|
||||
}
|
||||
},
|
||||
nodeId: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute('data-node-id'),
|
||||
renderHTML: (attributes) => {
|
||||
if (!attributes.nodeId) {
|
||||
return {}
|
||||
}
|
||||
return {
|
||||
'data-node-id': attributes.nodeId
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
renderHTML({ node, HTMLAttributes }) {
|
||||
const children = []
|
||||
children.push([
|
||||
'span',
|
||||
{ class: 'mention-label' },
|
||||
`${this.options.suggestion.char}${node.attrs.label}`
|
||||
])
|
||||
return ['span', mergeAttributes({ class: 'mention' }, HTMLAttributes), ...children]
|
||||
}
|
||||
}).configure({
|
||||
deleteTriggerWithBackspace: true,
|
||||
suggestion: {
|
||||
char: '@',
|
||||
items: ({ query }: { query: string }) => {
|
||||
const items = getMentionItemsFromNodes()
|
||||
return items
|
||||
.filter((item) => item.nodeLabel.toLowerCase().includes(query.toLowerCase()))
|
||||
.slice(0, 10)
|
||||
},
|
||||
|
||||
render: () => {
|
||||
let component: ReactRenderer<MentionListRef> | undefined
|
||||
|
||||
return {
|
||||
onStart: (props: SuggestionProps) => {
|
||||
component = new ReactRenderer(MentionList, {
|
||||
props,
|
||||
editor: props.editor
|
||||
})
|
||||
if (!props.clientRect) {
|
||||
return
|
||||
}
|
||||
const element = component.element as HTMLElement
|
||||
element.style.position = 'absolute'
|
||||
element.style.zIndex = '9999'
|
||||
document.body.appendChild(element)
|
||||
updatePosition(props.editor, element)
|
||||
},
|
||||
|
||||
onUpdate(props: SuggestionProps) {
|
||||
if (!component) return
|
||||
component.updateProps(props)
|
||||
if (!props.clientRect) {
|
||||
return
|
||||
}
|
||||
const element = component.element as HTMLElement
|
||||
updatePosition(props.editor, element)
|
||||
},
|
||||
|
||||
onKeyDown(props: { event: KeyboardEvent }) {
|
||||
if (props.event.key === 'Escape') {
|
||||
component?.destroy()
|
||||
return true
|
||||
}
|
||||
|
||||
return component?.ref?.onKeyDown(props) ?? false
|
||||
},
|
||||
|
||||
onExit() {
|
||||
if (!component) return
|
||||
component.element.remove()
|
||||
component.destroy()
|
||||
}
|
||||
}
|
||||
}
|
||||
} as Partial<SuggestionOptions>
|
||||
})
|
||||
|
||||
export default Mention
|
||||
@@ -16,7 +16,8 @@ import {
|
||||
Color,
|
||||
UnsetAllMarks,
|
||||
ResetMarksOnEnter,
|
||||
FileHandler
|
||||
FileHandler,
|
||||
Mention
|
||||
} from '../extensions'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { fileToBase64, getOutput, randomId } from '../utils'
|
||||
@@ -166,6 +167,7 @@ const createExtensions = ({
|
||||
HorizontalRule,
|
||||
ResetMarksOnEnter,
|
||||
CodeBlockLowlight,
|
||||
Mention,
|
||||
Placeholder.configure({ placeholder: () => placeholder })
|
||||
]
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ export const MinimalTiptapEditor = React.forwardRef<HTMLDivElement, MinimalTipta
|
||||
className={cn('flex hide-scrollbar w-full flex-col', className)}
|
||||
>
|
||||
{showToolbar && (
|
||||
<div className="border-t sticky top-11 bg-card/90 z-20 backdrop-blur-md">
|
||||
<div className="sticky top-10 bg-card/90 z-20 backdrop-blur-md">
|
||||
<Toolbar editor={editor} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -130,7 +130,7 @@ const ChatItem = ({
|
||||
{formatDistanceToNow(chat.last_updated_at, { addSuffix: true })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between flex-shrink-0 ml-2">
|
||||
<div className="flex items-center justify-between shrink-0 ml-2">
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={handleDeleteChat}>
|
||||
<Trash className="h-3 w-3" />
|
||||
</Button>
|
||||
|
||||
@@ -3,25 +3,25 @@ import { Badge } from '@/components/ui/badge'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { XIcon, ArrowUp } from 'lucide-react'
|
||||
import { useGraphStore } from '@/stores/graph-store'
|
||||
import { useRef, useEffect, memo } from 'react'
|
||||
import { useRef, useEffect, memo, useState } from 'react'
|
||||
import { useNodesDisplaySettings } from '@/stores/node-display-settings'
|
||||
import { ChatContextFormat } from '@/types'
|
||||
|
||||
interface ChatPanelProps {
|
||||
customPrompt: string
|
||||
setCustomPrompt: (prompt: string) => void
|
||||
handleCustomPrompt: (editorValue: any) => void
|
||||
isAiLoading: boolean
|
||||
editorValue: any
|
||||
onSend: (text: string, context?: string[]) => void
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
export const ChatPanel = ({
|
||||
customPrompt,
|
||||
setCustomPrompt,
|
||||
handleCustomPrompt,
|
||||
isAiLoading,
|
||||
editorValue
|
||||
}: ChatPanelProps) => {
|
||||
const selectedNodes = useGraphStore((s) => s.selectedNodes)
|
||||
const formatContext = (context: ChatContextFormat[]): string[] => {
|
||||
return context.map((item) => {
|
||||
if (item.type === 'relation') return `${item.fromLabel} -> ${item.label} -> ${item.toLabel}`
|
||||
return item.nodeLabel
|
||||
})
|
||||
}
|
||||
|
||||
export const ChatPanel = ({ onSend, isLoading }: ChatPanelProps) => {
|
||||
const [input, setInput] = useState('')
|
||||
const selectedNodes = useGraphStore((s) => s.selectedNodesWithEdgesAsList)
|
||||
const clearSelectedNodes = useGraphStore((s) => s.clearSelectedNodes)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
@@ -40,11 +40,12 @@ export const ChatPanel = ({
|
||||
textareaRef.current.style.height = 'auto'
|
||||
textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 120)}px`
|
||||
}
|
||||
}, [customPrompt])
|
||||
}, [input])
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (customPrompt.trim() && !isAiLoading) {
|
||||
handleCustomPrompt(editorValue)
|
||||
if (input.trim() && !isLoading) {
|
||||
onSend(input.trim(), selectedNodes.length > 0 ? formatContext(selectedNodes) : undefined)
|
||||
setInput('')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,21 +80,21 @@ export const ChatPanel = ({
|
||||
ref={textareaRef}
|
||||
autoFocus
|
||||
placeholder="Ask me anything about your investigation..."
|
||||
value={customPrompt}
|
||||
onChange={(e) => setCustomPrompt(e.target.value)}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={`
|
||||
min-h-[44px] max-h-[120px] resize-none
|
||||
border border-border bg-background
|
||||
min-h-11 max-h-[120px] resize-none
|
||||
border border-border bg-background
|
||||
focus:ring-2 focus:ring-primary/20 focus:border-primary
|
||||
transition-all duration-200
|
||||
pr-12 py-3 px-4 rounded-xl
|
||||
`}
|
||||
disabled={isAiLoading}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!customPrompt.trim() || isAiLoading}
|
||||
disabled={!input.trim() || isLoading}
|
||||
size="icon"
|
||||
variant={'ghost'}
|
||||
className={`
|
||||
@@ -101,48 +102,57 @@ export const ChatPanel = ({
|
||||
h-8 w-8 rounded-full
|
||||
transition-all duration-200
|
||||
${
|
||||
customPrompt.trim() && !isAiLoading
|
||||
input.trim() && !isLoading
|
||||
? 'text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{isAiLoading ? (
|
||||
{isLoading ? (
|
||||
<div className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<ArrowUp className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* <div className="text-xs text-muted-foreground flex items-center gap-2">
|
||||
{isAiLoading && (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 bg-primary rounded-full animate-pulse" />
|
||||
<span>Processing...</span>
|
||||
</div>
|
||||
)}
|
||||
</div> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const ContextList = memo(({ context }: { context: any }) => {
|
||||
export const ContextList = memo(({ context }: { context: ChatContextFormat[] }) => {
|
||||
const colors = useNodesDisplaySettings((s) => s.colors)
|
||||
return (
|
||||
<div className="flex flex-nowrap overflow-x-auto hide-scrollbar gap-1.5 items-center px-1">
|
||||
{context.map((item: any, index: number) => {
|
||||
const color = colors[item?.data?.type || item?.type]
|
||||
{context.map((item: ChatContextFormat, index: number) => {
|
||||
if (item.type === 'node') {
|
||||
const color = item.nodeColor ?? colors[item.nodeType]
|
||||
return (
|
||||
<Badge
|
||||
key={index}
|
||||
variant="secondary"
|
||||
className="flex items-center border border-border gap-1.5 text-xs"
|
||||
>
|
||||
<span style={{ background: color }} className="w-1.5 h-1.5 rounded-full" />
|
||||
{item.nodeLabel || 'Unknown'}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
const fromColor = item.fromColor ?? colors[item.fromType]
|
||||
const toColor = item.toColor ?? colors[item.toType]
|
||||
|
||||
return (
|
||||
<Badge
|
||||
key={index}
|
||||
variant="secondary"
|
||||
className="flex items-center border border-border gap-1.5 text-xs"
|
||||
>
|
||||
<span style={{ background: color }} className="w-1.5 h-1.5 rounded-full" />
|
||||
{item.data?.label || 'Unknown'}
|
||||
<span style={{ background: fromColor }} className="w-1.5 h-1.5 rounded-full" />
|
||||
{item.fromLabel || 'Unknown'} {'->'}
|
||||
<span style={{ background: toColor }} className="w-1.5 h-1.5 rounded-full" />
|
||||
{item.toLabel || 'Unknown'}
|
||||
</Badge>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import { useChat } from '@/hooks/use-chat'
|
||||
import { memo, useEffect, useState, useRef } from 'react'
|
||||
import { ChatPanel, ContextList } from './chat-prompt'
|
||||
import { memo, useEffect, useRef, useState } from 'react'
|
||||
import { ChatPanel } from './chat-prompt'
|
||||
import { Button } from '../ui/button'
|
||||
import { X, Plus, History } from 'lucide-react'
|
||||
import { useKeyboardShortcut } from '@/hooks/use-keyboard-shortcut'
|
||||
import { Card } from '../ui/card'
|
||||
import { useLayoutStore } from '@/stores/layout-store'
|
||||
import { useChatState } from '@/stores/use-chat-store'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { chatCRUDService } from '@/api/chat-service'
|
||||
import { ChatMessage } from '@/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { MemoizedMarkdown } from './memoized-markdown'
|
||||
import { ChatSkeleton } from './chat-skeleton'
|
||||
@@ -17,47 +13,27 @@ import { ResizableChat } from './resizable-chat'
|
||||
import ChatHistory from './chat-history'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip'
|
||||
import { CopyButton } from '../copy'
|
||||
import type { UIMessage } from 'ai'
|
||||
|
||||
function FloatingChat() {
|
||||
const [editorValue, setEditorValue] = useState<any>('')
|
||||
const bottomRef = useRef<HTMLDivElement>(null)
|
||||
const currentChatId = useChatState((s) => s.currentChatId)
|
||||
const isOpenChat = useLayoutStore((s) => s.isOpenChat)
|
||||
const toggleChat = useLayoutStore((s) => s.toggleChat)
|
||||
const closeChat = useLayoutStore((s) => s.closeChat)
|
||||
const messages = useChatState((s) => s.messages)
|
||||
const setMessages = useChatState((s) => s.setMessages)
|
||||
const [view, setView] = useState<'chat' | 'history'>('chat')
|
||||
|
||||
const { data: chat, isLoading } = useQuery({
|
||||
queryKey: ['chat', currentChatId],
|
||||
enabled: Boolean(currentChatId) && isOpenChat,
|
||||
queryFn: () => chatCRUDService.getById(currentChatId as string)
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setEditorValue('')
|
||||
setMessages([])
|
||||
}, [currentChatId, setMessages])
|
||||
|
||||
useEffect(() => {
|
||||
if (chat?.messages) {
|
||||
setMessages(chat.messages)
|
||||
}
|
||||
}, [chat, setMessages])
|
||||
|
||||
// Chat hook
|
||||
const {
|
||||
isAiLoading,
|
||||
customPrompt,
|
||||
setCustomPrompt,
|
||||
handleCustomPrompt,
|
||||
messages,
|
||||
status,
|
||||
sendMessage,
|
||||
currentChat,
|
||||
isLoadingChat,
|
||||
createNewChat,
|
||||
deleteChatMutation
|
||||
} = useChat({
|
||||
onContentUpdate: setEditorValue,
|
||||
onSuccess: () => {}
|
||||
})
|
||||
deleteChatMutation,
|
||||
currentChatId
|
||||
} = useChat()
|
||||
|
||||
const isStreaming = status === 'streaming' || status === 'submitted'
|
||||
|
||||
const { isMac } = useKeyboardShortcut({
|
||||
key: 'e',
|
||||
@@ -71,24 +47,18 @@ function FloatingChat() {
|
||||
callback: closeChat
|
||||
})
|
||||
|
||||
const handlecreateNewChat = () => {
|
||||
const handleCreateNewChat = () => {
|
||||
createNewChat()
|
||||
setView('chat')
|
||||
}
|
||||
|
||||
// Check if there's content in the editor
|
||||
const hasContent =
|
||||
editorValue &&
|
||||
(typeof editorValue === 'string'
|
||||
? editorValue.trim() !== ''
|
||||
: editorValue.content && editorValue.content.length > 0)
|
||||
const hasMessages = messages.length > 0
|
||||
|
||||
useEffect(() => {
|
||||
if (bottomRef.current) {
|
||||
bottomRef.current.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
}, [messages, editorValue])
|
||||
}, [messages])
|
||||
|
||||
const keyboardShortcut = isMac ? '⌘+E' : 'Ctrl+E'
|
||||
|
||||
@@ -105,7 +75,6 @@ function FloatingChat() {
|
||||
variant="outline"
|
||||
onClick={toggleChat}
|
||||
>
|
||||
{/* <Sparkles className='!h-5 !w-5' /> */}
|
||||
<img src="/icon.png" alt="Flowsint" className="h-12 w-12 object-contain" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -123,18 +92,18 @@ function FloatingChat() {
|
||||
|
||||
{/* Chat Panel */}
|
||||
{isOpenChat && (
|
||||
<div className="fixed bottom-12 overflow-hidden rounded-2xl right-4 shadow z-21">
|
||||
<div className="fixed bottom-12 overflow-hidden rounded-2xl right-4 shadow-xl z-50">
|
||||
<ResizableChat minWidth={400} minHeight={400} maxWidth={800} maxHeight={800}>
|
||||
<Card className="overflow-hidden !backdrop-blur bg-background/90 rounded-2xl gap-0 py-0 h-full">
|
||||
<Card className="overflow-hidden backdrop-blur! bg-background/90 rounded-2xl gap-0 py-0 h-full">
|
||||
{view === 'history' ? (
|
||||
<ChatHistory
|
||||
setView={setView}
|
||||
deleteChatMutation={deleteChatMutation}
|
||||
handleCreateNewChat={handlecreateNewChat}
|
||||
handleCreateNewChat={handleCreateNewChat}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between p-3 border-b">
|
||||
<div className="flex items-center justify-between h-10 p-3 border-b">
|
||||
<div className="flex w-full items-center justify-between gap-1">
|
||||
<div className="flex items-center gap-2 truncate text-ellipsis">
|
||||
<Button
|
||||
@@ -142,13 +111,13 @@ function FloatingChat() {
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
//@ts-ignore
|
||||
onClick={handlecreateNewChat}
|
||||
onClick={handleCreateNewChat}
|
||||
title="Create new chat"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
<span className="text-sm opacity-60 truncate text-ellipsis">
|
||||
{chat?.title}
|
||||
{currentChat?.title}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -171,36 +140,23 @@ function FloatingChat() {
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex flex-col flex-1 overflow-auto">
|
||||
{isLoading ? (
|
||||
{isLoadingChat ? (
|
||||
<ChatSkeleton />
|
||||
) : hasMessages ? (
|
||||
<div className="grow p-4 flex flex-col gap-2">
|
||||
{messages.map((message: ChatMessage) => (
|
||||
{messages.map((message: UIMessage) => (
|
||||
<ChatMessageComponent key={message.id} message={message} />
|
||||
))}
|
||||
{hasContent && (
|
||||
<ChatMessageComponent
|
||||
key="draft"
|
||||
message={{
|
||||
content: editorValue,
|
||||
is_bot: true,
|
||||
id: new Date().getTime().toString(),
|
||||
created_at: new Date().toISOString(),
|
||||
chatId: currentChatId || undefined
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
) : null}
|
||||
{!hasMessages && !hasContent && !isLoading && (
|
||||
{!hasMessages && !isLoadingChat && (
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-8 text-center text-muted-foreground">
|
||||
{!currentChatId ? (
|
||||
// No currentChatId - Empty state with sparkles
|
||||
<>
|
||||
<div className="relative mb-6">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-primary/20 to-primary/10 rounded-full blur-xl animate-pulse"></div>
|
||||
<div className="relative bg-gradient-to-br from-primary/10 to-primary/5 rounded-full p-4 border border-primary/20">
|
||||
<div className="absolute inset-0 bg-linear-to-r from-primary/20 to-primary/10 rounded-full blur-xl animate-pulse"></div>
|
||||
<div className="relative bg-linear-to-br from-primary/10 to-primary/5 rounded-full p-4 border border-primary/20">
|
||||
<img
|
||||
src="/icon.png"
|
||||
alt="Flowsint"
|
||||
@@ -221,22 +177,15 @@ function FloatingChat() {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handlecreateNewChat}
|
||||
className="bg-gradient-to-r from-primary/10 to-primary/5 border-primary/20 hover:from-primary/20 hover:to-primary/10"
|
||||
onClick={handleCreateNewChat}
|
||||
className="bg-linear-to-r from-primary/10 to-primary/5 border-primary/20 hover:from-primary/20 hover:to-primary/10"
|
||||
>
|
||||
Start your first chat
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
// Has currentChatId but no messages - Examples of things to ask
|
||||
<>
|
||||
{/* <div className="relative mb-6">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-primary/20 to-primary/10 rounded-full blur-xl animate-pulse"></div>
|
||||
<div className="relative bg-gradient-to-br from-primary/10 to-primary/5 rounded-full p-6 border border-primary/20">
|
||||
<Sparkles className="h-8 w-8 text-primary/60" />
|
||||
</div>
|
||||
</div> */}
|
||||
<div className="space-y-2 max-w-sm">
|
||||
<h3 className="text-lg font-semibold text-foreground">
|
||||
Start your conversation with{' '}
|
||||
@@ -255,14 +204,6 @@ function FloatingChat() {
|
||||
<li>• "Summarize the key findings from this investigation"</li>
|
||||
</ul>
|
||||
</div>
|
||||
{/* <div className="text-xs opacity-60 space-y-1">
|
||||
<p className="font-medium">Data Exploration:</p>
|
||||
<ul className="space-y-1 text-left">
|
||||
<li>• "Find all IP addresses related to this domain"</li>
|
||||
<li>• "Show me all crypto wallets connected to this address"</li>
|
||||
<li>• "What social media accounts are linked to this email?"</li>
|
||||
</ul>
|
||||
</div> */}
|
||||
<div className="text-xs space-y-1">
|
||||
<p className="font-medium text-left">Investigation Help</p>
|
||||
<ul className="space-y-1 text-left opacity-60">
|
||||
@@ -278,13 +219,7 @@ function FloatingChat() {
|
||||
)}
|
||||
</div>
|
||||
<div className="border-t">
|
||||
<ChatPanel
|
||||
customPrompt={customPrompt}
|
||||
setCustomPrompt={setCustomPrompt}
|
||||
handleCustomPrompt={handleCustomPrompt}
|
||||
isAiLoading={isAiLoading}
|
||||
editorValue={editorValue}
|
||||
/>
|
||||
<ChatPanel onSend={sendMessage} isLoading={isStreaming} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -296,20 +231,29 @@ function FloatingChat() {
|
||||
)
|
||||
}
|
||||
|
||||
const ChatMessageComponent = ({ message }: { message: ChatMessage }) => {
|
||||
if (message.is_bot)
|
||||
function getMessageText(message: UIMessage): string {
|
||||
return message.parts
|
||||
.filter((p): p is { type: 'text'; text: string } => p.type === 'text')
|
||||
.map((p) => p.text)
|
||||
.join('')
|
||||
}
|
||||
|
||||
const ChatMessageComponent = ({ message }: { message: UIMessage }) => {
|
||||
const content = getMessageText(message)
|
||||
|
||||
if (message.role === 'assistant')
|
||||
return (
|
||||
<MessageContainer copyContent={message.content}>
|
||||
<div className={cn('justify-start', 'flex w-full')}>
|
||||
<div className={cn('w-full', 'p-3 rounded-xl max-w-full', 'flex flex-col gap-2')}>
|
||||
<MemoizedMarkdown id={message.id} content={message.content} />
|
||||
<MessageContainer copyContent={content}>
|
||||
<div className={cn('justify-start', 'flex w-full border rounded-lg')}>
|
||||
<div className={cn('w-full', 'p-3 rounded-xl max-w-full', 'flex flex-col')}>
|
||||
<MemoizedMarkdown id={message.id} content={content} />
|
||||
</div>
|
||||
</div>
|
||||
</MessageContainer>
|
||||
)
|
||||
|
||||
return (
|
||||
<MessageContainer copyContent={message.content}>
|
||||
<MessageContainer copyContent={content}>
|
||||
<div className={cn('items-end', 'flex w-full flex-col gap-2')}>
|
||||
<div
|
||||
className={cn(
|
||||
@@ -318,9 +262,8 @@ const ChatMessageComponent = ({ message }: { message: ChatMessage }) => {
|
||||
'flex flex-col items-end overflow-x-hidden'
|
||||
)}
|
||||
>
|
||||
{/* {message?.context && message.context.length > 0 && <div className='flex items-center w-full overflow-x-auto justify-end mb-2'><ContextList context={message.context} /></div>} */}
|
||||
<span className="px-3">
|
||||
<MemoizedMarkdown id={message.id} content={message.content} />
|
||||
<MemoizedMarkdown id={message.id} content={content} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -331,10 +274,10 @@ const ChatMessageComponent = ({ message }: { message: ChatMessage }) => {
|
||||
const MessageContainer = memo(
|
||||
({ children, copyContent }: { children: React.ReactNode; copyContent?: string }) => {
|
||||
return (
|
||||
<div className="relative group">
|
||||
<div className="relative group/msg">
|
||||
{children}
|
||||
<div className="-mt-1 group-hover:opacity-100 opacity-0 flex items-center justify-end z-1">
|
||||
<div className="border rounded-md bg-background ">
|
||||
<div className="flex items-center justify-end z-1 ">
|
||||
<div className="group-hover/msg:opacity-100 border -mt-1 opacity-0 bg-background rounded">
|
||||
<CopyButton content={copyContent ?? ''} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -26,7 +26,7 @@ const MemoizedMarkdownBlock = memo(
|
||||
style={dracula}
|
||||
language={match[1]}
|
||||
PreTag="div"
|
||||
className="rounded border border-border bg-muted px-1 py-0.5 !m-0 text-sm w-auto not-prose"
|
||||
className="rounded border border-border bg-muted px-1 py-0.5 m-0! text-sm w-auto not-prose"
|
||||
{...props}
|
||||
/>
|
||||
<div className="absolute top-2 right-2">
|
||||
@@ -64,7 +64,7 @@ export const MemoizedMarkdown = memo(({ content, id }: { content: string; id: st
|
||||
const blocks = useMemo(() => parseMarkdownIntoBlocks(content), [content])
|
||||
|
||||
return (
|
||||
<div className="prose max-w-none dark:prose-invert">
|
||||
<div className="prose max-w-none dark:prose-invert text-sm">
|
||||
{blocks.map((block, index) => (
|
||||
<MemoizedMarkdownBlock content={block} key={`${id}-block_${index}`} />
|
||||
))}
|
||||
|
||||
@@ -101,7 +101,10 @@ export const ResizableChat: React.FC<ResizableChatProps> = ({
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn('relative group overflow-hidden', isResizing && 'pointer-events-none')}
|
||||
className={cn(
|
||||
'relative group/resize overflow-hidden max-h-[calc(100vh-60px)]',
|
||||
isResizing && 'pointer-events-none'
|
||||
)}
|
||||
style={{
|
||||
width: chatWidth,
|
||||
height: chatHeight
|
||||
@@ -119,8 +122,8 @@ export const ResizableChat: React.FC<ResizableChatProps> = ({
|
||||
className={cn(
|
||||
'absolute bottom-0 right-0 w-4 h-4 cursor-se-resize',
|
||||
'bg-transparent hover:bg-primary/20 transition-colors',
|
||||
'rounded-tl-md border-r border-b border-primary/20',
|
||||
'group-hover:border-primary/40',
|
||||
'rounded-tl-md border-primary/20',
|
||||
'group-hover/resize:border-primary/40',
|
||||
isResizing && 'bg-primary/30 border-primary/50'
|
||||
)}
|
||||
onMouseDown={handleMouseDown('se')}
|
||||
@@ -130,8 +133,8 @@ export const ResizableChat: React.FC<ResizableChatProps> = ({
|
||||
className={cn(
|
||||
'absolute bottom-0 left-0 w-4 h-4 cursor-sw-resize',
|
||||
'bg-transparent hover:bg-primary/20 transition-colors',
|
||||
'rounded-tr-md border-l border-b border-primary/20',
|
||||
'group-hover:border-primary/40',
|
||||
'rounded-tr-md border-primary/20',
|
||||
'group-hover/resize:border-primary/40',
|
||||
isResizing && 'bg-primary/30 border-primary/50'
|
||||
)}
|
||||
onMouseDown={handleMouseDown('sw')}
|
||||
@@ -141,8 +144,8 @@ export const ResizableChat: React.FC<ResizableChatProps> = ({
|
||||
className={cn(
|
||||
'absolute top-0 right-0 w-4 h-4 cursor-ne-resize',
|
||||
'bg-transparent hover:bg-primary/20 transition-colors',
|
||||
'rounded-bl-md border-r border-t border-primary/20',
|
||||
'group-hover:border-primary/40',
|
||||
'rounded-bl-md border-primary/20',
|
||||
'group-hover/resize:border-primary/40',
|
||||
isResizing && 'bg-primary/30 border-primary/50'
|
||||
)}
|
||||
onMouseDown={handleMouseDown('ne')}
|
||||
@@ -152,8 +155,8 @@ export const ResizableChat: React.FC<ResizableChatProps> = ({
|
||||
className={cn(
|
||||
'absolute top-0 left-0 w-4 h-4 cursor-nw-resize',
|
||||
'bg-transparent hover:bg-primary/20 transition-colors',
|
||||
'rounded-br-md border-l border-t border-primary/20',
|
||||
'group-hover:border-primary/40',
|
||||
'rounded-br-md border-primary/20',
|
||||
'group-hover/resize:border-primary/40',
|
||||
isResizing && 'bg-primary/30 border-primary/50'
|
||||
)}
|
||||
onMouseDown={handleMouseDown('nw')}
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { GripVertical, Trash2, ChevronRight, Asterisk } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { useState } from 'react'
|
||||
import { Reorder, useDragControls } from 'framer-motion'
|
||||
|
||||
export interface SchemaField {
|
||||
id: string
|
||||
key: string
|
||||
title: string
|
||||
type: string
|
||||
format?: string
|
||||
description?: string
|
||||
required: boolean
|
||||
}
|
||||
|
||||
const FIELD_TYPES = [
|
||||
{ value: 'string', label: 'Text' },
|
||||
{ value: 'number', label: 'Number' },
|
||||
{ value: 'integer', label: 'Integer' },
|
||||
{ value: 'boolean', label: 'Boolean' },
|
||||
{ value: 'array', label: 'List' },
|
||||
{ value: 'object', label: 'Object' }
|
||||
] as const
|
||||
|
||||
const FIELD_FORMATS = [
|
||||
{ value: 'none', label: 'None' },
|
||||
{ value: 'email', label: 'Email' },
|
||||
{ value: 'uri', label: 'URL' },
|
||||
{ value: 'date', label: 'Date' },
|
||||
{ value: 'date-time', label: 'Date & Time' },
|
||||
{ value: 'ipv4', label: 'IPv4' },
|
||||
{ value: 'ipv6', label: 'IPv6' }
|
||||
] as const
|
||||
|
||||
interface FieldRowProps {
|
||||
field: SchemaField
|
||||
onUpdate: (updates: Partial<SchemaField>) => void
|
||||
onDelete: () => void
|
||||
}
|
||||
|
||||
export function FieldRow({ field, onUpdate, onDelete }: FieldRowProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const dragControls = useDragControls()
|
||||
|
||||
return (
|
||||
<Reorder.Item
|
||||
value={field}
|
||||
dragListener={false}
|
||||
dragControls={dragControls}
|
||||
className="list-none"
|
||||
// whileDrag={{ scsale: 1.01, boxShadow: '0 4px 16px rgba(0,0,0,0.08)', zIndex: 50 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<Collapsible open={expanded} onOpenChange={setExpanded}>
|
||||
<div
|
||||
className={cn(
|
||||
'group rounded-lg border bg-background relative',
|
||||
field.required && 'border-primary/20 bg-primary/3'
|
||||
)}
|
||||
>
|
||||
{field.required && (
|
||||
<div className="absolute -top-2 -left-2 rounded-full border border-primary/50 bg-card">
|
||||
<Asterisk className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 px-2 py-1.5">
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
className="touch-none p-0.5 rounded hover:bg-muted transition-colors cursor-grab active:cursor-grabbing"
|
||||
onPointerDown={(e) => dragControls.start(e)}
|
||||
>
|
||||
<GripVertical className="h-3.5 w-3.5 text-muted-foreground/40 group-hover:text-muted-foreground/70 transition-colors" />
|
||||
</button>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button className="p-1 rounded hover:bg-muted transition-colors">
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'h-3.5 w-3.5 text-muted-foreground transition-transform',
|
||||
expanded && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 grid grid-cols-[1fr_1fr_120px_120px] gap-2 items-center">
|
||||
<Input
|
||||
value={field.key}
|
||||
onChange={(e) =>
|
||||
onUpdate({ key: e.target.value.toLowerCase().replace(/\s+/g, '_') })
|
||||
}
|
||||
placeholder="field_key"
|
||||
className="h-8 text-sm border-transparent bg-transparent hover:border-border focus:border-border focus:bg-background transition-colors"
|
||||
/>
|
||||
<Input
|
||||
value={field.title}
|
||||
onChange={(e) => onUpdate({ title: e.target.value })}
|
||||
placeholder="Display name"
|
||||
className="h-8 text-sm border-transparent bg-transparent hover:border-border focus:border-border focus:bg-background transition-colors"
|
||||
/>
|
||||
<Select value={field.type} onValueChange={(v: string) => onUpdate({ type: v })}>
|
||||
<SelectTrigger className="h-8 text-sm border-transparent bg-transparent hover:border-border transition-colors">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FIELD_TYPES.map((t) => (
|
||||
<SelectItem key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={field.format || 'none'}
|
||||
onValueChange={(v: string) => onUpdate({ format: v === 'none' ? undefined : v })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-sm border-transparent bg-transparent hover:border-border transition-colors">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FIELD_FORMATS.map((f) => (
|
||||
<SelectItem key={f.value} value={f.value}>
|
||||
{f.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-destructive"
|
||||
onClick={onDelete}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded details */}
|
||||
<CollapsibleContent>
|
||||
<div className="px-10 pb-3 space-y-3">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">Description</label>
|
||||
<Textarea
|
||||
value={field.description || ''}
|
||||
onChange={(e) => onUpdate({ description: e.target.value })}
|
||||
placeholder="Describe what this field is for..."
|
||||
rows={2}
|
||||
className="text-sm resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Required field
|
||||
</label>
|
||||
<Switch
|
||||
checked={field.required}
|
||||
onCheckedChange={(checked: boolean) => onUpdate({ required: checked })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</div>
|
||||
</Collapsible>
|
||||
</Reorder.Item>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useState } from 'react'
|
||||
import * as LucideIcons from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import IconPicker from '@/components/shared/icon-picker/popup'
|
||||
|
||||
interface IconPickerTriggerProps {
|
||||
icon: string
|
||||
color: string
|
||||
onIconChange: (icon: string) => void
|
||||
}
|
||||
|
||||
export function IconPickerTrigger({ icon, color, onIconChange }: IconPickerTriggerProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const Icon = (LucideIcons as any)[icon] || LucideIcons.FileQuestion
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className={cn(
|
||||
'group relative flex items-center justify-center rounded-full transition-all',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
width: 52,
|
||||
height: 52
|
||||
}}
|
||||
>
|
||||
<Icon className="text-white" size={26} />
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 rounded-xl bg-black/0 transition-colors',
|
||||
'group-hover:bg-black/10'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<IconPicker
|
||||
iconType={null}
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
onIconChange={(_type, iconName) => {
|
||||
onIconChange(iconName as string)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import * as LucideIcons from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import type { SchemaField } from './field-row'
|
||||
|
||||
interface TypePreviewProps {
|
||||
name: string
|
||||
description: string
|
||||
icon: string
|
||||
color: string
|
||||
fields: SchemaField[]
|
||||
status: 'draft' | 'published'
|
||||
}
|
||||
|
||||
export function TypePreview({ name, description, icon, color, fields, status }: TypePreviewProps) {
|
||||
const Icon = (LucideIcons as any)[icon] || LucideIcons.FileQuestion
|
||||
const validFields = fields.filter((f) => f.key.trim())
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Entity card preview */}
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-3">Entity card</p>
|
||||
<div className="border border-border/60 rounded-xl p-5 bg-card max-w-sm">
|
||||
<div className="flex items-start gap-3.5">
|
||||
<div
|
||||
className="rounded-full flex items-center justify-center shrink-0"
|
||||
style={{ backgroundColor: color, width: 44, height: 44 }}
|
||||
>
|
||||
<Icon className="text-white" size={22} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="font-semibold text-sm truncate">{name || 'Untitled type'}</h4>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
|
||||
{description || 'No description'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{validFields.length > 0 && (
|
||||
<div className="mt-4 pt-3 border-t border-border/40 space-y-2">
|
||||
{validFields.slice(0, 4).map((field) => (
|
||||
<div key={field.id} className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">{field.title || field.key}</span>
|
||||
<span className="text-xs text-muted-foreground/60 font-mono">
|
||||
{field.type}
|
||||
{field.format && field.format !== 'none' ? `:${field.format}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{validFields.length > 4 && (
|
||||
<p className="text-[10px] text-muted-foreground/50">
|
||||
+{validFields.length - 4} more fields
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Graph node preview */}
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-3">Graph node</p>
|
||||
<div className="flex items-center gap-3 p-4 border border-border/60 rounded-xl bg-card max-w-[200px]">
|
||||
<div
|
||||
className="rounded-full flex items-center justify-center shrink-0"
|
||||
style={{ backgroundColor: color, width: 36, height: 36 }}
|
||||
>
|
||||
<Icon className="text-white" size={18} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium truncate">{name || 'Untitled'}</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{validFields.length} field{validFields.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Schema preview */}
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-3">JSON Schema</p>
|
||||
<pre className="p-4 bg-muted/50 rounded-lg text-xs font-mono overflow-x-auto text-muted-foreground leading-relaxed">
|
||||
{JSON.stringify(buildSchema(name, fields), null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function buildSchema(name: string, fields: SchemaField[]) {
|
||||
const properties: Record<string, any> = {}
|
||||
const required: string[] = []
|
||||
|
||||
fields.forEach((field) => {
|
||||
if (!field.key.trim()) return
|
||||
const prop: any = { type: field.type, title: field.title || field.key }
|
||||
if (field.description) prop.description = field.description
|
||||
if (field.format && field.format !== 'none') prop.format = field.format
|
||||
properties[field.key] = prop
|
||||
if (field.required) required.push(field.key)
|
||||
})
|
||||
|
||||
return {
|
||||
title: name || 'MyCustomType',
|
||||
type: 'object',
|
||||
properties,
|
||||
required
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,42 @@
|
||||
import { FolderOpen, MoreHorizontal, FileText, Network } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Link } from "@tanstack/react-router"
|
||||
import { Investigation } from "@/types"
|
||||
import { formatDistanceToNow } from "date-fns"
|
||||
import { useMemo } from "react"
|
||||
import { FolderOpen, MoreHorizontal, FileText, Network } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { Investigation } from '@/types'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
interface InvestigationsListProps {
|
||||
investigations: Investigation[]
|
||||
view: "grid" | "list"
|
||||
filter: "all" | "active" | "closed"
|
||||
setFilter: (filter: "all" | "active" | "closed") => void
|
||||
casesCount: number,
|
||||
view: 'grid' | 'list'
|
||||
filter: 'all' | 'active' | 'closed'
|
||||
setFilter: (filter: 'all' | 'active' | 'closed') => void
|
||||
casesCount: number
|
||||
activeCasesCount: number
|
||||
search: string
|
||||
}
|
||||
|
||||
export function InvestigationsList({ investigations, view, filter, setFilter, casesCount, activeCasesCount, search }: InvestigationsListProps) {
|
||||
export function InvestigationsList({
|
||||
investigations,
|
||||
view,
|
||||
filter,
|
||||
setFilter,
|
||||
casesCount,
|
||||
activeCasesCount,
|
||||
search
|
||||
}: InvestigationsListProps) {
|
||||
const filteredInvestigations = investigations.filter((inv) => {
|
||||
if (filter === "all") return inv.name.toLowerCase().includes(search.toLowerCase())
|
||||
if (filter === 'all') return inv.name.toLowerCase().includes(search.toLowerCase())
|
||||
return inv.status === filter && inv.name.toLowerCase().includes(search.toLowerCase())
|
||||
})
|
||||
|
||||
const filters = useMemo(() => [
|
||||
{ label: "All", value: "all", count: casesCount },
|
||||
{ label: "Active", value: "active", count: activeCasesCount },
|
||||
{ label: "Closed", value: "closed", count: casesCount - activeCasesCount },
|
||||
], [casesCount, activeCasesCount])
|
||||
const filters = useMemo(
|
||||
() => [
|
||||
{ label: 'All', value: 'all', count: casesCount },
|
||||
{ label: 'Active', value: 'active', count: activeCasesCount },
|
||||
{ label: 'Closed', value: 'closed', count: casesCount - activeCasesCount }
|
||||
],
|
||||
[casesCount, activeCasesCount]
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -33,10 +44,12 @@ export function InvestigationsList({ investigations, view, filter, setFilter, ca
|
||||
{filters.map((f) => (
|
||||
<button
|
||||
key={f.value}
|
||||
onClick={() => setFilter(f.value as "all" | "active" | "closed")}
|
||||
onClick={() => setFilter(f.value as 'all' | 'active' | 'closed')}
|
||||
className={cn(
|
||||
"px-3 py-1.5 text-sm rounded-md transition-colors",
|
||||
filter === f.value ? "bg-muted text-foreground" : "text-muted-foreground hover:text-foreground",
|
||||
'px-3 py-1.5 text-sm rounded-md transition-colors',
|
||||
filter === f.value
|
||||
? 'bg-muted text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{f.label}
|
||||
@@ -45,23 +58,31 @@ export function InvestigationsList({ investigations, view, filter, setFilter, ca
|
||||
))}
|
||||
</div>
|
||||
|
||||
{view === "list" ? (
|
||||
{view === 'list' ? (
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/30">
|
||||
<th className="text-left text-xs font-medium text-muted-foreground px-4 py-2.5">Name</th>
|
||||
<th className="text-left text-xs font-medium text-muted-foreground px-4 py-2.5 w-24">Status</th>
|
||||
<th className="text-left text-xs font-medium text-muted-foreground px-4 py-2.5 w-28">Updated</th>
|
||||
<th className="text-left text-xs font-medium text-muted-foreground px-4 py-2.5">
|
||||
Name
|
||||
</th>
|
||||
<th className="text-left text-xs font-medium text-muted-foreground px-4 py-2.5 w-24">
|
||||
Status
|
||||
</th>
|
||||
<th className="text-left text-xs font-medium text-muted-foreground px-4 py-2.5 w-28">
|
||||
Updated
|
||||
</th>
|
||||
<th className="w-10"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredInvestigations.length === 0 && <div className="flex items-center justify-center p-4">
|
||||
<tr
|
||||
className="border-b border-border last:border-0 hover:bg-muted/20 transition-colors group"
|
||||
>No investigation found.</tr>
|
||||
</div>}
|
||||
{filteredInvestigations.length === 0 && (
|
||||
<div className="flex items-center justify-center p-4">
|
||||
<tr className="border-b border-border last:border-0 hover:bg-muted/20 transition-colors group">
|
||||
No investigation found.
|
||||
</tr>
|
||||
</div>
|
||||
)}
|
||||
{filteredInvestigations.map((inv) => (
|
||||
<tr
|
||||
key={inv.id}
|
||||
@@ -77,7 +98,9 @@ export function InvestigationsList({ investigations, view, filter, setFilter, ca
|
||||
<FolderOpen className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-foreground truncate">{inv.name}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{inv.description}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{inv.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
@@ -86,15 +109,17 @@ export function InvestigationsList({ investigations, view, filter, setFilter, ca
|
||||
<StatusBadge status={inv.status} />
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-xs text-muted-foreground">{formatDistanceToNow(new Date(inv.last_updated_at), {
|
||||
addSuffix: true
|
||||
})}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDistanceToNow(new Date(inv.last_updated_at), {
|
||||
addSuffix: true
|
||||
})}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{/*<td className="px-4 py-3">
|
||||
<button className="p-1 rounded hover:bg-muted opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<MoreHorizontal className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
</td>
|
||||
</td>*/}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -102,9 +127,7 @@ export function InvestigationsList({ investigations, view, filter, setFilter, ca
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{filteredInvestigations.length === 0 && <div>
|
||||
No investigation found.
|
||||
</div>}
|
||||
{filteredInvestigations.length === 0 && <div>No investigation found.</div>}
|
||||
{filteredInvestigations.map((inv) => (
|
||||
<Link
|
||||
to="/dashboard/investigations/$investigationId"
|
||||
@@ -116,9 +139,9 @@ export function InvestigationsList({ investigations, view, filter, setFilter, ca
|
||||
<FolderOpen className="w-4 h-4 text-muted-foreground" />
|
||||
<StatusBadge status={inv.status} />
|
||||
</div>
|
||||
<button className="p-1 rounded hover:bg-muted opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{/*<button className="p-1 rounded hover:bg-muted opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<MoreHorizontal className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
</button>*/}
|
||||
</div>
|
||||
<h3 className="text-sm font-medium text-foreground mb-1 truncate">{inv.name}</h3>
|
||||
<p className="text-xs text-muted-foreground mb-4 line-clamp-2">{inv.description}</p>
|
||||
@@ -154,21 +177,21 @@ function StatusBadge({ status }: { status: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 text-xs",
|
||||
status === "active" && "text-success",
|
||||
status === "closed" && "text-muted-foreground",
|
||||
status === "on-hold" && "text-warning",
|
||||
'inline-flex items-center gap-1.5 text-xs',
|
||||
status === 'active' && 'text-success',
|
||||
status === 'closed' && 'text-muted-foreground',
|
||||
status === 'on-hold' && 'text-warning'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"w-1.5 h-1.5 rounded-full",
|
||||
status === "active" && "bg-success",
|
||||
status === "closed" && "bg-muted-foreground/50",
|
||||
status === "on-hold" && "bg-warning",
|
||||
'w-1.5 h-1.5 rounded-full',
|
||||
status === 'active' && 'bg-success',
|
||||
status === 'closed' && 'bg-muted-foreground/50',
|
||||
status === 'on-hold' && 'bg-warning'
|
||||
)}
|
||||
/>
|
||||
{status === "on-hold" ? "On Hold" : status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
{status === 'on-hold' ? 'On Hold' : status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
|
||||
import { Plus, LayoutGrid, List, Search } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import NewInvestigation from "@/components/investigations/new-investigation"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Plus, LayoutGrid, List, Search } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import NewInvestigation from '@/components/investigations/new-investigation'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
interface DashboardHeaderProps {
|
||||
view: "grid" | "list"
|
||||
setView: (view: "grid" | "list") => void
|
||||
view: 'grid' | 'list'
|
||||
setView: (view: 'grid' | 'list') => void
|
||||
search: string
|
||||
setSearch: (val: string) => void
|
||||
}
|
||||
@@ -17,7 +16,9 @@ export function DashboardHeader({ view, setView, search, setSearch }: DashboardH
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Investigations</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">Manage and track your OSINT investigations</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Manage and track your OSINT investigations
|
||||
</p>
|
||||
</div>
|
||||
<NewInvestigation noDropDown>
|
||||
<Button size="sm" className="gap-1.5">
|
||||
@@ -31,7 +32,8 @@ export function DashboardHeader({ view, setView, search, setSearch }: DashboardH
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={search} onChange={(e) => setSearch(e.target.value)}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
type="text"
|
||||
placeholder="Search investigations..."
|
||||
className="w-72 h-9 pl-9 pr-3 text-sm bg-muted/50 border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/60"
|
||||
@@ -39,20 +41,22 @@ export function DashboardHeader({ view, setView, search, setSearch }: DashboardH
|
||||
</div>
|
||||
<div className="flex items-center gap-1 p-1 bg-muted/50 rounded-md">
|
||||
<button
|
||||
onClick={() => setView("list")}
|
||||
className={`p-1.5 rounded transition-colors ${view === "list"
|
||||
? "bg-background shadow-sm text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
onClick={() => setView('list')}
|
||||
className={`p-1.5 rounded transition-colors ${
|
||||
view === 'list'
|
||||
? 'bg-background text-foreground border'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView("grid")}
|
||||
className={`p-1.5 rounded transition-colors ${view === "grid"
|
||||
? "bg-background shadow-sm text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
onClick={() => setView('grid')}
|
||||
className={`p-1.5 rounded transition-colors ${
|
||||
view === 'grid'
|
||||
? 'bg-background text-foreground border'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<LayoutGrid className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
@@ -1,36 +1,36 @@
|
||||
|
||||
import { useState } from "react"
|
||||
import { DashboardHeader } from "./dashboard-header"
|
||||
import { useState } from 'react'
|
||||
import { DashboardHeader } from './dashboard-header'
|
||||
// import { DashboardStats } from "./dashboard-stats"
|
||||
import { InvestigationsList } from "../investigation/investigations-list"
|
||||
import { InvestigationsList } from '../investigation/investigations-list'
|
||||
// import { RecentActivity } from "./recent-activity"
|
||||
import { queryKeys } from "@/api/query-keys"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { investigationService } from "@/api/investigation-service"
|
||||
import { Investigation } from "@/types"
|
||||
import { InvestigationSkeleton } from "../investigation/investigation-skeleton"
|
||||
import { queryKeys } from '@/api/query-keys'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { investigationService } from '@/api/investigation-service'
|
||||
import { Investigation } from '@/types'
|
||||
import { InvestigationSkeleton } from '../investigation/investigation-skeleton'
|
||||
|
||||
export function DashboardPage() {
|
||||
const [view, setView] = useState<"grid" | "list">("list")
|
||||
const [filter, setFilter] = useState<"all" | "active" | "closed">("all")
|
||||
const [search, setSearch] = useState<string>("")
|
||||
const [view, setView] = useState<'grid' | 'list'>('grid')
|
||||
const [filter, setFilter] = useState<'all' | 'active' | 'closed'>('all')
|
||||
const [search, setSearch] = useState<string>('')
|
||||
|
||||
const {
|
||||
data: investigations,
|
||||
isLoading,
|
||||
isLoading
|
||||
// error,
|
||||
// refetch
|
||||
} = useQuery({
|
||||
queryKey: queryKeys.investigations.dashboard,
|
||||
queryFn: investigationService.get,
|
||||
queryFn: investigationService.get
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return <InvestigationSkeleton />
|
||||
}
|
||||
|
||||
const casesCount = investigations.length ?? 0 as number
|
||||
const activeCasesCount = investigations.filter((i: Investigation) => i.status === "active").length ?? 0 as number
|
||||
const casesCount = investigations.length ?? (0 as number)
|
||||
const activeCasesCount =
|
||||
investigations.filter((i: Investigation) => i.status === 'active').length ?? (0 as number)
|
||||
|
||||
return (
|
||||
<main className="flex-1 h-full overflow-auto">
|
||||
@@ -39,7 +39,15 @@ export function DashboardPage() {
|
||||
{/* <DashboardStats casesCount={casesCount} activeCasesCount={activeCasesCount} /> */}
|
||||
<div className="mt-10 grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
<div className="lg:col-span-3">
|
||||
<InvestigationsList search={search} casesCount={casesCount} activeCasesCount={activeCasesCount} investigations={investigations} view={view} filter={filter} setFilter={setFilter} />
|
||||
<InvestigationsList
|
||||
search={search}
|
||||
casesCount={casesCount}
|
||||
activeCasesCount={activeCasesCount}
|
||||
investigations={investigations}
|
||||
view={view}
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
/>
|
||||
</div>
|
||||
{/* <div>
|
||||
<RecentActivity />
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { useCallback } from 'react'
|
||||
import { JSX, useCallback } from 'react'
|
||||
import { Pencil, Trash } from 'lucide-react'
|
||||
import { GraphNode } from '@/types/graph'
|
||||
import BaseContextMenu from '@/components/xyflow/context-menu'
|
||||
import { FlowNode, useFlowStore } from '@/stores/flow-store'
|
||||
|
||||
interface GraphContextMenuProps {
|
||||
node: GraphNode | FlowNode
|
||||
node: FlowNode
|
||||
top?: number
|
||||
left?: number
|
||||
right?: number
|
||||
|
||||
@@ -70,7 +70,13 @@ export function FlowControls({
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="bg-card" onClick={onLayout} data-tour-id="layout-button">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="bg-card"
|
||||
onClick={onLayout}
|
||||
data-tour-id="layout-button"
|
||||
>
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
|
||||
@@ -95,7 +95,6 @@ const FlowEditor = memo(({ initialEdges, initialNodes, theme, flow }: FlowEditor
|
||||
const setLoading = useFlowStore((state) => state.setLoading)
|
||||
const colors = useNodesDisplaySettings((s) => s.colors)
|
||||
|
||||
// #### TanStack Query Mutations ####
|
||||
// Create flow mutation
|
||||
const createFlowMutation = useMutation({
|
||||
mutationFn: flowService.create,
|
||||
@@ -608,7 +607,11 @@ const FlowEditor = memo(({ initialEdges, initialNodes, theme, flow }: FlowEditor
|
||||
// #### Render ####
|
||||
return (
|
||||
<>
|
||||
<div ref={reactFlowWrapper} className="w-full h-full bg-background" data-tour-id="flow-canvas">
|
||||
<div
|
||||
ref={reactFlowWrapper}
|
||||
className="w-full h-full bg-background"
|
||||
data-tour-id="flow-canvas"
|
||||
>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
@@ -627,13 +630,23 @@ const FlowEditor = memo(({ initialEdges, initialNodes, theme, flow }: FlowEditor
|
||||
colorMode={theme}
|
||||
>
|
||||
{flowId && (
|
||||
<Panel position="top-right" className="space-x-2 mt-14 mr-2 z-50 flex items-center" data-tour-id="simulation-controls">
|
||||
<Panel
|
||||
position="top-right"
|
||||
className="space-x-2 mt-14 mr-2 z-50 flex items-center"
|
||||
data-tour-id="simulation-controls"
|
||||
>
|
||||
{isSimulating ? (
|
||||
<Button size="sm" variant="outline" className="h-7" onClick={pauseSimulation}>
|
||||
<Pause className="h-4 w-4 mr-1" /> Pause
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" variant="default" className="h-7" onClick={handleComputeFlow} data-tour-id="compute-button">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="h-7"
|
||||
onClick={handleComputeFlow}
|
||||
data-tour-id="compute-button"
|
||||
>
|
||||
<Play className="h-4 w-4 mr-1" /> Compute
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -34,9 +34,9 @@ const EnricherItem = memo(({ enricher, category }: EnricherItemProps) => {
|
||||
const borderOutputColor = colors[enricher.outputs.type.toLowerCase()]
|
||||
const Icon =
|
||||
enricher.type === 'type'
|
||||
? useIcon(enricher.outputs.type.toLowerCase() as string, null)
|
||||
? useIcon(enricher.outputs.type.toLowerCase() as string)
|
||||
: enricher.icon
|
||||
? useIcon(enricher.icon, null)
|
||||
? useIcon(enricher.icon)
|
||||
: null
|
||||
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||
|
||||
@@ -53,9 +53,9 @@ const EnricherNode = memo(({ data, isConnectable }: EnricherNodeProps) => {
|
||||
const setOpenFlowSheet = useFlowStore((state) => state.setOpenFlowSheet)
|
||||
const Icon =
|
||||
data.type === 'type'
|
||||
? useIcon(data.outputs.type.toLowerCase() as string, null)
|
||||
? useIcon(data.outputs.type.toLowerCase() as string)
|
||||
: data.icon
|
||||
? useIcon(data.icon, null)
|
||||
? useIcon(data.icon)
|
||||
: null
|
||||
|
||||
const handleAddConnector = useCallback(() => {
|
||||
|
||||
@@ -23,7 +23,7 @@ const FlowNavigation = () => {
|
||||
defaultValue="flows"
|
||||
className="w-full h-full flex flex-col gap-0 min-h-0"
|
||||
>
|
||||
<TabsList className="w-full p-0 rounded-none my-0 border-b flex-shrink-0">
|
||||
<TabsList className="w-full p-0 rounded-none my-0 border-b shrink-0">
|
||||
<TabsTrigger value="flows">
|
||||
<Users className="h-3 w-3 opacity-60" /> Flows
|
||||
</TabsTrigger>
|
||||
|
||||
@@ -169,80 +169,73 @@ function areEqual(prevProps: { enricher: Enricher }, nextProps: { enricher: Enri
|
||||
}
|
||||
|
||||
// Memoized enricher item component for the sidebar
|
||||
const EnricherItem = memo(
|
||||
({ enricher, onClick }: { enricher: Enricher; onClick: () => void }) => {
|
||||
const colors = useNodesDisplaySettings((s) => s.colors)
|
||||
const borderInputColor = colors[enricher.inputs.type.toLowerCase()]
|
||||
const borderOutputColor = colors[enricher.outputs.type.toLowerCase()]
|
||||
const Icon =
|
||||
enricher.type === 'type'
|
||||
? useIcon(enricher.outputs.type.toLowerCase() as string, null)
|
||||
: enricher.icon
|
||||
? useIcon(enricher.icon, null)
|
||||
: null
|
||||
const EnricherItem = memo(({ enricher, onClick }: { enricher: Enricher; onClick: () => void }) => {
|
||||
const colors = useNodesDisplaySettings((s) => s.colors)
|
||||
const borderInputColor = colors[enricher.inputs.type.toLowerCase()]
|
||||
const borderOutputColor = colors[enricher.outputs.type.toLowerCase()]
|
||||
const Icon =
|
||||
enricher.type === 'type'
|
||||
? useIcon(enricher.outputs.type.toLowerCase() as string)
|
||||
: enricher.icon
|
||||
? useIcon(enricher.icon)
|
||||
: null
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="p-3 rounded-md relative w-full overflow-hidden cursor-grab bg-card border ring-2 ring-transparent hover:ring-primary transition-all group"
|
||||
style={{
|
||||
borderLeftWidth: '5px',
|
||||
borderRightWidth: '5px',
|
||||
borderLeftColor: borderInputColor ?? borderOutputColor,
|
||||
borderRightColor: borderOutputColor,
|
||||
cursor: 'grab'
|
||||
}}
|
||||
>
|
||||
<div className="flex justify-between grow items-start">
|
||||
<div className="flex items-start gap-2 grow truncate text-ellipsis">
|
||||
<div className="space-y-1 truncate text-left">
|
||||
<div className="flex items-center gap-2 truncate text-ellipsis">
|
||||
{Icon && <Icon size={24} />}
|
||||
<h3 className="text-sm font-medium truncate text-ellipsis">
|
||||
{enricher.class_name}
|
||||
</h3>
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="p-3 rounded-md relative w-full overflow-hidden cursor-grab bg-card border ring-2 ring-transparent hover:ring-primary transition-all group"
|
||||
style={{
|
||||
borderLeftWidth: '5px',
|
||||
borderRightWidth: '5px',
|
||||
borderLeftColor: borderInputColor ?? borderOutputColor,
|
||||
borderRightColor: borderOutputColor,
|
||||
cursor: 'grab'
|
||||
}}
|
||||
>
|
||||
<div className="flex justify-between grow items-start">
|
||||
<div className="flex items-start gap-2 grow truncate text-ellipsis">
|
||||
<div className="space-y-1 truncate text-left">
|
||||
<div className="flex items-center gap-2 truncate text-ellipsis">
|
||||
{Icon && <Icon size={24} />}
|
||||
<h3 className="text-sm font-medium truncate text-ellipsis">
|
||||
{enricher.class_name}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm font-normal opacity-60 truncate text-ellipsis">
|
||||
{enricher.description}
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-2 text-xs">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-muted-foreground">Takes</span>
|
||||
<span className="font-bold truncate text-ellipsis">{enricher.inputs.type}</span>
|
||||
</div>
|
||||
<p className="text-sm font-normal opacity-60 truncate text-ellipsis">
|
||||
{enricher.description}
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-2 text-xs">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-muted-foreground">Takes</span>
|
||||
<span className="font-bold truncate text-ellipsis">
|
||||
{enricher.inputs.type}
|
||||
</span>
|
||||
</div>
|
||||
<span>
|
||||
<ArrowRight className="h-3 w-3" />
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-muted-foreground">Returns</span>
|
||||
<span className="font-bold truncate text-ellipsis">
|
||||
{enricher.outputs.type}
|
||||
</span>
|
||||
</div>
|
||||
<span>
|
||||
<ArrowRight className="h-3 w-3" />
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-muted-foreground">Returns</span>
|
||||
<span className="font-bold truncate text-ellipsis">{enricher.outputs.type}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{enricher.required_params && (
|
||||
<div className="absolute bottom-3 right-3">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<TriangleAlert className="h-4 w-4 text-yellow-500" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>API key required</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</TooltipProvider>
|
||||
)
|
||||
},
|
||||
areEqual
|
||||
)
|
||||
</div>
|
||||
{enricher.required_params && (
|
||||
<div className="absolute bottom-3 right-3">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<TriangleAlert className="h-4 w-4 text-yellow-500" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>API key required</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}, areEqual)
|
||||
|
||||
EnricherItem.displayName = 'EnricherItem'
|
||||
|
||||
@@ -19,6 +19,8 @@ export default function RawMaterial() {
|
||||
})
|
||||
const [searchTerm, setSearchTerm] = useState<string>('')
|
||||
|
||||
console.log(materials)
|
||||
|
||||
const filteredEnrichers = useMemo(() => {
|
||||
if (!materials?.items) return {}
|
||||
const result: Record<string, Enricher[]> = {}
|
||||
@@ -53,12 +55,12 @@ export default function RawMaterial() {
|
||||
)
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full min-h-0 bg-card overflow-y-auto p-4">
|
||||
<div className="relative mb-4 flex-shrink-0">
|
||||
<div className="relative mb-4 shrink-0">
|
||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search enrichers..."
|
||||
className="pl-8 !border border-border"
|
||||
className="pl-8 border! border-border"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
|
||||
@@ -29,7 +29,7 @@ function areEqual(prevProps: EnricherNodeProps, nextProps: EnricherNodeProps) {
|
||||
const TypeNode = memo(({ data }: EnricherNodeProps) => {
|
||||
const colors = useNodesDisplaySettings((s) => s.colors)
|
||||
const outputColor = colors[data.outputs.type.toLowerCase()]
|
||||
const Icon = useIcon(data.outputs.type.toLowerCase() as string, null)
|
||||
const Icon = useIcon(data.outputs.type.toLowerCase() as string)
|
||||
const setOpenFlowSheet = useFlowStore((state) => state.setOpenFlowSheet)
|
||||
const key = data.outputs.properties[0].name
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ export function PathBreadcrumb() {
|
||||
<div className="w-full overflow-hidden">
|
||||
<Breadcrumb className="w-full">
|
||||
<BreadcrumbList className="flex items-center justify-start gap-1 min-w-0">
|
||||
<BreadcrumbItem className="flex-shrink-0">
|
||||
<BreadcrumbItem className="shrink-0">
|
||||
<BreadcrumbLink asChild>
|
||||
<Link to="/dashboard" className="truncate font-medium">
|
||||
<Home className="h-4 w-4 opacity-60" strokeWidth={1.4} />
|
||||
@@ -52,7 +52,7 @@ export function PathBreadcrumb() {
|
||||
</BreadcrumbItem>
|
||||
{isFlowPage ? (
|
||||
<>
|
||||
<BreadcrumbSeparator className="flex-shrink-0" />
|
||||
<BreadcrumbSeparator className="shrink-0" />
|
||||
<BreadcrumbItem className="min-w-0">
|
||||
<BreadcrumbLink asChild>
|
||||
<Link to="/dashboard/flows" className="truncate block font-medium">
|
||||
@@ -62,7 +62,7 @@ export function PathBreadcrumb() {
|
||||
</BreadcrumbItem>
|
||||
{flowId && (
|
||||
<>
|
||||
<BreadcrumbSeparator className="flex-shrink-0" />
|
||||
<BreadcrumbSeparator className="shrink-0" />
|
||||
<BreadcrumbItem className="min-w-0 flex-1">
|
||||
<BreadcrumbPage className="truncate block text-muted-foreground">
|
||||
{isFlowLoading ? (
|
||||
@@ -78,7 +78,7 @@ export function PathBreadcrumb() {
|
||||
) : (
|
||||
investigationId && (
|
||||
<>
|
||||
<BreadcrumbSeparator className="flex-shrink-0" />
|
||||
<BreadcrumbSeparator className="shrink-0" />
|
||||
<BreadcrumbItem className="min-w-0">
|
||||
<BreadcrumbLink asChild>
|
||||
<Link
|
||||
@@ -96,7 +96,7 @@ export function PathBreadcrumb() {
|
||||
</BreadcrumbItem>
|
||||
{type && id && (
|
||||
<>
|
||||
<BreadcrumbSeparator className="flex-shrink-0" />
|
||||
<BreadcrumbSeparator className="shrink-0" />
|
||||
<BreadcrumbItem className="min-w-0 flex-1">
|
||||
<BreadcrumbPage className="truncate block text-muted-foreground">
|
||||
{type === 'graph' ? (
|
||||
|
||||
@@ -28,8 +28,8 @@ export default function RootLayout({ children }: LayoutProps) {
|
||||
const closePanel = useLayoutStore((s) => s.closePanel)
|
||||
const openPanel = useLayoutStore((s) => s.openPanel)
|
||||
const closeDetails = useLayoutStore((s) => s.closeDetails)
|
||||
const currentNode = useGraphStore((s) => s.currentNode)
|
||||
const currentEdge = useGraphStore((s) => s.currentEdge)
|
||||
const currentNode = useGraphStore((s) => s.getCurrentNode())
|
||||
const currentEdge = useGraphStore((s) => s.getCurrentEdge())
|
||||
const { id, type } = useParams({ strict: false })
|
||||
|
||||
// Set up keyboard shortcut for chat panel
|
||||
@@ -65,7 +65,7 @@ export default function RootLayout({ children }: LayoutProps) {
|
||||
minSize={16}
|
||||
maxSize={40}
|
||||
onCollapse={closeDetails}
|
||||
onExpand={() => {}} // No auto-open here
|
||||
onExpand={() => { }} // No auto-open here
|
||||
collapsible={true}
|
||||
collapsedSize={2}
|
||||
>
|
||||
@@ -90,7 +90,7 @@ export default function RootLayout({ children }: LayoutProps) {
|
||||
minSize={16}
|
||||
maxSize={40}
|
||||
onCollapse={closeDetails}
|
||||
onExpand={() => {}} // No auto-open here
|
||||
onExpand={() => { }} // No auto-open here
|
||||
collapsible={true}
|
||||
collapsedSize={2}
|
||||
>
|
||||
@@ -98,19 +98,19 @@ export default function RootLayout({ children }: LayoutProps) {
|
||||
// Both node and edge: show both in a vertical split
|
||||
<ResizablePanelGroup direction="vertical" className="h-full">
|
||||
<ResizablePanel defaultSize={70} minSize={30}>
|
||||
<DetailsPanel node={currentNode} />
|
||||
<DetailsPanel />
|
||||
</ResizablePanel>
|
||||
<ResizableHandle withHandle />
|
||||
<ResizablePanel defaultSize={30} minSize={20}>
|
||||
<EdgeDetailsPanel edge={currentEdge} />
|
||||
<EdgeDetailsPanel />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
) : hasNode ? (
|
||||
// Only node
|
||||
<DetailsPanel node={currentNode} />
|
||||
<DetailsPanel />
|
||||
) : (
|
||||
// Only edge
|
||||
<EdgeDetailsPanel edge={currentEdge} />
|
||||
<EdgeDetailsPanel />
|
||||
)}
|
||||
</ResizablePanel>
|
||||
</>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useLocation, useParams } from '@tanstack/react-router'
|
||||
import InvestigationList from '../investigations/investigation-list'
|
||||
import GraphNavigation from '../sketches/graph-navigation'
|
||||
import GraphNavigation from '../sketches/graph/panels/graph-navigation'
|
||||
import FlowNavigation from '../flows/flow-navigation'
|
||||
import SketchList from '../investigations/sketch-list'
|
||||
import AnalysesList from '../analyses/analyses-list'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Home, Lock, type LucideIcon, PanelLeft, Workflow, Shapes } from 'lucide-react'
|
||||
import { Home, Lock, type LucideIcon, PanelLeft, Workflow, Shapes, Puzzle } from 'lucide-react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { useLayoutStore } from '@/stores/layout-store'
|
||||
import { Button } from '../ui/button'
|
||||
@@ -6,6 +6,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/
|
||||
import { memo } from 'react'
|
||||
import { isMac } from '@/lib/utils'
|
||||
import { NavUser } from '../nav-user'
|
||||
import { CONFIG } from '@/config'
|
||||
|
||||
interface NavItem {
|
||||
icon: LucideIcon
|
||||
@@ -23,6 +24,9 @@ export const Sidebar = memo(() => {
|
||||
{ icon: Lock, label: 'Vault', href: '/dashboard/vault' }
|
||||
]
|
||||
|
||||
if (CONFIG.ENRICHER_TEMPLATES_FEATURE_FLAG)
|
||||
navItems.push({ icon: Puzzle, label: 'Enrichers', href: '/dashboard/enrichers' })
|
||||
|
||||
const commonClasses = 'flex items-center justify-center h-12 w-full rounded-sm hover:bg-muted'
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Terminal, Unlock } from 'lucide-react'
|
||||
import { ModeToggle } from '../mode-toggle'
|
||||
import { useLayoutStore } from '@/stores/layout-store'
|
||||
import Legend from '../sketches/legend'
|
||||
import Legend from '../sketches/graph/components/legend'
|
||||
import { Link, useParams } from '@tanstack/react-router'
|
||||
import InfoDialog from './info'
|
||||
import { memo } from 'react'
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Ellipsis, Upload } from 'lucide-react'
|
||||
import { Settings2, Upload } from 'lucide-react'
|
||||
import { isMac } from '@/lib/utils'
|
||||
import { useGraphSettingsStore } from '@/stores/graph-settings-store'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
@@ -78,7 +78,13 @@ export const TopNavbar = memo(() => {
|
||||
)
|
||||
})
|
||||
|
||||
export function InvestigationMenu({ investigationId, sketchId }: { investigationId?: string, sketchId: string }) {
|
||||
export function InvestigationMenu({
|
||||
investigationId,
|
||||
sketchId
|
||||
}: {
|
||||
investigationId?: string
|
||||
sketchId: string
|
||||
}) {
|
||||
const toggleSettingsModal = useGraphSettingsStore((s) => s.toggleSettingsModal)
|
||||
const toggleKeyboardShortcutsModal = useGraphSettingsStore((s) => s.toggleKeyboardShortcutsModal)
|
||||
const setImportModalOpen = useGraphSettingsStore((s) => s.setImportModalOpen)
|
||||
@@ -137,7 +143,7 @@ export function InvestigationMenu({ investigationId, sketchId }: { investigation
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div>
|
||||
<Button size="icon" variant="ghost">
|
||||
<Ellipsis />
|
||||
<Settings2 />
|
||||
</Button>
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -155,15 +161,23 @@ export function InvestigationMenu({ investigationId, sketchId }: { investigation
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<a className='h-full w-full' target='_blank' href="https://github.com/reconurge/flowsint">GitHub</a>
|
||||
<a className="h-full w-full" target="_blank" href="https://github.com/reconurge/flowsint">
|
||||
GitHub
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<a className='h-full w-full' target='_blank' href="https://github.com/reconurge/flowsint/issues">Support</a>
|
||||
<a
|
||||
className="h-full w-full"
|
||||
target="_blank"
|
||||
href="https://github.com/reconurge/flowsint/issues"
|
||||
>
|
||||
Support
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled>API</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => setImportModalOpen(true)}>
|
||||
<Upload /> Import entities
|
||||
<Upload /> Import entities
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleDelete} variant="destructive">
|
||||
|
||||
@@ -1,15 +1,29 @@
|
||||
import { useGraphStore } from '@/stores/graph-store'
|
||||
import { useNodesDisplaySettings, type ItemType } from '@/stores/node-display-settings'
|
||||
import { MapFromAddress } from './map'
|
||||
import { LocationPoint } from './map'
|
||||
const MapPanel = () => {
|
||||
const nodes = useGraphStore((state) => state.nodes)
|
||||
const nodeColors = useNodesDisplaySettings((s) => s.colors)
|
||||
const locationNodes = nodes
|
||||
.filter((node) => node.data.type === 'location' || (node.data.latitude && node.data.longitude))
|
||||
.filter(
|
||||
(node) =>
|
||||
node.nodeType === 'location' ||
|
||||
(node.nodeProperties.latitude && node.nodeProperties.longitude)
|
||||
)
|
||||
.map((node) => ({
|
||||
lat: node.data.latitude || 0,
|
||||
lon: node.data.longitude || 0,
|
||||
address: node.data.address || '',
|
||||
label: node.data.label || ''
|
||||
nodeId: node.id,
|
||||
lat: node.nodeProperties.latitude != null ? Number(node.nodeProperties.latitude) : undefined,
|
||||
lon:
|
||||
node.nodeProperties.longitude != null ? Number(node.nodeProperties.longitude) : undefined,
|
||||
address:
|
||||
[node.nodeProperties.address, node.nodeProperties.city, node.nodeProperties.country]
|
||||
.filter(Boolean)
|
||||
.join(', ') || '',
|
||||
label: node.nodeProperties.nodeLabel || node.nodeProperties.address || '',
|
||||
nodeType: node.nodeType,
|
||||
color: node.nodeColor || nodeColors[node.nodeType as ItemType] || null,
|
||||
icon: node.nodeIcon
|
||||
}))
|
||||
return (
|
||||
<div className="w-full grow h-full">
|
||||
|
||||
@@ -1,16 +1,35 @@
|
||||
import React from 'react'
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
// @ts-ignore
|
||||
import L from 'leaflet'
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
import Map, { Layer, Popup, Source } from 'react-map-gl/maplibre'
|
||||
import maplibregl from 'maplibre-gl'
|
||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||
import { useTheme } from '../theme-provider'
|
||||
import { MapPin } from 'lucide-react'
|
||||
import { Globe, Map as MapIcon, MapPin } from 'lucide-react'
|
||||
import LoadingSpinner from '@/components/shared/loader'
|
||||
import { preloadImage, getCachedImage } from '@/components/sketches/graph/utils/image-cache'
|
||||
import { useGraphControls } from '@/stores/graph-controls-store'
|
||||
import { useGraphStore } from '@/stores/graph-store'
|
||||
import { Switch } from '../ui/switch'
|
||||
import { Label } from '../ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
|
||||
import type { MapRef } from 'react-map-gl/maplibre'
|
||||
import type {
|
||||
GeoJSONSource,
|
||||
MapMouseEvent,
|
||||
StyleImageInterface,
|
||||
StyleSpecification
|
||||
} from 'maplibre-gl'
|
||||
import type { FeatureCollection, Point } from 'geojson'
|
||||
|
||||
export type LocationPoint = {
|
||||
nodeId?: string
|
||||
lat?: number
|
||||
lon?: number
|
||||
address?: string
|
||||
label?: string
|
||||
nodeType?: string
|
||||
color?: string | null
|
||||
icon?: string | null
|
||||
}
|
||||
|
||||
type MapFromAddressProps = {
|
||||
@@ -20,38 +39,192 @@ type MapFromAddressProps = {
|
||||
centerOnFirst?: boolean
|
||||
}
|
||||
|
||||
type MapStyleVariant = 'standard' | 'voyager' | 'satellite'
|
||||
|
||||
const VECTOR_STYLES: Record<'standard' | 'voyager', { dark: string; light: string }> = {
|
||||
standard: {
|
||||
dark: 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json',
|
||||
light: 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json'
|
||||
},
|
||||
voyager: {
|
||||
dark: 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json',
|
||||
light: 'https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json'
|
||||
}
|
||||
}
|
||||
|
||||
const SATELLITE_STYLE = {
|
||||
version: 8 as const,
|
||||
glyphs: 'https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf',
|
||||
sources: {
|
||||
'esri-satellite': {
|
||||
type: 'raster' as const,
|
||||
tiles: [
|
||||
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'
|
||||
],
|
||||
tileSize: 256,
|
||||
maxzoom: 19
|
||||
// attribution: '© Esri'
|
||||
}
|
||||
},
|
||||
layers: [
|
||||
{
|
||||
id: 'esri-satellite-layer',
|
||||
type: 'raster' as const,
|
||||
source: 'esri-satellite',
|
||||
minzoom: 0,
|
||||
maxzoom: 22
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
function resolveMapStyle(
|
||||
variant: MapStyleVariant,
|
||||
theme: 'dark' | 'light'
|
||||
): string | StyleSpecification {
|
||||
if (variant === 'satellite') return SATELLITE_STYLE
|
||||
return VECTOR_STYLES[variant][theme]
|
||||
}
|
||||
|
||||
function createPulsingDot(isDark: boolean): StyleImageInterface {
|
||||
const size = 100
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = size
|
||||
canvas.height = size
|
||||
const context = canvas.getContext('2d')!
|
||||
let animationTime = 0
|
||||
|
||||
return {
|
||||
width: size,
|
||||
height: size,
|
||||
data: new Uint8Array(size * size * 4),
|
||||
onAdd(this: StyleImageInterface & { map?: maplibregl.Map }) {
|
||||
this.map = undefined
|
||||
},
|
||||
render(this: StyleImageInterface & { map?: maplibregl.Map }) {
|
||||
const duration = 1500
|
||||
animationTime = (animationTime + 1) % duration
|
||||
|
||||
const t = animationTime / duration
|
||||
const radius = (size / 2) * 0.3
|
||||
const outerRadius = (size / 2) * 0.3 + 15 * t
|
||||
const opacity = 1 - t
|
||||
|
||||
context.clearRect(0, 0, size, size)
|
||||
|
||||
// Outer pulsing ring
|
||||
context.beginPath()
|
||||
context.arc(size / 2, size / 2, outerRadius, 0, Math.PI * 2)
|
||||
const ringColor = isDark
|
||||
? `rgba(99, 102, 241, ${opacity * 0.4})`
|
||||
: `rgba(79, 70, 229, ${opacity * 0.4})`
|
||||
context.strokeStyle = ringColor
|
||||
context.lineWidth = 2.5
|
||||
context.stroke()
|
||||
|
||||
// Inner solid dot
|
||||
context.beginPath()
|
||||
context.arc(size / 2, size / 2, radius, 0, Math.PI * 2)
|
||||
const gradient = context.createRadialGradient(
|
||||
size / 2,
|
||||
size / 2,
|
||||
0,
|
||||
size / 2,
|
||||
size / 2,
|
||||
radius
|
||||
)
|
||||
if (isDark) {
|
||||
gradient.addColorStop(0, 'rgba(129, 140, 248, 1)')
|
||||
gradient.addColorStop(1, 'rgba(99, 102, 241, 1)')
|
||||
} else {
|
||||
gradient.addColorStop(0, 'rgba(99, 102, 241, 1)')
|
||||
gradient.addColorStop(1, 'rgba(67, 56, 202, 1)')
|
||||
}
|
||||
context.fillStyle = gradient
|
||||
context.fill()
|
||||
|
||||
// Bright center highlight
|
||||
context.beginPath()
|
||||
context.arc(size / 2 - radius * 0.2, size / 2 - radius * 0.2, radius * 0.35, 0, Math.PI * 2)
|
||||
context.fillStyle = 'rgba(255, 255, 255, 0.3)'
|
||||
context.fill()
|
||||
|
||||
this.data = context.getImageData(0, 0, size, size).data as unknown as Uint8Array
|
||||
return true // Repaint every frame
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function useResolvedTheme() {
|
||||
const { theme } = useTheme()
|
||||
const [resolved, setResolved] = useState<'dark' | 'light'>(() => {
|
||||
if (theme === 'system') {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
}
|
||||
return theme
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (theme !== 'system') {
|
||||
setResolved(theme)
|
||||
return
|
||||
}
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
const handler = (e: MediaQueryListEvent) => setResolved(e.matches ? 'dark' : 'light')
|
||||
setResolved(mq.matches ? 'dark' : 'light')
|
||||
mq.addEventListener('change', handler)
|
||||
return () => mq.removeEventListener('change', handler)
|
||||
}, [theme])
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
export const MapFromAddress: React.FC<MapFromAddressProps> = ({
|
||||
locations,
|
||||
height = '400px',
|
||||
zoom = 15,
|
||||
centerOnFirst = true
|
||||
}) => {
|
||||
// Get locations that need geocoding
|
||||
const mapRef = useRef<MapRef>(null)
|
||||
const resolvedTheme = useResolvedTheme()
|
||||
const [popupInfo, setPopupInfo] = useState<{
|
||||
lng: number
|
||||
lat: number
|
||||
label: string
|
||||
color: string
|
||||
nodeType: string
|
||||
} | null>(null)
|
||||
const [isGlobe, setIsGlobe] = useState(true)
|
||||
const isGlobeRef = useRef(true)
|
||||
const [styleVariant, setStyleVariant] = useState<MapStyleVariant>('standard')
|
||||
const pulsingDotRef = useRef<StyleImageInterface | null>(null)
|
||||
const isDarkStyle = resolvedTheme === 'dark' || styleVariant === 'satellite'
|
||||
|
||||
// Geocoding
|
||||
const locationsToGeocode = locations.filter(
|
||||
(loc) => loc.lat === undefined && loc.lon === undefined && loc.address
|
||||
)
|
||||
const { theme } = useTheme()
|
||||
// Single query for all geocoding
|
||||
|
||||
const geocodeQuery = useQuery({
|
||||
queryKey: ['geocode', locationsToGeocode.map((loc) => loc.address)],
|
||||
queryFn: async () => {
|
||||
const results: ({ lat: number; lon: number } | null)[] = []
|
||||
for (const location of locationsToGeocode) {
|
||||
if (!location.address) continue
|
||||
|
||||
const res = await fetch(
|
||||
`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(location.address)}`
|
||||
)
|
||||
const json = await res.json()
|
||||
if (!json || json.length === 0) {
|
||||
results.push(null)
|
||||
} else {
|
||||
results.push({
|
||||
lat: parseFloat(json[0].lat),
|
||||
lon: parseFloat(json[0].lon)
|
||||
})
|
||||
}
|
||||
}
|
||||
const results = await Promise.all(
|
||||
locationsToGeocode.map(async (location) => {
|
||||
if (!location.address) return null
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(location.address)}`
|
||||
)
|
||||
const json = await res.json()
|
||||
if (!json || json.length === 0) return null
|
||||
return {
|
||||
lat: parseFloat(json[0].lat),
|
||||
lon: parseFloat(json[0].lon)
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
)
|
||||
return results
|
||||
},
|
||||
enabled: locationsToGeocode.length > 0
|
||||
@@ -59,7 +232,6 @@ export const MapFromAddress: React.FC<MapFromAddressProps> = ({
|
||||
|
||||
// Combine all locations with their coordinates
|
||||
const processedLocations = locations.map((location) => {
|
||||
// If location already has coordinates
|
||||
if (location.lat !== undefined && location.lon !== undefined) {
|
||||
return {
|
||||
...location,
|
||||
@@ -68,8 +240,6 @@ export const MapFromAddress: React.FC<MapFromAddressProps> = ({
|
||||
isError: false
|
||||
}
|
||||
}
|
||||
|
||||
// If location needs geocoding
|
||||
const geocodeIndex = locationsToGeocode.findIndex((loc) => loc.address === location.address)
|
||||
if (geocodeIndex !== -1 && geocodeQuery.data) {
|
||||
const geocoded = geocodeQuery.data[geocodeIndex]
|
||||
@@ -80,8 +250,6 @@ export const MapFromAddress: React.FC<MapFromAddressProps> = ({
|
||||
isError: geocodeQuery.isError
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for locations without address
|
||||
return {
|
||||
...location,
|
||||
coordinates: null,
|
||||
@@ -90,92 +258,252 @@ export const MapFromAddress: React.FC<MapFromAddressProps> = ({
|
||||
}
|
||||
})
|
||||
|
||||
// Get valid coordinates for map bounds
|
||||
const validCoordinates = processedLocations
|
||||
.filter((location) => location.coordinates)
|
||||
.map((location) => location.coordinates!)
|
||||
const validLocations = useMemo(
|
||||
() => processedLocations.filter((l) => l.coordinates),
|
||||
[JSON.stringify(processedLocations)]
|
||||
)
|
||||
|
||||
const mapId = `leaflet-map-${btoa(JSON.stringify(locations)).replace(/[^a-zA-Z0-9]/g, '')}`
|
||||
const DEFAULT_MARKER_COLOR = '#6366f1'
|
||||
|
||||
React.useEffect(() => {
|
||||
if (validCoordinates.length === 0) return
|
||||
// Build GeoJSON
|
||||
const geojson = useMemo<FeatureCollection<Point>>(
|
||||
() => ({
|
||||
type: 'FeatureCollection',
|
||||
features: validLocations.map((loc) => {
|
||||
const lat = Number(loc.coordinates!.lat)
|
||||
const lon = Number(loc.coordinates!.lon)
|
||||
const color = loc.color || DEFAULT_MARKER_COLOR
|
||||
const nodeType = loc.nodeType || ''
|
||||
return {
|
||||
type: 'Feature' as const,
|
||||
properties: {
|
||||
nodeId: loc.nodeId || '',
|
||||
label: loc.label || loc.address || `${lat.toFixed(6)}, ${lon.toFixed(6)}`,
|
||||
color,
|
||||
icon: nodeType ? `marker-icon-${nodeType}` : '',
|
||||
hasIcon: nodeType ? 'true' : 'false',
|
||||
nodeType
|
||||
},
|
||||
geometry: {
|
||||
type: 'Point' as const,
|
||||
coordinates: [lon, lat]
|
||||
}
|
||||
}
|
||||
})
|
||||
}),
|
||||
[validLocations]
|
||||
)
|
||||
|
||||
// Create custom icon using SVG
|
||||
const customIcon = L.divIcon({
|
||||
html: `
|
||||
<div style="
|
||||
background-color: ${theme === 'dark' ? '#3b82f6' : '#2563eb'};
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50% 50% 50% 0;
|
||||
transform: rotate(-45deg);
|
||||
border: 2px solid white;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
|
||||
"></div>
|
||||
`,
|
||||
className: 'custom-marker',
|
||||
iconSize: [24, 24],
|
||||
iconAnchor: [12, 24],
|
||||
popupAnchor: [0, -24]
|
||||
})
|
||||
// Generate and register icon images for each unique node type
|
||||
const registerMapImages = useCallback(async () => {
|
||||
const map = mapRef.current?.getMap()
|
||||
if (!map) return
|
||||
|
||||
const map = L.map(mapId)
|
||||
|
||||
const source = theme === 'dark' ? 'alidade_smooth_dark' : 'alidade_smooth'
|
||||
L.tileLayer('https://tiles.stadiamaps.com/tiles/{source}/{z}/{x}/{y}.{ext}', {
|
||||
// attribution: '© <a href="https://www.stadiamaps.com/" target="_blank">Stadia Maps</a> © <a href="https://openmaptiles.org/" target="_blank">OpenMapTiles</a> © <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||
ext: 'png',
|
||||
source: source
|
||||
}).addTo(map)
|
||||
|
||||
// Add markers for each location
|
||||
const markers: L.Marker[] = []
|
||||
processedLocations.forEach((location) => {
|
||||
if (!location.coordinates) return
|
||||
|
||||
const marker = L.marker([location.coordinates.lat, location.coordinates.lon], { icon: customIcon }).addTo(map)
|
||||
|
||||
// Create popup text
|
||||
const popupText =
|
||||
location.label ||
|
||||
location.address ||
|
||||
`${location.coordinates.lat.toFixed(6)}, ${location.coordinates.lon.toFixed(6)}`
|
||||
|
||||
marker.bindPopup(popupText)
|
||||
markers.push(marker)
|
||||
})
|
||||
|
||||
// Set map view
|
||||
if (centerOnFirst && validCoordinates.length > 0) {
|
||||
// Center on first coordinate
|
||||
map.setView([validCoordinates[0].lat, validCoordinates[0].lon], zoom)
|
||||
} else if (validCoordinates.length > 1) {
|
||||
// Fit bounds to include all markers
|
||||
const group = new L.featureGroup(markers)
|
||||
map.fitBounds(group.getBounds().pad(0.1))
|
||||
} else if (validCoordinates.length === 1) {
|
||||
// Single point
|
||||
map.setView([validCoordinates[0].lat, validCoordinates[0].lon], zoom)
|
||||
// Register pulsing dot
|
||||
if (map.hasImage('pulsing-dot')) {
|
||||
map.removeImage('pulsing-dot')
|
||||
}
|
||||
pulsingDotRef.current = createPulsingDot(isDarkStyle)
|
||||
map.addImage('pulsing-dot', pulsingDotRef.current)
|
||||
|
||||
// Collect unique node types from locations
|
||||
const uniqueTypes = new Set<string>()
|
||||
validLocations.forEach((loc) => {
|
||||
if (loc.nodeType) uniqueTypes.add(loc.nodeType)
|
||||
})
|
||||
|
||||
// Preload and register each icon via the shared image-cache
|
||||
const promises = Array.from(uniqueTypes).map(async (nodeType) => {
|
||||
const imageId = `marker-icon-${nodeType}`
|
||||
if (map.hasImage(imageId)) map.removeImage(imageId)
|
||||
try {
|
||||
const img = await preloadImage(nodeType, '#ffffff')
|
||||
if (!map.hasImage(imageId)) {
|
||||
map.addImage(imageId, img, { sdf: false })
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[map] Failed to load icon for type: ${nodeType}`, err)
|
||||
}
|
||||
})
|
||||
|
||||
await Promise.all(promises)
|
||||
map.triggerRepaint()
|
||||
}, [isDarkStyle, validLocations])
|
||||
|
||||
// Fly to markers on data load
|
||||
useEffect(() => {
|
||||
const map = mapRef.current?.getMap()
|
||||
if (!map || validLocations.length === 0) return
|
||||
|
||||
// Wait for map to be loaded
|
||||
const doFly = () => {
|
||||
if (validLocations.length === 1 || centerOnFirst) {
|
||||
const loc = validLocations[0].coordinates!
|
||||
map.flyTo({
|
||||
center: [Number(loc.lon), Number(loc.lat)],
|
||||
zoom,
|
||||
duration: 2000
|
||||
})
|
||||
} else {
|
||||
const bounds = new maplibregl.LngLatBounds()
|
||||
validLocations.forEach((l) => {
|
||||
bounds.extend([Number(l.coordinates!.lon), Number(l.coordinates!.lat)])
|
||||
})
|
||||
map.fitBounds(bounds, { padding: 60, duration: 2000 })
|
||||
}
|
||||
}
|
||||
|
||||
if (map.loaded()) {
|
||||
doFly()
|
||||
} else {
|
||||
map.once('load', doFly)
|
||||
}
|
||||
}, [validLocations, zoom, centerOnFirst])
|
||||
|
||||
// Handle click on clusters and points
|
||||
const setCurrentNodeId = useGraphStore((s) => s.setCurrentNodeId)
|
||||
|
||||
const onClick = useCallback(
|
||||
(e: MapMouseEvent) => {
|
||||
const map = mapRef.current?.getMap()
|
||||
if (!map) return
|
||||
|
||||
// Check for cluster click
|
||||
const clusterFeatures = map.queryRenderedFeatures(e.point, { layers: ['clusters'] })
|
||||
if (clusterFeatures.length > 0) {
|
||||
const feature = clusterFeatures[0]
|
||||
const clusterId = feature.properties?.cluster_id
|
||||
const source = map.getSource('locations') as GeoJSONSource
|
||||
source.getClusterExpansionZoom(clusterId).then((expansionZoom) => {
|
||||
const geo = feature.geometry as Point
|
||||
map.flyTo({
|
||||
center: geo.coordinates as [number, number],
|
||||
zoom: expansionZoom,
|
||||
duration: 500
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Check for unclustered point click
|
||||
const pointFeatures = map.queryRenderedFeatures(e.point, {
|
||||
layers: ['unclustered-point', 'unclustered-point-icon']
|
||||
})
|
||||
if (pointFeatures.length > 0) {
|
||||
const feature = pointFeatures[0]
|
||||
const geo = feature.geometry as Point
|
||||
const nodeId = feature.properties?.nodeId
|
||||
if (nodeId) setCurrentNodeId(nodeId)
|
||||
setPopupInfo({
|
||||
lng: geo.coordinates[0],
|
||||
lat: geo.coordinates[1],
|
||||
label: feature.properties?.label || '',
|
||||
color: feature.properties?.color || '#6366f1',
|
||||
nodeType: feature.properties?.nodeType || ''
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Click elsewhere → dismiss popup and deselect
|
||||
setPopupInfo(null)
|
||||
setCurrentNodeId(null)
|
||||
},
|
||||
[setCurrentNodeId]
|
||||
)
|
||||
|
||||
const applyProjection = useCallback(() => {
|
||||
const map = mapRef.current?.getMap()
|
||||
if (!map) return
|
||||
;(map as any).setProjection({ type: isGlobeRef.current ? 'globe' : 'mercator' })
|
||||
}, [])
|
||||
|
||||
const handleGlobeToggle = useCallback(
|
||||
(checked: boolean) => {
|
||||
setIsGlobe(checked)
|
||||
isGlobeRef.current = checked
|
||||
applyProjection()
|
||||
},
|
||||
[applyProjection]
|
||||
)
|
||||
|
||||
const onMapLoad = useCallback(() => {
|
||||
const map = mapRef.current?.getMap()
|
||||
if (!map) return
|
||||
registerMapImages()
|
||||
applyProjection()
|
||||
map.on('style.load', () => {
|
||||
registerMapImages()
|
||||
applyProjection()
|
||||
})
|
||||
// Handle missing images on-demand to avoid race conditions
|
||||
map.on('styleimagemissing', async (e: { id: string }) => {
|
||||
const id = e.id
|
||||
if (id === 'pulsing-dot' || map.hasImage(id)) return
|
||||
const match = id.match(/^marker-icon-(.+)$/)
|
||||
if (!match) return
|
||||
const nodeType = match[1]
|
||||
try {
|
||||
const img = await preloadImage(nodeType, '#ffffff')
|
||||
if (!map.hasImage(id)) {
|
||||
map.addImage(id, img, { sdf: false })
|
||||
}
|
||||
} catch {
|
||||
// Icon not available, ignore silently
|
||||
}
|
||||
})
|
||||
}, [registerMapImages, applyProjection])
|
||||
|
||||
// Wire up zoom actions to the shared toolbar controls
|
||||
const setActions = useGraphControls((s) => s.setActions)
|
||||
|
||||
useEffect(() => {
|
||||
setActions({
|
||||
zoomIn: () => {
|
||||
const map = mapRef.current?.getMap()
|
||||
if (map) map.zoomIn({ duration: 300 })
|
||||
},
|
||||
zoomOut: () => {
|
||||
const map = mapRef.current?.getMap()
|
||||
if (map) map.zoomOut({ duration: 300 })
|
||||
},
|
||||
zoomToFit: () => {
|
||||
const map = mapRef.current?.getMap()
|
||||
if (!map || validLocations.length === 0) return
|
||||
if (validLocations.length === 1) {
|
||||
const loc = validLocations[0].coordinates!
|
||||
map.flyTo({ center: [Number(loc.lon), Number(loc.lat)], zoom, duration: 500 })
|
||||
} else {
|
||||
const bounds = new maplibregl.LngLatBounds()
|
||||
validLocations.forEach((l) => {
|
||||
bounds.extend([Number(l.coordinates!.lon), Number(l.coordinates!.lat)])
|
||||
})
|
||||
map.fitBounds(bounds, { padding: 60, duration: 500 })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
map.remove() // cleanup
|
||||
setActions({
|
||||
zoomIn: () => {},
|
||||
zoomOut: () => {},
|
||||
zoomToFit: () => {}
|
||||
})
|
||||
}
|
||||
}, [validCoordinates, mapId, zoom, centerOnFirst, theme])
|
||||
}, [setActions, validLocations, zoom])
|
||||
|
||||
// Show loading if geocoding is in progress
|
||||
// Loading state
|
||||
if (geocodeQuery.isLoading) {
|
||||
return (
|
||||
<div className="h-full w-full flex items-center justify-center">
|
||||
<div className="text-center space-y-3">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
|
||||
<LoadingSpinner size="lg" className="mx-auto" />
|
||||
<p className="text-muted-foreground">Loading map data...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Show error if geocoding failed
|
||||
// Error state
|
||||
if (geocodeQuery.isError) {
|
||||
return (
|
||||
<div className="h-full w-full flex items-center justify-center">
|
||||
@@ -204,8 +532,8 @@ export const MapFromAddress: React.FC<MapFromAddressProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
// Don't render if we don't have any valid coordinates
|
||||
if (validCoordinates.length === 0) {
|
||||
// Empty state
|
||||
if (validLocations.length === 0) {
|
||||
return (
|
||||
<div className="w-full flex items-center justify-center h-full">
|
||||
<div className="text-center space-y-4">
|
||||
@@ -219,5 +547,181 @@ export const MapFromAddress: React.FC<MapFromAddressProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
return <div id={mapId} style={{ minHeight: height, height: '100%', width: '100%', zIndex: 0 }} />
|
||||
const mapStyle = resolveMapStyle(styleVariant, resolvedTheme)
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: height, height: '100%', width: '100%' }} className="relative">
|
||||
<div className="absolute bottom-6 left-3 z-10 flex flex-col gap-2">
|
||||
{/*<div className="flex items-center gap-2 rounded-lg bg-card/90 backdrop-blur-sm border border-border px-3 py-2 shadow-sm">
|
||||
<MapIcon className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
<Select value={styleVariant} onValueChange={(v) => setStyleVariant(v as MapStyleVariant)}>
|
||||
<SelectTrigger className="h-6 w-[110px] border-0 bg-transparent text-xs px-1 py-0 shadow-none focus:ring-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="standard">Standard</SelectItem>
|
||||
<SelectItem value="voyager">Voyager</SelectItem>
|
||||
<SelectItem value="satellite">Satellite</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>*/}
|
||||
<div className="flex items-center gap-2 rounded-lg bg-card/90 backdrop-blur-sm border border-border px-3 py-2 shadow-sm">
|
||||
<Globe className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Label
|
||||
htmlFor="globe-toggle"
|
||||
className="text-xs text-muted-foreground cursor-pointer select-none"
|
||||
>
|
||||
Globe
|
||||
</Label>
|
||||
<Switch
|
||||
id="globe-toggle"
|
||||
checked={isGlobe}
|
||||
onCheckedChange={handleGlobeToggle}
|
||||
className="scale-75"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Map
|
||||
ref={mapRef}
|
||||
initialViewState={{
|
||||
longitude: Number(validLocations[0].coordinates!.lon),
|
||||
latitude: Number(validLocations[0].coordinates!.lat),
|
||||
zoom: 2
|
||||
}}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
mapStyle={mapStyle}
|
||||
onClick={onClick}
|
||||
onLoad={onMapLoad}
|
||||
interactiveLayerIds={['clusters', 'unclustered-point', 'unclustered-point-icon']}
|
||||
cursor="auto"
|
||||
attributionControl={false}
|
||||
>
|
||||
<Source
|
||||
id="locations"
|
||||
type="geojson"
|
||||
data={geojson}
|
||||
cluster={true}
|
||||
clusterMaxZoom={14}
|
||||
clusterRadius={50}
|
||||
>
|
||||
{/* Cluster circles */}
|
||||
<Layer
|
||||
id="clusters"
|
||||
type="circle"
|
||||
filter={['has', 'point_count']}
|
||||
paint={{
|
||||
'circle-color': [
|
||||
'step',
|
||||
['get', 'point_count'],
|
||||
'#e8713a', // primary
|
||||
10,
|
||||
'#d4622e', // primary darker
|
||||
50,
|
||||
'#c05524' // primary darkest
|
||||
],
|
||||
'circle-radius': ['step', ['get', 'point_count'], 18, 10, 24, 50, 32],
|
||||
'circle-stroke-width': 2,
|
||||
'circle-stroke-color': isDarkStyle
|
||||
? 'rgba(255, 255, 255, 0.15)'
|
||||
: 'rgba(0, 0, 0, 0.1)'
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Cluster count labels */}
|
||||
<Layer
|
||||
id="cluster-count"
|
||||
type="symbol"
|
||||
filter={['has', 'point_count']}
|
||||
layout={{
|
||||
'text-field': '{point_count_abbreviated}',
|
||||
'text-font': ['Open Sans Bold'],
|
||||
'text-size': 13
|
||||
}}
|
||||
paint={{
|
||||
'text-color': '#ffffff'
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Unclustered point outer glow */}
|
||||
<Layer
|
||||
id="unclustered-point-glow"
|
||||
type="circle"
|
||||
filter={['!', ['has', 'point_count']]}
|
||||
paint={{
|
||||
'circle-radius': 18,
|
||||
'circle-color': ['get', 'color'],
|
||||
'circle-opacity': 0.2,
|
||||
'circle-blur': 0.6
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Unclustered point solid circle */}
|
||||
<Layer
|
||||
id="unclustered-point"
|
||||
type="circle"
|
||||
filter={['!', ['has', 'point_count']]}
|
||||
paint={{
|
||||
'circle-radius': 12,
|
||||
'circle-color': ['get', 'color'],
|
||||
'circle-stroke-width': 2,
|
||||
'circle-stroke-color': isDarkStyle
|
||||
? 'rgba(255, 255, 255, 0.25)'
|
||||
: 'rgba(255, 255, 255, 0.8)'
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Icon overlay on points that have icons */}
|
||||
<Layer
|
||||
id="unclustered-point-icon"
|
||||
type="symbol"
|
||||
filter={['all', ['!', ['has', 'point_count']], ['==', ['get', 'hasIcon'], 'true']]}
|
||||
layout={{
|
||||
'icon-image': ['get', 'icon'],
|
||||
'icon-size': 0.6,
|
||||
'icon-allow-overlap': true
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
|
||||
{popupInfo && (
|
||||
<Popup
|
||||
longitude={popupInfo.lng}
|
||||
latitude={popupInfo.lat}
|
||||
anchor="bottom"
|
||||
onClose={() => setPopupInfo(null)}
|
||||
closeButton={true}
|
||||
closeOnClick={false}
|
||||
className="maplibre-popup"
|
||||
>
|
||||
<div className="flex flex-col gap-1.5 px-1 py-0.5 min-w-[140px]">
|
||||
<div className="flex items-center gap-2">
|
||||
{(() => {
|
||||
const cachedImg = popupInfo.nodeType
|
||||
? getCachedImage(popupInfo.nodeType, '#ffffff')
|
||||
: undefined
|
||||
return cachedImg ? (
|
||||
<div
|
||||
className="rounded-full flex items-center justify-center shrink-0"
|
||||
style={{ background: popupInfo.color, width: 24, height: 24 }}
|
||||
>
|
||||
<img src={cachedImg.src} width={14} height={14} alt="" />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="rounded-full shrink-0"
|
||||
style={{ background: popupInfo.color, width: 10, height: 10 }}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
<span className="text-sm font-medium text-foreground">{popupInfo.label}</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground font-mono">
|
||||
{popupInfo.lat.toFixed(6)}, {popupInfo.lng.toFixed(6)}
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
)}
|
||||
</Map>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Check } from 'lucide-react'
|
||||
|
||||
const PRESET_COLORS = [
|
||||
// Row 1 - Muted naturals
|
||||
'#9B8E7E',
|
||||
'#A89F91',
|
||||
'#8E9E8C',
|
||||
'#7E9BA3',
|
||||
'#8A8DA3',
|
||||
'#9E8A9E',
|
||||
'#A3908A',
|
||||
'#8C9696',
|
||||
'#A39882',
|
||||
'#7E8F9E',
|
||||
// Row 2 - Soft pastels
|
||||
'#66A892',
|
||||
'#8E7CC3',
|
||||
'#5AA1C8',
|
||||
'#4CB5AE',
|
||||
'#8B83C1',
|
||||
'#BCA18A',
|
||||
'#4C8EDA',
|
||||
'#A76DAA',
|
||||
'#D97474',
|
||||
'#80BF80',
|
||||
// Row 3 - Warm tones
|
||||
'#D4B030',
|
||||
'#E3A857',
|
||||
'#CC7A7A',
|
||||
'#D279A6',
|
||||
'#E98973',
|
||||
'#C7BF50',
|
||||
'#A8BF50',
|
||||
'#6FA8DC',
|
||||
'#7C9CBF',
|
||||
'#897FC9'
|
||||
]
|
||||
|
||||
interface ColorPickerProps {
|
||||
value: string
|
||||
onChange: (color: string) => void
|
||||
/** Size of the trigger swatch */
|
||||
triggerSize?: 'sm' | 'md'
|
||||
/** Popover alignment */
|
||||
align?: 'start' | 'center' | 'end'
|
||||
}
|
||||
|
||||
export function ColorPicker({
|
||||
value,
|
||||
onChange,
|
||||
triggerSize = 'md',
|
||||
align = 'start'
|
||||
}: ColorPickerProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [customColor, setCustomColor] = useState(value)
|
||||
|
||||
useEffect(() => {
|
||||
setCustomColor(value)
|
||||
}, [value])
|
||||
|
||||
const handleCustomColorChange = (hex: string) => {
|
||||
setCustomColor(hex)
|
||||
if (/^#[0-9A-Fa-f]{6}$/.test(hex)) {
|
||||
onChange(hex)
|
||||
}
|
||||
}
|
||||
|
||||
const sizeClasses = triggerSize === 'sm' ? 'h-6 w-6 rounded-md' : 'h-8 w-8 rounded-lg'
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen} modal>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
sizeClasses,
|
||||
'border border-border/50 transition-colors',
|
||||
'hover:border-border',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
|
||||
)}
|
||||
style={{ backgroundColor: value }}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[280px] p-3" align={align}>
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs font-medium text-muted-foreground">Color</p>
|
||||
<div className="grid grid-cols-10 gap-1.5">
|
||||
{PRESET_COLORS.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
className={cn(
|
||||
'h-6 w-6 rounded-md transition-colors',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
value === color && 'ring-2 ring-foreground ring-offset-2 ring-offset-background'
|
||||
)}
|
||||
style={{ backgroundColor: color }}
|
||||
onClick={() => {
|
||||
onChange(color)
|
||||
setCustomColor(color)
|
||||
}}
|
||||
>
|
||||
{value === color && <Check className="h-3 w-3 text-white mx-auto" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={customColor}
|
||||
onChange={(e) => {
|
||||
setCustomColor(e.target.value)
|
||||
onChange(e.target.value)
|
||||
}}
|
||||
className="h-8 w-8 rounded-md border border-border/50 shrink-0 cursor-pointer bg-transparent p-0.5"
|
||||
/>
|
||||
<Input
|
||||
value={customColor}
|
||||
onChange={(e) => handleCustomColorChange(e.target.value)}
|
||||
placeholder="#000000"
|
||||
className="h-8 text-xs font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from './popup'
|
||||
export { NodeIconTrigger } from './trigger'
|
||||
@@ -0,0 +1,382 @@
|
||||
import { Dispatch, SetStateAction, useMemo, useRef, useState, useEffect } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import * as LucideIcons from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Curated list of popular and useful Lucide icons
|
||||
const AVAILABLE_ICONS = [
|
||||
'User',
|
||||
'Users',
|
||||
'UserPlus',
|
||||
'UserMinus',
|
||||
'UserCheck',
|
||||
'UserX',
|
||||
'UserCog',
|
||||
'UserCircle',
|
||||
'UserCircle2',
|
||||
'Phone',
|
||||
'PhoneCall',
|
||||
'PhoneIncoming',
|
||||
'PhoneOutgoing',
|
||||
'Smartphone',
|
||||
'Mail',
|
||||
'MailOpen',
|
||||
'Inbox',
|
||||
'Send',
|
||||
'MapPin',
|
||||
'Map',
|
||||
'Navigation',
|
||||
'Globe',
|
||||
'Globe2',
|
||||
'Network',
|
||||
'Wifi',
|
||||
'WifiOff',
|
||||
'Share2',
|
||||
'Link',
|
||||
'Link2',
|
||||
'Unlink',
|
||||
'Building',
|
||||
'Building2',
|
||||
'Home',
|
||||
'Briefcase',
|
||||
'Landmark',
|
||||
'Store',
|
||||
'Factory',
|
||||
'Warehouse',
|
||||
'Car',
|
||||
'Bike',
|
||||
'Ship',
|
||||
'Plane',
|
||||
'Rocket',
|
||||
'Train',
|
||||
'Bus',
|
||||
'Truck',
|
||||
'FileText',
|
||||
'File',
|
||||
'Files',
|
||||
'FileCode',
|
||||
'FileJson',
|
||||
'FileLock',
|
||||
'FileKey',
|
||||
'Folder',
|
||||
'FolderOpen',
|
||||
'DollarSign',
|
||||
'Wallet',
|
||||
'CreditCard',
|
||||
'Coins',
|
||||
'Banknote',
|
||||
'TrendingUp',
|
||||
'Calendar',
|
||||
'CalendarDays',
|
||||
'Clock',
|
||||
'Timer',
|
||||
'Image',
|
||||
'Images',
|
||||
'Video',
|
||||
'Film',
|
||||
'Music',
|
||||
'Mic',
|
||||
'Camera',
|
||||
'GraduationCap',
|
||||
'BookOpen',
|
||||
'Library',
|
||||
'School',
|
||||
'Activity',
|
||||
'TrendingUp',
|
||||
'BarChart',
|
||||
'LineChart',
|
||||
'PieChart',
|
||||
'Fingerprint',
|
||||
'ScanFace',
|
||||
'Key',
|
||||
'Lock',
|
||||
'Unlock',
|
||||
'Shield',
|
||||
'ShieldAlert',
|
||||
'ShieldCheck',
|
||||
'Bug',
|
||||
'AlertTriangle',
|
||||
'AlertCircle',
|
||||
'AlertOctagon',
|
||||
'Target',
|
||||
'Crosshair',
|
||||
'Database',
|
||||
'Server',
|
||||
'HardDrive',
|
||||
'Cpu',
|
||||
'MessageSquare',
|
||||
'MessageCircle',
|
||||
'Mail',
|
||||
'Sword',
|
||||
'Star',
|
||||
'StarHalf',
|
||||
'Heart',
|
||||
'ThumbsUp',
|
||||
'ThumbsDown',
|
||||
'Circle',
|
||||
'Square',
|
||||
'Triangle',
|
||||
'Hexagon',
|
||||
'Diamond',
|
||||
'Award',
|
||||
'Trophy',
|
||||
'Medal',
|
||||
'Gift',
|
||||
'Package',
|
||||
'Archive',
|
||||
'Plug',
|
||||
'Cable',
|
||||
'Wifi',
|
||||
'Bluetooth',
|
||||
'Usb',
|
||||
'Info',
|
||||
'HelpCircle',
|
||||
'CheckCircle',
|
||||
'XCircle',
|
||||
'Plus',
|
||||
'Minus',
|
||||
'X',
|
||||
'Check',
|
||||
'Search',
|
||||
'Filter',
|
||||
'SlidersHorizontal',
|
||||
'Settings',
|
||||
'Tool',
|
||||
'Wrench',
|
||||
'Hash',
|
||||
'AtSign',
|
||||
'Percent',
|
||||
'Power',
|
||||
'Zap',
|
||||
'Flame',
|
||||
'Droplet',
|
||||
'Wind',
|
||||
'Cloud',
|
||||
'Sun',
|
||||
'Moon',
|
||||
'Eye',
|
||||
'EyeOff',
|
||||
'Download',
|
||||
'Upload',
|
||||
'Shuffle',
|
||||
'Repeat',
|
||||
'RotateCw',
|
||||
'RefreshCw',
|
||||
'Copy',
|
||||
'Clipboard',
|
||||
'Edit',
|
||||
'Edit3',
|
||||
'Trash',
|
||||
'Trash2',
|
||||
'Save',
|
||||
'Bookmark',
|
||||
'Tag',
|
||||
'Tags',
|
||||
'Flag',
|
||||
'Bell',
|
||||
'BellOff',
|
||||
'Volume2',
|
||||
'VolumeX',
|
||||
'Play',
|
||||
'Pause',
|
||||
'StopCircle',
|
||||
'SkipForward',
|
||||
'SkipBack',
|
||||
'FastForward',
|
||||
'Rewind',
|
||||
'Maximize',
|
||||
'Minimize',
|
||||
'Expand',
|
||||
'Shrink',
|
||||
'ArrowRight',
|
||||
'ArrowLeft',
|
||||
'ArrowUp',
|
||||
'ArrowDown',
|
||||
'ArrowRightLeft',
|
||||
'ArrowUpDown',
|
||||
'ChevronsRight',
|
||||
'ChevronsLeft',
|
||||
'ChevronsUp',
|
||||
'ChevronsDown',
|
||||
'MoreHorizontal',
|
||||
'MoreVertical',
|
||||
'Footprints',
|
||||
'Smile',
|
||||
'Frown',
|
||||
'Meh',
|
||||
'Code',
|
||||
'Code2',
|
||||
'Terminal',
|
||||
'Command',
|
||||
'Binary',
|
||||
'Braces',
|
||||
'GitBranch',
|
||||
'GitCommit',
|
||||
'GitMerge',
|
||||
'Github',
|
||||
'Chrome',
|
||||
'Facebook',
|
||||
'Twitter',
|
||||
'Instagram',
|
||||
'Linkedin',
|
||||
'Quote',
|
||||
'BookmarkPlus'
|
||||
] as const
|
||||
|
||||
type IconPopoverProps = {
|
||||
iconType: string | null
|
||||
open: boolean
|
||||
setOpen: Dispatch<SetStateAction<boolean>>
|
||||
onIconChange: (
|
||||
iconType: IconPopoverProps['iconType'],
|
||||
iconName: keyof typeof AVAILABLE_ICONS
|
||||
) => void
|
||||
}
|
||||
|
||||
export default function IconPicker({ iconType, open, setOpen, onIconChange }: IconPopoverProps) {
|
||||
const [search, setSearch] = useState('')
|
||||
const [isReady, setIsReady] = useState(false)
|
||||
const parentRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const filteredIcons = useMemo(() => {
|
||||
const searchLower = search.toLowerCase()
|
||||
return AVAILABLE_ICONS.filter((icon) => icon.toLowerCase().includes(searchLower))
|
||||
}, [search])
|
||||
|
||||
const COLUMNS = 6
|
||||
const ICON_SIZE = 56
|
||||
|
||||
const rows = Math.ceil(filteredIcons.length / COLUMNS)
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: rows,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => ICON_SIZE,
|
||||
overscan: 5
|
||||
})
|
||||
|
||||
const handleClick = (
|
||||
iconType: IconPopoverProps['iconType'],
|
||||
iconName: keyof typeof AVAILABLE_ICONS
|
||||
) => {
|
||||
onIconChange(iconType, iconName)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
// Reset and initialize when dialog opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSearch('')
|
||||
setIsReady(false)
|
||||
// Wait for dialog to render and measure
|
||||
const timer = setTimeout(() => {
|
||||
setIsReady(true)
|
||||
virtualizer.measure()
|
||||
}, 50)
|
||||
return () => clearTimeout(timer)
|
||||
} else {
|
||||
setIsReady(false)
|
||||
}
|
||||
}, [open, virtualizer])
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{iconType ? (
|
||||
<>
|
||||
Change icon for <span className="text-primary">{iconType}</span>
|
||||
</>
|
||||
) : (
|
||||
<>Select an icon</>
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Choose from {AVAILABLE_ICONS.length} available Lucide icons
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
placeholder="Search icons..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
|
||||
<div ref={parentRef} className="h-[400px] overflow-auto rounded-md border border-border">
|
||||
{!isReady ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-muted-foreground text-sm">Loading icons...</div>
|
||||
</div>
|
||||
) : filteredIcons.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-muted-foreground text-sm">
|
||||
No icons found matching "{search}"
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
height: `${virtualizer.getTotalSize()}px`,
|
||||
width: '100%',
|
||||
position: 'relative'
|
||||
}}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const startIdx = virtualRow.index * COLUMNS
|
||||
const rowIcons = filteredIcons.slice(startIdx, startIdx + COLUMNS)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={virtualRow.key}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: `${virtualRow.size}px`,
|
||||
transform: `translateY(${virtualRow.start}px)`
|
||||
}}
|
||||
>
|
||||
<div className="grid grid-cols-6 gap-2 p-2">
|
||||
{rowIcons.map((iconName) => {
|
||||
const Icon = (LucideIcons as any)[iconName]
|
||||
|
||||
if (!Icon) return null
|
||||
|
||||
return (
|
||||
<button
|
||||
key={iconName}
|
||||
onClick={() => handleClick(iconType, iconName)}
|
||||
className={cn(
|
||||
'flex h-12 w-full flex-col items-center justify-center gap-1 rounded-md border border-border p-2 transition-colors hover:bg-accent hover:text-accent-foreground',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
|
||||
)}
|
||||
title={iconName}
|
||||
>
|
||||
<Icon size={24} />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useIcon } from '@/hooks/use-icon'
|
||||
|
||||
type NodeIconTriggerProps = {
|
||||
type: string
|
||||
onClick: () => void
|
||||
}
|
||||
export const NodeIconTrigger = ({ type, onClick }: NodeIconTriggerProps) => {
|
||||
const Icon = useIcon(type)
|
||||
return (
|
||||
<Button
|
||||
className="cursor-pointer hover:bg-muted"
|
||||
onClick={onClick}
|
||||
variant={'ghost'}
|
||||
size={'icon'}
|
||||
>
|
||||
<Icon size={24} />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { memo } from 'react'
|
||||
import { Cloud, CloudOff, Loader2, Check, AlertCircle } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
|
||||
|
||||
export type SaveStatusType = 'idle' | 'pending' | 'saving' | 'saved' | 'error' | 'unsaved'
|
||||
|
||||
interface SaveStatusBadgeProps {
|
||||
status: SaveStatusType
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const SaveStatusBadge = memo(({ status, className }: SaveStatusBadgeProps) => {
|
||||
const getStatusConfig = () => {
|
||||
// Normalize 'unsaved' to 'pending' for display
|
||||
const normalizedStatus = status === 'unsaved' ? 'pending' : status
|
||||
|
||||
switch (normalizedStatus) {
|
||||
case 'idle':
|
||||
return {
|
||||
icon: Cloud,
|
||||
text: 'All changes saved',
|
||||
className: 'text-muted-foreground/60 bg-muted/30 border-muted'
|
||||
}
|
||||
case 'pending':
|
||||
return {
|
||||
icon: Cloud,
|
||||
text: 'Pending...',
|
||||
className: 'text-muted-foreground bg-muted/50 border-muted'
|
||||
}
|
||||
case 'saving':
|
||||
return {
|
||||
icon: Loader2,
|
||||
text: 'Saving...',
|
||||
className:
|
||||
'text-blue-600 bg-blue-50 dark:bg-blue-950/30 dark:text-blue-400 border-blue-200 dark:border-blue-800',
|
||||
iconClassName: 'animate-spin'
|
||||
}
|
||||
case 'saved':
|
||||
return {
|
||||
icon: Check,
|
||||
text: 'Saved',
|
||||
className:
|
||||
'text-green-600 bg-green-50 dark:bg-green-950/30 dark:text-green-400 border-green-200 dark:border-green-800'
|
||||
}
|
||||
case 'error':
|
||||
return {
|
||||
icon: AlertCircle,
|
||||
text: 'Error saving',
|
||||
className:
|
||||
'text-red-600 bg-red-50 dark:bg-red-950/30 dark:text-red-400 border-red-200 dark:border-red-800'
|
||||
}
|
||||
default:
|
||||
return {
|
||||
icon: CloudOff,
|
||||
text: 'Unknown',
|
||||
className: 'text-muted-foreground bg-muted/50 border-muted'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const config = getStatusConfig()
|
||||
const Icon = config.icon
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center h-8 gap-1.5 px-1.5 py-1.5',
|
||||
'rounded-md !bg-transparent',
|
||||
'text-xs font-medium',
|
||||
'transition-all duration-200',
|
||||
config.className,
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Icon className={cn('h-3.5 w-3.5', config.iconClassName)} />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{config.text}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
})
|
||||
|
||||
SaveStatusBadge.displayName = 'SaveStatusBadge'
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ArrowLeft, ArrowRight, Upload } from 'lucide-react'
|
||||
import { ArrowLeft, ArrowRight, Download, Upload } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { cn, flattenObj } from '@/lib/utils'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog'
|
||||
@@ -23,13 +23,15 @@ import { useGraphControls } from '@/stores/graph-controls-store'
|
||||
export default function AddItemDialog() {
|
||||
const handleOpenFormModal = useGraphStore((state) => state.handleOpenFormModal)
|
||||
const currentNodeType = useGraphStore((state) => state.currentNodeType)
|
||||
const setCurrentNode = useGraphStore((state) => state.setCurrentNode)
|
||||
const setCurrentNodeId = useGraphStore((state) => state.setCurrentNodeId)
|
||||
const relatedNodeToAdd = useGraphStore((state) => state.relatedNodeToAdd)
|
||||
const setRelatedNodeToAdd = useGraphStore((state) => state.setRelatedNodeToAdd)
|
||||
const openMainDialog = useGraphStore((state) => state.openMainDialog)
|
||||
const setOpenMainDialog = useGraphStore((state) => state.setOpenMainDialog)
|
||||
const openFormDialog = useGraphStore((state) => state.openFormDialog)
|
||||
const setOpenFormDialog = useGraphStore((state) => state.setOpenFormDialog)
|
||||
const regenerateLayout = useGraphControls((s) => s.regenerateLayout)
|
||||
const currentLayoutType = useGraphControls((s) => s.currentLayoutType)
|
||||
const addNode = useGraphStore((state) => state.addNode)
|
||||
const addEdge = useGraphStore((state) => state.addEdge)
|
||||
const replaceNode = useGraphStore((state) => state.replaceNode)
|
||||
@@ -73,41 +75,35 @@ export default function AddItemDialog() {
|
||||
const label_key =
|
||||
currentNodeType.fields.find((f: FormField) => f.name === currentNodeType.label_key)?.name ||
|
||||
currentNodeType.fields[0].name
|
||||
const type = currentNodeType.type
|
||||
const label = data[label_key as keyof typeof data]
|
||||
|
||||
const type = currentNodeType.type
|
||||
let label = data[label_key as keyof typeof data]
|
||||
|
||||
// if no value, fallback to fist key
|
||||
if (!label) label = data[Object.keys(data)[0] as keyof typeof data]
|
||||
// Get viewport center for positioning new node
|
||||
const center = getViewportCenter()
|
||||
const nodeX = center?.x ?? 0
|
||||
const nodeY = center?.y ?? 0
|
||||
const tempId = generateTempId()
|
||||
|
||||
// Node data for API (with correct structure)
|
||||
const newNode = {
|
||||
type: type.toLowerCase(), // Required at root level for API validation
|
||||
label: label,
|
||||
data: {
|
||||
...flattenObj(data),
|
||||
label,
|
||||
type: type.toLowerCase(),
|
||||
x: nodeX, // Position at viewport center
|
||||
y: nodeY
|
||||
}
|
||||
}
|
||||
|
||||
// Generate temporary ID for optimistic update
|
||||
const tempId = generateTempId()
|
||||
// Optimistically add the node to local state with temporary ID and position fields
|
||||
const nodeWithTempId: GraphNode = {
|
||||
id: tempId,
|
||||
data: {
|
||||
...newNode.data,
|
||||
id: tempId,
|
||||
created_at: new Date().toISOString()
|
||||
},
|
||||
// Position fields for client-side graph rendering (at viewport center)
|
||||
x: nodeX,
|
||||
y: nodeY
|
||||
nodeType: type.toLowerCase(), // Required at root level for API validation
|
||||
nodeLabel: label,
|
||||
x: nodeX, // Position at viewport center
|
||||
y: nodeY,
|
||||
nodeSize: 4,
|
||||
nodeShape: 'circle',
|
||||
nodeColor: null,
|
||||
nodeFlag: null,
|
||||
nodeIcon: null,
|
||||
nodeImage: null,
|
||||
nodeProperties: flattenObj(data),
|
||||
nodeMetadata: {}
|
||||
}
|
||||
|
||||
if (addNode) {
|
||||
addNode(nodeWithTempId)
|
||||
}
|
||||
@@ -116,7 +112,7 @@ export default function AddItemDialog() {
|
||||
if (relatedNodeToAdd) {
|
||||
const tempEdgePayload = {
|
||||
type: 'custom',
|
||||
label: `HAS_${newNode.data.type.toUpperCase()}`,
|
||||
label: `HAS_${nodeWithTempId.nodeType.toUpperCase()}`,
|
||||
source: relatedNodeToAdd.id,
|
||||
target: tempId
|
||||
}
|
||||
@@ -126,32 +122,39 @@ export default function AddItemDialog() {
|
||||
}
|
||||
}
|
||||
// Set current node optimistically
|
||||
if (setCurrentNode) {
|
||||
setCurrentNode(nodeWithTempId)
|
||||
if (setCurrentNodeId) {
|
||||
setCurrentNodeId(nodeWithTempId.id)
|
||||
}
|
||||
setActiveTab('entities')
|
||||
// Close dialogs immediately for better UX
|
||||
setOpenMainDialog(false)
|
||||
setOpenFormDialog(false)
|
||||
// Show optimistic success message
|
||||
toast.success(
|
||||
relatedNodeToAdd ? `New relation added to ${relatedNodeToAdd.data.label}.` : 'New node added.'
|
||||
)
|
||||
toast(`New ${type.toLowerCase()} added to sketch.`, {
|
||||
description: relatedNodeToAdd
|
||||
? `A new ${type.toLowerCase()} "${label}" was added to relation ${relatedNodeToAdd.nodeLabel}.`
|
||||
: `A new ${type.toLowerCase()} "${label}" was added.`,
|
||||
action: {
|
||||
label: 'Beautify layout',
|
||||
onClick: () => regenerateLayout(currentLayoutType)
|
||||
}
|
||||
})
|
||||
// Make API calls in the background
|
||||
try {
|
||||
// Create the node via API to get the real database ID
|
||||
const newNodeResponse = await sketchService.addNode(
|
||||
sketch_id as string,
|
||||
JSON.stringify(newNode)
|
||||
JSON.stringify(nodeWithTempId)
|
||||
)
|
||||
if (newNodeResponse.node && replaceNode) {
|
||||
replaceNode(tempId, newNodeResponse.node.data)
|
||||
const newNode: GraphNode = newNodeResponse.node
|
||||
if (newNode && replaceNode) {
|
||||
replaceNode(tempId, newNode.id, newNode.nodeProperties)
|
||||
if (relatedNodeToAdd && tempEdgeId) {
|
||||
const relationPayload = {
|
||||
source: relatedNodeToAdd.id,
|
||||
target: newNodeResponse.node.id,
|
||||
target: newNode.id,
|
||||
type: 'one-way',
|
||||
label: `HAS_${newNode.data.type.toUpperCase()}`
|
||||
label: `HAS_${nodeWithTempId.nodeType.toUpperCase()}`
|
||||
}
|
||||
// Make API call to persist the edge
|
||||
await sketchService.addEdge(sketch_id as string, JSON.stringify(relationPayload))
|
||||
@@ -239,14 +242,19 @@ export default function AddItemDialog() {
|
||||
: 'Select an item to insert'}
|
||||
{relatedNodeToAdd && (
|
||||
<span className="text-primary truncate max-w-[50%] text-ellipsis font-semibold ml-1">
|
||||
{relatedNodeToAdd.data.label}
|
||||
{relatedNodeToAdd.nodeLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="pt-6">
|
||||
<Button size="sm" onClick={handleImportData} className="flex items-center gap-2">
|
||||
<Upload className="h-4 w-4" />
|
||||
Import
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleImportData}
|
||||
className="flex items-center gap-2 rounded"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Import entities
|
||||
</Button>
|
||||
</div>
|
||||
</DialogTitle>
|
||||
@@ -304,7 +312,7 @@ interface ActionCardProps {
|
||||
}
|
||||
|
||||
function ActionCard({ item, onSelect }: ActionCardProps) {
|
||||
const IconComponent = useIcon(item.icon)
|
||||
const IconComponent = useIcon(item.icon, { nodeIcon: item.icon, nodeColor: item.color })
|
||||
|
||||
return (
|
||||
<Card
|
||||
|
||||
@@ -16,6 +16,7 @@ import { sketchService } from '@/api/sketch-service'
|
||||
import { useParams } from '@tanstack/react-router'
|
||||
import { useIcon } from '@/hooks/use-icon'
|
||||
import { RadioGroupItem } from '@/components/ui/radio-group'
|
||||
import { type GraphNode } from '@/types'
|
||||
|
||||
export function CreateRelationDialog() {
|
||||
const { id: sketchId } = useParams({ strict: false })
|
||||
@@ -63,7 +64,7 @@ export function CreateRelationDialog() {
|
||||
|
||||
return (
|
||||
<Dialog open={openAddRelationDialog} onOpenChange={setOpenAddRelationDialog}>
|
||||
<DialogContent className="!w-full !max-w-[600px]">
|
||||
<DialogContent className="w-full! max-w-[600px]!">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<GitPullRequestArrow className="h-4 w-4" />
|
||||
@@ -112,7 +113,7 @@ export function CreateRelationDialog() {
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="shadow-sm"
|
||||
className="border"
|
||||
onClick={() => setIsReversed((v) => !v)}
|
||||
aria-label="Switch direction"
|
||||
title="Switch direction"
|
||||
@@ -131,7 +132,7 @@ export function CreateRelationDialog() {
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center truncate justify-center px-2 flex-shrink-0 min-w-0 grow">
|
||||
<div className="flex items-center truncate justify-center px-2 shrink-0 min-w-0 grow">
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<div className="h-px bg-muted-foreground/30 flex-1"></div>
|
||||
<span className="px-2 py-1 bg-muted/50 rounded-sm truncate">
|
||||
@@ -172,7 +173,7 @@ export function CreateRelationDialog() {
|
||||
}
|
||||
|
||||
interface NodeDisplayCardProps {
|
||||
node: any
|
||||
node: GraphNode
|
||||
variant?: 'default' | 'preview'
|
||||
asRadio?: boolean
|
||||
radioValue?: string
|
||||
@@ -186,19 +187,20 @@ export function NodeDisplayCard({
|
||||
radioValue,
|
||||
id
|
||||
}: NodeDisplayCardProps) {
|
||||
const NodeIcon = useIcon(node.data?.type, node.data?.src)
|
||||
|
||||
const getNodeDisplayName = (node: any) => {
|
||||
return node.data?.label || node.data?.username || node.id
|
||||
}
|
||||
|
||||
const NodeIcon = useIcon(node.nodeType, {
|
||||
nodeColor: node.nodeColor,
|
||||
nodeIcon: node.nodeIcon,
|
||||
nodeImage: node.nodeImage
|
||||
})
|
||||
if (variant === 'preview') {
|
||||
return (
|
||||
<div className="flex min-w-[170px] items-center gap-2 min-w-0">
|
||||
<div className="flex items-center justify-center w-6 h-6 rounded-full bg-muted">
|
||||
<NodeIcon size={20} />
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground max-w-[200px] truncate">{getNodeDisplayName(node)}</span>
|
||||
<span className="text-xs text-muted-foreground max-w-[200px] truncate">
|
||||
{node.nodeLabel}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -214,7 +216,7 @@ export function NodeDisplayCard({
|
||||
<div className="flex items-center justify-center w-5 h-5 rounded-full bg-muted">
|
||||
<NodeIcon size={16} />
|
||||
</div>
|
||||
<span className="text-sm max-w-[200px] truncate">{getNodeDisplayName(node)}</span>
|
||||
<span className="text-sm max-w-[200px] truncate">{node.nodeLabel}</span>
|
||||
</div>
|
||||
</label>
|
||||
)
|
||||
@@ -225,7 +227,7 @@ export function NodeDisplayCard({
|
||||
<div className="flex items-center justify-center w-5 h-5 rounded-full bg-muted">
|
||||
<NodeIcon size={16} />
|
||||
</div>
|
||||
<span className="text-sm max-w-[200px] truncate">{getNodeDisplayName(node)}</span>
|
||||
<span className="text-sm max-w-[200px] truncate">{node.nodeLabel}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,213 +1,647 @@
|
||||
import { memo } from 'react'
|
||||
'use client'
|
||||
|
||||
import type React from 'react'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverHeader,
|
||||
PopoverTitle,
|
||||
PopoverTrigger
|
||||
} from '@/components/ui/popover'
|
||||
import { memo, useState, useEffect, useCallback } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { CopyButton } from '@/components/copy'
|
||||
import { Check, Rocket, X, MousePointer } from 'lucide-react'
|
||||
import { X, Loader2, Rocket, Link2, MousePointer, ArrowDownLeft } from 'lucide-react'
|
||||
import LaunchFlow from '../launch-enricher'
|
||||
import NodeActions from '../node-actions'
|
||||
import { Button } from '../../ui/button'
|
||||
import NodeActions from '../graph/node/actions/node-actions'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../../ui/tooltip'
|
||||
import { useParams } from '@tanstack/react-router'
|
||||
import NeighborsGraph from './neighbors'
|
||||
import Relationships from './relationships'
|
||||
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '../../ui/resizable'
|
||||
import { GraphNode } from '@/types'
|
||||
import { useGraphStore } from '@/stores/graph-store'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import DOMPurify from 'dompurify'
|
||||
import { useIcon } from '@/hooks/use-icon'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import type { GraphNode, NodeProperties, NodeMetadata, NodeShape } from '@/types'
|
||||
import { sketchService } from '@/api/sketch-service'
|
||||
import { toast } from 'sonner'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { queryKeys } from '@/api/query-keys'
|
||||
import IconPicker from '@/components/shared/icon-picker'
|
||||
import { ResizableDetailsPanel, Row } from './resizable-details-panels'
|
||||
import Relationships from './relationships'
|
||||
import NeighborsGraph from './neighbors'
|
||||
import { Circle, Square, Triangle, Hexagon } from 'lucide-react'
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
|
||||
import { Slider } from '@/components/ui/slider'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
const DetailsPanel = memo(({ node }: { node: GraphNode | null }) => {
|
||||
// Types
|
||||
type FormData = {
|
||||
nodeLabel: string
|
||||
nodeColor: string | null
|
||||
nodeIcon: string | null
|
||||
nodeImage: string | null
|
||||
nodeFlag: string | null
|
||||
nodeShape: string | null
|
||||
nodeSize: number | null
|
||||
nodeProperties: NodeProperties
|
||||
nodeMetadata: NodeMetadata
|
||||
notes: string
|
||||
}
|
||||
|
||||
// Constants
|
||||
const COLORS = [
|
||||
{ name: 'default', value: null },
|
||||
{ name: 'red', value: '#ef4444' },
|
||||
{ name: 'orange', value: '#f97316' },
|
||||
{ name: 'yellow', value: '#eab308' },
|
||||
{ name: 'green', value: '#22c55e' },
|
||||
{ name: 'blue', value: '#3b82f6' },
|
||||
{ name: 'purple', value: '#a855f7' },
|
||||
{ name: 'pink', value: '#ec4899' }
|
||||
]
|
||||
|
||||
const NODE_SHAPES = {
|
||||
circle: Circle,
|
||||
square: Square,
|
||||
hexagon: Hexagon,
|
||||
triangle: Triangle
|
||||
} satisfies Record<NodeShape, React.ComponentType<{ size?: number; className?: string }>>
|
||||
// Extended Row Component with copy support
|
||||
const RowWithCopy = memo(
|
||||
({
|
||||
label,
|
||||
copyValue,
|
||||
children
|
||||
}: {
|
||||
label: string
|
||||
copyValue?: string
|
||||
children: React.ReactNode
|
||||
}) => (
|
||||
<div className="flex items-center justify-between py-1.5 group hover:bg-muted/20 -mx-2 px-2 rounded-sm transition-colors">
|
||||
<span className="text-[13px] text-muted-foreground">{label.replace(/_/g, ' ')}</span>
|
||||
<div className="text-[13px] text-foreground flex items-center gap-1">
|
||||
{copyValue && (
|
||||
<CopyButton
|
||||
content={copyValue}
|
||||
className="h-4 w-4 opacity-0 group-hover:opacity-60 transition-opacity"
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)
|
||||
RowWithCopy.displayName = 'RowWithCopy'
|
||||
|
||||
// Status Code Badge
|
||||
const StatusCodeBadge = memo(({ statusCode }: { statusCode: number }) => {
|
||||
const category = Math.floor(statusCode / 100)
|
||||
const colors: Record<number, string> = {
|
||||
1: 'text-blue-600',
|
||||
2: 'text-green-600',
|
||||
3: 'text-purple-600',
|
||||
4: 'text-orange-600',
|
||||
5: 'text-red-600'
|
||||
}
|
||||
return <span className={cn('font-medium', colors[category])}>{statusCode}</span>
|
||||
})
|
||||
|
||||
// Helpers
|
||||
const formatValue = (value: any): string => {
|
||||
if (value === null || value === undefined) return '—'
|
||||
if (typeof value === 'boolean') return value ? 'Yes' : 'No'
|
||||
if (typeof value === 'object') return JSON.stringify(value)
|
||||
return String(value)
|
||||
}
|
||||
|
||||
const formatDate = (value: any): string => {
|
||||
if (!value) return '—'
|
||||
try {
|
||||
return new Date(value).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
// Main Component
|
||||
const DetailsPanel = memo(() => {
|
||||
const { id: sketchId } = useParams({ strict: false })
|
||||
const nodes = useGraphStore((s) => s.nodes)
|
||||
node = nodes.find((n) => n.id === node?.id) || null
|
||||
const nodesLength = useGraphStore((s) => s.nodesLength)
|
||||
const node = useGraphStore((s) => s.getCurrentNode())
|
||||
const setCurrentNodeId = useGraphStore((s) => s.setCurrentNodeId)
|
||||
const updateNode = useGraphStore((s) => s.updateNode)
|
||||
const [openIconPicker, setOpenIconPicker] = useState(false)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [hasChanges, setHasChanges] = useState(false)
|
||||
|
||||
const [formData, setFormData] = useState<FormData>({
|
||||
nodeLabel: '',
|
||||
nodeColor: null,
|
||||
nodeIcon: null,
|
||||
nodeImage: null,
|
||||
nodeFlag: null,
|
||||
nodeShape: null,
|
||||
nodeSize: 0,
|
||||
nodeProperties: {},
|
||||
nodeMetadata: {},
|
||||
notes: ''
|
||||
})
|
||||
const [nodeSize, setNodeSize] = useState<number>(0)
|
||||
|
||||
const IconComponent = useIcon(node?.nodeType as string, {
|
||||
nodeColor: node?.nodeColor,
|
||||
nodeIcon: node?.nodeIcon,
|
||||
nodeImage: node?.nodeImage
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
nodeSize !== node?.nodeSize && setHasChanges(true)
|
||||
}, [nodeSize])
|
||||
|
||||
// Sync form data when node changes
|
||||
useEffect(() => {
|
||||
if (node) {
|
||||
const {
|
||||
nodeLabel,
|
||||
nodeImage,
|
||||
nodeIcon,
|
||||
nodeFlag,
|
||||
nodeColor,
|
||||
nodeShape,
|
||||
nodeMetadata,
|
||||
nodeProperties,
|
||||
nodeSize
|
||||
} = node
|
||||
setFormData({
|
||||
nodeLabel: nodeLabel || '',
|
||||
nodeProperties: nodeProperties || {},
|
||||
nodeMetadata: nodeMetadata || {},
|
||||
nodeColor,
|
||||
nodeIcon,
|
||||
nodeImage,
|
||||
nodeFlag,
|
||||
nodeShape,
|
||||
nodeSize,
|
||||
notes: (nodeMetadata?.notes as string) || ''
|
||||
})
|
||||
setNodeSize(node.nodeSize ?? 0)
|
||||
setHasChanges(false)
|
||||
}
|
||||
}, [node])
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// Mutation
|
||||
const updateNodeMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
sketchId,
|
||||
body
|
||||
}: {
|
||||
sketchId: string
|
||||
body: { nodeId: string; updates: Partial<GraphNode> }
|
||||
}) => {
|
||||
return sketchService.updateNode(sketchId, JSON.stringify(body))
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
if (result.status === 'node updated' && node) {
|
||||
const { notes, ...rest } = formData
|
||||
const updates = { ...rest, nodeMetadata: { ...rest.nodeMetadata, notes }, nodeSize }
|
||||
updateNode(node.id, updates as Partial<GraphNode>)
|
||||
if (sketchId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.sketches.detail(sketchId) })
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.sketches.graph(sketchId, sketchId) })
|
||||
}
|
||||
toast.success('Saved')
|
||||
setHasChanges(false)
|
||||
} else {
|
||||
toast.error('Failed to update')
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
console.log(error)
|
||||
toast.error('Failed to save')
|
||||
}
|
||||
})
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!node || !sketchId) return
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const { notes, ...rest } = formData
|
||||
const updateData = {
|
||||
nodeId: node.id,
|
||||
updates: {
|
||||
...rest,
|
||||
nodeType: node.nodeType,
|
||||
nodeMetadata: { ...rest.nodeMetadata, notes }
|
||||
} as Partial<GraphNode>
|
||||
}
|
||||
await updateNodeMutation.mutateAsync({ sketchId, body: updateData })
|
||||
} catch (error) {
|
||||
console.error('Error updating node:', error)
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}, [node, sketchId, formData, updateNodeMutation])
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setCurrentNodeId(null)
|
||||
}, [setCurrentNodeId])
|
||||
|
||||
const handleChange = useCallback(<K extends keyof FormData>(key: K, value: FormData[K]) => {
|
||||
setFormData((prev) => ({ ...prev, [key]: value }))
|
||||
setHasChanges(true)
|
||||
}, [])
|
||||
|
||||
const handlePropertyChange = useCallback((key: string, value: string | boolean) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
nodeProperties: { ...prev.nodeProperties, [key]: value }
|
||||
}))
|
||||
setHasChanges(true)
|
||||
}, [])
|
||||
|
||||
const handleIconSelect = useCallback(
|
||||
(_iconType: string, iconName: string | null) => {
|
||||
handleChange('nodeIcon', iconName)
|
||||
},
|
||||
[handleChange]
|
||||
)
|
||||
|
||||
// Empty state
|
||||
if (!node) {
|
||||
return (
|
||||
<div className="flex p-12 flex-col items-center justify-center h-full text-center p-8">
|
||||
<div className="w-16 h-16 bg-muted rounded-full flex items-center justify-center mb-4">
|
||||
<MousePointer className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">No Node Selected</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-xs">
|
||||
Click on any node in the graph to view its details and properties
|
||||
</p>
|
||||
<div className="flex flex-col items-center justify-center h-full p-8">
|
||||
<MousePointer className="h-5 w-5 text-muted-foreground/40 mb-3" />
|
||||
<p className="text-[13px] text-muted-foreground/60">Select a node</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const propertiesFields = Object.entries(formData.nodeProperties)
|
||||
const metadataFields = Object.entries(formData.nodeMetadata).filter(([key]) => key !== 'notes')
|
||||
const description = node.nodeProperties?.description as string | undefined
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden bg-card">
|
||||
{/* <ItemHero node={node} /> */}
|
||||
<div className="flex items-center bg-card h-10 sticky w-full top-0 border-b justify-start px-4 gap-2 z-1">
|
||||
<p className="text-sm font-semibold truncate">{node.data?.label}</p>
|
||||
<div className="grow" />
|
||||
<div className="flex items-center">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<LaunchFlow values={[node.id]} type={node.data?.type}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 hover:bg-muted opacity-70 hover:opacity-100"
|
||||
>
|
||||
<Rocket className="h-3 w-3" strokeWidth={2} />
|
||||
</Button>
|
||||
</LaunchFlow>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Launch enricher</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<NodeActions node={node} />
|
||||
<div className="flex flex-col h-full bg-card border-border">
|
||||
{/* Header - Minimal */}
|
||||
<div className="h-10 border-b flex w-full items-center justify-between">
|
||||
<div className="px-4 flex items-center w-full justify-between gap-3">
|
||||
<div className="flex items-start gap-2.5 flex-1 min-w-0">
|
||||
<button
|
||||
onClick={() => setOpenIconPicker(true)}
|
||||
className="shrink-0 hover:opacity-70 transition-opacity"
|
||||
>
|
||||
<IconComponent size={18} />
|
||||
</button>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<input
|
||||
type="text"
|
||||
value={formData.nodeLabel}
|
||||
onChange={(e) => handleChange('nodeLabel', e.target.value)}
|
||||
className="w-full text-md font-medium bg-transparent outline-none placeholder:text-muted-foreground/40 focus:bg-muted/20 -ml-1 px-1 rounded transition-colors"
|
||||
placeholder="Untitled"
|
||||
/>
|
||||
{/*<span className="text-[11px] text-muted-foreground/60 ml-0.5">{node.nodeType}</span>*/}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<LaunchFlow values={[node.id]} type={node.nodeType}>
|
||||
<Button className="rounded-full h-7" size={'sm'}>
|
||||
Enrich <Rocket className="h-4 w-4 opacity-70" strokeWidth={1.7} />
|
||||
</Button>
|
||||
</LaunchFlow>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p className="text-xs">Enrich</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<NodeActions node={node} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0">
|
||||
<ResizablePanelGroup direction="vertical" className="h-full">
|
||||
<ResizablePanel defaultSize={30} minSize={20}>
|
||||
<div className="h-full overflow-auto">
|
||||
{node.data?.description && (
|
||||
<div className="px-4 py-3 border-b border-border">
|
||||
<div
|
||||
className="text-sm text-muted-foreground prose dark:prose-invert prose-sm max-w-none"
|
||||
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(node.data.description) }}
|
||||
|
||||
{/* Description */}
|
||||
{description && (
|
||||
<div className="py-2 px-4 border-b border-border/50 shrink-0">
|
||||
<div
|
||||
className="text-[13px] text-muted-foreground leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(description) }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content - Resizable Panels */}
|
||||
<ResizableDetailsPanel
|
||||
className="flex-1 min-h-0"
|
||||
sections={[
|
||||
{
|
||||
id: 'properties',
|
||||
label: 'Properties',
|
||||
defaultOpen: true,
|
||||
defaultSize: 25,
|
||||
minSize: 10,
|
||||
content: (
|
||||
<div className="p-3">
|
||||
{propertiesFields.length > 0 ? (
|
||||
propertiesFields.map(([key, value]) => (
|
||||
<RowWithCopy
|
||||
key={key}
|
||||
label={key}
|
||||
copyValue={typeof value === 'string' ? value : undefined}
|
||||
>
|
||||
{typeof value === 'boolean' ? (
|
||||
<Switch
|
||||
checked={value}
|
||||
onCheckedChange={(checked) => handlePropertyChange(key, checked)}
|
||||
className="scale-75"
|
||||
/>
|
||||
) : typeof value === 'number' ? (
|
||||
<StatusCodeBadge statusCode={value} />
|
||||
) : typeof value === 'string' && value.startsWith('https://') ? (
|
||||
<a
|
||||
href={value}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground flex items-center gap-1 text-[12px] transition-colors"
|
||||
>
|
||||
<Link2 className="h-3 w-3" />
|
||||
<span className="truncate max-w-[100px]">{new URL(value).hostname}</span>
|
||||
</a>
|
||||
) : value && value.constructor === Object ? (
|
||||
<PopoverProperty label={key} property={value} />
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={String(value || '')}
|
||||
onChange={(e) => handlePropertyChange(key, e.target.value)}
|
||||
className="w-28 text-right text-[12px] bg-transparent outline-none placeholder:text-muted-foreground/30 focus:bg-muted/20 px-1 rounded transition-colors truncate"
|
||||
placeholder="Empty"
|
||||
/>
|
||||
)}
|
||||
</RowWithCopy>
|
||||
))
|
||||
) : (
|
||||
<p className="text-[12px] text-muted-foreground/40 py-1">No properties</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'neighbors',
|
||||
label: 'Neighbors',
|
||||
defaultOpen: true,
|
||||
defaultSize: 40,
|
||||
minSize: 25,
|
||||
content: (
|
||||
<div className="h-full overflow-hidden">
|
||||
<NeighborsGraph
|
||||
sketchId={sketchId as string}
|
||||
currentNode={node}
|
||||
nodeLength={nodesLength}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'relations',
|
||||
label: 'Relations',
|
||||
defaultOpen: true,
|
||||
defaultSize: 20,
|
||||
minSize: 15,
|
||||
content: (
|
||||
<div className="h-full p-3">
|
||||
<Relationships
|
||||
sketchId={sketchId as string}
|
||||
nodeId={node.id}
|
||||
nodeLength={nodesLength}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'appearance',
|
||||
label: 'Appearance',
|
||||
defaultOpen: false,
|
||||
fixedSize: 12,
|
||||
minSize: 10,
|
||||
content: (
|
||||
<div className="p-3">
|
||||
<Row label={`Size (${nodeSize})`}>
|
||||
<div className="flex w-full items-center">
|
||||
<Slider
|
||||
value={[nodeSize]}
|
||||
onValueChange={([value]) => setNodeSize(value)}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
className="flex-1 grow"
|
||||
/>
|
||||
</div>
|
||||
</Row>
|
||||
<Row label="Color">
|
||||
<div className="flex items-center gap-1">
|
||||
{COLORS.map((color) => (
|
||||
<button
|
||||
key={color.name}
|
||||
onClick={() => handleChange('nodeColor', color.value)}
|
||||
className={cn(
|
||||
'w-3 h-3 rounded-full transition-all',
|
||||
color.value ? '' : 'bg-muted-foreground/20',
|
||||
formData.nodeColor === color.value &&
|
||||
'ring-1 ring-offset-1 ring-offset-background ring-foreground/30'
|
||||
)}
|
||||
style={color.value ? { backgroundColor: color.value } : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Row>
|
||||
<Row label="Icon">
|
||||
<button
|
||||
onClick={() => setOpenIconPicker(true)}
|
||||
className="text-[12px] text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{formData.nodeIcon || 'Default'}
|
||||
</button>
|
||||
</Row>
|
||||
<Row label="Shape">
|
||||
<div className="flex items-center gap-1">
|
||||
<ToggleGroup
|
||||
onValueChange={(value) => handleChange('nodeShape', value || null)}
|
||||
type="single"
|
||||
size="sm"
|
||||
defaultValue={node.nodeShape || 'circle'}
|
||||
variant="outline"
|
||||
className="shadow-none!"
|
||||
>
|
||||
{(
|
||||
Object.entries(NODE_SHAPES) as [
|
||||
NodeShape,
|
||||
(typeof NODE_SHAPES)[NodeShape]
|
||||
][]
|
||||
).map(([shape, Icon]) => (
|
||||
<ToggleGroupItem value={shape} aria-label={shape}>
|
||||
<Icon size={14} />
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
</Row>
|
||||
<Row label="Image">
|
||||
<input
|
||||
type="text"
|
||||
value={formData.nodeImage || ''}
|
||||
onChange={(e) => handleChange('nodeImage', e.target.value || null)}
|
||||
className="w-24 text-right text-[12px] bg-transparent outline-none placeholder:text-muted-foreground/30 focus:bg-muted/20 px-1 rounded transition-colors"
|
||||
placeholder="URL..."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<MemoizedKeyValueDisplay data={node.data} />
|
||||
</Row>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'metadata',
|
||||
label: 'Metadata',
|
||||
defaultOpen: false,
|
||||
defaultSize: 20,
|
||||
minSize: 10,
|
||||
content: (
|
||||
<div className="p-3">
|
||||
{metadataFields.length > 0 ? (
|
||||
metadataFields.map(([key, value]) => (
|
||||
<RowWithCopy
|
||||
key={key}
|
||||
label={key}
|
||||
copyValue={typeof value === 'string' ? value : undefined}
|
||||
>
|
||||
{key.includes('_at') || key.includes('date')
|
||||
? formatDate(value)
|
||||
: formatValue(value)}
|
||||
</RowWithCopy>
|
||||
))
|
||||
) : (
|
||||
<p className="text-[12px] text-muted-foreground/40 py-1">No metadata</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
// {
|
||||
// id: 'notes',
|
||||
// label: 'Notes',
|
||||
// defaultOpen: false,
|
||||
// defaultSize: 15,
|
||||
// minSize: 10,
|
||||
// content: (
|
||||
// <textarea
|
||||
// value={formData.notes}
|
||||
// onChange={(e) => handleChange('notes', e.target.value)}
|
||||
// placeholder="Add notes..."
|
||||
// className="w-full h-full text-[13px] bg-transparent outline-none resize-none placeholder:text-muted-foreground/30 leading-relaxed"
|
||||
// />
|
||||
// )
|
||||
// },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Footer - Save bar */}
|
||||
{hasChanges && (
|
||||
<div className="px-4 py-2.5 border-t border-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[11px] text-muted-foreground/60">Unsaved changes</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (node) {
|
||||
const {
|
||||
nodeLabel,
|
||||
nodeImage,
|
||||
nodeIcon,
|
||||
nodeFlag,
|
||||
nodeColor,
|
||||
nodeMetadata,
|
||||
nodeShape,
|
||||
nodeSize,
|
||||
nodeProperties
|
||||
} = node
|
||||
setFormData({
|
||||
nodeLabel: nodeLabel || '',
|
||||
nodeProperties: nodeProperties || {},
|
||||
nodeMetadata: nodeMetadata || {},
|
||||
nodeColor,
|
||||
nodeIcon,
|
||||
nodeImage,
|
||||
nodeFlag,
|
||||
nodeShape,
|
||||
nodeSize,
|
||||
notes: (nodeMetadata?.notes as string) || ''
|
||||
})
|
||||
setHasChanges(false)
|
||||
}
|
||||
}}
|
||||
className="text-[11px] text-muted-foreground/60 hover:text-muted-foreground transition-colors"
|
||||
>
|
||||
Discard
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="text-[11px] bg-foreground text-background px-2.5 py-1 rounded hover:opacity-80 transition-opacity flex items-center gap-1"
|
||||
>
|
||||
{isSaving && <Loader2 className="h-3 w-3 animate-spin" />}
|
||||
{isSaving ? 'Saving' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle />
|
||||
<ResizablePanel defaultSize={40} minSize={25}>
|
||||
<div className="h-full w-full p-3">
|
||||
<NeighborsGraph
|
||||
nodeLength={nodes.length}
|
||||
sketchId={sketchId as string}
|
||||
nodeId={node.id}
|
||||
/>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle />
|
||||
<ResizablePanel defaultSize={30} minSize={20}>
|
||||
<Relationships
|
||||
nodeLength={nodes.length}
|
||||
sketchId={sketchId as string}
|
||||
nodeId={node.id}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<IconPicker
|
||||
// @ts-ignore
|
||||
onIconChange={handleIconSelect}
|
||||
open={openIconPicker}
|
||||
setOpen={setOpenIconPicker}
|
||||
iconType={null}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export default DetailsPanel
|
||||
export { StatusCodeBadge }
|
||||
|
||||
interface KeyValueDisplayProps {
|
||||
data: Record<string, any>
|
||||
className?: string
|
||||
}
|
||||
|
||||
function KeyValueDisplay({ data, className }: KeyValueDisplayProps) {
|
||||
export const PopoverProperty = ({ label, property }: { label: string; property: object }) => {
|
||||
return (
|
||||
<div className={cn('w-full border-collapse', className)}>
|
||||
{data &&
|
||||
Object.entries(data)
|
||||
.filter(
|
||||
([key]) => !['id', 'sketch_id', 'caption', 'size', 'color', 'description', 'x', 'y'].includes(key)
|
||||
)
|
||||
.map(([key, value], index) => {
|
||||
let val: string | null = null
|
||||
let display: React.ReactNode = null
|
||||
if (typeof value === 'number') {
|
||||
val = value.toString()
|
||||
display = <StatusCodeBadge statusCode={value} />
|
||||
}
|
||||
else if (typeof value === 'boolean') {
|
||||
val = value.toString()
|
||||
display = value ? (
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<X className="h-4 w-4 text-red-600" />
|
||||
)
|
||||
} else if (typeof value === 'object') {
|
||||
val = value?.["label"] ?? "N/A"
|
||||
display = val
|
||||
} else if (Array.isArray(value)) {
|
||||
val = `${value.length} items`
|
||||
display = val
|
||||
} else {
|
||||
val = value?.toString() || null
|
||||
display =
|
||||
typeof val === 'string' && val.startsWith('https://') ? (
|
||||
<a
|
||||
href={val}
|
||||
className="underline text-primary truncate"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{val}
|
||||
</a>
|
||||
) : (
|
||||
val
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="flex w-full bg-card items-center divide-x divide-border border-b border-border p-0"
|
||||
>
|
||||
<div className="w-1/2 px-4 p-2 text-sm text-muted-foreground font-normal truncate">
|
||||
{key}
|
||||
</div>
|
||||
<div className="w-1/2 px-4 p-2 text-sm font-medium flex items-center justify-between min-w-0">
|
||||
<div className="truncate font-semibold">
|
||||
{display || <span className="italic text-muted-foreground">N/A</span>}
|
||||
</div>
|
||||
{display && val && typeof value !== 'boolean' && (
|
||||
<CopyButton className="h-6 w-6 shrink-0" content={val} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button className="h-6 flex items-end text-xs cursor-pointer">
|
||||
<ArrowDownLeft className="h-3 w-3 opacity-60" />{' '}
|
||||
<span className="underline">{label}</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="mr-4">
|
||||
<PopoverHeader>
|
||||
<PopoverTitle>{label}</PopoverTitle>
|
||||
{Object.entries(property).map(([key, value]) => (
|
||||
<div>
|
||||
{key} : {JSON.stringify(value)}
|
||||
</div>
|
||||
))}
|
||||
</PopoverHeader>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
export const MemoizedKeyValueDisplay = memo(KeyValueDisplay)
|
||||
|
||||
interface StatusCodeBadgeProps {
|
||||
statusCode: number
|
||||
}
|
||||
export function StatusCodeBadge({ statusCode }: StatusCodeBadgeProps) {
|
||||
const category = Math.floor(statusCode / 100)
|
||||
|
||||
const variants = {
|
||||
1: 'bg-blue-100 text-blue-800 border-blue-300',
|
||||
2: 'bg-green-100 text-green-800 border-green-300',
|
||||
3: 'bg-purple-100 text-purple-800 border-purple-300',
|
||||
4: 'bg-orange-100 text-orange-800 border-orange-300',
|
||||
5: 'bg-red-100 text-red-800 border-red-300',
|
||||
}
|
||||
|
||||
const labels = {
|
||||
1: 'Informational',
|
||||
2: 'Success',
|
||||
3: 'Redirect',
|
||||
4: 'Client Error',
|
||||
5: 'Server Error',
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn("text-xs", variants[category as keyof typeof variants] || 'bg-gray-100 text-gray-800')}
|
||||
>
|
||||
{statusCode}
|
||||
{/* {labels[category as keyof typeof labels]} */}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
@@ -8,32 +8,36 @@ import { useIcon } from '@/hooks/use-icon'
|
||||
import { useConfirm } from '@/components/use-confirm-dialog'
|
||||
import { sketchService } from '@/api/sketch-service'
|
||||
import { CopyButton } from '@/components/copy'
|
||||
import { GraphEdge } from '@/types'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
const EdgeDetailsPanel = memo(({ edge }: { edge: GraphEdge | null }) => {
|
||||
const EdgeDetailsPanel = memo(() => {
|
||||
const { id: sketchId } = useParams({ strict: false })
|
||||
const edges = useGraphStore((s) => s.edges)
|
||||
const nodes = useGraphStore((s) => s.nodes)
|
||||
const setCurrentNode = useGraphStore((s) => s.setCurrentNode)
|
||||
const setCurrentEdge = useGraphStore((s) => s.setCurrentEdge)
|
||||
const setCurrentNodeId = useGraphStore((s) => s.setCurrentNodeId)
|
||||
const setCurrentEdgeId = useGraphStore((s) => s.setCurrentEdgeId)
|
||||
const removeEdges = useGraphStore((s) => s.removeEdges)
|
||||
const updateEdge = useGraphStore((s) => s.updateEdge)
|
||||
const { confirm } = useConfirm()
|
||||
const labelRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Get fresh edge data from store
|
||||
edge = edges.find((e) => e.id === edge?.id) || null
|
||||
const edge = useGraphStore((s) => s.getCurrentEdge())
|
||||
|
||||
// Find source and target nodes
|
||||
const sourceNode = edge ? nodes.find((n) => n.id === edge.source) : null
|
||||
const targetNode = edge ? nodes.find((n) => n.id === edge.target) : null
|
||||
|
||||
const SourceIcon = useIcon(sourceNode?.data?.type ?? 'default', sourceNode?.data?.src)
|
||||
const TargetIcon = useIcon(targetNode?.data?.type ?? 'default', targetNode?.data?.src)
|
||||
const SourceIcon = useIcon(sourceNode?.nodeType ?? 'default', {
|
||||
nodeColor: sourceNode?.nodeColor,
|
||||
nodeIcon: sourceNode?.nodeIcon,
|
||||
nodeImage: sourceNode?.nodeImage
|
||||
})
|
||||
const TargetIcon = useIcon(targetNode?.nodeType ?? 'default', {
|
||||
nodeColor: targetNode?.nodeColor,
|
||||
nodeIcon: targetNode?.nodeIcon,
|
||||
nodeImage: targetNode?.nodeImage
|
||||
})
|
||||
|
||||
const handleInputChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
|
||||
e.stopPropagation()
|
||||
@@ -86,7 +90,7 @@ const EdgeDetailsPanel = memo(({ edge }: { edge: GraphEdge | null }) => {
|
||||
}
|
||||
|
||||
// Close panel
|
||||
setCurrentEdge(null)
|
||||
setCurrentEdgeId(null)
|
||||
|
||||
// Optimistic delete + API call with toast
|
||||
toast.promise(
|
||||
@@ -103,19 +107,19 @@ const EdgeDetailsPanel = memo(({ edge }: { edge: GraphEdge | null }) => {
|
||||
error: 'Failed to delete relationship.'
|
||||
}
|
||||
)
|
||||
}, [edge, sketchId, confirm, removeEdges, setCurrentEdge])
|
||||
}, [edge, sketchId, confirm, removeEdges, setCurrentEdgeId])
|
||||
|
||||
const handleJumpToSource = useCallback(() => {
|
||||
if (sourceNode) {
|
||||
setCurrentNode(sourceNode)
|
||||
setCurrentNodeId(sourceNode.id)
|
||||
}
|
||||
}, [sourceNode, setCurrentNode])
|
||||
}, [sourceNode, setCurrentNodeId])
|
||||
|
||||
const handleJumpToTarget = useCallback(() => {
|
||||
if (targetNode) {
|
||||
setCurrentNode(targetNode)
|
||||
setCurrentNodeId(targetNode.id)
|
||||
}
|
||||
}, [targetNode, setCurrentNode])
|
||||
}, [targetNode, setCurrentNodeId])
|
||||
|
||||
if (!edge) {
|
||||
return (
|
||||
@@ -177,31 +181,31 @@ const EdgeDetailsPanel = memo(({ edge }: { edge: GraphEdge | null }) => {
|
||||
onClick={handleJumpToSource}
|
||||
className="flex-1 flex items-center gap-2 p-2 rounded-lg border bg-muted/30 hover:bg-muted/50 transition-colors group min-w-0"
|
||||
>
|
||||
<div className="flex items-center justify-center w-6 h-6 rounded-full bg-background flex-shrink-0">
|
||||
<div className="flex items-center justify-center w-6 h-6 rounded-full bg-background shrink-0">
|
||||
<SourceIcon className="h-3 w-3" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 text-left">
|
||||
<p className="text-[10px] text-muted-foreground mb-0.5">Source</p>
|
||||
<p className="text-xs font-medium truncate group-hover:text-primary transition-colors">
|
||||
{sourceNode?.data?.label || 'Unknown'}
|
||||
{sourceNode?.nodeLabel || 'Unknown'}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
|
||||
{/* Target Node */}
|
||||
<button
|
||||
onClick={handleJumpToTarget}
|
||||
className="flex-1 flex items-center gap-2 p-2 rounded-lg border bg-muted/30 hover:bg-muted/50 transition-colors group min-w-0"
|
||||
>
|
||||
<div className="flex items-center justify-center w-6 h-6 rounded-full bg-background flex-shrink-0">
|
||||
<div className="flex items-center justify-center w-6 h-6 rounded-full bg-background shrink-0">
|
||||
<TargetIcon className="h-3 w-3" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 text-left">
|
||||
<p className="text-[10px] text-muted-foreground mb-0.5">Target</p>
|
||||
<p className="text-xs font-medium truncate group-hover:text-primary transition-colors">
|
||||
{targetNode?.data?.label || 'Unknown'}
|
||||
{targetNode?.nodeLabel || 'Unknown'}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
@@ -271,7 +275,7 @@ const EdgeDetailsPanel = memo(({ edge }: { edge: GraphEdge | null }) => {
|
||||
</div>
|
||||
<div className="w-1/2 px-3 py-1.5 text-xs font-medium flex items-center justify-between min-w-0 gap-1">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<div className="h-1.5 flex-1 min-w-[40px] max-w-[60px] bg-muted rounded-full overflow-hidden">
|
||||
<div className="h-1.5 flex-1 min-w-10 max-w-[60px] bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full transition-all',
|
||||
|
||||
@@ -1,22 +1,62 @@
|
||||
import { memo, useRef, useMemo, useCallback } from 'react'
|
||||
import { sketchService } from '@/api/sketch-service'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import ForceGraphViewer from '../graph-viewer'
|
||||
import Loader from '@/components/loader'
|
||||
import { memo, useRef } from 'react'
|
||||
import { GraphNode } from '@/types'
|
||||
import { RotateCcw } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
const NeighborsGraph = memo(
|
||||
({ sketchId, nodeId, nodeLength }: { sketchId: string; nodeId: string; nodeLength: number }) => {
|
||||
({
|
||||
sketchId,
|
||||
currentNode,
|
||||
nodeLength
|
||||
}: {
|
||||
sketchId: string
|
||||
currentNode: GraphNode
|
||||
nodeLength: number
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const { data: neighborsData, isLoading } = useQuery({
|
||||
queryKey: ['neighbors', sketchId, nodeId, nodeLength],
|
||||
queryFn: () => sketchService.getNodeNeighbors(sketchId, nodeId)
|
||||
const {
|
||||
data: neighborsData,
|
||||
isLoading,
|
||||
error,
|
||||
refetch
|
||||
} = useQuery({
|
||||
queryKey: ['neighbors', sketchId, currentNode.id, nodeLength],
|
||||
queryFn: () => sketchService.getNodeNeighbors(sketchId, currentNode.id)
|
||||
})
|
||||
|
||||
// omit x and y positions to let the GraphViewer manange the forces
|
||||
const nodes = useMemo(
|
||||
() =>
|
||||
neighborsData?.nds.map(
|
||||
({ id, nodeType, nodeColor, nodeIcon, nodeLabel, nodeImage, nodeShape }: GraphNode) => ({
|
||||
id,
|
||||
nodeType,
|
||||
nodeColor,
|
||||
nodeIcon,
|
||||
nodeLabel,
|
||||
nodeImage,
|
||||
nodeShape
|
||||
})
|
||||
),
|
||||
[neighborsData?.nds]
|
||||
)
|
||||
const handleRefetch = useCallback(() => {
|
||||
refetch()
|
||||
}, [])
|
||||
|
||||
if (error)
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
|
||||
Could not load neighbors.
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="overflow-hidden bg-background border relative h-full w-full"
|
||||
>
|
||||
<div ref={containerRef} className="overflow-hidden bg-background relative h-full w-full">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center grow h-full">
|
||||
<Loader />
|
||||
@@ -27,12 +67,18 @@ const NeighborsGraph = memo(
|
||||
<span className="font-semibold">{neighborsData.nds.length - 1}</span>
|
||||
<span className="opacity-70">neighbor(s)</span>
|
||||
</div>
|
||||
|
||||
<div className="top-.5 p-1 right-8 bg-card/60 backdrop-blur z-10 rounded-br-lg absolute text-sm flex gap-1">
|
||||
<Button onClick={handleRefetch} variant={'ghost'} size={'icon'} className="h-6 w-6">
|
||||
<RotateCcw className="h-3.5 w-3.5 opacity-70" />
|
||||
</Button>
|
||||
</div>
|
||||
<ForceGraphViewer
|
||||
nodes={neighborsData.nds}
|
||||
nodes={nodes}
|
||||
edges={neighborsData.rls}
|
||||
onNodeClick={() => { }}
|
||||
onNodeRightClick={() => { }}
|
||||
onBackgroundClick={() => { }}
|
||||
onNodeClick={() => {}}
|
||||
onNodeRightClick={() => {}}
|
||||
onBackgroundClick={() => {}}
|
||||
showLabels={true}
|
||||
showIcons={true}
|
||||
backgroundColor="transparent"
|
||||
@@ -40,6 +86,7 @@ const NeighborsGraph = memo(
|
||||
allowForces={true}
|
||||
autoZoomOnNode={false}
|
||||
showMinimap={false}
|
||||
showMinimalControls={true}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,357 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Edit3, Save, X, Hash, Type, FileText, Check, Loader2 } from 'lucide-react'
|
||||
import type { NodeData } from '@/types'
|
||||
import { useGraphStore } from '@/stores/graph-store'
|
||||
import { MapFromAddress } from '../../map/map'
|
||||
import { sketchService } from '@/api/sketch-service'
|
||||
import { useParams } from '@tanstack/react-router'
|
||||
import { toast } from 'sonner'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { useIcon } from '@/hooks/use-icon'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { queryKeys } from '@/api/query-keys'
|
||||
|
||||
export const NodeEditorModal: React.FC = () => {
|
||||
const currentNode = useGraphStore((state) => state.currentNode)
|
||||
const openNodeEditorModal = useGraphStore((state) => state.openNodeEditorModal)
|
||||
const setOpenNodeEditorModal = useGraphStore((state) => state.setOpenNodeEditorModal)
|
||||
const updateNode = useGraphStore((state) => state.updateNode)
|
||||
const setCurrentNode = useGraphStore((state) => state.setCurrentNode)
|
||||
const { id: sketchId } = useParams({ strict: false })
|
||||
const IconComponent = useIcon(
|
||||
currentNode?.data.type as string,
|
||||
currentNode?.data?.src as string | null
|
||||
)
|
||||
|
||||
const [formData, setFormData] = useState<Partial<NodeData>>({
|
||||
label: '',
|
||||
caption: '',
|
||||
type: ''
|
||||
})
|
||||
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
// Update form data when currentNode changes
|
||||
useEffect(() => {
|
||||
if (currentNode) {
|
||||
setFormData({
|
||||
...currentNode.data,
|
||||
label: currentNode.data.label || '',
|
||||
caption: currentNode.data.caption || '',
|
||||
type: currentNode.data.type || ''
|
||||
})
|
||||
}
|
||||
}, [currentNode])
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// Update node mutation
|
||||
const updateNodeMutation = useMutation({
|
||||
mutationFn: async ({ sketchId, updateData }: { sketchId: string; updateData: any }) => {
|
||||
return sketchService.updateNode(sketchId, JSON.stringify(updateData))
|
||||
},
|
||||
onSuccess: (result, variables) => {
|
||||
if (result.status === 'node updated' && currentNode) {
|
||||
// Update the local store
|
||||
updateNode(currentNode.id, formData)
|
||||
setCurrentNode({
|
||||
...currentNode,
|
||||
data: {
|
||||
...currentNode.data,
|
||||
...formData
|
||||
}
|
||||
})
|
||||
|
||||
// Invalidate related queries
|
||||
if (sketchId) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.sketches.detail(sketchId)
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.sketches.graph(sketchId, sketchId)
|
||||
})
|
||||
// Also invalidate investigation queries that might show sketch data
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.investigations.list
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.investigations.dashboard
|
||||
})
|
||||
}
|
||||
|
||||
toast.success('Node updated successfully')
|
||||
setOpenNodeEditorModal(false)
|
||||
} else {
|
||||
toast.error('Failed to update node')
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Error updating node:', error)
|
||||
toast.error('Failed to update node. Please try again.')
|
||||
}
|
||||
})
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!currentNode || !sketchId) return
|
||||
setIsSaving(true)
|
||||
|
||||
try {
|
||||
// Prepare the data for the API
|
||||
const updateData = {
|
||||
nodeId: currentNode.id,
|
||||
data: formData
|
||||
}
|
||||
|
||||
// Call the mutation
|
||||
await updateNodeMutation.mutateAsync({ sketchId, updateData })
|
||||
} catch (error) {
|
||||
console.error('Error updating node:', error)
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setOpenNodeEditorModal(false)
|
||||
}
|
||||
|
||||
const handleInputChange = (field: keyof NodeData, value: string) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[field]: value
|
||||
}))
|
||||
}
|
||||
|
||||
const isLocation =
|
||||
currentNode?.data.type === 'location' ||
|
||||
(currentNode?.data.latitude && currentNode?.data.longitude)
|
||||
|
||||
if (!currentNode) return null
|
||||
|
||||
// Separate core fields from additional properties
|
||||
const coreFields = ['label', 'caption', 'type', 'id']
|
||||
const additionalFields = Object.entries(currentNode.data).filter(
|
||||
([key]) => !coreFields.includes(key) && key !== 'ID'
|
||||
)
|
||||
|
||||
return (
|
||||
<Sheet open={openNodeEditorModal} onOpenChange={setOpenNodeEditorModal}>
|
||||
<SheetContent className="!w-full !max-w-2xl h-full !duration-100 p-0 flex flex-col h-full">
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<SheetHeader className="px-6 py-4 border-b bg-muted/30 flex-shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-primary/10">
|
||||
<Edit3 className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<SheetTitle className="text-lg font-semibold">Edit Node Properties</SheetTitle>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{currentNode.data.label || currentNode.id}
|
||||
</p>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{currentNode.data.type || 'Unknown Type'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 px-6 py-6 grow overflow-y-auto">
|
||||
<div className="space-y-6">
|
||||
{/* Preview Section */}
|
||||
<div className="p-4 rounded-lg bg-background border">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex-shrink-0">
|
||||
{currentNode.data.src ? (
|
||||
<Avatar className="rounded-lg h-16 w-16 border-2 border-muted">
|
||||
<AvatarImage
|
||||
className="rounded-lg object-cover"
|
||||
src={currentNode.data.src}
|
||||
/>
|
||||
<AvatarFallback className="text-lg font-semibold rounded-lg">
|
||||
{formData.label?.[0]?.toUpperCase() || '?'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
) : (
|
||||
<div className="h-16 w-16 rounded-lg border-2 border-muted bg-muted/30 flex items-center justify-center">
|
||||
<IconComponent className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-1">
|
||||
{formData.label || 'No label'}
|
||||
</h3>
|
||||
<p className="text-sm font-medium text-primary">{formData.type || 'No type'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Core Properties */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Core Properties</h3>
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="label" className="text-sm font-medium flex items-center gap-2">
|
||||
<FileText className="h-3 w-3" />
|
||||
Label
|
||||
</Label>
|
||||
<Input
|
||||
id="label"
|
||||
value={formData.label || ''}
|
||||
onChange={(e) => handleInputChange('label', e.target.value)}
|
||||
placeholder="Enter node label"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="type" className="text-sm font-medium flex items-center gap-2">
|
||||
<Type className="h-3 w-3" />
|
||||
Type
|
||||
</Label>
|
||||
<Input
|
||||
id="type"
|
||||
value={formData.type || ''}
|
||||
onChange={(e) => handleInputChange('type', e.target.value)}
|
||||
placeholder="Enter node type"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="caption"
|
||||
className="text-sm font-medium flex items-center gap-2"
|
||||
>
|
||||
<FileText className="h-3 w-3" />
|
||||
Caption
|
||||
</Label>
|
||||
<Input
|
||||
id="caption"
|
||||
value={formData.caption || ''}
|
||||
onChange={(e) => handleInputChange('caption', e.target.value)}
|
||||
placeholder="Enter node caption"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Additional Properties */}
|
||||
{additionalFields.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">
|
||||
Additional Properties
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{additionalFields.map(([key, value]) => (
|
||||
<div key={key} className="space-y-2">
|
||||
<Label htmlFor={key} className="text-sm font-medium capitalize">
|
||||
{key.replace(/_/g, ' ')}
|
||||
</Label>
|
||||
{typeof value === 'boolean' ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={formData[key as keyof NodeData] ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleInputChange(
|
||||
key as keyof NodeData,
|
||||
(!formData[key as keyof NodeData]).toString()
|
||||
)
|
||||
}
|
||||
className="gap-2"
|
||||
>
|
||||
{formData[key as keyof NodeData] ? (
|
||||
<>
|
||||
<Check className="h-3 w-3" />
|
||||
True
|
||||
</>
|
||||
) : (
|
||||
'False'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : typeof value === 'string' && value.length > 100 ? (
|
||||
<Textarea
|
||||
id={key}
|
||||
value={(formData[key as keyof NodeData] as string) || ''}
|
||||
onChange={(e) =>
|
||||
handleInputChange(key as keyof NodeData, e.target.value)
|
||||
}
|
||||
placeholder={`Enter ${key.replace(/_/g, ' ').toLowerCase()}`}
|
||||
className="min-h-[80px] resize-none"
|
||||
rows={3}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
id={key}
|
||||
value={(formData[key as keyof NodeData] as string) || ''}
|
||||
onChange={(e) =>
|
||||
handleInputChange(key as keyof NodeData, e.target.value)
|
||||
}
|
||||
placeholder={`Enter ${key.replace(/_/g, ' ').toLowerCase()}`}
|
||||
className="h-9"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Location Map */}
|
||||
{isLocation && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Location</h3>
|
||||
<div className="overflow-hidden rounded-lg bg-background border">
|
||||
<MapFromAddress
|
||||
locations={[
|
||||
{
|
||||
lat: formData.latitude,
|
||||
lon: formData.longitude,
|
||||
address: formData.label as string,
|
||||
label: formData.label as string
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<SheetFooter className="px-6 py-4 border-t bg-muted/30 flex-shrink-0">
|
||||
<div className="flex justify-between items-center w-full">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{additionalFields.length} additional properties
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleCancel} className="gap-2">
|
||||
<X className="h-4 w-4" />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isSaving} className="gap-2">
|
||||
{isSaving ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-4 w-4" />
|
||||
)}
|
||||
{isSaving ? 'Saving...' : 'Save changes'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SheetFooter>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
@@ -76,8 +76,7 @@ const VirtualizedList = memo(({ relationships }: { relationships: Relation[] })
|
||||
firstItem: virtualItems[0],
|
||||
parentRefCurrent: parentRef.current
|
||||
})
|
||||
const totalSize
|
||||
= virtualizer.getTotalSize()
|
||||
const totalSize = virtualizer.getTotalSize()
|
||||
|
||||
return (
|
||||
<div ref={parentRef} className="h-full py-1.5 overflow-auto">
|
||||
@@ -104,18 +103,18 @@ const VirtualizedList = memo(({ relationships }: { relationships: Relation[] })
|
||||
height: `${virtualRow.size}px`,
|
||||
transform: `translateY(${virtualRow.start}px)`
|
||||
}}
|
||||
className="pb-1 px-3"
|
||||
className="pb-1 px-1"
|
||||
>
|
||||
<Badge variant={'outline'} className="h-8 px-2 py-1 w-full block">
|
||||
<div className="flex items-center gap-1 w-full min-w-0 h-full">
|
||||
<div className="min-w-0 flex-1 flex items-center">
|
||||
<RelationshipItem node={rel.source} />
|
||||
</div>
|
||||
<ArrowRight className="flex-shrink-0 opacity-60 h-4 w-4" />
|
||||
<ArrowRight className="shrink-0 opacity-60 h-4 w-4" />
|
||||
<div className="min-w-0 flex-1 flex items-center justify-center">
|
||||
<span className="opacity-60 text-xs truncate block">{rel.edge.label}</span>
|
||||
</div>
|
||||
<ArrowRight className="flex-shrink-0 opacity-60 h-4 w-4" />
|
||||
<ArrowRight className="shrink-0 opacity-60 h-4 w-4" />
|
||||
<div className="min-w-0 flex-1 flex items-center justify-end">
|
||||
<RelationshipItem node={rel.target} />
|
||||
</div>
|
||||
@@ -127,23 +126,22 @@ const VirtualizedList = memo(({ relationships }: { relationships: Relation[] })
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
export default Relationships
|
||||
|
||||
const RelationshipItem = memo(({ node }: { node: GraphNode }) => {
|
||||
const setCurrentNode = useGraphStore((s) => s.setCurrentNode)
|
||||
const setCurrentNodeId = useGraphStore((s) => s.setCurrentNodeId)
|
||||
const handleClick = useCallback(() => {
|
||||
setCurrentNode(node)
|
||||
}, [setCurrentNode, node])
|
||||
setCurrentNodeId(node.id)
|
||||
}, [setCurrentNodeId, node.id])
|
||||
return (
|
||||
<button
|
||||
className="w-full h-full text-left hover:underline cursor-pointer flex items-center"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<TypeBadge className="w-full truncate block text-center" type={node.data.type}>
|
||||
{node.data.label}
|
||||
<TypeBadge className="w-full truncate block text-center" type={node.nodeType}>
|
||||
{node.nodeLabel}
|
||||
</TypeBadge>
|
||||
</button>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
'use client'
|
||||
|
||||
import React, { memo, useState, useCallback, useMemo } from 'react'
|
||||
import { PanelGroup, Panel, PanelResizeHandle } from 'react-resizable-panels'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ChevronRight, GripHorizontal } from 'lucide-react'
|
||||
|
||||
// Section Header Component
|
||||
const SectionHeader = memo(
|
||||
({
|
||||
label,
|
||||
isOpen,
|
||||
onToggle,
|
||||
count
|
||||
}: {
|
||||
label: string
|
||||
isOpen: boolean
|
||||
onToggle: () => void
|
||||
count?: number
|
||||
}) => (
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="flex items-center gap-1 w-full px-3 py-2 text-left border-b border-border bg-muted/30 hover:bg-muted/50 transition-colors shrink-0"
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'h-3 w-3 text-muted-foreground/50 transition-transform duration-150',
|
||||
isOpen && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
<span className="text-[11px] uppercase tracking-wide text-muted-foreground font-medium">
|
||||
{label}
|
||||
</span>
|
||||
{count !== undefined && count > 0 && (
|
||||
<span className="text-[10px] text-muted-foreground/40 ml-1">{count}</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
)
|
||||
SectionHeader.displayName = 'SectionHeader'
|
||||
|
||||
// Resize Handle Component
|
||||
const ResizeHandle = memo(({ id }: { id: string }) => (
|
||||
<PanelResizeHandle
|
||||
id={`resize-handle-${id}`}
|
||||
className="h-2 flex items-center justify-center bg-border/30 hover:bg-border/60 transition-colors group relative"
|
||||
>
|
||||
<GripHorizontal className="h-3 w-3 text-muted-foreground/30 group-hover:text-muted-foreground/60 transition-colors" />
|
||||
</PanelResizeHandle>
|
||||
))
|
||||
ResizeHandle.displayName = 'ResizeHandle'
|
||||
|
||||
// Panel Section Props
|
||||
export type PanelSectionConfig = {
|
||||
id: string
|
||||
label: string
|
||||
content: React.ReactNode
|
||||
defaultOpen?: boolean
|
||||
fixedSize?: number // percentage - makes section non-resizable
|
||||
defaultSize?: number // percentage
|
||||
minSize?: number // percentage
|
||||
}
|
||||
|
||||
// Main Resizable Details Panel
|
||||
export function ResizableDetailsPanel({
|
||||
sections,
|
||||
className
|
||||
}: {
|
||||
sections: PanelSectionConfig[]
|
||||
className?: string
|
||||
}) {
|
||||
const [openSections, setOpenSections] = useState<Record<string, boolean>>(() =>
|
||||
sections.reduce(
|
||||
(acc, section) => ({
|
||||
...acc,
|
||||
[section.id]: section.defaultOpen ?? true
|
||||
}),
|
||||
{}
|
||||
)
|
||||
)
|
||||
|
||||
const toggleSection = useCallback((id: string) => {
|
||||
setOpenSections((prev) => ({ ...prev, [id]: !prev[id] }))
|
||||
}, [])
|
||||
|
||||
// Get open sections while maintaining original order
|
||||
const openSectionsList = useMemo(
|
||||
() => sections.filter((s) => openSections[s.id]),
|
||||
[sections, openSections]
|
||||
)
|
||||
|
||||
// Calculate default sizes for open sections
|
||||
const getDefaultSize = useCallback(
|
||||
(section: PanelSectionConfig) => {
|
||||
if (section.fixedSize) return section.fixedSize
|
||||
if (section.defaultSize) return section.defaultSize
|
||||
// Distribute remaining space equally
|
||||
const fixedTotal = openSectionsList
|
||||
.filter((s) => s.fixedSize)
|
||||
.reduce((sum, s) => sum + (s.fixedSize || 0), 0)
|
||||
const flexibleCount = openSectionsList.filter((s) => !s.fixedSize).length
|
||||
return flexibleCount > 0 ? (100 - fixedTotal) / flexibleCount : 100 / openSectionsList.length
|
||||
},
|
||||
[openSectionsList]
|
||||
)
|
||||
|
||||
// Build the render structure: interleave collapsed headers with resizable panel group
|
||||
// We render collapsed sections inline, in their original position
|
||||
const buildRenderGroups = () => {
|
||||
const result: React.ReactNode[] = []
|
||||
let currentOpenGroup: PanelSectionConfig[] = []
|
||||
|
||||
const flushOpenGroup = () => {
|
||||
if (currentOpenGroup.length > 0) {
|
||||
const groupKey = currentOpenGroup.map((s) => s.id).join('-')
|
||||
result.push(
|
||||
<PanelGroup key={groupKey} direction="vertical" className="flex-1 min-h-0">
|
||||
{currentOpenGroup.map((section, index) => {
|
||||
const isLast = index === currentOpenGroup.length - 1
|
||||
return (
|
||||
<React.Fragment key={section.id}>
|
||||
<Panel
|
||||
id={section.id}
|
||||
defaultSize={getDefaultSize(section)}
|
||||
minSize={section.minSize ?? 8}
|
||||
className="flex flex-col min-h-0"
|
||||
>
|
||||
<SectionHeader
|
||||
label={section.label}
|
||||
isOpen={true}
|
||||
onToggle={() => toggleSection(section.id)}
|
||||
/>
|
||||
<div className="flex-1 overflow-y-auto min-h-0">{section.content}</div>
|
||||
</Panel>
|
||||
{!isLast && <ResizeHandle id={section.id} />}
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</PanelGroup>
|
||||
)
|
||||
currentOpenGroup = []
|
||||
}
|
||||
}
|
||||
|
||||
sections.forEach((section) => {
|
||||
const isOpen = openSections[section.id]
|
||||
|
||||
if (isOpen) {
|
||||
currentOpenGroup.push(section)
|
||||
} else {
|
||||
// Flush any accumulated open sections first
|
||||
flushOpenGroup()
|
||||
// Then render the collapsed header
|
||||
result.push(
|
||||
<SectionHeader
|
||||
key={`collapsed-${section.id}`}
|
||||
label={section.label}
|
||||
isOpen={false}
|
||||
onToggle={() => toggleSection(section.id)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// Flush any remaining open sections
|
||||
flushOpenGroup()
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col h-full bg-card overflow-hidden', className)}>
|
||||
{buildRenderGroups()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Row Component for displaying key-value pairs
|
||||
export const Row = memo(({ label, children }: { label: string; children: React.ReactNode }) => (
|
||||
<div className="flex items-center justify-between gap-4 py-1.5 group hover:bg-muted/20 -mx-2 px-2 rounded-sm transition-colors">
|
||||
<span className="text-[13px] text-muted-foreground">{label.replace(/_/g, ' ')}</span>
|
||||
<div className="text-[13px] grow text-foreground flex items-center justify-end gap-1">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
Row.displayName = 'Row'
|
||||
@@ -32,7 +32,7 @@ export const DraggableItem = memo(function DraggableItem({
|
||||
const colors = useNodesDisplaySettings((s) => s.colors)
|
||||
// @ts-ignore
|
||||
const colorStr = colors[icon as string] || color
|
||||
const IconComponent = useIcon(icon)
|
||||
const IconComponent = useIcon(icon, { nodeIcon: icon, nodeColor: color })
|
||||
|
||||
const handleDragStart = (e: React.DragEvent<HTMLButtonElement>) => {
|
||||
const itemData = JSON.stringify({ label, type, color, description, itemKey })
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user