Spaces:
Running
Running
Ashhar
commited on
Commit
·
c0a9bce
1
Parent(s):
306fc1f
sync with bolt.diy
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .github/ISSUE_TEMPLATE/bug_report.yml +1 -1
- .github/ISSUE_TEMPLATE/epic.md +1 -1
- .github/ISSUE_TEMPLATE/feature.md +3 -3
- .github/workflows/docker.yaml +28 -48
- .github/workflows/docs.yaml +3 -3
- .github/workflows/pr-release-validation.yaml +3 -3
- .github/workflows/semantic-pr.yaml +1 -1
- .github/workflows/stale.yml +11 -11
- .github/workflows/update-stable.yml +8 -7
- .gitignore +5 -1
- .tool-versions +1 -1
- CONTRIBUTING.md +87 -64
- FAQ.md +19 -5
- PROJECT.md +4 -4
- app/components/@settings/core/AvatarDropdown.tsx +158 -0
- app/components/@settings/core/ControlPanel.tsx +555 -0
- app/components/@settings/core/constants.ts +88 -0
- app/components/@settings/core/types.ts +114 -0
- app/components/@settings/index.ts +14 -0
- app/components/@settings/shared/components/DraggableTabList.tsx +163 -0
- app/components/@settings/shared/components/TabManagement.tsx +380 -0
- app/components/@settings/shared/components/TabTile.tsx +135 -0
- app/components/@settings/tabs/connections/ConnectionsTab.tsx +28 -0
- app/components/@settings/tabs/connections/GithubConnection.tsx +557 -0
- app/components/@settings/tabs/connections/NetlifyConnection.tsx +263 -0
- app/components/@settings/tabs/connections/components/ConnectionForm.tsx +180 -0
- app/components/@settings/tabs/connections/components/CreateBranchDialog.tsx +150 -0
- app/components/@settings/tabs/connections/components/PushToGitHubDialog.tsx +528 -0
- app/components/@settings/tabs/connections/components/RepositorySelectionDialog.tsx +693 -0
- app/components/@settings/tabs/connections/types/GitHub.ts +95 -0
- app/components/@settings/tabs/data/DataTab.tsx +452 -0
- app/components/@settings/tabs/debug/DebugTab.tsx +2045 -0
- app/components/@settings/tabs/event-logs/EventLogsTab.tsx +1013 -0
- app/components/@settings/tabs/features/FeaturesTab.tsx +295 -0
- app/components/@settings/tabs/notifications/NotificationsTab.tsx +300 -0
- app/components/@settings/tabs/profile/ProfileTab.tsx +181 -0
- app/components/@settings/tabs/providers/cloud/CloudProvidersTab.tsx +305 -0
- app/components/@settings/tabs/providers/local/LocalProvidersTab.tsx +777 -0
- app/components/@settings/tabs/providers/local/OllamaModelInstaller.tsx +603 -0
- app/components/@settings/tabs/providers/service-status/ServiceStatusTab.tsx +135 -0
- app/components/@settings/tabs/providers/service-status/base-provider.ts +121 -0
- app/components/@settings/tabs/providers/service-status/provider-factory.ts +154 -0
- app/components/@settings/tabs/providers/service-status/providers/amazon-bedrock.ts +76 -0
- app/components/@settings/tabs/providers/service-status/providers/anthropic.ts +80 -0
- app/components/@settings/tabs/providers/service-status/providers/cohere.ts +91 -0
- app/components/@settings/tabs/providers/service-status/providers/deepseek.ts +40 -0
- app/components/@settings/tabs/providers/service-status/providers/google.ts +77 -0
- app/components/@settings/tabs/providers/service-status/providers/groq.ts +72 -0
- app/components/@settings/tabs/providers/service-status/providers/huggingface.ts +98 -0
- app/components/@settings/tabs/providers/service-status/providers/hyperbolic.ts +40 -0
.github/ISSUE_TEMPLATE/bug_report.yml
CHANGED
@@ -1,4 +1,4 @@
|
|
1 |
-
name:
|
2 |
description: Create a report to help us improve
|
3 |
body:
|
4 |
- type: markdown
|
|
|
1 |
+
name: 'Bug report'
|
2 |
description: Create a report to help us improve
|
3 |
body:
|
4 |
- type: markdown
|
.github/ISSUE_TEMPLATE/epic.md
CHANGED
@@ -19,5 +19,5 @@ Usual values: Software Developers using the IDE | Contributors -->
|
|
19 |
|
20 |
# Capabilities
|
21 |
|
22 |
-
<!-- which existing capabilities or future features can be imagined that belong to this epic? This list serves as illustration to sketch the boundaries of this epic.
|
23 |
Once features are actually being planned / described in detail, they can be linked here. -->
|
|
|
19 |
|
20 |
# Capabilities
|
21 |
|
22 |
+
<!-- which existing capabilities or future features can be imagined that belong to this epic? This list serves as illustration to sketch the boundaries of this epic.
|
23 |
Once features are actually being planned / described in detail, they can be linked here. -->
|
.github/ISSUE_TEMPLATE/feature.md
CHANGED
@@ -13,13 +13,13 @@ assignees: ''
|
|
13 |
|
14 |
# Scope
|
15 |
|
16 |
-
<!-- This is kind-of the definition-of-done for a feature.
|
17 |
Try to keep the scope as small as possible and prefer creating multiple, small features which each solve a single problem / make something better
|
18 |
-->
|
19 |
|
20 |
# Options
|
21 |
-
|
22 |
-
<!-- If you already have an idea how this can be implemented, please describe it here.
|
23 |
This allows potential other contributors to join forces and provide meaningful feedback prio to even starting work on it.
|
24 |
-->
|
25 |
|
|
|
13 |
|
14 |
# Scope
|
15 |
|
16 |
+
<!-- This is kind-of the definition-of-done for a feature.
|
17 |
Try to keep the scope as small as possible and prefer creating multiple, small features which each solve a single problem / make something better
|
18 |
-->
|
19 |
|
20 |
# Options
|
21 |
+
|
22 |
+
<!-- If you already have an idea how this can be implemented, please describe it here.
|
23 |
This allows potential other contributors to join forces and provide meaningful feedback prio to even starting work on it.
|
24 |
-->
|
25 |
|
.github/workflows/docker.yaml
CHANGED
@@ -1,14 +1,11 @@
|
|
1 |
-
---
|
2 |
name: Docker Publish
|
3 |
|
4 |
on:
|
5 |
-
workflow_dispatch:
|
6 |
push:
|
7 |
branches:
|
8 |
- main
|
9 |
-
|
10 |
-
|
11 |
-
- "*"
|
12 |
|
13 |
permissions:
|
14 |
packages: write
|
@@ -16,66 +13,49 @@ permissions:
|
|
16 |
|
17 |
env:
|
18 |
REGISTRY: ghcr.io
|
19 |
-
|
20 |
-
BUILD_TARGET: bolt-ai-production # bolt-ai-development
|
21 |
|
22 |
jobs:
|
23 |
docker-build-publish:
|
24 |
runs-on: ubuntu-latest
|
25 |
steps:
|
26 |
-
- name: Checkout
|
27 |
uses: actions/checkout@v4
|
28 |
|
29 |
-
- id: string
|
30 |
-
uses: ASzc/change-string-case-action@v6
|
31 |
-
with:
|
32 |
-
string: ${{ env.DOCKER_IMAGE }}
|
33 |
-
|
34 |
-
- name: Docker meta
|
35 |
-
id: meta
|
36 |
-
uses: crazy-max/ghaction-docker-meta@v5
|
37 |
-
with:
|
38 |
-
images: ${{ steps.string.outputs.lowercase }}
|
39 |
-
flavor: |
|
40 |
-
latest=true
|
41 |
-
prefix=
|
42 |
-
suffix=
|
43 |
-
tags: |
|
44 |
-
type=semver,pattern={{version}}
|
45 |
-
type=pep440,pattern={{version}}
|
46 |
-
type=ref,event=tag
|
47 |
-
type=raw,value={{sha}}
|
48 |
-
|
49 |
-
- name: Set up QEMU
|
50 |
-
uses: docker/setup-qemu-action@v3
|
51 |
-
|
52 |
- name: Set up Docker Buildx
|
53 |
uses: docker/setup-buildx-action@v3
|
54 |
|
55 |
-
- name:
|
56 |
uses: docker/login-action@v3
|
57 |
with:
|
58 |
registry: ${{ env.REGISTRY }}
|
59 |
-
username: ${{ github.actor }}
|
60 |
-
password: ${{ secrets.GITHUB_TOKEN }}
|
|
|
|
|
|
|
|
|
|
|
|
|
61 |
|
62 |
-
- name: Build and push
|
|
|
63 |
uses: docker/build-push-action@v6
|
64 |
with:
|
65 |
context: .
|
66 |
-
file: ./Dockerfile
|
67 |
-
target: ${{ env.BUILD_TARGET }}
|
68 |
-
platforms: linux/amd64,linux/arm64
|
69 |
push: true
|
70 |
-
tags:
|
|
|
|
|
71 |
labels: ${{ steps.meta.outputs.labels }}
|
72 |
-
cache-from: type=registry,ref=${{ steps.string.outputs.lowercase }}:latest
|
73 |
-
cache-to: type=inline
|
74 |
|
75 |
-
- name:
|
76 |
-
|
77 |
-
|
78 |
-
|
79 |
-
|
80 |
-
|
81 |
-
|
|
|
|
|
|
|
|
|
|
1 |
name: Docker Publish
|
2 |
|
3 |
on:
|
|
|
4 |
push:
|
5 |
branches:
|
6 |
- main
|
7 |
+
- stable
|
8 |
+
workflow_dispatch:
|
|
|
9 |
|
10 |
permissions:
|
11 |
packages: write
|
|
|
13 |
|
14 |
env:
|
15 |
REGISTRY: ghcr.io
|
16 |
+
IMAGE_NAME: ${{ github.repository }}
|
|
|
17 |
|
18 |
jobs:
|
19 |
docker-build-publish:
|
20 |
runs-on: ubuntu-latest
|
21 |
steps:
|
22 |
+
- name: Checkout code
|
23 |
uses: actions/checkout@v4
|
24 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
25 |
- name: Set up Docker Buildx
|
26 |
uses: docker/setup-buildx-action@v3
|
27 |
|
28 |
+
- name: Log in to GitHub Container Registry
|
29 |
uses: docker/login-action@v3
|
30 |
with:
|
31 |
registry: ${{ env.REGISTRY }}
|
32 |
+
username: ${{ github.actor }}
|
33 |
+
password: ${{ secrets.GITHUB_TOKEN }}
|
34 |
+
|
35 |
+
- name: Extract metadata for Docker image
|
36 |
+
id: meta
|
37 |
+
uses: docker/metadata-action@v4
|
38 |
+
with:
|
39 |
+
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
40 |
|
41 |
+
- name: Build and push Docker image for main
|
42 |
+
if: github.ref == 'refs/heads/main'
|
43 |
uses: docker/build-push-action@v6
|
44 |
with:
|
45 |
context: .
|
|
|
|
|
|
|
46 |
push: true
|
47 |
+
tags: |
|
48 |
+
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
49 |
+
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
|
50 |
labels: ${{ steps.meta.outputs.labels }}
|
|
|
|
|
51 |
|
52 |
+
- name: Build and push Docker image for stable
|
53 |
+
if: github.ref == 'refs/heads/stable'
|
54 |
+
uses: docker/build-push-action@v6
|
55 |
+
with:
|
56 |
+
context: .
|
57 |
+
push: true
|
58 |
+
tags: |
|
59 |
+
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:stable
|
60 |
+
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
|
61 |
+
labels: ${{ steps.meta.outputs.labels }}
|
.github/workflows/docs.yaml
CHANGED
@@ -5,7 +5,7 @@ on:
|
|
5 |
branches:
|
6 |
- main
|
7 |
paths:
|
8 |
-
- 'docs/**'
|
9 |
permissions:
|
10 |
contents: write
|
11 |
jobs:
|
@@ -23,7 +23,7 @@ jobs:
|
|
23 |
- uses: actions/setup-python@v5
|
24 |
with:
|
25 |
python-version: 3.x
|
26 |
-
- run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV
|
27 |
- uses: actions/cache@v4
|
28 |
with:
|
29 |
key: mkdocs-material-${{ env.cache_id }}
|
@@ -32,4 +32,4 @@ jobs:
|
|
32 |
mkdocs-material-
|
33 |
|
34 |
- run: pip install mkdocs-material
|
35 |
-
- run: mkdocs gh-deploy --force
|
|
|
5 |
branches:
|
6 |
- main
|
7 |
paths:
|
8 |
+
- 'docs/**' # This will only trigger the workflow when files in docs directory change
|
9 |
permissions:
|
10 |
contents: write
|
11 |
jobs:
|
|
|
23 |
- uses: actions/setup-python@v5
|
24 |
with:
|
25 |
python-version: 3.x
|
26 |
+
- run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV
|
27 |
- uses: actions/cache@v4
|
28 |
with:
|
29 |
key: mkdocs-material-${{ env.cache_id }}
|
|
|
32 |
mkdocs-material-
|
33 |
|
34 |
- run: pip install mkdocs-material
|
35 |
+
- run: mkdocs gh-deploy --force
|
.github/workflows/pr-release-validation.yaml
CHANGED
@@ -9,10 +9,10 @@ on:
|
|
9 |
jobs:
|
10 |
validate:
|
11 |
runs-on: ubuntu-latest
|
12 |
-
|
13 |
steps:
|
14 |
- uses: actions/checkout@v4
|
15 |
-
|
16 |
- name: Validate PR Labels
|
17 |
run: |
|
18 |
if [[ "${{ contains(github.event.pull_request.labels.*.name, 'stable-release') }}" == "true" ]]; then
|
@@ -28,4 +28,4 @@ jobs:
|
|
28 |
fi
|
29 |
else
|
30 |
echo "This PR doesn't have the stable-release label. No release will be created."
|
31 |
-
fi
|
|
|
9 |
jobs:
|
10 |
validate:
|
11 |
runs-on: ubuntu-latest
|
12 |
+
|
13 |
steps:
|
14 |
- uses: actions/checkout@v4
|
15 |
+
|
16 |
- name: Validate PR Labels
|
17 |
run: |
|
18 |
if [[ "${{ contains(github.event.pull_request.labels.*.name, 'stable-release') }}" == "true" ]]; then
|
|
|
28 |
fi
|
29 |
else
|
30 |
echo "This PR doesn't have the stable-release label. No release will be created."
|
31 |
+
fi
|
.github/workflows/semantic-pr.yaml
CHANGED
@@ -29,4 +29,4 @@ jobs:
|
|
29 |
docs
|
30 |
refactor
|
31 |
revert
|
32 |
-
test
|
|
|
29 |
docs
|
30 |
refactor
|
31 |
revert
|
32 |
+
test
|
.github/workflows/stale.yml
CHANGED
@@ -2,8 +2,8 @@ name: Mark Stale Issues and Pull Requests
|
|
2 |
|
3 |
on:
|
4 |
schedule:
|
5 |
-
- cron: '0 2 * * *'
|
6 |
-
workflow_dispatch:
|
7 |
|
8 |
jobs:
|
9 |
stale:
|
@@ -14,12 +14,12 @@ jobs:
|
|
14 |
uses: actions/stale@v8
|
15 |
with:
|
16 |
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
17 |
-
stale-issue-message:
|
18 |
-
stale-pr-message:
|
19 |
-
days-before-stale: 10
|
20 |
-
days-before-close: 4
|
21 |
-
stale-issue-label:
|
22 |
-
stale-pr-label:
|
23 |
-
exempt-issue-labels:
|
24 |
-
exempt-pr-labels:
|
25 |
-
operations-per-run: 75
|
|
|
2 |
|
3 |
on:
|
4 |
schedule:
|
5 |
+
- cron: '0 2 * * *' # Runs daily at 2:00 AM UTC
|
6 |
+
workflow_dispatch: # Allows manual triggering of the workflow
|
7 |
|
8 |
jobs:
|
9 |
stale:
|
|
|
14 |
uses: actions/stale@v8
|
15 |
with:
|
16 |
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
17 |
+
stale-issue-message: 'This issue has been marked as stale due to inactivity. If no further activity occurs, it will be closed in 7 days.'
|
18 |
+
stale-pr-message: 'This pull request has been marked as stale due to inactivity. If no further activity occurs, it will be closed in 7 days.'
|
19 |
+
days-before-stale: 10 # Number of days before marking an issue or PR as stale
|
20 |
+
days-before-close: 4 # Number of days after being marked stale before closing
|
21 |
+
stale-issue-label: 'stale' # Label to apply to stale issues
|
22 |
+
stale-pr-label: 'stale' # Label to apply to stale pull requests
|
23 |
+
exempt-issue-labels: 'pinned,important' # Issues with these labels won't be marked stale
|
24 |
+
exempt-pr-labels: 'pinned,important' # PRs with these labels won't be marked stale
|
25 |
+
operations-per-run: 75 # Limits the number of actions per run to avoid API rate limits
|
.github/workflows/update-stable.yml
CHANGED
@@ -7,12 +7,12 @@ on:
|
|
7 |
|
8 |
permissions:
|
9 |
contents: write
|
10 |
-
|
11 |
jobs:
|
12 |
prepare-release:
|
13 |
if: contains(github.event.head_commit.message, '#release')
|
14 |
runs-on: ubuntu-latest
|
15 |
-
|
16 |
steps:
|
17 |
- uses: actions/checkout@v4
|
18 |
with:
|
@@ -80,7 +80,6 @@ jobs:
|
|
80 |
NEW_VERSION=${{ steps.bump_version.outputs.new_version }}
|
81 |
pnpm version $NEW_VERSION --no-git-tag-version --allow-same-version
|
82 |
|
83 |
-
|
84 |
- name: Prepare changelog script
|
85 |
run: chmod +x .github/scripts/generate-changelog.sh
|
86 |
|
@@ -89,14 +88,14 @@ jobs:
|
|
89 |
env:
|
90 |
NEW_VERSION: ${{ steps.bump_version.outputs.new_version }}
|
91 |
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
92 |
-
|
93 |
run: .github/scripts/generate-changelog.sh
|
94 |
|
95 |
- name: Get the latest commit hash and version tag
|
96 |
run: |
|
97 |
echo "COMMIT_HASH=$(git rev-parse HEAD)" >> $GITHUB_ENV
|
98 |
echo "NEW_VERSION=${{ steps.bump_version.outputs.new_version }}" >> $GITHUB_ENV
|
99 |
-
|
100 |
- name: Commit and Tag Release
|
101 |
run: |
|
102 |
git pull
|
@@ -120,7 +119,9 @@ jobs:
|
|
120 |
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
121 |
run: |
|
122 |
VERSION="v${{ steps.bump_version.outputs.new_version }}"
|
|
|
|
|
123 |
gh release create "$VERSION" \
|
124 |
--title "Release $VERSION" \
|
125 |
-
--notes
|
126 |
-
--target stable
|
|
|
7 |
|
8 |
permissions:
|
9 |
contents: write
|
10 |
+
|
11 |
jobs:
|
12 |
prepare-release:
|
13 |
if: contains(github.event.head_commit.message, '#release')
|
14 |
runs-on: ubuntu-latest
|
15 |
+
|
16 |
steps:
|
17 |
- uses: actions/checkout@v4
|
18 |
with:
|
|
|
80 |
NEW_VERSION=${{ steps.bump_version.outputs.new_version }}
|
81 |
pnpm version $NEW_VERSION --no-git-tag-version --allow-same-version
|
82 |
|
|
|
83 |
- name: Prepare changelog script
|
84 |
run: chmod +x .github/scripts/generate-changelog.sh
|
85 |
|
|
|
88 |
env:
|
89 |
NEW_VERSION: ${{ steps.bump_version.outputs.new_version }}
|
90 |
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
91 |
+
|
92 |
run: .github/scripts/generate-changelog.sh
|
93 |
|
94 |
- name: Get the latest commit hash and version tag
|
95 |
run: |
|
96 |
echo "COMMIT_HASH=$(git rev-parse HEAD)" >> $GITHUB_ENV
|
97 |
echo "NEW_VERSION=${{ steps.bump_version.outputs.new_version }}" >> $GITHUB_ENV
|
98 |
+
|
99 |
- name: Commit and Tag Release
|
100 |
run: |
|
101 |
git pull
|
|
|
119 |
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
120 |
run: |
|
121 |
VERSION="v${{ steps.bump_version.outputs.new_version }}"
|
122 |
+
# Save changelog to a file
|
123 |
+
echo "${{ steps.changelog.outputs.content }}" > release_notes.md
|
124 |
gh release create "$VERSION" \
|
125 |
--title "Release $VERSION" \
|
126 |
+
--notes-file release_notes.md \
|
127 |
+
--target stable
|
.gitignore
CHANGED
@@ -40,4 +40,8 @@ site
|
|
40 |
|
41 |
# commit file ignore
|
42 |
app/commit.json
|
43 |
-
messages.json
|
|
|
|
|
|
|
|
|
|
40 |
|
41 |
# commit file ignore
|
42 |
app/commit.json
|
43 |
+
messages.json
|
44 |
+
changelogUI.md
|
45 |
+
docs/instructions/Roadmap.md
|
46 |
+
.cursorrules
|
47 |
+
*.md
|
.tool-versions
CHANGED
@@ -1,2 +1,2 @@
|
|
1 |
nodejs 20.15.1
|
2 |
-
pnpm 9.4.0
|
|
|
1 |
nodejs 20.15.1
|
2 |
+
pnpm 9.4.0
|
CONTRIBUTING.md
CHANGED
@@ -6,15 +6,15 @@ Welcome! This guide provides all the details you need to contribute effectively
|
|
6 |
|
7 |
## 📋 Table of Contents
|
8 |
|
9 |
-
1. [Code of Conduct](#code-of-conduct)
|
10 |
-
2. [How Can I Contribute?](#how-can-i-contribute)
|
11 |
-
3. [Pull Request Guidelines](#pull-request-guidelines)
|
12 |
-
4. [Coding Standards](#coding-standards)
|
13 |
-
5. [Development Setup](#development-setup)
|
14 |
-
6. [Testing](#testing)
|
15 |
-
7. [Deployment](#deployment)
|
16 |
-
8. [Docker Deployment](#docker-deployment)
|
17 |
-
9. [VS Code Dev Containers Integration](#vs-code-dev-containers-integration)
|
18 |
|
19 |
---
|
20 |
|
@@ -27,60 +27,67 @@ This project is governed by our **Code of Conduct**. By participating, you agree
|
|
27 |
## 🛠️ How Can I Contribute?
|
28 |
|
29 |
### 1️⃣ Reporting Bugs or Feature Requests
|
|
|
30 |
- Check the [issue tracker](#) to avoid duplicates.
|
31 |
-
- Use issue templates (if available).
|
32 |
- Provide detailed, relevant information and steps to reproduce bugs.
|
33 |
|
34 |
### 2️⃣ Code Contributions
|
35 |
-
|
36 |
-
|
37 |
-
|
|
|
38 |
4. Submit a pull request (PR).
|
39 |
|
40 |
-
### 3️⃣ Join as a Core Contributor
|
|
|
41 |
Interested in maintaining and growing the project? Fill out our [Contributor Application Form](https://forms.gle/TBSteXSDCtBDwr5m7).
|
42 |
|
43 |
---
|
44 |
|
45 |
## ✅ Pull Request Guidelines
|
46 |
|
47 |
-
### PR Checklist
|
48 |
-
- Branch from the **main** branch.
|
49 |
-
- Update documentation, if needed.
|
50 |
-
- Test all functionality manually.
|
51 |
-
- Focus on one feature/bug per PR.
|
52 |
|
53 |
-
|
54 |
-
|
55 |
-
|
56 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
57 |
4. Maintain a clean commit history.
|
58 |
|
59 |
---
|
60 |
|
61 |
## 📏 Coding Standards
|
62 |
|
63 |
-
### General Guidelines
|
64 |
-
|
65 |
-
-
|
66 |
-
-
|
|
|
67 |
- Use meaningful variable names.
|
68 |
|
69 |
---
|
70 |
|
71 |
## 🖥️ Development Setup
|
72 |
|
73 |
-
### 1️⃣ Initial Setup
|
74 |
-
|
|
|
75 |
```bash
|
76 |
git clone https://github.com/stackblitz-labs/bolt.diy.git
|
77 |
```
|
78 |
-
- Install dependencies:
|
79 |
```bash
|
80 |
pnpm install
|
81 |
```
|
82 |
-
- Set up environment variables:
|
83 |
-
1. Rename `.env.example` to `.env.local`.
|
84 |
2. Add your API keys:
|
85 |
```bash
|
86 |
GROQ_API_KEY=XXX
|
@@ -88,23 +95,26 @@ Interested in maintaining and growing the project? Fill out our [Contributor App
|
|
88 |
OPENAI_API_KEY=XXX
|
89 |
...
|
90 |
```
|
91 |
-
3. Optionally set:
|
92 |
-
- Debug level: `VITE_LOG_LEVEL=debug`
|
93 |
-
- Context size: `DEFAULT_NUM_CTX=32768`
|
94 |
|
95 |
**Note**: Never commit your `.env.local` file to version control. It’s already in `.gitignore`.
|
96 |
|
97 |
-
### 2️⃣ Run Development Server
|
|
|
98 |
```bash
|
99 |
pnpm run dev
|
100 |
```
|
|
|
101 |
**Tip**: Use **Google Chrome Canary** for local testing.
|
102 |
|
103 |
---
|
104 |
|
105 |
## 🧪 Testing
|
106 |
|
107 |
-
Run the test suite with:
|
|
|
108 |
```bash
|
109 |
pnpm test
|
110 |
```
|
@@ -113,10 +123,12 @@ pnpm test
|
|
113 |
|
114 |
## 🚀 Deployment
|
115 |
|
116 |
-
### Deploy to Cloudflare Pages
|
|
|
117 |
```bash
|
118 |
pnpm run deploy
|
119 |
```
|
|
|
120 |
Ensure you have required permissions and that Wrangler is configured.
|
121 |
|
122 |
---
|
@@ -127,67 +139,76 @@ This section outlines the methods for deploying the application using Docker. Th
|
|
127 |
|
128 |
---
|
129 |
|
130 |
-
### 🧑💻 Development Environment
|
131 |
|
132 |
-
#### Build Options
|
|
|
|
|
133 |
|
134 |
-
**Option 1: Helper Scripts**
|
135 |
```bash
|
136 |
# Development build
|
137 |
npm run dockerbuild
|
138 |
```
|
139 |
|
140 |
-
**Option 2: Direct Docker Build Command**
|
|
|
141 |
```bash
|
142 |
docker build . --target bolt-ai-development
|
143 |
```
|
144 |
|
145 |
-
**Option 3: Docker Compose Profile**
|
|
|
146 |
```bash
|
147 |
docker compose --profile development up
|
148 |
```
|
149 |
|
150 |
-
#### Running the Development Container
|
|
|
151 |
```bash
|
152 |
docker run -p 5173:5173 --env-file .env.local bolt-ai:development
|
153 |
```
|
154 |
|
155 |
---
|
156 |
|
157 |
-
### 🏭 Production Environment
|
|
|
|
|
158 |
|
159 |
-
|
160 |
|
161 |
-
**Option 1: Helper Scripts**
|
162 |
```bash
|
163 |
# Production build
|
164 |
npm run dockerbuild:prod
|
165 |
```
|
166 |
|
167 |
-
**Option 2: Direct Docker Build Command**
|
|
|
168 |
```bash
|
169 |
docker build . --target bolt-ai-production
|
170 |
```
|
171 |
|
172 |
-
**Option 3: Docker Compose Profile**
|
|
|
173 |
```bash
|
174 |
docker compose --profile production up
|
175 |
```
|
176 |
|
177 |
-
#### Running the Production Container
|
|
|
178 |
```bash
|
179 |
docker run -p 5173:5173 --env-file .env.local bolt-ai:production
|
180 |
```
|
181 |
|
182 |
---
|
183 |
|
184 |
-
### Coolify Deployment
|
|
|
|
|
185 |
|
186 |
-
|
187 |
-
|
188 |
-
|
189 |
-
|
190 |
-
4. Set the start command:
|
191 |
```bash
|
192 |
docker compose --profile production up
|
193 |
```
|
@@ -200,20 +221,22 @@ The `docker-compose.yaml` configuration is compatible with **VS Code Dev Contain
|
|
200 |
|
201 |
### Steps to Use Dev Containers
|
202 |
|
203 |
-
1. Open the command palette in VS Code (`Ctrl+Shift+P` or `Cmd+Shift+P` on macOS).
|
204 |
-
2. Select **Dev Containers: Reopen in Container**.
|
205 |
-
3. Choose the **development** profile when prompted.
|
206 |
4. VS Code will rebuild the container and open it with the pre-configured environment.
|
207 |
|
208 |
---
|
209 |
|
210 |
## 🔑 Environment Variables
|
211 |
|
212 |
-
Ensure `.env.local` is configured correctly with:
|
213 |
-
|
214 |
-
-
|
|
|
|
|
|
|
215 |
|
216 |
-
Example for the `DEFAULT_NUM_CTX` variable:
|
217 |
```bash
|
218 |
DEFAULT_NUM_CTX=24576 # Uses 32GB VRAM
|
219 |
-
```
|
|
|
6 |
|
7 |
## 📋 Table of Contents
|
8 |
|
9 |
+
1. [Code of Conduct](#code-of-conduct)
|
10 |
+
2. [How Can I Contribute?](#how-can-i-contribute)
|
11 |
+
3. [Pull Request Guidelines](#pull-request-guidelines)
|
12 |
+
4. [Coding Standards](#coding-standards)
|
13 |
+
5. [Development Setup](#development-setup)
|
14 |
+
6. [Testing](#testing)
|
15 |
+
7. [Deployment](#deployment)
|
16 |
+
8. [Docker Deployment](#docker-deployment)
|
17 |
+
9. [VS Code Dev Containers Integration](#vs-code-dev-containers-integration)
|
18 |
|
19 |
---
|
20 |
|
|
|
27 |
## 🛠️ How Can I Contribute?
|
28 |
|
29 |
### 1️⃣ Reporting Bugs or Feature Requests
|
30 |
+
|
31 |
- Check the [issue tracker](#) to avoid duplicates.
|
32 |
+
- Use issue templates (if available).
|
33 |
- Provide detailed, relevant information and steps to reproduce bugs.
|
34 |
|
35 |
### 2️⃣ Code Contributions
|
36 |
+
|
37 |
+
1. Fork the repository.
|
38 |
+
2. Create a feature or fix branch.
|
39 |
+
3. Write and test your code.
|
40 |
4. Submit a pull request (PR).
|
41 |
|
42 |
+
### 3️⃣ Join as a Core Contributor
|
43 |
+
|
44 |
Interested in maintaining and growing the project? Fill out our [Contributor Application Form](https://forms.gle/TBSteXSDCtBDwr5m7).
|
45 |
|
46 |
---
|
47 |
|
48 |
## ✅ Pull Request Guidelines
|
49 |
|
50 |
+
### PR Checklist
|
|
|
|
|
|
|
|
|
51 |
|
52 |
+
- Branch from the **main** branch.
|
53 |
+
- Update documentation, if needed.
|
54 |
+
- Test all functionality manually.
|
55 |
+
- Focus on one feature/bug per PR.
|
56 |
+
|
57 |
+
### Review Process
|
58 |
+
|
59 |
+
1. Manual testing by reviewers.
|
60 |
+
2. At least one maintainer review required.
|
61 |
+
3. Address review comments.
|
62 |
4. Maintain a clean commit history.
|
63 |
|
64 |
---
|
65 |
|
66 |
## 📏 Coding Standards
|
67 |
|
68 |
+
### General Guidelines
|
69 |
+
|
70 |
+
- Follow existing code style.
|
71 |
+
- Comment complex logic.
|
72 |
+
- Keep functions small and focused.
|
73 |
- Use meaningful variable names.
|
74 |
|
75 |
---
|
76 |
|
77 |
## 🖥️ Development Setup
|
78 |
|
79 |
+
### 1️⃣ Initial Setup
|
80 |
+
|
81 |
+
- Clone the repository:
|
82 |
```bash
|
83 |
git clone https://github.com/stackblitz-labs/bolt.diy.git
|
84 |
```
|
85 |
+
- Install dependencies:
|
86 |
```bash
|
87 |
pnpm install
|
88 |
```
|
89 |
+
- Set up environment variables:
|
90 |
+
1. Rename `.env.example` to `.env.local`.
|
91 |
2. Add your API keys:
|
92 |
```bash
|
93 |
GROQ_API_KEY=XXX
|
|
|
95 |
OPENAI_API_KEY=XXX
|
96 |
...
|
97 |
```
|
98 |
+
3. Optionally set:
|
99 |
+
- Debug level: `VITE_LOG_LEVEL=debug`
|
100 |
+
- Context size: `DEFAULT_NUM_CTX=32768`
|
101 |
|
102 |
**Note**: Never commit your `.env.local` file to version control. It’s already in `.gitignore`.
|
103 |
|
104 |
+
### 2️⃣ Run Development Server
|
105 |
+
|
106 |
```bash
|
107 |
pnpm run dev
|
108 |
```
|
109 |
+
|
110 |
**Tip**: Use **Google Chrome Canary** for local testing.
|
111 |
|
112 |
---
|
113 |
|
114 |
## 🧪 Testing
|
115 |
|
116 |
+
Run the test suite with:
|
117 |
+
|
118 |
```bash
|
119 |
pnpm test
|
120 |
```
|
|
|
123 |
|
124 |
## 🚀 Deployment
|
125 |
|
126 |
+
### Deploy to Cloudflare Pages
|
127 |
+
|
128 |
```bash
|
129 |
pnpm run deploy
|
130 |
```
|
131 |
+
|
132 |
Ensure you have required permissions and that Wrangler is configured.
|
133 |
|
134 |
---
|
|
|
139 |
|
140 |
---
|
141 |
|
142 |
+
### 🧑💻 Development Environment
|
143 |
|
144 |
+
#### Build Options
|
145 |
+
|
146 |
+
**Option 1: Helper Scripts**
|
147 |
|
|
|
148 |
```bash
|
149 |
# Development build
|
150 |
npm run dockerbuild
|
151 |
```
|
152 |
|
153 |
+
**Option 2: Direct Docker Build Command**
|
154 |
+
|
155 |
```bash
|
156 |
docker build . --target bolt-ai-development
|
157 |
```
|
158 |
|
159 |
+
**Option 3: Docker Compose Profile**
|
160 |
+
|
161 |
```bash
|
162 |
docker compose --profile development up
|
163 |
```
|
164 |
|
165 |
+
#### Running the Development Container
|
166 |
+
|
167 |
```bash
|
168 |
docker run -p 5173:5173 --env-file .env.local bolt-ai:development
|
169 |
```
|
170 |
|
171 |
---
|
172 |
|
173 |
+
### 🏭 Production Environment
|
174 |
+
|
175 |
+
#### Build Options
|
176 |
|
177 |
+
**Option 1: Helper Scripts**
|
178 |
|
|
|
179 |
```bash
|
180 |
# Production build
|
181 |
npm run dockerbuild:prod
|
182 |
```
|
183 |
|
184 |
+
**Option 2: Direct Docker Build Command**
|
185 |
+
|
186 |
```bash
|
187 |
docker build . --target bolt-ai-production
|
188 |
```
|
189 |
|
190 |
+
**Option 3: Docker Compose Profile**
|
191 |
+
|
192 |
```bash
|
193 |
docker compose --profile production up
|
194 |
```
|
195 |
|
196 |
+
#### Running the Production Container
|
197 |
+
|
198 |
```bash
|
199 |
docker run -p 5173:5173 --env-file .env.local bolt-ai:production
|
200 |
```
|
201 |
|
202 |
---
|
203 |
|
204 |
+
### Coolify Deployment
|
205 |
+
|
206 |
+
For an easy deployment process, use [Coolify](https://github.com/coollabsio/coolify):
|
207 |
|
208 |
+
1. Import your Git repository into Coolify.
|
209 |
+
2. Choose **Docker Compose** as the build pack.
|
210 |
+
3. Configure environment variables (e.g., API keys).
|
211 |
+
4. Set the start command:
|
|
|
212 |
```bash
|
213 |
docker compose --profile production up
|
214 |
```
|
|
|
221 |
|
222 |
### Steps to Use Dev Containers
|
223 |
|
224 |
+
1. Open the command palette in VS Code (`Ctrl+Shift+P` or `Cmd+Shift+P` on macOS).
|
225 |
+
2. Select **Dev Containers: Reopen in Container**.
|
226 |
+
3. Choose the **development** profile when prompted.
|
227 |
4. VS Code will rebuild the container and open it with the pre-configured environment.
|
228 |
|
229 |
---
|
230 |
|
231 |
## 🔑 Environment Variables
|
232 |
|
233 |
+
Ensure `.env.local` is configured correctly with:
|
234 |
+
|
235 |
+
- API keys.
|
236 |
+
- Context-specific configurations.
|
237 |
+
|
238 |
+
Example for the `DEFAULT_NUM_CTX` variable:
|
239 |
|
|
|
240 |
```bash
|
241 |
DEFAULT_NUM_CTX=24576 # Uses 32GB VRAM
|
242 |
+
```
|
FAQ.md
CHANGED
@@ -12,6 +12,7 @@ For the best experience with bolt.diy, we recommend using the following models:
|
|
12 |
- **Qwen 2.5 Coder 32b**: Best model for self-hosting with reasonable hardware requirements
|
13 |
|
14 |
**Note**: Models with less than 7b parameters typically lack the capability to properly interact with bolt!
|
|
|
15 |
</details>
|
16 |
|
17 |
<details>
|
@@ -21,20 +22,21 @@ For the best experience with bolt.diy, we recommend using the following models:
|
|
21 |
Mention the frameworks or libraries you want to use (e.g., Astro, Tailwind, ShadCN) in your initial prompt. This ensures that bolt.diy scaffolds the project according to your preferences.
|
22 |
|
23 |
- **Use the enhance prompt icon**:
|
24 |
-
Before sending your prompt, click the
|
25 |
|
26 |
- **Scaffold the basics first, then add features**:
|
27 |
Ensure the foundational structure of your application is in place before introducing advanced functionality. This helps bolt.diy establish a solid base to build on.
|
28 |
|
29 |
- **Batch simple instructions**:
|
30 |
-
|
31 |
-
|
32 |
</details>
|
33 |
|
34 |
<details>
|
35 |
<summary><strong>How do I contribute to bolt.diy?</strong></summary>
|
36 |
|
37 |
Check out our [Contribution Guide](CONTRIBUTING.md) for more details on how to get involved!
|
|
|
38 |
</details>
|
39 |
|
40 |
<details>
|
@@ -42,48 +44,60 @@ Check out our [Contribution Guide](CONTRIBUTING.md) for more details on how to g
|
|
42 |
|
43 |
Visit our [Roadmap](https://roadmap.sh/r/ottodev-roadmap-2ovzo) for the latest updates.
|
44 |
New features and improvements are on the way!
|
|
|
45 |
</details>
|
46 |
|
47 |
<details>
|
48 |
<summary><strong>Why are there so many open issues/pull requests?</strong></summary>
|
49 |
|
50 |
-
bolt.diy began as a small showcase project on @ColeMedin's YouTube channel to explore editing open-source projects with local LLMs. However, it quickly grew into a massive community effort!
|
51 |
|
52 |
We're forming a team of maintainers to manage demand and streamline issue resolution. The maintainers are rockstars, and we're also exploring partnerships to help the project thrive.
|
|
|
53 |
</details>
|
54 |
|
55 |
<details>
|
56 |
<summary><strong>How do local LLMs compare to larger models like Claude 3.5 Sonnet for bolt.diy?</strong></summary>
|
57 |
|
58 |
While local LLMs are improving rapidly, larger models like GPT-4o, Claude 3.5 Sonnet, and DeepSeek Coder V2 236b still offer the best results for complex applications. Our ongoing focus is to improve prompts, agents, and the platform to better support smaller local LLMs.
|
|
|
59 |
</details>
|
60 |
|
61 |
<details>
|
62 |
<summary><strong>Common Errors and Troubleshooting</strong></summary>
|
63 |
|
64 |
### **"There was an error processing this request"**
|
|
|
65 |
This generic error message means something went wrong. Check both:
|
|
|
66 |
- The terminal (if you started the app with Docker or `pnpm`).
|
67 |
-
- The developer console in your browser (press `F12` or right-click >
|
68 |
|
69 |
### **"x-api-key header missing"**
|
|
|
70 |
This error is sometimes resolved by restarting the Docker container.
|
71 |
If that doesn't work, try switching from Docker to `pnpm` or vice versa. We're actively investigating this issue.
|
72 |
|
73 |
### **Blank preview when running the app**
|
|
|
74 |
A blank preview often occurs due to hallucinated bad code or incorrect commands.
|
75 |
To troubleshoot:
|
|
|
76 |
- Check the developer console for errors.
|
77 |
- Remember, previews are core functionality, so the app isn't broken! We're working on making these errors more transparent.
|
78 |
|
79 |
### **"Everything works, but the results are bad"**
|
|
|
80 |
Local LLMs like Qwen-2.5-Coder are powerful for small applications but still experimental for larger projects. For better results, consider using larger models like GPT-4o, Claude 3.5 Sonnet, or DeepSeek Coder V2 236b.
|
81 |
|
82 |
### **"Received structured exception #0xc0000005: access violation"**
|
|
|
83 |
If you are getting this, you are probably on Windows. The fix is generally to update the [Visual C++ Redistributable](https://learn.microsoft.com/en-us/cpp/windows/latest-supported-vc-redist?view=msvc-170)
|
84 |
|
85 |
### **"Miniflare or Wrangler errors in Windows"**
|
|
|
86 |
You will need to make sure you have the latest version of Visual Studio C++ installed (14.40.33816), more information here https://github.com/stackblitz-labs/bolt.diy/issues/19.
|
|
|
87 |
</details>
|
88 |
|
89 |
---
|
|
|
12 |
- **Qwen 2.5 Coder 32b**: Best model for self-hosting with reasonable hardware requirements
|
13 |
|
14 |
**Note**: Models with less than 7b parameters typically lack the capability to properly interact with bolt!
|
15 |
+
|
16 |
</details>
|
17 |
|
18 |
<details>
|
|
|
22 |
Mention the frameworks or libraries you want to use (e.g., Astro, Tailwind, ShadCN) in your initial prompt. This ensures that bolt.diy scaffolds the project according to your preferences.
|
23 |
|
24 |
- **Use the enhance prompt icon**:
|
25 |
+
Before sending your prompt, click the _enhance_ icon to let the AI refine your prompt. You can edit the suggested improvements before submitting.
|
26 |
|
27 |
- **Scaffold the basics first, then add features**:
|
28 |
Ensure the foundational structure of your application is in place before introducing advanced functionality. This helps bolt.diy establish a solid base to build on.
|
29 |
|
30 |
- **Batch simple instructions**:
|
31 |
+
Combine simple tasks into a single prompt to save time and reduce API credit consumption. For example:
|
32 |
+
_"Change the color scheme, add mobile responsiveness, and restart the dev server."_
|
33 |
</details>
|
34 |
|
35 |
<details>
|
36 |
<summary><strong>How do I contribute to bolt.diy?</strong></summary>
|
37 |
|
38 |
Check out our [Contribution Guide](CONTRIBUTING.md) for more details on how to get involved!
|
39 |
+
|
40 |
</details>
|
41 |
|
42 |
<details>
|
|
|
44 |
|
45 |
Visit our [Roadmap](https://roadmap.sh/r/ottodev-roadmap-2ovzo) for the latest updates.
|
46 |
New features and improvements are on the way!
|
47 |
+
|
48 |
</details>
|
49 |
|
50 |
<details>
|
51 |
<summary><strong>Why are there so many open issues/pull requests?</strong></summary>
|
52 |
|
53 |
+
bolt.diy began as a small showcase project on @ColeMedin's YouTube channel to explore editing open-source projects with local LLMs. However, it quickly grew into a massive community effort!
|
54 |
|
55 |
We're forming a team of maintainers to manage demand and streamline issue resolution. The maintainers are rockstars, and we're also exploring partnerships to help the project thrive.
|
56 |
+
|
57 |
</details>
|
58 |
|
59 |
<details>
|
60 |
<summary><strong>How do local LLMs compare to larger models like Claude 3.5 Sonnet for bolt.diy?</strong></summary>
|
61 |
|
62 |
While local LLMs are improving rapidly, larger models like GPT-4o, Claude 3.5 Sonnet, and DeepSeek Coder V2 236b still offer the best results for complex applications. Our ongoing focus is to improve prompts, agents, and the platform to better support smaller local LLMs.
|
63 |
+
|
64 |
</details>
|
65 |
|
66 |
<details>
|
67 |
<summary><strong>Common Errors and Troubleshooting</strong></summary>
|
68 |
|
69 |
### **"There was an error processing this request"**
|
70 |
+
|
71 |
This generic error message means something went wrong. Check both:
|
72 |
+
|
73 |
- The terminal (if you started the app with Docker or `pnpm`).
|
74 |
+
- The developer console in your browser (press `F12` or right-click > _Inspect_, then go to the _Console_ tab).
|
75 |
|
76 |
### **"x-api-key header missing"**
|
77 |
+
|
78 |
This error is sometimes resolved by restarting the Docker container.
|
79 |
If that doesn't work, try switching from Docker to `pnpm` or vice versa. We're actively investigating this issue.
|
80 |
|
81 |
### **Blank preview when running the app**
|
82 |
+
|
83 |
A blank preview often occurs due to hallucinated bad code or incorrect commands.
|
84 |
To troubleshoot:
|
85 |
+
|
86 |
- Check the developer console for errors.
|
87 |
- Remember, previews are core functionality, so the app isn't broken! We're working on making these errors more transparent.
|
88 |
|
89 |
### **"Everything works, but the results are bad"**
|
90 |
+
|
91 |
Local LLMs like Qwen-2.5-Coder are powerful for small applications but still experimental for larger projects. For better results, consider using larger models like GPT-4o, Claude 3.5 Sonnet, or DeepSeek Coder V2 236b.
|
92 |
|
93 |
### **"Received structured exception #0xc0000005: access violation"**
|
94 |
+
|
95 |
If you are getting this, you are probably on Windows. The fix is generally to update the [Visual C++ Redistributable](https://learn.microsoft.com/en-us/cpp/windows/latest-supported-vc-redist?view=msvc-170)
|
96 |
|
97 |
### **"Miniflare or Wrangler errors in Windows"**
|
98 |
+
|
99 |
You will need to make sure you have the latest version of Visual Studio C++ installed (14.40.33816), more information here https://github.com/stackblitz-labs/bolt.diy/issues/19.
|
100 |
+
|
101 |
</details>
|
102 |
|
103 |
---
|
PROJECT.md
CHANGED
@@ -31,7 +31,7 @@ and this way communicate where the focus currently is.
|
|
31 |
|
32 |
2. Grouping of features
|
33 |
|
34 |
-
By linking features with epics, we can keep them together and document
|
35 |
|
36 |
## Features (mid-term)
|
37 |
|
@@ -41,13 +41,13 @@ function, you name it).
|
|
41 |
However, we intentionally describe features in a more vague manner. Why? Everybody loves crisp, well-defined
|
42 |
acceptance-criteria, no? Well, every product owner loves it. because he knows what he’ll get once it’s done.
|
43 |
|
44 |
-
But: **here is no owner of this product**. Therefore, we grant
|
45 |
|
46 |
-
The feature therefore tries to describe
|
47 |
|
48 |
## PRs as materialized features (short-term)
|
49 |
|
50 |
-
Once a developer starts working on a feature, a draft-PR
|
51 |
|
52 |
In a loosely organized project, it may as well happen that multiple PRs are opened for the same feature. This is no real issue: Usually, peoply being passionate about a solution are willing to join forces and get it done together. And if a second developer was just faster getting the same feature realized: Be happy that it's been done, close the PR and look out for the next feature to implement 🤓
|
53 |
|
|
|
31 |
|
32 |
2. Grouping of features
|
33 |
|
34 |
+
By linking features with epics, we can keep them together and document _why_ we invest work into a particular thing.
|
35 |
|
36 |
## Features (mid-term)
|
37 |
|
|
|
41 |
However, we intentionally describe features in a more vague manner. Why? Everybody loves crisp, well-defined
|
42 |
acceptance-criteria, no? Well, every product owner loves it. because he knows what he’ll get once it’s done.
|
43 |
|
44 |
+
But: **here is no owner of this product**. Therefore, we grant _maximum flexibility to the developer contributing a feature_ – so that he can bring in his ideas and have most fun implementing it.
|
45 |
|
46 |
+
The feature therefore tries to describe _what_ should be improved but not in detail _how_.
|
47 |
|
48 |
## PRs as materialized features (short-term)
|
49 |
|
50 |
+
Once a developer starts working on a feature, a draft-PR _can_ be opened asap to share, describe and discuss, how the feature shall be implemented. But: this is not a must. It just helps to get early feedback and get other developers involved. Sometimes, the developer just wants to get started and then open a PR later.
|
51 |
|
52 |
In a loosely organized project, it may as well happen that multiple PRs are opened for the same feature. This is no real issue: Usually, peoply being passionate about a solution are willing to join forces and get it done together. And if a second developer was just faster getting the same feature realized: Be happy that it's been done, close the PR and look out for the next feature to implement 🤓
|
53 |
|
app/components/@settings/core/AvatarDropdown.tsx
ADDED
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
2 |
+
import { motion } from 'framer-motion';
|
3 |
+
import { useStore } from '@nanostores/react';
|
4 |
+
import { classNames } from '~/utils/classNames';
|
5 |
+
import { profileStore } from '~/lib/stores/profile';
|
6 |
+
import type { TabType, Profile } from './types';
|
7 |
+
|
8 |
+
const BetaLabel = () => (
|
9 |
+
<span className="px-1.5 py-0.5 rounded-full bg-purple-500/10 dark:bg-purple-500/20 text-[10px] font-medium text-purple-600 dark:text-purple-400 ml-2">
|
10 |
+
BETA
|
11 |
+
</span>
|
12 |
+
);
|
13 |
+
|
14 |
+
interface AvatarDropdownProps {
|
15 |
+
onSelectTab: (tab: TabType) => void;
|
16 |
+
}
|
17 |
+
|
18 |
+
export const AvatarDropdown = ({ onSelectTab }: AvatarDropdownProps) => {
|
19 |
+
const profile = useStore(profileStore) as Profile;
|
20 |
+
|
21 |
+
return (
|
22 |
+
<DropdownMenu.Root>
|
23 |
+
<DropdownMenu.Trigger asChild>
|
24 |
+
<motion.button
|
25 |
+
className="w-10 h-10 rounded-full bg-transparent flex items-center justify-center focus:outline-none"
|
26 |
+
whileHover={{ scale: 1.02 }}
|
27 |
+
whileTap={{ scale: 0.98 }}
|
28 |
+
>
|
29 |
+
{profile?.avatar ? (
|
30 |
+
<img
|
31 |
+
src={profile.avatar}
|
32 |
+
alt={profile?.username || 'Profile'}
|
33 |
+
className="w-full h-full rounded-full object-cover"
|
34 |
+
loading="eager"
|
35 |
+
decoding="sync"
|
36 |
+
/>
|
37 |
+
) : (
|
38 |
+
<div className="w-full h-full rounded-full flex items-center justify-center bg-white dark:bg-gray-800 text-gray-400 dark:text-gray-500">
|
39 |
+
<div className="i-ph:question w-6 h-6" />
|
40 |
+
</div>
|
41 |
+
)}
|
42 |
+
</motion.button>
|
43 |
+
</DropdownMenu.Trigger>
|
44 |
+
|
45 |
+
<DropdownMenu.Portal>
|
46 |
+
<DropdownMenu.Content
|
47 |
+
className={classNames(
|
48 |
+
'min-w-[240px] z-[250]',
|
49 |
+
'bg-white dark:bg-[#141414]',
|
50 |
+
'rounded-lg shadow-lg',
|
51 |
+
'border border-gray-200/50 dark:border-gray-800/50',
|
52 |
+
'animate-in fade-in-0 zoom-in-95',
|
53 |
+
'py-1',
|
54 |
+
)}
|
55 |
+
sideOffset={5}
|
56 |
+
align="end"
|
57 |
+
>
|
58 |
+
<div
|
59 |
+
className={classNames(
|
60 |
+
'px-4 py-3 flex items-center gap-3',
|
61 |
+
'border-b border-gray-200/50 dark:border-gray-800/50',
|
62 |
+
)}
|
63 |
+
>
|
64 |
+
<div className="w-10 h-10 rounded-full overflow-hidden flex-shrink-0 bg-white dark:bg-gray-800 shadow-sm">
|
65 |
+
{profile?.avatar ? (
|
66 |
+
<img
|
67 |
+
src={profile.avatar}
|
68 |
+
alt={profile?.username || 'Profile'}
|
69 |
+
className={classNames('w-full h-full', 'object-cover', 'transform-gpu', 'image-rendering-crisp')}
|
70 |
+
loading="eager"
|
71 |
+
decoding="sync"
|
72 |
+
/>
|
73 |
+
) : (
|
74 |
+
<div className="w-full h-full flex items-center justify-center text-gray-400 dark:text-gray-500 font-medium text-lg">
|
75 |
+
<span className="relative -top-0.5">?</span>
|
76 |
+
</div>
|
77 |
+
)}
|
78 |
+
</div>
|
79 |
+
<div className="flex-1 min-w-0">
|
80 |
+
<div className="font-medium text-sm text-gray-900 dark:text-white truncate">
|
81 |
+
{profile?.username || 'Guest User'}
|
82 |
+
</div>
|
83 |
+
{profile?.bio && <div className="text-xs text-gray-500 dark:text-gray-400 truncate">{profile.bio}</div>}
|
84 |
+
</div>
|
85 |
+
</div>
|
86 |
+
|
87 |
+
<DropdownMenu.Item
|
88 |
+
className={classNames(
|
89 |
+
'flex items-center gap-2 px-4 py-2.5',
|
90 |
+
'text-sm text-gray-700 dark:text-gray-200',
|
91 |
+
'hover:bg-purple-50 dark:hover:bg-purple-500/10',
|
92 |
+
'hover:text-purple-500 dark:hover:text-purple-400',
|
93 |
+
'cursor-pointer transition-all duration-200',
|
94 |
+
'outline-none',
|
95 |
+
'group',
|
96 |
+
)}
|
97 |
+
onClick={() => onSelectTab('profile')}
|
98 |
+
>
|
99 |
+
<div className="i-ph:user-circle w-4 h-4 text-gray-400 group-hover:text-purple-500 dark:group-hover:text-purple-400 transition-colors" />
|
100 |
+
Edit Profile
|
101 |
+
</DropdownMenu.Item>
|
102 |
+
|
103 |
+
<DropdownMenu.Item
|
104 |
+
className={classNames(
|
105 |
+
'flex items-center gap-2 px-4 py-2.5',
|
106 |
+
'text-sm text-gray-700 dark:text-gray-200',
|
107 |
+
'hover:bg-purple-50 dark:hover:bg-purple-500/10',
|
108 |
+
'hover:text-purple-500 dark:hover:text-purple-400',
|
109 |
+
'cursor-pointer transition-all duration-200',
|
110 |
+
'outline-none',
|
111 |
+
'group',
|
112 |
+
)}
|
113 |
+
onClick={() => onSelectTab('settings')}
|
114 |
+
>
|
115 |
+
<div className="i-ph:gear-six w-4 h-4 text-gray-400 group-hover:text-purple-500 dark:group-hover:text-purple-400 transition-colors" />
|
116 |
+
Settings
|
117 |
+
</DropdownMenu.Item>
|
118 |
+
|
119 |
+
<div className="my-1 border-t border-gray-200/50 dark:border-gray-800/50" />
|
120 |
+
|
121 |
+
<DropdownMenu.Item
|
122 |
+
className={classNames(
|
123 |
+
'flex items-center gap-2 px-4 py-2.5',
|
124 |
+
'text-sm text-gray-700 dark:text-gray-200',
|
125 |
+
'hover:bg-purple-50 dark:hover:bg-purple-500/10',
|
126 |
+
'hover:text-purple-500 dark:hover:text-purple-400',
|
127 |
+
'cursor-pointer transition-all duration-200',
|
128 |
+
'outline-none',
|
129 |
+
'group',
|
130 |
+
)}
|
131 |
+
onClick={() => onSelectTab('task-manager')}
|
132 |
+
>
|
133 |
+
<div className="i-ph:activity w-4 h-4 text-gray-400 group-hover:text-purple-500 dark:group-hover:text-purple-400 transition-colors" />
|
134 |
+
Task Manager
|
135 |
+
<BetaLabel />
|
136 |
+
</DropdownMenu.Item>
|
137 |
+
|
138 |
+
<DropdownMenu.Item
|
139 |
+
className={classNames(
|
140 |
+
'flex items-center gap-2 px-4 py-2.5',
|
141 |
+
'text-sm text-gray-700 dark:text-gray-200',
|
142 |
+
'hover:bg-purple-50 dark:hover:bg-purple-500/10',
|
143 |
+
'hover:text-purple-500 dark:hover:text-purple-400',
|
144 |
+
'cursor-pointer transition-all duration-200',
|
145 |
+
'outline-none',
|
146 |
+
'group',
|
147 |
+
)}
|
148 |
+
onClick={() => onSelectTab('service-status')}
|
149 |
+
>
|
150 |
+
<div className="i-ph:heartbeat w-4 h-4 text-gray-400 group-hover:text-purple-500 dark:group-hover:text-purple-400 transition-colors" />
|
151 |
+
Service Status
|
152 |
+
<BetaLabel />
|
153 |
+
</DropdownMenu.Item>
|
154 |
+
</DropdownMenu.Content>
|
155 |
+
</DropdownMenu.Portal>
|
156 |
+
</DropdownMenu.Root>
|
157 |
+
);
|
158 |
+
};
|
app/components/@settings/core/ControlPanel.tsx
ADDED
@@ -0,0 +1,555 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { useState, useEffect, useMemo } from 'react';
|
2 |
+
import { motion, AnimatePresence } from 'framer-motion';
|
3 |
+
import { useStore } from '@nanostores/react';
|
4 |
+
import { Switch } from '@radix-ui/react-switch';
|
5 |
+
import * as RadixDialog from '@radix-ui/react-dialog';
|
6 |
+
import { classNames } from '~/utils/classNames';
|
7 |
+
import { TabManagement } from '~/components/@settings/shared/components/TabManagement';
|
8 |
+
import { TabTile } from '~/components/@settings/shared/components/TabTile';
|
9 |
+
import { useUpdateCheck } from '~/lib/hooks/useUpdateCheck';
|
10 |
+
import { useFeatures } from '~/lib/hooks/useFeatures';
|
11 |
+
import { useNotifications } from '~/lib/hooks/useNotifications';
|
12 |
+
import { useConnectionStatus } from '~/lib/hooks/useConnectionStatus';
|
13 |
+
import { useDebugStatus } from '~/lib/hooks/useDebugStatus';
|
14 |
+
import {
|
15 |
+
tabConfigurationStore,
|
16 |
+
developerModeStore,
|
17 |
+
setDeveloperMode,
|
18 |
+
resetTabConfiguration,
|
19 |
+
} from '~/lib/stores/settings';
|
20 |
+
import { profileStore } from '~/lib/stores/profile';
|
21 |
+
import type { TabType, TabVisibilityConfig, Profile } from './types';
|
22 |
+
import { TAB_LABELS, DEFAULT_TAB_CONFIG } from './constants';
|
23 |
+
import { DialogTitle } from '~/components/ui/Dialog';
|
24 |
+
import { AvatarDropdown } from './AvatarDropdown';
|
25 |
+
import BackgroundRays from '~/components/ui/BackgroundRays';
|
26 |
+
|
27 |
+
// Import all tab components
|
28 |
+
import ProfileTab from '~/components/@settings/tabs/profile/ProfileTab';
|
29 |
+
import SettingsTab from '~/components/@settings/tabs/settings/SettingsTab';
|
30 |
+
import NotificationsTab from '~/components/@settings/tabs/notifications/NotificationsTab';
|
31 |
+
import FeaturesTab from '~/components/@settings/tabs/features/FeaturesTab';
|
32 |
+
import DataTab from '~/components/@settings/tabs/data/DataTab';
|
33 |
+
import DebugTab from '~/components/@settings/tabs/debug/DebugTab';
|
34 |
+
import { EventLogsTab } from '~/components/@settings/tabs/event-logs/EventLogsTab';
|
35 |
+
import UpdateTab from '~/components/@settings/tabs/update/UpdateTab';
|
36 |
+
import ConnectionsTab from '~/components/@settings/tabs/connections/ConnectionsTab';
|
37 |
+
import CloudProvidersTab from '~/components/@settings/tabs/providers/cloud/CloudProvidersTab';
|
38 |
+
import ServiceStatusTab from '~/components/@settings/tabs/providers/status/ServiceStatusTab';
|
39 |
+
import LocalProvidersTab from '~/components/@settings/tabs/providers/local/LocalProvidersTab';
|
40 |
+
import TaskManagerTab from '~/components/@settings/tabs/task-manager/TaskManagerTab';
|
41 |
+
|
42 |
+
interface ControlPanelProps {
|
43 |
+
open: boolean;
|
44 |
+
onClose: () => void;
|
45 |
+
}
|
46 |
+
|
47 |
+
interface TabWithDevType extends TabVisibilityConfig {
|
48 |
+
isExtraDevTab?: boolean;
|
49 |
+
}
|
50 |
+
|
51 |
+
interface ExtendedTabConfig extends TabVisibilityConfig {
|
52 |
+
isExtraDevTab?: boolean;
|
53 |
+
}
|
54 |
+
|
55 |
+
interface BaseTabConfig {
|
56 |
+
id: TabType;
|
57 |
+
visible: boolean;
|
58 |
+
window: 'user' | 'developer';
|
59 |
+
order: number;
|
60 |
+
}
|
61 |
+
|
62 |
+
interface AnimatedSwitchProps {
|
63 |
+
checked: boolean;
|
64 |
+
onCheckedChange: (checked: boolean) => void;
|
65 |
+
id: string;
|
66 |
+
label: string;
|
67 |
+
}
|
68 |
+
|
69 |
+
const TAB_DESCRIPTIONS: Record<TabType, string> = {
|
70 |
+
profile: 'Manage your profile and account settings',
|
71 |
+
settings: 'Configure application preferences',
|
72 |
+
notifications: 'View and manage your notifications',
|
73 |
+
features: 'Explore new and upcoming features',
|
74 |
+
data: 'Manage your data and storage',
|
75 |
+
'cloud-providers': 'Configure cloud AI providers and models',
|
76 |
+
'local-providers': 'Configure local AI providers and models',
|
77 |
+
'service-status': 'Monitor cloud LLM service status',
|
78 |
+
connection: 'Check connection status and settings',
|
79 |
+
debug: 'Debug tools and system information',
|
80 |
+
'event-logs': 'View system events and logs',
|
81 |
+
update: 'Check for updates and release notes',
|
82 |
+
'task-manager': 'Monitor system resources and processes',
|
83 |
+
'tab-management': 'Configure visible tabs and their order',
|
84 |
+
};
|
85 |
+
|
86 |
+
// Beta status for experimental features
|
87 |
+
const BETA_TABS = new Set<TabType>(['task-manager', 'service-status', 'update', 'local-providers']);
|
88 |
+
|
89 |
+
const BetaLabel = () => (
|
90 |
+
<div className="absolute top-2 right-2 px-1.5 py-0.5 rounded-full bg-purple-500/10 dark:bg-purple-500/20">
|
91 |
+
<span className="text-[10px] font-medium text-purple-600 dark:text-purple-400">BETA</span>
|
92 |
+
</div>
|
93 |
+
);
|
94 |
+
|
95 |
+
const AnimatedSwitch = ({ checked, onCheckedChange, id, label }: AnimatedSwitchProps) => {
|
96 |
+
return (
|
97 |
+
<div className="flex items-center gap-2">
|
98 |
+
<Switch
|
99 |
+
id={id}
|
100 |
+
checked={checked}
|
101 |
+
onCheckedChange={onCheckedChange}
|
102 |
+
className={classNames(
|
103 |
+
'relative inline-flex h-6 w-11 items-center rounded-full',
|
104 |
+
'transition-all duration-300 ease-[cubic-bezier(0.87,_0,_0.13,_1)]',
|
105 |
+
'bg-gray-200 dark:bg-gray-700',
|
106 |
+
'data-[state=checked]:bg-purple-500',
|
107 |
+
'focus:outline-none focus:ring-2 focus:ring-purple-500/20',
|
108 |
+
'cursor-pointer',
|
109 |
+
'group',
|
110 |
+
)}
|
111 |
+
>
|
112 |
+
<motion.span
|
113 |
+
className={classNames(
|
114 |
+
'absolute left-[2px] top-[2px]',
|
115 |
+
'inline-block h-5 w-5 rounded-full',
|
116 |
+
'bg-white shadow-lg',
|
117 |
+
'transition-shadow duration-300',
|
118 |
+
'group-hover:shadow-md group-active:shadow-sm',
|
119 |
+
'group-hover:scale-95 group-active:scale-90',
|
120 |
+
)}
|
121 |
+
initial={false}
|
122 |
+
transition={{
|
123 |
+
type: 'spring',
|
124 |
+
stiffness: 500,
|
125 |
+
damping: 30,
|
126 |
+
duration: 0.2,
|
127 |
+
}}
|
128 |
+
animate={{
|
129 |
+
x: checked ? '1.25rem' : '0rem',
|
130 |
+
}}
|
131 |
+
>
|
132 |
+
<motion.div
|
133 |
+
className="absolute inset-0 rounded-full bg-white"
|
134 |
+
initial={false}
|
135 |
+
animate={{
|
136 |
+
scale: checked ? 1 : 0.8,
|
137 |
+
}}
|
138 |
+
transition={{ duration: 0.2 }}
|
139 |
+
/>
|
140 |
+
</motion.span>
|
141 |
+
<span className="sr-only">Toggle {label}</span>
|
142 |
+
</Switch>
|
143 |
+
<div className="flex items-center gap-2">
|
144 |
+
<label
|
145 |
+
htmlFor={id}
|
146 |
+
className="text-sm text-gray-500 dark:text-gray-400 select-none cursor-pointer whitespace-nowrap w-[88px]"
|
147 |
+
>
|
148 |
+
{label}
|
149 |
+
</label>
|
150 |
+
</div>
|
151 |
+
</div>
|
152 |
+
);
|
153 |
+
};
|
154 |
+
|
155 |
+
export const ControlPanel = ({ open, onClose }: ControlPanelProps) => {
|
156 |
+
// State
|
157 |
+
const [activeTab, setActiveTab] = useState<TabType | null>(null);
|
158 |
+
const [loadingTab, setLoadingTab] = useState<TabType | null>(null);
|
159 |
+
const [showTabManagement, setShowTabManagement] = useState(false);
|
160 |
+
|
161 |
+
// Store values
|
162 |
+
const tabConfiguration = useStore(tabConfigurationStore);
|
163 |
+
const developerMode = useStore(developerModeStore);
|
164 |
+
const profile = useStore(profileStore) as Profile;
|
165 |
+
|
166 |
+
// Status hooks
|
167 |
+
const { hasUpdate, currentVersion, acknowledgeUpdate } = useUpdateCheck();
|
168 |
+
const { hasNewFeatures, unviewedFeatures, acknowledgeAllFeatures } = useFeatures();
|
169 |
+
const { hasUnreadNotifications, unreadNotifications, markAllAsRead } = useNotifications();
|
170 |
+
const { hasConnectionIssues, currentIssue, acknowledgeIssue } = useConnectionStatus();
|
171 |
+
const { hasActiveWarnings, activeIssues, acknowledgeAllIssues } = useDebugStatus();
|
172 |
+
|
173 |
+
// Memoize the base tab configurations to avoid recalculation
|
174 |
+
const baseTabConfig = useMemo(() => {
|
175 |
+
return new Map(DEFAULT_TAB_CONFIG.map((tab) => [tab.id, tab]));
|
176 |
+
}, []);
|
177 |
+
|
178 |
+
// Add visibleTabs logic using useMemo with optimized calculations
|
179 |
+
const visibleTabs = useMemo(() => {
|
180 |
+
if (!tabConfiguration?.userTabs || !Array.isArray(tabConfiguration.userTabs)) {
|
181 |
+
console.warn('Invalid tab configuration, resetting to defaults');
|
182 |
+
resetTabConfiguration();
|
183 |
+
|
184 |
+
return [];
|
185 |
+
}
|
186 |
+
|
187 |
+
const notificationsDisabled = profile?.preferences?.notifications === false;
|
188 |
+
|
189 |
+
// In developer mode, show ALL tabs without restrictions
|
190 |
+
if (developerMode) {
|
191 |
+
const seenTabs = new Set<TabType>();
|
192 |
+
const devTabs: ExtendedTabConfig[] = [];
|
193 |
+
|
194 |
+
// Process tabs in order of priority: developer, user, default
|
195 |
+
const processTab = (tab: BaseTabConfig) => {
|
196 |
+
if (!seenTabs.has(tab.id)) {
|
197 |
+
seenTabs.add(tab.id);
|
198 |
+
devTabs.push({
|
199 |
+
id: tab.id,
|
200 |
+
visible: true,
|
201 |
+
window: 'developer',
|
202 |
+
order: tab.order || devTabs.length,
|
203 |
+
});
|
204 |
+
}
|
205 |
+
};
|
206 |
+
|
207 |
+
// Process tabs in priority order
|
208 |
+
tabConfiguration.developerTabs?.forEach((tab) => processTab(tab as BaseTabConfig));
|
209 |
+
tabConfiguration.userTabs.forEach((tab) => processTab(tab as BaseTabConfig));
|
210 |
+
DEFAULT_TAB_CONFIG.forEach((tab) => processTab(tab as BaseTabConfig));
|
211 |
+
|
212 |
+
// Add Tab Management tile
|
213 |
+
devTabs.push({
|
214 |
+
id: 'tab-management' as TabType,
|
215 |
+
visible: true,
|
216 |
+
window: 'developer',
|
217 |
+
order: devTabs.length,
|
218 |
+
isExtraDevTab: true,
|
219 |
+
});
|
220 |
+
|
221 |
+
return devTabs.sort((a, b) => a.order - b.order);
|
222 |
+
}
|
223 |
+
|
224 |
+
// Optimize user mode tab filtering
|
225 |
+
return tabConfiguration.userTabs
|
226 |
+
.filter((tab) => {
|
227 |
+
if (!tab?.id) {
|
228 |
+
return false;
|
229 |
+
}
|
230 |
+
|
231 |
+
if (tab.id === 'notifications' && notificationsDisabled) {
|
232 |
+
return false;
|
233 |
+
}
|
234 |
+
|
235 |
+
return tab.visible && tab.window === 'user';
|
236 |
+
})
|
237 |
+
.sort((a, b) => a.order - b.order);
|
238 |
+
}, [tabConfiguration, developerMode, profile?.preferences?.notifications, baseTabConfig]);
|
239 |
+
|
240 |
+
// Optimize animation performance with layout animations
|
241 |
+
const gridLayoutVariants = {
|
242 |
+
hidden: { opacity: 0 },
|
243 |
+
visible: {
|
244 |
+
opacity: 1,
|
245 |
+
transition: {
|
246 |
+
staggerChildren: 0.05,
|
247 |
+
delayChildren: 0.1,
|
248 |
+
},
|
249 |
+
},
|
250 |
+
};
|
251 |
+
|
252 |
+
const itemVariants = {
|
253 |
+
hidden: { opacity: 0, scale: 0.8 },
|
254 |
+
visible: {
|
255 |
+
opacity: 1,
|
256 |
+
scale: 1,
|
257 |
+
transition: {
|
258 |
+
type: 'spring',
|
259 |
+
stiffness: 200,
|
260 |
+
damping: 20,
|
261 |
+
mass: 0.6,
|
262 |
+
},
|
263 |
+
},
|
264 |
+
};
|
265 |
+
|
266 |
+
// Reset to default view when modal opens/closes
|
267 |
+
useEffect(() => {
|
268 |
+
if (!open) {
|
269 |
+
// Reset when closing
|
270 |
+
setActiveTab(null);
|
271 |
+
setLoadingTab(null);
|
272 |
+
setShowTabManagement(false);
|
273 |
+
} else {
|
274 |
+
// When opening, set to null to show the main view
|
275 |
+
setActiveTab(null);
|
276 |
+
}
|
277 |
+
}, [open]);
|
278 |
+
|
279 |
+
// Handle closing
|
280 |
+
const handleClose = () => {
|
281 |
+
setActiveTab(null);
|
282 |
+
setLoadingTab(null);
|
283 |
+
setShowTabManagement(false);
|
284 |
+
onClose();
|
285 |
+
};
|
286 |
+
|
287 |
+
// Handlers
|
288 |
+
const handleBack = () => {
|
289 |
+
if (showTabManagement) {
|
290 |
+
setShowTabManagement(false);
|
291 |
+
} else if (activeTab) {
|
292 |
+
setActiveTab(null);
|
293 |
+
}
|
294 |
+
};
|
295 |
+
|
296 |
+
const handleDeveloperModeChange = (checked: boolean) => {
|
297 |
+
console.log('Developer mode changed:', checked);
|
298 |
+
setDeveloperMode(checked);
|
299 |
+
};
|
300 |
+
|
301 |
+
// Add effect to log developer mode changes
|
302 |
+
useEffect(() => {
|
303 |
+
console.log('Current developer mode:', developerMode);
|
304 |
+
}, [developerMode]);
|
305 |
+
|
306 |
+
const getTabComponent = (tabId: TabType | 'tab-management') => {
|
307 |
+
if (tabId === 'tab-management') {
|
308 |
+
return <TabManagement />;
|
309 |
+
}
|
310 |
+
|
311 |
+
switch (tabId) {
|
312 |
+
case 'profile':
|
313 |
+
return <ProfileTab />;
|
314 |
+
case 'settings':
|
315 |
+
return <SettingsTab />;
|
316 |
+
case 'notifications':
|
317 |
+
return <NotificationsTab />;
|
318 |
+
case 'features':
|
319 |
+
return <FeaturesTab />;
|
320 |
+
case 'data':
|
321 |
+
return <DataTab />;
|
322 |
+
case 'cloud-providers':
|
323 |
+
return <CloudProvidersTab />;
|
324 |
+
case 'local-providers':
|
325 |
+
return <LocalProvidersTab />;
|
326 |
+
case 'connection':
|
327 |
+
return <ConnectionsTab />;
|
328 |
+
case 'debug':
|
329 |
+
return <DebugTab />;
|
330 |
+
case 'event-logs':
|
331 |
+
return <EventLogsTab />;
|
332 |
+
case 'update':
|
333 |
+
return <UpdateTab />;
|
334 |
+
case 'task-manager':
|
335 |
+
return <TaskManagerTab />;
|
336 |
+
case 'service-status':
|
337 |
+
return <ServiceStatusTab />;
|
338 |
+
default:
|
339 |
+
return null;
|
340 |
+
}
|
341 |
+
};
|
342 |
+
|
343 |
+
const getTabUpdateStatus = (tabId: TabType): boolean => {
|
344 |
+
switch (tabId) {
|
345 |
+
case 'update':
|
346 |
+
return hasUpdate;
|
347 |
+
case 'features':
|
348 |
+
return hasNewFeatures;
|
349 |
+
case 'notifications':
|
350 |
+
return hasUnreadNotifications;
|
351 |
+
case 'connection':
|
352 |
+
return hasConnectionIssues;
|
353 |
+
case 'debug':
|
354 |
+
return hasActiveWarnings;
|
355 |
+
default:
|
356 |
+
return false;
|
357 |
+
}
|
358 |
+
};
|
359 |
+
|
360 |
+
const getStatusMessage = (tabId: TabType): string => {
|
361 |
+
switch (tabId) {
|
362 |
+
case 'update':
|
363 |
+
return `New update available (v${currentVersion})`;
|
364 |
+
case 'features':
|
365 |
+
return `${unviewedFeatures.length} new feature${unviewedFeatures.length === 1 ? '' : 's'} to explore`;
|
366 |
+
case 'notifications':
|
367 |
+
return `${unreadNotifications.length} unread notification${unreadNotifications.length === 1 ? '' : 's'}`;
|
368 |
+
case 'connection':
|
369 |
+
return currentIssue === 'disconnected'
|
370 |
+
? 'Connection lost'
|
371 |
+
: currentIssue === 'high-latency'
|
372 |
+
? 'High latency detected'
|
373 |
+
: 'Connection issues detected';
|
374 |
+
case 'debug': {
|
375 |
+
const warnings = activeIssues.filter((i) => i.type === 'warning').length;
|
376 |
+
const errors = activeIssues.filter((i) => i.type === 'error').length;
|
377 |
+
|
378 |
+
return `${warnings} warning${warnings === 1 ? '' : 's'}, ${errors} error${errors === 1 ? '' : 's'}`;
|
379 |
+
}
|
380 |
+
default:
|
381 |
+
return '';
|
382 |
+
}
|
383 |
+
};
|
384 |
+
|
385 |
+
const handleTabClick = (tabId: TabType) => {
|
386 |
+
setLoadingTab(tabId);
|
387 |
+
setActiveTab(tabId);
|
388 |
+
setShowTabManagement(false);
|
389 |
+
|
390 |
+
// Acknowledge notifications based on tab
|
391 |
+
switch (tabId) {
|
392 |
+
case 'update':
|
393 |
+
acknowledgeUpdate();
|
394 |
+
break;
|
395 |
+
case 'features':
|
396 |
+
acknowledgeAllFeatures();
|
397 |
+
break;
|
398 |
+
case 'notifications':
|
399 |
+
markAllAsRead();
|
400 |
+
break;
|
401 |
+
case 'connection':
|
402 |
+
acknowledgeIssue();
|
403 |
+
break;
|
404 |
+
case 'debug':
|
405 |
+
acknowledgeAllIssues();
|
406 |
+
break;
|
407 |
+
}
|
408 |
+
|
409 |
+
// Clear loading state after a delay
|
410 |
+
setTimeout(() => setLoadingTab(null), 500);
|
411 |
+
};
|
412 |
+
|
413 |
+
return (
|
414 |
+
<RadixDialog.Root open={open}>
|
415 |
+
<RadixDialog.Portal>
|
416 |
+
<div className="fixed inset-0 flex items-center justify-center z-[100]">
|
417 |
+
<RadixDialog.Overlay asChild>
|
418 |
+
<motion.div
|
419 |
+
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
420 |
+
initial={{ opacity: 0 }}
|
421 |
+
animate={{ opacity: 1 }}
|
422 |
+
exit={{ opacity: 0 }}
|
423 |
+
transition={{ duration: 0.2 }}
|
424 |
+
/>
|
425 |
+
</RadixDialog.Overlay>
|
426 |
+
|
427 |
+
<RadixDialog.Content
|
428 |
+
aria-describedby={undefined}
|
429 |
+
onEscapeKeyDown={handleClose}
|
430 |
+
onPointerDownOutside={handleClose}
|
431 |
+
className="relative z-[101]"
|
432 |
+
>
|
433 |
+
<motion.div
|
434 |
+
className={classNames(
|
435 |
+
'w-[1200px] h-[90vh]',
|
436 |
+
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
|
437 |
+
'rounded-2xl shadow-2xl',
|
438 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
439 |
+
'flex flex-col overflow-hidden',
|
440 |
+
'relative',
|
441 |
+
)}
|
442 |
+
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
443 |
+
animate={{ opacity: 1, scale: 1, y: 0 }}
|
444 |
+
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
445 |
+
transition={{ duration: 0.2 }}
|
446 |
+
>
|
447 |
+
<div className="absolute inset-0 overflow-hidden rounded-2xl">
|
448 |
+
<BackgroundRays />
|
449 |
+
</div>
|
450 |
+
<div className="relative z-10 flex flex-col h-full">
|
451 |
+
{/* Header */}
|
452 |
+
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
453 |
+
<div className="flex items-center space-x-4">
|
454 |
+
{(activeTab || showTabManagement) && (
|
455 |
+
<button
|
456 |
+
onClick={handleBack}
|
457 |
+
className="flex items-center justify-center w-8 h-8 rounded-full bg-transparent hover:bg-purple-500/10 dark:hover:bg-purple-500/20 group transition-all duration-200"
|
458 |
+
>
|
459 |
+
<div className="i-ph:arrow-left w-4 h-4 text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
|
460 |
+
</button>
|
461 |
+
)}
|
462 |
+
<DialogTitle className="text-xl font-semibold text-gray-900 dark:text-white">
|
463 |
+
{showTabManagement ? 'Tab Management' : activeTab ? TAB_LABELS[activeTab] : 'Control Panel'}
|
464 |
+
</DialogTitle>
|
465 |
+
</div>
|
466 |
+
|
467 |
+
<div className="flex items-center gap-6">
|
468 |
+
{/* Mode Toggle */}
|
469 |
+
<div className="flex items-center gap-2 min-w-[140px] border-r border-gray-200 dark:border-gray-800 pr-6">
|
470 |
+
<AnimatedSwitch
|
471 |
+
id="developer-mode"
|
472 |
+
checked={developerMode}
|
473 |
+
onCheckedChange={handleDeveloperModeChange}
|
474 |
+
label={developerMode ? 'Developer Mode' : 'User Mode'}
|
475 |
+
/>
|
476 |
+
</div>
|
477 |
+
|
478 |
+
{/* Avatar and Dropdown */}
|
479 |
+
<div className="border-l border-gray-200 dark:border-gray-800 pl-6">
|
480 |
+
<AvatarDropdown onSelectTab={handleTabClick} />
|
481 |
+
</div>
|
482 |
+
|
483 |
+
{/* Close Button */}
|
484 |
+
<button
|
485 |
+
onClick={handleClose}
|
486 |
+
className="flex items-center justify-center w-8 h-8 rounded-full bg-transparent hover:bg-purple-500/10 dark:hover:bg-purple-500/20 group transition-all duration-200"
|
487 |
+
>
|
488 |
+
<div className="i-ph:x w-4 h-4 text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
|
489 |
+
</button>
|
490 |
+
</div>
|
491 |
+
</div>
|
492 |
+
|
493 |
+
{/* Content */}
|
494 |
+
<div
|
495 |
+
className={classNames(
|
496 |
+
'flex-1',
|
497 |
+
'overflow-y-auto',
|
498 |
+
'hover:overflow-y-auto',
|
499 |
+
'scrollbar scrollbar-w-2',
|
500 |
+
'scrollbar-track-transparent',
|
501 |
+
'scrollbar-thumb-[#E5E5E5] hover:scrollbar-thumb-[#CCCCCC]',
|
502 |
+
'dark:scrollbar-thumb-[#333333] dark:hover:scrollbar-thumb-[#444444]',
|
503 |
+
'will-change-scroll',
|
504 |
+
'touch-auto',
|
505 |
+
)}
|
506 |
+
>
|
507 |
+
<motion.div
|
508 |
+
key={activeTab || 'home'}
|
509 |
+
initial={{ opacity: 0 }}
|
510 |
+
animate={{ opacity: 1 }}
|
511 |
+
exit={{ opacity: 0 }}
|
512 |
+
transition={{ duration: 0.2 }}
|
513 |
+
className="p-6"
|
514 |
+
>
|
515 |
+
{showTabManagement ? (
|
516 |
+
<TabManagement />
|
517 |
+
) : activeTab ? (
|
518 |
+
getTabComponent(activeTab)
|
519 |
+
) : (
|
520 |
+
<motion.div
|
521 |
+
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 relative"
|
522 |
+
variants={gridLayoutVariants}
|
523 |
+
initial="hidden"
|
524 |
+
animate="visible"
|
525 |
+
>
|
526 |
+
<AnimatePresence mode="popLayout">
|
527 |
+
{(visibleTabs as TabWithDevType[]).map((tab: TabWithDevType) => (
|
528 |
+
<motion.div key={tab.id} layout variants={itemVariants} className="aspect-[1.5/1]">
|
529 |
+
<TabTile
|
530 |
+
tab={tab}
|
531 |
+
onClick={() => handleTabClick(tab.id as TabType)}
|
532 |
+
isActive={activeTab === tab.id}
|
533 |
+
hasUpdate={getTabUpdateStatus(tab.id)}
|
534 |
+
statusMessage={getStatusMessage(tab.id)}
|
535 |
+
description={TAB_DESCRIPTIONS[tab.id]}
|
536 |
+
isLoading={loadingTab === tab.id}
|
537 |
+
className="h-full relative"
|
538 |
+
>
|
539 |
+
{BETA_TABS.has(tab.id) && <BetaLabel />}
|
540 |
+
</TabTile>
|
541 |
+
</motion.div>
|
542 |
+
))}
|
543 |
+
</AnimatePresence>
|
544 |
+
</motion.div>
|
545 |
+
)}
|
546 |
+
</motion.div>
|
547 |
+
</div>
|
548 |
+
</div>
|
549 |
+
</motion.div>
|
550 |
+
</RadixDialog.Content>
|
551 |
+
</div>
|
552 |
+
</RadixDialog.Portal>
|
553 |
+
</RadixDialog.Root>
|
554 |
+
);
|
555 |
+
};
|
app/components/@settings/core/constants.ts
ADDED
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import type { TabType } from './types';
|
2 |
+
|
3 |
+
export const TAB_ICONS: Record<TabType, string> = {
|
4 |
+
profile: 'i-ph:user-circle-fill',
|
5 |
+
settings: 'i-ph:gear-six-fill',
|
6 |
+
notifications: 'i-ph:bell-fill',
|
7 |
+
features: 'i-ph:star-fill',
|
8 |
+
data: 'i-ph:database-fill',
|
9 |
+
'cloud-providers': 'i-ph:cloud-fill',
|
10 |
+
'local-providers': 'i-ph:desktop-fill',
|
11 |
+
'service-status': 'i-ph:activity-bold',
|
12 |
+
connection: 'i-ph:wifi-high-fill',
|
13 |
+
debug: 'i-ph:bug-fill',
|
14 |
+
'event-logs': 'i-ph:list-bullets-fill',
|
15 |
+
update: 'i-ph:arrow-clockwise-fill',
|
16 |
+
'task-manager': 'i-ph:chart-line-fill',
|
17 |
+
'tab-management': 'i-ph:squares-four-fill',
|
18 |
+
};
|
19 |
+
|
20 |
+
export const TAB_LABELS: Record<TabType, string> = {
|
21 |
+
profile: 'Profile',
|
22 |
+
settings: 'Settings',
|
23 |
+
notifications: 'Notifications',
|
24 |
+
features: 'Features',
|
25 |
+
data: 'Data Management',
|
26 |
+
'cloud-providers': 'Cloud Providers',
|
27 |
+
'local-providers': 'Local Providers',
|
28 |
+
'service-status': 'Service Status',
|
29 |
+
connection: 'Connection',
|
30 |
+
debug: 'Debug',
|
31 |
+
'event-logs': 'Event Logs',
|
32 |
+
update: 'Updates',
|
33 |
+
'task-manager': 'Task Manager',
|
34 |
+
'tab-management': 'Tab Management',
|
35 |
+
};
|
36 |
+
|
37 |
+
export const TAB_DESCRIPTIONS: Record<TabType, string> = {
|
38 |
+
profile: 'Manage your profile and account settings',
|
39 |
+
settings: 'Configure application preferences',
|
40 |
+
notifications: 'View and manage your notifications',
|
41 |
+
features: 'Explore new and upcoming features',
|
42 |
+
data: 'Manage your data and storage',
|
43 |
+
'cloud-providers': 'Configure cloud AI providers and models',
|
44 |
+
'local-providers': 'Configure local AI providers and models',
|
45 |
+
'service-status': 'Monitor cloud LLM service status',
|
46 |
+
connection: 'Check connection status and settings',
|
47 |
+
debug: 'Debug tools and system information',
|
48 |
+
'event-logs': 'View system events and logs',
|
49 |
+
update: 'Check for updates and release notes',
|
50 |
+
'task-manager': 'Monitor system resources and processes',
|
51 |
+
'tab-management': 'Configure visible tabs and their order',
|
52 |
+
};
|
53 |
+
|
54 |
+
export const DEFAULT_TAB_CONFIG = [
|
55 |
+
// User Window Tabs (Always visible by default)
|
56 |
+
{ id: 'features', visible: true, window: 'user' as const, order: 0 },
|
57 |
+
{ id: 'data', visible: true, window: 'user' as const, order: 1 },
|
58 |
+
{ id: 'cloud-providers', visible: true, window: 'user' as const, order: 2 },
|
59 |
+
{ id: 'local-providers', visible: true, window: 'user' as const, order: 3 },
|
60 |
+
{ id: 'connection', visible: true, window: 'user' as const, order: 4 },
|
61 |
+
{ id: 'notifications', visible: true, window: 'user' as const, order: 5 },
|
62 |
+
{ id: 'event-logs', visible: true, window: 'user' as const, order: 6 },
|
63 |
+
|
64 |
+
// User Window Tabs (In dropdown, initially hidden)
|
65 |
+
{ id: 'profile', visible: false, window: 'user' as const, order: 7 },
|
66 |
+
{ id: 'settings', visible: false, window: 'user' as const, order: 8 },
|
67 |
+
{ id: 'task-manager', visible: false, window: 'user' as const, order: 9 },
|
68 |
+
{ id: 'service-status', visible: false, window: 'user' as const, order: 10 },
|
69 |
+
|
70 |
+
// User Window Tabs (Hidden, controlled by TaskManagerTab)
|
71 |
+
{ id: 'debug', visible: false, window: 'user' as const, order: 11 },
|
72 |
+
{ id: 'update', visible: false, window: 'user' as const, order: 12 },
|
73 |
+
|
74 |
+
// Developer Window Tabs (All visible by default)
|
75 |
+
{ id: 'features', visible: true, window: 'developer' as const, order: 0 },
|
76 |
+
{ id: 'data', visible: true, window: 'developer' as const, order: 1 },
|
77 |
+
{ id: 'cloud-providers', visible: true, window: 'developer' as const, order: 2 },
|
78 |
+
{ id: 'local-providers', visible: true, window: 'developer' as const, order: 3 },
|
79 |
+
{ id: 'connection', visible: true, window: 'developer' as const, order: 4 },
|
80 |
+
{ id: 'notifications', visible: true, window: 'developer' as const, order: 5 },
|
81 |
+
{ id: 'event-logs', visible: true, window: 'developer' as const, order: 6 },
|
82 |
+
{ id: 'profile', visible: true, window: 'developer' as const, order: 7 },
|
83 |
+
{ id: 'settings', visible: true, window: 'developer' as const, order: 8 },
|
84 |
+
{ id: 'task-manager', visible: true, window: 'developer' as const, order: 9 },
|
85 |
+
{ id: 'service-status', visible: true, window: 'developer' as const, order: 10 },
|
86 |
+
{ id: 'debug', visible: true, window: 'developer' as const, order: 11 },
|
87 |
+
{ id: 'update', visible: true, window: 'developer' as const, order: 12 },
|
88 |
+
];
|
app/components/@settings/core/types.ts
ADDED
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import type { ReactNode } from 'react';
|
2 |
+
|
3 |
+
export type SettingCategory = 'profile' | 'file_sharing' | 'connectivity' | 'system' | 'services' | 'preferences';
|
4 |
+
|
5 |
+
export type TabType =
|
6 |
+
| 'profile'
|
7 |
+
| 'settings'
|
8 |
+
| 'notifications'
|
9 |
+
| 'features'
|
10 |
+
| 'data'
|
11 |
+
| 'cloud-providers'
|
12 |
+
| 'local-providers'
|
13 |
+
| 'service-status'
|
14 |
+
| 'connection'
|
15 |
+
| 'debug'
|
16 |
+
| 'event-logs'
|
17 |
+
| 'update'
|
18 |
+
| 'task-manager'
|
19 |
+
| 'tab-management';
|
20 |
+
|
21 |
+
export type WindowType = 'user' | 'developer';
|
22 |
+
|
23 |
+
export interface UserProfile {
|
24 |
+
nickname: any;
|
25 |
+
name: string;
|
26 |
+
email: string;
|
27 |
+
avatar?: string;
|
28 |
+
theme: 'light' | 'dark' | 'system';
|
29 |
+
notifications: boolean;
|
30 |
+
password?: string;
|
31 |
+
bio?: string;
|
32 |
+
language: string;
|
33 |
+
timezone: string;
|
34 |
+
}
|
35 |
+
|
36 |
+
export interface SettingItem {
|
37 |
+
id: TabType;
|
38 |
+
label: string;
|
39 |
+
icon: string;
|
40 |
+
category: SettingCategory;
|
41 |
+
description?: string;
|
42 |
+
component: () => ReactNode;
|
43 |
+
badge?: string;
|
44 |
+
keywords?: string[];
|
45 |
+
}
|
46 |
+
|
47 |
+
export interface TabVisibilityConfig {
|
48 |
+
id: TabType;
|
49 |
+
visible: boolean;
|
50 |
+
window: WindowType;
|
51 |
+
order: number;
|
52 |
+
isExtraDevTab?: boolean;
|
53 |
+
locked?: boolean;
|
54 |
+
}
|
55 |
+
|
56 |
+
export interface DevTabConfig extends TabVisibilityConfig {
|
57 |
+
window: 'developer';
|
58 |
+
}
|
59 |
+
|
60 |
+
export interface UserTabConfig extends TabVisibilityConfig {
|
61 |
+
window: 'user';
|
62 |
+
}
|
63 |
+
|
64 |
+
export interface TabWindowConfig {
|
65 |
+
userTabs: UserTabConfig[];
|
66 |
+
developerTabs: DevTabConfig[];
|
67 |
+
}
|
68 |
+
|
69 |
+
export const TAB_LABELS: Record<TabType, string> = {
|
70 |
+
profile: 'Profile',
|
71 |
+
settings: 'Settings',
|
72 |
+
notifications: 'Notifications',
|
73 |
+
features: 'Features',
|
74 |
+
data: 'Data Management',
|
75 |
+
'cloud-providers': 'Cloud Providers',
|
76 |
+
'local-providers': 'Local Providers',
|
77 |
+
'service-status': 'Service Status',
|
78 |
+
connection: 'Connections',
|
79 |
+
debug: 'Debug',
|
80 |
+
'event-logs': 'Event Logs',
|
81 |
+
update: 'Updates',
|
82 |
+
'task-manager': 'Task Manager',
|
83 |
+
'tab-management': 'Tab Management',
|
84 |
+
};
|
85 |
+
|
86 |
+
export const categoryLabels: Record<SettingCategory, string> = {
|
87 |
+
profile: 'Profile & Account',
|
88 |
+
file_sharing: 'File Sharing',
|
89 |
+
connectivity: 'Connectivity',
|
90 |
+
system: 'System',
|
91 |
+
services: 'Services',
|
92 |
+
preferences: 'Preferences',
|
93 |
+
};
|
94 |
+
|
95 |
+
export const categoryIcons: Record<SettingCategory, string> = {
|
96 |
+
profile: 'i-ph:user-circle',
|
97 |
+
file_sharing: 'i-ph:folder-simple',
|
98 |
+
connectivity: 'i-ph:wifi-high',
|
99 |
+
system: 'i-ph:gear',
|
100 |
+
services: 'i-ph:cube',
|
101 |
+
preferences: 'i-ph:sliders',
|
102 |
+
};
|
103 |
+
|
104 |
+
export interface Profile {
|
105 |
+
username?: string;
|
106 |
+
bio?: string;
|
107 |
+
avatar?: string;
|
108 |
+
preferences?: {
|
109 |
+
notifications?: boolean;
|
110 |
+
theme?: 'light' | 'dark' | 'system';
|
111 |
+
language?: string;
|
112 |
+
timezone?: string;
|
113 |
+
};
|
114 |
+
}
|
app/components/@settings/index.ts
ADDED
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
// Core exports
|
2 |
+
export { ControlPanel } from './core/ControlPanel';
|
3 |
+
export type { TabType, TabVisibilityConfig } from './core/types';
|
4 |
+
|
5 |
+
// Constants
|
6 |
+
export { TAB_LABELS, TAB_DESCRIPTIONS, DEFAULT_TAB_CONFIG } from './core/constants';
|
7 |
+
|
8 |
+
// Shared components
|
9 |
+
export { TabTile } from './shared/components/TabTile';
|
10 |
+
export { TabManagement } from './shared/components/TabManagement';
|
11 |
+
|
12 |
+
// Utils
|
13 |
+
export { getVisibleTabs, reorderTabs, resetToDefaultConfig } from './utils/tab-helpers';
|
14 |
+
export * from './utils/animations';
|
app/components/@settings/shared/components/DraggableTabList.tsx
ADDED
@@ -0,0 +1,163 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { useDrag, useDrop } from 'react-dnd';
|
2 |
+
import { motion } from 'framer-motion';
|
3 |
+
import { classNames } from '~/utils/classNames';
|
4 |
+
import type { TabVisibilityConfig } from '~/components/@settings/core/types';
|
5 |
+
import { TAB_LABELS } from '~/components/@settings/core/types';
|
6 |
+
import { Switch } from '~/components/ui/Switch';
|
7 |
+
|
8 |
+
interface DraggableTabListProps {
|
9 |
+
tabs: TabVisibilityConfig[];
|
10 |
+
onReorder: (tabs: TabVisibilityConfig[]) => void;
|
11 |
+
onWindowChange?: (tab: TabVisibilityConfig, window: 'user' | 'developer') => void;
|
12 |
+
onVisibilityChange?: (tab: TabVisibilityConfig, visible: boolean) => void;
|
13 |
+
showControls?: boolean;
|
14 |
+
}
|
15 |
+
|
16 |
+
interface DraggableTabItemProps {
|
17 |
+
tab: TabVisibilityConfig;
|
18 |
+
index: number;
|
19 |
+
moveTab: (dragIndex: number, hoverIndex: number) => void;
|
20 |
+
showControls?: boolean;
|
21 |
+
onWindowChange?: (tab: TabVisibilityConfig, window: 'user' | 'developer') => void;
|
22 |
+
onVisibilityChange?: (tab: TabVisibilityConfig, visible: boolean) => void;
|
23 |
+
}
|
24 |
+
|
25 |
+
interface DragItem {
|
26 |
+
type: string;
|
27 |
+
index: number;
|
28 |
+
id: string;
|
29 |
+
}
|
30 |
+
|
31 |
+
const DraggableTabItem = ({
|
32 |
+
tab,
|
33 |
+
index,
|
34 |
+
moveTab,
|
35 |
+
showControls,
|
36 |
+
onWindowChange,
|
37 |
+
onVisibilityChange,
|
38 |
+
}: DraggableTabItemProps) => {
|
39 |
+
const [{ isDragging }, dragRef] = useDrag({
|
40 |
+
type: 'tab',
|
41 |
+
item: { type: 'tab', index, id: tab.id },
|
42 |
+
collect: (monitor) => ({
|
43 |
+
isDragging: monitor.isDragging(),
|
44 |
+
}),
|
45 |
+
});
|
46 |
+
|
47 |
+
const [, dropRef] = useDrop({
|
48 |
+
accept: 'tab',
|
49 |
+
hover: (item: DragItem, monitor) => {
|
50 |
+
if (!monitor.isOver({ shallow: true })) {
|
51 |
+
return;
|
52 |
+
}
|
53 |
+
|
54 |
+
if (item.index === index) {
|
55 |
+
return;
|
56 |
+
}
|
57 |
+
|
58 |
+
if (item.id === tab.id) {
|
59 |
+
return;
|
60 |
+
}
|
61 |
+
|
62 |
+
moveTab(item.index, index);
|
63 |
+
item.index = index;
|
64 |
+
},
|
65 |
+
});
|
66 |
+
|
67 |
+
const ref = (node: HTMLDivElement | null) => {
|
68 |
+
dragRef(node);
|
69 |
+
dropRef(node);
|
70 |
+
};
|
71 |
+
|
72 |
+
return (
|
73 |
+
<motion.div
|
74 |
+
ref={ref}
|
75 |
+
initial={false}
|
76 |
+
animate={{
|
77 |
+
scale: isDragging ? 1.02 : 1,
|
78 |
+
boxShadow: isDragging ? '0 8px 16px rgba(0,0,0,0.1)' : 'none',
|
79 |
+
}}
|
80 |
+
className={classNames(
|
81 |
+
'flex items-center justify-between p-4 rounded-lg',
|
82 |
+
'bg-[#F5F5F5] dark:bg-[#1A1A1A]',
|
83 |
+
'border border-[#E5E5E5] dark:border-[#333333]',
|
84 |
+
isDragging ? 'z-50' : '',
|
85 |
+
)}
|
86 |
+
>
|
87 |
+
<div className="flex items-center gap-4">
|
88 |
+
<div className="cursor-grab">
|
89 |
+
<div className="i-ph:dots-six-vertical w-4 h-4 text-bolt-elements-textSecondary" />
|
90 |
+
</div>
|
91 |
+
<div>
|
92 |
+
<div className="font-medium text-bolt-elements-textPrimary">{TAB_LABELS[tab.id]}</div>
|
93 |
+
{showControls && (
|
94 |
+
<div className="text-xs text-bolt-elements-textSecondary">
|
95 |
+
Order: {tab.order}, Window: {tab.window}
|
96 |
+
</div>
|
97 |
+
)}
|
98 |
+
</div>
|
99 |
+
</div>
|
100 |
+
{showControls && !tab.locked && (
|
101 |
+
<div className="flex items-center gap-4">
|
102 |
+
<div className="flex items-center gap-2">
|
103 |
+
<Switch
|
104 |
+
checked={tab.visible}
|
105 |
+
onCheckedChange={(checked: boolean) => onVisibilityChange?.(tab, checked)}
|
106 |
+
className="data-[state=checked]:bg-purple-500"
|
107 |
+
aria-label={`Toggle ${TAB_LABELS[tab.id]} visibility`}
|
108 |
+
/>
|
109 |
+
<label className="text-sm text-bolt-elements-textSecondary">Visible</label>
|
110 |
+
</div>
|
111 |
+
<div className="flex items-center gap-2">
|
112 |
+
<label className="text-sm text-bolt-elements-textSecondary">User</label>
|
113 |
+
<Switch
|
114 |
+
checked={tab.window === 'developer'}
|
115 |
+
onCheckedChange={(checked: boolean) => onWindowChange?.(tab, checked ? 'developer' : 'user')}
|
116 |
+
className="data-[state=checked]:bg-purple-500"
|
117 |
+
aria-label={`Toggle ${TAB_LABELS[tab.id]} window assignment`}
|
118 |
+
/>
|
119 |
+
<label className="text-sm text-bolt-elements-textSecondary">Dev</label>
|
120 |
+
</div>
|
121 |
+
</div>
|
122 |
+
)}
|
123 |
+
</motion.div>
|
124 |
+
);
|
125 |
+
};
|
126 |
+
|
127 |
+
export const DraggableTabList = ({
|
128 |
+
tabs,
|
129 |
+
onReorder,
|
130 |
+
onWindowChange,
|
131 |
+
onVisibilityChange,
|
132 |
+
showControls = false,
|
133 |
+
}: DraggableTabListProps) => {
|
134 |
+
const moveTab = (dragIndex: number, hoverIndex: number) => {
|
135 |
+
const items = Array.from(tabs);
|
136 |
+
const [reorderedItem] = items.splice(dragIndex, 1);
|
137 |
+
items.splice(hoverIndex, 0, reorderedItem);
|
138 |
+
|
139 |
+
// Update order numbers based on position
|
140 |
+
const reorderedTabs = items.map((tab, index) => ({
|
141 |
+
...tab,
|
142 |
+
order: index + 1,
|
143 |
+
}));
|
144 |
+
|
145 |
+
onReorder(reorderedTabs);
|
146 |
+
};
|
147 |
+
|
148 |
+
return (
|
149 |
+
<div className="space-y-2">
|
150 |
+
{tabs.map((tab, index) => (
|
151 |
+
<DraggableTabItem
|
152 |
+
key={tab.id}
|
153 |
+
tab={tab}
|
154 |
+
index={index}
|
155 |
+
moveTab={moveTab}
|
156 |
+
showControls={showControls}
|
157 |
+
onWindowChange={onWindowChange}
|
158 |
+
onVisibilityChange={onVisibilityChange}
|
159 |
+
/>
|
160 |
+
))}
|
161 |
+
</div>
|
162 |
+
);
|
163 |
+
};
|
app/components/@settings/shared/components/TabManagement.tsx
ADDED
@@ -0,0 +1,380 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { useState, useEffect } from 'react';
|
2 |
+
import { motion } from 'framer-motion';
|
3 |
+
import { useStore } from '@nanostores/react';
|
4 |
+
import { Switch } from '~/components/ui/Switch';
|
5 |
+
import { classNames } from '~/utils/classNames';
|
6 |
+
import { tabConfigurationStore } from '~/lib/stores/settings';
|
7 |
+
import { TAB_LABELS } from '~/components/@settings/core/constants';
|
8 |
+
import type { TabType } from '~/components/@settings/core/types';
|
9 |
+
import { toast } from 'react-toastify';
|
10 |
+
import { TbLayoutGrid } from 'react-icons/tb';
|
11 |
+
import { useSettingsStore } from '~/lib/stores/settings';
|
12 |
+
|
13 |
+
// Define tab icons mapping
|
14 |
+
const TAB_ICONS: Record<TabType, string> = {
|
15 |
+
profile: 'i-ph:user-circle-fill',
|
16 |
+
settings: 'i-ph:gear-six-fill',
|
17 |
+
notifications: 'i-ph:bell-fill',
|
18 |
+
features: 'i-ph:star-fill',
|
19 |
+
data: 'i-ph:database-fill',
|
20 |
+
'cloud-providers': 'i-ph:cloud-fill',
|
21 |
+
'local-providers': 'i-ph:desktop-fill',
|
22 |
+
'service-status': 'i-ph:activity-fill',
|
23 |
+
connection: 'i-ph:wifi-high-fill',
|
24 |
+
debug: 'i-ph:bug-fill',
|
25 |
+
'event-logs': 'i-ph:list-bullets-fill',
|
26 |
+
update: 'i-ph:arrow-clockwise-fill',
|
27 |
+
'task-manager': 'i-ph:chart-line-fill',
|
28 |
+
'tab-management': 'i-ph:squares-four-fill',
|
29 |
+
};
|
30 |
+
|
31 |
+
// Define which tabs are default in user mode
|
32 |
+
const DEFAULT_USER_TABS: TabType[] = [
|
33 |
+
'features',
|
34 |
+
'data',
|
35 |
+
'cloud-providers',
|
36 |
+
'local-providers',
|
37 |
+
'connection',
|
38 |
+
'notifications',
|
39 |
+
'event-logs',
|
40 |
+
];
|
41 |
+
|
42 |
+
// Define which tabs can be added to user mode
|
43 |
+
const OPTIONAL_USER_TABS: TabType[] = ['profile', 'settings', 'task-manager', 'service-status', 'debug', 'update'];
|
44 |
+
|
45 |
+
// All available tabs for user mode
|
46 |
+
const ALL_USER_TABS = [...DEFAULT_USER_TABS, ...OPTIONAL_USER_TABS];
|
47 |
+
|
48 |
+
// Define which tabs are beta
|
49 |
+
const BETA_TABS = new Set<TabType>(['task-manager', 'service-status', 'update', 'local-providers']);
|
50 |
+
|
51 |
+
// Beta label component
|
52 |
+
const BetaLabel = () => (
|
53 |
+
<span className="px-1.5 py-0.5 text-[10px] rounded-full bg-purple-500/10 text-purple-500 font-medium">BETA</span>
|
54 |
+
);
|
55 |
+
|
56 |
+
export const TabManagement = () => {
|
57 |
+
const [searchQuery, setSearchQuery] = useState('');
|
58 |
+
const tabConfiguration = useStore(tabConfigurationStore);
|
59 |
+
const { setSelectedTab } = useSettingsStore();
|
60 |
+
|
61 |
+
const handleTabVisibilityChange = (tabId: TabType, checked: boolean) => {
|
62 |
+
// Get current tab configuration
|
63 |
+
const currentTab = tabConfiguration.userTabs.find((tab) => tab.id === tabId);
|
64 |
+
|
65 |
+
// If tab doesn't exist in configuration, create it
|
66 |
+
if (!currentTab) {
|
67 |
+
const newTab = {
|
68 |
+
id: tabId,
|
69 |
+
visible: checked,
|
70 |
+
window: 'user' as const,
|
71 |
+
order: tabConfiguration.userTabs.length,
|
72 |
+
};
|
73 |
+
|
74 |
+
const updatedTabs = [...tabConfiguration.userTabs, newTab];
|
75 |
+
|
76 |
+
tabConfigurationStore.set({
|
77 |
+
...tabConfiguration,
|
78 |
+
userTabs: updatedTabs,
|
79 |
+
});
|
80 |
+
|
81 |
+
toast.success(`Tab ${checked ? 'enabled' : 'disabled'} successfully`);
|
82 |
+
|
83 |
+
return;
|
84 |
+
}
|
85 |
+
|
86 |
+
// Check if tab can be enabled in user mode
|
87 |
+
const canBeEnabled = DEFAULT_USER_TABS.includes(tabId) || OPTIONAL_USER_TABS.includes(tabId);
|
88 |
+
|
89 |
+
if (!canBeEnabled && checked) {
|
90 |
+
toast.error('This tab cannot be enabled in user mode');
|
91 |
+
return;
|
92 |
+
}
|
93 |
+
|
94 |
+
// Update tab visibility
|
95 |
+
const updatedTabs = tabConfiguration.userTabs.map((tab) => {
|
96 |
+
if (tab.id === tabId) {
|
97 |
+
return { ...tab, visible: checked };
|
98 |
+
}
|
99 |
+
|
100 |
+
return tab;
|
101 |
+
});
|
102 |
+
|
103 |
+
// Update store
|
104 |
+
tabConfigurationStore.set({
|
105 |
+
...tabConfiguration,
|
106 |
+
userTabs: updatedTabs,
|
107 |
+
});
|
108 |
+
|
109 |
+
// Show success message
|
110 |
+
toast.success(`Tab ${checked ? 'enabled' : 'disabled'} successfully`);
|
111 |
+
};
|
112 |
+
|
113 |
+
// Create a map of existing tab configurations
|
114 |
+
const tabConfigMap = new Map(tabConfiguration.userTabs.map((tab) => [tab.id, tab]));
|
115 |
+
|
116 |
+
// Generate the complete list of tabs, including those not in the configuration
|
117 |
+
const allTabs = ALL_USER_TABS.map((tabId) => {
|
118 |
+
return (
|
119 |
+
tabConfigMap.get(tabId) || {
|
120 |
+
id: tabId,
|
121 |
+
visible: false,
|
122 |
+
window: 'user' as const,
|
123 |
+
order: -1,
|
124 |
+
}
|
125 |
+
);
|
126 |
+
});
|
127 |
+
|
128 |
+
// Filter tabs based on search query
|
129 |
+
const filteredTabs = allTabs.filter((tab) => TAB_LABELS[tab.id].toLowerCase().includes(searchQuery.toLowerCase()));
|
130 |
+
|
131 |
+
useEffect(() => {
|
132 |
+
// Reset to first tab when component unmounts
|
133 |
+
return () => {
|
134 |
+
setSelectedTab('user'); // Reset to user tab when unmounting
|
135 |
+
};
|
136 |
+
}, [setSelectedTab]);
|
137 |
+
|
138 |
+
return (
|
139 |
+
<div className="space-y-6">
|
140 |
+
<motion.div
|
141 |
+
className="space-y-4"
|
142 |
+
initial={{ opacity: 0, y: 20 }}
|
143 |
+
animate={{ opacity: 1, y: 0 }}
|
144 |
+
transition={{ duration: 0.3 }}
|
145 |
+
>
|
146 |
+
{/* Header */}
|
147 |
+
<div className="flex items-center justify-between gap-4 mt-8 mb-4">
|
148 |
+
<div className="flex items-center gap-2">
|
149 |
+
<div
|
150 |
+
className={classNames(
|
151 |
+
'w-8 h-8 flex items-center justify-center rounded-lg',
|
152 |
+
'bg-bolt-elements-background-depth-3',
|
153 |
+
'text-purple-500',
|
154 |
+
)}
|
155 |
+
>
|
156 |
+
<TbLayoutGrid className="w-5 h-5" />
|
157 |
+
</div>
|
158 |
+
<div>
|
159 |
+
<h4 className="text-md font-medium text-bolt-elements-textPrimary">Tab Management</h4>
|
160 |
+
<p className="text-sm text-bolt-elements-textSecondary">Configure visible tabs and their order</p>
|
161 |
+
</div>
|
162 |
+
</div>
|
163 |
+
|
164 |
+
{/* Search */}
|
165 |
+
<div className="relative w-64">
|
166 |
+
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
167 |
+
<div className="i-ph:magnifying-glass w-4 h-4 text-gray-400" />
|
168 |
+
</div>
|
169 |
+
<input
|
170 |
+
type="text"
|
171 |
+
value={searchQuery}
|
172 |
+
onChange={(e) => setSearchQuery(e.target.value)}
|
173 |
+
placeholder="Search tabs..."
|
174 |
+
className={classNames(
|
175 |
+
'w-full pl-10 pr-4 py-2 rounded-lg',
|
176 |
+
'bg-bolt-elements-background-depth-2',
|
177 |
+
'border border-bolt-elements-borderColor',
|
178 |
+
'text-bolt-elements-textPrimary',
|
179 |
+
'placeholder-bolt-elements-textTertiary',
|
180 |
+
'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
|
181 |
+
'transition-all duration-200',
|
182 |
+
)}
|
183 |
+
/>
|
184 |
+
</div>
|
185 |
+
</div>
|
186 |
+
|
187 |
+
{/* Tab Grid */}
|
188 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
189 |
+
{/* Default Section Header */}
|
190 |
+
{filteredTabs.some((tab) => DEFAULT_USER_TABS.includes(tab.id)) && (
|
191 |
+
<div className="col-span-full flex items-center gap-2 mt-4 mb-2">
|
192 |
+
<div className="i-ph:star-fill w-4 h-4 text-purple-500" />
|
193 |
+
<span className="text-sm font-medium text-bolt-elements-textPrimary">Default Tabs</span>
|
194 |
+
</div>
|
195 |
+
)}
|
196 |
+
|
197 |
+
{/* Default Tabs */}
|
198 |
+
{filteredTabs
|
199 |
+
.filter((tab) => DEFAULT_USER_TABS.includes(tab.id))
|
200 |
+
.map((tab, index) => (
|
201 |
+
<motion.div
|
202 |
+
key={tab.id}
|
203 |
+
className={classNames(
|
204 |
+
'rounded-lg border bg-bolt-elements-background text-bolt-elements-textPrimary',
|
205 |
+
'bg-bolt-elements-background-depth-2',
|
206 |
+
'hover:bg-bolt-elements-background-depth-3',
|
207 |
+
'transition-all duration-200',
|
208 |
+
'relative overflow-hidden group',
|
209 |
+
)}
|
210 |
+
initial={{ opacity: 0, y: 20 }}
|
211 |
+
animate={{ opacity: 1, y: 0 }}
|
212 |
+
transition={{ delay: index * 0.1 }}
|
213 |
+
whileHover={{ scale: 1.02 }}
|
214 |
+
>
|
215 |
+
{/* Status Badges */}
|
216 |
+
<div className="absolute top-1 right-1.5 flex gap-1">
|
217 |
+
<span className="px-1.5 py-0.25 text-xs rounded-full bg-purple-500/10 text-purple-500 font-medium mr-2">
|
218 |
+
Default
|
219 |
+
</span>
|
220 |
+
</div>
|
221 |
+
|
222 |
+
<div className="flex items-start gap-4 p-4">
|
223 |
+
<motion.div
|
224 |
+
className={classNames(
|
225 |
+
'w-10 h-10 flex items-center justify-center rounded-xl',
|
226 |
+
'bg-bolt-elements-background-depth-3 group-hover:bg-bolt-elements-background-depth-4',
|
227 |
+
'transition-all duration-200',
|
228 |
+
tab.visible ? 'text-purple-500' : 'text-bolt-elements-textSecondary',
|
229 |
+
)}
|
230 |
+
whileHover={{ scale: 1.1 }}
|
231 |
+
whileTap={{ scale: 0.9 }}
|
232 |
+
>
|
233 |
+
<div
|
234 |
+
className={classNames('w-6 h-6', 'transition-transform duration-200', 'group-hover:rotate-12')}
|
235 |
+
>
|
236 |
+
<div className={classNames(TAB_ICONS[tab.id], 'w-full h-full')} />
|
237 |
+
</div>
|
238 |
+
</motion.div>
|
239 |
+
|
240 |
+
<div className="flex-1 min-w-0">
|
241 |
+
<div className="flex items-center justify-between gap-4">
|
242 |
+
<div>
|
243 |
+
<div className="flex items-center gap-2">
|
244 |
+
<h4 className="text-sm font-medium text-bolt-elements-textPrimary group-hover:text-purple-500 transition-colors">
|
245 |
+
{TAB_LABELS[tab.id]}
|
246 |
+
</h4>
|
247 |
+
{BETA_TABS.has(tab.id) && <BetaLabel />}
|
248 |
+
</div>
|
249 |
+
<p className="text-xs text-bolt-elements-textSecondary mt-0.5">
|
250 |
+
{tab.visible ? 'Visible in user mode' : 'Hidden in user mode'}
|
251 |
+
</p>
|
252 |
+
</div>
|
253 |
+
<Switch
|
254 |
+
checked={tab.visible}
|
255 |
+
onCheckedChange={(checked) => {
|
256 |
+
const isDisabled =
|
257 |
+
!DEFAULT_USER_TABS.includes(tab.id) && !OPTIONAL_USER_TABS.includes(tab.id);
|
258 |
+
|
259 |
+
if (!isDisabled) {
|
260 |
+
handleTabVisibilityChange(tab.id, checked);
|
261 |
+
}
|
262 |
+
}}
|
263 |
+
className={classNames('data-[state=checked]:bg-purple-500 ml-4', {
|
264 |
+
'opacity-50 pointer-events-none':
|
265 |
+
!DEFAULT_USER_TABS.includes(tab.id) && !OPTIONAL_USER_TABS.includes(tab.id),
|
266 |
+
})}
|
267 |
+
/>
|
268 |
+
</div>
|
269 |
+
</div>
|
270 |
+
</div>
|
271 |
+
|
272 |
+
<motion.div
|
273 |
+
className="absolute inset-0 border-2 border-purple-500/0 rounded-lg pointer-events-none"
|
274 |
+
animate={{
|
275 |
+
borderColor: tab.visible ? 'rgba(168, 85, 247, 0.2)' : 'rgba(168, 85, 247, 0)',
|
276 |
+
scale: tab.visible ? 1 : 0.98,
|
277 |
+
}}
|
278 |
+
transition={{ duration: 0.2 }}
|
279 |
+
/>
|
280 |
+
</motion.div>
|
281 |
+
))}
|
282 |
+
|
283 |
+
{/* Optional Section Header */}
|
284 |
+
{filteredTabs.some((tab) => OPTIONAL_USER_TABS.includes(tab.id)) && (
|
285 |
+
<div className="col-span-full flex items-center gap-2 mt-8 mb-2">
|
286 |
+
<div className="i-ph:plus-circle-fill w-4 h-4 text-blue-500" />
|
287 |
+
<span className="text-sm font-medium text-bolt-elements-textPrimary">Optional Tabs</span>
|
288 |
+
</div>
|
289 |
+
)}
|
290 |
+
|
291 |
+
{/* Optional Tabs */}
|
292 |
+
{filteredTabs
|
293 |
+
.filter((tab) => OPTIONAL_USER_TABS.includes(tab.id))
|
294 |
+
.map((tab, index) => (
|
295 |
+
<motion.div
|
296 |
+
key={tab.id}
|
297 |
+
className={classNames(
|
298 |
+
'rounded-lg border bg-bolt-elements-background text-bolt-elements-textPrimary',
|
299 |
+
'bg-bolt-elements-background-depth-2',
|
300 |
+
'hover:bg-bolt-elements-background-depth-3',
|
301 |
+
'transition-all duration-200',
|
302 |
+
'relative overflow-hidden group',
|
303 |
+
)}
|
304 |
+
initial={{ opacity: 0, y: 20 }}
|
305 |
+
animate={{ opacity: 1, y: 0 }}
|
306 |
+
transition={{ delay: index * 0.1 }}
|
307 |
+
whileHover={{ scale: 1.02 }}
|
308 |
+
>
|
309 |
+
{/* Status Badges */}
|
310 |
+
<div className="absolute top-1 right-1.5 flex gap-1">
|
311 |
+
<span className="px-1.5 py-0.25 text-xs rounded-full bg-blue-500/10 text-blue-500 font-medium mr-2">
|
312 |
+
Optional
|
313 |
+
</span>
|
314 |
+
</div>
|
315 |
+
|
316 |
+
<div className="flex items-start gap-4 p-4">
|
317 |
+
<motion.div
|
318 |
+
className={classNames(
|
319 |
+
'w-10 h-10 flex items-center justify-center rounded-xl',
|
320 |
+
'bg-bolt-elements-background-depth-3 group-hover:bg-bolt-elements-background-depth-4',
|
321 |
+
'transition-all duration-200',
|
322 |
+
tab.visible ? 'text-purple-500' : 'text-bolt-elements-textSecondary',
|
323 |
+
)}
|
324 |
+
whileHover={{ scale: 1.1 }}
|
325 |
+
whileTap={{ scale: 0.9 }}
|
326 |
+
>
|
327 |
+
<div
|
328 |
+
className={classNames('w-6 h-6', 'transition-transform duration-200', 'group-hover:rotate-12')}
|
329 |
+
>
|
330 |
+
<div className={classNames(TAB_ICONS[tab.id], 'w-full h-full')} />
|
331 |
+
</div>
|
332 |
+
</motion.div>
|
333 |
+
|
334 |
+
<div className="flex-1 min-w-0">
|
335 |
+
<div className="flex items-center justify-between gap-4">
|
336 |
+
<div>
|
337 |
+
<div className="flex items-center gap-2">
|
338 |
+
<h4 className="text-sm font-medium text-bolt-elements-textPrimary group-hover:text-purple-500 transition-colors">
|
339 |
+
{TAB_LABELS[tab.id]}
|
340 |
+
</h4>
|
341 |
+
{BETA_TABS.has(tab.id) && <BetaLabel />}
|
342 |
+
</div>
|
343 |
+
<p className="text-xs text-bolt-elements-textSecondary mt-0.5">
|
344 |
+
{tab.visible ? 'Visible in user mode' : 'Hidden in user mode'}
|
345 |
+
</p>
|
346 |
+
</div>
|
347 |
+
<Switch
|
348 |
+
checked={tab.visible}
|
349 |
+
onCheckedChange={(checked) => {
|
350 |
+
const isDisabled =
|
351 |
+
!DEFAULT_USER_TABS.includes(tab.id) && !OPTIONAL_USER_TABS.includes(tab.id);
|
352 |
+
|
353 |
+
if (!isDisabled) {
|
354 |
+
handleTabVisibilityChange(tab.id, checked);
|
355 |
+
}
|
356 |
+
}}
|
357 |
+
className={classNames('data-[state=checked]:bg-purple-500 ml-4', {
|
358 |
+
'opacity-50 pointer-events-none':
|
359 |
+
!DEFAULT_USER_TABS.includes(tab.id) && !OPTIONAL_USER_TABS.includes(tab.id),
|
360 |
+
})}
|
361 |
+
/>
|
362 |
+
</div>
|
363 |
+
</div>
|
364 |
+
</div>
|
365 |
+
|
366 |
+
<motion.div
|
367 |
+
className="absolute inset-0 border-2 border-purple-500/0 rounded-lg pointer-events-none"
|
368 |
+
animate={{
|
369 |
+
borderColor: tab.visible ? 'rgba(168, 85, 247, 0.2)' : 'rgba(168, 85, 247, 0)',
|
370 |
+
scale: tab.visible ? 1 : 0.98,
|
371 |
+
}}
|
372 |
+
transition={{ duration: 0.2 }}
|
373 |
+
/>
|
374 |
+
</motion.div>
|
375 |
+
))}
|
376 |
+
</div>
|
377 |
+
</motion.div>
|
378 |
+
</div>
|
379 |
+
);
|
380 |
+
};
|
app/components/@settings/shared/components/TabTile.tsx
ADDED
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { motion } from 'framer-motion';
|
2 |
+
import * as Tooltip from '@radix-ui/react-tooltip';
|
3 |
+
import { classNames } from '~/utils/classNames';
|
4 |
+
import type { TabVisibilityConfig } from '~/components/@settings/core/types';
|
5 |
+
import { TAB_LABELS, TAB_ICONS } from '~/components/@settings/core/constants';
|
6 |
+
|
7 |
+
interface TabTileProps {
|
8 |
+
tab: TabVisibilityConfig;
|
9 |
+
onClick?: () => void;
|
10 |
+
isActive?: boolean;
|
11 |
+
hasUpdate?: boolean;
|
12 |
+
statusMessage?: string;
|
13 |
+
description?: string;
|
14 |
+
isLoading?: boolean;
|
15 |
+
className?: string;
|
16 |
+
children?: React.ReactNode;
|
17 |
+
}
|
18 |
+
|
19 |
+
export const TabTile: React.FC<TabTileProps> = ({
|
20 |
+
tab,
|
21 |
+
onClick,
|
22 |
+
isActive,
|
23 |
+
hasUpdate,
|
24 |
+
statusMessage,
|
25 |
+
description,
|
26 |
+
isLoading,
|
27 |
+
className,
|
28 |
+
children,
|
29 |
+
}: TabTileProps) => {
|
30 |
+
return (
|
31 |
+
<Tooltip.Provider delayDuration={200}>
|
32 |
+
<Tooltip.Root>
|
33 |
+
<Tooltip.Trigger asChild>
|
34 |
+
<motion.div
|
35 |
+
onClick={onClick}
|
36 |
+
className={classNames(
|
37 |
+
'relative flex flex-col items-center p-6 rounded-xl',
|
38 |
+
'w-full h-full min-h-[160px]',
|
39 |
+
'bg-white dark:bg-[#141414]',
|
40 |
+
'border border-[#E5E5E5] dark:border-[#333333]',
|
41 |
+
'group',
|
42 |
+
'hover:bg-purple-50 dark:hover:bg-[#1a1a1a]',
|
43 |
+
'hover:border-purple-200 dark:hover:border-purple-900/30',
|
44 |
+
isActive ? 'border-purple-500 dark:border-purple-500/50 bg-purple-500/5 dark:bg-purple-500/10' : '',
|
45 |
+
isLoading ? 'cursor-wait opacity-70' : '',
|
46 |
+
className || '',
|
47 |
+
)}
|
48 |
+
>
|
49 |
+
{/* Main Content */}
|
50 |
+
<div className="flex flex-col items-center justify-center flex-1 w-full">
|
51 |
+
{/* Icon */}
|
52 |
+
<motion.div
|
53 |
+
className={classNames(
|
54 |
+
'relative',
|
55 |
+
'w-14 h-14',
|
56 |
+
'flex items-center justify-center',
|
57 |
+
'rounded-xl',
|
58 |
+
'bg-gray-100 dark:bg-gray-800',
|
59 |
+
'ring-1 ring-gray-200 dark:ring-gray-700',
|
60 |
+
'group-hover:bg-purple-100 dark:group-hover:bg-gray-700/80',
|
61 |
+
'group-hover:ring-purple-200 dark:group-hover:ring-purple-800/30',
|
62 |
+
isActive ? 'bg-purple-500/10 dark:bg-purple-500/10 ring-purple-500/30 dark:ring-purple-500/20' : '',
|
63 |
+
)}
|
64 |
+
>
|
65 |
+
<motion.div
|
66 |
+
className={classNames(
|
67 |
+
TAB_ICONS[tab.id],
|
68 |
+
'w-8 h-8',
|
69 |
+
'text-gray-600 dark:text-gray-300',
|
70 |
+
'group-hover:text-purple-500 dark:group-hover:text-purple-400/80',
|
71 |
+
isActive ? 'text-purple-500 dark:text-purple-400/90' : '',
|
72 |
+
)}
|
73 |
+
/>
|
74 |
+
</motion.div>
|
75 |
+
|
76 |
+
{/* Label and Description */}
|
77 |
+
<div className="flex flex-col items-center mt-5 w-full">
|
78 |
+
<h3
|
79 |
+
className={classNames(
|
80 |
+
'text-[15px] font-medium leading-snug mb-2',
|
81 |
+
'text-gray-700 dark:text-gray-200',
|
82 |
+
'group-hover:text-purple-600 dark:group-hover:text-purple-300/90',
|
83 |
+
isActive ? 'text-purple-500 dark:text-purple-400/90' : '',
|
84 |
+
)}
|
85 |
+
>
|
86 |
+
{TAB_LABELS[tab.id]}
|
87 |
+
</h3>
|
88 |
+
{description && (
|
89 |
+
<p
|
90 |
+
className={classNames(
|
91 |
+
'text-[13px] leading-relaxed',
|
92 |
+
'text-gray-500 dark:text-gray-400',
|
93 |
+
'max-w-[85%]',
|
94 |
+
'text-center',
|
95 |
+
'group-hover:text-purple-500 dark:group-hover:text-purple-400/70',
|
96 |
+
isActive ? 'text-purple-400 dark:text-purple-400/80' : '',
|
97 |
+
)}
|
98 |
+
>
|
99 |
+
{description}
|
100 |
+
</p>
|
101 |
+
)}
|
102 |
+
</div>
|
103 |
+
</div>
|
104 |
+
|
105 |
+
{/* Update Indicator with Tooltip */}
|
106 |
+
{hasUpdate && (
|
107 |
+
<>
|
108 |
+
<div className="absolute top-4 right-4 w-2 h-2 rounded-full bg-purple-500 dark:bg-purple-400 animate-pulse" />
|
109 |
+
<Tooltip.Portal>
|
110 |
+
<Tooltip.Content
|
111 |
+
className={classNames(
|
112 |
+
'px-3 py-1.5 rounded-lg',
|
113 |
+
'bg-[#18181B] text-white',
|
114 |
+
'text-sm font-medium',
|
115 |
+
'select-none',
|
116 |
+
'z-[100]',
|
117 |
+
)}
|
118 |
+
side="top"
|
119 |
+
sideOffset={5}
|
120 |
+
>
|
121 |
+
{statusMessage}
|
122 |
+
<Tooltip.Arrow className="fill-[#18181B]" />
|
123 |
+
</Tooltip.Content>
|
124 |
+
</Tooltip.Portal>
|
125 |
+
</>
|
126 |
+
)}
|
127 |
+
|
128 |
+
{/* Children (e.g. Beta Label) */}
|
129 |
+
{children}
|
130 |
+
</motion.div>
|
131 |
+
</Tooltip.Trigger>
|
132 |
+
</Tooltip.Root>
|
133 |
+
</Tooltip.Provider>
|
134 |
+
);
|
135 |
+
};
|
app/components/@settings/tabs/connections/ConnectionsTab.tsx
ADDED
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { motion } from 'framer-motion';
|
2 |
+
import { GithubConnection } from './GithubConnection';
|
3 |
+
import { NetlifyConnection } from './NetlifyConnection';
|
4 |
+
|
5 |
+
export default function ConnectionsTab() {
|
6 |
+
return (
|
7 |
+
<div className="space-y-4">
|
8 |
+
{/* Header */}
|
9 |
+
<motion.div
|
10 |
+
className="flex items-center gap-2 mb-2"
|
11 |
+
initial={{ opacity: 0, y: 20 }}
|
12 |
+
animate={{ opacity: 1, y: 0 }}
|
13 |
+
transition={{ delay: 0.1 }}
|
14 |
+
>
|
15 |
+
<div className="i-ph:plugs-connected w-5 h-5 text-purple-500" />
|
16 |
+
<h2 className="text-lg font-medium text-bolt-elements-textPrimary">Connection Settings</h2>
|
17 |
+
</motion.div>
|
18 |
+
<p className="text-sm text-bolt-elements-textSecondary mb-6">
|
19 |
+
Manage your external service connections and integrations
|
20 |
+
</p>
|
21 |
+
|
22 |
+
<div className="grid grid-cols-1 gap-4">
|
23 |
+
<GithubConnection />
|
24 |
+
<NetlifyConnection />
|
25 |
+
</div>
|
26 |
+
</div>
|
27 |
+
);
|
28 |
+
}
|
app/components/@settings/tabs/connections/GithubConnection.tsx
ADDED
@@ -0,0 +1,557 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React, { useState, useEffect } from 'react';
|
2 |
+
import { motion } from 'framer-motion';
|
3 |
+
import { toast } from 'react-toastify';
|
4 |
+
import { logStore } from '~/lib/stores/logs';
|
5 |
+
import { classNames } from '~/utils/classNames';
|
6 |
+
|
7 |
+
interface GitHubUserResponse {
|
8 |
+
login: string;
|
9 |
+
avatar_url: string;
|
10 |
+
html_url: string;
|
11 |
+
name: string;
|
12 |
+
bio: string;
|
13 |
+
public_repos: number;
|
14 |
+
followers: number;
|
15 |
+
following: number;
|
16 |
+
created_at: string;
|
17 |
+
public_gists: number;
|
18 |
+
}
|
19 |
+
|
20 |
+
interface GitHubRepoInfo {
|
21 |
+
name: string;
|
22 |
+
full_name: string;
|
23 |
+
html_url: string;
|
24 |
+
description: string;
|
25 |
+
stargazers_count: number;
|
26 |
+
forks_count: number;
|
27 |
+
default_branch: string;
|
28 |
+
updated_at: string;
|
29 |
+
languages_url: string;
|
30 |
+
}
|
31 |
+
|
32 |
+
interface GitHubOrganization {
|
33 |
+
login: string;
|
34 |
+
avatar_url: string;
|
35 |
+
html_url: string;
|
36 |
+
}
|
37 |
+
|
38 |
+
interface GitHubEvent {
|
39 |
+
id: string;
|
40 |
+
type: string;
|
41 |
+
repo: {
|
42 |
+
name: string;
|
43 |
+
};
|
44 |
+
created_at: string;
|
45 |
+
}
|
46 |
+
|
47 |
+
interface GitHubLanguageStats {
|
48 |
+
[language: string]: number;
|
49 |
+
}
|
50 |
+
|
51 |
+
interface GitHubStats {
|
52 |
+
repos: GitHubRepoInfo[];
|
53 |
+
totalStars: number;
|
54 |
+
totalForks: number;
|
55 |
+
organizations: GitHubOrganization[];
|
56 |
+
recentActivity: GitHubEvent[];
|
57 |
+
languages: GitHubLanguageStats;
|
58 |
+
totalGists: number;
|
59 |
+
}
|
60 |
+
|
61 |
+
interface GitHubConnection {
|
62 |
+
user: GitHubUserResponse | null;
|
63 |
+
token: string;
|
64 |
+
tokenType: 'classic' | 'fine-grained';
|
65 |
+
stats?: GitHubStats;
|
66 |
+
}
|
67 |
+
|
68 |
+
export function GithubConnection() {
|
69 |
+
const [connection, setConnection] = useState<GitHubConnection>({
|
70 |
+
user: null,
|
71 |
+
token: '',
|
72 |
+
tokenType: 'classic',
|
73 |
+
});
|
74 |
+
const [isLoading, setIsLoading] = useState(true);
|
75 |
+
const [isConnecting, setIsConnecting] = useState(false);
|
76 |
+
const [isFetchingStats, setIsFetchingStats] = useState(false);
|
77 |
+
const [isStatsExpanded, setIsStatsExpanded] = useState(false);
|
78 |
+
|
79 |
+
const fetchGitHubStats = async (token: string) => {
|
80 |
+
try {
|
81 |
+
setIsFetchingStats(true);
|
82 |
+
|
83 |
+
const reposResponse = await fetch(
|
84 |
+
'https://api.github.com/user/repos?sort=updated&per_page=10&affiliation=owner,organization_member,collaborator',
|
85 |
+
{
|
86 |
+
headers: {
|
87 |
+
Authorization: `Bearer ${token}`,
|
88 |
+
},
|
89 |
+
},
|
90 |
+
);
|
91 |
+
|
92 |
+
if (!reposResponse.ok) {
|
93 |
+
throw new Error('Failed to fetch repositories');
|
94 |
+
}
|
95 |
+
|
96 |
+
const repos = (await reposResponse.json()) as GitHubRepoInfo[];
|
97 |
+
|
98 |
+
const orgsResponse = await fetch('https://api.github.com/user/orgs', {
|
99 |
+
headers: {
|
100 |
+
Authorization: `Bearer ${token}`,
|
101 |
+
},
|
102 |
+
});
|
103 |
+
|
104 |
+
if (!orgsResponse.ok) {
|
105 |
+
throw new Error('Failed to fetch organizations');
|
106 |
+
}
|
107 |
+
|
108 |
+
const organizations = (await orgsResponse.json()) as GitHubOrganization[];
|
109 |
+
|
110 |
+
const eventsResponse = await fetch('https://api.github.com/users/' + connection.user?.login + '/events/public', {
|
111 |
+
headers: {
|
112 |
+
Authorization: `Bearer ${token}`,
|
113 |
+
},
|
114 |
+
});
|
115 |
+
|
116 |
+
if (!eventsResponse.ok) {
|
117 |
+
throw new Error('Failed to fetch events');
|
118 |
+
}
|
119 |
+
|
120 |
+
const recentActivity = ((await eventsResponse.json()) as GitHubEvent[]).slice(0, 5);
|
121 |
+
|
122 |
+
const languagePromises = repos.map((repo) =>
|
123 |
+
fetch(repo.languages_url, {
|
124 |
+
headers: {
|
125 |
+
Authorization: `Bearer ${token}`,
|
126 |
+
},
|
127 |
+
}).then((res) => res.json() as Promise<Record<string, number>>),
|
128 |
+
);
|
129 |
+
|
130 |
+
const repoLanguages = await Promise.all(languagePromises);
|
131 |
+
const languages: GitHubLanguageStats = {};
|
132 |
+
|
133 |
+
repoLanguages.forEach((repoLang) => {
|
134 |
+
Object.entries(repoLang).forEach(([lang, bytes]) => {
|
135 |
+
languages[lang] = (languages[lang] || 0) + bytes;
|
136 |
+
});
|
137 |
+
});
|
138 |
+
|
139 |
+
const totalStars = repos.reduce((acc, repo) => acc + repo.stargazers_count, 0);
|
140 |
+
const totalForks = repos.reduce((acc, repo) => acc + repo.forks_count, 0);
|
141 |
+
const totalGists = connection.user?.public_gists || 0;
|
142 |
+
|
143 |
+
setConnection((prev) => ({
|
144 |
+
...prev,
|
145 |
+
stats: {
|
146 |
+
repos,
|
147 |
+
totalStars,
|
148 |
+
totalForks,
|
149 |
+
organizations,
|
150 |
+
recentActivity,
|
151 |
+
languages,
|
152 |
+
totalGists,
|
153 |
+
},
|
154 |
+
}));
|
155 |
+
} catch (error) {
|
156 |
+
logStore.logError('Failed to fetch GitHub stats', { error });
|
157 |
+
toast.error('Failed to fetch GitHub statistics');
|
158 |
+
} finally {
|
159 |
+
setIsFetchingStats(false);
|
160 |
+
}
|
161 |
+
};
|
162 |
+
|
163 |
+
useEffect(() => {
|
164 |
+
const savedConnection = localStorage.getItem('github_connection');
|
165 |
+
|
166 |
+
if (savedConnection) {
|
167 |
+
const parsed = JSON.parse(savedConnection);
|
168 |
+
|
169 |
+
if (!parsed.tokenType) {
|
170 |
+
parsed.tokenType = 'classic';
|
171 |
+
}
|
172 |
+
|
173 |
+
setConnection(parsed);
|
174 |
+
|
175 |
+
if (parsed.user && parsed.token) {
|
176 |
+
fetchGitHubStats(parsed.token);
|
177 |
+
}
|
178 |
+
}
|
179 |
+
|
180 |
+
setIsLoading(false);
|
181 |
+
}, []);
|
182 |
+
|
183 |
+
if (isLoading || isConnecting || isFetchingStats) {
|
184 |
+
return <LoadingSpinner />;
|
185 |
+
}
|
186 |
+
|
187 |
+
const fetchGithubUser = async (token: string) => {
|
188 |
+
try {
|
189 |
+
setIsConnecting(true);
|
190 |
+
|
191 |
+
const response = await fetch('https://api.github.com/user', {
|
192 |
+
headers: {
|
193 |
+
Authorization: `Bearer ${token}`,
|
194 |
+
},
|
195 |
+
});
|
196 |
+
|
197 |
+
if (!response.ok) {
|
198 |
+
throw new Error('Invalid token or unauthorized');
|
199 |
+
}
|
200 |
+
|
201 |
+
const data = (await response.json()) as GitHubUserResponse;
|
202 |
+
const newConnection: GitHubConnection = {
|
203 |
+
user: data,
|
204 |
+
token,
|
205 |
+
tokenType: connection.tokenType,
|
206 |
+
};
|
207 |
+
|
208 |
+
localStorage.setItem('github_connection', JSON.stringify(newConnection));
|
209 |
+
setConnection(newConnection);
|
210 |
+
|
211 |
+
await fetchGitHubStats(token);
|
212 |
+
|
213 |
+
toast.success('Successfully connected to GitHub');
|
214 |
+
} catch (error) {
|
215 |
+
logStore.logError('Failed to authenticate with GitHub', { error });
|
216 |
+
toast.error('Failed to connect to GitHub');
|
217 |
+
setConnection({ user: null, token: '', tokenType: 'classic' });
|
218 |
+
} finally {
|
219 |
+
setIsConnecting(false);
|
220 |
+
}
|
221 |
+
};
|
222 |
+
|
223 |
+
const handleConnect = async (event: React.FormEvent) => {
|
224 |
+
event.preventDefault();
|
225 |
+
await fetchGithubUser(connection.token);
|
226 |
+
};
|
227 |
+
|
228 |
+
const handleDisconnect = () => {
|
229 |
+
localStorage.removeItem('github_connection');
|
230 |
+
setConnection({ user: null, token: '', tokenType: 'classic' });
|
231 |
+
toast.success('Disconnected from GitHub');
|
232 |
+
};
|
233 |
+
|
234 |
+
return (
|
235 |
+
<motion.div
|
236 |
+
className="bg-[#FFFFFF] dark:bg-[#0A0A0A] rounded-lg border border-[#E5E5E5] dark:border-[#1A1A1A]"
|
237 |
+
initial={{ opacity: 0, y: 20 }}
|
238 |
+
animate={{ opacity: 1, y: 0 }}
|
239 |
+
transition={{ delay: 0.2 }}
|
240 |
+
>
|
241 |
+
<div className="p-6 space-y-6">
|
242 |
+
<div className="flex items-center gap-2">
|
243 |
+
<div className="i-ph:github-logo w-5 h-5 text-bolt-elements-textPrimary" />
|
244 |
+
<h3 className="text-base font-medium text-bolt-elements-textPrimary">GitHub Connection</h3>
|
245 |
+
</div>
|
246 |
+
|
247 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
248 |
+
<div>
|
249 |
+
<label className="block text-sm text-bolt-elements-textSecondary mb-2">Token Type</label>
|
250 |
+
<select
|
251 |
+
value={connection.tokenType}
|
252 |
+
onChange={(e) =>
|
253 |
+
setConnection((prev) => ({ ...prev, tokenType: e.target.value as 'classic' | 'fine-grained' }))
|
254 |
+
}
|
255 |
+
disabled={isConnecting || !!connection.user}
|
256 |
+
className={classNames(
|
257 |
+
'w-full px-3 py-2 rounded-lg text-sm',
|
258 |
+
'bg-[#F8F8F8] dark:bg-[#1A1A1A]',
|
259 |
+
'border border-[#E5E5E5] dark:border-[#333333]',
|
260 |
+
'text-bolt-elements-textPrimary',
|
261 |
+
'focus:outline-none focus:ring-1 focus:ring-purple-500',
|
262 |
+
'disabled:opacity-50',
|
263 |
+
)}
|
264 |
+
>
|
265 |
+
<option value="classic">Personal Access Token (Classic)</option>
|
266 |
+
<option value="fine-grained">Fine-grained Token</option>
|
267 |
+
</select>
|
268 |
+
</div>
|
269 |
+
|
270 |
+
<div>
|
271 |
+
<label className="block text-sm text-bolt-elements-textSecondary mb-2">
|
272 |
+
{connection.tokenType === 'classic' ? 'Personal Access Token' : 'Fine-grained Token'}
|
273 |
+
</label>
|
274 |
+
<input
|
275 |
+
type="password"
|
276 |
+
value={connection.token}
|
277 |
+
onChange={(e) => setConnection((prev) => ({ ...prev, token: e.target.value }))}
|
278 |
+
disabled={isConnecting || !!connection.user}
|
279 |
+
placeholder={`Enter your GitHub ${
|
280 |
+
connection.tokenType === 'classic' ? 'personal access token' : 'fine-grained token'
|
281 |
+
}`}
|
282 |
+
className={classNames(
|
283 |
+
'w-full px-3 py-2 rounded-lg text-sm',
|
284 |
+
'bg-[#F8F8F8] dark:bg-[#1A1A1A]',
|
285 |
+
'border border-[#E5E5E5] dark:border-[#333333]',
|
286 |
+
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
|
287 |
+
'focus:outline-none focus:ring-1 focus:ring-purple-500',
|
288 |
+
'disabled:opacity-50',
|
289 |
+
)}
|
290 |
+
/>
|
291 |
+
<div className="mt-2 text-sm text-bolt-elements-textSecondary">
|
292 |
+
<a
|
293 |
+
href={`https://github.com/settings/tokens${connection.tokenType === 'fine-grained' ? '/beta' : '/new'}`}
|
294 |
+
target="_blank"
|
295 |
+
rel="noopener noreferrer"
|
296 |
+
className="text-purple-500 hover:underline inline-flex items-center gap-1"
|
297 |
+
>
|
298 |
+
Get your token
|
299 |
+
<div className="i-ph:arrow-square-out w-10 h-5" />
|
300 |
+
</a>
|
301 |
+
<span className="mx-2">•</span>
|
302 |
+
<span>
|
303 |
+
Required scopes:{' '}
|
304 |
+
{connection.tokenType === 'classic'
|
305 |
+
? 'repo, read:org, read:user'
|
306 |
+
: 'Repository access, Organization access'}
|
307 |
+
</span>
|
308 |
+
</div>
|
309 |
+
</div>
|
310 |
+
</div>
|
311 |
+
|
312 |
+
<div className="flex items-center gap-3">
|
313 |
+
{!connection.user ? (
|
314 |
+
<button
|
315 |
+
onClick={handleConnect}
|
316 |
+
disabled={isConnecting || !connection.token}
|
317 |
+
className={classNames(
|
318 |
+
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
|
319 |
+
'bg-purple-500 text-white',
|
320 |
+
'hover:bg-purple-600',
|
321 |
+
'disabled:opacity-50 disabled:cursor-not-allowed',
|
322 |
+
)}
|
323 |
+
>
|
324 |
+
{isConnecting ? (
|
325 |
+
<>
|
326 |
+
<div className="i-ph:spinner-gap animate-spin" />
|
327 |
+
Connecting...
|
328 |
+
</>
|
329 |
+
) : (
|
330 |
+
<>
|
331 |
+
<div className="i-ph:plug-charging w-4 h-4" />
|
332 |
+
Connect
|
333 |
+
</>
|
334 |
+
)}
|
335 |
+
</button>
|
336 |
+
) : (
|
337 |
+
<button
|
338 |
+
onClick={handleDisconnect}
|
339 |
+
className={classNames(
|
340 |
+
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
|
341 |
+
'bg-red-500 text-white',
|
342 |
+
'hover:bg-red-600',
|
343 |
+
)}
|
344 |
+
>
|
345 |
+
<div className="i-ph:plug-x w-4 h-4" />
|
346 |
+
Disconnect
|
347 |
+
</button>
|
348 |
+
)}
|
349 |
+
|
350 |
+
{connection.user && (
|
351 |
+
<span className="text-sm text-bolt-elements-textSecondary flex items-center gap-1">
|
352 |
+
<div className="i-ph:check-circle w-4 h-4" />
|
353 |
+
Connected to GitHub
|
354 |
+
</span>
|
355 |
+
)}
|
356 |
+
</div>
|
357 |
+
|
358 |
+
{connection.user && connection.stats && (
|
359 |
+
<div className="mt-6 border-t border-[#E5E5E5] dark:border-[#1A1A1A] pt-6">
|
360 |
+
<button onClick={() => setIsStatsExpanded(!isStatsExpanded)} className="w-full bg-transparent">
|
361 |
+
<div className="flex items-center gap-4">
|
362 |
+
<img src={connection.user.avatar_url} alt={connection.user.login} className="w-16 h-16 rounded-full" />
|
363 |
+
<div className="flex-1">
|
364 |
+
<div className="flex items-center justify-between">
|
365 |
+
<h3 className="text-lg font-medium text-bolt-elements-textPrimary">
|
366 |
+
{connection.user.name || connection.user.login}
|
367 |
+
</h3>
|
368 |
+
<div
|
369 |
+
className={classNames(
|
370 |
+
'i-ph:caret-down w-4 h-4 text-bolt-elements-textSecondary transition-transform',
|
371 |
+
isStatsExpanded ? 'rotate-180' : '',
|
372 |
+
)}
|
373 |
+
/>
|
374 |
+
</div>
|
375 |
+
{connection.user.bio && (
|
376 |
+
<p className="text-sm text-start text-bolt-elements-textSecondary">{connection.user.bio}</p>
|
377 |
+
)}
|
378 |
+
<div className="flex gap-4 mt-2 text-sm text-bolt-elements-textSecondary">
|
379 |
+
<span className="flex items-center gap-1">
|
380 |
+
<div className="i-ph:users w-4 h-4" />
|
381 |
+
{connection.user.followers} followers
|
382 |
+
</span>
|
383 |
+
<span className="flex items-center gap-1">
|
384 |
+
<div className="i-ph:book-bookmark w-4 h-4" />
|
385 |
+
{connection.user.public_repos} public repos
|
386 |
+
</span>
|
387 |
+
<span className="flex items-center gap-1">
|
388 |
+
<div className="i-ph:star w-4 h-4" />
|
389 |
+
{connection.stats.totalStars} stars
|
390 |
+
</span>
|
391 |
+
<span className="flex items-center gap-1">
|
392 |
+
<div className="i-ph:git-fork w-4 h-4" />
|
393 |
+
{connection.stats.totalForks} forks
|
394 |
+
</span>
|
395 |
+
</div>
|
396 |
+
</div>
|
397 |
+
</div>
|
398 |
+
</button>
|
399 |
+
|
400 |
+
{isStatsExpanded && (
|
401 |
+
<div className="pt-4">
|
402 |
+
{connection.stats.organizations.length > 0 && (
|
403 |
+
<div className="mb-6">
|
404 |
+
<h4 className="text-sm font-medium text-bolt-elements-textPrimary mb-3">Organizations</h4>
|
405 |
+
<div className="flex flex-wrap gap-3">
|
406 |
+
{connection.stats.organizations.map((org) => (
|
407 |
+
<a
|
408 |
+
key={org.login}
|
409 |
+
href={org.html_url}
|
410 |
+
target="_blank"
|
411 |
+
rel="noopener noreferrer"
|
412 |
+
className="flex items-center gap-2 p-2 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A] hover:bg-[#F0F0F0] dark:hover:bg-[#252525] transition-colors"
|
413 |
+
>
|
414 |
+
<img src={org.avatar_url} alt={org.login} className="w-6 h-6 rounded-md" />
|
415 |
+
<span className="text-sm text-bolt-elements-textPrimary">{org.login}</span>
|
416 |
+
</a>
|
417 |
+
))}
|
418 |
+
</div>
|
419 |
+
</div>
|
420 |
+
)}
|
421 |
+
|
422 |
+
{/* Languages Section */}
|
423 |
+
<div className="mb-6">
|
424 |
+
<h4 className="text-sm font-medium text-bolt-elements-textPrimary mb-3">Top Languages</h4>
|
425 |
+
<div className="flex flex-wrap gap-2">
|
426 |
+
{Object.entries(connection.stats.languages)
|
427 |
+
.sort(([, a], [, b]) => b - a)
|
428 |
+
.slice(0, 5)
|
429 |
+
.map(([language]) => (
|
430 |
+
<span
|
431 |
+
key={language}
|
432 |
+
className="px-3 py-1 text-xs rounded-full bg-purple-500/10 text-purple-500 dark:bg-purple-500/20"
|
433 |
+
>
|
434 |
+
{language}
|
435 |
+
</span>
|
436 |
+
))}
|
437 |
+
</div>
|
438 |
+
</div>
|
439 |
+
|
440 |
+
{/* Recent Activity Section */}
|
441 |
+
<div className="mb-6">
|
442 |
+
<h4 className="text-sm font-medium text-bolt-elements-textPrimary mb-3">Recent Activity</h4>
|
443 |
+
<div className="space-y-3">
|
444 |
+
{connection.stats.recentActivity.map((event) => (
|
445 |
+
<div key={event.id} className="p-3 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A] text-sm">
|
446 |
+
<div className="flex items-center gap-2 text-bolt-elements-textPrimary">
|
447 |
+
<div className="i-ph:git-commit w-4 h-4 text-bolt-elements-textSecondary" />
|
448 |
+
<span className="font-medium">{event.type.replace('Event', '')}</span>
|
449 |
+
<span>on</span>
|
450 |
+
<a
|
451 |
+
href={`https://github.com/${event.repo.name}`}
|
452 |
+
target="_blank"
|
453 |
+
rel="noopener noreferrer"
|
454 |
+
className="text-purple-500 hover:underline"
|
455 |
+
>
|
456 |
+
{event.repo.name}
|
457 |
+
</a>
|
458 |
+
</div>
|
459 |
+
<div className="mt-1 text-xs text-bolt-elements-textSecondary">
|
460 |
+
{new Date(event.created_at).toLocaleDateString()} at{' '}
|
461 |
+
{new Date(event.created_at).toLocaleTimeString()}
|
462 |
+
</div>
|
463 |
+
</div>
|
464 |
+
))}
|
465 |
+
</div>
|
466 |
+
</div>
|
467 |
+
|
468 |
+
{/* Additional Stats */}
|
469 |
+
<div className="grid grid-cols-4 gap-4 mb-6">
|
470 |
+
<div className="p-4 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A]">
|
471 |
+
<div className="text-sm text-bolt-elements-textSecondary">Member Since</div>
|
472 |
+
<div className="text-lg font-medium text-bolt-elements-textPrimary">
|
473 |
+
{new Date(connection.user.created_at).toLocaleDateString()}
|
474 |
+
</div>
|
475 |
+
</div>
|
476 |
+
<div className="p-4 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A]">
|
477 |
+
<div className="text-sm text-bolt-elements-textSecondary">Public Gists</div>
|
478 |
+
<div className="text-lg font-medium text-bolt-elements-textPrimary">
|
479 |
+
{connection.stats.totalGists}
|
480 |
+
</div>
|
481 |
+
</div>
|
482 |
+
<div className="p-4 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A]">
|
483 |
+
<div className="text-sm text-bolt-elements-textSecondary">Organizations</div>
|
484 |
+
<div className="text-lg font-medium text-bolt-elements-textPrimary">
|
485 |
+
{connection.stats.organizations.length}
|
486 |
+
</div>
|
487 |
+
</div>
|
488 |
+
<div className="p-4 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A]">
|
489 |
+
<div className="text-sm text-bolt-elements-textSecondary">Languages</div>
|
490 |
+
<div className="text-lg font-medium text-bolt-elements-textPrimary">
|
491 |
+
{Object.keys(connection.stats.languages).length}
|
492 |
+
</div>
|
493 |
+
</div>
|
494 |
+
</div>
|
495 |
+
|
496 |
+
{/* Repositories Section */}
|
497 |
+
<h4 className="text-sm font-medium text-bolt-elements-textPrimary mb-3">Recent Repositories</h4>
|
498 |
+
<div className="space-y-3">
|
499 |
+
{connection.stats.repos.map((repo) => (
|
500 |
+
<a
|
501 |
+
key={repo.full_name}
|
502 |
+
href={repo.html_url}
|
503 |
+
target="_blank"
|
504 |
+
rel="noopener noreferrer"
|
505 |
+
className="block p-3 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A] hover:bg-[#F0F0F0] dark:hover:bg-[#252525] transition-colors"
|
506 |
+
>
|
507 |
+
<div className="flex items-center justify-between">
|
508 |
+
<div>
|
509 |
+
<h5 className="text-sm font-medium text-bolt-elements-textPrimary flex items-center gap-2">
|
510 |
+
<div className="i-ph:git-repository w-4 h-4 text-bolt-elements-textSecondary" />
|
511 |
+
{repo.name}
|
512 |
+
</h5>
|
513 |
+
{repo.description && (
|
514 |
+
<p className="text-xs text-bolt-elements-textSecondary mt-1">{repo.description}</p>
|
515 |
+
)}
|
516 |
+
<div className="flex items-center gap-2 mt-2 text-xs text-bolt-elements-textSecondary">
|
517 |
+
<span className="flex items-center gap-1">
|
518 |
+
<div className="i-ph:git-branch w-3 h-3" />
|
519 |
+
{repo.default_branch}
|
520 |
+
</span>
|
521 |
+
<span>•</span>
|
522 |
+
<span>Updated {new Date(repo.updated_at).toLocaleDateString()}</span>
|
523 |
+
</div>
|
524 |
+
</div>
|
525 |
+
<div className="flex items-center gap-3 text-xs text-bolt-elements-textSecondary">
|
526 |
+
<span className="flex items-center gap-1">
|
527 |
+
<div className="i-ph:star w-3 h-3" />
|
528 |
+
{repo.stargazers_count}
|
529 |
+
</span>
|
530 |
+
<span className="flex items-center gap-1">
|
531 |
+
<div className="i-ph:git-fork w-3 h-3" />
|
532 |
+
{repo.forks_count}
|
533 |
+
</span>
|
534 |
+
</div>
|
535 |
+
</div>
|
536 |
+
</a>
|
537 |
+
))}
|
538 |
+
</div>
|
539 |
+
</div>
|
540 |
+
)}
|
541 |
+
</div>
|
542 |
+
)}
|
543 |
+
</div>
|
544 |
+
</motion.div>
|
545 |
+
);
|
546 |
+
}
|
547 |
+
|
548 |
+
function LoadingSpinner() {
|
549 |
+
return (
|
550 |
+
<div className="flex items-center justify-center p-4">
|
551 |
+
<div className="flex items-center gap-2">
|
552 |
+
<div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
|
553 |
+
<span className="text-bolt-elements-textSecondary">Loading...</span>
|
554 |
+
</div>
|
555 |
+
</div>
|
556 |
+
);
|
557 |
+
}
|
app/components/@settings/tabs/connections/NetlifyConnection.tsx
ADDED
@@ -0,0 +1,263 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React, { useEffect, useState } from 'react';
|
2 |
+
import { motion } from 'framer-motion';
|
3 |
+
import { toast } from 'react-toastify';
|
4 |
+
import { useStore } from '@nanostores/react';
|
5 |
+
import { logStore } from '~/lib/stores/logs';
|
6 |
+
import { classNames } from '~/utils/classNames';
|
7 |
+
import {
|
8 |
+
netlifyConnection,
|
9 |
+
isConnecting,
|
10 |
+
isFetchingStats,
|
11 |
+
updateNetlifyConnection,
|
12 |
+
fetchNetlifyStats,
|
13 |
+
} from '~/lib/stores/netlify';
|
14 |
+
import type { NetlifyUser } from '~/types/netlify';
|
15 |
+
|
16 |
+
export function NetlifyConnection() {
|
17 |
+
const connection = useStore(netlifyConnection);
|
18 |
+
const connecting = useStore(isConnecting);
|
19 |
+
const fetchingStats = useStore(isFetchingStats);
|
20 |
+
const [isSitesExpanded, setIsSitesExpanded] = useState(false);
|
21 |
+
|
22 |
+
useEffect(() => {
|
23 |
+
const fetchSites = async () => {
|
24 |
+
if (connection.user && connection.token) {
|
25 |
+
await fetchNetlifyStats(connection.token);
|
26 |
+
}
|
27 |
+
};
|
28 |
+
fetchSites();
|
29 |
+
}, [connection.user, connection.token]);
|
30 |
+
|
31 |
+
const handleConnect = async (event: React.FormEvent) => {
|
32 |
+
event.preventDefault();
|
33 |
+
isConnecting.set(true);
|
34 |
+
|
35 |
+
try {
|
36 |
+
const response = await fetch('https://api.netlify.com/api/v1/user', {
|
37 |
+
headers: {
|
38 |
+
Authorization: `Bearer ${connection.token}`,
|
39 |
+
'Content-Type': 'application/json',
|
40 |
+
},
|
41 |
+
});
|
42 |
+
|
43 |
+
if (!response.ok) {
|
44 |
+
throw new Error('Invalid token or unauthorized');
|
45 |
+
}
|
46 |
+
|
47 |
+
const userData = (await response.json()) as NetlifyUser;
|
48 |
+
updateNetlifyConnection({
|
49 |
+
user: userData,
|
50 |
+
token: connection.token,
|
51 |
+
});
|
52 |
+
|
53 |
+
await fetchNetlifyStats(connection.token);
|
54 |
+
toast.success('Successfully connected to Netlify');
|
55 |
+
} catch (error) {
|
56 |
+
console.error('Auth error:', error);
|
57 |
+
logStore.logError('Failed to authenticate with Netlify', { error });
|
58 |
+
toast.error('Failed to connect to Netlify');
|
59 |
+
updateNetlifyConnection({ user: null, token: '' });
|
60 |
+
} finally {
|
61 |
+
isConnecting.set(false);
|
62 |
+
}
|
63 |
+
};
|
64 |
+
|
65 |
+
const handleDisconnect = () => {
|
66 |
+
updateNetlifyConnection({ user: null, token: '' });
|
67 |
+
toast.success('Disconnected from Netlify');
|
68 |
+
};
|
69 |
+
|
70 |
+
return (
|
71 |
+
<motion.div
|
72 |
+
className="bg-[#FFFFFF] dark:bg-[#0A0A0A] rounded-lg border border-[#E5E5E5] dark:border-[#1A1A1A]"
|
73 |
+
initial={{ opacity: 0, y: 20 }}
|
74 |
+
animate={{ opacity: 1, y: 0 }}
|
75 |
+
transition={{ delay: 0.3 }}
|
76 |
+
>
|
77 |
+
<div className="p-6 space-y-6">
|
78 |
+
<div className="flex items-center justify-between">
|
79 |
+
<div className="flex items-center gap-2">
|
80 |
+
<img
|
81 |
+
className="w-5 h-5"
|
82 |
+
height="24"
|
83 |
+
width="24"
|
84 |
+
crossOrigin="anonymous"
|
85 |
+
src="https://cdn.simpleicons.org/netlify"
|
86 |
+
/>
|
87 |
+
<h3 className="text-base font-medium text-bolt-elements-textPrimary">Netlify Connection</h3>
|
88 |
+
</div>
|
89 |
+
</div>
|
90 |
+
|
91 |
+
{!connection.user ? (
|
92 |
+
<div className="space-y-4">
|
93 |
+
<div>
|
94 |
+
<label className="block text-sm text-bolt-elements-textSecondary mb-2">Personal Access Token</label>
|
95 |
+
<input
|
96 |
+
type="password"
|
97 |
+
value={connection.token}
|
98 |
+
onChange={(e) => updateNetlifyConnection({ ...connection, token: e.target.value })}
|
99 |
+
disabled={connecting}
|
100 |
+
placeholder="Enter your Netlify personal access token"
|
101 |
+
className={classNames(
|
102 |
+
'w-full px-3 py-2 rounded-lg text-sm',
|
103 |
+
'bg-[#F8F8F8] dark:bg-[#1A1A1A]',
|
104 |
+
'border border-[#E5E5E5] dark:border-[#333333]',
|
105 |
+
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
|
106 |
+
'focus:outline-none focus:ring-1 focus:ring-[#00AD9F]',
|
107 |
+
'disabled:opacity-50',
|
108 |
+
)}
|
109 |
+
/>
|
110 |
+
<div className="mt-2 text-sm text-bolt-elements-textSecondary">
|
111 |
+
<a
|
112 |
+
href="https://app.netlify.com/user/applications#personal-access-tokens"
|
113 |
+
target="_blank"
|
114 |
+
rel="noopener noreferrer"
|
115 |
+
className="text-[#00AD9F] hover:underline inline-flex items-center gap-1"
|
116 |
+
>
|
117 |
+
Get your token
|
118 |
+
<div className="i-ph:arrow-square-out w-4 h-4" />
|
119 |
+
</a>
|
120 |
+
</div>
|
121 |
+
</div>
|
122 |
+
|
123 |
+
<button
|
124 |
+
onClick={handleConnect}
|
125 |
+
disabled={connecting || !connection.token}
|
126 |
+
className={classNames(
|
127 |
+
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
|
128 |
+
'bg-[#00AD9F] text-white',
|
129 |
+
'hover:bg-[#00968A]',
|
130 |
+
'disabled:opacity-50 disabled:cursor-not-allowed',
|
131 |
+
)}
|
132 |
+
>
|
133 |
+
{connecting ? (
|
134 |
+
<>
|
135 |
+
<div className="i-ph:spinner-gap animate-spin" />
|
136 |
+
Connecting...
|
137 |
+
</>
|
138 |
+
) : (
|
139 |
+
<>
|
140 |
+
<div className="i-ph:plug-charging w-4 h-4" />
|
141 |
+
Connect
|
142 |
+
</>
|
143 |
+
)}
|
144 |
+
</button>
|
145 |
+
</div>
|
146 |
+
) : (
|
147 |
+
<div className="space-y-6">
|
148 |
+
<div className="flex items-center justify-between">
|
149 |
+
<div className="flex items-center gap-3">
|
150 |
+
<button
|
151 |
+
onClick={handleDisconnect}
|
152 |
+
className={classNames(
|
153 |
+
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
|
154 |
+
'bg-red-500 text-white',
|
155 |
+
'hover:bg-red-600',
|
156 |
+
)}
|
157 |
+
>
|
158 |
+
<div className="i-ph:plug w-4 h-4" />
|
159 |
+
Disconnect
|
160 |
+
</button>
|
161 |
+
<span className="text-sm text-bolt-elements-textSecondary flex items-center gap-1">
|
162 |
+
<div className="i-ph:check-circle w-4 h-4 text-green-500" />
|
163 |
+
Connected to Netlify
|
164 |
+
</span>
|
165 |
+
</div>
|
166 |
+
</div>
|
167 |
+
|
168 |
+
<div className="flex items-center gap-4 p-4 bg-[#F8F8F8] dark:bg-[#1A1A1A] rounded-lg">
|
169 |
+
<img
|
170 |
+
src={connection.user.avatar_url}
|
171 |
+
referrerPolicy="no-referrer"
|
172 |
+
crossOrigin="anonymous"
|
173 |
+
alt={connection.user.full_name}
|
174 |
+
className="w-12 h-12 rounded-full border-2 border-[#00AD9F]"
|
175 |
+
/>
|
176 |
+
<div>
|
177 |
+
<h4 className="text-sm font-medium text-bolt-elements-textPrimary">{connection.user.full_name}</h4>
|
178 |
+
<p className="text-sm text-bolt-elements-textSecondary">{connection.user.email}</p>
|
179 |
+
</div>
|
180 |
+
</div>
|
181 |
+
|
182 |
+
{fetchingStats ? (
|
183 |
+
<div className="flex items-center gap-2 text-sm text-bolt-elements-textSecondary">
|
184 |
+
<div className="i-ph:spinner-gap w-4 h-4 animate-spin" />
|
185 |
+
Fetching Netlify sites...
|
186 |
+
</div>
|
187 |
+
) : (
|
188 |
+
<div>
|
189 |
+
<button
|
190 |
+
onClick={() => setIsSitesExpanded(!isSitesExpanded)}
|
191 |
+
className="w-full bg-transparent text-left text-sm font-medium text-bolt-elements-textPrimary mb-3 flex items-center gap-2"
|
192 |
+
>
|
193 |
+
<div className="i-ph:buildings w-4 h-4" />
|
194 |
+
Your Sites ({connection.stats?.totalSites || 0})
|
195 |
+
<div
|
196 |
+
className={classNames(
|
197 |
+
'i-ph:caret-down w-4 h-4 ml-auto transition-transform',
|
198 |
+
isSitesExpanded ? 'rotate-180' : '',
|
199 |
+
)}
|
200 |
+
/>
|
201 |
+
</button>
|
202 |
+
{isSitesExpanded && connection.stats?.sites?.length ? (
|
203 |
+
<div className="grid gap-3">
|
204 |
+
{connection.stats.sites.map((site) => (
|
205 |
+
<a
|
206 |
+
key={site.id}
|
207 |
+
href={site.admin_url}
|
208 |
+
target="_blank"
|
209 |
+
rel="noopener noreferrer"
|
210 |
+
className="block p-4 rounded-lg border border-[#E5E5E5] dark:border-[#1A1A1A] hover:border-[#00AD9F] dark:hover:border-[#00AD9F] transition-colors"
|
211 |
+
>
|
212 |
+
<div className="flex items-center justify-between">
|
213 |
+
<div>
|
214 |
+
<h5 className="text-sm font-medium text-bolt-elements-textPrimary flex items-center gap-2">
|
215 |
+
<div className="i-ph:globe w-4 h-4 text-[#00AD9F]" />
|
216 |
+
{site.name}
|
217 |
+
</h5>
|
218 |
+
<div className="flex items-center gap-2 mt-2 text-xs text-bolt-elements-textSecondary">
|
219 |
+
<a
|
220 |
+
href={site.url}
|
221 |
+
target="_blank"
|
222 |
+
rel="noopener noreferrer"
|
223 |
+
className="hover:text-[#00AD9F]"
|
224 |
+
>
|
225 |
+
{site.url}
|
226 |
+
</a>
|
227 |
+
{site.published_deploy && (
|
228 |
+
<>
|
229 |
+
<span>•</span>
|
230 |
+
<span className="flex items-center gap-1">
|
231 |
+
<div className="i-ph:clock w-3 h-3" />
|
232 |
+
{new Date(site.published_deploy.published_at).toLocaleDateString()}
|
233 |
+
</span>
|
234 |
+
</>
|
235 |
+
)}
|
236 |
+
</div>
|
237 |
+
</div>
|
238 |
+
{site.build_settings?.provider && (
|
239 |
+
<div className="text-xs text-bolt-elements-textSecondary px-2 py-1 rounded-md bg-[#F0F0F0] dark:bg-[#252525]">
|
240 |
+
<span className="flex items-center gap-1">
|
241 |
+
<div className="i-ph:git-branch w-3 h-3" />
|
242 |
+
{site.build_settings.provider}
|
243 |
+
</span>
|
244 |
+
</div>
|
245 |
+
)}
|
246 |
+
</div>
|
247 |
+
</a>
|
248 |
+
))}
|
249 |
+
</div>
|
250 |
+
) : isSitesExpanded ? (
|
251 |
+
<div className="text-sm text-bolt-elements-textSecondary flex items-center gap-2">
|
252 |
+
<div className="i-ph:info w-4 h-4" />
|
253 |
+
No sites found in your Netlify account
|
254 |
+
</div>
|
255 |
+
) : null}
|
256 |
+
</div>
|
257 |
+
)}
|
258 |
+
</div>
|
259 |
+
)}
|
260 |
+
</div>
|
261 |
+
</motion.div>
|
262 |
+
);
|
263 |
+
}
|
app/components/@settings/tabs/connections/components/ConnectionForm.tsx
ADDED
@@ -0,0 +1,180 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React, { useEffect } from 'react';
|
2 |
+
import { classNames } from '~/utils/classNames';
|
3 |
+
import type { GitHubAuthState } from '~/components/@settings/tabs/connections/types/GitHub';
|
4 |
+
import Cookies from 'js-cookie';
|
5 |
+
import { getLocalStorage } from '~/lib/persistence';
|
6 |
+
|
7 |
+
const GITHUB_TOKEN_KEY = 'github_token';
|
8 |
+
|
9 |
+
interface ConnectionFormProps {
|
10 |
+
authState: GitHubAuthState;
|
11 |
+
setAuthState: React.Dispatch<React.SetStateAction<GitHubAuthState>>;
|
12 |
+
onSave: (e: React.FormEvent) => void;
|
13 |
+
onDisconnect: () => void;
|
14 |
+
}
|
15 |
+
|
16 |
+
export function ConnectionForm({ authState, setAuthState, onSave, onDisconnect }: ConnectionFormProps) {
|
17 |
+
// Check for saved token on mount
|
18 |
+
useEffect(() => {
|
19 |
+
const savedToken = Cookies.get(GITHUB_TOKEN_KEY) || getLocalStorage(GITHUB_TOKEN_KEY);
|
20 |
+
|
21 |
+
if (savedToken && !authState.tokenInfo?.token) {
|
22 |
+
setAuthState((prev: GitHubAuthState) => ({
|
23 |
+
...prev,
|
24 |
+
tokenInfo: {
|
25 |
+
token: savedToken,
|
26 |
+
scope: [],
|
27 |
+
avatar_url: '',
|
28 |
+
name: null,
|
29 |
+
created_at: new Date().toISOString(),
|
30 |
+
followers: 0,
|
31 |
+
},
|
32 |
+
}));
|
33 |
+
}
|
34 |
+
}, []);
|
35 |
+
|
36 |
+
return (
|
37 |
+
<div className="rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A] overflow-hidden">
|
38 |
+
<div className="p-6">
|
39 |
+
<div className="flex items-center justify-between mb-6">
|
40 |
+
<div className="flex items-center gap-3">
|
41 |
+
<div className="p-2 rounded-lg bg-[#F5F5F5] dark:bg-[#1A1A1A] border border-[#E5E5E5] dark:border-[#1A1A1A]">
|
42 |
+
<div className="i-ph:plug-fill text-bolt-elements-textTertiary" />
|
43 |
+
</div>
|
44 |
+
<div>
|
45 |
+
<h3 className="text-lg font-medium text-bolt-elements-textPrimary">Connection Settings</h3>
|
46 |
+
<p className="text-sm text-bolt-elements-textSecondary">Configure your GitHub connection</p>
|
47 |
+
</div>
|
48 |
+
</div>
|
49 |
+
</div>
|
50 |
+
|
51 |
+
<form onSubmit={onSave} className="space-y-4">
|
52 |
+
<div>
|
53 |
+
<label htmlFor="username" className="block text-sm font-medium text-bolt-elements-textSecondary mb-2">
|
54 |
+
GitHub Username
|
55 |
+
</label>
|
56 |
+
<input
|
57 |
+
id="username"
|
58 |
+
type="text"
|
59 |
+
value={authState.username}
|
60 |
+
onChange={(e) => setAuthState((prev: GitHubAuthState) => ({ ...prev, username: e.target.value }))}
|
61 |
+
className={classNames(
|
62 |
+
'w-full px-4 py-2.5 bg-[#F5F5F5] dark:bg-[#1A1A1A] border rounded-lg',
|
63 |
+
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary text-base',
|
64 |
+
'border-[#E5E5E5] dark:border-[#1A1A1A]',
|
65 |
+
'focus:ring-2 focus:ring-purple-500/50 focus:border-purple-500',
|
66 |
+
'transition-all duration-200',
|
67 |
+
)}
|
68 |
+
placeholder="e.g., octocat"
|
69 |
+
/>
|
70 |
+
</div>
|
71 |
+
|
72 |
+
<div>
|
73 |
+
<div className="flex items-center justify-between mb-2">
|
74 |
+
<label htmlFor="token" className="block text-sm font-medium text-bolt-elements-textSecondary">
|
75 |
+
Personal Access Token
|
76 |
+
</label>
|
77 |
+
<a
|
78 |
+
href="https://github.com/settings/tokens/new?scopes=repo,user,read:org,workflow,delete_repo,write:packages,read:packages"
|
79 |
+
target="_blank"
|
80 |
+
rel="noopener noreferrer"
|
81 |
+
className={classNames(
|
82 |
+
'inline-flex items-center gap-1.5 text-xs',
|
83 |
+
'text-purple-500 hover:text-purple-600 dark:text-purple-400 dark:hover:text-purple-300',
|
84 |
+
'transition-colors duration-200',
|
85 |
+
)}
|
86 |
+
>
|
87 |
+
<span>Generate new token</span>
|
88 |
+
<div className="i-ph:plus-circle" />
|
89 |
+
</a>
|
90 |
+
</div>
|
91 |
+
<input
|
92 |
+
id="token"
|
93 |
+
type="password"
|
94 |
+
value={authState.tokenInfo?.token || ''}
|
95 |
+
onChange={(e) =>
|
96 |
+
setAuthState((prev: GitHubAuthState) => ({
|
97 |
+
...prev,
|
98 |
+
tokenInfo: {
|
99 |
+
token: e.target.value,
|
100 |
+
scope: [],
|
101 |
+
avatar_url: '',
|
102 |
+
name: null,
|
103 |
+
created_at: new Date().toISOString(),
|
104 |
+
followers: 0,
|
105 |
+
},
|
106 |
+
username: '',
|
107 |
+
isConnected: false,
|
108 |
+
isVerifying: false,
|
109 |
+
isLoadingRepos: false,
|
110 |
+
}))
|
111 |
+
}
|
112 |
+
className={classNames(
|
113 |
+
'w-full px-4 py-2.5 bg-[#F5F5F5] dark:bg-[#1A1A1A] border rounded-lg',
|
114 |
+
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary text-base',
|
115 |
+
'border-[#E5E5E5] dark:border-[#1A1A1A]',
|
116 |
+
'focus:ring-2 focus:ring-purple-500/50 focus:border-purple-500',
|
117 |
+
'transition-all duration-200',
|
118 |
+
)}
|
119 |
+
placeholder="ghp_xxxxxxxxxxxx"
|
120 |
+
/>
|
121 |
+
</div>
|
122 |
+
|
123 |
+
<div className="flex items-center justify-between pt-4 border-t border-[#E5E5E5] dark:border-[#1A1A1A]">
|
124 |
+
<div className="flex items-center gap-4">
|
125 |
+
{!authState.isConnected ? (
|
126 |
+
<button
|
127 |
+
type="submit"
|
128 |
+
disabled={authState.isVerifying || !authState.username || !authState.tokenInfo?.token}
|
129 |
+
className={classNames(
|
130 |
+
'inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-colors',
|
131 |
+
'bg-purple-500 hover:bg-purple-600',
|
132 |
+
'text-white',
|
133 |
+
'disabled:opacity-50 disabled:cursor-not-allowed',
|
134 |
+
)}
|
135 |
+
>
|
136 |
+
{authState.isVerifying ? (
|
137 |
+
<>
|
138 |
+
<div className="i-ph:spinner animate-spin" />
|
139 |
+
<span>Verifying...</span>
|
140 |
+
</>
|
141 |
+
) : (
|
142 |
+
<>
|
143 |
+
<div className="i-ph:plug-fill" />
|
144 |
+
<span>Connect</span>
|
145 |
+
</>
|
146 |
+
)}
|
147 |
+
</button>
|
148 |
+
) : (
|
149 |
+
<>
|
150 |
+
<button
|
151 |
+
onClick={onDisconnect}
|
152 |
+
className={classNames(
|
153 |
+
'inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-colors',
|
154 |
+
'bg-[#F5F5F5] hover:bg-red-500/10 hover:text-red-500',
|
155 |
+
'dark:bg-[#1A1A1A] dark:hover:bg-red-500/20 dark:hover:text-red-500',
|
156 |
+
'text-bolt-elements-textPrimary',
|
157 |
+
)}
|
158 |
+
>
|
159 |
+
<div className="i-ph:plug-fill" />
|
160 |
+
<span>Disconnect</span>
|
161 |
+
</button>
|
162 |
+
<span className="inline-flex items-center gap-2 px-3 py-1.5 text-sm text-green-600 dark:text-green-400 bg-green-500/5 rounded-lg border border-green-500/20">
|
163 |
+
<div className="i-ph:check-circle-fill" />
|
164 |
+
<span>Connected</span>
|
165 |
+
</span>
|
166 |
+
</>
|
167 |
+
)}
|
168 |
+
</div>
|
169 |
+
{authState.rateLimits && (
|
170 |
+
<div className="flex items-center gap-2 text-sm text-bolt-elements-textTertiary">
|
171 |
+
<div className="i-ph:clock-countdown opacity-60" />
|
172 |
+
<span>Rate limit resets at {authState.rateLimits.reset.toLocaleTimeString()}</span>
|
173 |
+
</div>
|
174 |
+
)}
|
175 |
+
</div>
|
176 |
+
</form>
|
177 |
+
</div>
|
178 |
+
</div>
|
179 |
+
);
|
180 |
+
}
|
app/components/@settings/tabs/connections/components/CreateBranchDialog.tsx
ADDED
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { useState } from 'react';
|
2 |
+
import * as Dialog from '@radix-ui/react-dialog';
|
3 |
+
import { classNames } from '~/utils/classNames';
|
4 |
+
import type { GitHubRepoInfo } from '~/components/@settings/tabs/connections/types/GitHub';
|
5 |
+
import { GitBranch } from '@phosphor-icons/react';
|
6 |
+
|
7 |
+
interface GitHubBranch {
|
8 |
+
name: string;
|
9 |
+
default?: boolean;
|
10 |
+
}
|
11 |
+
|
12 |
+
interface CreateBranchDialogProps {
|
13 |
+
isOpen: boolean;
|
14 |
+
onClose: () => void;
|
15 |
+
onConfirm: (branchName: string, sourceBranch: string) => void;
|
16 |
+
repository: GitHubRepoInfo;
|
17 |
+
branches?: GitHubBranch[];
|
18 |
+
}
|
19 |
+
|
20 |
+
export function CreateBranchDialog({ isOpen, onClose, onConfirm, repository, branches }: CreateBranchDialogProps) {
|
21 |
+
const [branchName, setBranchName] = useState('');
|
22 |
+
const [sourceBranch, setSourceBranch] = useState(branches?.find((b) => b.default)?.name || 'main');
|
23 |
+
|
24 |
+
const handleSubmit = (e: React.FormEvent) => {
|
25 |
+
e.preventDefault();
|
26 |
+
onConfirm(branchName, sourceBranch);
|
27 |
+
setBranchName('');
|
28 |
+
onClose();
|
29 |
+
};
|
30 |
+
|
31 |
+
return (
|
32 |
+
<Dialog.Root open={isOpen} onOpenChange={onClose}>
|
33 |
+
<Dialog.Portal>
|
34 |
+
<Dialog.Overlay className="fixed inset-0 bg-black/50 dark:bg-black/80" />
|
35 |
+
<Dialog.Content
|
36 |
+
className={classNames(
|
37 |
+
'fixed top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%]',
|
38 |
+
'w-full max-w-md p-6 rounded-xl shadow-lg',
|
39 |
+
'bg-white dark:bg-[#0A0A0A]',
|
40 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
41 |
+
)}
|
42 |
+
>
|
43 |
+
<Dialog.Title className="text-lg font-medium text-bolt-elements-textPrimary mb-4">
|
44 |
+
Create New Branch
|
45 |
+
</Dialog.Title>
|
46 |
+
|
47 |
+
<form onSubmit={handleSubmit}>
|
48 |
+
<div className="space-y-4">
|
49 |
+
<div>
|
50 |
+
<label htmlFor="branchName" className="block text-sm font-medium text-bolt-elements-textSecondary mb-2">
|
51 |
+
Branch Name
|
52 |
+
</label>
|
53 |
+
<input
|
54 |
+
id="branchName"
|
55 |
+
type="text"
|
56 |
+
value={branchName}
|
57 |
+
onChange={(e) => setBranchName(e.target.value)}
|
58 |
+
placeholder="feature/my-new-branch"
|
59 |
+
className={classNames(
|
60 |
+
'w-full px-3 py-2 rounded-lg',
|
61 |
+
'bg-[#F5F5F5] dark:bg-[#1A1A1A]',
|
62 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
63 |
+
'text-bolt-elements-textPrimary placeholder:text-bolt-elements-textTertiary',
|
64 |
+
'focus:outline-none focus:ring-2 focus:ring-purple-500/50',
|
65 |
+
)}
|
66 |
+
required
|
67 |
+
/>
|
68 |
+
</div>
|
69 |
+
|
70 |
+
<div>
|
71 |
+
<label
|
72 |
+
htmlFor="sourceBranch"
|
73 |
+
className="block text-sm font-medium text-bolt-elements-textSecondary mb-2"
|
74 |
+
>
|
75 |
+
Source Branch
|
76 |
+
</label>
|
77 |
+
<select
|
78 |
+
id="sourceBranch"
|
79 |
+
value={sourceBranch}
|
80 |
+
onChange={(e) => setSourceBranch(e.target.value)}
|
81 |
+
className={classNames(
|
82 |
+
'w-full px-3 py-2 rounded-lg',
|
83 |
+
'bg-[#F5F5F5] dark:bg-[#1A1A1A]',
|
84 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
85 |
+
'text-bolt-elements-textPrimary',
|
86 |
+
'focus:outline-none focus:ring-2 focus:ring-purple-500/50',
|
87 |
+
)}
|
88 |
+
>
|
89 |
+
{branches?.map((branch) => (
|
90 |
+
<option key={branch.name} value={branch.name}>
|
91 |
+
{branch.name} {branch.default ? '(default)' : ''}
|
92 |
+
</option>
|
93 |
+
))}
|
94 |
+
</select>
|
95 |
+
</div>
|
96 |
+
|
97 |
+
<div className="mt-4 p-3 bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-lg">
|
98 |
+
<h4 className="text-sm font-medium text-bolt-elements-textSecondary mb-2">Branch Overview</h4>
|
99 |
+
<ul className="space-y-2 text-sm text-bolt-elements-textSecondary">
|
100 |
+
<li className="flex items-center gap-2">
|
101 |
+
<GitBranch className="text-lg" />
|
102 |
+
Repository: {repository.name}
|
103 |
+
</li>
|
104 |
+
{branchName && (
|
105 |
+
<li className="flex items-center gap-2">
|
106 |
+
<div className="i-ph:check-circle text-green-500" />
|
107 |
+
New branch will be created as: {branchName}
|
108 |
+
</li>
|
109 |
+
)}
|
110 |
+
<li className="flex items-center gap-2">
|
111 |
+
<div className="i-ph:check-circle text-green-500" />
|
112 |
+
Based on: {sourceBranch}
|
113 |
+
</li>
|
114 |
+
</ul>
|
115 |
+
</div>
|
116 |
+
</div>
|
117 |
+
|
118 |
+
<div className="mt-6 flex justify-end gap-3">
|
119 |
+
<button
|
120 |
+
type="button"
|
121 |
+
onClick={onClose}
|
122 |
+
className={classNames(
|
123 |
+
'px-4 py-2 rounded-lg text-sm font-medium',
|
124 |
+
'text-bolt-elements-textPrimary',
|
125 |
+
'bg-[#F5F5F5] dark:bg-[#1A1A1A]',
|
126 |
+
'hover:bg-purple-500/10 hover:text-purple-500',
|
127 |
+
'dark:hover:bg-purple-500/20 dark:hover:text-purple-500',
|
128 |
+
'transition-colors',
|
129 |
+
)}
|
130 |
+
>
|
131 |
+
Cancel
|
132 |
+
</button>
|
133 |
+
<button
|
134 |
+
type="submit"
|
135 |
+
className={classNames(
|
136 |
+
'px-4 py-2 rounded-lg text-sm font-medium',
|
137 |
+
'text-white bg-purple-500',
|
138 |
+
'hover:bg-purple-600',
|
139 |
+
'transition-colors',
|
140 |
+
)}
|
141 |
+
>
|
142 |
+
Create Branch
|
143 |
+
</button>
|
144 |
+
</div>
|
145 |
+
</form>
|
146 |
+
</Dialog.Content>
|
147 |
+
</Dialog.Portal>
|
148 |
+
</Dialog.Root>
|
149 |
+
);
|
150 |
+
}
|
app/components/@settings/tabs/connections/components/PushToGitHubDialog.tsx
ADDED
@@ -0,0 +1,528 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import * as Dialog from '@radix-ui/react-dialog';
|
2 |
+
import { useState, useEffect } from 'react';
|
3 |
+
import { toast } from 'react-toastify';
|
4 |
+
import { motion } from 'framer-motion';
|
5 |
+
import { getLocalStorage } from '~/lib/persistence';
|
6 |
+
import { classNames } from '~/utils/classNames';
|
7 |
+
import type { GitHubUserResponse } from '~/types/GitHub';
|
8 |
+
import { logStore } from '~/lib/stores/logs';
|
9 |
+
import { workbenchStore } from '~/lib/stores/workbench';
|
10 |
+
import { extractRelativePath } from '~/utils/diff';
|
11 |
+
import { formatSize } from '~/utils/formatSize';
|
12 |
+
import type { FileMap, File } from '~/lib/stores/files';
|
13 |
+
import { Octokit } from '@octokit/rest';
|
14 |
+
|
15 |
+
interface PushToGitHubDialogProps {
|
16 |
+
isOpen: boolean;
|
17 |
+
onClose: () => void;
|
18 |
+
onPush: (repoName: string, username?: string, token?: string, isPrivate?: boolean) => Promise<string>;
|
19 |
+
}
|
20 |
+
|
21 |
+
interface GitHubRepo {
|
22 |
+
name: string;
|
23 |
+
full_name: string;
|
24 |
+
html_url: string;
|
25 |
+
description: string;
|
26 |
+
stargazers_count: number;
|
27 |
+
forks_count: number;
|
28 |
+
default_branch: string;
|
29 |
+
updated_at: string;
|
30 |
+
language: string;
|
31 |
+
private: boolean;
|
32 |
+
}
|
33 |
+
|
34 |
+
export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDialogProps) {
|
35 |
+
const [repoName, setRepoName] = useState('');
|
36 |
+
const [isPrivate, setIsPrivate] = useState(false);
|
37 |
+
const [isLoading, setIsLoading] = useState(false);
|
38 |
+
const [user, setUser] = useState<GitHubUserResponse | null>(null);
|
39 |
+
const [recentRepos, setRecentRepos] = useState<GitHubRepo[]>([]);
|
40 |
+
const [isFetchingRepos, setIsFetchingRepos] = useState(false);
|
41 |
+
const [showSuccessDialog, setShowSuccessDialog] = useState(false);
|
42 |
+
const [createdRepoUrl, setCreatedRepoUrl] = useState('');
|
43 |
+
const [pushedFiles, setPushedFiles] = useState<{ path: string; size: number }[]>([]);
|
44 |
+
|
45 |
+
// Load GitHub connection on mount
|
46 |
+
useEffect(() => {
|
47 |
+
if (isOpen) {
|
48 |
+
const connection = getLocalStorage('github_connection');
|
49 |
+
|
50 |
+
if (connection?.user && connection?.token) {
|
51 |
+
setUser(connection.user);
|
52 |
+
|
53 |
+
// Only fetch if we have both user and token
|
54 |
+
if (connection.token.trim()) {
|
55 |
+
fetchRecentRepos(connection.token);
|
56 |
+
}
|
57 |
+
}
|
58 |
+
}
|
59 |
+
}, [isOpen]);
|
60 |
+
|
61 |
+
const fetchRecentRepos = async (token: string) => {
|
62 |
+
if (!token) {
|
63 |
+
logStore.logError('No GitHub token available');
|
64 |
+
toast.error('GitHub authentication required');
|
65 |
+
|
66 |
+
return;
|
67 |
+
}
|
68 |
+
|
69 |
+
try {
|
70 |
+
setIsFetchingRepos(true);
|
71 |
+
|
72 |
+
const response = await fetch(
|
73 |
+
'https://api.github.com/user/repos?sort=updated&per_page=5&type=all&affiliation=owner,organization_member',
|
74 |
+
{
|
75 |
+
headers: {
|
76 |
+
Accept: 'application/vnd.github.v3+json',
|
77 |
+
Authorization: `Bearer ${token.trim()}`,
|
78 |
+
},
|
79 |
+
},
|
80 |
+
);
|
81 |
+
|
82 |
+
if (!response.ok) {
|
83 |
+
const errorData = await response.json().catch(() => ({}));
|
84 |
+
|
85 |
+
if (response.status === 401) {
|
86 |
+
toast.error('GitHub token expired. Please reconnect your account.');
|
87 |
+
|
88 |
+
// Clear invalid token
|
89 |
+
const connection = getLocalStorage('github_connection');
|
90 |
+
|
91 |
+
if (connection) {
|
92 |
+
localStorage.removeItem('github_connection');
|
93 |
+
setUser(null);
|
94 |
+
}
|
95 |
+
} else {
|
96 |
+
logStore.logError('Failed to fetch GitHub repositories', {
|
97 |
+
status: response.status,
|
98 |
+
statusText: response.statusText,
|
99 |
+
error: errorData,
|
100 |
+
});
|
101 |
+
toast.error(`Failed to fetch repositories: ${response.statusText}`);
|
102 |
+
}
|
103 |
+
|
104 |
+
return;
|
105 |
+
}
|
106 |
+
|
107 |
+
const repos = (await response.json()) as GitHubRepo[];
|
108 |
+
setRecentRepos(repos);
|
109 |
+
} catch (error) {
|
110 |
+
logStore.logError('Failed to fetch GitHub repositories', { error });
|
111 |
+
toast.error('Failed to fetch recent repositories');
|
112 |
+
} finally {
|
113 |
+
setIsFetchingRepos(false);
|
114 |
+
}
|
115 |
+
};
|
116 |
+
|
117 |
+
const handleSubmit = async (e: React.FormEvent) => {
|
118 |
+
e.preventDefault();
|
119 |
+
|
120 |
+
const connection = getLocalStorage('github_connection');
|
121 |
+
|
122 |
+
if (!connection?.token || !connection?.user) {
|
123 |
+
toast.error('Please connect your GitHub account in Settings > Connections first');
|
124 |
+
return;
|
125 |
+
}
|
126 |
+
|
127 |
+
if (!repoName.trim()) {
|
128 |
+
toast.error('Repository name is required');
|
129 |
+
return;
|
130 |
+
}
|
131 |
+
|
132 |
+
setIsLoading(true);
|
133 |
+
|
134 |
+
try {
|
135 |
+
// Check if repository exists first
|
136 |
+
const octokit = new Octokit({ auth: connection.token });
|
137 |
+
|
138 |
+
try {
|
139 |
+
await octokit.repos.get({
|
140 |
+
owner: connection.user.login,
|
141 |
+
repo: repoName,
|
142 |
+
});
|
143 |
+
|
144 |
+
// If we get here, the repo exists
|
145 |
+
const confirmOverwrite = window.confirm(
|
146 |
+
`Repository "${repoName}" already exists. Do you want to update it? This will add or modify files in the repository.`,
|
147 |
+
);
|
148 |
+
|
149 |
+
if (!confirmOverwrite) {
|
150 |
+
setIsLoading(false);
|
151 |
+
return;
|
152 |
+
}
|
153 |
+
} catch (error) {
|
154 |
+
// 404 means repo doesn't exist, which is what we want for new repos
|
155 |
+
if (error instanceof Error && 'status' in error && error.status !== 404) {
|
156 |
+
throw error;
|
157 |
+
}
|
158 |
+
}
|
159 |
+
|
160 |
+
const repoUrl = await onPush(repoName, connection.user.login, connection.token, isPrivate);
|
161 |
+
setCreatedRepoUrl(repoUrl);
|
162 |
+
|
163 |
+
// Get list of pushed files
|
164 |
+
const files = workbenchStore.files.get();
|
165 |
+
const filesList = Object.entries(files as FileMap)
|
166 |
+
.filter(([, dirent]) => dirent?.type === 'file' && !dirent.isBinary)
|
167 |
+
.map(([path, dirent]) => ({
|
168 |
+
path: extractRelativePath(path),
|
169 |
+
size: new TextEncoder().encode((dirent as File).content || '').length,
|
170 |
+
}));
|
171 |
+
|
172 |
+
setPushedFiles(filesList);
|
173 |
+
setShowSuccessDialog(true);
|
174 |
+
} catch (error) {
|
175 |
+
console.error('Error pushing to GitHub:', error);
|
176 |
+
toast.error('Failed to push to GitHub. Please check your repository name and try again.');
|
177 |
+
} finally {
|
178 |
+
setIsLoading(false);
|
179 |
+
}
|
180 |
+
};
|
181 |
+
|
182 |
+
const handleClose = () => {
|
183 |
+
setRepoName('');
|
184 |
+
setIsPrivate(false);
|
185 |
+
setShowSuccessDialog(false);
|
186 |
+
setCreatedRepoUrl('');
|
187 |
+
onClose();
|
188 |
+
};
|
189 |
+
|
190 |
+
// Success Dialog
|
191 |
+
if (showSuccessDialog) {
|
192 |
+
return (
|
193 |
+
<Dialog.Root open={isOpen} onOpenChange={(open) => !open && handleClose()}>
|
194 |
+
<Dialog.Portal>
|
195 |
+
<Dialog.Overlay className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[9999]" />
|
196 |
+
<div className="fixed inset-0 flex items-center justify-center z-[9999]">
|
197 |
+
<motion.div
|
198 |
+
initial={{ opacity: 0, scale: 0.95 }}
|
199 |
+
animate={{ opacity: 1, scale: 1 }}
|
200 |
+
exit={{ opacity: 0, scale: 0.95 }}
|
201 |
+
transition={{ duration: 0.2 }}
|
202 |
+
className="w-[90vw] md:w-[600px] max-h-[85vh] overflow-y-auto"
|
203 |
+
>
|
204 |
+
<Dialog.Content className="bg-white dark:bg-[#1E1E1E] rounded-lg border border-[#E5E5E5] dark:border-[#333333] shadow-xl">
|
205 |
+
<div className="p-6 space-y-4">
|
206 |
+
<div className="flex items-center justify-between">
|
207 |
+
<div className="flex items-center gap-2 text-green-500">
|
208 |
+
<div className="i-ph:check-circle w-5 h-5" />
|
209 |
+
<h3 className="text-lg font-medium">Successfully pushed to GitHub</h3>
|
210 |
+
</div>
|
211 |
+
<Dialog.Close
|
212 |
+
onClick={handleClose}
|
213 |
+
className="p-2 text-gray-400 hover:text-gray-500 dark:text-gray-500 dark:hover:text-gray-400"
|
214 |
+
>
|
215 |
+
<div className="i-ph:x w-5 h-5" />
|
216 |
+
</Dialog.Close>
|
217 |
+
</div>
|
218 |
+
|
219 |
+
<div className="bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 rounded-lg p-3 text-left">
|
220 |
+
<p className="text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark mb-2">
|
221 |
+
Repository URL
|
222 |
+
</p>
|
223 |
+
<div className="flex items-center gap-2">
|
224 |
+
<code className="flex-1 text-sm bg-bolt-elements-background dark:bg-bolt-elements-background-dark px-3 py-2 rounded border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark font-mono">
|
225 |
+
{createdRepoUrl}
|
226 |
+
</code>
|
227 |
+
<motion.button
|
228 |
+
onClick={() => {
|
229 |
+
navigator.clipboard.writeText(createdRepoUrl);
|
230 |
+
toast.success('URL copied to clipboard');
|
231 |
+
}}
|
232 |
+
className="p-2 text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary dark:text-bolt-elements-textSecondary-dark dark:hover:text-bolt-elements-textPrimary-dark"
|
233 |
+
whileHover={{ scale: 1.1 }}
|
234 |
+
whileTap={{ scale: 0.9 }}
|
235 |
+
>
|
236 |
+
<div className="i-ph:copy w-4 h-4" />
|
237 |
+
</motion.button>
|
238 |
+
</div>
|
239 |
+
</div>
|
240 |
+
|
241 |
+
<div className="bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 rounded-lg p-3">
|
242 |
+
<p className="text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark mb-2">
|
243 |
+
Pushed Files ({pushedFiles.length})
|
244 |
+
</p>
|
245 |
+
<div className="max-h-[200px] overflow-y-auto custom-scrollbar">
|
246 |
+
{pushedFiles.map((file) => (
|
247 |
+
<div
|
248 |
+
key={file.path}
|
249 |
+
className="flex items-center justify-between py-1 text-sm text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark"
|
250 |
+
>
|
251 |
+
<span className="font-mono truncate flex-1">{file.path}</span>
|
252 |
+
<span className="text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark ml-2">
|
253 |
+
{formatSize(file.size)}
|
254 |
+
</span>
|
255 |
+
</div>
|
256 |
+
))}
|
257 |
+
</div>
|
258 |
+
</div>
|
259 |
+
|
260 |
+
<div className="flex justify-end gap-2 pt-2">
|
261 |
+
<motion.a
|
262 |
+
href={createdRepoUrl}
|
263 |
+
target="_blank"
|
264 |
+
rel="noopener noreferrer"
|
265 |
+
className="px-4 py-2 rounded-lg bg-purple-500 text-white hover:bg-purple-600 text-sm inline-flex items-center gap-2"
|
266 |
+
whileHover={{ scale: 1.02 }}
|
267 |
+
whileTap={{ scale: 0.98 }}
|
268 |
+
>
|
269 |
+
<div className="i-ph:github-logo w-4 h-4" />
|
270 |
+
View Repository
|
271 |
+
</motion.a>
|
272 |
+
<motion.button
|
273 |
+
onClick={() => {
|
274 |
+
navigator.clipboard.writeText(createdRepoUrl);
|
275 |
+
toast.success('URL copied to clipboard');
|
276 |
+
}}
|
277 |
+
className="px-4 py-2 rounded-lg bg-[#F5F5F5] dark:bg-[#1A1A1A] text-gray-600 dark:text-gray-400 hover:bg-[#E5E5E5] dark:hover:bg-[#252525] text-sm inline-flex items-center gap-2"
|
278 |
+
whileHover={{ scale: 1.02 }}
|
279 |
+
whileTap={{ scale: 0.98 }}
|
280 |
+
>
|
281 |
+
<div className="i-ph:copy w-4 h-4" />
|
282 |
+
Copy URL
|
283 |
+
</motion.button>
|
284 |
+
<motion.button
|
285 |
+
onClick={handleClose}
|
286 |
+
className="px-4 py-2 rounded-lg bg-[#F5F5F5] dark:bg-[#1A1A1A] text-gray-600 dark:text-gray-400 hover:bg-[#E5E5E5] dark:hover:bg-[#252525] text-sm"
|
287 |
+
whileHover={{ scale: 1.02 }}
|
288 |
+
whileTap={{ scale: 0.98 }}
|
289 |
+
>
|
290 |
+
Close
|
291 |
+
</motion.button>
|
292 |
+
</div>
|
293 |
+
</div>
|
294 |
+
</Dialog.Content>
|
295 |
+
</motion.div>
|
296 |
+
</div>
|
297 |
+
</Dialog.Portal>
|
298 |
+
</Dialog.Root>
|
299 |
+
);
|
300 |
+
}
|
301 |
+
|
302 |
+
if (!user) {
|
303 |
+
return (
|
304 |
+
<Dialog.Root open={isOpen} onOpenChange={(open) => !open && handleClose()}>
|
305 |
+
<Dialog.Portal>
|
306 |
+
<Dialog.Overlay className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[9999]" />
|
307 |
+
<div className="fixed inset-0 flex items-center justify-center z-[9999]">
|
308 |
+
<motion.div
|
309 |
+
initial={{ opacity: 0, scale: 0.95 }}
|
310 |
+
animate={{ opacity: 1, scale: 1 }}
|
311 |
+
exit={{ opacity: 0, scale: 0.95 }}
|
312 |
+
transition={{ duration: 0.2 }}
|
313 |
+
className="w-[90vw] md:w-[500px]"
|
314 |
+
>
|
315 |
+
<Dialog.Content className="bg-white dark:bg-[#0A0A0A] rounded-lg p-6 border border-[#E5E5E5] dark:border-[#1A1A1A] shadow-xl">
|
316 |
+
<div className="text-center space-y-4">
|
317 |
+
<motion.div
|
318 |
+
initial={{ scale: 0.8 }}
|
319 |
+
animate={{ scale: 1 }}
|
320 |
+
transition={{ delay: 0.1 }}
|
321 |
+
className="mx-auto w-12 h-12 rounded-xl bg-bolt-elements-background-depth-3 flex items-center justify-center text-purple-500"
|
322 |
+
>
|
323 |
+
<div className="i-ph:github-logo w-6 h-6" />
|
324 |
+
</motion.div>
|
325 |
+
<h3 className="text-lg font-medium text-gray-900 dark:text-white">GitHub Connection Required</h3>
|
326 |
+
<p className="text-sm text-gray-600 dark:text-gray-400">
|
327 |
+
Please connect your GitHub account in Settings {'>'} Connections to push your code to GitHub.
|
328 |
+
</p>
|
329 |
+
<motion.button
|
330 |
+
className="px-4 py-2 rounded-lg bg-purple-500 text-white text-sm hover:bg-purple-600 inline-flex items-center gap-2"
|
331 |
+
whileHover={{ scale: 1.02 }}
|
332 |
+
whileTap={{ scale: 0.98 }}
|
333 |
+
onClick={handleClose}
|
334 |
+
>
|
335 |
+
<div className="i-ph:x-circle" />
|
336 |
+
Close
|
337 |
+
</motion.button>
|
338 |
+
</div>
|
339 |
+
</Dialog.Content>
|
340 |
+
</motion.div>
|
341 |
+
</div>
|
342 |
+
</Dialog.Portal>
|
343 |
+
</Dialog.Root>
|
344 |
+
);
|
345 |
+
}
|
346 |
+
|
347 |
+
return (
|
348 |
+
<Dialog.Root open={isOpen} onOpenChange={(open) => !open && handleClose()}>
|
349 |
+
<Dialog.Portal>
|
350 |
+
<Dialog.Overlay className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[9999]" />
|
351 |
+
<div className="fixed inset-0 flex items-center justify-center z-[9999]">
|
352 |
+
<motion.div
|
353 |
+
initial={{ opacity: 0, scale: 0.95 }}
|
354 |
+
animate={{ opacity: 1, scale: 1 }}
|
355 |
+
exit={{ opacity: 0, scale: 0.95 }}
|
356 |
+
transition={{ duration: 0.2 }}
|
357 |
+
className="w-[90vw] md:w-[500px]"
|
358 |
+
>
|
359 |
+
<Dialog.Content className="bg-white dark:bg-[#0A0A0A] rounded-lg border border-[#E5E5E5] dark:border-[#1A1A1A] shadow-xl">
|
360 |
+
<div className="p-6">
|
361 |
+
<div className="flex items-center gap-4 mb-6">
|
362 |
+
<motion.div
|
363 |
+
initial={{ scale: 0.8 }}
|
364 |
+
animate={{ scale: 1 }}
|
365 |
+
transition={{ delay: 0.1 }}
|
366 |
+
className="w-10 h-10 rounded-xl bg-bolt-elements-background-depth-3 flex items-center justify-center text-purple-500"
|
367 |
+
>
|
368 |
+
<div className="i-ph:git-branch w-5 h-5" />
|
369 |
+
</motion.div>
|
370 |
+
<div>
|
371 |
+
<Dialog.Title className="text-lg font-medium text-gray-900 dark:text-white">
|
372 |
+
Push to GitHub
|
373 |
+
</Dialog.Title>
|
374 |
+
<p className="text-sm text-gray-600 dark:text-gray-400">
|
375 |
+
Push your code to a new or existing GitHub repository
|
376 |
+
</p>
|
377 |
+
</div>
|
378 |
+
<Dialog.Close
|
379 |
+
className="ml-auto p-2 text-gray-400 hover:text-gray-500 dark:text-gray-500 dark:hover:text-gray-400"
|
380 |
+
onClick={handleClose}
|
381 |
+
>
|
382 |
+
<div className="i-ph:x w-5 h-5" />
|
383 |
+
</Dialog.Close>
|
384 |
+
</div>
|
385 |
+
|
386 |
+
<div className="flex items-center gap-3 mb-6 p-3 bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 rounded-lg">
|
387 |
+
<img src={user.avatar_url} alt={user.login} className="w-10 h-10 rounded-full" />
|
388 |
+
<div>
|
389 |
+
<p className="text-sm font-medium text-gray-900 dark:text-white">{user.name || user.login}</p>
|
390 |
+
<p className="text-sm text-gray-500 dark:text-gray-400">@{user.login}</p>
|
391 |
+
</div>
|
392 |
+
</div>
|
393 |
+
|
394 |
+
<form onSubmit={handleSubmit} className="space-y-4">
|
395 |
+
<div className="space-y-2">
|
396 |
+
<label htmlFor="repoName" className="text-sm text-gray-600 dark:text-gray-400">
|
397 |
+
Repository Name
|
398 |
+
</label>
|
399 |
+
<input
|
400 |
+
id="repoName"
|
401 |
+
type="text"
|
402 |
+
value={repoName}
|
403 |
+
onChange={(e) => setRepoName(e.target.value)}
|
404 |
+
placeholder="my-awesome-project"
|
405 |
+
className="w-full px-4 py-2 rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 border border-[#E5E5E5] dark:border-[#1A1A1A] text-gray-900 dark:text-white placeholder-gray-400"
|
406 |
+
required
|
407 |
+
/>
|
408 |
+
</div>
|
409 |
+
|
410 |
+
{recentRepos.length > 0 && (
|
411 |
+
<div className="space-y-2">
|
412 |
+
<label className="text-sm text-gray-600 dark:text-gray-400">Recent Repositories</label>
|
413 |
+
<div className="space-y-2">
|
414 |
+
{recentRepos.map((repo) => (
|
415 |
+
<motion.button
|
416 |
+
key={repo.full_name}
|
417 |
+
type="button"
|
418 |
+
onClick={() => setRepoName(repo.name)}
|
419 |
+
className="w-full p-3 text-left rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 hover:bg-bolt-elements-background-depth-3 dark:hover:bg-bolt-elements-background-depth-4 transition-colors group"
|
420 |
+
whileHover={{ scale: 1.01 }}
|
421 |
+
whileTap={{ scale: 0.99 }}
|
422 |
+
>
|
423 |
+
<div className="flex items-center justify-between">
|
424 |
+
<div className="flex items-center gap-2">
|
425 |
+
<div className="i-ph:git-repository w-4 h-4 text-purple-500" />
|
426 |
+
<span className="text-sm font-medium text-gray-900 dark:text-white group-hover:text-purple-500">
|
427 |
+
{repo.name}
|
428 |
+
</span>
|
429 |
+
</div>
|
430 |
+
{repo.private && (
|
431 |
+
<span className="text-xs px-2 py-1 rounded-full bg-purple-500/10 text-purple-500">
|
432 |
+
Private
|
433 |
+
</span>
|
434 |
+
)}
|
435 |
+
</div>
|
436 |
+
{repo.description && (
|
437 |
+
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400 line-clamp-2">
|
438 |
+
{repo.description}
|
439 |
+
</p>
|
440 |
+
)}
|
441 |
+
<div className="mt-2 flex items-center gap-3 text-xs text-gray-400 dark:text-gray-500">
|
442 |
+
{repo.language && (
|
443 |
+
<span className="flex items-center gap-1">
|
444 |
+
<div className="i-ph:code w-3 h-3" />
|
445 |
+
{repo.language}
|
446 |
+
</span>
|
447 |
+
)}
|
448 |
+
<span className="flex items-center gap-1">
|
449 |
+
<div className="i-ph:star w-3 h-3" />
|
450 |
+
{repo.stargazers_count.toLocaleString()}
|
451 |
+
</span>
|
452 |
+
<span className="flex items-center gap-1">
|
453 |
+
<div className="i-ph:git-fork w-3 h-3" />
|
454 |
+
{repo.forks_count.toLocaleString()}
|
455 |
+
</span>
|
456 |
+
<span className="flex items-center gap-1">
|
457 |
+
<div className="i-ph:clock w-3 h-3" />
|
458 |
+
{new Date(repo.updated_at).toLocaleDateString()}
|
459 |
+
</span>
|
460 |
+
</div>
|
461 |
+
</motion.button>
|
462 |
+
))}
|
463 |
+
</div>
|
464 |
+
</div>
|
465 |
+
)}
|
466 |
+
|
467 |
+
{isFetchingRepos && (
|
468 |
+
<div className="flex items-center justify-center py-4 text-gray-500 dark:text-gray-400">
|
469 |
+
<div className="i-ph:spinner-gap-bold animate-spin w-4 h-4 mr-2" />
|
470 |
+
Loading repositories...
|
471 |
+
</div>
|
472 |
+
)}
|
473 |
+
|
474 |
+
<div className="flex items-center gap-2">
|
475 |
+
<input
|
476 |
+
type="checkbox"
|
477 |
+
id="private"
|
478 |
+
checked={isPrivate}
|
479 |
+
onChange={(e) => setIsPrivate(e.target.checked)}
|
480 |
+
className="rounded border-[#E5E5E5] dark:border-[#1A1A1A] text-purple-500 focus:ring-purple-500 dark:bg-[#0A0A0A]"
|
481 |
+
/>
|
482 |
+
<label htmlFor="private" className="text-sm text-gray-600 dark:text-gray-400">
|
483 |
+
Make repository private
|
484 |
+
</label>
|
485 |
+
</div>
|
486 |
+
|
487 |
+
<div className="pt-4 flex gap-2">
|
488 |
+
<motion.button
|
489 |
+
type="button"
|
490 |
+
onClick={handleClose}
|
491 |
+
className="px-4 py-2 rounded-lg bg-[#F5F5F5] dark:bg-[#1A1A1A] text-gray-600 dark:text-gray-400 hover:bg-[#E5E5E5] dark:hover:bg-[#252525] text-sm"
|
492 |
+
whileHover={{ scale: 1.02 }}
|
493 |
+
whileTap={{ scale: 0.98 }}
|
494 |
+
>
|
495 |
+
Cancel
|
496 |
+
</motion.button>
|
497 |
+
<motion.button
|
498 |
+
type="submit"
|
499 |
+
disabled={isLoading}
|
500 |
+
className={classNames(
|
501 |
+
'flex-1 px-4 py-2 bg-purple-500 text-white rounded-lg hover:bg-purple-600 text-sm inline-flex items-center justify-center gap-2',
|
502 |
+
isLoading ? 'opacity-50 cursor-not-allowed' : '',
|
503 |
+
)}
|
504 |
+
whileHover={!isLoading ? { scale: 1.02 } : {}}
|
505 |
+
whileTap={!isLoading ? { scale: 0.98 } : {}}
|
506 |
+
>
|
507 |
+
{isLoading ? (
|
508 |
+
<>
|
509 |
+
<div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
|
510 |
+
Pushing...
|
511 |
+
</>
|
512 |
+
) : (
|
513 |
+
<>
|
514 |
+
<div className="i-ph:git-branch w-4 h-4" />
|
515 |
+
Push to GitHub
|
516 |
+
</>
|
517 |
+
)}
|
518 |
+
</motion.button>
|
519 |
+
</div>
|
520 |
+
</form>
|
521 |
+
</div>
|
522 |
+
</Dialog.Content>
|
523 |
+
</motion.div>
|
524 |
+
</div>
|
525 |
+
</Dialog.Portal>
|
526 |
+
</Dialog.Root>
|
527 |
+
);
|
528 |
+
}
|
app/components/@settings/tabs/connections/components/RepositorySelectionDialog.tsx
ADDED
@@ -0,0 +1,693 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import type { GitHubRepoInfo, GitHubContent, RepositoryStats } from '~/types/GitHub';
|
2 |
+
import { useState, useEffect } from 'react';
|
3 |
+
import { toast } from 'react-toastify';
|
4 |
+
import * as Dialog from '@radix-ui/react-dialog';
|
5 |
+
import { classNames } from '~/utils/classNames';
|
6 |
+
import { getLocalStorage } from '~/lib/persistence';
|
7 |
+
import { motion } from 'framer-motion';
|
8 |
+
import { formatSize } from '~/utils/formatSize';
|
9 |
+
import { Input } from '~/components/ui/Input';
|
10 |
+
|
11 |
+
interface GitHubTreeResponse {
|
12 |
+
tree: Array<{
|
13 |
+
path: string;
|
14 |
+
type: string;
|
15 |
+
size?: number;
|
16 |
+
}>;
|
17 |
+
}
|
18 |
+
|
19 |
+
interface RepositorySelectionDialogProps {
|
20 |
+
isOpen: boolean;
|
21 |
+
onClose: () => void;
|
22 |
+
onSelect: (url: string) => void;
|
23 |
+
}
|
24 |
+
|
25 |
+
interface SearchFilters {
|
26 |
+
language?: string;
|
27 |
+
stars?: number;
|
28 |
+
forks?: number;
|
29 |
+
}
|
30 |
+
|
31 |
+
interface StatsDialogProps {
|
32 |
+
isOpen: boolean;
|
33 |
+
onClose: () => void;
|
34 |
+
onConfirm: () => void;
|
35 |
+
stats: RepositoryStats;
|
36 |
+
isLargeRepo?: boolean;
|
37 |
+
}
|
38 |
+
|
39 |
+
function StatsDialog({ isOpen, onClose, onConfirm, stats, isLargeRepo }: StatsDialogProps) {
|
40 |
+
return (
|
41 |
+
<Dialog.Root open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
42 |
+
<Dialog.Portal>
|
43 |
+
<Dialog.Overlay className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[9999]" />
|
44 |
+
<div className="fixed inset-0 flex items-center justify-center z-[9999]">
|
45 |
+
<motion.div
|
46 |
+
initial={{ opacity: 0, scale: 0.95 }}
|
47 |
+
animate={{ opacity: 1, scale: 1 }}
|
48 |
+
exit={{ opacity: 0, scale: 0.95 }}
|
49 |
+
transition={{ duration: 0.2 }}
|
50 |
+
className="w-[90vw] md:w-[500px]"
|
51 |
+
>
|
52 |
+
<Dialog.Content className="bg-white dark:bg-[#1E1E1E] rounded-lg border border-[#E5E5E5] dark:border-[#333333] shadow-xl">
|
53 |
+
<div className="p-6 space-y-4">
|
54 |
+
<div>
|
55 |
+
<h3 className="text-lg font-medium text-[#111111] dark:text-white">Repository Overview</h3>
|
56 |
+
<div className="mt-4 space-y-2">
|
57 |
+
<p className="text-sm text-[#666666] dark:text-[#999999]">Repository Statistics:</p>
|
58 |
+
<div className="space-y-2 text-sm text-[#111111] dark:text-white">
|
59 |
+
<div className="flex items-center gap-2">
|
60 |
+
<span className="i-ph:files text-purple-500 w-4 h-4" />
|
61 |
+
<span>Total Files: {stats.totalFiles}</span>
|
62 |
+
</div>
|
63 |
+
<div className="flex items-center gap-2">
|
64 |
+
<span className="i-ph:database text-purple-500 w-4 h-4" />
|
65 |
+
<span>Total Size: {formatSize(stats.totalSize)}</span>
|
66 |
+
</div>
|
67 |
+
<div className="flex items-center gap-2">
|
68 |
+
<span className="i-ph:code text-purple-500 w-4 h-4" />
|
69 |
+
<span>
|
70 |
+
Languages:{' '}
|
71 |
+
{Object.entries(stats.languages)
|
72 |
+
.sort(([, a], [, b]) => b - a)
|
73 |
+
.slice(0, 3)
|
74 |
+
.map(([lang, size]) => `${lang} (${formatSize(size)})`)
|
75 |
+
.join(', ')}
|
76 |
+
</span>
|
77 |
+
</div>
|
78 |
+
{stats.hasPackageJson && (
|
79 |
+
<div className="flex items-center gap-2">
|
80 |
+
<span className="i-ph:package text-purple-500 w-4 h-4" />
|
81 |
+
<span>Has package.json</span>
|
82 |
+
</div>
|
83 |
+
)}
|
84 |
+
{stats.hasDependencies && (
|
85 |
+
<div className="flex items-center gap-2">
|
86 |
+
<span className="i-ph:tree-structure text-purple-500 w-4 h-4" />
|
87 |
+
<span>Has dependencies</span>
|
88 |
+
</div>
|
89 |
+
)}
|
90 |
+
</div>
|
91 |
+
</div>
|
92 |
+
{isLargeRepo && (
|
93 |
+
<div className="mt-4 p-3 bg-yellow-50 dark:bg-yellow-500/10 rounded-lg text-sm flex items-start gap-2">
|
94 |
+
<span className="i-ph:warning text-yellow-600 dark:text-yellow-500 w-4 h-4 flex-shrink-0 mt-0.5" />
|
95 |
+
<div className="text-yellow-800 dark:text-yellow-500">
|
96 |
+
This repository is quite large ({formatSize(stats.totalSize)}). Importing it might take a while
|
97 |
+
and could impact performance.
|
98 |
+
</div>
|
99 |
+
</div>
|
100 |
+
)}
|
101 |
+
</div>
|
102 |
+
</div>
|
103 |
+
<div className="border-t border-[#E5E5E5] dark:border-[#333333] p-4 flex justify-end gap-3 bg-[#F9F9F9] dark:bg-[#252525] rounded-b-lg">
|
104 |
+
<button
|
105 |
+
onClick={onClose}
|
106 |
+
className="px-4 py-2 rounded-lg bg-[#F5F5F5] dark:bg-[#333333] text-[#666666] hover:text-[#111111] dark:text-[#999999] dark:hover:text-white transition-colors"
|
107 |
+
>
|
108 |
+
Cancel
|
109 |
+
</button>
|
110 |
+
<button
|
111 |
+
onClick={onConfirm}
|
112 |
+
className="px-4 py-2 rounded-lg bg-purple-500 text-white hover:bg-purple-600 transition-colors"
|
113 |
+
>
|
114 |
+
OK
|
115 |
+
</button>
|
116 |
+
</div>
|
117 |
+
</Dialog.Content>
|
118 |
+
</motion.div>
|
119 |
+
</div>
|
120 |
+
</Dialog.Portal>
|
121 |
+
</Dialog.Root>
|
122 |
+
);
|
123 |
+
}
|
124 |
+
|
125 |
+
export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: RepositorySelectionDialogProps) {
|
126 |
+
const [selectedRepository, setSelectedRepository] = useState<GitHubRepoInfo | null>(null);
|
127 |
+
const [isLoading, setIsLoading] = useState(false);
|
128 |
+
const [repositories, setRepositories] = useState<GitHubRepoInfo[]>([]);
|
129 |
+
const [searchQuery, setSearchQuery] = useState('');
|
130 |
+
const [searchResults, setSearchResults] = useState<GitHubRepoInfo[]>([]);
|
131 |
+
const [activeTab, setActiveTab] = useState<'my-repos' | 'search' | 'url'>('my-repos');
|
132 |
+
const [customUrl, setCustomUrl] = useState('');
|
133 |
+
const [branches, setBranches] = useState<{ name: string; default?: boolean }[]>([]);
|
134 |
+
const [selectedBranch, setSelectedBranch] = useState('');
|
135 |
+
const [filters, setFilters] = useState<SearchFilters>({});
|
136 |
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
137 |
+
const [stats, setStats] = useState<RepositoryStats | null>(null);
|
138 |
+
const [showStatsDialog, setShowStatsDialog] = useState(false);
|
139 |
+
const [currentStats, setCurrentStats] = useState<RepositoryStats | null>(null);
|
140 |
+
const [pendingGitUrl, setPendingGitUrl] = useState<string>('');
|
141 |
+
|
142 |
+
// Fetch user's repositories when dialog opens
|
143 |
+
useEffect(() => {
|
144 |
+
if (isOpen && activeTab === 'my-repos') {
|
145 |
+
fetchUserRepos();
|
146 |
+
}
|
147 |
+
}, [isOpen, activeTab]);
|
148 |
+
|
149 |
+
const fetchUserRepos = async () => {
|
150 |
+
const connection = getLocalStorage('github_connection');
|
151 |
+
|
152 |
+
if (!connection?.token) {
|
153 |
+
toast.error('Please connect your GitHub account first');
|
154 |
+
return;
|
155 |
+
}
|
156 |
+
|
157 |
+
setIsLoading(true);
|
158 |
+
|
159 |
+
try {
|
160 |
+
const response = await fetch('https://api.github.com/user/repos?sort=updated&per_page=100&type=all', {
|
161 |
+
headers: {
|
162 |
+
Authorization: `Bearer ${connection.token}`,
|
163 |
+
},
|
164 |
+
});
|
165 |
+
|
166 |
+
if (!response.ok) {
|
167 |
+
throw new Error('Failed to fetch repositories');
|
168 |
+
}
|
169 |
+
|
170 |
+
const data = await response.json();
|
171 |
+
|
172 |
+
// Add type assertion and validation
|
173 |
+
if (
|
174 |
+
Array.isArray(data) &&
|
175 |
+
data.every((item) => typeof item === 'object' && item !== null && 'full_name' in item)
|
176 |
+
) {
|
177 |
+
setRepositories(data as GitHubRepoInfo[]);
|
178 |
+
} else {
|
179 |
+
throw new Error('Invalid repository data format');
|
180 |
+
}
|
181 |
+
} catch (error) {
|
182 |
+
console.error('Error fetching repos:', error);
|
183 |
+
toast.error('Failed to fetch your repositories');
|
184 |
+
} finally {
|
185 |
+
setIsLoading(false);
|
186 |
+
}
|
187 |
+
};
|
188 |
+
|
189 |
+
const handleSearch = async (query: string) => {
|
190 |
+
setIsLoading(true);
|
191 |
+
setSearchResults([]);
|
192 |
+
|
193 |
+
try {
|
194 |
+
let searchQuery = query;
|
195 |
+
|
196 |
+
if (filters.language) {
|
197 |
+
searchQuery += ` language:${filters.language}`;
|
198 |
+
}
|
199 |
+
|
200 |
+
if (filters.stars) {
|
201 |
+
searchQuery += ` stars:>${filters.stars}`;
|
202 |
+
}
|
203 |
+
|
204 |
+
if (filters.forks) {
|
205 |
+
searchQuery += ` forks:>${filters.forks}`;
|
206 |
+
}
|
207 |
+
|
208 |
+
const response = await fetch(
|
209 |
+
`https://api.github.com/search/repositories?q=${encodeURIComponent(searchQuery)}&sort=stars&order=desc`,
|
210 |
+
{
|
211 |
+
headers: {
|
212 |
+
Accept: 'application/vnd.github.v3+json',
|
213 |
+
},
|
214 |
+
},
|
215 |
+
);
|
216 |
+
|
217 |
+
if (!response.ok) {
|
218 |
+
throw new Error('Failed to search repositories');
|
219 |
+
}
|
220 |
+
|
221 |
+
const data = await response.json();
|
222 |
+
|
223 |
+
// Add type assertion and validation
|
224 |
+
if (typeof data === 'object' && data !== null && 'items' in data && Array.isArray(data.items)) {
|
225 |
+
setSearchResults(data.items as GitHubRepoInfo[]);
|
226 |
+
} else {
|
227 |
+
throw new Error('Invalid search results format');
|
228 |
+
}
|
229 |
+
} catch (error) {
|
230 |
+
console.error('Error searching repos:', error);
|
231 |
+
toast.error('Failed to search repositories');
|
232 |
+
} finally {
|
233 |
+
setIsLoading(false);
|
234 |
+
}
|
235 |
+
};
|
236 |
+
|
237 |
+
const fetchBranches = async (repo: GitHubRepoInfo) => {
|
238 |
+
setIsLoading(true);
|
239 |
+
|
240 |
+
try {
|
241 |
+
const response = await fetch(`https://api.github.com/repos/${repo.full_name}/branches`, {
|
242 |
+
headers: {
|
243 |
+
Authorization: `Bearer ${getLocalStorage('github_connection')?.token}`,
|
244 |
+
},
|
245 |
+
});
|
246 |
+
|
247 |
+
if (!response.ok) {
|
248 |
+
throw new Error('Failed to fetch branches');
|
249 |
+
}
|
250 |
+
|
251 |
+
const data = await response.json();
|
252 |
+
|
253 |
+
// Add type assertion and validation
|
254 |
+
if (Array.isArray(data) && data.every((item) => typeof item === 'object' && item !== null && 'name' in item)) {
|
255 |
+
setBranches(
|
256 |
+
data.map((branch) => ({
|
257 |
+
name: branch.name,
|
258 |
+
default: branch.name === repo.default_branch,
|
259 |
+
})),
|
260 |
+
);
|
261 |
+
} else {
|
262 |
+
throw new Error('Invalid branch data format');
|
263 |
+
}
|
264 |
+
} catch (error) {
|
265 |
+
console.error('Error fetching branches:', error);
|
266 |
+
toast.error('Failed to fetch branches');
|
267 |
+
} finally {
|
268 |
+
setIsLoading(false);
|
269 |
+
}
|
270 |
+
};
|
271 |
+
|
272 |
+
const handleRepoSelect = async (repo: GitHubRepoInfo) => {
|
273 |
+
setSelectedRepository(repo);
|
274 |
+
await fetchBranches(repo);
|
275 |
+
};
|
276 |
+
|
277 |
+
const formatGitUrl = (url: string): string => {
|
278 |
+
// Remove any tree references and ensure .git extension
|
279 |
+
const baseUrl = url
|
280 |
+
.replace(/\/tree\/[^/]+/, '') // Remove /tree/branch-name
|
281 |
+
.replace(/\/$/, '') // Remove trailing slash
|
282 |
+
.replace(/\.git$/, ''); // Remove .git if present
|
283 |
+
return `${baseUrl}.git`;
|
284 |
+
};
|
285 |
+
|
286 |
+
const verifyRepository = async (repoUrl: string): Promise<RepositoryStats | null> => {
|
287 |
+
try {
|
288 |
+
const [owner, repo] = repoUrl
|
289 |
+
.replace(/\.git$/, '')
|
290 |
+
.split('/')
|
291 |
+
.slice(-2);
|
292 |
+
|
293 |
+
const connection = getLocalStorage('github_connection');
|
294 |
+
const headers: HeadersInit = connection?.token ? { Authorization: `Bearer ${connection.token}` } : {};
|
295 |
+
|
296 |
+
// Fetch repository tree
|
297 |
+
const treeResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/trees/main?recursive=1`, {
|
298 |
+
headers,
|
299 |
+
});
|
300 |
+
|
301 |
+
if (!treeResponse.ok) {
|
302 |
+
throw new Error('Failed to fetch repository structure');
|
303 |
+
}
|
304 |
+
|
305 |
+
const treeData = (await treeResponse.json()) as GitHubTreeResponse;
|
306 |
+
|
307 |
+
// Calculate repository stats
|
308 |
+
let totalSize = 0;
|
309 |
+
let totalFiles = 0;
|
310 |
+
const languages: { [key: string]: number } = {};
|
311 |
+
let hasPackageJson = false;
|
312 |
+
let hasDependencies = false;
|
313 |
+
|
314 |
+
for (const file of treeData.tree) {
|
315 |
+
if (file.type === 'blob') {
|
316 |
+
totalFiles++;
|
317 |
+
|
318 |
+
if (file.size) {
|
319 |
+
totalSize += file.size;
|
320 |
+
}
|
321 |
+
|
322 |
+
// Check for package.json
|
323 |
+
if (file.path === 'package.json') {
|
324 |
+
hasPackageJson = true;
|
325 |
+
|
326 |
+
// Fetch package.json content to check dependencies
|
327 |
+
const contentResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}/contents/package.json`, {
|
328 |
+
headers,
|
329 |
+
});
|
330 |
+
|
331 |
+
if (contentResponse.ok) {
|
332 |
+
const content = (await contentResponse.json()) as GitHubContent;
|
333 |
+
const packageJson = JSON.parse(Buffer.from(content.content, 'base64').toString());
|
334 |
+
hasDependencies = !!(
|
335 |
+
packageJson.dependencies ||
|
336 |
+
packageJson.devDependencies ||
|
337 |
+
packageJson.peerDependencies
|
338 |
+
);
|
339 |
+
}
|
340 |
+
}
|
341 |
+
|
342 |
+
// Detect language based on file extension
|
343 |
+
const ext = file.path.split('.').pop()?.toLowerCase();
|
344 |
+
|
345 |
+
if (ext) {
|
346 |
+
languages[ext] = (languages[ext] || 0) + (file.size || 0);
|
347 |
+
}
|
348 |
+
}
|
349 |
+
}
|
350 |
+
|
351 |
+
const stats: RepositoryStats = {
|
352 |
+
totalFiles,
|
353 |
+
totalSize,
|
354 |
+
languages,
|
355 |
+
hasPackageJson,
|
356 |
+
hasDependencies,
|
357 |
+
};
|
358 |
+
|
359 |
+
setStats(stats);
|
360 |
+
|
361 |
+
return stats;
|
362 |
+
} catch (error) {
|
363 |
+
console.error('Error verifying repository:', error);
|
364 |
+
toast.error('Failed to verify repository');
|
365 |
+
|
366 |
+
return null;
|
367 |
+
}
|
368 |
+
};
|
369 |
+
|
370 |
+
const handleImport = async () => {
|
371 |
+
try {
|
372 |
+
let gitUrl: string;
|
373 |
+
|
374 |
+
if (activeTab === 'url' && customUrl) {
|
375 |
+
gitUrl = formatGitUrl(customUrl);
|
376 |
+
} else if (selectedRepository) {
|
377 |
+
gitUrl = formatGitUrl(selectedRepository.html_url);
|
378 |
+
|
379 |
+
if (selectedBranch) {
|
380 |
+
gitUrl = `${gitUrl}#${selectedBranch}`;
|
381 |
+
}
|
382 |
+
} else {
|
383 |
+
return;
|
384 |
+
}
|
385 |
+
|
386 |
+
// Verify repository before importing
|
387 |
+
const stats = await verifyRepository(gitUrl);
|
388 |
+
|
389 |
+
if (!stats) {
|
390 |
+
return;
|
391 |
+
}
|
392 |
+
|
393 |
+
setCurrentStats(stats);
|
394 |
+
setPendingGitUrl(gitUrl);
|
395 |
+
setShowStatsDialog(true);
|
396 |
+
} catch (error) {
|
397 |
+
console.error('Error preparing repository:', error);
|
398 |
+
toast.error('Failed to prepare repository. Please try again.');
|
399 |
+
}
|
400 |
+
};
|
401 |
+
|
402 |
+
const handleStatsConfirm = () => {
|
403 |
+
setShowStatsDialog(false);
|
404 |
+
|
405 |
+
if (pendingGitUrl) {
|
406 |
+
onSelect(pendingGitUrl);
|
407 |
+
onClose();
|
408 |
+
}
|
409 |
+
};
|
410 |
+
|
411 |
+
const handleFilterChange = (key: keyof SearchFilters, value: string) => {
|
412 |
+
let parsedValue: string | number | undefined = value;
|
413 |
+
|
414 |
+
if (key === 'stars' || key === 'forks') {
|
415 |
+
parsedValue = value ? parseInt(value, 10) : undefined;
|
416 |
+
}
|
417 |
+
|
418 |
+
setFilters((prev) => ({ ...prev, [key]: parsedValue }));
|
419 |
+
handleSearch(searchQuery);
|
420 |
+
};
|
421 |
+
|
422 |
+
// Handle dialog close properly
|
423 |
+
const handleClose = () => {
|
424 |
+
setIsLoading(false); // Reset loading state
|
425 |
+
setSearchQuery(''); // Reset search
|
426 |
+
setSearchResults([]); // Reset results
|
427 |
+
onClose();
|
428 |
+
};
|
429 |
+
|
430 |
+
return (
|
431 |
+
<Dialog.Root
|
432 |
+
open={isOpen}
|
433 |
+
onOpenChange={(open) => {
|
434 |
+
if (!open) {
|
435 |
+
handleClose();
|
436 |
+
}
|
437 |
+
}}
|
438 |
+
>
|
439 |
+
<Dialog.Portal>
|
440 |
+
<Dialog.Overlay className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50" />
|
441 |
+
<Dialog.Content className="fixed top-[50%] left-[50%] -translate-x-1/2 -translate-y-1/2 w-[90vw] md:w-[600px] max-h-[85vh] overflow-hidden bg-white dark:bg-[#1A1A1A] rounded-xl shadow-xl z-[51] border border-[#E5E5E5] dark:border-[#333333]">
|
442 |
+
<div className="p-4 border-b border-[#E5E5E5] dark:border-[#333333] flex items-center justify-between">
|
443 |
+
<Dialog.Title className="text-lg font-semibold text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark">
|
444 |
+
Import GitHub Repository
|
445 |
+
</Dialog.Title>
|
446 |
+
<Dialog.Close
|
447 |
+
onClick={handleClose}
|
448 |
+
className={classNames(
|
449 |
+
'p-2 rounded-lg transition-all duration-200 ease-in-out',
|
450 |
+
'text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary',
|
451 |
+
'dark:text-bolt-elements-textTertiary-dark dark:hover:text-bolt-elements-textPrimary-dark',
|
452 |
+
'hover:bg-bolt-elements-background-depth-2 dark:hover:bg-bolt-elements-background-depth-3',
|
453 |
+
'focus:outline-none focus:ring-2 focus:ring-bolt-elements-borderColor dark:focus:ring-bolt-elements-borderColor-dark',
|
454 |
+
)}
|
455 |
+
>
|
456 |
+
<span className="i-ph:x block w-5 h-5" aria-hidden="true" />
|
457 |
+
<span className="sr-only">Close dialog</span>
|
458 |
+
</Dialog.Close>
|
459 |
+
</div>
|
460 |
+
|
461 |
+
<div className="p-4">
|
462 |
+
<div className="flex gap-2 mb-4">
|
463 |
+
<TabButton active={activeTab === 'my-repos'} onClick={() => setActiveTab('my-repos')}>
|
464 |
+
<span className="i-ph:book-bookmark" />
|
465 |
+
My Repos
|
466 |
+
</TabButton>
|
467 |
+
<TabButton active={activeTab === 'search'} onClick={() => setActiveTab('search')}>
|
468 |
+
<span className="i-ph:magnifying-glass" />
|
469 |
+
Search
|
470 |
+
</TabButton>
|
471 |
+
<TabButton active={activeTab === 'url'} onClick={() => setActiveTab('url')}>
|
472 |
+
<span className="i-ph:link" />
|
473 |
+
URL
|
474 |
+
</TabButton>
|
475 |
+
</div>
|
476 |
+
|
477 |
+
{activeTab === 'url' ? (
|
478 |
+
<div className="space-y-4">
|
479 |
+
<Input
|
480 |
+
placeholder="Enter repository URL"
|
481 |
+
value={customUrl}
|
482 |
+
onChange={(e) => setCustomUrl(e.target.value)}
|
483 |
+
className={classNames('w-full', {
|
484 |
+
'border-red-500': false,
|
485 |
+
})}
|
486 |
+
/>
|
487 |
+
<button
|
488 |
+
onClick={handleImport}
|
489 |
+
disabled={!customUrl}
|
490 |
+
className="w-full h-10 px-4 py-2 rounded-lg bg-purple-500 text-white hover:bg-purple-600 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 flex items-center gap-2 justify-center"
|
491 |
+
>
|
492 |
+
Import Repository
|
493 |
+
</button>
|
494 |
+
</div>
|
495 |
+
) : (
|
496 |
+
<>
|
497 |
+
{activeTab === 'search' && (
|
498 |
+
<div className="space-y-4 mb-4">
|
499 |
+
<div className="flex gap-2">
|
500 |
+
<input
|
501 |
+
type="text"
|
502 |
+
placeholder="Search repositories..."
|
503 |
+
value={searchQuery}
|
504 |
+
onChange={(e) => {
|
505 |
+
setSearchQuery(e.target.value);
|
506 |
+
handleSearch(e.target.value);
|
507 |
+
}}
|
508 |
+
className="flex-1 px-4 py-2 rounded-lg bg-[#F5F5F5] dark:bg-[#252525] border border-[#E5E5E5] dark:border-[#333333] text-bolt-elements-textPrimary"
|
509 |
+
/>
|
510 |
+
<button
|
511 |
+
onClick={() => setFilters({})}
|
512 |
+
className="px-3 py-2 rounded-lg bg-[#F5F5F5] dark:bg-[#252525] text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary"
|
513 |
+
>
|
514 |
+
<span className="i-ph:funnel-simple" />
|
515 |
+
</button>
|
516 |
+
</div>
|
517 |
+
<div className="grid grid-cols-2 gap-2">
|
518 |
+
<input
|
519 |
+
type="text"
|
520 |
+
placeholder="Filter by language..."
|
521 |
+
value={filters.language || ''}
|
522 |
+
onChange={(e) => {
|
523 |
+
setFilters({ ...filters, language: e.target.value });
|
524 |
+
handleSearch(searchQuery);
|
525 |
+
}}
|
526 |
+
className="px-3 py-1.5 text-sm rounded-lg bg-[#F5F5F5] dark:bg-[#252525] border border-[#E5E5E5] dark:border-[#333333]"
|
527 |
+
/>
|
528 |
+
<input
|
529 |
+
type="number"
|
530 |
+
placeholder="Min stars..."
|
531 |
+
value={filters.stars || ''}
|
532 |
+
onChange={(e) => handleFilterChange('stars', e.target.value)}
|
533 |
+
className="px-3 py-1.5 text-sm rounded-lg bg-[#F5F5F5] dark:bg-[#252525] border border-[#E5E5E5] dark:border-[#333333]"
|
534 |
+
/>
|
535 |
+
</div>
|
536 |
+
<input
|
537 |
+
type="number"
|
538 |
+
placeholder="Min forks..."
|
539 |
+
value={filters.forks || ''}
|
540 |
+
onChange={(e) => handleFilterChange('forks', e.target.value)}
|
541 |
+
className="px-3 py-1.5 text-sm rounded-lg bg-[#F5F5F5] dark:bg-[#252525] border border-[#E5E5E5] dark:border-[#333333]"
|
542 |
+
/>
|
543 |
+
</div>
|
544 |
+
)}
|
545 |
+
|
546 |
+
<div className="space-y-3 max-h-[400px] overflow-y-auto pr-2 custom-scrollbar">
|
547 |
+
{selectedRepository ? (
|
548 |
+
<div className="space-y-4">
|
549 |
+
<div className="flex items-center gap-2">
|
550 |
+
<button
|
551 |
+
onClick={() => setSelectedRepository(null)}
|
552 |
+
className="p-1.5 rounded-lg hover:bg-[#F5F5F5] dark:hover:bg-[#252525]"
|
553 |
+
>
|
554 |
+
<span className="i-ph:arrow-left w-4 h-4" />
|
555 |
+
</button>
|
556 |
+
<h3 className="font-medium">{selectedRepository.full_name}</h3>
|
557 |
+
</div>
|
558 |
+
<div className="space-y-2">
|
559 |
+
<label className="text-sm text-bolt-elements-textSecondary">Select Branch</label>
|
560 |
+
<select
|
561 |
+
value={selectedBranch}
|
562 |
+
onChange={(e) => setSelectedBranch(e.target.value)}
|
563 |
+
className="w-full px-3 py-2 rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark focus:outline-none focus:ring-2 focus:ring-bolt-elements-borderColor dark:focus:ring-bolt-elements-borderColor-dark"
|
564 |
+
>
|
565 |
+
{branches.map((branch) => (
|
566 |
+
<option
|
567 |
+
key={branch.name}
|
568 |
+
value={branch.name}
|
569 |
+
className="bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark"
|
570 |
+
>
|
571 |
+
{branch.name} {branch.default ? '(default)' : ''}
|
572 |
+
</option>
|
573 |
+
))}
|
574 |
+
</select>
|
575 |
+
<button
|
576 |
+
onClick={handleImport}
|
577 |
+
className="w-full h-10 px-4 py-2 rounded-lg bg-purple-500 text-white hover:bg-purple-600 transition-all duration-200 flex items-center gap-2 justify-center"
|
578 |
+
>
|
579 |
+
Import Selected Branch
|
580 |
+
</button>
|
581 |
+
</div>
|
582 |
+
</div>
|
583 |
+
) : (
|
584 |
+
<RepositoryList
|
585 |
+
repos={activeTab === 'my-repos' ? repositories : searchResults}
|
586 |
+
isLoading={isLoading}
|
587 |
+
onSelect={handleRepoSelect}
|
588 |
+
activeTab={activeTab}
|
589 |
+
/>
|
590 |
+
)}
|
591 |
+
</div>
|
592 |
+
</>
|
593 |
+
)}
|
594 |
+
</div>
|
595 |
+
</Dialog.Content>
|
596 |
+
</Dialog.Portal>
|
597 |
+
{currentStats && (
|
598 |
+
<StatsDialog
|
599 |
+
isOpen={showStatsDialog}
|
600 |
+
onClose={handleStatsConfirm}
|
601 |
+
onConfirm={handleStatsConfirm}
|
602 |
+
stats={currentStats}
|
603 |
+
isLargeRepo={currentStats.totalSize > 50 * 1024 * 1024}
|
604 |
+
/>
|
605 |
+
)}
|
606 |
+
</Dialog.Root>
|
607 |
+
);
|
608 |
+
}
|
609 |
+
|
610 |
+
function TabButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
|
611 |
+
return (
|
612 |
+
<button
|
613 |
+
onClick={onClick}
|
614 |
+
className={classNames(
|
615 |
+
'px-4 py-2 h-10 rounded-lg transition-all duration-200 flex items-center gap-2 min-w-[120px] justify-center',
|
616 |
+
active
|
617 |
+
? 'bg-purple-500 text-white hover:bg-purple-600'
|
618 |
+
: 'bg-[#F5F5F5] dark:bg-[#252525] text-bolt-elements-textPrimary dark:text-white hover:bg-[#E5E5E5] dark:hover:bg-[#333333] border border-[#E5E5E5] dark:border-[#333333]',
|
619 |
+
)}
|
620 |
+
>
|
621 |
+
{children}
|
622 |
+
</button>
|
623 |
+
);
|
624 |
+
}
|
625 |
+
|
626 |
+
function RepositoryList({
|
627 |
+
repos,
|
628 |
+
isLoading,
|
629 |
+
onSelect,
|
630 |
+
activeTab,
|
631 |
+
}: {
|
632 |
+
repos: GitHubRepoInfo[];
|
633 |
+
isLoading: boolean;
|
634 |
+
onSelect: (repo: GitHubRepoInfo) => void;
|
635 |
+
activeTab: string;
|
636 |
+
}) {
|
637 |
+
if (isLoading) {
|
638 |
+
return (
|
639 |
+
<div className="flex items-center justify-center py-8 text-bolt-elements-textSecondary">
|
640 |
+
<span className="i-ph:spinner animate-spin mr-2" />
|
641 |
+
Loading repositories...
|
642 |
+
</div>
|
643 |
+
);
|
644 |
+
}
|
645 |
+
|
646 |
+
if (repos.length === 0) {
|
647 |
+
return (
|
648 |
+
<div className="flex flex-col items-center justify-center py-8 text-bolt-elements-textSecondary">
|
649 |
+
<span className="i-ph:folder-simple-dashed w-12 h-12 mb-2 opacity-50" />
|
650 |
+
<p>{activeTab === 'my-repos' ? 'No repositories found' : 'Search for repositories'}</p>
|
651 |
+
</div>
|
652 |
+
);
|
653 |
+
}
|
654 |
+
|
655 |
+
return repos.map((repo) => <RepositoryCard key={repo.full_name} repo={repo} onSelect={() => onSelect(repo)} />);
|
656 |
+
}
|
657 |
+
|
658 |
+
function RepositoryCard({ repo, onSelect }: { repo: GitHubRepoInfo; onSelect: () => void }) {
|
659 |
+
return (
|
660 |
+
<div className="p-4 rounded-lg bg-[#F5F5F5] dark:bg-[#252525] border border-[#E5E5E5] dark:border-[#333333] hover:border-purple-500/50 transition-colors">
|
661 |
+
<div className="flex items-center justify-between mb-2">
|
662 |
+
<div className="flex items-center gap-2">
|
663 |
+
<span className="i-ph:git-repository text-bolt-elements-textTertiary" />
|
664 |
+
<h3 className="font-medium text-bolt-elements-textPrimary dark:text-white">{repo.name}</h3>
|
665 |
+
</div>
|
666 |
+
<button
|
667 |
+
onClick={onSelect}
|
668 |
+
className="px-4 py-2 h-10 rounded-lg bg-purple-500 text-white hover:bg-purple-600 transition-all duration-200 flex items-center gap-2 min-w-[120px] justify-center"
|
669 |
+
>
|
670 |
+
<span className="i-ph:download-simple w-4 h-4" />
|
671 |
+
Import
|
672 |
+
</button>
|
673 |
+
</div>
|
674 |
+
{repo.description && <p className="text-sm text-bolt-elements-textSecondary mb-3">{repo.description}</p>}
|
675 |
+
<div className="flex items-center gap-4 text-sm text-bolt-elements-textTertiary">
|
676 |
+
{repo.language && (
|
677 |
+
<span className="flex items-center gap-1">
|
678 |
+
<span className="i-ph:code" />
|
679 |
+
{repo.language}
|
680 |
+
</span>
|
681 |
+
)}
|
682 |
+
<span className="flex items-center gap-1">
|
683 |
+
<span className="i-ph:star" />
|
684 |
+
{repo.stargazers_count.toLocaleString()}
|
685 |
+
</span>
|
686 |
+
<span className="flex items-center gap-1">
|
687 |
+
<span className="i-ph:clock" />
|
688 |
+
{new Date(repo.updated_at).toLocaleDateString()}
|
689 |
+
</span>
|
690 |
+
</div>
|
691 |
+
</div>
|
692 |
+
);
|
693 |
+
}
|
app/components/@settings/tabs/connections/types/GitHub.ts
ADDED
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
export interface GitHubUserResponse {
|
2 |
+
login: string;
|
3 |
+
avatar_url: string;
|
4 |
+
html_url: string;
|
5 |
+
name: string;
|
6 |
+
bio: string;
|
7 |
+
public_repos: number;
|
8 |
+
followers: number;
|
9 |
+
following: number;
|
10 |
+
public_gists: number;
|
11 |
+
created_at: string;
|
12 |
+
updated_at: string;
|
13 |
+
}
|
14 |
+
|
15 |
+
export interface GitHubRepoInfo {
|
16 |
+
name: string;
|
17 |
+
full_name: string;
|
18 |
+
html_url: string;
|
19 |
+
description: string;
|
20 |
+
stargazers_count: number;
|
21 |
+
forks_count: number;
|
22 |
+
default_branch: string;
|
23 |
+
updated_at: string;
|
24 |
+
language: string;
|
25 |
+
languages_url: string;
|
26 |
+
}
|
27 |
+
|
28 |
+
export interface GitHubOrganization {
|
29 |
+
login: string;
|
30 |
+
avatar_url: string;
|
31 |
+
description: string;
|
32 |
+
html_url: string;
|
33 |
+
}
|
34 |
+
|
35 |
+
export interface GitHubEvent {
|
36 |
+
id: string;
|
37 |
+
type: string;
|
38 |
+
created_at: string;
|
39 |
+
repo: {
|
40 |
+
name: string;
|
41 |
+
url: string;
|
42 |
+
};
|
43 |
+
payload: {
|
44 |
+
action?: string;
|
45 |
+
ref?: string;
|
46 |
+
ref_type?: string;
|
47 |
+
description?: string;
|
48 |
+
};
|
49 |
+
}
|
50 |
+
|
51 |
+
export interface GitHubLanguageStats {
|
52 |
+
[key: string]: number;
|
53 |
+
}
|
54 |
+
|
55 |
+
export interface GitHubStats {
|
56 |
+
repos: GitHubRepoInfo[];
|
57 |
+
totalStars: number;
|
58 |
+
totalForks: number;
|
59 |
+
organizations: GitHubOrganization[];
|
60 |
+
recentActivity: GitHubEvent[];
|
61 |
+
languages: GitHubLanguageStats;
|
62 |
+
totalGists: number;
|
63 |
+
}
|
64 |
+
|
65 |
+
export interface GitHubConnection {
|
66 |
+
user: GitHubUserResponse | null;
|
67 |
+
token: string;
|
68 |
+
tokenType: 'classic' | 'fine-grained';
|
69 |
+
stats?: GitHubStats;
|
70 |
+
}
|
71 |
+
|
72 |
+
export interface GitHubTokenInfo {
|
73 |
+
token: string;
|
74 |
+
scope: string[];
|
75 |
+
avatar_url: string;
|
76 |
+
name: string | null;
|
77 |
+
created_at: string;
|
78 |
+
followers: number;
|
79 |
+
}
|
80 |
+
|
81 |
+
export interface GitHubRateLimits {
|
82 |
+
limit: number;
|
83 |
+
remaining: number;
|
84 |
+
reset: Date;
|
85 |
+
used: number;
|
86 |
+
}
|
87 |
+
|
88 |
+
export interface GitHubAuthState {
|
89 |
+
username: string;
|
90 |
+
tokenInfo: GitHubTokenInfo | null;
|
91 |
+
isConnected: boolean;
|
92 |
+
isVerifying: boolean;
|
93 |
+
isLoadingRepos: boolean;
|
94 |
+
rateLimits?: GitHubRateLimits;
|
95 |
+
}
|
app/components/@settings/tabs/data/DataTab.tsx
ADDED
@@ -0,0 +1,452 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { useState, useRef } from 'react';
|
2 |
+
import { motion } from 'framer-motion';
|
3 |
+
import { toast } from 'react-toastify';
|
4 |
+
import { DialogRoot, DialogClose, Dialog, DialogTitle } from '~/components/ui/Dialog';
|
5 |
+
import { db, getAll, deleteById } from '~/lib/persistence';
|
6 |
+
|
7 |
+
export default function DataTab() {
|
8 |
+
const [isDownloadingTemplate, setIsDownloadingTemplate] = useState(false);
|
9 |
+
const [isImportingKeys, setIsImportingKeys] = useState(false);
|
10 |
+
const [isResetting, setIsResetting] = useState(false);
|
11 |
+
const [isDeleting, setIsDeleting] = useState(false);
|
12 |
+
const [showResetInlineConfirm, setShowResetInlineConfirm] = useState(false);
|
13 |
+
const [showDeleteInlineConfirm, setShowDeleteInlineConfirm] = useState(false);
|
14 |
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
15 |
+
const apiKeyFileInputRef = useRef<HTMLInputElement>(null);
|
16 |
+
|
17 |
+
const handleExportAllChats = async () => {
|
18 |
+
try {
|
19 |
+
if (!db) {
|
20 |
+
throw new Error('Database not initialized');
|
21 |
+
}
|
22 |
+
|
23 |
+
// Get all chats from IndexedDB
|
24 |
+
const allChats = await getAll(db);
|
25 |
+
const exportData = {
|
26 |
+
chats: allChats,
|
27 |
+
exportDate: new Date().toISOString(),
|
28 |
+
};
|
29 |
+
|
30 |
+
// Download as JSON
|
31 |
+
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
|
32 |
+
const url = URL.createObjectURL(blob);
|
33 |
+
const a = document.createElement('a');
|
34 |
+
a.href = url;
|
35 |
+
a.download = `bolt-chats-${new Date().toISOString()}.json`;
|
36 |
+
document.body.appendChild(a);
|
37 |
+
a.click();
|
38 |
+
document.body.removeChild(a);
|
39 |
+
URL.revokeObjectURL(url);
|
40 |
+
|
41 |
+
toast.success('Chats exported successfully');
|
42 |
+
} catch (error) {
|
43 |
+
console.error('Export error:', error);
|
44 |
+
toast.error('Failed to export chats');
|
45 |
+
}
|
46 |
+
};
|
47 |
+
|
48 |
+
const handleExportSettings = () => {
|
49 |
+
try {
|
50 |
+
const settings = {
|
51 |
+
userProfile: localStorage.getItem('bolt_user_profile'),
|
52 |
+
settings: localStorage.getItem('bolt_settings'),
|
53 |
+
exportDate: new Date().toISOString(),
|
54 |
+
};
|
55 |
+
|
56 |
+
const blob = new Blob([JSON.stringify(settings, null, 2)], { type: 'application/json' });
|
57 |
+
const url = URL.createObjectURL(blob);
|
58 |
+
const a = document.createElement('a');
|
59 |
+
a.href = url;
|
60 |
+
a.download = `bolt-settings-${new Date().toISOString()}.json`;
|
61 |
+
document.body.appendChild(a);
|
62 |
+
a.click();
|
63 |
+
document.body.removeChild(a);
|
64 |
+
URL.revokeObjectURL(url);
|
65 |
+
|
66 |
+
toast.success('Settings exported successfully');
|
67 |
+
} catch (error) {
|
68 |
+
console.error('Export error:', error);
|
69 |
+
toast.error('Failed to export settings');
|
70 |
+
}
|
71 |
+
};
|
72 |
+
|
73 |
+
const handleImportSettings = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
74 |
+
const file = event.target.files?.[0];
|
75 |
+
|
76 |
+
if (!file) {
|
77 |
+
return;
|
78 |
+
}
|
79 |
+
|
80 |
+
try {
|
81 |
+
const content = await file.text();
|
82 |
+
const settings = JSON.parse(content);
|
83 |
+
|
84 |
+
if (settings.userProfile) {
|
85 |
+
localStorage.setItem('bolt_user_profile', settings.userProfile);
|
86 |
+
}
|
87 |
+
|
88 |
+
if (settings.settings) {
|
89 |
+
localStorage.setItem('bolt_settings', settings.settings);
|
90 |
+
}
|
91 |
+
|
92 |
+
window.location.reload(); // Reload to apply settings
|
93 |
+
toast.success('Settings imported successfully');
|
94 |
+
} catch (error) {
|
95 |
+
console.error('Import error:', error);
|
96 |
+
toast.error('Failed to import settings');
|
97 |
+
}
|
98 |
+
};
|
99 |
+
|
100 |
+
const handleImportAPIKeys = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
101 |
+
const file = event.target.files?.[0];
|
102 |
+
|
103 |
+
if (!file) {
|
104 |
+
return;
|
105 |
+
}
|
106 |
+
|
107 |
+
setIsImportingKeys(true);
|
108 |
+
|
109 |
+
try {
|
110 |
+
const content = await file.text();
|
111 |
+
const keys = JSON.parse(content);
|
112 |
+
|
113 |
+
// Validate and save each key
|
114 |
+
Object.entries(keys).forEach(([key, value]) => {
|
115 |
+
if (typeof value !== 'string') {
|
116 |
+
throw new Error(`Invalid value for key: ${key}`);
|
117 |
+
}
|
118 |
+
|
119 |
+
localStorage.setItem(`bolt_${key.toLowerCase()}`, value);
|
120 |
+
});
|
121 |
+
|
122 |
+
toast.success('API keys imported successfully');
|
123 |
+
} catch (error) {
|
124 |
+
console.error('Error importing API keys:', error);
|
125 |
+
toast.error('Failed to import API keys');
|
126 |
+
} finally {
|
127 |
+
setIsImportingKeys(false);
|
128 |
+
|
129 |
+
if (apiKeyFileInputRef.current) {
|
130 |
+
apiKeyFileInputRef.current.value = '';
|
131 |
+
}
|
132 |
+
}
|
133 |
+
};
|
134 |
+
|
135 |
+
const handleDownloadTemplate = () => {
|
136 |
+
setIsDownloadingTemplate(true);
|
137 |
+
|
138 |
+
try {
|
139 |
+
const template = {
|
140 |
+
Anthropic_API_KEY: '',
|
141 |
+
OpenAI_API_KEY: '',
|
142 |
+
Google_API_KEY: '',
|
143 |
+
Groq_API_KEY: '',
|
144 |
+
HuggingFace_API_KEY: '',
|
145 |
+
OpenRouter_API_KEY: '',
|
146 |
+
Deepseek_API_KEY: '',
|
147 |
+
Mistral_API_KEY: '',
|
148 |
+
OpenAILike_API_KEY: '',
|
149 |
+
Together_API_KEY: '',
|
150 |
+
xAI_API_KEY: '',
|
151 |
+
Perplexity_API_KEY: '',
|
152 |
+
Cohere_API_KEY: '',
|
153 |
+
AzureOpenAI_API_KEY: '',
|
154 |
+
OPENAI_LIKE_API_BASE_URL: '',
|
155 |
+
LMSTUDIO_API_BASE_URL: '',
|
156 |
+
OLLAMA_API_BASE_URL: '',
|
157 |
+
TOGETHER_API_BASE_URL: '',
|
158 |
+
};
|
159 |
+
|
160 |
+
const blob = new Blob([JSON.stringify(template, null, 2)], { type: 'application/json' });
|
161 |
+
const url = URL.createObjectURL(blob);
|
162 |
+
const a = document.createElement('a');
|
163 |
+
a.href = url;
|
164 |
+
a.download = 'bolt-api-keys-template.json';
|
165 |
+
document.body.appendChild(a);
|
166 |
+
a.click();
|
167 |
+
document.body.removeChild(a);
|
168 |
+
URL.revokeObjectURL(url);
|
169 |
+
|
170 |
+
toast.success('Template downloaded successfully');
|
171 |
+
} catch (error) {
|
172 |
+
console.error('Error downloading template:', error);
|
173 |
+
toast.error('Failed to download template');
|
174 |
+
} finally {
|
175 |
+
setIsDownloadingTemplate(false);
|
176 |
+
}
|
177 |
+
};
|
178 |
+
|
179 |
+
const handleResetSettings = async () => {
|
180 |
+
setIsResetting(true);
|
181 |
+
|
182 |
+
try {
|
183 |
+
// Clear all stored settings from localStorage
|
184 |
+
localStorage.removeItem('bolt_user_profile');
|
185 |
+
localStorage.removeItem('bolt_settings');
|
186 |
+
localStorage.removeItem('bolt_chat_history');
|
187 |
+
|
188 |
+
// Clear all data from IndexedDB
|
189 |
+
if (!db) {
|
190 |
+
throw new Error('Database not initialized');
|
191 |
+
}
|
192 |
+
|
193 |
+
// Get all chats and delete them
|
194 |
+
const chats = await getAll(db as IDBDatabase);
|
195 |
+
const deletePromises = chats.map((chat) => deleteById(db as IDBDatabase, chat.id));
|
196 |
+
await Promise.all(deletePromises);
|
197 |
+
|
198 |
+
// Close the dialog first
|
199 |
+
setShowResetInlineConfirm(false);
|
200 |
+
|
201 |
+
// Then reload and show success message
|
202 |
+
window.location.reload();
|
203 |
+
toast.success('Settings reset successfully');
|
204 |
+
} catch (error) {
|
205 |
+
console.error('Reset error:', error);
|
206 |
+
setShowResetInlineConfirm(false);
|
207 |
+
toast.error('Failed to reset settings');
|
208 |
+
} finally {
|
209 |
+
setIsResetting(false);
|
210 |
+
}
|
211 |
+
};
|
212 |
+
|
213 |
+
const handleDeleteAllChats = async () => {
|
214 |
+
setIsDeleting(true);
|
215 |
+
|
216 |
+
try {
|
217 |
+
// Clear chat history from localStorage
|
218 |
+
localStorage.removeItem('bolt_chat_history');
|
219 |
+
|
220 |
+
// Clear chats from IndexedDB
|
221 |
+
if (!db) {
|
222 |
+
throw new Error('Database not initialized');
|
223 |
+
}
|
224 |
+
|
225 |
+
// Get all chats and delete them one by one
|
226 |
+
const chats = await getAll(db as IDBDatabase);
|
227 |
+
const deletePromises = chats.map((chat) => deleteById(db as IDBDatabase, chat.id));
|
228 |
+
await Promise.all(deletePromises);
|
229 |
+
|
230 |
+
// Close the dialog first
|
231 |
+
setShowDeleteInlineConfirm(false);
|
232 |
+
|
233 |
+
// Then show the success message
|
234 |
+
toast.success('Chat history deleted successfully');
|
235 |
+
} catch (error) {
|
236 |
+
console.error('Delete error:', error);
|
237 |
+
setShowDeleteInlineConfirm(false);
|
238 |
+
toast.error('Failed to delete chat history');
|
239 |
+
} finally {
|
240 |
+
setIsDeleting(false);
|
241 |
+
}
|
242 |
+
};
|
243 |
+
|
244 |
+
return (
|
245 |
+
<div className="space-y-6">
|
246 |
+
<input ref={fileInputRef} type="file" accept=".json" onChange={handleImportSettings} className="hidden" />
|
247 |
+
{/* Reset Settings Dialog */}
|
248 |
+
<DialogRoot open={showResetInlineConfirm} onOpenChange={setShowResetInlineConfirm}>
|
249 |
+
<Dialog showCloseButton={false} className="z-[1000]">
|
250 |
+
<div className="p-6">
|
251 |
+
<div className="flex items-center gap-3">
|
252 |
+
<div className="i-ph:warning-circle-fill w-5 h-5 text-yellow-500" />
|
253 |
+
<DialogTitle>Reset All Settings?</DialogTitle>
|
254 |
+
</div>
|
255 |
+
<p className="text-sm text-bolt-elements-textSecondary mt-2">
|
256 |
+
This will reset all your settings to their default values. This action cannot be undone.
|
257 |
+
</p>
|
258 |
+
<div className="flex justify-end items-center gap-3 mt-6">
|
259 |
+
<DialogClose asChild>
|
260 |
+
<button className="px-4 py-2 rounded-lg text-sm bg-[#F5F5F5] dark:bg-[#1A1A1A] text-[#666666] dark:text-[#999999] hover:text-[#333333] dark:hover:text-white">
|
261 |
+
Cancel
|
262 |
+
</button>
|
263 |
+
</DialogClose>
|
264 |
+
<motion.button
|
265 |
+
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm bg-white dark:bg-[#1A1A1A] text-yellow-600 dark:text-yellow-500 hover:bg-yellow-50 dark:hover:bg-yellow-500/10 border border-transparent hover:border-yellow-500/10 dark:hover:border-yellow-500/20"
|
266 |
+
onClick={handleResetSettings}
|
267 |
+
disabled={isResetting}
|
268 |
+
whileHover={{ scale: 1.02 }}
|
269 |
+
whileTap={{ scale: 0.98 }}
|
270 |
+
>
|
271 |
+
{isResetting ? (
|
272 |
+
<div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
|
273 |
+
) : (
|
274 |
+
<div className="i-ph:arrow-counter-clockwise w-4 h-4" />
|
275 |
+
)}
|
276 |
+
Reset Settings
|
277 |
+
</motion.button>
|
278 |
+
</div>
|
279 |
+
</div>
|
280 |
+
</Dialog>
|
281 |
+
</DialogRoot>
|
282 |
+
|
283 |
+
{/* Delete Confirmation Dialog */}
|
284 |
+
<DialogRoot open={showDeleteInlineConfirm} onOpenChange={setShowDeleteInlineConfirm}>
|
285 |
+
<Dialog showCloseButton={false} className="z-[1000]">
|
286 |
+
<div className="p-6">
|
287 |
+
<div className="flex items-center gap-3">
|
288 |
+
<div className="i-ph:warning-circle-fill w-5 h-5 text-red-500" />
|
289 |
+
<DialogTitle>Delete All Chats?</DialogTitle>
|
290 |
+
</div>
|
291 |
+
<p className="text-sm text-bolt-elements-textSecondary mt-2">
|
292 |
+
This will permanently delete all your chat history. This action cannot be undone.
|
293 |
+
</p>
|
294 |
+
<div className="flex justify-end items-center gap-3 mt-6">
|
295 |
+
<DialogClose asChild>
|
296 |
+
<button className="px-4 py-2 rounded-lg text-sm bg-[#F5F5F5] dark:bg-[#1A1A1A] text-[#666666] dark:text-[#999999] hover:text-[#333333] dark:hover:text-white">
|
297 |
+
Cancel
|
298 |
+
</button>
|
299 |
+
</DialogClose>
|
300 |
+
<motion.button
|
301 |
+
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm bg-white dark:bg-[#1A1A1A] text-red-500 dark:text-red-500 hover:bg-red-50 dark:hover:bg-red-500/10 border border-transparent hover:border-red-500/10 dark:hover:border-red-500/20"
|
302 |
+
onClick={handleDeleteAllChats}
|
303 |
+
disabled={isDeleting}
|
304 |
+
whileHover={{ scale: 1.02 }}
|
305 |
+
whileTap={{ scale: 0.98 }}
|
306 |
+
>
|
307 |
+
{isDeleting ? (
|
308 |
+
<div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
|
309 |
+
) : (
|
310 |
+
<div className="i-ph:trash w-4 h-4" />
|
311 |
+
)}
|
312 |
+
Delete All
|
313 |
+
</motion.button>
|
314 |
+
</div>
|
315 |
+
</div>
|
316 |
+
</Dialog>
|
317 |
+
</DialogRoot>
|
318 |
+
|
319 |
+
{/* Chat History Section */}
|
320 |
+
<motion.div
|
321 |
+
className="bg-white dark:bg-[#0A0A0A] rounded-lg p-6 border border-[#E5E5E5] dark:border-[#1A1A1A]"
|
322 |
+
initial={{ opacity: 0, y: 20 }}
|
323 |
+
animate={{ opacity: 1, y: 0 }}
|
324 |
+
transition={{ delay: 0.1 }}
|
325 |
+
>
|
326 |
+
<div className="flex items-center gap-2 mb-2">
|
327 |
+
<div className="i-ph:chat-circle-duotone w-5 h-5 text-purple-500" />
|
328 |
+
<h3 className="text-lg font-medium text-gray-900 dark:text-white">Chat History</h3>
|
329 |
+
</div>
|
330 |
+
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">Export or delete all your chat history.</p>
|
331 |
+
<div className="flex gap-4">
|
332 |
+
<motion.button
|
333 |
+
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-purple-500 text-white text-sm hover:bg-purple-600"
|
334 |
+
whileHover={{ scale: 1.02 }}
|
335 |
+
whileTap={{ scale: 0.98 }}
|
336 |
+
onClick={handleExportAllChats}
|
337 |
+
>
|
338 |
+
<div className="i-ph:download-simple w-4 h-4" />
|
339 |
+
Export All Chats
|
340 |
+
</motion.button>
|
341 |
+
<motion.button
|
342 |
+
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-red-50 text-red-500 text-sm hover:bg-red-100 dark:bg-red-500/10 dark:hover:bg-red-500/20"
|
343 |
+
whileHover={{ scale: 1.02 }}
|
344 |
+
whileTap={{ scale: 0.98 }}
|
345 |
+
onClick={() => setShowDeleteInlineConfirm(true)}
|
346 |
+
>
|
347 |
+
<div className="i-ph:trash w-4 h-4" />
|
348 |
+
Delete All Chats
|
349 |
+
</motion.button>
|
350 |
+
</div>
|
351 |
+
</motion.div>
|
352 |
+
|
353 |
+
{/* Settings Backup Section */}
|
354 |
+
<motion.div
|
355 |
+
className="bg-white dark:bg-[#0A0A0A] rounded-lg p-6 border border-[#E5E5E5] dark:border-[#1A1A1A]"
|
356 |
+
initial={{ opacity: 0, y: 20 }}
|
357 |
+
animate={{ opacity: 1, y: 0 }}
|
358 |
+
transition={{ delay: 0.2 }}
|
359 |
+
>
|
360 |
+
<div className="flex items-center gap-2 mb-2">
|
361 |
+
<div className="i-ph:gear-duotone w-5 h-5 text-purple-500" />
|
362 |
+
<h3 className="text-lg font-medium text-gray-900 dark:text-white">Settings Backup</h3>
|
363 |
+
</div>
|
364 |
+
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
365 |
+
Export your settings to a JSON file or import settings from a previously exported file.
|
366 |
+
</p>
|
367 |
+
<div className="flex gap-4">
|
368 |
+
<motion.button
|
369 |
+
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-purple-500 text-white text-sm hover:bg-purple-600"
|
370 |
+
whileHover={{ scale: 1.02 }}
|
371 |
+
whileTap={{ scale: 0.98 }}
|
372 |
+
onClick={handleExportSettings}
|
373 |
+
>
|
374 |
+
<div className="i-ph:download-simple w-4 h-4" />
|
375 |
+
Export Settings
|
376 |
+
</motion.button>
|
377 |
+
<motion.button
|
378 |
+
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-purple-500 text-white text-sm hover:bg-purple-600"
|
379 |
+
whileHover={{ scale: 1.02 }}
|
380 |
+
whileTap={{ scale: 0.98 }}
|
381 |
+
onClick={() => fileInputRef.current?.click()}
|
382 |
+
>
|
383 |
+
<div className="i-ph:upload-simple w-4 h-4" />
|
384 |
+
Import Settings
|
385 |
+
</motion.button>
|
386 |
+
<motion.button
|
387 |
+
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-yellow-50 text-yellow-600 text-sm hover:bg-yellow-100 dark:bg-yellow-500/10 dark:hover:bg-yellow-500/20 dark:text-yellow-500"
|
388 |
+
whileHover={{ scale: 1.02 }}
|
389 |
+
whileTap={{ scale: 0.98 }}
|
390 |
+
onClick={() => setShowResetInlineConfirm(true)}
|
391 |
+
>
|
392 |
+
<div className="i-ph:arrow-counter-clockwise w-4 h-4" />
|
393 |
+
Reset Settings
|
394 |
+
</motion.button>
|
395 |
+
</div>
|
396 |
+
</motion.div>
|
397 |
+
|
398 |
+
{/* API Keys Management Section */}
|
399 |
+
<motion.div
|
400 |
+
className="bg-white dark:bg-[#0A0A0A] rounded-lg p-6 border border-[#E5E5E5] dark:border-[#1A1A1A]"
|
401 |
+
initial={{ opacity: 0, y: 20 }}
|
402 |
+
animate={{ opacity: 1, y: 0 }}
|
403 |
+
transition={{ delay: 0.3 }}
|
404 |
+
>
|
405 |
+
<div className="flex items-center gap-2 mb-2">
|
406 |
+
<div className="i-ph:key-duotone w-5 h-5 text-purple-500" />
|
407 |
+
<h3 className="text-lg font-medium text-gray-900 dark:text-white">API Keys Management</h3>
|
408 |
+
</div>
|
409 |
+
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
410 |
+
Import API keys from a JSON file or download a template to fill in your keys.
|
411 |
+
</p>
|
412 |
+
<div className="flex gap-4">
|
413 |
+
<input
|
414 |
+
ref={apiKeyFileInputRef}
|
415 |
+
type="file"
|
416 |
+
accept=".json"
|
417 |
+
onChange={handleImportAPIKeys}
|
418 |
+
className="hidden"
|
419 |
+
/>
|
420 |
+
<motion.button
|
421 |
+
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-purple-500 text-white text-sm hover:bg-purple-600"
|
422 |
+
whileHover={{ scale: 1.02 }}
|
423 |
+
whileTap={{ scale: 0.98 }}
|
424 |
+
onClick={handleDownloadTemplate}
|
425 |
+
disabled={isDownloadingTemplate}
|
426 |
+
>
|
427 |
+
{isDownloadingTemplate ? (
|
428 |
+
<div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
|
429 |
+
) : (
|
430 |
+
<div className="i-ph:download-simple w-4 h-4" />
|
431 |
+
)}
|
432 |
+
Download Template
|
433 |
+
</motion.button>
|
434 |
+
<motion.button
|
435 |
+
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-purple-500 text-white text-sm hover:bg-purple-600"
|
436 |
+
whileHover={{ scale: 1.02 }}
|
437 |
+
whileTap={{ scale: 0.98 }}
|
438 |
+
onClick={() => apiKeyFileInputRef.current?.click()}
|
439 |
+
disabled={isImportingKeys}
|
440 |
+
>
|
441 |
+
{isImportingKeys ? (
|
442 |
+
<div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
|
443 |
+
) : (
|
444 |
+
<div className="i-ph:upload-simple w-4 h-4" />
|
445 |
+
)}
|
446 |
+
Import API Keys
|
447 |
+
</motion.button>
|
448 |
+
</div>
|
449 |
+
</motion.div>
|
450 |
+
</div>
|
451 |
+
);
|
452 |
+
}
|
app/components/@settings/tabs/debug/DebugTab.tsx
ADDED
@@ -0,0 +1,2045 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React, { useEffect, useState, useMemo, useCallback } from 'react';
|
2 |
+
import { toast } from 'react-toastify';
|
3 |
+
import { classNames } from '~/utils/classNames';
|
4 |
+
import { logStore, type LogEntry } from '~/lib/stores/logs';
|
5 |
+
import { useStore } from '@nanostores/react';
|
6 |
+
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '~/components/ui/Collapsible';
|
7 |
+
import { Progress } from '~/components/ui/Progress';
|
8 |
+
import { ScrollArea } from '~/components/ui/ScrollArea';
|
9 |
+
import { Badge } from '~/components/ui/Badge';
|
10 |
+
import { Dialog, DialogRoot, DialogTitle } from '~/components/ui/Dialog';
|
11 |
+
import { jsPDF } from 'jspdf';
|
12 |
+
import { useSettings } from '~/lib/hooks/useSettings';
|
13 |
+
|
14 |
+
interface SystemInfo {
|
15 |
+
os: string;
|
16 |
+
arch: string;
|
17 |
+
platform: string;
|
18 |
+
cpus: string;
|
19 |
+
memory: {
|
20 |
+
total: string;
|
21 |
+
free: string;
|
22 |
+
used: string;
|
23 |
+
percentage: number;
|
24 |
+
};
|
25 |
+
node: string;
|
26 |
+
browser: {
|
27 |
+
name: string;
|
28 |
+
version: string;
|
29 |
+
language: string;
|
30 |
+
userAgent: string;
|
31 |
+
cookiesEnabled: boolean;
|
32 |
+
online: boolean;
|
33 |
+
platform: string;
|
34 |
+
cores: number;
|
35 |
+
};
|
36 |
+
screen: {
|
37 |
+
width: number;
|
38 |
+
height: number;
|
39 |
+
colorDepth: number;
|
40 |
+
pixelRatio: number;
|
41 |
+
};
|
42 |
+
time: {
|
43 |
+
timezone: string;
|
44 |
+
offset: number;
|
45 |
+
locale: string;
|
46 |
+
};
|
47 |
+
performance: {
|
48 |
+
memory: {
|
49 |
+
jsHeapSizeLimit: number;
|
50 |
+
totalJSHeapSize: number;
|
51 |
+
usedJSHeapSize: number;
|
52 |
+
usagePercentage: number;
|
53 |
+
};
|
54 |
+
timing: {
|
55 |
+
loadTime: number;
|
56 |
+
domReadyTime: number;
|
57 |
+
readyStart: number;
|
58 |
+
redirectTime: number;
|
59 |
+
appcacheTime: number;
|
60 |
+
unloadEventTime: number;
|
61 |
+
lookupDomainTime: number;
|
62 |
+
connectTime: number;
|
63 |
+
requestTime: number;
|
64 |
+
initDomTreeTime: number;
|
65 |
+
loadEventTime: number;
|
66 |
+
};
|
67 |
+
navigation: {
|
68 |
+
type: number;
|
69 |
+
redirectCount: number;
|
70 |
+
};
|
71 |
+
};
|
72 |
+
network: {
|
73 |
+
downlink: number;
|
74 |
+
effectiveType: string;
|
75 |
+
rtt: number;
|
76 |
+
saveData: boolean;
|
77 |
+
type: string;
|
78 |
+
};
|
79 |
+
battery?: {
|
80 |
+
charging: boolean;
|
81 |
+
chargingTime: number;
|
82 |
+
dischargingTime: number;
|
83 |
+
level: number;
|
84 |
+
};
|
85 |
+
storage: {
|
86 |
+
quota: number;
|
87 |
+
usage: number;
|
88 |
+
persistent: boolean;
|
89 |
+
temporary: boolean;
|
90 |
+
};
|
91 |
+
}
|
92 |
+
|
93 |
+
interface GitHubRepoInfo {
|
94 |
+
fullName: string;
|
95 |
+
defaultBranch: string;
|
96 |
+
stars: number;
|
97 |
+
forks: number;
|
98 |
+
openIssues?: number;
|
99 |
+
}
|
100 |
+
|
101 |
+
interface GitInfo {
|
102 |
+
local: {
|
103 |
+
commitHash: string;
|
104 |
+
branch: string;
|
105 |
+
commitTime: string;
|
106 |
+
author: string;
|
107 |
+
email: string;
|
108 |
+
remoteUrl: string;
|
109 |
+
repoName: string;
|
110 |
+
};
|
111 |
+
github?: {
|
112 |
+
currentRepo: GitHubRepoInfo;
|
113 |
+
upstream?: GitHubRepoInfo;
|
114 |
+
};
|
115 |
+
isForked?: boolean;
|
116 |
+
}
|
117 |
+
|
118 |
+
interface WebAppInfo {
|
119 |
+
name: string;
|
120 |
+
version: string;
|
121 |
+
description: string;
|
122 |
+
license: string;
|
123 |
+
environment: string;
|
124 |
+
timestamp: string;
|
125 |
+
runtimeInfo: {
|
126 |
+
nodeVersion: string;
|
127 |
+
};
|
128 |
+
dependencies: {
|
129 |
+
production: Array<{ name: string; version: string; type: string }>;
|
130 |
+
development: Array<{ name: string; version: string; type: string }>;
|
131 |
+
peer: Array<{ name: string; version: string; type: string }>;
|
132 |
+
optional: Array<{ name: string; version: string; type: string }>;
|
133 |
+
};
|
134 |
+
gitInfo: GitInfo;
|
135 |
+
}
|
136 |
+
|
137 |
+
// Add Ollama service status interface
|
138 |
+
interface OllamaServiceStatus {
|
139 |
+
isRunning: boolean;
|
140 |
+
lastChecked: Date;
|
141 |
+
error?: string;
|
142 |
+
models?: Array<{
|
143 |
+
name: string;
|
144 |
+
size: string;
|
145 |
+
quantization: string;
|
146 |
+
}>;
|
147 |
+
}
|
148 |
+
|
149 |
+
interface ExportFormat {
|
150 |
+
id: string;
|
151 |
+
label: string;
|
152 |
+
icon: string;
|
153 |
+
handler: () => void;
|
154 |
+
}
|
155 |
+
|
156 |
+
const DependencySection = ({
|
157 |
+
title,
|
158 |
+
deps,
|
159 |
+
}: {
|
160 |
+
title: string;
|
161 |
+
deps: Array<{ name: string; version: string; type: string }>;
|
162 |
+
}) => {
|
163 |
+
const [isOpen, setIsOpen] = useState(false);
|
164 |
+
|
165 |
+
if (deps.length === 0) {
|
166 |
+
return null;
|
167 |
+
}
|
168 |
+
|
169 |
+
return (
|
170 |
+
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
171 |
+
<CollapsibleTrigger
|
172 |
+
className={classNames(
|
173 |
+
'flex w-full items-center justify-between p-4',
|
174 |
+
'bg-white dark:bg-[#0A0A0A]',
|
175 |
+
'hover:bg-purple-50/50 dark:hover:bg-[#1a1a1a]',
|
176 |
+
'border-b border-[#E5E5E5] dark:border-[#1A1A1A]',
|
177 |
+
'transition-colors duration-200',
|
178 |
+
'first:rounded-t-lg last:rounded-b-lg',
|
179 |
+
{ 'hover:rounded-lg': !isOpen },
|
180 |
+
)}
|
181 |
+
>
|
182 |
+
<div className="flex items-center gap-3">
|
183 |
+
<div className="i-ph:package text-bolt-elements-textSecondary w-4 h-4" />
|
184 |
+
<span className="text-base text-bolt-elements-textPrimary">
|
185 |
+
{title} Dependencies ({deps.length})
|
186 |
+
</span>
|
187 |
+
</div>
|
188 |
+
<div className="flex items-center gap-2">
|
189 |
+
<span className="text-sm text-bolt-elements-textSecondary">{isOpen ? 'Hide' : 'Show'}</span>
|
190 |
+
<div
|
191 |
+
className={classNames(
|
192 |
+
'i-ph:caret-down w-4 h-4 transform transition-transform duration-200 text-bolt-elements-textSecondary',
|
193 |
+
isOpen ? 'rotate-180' : '',
|
194 |
+
)}
|
195 |
+
/>
|
196 |
+
</div>
|
197 |
+
</CollapsibleTrigger>
|
198 |
+
<CollapsibleContent>
|
199 |
+
<ScrollArea
|
200 |
+
className={classNames(
|
201 |
+
'h-[200px] w-full',
|
202 |
+
'bg-white dark:bg-[#0A0A0A]',
|
203 |
+
'border-b border-[#E5E5E5] dark:border-[#1A1A1A]',
|
204 |
+
'last:rounded-b-lg last:border-b-0',
|
205 |
+
)}
|
206 |
+
>
|
207 |
+
<div className="space-y-2 p-4">
|
208 |
+
{deps.map((dep) => (
|
209 |
+
<div key={dep.name} className="flex items-center justify-between text-sm">
|
210 |
+
<span className="text-bolt-elements-textPrimary">{dep.name}</span>
|
211 |
+
<span className="text-bolt-elements-textSecondary">{dep.version}</span>
|
212 |
+
</div>
|
213 |
+
))}
|
214 |
+
</div>
|
215 |
+
</ScrollArea>
|
216 |
+
</CollapsibleContent>
|
217 |
+
</Collapsible>
|
218 |
+
);
|
219 |
+
};
|
220 |
+
|
221 |
+
export default function DebugTab() {
|
222 |
+
const [systemInfo, setSystemInfo] = useState<SystemInfo | null>(null);
|
223 |
+
const [webAppInfo, setWebAppInfo] = useState<WebAppInfo | null>(null);
|
224 |
+
const [ollamaStatus, setOllamaStatus] = useState<OllamaServiceStatus>({
|
225 |
+
isRunning: false,
|
226 |
+
lastChecked: new Date(),
|
227 |
+
});
|
228 |
+
const [loading, setLoading] = useState({
|
229 |
+
systemInfo: false,
|
230 |
+
webAppInfo: false,
|
231 |
+
errors: false,
|
232 |
+
performance: false,
|
233 |
+
});
|
234 |
+
const [openSections, setOpenSections] = useState({
|
235 |
+
system: false,
|
236 |
+
webapp: false,
|
237 |
+
errors: false,
|
238 |
+
performance: false,
|
239 |
+
});
|
240 |
+
|
241 |
+
const { providers } = useSettings();
|
242 |
+
|
243 |
+
// Subscribe to logStore updates
|
244 |
+
const logs = useStore(logStore.logs);
|
245 |
+
const errorLogs = useMemo(() => {
|
246 |
+
return Object.values(logs).filter(
|
247 |
+
(log): log is LogEntry => typeof log === 'object' && log !== null && 'level' in log && log.level === 'error',
|
248 |
+
);
|
249 |
+
}, [logs]);
|
250 |
+
|
251 |
+
// Set up error listeners when component mounts
|
252 |
+
useEffect(() => {
|
253 |
+
const handleError = (event: ErrorEvent) => {
|
254 |
+
logStore.logError(event.message, event.error, {
|
255 |
+
filename: event.filename,
|
256 |
+
lineNumber: event.lineno,
|
257 |
+
columnNumber: event.colno,
|
258 |
+
});
|
259 |
+
};
|
260 |
+
|
261 |
+
const handleRejection = (event: PromiseRejectionEvent) => {
|
262 |
+
logStore.logError('Unhandled Promise Rejection', event.reason);
|
263 |
+
};
|
264 |
+
|
265 |
+
window.addEventListener('error', handleError);
|
266 |
+
window.addEventListener('unhandledrejection', handleRejection);
|
267 |
+
|
268 |
+
return () => {
|
269 |
+
window.removeEventListener('error', handleError);
|
270 |
+
window.removeEventListener('unhandledrejection', handleRejection);
|
271 |
+
};
|
272 |
+
}, []);
|
273 |
+
|
274 |
+
// Check for errors when the errors section is opened
|
275 |
+
useEffect(() => {
|
276 |
+
if (openSections.errors) {
|
277 |
+
checkErrors();
|
278 |
+
}
|
279 |
+
}, [openSections.errors]);
|
280 |
+
|
281 |
+
// Load initial data when component mounts
|
282 |
+
useEffect(() => {
|
283 |
+
const loadInitialData = async () => {
|
284 |
+
await Promise.all([getSystemInfo(), getWebAppInfo()]);
|
285 |
+
};
|
286 |
+
|
287 |
+
loadInitialData();
|
288 |
+
}, []);
|
289 |
+
|
290 |
+
// Refresh data when sections are opened
|
291 |
+
useEffect(() => {
|
292 |
+
if (openSections.system) {
|
293 |
+
getSystemInfo();
|
294 |
+
}
|
295 |
+
|
296 |
+
if (openSections.webapp) {
|
297 |
+
getWebAppInfo();
|
298 |
+
}
|
299 |
+
}, [openSections.system, openSections.webapp]);
|
300 |
+
|
301 |
+
// Add periodic refresh of git info
|
302 |
+
useEffect(() => {
|
303 |
+
if (!openSections.webapp) {
|
304 |
+
return undefined;
|
305 |
+
}
|
306 |
+
|
307 |
+
// Initial fetch
|
308 |
+
const fetchGitInfo = async () => {
|
309 |
+
try {
|
310 |
+
const response = await fetch('/api/system/git-info');
|
311 |
+
const updatedGitInfo = (await response.json()) as GitInfo;
|
312 |
+
|
313 |
+
setWebAppInfo((prev) => {
|
314 |
+
if (!prev) {
|
315 |
+
return null;
|
316 |
+
}
|
317 |
+
|
318 |
+
// Only update if the data has changed
|
319 |
+
if (JSON.stringify(prev.gitInfo) === JSON.stringify(updatedGitInfo)) {
|
320 |
+
return prev;
|
321 |
+
}
|
322 |
+
|
323 |
+
return {
|
324 |
+
...prev,
|
325 |
+
gitInfo: updatedGitInfo,
|
326 |
+
};
|
327 |
+
});
|
328 |
+
} catch (error) {
|
329 |
+
console.error('Failed to fetch git info:', error);
|
330 |
+
}
|
331 |
+
};
|
332 |
+
|
333 |
+
fetchGitInfo();
|
334 |
+
|
335 |
+
// Refresh every 5 minutes instead of every second
|
336 |
+
const interval = setInterval(fetchGitInfo, 5 * 60 * 1000);
|
337 |
+
|
338 |
+
return () => clearInterval(interval);
|
339 |
+
}, [openSections.webapp]);
|
340 |
+
|
341 |
+
const getSystemInfo = async () => {
|
342 |
+
try {
|
343 |
+
setLoading((prev) => ({ ...prev, systemInfo: true }));
|
344 |
+
|
345 |
+
// Get browser info
|
346 |
+
const ua = navigator.userAgent;
|
347 |
+
const browserName = ua.includes('Firefox')
|
348 |
+
? 'Firefox'
|
349 |
+
: ua.includes('Chrome')
|
350 |
+
? 'Chrome'
|
351 |
+
: ua.includes('Safari')
|
352 |
+
? 'Safari'
|
353 |
+
: ua.includes('Edge')
|
354 |
+
? 'Edge'
|
355 |
+
: 'Unknown';
|
356 |
+
const browserVersion = ua.match(/(Firefox|Chrome|Safari|Edge)\/([0-9.]+)/)?.[2] || 'Unknown';
|
357 |
+
|
358 |
+
// Get performance metrics
|
359 |
+
const memory = (performance as any).memory || {};
|
360 |
+
const timing = performance.timing;
|
361 |
+
const navigation = performance.navigation;
|
362 |
+
const connection = (navigator as any).connection;
|
363 |
+
|
364 |
+
// Get battery info
|
365 |
+
let batteryInfo;
|
366 |
+
|
367 |
+
try {
|
368 |
+
const battery = await (navigator as any).getBattery();
|
369 |
+
batteryInfo = {
|
370 |
+
charging: battery.charging,
|
371 |
+
chargingTime: battery.chargingTime,
|
372 |
+
dischargingTime: battery.dischargingTime,
|
373 |
+
level: battery.level * 100,
|
374 |
+
};
|
375 |
+
} catch {
|
376 |
+
console.log('Battery API not supported');
|
377 |
+
}
|
378 |
+
|
379 |
+
// Get storage info
|
380 |
+
let storageInfo = {
|
381 |
+
quota: 0,
|
382 |
+
usage: 0,
|
383 |
+
persistent: false,
|
384 |
+
temporary: false,
|
385 |
+
};
|
386 |
+
|
387 |
+
try {
|
388 |
+
const storage = await navigator.storage.estimate();
|
389 |
+
const persistent = await navigator.storage.persist();
|
390 |
+
storageInfo = {
|
391 |
+
quota: storage.quota || 0,
|
392 |
+
usage: storage.usage || 0,
|
393 |
+
persistent,
|
394 |
+
temporary: !persistent,
|
395 |
+
};
|
396 |
+
} catch {
|
397 |
+
console.log('Storage API not supported');
|
398 |
+
}
|
399 |
+
|
400 |
+
// Get memory info from browser performance API
|
401 |
+
const performanceMemory = (performance as any).memory || {};
|
402 |
+
const totalMemory = performanceMemory.jsHeapSizeLimit || 0;
|
403 |
+
const usedMemory = performanceMemory.usedJSHeapSize || 0;
|
404 |
+
const freeMemory = totalMemory - usedMemory;
|
405 |
+
const memoryPercentage = totalMemory ? (usedMemory / totalMemory) * 100 : 0;
|
406 |
+
|
407 |
+
const systemInfo: SystemInfo = {
|
408 |
+
os: navigator.platform,
|
409 |
+
arch: navigator.userAgent.includes('x64') ? 'x64' : navigator.userAgent.includes('arm') ? 'arm' : 'unknown',
|
410 |
+
platform: navigator.platform,
|
411 |
+
cpus: navigator.hardwareConcurrency + ' cores',
|
412 |
+
memory: {
|
413 |
+
total: formatBytes(totalMemory),
|
414 |
+
free: formatBytes(freeMemory),
|
415 |
+
used: formatBytes(usedMemory),
|
416 |
+
percentage: Math.round(memoryPercentage),
|
417 |
+
},
|
418 |
+
node: 'browser',
|
419 |
+
browser: {
|
420 |
+
name: browserName,
|
421 |
+
version: browserVersion,
|
422 |
+
language: navigator.language,
|
423 |
+
userAgent: navigator.userAgent,
|
424 |
+
cookiesEnabled: navigator.cookieEnabled,
|
425 |
+
online: navigator.onLine,
|
426 |
+
platform: navigator.platform,
|
427 |
+
cores: navigator.hardwareConcurrency,
|
428 |
+
},
|
429 |
+
screen: {
|
430 |
+
width: window.screen.width,
|
431 |
+
height: window.screen.height,
|
432 |
+
colorDepth: window.screen.colorDepth,
|
433 |
+
pixelRatio: window.devicePixelRatio,
|
434 |
+
},
|
435 |
+
time: {
|
436 |
+
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
437 |
+
offset: new Date().getTimezoneOffset(),
|
438 |
+
locale: navigator.language,
|
439 |
+
},
|
440 |
+
performance: {
|
441 |
+
memory: {
|
442 |
+
jsHeapSizeLimit: memory.jsHeapSizeLimit || 0,
|
443 |
+
totalJSHeapSize: memory.totalJSHeapSize || 0,
|
444 |
+
usedJSHeapSize: memory.usedJSHeapSize || 0,
|
445 |
+
usagePercentage: memory.totalJSHeapSize ? (memory.usedJSHeapSize / memory.totalJSHeapSize) * 100 : 0,
|
446 |
+
},
|
447 |
+
timing: {
|
448 |
+
loadTime: timing.loadEventEnd - timing.navigationStart,
|
449 |
+
domReadyTime: timing.domContentLoadedEventEnd - timing.navigationStart,
|
450 |
+
readyStart: timing.fetchStart - timing.navigationStart,
|
451 |
+
redirectTime: timing.redirectEnd - timing.redirectStart,
|
452 |
+
appcacheTime: timing.domainLookupStart - timing.fetchStart,
|
453 |
+
unloadEventTime: timing.unloadEventEnd - timing.unloadEventStart,
|
454 |
+
lookupDomainTime: timing.domainLookupEnd - timing.domainLookupStart,
|
455 |
+
connectTime: timing.connectEnd - timing.connectStart,
|
456 |
+
requestTime: timing.responseEnd - timing.requestStart,
|
457 |
+
initDomTreeTime: timing.domInteractive - timing.responseEnd,
|
458 |
+
loadEventTime: timing.loadEventEnd - timing.loadEventStart,
|
459 |
+
},
|
460 |
+
navigation: {
|
461 |
+
type: navigation.type,
|
462 |
+
redirectCount: navigation.redirectCount,
|
463 |
+
},
|
464 |
+
},
|
465 |
+
network: {
|
466 |
+
downlink: connection?.downlink || 0,
|
467 |
+
effectiveType: connection?.effectiveType || 'unknown',
|
468 |
+
rtt: connection?.rtt || 0,
|
469 |
+
saveData: connection?.saveData || false,
|
470 |
+
type: connection?.type || 'unknown',
|
471 |
+
},
|
472 |
+
battery: batteryInfo,
|
473 |
+
storage: storageInfo,
|
474 |
+
};
|
475 |
+
|
476 |
+
setSystemInfo(systemInfo);
|
477 |
+
toast.success('System information updated');
|
478 |
+
} catch (error) {
|
479 |
+
toast.error('Failed to get system information');
|
480 |
+
console.error('Failed to get system information:', error);
|
481 |
+
} finally {
|
482 |
+
setLoading((prev) => ({ ...prev, systemInfo: false }));
|
483 |
+
}
|
484 |
+
};
|
485 |
+
|
486 |
+
const getWebAppInfo = async () => {
|
487 |
+
try {
|
488 |
+
setLoading((prev) => ({ ...prev, webAppInfo: true }));
|
489 |
+
|
490 |
+
const [appResponse, gitResponse] = await Promise.all([
|
491 |
+
fetch('/api/system/app-info'),
|
492 |
+
fetch('/api/system/git-info'),
|
493 |
+
]);
|
494 |
+
|
495 |
+
if (!appResponse.ok || !gitResponse.ok) {
|
496 |
+
throw new Error('Failed to fetch webapp info');
|
497 |
+
}
|
498 |
+
|
499 |
+
const appData = (await appResponse.json()) as Omit<WebAppInfo, 'gitInfo'>;
|
500 |
+
const gitData = (await gitResponse.json()) as GitInfo;
|
501 |
+
|
502 |
+
console.log('Git Info Response:', gitData); // Add logging to debug
|
503 |
+
|
504 |
+
setWebAppInfo({
|
505 |
+
...appData,
|
506 |
+
gitInfo: gitData,
|
507 |
+
});
|
508 |
+
|
509 |
+
toast.success('WebApp information updated');
|
510 |
+
|
511 |
+
return true;
|
512 |
+
} catch (error) {
|
513 |
+
console.error('Failed to fetch webapp info:', error);
|
514 |
+
toast.error('Failed to fetch webapp information');
|
515 |
+
setWebAppInfo(null);
|
516 |
+
|
517 |
+
return false;
|
518 |
+
} finally {
|
519 |
+
setLoading((prev) => ({ ...prev, webAppInfo: false }));
|
520 |
+
}
|
521 |
+
};
|
522 |
+
|
523 |
+
// Helper function to format bytes to human readable format
|
524 |
+
const formatBytes = (bytes: number) => {
|
525 |
+
const units = ['B', 'KB', 'MB', 'GB'];
|
526 |
+
let size = bytes;
|
527 |
+
let unitIndex = 0;
|
528 |
+
|
529 |
+
while (size >= 1024 && unitIndex < units.length - 1) {
|
530 |
+
size /= 1024;
|
531 |
+
unitIndex++;
|
532 |
+
}
|
533 |
+
|
534 |
+
return `${Math.round(size)} ${units[unitIndex]}`;
|
535 |
+
};
|
536 |
+
|
537 |
+
const handleLogPerformance = () => {
|
538 |
+
try {
|
539 |
+
setLoading((prev) => ({ ...prev, performance: true }));
|
540 |
+
|
541 |
+
// Get performance metrics using modern Performance API
|
542 |
+
const performanceEntries = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
|
543 |
+
const memory = (performance as any).memory;
|
544 |
+
|
545 |
+
// Calculate timing metrics
|
546 |
+
const timingMetrics = {
|
547 |
+
loadTime: performanceEntries.loadEventEnd - performanceEntries.startTime,
|
548 |
+
domReadyTime: performanceEntries.domContentLoadedEventEnd - performanceEntries.startTime,
|
549 |
+
fetchTime: performanceEntries.responseEnd - performanceEntries.fetchStart,
|
550 |
+
redirectTime: performanceEntries.redirectEnd - performanceEntries.redirectStart,
|
551 |
+
dnsTime: performanceEntries.domainLookupEnd - performanceEntries.domainLookupStart,
|
552 |
+
tcpTime: performanceEntries.connectEnd - performanceEntries.connectStart,
|
553 |
+
ttfb: performanceEntries.responseStart - performanceEntries.requestStart,
|
554 |
+
processingTime: performanceEntries.loadEventEnd - performanceEntries.responseEnd,
|
555 |
+
};
|
556 |
+
|
557 |
+
// Get resource timing data
|
558 |
+
const resourceEntries = performance.getEntriesByType('resource');
|
559 |
+
const resourceStats = {
|
560 |
+
totalResources: resourceEntries.length,
|
561 |
+
totalSize: resourceEntries.reduce((total, entry) => total + ((entry as any).transferSize || 0), 0),
|
562 |
+
totalTime: Math.max(...resourceEntries.map((entry) => entry.duration)),
|
563 |
+
};
|
564 |
+
|
565 |
+
// Get memory metrics
|
566 |
+
const memoryMetrics = memory
|
567 |
+
? {
|
568 |
+
jsHeapSizeLimit: memory.jsHeapSizeLimit,
|
569 |
+
totalJSHeapSize: memory.totalJSHeapSize,
|
570 |
+
usedJSHeapSize: memory.usedJSHeapSize,
|
571 |
+
heapUtilization: (memory.usedJSHeapSize / memory.totalJSHeapSize) * 100,
|
572 |
+
}
|
573 |
+
: null;
|
574 |
+
|
575 |
+
// Get frame rate metrics
|
576 |
+
let fps = 0;
|
577 |
+
|
578 |
+
if ('requestAnimationFrame' in window) {
|
579 |
+
const times: number[] = [];
|
580 |
+
|
581 |
+
function calculateFPS(now: number) {
|
582 |
+
times.push(now);
|
583 |
+
|
584 |
+
if (times.length > 10) {
|
585 |
+
const fps = Math.round((1000 * 10) / (now - times[0]));
|
586 |
+
times.shift();
|
587 |
+
|
588 |
+
return fps;
|
589 |
+
}
|
590 |
+
|
591 |
+
requestAnimationFrame(calculateFPS);
|
592 |
+
|
593 |
+
return 0;
|
594 |
+
}
|
595 |
+
|
596 |
+
fps = calculateFPS(performance.now());
|
597 |
+
}
|
598 |
+
|
599 |
+
// Log all performance metrics
|
600 |
+
logStore.logSystem('Performance Metrics', {
|
601 |
+
timing: timingMetrics,
|
602 |
+
resources: resourceStats,
|
603 |
+
memory: memoryMetrics,
|
604 |
+
fps,
|
605 |
+
timestamp: new Date().toISOString(),
|
606 |
+
navigationEntry: {
|
607 |
+
type: performanceEntries.type,
|
608 |
+
redirectCount: performanceEntries.redirectCount,
|
609 |
+
},
|
610 |
+
});
|
611 |
+
|
612 |
+
toast.success('Performance metrics logged');
|
613 |
+
} catch (error) {
|
614 |
+
toast.error('Failed to log performance metrics');
|
615 |
+
console.error('Failed to log performance metrics:', error);
|
616 |
+
} finally {
|
617 |
+
setLoading((prev) => ({ ...prev, performance: false }));
|
618 |
+
}
|
619 |
+
};
|
620 |
+
|
621 |
+
const checkErrors = async () => {
|
622 |
+
try {
|
623 |
+
setLoading((prev) => ({ ...prev, errors: true }));
|
624 |
+
|
625 |
+
// Get errors from log store
|
626 |
+
const storedErrors = errorLogs;
|
627 |
+
|
628 |
+
if (storedErrors.length === 0) {
|
629 |
+
toast.success('No errors found');
|
630 |
+
} else {
|
631 |
+
toast.warning(`Found ${storedErrors.length} error(s)`);
|
632 |
+
}
|
633 |
+
} catch (error) {
|
634 |
+
toast.error('Failed to check errors');
|
635 |
+
console.error('Failed to check errors:', error);
|
636 |
+
} finally {
|
637 |
+
setLoading((prev) => ({ ...prev, errors: false }));
|
638 |
+
}
|
639 |
+
};
|
640 |
+
|
641 |
+
const exportDebugInfo = () => {
|
642 |
+
try {
|
643 |
+
const debugData = {
|
644 |
+
timestamp: new Date().toISOString(),
|
645 |
+
system: systemInfo,
|
646 |
+
webApp: webAppInfo,
|
647 |
+
errors: logStore.getLogs().filter((log: LogEntry) => log.level === 'error'),
|
648 |
+
performance: {
|
649 |
+
memory: (performance as any).memory || {},
|
650 |
+
timing: performance.timing,
|
651 |
+
navigation: performance.navigation,
|
652 |
+
},
|
653 |
+
};
|
654 |
+
|
655 |
+
const blob = new Blob([JSON.stringify(debugData, null, 2)], { type: 'application/json' });
|
656 |
+
const url = window.URL.createObjectURL(blob);
|
657 |
+
const a = document.createElement('a');
|
658 |
+
a.href = url;
|
659 |
+
a.download = `bolt-debug-info-${new Date().toISOString()}.json`;
|
660 |
+
document.body.appendChild(a);
|
661 |
+
a.click();
|
662 |
+
window.URL.revokeObjectURL(url);
|
663 |
+
document.body.removeChild(a);
|
664 |
+
toast.success('Debug information exported successfully');
|
665 |
+
} catch (error) {
|
666 |
+
console.error('Failed to export debug info:', error);
|
667 |
+
toast.error('Failed to export debug information');
|
668 |
+
}
|
669 |
+
};
|
670 |
+
|
671 |
+
const exportAsCSV = () => {
|
672 |
+
try {
|
673 |
+
const debugData = {
|
674 |
+
system: systemInfo,
|
675 |
+
webApp: webAppInfo,
|
676 |
+
errors: logStore.getLogs().filter((log: LogEntry) => log.level === 'error'),
|
677 |
+
performance: {
|
678 |
+
memory: (performance as any).memory || {},
|
679 |
+
timing: performance.timing,
|
680 |
+
navigation: performance.navigation,
|
681 |
+
},
|
682 |
+
};
|
683 |
+
|
684 |
+
// Convert the data to CSV format
|
685 |
+
const csvData = [
|
686 |
+
['Category', 'Key', 'Value'],
|
687 |
+
...Object.entries(debugData).flatMap(([category, data]) =>
|
688 |
+
Object.entries(data || {}).map(([key, value]) => [
|
689 |
+
category,
|
690 |
+
key,
|
691 |
+
typeof value === 'object' ? JSON.stringify(value) : String(value),
|
692 |
+
]),
|
693 |
+
),
|
694 |
+
];
|
695 |
+
|
696 |
+
// Create CSV content
|
697 |
+
const csvContent = csvData.map((row) => row.join(',')).join('\n');
|
698 |
+
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
699 |
+
const url = window.URL.createObjectURL(blob);
|
700 |
+
const a = document.createElement('a');
|
701 |
+
a.href = url;
|
702 |
+
a.download = `bolt-debug-info-${new Date().toISOString()}.csv`;
|
703 |
+
document.body.appendChild(a);
|
704 |
+
a.click();
|
705 |
+
window.URL.revokeObjectURL(url);
|
706 |
+
document.body.removeChild(a);
|
707 |
+
toast.success('Debug information exported as CSV');
|
708 |
+
} catch (error) {
|
709 |
+
console.error('Failed to export CSV:', error);
|
710 |
+
toast.error('Failed to export debug information as CSV');
|
711 |
+
}
|
712 |
+
};
|
713 |
+
|
714 |
+
const exportAsPDF = () => {
|
715 |
+
try {
|
716 |
+
const debugData = {
|
717 |
+
system: systemInfo,
|
718 |
+
webApp: webAppInfo,
|
719 |
+
errors: logStore.getLogs().filter((log: LogEntry) => log.level === 'error'),
|
720 |
+
performance: {
|
721 |
+
memory: (performance as any).memory || {},
|
722 |
+
timing: performance.timing,
|
723 |
+
navigation: performance.navigation,
|
724 |
+
},
|
725 |
+
};
|
726 |
+
|
727 |
+
// Create new PDF document
|
728 |
+
const doc = new jsPDF();
|
729 |
+
const lineHeight = 7;
|
730 |
+
let yPos = 20;
|
731 |
+
const margin = 20;
|
732 |
+
const pageWidth = doc.internal.pageSize.getWidth();
|
733 |
+
const maxLineWidth = pageWidth - 2 * margin;
|
734 |
+
|
735 |
+
// Add key-value pair with better formatting
|
736 |
+
const addKeyValue = (key: string, value: any, indent = 0) => {
|
737 |
+
// Check if we need a new page
|
738 |
+
if (yPos > doc.internal.pageSize.getHeight() - 20) {
|
739 |
+
doc.addPage();
|
740 |
+
yPos = margin;
|
741 |
+
}
|
742 |
+
|
743 |
+
doc.setFontSize(10);
|
744 |
+
doc.setTextColor('#374151');
|
745 |
+
doc.setFont('helvetica', 'bold');
|
746 |
+
|
747 |
+
// Format the key with proper spacing
|
748 |
+
const formattedKey = key.replace(/([A-Z])/g, ' $1').trim();
|
749 |
+
doc.text(formattedKey + ':', margin + indent, yPos);
|
750 |
+
doc.setFont('helvetica', 'normal');
|
751 |
+
doc.setTextColor('#6B7280');
|
752 |
+
|
753 |
+
let valueText;
|
754 |
+
|
755 |
+
if (typeof value === 'object' && value !== null) {
|
756 |
+
// Skip rendering if value is empty object
|
757 |
+
if (Object.keys(value).length === 0) {
|
758 |
+
return;
|
759 |
+
}
|
760 |
+
|
761 |
+
yPos += lineHeight;
|
762 |
+
Object.entries(value).forEach(([subKey, subValue]) => {
|
763 |
+
// Check for page break before each sub-item
|
764 |
+
if (yPos > doc.internal.pageSize.getHeight() - 20) {
|
765 |
+
doc.addPage();
|
766 |
+
yPos = margin;
|
767 |
+
}
|
768 |
+
|
769 |
+
const formattedSubKey = subKey.replace(/([A-Z])/g, ' $1').trim();
|
770 |
+
addKeyValue(formattedSubKey, subValue, indent + 10);
|
771 |
+
});
|
772 |
+
|
773 |
+
return;
|
774 |
+
} else {
|
775 |
+
valueText = String(value);
|
776 |
+
}
|
777 |
+
|
778 |
+
const valueX = margin + indent + doc.getTextWidth(formattedKey + ': ');
|
779 |
+
const maxValueWidth = maxLineWidth - indent - doc.getTextWidth(formattedKey + ': ');
|
780 |
+
const lines = doc.splitTextToSize(valueText, maxValueWidth);
|
781 |
+
|
782 |
+
// Check if we need a new page for the value
|
783 |
+
if (yPos + lines.length * lineHeight > doc.internal.pageSize.getHeight() - 20) {
|
784 |
+
doc.addPage();
|
785 |
+
yPos = margin;
|
786 |
+
}
|
787 |
+
|
788 |
+
doc.text(lines, valueX, yPos);
|
789 |
+
yPos += lines.length * lineHeight;
|
790 |
+
};
|
791 |
+
|
792 |
+
// Add section header with page break check
|
793 |
+
const addSectionHeader = (title: string) => {
|
794 |
+
// Check if we need a new page
|
795 |
+
if (yPos + 20 > doc.internal.pageSize.getHeight() - 20) {
|
796 |
+
doc.addPage();
|
797 |
+
yPos = margin;
|
798 |
+
}
|
799 |
+
|
800 |
+
yPos += lineHeight;
|
801 |
+
doc.setFillColor('#F3F4F6');
|
802 |
+
doc.rect(margin - 2, yPos - 5, pageWidth - 2 * (margin - 2), lineHeight + 6, 'F');
|
803 |
+
doc.setFont('helvetica', 'bold');
|
804 |
+
doc.setTextColor('#111827');
|
805 |
+
doc.setFontSize(12);
|
806 |
+
doc.text(title.toUpperCase(), margin, yPos);
|
807 |
+
doc.setFont('helvetica', 'normal');
|
808 |
+
yPos += lineHeight * 1.5;
|
809 |
+
};
|
810 |
+
|
811 |
+
// Add horizontal line with page break check
|
812 |
+
const addHorizontalLine = () => {
|
813 |
+
// Check if we need a new page
|
814 |
+
if (yPos + 10 > doc.internal.pageSize.getHeight() - 20) {
|
815 |
+
doc.addPage();
|
816 |
+
yPos = margin;
|
817 |
+
|
818 |
+
return; // Skip drawing line if we just started a new page
|
819 |
+
}
|
820 |
+
|
821 |
+
doc.setDrawColor('#E5E5E5');
|
822 |
+
doc.line(margin, yPos, pageWidth - margin, yPos);
|
823 |
+
yPos += lineHeight;
|
824 |
+
};
|
825 |
+
|
826 |
+
// Helper function to add footer to all pages
|
827 |
+
const addFooters = () => {
|
828 |
+
const totalPages = doc.internal.pages.length - 1;
|
829 |
+
|
830 |
+
for (let i = 1; i <= totalPages; i++) {
|
831 |
+
doc.setPage(i);
|
832 |
+
doc.setFontSize(8);
|
833 |
+
doc.setTextColor('#9CA3AF');
|
834 |
+
doc.text(`Page ${i} of ${totalPages}`, pageWidth / 2, doc.internal.pageSize.getHeight() - 10, {
|
835 |
+
align: 'center',
|
836 |
+
});
|
837 |
+
}
|
838 |
+
};
|
839 |
+
|
840 |
+
// Title and Header (first page only)
|
841 |
+
doc.setFillColor('#6366F1');
|
842 |
+
doc.rect(0, 0, pageWidth, 40, 'F');
|
843 |
+
doc.setTextColor('#FFFFFF');
|
844 |
+
doc.setFontSize(24);
|
845 |
+
doc.setFont('helvetica', 'bold');
|
846 |
+
doc.text('Debug Information Report', margin, 25);
|
847 |
+
yPos = 50;
|
848 |
+
|
849 |
+
// Timestamp and metadata
|
850 |
+
doc.setTextColor('#6B7280');
|
851 |
+
doc.setFontSize(10);
|
852 |
+
doc.setFont('helvetica', 'normal');
|
853 |
+
|
854 |
+
const timestamp = new Date().toLocaleString(undefined, {
|
855 |
+
year: 'numeric',
|
856 |
+
month: '2-digit',
|
857 |
+
day: '2-digit',
|
858 |
+
hour: '2-digit',
|
859 |
+
minute: '2-digit',
|
860 |
+
second: '2-digit',
|
861 |
+
});
|
862 |
+
doc.text(`Generated: ${timestamp}`, margin, yPos);
|
863 |
+
yPos += lineHeight * 2;
|
864 |
+
|
865 |
+
// System Information Section
|
866 |
+
if (debugData.system) {
|
867 |
+
addSectionHeader('System Information');
|
868 |
+
|
869 |
+
// OS and Architecture
|
870 |
+
addKeyValue('Operating System', debugData.system.os);
|
871 |
+
addKeyValue('Architecture', debugData.system.arch);
|
872 |
+
addKeyValue('Platform', debugData.system.platform);
|
873 |
+
addKeyValue('CPU Cores', debugData.system.cpus);
|
874 |
+
|
875 |
+
// Memory
|
876 |
+
const memory = debugData.system.memory;
|
877 |
+
addKeyValue('Memory', {
|
878 |
+
'Total Memory': memory.total,
|
879 |
+
'Used Memory': memory.used,
|
880 |
+
'Free Memory': memory.free,
|
881 |
+
Usage: memory.percentage + '%',
|
882 |
+
});
|
883 |
+
|
884 |
+
// Browser Information
|
885 |
+
const browser = debugData.system.browser;
|
886 |
+
addKeyValue('Browser', {
|
887 |
+
Name: browser.name,
|
888 |
+
Version: browser.version,
|
889 |
+
Language: browser.language,
|
890 |
+
Platform: browser.platform,
|
891 |
+
'Cookies Enabled': browser.cookiesEnabled ? 'Yes' : 'No',
|
892 |
+
'Online Status': browser.online ? 'Online' : 'Offline',
|
893 |
+
});
|
894 |
+
|
895 |
+
// Screen Information
|
896 |
+
const screen = debugData.system.screen;
|
897 |
+
addKeyValue('Screen', {
|
898 |
+
Resolution: `${screen.width}x${screen.height}`,
|
899 |
+
'Color Depth': screen.colorDepth + ' bit',
|
900 |
+
'Pixel Ratio': screen.pixelRatio + 'x',
|
901 |
+
});
|
902 |
+
|
903 |
+
// Time Information
|
904 |
+
const time = debugData.system.time;
|
905 |
+
addKeyValue('Time Settings', {
|
906 |
+
Timezone: time.timezone,
|
907 |
+
'UTC Offset': time.offset / 60 + ' hours',
|
908 |
+
Locale: time.locale,
|
909 |
+
});
|
910 |
+
|
911 |
+
addHorizontalLine();
|
912 |
+
}
|
913 |
+
|
914 |
+
// Web App Information Section
|
915 |
+
if (debugData.webApp) {
|
916 |
+
addSectionHeader('Web App Information');
|
917 |
+
|
918 |
+
// Basic Info
|
919 |
+
addKeyValue('Application', {
|
920 |
+
Name: debugData.webApp.name,
|
921 |
+
Version: debugData.webApp.version,
|
922 |
+
Environment: debugData.webApp.environment,
|
923 |
+
'Node Version': debugData.webApp.runtimeInfo.nodeVersion,
|
924 |
+
});
|
925 |
+
|
926 |
+
// Git Information
|
927 |
+
if (debugData.webApp.gitInfo) {
|
928 |
+
const gitInfo = debugData.webApp.gitInfo.local;
|
929 |
+
addKeyValue('Git Information', {
|
930 |
+
Branch: gitInfo.branch,
|
931 |
+
Commit: gitInfo.commitHash,
|
932 |
+
Author: gitInfo.author,
|
933 |
+
'Commit Time': gitInfo.commitTime,
|
934 |
+
Repository: gitInfo.repoName,
|
935 |
+
});
|
936 |
+
|
937 |
+
if (debugData.webApp.gitInfo.github) {
|
938 |
+
const githubInfo = debugData.webApp.gitInfo.github.currentRepo;
|
939 |
+
addKeyValue('GitHub Information', {
|
940 |
+
Repository: githubInfo.fullName,
|
941 |
+
'Default Branch': githubInfo.defaultBranch,
|
942 |
+
Stars: githubInfo.stars,
|
943 |
+
Forks: githubInfo.forks,
|
944 |
+
'Open Issues': githubInfo.openIssues || 0,
|
945 |
+
});
|
946 |
+
}
|
947 |
+
}
|
948 |
+
|
949 |
+
addHorizontalLine();
|
950 |
+
}
|
951 |
+
|
952 |
+
// Performance Section
|
953 |
+
if (debugData.performance) {
|
954 |
+
addSectionHeader('Performance Metrics');
|
955 |
+
|
956 |
+
// Memory Usage
|
957 |
+
const memory = debugData.performance.memory || {};
|
958 |
+
const totalHeap = memory.totalJSHeapSize || 0;
|
959 |
+
const usedHeap = memory.usedJSHeapSize || 0;
|
960 |
+
const usagePercentage = memory.usagePercentage || 0;
|
961 |
+
|
962 |
+
addKeyValue('Memory Usage', {
|
963 |
+
'Total Heap Size': formatBytes(totalHeap),
|
964 |
+
'Used Heap Size': formatBytes(usedHeap),
|
965 |
+
Usage: usagePercentage.toFixed(1) + '%',
|
966 |
+
});
|
967 |
+
|
968 |
+
// Timing Metrics
|
969 |
+
const timing = debugData.performance.timing || {};
|
970 |
+
const navigationStart = timing.navigationStart || 0;
|
971 |
+
const loadEventEnd = timing.loadEventEnd || 0;
|
972 |
+
const domContentLoadedEventEnd = timing.domContentLoadedEventEnd || 0;
|
973 |
+
const responseEnd = timing.responseEnd || 0;
|
974 |
+
const requestStart = timing.requestStart || 0;
|
975 |
+
|
976 |
+
const loadTime = loadEventEnd > navigationStart ? loadEventEnd - navigationStart : 0;
|
977 |
+
const domReadyTime =
|
978 |
+
domContentLoadedEventEnd > navigationStart ? domContentLoadedEventEnd - navigationStart : 0;
|
979 |
+
const requestTime = responseEnd > requestStart ? responseEnd - requestStart : 0;
|
980 |
+
|
981 |
+
addKeyValue('Page Load Metrics', {
|
982 |
+
'Total Load Time': (loadTime / 1000).toFixed(2) + ' seconds',
|
983 |
+
'DOM Ready Time': (domReadyTime / 1000).toFixed(2) + ' seconds',
|
984 |
+
'Request Time': (requestTime / 1000).toFixed(2) + ' seconds',
|
985 |
+
});
|
986 |
+
|
987 |
+
// Network Information
|
988 |
+
if (debugData.system?.network) {
|
989 |
+
const network = debugData.system.network;
|
990 |
+
addKeyValue('Network Information', {
|
991 |
+
'Connection Type': network.type || 'Unknown',
|
992 |
+
'Effective Type': network.effectiveType || 'Unknown',
|
993 |
+
'Download Speed': (network.downlink || 0) + ' Mbps',
|
994 |
+
'Latency (RTT)': (network.rtt || 0) + ' ms',
|
995 |
+
'Data Saver': network.saveData ? 'Enabled' : 'Disabled',
|
996 |
+
});
|
997 |
+
}
|
998 |
+
|
999 |
+
addHorizontalLine();
|
1000 |
+
}
|
1001 |
+
|
1002 |
+
// Errors Section
|
1003 |
+
if (debugData.errors && debugData.errors.length > 0) {
|
1004 |
+
addSectionHeader('Error Log');
|
1005 |
+
|
1006 |
+
debugData.errors.forEach((error: LogEntry, index: number) => {
|
1007 |
+
doc.setTextColor('#DC2626');
|
1008 |
+
doc.setFontSize(10);
|
1009 |
+
doc.setFont('helvetica', 'bold');
|
1010 |
+
doc.text(`Error ${index + 1}:`, margin, yPos);
|
1011 |
+
yPos += lineHeight;
|
1012 |
+
|
1013 |
+
doc.setFont('helvetica', 'normal');
|
1014 |
+
doc.setTextColor('#6B7280');
|
1015 |
+
addKeyValue('Message', error.message, 10);
|
1016 |
+
|
1017 |
+
if (error.stack) {
|
1018 |
+
addKeyValue('Stack', error.stack, 10);
|
1019 |
+
}
|
1020 |
+
|
1021 |
+
if (error.source) {
|
1022 |
+
addKeyValue('Source', error.source, 10);
|
1023 |
+
}
|
1024 |
+
|
1025 |
+
yPos += lineHeight;
|
1026 |
+
});
|
1027 |
+
}
|
1028 |
+
|
1029 |
+
// Add footers to all pages at the end
|
1030 |
+
addFooters();
|
1031 |
+
|
1032 |
+
// Save the PDF
|
1033 |
+
doc.save(`bolt-debug-info-${new Date().toISOString()}.pdf`);
|
1034 |
+
toast.success('Debug information exported as PDF');
|
1035 |
+
} catch (error) {
|
1036 |
+
console.error('Failed to export PDF:', error);
|
1037 |
+
toast.error('Failed to export debug information as PDF');
|
1038 |
+
}
|
1039 |
+
};
|
1040 |
+
|
1041 |
+
const exportAsText = () => {
|
1042 |
+
try {
|
1043 |
+
const debugData = {
|
1044 |
+
system: systemInfo,
|
1045 |
+
webApp: webAppInfo,
|
1046 |
+
errors: logStore.getLogs().filter((log: LogEntry) => log.level === 'error'),
|
1047 |
+
performance: {
|
1048 |
+
memory: (performance as any).memory || {},
|
1049 |
+
timing: performance.timing,
|
1050 |
+
navigation: performance.navigation,
|
1051 |
+
},
|
1052 |
+
};
|
1053 |
+
|
1054 |
+
const textContent = Object.entries(debugData)
|
1055 |
+
.map(([category, data]) => {
|
1056 |
+
return `${category.toUpperCase()}\n${'-'.repeat(30)}\n${JSON.stringify(data, null, 2)}\n\n`;
|
1057 |
+
})
|
1058 |
+
.join('\n');
|
1059 |
+
|
1060 |
+
const blob = new Blob([textContent], { type: 'text/plain' });
|
1061 |
+
const url = window.URL.createObjectURL(blob);
|
1062 |
+
const a = document.createElement('a');
|
1063 |
+
a.href = url;
|
1064 |
+
a.download = `bolt-debug-info-${new Date().toISOString()}.txt`;
|
1065 |
+
document.body.appendChild(a);
|
1066 |
+
a.click();
|
1067 |
+
window.URL.revokeObjectURL(url);
|
1068 |
+
document.body.removeChild(a);
|
1069 |
+
toast.success('Debug information exported as text file');
|
1070 |
+
} catch (error) {
|
1071 |
+
console.error('Failed to export text file:', error);
|
1072 |
+
toast.error('Failed to export debug information as text file');
|
1073 |
+
}
|
1074 |
+
};
|
1075 |
+
|
1076 |
+
const exportFormats: ExportFormat[] = [
|
1077 |
+
{
|
1078 |
+
id: 'json',
|
1079 |
+
label: 'Export as JSON',
|
1080 |
+
icon: 'i-ph:file-json',
|
1081 |
+
handler: exportDebugInfo,
|
1082 |
+
},
|
1083 |
+
{
|
1084 |
+
id: 'csv',
|
1085 |
+
label: 'Export as CSV',
|
1086 |
+
icon: 'i-ph:file-csv',
|
1087 |
+
handler: exportAsCSV,
|
1088 |
+
},
|
1089 |
+
{
|
1090 |
+
id: 'pdf',
|
1091 |
+
label: 'Export as PDF',
|
1092 |
+
icon: 'i-ph:file-pdf',
|
1093 |
+
handler: exportAsPDF,
|
1094 |
+
},
|
1095 |
+
{
|
1096 |
+
id: 'txt',
|
1097 |
+
label: 'Export as Text',
|
1098 |
+
icon: 'i-ph:file-text',
|
1099 |
+
handler: exportAsText,
|
1100 |
+
},
|
1101 |
+
];
|
1102 |
+
|
1103 |
+
// Add Ollama health check function
|
1104 |
+
const checkOllamaStatus = useCallback(async () => {
|
1105 |
+
try {
|
1106 |
+
const ollamaProvider = providers?.Ollama;
|
1107 |
+
const baseUrl = ollamaProvider?.settings?.baseUrl || 'http://127.0.0.1:11434';
|
1108 |
+
|
1109 |
+
// First check if service is running
|
1110 |
+
const versionResponse = await fetch(`${baseUrl}/api/version`);
|
1111 |
+
|
1112 |
+
if (!versionResponse.ok) {
|
1113 |
+
throw new Error('Service not running');
|
1114 |
+
}
|
1115 |
+
|
1116 |
+
// Then fetch installed models
|
1117 |
+
const modelsResponse = await fetch(`${baseUrl}/api/tags`);
|
1118 |
+
|
1119 |
+
const modelsData = (await modelsResponse.json()) as {
|
1120 |
+
models: Array<{ name: string; size: string; quantization: string }>;
|
1121 |
+
};
|
1122 |
+
|
1123 |
+
setOllamaStatus({
|
1124 |
+
isRunning: true,
|
1125 |
+
lastChecked: new Date(),
|
1126 |
+
models: modelsData.models,
|
1127 |
+
});
|
1128 |
+
} catch {
|
1129 |
+
setOllamaStatus({
|
1130 |
+
isRunning: false,
|
1131 |
+
error: 'Connection failed',
|
1132 |
+
lastChecked: new Date(),
|
1133 |
+
models: undefined,
|
1134 |
+
});
|
1135 |
+
}
|
1136 |
+
}, [providers]);
|
1137 |
+
|
1138 |
+
// Monitor Ollama provider status and check periodically
|
1139 |
+
useEffect(() => {
|
1140 |
+
const ollamaProvider = providers?.Ollama;
|
1141 |
+
|
1142 |
+
if (ollamaProvider?.settings?.enabled) {
|
1143 |
+
// Check immediately when provider is enabled
|
1144 |
+
checkOllamaStatus();
|
1145 |
+
|
1146 |
+
// Set up periodic checks every 10 seconds
|
1147 |
+
const intervalId = setInterval(checkOllamaStatus, 10000);
|
1148 |
+
|
1149 |
+
return () => clearInterval(intervalId);
|
1150 |
+
}
|
1151 |
+
|
1152 |
+
return undefined;
|
1153 |
+
}, [providers, checkOllamaStatus]);
|
1154 |
+
|
1155 |
+
// Replace the existing export button with this new component
|
1156 |
+
const ExportButton = () => {
|
1157 |
+
const [isOpen, setIsOpen] = useState(false);
|
1158 |
+
|
1159 |
+
const handleOpenChange = useCallback((open: boolean) => {
|
1160 |
+
setIsOpen(open);
|
1161 |
+
}, []);
|
1162 |
+
|
1163 |
+
const handleFormatClick = useCallback((handler: () => void) => {
|
1164 |
+
handler();
|
1165 |
+
setIsOpen(false);
|
1166 |
+
}, []);
|
1167 |
+
|
1168 |
+
return (
|
1169 |
+
<DialogRoot open={isOpen} onOpenChange={handleOpenChange}>
|
1170 |
+
<button
|
1171 |
+
onClick={() => setIsOpen(true)}
|
1172 |
+
className={classNames(
|
1173 |
+
'group flex items-center gap-2',
|
1174 |
+
'rounded-lg px-3 py-1.5',
|
1175 |
+
'text-sm text-gray-900 dark:text-white',
|
1176 |
+
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
|
1177 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
1178 |
+
'hover:bg-purple-500/10 dark:hover:bg-purple-500/20',
|
1179 |
+
'transition-all duration-200',
|
1180 |
+
)}
|
1181 |
+
>
|
1182 |
+
<span className="i-ph:download text-lg text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
|
1183 |
+
Export
|
1184 |
+
</button>
|
1185 |
+
|
1186 |
+
<Dialog showCloseButton>
|
1187 |
+
<div className="p-6">
|
1188 |
+
<DialogTitle className="flex items-center gap-2">
|
1189 |
+
<div className="i-ph:download w-5 h-5" />
|
1190 |
+
Export Debug Information
|
1191 |
+
</DialogTitle>
|
1192 |
+
|
1193 |
+
<div className="mt-4 flex flex-col gap-2">
|
1194 |
+
{exportFormats.map((format) => (
|
1195 |
+
<button
|
1196 |
+
key={format.id}
|
1197 |
+
onClick={() => handleFormatClick(format.handler)}
|
1198 |
+
className={classNames(
|
1199 |
+
'flex items-center gap-3 px-4 py-3 text-sm rounded-lg transition-colors w-full text-left',
|
1200 |
+
'bg-white dark:bg-[#0A0A0A]',
|
1201 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
1202 |
+
'hover:bg-purple-50 dark:hover:bg-[#1a1a1a]',
|
1203 |
+
'hover:border-purple-200 dark:hover:border-purple-900/30',
|
1204 |
+
'text-bolt-elements-textPrimary',
|
1205 |
+
)}
|
1206 |
+
>
|
1207 |
+
<div className={classNames(format.icon, 'w-5 h-5')} />
|
1208 |
+
<div>
|
1209 |
+
<div className="font-medium">{format.label}</div>
|
1210 |
+
<div className="text-xs text-bolt-elements-textSecondary mt-0.5">
|
1211 |
+
{format.id === 'json' && 'Export as a structured JSON file'}
|
1212 |
+
{format.id === 'csv' && 'Export as a CSV spreadsheet'}
|
1213 |
+
{format.id === 'pdf' && 'Export as a formatted PDF document'}
|
1214 |
+
{format.id === 'txt' && 'Export as a formatted text file'}
|
1215 |
+
</div>
|
1216 |
+
</div>
|
1217 |
+
</button>
|
1218 |
+
))}
|
1219 |
+
</div>
|
1220 |
+
</div>
|
1221 |
+
</Dialog>
|
1222 |
+
</DialogRoot>
|
1223 |
+
);
|
1224 |
+
};
|
1225 |
+
|
1226 |
+
// Add helper function to get Ollama status text and color
|
1227 |
+
const getOllamaStatus = () => {
|
1228 |
+
const ollamaProvider = providers?.Ollama;
|
1229 |
+
const isOllamaEnabled = ollamaProvider?.settings?.enabled;
|
1230 |
+
|
1231 |
+
if (!isOllamaEnabled) {
|
1232 |
+
return {
|
1233 |
+
status: 'Disabled',
|
1234 |
+
color: 'text-red-500',
|
1235 |
+
bgColor: 'bg-red-500',
|
1236 |
+
message: 'Ollama provider is disabled in settings',
|
1237 |
+
};
|
1238 |
+
}
|
1239 |
+
|
1240 |
+
if (!ollamaStatus.isRunning) {
|
1241 |
+
return {
|
1242 |
+
status: 'Not Running',
|
1243 |
+
color: 'text-red-500',
|
1244 |
+
bgColor: 'bg-red-500',
|
1245 |
+
message: ollamaStatus.error || 'Ollama service is not running',
|
1246 |
+
};
|
1247 |
+
}
|
1248 |
+
|
1249 |
+
const modelCount = ollamaStatus.models?.length ?? 0;
|
1250 |
+
|
1251 |
+
return {
|
1252 |
+
status: 'Running',
|
1253 |
+
color: 'text-green-500',
|
1254 |
+
bgColor: 'bg-green-500',
|
1255 |
+
message: `Ollama service is running with ${modelCount} installed models (Provider: Enabled)`,
|
1256 |
+
};
|
1257 |
+
};
|
1258 |
+
|
1259 |
+
// Add type for status result
|
1260 |
+
type StatusResult = {
|
1261 |
+
status: string;
|
1262 |
+
color: string;
|
1263 |
+
bgColor: string;
|
1264 |
+
message: string;
|
1265 |
+
};
|
1266 |
+
|
1267 |
+
const status = getOllamaStatus() as StatusResult;
|
1268 |
+
|
1269 |
+
return (
|
1270 |
+
<div className="flex flex-col gap-6 max-w-7xl mx-auto p-4">
|
1271 |
+
{/* Quick Stats Banner */}
|
1272 |
+
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
1273 |
+
{/* Errors Card */}
|
1274 |
+
<div className="p-4 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A] hover:border-purple-500/30 transition-all duration-200 h-[180px] flex flex-col">
|
1275 |
+
<div className="flex items-center gap-2">
|
1276 |
+
<div className="i-ph:warning-octagon text-purple-500 w-4 h-4" />
|
1277 |
+
<div className="text-sm text-bolt-elements-textSecondary">Errors</div>
|
1278 |
+
</div>
|
1279 |
+
<div className="flex items-center gap-2 mt-2">
|
1280 |
+
<span
|
1281 |
+
className={classNames('text-2xl font-semibold', errorLogs.length > 0 ? 'text-red-500' : 'text-green-500')}
|
1282 |
+
>
|
1283 |
+
{errorLogs.length}
|
1284 |
+
</span>
|
1285 |
+
</div>
|
1286 |
+
<div className="text-xs text-bolt-elements-textSecondary mt-2 flex items-center gap-1.5">
|
1287 |
+
<div
|
1288 |
+
className={classNames(
|
1289 |
+
'w-3.5 h-3.5',
|
1290 |
+
errorLogs.length > 0 ? 'i-ph:warning text-red-500' : 'i-ph:check-circle text-green-500',
|
1291 |
+
)}
|
1292 |
+
/>
|
1293 |
+
{errorLogs.length > 0 ? 'Errors detected' : 'No errors detected'}
|
1294 |
+
</div>
|
1295 |
+
</div>
|
1296 |
+
|
1297 |
+
{/* Memory Usage Card */}
|
1298 |
+
<div className="p-4 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A] hover:border-purple-500/30 transition-all duration-200 h-[180px] flex flex-col">
|
1299 |
+
<div className="flex items-center gap-2">
|
1300 |
+
<div className="i-ph:cpu text-purple-500 w-4 h-4" />
|
1301 |
+
<div className="text-sm text-bolt-elements-textSecondary">Memory Usage</div>
|
1302 |
+
</div>
|
1303 |
+
<div className="flex items-center gap-2 mt-2">
|
1304 |
+
<span
|
1305 |
+
className={classNames(
|
1306 |
+
'text-2xl font-semibold',
|
1307 |
+
(systemInfo?.memory?.percentage ?? 0) > 80
|
1308 |
+
? 'text-red-500'
|
1309 |
+
: (systemInfo?.memory?.percentage ?? 0) > 60
|
1310 |
+
? 'text-yellow-500'
|
1311 |
+
: 'text-green-500',
|
1312 |
+
)}
|
1313 |
+
>
|
1314 |
+
{systemInfo?.memory?.percentage ?? 0}%
|
1315 |
+
</span>
|
1316 |
+
</div>
|
1317 |
+
<Progress
|
1318 |
+
value={systemInfo?.memory?.percentage ?? 0}
|
1319 |
+
className={classNames(
|
1320 |
+
'mt-2',
|
1321 |
+
(systemInfo?.memory?.percentage ?? 0) > 80
|
1322 |
+
? '[&>div]:bg-red-500'
|
1323 |
+
: (systemInfo?.memory?.percentage ?? 0) > 60
|
1324 |
+
? '[&>div]:bg-yellow-500'
|
1325 |
+
: '[&>div]:bg-green-500',
|
1326 |
+
)}
|
1327 |
+
/>
|
1328 |
+
<div className="text-xs text-bolt-elements-textSecondary mt-2 flex items-center gap-1.5">
|
1329 |
+
<div className="i-ph:info w-3.5 h-3.5 text-purple-500" />
|
1330 |
+
Used: {systemInfo?.memory.used ?? '0 GB'} / {systemInfo?.memory.total ?? '0 GB'}
|
1331 |
+
</div>
|
1332 |
+
</div>
|
1333 |
+
|
1334 |
+
{/* Page Load Time Card */}
|
1335 |
+
<div className="p-4 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A] hover:border-purple-500/30 transition-all duration-200 h-[180px] flex flex-col">
|
1336 |
+
<div className="flex items-center gap-2">
|
1337 |
+
<div className="i-ph:timer text-purple-500 w-4 h-4" />
|
1338 |
+
<div className="text-sm text-bolt-elements-textSecondary">Page Load Time</div>
|
1339 |
+
</div>
|
1340 |
+
<div className="flex items-center gap-2 mt-2">
|
1341 |
+
<span
|
1342 |
+
className={classNames(
|
1343 |
+
'text-2xl font-semibold',
|
1344 |
+
(systemInfo?.performance.timing.loadTime ?? 0) > 2000
|
1345 |
+
? 'text-red-500'
|
1346 |
+
: (systemInfo?.performance.timing.loadTime ?? 0) > 1000
|
1347 |
+
? 'text-yellow-500'
|
1348 |
+
: 'text-green-500',
|
1349 |
+
)}
|
1350 |
+
>
|
1351 |
+
{systemInfo ? (systemInfo.performance.timing.loadTime / 1000).toFixed(2) : '-'}s
|
1352 |
+
</span>
|
1353 |
+
</div>
|
1354 |
+
<div className="text-xs text-bolt-elements-textSecondary mt-2 flex items-center gap-1.5">
|
1355 |
+
<div className="i-ph:code w-3.5 h-3.5 text-purple-500" />
|
1356 |
+
DOM Ready: {systemInfo ? (systemInfo.performance.timing.domReadyTime / 1000).toFixed(2) : '-'}s
|
1357 |
+
</div>
|
1358 |
+
</div>
|
1359 |
+
|
1360 |
+
{/* Network Speed Card */}
|
1361 |
+
<div className="p-4 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A] hover:border-purple-500/30 transition-all duration-200 h-[180px] flex flex-col">
|
1362 |
+
<div className="flex items-center gap-2">
|
1363 |
+
<div className="i-ph:wifi-high text-purple-500 w-4 h-4" />
|
1364 |
+
<div className="text-sm text-bolt-elements-textSecondary">Network Speed</div>
|
1365 |
+
</div>
|
1366 |
+
<div className="flex items-center gap-2 mt-2">
|
1367 |
+
<span
|
1368 |
+
className={classNames(
|
1369 |
+
'text-2xl font-semibold',
|
1370 |
+
(systemInfo?.network.downlink ?? 0) < 5
|
1371 |
+
? 'text-red-500'
|
1372 |
+
: (systemInfo?.network.downlink ?? 0) < 10
|
1373 |
+
? 'text-yellow-500'
|
1374 |
+
: 'text-green-500',
|
1375 |
+
)}
|
1376 |
+
>
|
1377 |
+
{systemInfo?.network.downlink ?? '-'} Mbps
|
1378 |
+
</span>
|
1379 |
+
</div>
|
1380 |
+
<div className="text-xs text-bolt-elements-textSecondary mt-2 flex items-center gap-1.5">
|
1381 |
+
<div className="i-ph:activity w-3.5 h-3.5 text-purple-500" />
|
1382 |
+
RTT: {systemInfo?.network.rtt ?? '-'} ms
|
1383 |
+
</div>
|
1384 |
+
</div>
|
1385 |
+
|
1386 |
+
{/* Ollama Service Card - Now spans all 4 columns */}
|
1387 |
+
<div className="md:col-span-4 p-6 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A] hover:border-purple-500/30 transition-all duration-200 h-[260px] flex flex-col">
|
1388 |
+
<div className="flex items-center justify-between">
|
1389 |
+
<div className="flex items-center gap-3">
|
1390 |
+
<div className="i-ph:robot text-purple-500 w-5 h-5" />
|
1391 |
+
<div>
|
1392 |
+
<div className="text-base font-medium text-bolt-elements-textPrimary">Ollama Service</div>
|
1393 |
+
<div className="text-xs text-bolt-elements-textSecondary mt-0.5">{status.message}</div>
|
1394 |
+
</div>
|
1395 |
+
</div>
|
1396 |
+
<div className="flex items-center gap-3">
|
1397 |
+
<div className="flex items-center gap-2 px-2.5 py-1 rounded-full bg-bolt-elements-background-depth-3">
|
1398 |
+
<div
|
1399 |
+
className={classNames('w-2 h-2 rounded-full animate-pulse', status.bgColor, {
|
1400 |
+
'shadow-lg shadow-green-500/20': status.status === 'Running',
|
1401 |
+
'shadow-lg shadow-red-500/20': status.status === 'Not Running',
|
1402 |
+
})}
|
1403 |
+
/>
|
1404 |
+
<span className={classNames('text-xs font-medium flex items-center gap-1', status.color)}>
|
1405 |
+
{status.status}
|
1406 |
+
</span>
|
1407 |
+
</div>
|
1408 |
+
<div className="text-[10px] text-bolt-elements-textTertiary flex items-center gap-1.5">
|
1409 |
+
<div className="i-ph:clock w-3 h-3" />
|
1410 |
+
{ollamaStatus.lastChecked.toLocaleTimeString()}
|
1411 |
+
</div>
|
1412 |
+
</div>
|
1413 |
+
</div>
|
1414 |
+
|
1415 |
+
<div className="mt-6 flex-1 min-h-0 flex flex-col">
|
1416 |
+
{status.status === 'Running' && ollamaStatus.models && ollamaStatus.models.length > 0 ? (
|
1417 |
+
<>
|
1418 |
+
<div className="text-xs font-medium text-bolt-elements-textSecondary flex items-center justify-between mb-3">
|
1419 |
+
<div className="flex items-center gap-2">
|
1420 |
+
<div className="i-ph:cube-duotone w-4 h-4 text-purple-500" />
|
1421 |
+
<span>Installed Models</span>
|
1422 |
+
<Badge variant="secondary" className="ml-1">
|
1423 |
+
{ollamaStatus.models.length}
|
1424 |
+
</Badge>
|
1425 |
+
</div>
|
1426 |
+
</div>
|
1427 |
+
<div className="overflow-y-auto flex-1 scrollbar-thin scrollbar-thumb-gray-300 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-400 dark:hover:scrollbar-thumb-gray-600">
|
1428 |
+
<div className="grid grid-cols-2 gap-3 pr-2">
|
1429 |
+
{ollamaStatus.models.map((model) => (
|
1430 |
+
<div
|
1431 |
+
key={model.name}
|
1432 |
+
className="text-sm bg-bolt-elements-background-depth-3 hover:bg-bolt-elements-background-depth-4 rounded-lg px-4 py-3 flex items-center justify-between transition-colors group"
|
1433 |
+
>
|
1434 |
+
<div className="flex items-center gap-2 text-bolt-elements-textSecondary">
|
1435 |
+
<div className="i-ph:cube w-4 h-4 text-purple-500/70 group-hover:text-purple-500 transition-colors" />
|
1436 |
+
<span className="font-mono truncate">{model.name}</span>
|
1437 |
+
</div>
|
1438 |
+
<Badge variant="outline" className="ml-2 text-xs font-mono">
|
1439 |
+
{Math.round(parseInt(model.size) / 1024 / 1024)}MB
|
1440 |
+
</Badge>
|
1441 |
+
</div>
|
1442 |
+
))}
|
1443 |
+
</div>
|
1444 |
+
</div>
|
1445 |
+
</>
|
1446 |
+
) : (
|
1447 |
+
<div className="flex-1 flex items-center justify-center">
|
1448 |
+
<div className="flex flex-col items-center gap-3 max-w-[280px] text-center">
|
1449 |
+
<div
|
1450 |
+
className={classNames('w-12 h-12', {
|
1451 |
+
'i-ph:warning-circle text-red-500/80':
|
1452 |
+
status.status === 'Not Running' || status.status === 'Disabled',
|
1453 |
+
'i-ph:cube-duotone text-purple-500/80': status.status === 'Running',
|
1454 |
+
})}
|
1455 |
+
/>
|
1456 |
+
<span className="text-sm text-bolt-elements-textSecondary">{status.message}</span>
|
1457 |
+
</div>
|
1458 |
+
</div>
|
1459 |
+
)}
|
1460 |
+
</div>
|
1461 |
+
</div>
|
1462 |
+
</div>
|
1463 |
+
|
1464 |
+
{/* Action Buttons */}
|
1465 |
+
<div className="flex flex-wrap gap-4">
|
1466 |
+
<button
|
1467 |
+
onClick={getSystemInfo}
|
1468 |
+
disabled={loading.systemInfo}
|
1469 |
+
className={classNames(
|
1470 |
+
'flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-colors',
|
1471 |
+
'bg-white dark:bg-[#0A0A0A]',
|
1472 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
1473 |
+
'hover:bg-purple-50 dark:hover:bg-[#1a1a1a]',
|
1474 |
+
'hover:border-purple-200 dark:hover:border-purple-900/30',
|
1475 |
+
'text-bolt-elements-textPrimary',
|
1476 |
+
{ 'opacity-50 cursor-not-allowed': loading.systemInfo },
|
1477 |
+
)}
|
1478 |
+
>
|
1479 |
+
{loading.systemInfo ? (
|
1480 |
+
<div className="i-ph:spinner-gap w-4 h-4 animate-spin" />
|
1481 |
+
) : (
|
1482 |
+
<div className="i-ph:gear w-4 h-4" />
|
1483 |
+
)}
|
1484 |
+
Update System Info
|
1485 |
+
</button>
|
1486 |
+
|
1487 |
+
<button
|
1488 |
+
onClick={handleLogPerformance}
|
1489 |
+
disabled={loading.performance}
|
1490 |
+
className={classNames(
|
1491 |
+
'flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-colors',
|
1492 |
+
'bg-white dark:bg-[#0A0A0A]',
|
1493 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
1494 |
+
'hover:bg-purple-50 dark:hover:bg-[#1a1a1a]',
|
1495 |
+
'hover:border-purple-200 dark:hover:border-purple-900/30',
|
1496 |
+
'text-bolt-elements-textPrimary',
|
1497 |
+
{ 'opacity-50 cursor-not-allowed': loading.performance },
|
1498 |
+
)}
|
1499 |
+
>
|
1500 |
+
{loading.performance ? (
|
1501 |
+
<div className="i-ph:spinner-gap w-4 h-4 animate-spin" />
|
1502 |
+
) : (
|
1503 |
+
<div className="i-ph:chart-bar w-4 h-4" />
|
1504 |
+
)}
|
1505 |
+
Log Performance
|
1506 |
+
</button>
|
1507 |
+
|
1508 |
+
<button
|
1509 |
+
onClick={checkErrors}
|
1510 |
+
disabled={loading.errors}
|
1511 |
+
className={classNames(
|
1512 |
+
'flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-colors',
|
1513 |
+
'bg-white dark:bg-[#0A0A0A]',
|
1514 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
1515 |
+
'hover:bg-purple-50 dark:hover:bg-[#1a1a1a]',
|
1516 |
+
'hover:border-purple-200 dark:hover:border-purple-900/30',
|
1517 |
+
'text-bolt-elements-textPrimary',
|
1518 |
+
{ 'opacity-50 cursor-not-allowed': loading.errors },
|
1519 |
+
)}
|
1520 |
+
>
|
1521 |
+
{loading.errors ? (
|
1522 |
+
<div className="i-ph:spinner-gap w-4 h-4 animate-spin" />
|
1523 |
+
) : (
|
1524 |
+
<div className="i-ph:warning w-4 h-4" />
|
1525 |
+
)}
|
1526 |
+
Check Errors
|
1527 |
+
</button>
|
1528 |
+
|
1529 |
+
<button
|
1530 |
+
onClick={getWebAppInfo}
|
1531 |
+
disabled={loading.webAppInfo}
|
1532 |
+
className={classNames(
|
1533 |
+
'flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-colors',
|
1534 |
+
'bg-white dark:bg-[#0A0A0A]',
|
1535 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
1536 |
+
'hover:bg-purple-50 dark:hover:bg-[#1a1a1a]',
|
1537 |
+
'hover:border-purple-200 dark:hover:border-purple-900/30',
|
1538 |
+
'text-bolt-elements-textPrimary',
|
1539 |
+
{ 'opacity-50 cursor-not-allowed': loading.webAppInfo },
|
1540 |
+
)}
|
1541 |
+
>
|
1542 |
+
{loading.webAppInfo ? (
|
1543 |
+
<div className="i-ph:spinner-gap w-4 h-4 animate-spin" />
|
1544 |
+
) : (
|
1545 |
+
<div className="i-ph:info w-4 h-4" />
|
1546 |
+
)}
|
1547 |
+
Fetch WebApp Info
|
1548 |
+
</button>
|
1549 |
+
|
1550 |
+
<ExportButton />
|
1551 |
+
</div>
|
1552 |
+
|
1553 |
+
{/* System Information */}
|
1554 |
+
<Collapsible
|
1555 |
+
open={openSections.system}
|
1556 |
+
onOpenChange={(open: boolean) => setOpenSections((prev) => ({ ...prev, system: open }))}
|
1557 |
+
className="w-full"
|
1558 |
+
>
|
1559 |
+
<CollapsibleTrigger className="w-full">
|
1560 |
+
<div className="flex items-center justify-between p-6 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A]">
|
1561 |
+
<div className="flex items-center gap-3">
|
1562 |
+
<div className="i-ph:cpu text-purple-500 w-5 h-5" />
|
1563 |
+
<h3 className="text-base font-medium text-bolt-elements-textPrimary">System Information</h3>
|
1564 |
+
</div>
|
1565 |
+
<div
|
1566 |
+
className={classNames(
|
1567 |
+
'i-ph:caret-down w-4 h-4 transform transition-transform duration-200',
|
1568 |
+
openSections.system ? 'rotate-180' : '',
|
1569 |
+
)}
|
1570 |
+
/>
|
1571 |
+
</div>
|
1572 |
+
</CollapsibleTrigger>
|
1573 |
+
|
1574 |
+
<CollapsibleContent>
|
1575 |
+
<div className="p-6 mt-2 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A]">
|
1576 |
+
{systemInfo ? (
|
1577 |
+
<div className="grid grid-cols-2 gap-6">
|
1578 |
+
<div className="space-y-2">
|
1579 |
+
<div className="text-sm flex items-center gap-2">
|
1580 |
+
<div className="i-ph:desktop text-bolt-elements-textSecondary w-4 h-4" />
|
1581 |
+
<span className="text-bolt-elements-textSecondary">OS: </span>
|
1582 |
+
<span className="text-bolt-elements-textPrimary">{systemInfo.os}</span>
|
1583 |
+
</div>
|
1584 |
+
<div className="text-sm flex items-center gap-2">
|
1585 |
+
<div className="i-ph:device-mobile text-bolt-elements-textSecondary w-4 h-4" />
|
1586 |
+
<span className="text-bolt-elements-textSecondary">Platform: </span>
|
1587 |
+
<span className="text-bolt-elements-textPrimary">{systemInfo.platform}</span>
|
1588 |
+
</div>
|
1589 |
+
<div className="text-sm flex items-center gap-2">
|
1590 |
+
<div className="i-ph:microchip text-bolt-elements-textSecondary w-4 h-4" />
|
1591 |
+
<span className="text-bolt-elements-textSecondary">Architecture: </span>
|
1592 |
+
<span className="text-bolt-elements-textPrimary">{systemInfo.arch}</span>
|
1593 |
+
</div>
|
1594 |
+
<div className="text-sm flex items-center gap-2">
|
1595 |
+
<div className="i-ph:cpu text-bolt-elements-textSecondary w-4 h-4" />
|
1596 |
+
<span className="text-bolt-elements-textSecondary">CPU Cores: </span>
|
1597 |
+
<span className="text-bolt-elements-textPrimary">{systemInfo.cpus}</span>
|
1598 |
+
</div>
|
1599 |
+
<div className="text-sm flex items-center gap-2">
|
1600 |
+
<div className="i-ph:node text-bolt-elements-textSecondary w-4 h-4" />
|
1601 |
+
<span className="text-bolt-elements-textSecondary">Node Version: </span>
|
1602 |
+
<span className="text-bolt-elements-textPrimary">{systemInfo.node}</span>
|
1603 |
+
</div>
|
1604 |
+
<div className="text-sm flex items-center gap-2">
|
1605 |
+
<div className="i-ph:wifi-high text-bolt-elements-textSecondary w-4 h-4" />
|
1606 |
+
<span className="text-bolt-elements-textSecondary">Network Type: </span>
|
1607 |
+
<span className="text-bolt-elements-textPrimary">
|
1608 |
+
{systemInfo.network.type} ({systemInfo.network.effectiveType})
|
1609 |
+
</span>
|
1610 |
+
</div>
|
1611 |
+
<div className="text-sm flex items-center gap-2">
|
1612 |
+
<div className="i-ph:gauge text-bolt-elements-textSecondary w-4 h-4" />
|
1613 |
+
<span className="text-bolt-elements-textSecondary">Network Speed: </span>
|
1614 |
+
<span className="text-bolt-elements-textPrimary">
|
1615 |
+
{systemInfo.network.downlink}Mbps (RTT: {systemInfo.network.rtt}ms)
|
1616 |
+
</span>
|
1617 |
+
</div>
|
1618 |
+
{systemInfo.battery && (
|
1619 |
+
<div className="text-sm flex items-center gap-2">
|
1620 |
+
<div className="i-ph:battery-charging text-bolt-elements-textSecondary w-4 h-4" />
|
1621 |
+
<span className="text-bolt-elements-textSecondary">Battery: </span>
|
1622 |
+
<span className="text-bolt-elements-textPrimary">
|
1623 |
+
{systemInfo.battery.level.toFixed(1)}% {systemInfo.battery.charging ? '(Charging)' : ''}
|
1624 |
+
</span>
|
1625 |
+
</div>
|
1626 |
+
)}
|
1627 |
+
<div className="text-sm flex items-center gap-2">
|
1628 |
+
<div className="i-ph:hard-drive text-bolt-elements-textSecondary w-4 h-4" />
|
1629 |
+
<span className="text-bolt-elements-textSecondary">Storage: </span>
|
1630 |
+
<span className="text-bolt-elements-textPrimary">
|
1631 |
+
{(systemInfo.storage.usage / (1024 * 1024 * 1024)).toFixed(2)}GB /{' '}
|
1632 |
+
{(systemInfo.storage.quota / (1024 * 1024 * 1024)).toFixed(2)}GB
|
1633 |
+
</span>
|
1634 |
+
</div>
|
1635 |
+
</div>
|
1636 |
+
<div className="space-y-2">
|
1637 |
+
<div className="text-sm flex items-center gap-2">
|
1638 |
+
<div className="i-ph:database text-bolt-elements-textSecondary w-4 h-4" />
|
1639 |
+
<span className="text-bolt-elements-textSecondary">Memory Usage: </span>
|
1640 |
+
<span className="text-bolt-elements-textPrimary">
|
1641 |
+
{systemInfo.memory.used} / {systemInfo.memory.total} ({systemInfo.memory.percentage}%)
|
1642 |
+
</span>
|
1643 |
+
</div>
|
1644 |
+
<div className="text-sm flex items-center gap-2">
|
1645 |
+
<div className="i-ph:browser text-bolt-elements-textSecondary w-4 h-4" />
|
1646 |
+
<span className="text-bolt-elements-textSecondary">Browser: </span>
|
1647 |
+
<span className="text-bolt-elements-textPrimary">
|
1648 |
+
{systemInfo.browser.name} {systemInfo.browser.version}
|
1649 |
+
</span>
|
1650 |
+
</div>
|
1651 |
+
<div className="text-sm flex items-center gap-2">
|
1652 |
+
<div className="i-ph:monitor text-bolt-elements-textSecondary w-4 h-4" />
|
1653 |
+
<span className="text-bolt-elements-textSecondary">Screen: </span>
|
1654 |
+
<span className="text-bolt-elements-textPrimary">
|
1655 |
+
{systemInfo.screen.width}x{systemInfo.screen.height} ({systemInfo.screen.pixelRatio}x)
|
1656 |
+
</span>
|
1657 |
+
</div>
|
1658 |
+
<div className="text-sm flex items-center gap-2">
|
1659 |
+
<div className="i-ph:clock text-bolt-elements-textSecondary w-4 h-4" />
|
1660 |
+
<span className="text-bolt-elements-textSecondary">Timezone: </span>
|
1661 |
+
<span className="text-bolt-elements-textPrimary">{systemInfo.time.timezone}</span>
|
1662 |
+
</div>
|
1663 |
+
<div className="text-sm flex items-center gap-2">
|
1664 |
+
<div className="i-ph:translate text-bolt-elements-textSecondary w-4 h-4" />
|
1665 |
+
<span className="text-bolt-elements-textSecondary">Language: </span>
|
1666 |
+
<span className="text-bolt-elements-textPrimary">{systemInfo.browser.language}</span>
|
1667 |
+
</div>
|
1668 |
+
<div className="text-sm flex items-center gap-2">
|
1669 |
+
<div className="i-ph:chart-pie text-bolt-elements-textSecondary w-4 h-4" />
|
1670 |
+
<span className="text-bolt-elements-textSecondary">JS Heap: </span>
|
1671 |
+
<span className="text-bolt-elements-textPrimary">
|
1672 |
+
{(systemInfo.performance.memory.usedJSHeapSize / (1024 * 1024)).toFixed(1)}MB /{' '}
|
1673 |
+
{(systemInfo.performance.memory.totalJSHeapSize / (1024 * 1024)).toFixed(1)}MB (
|
1674 |
+
{systemInfo.performance.memory.usagePercentage.toFixed(1)}%)
|
1675 |
+
</span>
|
1676 |
+
</div>
|
1677 |
+
<div className="text-sm flex items-center gap-2">
|
1678 |
+
<div className="i-ph:timer text-bolt-elements-textSecondary w-4 h-4" />
|
1679 |
+
<span className="text-bolt-elements-textSecondary">Page Load: </span>
|
1680 |
+
<span className="text-bolt-elements-textPrimary">
|
1681 |
+
{(systemInfo.performance.timing.loadTime / 1000).toFixed(2)}s
|
1682 |
+
</span>
|
1683 |
+
</div>
|
1684 |
+
<div className="text-sm flex items-center gap-2">
|
1685 |
+
<div className="i-ph:code text-bolt-elements-textSecondary w-4 h-4" />
|
1686 |
+
<span className="text-bolt-elements-textSecondary">DOM Ready: </span>
|
1687 |
+
<span className="text-bolt-elements-textPrimary">
|
1688 |
+
{(systemInfo.performance.timing.domReadyTime / 1000).toFixed(2)}s
|
1689 |
+
</span>
|
1690 |
+
</div>
|
1691 |
+
</div>
|
1692 |
+
</div>
|
1693 |
+
) : (
|
1694 |
+
<div className="text-sm text-bolt-elements-textSecondary">Loading system information...</div>
|
1695 |
+
)}
|
1696 |
+
</div>
|
1697 |
+
</CollapsibleContent>
|
1698 |
+
</Collapsible>
|
1699 |
+
|
1700 |
+
{/* Performance Metrics */}
|
1701 |
+
<Collapsible
|
1702 |
+
open={openSections.performance}
|
1703 |
+
onOpenChange={(open: boolean) => setOpenSections((prev) => ({ ...prev, performance: open }))}
|
1704 |
+
className="w-full"
|
1705 |
+
>
|
1706 |
+
<CollapsibleTrigger className="w-full">
|
1707 |
+
<div className="flex items-center justify-between p-6 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A]">
|
1708 |
+
<div className="flex items-center gap-3">
|
1709 |
+
<div className="i-ph:chart-line text-purple-500 w-5 h-5" />
|
1710 |
+
<h3 className="text-base font-medium text-bolt-elements-textPrimary">Performance Metrics</h3>
|
1711 |
+
</div>
|
1712 |
+
<div
|
1713 |
+
className={classNames(
|
1714 |
+
'i-ph:caret-down w-4 h-4 transform transition-transform duration-200',
|
1715 |
+
openSections.performance ? 'rotate-180' : '',
|
1716 |
+
)}
|
1717 |
+
/>
|
1718 |
+
</div>
|
1719 |
+
</CollapsibleTrigger>
|
1720 |
+
|
1721 |
+
<CollapsibleContent>
|
1722 |
+
<div className="p-6 mt-2 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A]">
|
1723 |
+
{systemInfo && (
|
1724 |
+
<div className="grid grid-cols-2 gap-4">
|
1725 |
+
<div className="space-y-2">
|
1726 |
+
<div className="text-sm">
|
1727 |
+
<span className="text-bolt-elements-textSecondary">Page Load Time: </span>
|
1728 |
+
<span className="text-bolt-elements-textPrimary">
|
1729 |
+
{(systemInfo.performance.timing.loadTime / 1000).toFixed(2)}s
|
1730 |
+
</span>
|
1731 |
+
</div>
|
1732 |
+
<div className="text-sm">
|
1733 |
+
<span className="text-bolt-elements-textSecondary">DOM Ready Time: </span>
|
1734 |
+
<span className="text-bolt-elements-textPrimary">
|
1735 |
+
{(systemInfo.performance.timing.domReadyTime / 1000).toFixed(2)}s
|
1736 |
+
</span>
|
1737 |
+
</div>
|
1738 |
+
<div className="text-sm">
|
1739 |
+
<span className="text-bolt-elements-textSecondary">Request Time: </span>
|
1740 |
+
<span className="text-bolt-elements-textPrimary">
|
1741 |
+
{(systemInfo.performance.timing.requestTime / 1000).toFixed(2)}s
|
1742 |
+
</span>
|
1743 |
+
</div>
|
1744 |
+
<div className="text-sm">
|
1745 |
+
<span className="text-bolt-elements-textSecondary">Redirect Time: </span>
|
1746 |
+
<span className="text-bolt-elements-textPrimary">
|
1747 |
+
{(systemInfo.performance.timing.redirectTime / 1000).toFixed(2)}s
|
1748 |
+
</span>
|
1749 |
+
</div>
|
1750 |
+
</div>
|
1751 |
+
<div className="space-y-2">
|
1752 |
+
<div className="text-sm">
|
1753 |
+
<span className="text-bolt-elements-textSecondary">JS Heap Usage: </span>
|
1754 |
+
<span className="text-bolt-elements-textPrimary">
|
1755 |
+
{(systemInfo.performance.memory.usedJSHeapSize / (1024 * 1024)).toFixed(1)}MB /{' '}
|
1756 |
+
{(systemInfo.performance.memory.totalJSHeapSize / (1024 * 1024)).toFixed(1)}MB
|
1757 |
+
</span>
|
1758 |
+
</div>
|
1759 |
+
<div className="text-sm">
|
1760 |
+
<span className="text-bolt-elements-textSecondary">Heap Utilization: </span>
|
1761 |
+
<span className="text-bolt-elements-textPrimary">
|
1762 |
+
{systemInfo.performance.memory.usagePercentage.toFixed(1)}%
|
1763 |
+
</span>
|
1764 |
+
</div>
|
1765 |
+
<div className="text-sm">
|
1766 |
+
<span className="text-bolt-elements-textSecondary">Navigation Type: </span>
|
1767 |
+
<span className="text-bolt-elements-textPrimary">
|
1768 |
+
{systemInfo.performance.navigation.type === 0
|
1769 |
+
? 'Navigate'
|
1770 |
+
: systemInfo.performance.navigation.type === 1
|
1771 |
+
? 'Reload'
|
1772 |
+
: systemInfo.performance.navigation.type === 2
|
1773 |
+
? 'Back/Forward'
|
1774 |
+
: 'Other'}
|
1775 |
+
</span>
|
1776 |
+
</div>
|
1777 |
+
<div className="text-sm">
|
1778 |
+
<span className="text-bolt-elements-textSecondary">Redirects: </span>
|
1779 |
+
<span className="text-bolt-elements-textPrimary">
|
1780 |
+
{systemInfo.performance.navigation.redirectCount}
|
1781 |
+
</span>
|
1782 |
+
</div>
|
1783 |
+
</div>
|
1784 |
+
</div>
|
1785 |
+
)}
|
1786 |
+
</div>
|
1787 |
+
</CollapsibleContent>
|
1788 |
+
</Collapsible>
|
1789 |
+
|
1790 |
+
{/* WebApp Information */}
|
1791 |
+
<Collapsible
|
1792 |
+
open={openSections.webapp}
|
1793 |
+
onOpenChange={(open) => setOpenSections((prev) => ({ ...prev, webapp: open }))}
|
1794 |
+
className="w-full"
|
1795 |
+
>
|
1796 |
+
<CollapsibleTrigger className="w-full">
|
1797 |
+
<div className="flex items-center justify-between p-6 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A]">
|
1798 |
+
<div className="flex items-center gap-3">
|
1799 |
+
<div className="i-ph:info text-blue-500 w-5 h-5" />
|
1800 |
+
<h3 className="text-base font-medium text-bolt-elements-textPrimary">WebApp Information</h3>
|
1801 |
+
{loading.webAppInfo && <span className="loading loading-spinner loading-sm" />}
|
1802 |
+
</div>
|
1803 |
+
<div
|
1804 |
+
className={classNames(
|
1805 |
+
'i-ph:caret-down w-4 h-4 transform transition-transform duration-200',
|
1806 |
+
openSections.webapp ? 'rotate-180' : '',
|
1807 |
+
)}
|
1808 |
+
/>
|
1809 |
+
</div>
|
1810 |
+
</CollapsibleTrigger>
|
1811 |
+
|
1812 |
+
<CollapsibleContent>
|
1813 |
+
<div className="p-6 mt-2 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A]">
|
1814 |
+
{loading.webAppInfo ? (
|
1815 |
+
<div className="flex items-center justify-center p-8">
|
1816 |
+
<span className="loading loading-spinner loading-lg" />
|
1817 |
+
</div>
|
1818 |
+
) : !webAppInfo ? (
|
1819 |
+
<div className="flex flex-col items-center justify-center p-8 text-bolt-elements-textSecondary">
|
1820 |
+
<div className="i-ph:warning-circle w-8 h-8 mb-2" />
|
1821 |
+
<p>Failed to load WebApp information</p>
|
1822 |
+
<button
|
1823 |
+
onClick={() => getWebAppInfo()}
|
1824 |
+
className="mt-4 px-4 py-2 text-sm bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
|
1825 |
+
>
|
1826 |
+
Retry
|
1827 |
+
</button>
|
1828 |
+
</div>
|
1829 |
+
) : (
|
1830 |
+
<div className="grid grid-cols-2 gap-6">
|
1831 |
+
<div>
|
1832 |
+
<h3 className="mb-4 text-base font-medium text-bolt-elements-textPrimary">Basic Information</h3>
|
1833 |
+
<div className="space-y-3">
|
1834 |
+
<div className="text-sm flex items-center gap-2">
|
1835 |
+
<div className="i-ph:app-window text-bolt-elements-textSecondary w-4 h-4" />
|
1836 |
+
<span className="text-bolt-elements-textSecondary">Name:</span>
|
1837 |
+
<span className="text-bolt-elements-textPrimary">{webAppInfo.name}</span>
|
1838 |
+
</div>
|
1839 |
+
<div className="text-sm flex items-center gap-2">
|
1840 |
+
<div className="i-ph:tag text-bolt-elements-textSecondary w-4 h-4" />
|
1841 |
+
<span className="text-bolt-elements-textSecondary">Version:</span>
|
1842 |
+
<span className="text-bolt-elements-textPrimary">{webAppInfo.version}</span>
|
1843 |
+
</div>
|
1844 |
+
<div className="text-sm flex items-center gap-2">
|
1845 |
+
<div className="i-ph:certificate text-bolt-elements-textSecondary w-4 h-4" />
|
1846 |
+
<span className="text-bolt-elements-textSecondary">License:</span>
|
1847 |
+
<span className="text-bolt-elements-textPrimary">{webAppInfo.license}</span>
|
1848 |
+
</div>
|
1849 |
+
<div className="text-sm flex items-center gap-2">
|
1850 |
+
<div className="i-ph:cloud text-bolt-elements-textSecondary w-4 h-4" />
|
1851 |
+
<span className="text-bolt-elements-textSecondary">Environment:</span>
|
1852 |
+
<span className="text-bolt-elements-textPrimary">{webAppInfo.environment}</span>
|
1853 |
+
</div>
|
1854 |
+
<div className="text-sm flex items-center gap-2">
|
1855 |
+
<div className="i-ph:node text-bolt-elements-textSecondary w-4 h-4" />
|
1856 |
+
<span className="text-bolt-elements-textSecondary">Node Version:</span>
|
1857 |
+
<span className="text-bolt-elements-textPrimary">{webAppInfo.runtimeInfo.nodeVersion}</span>
|
1858 |
+
</div>
|
1859 |
+
</div>
|
1860 |
+
</div>
|
1861 |
+
|
1862 |
+
<div>
|
1863 |
+
<h3 className="mb-4 text-base font-medium text-bolt-elements-textPrimary">Git Information</h3>
|
1864 |
+
<div className="space-y-3">
|
1865 |
+
<div className="text-sm flex items-center gap-2">
|
1866 |
+
<div className="i-ph:git-branch text-bolt-elements-textSecondary w-4 h-4" />
|
1867 |
+
<span className="text-bolt-elements-textSecondary">Branch:</span>
|
1868 |
+
<span className="text-bolt-elements-textPrimary">{webAppInfo.gitInfo.local.branch}</span>
|
1869 |
+
</div>
|
1870 |
+
<div className="text-sm flex items-center gap-2">
|
1871 |
+
<div className="i-ph:git-commit text-bolt-elements-textSecondary w-4 h-4" />
|
1872 |
+
<span className="text-bolt-elements-textSecondary">Commit:</span>
|
1873 |
+
<span className="text-bolt-elements-textPrimary">{webAppInfo.gitInfo.local.commitHash}</span>
|
1874 |
+
</div>
|
1875 |
+
<div className="text-sm flex items-center gap-2">
|
1876 |
+
<div className="i-ph:user text-bolt-elements-textSecondary w-4 h-4" />
|
1877 |
+
<span className="text-bolt-elements-textSecondary">Author:</span>
|
1878 |
+
<span className="text-bolt-elements-textPrimary">{webAppInfo.gitInfo.local.author}</span>
|
1879 |
+
</div>
|
1880 |
+
<div className="text-sm flex items-center gap-2">
|
1881 |
+
<div className="i-ph:clock text-bolt-elements-textSecondary w-4 h-4" />
|
1882 |
+
<span className="text-bolt-elements-textSecondary">Commit Time:</span>
|
1883 |
+
<span className="text-bolt-elements-textPrimary">{webAppInfo.gitInfo.local.commitTime}</span>
|
1884 |
+
</div>
|
1885 |
+
|
1886 |
+
{webAppInfo.gitInfo.github && (
|
1887 |
+
<>
|
1888 |
+
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-800">
|
1889 |
+
<div className="text-sm flex items-center gap-2">
|
1890 |
+
<div className="i-ph:git-repository text-bolt-elements-textSecondary w-4 h-4" />
|
1891 |
+
<span className="text-bolt-elements-textSecondary">Repository:</span>
|
1892 |
+
<span className="text-bolt-elements-textPrimary">
|
1893 |
+
{webAppInfo.gitInfo.github.currentRepo.fullName}
|
1894 |
+
{webAppInfo.gitInfo.isForked && ' (fork)'}
|
1895 |
+
</span>
|
1896 |
+
</div>
|
1897 |
+
|
1898 |
+
<div className="mt-2 flex items-center gap-4 text-sm">
|
1899 |
+
<div className="flex items-center gap-1">
|
1900 |
+
<div className="i-ph:star text-yellow-500 w-4 h-4" />
|
1901 |
+
<span className="text-bolt-elements-textSecondary">
|
1902 |
+
{webAppInfo.gitInfo.github.currentRepo.stars}
|
1903 |
+
</span>
|
1904 |
+
</div>
|
1905 |
+
<div className="flex items-center gap-1">
|
1906 |
+
<div className="i-ph:git-fork text-blue-500 w-4 h-4" />
|
1907 |
+
<span className="text-bolt-elements-textSecondary">
|
1908 |
+
{webAppInfo.gitInfo.github.currentRepo.forks}
|
1909 |
+
</span>
|
1910 |
+
</div>
|
1911 |
+
<div className="flex items-center gap-1">
|
1912 |
+
<div className="i-ph:warning-circle text-red-500 w-4 h-4" />
|
1913 |
+
<span className="text-bolt-elements-textSecondary">
|
1914 |
+
{webAppInfo.gitInfo.github.currentRepo.openIssues}
|
1915 |
+
</span>
|
1916 |
+
</div>
|
1917 |
+
</div>
|
1918 |
+
</div>
|
1919 |
+
|
1920 |
+
{webAppInfo.gitInfo.github.upstream && (
|
1921 |
+
<div className="mt-2">
|
1922 |
+
<div className="text-sm flex items-center gap-2">
|
1923 |
+
<div className="i-ph:git-fork text-bolt-elements-textSecondary w-4 h-4" />
|
1924 |
+
<span className="text-bolt-elements-textSecondary">Upstream:</span>
|
1925 |
+
<span className="text-bolt-elements-textPrimary">
|
1926 |
+
{webAppInfo.gitInfo.github.upstream.fullName}
|
1927 |
+
</span>
|
1928 |
+
</div>
|
1929 |
+
|
1930 |
+
<div className="mt-2 flex items-center gap-4 text-sm">
|
1931 |
+
<div className="flex items-center gap-1">
|
1932 |
+
<div className="i-ph:star text-yellow-500 w-4 h-4" />
|
1933 |
+
<span className="text-bolt-elements-textSecondary">
|
1934 |
+
{webAppInfo.gitInfo.github.upstream.stars}
|
1935 |
+
</span>
|
1936 |
+
</div>
|
1937 |
+
<div className="flex items-center gap-1">
|
1938 |
+
<div className="i-ph:git-fork text-blue-500 w-4 h-4" />
|
1939 |
+
<span className="text-bolt-elements-textSecondary">
|
1940 |
+
{webAppInfo.gitInfo.github.upstream.forks}
|
1941 |
+
</span>
|
1942 |
+
</div>
|
1943 |
+
</div>
|
1944 |
+
</div>
|
1945 |
+
)}
|
1946 |
+
</>
|
1947 |
+
)}
|
1948 |
+
</div>
|
1949 |
+
</div>
|
1950 |
+
</div>
|
1951 |
+
)}
|
1952 |
+
|
1953 |
+
{webAppInfo && (
|
1954 |
+
<div className="mt-6">
|
1955 |
+
<h3 className="mb-4 text-base font-medium text-bolt-elements-textPrimary">Dependencies</h3>
|
1956 |
+
<div className="bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A] rounded-lg divide-y divide-[#E5E5E5] dark:divide-[#1A1A1A]">
|
1957 |
+
<DependencySection title="Production" deps={webAppInfo.dependencies.production} />
|
1958 |
+
<DependencySection title="Development" deps={webAppInfo.dependencies.development} />
|
1959 |
+
<DependencySection title="Peer" deps={webAppInfo.dependencies.peer} />
|
1960 |
+
<DependencySection title="Optional" deps={webAppInfo.dependencies.optional} />
|
1961 |
+
</div>
|
1962 |
+
</div>
|
1963 |
+
)}
|
1964 |
+
</div>
|
1965 |
+
</CollapsibleContent>
|
1966 |
+
</Collapsible>
|
1967 |
+
|
1968 |
+
{/* Error Check */}
|
1969 |
+
<Collapsible
|
1970 |
+
open={openSections.errors}
|
1971 |
+
onOpenChange={(open) => setOpenSections((prev) => ({ ...prev, errors: open }))}
|
1972 |
+
className="w-full"
|
1973 |
+
>
|
1974 |
+
<CollapsibleTrigger className="w-full">
|
1975 |
+
<div className="flex items-center justify-between p-6 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A]">
|
1976 |
+
<div className="flex items-center gap-3">
|
1977 |
+
<div className="i-ph:warning text-red-500 w-5 h-5" />
|
1978 |
+
<h3 className="text-base font-medium text-bolt-elements-textPrimary">Error Check</h3>
|
1979 |
+
{errorLogs.length > 0 && (
|
1980 |
+
<Badge variant="destructive" className="ml-2">
|
1981 |
+
{errorLogs.length} Errors
|
1982 |
+
</Badge>
|
1983 |
+
)}
|
1984 |
+
</div>
|
1985 |
+
<div
|
1986 |
+
className={classNames(
|
1987 |
+
'i-ph:caret-down w-4 h-4 transform transition-transform duration-200',
|
1988 |
+
openSections.errors ? 'rotate-180' : '',
|
1989 |
+
)}
|
1990 |
+
/>
|
1991 |
+
</div>
|
1992 |
+
</CollapsibleTrigger>
|
1993 |
+
|
1994 |
+
<CollapsibleContent>
|
1995 |
+
<div className="p-6 mt-2 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A]">
|
1996 |
+
<ScrollArea className="h-[300px]">
|
1997 |
+
<div className="space-y-4">
|
1998 |
+
<div className="text-sm text-bolt-elements-textSecondary">
|
1999 |
+
Checks for:
|
2000 |
+
<ul className="list-disc list-inside mt-2 space-y-1">
|
2001 |
+
<li>Unhandled JavaScript errors</li>
|
2002 |
+
<li>Unhandled Promise rejections</li>
|
2003 |
+
<li>Runtime exceptions</li>
|
2004 |
+
<li>Network errors</li>
|
2005 |
+
</ul>
|
2006 |
+
</div>
|
2007 |
+
<div className="text-sm">
|
2008 |
+
<span className="text-bolt-elements-textSecondary">Status: </span>
|
2009 |
+
<span className="text-bolt-elements-textPrimary">
|
2010 |
+
{loading.errors
|
2011 |
+
? 'Checking...'
|
2012 |
+
: errorLogs.length > 0
|
2013 |
+
? `${errorLogs.length} errors found`
|
2014 |
+
: 'No errors found'}
|
2015 |
+
</span>
|
2016 |
+
</div>
|
2017 |
+
{errorLogs.length > 0 && (
|
2018 |
+
<div className="mt-4">
|
2019 |
+
<div className="text-sm font-medium text-bolt-elements-textPrimary mb-2">Recent Errors:</div>
|
2020 |
+
<div className="space-y-2">
|
2021 |
+
{errorLogs.map((error) => (
|
2022 |
+
<div key={error.id} className="text-sm text-red-500 dark:text-red-400 p-2 rounded bg-red-500/5">
|
2023 |
+
<div className="font-medium">{error.message}</div>
|
2024 |
+
{error.source && (
|
2025 |
+
<div className="text-xs mt-1 text-red-400">
|
2026 |
+
Source: {error.source}
|
2027 |
+
{error.details?.lineNumber && `:${error.details.lineNumber}`}
|
2028 |
+
</div>
|
2029 |
+
)}
|
2030 |
+
{error.stack && (
|
2031 |
+
<div className="text-xs mt-1 text-red-400 font-mono whitespace-pre-wrap">{error.stack}</div>
|
2032 |
+
)}
|
2033 |
+
</div>
|
2034 |
+
))}
|
2035 |
+
</div>
|
2036 |
+
</div>
|
2037 |
+
)}
|
2038 |
+
</div>
|
2039 |
+
</ScrollArea>
|
2040 |
+
</div>
|
2041 |
+
</CollapsibleContent>
|
2042 |
+
</Collapsible>
|
2043 |
+
</div>
|
2044 |
+
);
|
2045 |
+
}
|
app/components/@settings/tabs/event-logs/EventLogsTab.tsx
ADDED
@@ -0,0 +1,1013 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
2 |
+
import { motion } from 'framer-motion';
|
3 |
+
import { Switch } from '~/components/ui/Switch';
|
4 |
+
import { logStore, type LogEntry } from '~/lib/stores/logs';
|
5 |
+
import { useStore } from '@nanostores/react';
|
6 |
+
import { classNames } from '~/utils/classNames';
|
7 |
+
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
8 |
+
import { Dialog, DialogRoot, DialogTitle } from '~/components/ui/Dialog';
|
9 |
+
import { jsPDF } from 'jspdf';
|
10 |
+
import { toast } from 'react-toastify';
|
11 |
+
|
12 |
+
interface SelectOption {
|
13 |
+
value: string;
|
14 |
+
label: string;
|
15 |
+
icon?: string;
|
16 |
+
color?: string;
|
17 |
+
}
|
18 |
+
|
19 |
+
const logLevelOptions: SelectOption[] = [
|
20 |
+
{
|
21 |
+
value: 'all',
|
22 |
+
label: 'All Types',
|
23 |
+
icon: 'i-ph:funnel',
|
24 |
+
color: '#9333ea',
|
25 |
+
},
|
26 |
+
{
|
27 |
+
value: 'provider',
|
28 |
+
label: 'LLM',
|
29 |
+
icon: 'i-ph:robot',
|
30 |
+
color: '#10b981',
|
31 |
+
},
|
32 |
+
{
|
33 |
+
value: 'api',
|
34 |
+
label: 'API',
|
35 |
+
icon: 'i-ph:cloud',
|
36 |
+
color: '#3b82f6',
|
37 |
+
},
|
38 |
+
{
|
39 |
+
value: 'error',
|
40 |
+
label: 'Errors',
|
41 |
+
icon: 'i-ph:warning-circle',
|
42 |
+
color: '#ef4444',
|
43 |
+
},
|
44 |
+
{
|
45 |
+
value: 'warning',
|
46 |
+
label: 'Warnings',
|
47 |
+
icon: 'i-ph:warning',
|
48 |
+
color: '#f59e0b',
|
49 |
+
},
|
50 |
+
{
|
51 |
+
value: 'info',
|
52 |
+
label: 'Info',
|
53 |
+
icon: 'i-ph:info',
|
54 |
+
color: '#3b82f6',
|
55 |
+
},
|
56 |
+
{
|
57 |
+
value: 'debug',
|
58 |
+
label: 'Debug',
|
59 |
+
icon: 'i-ph:bug',
|
60 |
+
color: '#6b7280',
|
61 |
+
},
|
62 |
+
];
|
63 |
+
|
64 |
+
interface LogEntryItemProps {
|
65 |
+
log: LogEntry;
|
66 |
+
isExpanded: boolean;
|
67 |
+
use24Hour: boolean;
|
68 |
+
showTimestamp: boolean;
|
69 |
+
}
|
70 |
+
|
71 |
+
const LogEntryItem = ({ log, isExpanded: forceExpanded, use24Hour, showTimestamp }: LogEntryItemProps) => {
|
72 |
+
const [localExpanded, setLocalExpanded] = useState(forceExpanded);
|
73 |
+
|
74 |
+
useEffect(() => {
|
75 |
+
setLocalExpanded(forceExpanded);
|
76 |
+
}, [forceExpanded]);
|
77 |
+
|
78 |
+
const timestamp = useMemo(() => {
|
79 |
+
const date = new Date(log.timestamp);
|
80 |
+
return date.toLocaleTimeString('en-US', { hour12: !use24Hour });
|
81 |
+
}, [log.timestamp, use24Hour]);
|
82 |
+
|
83 |
+
const style = useMemo(() => {
|
84 |
+
if (log.category === 'provider') {
|
85 |
+
return {
|
86 |
+
icon: 'i-ph:robot',
|
87 |
+
color: 'text-emerald-500 dark:text-emerald-400',
|
88 |
+
bg: 'hover:bg-emerald-500/10 dark:hover:bg-emerald-500/20',
|
89 |
+
badge: 'text-emerald-500 bg-emerald-50 dark:bg-emerald-500/10',
|
90 |
+
};
|
91 |
+
}
|
92 |
+
|
93 |
+
if (log.category === 'api') {
|
94 |
+
return {
|
95 |
+
icon: 'i-ph:cloud',
|
96 |
+
color: 'text-blue-500 dark:text-blue-400',
|
97 |
+
bg: 'hover:bg-blue-500/10 dark:hover:bg-blue-500/20',
|
98 |
+
badge: 'text-blue-500 bg-blue-50 dark:bg-blue-500/10',
|
99 |
+
};
|
100 |
+
}
|
101 |
+
|
102 |
+
switch (log.level) {
|
103 |
+
case 'error':
|
104 |
+
return {
|
105 |
+
icon: 'i-ph:warning-circle',
|
106 |
+
color: 'text-red-500 dark:text-red-400',
|
107 |
+
bg: 'hover:bg-red-500/10 dark:hover:bg-red-500/20',
|
108 |
+
badge: 'text-red-500 bg-red-50 dark:bg-red-500/10',
|
109 |
+
};
|
110 |
+
case 'warning':
|
111 |
+
return {
|
112 |
+
icon: 'i-ph:warning',
|
113 |
+
color: 'text-yellow-500 dark:text-yellow-400',
|
114 |
+
bg: 'hover:bg-yellow-500/10 dark:hover:bg-yellow-500/20',
|
115 |
+
badge: 'text-yellow-500 bg-yellow-50 dark:bg-yellow-500/10',
|
116 |
+
};
|
117 |
+
case 'debug':
|
118 |
+
return {
|
119 |
+
icon: 'i-ph:bug',
|
120 |
+
color: 'text-gray-500 dark:text-gray-400',
|
121 |
+
bg: 'hover:bg-gray-500/10 dark:hover:bg-gray-500/20',
|
122 |
+
badge: 'text-gray-500 bg-gray-50 dark:bg-gray-500/10',
|
123 |
+
};
|
124 |
+
default:
|
125 |
+
return {
|
126 |
+
icon: 'i-ph:info',
|
127 |
+
color: 'text-blue-500 dark:text-blue-400',
|
128 |
+
bg: 'hover:bg-blue-500/10 dark:hover:bg-blue-500/20',
|
129 |
+
badge: 'text-blue-500 bg-blue-50 dark:bg-blue-500/10',
|
130 |
+
};
|
131 |
+
}
|
132 |
+
}, [log.level, log.category]);
|
133 |
+
|
134 |
+
const renderDetails = (details: any) => {
|
135 |
+
if (log.category === 'provider') {
|
136 |
+
return (
|
137 |
+
<div className="flex flex-col gap-2">
|
138 |
+
<div className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
|
139 |
+
<span>Model: {details.model}</span>
|
140 |
+
<span>•</span>
|
141 |
+
<span>Tokens: {details.totalTokens}</span>
|
142 |
+
<span>•</span>
|
143 |
+
<span>Duration: {details.duration}ms</span>
|
144 |
+
</div>
|
145 |
+
{details.prompt && (
|
146 |
+
<div className="flex flex-col gap-1">
|
147 |
+
<div className="text-xs font-medium text-gray-700 dark:text-gray-300">Prompt:</div>
|
148 |
+
<pre className="text-xs text-gray-600 dark:text-gray-400 bg-gray-50 dark:bg-gray-800/50 rounded p-2 whitespace-pre-wrap">
|
149 |
+
{details.prompt}
|
150 |
+
</pre>
|
151 |
+
</div>
|
152 |
+
)}
|
153 |
+
{details.response && (
|
154 |
+
<div className="flex flex-col gap-1">
|
155 |
+
<div className="text-xs font-medium text-gray-700 dark:text-gray-300">Response:</div>
|
156 |
+
<pre className="text-xs text-gray-600 dark:text-gray-400 bg-gray-50 dark:bg-gray-800/50 rounded p-2 whitespace-pre-wrap">
|
157 |
+
{details.response}
|
158 |
+
</pre>
|
159 |
+
</div>
|
160 |
+
)}
|
161 |
+
</div>
|
162 |
+
);
|
163 |
+
}
|
164 |
+
|
165 |
+
if (log.category === 'api') {
|
166 |
+
return (
|
167 |
+
<div className="flex flex-col gap-2">
|
168 |
+
<div className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
|
169 |
+
<span className={details.method === 'GET' ? 'text-green-500' : 'text-blue-500'}>{details.method}</span>
|
170 |
+
<span>•</span>
|
171 |
+
<span>Status: {details.statusCode}</span>
|
172 |
+
<span>•</span>
|
173 |
+
<span>Duration: {details.duration}ms</span>
|
174 |
+
</div>
|
175 |
+
<div className="text-xs text-gray-600 dark:text-gray-400 break-all">{details.url}</div>
|
176 |
+
{details.request && (
|
177 |
+
<div className="flex flex-col gap-1">
|
178 |
+
<div className="text-xs font-medium text-gray-700 dark:text-gray-300">Request:</div>
|
179 |
+
<pre className="text-xs text-gray-600 dark:text-gray-400 bg-gray-50 dark:bg-gray-800/50 rounded p-2 whitespace-pre-wrap">
|
180 |
+
{JSON.stringify(details.request, null, 2)}
|
181 |
+
</pre>
|
182 |
+
</div>
|
183 |
+
)}
|
184 |
+
{details.response && (
|
185 |
+
<div className="flex flex-col gap-1">
|
186 |
+
<div className="text-xs font-medium text-gray-700 dark:text-gray-300">Response:</div>
|
187 |
+
<pre className="text-xs text-gray-600 dark:text-gray-400 bg-gray-50 dark:bg-gray-800/50 rounded p-2 whitespace-pre-wrap">
|
188 |
+
{JSON.stringify(details.response, null, 2)}
|
189 |
+
</pre>
|
190 |
+
</div>
|
191 |
+
)}
|
192 |
+
{details.error && (
|
193 |
+
<div className="flex flex-col gap-1">
|
194 |
+
<div className="text-xs font-medium text-red-500">Error:</div>
|
195 |
+
<pre className="text-xs text-red-400 bg-red-50 dark:bg-red-500/10 rounded p-2 whitespace-pre-wrap">
|
196 |
+
{JSON.stringify(details.error, null, 2)}
|
197 |
+
</pre>
|
198 |
+
</div>
|
199 |
+
)}
|
200 |
+
</div>
|
201 |
+
);
|
202 |
+
}
|
203 |
+
|
204 |
+
return (
|
205 |
+
<pre className="text-xs text-gray-600 dark:text-gray-400 bg-gray-50 dark:bg-gray-800/50 rounded whitespace-pre-wrap">
|
206 |
+
{JSON.stringify(details, null, 2)}
|
207 |
+
</pre>
|
208 |
+
);
|
209 |
+
};
|
210 |
+
|
211 |
+
return (
|
212 |
+
<motion.div
|
213 |
+
initial={{ opacity: 0, y: 20 }}
|
214 |
+
animate={{ opacity: 1, y: 0 }}
|
215 |
+
className={classNames(
|
216 |
+
'flex flex-col gap-2',
|
217 |
+
'rounded-lg p-4',
|
218 |
+
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
|
219 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
220 |
+
style.bg,
|
221 |
+
'transition-all duration-200',
|
222 |
+
)}
|
223 |
+
>
|
224 |
+
<div className="flex items-start justify-between gap-4">
|
225 |
+
<div className="flex items-start gap-3">
|
226 |
+
<span className={classNames('text-lg', style.icon, style.color)} />
|
227 |
+
<div className="flex flex-col gap-1">
|
228 |
+
<div className="text-sm font-medium text-gray-900 dark:text-white">{log.message}</div>
|
229 |
+
{log.details && (
|
230 |
+
<>
|
231 |
+
<button
|
232 |
+
onClick={() => setLocalExpanded(!localExpanded)}
|
233 |
+
className="text-xs text-gray-500 dark:text-gray-400 hover:text-purple-500 dark:hover:text-purple-400 transition-colors"
|
234 |
+
>
|
235 |
+
{localExpanded ? 'Hide' : 'Show'} Details
|
236 |
+
</button>
|
237 |
+
{localExpanded && renderDetails(log.details)}
|
238 |
+
</>
|
239 |
+
)}
|
240 |
+
<div className="flex items-center gap-2">
|
241 |
+
<div className={classNames('px-2 py-0.5 rounded text-xs font-medium uppercase', style.badge)}>
|
242 |
+
{log.level}
|
243 |
+
</div>
|
244 |
+
{log.category && (
|
245 |
+
<div className="px-2 py-0.5 rounded-full text-xs bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400">
|
246 |
+
{log.category}
|
247 |
+
</div>
|
248 |
+
)}
|
249 |
+
</div>
|
250 |
+
</div>
|
251 |
+
</div>
|
252 |
+
{showTimestamp && <time className="shrink-0 text-xs text-gray-500 dark:text-gray-400">{timestamp}</time>}
|
253 |
+
</div>
|
254 |
+
</motion.div>
|
255 |
+
);
|
256 |
+
};
|
257 |
+
|
258 |
+
interface ExportFormat {
|
259 |
+
id: string;
|
260 |
+
label: string;
|
261 |
+
icon: string;
|
262 |
+
handler: () => void;
|
263 |
+
}
|
264 |
+
|
265 |
+
export function EventLogsTab() {
|
266 |
+
const logs = useStore(logStore.logs);
|
267 |
+
const [selectedLevel, setSelectedLevel] = useState<'all' | string>('all');
|
268 |
+
const [searchQuery, setSearchQuery] = useState('');
|
269 |
+
const [use24Hour, setUse24Hour] = useState(false);
|
270 |
+
const [autoExpand, setAutoExpand] = useState(false);
|
271 |
+
const [showTimestamps, setShowTimestamps] = useState(true);
|
272 |
+
const [showLevelFilter, setShowLevelFilter] = useState(false);
|
273 |
+
const [isRefreshing, setIsRefreshing] = useState(false);
|
274 |
+
const levelFilterRef = useRef<HTMLDivElement>(null);
|
275 |
+
|
276 |
+
const filteredLogs = useMemo(() => {
|
277 |
+
const allLogs = Object.values(logs);
|
278 |
+
|
279 |
+
if (selectedLevel === 'all') {
|
280 |
+
return allLogs.filter((log) =>
|
281 |
+
searchQuery ? log.message.toLowerCase().includes(searchQuery.toLowerCase()) : true,
|
282 |
+
);
|
283 |
+
}
|
284 |
+
|
285 |
+
return allLogs.filter((log) => {
|
286 |
+
const matchesType = log.category === selectedLevel || log.level === selectedLevel;
|
287 |
+
const matchesSearch = searchQuery ? log.message.toLowerCase().includes(searchQuery.toLowerCase()) : true;
|
288 |
+
|
289 |
+
return matchesType && matchesSearch;
|
290 |
+
});
|
291 |
+
}, [logs, selectedLevel, searchQuery]);
|
292 |
+
|
293 |
+
// Add performance tracking on mount
|
294 |
+
useEffect(() => {
|
295 |
+
const startTime = performance.now();
|
296 |
+
|
297 |
+
logStore.logInfo('Event Logs tab mounted', {
|
298 |
+
type: 'component_mount',
|
299 |
+
message: 'Event Logs tab component mounted',
|
300 |
+
component: 'EventLogsTab',
|
301 |
+
});
|
302 |
+
|
303 |
+
return () => {
|
304 |
+
const duration = performance.now() - startTime;
|
305 |
+
logStore.logPerformanceMetric('EventLogsTab', 'mount-duration', duration);
|
306 |
+
};
|
307 |
+
}, []);
|
308 |
+
|
309 |
+
// Log filter changes
|
310 |
+
const handleLevelFilterChange = useCallback(
|
311 |
+
(newLevel: string) => {
|
312 |
+
logStore.logInfo('Log level filter changed', {
|
313 |
+
type: 'filter_change',
|
314 |
+
message: `Log level filter changed from ${selectedLevel} to ${newLevel}`,
|
315 |
+
component: 'EventLogsTab',
|
316 |
+
previousLevel: selectedLevel,
|
317 |
+
newLevel,
|
318 |
+
});
|
319 |
+
setSelectedLevel(newLevel as string);
|
320 |
+
setShowLevelFilter(false);
|
321 |
+
},
|
322 |
+
[selectedLevel],
|
323 |
+
);
|
324 |
+
|
325 |
+
// Log search changes with debounce
|
326 |
+
useEffect(() => {
|
327 |
+
const timeoutId = setTimeout(() => {
|
328 |
+
if (searchQuery) {
|
329 |
+
logStore.logInfo('Log search performed', {
|
330 |
+
type: 'search',
|
331 |
+
message: `Search performed with query "${searchQuery}" (${filteredLogs.length} results)`,
|
332 |
+
component: 'EventLogsTab',
|
333 |
+
query: searchQuery,
|
334 |
+
resultsCount: filteredLogs.length,
|
335 |
+
});
|
336 |
+
}
|
337 |
+
}, 1000);
|
338 |
+
|
339 |
+
return () => clearTimeout(timeoutId);
|
340 |
+
}, [searchQuery, filteredLogs.length]);
|
341 |
+
|
342 |
+
// Enhanced refresh handler
|
343 |
+
const handleRefresh = useCallback(async () => {
|
344 |
+
const startTime = performance.now();
|
345 |
+
setIsRefreshing(true);
|
346 |
+
|
347 |
+
try {
|
348 |
+
await logStore.refreshLogs();
|
349 |
+
|
350 |
+
const duration = performance.now() - startTime;
|
351 |
+
|
352 |
+
logStore.logSuccess('Logs refreshed successfully', {
|
353 |
+
type: 'refresh',
|
354 |
+
message: `Successfully refreshed ${Object.keys(logs).length} logs`,
|
355 |
+
component: 'EventLogsTab',
|
356 |
+
duration,
|
357 |
+
logsCount: Object.keys(logs).length,
|
358 |
+
});
|
359 |
+
} catch (error) {
|
360 |
+
logStore.logError('Failed to refresh logs', error, {
|
361 |
+
type: 'refresh_error',
|
362 |
+
message: 'Failed to refresh logs',
|
363 |
+
component: 'EventLogsTab',
|
364 |
+
});
|
365 |
+
} finally {
|
366 |
+
setTimeout(() => setIsRefreshing(false), 500);
|
367 |
+
}
|
368 |
+
}, [logs]);
|
369 |
+
|
370 |
+
// Log preference changes
|
371 |
+
const handlePreferenceChange = useCallback((type: string, value: boolean) => {
|
372 |
+
logStore.logInfo('Log preference changed', {
|
373 |
+
type: 'preference_change',
|
374 |
+
message: `Log preference "${type}" changed to ${value}`,
|
375 |
+
component: 'EventLogsTab',
|
376 |
+
preference: type,
|
377 |
+
value,
|
378 |
+
});
|
379 |
+
|
380 |
+
switch (type) {
|
381 |
+
case 'timestamps':
|
382 |
+
setShowTimestamps(value);
|
383 |
+
break;
|
384 |
+
case '24hour':
|
385 |
+
setUse24Hour(value);
|
386 |
+
break;
|
387 |
+
case 'autoExpand':
|
388 |
+
setAutoExpand(value);
|
389 |
+
break;
|
390 |
+
}
|
391 |
+
}, []);
|
392 |
+
|
393 |
+
// Close filters when clicking outside
|
394 |
+
useEffect(() => {
|
395 |
+
const handleClickOutside = (event: MouseEvent) => {
|
396 |
+
if (levelFilterRef.current && !levelFilterRef.current.contains(event.target as Node)) {
|
397 |
+
setShowLevelFilter(false);
|
398 |
+
}
|
399 |
+
};
|
400 |
+
|
401 |
+
document.addEventListener('mousedown', handleClickOutside);
|
402 |
+
|
403 |
+
return () => {
|
404 |
+
document.removeEventListener('mousedown', handleClickOutside);
|
405 |
+
};
|
406 |
+
}, []);
|
407 |
+
|
408 |
+
const selectedLevelOption = logLevelOptions.find((opt) => opt.value === selectedLevel);
|
409 |
+
|
410 |
+
// Export functions
|
411 |
+
const exportAsJSON = () => {
|
412 |
+
try {
|
413 |
+
const exportData = {
|
414 |
+
timestamp: new Date().toISOString(),
|
415 |
+
logs: filteredLogs,
|
416 |
+
filters: {
|
417 |
+
level: selectedLevel,
|
418 |
+
searchQuery,
|
419 |
+
},
|
420 |
+
preferences: {
|
421 |
+
use24Hour,
|
422 |
+
showTimestamps,
|
423 |
+
autoExpand,
|
424 |
+
},
|
425 |
+
};
|
426 |
+
|
427 |
+
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
|
428 |
+
const url = window.URL.createObjectURL(blob);
|
429 |
+
const a = document.createElement('a');
|
430 |
+
a.href = url;
|
431 |
+
a.download = `bolt-event-logs-${new Date().toISOString()}.json`;
|
432 |
+
document.body.appendChild(a);
|
433 |
+
a.click();
|
434 |
+
window.URL.revokeObjectURL(url);
|
435 |
+
document.body.removeChild(a);
|
436 |
+
toast.success('Event logs exported successfully as JSON');
|
437 |
+
} catch (error) {
|
438 |
+
console.error('Failed to export JSON:', error);
|
439 |
+
toast.error('Failed to export event logs as JSON');
|
440 |
+
}
|
441 |
+
};
|
442 |
+
|
443 |
+
const exportAsCSV = () => {
|
444 |
+
try {
|
445 |
+
// Convert logs to CSV format
|
446 |
+
const headers = ['Timestamp', 'Level', 'Category', 'Message', 'Details'];
|
447 |
+
const csvData = [
|
448 |
+
headers,
|
449 |
+
...filteredLogs.map((log) => [
|
450 |
+
new Date(log.timestamp).toISOString(),
|
451 |
+
log.level,
|
452 |
+
log.category || '',
|
453 |
+
log.message,
|
454 |
+
log.details ? JSON.stringify(log.details) : '',
|
455 |
+
]),
|
456 |
+
];
|
457 |
+
|
458 |
+
const csvContent = csvData
|
459 |
+
.map((row) => row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(','))
|
460 |
+
.join('\n');
|
461 |
+
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
462 |
+
const url = window.URL.createObjectURL(blob);
|
463 |
+
const a = document.createElement('a');
|
464 |
+
a.href = url;
|
465 |
+
a.download = `bolt-event-logs-${new Date().toISOString()}.csv`;
|
466 |
+
document.body.appendChild(a);
|
467 |
+
a.click();
|
468 |
+
window.URL.revokeObjectURL(url);
|
469 |
+
document.body.removeChild(a);
|
470 |
+
toast.success('Event logs exported successfully as CSV');
|
471 |
+
} catch (error) {
|
472 |
+
console.error('Failed to export CSV:', error);
|
473 |
+
toast.error('Failed to export event logs as CSV');
|
474 |
+
}
|
475 |
+
};
|
476 |
+
|
477 |
+
const exportAsPDF = () => {
|
478 |
+
try {
|
479 |
+
// Create new PDF document
|
480 |
+
const doc = new jsPDF();
|
481 |
+
const lineHeight = 7;
|
482 |
+
let yPos = 20;
|
483 |
+
const margin = 20;
|
484 |
+
const pageWidth = doc.internal.pageSize.getWidth();
|
485 |
+
const maxLineWidth = pageWidth - 2 * margin;
|
486 |
+
|
487 |
+
// Helper function to add section header
|
488 |
+
const addSectionHeader = (title: string) => {
|
489 |
+
// Check if we need a new page
|
490 |
+
if (yPos > doc.internal.pageSize.getHeight() - 30) {
|
491 |
+
doc.addPage();
|
492 |
+
yPos = margin;
|
493 |
+
}
|
494 |
+
|
495 |
+
doc.setFillColor('#F3F4F6');
|
496 |
+
doc.rect(margin - 2, yPos - 5, pageWidth - 2 * (margin - 2), lineHeight + 6, 'F');
|
497 |
+
doc.setFont('helvetica', 'bold');
|
498 |
+
doc.setTextColor('#111827');
|
499 |
+
doc.setFontSize(12);
|
500 |
+
doc.text(title.toUpperCase(), margin, yPos);
|
501 |
+
yPos += lineHeight * 2;
|
502 |
+
};
|
503 |
+
|
504 |
+
// Add title and header
|
505 |
+
doc.setFillColor('#6366F1');
|
506 |
+
doc.rect(0, 0, pageWidth, 50, 'F');
|
507 |
+
doc.setTextColor('#FFFFFF');
|
508 |
+
doc.setFontSize(24);
|
509 |
+
doc.setFont('helvetica', 'bold');
|
510 |
+
doc.text('Event Logs Report', margin, 35);
|
511 |
+
|
512 |
+
// Add subtitle with bolt.diy
|
513 |
+
doc.setFontSize(12);
|
514 |
+
doc.setFont('helvetica', 'normal');
|
515 |
+
doc.text('bolt.diy - AI Development Platform', margin, 45);
|
516 |
+
yPos = 70;
|
517 |
+
|
518 |
+
// Add report summary section
|
519 |
+
addSectionHeader('Report Summary');
|
520 |
+
|
521 |
+
doc.setFontSize(10);
|
522 |
+
doc.setFont('helvetica', 'normal');
|
523 |
+
doc.setTextColor('#374151');
|
524 |
+
|
525 |
+
const summaryItems = [
|
526 |
+
{ label: 'Generated', value: new Date().toLocaleString() },
|
527 |
+
{ label: 'Total Logs', value: filteredLogs.length.toString() },
|
528 |
+
{ label: 'Filter Applied', value: selectedLevel === 'all' ? 'All Types' : selectedLevel },
|
529 |
+
{ label: 'Search Query', value: searchQuery || 'None' },
|
530 |
+
{ label: 'Time Format', value: use24Hour ? '24-hour' : '12-hour' },
|
531 |
+
];
|
532 |
+
|
533 |
+
summaryItems.forEach((item) => {
|
534 |
+
doc.setFont('helvetica', 'bold');
|
535 |
+
doc.text(`${item.label}:`, margin, yPos);
|
536 |
+
doc.setFont('helvetica', 'normal');
|
537 |
+
doc.text(item.value, margin + 60, yPos);
|
538 |
+
yPos += lineHeight;
|
539 |
+
});
|
540 |
+
|
541 |
+
yPos += lineHeight * 2;
|
542 |
+
|
543 |
+
// Add statistics section
|
544 |
+
addSectionHeader('Log Statistics');
|
545 |
+
|
546 |
+
// Calculate statistics
|
547 |
+
const stats = {
|
548 |
+
error: filteredLogs.filter((log) => log.level === 'error').length,
|
549 |
+
warning: filteredLogs.filter((log) => log.level === 'warning').length,
|
550 |
+
info: filteredLogs.filter((log) => log.level === 'info').length,
|
551 |
+
debug: filteredLogs.filter((log) => log.level === 'debug').length,
|
552 |
+
provider: filteredLogs.filter((log) => log.category === 'provider').length,
|
553 |
+
api: filteredLogs.filter((log) => log.category === 'api').length,
|
554 |
+
};
|
555 |
+
|
556 |
+
// Create two columns for statistics
|
557 |
+
const leftStats = [
|
558 |
+
{ label: 'Error Logs', value: stats.error, color: '#DC2626' },
|
559 |
+
{ label: 'Warning Logs', value: stats.warning, color: '#F59E0B' },
|
560 |
+
{ label: 'Info Logs', value: stats.info, color: '#3B82F6' },
|
561 |
+
];
|
562 |
+
|
563 |
+
const rightStats = [
|
564 |
+
{ label: 'Debug Logs', value: stats.debug, color: '#6B7280' },
|
565 |
+
{ label: 'LLM Logs', value: stats.provider, color: '#10B981' },
|
566 |
+
{ label: 'API Logs', value: stats.api, color: '#3B82F6' },
|
567 |
+
];
|
568 |
+
|
569 |
+
const colWidth = (pageWidth - 2 * margin) / 2;
|
570 |
+
|
571 |
+
// Draw statistics in two columns
|
572 |
+
leftStats.forEach((stat, index) => {
|
573 |
+
doc.setTextColor(stat.color);
|
574 |
+
doc.setFont('helvetica', 'bold');
|
575 |
+
doc.text(stat.value.toString(), margin, yPos);
|
576 |
+
doc.setTextColor('#374151');
|
577 |
+
doc.setFont('helvetica', 'normal');
|
578 |
+
doc.text(stat.label, margin + 20, yPos);
|
579 |
+
|
580 |
+
if (rightStats[index]) {
|
581 |
+
doc.setTextColor(rightStats[index].color);
|
582 |
+
doc.setFont('helvetica', 'bold');
|
583 |
+
doc.text(rightStats[index].value.toString(), margin + colWidth, yPos);
|
584 |
+
doc.setTextColor('#374151');
|
585 |
+
doc.setFont('helvetica', 'normal');
|
586 |
+
doc.text(rightStats[index].label, margin + colWidth + 20, yPos);
|
587 |
+
}
|
588 |
+
|
589 |
+
yPos += lineHeight;
|
590 |
+
});
|
591 |
+
|
592 |
+
yPos += lineHeight * 2;
|
593 |
+
|
594 |
+
// Add logs section
|
595 |
+
addSectionHeader('Event Logs');
|
596 |
+
|
597 |
+
// Helper function to add a log entry with improved formatting
|
598 |
+
const addLogEntry = (log: LogEntry) => {
|
599 |
+
const entryHeight = 20 + (log.details ? 40 : 0); // Estimate entry height
|
600 |
+
|
601 |
+
// Check if we need a new page
|
602 |
+
if (yPos + entryHeight > doc.internal.pageSize.getHeight() - 20) {
|
603 |
+
doc.addPage();
|
604 |
+
yPos = margin;
|
605 |
+
}
|
606 |
+
|
607 |
+
// Add timestamp and level
|
608 |
+
const timestamp = new Date(log.timestamp).toLocaleString(undefined, {
|
609 |
+
year: 'numeric',
|
610 |
+
month: '2-digit',
|
611 |
+
day: '2-digit',
|
612 |
+
hour: '2-digit',
|
613 |
+
minute: '2-digit',
|
614 |
+
second: '2-digit',
|
615 |
+
hour12: !use24Hour,
|
616 |
+
});
|
617 |
+
|
618 |
+
// Draw log level badge background
|
619 |
+
const levelColors: Record<string, string> = {
|
620 |
+
error: '#FEE2E2',
|
621 |
+
warning: '#FEF3C7',
|
622 |
+
info: '#DBEAFE',
|
623 |
+
debug: '#F3F4F6',
|
624 |
+
};
|
625 |
+
|
626 |
+
const textColors: Record<string, string> = {
|
627 |
+
error: '#DC2626',
|
628 |
+
warning: '#F59E0B',
|
629 |
+
info: '#3B82F6',
|
630 |
+
debug: '#6B7280',
|
631 |
+
};
|
632 |
+
|
633 |
+
const levelWidth = doc.getTextWidth(log.level.toUpperCase()) + 10;
|
634 |
+
doc.setFillColor(levelColors[log.level] || '#F3F4F6');
|
635 |
+
doc.roundedRect(margin, yPos - 4, levelWidth, lineHeight + 4, 1, 1, 'F');
|
636 |
+
|
637 |
+
// Add log level text
|
638 |
+
doc.setTextColor(textColors[log.level] || '#6B7280');
|
639 |
+
doc.setFont('helvetica', 'bold');
|
640 |
+
doc.setFontSize(8);
|
641 |
+
doc.text(log.level.toUpperCase(), margin + 5, yPos);
|
642 |
+
|
643 |
+
// Add timestamp
|
644 |
+
doc.setTextColor('#6B7280');
|
645 |
+
doc.setFont('helvetica', 'normal');
|
646 |
+
doc.setFontSize(9);
|
647 |
+
doc.text(timestamp, margin + levelWidth + 10, yPos);
|
648 |
+
|
649 |
+
// Add category if present
|
650 |
+
if (log.category) {
|
651 |
+
const categoryX = margin + levelWidth + doc.getTextWidth(timestamp) + 20;
|
652 |
+
doc.setFillColor('#F3F4F6');
|
653 |
+
|
654 |
+
const categoryWidth = doc.getTextWidth(log.category) + 10;
|
655 |
+
doc.roundedRect(categoryX, yPos - 4, categoryWidth, lineHeight + 4, 2, 2, 'F');
|
656 |
+
doc.setTextColor('#6B7280');
|
657 |
+
doc.text(log.category, categoryX + 5, yPos);
|
658 |
+
}
|
659 |
+
|
660 |
+
yPos += lineHeight * 1.5;
|
661 |
+
|
662 |
+
// Add message
|
663 |
+
doc.setTextColor('#111827');
|
664 |
+
doc.setFontSize(10);
|
665 |
+
|
666 |
+
const messageLines = doc.splitTextToSize(log.message, maxLineWidth - 10);
|
667 |
+
doc.text(messageLines, margin + 5, yPos);
|
668 |
+
yPos += messageLines.length * lineHeight;
|
669 |
+
|
670 |
+
// Add details if present
|
671 |
+
if (log.details) {
|
672 |
+
doc.setTextColor('#6B7280');
|
673 |
+
doc.setFontSize(8);
|
674 |
+
|
675 |
+
const detailsStr = JSON.stringify(log.details, null, 2);
|
676 |
+
const detailsLines = doc.splitTextToSize(detailsStr, maxLineWidth - 15);
|
677 |
+
|
678 |
+
// Add details background
|
679 |
+
doc.setFillColor('#F9FAFB');
|
680 |
+
doc.roundedRect(margin + 5, yPos - 2, maxLineWidth - 10, detailsLines.length * lineHeight + 8, 1, 1, 'F');
|
681 |
+
|
682 |
+
doc.text(detailsLines, margin + 10, yPos + 4);
|
683 |
+
yPos += detailsLines.length * lineHeight + 10;
|
684 |
+
}
|
685 |
+
|
686 |
+
// Add separator line
|
687 |
+
doc.setDrawColor('#E5E7EB');
|
688 |
+
doc.setLineWidth(0.1);
|
689 |
+
doc.line(margin, yPos, pageWidth - margin, yPos);
|
690 |
+
yPos += lineHeight * 1.5;
|
691 |
+
};
|
692 |
+
|
693 |
+
// Add all logs
|
694 |
+
filteredLogs.forEach((log) => {
|
695 |
+
addLogEntry(log);
|
696 |
+
});
|
697 |
+
|
698 |
+
// Add footer to all pages
|
699 |
+
const totalPages = doc.internal.pages.length - 1;
|
700 |
+
|
701 |
+
for (let i = 1; i <= totalPages; i++) {
|
702 |
+
doc.setPage(i);
|
703 |
+
doc.setFontSize(8);
|
704 |
+
doc.setTextColor('#9CA3AF');
|
705 |
+
|
706 |
+
// Add page numbers
|
707 |
+
doc.text(`Page ${i} of ${totalPages}`, pageWidth / 2, doc.internal.pageSize.getHeight() - 10, {
|
708 |
+
align: 'center',
|
709 |
+
});
|
710 |
+
|
711 |
+
// Add footer text
|
712 |
+
doc.text('Generated by bolt.diy', margin, doc.internal.pageSize.getHeight() - 10);
|
713 |
+
|
714 |
+
const dateStr = new Date().toLocaleDateString();
|
715 |
+
doc.text(dateStr, pageWidth - margin, doc.internal.pageSize.getHeight() - 10, { align: 'right' });
|
716 |
+
}
|
717 |
+
|
718 |
+
// Save the PDF
|
719 |
+
doc.save(`bolt-event-logs-${new Date().toISOString()}.pdf`);
|
720 |
+
toast.success('Event logs exported successfully as PDF');
|
721 |
+
} catch (error) {
|
722 |
+
console.error('Failed to export PDF:', error);
|
723 |
+
toast.error('Failed to export event logs as PDF');
|
724 |
+
}
|
725 |
+
};
|
726 |
+
|
727 |
+
const exportAsText = () => {
|
728 |
+
try {
|
729 |
+
const textContent = filteredLogs
|
730 |
+
.map((log) => {
|
731 |
+
const timestamp = new Date(log.timestamp).toLocaleString();
|
732 |
+
let content = `[${timestamp}] ${log.level.toUpperCase()}: ${log.message}\n`;
|
733 |
+
|
734 |
+
if (log.category) {
|
735 |
+
content += `Category: ${log.category}\n`;
|
736 |
+
}
|
737 |
+
|
738 |
+
if (log.details) {
|
739 |
+
content += `Details:\n${JSON.stringify(log.details, null, 2)}\n`;
|
740 |
+
}
|
741 |
+
|
742 |
+
return content + '-'.repeat(80) + '\n';
|
743 |
+
})
|
744 |
+
.join('\n');
|
745 |
+
|
746 |
+
const blob = new Blob([textContent], { type: 'text/plain' });
|
747 |
+
const url = window.URL.createObjectURL(blob);
|
748 |
+
const a = document.createElement('a');
|
749 |
+
a.href = url;
|
750 |
+
a.download = `bolt-event-logs-${new Date().toISOString()}.txt`;
|
751 |
+
document.body.appendChild(a);
|
752 |
+
a.click();
|
753 |
+
window.URL.revokeObjectURL(url);
|
754 |
+
document.body.removeChild(a);
|
755 |
+
toast.success('Event logs exported successfully as text file');
|
756 |
+
} catch (error) {
|
757 |
+
console.error('Failed to export text file:', error);
|
758 |
+
toast.error('Failed to export event logs as text file');
|
759 |
+
}
|
760 |
+
};
|
761 |
+
|
762 |
+
const exportFormats: ExportFormat[] = [
|
763 |
+
{
|
764 |
+
id: 'json',
|
765 |
+
label: 'Export as JSON',
|
766 |
+
icon: 'i-ph:file-json',
|
767 |
+
handler: exportAsJSON,
|
768 |
+
},
|
769 |
+
{
|
770 |
+
id: 'csv',
|
771 |
+
label: 'Export as CSV',
|
772 |
+
icon: 'i-ph:file-csv',
|
773 |
+
handler: exportAsCSV,
|
774 |
+
},
|
775 |
+
{
|
776 |
+
id: 'pdf',
|
777 |
+
label: 'Export as PDF',
|
778 |
+
icon: 'i-ph:file-pdf',
|
779 |
+
handler: exportAsPDF,
|
780 |
+
},
|
781 |
+
{
|
782 |
+
id: 'txt',
|
783 |
+
label: 'Export as Text',
|
784 |
+
icon: 'i-ph:file-text',
|
785 |
+
handler: exportAsText,
|
786 |
+
},
|
787 |
+
];
|
788 |
+
|
789 |
+
const ExportButton = () => {
|
790 |
+
const [isOpen, setIsOpen] = useState(false);
|
791 |
+
|
792 |
+
const handleOpenChange = useCallback((open: boolean) => {
|
793 |
+
setIsOpen(open);
|
794 |
+
}, []);
|
795 |
+
|
796 |
+
const handleFormatClick = useCallback((handler: () => void) => {
|
797 |
+
handler();
|
798 |
+
setIsOpen(false);
|
799 |
+
}, []);
|
800 |
+
|
801 |
+
return (
|
802 |
+
<DialogRoot open={isOpen} onOpenChange={handleOpenChange}>
|
803 |
+
<button
|
804 |
+
onClick={() => setIsOpen(true)}
|
805 |
+
className={classNames(
|
806 |
+
'group flex items-center gap-2',
|
807 |
+
'rounded-lg px-3 py-1.5',
|
808 |
+
'text-sm text-gray-900 dark:text-white',
|
809 |
+
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
|
810 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
811 |
+
'hover:bg-purple-500/10 dark:hover:bg-purple-500/20',
|
812 |
+
'transition-all duration-200',
|
813 |
+
)}
|
814 |
+
>
|
815 |
+
<span className="i-ph:download text-lg text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
|
816 |
+
Export
|
817 |
+
</button>
|
818 |
+
|
819 |
+
<Dialog showCloseButton>
|
820 |
+
<div className="p-6">
|
821 |
+
<DialogTitle className="flex items-center gap-2">
|
822 |
+
<div className="i-ph:download w-5 h-5" />
|
823 |
+
Export Event Logs
|
824 |
+
</DialogTitle>
|
825 |
+
|
826 |
+
<div className="mt-4 flex flex-col gap-2">
|
827 |
+
{exportFormats.map((format) => (
|
828 |
+
<button
|
829 |
+
key={format.id}
|
830 |
+
onClick={() => handleFormatClick(format.handler)}
|
831 |
+
className={classNames(
|
832 |
+
'flex items-center gap-3 px-4 py-3 text-sm rounded-lg transition-colors w-full text-left',
|
833 |
+
'bg-white dark:bg-[#0A0A0A]',
|
834 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
835 |
+
'hover:bg-purple-50 dark:hover:bg-[#1a1a1a]',
|
836 |
+
'hover:border-purple-200 dark:hover:border-purple-900/30',
|
837 |
+
'text-bolt-elements-textPrimary',
|
838 |
+
)}
|
839 |
+
>
|
840 |
+
<div className={classNames(format.icon, 'w-5 h-5')} />
|
841 |
+
<div>
|
842 |
+
<div className="font-medium">{format.label}</div>
|
843 |
+
<div className="text-xs text-bolt-elements-textSecondary mt-0.5">
|
844 |
+
{format.id === 'json' && 'Export as a structured JSON file'}
|
845 |
+
{format.id === 'csv' && 'Export as a CSV spreadsheet'}
|
846 |
+
{format.id === 'pdf' && 'Export as a formatted PDF document'}
|
847 |
+
{format.id === 'txt' && 'Export as a formatted text file'}
|
848 |
+
</div>
|
849 |
+
</div>
|
850 |
+
</button>
|
851 |
+
))}
|
852 |
+
</div>
|
853 |
+
</div>
|
854 |
+
</Dialog>
|
855 |
+
</DialogRoot>
|
856 |
+
);
|
857 |
+
};
|
858 |
+
|
859 |
+
return (
|
860 |
+
<div className="flex h-full flex-col gap-6">
|
861 |
+
<div className="flex items-center justify-between">
|
862 |
+
<DropdownMenu.Root open={showLevelFilter} onOpenChange={setShowLevelFilter}>
|
863 |
+
<DropdownMenu.Trigger asChild>
|
864 |
+
<button
|
865 |
+
className={classNames(
|
866 |
+
'flex items-center gap-2',
|
867 |
+
'rounded-lg px-3 py-1.5',
|
868 |
+
'text-sm text-gray-900 dark:text-white',
|
869 |
+
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
|
870 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
871 |
+
'hover:bg-purple-500/10 dark:hover:bg-purple-500/20',
|
872 |
+
'transition-all duration-200',
|
873 |
+
)}
|
874 |
+
>
|
875 |
+
<span
|
876 |
+
className={classNames('text-lg', selectedLevelOption?.icon || 'i-ph:funnel')}
|
877 |
+
style={{ color: selectedLevelOption?.color }}
|
878 |
+
/>
|
879 |
+
{selectedLevelOption?.label || 'All Types'}
|
880 |
+
<span className="i-ph:caret-down text-lg text-gray-500 dark:text-gray-400" />
|
881 |
+
</button>
|
882 |
+
</DropdownMenu.Trigger>
|
883 |
+
|
884 |
+
<DropdownMenu.Portal>
|
885 |
+
<DropdownMenu.Content
|
886 |
+
className="min-w-[200px] bg-white dark:bg-[#0A0A0A] rounded-lg shadow-lg py-1 z-[250] animate-in fade-in-0 zoom-in-95 border border-[#E5E5E5] dark:border-[#1A1A1A]"
|
887 |
+
sideOffset={5}
|
888 |
+
align="start"
|
889 |
+
side="bottom"
|
890 |
+
>
|
891 |
+
{logLevelOptions.map((option) => (
|
892 |
+
<DropdownMenu.Item
|
893 |
+
key={option.value}
|
894 |
+
className="group flex items-center px-4 py-2.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-purple-500/10 dark:hover:bg-purple-500/20 cursor-pointer transition-colors"
|
895 |
+
onClick={() => handleLevelFilterChange(option.value)}
|
896 |
+
>
|
897 |
+
<div className="mr-3 flex h-5 w-5 items-center justify-center">
|
898 |
+
<div
|
899 |
+
className={classNames(option.icon, 'text-lg group-hover:text-purple-500 transition-colors')}
|
900 |
+
style={{ color: option.color }}
|
901 |
+
/>
|
902 |
+
</div>
|
903 |
+
<span className="group-hover:text-purple-500 transition-colors">{option.label}</span>
|
904 |
+
</DropdownMenu.Item>
|
905 |
+
))}
|
906 |
+
</DropdownMenu.Content>
|
907 |
+
</DropdownMenu.Portal>
|
908 |
+
</DropdownMenu.Root>
|
909 |
+
|
910 |
+
<div className="flex items-center gap-4">
|
911 |
+
<div className="flex items-center gap-2">
|
912 |
+
<Switch
|
913 |
+
checked={showTimestamps}
|
914 |
+
onCheckedChange={(value) => handlePreferenceChange('timestamps', value)}
|
915 |
+
className="data-[state=checked]:bg-purple-500"
|
916 |
+
/>
|
917 |
+
<span className="text-sm text-gray-500 dark:text-gray-400">Show Timestamps</span>
|
918 |
+
</div>
|
919 |
+
|
920 |
+
<div className="flex items-center gap-2">
|
921 |
+
<Switch
|
922 |
+
checked={use24Hour}
|
923 |
+
onCheckedChange={(value) => handlePreferenceChange('24hour', value)}
|
924 |
+
className="data-[state=checked]:bg-purple-500"
|
925 |
+
/>
|
926 |
+
<span className="text-sm text-gray-500 dark:text-gray-400">24h Time</span>
|
927 |
+
</div>
|
928 |
+
|
929 |
+
<div className="flex items-center gap-2">
|
930 |
+
<Switch
|
931 |
+
checked={autoExpand}
|
932 |
+
onCheckedChange={(value) => handlePreferenceChange('autoExpand', value)}
|
933 |
+
className="data-[state=checked]:bg-purple-500"
|
934 |
+
/>
|
935 |
+
<span className="text-sm text-gray-500 dark:text-gray-400">Auto Expand</span>
|
936 |
+
</div>
|
937 |
+
|
938 |
+
<div className="w-px h-4 bg-gray-200 dark:bg-gray-700" />
|
939 |
+
|
940 |
+
<button
|
941 |
+
onClick={handleRefresh}
|
942 |
+
className={classNames(
|
943 |
+
'group flex items-center gap-2',
|
944 |
+
'rounded-lg px-3 py-1.5',
|
945 |
+
'text-sm text-gray-900 dark:text-white',
|
946 |
+
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
|
947 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
948 |
+
'hover:bg-purple-500/10 dark:hover:bg-purple-500/20',
|
949 |
+
'transition-all duration-200',
|
950 |
+
{ 'animate-spin': isRefreshing },
|
951 |
+
)}
|
952 |
+
>
|
953 |
+
<span className="i-ph:arrows-clockwise text-lg text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
|
954 |
+
Refresh
|
955 |
+
</button>
|
956 |
+
|
957 |
+
<ExportButton />
|
958 |
+
</div>
|
959 |
+
</div>
|
960 |
+
|
961 |
+
<div className="flex flex-col gap-4">
|
962 |
+
<div className="relative">
|
963 |
+
<input
|
964 |
+
type="text"
|
965 |
+
placeholder="Search logs..."
|
966 |
+
value={searchQuery}
|
967 |
+
onChange={(e) => setSearchQuery(e.target.value)}
|
968 |
+
className={classNames(
|
969 |
+
'w-full px-4 py-2 pl-10 rounded-lg',
|
970 |
+
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
|
971 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
972 |
+
'text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400',
|
973 |
+
'focus:outline-none focus:ring-2 focus:ring-purple-500/20 focus:border-purple-500',
|
974 |
+
'transition-all duration-200',
|
975 |
+
)}
|
976 |
+
/>
|
977 |
+
<div className="absolute left-3 top-1/2 -translate-y-1/2">
|
978 |
+
<div className="i-ph:magnifying-glass text-lg text-gray-500 dark:text-gray-400" />
|
979 |
+
</div>
|
980 |
+
</div>
|
981 |
+
|
982 |
+
{filteredLogs.length === 0 ? (
|
983 |
+
<motion.div
|
984 |
+
initial={{ opacity: 0, y: 20 }}
|
985 |
+
animate={{ opacity: 1, y: 0 }}
|
986 |
+
className={classNames(
|
987 |
+
'flex flex-col items-center justify-center gap-4',
|
988 |
+
'rounded-lg p-8 text-center',
|
989 |
+
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
|
990 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
991 |
+
)}
|
992 |
+
>
|
993 |
+
<span className="i-ph:clipboard-text text-4xl text-gray-400 dark:text-gray-600" />
|
994 |
+
<div className="flex flex-col gap-1">
|
995 |
+
<h3 className="text-sm font-medium text-gray-900 dark:text-white">No Logs Found</h3>
|
996 |
+
<p className="text-sm text-gray-500 dark:text-gray-400">Try adjusting your search or filters</p>
|
997 |
+
</div>
|
998 |
+
</motion.div>
|
999 |
+
) : (
|
1000 |
+
filteredLogs.map((log) => (
|
1001 |
+
<LogEntryItem
|
1002 |
+
key={log.id}
|
1003 |
+
log={log}
|
1004 |
+
isExpanded={autoExpand}
|
1005 |
+
use24Hour={use24Hour}
|
1006 |
+
showTimestamp={showTimestamps}
|
1007 |
+
/>
|
1008 |
+
))
|
1009 |
+
)}
|
1010 |
+
</div>
|
1011 |
+
</div>
|
1012 |
+
);
|
1013 |
+
}
|
app/components/@settings/tabs/features/FeaturesTab.tsx
ADDED
@@ -0,0 +1,295 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
// Remove unused imports
|
2 |
+
import React, { memo, useCallback } from 'react';
|
3 |
+
import { motion } from 'framer-motion';
|
4 |
+
import { Switch } from '~/components/ui/Switch';
|
5 |
+
import { useSettings } from '~/lib/hooks/useSettings';
|
6 |
+
import { classNames } from '~/utils/classNames';
|
7 |
+
import { toast } from 'react-toastify';
|
8 |
+
import { PromptLibrary } from '~/lib/common/prompt-library';
|
9 |
+
|
10 |
+
interface FeatureToggle {
|
11 |
+
id: string;
|
12 |
+
title: string;
|
13 |
+
description: string;
|
14 |
+
icon: string;
|
15 |
+
enabled: boolean;
|
16 |
+
beta?: boolean;
|
17 |
+
experimental?: boolean;
|
18 |
+
tooltip?: string;
|
19 |
+
}
|
20 |
+
|
21 |
+
const FeatureCard = memo(
|
22 |
+
({
|
23 |
+
feature,
|
24 |
+
index,
|
25 |
+
onToggle,
|
26 |
+
}: {
|
27 |
+
feature: FeatureToggle;
|
28 |
+
index: number;
|
29 |
+
onToggle: (id: string, enabled: boolean) => void;
|
30 |
+
}) => (
|
31 |
+
<motion.div
|
32 |
+
key={feature.id}
|
33 |
+
layoutId={feature.id}
|
34 |
+
className={classNames(
|
35 |
+
'relative group cursor-pointer',
|
36 |
+
'bg-bolt-elements-background-depth-2',
|
37 |
+
'hover:bg-bolt-elements-background-depth-3',
|
38 |
+
'transition-colors duration-200',
|
39 |
+
'rounded-lg overflow-hidden',
|
40 |
+
)}
|
41 |
+
initial={{ opacity: 0, y: 20 }}
|
42 |
+
animate={{ opacity: 1, y: 0 }}
|
43 |
+
transition={{ delay: index * 0.1 }}
|
44 |
+
>
|
45 |
+
<div className="p-4">
|
46 |
+
<div className="flex items-center justify-between">
|
47 |
+
<div className="flex items-center gap-3">
|
48 |
+
<div className={classNames(feature.icon, 'w-5 h-5 text-bolt-elements-textSecondary')} />
|
49 |
+
<div className="flex items-center gap-2">
|
50 |
+
<h4 className="font-medium text-bolt-elements-textPrimary">{feature.title}</h4>
|
51 |
+
{feature.beta && (
|
52 |
+
<span className="px-2 py-0.5 text-xs rounded-full bg-blue-500/10 text-blue-500 font-medium">Beta</span>
|
53 |
+
)}
|
54 |
+
{feature.experimental && (
|
55 |
+
<span className="px-2 py-0.5 text-xs rounded-full bg-orange-500/10 text-orange-500 font-medium">
|
56 |
+
Experimental
|
57 |
+
</span>
|
58 |
+
)}
|
59 |
+
</div>
|
60 |
+
</div>
|
61 |
+
<Switch checked={feature.enabled} onCheckedChange={(checked) => onToggle(feature.id, checked)} />
|
62 |
+
</div>
|
63 |
+
<p className="mt-2 text-sm text-bolt-elements-textSecondary">{feature.description}</p>
|
64 |
+
{feature.tooltip && <p className="mt-1 text-xs text-bolt-elements-textTertiary">{feature.tooltip}</p>}
|
65 |
+
</div>
|
66 |
+
</motion.div>
|
67 |
+
),
|
68 |
+
);
|
69 |
+
|
70 |
+
const FeatureSection = memo(
|
71 |
+
({
|
72 |
+
title,
|
73 |
+
features,
|
74 |
+
icon,
|
75 |
+
description,
|
76 |
+
onToggleFeature,
|
77 |
+
}: {
|
78 |
+
title: string;
|
79 |
+
features: FeatureToggle[];
|
80 |
+
icon: string;
|
81 |
+
description: string;
|
82 |
+
onToggleFeature: (id: string, enabled: boolean) => void;
|
83 |
+
}) => (
|
84 |
+
<motion.div
|
85 |
+
layout
|
86 |
+
className="flex flex-col gap-4"
|
87 |
+
initial={{ opacity: 0, y: 20 }}
|
88 |
+
animate={{ opacity: 1, y: 0 }}
|
89 |
+
transition={{ duration: 0.3 }}
|
90 |
+
>
|
91 |
+
<div className="flex items-center gap-3">
|
92 |
+
<div className={classNames(icon, 'text-xl text-purple-500')} />
|
93 |
+
<div>
|
94 |
+
<h3 className="text-lg font-medium text-bolt-elements-textPrimary">{title}</h3>
|
95 |
+
<p className="text-sm text-bolt-elements-textSecondary">{description}</p>
|
96 |
+
</div>
|
97 |
+
</div>
|
98 |
+
|
99 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
100 |
+
{features.map((feature, index) => (
|
101 |
+
<FeatureCard key={feature.id} feature={feature} index={index} onToggle={onToggleFeature} />
|
102 |
+
))}
|
103 |
+
</div>
|
104 |
+
</motion.div>
|
105 |
+
),
|
106 |
+
);
|
107 |
+
|
108 |
+
export default function FeaturesTab() {
|
109 |
+
const {
|
110 |
+
autoSelectTemplate,
|
111 |
+
isLatestBranch,
|
112 |
+
contextOptimizationEnabled,
|
113 |
+
eventLogs,
|
114 |
+
setAutoSelectTemplate,
|
115 |
+
enableLatestBranch,
|
116 |
+
enableContextOptimization,
|
117 |
+
setEventLogs,
|
118 |
+
setPromptId,
|
119 |
+
promptId,
|
120 |
+
} = useSettings();
|
121 |
+
|
122 |
+
// Enable features by default on first load
|
123 |
+
React.useEffect(() => {
|
124 |
+
// Only set defaults if values are undefined
|
125 |
+
if (isLatestBranch === undefined) {
|
126 |
+
enableLatestBranch(false); // Default: OFF - Don't auto-update from main branch
|
127 |
+
}
|
128 |
+
|
129 |
+
if (contextOptimizationEnabled === undefined) {
|
130 |
+
enableContextOptimization(true); // Default: ON - Enable context optimization
|
131 |
+
}
|
132 |
+
|
133 |
+
if (autoSelectTemplate === undefined) {
|
134 |
+
setAutoSelectTemplate(true); // Default: ON - Enable auto-select templates
|
135 |
+
}
|
136 |
+
|
137 |
+
if (promptId === undefined) {
|
138 |
+
setPromptId('default'); // Default: 'default'
|
139 |
+
}
|
140 |
+
|
141 |
+
if (eventLogs === undefined) {
|
142 |
+
setEventLogs(true); // Default: ON - Enable event logging
|
143 |
+
}
|
144 |
+
}, []); // Only run once on component mount
|
145 |
+
|
146 |
+
const handleToggleFeature = useCallback(
|
147 |
+
(id: string, enabled: boolean) => {
|
148 |
+
switch (id) {
|
149 |
+
case 'latestBranch': {
|
150 |
+
enableLatestBranch(enabled);
|
151 |
+
toast.success(`Main branch updates ${enabled ? 'enabled' : 'disabled'}`);
|
152 |
+
break;
|
153 |
+
}
|
154 |
+
|
155 |
+
case 'autoSelectTemplate': {
|
156 |
+
setAutoSelectTemplate(enabled);
|
157 |
+
toast.success(`Auto select template ${enabled ? 'enabled' : 'disabled'}`);
|
158 |
+
break;
|
159 |
+
}
|
160 |
+
|
161 |
+
case 'contextOptimization': {
|
162 |
+
enableContextOptimization(enabled);
|
163 |
+
toast.success(`Context optimization ${enabled ? 'enabled' : 'disabled'}`);
|
164 |
+
break;
|
165 |
+
}
|
166 |
+
|
167 |
+
case 'eventLogs': {
|
168 |
+
setEventLogs(enabled);
|
169 |
+
toast.success(`Event logging ${enabled ? 'enabled' : 'disabled'}`);
|
170 |
+
break;
|
171 |
+
}
|
172 |
+
|
173 |
+
default:
|
174 |
+
break;
|
175 |
+
}
|
176 |
+
},
|
177 |
+
[enableLatestBranch, setAutoSelectTemplate, enableContextOptimization, setEventLogs],
|
178 |
+
);
|
179 |
+
|
180 |
+
const features = {
|
181 |
+
stable: [
|
182 |
+
{
|
183 |
+
id: 'latestBranch',
|
184 |
+
title: 'Main Branch Updates',
|
185 |
+
description: 'Get the latest updates from the main branch',
|
186 |
+
icon: 'i-ph:git-branch',
|
187 |
+
enabled: isLatestBranch,
|
188 |
+
tooltip: 'Enabled by default to receive updates from the main development branch',
|
189 |
+
},
|
190 |
+
{
|
191 |
+
id: 'autoSelectTemplate',
|
192 |
+
title: 'Auto Select Template',
|
193 |
+
description: 'Automatically select starter template',
|
194 |
+
icon: 'i-ph:selection',
|
195 |
+
enabled: autoSelectTemplate,
|
196 |
+
tooltip: 'Enabled by default to automatically select the most appropriate starter template',
|
197 |
+
},
|
198 |
+
{
|
199 |
+
id: 'contextOptimization',
|
200 |
+
title: 'Context Optimization',
|
201 |
+
description: 'Optimize context for better responses',
|
202 |
+
icon: 'i-ph:brain',
|
203 |
+
enabled: contextOptimizationEnabled,
|
204 |
+
tooltip: 'Enabled by default for improved AI responses',
|
205 |
+
},
|
206 |
+
{
|
207 |
+
id: 'eventLogs',
|
208 |
+
title: 'Event Logging',
|
209 |
+
description: 'Enable detailed event logging and history',
|
210 |
+
icon: 'i-ph:list-bullets',
|
211 |
+
enabled: eventLogs,
|
212 |
+
tooltip: 'Enabled by default to record detailed logs of system events and user actions',
|
213 |
+
},
|
214 |
+
],
|
215 |
+
beta: [],
|
216 |
+
};
|
217 |
+
|
218 |
+
return (
|
219 |
+
<div className="flex flex-col gap-8">
|
220 |
+
<FeatureSection
|
221 |
+
title="Core Features"
|
222 |
+
features={features.stable}
|
223 |
+
icon="i-ph:check-circle"
|
224 |
+
description="Essential features that are enabled by default for optimal performance"
|
225 |
+
onToggleFeature={handleToggleFeature}
|
226 |
+
/>
|
227 |
+
|
228 |
+
{features.beta.length > 0 && (
|
229 |
+
<FeatureSection
|
230 |
+
title="Beta Features"
|
231 |
+
features={features.beta}
|
232 |
+
icon="i-ph:test-tube"
|
233 |
+
description="New features that are ready for testing but may have some rough edges"
|
234 |
+
onToggleFeature={handleToggleFeature}
|
235 |
+
/>
|
236 |
+
)}
|
237 |
+
|
238 |
+
<motion.div
|
239 |
+
layout
|
240 |
+
className={classNames(
|
241 |
+
'bg-bolt-elements-background-depth-2',
|
242 |
+
'hover:bg-bolt-elements-background-depth-3',
|
243 |
+
'transition-all duration-200',
|
244 |
+
'rounded-lg p-4',
|
245 |
+
'group',
|
246 |
+
)}
|
247 |
+
initial={{ opacity: 0, y: 20 }}
|
248 |
+
animate={{ opacity: 1, y: 0 }}
|
249 |
+
transition={{ delay: 0.3 }}
|
250 |
+
>
|
251 |
+
<div className="flex items-center gap-4">
|
252 |
+
<div
|
253 |
+
className={classNames(
|
254 |
+
'p-2 rounded-lg text-xl',
|
255 |
+
'bg-bolt-elements-background-depth-3 group-hover:bg-bolt-elements-background-depth-4',
|
256 |
+
'transition-colors duration-200',
|
257 |
+
'text-purple-500',
|
258 |
+
)}
|
259 |
+
>
|
260 |
+
<div className="i-ph:book" />
|
261 |
+
</div>
|
262 |
+
<div className="flex-1">
|
263 |
+
<h4 className="text-sm font-medium text-bolt-elements-textPrimary group-hover:text-purple-500 transition-colors">
|
264 |
+
Prompt Library
|
265 |
+
</h4>
|
266 |
+
<p className="text-xs text-bolt-elements-textSecondary mt-0.5">
|
267 |
+
Choose a prompt from the library to use as the system prompt
|
268 |
+
</p>
|
269 |
+
</div>
|
270 |
+
<select
|
271 |
+
value={promptId}
|
272 |
+
onChange={(e) => {
|
273 |
+
setPromptId(e.target.value);
|
274 |
+
toast.success('Prompt template updated');
|
275 |
+
}}
|
276 |
+
className={classNames(
|
277 |
+
'p-2 rounded-lg text-sm min-w-[200px]',
|
278 |
+
'bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor',
|
279 |
+
'text-bolt-elements-textPrimary',
|
280 |
+
'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
|
281 |
+
'group-hover:border-purple-500/30',
|
282 |
+
'transition-all duration-200',
|
283 |
+
)}
|
284 |
+
>
|
285 |
+
{PromptLibrary.getList().map((x) => (
|
286 |
+
<option key={x.id} value={x.id}>
|
287 |
+
{x.label}
|
288 |
+
</option>
|
289 |
+
))}
|
290 |
+
</select>
|
291 |
+
</div>
|
292 |
+
</motion.div>
|
293 |
+
</div>
|
294 |
+
);
|
295 |
+
}
|
app/components/@settings/tabs/notifications/NotificationsTab.tsx
ADDED
@@ -0,0 +1,300 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React, { useState, useEffect } from 'react';
|
2 |
+
import { motion } from 'framer-motion';
|
3 |
+
import { logStore } from '~/lib/stores/logs';
|
4 |
+
import { useStore } from '@nanostores/react';
|
5 |
+
import { formatDistanceToNow } from 'date-fns';
|
6 |
+
import { classNames } from '~/utils/classNames';
|
7 |
+
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
8 |
+
|
9 |
+
interface NotificationDetails {
|
10 |
+
type?: string;
|
11 |
+
message?: string;
|
12 |
+
currentVersion?: string;
|
13 |
+
latestVersion?: string;
|
14 |
+
branch?: string;
|
15 |
+
updateUrl?: string;
|
16 |
+
}
|
17 |
+
|
18 |
+
type FilterType = 'all' | 'system' | 'error' | 'warning' | 'update' | 'info' | 'provider' | 'network';
|
19 |
+
|
20 |
+
const NotificationsTab = () => {
|
21 |
+
const [filter, setFilter] = useState<FilterType>('all');
|
22 |
+
const logs = useStore(logStore.logs);
|
23 |
+
|
24 |
+
useEffect(() => {
|
25 |
+
const startTime = performance.now();
|
26 |
+
|
27 |
+
return () => {
|
28 |
+
const duration = performance.now() - startTime;
|
29 |
+
logStore.logPerformanceMetric('NotificationsTab', 'mount-duration', duration);
|
30 |
+
};
|
31 |
+
}, []);
|
32 |
+
|
33 |
+
const handleClearNotifications = () => {
|
34 |
+
const count = Object.keys(logs).length;
|
35 |
+
logStore.logInfo('Cleared notifications', {
|
36 |
+
type: 'notification_clear',
|
37 |
+
message: `Cleared ${count} notifications`,
|
38 |
+
clearedCount: count,
|
39 |
+
component: 'notifications',
|
40 |
+
});
|
41 |
+
logStore.clearLogs();
|
42 |
+
};
|
43 |
+
|
44 |
+
const handleUpdateAction = (updateUrl: string) => {
|
45 |
+
logStore.logInfo('Update link clicked', {
|
46 |
+
type: 'update_click',
|
47 |
+
message: 'User clicked update link',
|
48 |
+
updateUrl,
|
49 |
+
component: 'notifications',
|
50 |
+
});
|
51 |
+
window.open(updateUrl, '_blank');
|
52 |
+
};
|
53 |
+
|
54 |
+
const handleFilterChange = (newFilter: FilterType) => {
|
55 |
+
logStore.logInfo('Notification filter changed', {
|
56 |
+
type: 'filter_change',
|
57 |
+
message: `Filter changed to ${newFilter}`,
|
58 |
+
previousFilter: filter,
|
59 |
+
newFilter,
|
60 |
+
component: 'notifications',
|
61 |
+
});
|
62 |
+
setFilter(newFilter);
|
63 |
+
};
|
64 |
+
|
65 |
+
const filteredLogs = Object.values(logs)
|
66 |
+
.filter((log) => {
|
67 |
+
if (filter === 'all') {
|
68 |
+
return true;
|
69 |
+
}
|
70 |
+
|
71 |
+
if (filter === 'update') {
|
72 |
+
return log.details?.type === 'update';
|
73 |
+
}
|
74 |
+
|
75 |
+
if (filter === 'system') {
|
76 |
+
return log.category === 'system';
|
77 |
+
}
|
78 |
+
|
79 |
+
if (filter === 'provider') {
|
80 |
+
return log.category === 'provider';
|
81 |
+
}
|
82 |
+
|
83 |
+
if (filter === 'network') {
|
84 |
+
return log.category === 'network';
|
85 |
+
}
|
86 |
+
|
87 |
+
return log.level === filter;
|
88 |
+
})
|
89 |
+
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
90 |
+
|
91 |
+
const getNotificationStyle = (level: string, type?: string) => {
|
92 |
+
if (type === 'update') {
|
93 |
+
return {
|
94 |
+
icon: 'i-ph:arrow-circle-up',
|
95 |
+
color: 'text-purple-500 dark:text-purple-400',
|
96 |
+
bg: 'hover:bg-purple-500/10 dark:hover:bg-purple-500/20',
|
97 |
+
};
|
98 |
+
}
|
99 |
+
|
100 |
+
switch (level) {
|
101 |
+
case 'error':
|
102 |
+
return {
|
103 |
+
icon: 'i-ph:warning-circle',
|
104 |
+
color: 'text-red-500 dark:text-red-400',
|
105 |
+
bg: 'hover:bg-red-500/10 dark:hover:bg-red-500/20',
|
106 |
+
};
|
107 |
+
case 'warning':
|
108 |
+
return {
|
109 |
+
icon: 'i-ph:warning',
|
110 |
+
color: 'text-yellow-500 dark:text-yellow-400',
|
111 |
+
bg: 'hover:bg-yellow-500/10 dark:hover:bg-yellow-500/20',
|
112 |
+
};
|
113 |
+
case 'info':
|
114 |
+
return {
|
115 |
+
icon: 'i-ph:info',
|
116 |
+
color: 'text-blue-500 dark:text-blue-400',
|
117 |
+
bg: 'hover:bg-blue-500/10 dark:hover:bg-blue-500/20',
|
118 |
+
};
|
119 |
+
default:
|
120 |
+
return {
|
121 |
+
icon: 'i-ph:bell',
|
122 |
+
color: 'text-gray-500 dark:text-gray-400',
|
123 |
+
bg: 'hover:bg-gray-500/10 dark:hover:bg-gray-500/20',
|
124 |
+
};
|
125 |
+
}
|
126 |
+
};
|
127 |
+
|
128 |
+
const renderNotificationDetails = (details: NotificationDetails) => {
|
129 |
+
if (details.type === 'update') {
|
130 |
+
return (
|
131 |
+
<div className="flex flex-col gap-2">
|
132 |
+
<p className="text-sm text-gray-600 dark:text-gray-400">{details.message}</p>
|
133 |
+
<div className="flex flex-col gap-1 text-xs text-gray-500 dark:text-gray-500">
|
134 |
+
<p>Current Version: {details.currentVersion}</p>
|
135 |
+
<p>Latest Version: {details.latestVersion}</p>
|
136 |
+
<p>Branch: {details.branch}</p>
|
137 |
+
</div>
|
138 |
+
<button
|
139 |
+
onClick={() => details.updateUrl && handleUpdateAction(details.updateUrl)}
|
140 |
+
className={classNames(
|
141 |
+
'mt-2 inline-flex items-center gap-2',
|
142 |
+
'rounded-lg px-3 py-1.5',
|
143 |
+
'text-sm font-medium',
|
144 |
+
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
|
145 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
146 |
+
'text-gray-900 dark:text-white',
|
147 |
+
'hover:bg-purple-500/10 dark:hover:bg-purple-500/20',
|
148 |
+
'transition-all duration-200',
|
149 |
+
)}
|
150 |
+
>
|
151 |
+
<span className="i-ph:git-branch text-lg" />
|
152 |
+
View Changes
|
153 |
+
</button>
|
154 |
+
</div>
|
155 |
+
);
|
156 |
+
}
|
157 |
+
|
158 |
+
return details.message ? <p className="text-sm text-gray-600 dark:text-gray-400">{details.message}</p> : null;
|
159 |
+
};
|
160 |
+
|
161 |
+
const filterOptions: { id: FilterType; label: string; icon: string; color: string }[] = [
|
162 |
+
{ id: 'all', label: 'All Notifications', icon: 'i-ph:bell', color: '#9333ea' },
|
163 |
+
{ id: 'system', label: 'System', icon: 'i-ph:gear', color: '#6b7280' },
|
164 |
+
{ id: 'update', label: 'Updates', icon: 'i-ph:arrow-circle-up', color: '#9333ea' },
|
165 |
+
{ id: 'error', label: 'Errors', icon: 'i-ph:warning-circle', color: '#ef4444' },
|
166 |
+
{ id: 'warning', label: 'Warnings', icon: 'i-ph:warning', color: '#f59e0b' },
|
167 |
+
{ id: 'info', label: 'Information', icon: 'i-ph:info', color: '#3b82f6' },
|
168 |
+
{ id: 'provider', label: 'Providers', icon: 'i-ph:robot', color: '#10b981' },
|
169 |
+
{ id: 'network', label: 'Network', icon: 'i-ph:wifi-high', color: '#6366f1' },
|
170 |
+
];
|
171 |
+
|
172 |
+
return (
|
173 |
+
<div className="flex h-full flex-col gap-6">
|
174 |
+
<div className="flex items-center justify-between">
|
175 |
+
<DropdownMenu.Root>
|
176 |
+
<DropdownMenu.Trigger asChild>
|
177 |
+
<button
|
178 |
+
className={classNames(
|
179 |
+
'flex items-center gap-2',
|
180 |
+
'rounded-lg px-3 py-1.5',
|
181 |
+
'text-sm text-gray-900 dark:text-white',
|
182 |
+
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
|
183 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
184 |
+
'hover:bg-purple-500/10 dark:hover:bg-purple-500/20',
|
185 |
+
'transition-all duration-200',
|
186 |
+
)}
|
187 |
+
>
|
188 |
+
<span
|
189 |
+
className={classNames('text-lg', filterOptions.find((opt) => opt.id === filter)?.icon || 'i-ph:funnel')}
|
190 |
+
style={{ color: filterOptions.find((opt) => opt.id === filter)?.color }}
|
191 |
+
/>
|
192 |
+
{filterOptions.find((opt) => opt.id === filter)?.label || 'Filter Notifications'}
|
193 |
+
<span className="i-ph:caret-down text-lg text-gray-500 dark:text-gray-400" />
|
194 |
+
</button>
|
195 |
+
</DropdownMenu.Trigger>
|
196 |
+
|
197 |
+
<DropdownMenu.Portal>
|
198 |
+
<DropdownMenu.Content
|
199 |
+
className="min-w-[200px] bg-white dark:bg-[#0A0A0A] rounded-lg shadow-lg py-1 z-[250] animate-in fade-in-0 zoom-in-95 border border-[#E5E5E5] dark:border-[#1A1A1A]"
|
200 |
+
sideOffset={5}
|
201 |
+
align="start"
|
202 |
+
side="bottom"
|
203 |
+
>
|
204 |
+
{filterOptions.map((option) => (
|
205 |
+
<DropdownMenu.Item
|
206 |
+
key={option.id}
|
207 |
+
className="group flex items-center px-4 py-2.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-purple-500/10 dark:hover:bg-purple-500/20 cursor-pointer transition-colors"
|
208 |
+
onClick={() => handleFilterChange(option.id)}
|
209 |
+
>
|
210 |
+
<div className="mr-3 flex h-5 w-5 items-center justify-center">
|
211 |
+
<div
|
212 |
+
className={classNames(option.icon, 'text-lg group-hover:text-purple-500 transition-colors')}
|
213 |
+
style={{ color: option.color }}
|
214 |
+
/>
|
215 |
+
</div>
|
216 |
+
<span className="group-hover:text-purple-500 transition-colors">{option.label}</span>
|
217 |
+
</DropdownMenu.Item>
|
218 |
+
))}
|
219 |
+
</DropdownMenu.Content>
|
220 |
+
</DropdownMenu.Portal>
|
221 |
+
</DropdownMenu.Root>
|
222 |
+
|
223 |
+
<button
|
224 |
+
onClick={handleClearNotifications}
|
225 |
+
className={classNames(
|
226 |
+
'group flex items-center gap-2',
|
227 |
+
'rounded-lg px-3 py-1.5',
|
228 |
+
'text-sm text-gray-900 dark:text-white',
|
229 |
+
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
|
230 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
231 |
+
'hover:bg-purple-500/10 dark:hover:bg-purple-500/20',
|
232 |
+
'transition-all duration-200',
|
233 |
+
)}
|
234 |
+
>
|
235 |
+
<span className="i-ph:trash text-lg text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
|
236 |
+
Clear All
|
237 |
+
</button>
|
238 |
+
</div>
|
239 |
+
|
240 |
+
<div className="flex flex-col gap-4">
|
241 |
+
{filteredLogs.length === 0 ? (
|
242 |
+
<motion.div
|
243 |
+
initial={{ opacity: 0, y: 20 }}
|
244 |
+
animate={{ opacity: 1, y: 0 }}
|
245 |
+
className={classNames(
|
246 |
+
'flex flex-col items-center justify-center gap-4',
|
247 |
+
'rounded-lg p-8 text-center',
|
248 |
+
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
|
249 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
250 |
+
)}
|
251 |
+
>
|
252 |
+
<span className="i-ph:bell-slash text-4xl text-gray-400 dark:text-gray-600" />
|
253 |
+
<div className="flex flex-col gap-1">
|
254 |
+
<h3 className="text-sm font-medium text-gray-900 dark:text-white">No Notifications</h3>
|
255 |
+
<p className="text-sm text-gray-500 dark:text-gray-400">You're all caught up!</p>
|
256 |
+
</div>
|
257 |
+
</motion.div>
|
258 |
+
) : (
|
259 |
+
filteredLogs.map((log) => {
|
260 |
+
const style = getNotificationStyle(log.level, log.details?.type);
|
261 |
+
return (
|
262 |
+
<motion.div
|
263 |
+
key={log.id}
|
264 |
+
initial={{ opacity: 0, y: 20 }}
|
265 |
+
animate={{ opacity: 1, y: 0 }}
|
266 |
+
className={classNames(
|
267 |
+
'flex flex-col gap-2',
|
268 |
+
'rounded-lg p-4',
|
269 |
+
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
|
270 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
271 |
+
style.bg,
|
272 |
+
'transition-all duration-200',
|
273 |
+
)}
|
274 |
+
>
|
275 |
+
<div className="flex items-start justify-between gap-4">
|
276 |
+
<div className="flex items-start gap-3">
|
277 |
+
<span className={classNames('text-lg', style.icon, style.color)} />
|
278 |
+
<div className="flex flex-col gap-1">
|
279 |
+
<h3 className="text-sm font-medium text-gray-900 dark:text-white">{log.message}</h3>
|
280 |
+
{log.details && renderNotificationDetails(log.details as NotificationDetails)}
|
281 |
+
<p className="text-xs text-gray-500 dark:text-gray-400">
|
282 |
+
Category: {log.category}
|
283 |
+
{log.subCategory ? ` > ${log.subCategory}` : ''}
|
284 |
+
</p>
|
285 |
+
</div>
|
286 |
+
</div>
|
287 |
+
<time className="shrink-0 text-xs text-gray-500 dark:text-gray-400">
|
288 |
+
{formatDistanceToNow(new Date(log.timestamp), { addSuffix: true })}
|
289 |
+
</time>
|
290 |
+
</div>
|
291 |
+
</motion.div>
|
292 |
+
);
|
293 |
+
})
|
294 |
+
)}
|
295 |
+
</div>
|
296 |
+
</div>
|
297 |
+
);
|
298 |
+
};
|
299 |
+
|
300 |
+
export default NotificationsTab;
|
app/components/@settings/tabs/profile/ProfileTab.tsx
ADDED
@@ -0,0 +1,181 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { useState, useCallback } from 'react';
|
2 |
+
import { useStore } from '@nanostores/react';
|
3 |
+
import { classNames } from '~/utils/classNames';
|
4 |
+
import { profileStore, updateProfile } from '~/lib/stores/profile';
|
5 |
+
import { toast } from 'react-toastify';
|
6 |
+
import { debounce } from '~/utils/debounce';
|
7 |
+
|
8 |
+
export default function ProfileTab() {
|
9 |
+
const profile = useStore(profileStore);
|
10 |
+
const [isUploading, setIsUploading] = useState(false);
|
11 |
+
|
12 |
+
// Create debounced update functions
|
13 |
+
const debouncedUpdate = useCallback(
|
14 |
+
debounce((field: 'username' | 'bio', value: string) => {
|
15 |
+
updateProfile({ [field]: value });
|
16 |
+
toast.success(`${field.charAt(0).toUpperCase() + field.slice(1)} updated`);
|
17 |
+
}, 1000),
|
18 |
+
[],
|
19 |
+
);
|
20 |
+
|
21 |
+
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
22 |
+
const file = e.target.files?.[0];
|
23 |
+
|
24 |
+
if (!file) {
|
25 |
+
return;
|
26 |
+
}
|
27 |
+
|
28 |
+
try {
|
29 |
+
setIsUploading(true);
|
30 |
+
|
31 |
+
// Convert the file to base64
|
32 |
+
const reader = new FileReader();
|
33 |
+
|
34 |
+
reader.onloadend = () => {
|
35 |
+
const base64String = reader.result as string;
|
36 |
+
updateProfile({ avatar: base64String });
|
37 |
+
setIsUploading(false);
|
38 |
+
toast.success('Profile picture updated');
|
39 |
+
};
|
40 |
+
|
41 |
+
reader.onerror = () => {
|
42 |
+
console.error('Error reading file:', reader.error);
|
43 |
+
setIsUploading(false);
|
44 |
+
toast.error('Failed to update profile picture');
|
45 |
+
};
|
46 |
+
reader.readAsDataURL(file);
|
47 |
+
} catch (error) {
|
48 |
+
console.error('Error uploading avatar:', error);
|
49 |
+
setIsUploading(false);
|
50 |
+
toast.error('Failed to update profile picture');
|
51 |
+
}
|
52 |
+
};
|
53 |
+
|
54 |
+
const handleProfileUpdate = (field: 'username' | 'bio', value: string) => {
|
55 |
+
// Update the store immediately for UI responsiveness
|
56 |
+
updateProfile({ [field]: value });
|
57 |
+
|
58 |
+
// Debounce the toast notification
|
59 |
+
debouncedUpdate(field, value);
|
60 |
+
};
|
61 |
+
|
62 |
+
return (
|
63 |
+
<div className="max-w-2xl mx-auto">
|
64 |
+
<div className="space-y-6">
|
65 |
+
{/* Personal Information Section */}
|
66 |
+
<div>
|
67 |
+
{/* Avatar Upload */}
|
68 |
+
<div className="flex items-start gap-6 mb-8">
|
69 |
+
<div
|
70 |
+
className={classNames(
|
71 |
+
'w-24 h-24 rounded-full overflow-hidden',
|
72 |
+
'bg-gray-100 dark:bg-gray-800/50',
|
73 |
+
'flex items-center justify-center',
|
74 |
+
'ring-1 ring-gray-200 dark:ring-gray-700',
|
75 |
+
'relative group',
|
76 |
+
'transition-all duration-300 ease-out',
|
77 |
+
'hover:ring-purple-500/30 dark:hover:ring-purple-500/30',
|
78 |
+
'hover:shadow-lg hover:shadow-purple-500/10',
|
79 |
+
)}
|
80 |
+
>
|
81 |
+
{profile.avatar ? (
|
82 |
+
<img
|
83 |
+
src={profile.avatar}
|
84 |
+
alt="Profile"
|
85 |
+
className={classNames(
|
86 |
+
'w-full h-full object-cover',
|
87 |
+
'transition-all duration-300 ease-out',
|
88 |
+
'group-hover:scale-105 group-hover:brightness-90',
|
89 |
+
)}
|
90 |
+
/>
|
91 |
+
) : (
|
92 |
+
<div className="i-ph:robot-fill w-16 h-16 text-gray-400 dark:text-gray-500 transition-colors group-hover:text-purple-500/70 transform -translate-y-1" />
|
93 |
+
)}
|
94 |
+
|
95 |
+
<label
|
96 |
+
className={classNames(
|
97 |
+
'absolute inset-0',
|
98 |
+
'flex items-center justify-center',
|
99 |
+
'bg-black/0 group-hover:bg-black/40',
|
100 |
+
'cursor-pointer transition-all duration-300 ease-out',
|
101 |
+
isUploading ? 'cursor-wait' : '',
|
102 |
+
)}
|
103 |
+
>
|
104 |
+
<input
|
105 |
+
type="file"
|
106 |
+
accept="image/*"
|
107 |
+
className="hidden"
|
108 |
+
onChange={handleAvatarUpload}
|
109 |
+
disabled={isUploading}
|
110 |
+
/>
|
111 |
+
{isUploading ? (
|
112 |
+
<div className="i-ph:spinner-gap w-6 h-6 text-white animate-spin" />
|
113 |
+
) : (
|
114 |
+
<div className="i-ph:camera-plus w-6 h-6 text-white opacity-0 group-hover:opacity-100 transition-all duration-300 ease-out transform group-hover:scale-110" />
|
115 |
+
)}
|
116 |
+
</label>
|
117 |
+
</div>
|
118 |
+
|
119 |
+
<div className="flex-1 pt-1">
|
120 |
+
<label className="block text-base font-medium text-gray-900 dark:text-gray-100 mb-1">
|
121 |
+
Profile Picture
|
122 |
+
</label>
|
123 |
+
<p className="text-sm text-gray-500 dark:text-gray-400">Upload a profile picture or avatar</p>
|
124 |
+
</div>
|
125 |
+
</div>
|
126 |
+
|
127 |
+
{/* Username Input */}
|
128 |
+
<div className="mb-6">
|
129 |
+
<label className="block text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">Username</label>
|
130 |
+
<div className="relative group">
|
131 |
+
<div className="absolute left-3.5 top-1/2 -translate-y-1/2">
|
132 |
+
<div className="i-ph:user-circle-fill w-5 h-5 text-gray-400 dark:text-gray-500 transition-colors group-focus-within:text-purple-500" />
|
133 |
+
</div>
|
134 |
+
<input
|
135 |
+
type="text"
|
136 |
+
value={profile.username}
|
137 |
+
onChange={(e) => handleProfileUpdate('username', e.target.value)}
|
138 |
+
className={classNames(
|
139 |
+
'w-full pl-11 pr-4 py-2.5 rounded-xl',
|
140 |
+
'bg-white dark:bg-gray-800/50',
|
141 |
+
'border border-gray-200 dark:border-gray-700/50',
|
142 |
+
'text-gray-900 dark:text-white',
|
143 |
+
'placeholder-gray-400 dark:placeholder-gray-500',
|
144 |
+
'focus:outline-none focus:ring-2 focus:ring-purple-500/50 focus:border-purple-500/50',
|
145 |
+
'transition-all duration-300 ease-out',
|
146 |
+
)}
|
147 |
+
placeholder="Enter your username"
|
148 |
+
/>
|
149 |
+
</div>
|
150 |
+
</div>
|
151 |
+
|
152 |
+
{/* Bio Input */}
|
153 |
+
<div className="mb-8">
|
154 |
+
<label className="block text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">Bio</label>
|
155 |
+
<div className="relative group">
|
156 |
+
<div className="absolute left-3.5 top-3">
|
157 |
+
<div className="i-ph:text-aa w-5 h-5 text-gray-400 dark:text-gray-500 transition-colors group-focus-within:text-purple-500" />
|
158 |
+
</div>
|
159 |
+
<textarea
|
160 |
+
value={profile.bio}
|
161 |
+
onChange={(e) => handleProfileUpdate('bio', e.target.value)}
|
162 |
+
className={classNames(
|
163 |
+
'w-full pl-11 pr-4 py-2.5 rounded-xl',
|
164 |
+
'bg-white dark:bg-gray-800/50',
|
165 |
+
'border border-gray-200 dark:border-gray-700/50',
|
166 |
+
'text-gray-900 dark:text-white',
|
167 |
+
'placeholder-gray-400 dark:placeholder-gray-500',
|
168 |
+
'focus:outline-none focus:ring-2 focus:ring-purple-500/50 focus:border-purple-500/50',
|
169 |
+
'transition-all duration-300 ease-out',
|
170 |
+
'resize-none',
|
171 |
+
'h-32',
|
172 |
+
)}
|
173 |
+
placeholder="Tell us about yourself"
|
174 |
+
/>
|
175 |
+
</div>
|
176 |
+
</div>
|
177 |
+
</div>
|
178 |
+
</div>
|
179 |
+
</div>
|
180 |
+
);
|
181 |
+
}
|
app/components/@settings/tabs/providers/cloud/CloudProvidersTab.tsx
ADDED
@@ -0,0 +1,305 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React, { useEffect, useState, useCallback } from 'react';
|
2 |
+
import { Switch } from '~/components/ui/Switch';
|
3 |
+
import { useSettings } from '~/lib/hooks/useSettings';
|
4 |
+
import { URL_CONFIGURABLE_PROVIDERS } from '~/lib/stores/settings';
|
5 |
+
import type { IProviderConfig } from '~/types/model';
|
6 |
+
import { logStore } from '~/lib/stores/logs';
|
7 |
+
import { motion } from 'framer-motion';
|
8 |
+
import { classNames } from '~/utils/classNames';
|
9 |
+
import { toast } from 'react-toastify';
|
10 |
+
import { providerBaseUrlEnvKeys } from '~/utils/constants';
|
11 |
+
import { SiAmazon, SiGoogle, SiHuggingface, SiPerplexity, SiOpenai } from 'react-icons/si';
|
12 |
+
import { BsRobot, BsCloud } from 'react-icons/bs';
|
13 |
+
import { TbBrain, TbCloudComputing } from 'react-icons/tb';
|
14 |
+
import { BiCodeBlock, BiChip } from 'react-icons/bi';
|
15 |
+
import { FaCloud, FaBrain } from 'react-icons/fa';
|
16 |
+
import type { IconType } from 'react-icons';
|
17 |
+
|
18 |
+
// Add type for provider names to ensure type safety
|
19 |
+
type ProviderName =
|
20 |
+
| 'AmazonBedrock'
|
21 |
+
| 'Anthropic'
|
22 |
+
| 'Cohere'
|
23 |
+
| 'Deepseek'
|
24 |
+
| 'Google'
|
25 |
+
| 'Groq'
|
26 |
+
| 'HuggingFace'
|
27 |
+
| 'Hyperbolic'
|
28 |
+
| 'Mistral'
|
29 |
+
| 'OpenAI'
|
30 |
+
| 'OpenRouter'
|
31 |
+
| 'Perplexity'
|
32 |
+
| 'Together'
|
33 |
+
| 'XAI';
|
34 |
+
|
35 |
+
// Update the PROVIDER_ICONS type to use the ProviderName type
|
36 |
+
const PROVIDER_ICONS: Record<ProviderName, IconType> = {
|
37 |
+
AmazonBedrock: SiAmazon,
|
38 |
+
Anthropic: FaBrain,
|
39 |
+
Cohere: BiChip,
|
40 |
+
Deepseek: BiCodeBlock,
|
41 |
+
Google: SiGoogle,
|
42 |
+
Groq: BsCloud,
|
43 |
+
HuggingFace: SiHuggingface,
|
44 |
+
Hyperbolic: TbCloudComputing,
|
45 |
+
Mistral: TbBrain,
|
46 |
+
OpenAI: SiOpenai,
|
47 |
+
OpenRouter: FaCloud,
|
48 |
+
Perplexity: SiPerplexity,
|
49 |
+
Together: BsCloud,
|
50 |
+
XAI: BsRobot,
|
51 |
+
};
|
52 |
+
|
53 |
+
// Update PROVIDER_DESCRIPTIONS to use the same type
|
54 |
+
const PROVIDER_DESCRIPTIONS: Partial<Record<ProviderName, string>> = {
|
55 |
+
Anthropic: 'Access Claude and other Anthropic models',
|
56 |
+
OpenAI: 'Use GPT-4, GPT-3.5, and other OpenAI models',
|
57 |
+
};
|
58 |
+
|
59 |
+
const CloudProvidersTab = () => {
|
60 |
+
const settings = useSettings();
|
61 |
+
const [editingProvider, setEditingProvider] = useState<string | null>(null);
|
62 |
+
const [filteredProviders, setFilteredProviders] = useState<IProviderConfig[]>([]);
|
63 |
+
const [categoryEnabled, setCategoryEnabled] = useState<boolean>(false);
|
64 |
+
|
65 |
+
// Load and filter providers
|
66 |
+
useEffect(() => {
|
67 |
+
const newFilteredProviders = Object.entries(settings.providers || {})
|
68 |
+
.filter(([key]) => !['Ollama', 'LMStudio', 'OpenAILike'].includes(key))
|
69 |
+
.map(([key, value]) => ({
|
70 |
+
name: key,
|
71 |
+
settings: value.settings,
|
72 |
+
staticModels: value.staticModels || [],
|
73 |
+
getDynamicModels: value.getDynamicModels,
|
74 |
+
getApiKeyLink: value.getApiKeyLink,
|
75 |
+
labelForGetApiKey: value.labelForGetApiKey,
|
76 |
+
icon: value.icon,
|
77 |
+
}));
|
78 |
+
|
79 |
+
const sorted = newFilteredProviders.sort((a, b) => a.name.localeCompare(b.name));
|
80 |
+
setFilteredProviders(sorted);
|
81 |
+
|
82 |
+
// Update category enabled state
|
83 |
+
const allEnabled = newFilteredProviders.every((p) => p.settings.enabled);
|
84 |
+
setCategoryEnabled(allEnabled);
|
85 |
+
}, [settings.providers]);
|
86 |
+
|
87 |
+
const handleToggleCategory = useCallback(
|
88 |
+
(enabled: boolean) => {
|
89 |
+
// Update all providers
|
90 |
+
filteredProviders.forEach((provider) => {
|
91 |
+
settings.updateProviderSettings(provider.name, { ...provider.settings, enabled });
|
92 |
+
});
|
93 |
+
|
94 |
+
setCategoryEnabled(enabled);
|
95 |
+
toast.success(enabled ? 'All cloud providers enabled' : 'All cloud providers disabled');
|
96 |
+
},
|
97 |
+
[filteredProviders, settings],
|
98 |
+
);
|
99 |
+
|
100 |
+
const handleToggleProvider = useCallback(
|
101 |
+
(provider: IProviderConfig, enabled: boolean) => {
|
102 |
+
// Update the provider settings in the store
|
103 |
+
settings.updateProviderSettings(provider.name, { ...provider.settings, enabled });
|
104 |
+
|
105 |
+
if (enabled) {
|
106 |
+
logStore.logProvider(`Provider ${provider.name} enabled`, { provider: provider.name });
|
107 |
+
toast.success(`${provider.name} enabled`);
|
108 |
+
} else {
|
109 |
+
logStore.logProvider(`Provider ${provider.name} disabled`, { provider: provider.name });
|
110 |
+
toast.success(`${provider.name} disabled`);
|
111 |
+
}
|
112 |
+
},
|
113 |
+
[settings],
|
114 |
+
);
|
115 |
+
|
116 |
+
const handleUpdateBaseUrl = useCallback(
|
117 |
+
(provider: IProviderConfig, baseUrl: string) => {
|
118 |
+
const newBaseUrl: string | undefined = baseUrl.trim() || undefined;
|
119 |
+
|
120 |
+
// Update the provider settings in the store
|
121 |
+
settings.updateProviderSettings(provider.name, { ...provider.settings, baseUrl: newBaseUrl });
|
122 |
+
|
123 |
+
logStore.logProvider(`Base URL updated for ${provider.name}`, {
|
124 |
+
provider: provider.name,
|
125 |
+
baseUrl: newBaseUrl,
|
126 |
+
});
|
127 |
+
toast.success(`${provider.name} base URL updated`);
|
128 |
+
setEditingProvider(null);
|
129 |
+
},
|
130 |
+
[settings],
|
131 |
+
);
|
132 |
+
|
133 |
+
return (
|
134 |
+
<div className="space-y-6">
|
135 |
+
<motion.div
|
136 |
+
className="space-y-4"
|
137 |
+
initial={{ opacity: 0, y: 20 }}
|
138 |
+
animate={{ opacity: 1, y: 0 }}
|
139 |
+
transition={{ duration: 0.3 }}
|
140 |
+
>
|
141 |
+
<div className="flex items-center justify-between gap-4 mt-8 mb-4">
|
142 |
+
<div className="flex items-center gap-2">
|
143 |
+
<div
|
144 |
+
className={classNames(
|
145 |
+
'w-8 h-8 flex items-center justify-center rounded-lg',
|
146 |
+
'bg-bolt-elements-background-depth-3',
|
147 |
+
'text-purple-500',
|
148 |
+
)}
|
149 |
+
>
|
150 |
+
<TbCloudComputing className="w-5 h-5" />
|
151 |
+
</div>
|
152 |
+
<div>
|
153 |
+
<h4 className="text-md font-medium text-bolt-elements-textPrimary">Cloud Providers</h4>
|
154 |
+
<p className="text-sm text-bolt-elements-textSecondary">Connect to cloud-based AI models and services</p>
|
155 |
+
</div>
|
156 |
+
</div>
|
157 |
+
|
158 |
+
<div className="flex items-center gap-2">
|
159 |
+
<span className="text-sm text-bolt-elements-textSecondary">Enable All Cloud</span>
|
160 |
+
<Switch checked={categoryEnabled} onCheckedChange={handleToggleCategory} />
|
161 |
+
</div>
|
162 |
+
</div>
|
163 |
+
|
164 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
165 |
+
{filteredProviders.map((provider, index) => (
|
166 |
+
<motion.div
|
167 |
+
key={provider.name}
|
168 |
+
className={classNames(
|
169 |
+
'rounded-lg border bg-bolt-elements-background text-bolt-elements-textPrimary shadow-sm',
|
170 |
+
'bg-bolt-elements-background-depth-2',
|
171 |
+
'hover:bg-bolt-elements-background-depth-3',
|
172 |
+
'transition-all duration-200',
|
173 |
+
'relative overflow-hidden group',
|
174 |
+
'flex flex-col',
|
175 |
+
)}
|
176 |
+
initial={{ opacity: 0, y: 20 }}
|
177 |
+
animate={{ opacity: 1, y: 0 }}
|
178 |
+
transition={{ delay: index * 0.1 }}
|
179 |
+
whileHover={{ scale: 1.02 }}
|
180 |
+
>
|
181 |
+
<div className="absolute top-0 right-0 p-2 flex gap-1">
|
182 |
+
{URL_CONFIGURABLE_PROVIDERS.includes(provider.name) && (
|
183 |
+
<motion.span
|
184 |
+
className="px-2 py-0.5 text-xs rounded-full bg-purple-500/10 text-purple-500 font-medium"
|
185 |
+
whileHover={{ scale: 1.05 }}
|
186 |
+
whileTap={{ scale: 0.95 }}
|
187 |
+
>
|
188 |
+
Configurable
|
189 |
+
</motion.span>
|
190 |
+
)}
|
191 |
+
</div>
|
192 |
+
|
193 |
+
<div className="flex items-start gap-4 p-4">
|
194 |
+
<motion.div
|
195 |
+
className={classNames(
|
196 |
+
'w-10 h-10 flex items-center justify-center rounded-xl',
|
197 |
+
'bg-bolt-elements-background-depth-3 group-hover:bg-bolt-elements-background-depth-4',
|
198 |
+
'transition-all duration-200',
|
199 |
+
provider.settings.enabled ? 'text-purple-500' : 'text-bolt-elements-textSecondary',
|
200 |
+
)}
|
201 |
+
whileHover={{ scale: 1.1 }}
|
202 |
+
whileTap={{ scale: 0.9 }}
|
203 |
+
>
|
204 |
+
<div className={classNames('w-6 h-6', 'transition-transform duration-200', 'group-hover:rotate-12')}>
|
205 |
+
{React.createElement(PROVIDER_ICONS[provider.name as ProviderName] || BsRobot, {
|
206 |
+
className: 'w-full h-full',
|
207 |
+
'aria-label': `${provider.name} logo`,
|
208 |
+
})}
|
209 |
+
</div>
|
210 |
+
</motion.div>
|
211 |
+
|
212 |
+
<div className="flex-1 min-w-0">
|
213 |
+
<div className="flex items-center justify-between gap-4 mb-2">
|
214 |
+
<div>
|
215 |
+
<h4 className="text-sm font-medium text-bolt-elements-textPrimary group-hover:text-purple-500 transition-colors">
|
216 |
+
{provider.name}
|
217 |
+
</h4>
|
218 |
+
<p className="text-xs text-bolt-elements-textSecondary mt-0.5">
|
219 |
+
{PROVIDER_DESCRIPTIONS[provider.name as keyof typeof PROVIDER_DESCRIPTIONS] ||
|
220 |
+
(URL_CONFIGURABLE_PROVIDERS.includes(provider.name)
|
221 |
+
? 'Configure custom endpoint for this provider'
|
222 |
+
: 'Standard AI provider integration')}
|
223 |
+
</p>
|
224 |
+
</div>
|
225 |
+
<Switch
|
226 |
+
checked={provider.settings.enabled}
|
227 |
+
onCheckedChange={(checked) => handleToggleProvider(provider, checked)}
|
228 |
+
/>
|
229 |
+
</div>
|
230 |
+
|
231 |
+
{provider.settings.enabled && URL_CONFIGURABLE_PROVIDERS.includes(provider.name) && (
|
232 |
+
<motion.div
|
233 |
+
initial={{ opacity: 0, height: 0 }}
|
234 |
+
animate={{ opacity: 1, height: 'auto' }}
|
235 |
+
exit={{ opacity: 0, height: 0 }}
|
236 |
+
transition={{ duration: 0.2 }}
|
237 |
+
>
|
238 |
+
<div className="flex items-center gap-2 mt-4">
|
239 |
+
{editingProvider === provider.name ? (
|
240 |
+
<input
|
241 |
+
type="text"
|
242 |
+
defaultValue={provider.settings.baseUrl}
|
243 |
+
placeholder={`Enter ${provider.name} base URL`}
|
244 |
+
className={classNames(
|
245 |
+
'flex-1 px-3 py-1.5 rounded-lg text-sm',
|
246 |
+
'bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor',
|
247 |
+
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
|
248 |
+
'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
|
249 |
+
'transition-all duration-200',
|
250 |
+
)}
|
251 |
+
onKeyDown={(e) => {
|
252 |
+
if (e.key === 'Enter') {
|
253 |
+
handleUpdateBaseUrl(provider, e.currentTarget.value);
|
254 |
+
} else if (e.key === 'Escape') {
|
255 |
+
setEditingProvider(null);
|
256 |
+
}
|
257 |
+
}}
|
258 |
+
onBlur={(e) => handleUpdateBaseUrl(provider, e.target.value)}
|
259 |
+
autoFocus
|
260 |
+
/>
|
261 |
+
) : (
|
262 |
+
<div
|
263 |
+
className="flex-1 px-3 py-1.5 rounded-lg text-sm cursor-pointer group/url"
|
264 |
+
onClick={() => setEditingProvider(provider.name)}
|
265 |
+
>
|
266 |
+
<div className="flex items-center gap-2 text-bolt-elements-textSecondary">
|
267 |
+
<div className="i-ph:link text-sm" />
|
268 |
+
<span className="group-hover/url:text-purple-500 transition-colors">
|
269 |
+
{provider.settings.baseUrl || 'Click to set base URL'}
|
270 |
+
</span>
|
271 |
+
</div>
|
272 |
+
</div>
|
273 |
+
)}
|
274 |
+
</div>
|
275 |
+
|
276 |
+
{providerBaseUrlEnvKeys[provider.name]?.baseUrlKey && (
|
277 |
+
<div className="mt-2 text-xs text-green-500">
|
278 |
+
<div className="flex items-center gap-1">
|
279 |
+
<div className="i-ph:info" />
|
280 |
+
<span>Environment URL set in .env file</span>
|
281 |
+
</div>
|
282 |
+
</div>
|
283 |
+
)}
|
284 |
+
</motion.div>
|
285 |
+
)}
|
286 |
+
</div>
|
287 |
+
</div>
|
288 |
+
|
289 |
+
<motion.div
|
290 |
+
className="absolute inset-0 border-2 border-purple-500/0 rounded-lg pointer-events-none"
|
291 |
+
animate={{
|
292 |
+
borderColor: provider.settings.enabled ? 'rgba(168, 85, 247, 0.2)' : 'rgba(168, 85, 247, 0)',
|
293 |
+
scale: provider.settings.enabled ? 1 : 0.98,
|
294 |
+
}}
|
295 |
+
transition={{ duration: 0.2 }}
|
296 |
+
/>
|
297 |
+
</motion.div>
|
298 |
+
))}
|
299 |
+
</div>
|
300 |
+
</motion.div>
|
301 |
+
</div>
|
302 |
+
);
|
303 |
+
};
|
304 |
+
|
305 |
+
export default CloudProvidersTab;
|
app/components/@settings/tabs/providers/local/LocalProvidersTab.tsx
ADDED
@@ -0,0 +1,777 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React, { useEffect, useState, useCallback } from 'react';
|
2 |
+
import { Switch } from '~/components/ui/Switch';
|
3 |
+
import { useSettings } from '~/lib/hooks/useSettings';
|
4 |
+
import { LOCAL_PROVIDERS, URL_CONFIGURABLE_PROVIDERS } from '~/lib/stores/settings';
|
5 |
+
import type { IProviderConfig } from '~/types/model';
|
6 |
+
import { logStore } from '~/lib/stores/logs';
|
7 |
+
import { motion, AnimatePresence } from 'framer-motion';
|
8 |
+
import { classNames } from '~/utils/classNames';
|
9 |
+
import { BsRobot } from 'react-icons/bs';
|
10 |
+
import type { IconType } from 'react-icons';
|
11 |
+
import { BiChip } from 'react-icons/bi';
|
12 |
+
import { TbBrandOpenai } from 'react-icons/tb';
|
13 |
+
import { providerBaseUrlEnvKeys } from '~/utils/constants';
|
14 |
+
import { useToast } from '~/components/ui/use-toast';
|
15 |
+
import { Progress } from '~/components/ui/Progress';
|
16 |
+
import OllamaModelInstaller from './OllamaModelInstaller';
|
17 |
+
|
18 |
+
// Add type for provider names to ensure type safety
|
19 |
+
type ProviderName = 'Ollama' | 'LMStudio' | 'OpenAILike';
|
20 |
+
|
21 |
+
// Update the PROVIDER_ICONS type to use the ProviderName type
|
22 |
+
const PROVIDER_ICONS: Record<ProviderName, IconType> = {
|
23 |
+
Ollama: BsRobot,
|
24 |
+
LMStudio: BsRobot,
|
25 |
+
OpenAILike: TbBrandOpenai,
|
26 |
+
};
|
27 |
+
|
28 |
+
// Update PROVIDER_DESCRIPTIONS to use the same type
|
29 |
+
const PROVIDER_DESCRIPTIONS: Record<ProviderName, string> = {
|
30 |
+
Ollama: 'Run open-source models locally on your machine',
|
31 |
+
LMStudio: 'Local model inference with LM Studio',
|
32 |
+
OpenAILike: 'Connect to OpenAI-compatible API endpoints',
|
33 |
+
};
|
34 |
+
|
35 |
+
// Add a constant for the Ollama API base URL
|
36 |
+
const OLLAMA_API_URL = 'http://127.0.0.1:11434';
|
37 |
+
|
38 |
+
interface OllamaModel {
|
39 |
+
name: string;
|
40 |
+
digest: string;
|
41 |
+
size: number;
|
42 |
+
modified_at: string;
|
43 |
+
details?: {
|
44 |
+
family: string;
|
45 |
+
parameter_size: string;
|
46 |
+
quantization_level: string;
|
47 |
+
};
|
48 |
+
status?: 'idle' | 'updating' | 'updated' | 'error' | 'checking';
|
49 |
+
error?: string;
|
50 |
+
newDigest?: string;
|
51 |
+
progress?: {
|
52 |
+
current: number;
|
53 |
+
total: number;
|
54 |
+
status: string;
|
55 |
+
};
|
56 |
+
}
|
57 |
+
|
58 |
+
interface OllamaPullResponse {
|
59 |
+
status: string;
|
60 |
+
completed?: number;
|
61 |
+
total?: number;
|
62 |
+
digest?: string;
|
63 |
+
}
|
64 |
+
|
65 |
+
const isOllamaPullResponse = (data: unknown): data is OllamaPullResponse => {
|
66 |
+
return (
|
67 |
+
typeof data === 'object' &&
|
68 |
+
data !== null &&
|
69 |
+
'status' in data &&
|
70 |
+
typeof (data as OllamaPullResponse).status === 'string'
|
71 |
+
);
|
72 |
+
};
|
73 |
+
|
74 |
+
export default function LocalProvidersTab() {
|
75 |
+
const { providers, updateProviderSettings } = useSettings();
|
76 |
+
const [filteredProviders, setFilteredProviders] = useState<IProviderConfig[]>([]);
|
77 |
+
const [categoryEnabled, setCategoryEnabled] = useState(false);
|
78 |
+
const [ollamaModels, setOllamaModels] = useState<OllamaModel[]>([]);
|
79 |
+
const [isLoadingModels, setIsLoadingModels] = useState(false);
|
80 |
+
const [editingProvider, setEditingProvider] = useState<string | null>(null);
|
81 |
+
const { toast } = useToast();
|
82 |
+
|
83 |
+
// Effect to filter and sort providers
|
84 |
+
useEffect(() => {
|
85 |
+
const newFilteredProviders = Object.entries(providers || {})
|
86 |
+
.filter(([key]) => [...LOCAL_PROVIDERS, 'OpenAILike'].includes(key))
|
87 |
+
.map(([key, value]) => {
|
88 |
+
const provider = value as IProviderConfig;
|
89 |
+
const envKey = providerBaseUrlEnvKeys[key]?.baseUrlKey;
|
90 |
+
const envUrl = envKey ? (import.meta.env[envKey] as string | undefined) : undefined;
|
91 |
+
|
92 |
+
// Set base URL if provided by environment
|
93 |
+
if (envUrl && !provider.settings.baseUrl) {
|
94 |
+
updateProviderSettings(key, {
|
95 |
+
...provider.settings,
|
96 |
+
baseUrl: envUrl,
|
97 |
+
});
|
98 |
+
}
|
99 |
+
|
100 |
+
return {
|
101 |
+
name: key,
|
102 |
+
settings: {
|
103 |
+
...provider.settings,
|
104 |
+
baseUrl: provider.settings.baseUrl || envUrl,
|
105 |
+
},
|
106 |
+
staticModels: provider.staticModels || [],
|
107 |
+
getDynamicModels: provider.getDynamicModels,
|
108 |
+
getApiKeyLink: provider.getApiKeyLink,
|
109 |
+
labelForGetApiKey: provider.labelForGetApiKey,
|
110 |
+
icon: provider.icon,
|
111 |
+
} as IProviderConfig;
|
112 |
+
});
|
113 |
+
|
114 |
+
// Custom sort function to ensure LMStudio appears before OpenAILike
|
115 |
+
const sorted = newFilteredProviders.sort((a, b) => {
|
116 |
+
if (a.name === 'LMStudio') {
|
117 |
+
return -1;
|
118 |
+
}
|
119 |
+
|
120 |
+
if (b.name === 'LMStudio') {
|
121 |
+
return 1;
|
122 |
+
}
|
123 |
+
|
124 |
+
if (a.name === 'OpenAILike') {
|
125 |
+
return 1;
|
126 |
+
}
|
127 |
+
|
128 |
+
if (b.name === 'OpenAILike') {
|
129 |
+
return -1;
|
130 |
+
}
|
131 |
+
|
132 |
+
return a.name.localeCompare(b.name);
|
133 |
+
});
|
134 |
+
setFilteredProviders(sorted);
|
135 |
+
}, [providers, updateProviderSettings]);
|
136 |
+
|
137 |
+
// Add effect to update category toggle state based on provider states
|
138 |
+
useEffect(() => {
|
139 |
+
const newCategoryState = filteredProviders.every((p) => p.settings.enabled);
|
140 |
+
setCategoryEnabled(newCategoryState);
|
141 |
+
}, [filteredProviders]);
|
142 |
+
|
143 |
+
// Fetch Ollama models when enabled
|
144 |
+
useEffect(() => {
|
145 |
+
const ollamaProvider = filteredProviders.find((p) => p.name === 'Ollama');
|
146 |
+
|
147 |
+
if (ollamaProvider?.settings.enabled) {
|
148 |
+
fetchOllamaModels();
|
149 |
+
}
|
150 |
+
}, [filteredProviders]);
|
151 |
+
|
152 |
+
const fetchOllamaModels = async () => {
|
153 |
+
try {
|
154 |
+
setIsLoadingModels(true);
|
155 |
+
|
156 |
+
const response = await fetch('http://127.0.0.1:11434/api/tags');
|
157 |
+
const data = (await response.json()) as { models: OllamaModel[] };
|
158 |
+
|
159 |
+
setOllamaModels(
|
160 |
+
data.models.map((model) => ({
|
161 |
+
...model,
|
162 |
+
status: 'idle' as const,
|
163 |
+
})),
|
164 |
+
);
|
165 |
+
} catch (error) {
|
166 |
+
console.error('Error fetching Ollama models:', error);
|
167 |
+
} finally {
|
168 |
+
setIsLoadingModels(false);
|
169 |
+
}
|
170 |
+
};
|
171 |
+
|
172 |
+
const updateOllamaModel = async (modelName: string): Promise<boolean> => {
|
173 |
+
try {
|
174 |
+
const response = await fetch(`${OLLAMA_API_URL}/api/pull`, {
|
175 |
+
method: 'POST',
|
176 |
+
headers: { 'Content-Type': 'application/json' },
|
177 |
+
body: JSON.stringify({ name: modelName }),
|
178 |
+
});
|
179 |
+
|
180 |
+
if (!response.ok) {
|
181 |
+
throw new Error(`Failed to update ${modelName}`);
|
182 |
+
}
|
183 |
+
|
184 |
+
const reader = response.body?.getReader();
|
185 |
+
|
186 |
+
if (!reader) {
|
187 |
+
throw new Error('No response reader available');
|
188 |
+
}
|
189 |
+
|
190 |
+
while (true) {
|
191 |
+
const { done, value } = await reader.read();
|
192 |
+
|
193 |
+
if (done) {
|
194 |
+
break;
|
195 |
+
}
|
196 |
+
|
197 |
+
const text = new TextDecoder().decode(value);
|
198 |
+
const lines = text.split('\n').filter(Boolean);
|
199 |
+
|
200 |
+
for (const line of lines) {
|
201 |
+
const rawData = JSON.parse(line);
|
202 |
+
|
203 |
+
if (!isOllamaPullResponse(rawData)) {
|
204 |
+
console.error('Invalid response format:', rawData);
|
205 |
+
continue;
|
206 |
+
}
|
207 |
+
|
208 |
+
setOllamaModels((current) =>
|
209 |
+
current.map((m) =>
|
210 |
+
m.name === modelName
|
211 |
+
? {
|
212 |
+
...m,
|
213 |
+
progress: {
|
214 |
+
current: rawData.completed || 0,
|
215 |
+
total: rawData.total || 0,
|
216 |
+
status: rawData.status,
|
217 |
+
},
|
218 |
+
newDigest: rawData.digest,
|
219 |
+
}
|
220 |
+
: m,
|
221 |
+
),
|
222 |
+
);
|
223 |
+
}
|
224 |
+
}
|
225 |
+
|
226 |
+
const updatedResponse = await fetch('http://127.0.0.1:11434/api/tags');
|
227 |
+
const updatedData = (await updatedResponse.json()) as { models: OllamaModel[] };
|
228 |
+
const updatedModel = updatedData.models.find((m) => m.name === modelName);
|
229 |
+
|
230 |
+
return updatedModel !== undefined;
|
231 |
+
} catch (error) {
|
232 |
+
console.error(`Error updating ${modelName}:`, error);
|
233 |
+
return false;
|
234 |
+
}
|
235 |
+
};
|
236 |
+
|
237 |
+
const handleToggleCategory = useCallback(
|
238 |
+
async (enabled: boolean) => {
|
239 |
+
filteredProviders.forEach((provider) => {
|
240 |
+
updateProviderSettings(provider.name, { ...provider.settings, enabled });
|
241 |
+
});
|
242 |
+
toast(enabled ? 'All local providers enabled' : 'All local providers disabled');
|
243 |
+
},
|
244 |
+
[filteredProviders, updateProviderSettings],
|
245 |
+
);
|
246 |
+
|
247 |
+
const handleToggleProvider = (provider: IProviderConfig, enabled: boolean) => {
|
248 |
+
updateProviderSettings(provider.name, {
|
249 |
+
...provider.settings,
|
250 |
+
enabled,
|
251 |
+
});
|
252 |
+
|
253 |
+
if (enabled) {
|
254 |
+
logStore.logProvider(`Provider ${provider.name} enabled`, { provider: provider.name });
|
255 |
+
toast(`${provider.name} enabled`);
|
256 |
+
} else {
|
257 |
+
logStore.logProvider(`Provider ${provider.name} disabled`, { provider: provider.name });
|
258 |
+
toast(`${provider.name} disabled`);
|
259 |
+
}
|
260 |
+
};
|
261 |
+
|
262 |
+
const handleUpdateBaseUrl = (provider: IProviderConfig, newBaseUrl: string) => {
|
263 |
+
updateProviderSettings(provider.name, {
|
264 |
+
...provider.settings,
|
265 |
+
baseUrl: newBaseUrl,
|
266 |
+
});
|
267 |
+
toast(`${provider.name} base URL updated`);
|
268 |
+
setEditingProvider(null);
|
269 |
+
};
|
270 |
+
|
271 |
+
const handleUpdateOllamaModel = async (modelName: string) => {
|
272 |
+
const updateSuccess = await updateOllamaModel(modelName);
|
273 |
+
|
274 |
+
if (updateSuccess) {
|
275 |
+
toast(`Updated ${modelName}`);
|
276 |
+
} else {
|
277 |
+
toast(`Failed to update ${modelName}`);
|
278 |
+
}
|
279 |
+
};
|
280 |
+
|
281 |
+
const handleDeleteOllamaModel = async (modelName: string) => {
|
282 |
+
try {
|
283 |
+
const response = await fetch(`${OLLAMA_API_URL}/api/delete`, {
|
284 |
+
method: 'DELETE',
|
285 |
+
headers: {
|
286 |
+
'Content-Type': 'application/json',
|
287 |
+
},
|
288 |
+
body: JSON.stringify({ name: modelName }),
|
289 |
+
});
|
290 |
+
|
291 |
+
if (!response.ok) {
|
292 |
+
throw new Error(`Failed to delete ${modelName}`);
|
293 |
+
}
|
294 |
+
|
295 |
+
setOllamaModels((current) => current.filter((m) => m.name !== modelName));
|
296 |
+
toast(`Deleted ${modelName}`);
|
297 |
+
} catch (err) {
|
298 |
+
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
|
299 |
+
console.error(`Error deleting ${modelName}:`, errorMessage);
|
300 |
+
toast(`Failed to delete ${modelName}`);
|
301 |
+
}
|
302 |
+
};
|
303 |
+
|
304 |
+
// Update model details display
|
305 |
+
const ModelDetails = ({ model }: { model: OllamaModel }) => (
|
306 |
+
<div className="flex items-center gap-3 text-xs text-bolt-elements-textSecondary">
|
307 |
+
<div className="flex items-center gap-1">
|
308 |
+
<div className="i-ph:code text-purple-500" />
|
309 |
+
<span>{model.digest.substring(0, 7)}</span>
|
310 |
+
</div>
|
311 |
+
{model.details && (
|
312 |
+
<>
|
313 |
+
<div className="flex items-center gap-1">
|
314 |
+
<div className="i-ph:database text-purple-500" />
|
315 |
+
<span>{model.details.parameter_size}</span>
|
316 |
+
</div>
|
317 |
+
<div className="flex items-center gap-1">
|
318 |
+
<div className="i-ph:cube text-purple-500" />
|
319 |
+
<span>{model.details.quantization_level}</span>
|
320 |
+
</div>
|
321 |
+
</>
|
322 |
+
)}
|
323 |
+
</div>
|
324 |
+
);
|
325 |
+
|
326 |
+
// Update model actions to not use Tooltip
|
327 |
+
const ModelActions = ({
|
328 |
+
model,
|
329 |
+
onUpdate,
|
330 |
+
onDelete,
|
331 |
+
}: {
|
332 |
+
model: OllamaModel;
|
333 |
+
onUpdate: () => void;
|
334 |
+
onDelete: () => void;
|
335 |
+
}) => (
|
336 |
+
<div className="flex items-center gap-2">
|
337 |
+
<motion.button
|
338 |
+
onClick={onUpdate}
|
339 |
+
disabled={model.status === 'updating'}
|
340 |
+
className={classNames(
|
341 |
+
'rounded-lg p-2',
|
342 |
+
'bg-purple-500/10 text-purple-500',
|
343 |
+
'hover:bg-purple-500/20',
|
344 |
+
'transition-all duration-200',
|
345 |
+
{ 'opacity-50 cursor-not-allowed': model.status === 'updating' },
|
346 |
+
)}
|
347 |
+
whileHover={{ scale: 1.05 }}
|
348 |
+
whileTap={{ scale: 0.95 }}
|
349 |
+
title="Update model"
|
350 |
+
>
|
351 |
+
{model.status === 'updating' ? (
|
352 |
+
<div className="flex items-center gap-2">
|
353 |
+
<div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
|
354 |
+
<span className="text-sm">Updating...</span>
|
355 |
+
</div>
|
356 |
+
) : (
|
357 |
+
<div className="i-ph:arrows-clockwise text-lg" />
|
358 |
+
)}
|
359 |
+
</motion.button>
|
360 |
+
<motion.button
|
361 |
+
onClick={onDelete}
|
362 |
+
disabled={model.status === 'updating'}
|
363 |
+
className={classNames(
|
364 |
+
'rounded-lg p-2',
|
365 |
+
'bg-red-500/10 text-red-500',
|
366 |
+
'hover:bg-red-500/20',
|
367 |
+
'transition-all duration-200',
|
368 |
+
{ 'opacity-50 cursor-not-allowed': model.status === 'updating' },
|
369 |
+
)}
|
370 |
+
whileHover={{ scale: 1.05 }}
|
371 |
+
whileTap={{ scale: 0.95 }}
|
372 |
+
title="Delete model"
|
373 |
+
>
|
374 |
+
<div className="i-ph:trash text-lg" />
|
375 |
+
</motion.button>
|
376 |
+
</div>
|
377 |
+
);
|
378 |
+
|
379 |
+
return (
|
380 |
+
<div
|
381 |
+
className={classNames(
|
382 |
+
'rounded-lg bg-bolt-elements-background text-bolt-elements-textPrimary shadow-sm p-4',
|
383 |
+
'hover:bg-bolt-elements-background-depth-2',
|
384 |
+
'transition-all duration-200',
|
385 |
+
)}
|
386 |
+
role="region"
|
387 |
+
aria-label="Local Providers Configuration"
|
388 |
+
>
|
389 |
+
<motion.div
|
390 |
+
className="space-y-6"
|
391 |
+
initial={{ opacity: 0, y: 20 }}
|
392 |
+
animate={{ opacity: 1, y: 0 }}
|
393 |
+
transition={{ duration: 0.3 }}
|
394 |
+
>
|
395 |
+
{/* Header section */}
|
396 |
+
<div className="flex items-center justify-between gap-4 border-b border-bolt-elements-borderColor pb-4">
|
397 |
+
<div className="flex items-center gap-3">
|
398 |
+
<motion.div
|
399 |
+
className={classNames(
|
400 |
+
'w-10 h-10 flex items-center justify-center rounded-xl',
|
401 |
+
'bg-purple-500/10 text-purple-500',
|
402 |
+
)}
|
403 |
+
whileHover={{ scale: 1.05 }}
|
404 |
+
>
|
405 |
+
<BiChip className="w-6 h-6" />
|
406 |
+
</motion.div>
|
407 |
+
<div>
|
408 |
+
<div className="flex items-center gap-2">
|
409 |
+
<h2 className="text-lg font-semibold text-bolt-elements-textPrimary">Local AI Models</h2>
|
410 |
+
</div>
|
411 |
+
<p className="text-sm text-bolt-elements-textSecondary">Configure and manage your local AI providers</p>
|
412 |
+
</div>
|
413 |
+
</div>
|
414 |
+
|
415 |
+
<div className="flex items-center gap-2">
|
416 |
+
<span className="text-sm text-bolt-elements-textSecondary">Enable All</span>
|
417 |
+
<Switch
|
418 |
+
checked={categoryEnabled}
|
419 |
+
onCheckedChange={handleToggleCategory}
|
420 |
+
aria-label="Toggle all local providers"
|
421 |
+
/>
|
422 |
+
</div>
|
423 |
+
</div>
|
424 |
+
|
425 |
+
{/* Ollama Section */}
|
426 |
+
{filteredProviders
|
427 |
+
.filter((provider) => provider.name === 'Ollama')
|
428 |
+
.map((provider) => (
|
429 |
+
<motion.div
|
430 |
+
key={provider.name}
|
431 |
+
className={classNames(
|
432 |
+
'bg-bolt-elements-background-depth-2 rounded-xl',
|
433 |
+
'hover:bg-bolt-elements-background-depth-3',
|
434 |
+
'transition-all duration-200 p-5',
|
435 |
+
'relative overflow-hidden group',
|
436 |
+
)}
|
437 |
+
initial={{ opacity: 0, y: 20 }}
|
438 |
+
animate={{ opacity: 1, y: 0 }}
|
439 |
+
whileHover={{ scale: 1.01 }}
|
440 |
+
>
|
441 |
+
{/* Provider Header */}
|
442 |
+
<div className="flex items-start justify-between gap-4">
|
443 |
+
<div className="flex items-start gap-4">
|
444 |
+
<motion.div
|
445 |
+
className={classNames(
|
446 |
+
'w-12 h-12 flex items-center justify-center rounded-xl',
|
447 |
+
'bg-bolt-elements-background-depth-3',
|
448 |
+
provider.settings.enabled ? 'text-purple-500' : 'text-bolt-elements-textSecondary',
|
449 |
+
)}
|
450 |
+
whileHover={{ scale: 1.1, rotate: 5 }}
|
451 |
+
>
|
452 |
+
{React.createElement(PROVIDER_ICONS[provider.name as ProviderName] || BsRobot, {
|
453 |
+
className: 'w-7 h-7',
|
454 |
+
'aria-label': `${provider.name} icon`,
|
455 |
+
})}
|
456 |
+
</motion.div>
|
457 |
+
<div>
|
458 |
+
<div className="flex items-center gap-2">
|
459 |
+
<h3 className="text-md font-semibold text-bolt-elements-textPrimary">{provider.name}</h3>
|
460 |
+
<span className="px-2 py-0.5 text-xs rounded-full bg-green-500/10 text-green-500">Local</span>
|
461 |
+
</div>
|
462 |
+
<p className="text-sm text-bolt-elements-textSecondary mt-1">
|
463 |
+
{PROVIDER_DESCRIPTIONS[provider.name as ProviderName]}
|
464 |
+
</p>
|
465 |
+
</div>
|
466 |
+
</div>
|
467 |
+
<Switch
|
468 |
+
checked={provider.settings.enabled}
|
469 |
+
onCheckedChange={(checked) => handleToggleProvider(provider, checked)}
|
470 |
+
aria-label={`Toggle ${provider.name} provider`}
|
471 |
+
/>
|
472 |
+
</div>
|
473 |
+
|
474 |
+
{/* URL Configuration Section */}
|
475 |
+
<AnimatePresence>
|
476 |
+
{provider.settings.enabled && (
|
477 |
+
<motion.div
|
478 |
+
initial={{ opacity: 0, height: 0 }}
|
479 |
+
animate={{ opacity: 1, height: 'auto' }}
|
480 |
+
exit={{ opacity: 0, height: 0 }}
|
481 |
+
className="mt-4"
|
482 |
+
>
|
483 |
+
<div className="flex flex-col gap-2">
|
484 |
+
<label className="text-sm text-bolt-elements-textSecondary">API Endpoint</label>
|
485 |
+
{editingProvider === provider.name ? (
|
486 |
+
<input
|
487 |
+
type="text"
|
488 |
+
defaultValue={provider.settings.baseUrl || OLLAMA_API_URL}
|
489 |
+
placeholder="Enter Ollama base URL"
|
490 |
+
className={classNames(
|
491 |
+
'w-full px-3 py-2 rounded-lg text-sm',
|
492 |
+
'bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor',
|
493 |
+
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
|
494 |
+
'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
|
495 |
+
'transition-all duration-200',
|
496 |
+
)}
|
497 |
+
onKeyDown={(e) => {
|
498 |
+
if (e.key === 'Enter') {
|
499 |
+
handleUpdateBaseUrl(provider, e.currentTarget.value);
|
500 |
+
} else if (e.key === 'Escape') {
|
501 |
+
setEditingProvider(null);
|
502 |
+
}
|
503 |
+
}}
|
504 |
+
onBlur={(e) => handleUpdateBaseUrl(provider, e.target.value)}
|
505 |
+
autoFocus
|
506 |
+
/>
|
507 |
+
) : (
|
508 |
+
<div
|
509 |
+
onClick={() => setEditingProvider(provider.name)}
|
510 |
+
className={classNames(
|
511 |
+
'w-full px-3 py-2 rounded-lg text-sm cursor-pointer',
|
512 |
+
'bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor',
|
513 |
+
'hover:border-purple-500/30 hover:bg-bolt-elements-background-depth-4',
|
514 |
+
'transition-all duration-200',
|
515 |
+
)}
|
516 |
+
>
|
517 |
+
<div className="flex items-center gap-2 text-bolt-elements-textSecondary">
|
518 |
+
<div className="i-ph:link text-sm" />
|
519 |
+
<span>{provider.settings.baseUrl || OLLAMA_API_URL}</span>
|
520 |
+
</div>
|
521 |
+
</div>
|
522 |
+
)}
|
523 |
+
</div>
|
524 |
+
</motion.div>
|
525 |
+
)}
|
526 |
+
</AnimatePresence>
|
527 |
+
|
528 |
+
{/* Ollama Models Section */}
|
529 |
+
{provider.settings.enabled && (
|
530 |
+
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="mt-6 space-y-4">
|
531 |
+
<div className="flex items-center justify-between">
|
532 |
+
<div className="flex items-center gap-2">
|
533 |
+
<div className="i-ph:cube-duotone text-purple-500" />
|
534 |
+
<h4 className="text-sm font-medium text-bolt-elements-textPrimary">Installed Models</h4>
|
535 |
+
</div>
|
536 |
+
{isLoadingModels ? (
|
537 |
+
<div className="flex items-center gap-2">
|
538 |
+
<div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
|
539 |
+
<span className="text-sm text-bolt-elements-textSecondary">Loading models...</span>
|
540 |
+
</div>
|
541 |
+
) : (
|
542 |
+
<span className="text-sm text-bolt-elements-textSecondary">
|
543 |
+
{ollamaModels.length} models available
|
544 |
+
</span>
|
545 |
+
)}
|
546 |
+
</div>
|
547 |
+
|
548 |
+
<div className="space-y-3">
|
549 |
+
{isLoadingModels ? (
|
550 |
+
<div className="space-y-3">
|
551 |
+
{Array.from({ length: 3 }).map((_, i) => (
|
552 |
+
<div
|
553 |
+
key={i}
|
554 |
+
className="h-20 w-full bg-bolt-elements-background-depth-3 rounded-lg animate-pulse"
|
555 |
+
/>
|
556 |
+
))}
|
557 |
+
</div>
|
558 |
+
) : ollamaModels.length === 0 ? (
|
559 |
+
<div className="text-center py-8 text-bolt-elements-textSecondary">
|
560 |
+
<div className="i-ph:cube-transparent text-4xl mx-auto mb-2" />
|
561 |
+
<p>No models installed yet</p>
|
562 |
+
<p className="text-sm text-bolt-elements-textTertiary px-1">
|
563 |
+
Browse models at{' '}
|
564 |
+
<a
|
565 |
+
href="https://ollama.com/library"
|
566 |
+
target="_blank"
|
567 |
+
rel="noopener noreferrer"
|
568 |
+
className="text-purple-500 hover:underline inline-flex items-center gap-0.5 text-base font-medium"
|
569 |
+
>
|
570 |
+
ollama.com/library
|
571 |
+
<div className="i-ph:arrow-square-out text-xs" />
|
572 |
+
</a>{' '}
|
573 |
+
and copy model names to install
|
574 |
+
</p>
|
575 |
+
</div>
|
576 |
+
) : (
|
577 |
+
ollamaModels.map((model) => (
|
578 |
+
<motion.div
|
579 |
+
key={model.name}
|
580 |
+
className={classNames(
|
581 |
+
'p-4 rounded-xl',
|
582 |
+
'bg-bolt-elements-background-depth-3',
|
583 |
+
'hover:bg-bolt-elements-background-depth-4',
|
584 |
+
'transition-all duration-200',
|
585 |
+
)}
|
586 |
+
whileHover={{ scale: 1.01 }}
|
587 |
+
>
|
588 |
+
<div className="flex items-center justify-between">
|
589 |
+
<div className="space-y-2">
|
590 |
+
<div className="flex items-center gap-2">
|
591 |
+
<h5 className="text-sm font-medium text-bolt-elements-textPrimary">{model.name}</h5>
|
592 |
+
<ModelStatusBadge status={model.status} />
|
593 |
+
</div>
|
594 |
+
<ModelDetails model={model} />
|
595 |
+
</div>
|
596 |
+
<ModelActions
|
597 |
+
model={model}
|
598 |
+
onUpdate={() => handleUpdateOllamaModel(model.name)}
|
599 |
+
onDelete={() => {
|
600 |
+
if (window.confirm(`Are you sure you want to delete ${model.name}?`)) {
|
601 |
+
handleDeleteOllamaModel(model.name);
|
602 |
+
}
|
603 |
+
}}
|
604 |
+
/>
|
605 |
+
</div>
|
606 |
+
{model.progress && (
|
607 |
+
<div className="mt-3">
|
608 |
+
<Progress
|
609 |
+
value={Math.round((model.progress.current / model.progress.total) * 100)}
|
610 |
+
className="h-1"
|
611 |
+
/>
|
612 |
+
<div className="flex justify-between mt-1 text-xs text-bolt-elements-textSecondary">
|
613 |
+
<span>{model.progress.status}</span>
|
614 |
+
<span>{Math.round((model.progress.current / model.progress.total) * 100)}%</span>
|
615 |
+
</div>
|
616 |
+
</div>
|
617 |
+
)}
|
618 |
+
</motion.div>
|
619 |
+
))
|
620 |
+
)}
|
621 |
+
</div>
|
622 |
+
|
623 |
+
{/* Model Installation Section */}
|
624 |
+
<OllamaModelInstaller onModelInstalled={fetchOllamaModels} />
|
625 |
+
</motion.div>
|
626 |
+
)}
|
627 |
+
</motion.div>
|
628 |
+
))}
|
629 |
+
|
630 |
+
{/* Other Providers Section */}
|
631 |
+
<div className="border-t border-bolt-elements-borderColor pt-6 mt-8">
|
632 |
+
<h3 className="text-lg font-semibold text-bolt-elements-textPrimary mb-4">Other Local Providers</h3>
|
633 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
634 |
+
{filteredProviders
|
635 |
+
.filter((provider) => provider.name !== 'Ollama')
|
636 |
+
.map((provider, index) => (
|
637 |
+
<motion.div
|
638 |
+
key={provider.name}
|
639 |
+
className={classNames(
|
640 |
+
'bg-bolt-elements-background-depth-2 rounded-xl',
|
641 |
+
'hover:bg-bolt-elements-background-depth-3',
|
642 |
+
'transition-all duration-200 p-5',
|
643 |
+
'relative overflow-hidden group',
|
644 |
+
)}
|
645 |
+
initial={{ opacity: 0, y: 20 }}
|
646 |
+
animate={{ opacity: 1, y: 0 }}
|
647 |
+
transition={{ delay: index * 0.1 }}
|
648 |
+
whileHover={{ scale: 1.01 }}
|
649 |
+
>
|
650 |
+
{/* Provider Header */}
|
651 |
+
<div className="flex items-start justify-between gap-4">
|
652 |
+
<div className="flex items-start gap-4">
|
653 |
+
<motion.div
|
654 |
+
className={classNames(
|
655 |
+
'w-12 h-12 flex items-center justify-center rounded-xl',
|
656 |
+
'bg-bolt-elements-background-depth-3',
|
657 |
+
provider.settings.enabled ? 'text-purple-500' : 'text-bolt-elements-textSecondary',
|
658 |
+
)}
|
659 |
+
whileHover={{ scale: 1.1, rotate: 5 }}
|
660 |
+
>
|
661 |
+
{React.createElement(PROVIDER_ICONS[provider.name as ProviderName] || BsRobot, {
|
662 |
+
className: 'w-7 h-7',
|
663 |
+
'aria-label': `${provider.name} icon`,
|
664 |
+
})}
|
665 |
+
</motion.div>
|
666 |
+
<div>
|
667 |
+
<div className="flex items-center gap-2">
|
668 |
+
<h3 className="text-md font-semibold text-bolt-elements-textPrimary">{provider.name}</h3>
|
669 |
+
<div className="flex gap-1">
|
670 |
+
<span className="px-2 py-0.5 text-xs rounded-full bg-green-500/10 text-green-500">
|
671 |
+
Local
|
672 |
+
</span>
|
673 |
+
{URL_CONFIGURABLE_PROVIDERS.includes(provider.name) && (
|
674 |
+
<span className="px-2 py-0.5 text-xs rounded-full bg-purple-500/10 text-purple-500">
|
675 |
+
Configurable
|
676 |
+
</span>
|
677 |
+
)}
|
678 |
+
</div>
|
679 |
+
</div>
|
680 |
+
<p className="text-sm text-bolt-elements-textSecondary mt-1">
|
681 |
+
{PROVIDER_DESCRIPTIONS[provider.name as ProviderName]}
|
682 |
+
</p>
|
683 |
+
</div>
|
684 |
+
</div>
|
685 |
+
<Switch
|
686 |
+
checked={provider.settings.enabled}
|
687 |
+
onCheckedChange={(checked) => handleToggleProvider(provider, checked)}
|
688 |
+
aria-label={`Toggle ${provider.name} provider`}
|
689 |
+
/>
|
690 |
+
</div>
|
691 |
+
|
692 |
+
{/* URL Configuration Section */}
|
693 |
+
<AnimatePresence>
|
694 |
+
{provider.settings.enabled && URL_CONFIGURABLE_PROVIDERS.includes(provider.name) && (
|
695 |
+
<motion.div
|
696 |
+
initial={{ opacity: 0, height: 0 }}
|
697 |
+
animate={{ opacity: 1, height: 'auto' }}
|
698 |
+
exit={{ opacity: 0, height: 0 }}
|
699 |
+
className="mt-4"
|
700 |
+
>
|
701 |
+
<div className="flex flex-col gap-2">
|
702 |
+
<label className="text-sm text-bolt-elements-textSecondary">API Endpoint</label>
|
703 |
+
{editingProvider === provider.name ? (
|
704 |
+
<input
|
705 |
+
type="text"
|
706 |
+
defaultValue={provider.settings.baseUrl}
|
707 |
+
placeholder={`Enter ${provider.name} base URL`}
|
708 |
+
className={classNames(
|
709 |
+
'w-full px-3 py-2 rounded-lg text-sm',
|
710 |
+
'bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor',
|
711 |
+
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
|
712 |
+
'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
|
713 |
+
'transition-all duration-200',
|
714 |
+
)}
|
715 |
+
onKeyDown={(e) => {
|
716 |
+
if (e.key === 'Enter') {
|
717 |
+
handleUpdateBaseUrl(provider, e.currentTarget.value);
|
718 |
+
} else if (e.key === 'Escape') {
|
719 |
+
setEditingProvider(null);
|
720 |
+
}
|
721 |
+
}}
|
722 |
+
onBlur={(e) => handleUpdateBaseUrl(provider, e.target.value)}
|
723 |
+
autoFocus
|
724 |
+
/>
|
725 |
+
) : (
|
726 |
+
<div
|
727 |
+
onClick={() => setEditingProvider(provider.name)}
|
728 |
+
className={classNames(
|
729 |
+
'w-full px-3 py-2 rounded-lg text-sm cursor-pointer',
|
730 |
+
'bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor',
|
731 |
+
'hover:border-purple-500/30 hover:bg-bolt-elements-background-depth-4',
|
732 |
+
'transition-all duration-200',
|
733 |
+
)}
|
734 |
+
>
|
735 |
+
<div className="flex items-center gap-2 text-bolt-elements-textSecondary">
|
736 |
+
<div className="i-ph:link text-sm" />
|
737 |
+
<span>{provider.settings.baseUrl || 'Click to set base URL'}</span>
|
738 |
+
</div>
|
739 |
+
</div>
|
740 |
+
)}
|
741 |
+
</div>
|
742 |
+
</motion.div>
|
743 |
+
)}
|
744 |
+
</AnimatePresence>
|
745 |
+
</motion.div>
|
746 |
+
))}
|
747 |
+
</div>
|
748 |
+
</div>
|
749 |
+
</motion.div>
|
750 |
+
</div>
|
751 |
+
);
|
752 |
+
}
|
753 |
+
|
754 |
+
// Helper component for model status badge
|
755 |
+
function ModelStatusBadge({ status }: { status?: string }) {
|
756 |
+
if (!status || status === 'idle') {
|
757 |
+
return null;
|
758 |
+
}
|
759 |
+
|
760 |
+
const statusConfig = {
|
761 |
+
updating: { bg: 'bg-yellow-500/10', text: 'text-yellow-500', label: 'Updating' },
|
762 |
+
updated: { bg: 'bg-green-500/10', text: 'text-green-500', label: 'Updated' },
|
763 |
+
error: { bg: 'bg-red-500/10', text: 'text-red-500', label: 'Error' },
|
764 |
+
};
|
765 |
+
|
766 |
+
const config = statusConfig[status as keyof typeof statusConfig];
|
767 |
+
|
768 |
+
if (!config) {
|
769 |
+
return null;
|
770 |
+
}
|
771 |
+
|
772 |
+
return (
|
773 |
+
<span className={classNames('px-2 py-0.5 rounded-full text-xs font-medium', config.bg, config.text)}>
|
774 |
+
{config.label}
|
775 |
+
</span>
|
776 |
+
);
|
777 |
+
}
|
app/components/@settings/tabs/providers/local/OllamaModelInstaller.tsx
ADDED
@@ -0,0 +1,603 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React, { useState, useEffect } from 'react';
|
2 |
+
import { motion } from 'framer-motion';
|
3 |
+
import { classNames } from '~/utils/classNames';
|
4 |
+
import { Progress } from '~/components/ui/Progress';
|
5 |
+
import { useToast } from '~/components/ui/use-toast';
|
6 |
+
import { useSettings } from '~/lib/hooks/useSettings';
|
7 |
+
|
8 |
+
interface OllamaModelInstallerProps {
|
9 |
+
onModelInstalled: () => void;
|
10 |
+
}
|
11 |
+
|
12 |
+
interface InstallProgress {
|
13 |
+
status: string;
|
14 |
+
progress: number;
|
15 |
+
downloadedSize?: string;
|
16 |
+
totalSize?: string;
|
17 |
+
speed?: string;
|
18 |
+
}
|
19 |
+
|
20 |
+
interface ModelInfo {
|
21 |
+
name: string;
|
22 |
+
desc: string;
|
23 |
+
size: string;
|
24 |
+
tags: string[];
|
25 |
+
installedVersion?: string;
|
26 |
+
latestVersion?: string;
|
27 |
+
needsUpdate?: boolean;
|
28 |
+
status?: 'idle' | 'installing' | 'updating' | 'updated' | 'error';
|
29 |
+
details?: {
|
30 |
+
family: string;
|
31 |
+
parameter_size: string;
|
32 |
+
quantization_level: string;
|
33 |
+
};
|
34 |
+
}
|
35 |
+
|
36 |
+
const POPULAR_MODELS: ModelInfo[] = [
|
37 |
+
{
|
38 |
+
name: 'deepseek-coder:6.7b',
|
39 |
+
desc: "DeepSeek's code generation model",
|
40 |
+
size: '4.1GB',
|
41 |
+
tags: ['coding', 'popular'],
|
42 |
+
},
|
43 |
+
{
|
44 |
+
name: 'llama2:7b',
|
45 |
+
desc: "Meta's Llama 2 (7B parameters)",
|
46 |
+
size: '3.8GB',
|
47 |
+
tags: ['general', 'popular'],
|
48 |
+
},
|
49 |
+
{
|
50 |
+
name: 'mistral:7b',
|
51 |
+
desc: "Mistral's 7B model",
|
52 |
+
size: '4.1GB',
|
53 |
+
tags: ['general', 'popular'],
|
54 |
+
},
|
55 |
+
{
|
56 |
+
name: 'gemma:7b',
|
57 |
+
desc: "Google's Gemma model",
|
58 |
+
size: '4.0GB',
|
59 |
+
tags: ['general', 'new'],
|
60 |
+
},
|
61 |
+
{
|
62 |
+
name: 'codellama:7b',
|
63 |
+
desc: "Meta's Code Llama model",
|
64 |
+
size: '4.1GB',
|
65 |
+
tags: ['coding', 'popular'],
|
66 |
+
},
|
67 |
+
{
|
68 |
+
name: 'neural-chat:7b',
|
69 |
+
desc: "Intel's Neural Chat model",
|
70 |
+
size: '4.1GB',
|
71 |
+
tags: ['chat', 'popular'],
|
72 |
+
},
|
73 |
+
{
|
74 |
+
name: 'phi:latest',
|
75 |
+
desc: "Microsoft's Phi-2 model",
|
76 |
+
size: '2.7GB',
|
77 |
+
tags: ['small', 'fast'],
|
78 |
+
},
|
79 |
+
{
|
80 |
+
name: 'qwen:7b',
|
81 |
+
desc: "Alibaba's Qwen model",
|
82 |
+
size: '4.1GB',
|
83 |
+
tags: ['general'],
|
84 |
+
},
|
85 |
+
{
|
86 |
+
name: 'solar:10.7b',
|
87 |
+
desc: "Upstage's Solar model",
|
88 |
+
size: '6.1GB',
|
89 |
+
tags: ['large', 'powerful'],
|
90 |
+
},
|
91 |
+
{
|
92 |
+
name: 'openchat:7b',
|
93 |
+
desc: 'Open-source chat model',
|
94 |
+
size: '4.1GB',
|
95 |
+
tags: ['chat', 'popular'],
|
96 |
+
},
|
97 |
+
{
|
98 |
+
name: 'dolphin-phi:2.7b',
|
99 |
+
desc: 'Lightweight chat model',
|
100 |
+
size: '1.6GB',
|
101 |
+
tags: ['small', 'fast'],
|
102 |
+
},
|
103 |
+
{
|
104 |
+
name: 'stable-code:3b',
|
105 |
+
desc: 'Lightweight coding model',
|
106 |
+
size: '1.8GB',
|
107 |
+
tags: ['coding', 'small'],
|
108 |
+
},
|
109 |
+
];
|
110 |
+
|
111 |
+
function formatBytes(bytes: number): string {
|
112 |
+
if (bytes === 0) {
|
113 |
+
return '0 B';
|
114 |
+
}
|
115 |
+
|
116 |
+
const k = 1024;
|
117 |
+
const sizes = ['B', 'KB', 'MB', 'GB'];
|
118 |
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
119 |
+
|
120 |
+
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
121 |
+
}
|
122 |
+
|
123 |
+
function formatSpeed(bytesPerSecond: number): string {
|
124 |
+
return `${formatBytes(bytesPerSecond)}/s`;
|
125 |
+
}
|
126 |
+
|
127 |
+
// Add Ollama Icon SVG component
|
128 |
+
function OllamaIcon({ className }: { className?: string }) {
|
129 |
+
return (
|
130 |
+
<svg viewBox="0 0 1024 1024" className={className} fill="currentColor">
|
131 |
+
<path d="M684.3 322.2H339.8c-9.5.1-17.7 6.8-19.6 16.1-8.2 41.4-12.4 83.5-12.4 125.7 0 42.2 4.2 84.3 12.4 125.7 1.9 9.3 10.1 16 19.6 16.1h344.5c9.5-.1 17.7-6.8 19.6-16.1 8.2-41.4 12.4-83.5 12.4-125.7 0-42.2-4.2-84.3-12.4-125.7-1.9-9.3-10.1-16-19.6-16.1zM512 640c-176.7 0-320-143.3-320-320S335.3 0 512 0s320 143.3 320 320-143.3 320-320 320z" />
|
132 |
+
</svg>
|
133 |
+
);
|
134 |
+
}
|
135 |
+
|
136 |
+
export default function OllamaModelInstaller({ onModelInstalled }: OllamaModelInstallerProps) {
|
137 |
+
const [modelString, setModelString] = useState('');
|
138 |
+
const [searchQuery, setSearchQuery] = useState('');
|
139 |
+
const [isInstalling, setIsInstalling] = useState(false);
|
140 |
+
const [isChecking, setIsChecking] = useState(false);
|
141 |
+
const [installProgress, setInstallProgress] = useState<InstallProgress | null>(null);
|
142 |
+
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
143 |
+
const [models, setModels] = useState<ModelInfo[]>(POPULAR_MODELS);
|
144 |
+
const { toast } = useToast();
|
145 |
+
const { providers } = useSettings();
|
146 |
+
|
147 |
+
// Get base URL from provider settings
|
148 |
+
const baseUrl = providers?.Ollama?.settings?.baseUrl || 'http://127.0.0.1:11434';
|
149 |
+
|
150 |
+
// Function to check installed models and their versions
|
151 |
+
const checkInstalledModels = async () => {
|
152 |
+
try {
|
153 |
+
const response = await fetch(`${baseUrl}/api/tags`, {
|
154 |
+
method: 'GET',
|
155 |
+
});
|
156 |
+
|
157 |
+
if (!response.ok) {
|
158 |
+
throw new Error('Failed to fetch installed models');
|
159 |
+
}
|
160 |
+
|
161 |
+
const data = (await response.json()) as { models: Array<{ name: string; digest: string; latest: string }> };
|
162 |
+
const installedModels = data.models || [];
|
163 |
+
|
164 |
+
// Update models with installed versions
|
165 |
+
setModels((prevModels) =>
|
166 |
+
prevModels.map((model) => {
|
167 |
+
const installed = installedModels.find((m) => m.name.toLowerCase() === model.name.toLowerCase());
|
168 |
+
|
169 |
+
if (installed) {
|
170 |
+
return {
|
171 |
+
...model,
|
172 |
+
installedVersion: installed.digest.substring(0, 8),
|
173 |
+
needsUpdate: installed.digest !== installed.latest,
|
174 |
+
latestVersion: installed.latest?.substring(0, 8),
|
175 |
+
};
|
176 |
+
}
|
177 |
+
|
178 |
+
return model;
|
179 |
+
}),
|
180 |
+
);
|
181 |
+
} catch (error) {
|
182 |
+
console.error('Error checking installed models:', error);
|
183 |
+
}
|
184 |
+
};
|
185 |
+
|
186 |
+
// Check installed models on mount and after installation
|
187 |
+
useEffect(() => {
|
188 |
+
checkInstalledModels();
|
189 |
+
}, [baseUrl]);
|
190 |
+
|
191 |
+
const handleCheckUpdates = async () => {
|
192 |
+
setIsChecking(true);
|
193 |
+
|
194 |
+
try {
|
195 |
+
await checkInstalledModels();
|
196 |
+
toast('Model versions checked');
|
197 |
+
} catch (err) {
|
198 |
+
console.error('Failed to check model versions:', err);
|
199 |
+
toast('Failed to check model versions');
|
200 |
+
} finally {
|
201 |
+
setIsChecking(false);
|
202 |
+
}
|
203 |
+
};
|
204 |
+
|
205 |
+
const filteredModels = models.filter((model) => {
|
206 |
+
const matchesSearch =
|
207 |
+
searchQuery === '' ||
|
208 |
+
model.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
209 |
+
model.desc.toLowerCase().includes(searchQuery.toLowerCase());
|
210 |
+
const matchesTags = selectedTags.length === 0 || selectedTags.some((tag) => model.tags.includes(tag));
|
211 |
+
|
212 |
+
return matchesSearch && matchesTags;
|
213 |
+
});
|
214 |
+
|
215 |
+
const handleInstallModel = async (modelToInstall: string) => {
|
216 |
+
if (!modelToInstall) {
|
217 |
+
return;
|
218 |
+
}
|
219 |
+
|
220 |
+
try {
|
221 |
+
setIsInstalling(true);
|
222 |
+
setInstallProgress({
|
223 |
+
status: 'Starting download...',
|
224 |
+
progress: 0,
|
225 |
+
downloadedSize: '0 B',
|
226 |
+
totalSize: 'Calculating...',
|
227 |
+
speed: '0 B/s',
|
228 |
+
});
|
229 |
+
setModelString('');
|
230 |
+
setSearchQuery('');
|
231 |
+
|
232 |
+
const response = await fetch(`${baseUrl}/api/pull`, {
|
233 |
+
method: 'POST',
|
234 |
+
headers: {
|
235 |
+
'Content-Type': 'application/json',
|
236 |
+
},
|
237 |
+
body: JSON.stringify({ name: modelToInstall }),
|
238 |
+
});
|
239 |
+
|
240 |
+
if (!response.ok) {
|
241 |
+
throw new Error(`HTTP error! status: ${response.status}`);
|
242 |
+
}
|
243 |
+
|
244 |
+
const reader = response.body?.getReader();
|
245 |
+
|
246 |
+
if (!reader) {
|
247 |
+
throw new Error('Failed to get response reader');
|
248 |
+
}
|
249 |
+
|
250 |
+
let lastTime = Date.now();
|
251 |
+
let lastBytes = 0;
|
252 |
+
|
253 |
+
while (true) {
|
254 |
+
const { done, value } = await reader.read();
|
255 |
+
|
256 |
+
if (done) {
|
257 |
+
break;
|
258 |
+
}
|
259 |
+
|
260 |
+
const text = new TextDecoder().decode(value);
|
261 |
+
const lines = text.split('\n').filter(Boolean);
|
262 |
+
|
263 |
+
for (const line of lines) {
|
264 |
+
try {
|
265 |
+
const data = JSON.parse(line);
|
266 |
+
|
267 |
+
if ('status' in data) {
|
268 |
+
const currentTime = Date.now();
|
269 |
+
const timeDiff = (currentTime - lastTime) / 1000; // Convert to seconds
|
270 |
+
const bytesDiff = (data.completed || 0) - lastBytes;
|
271 |
+
const speed = bytesDiff / timeDiff;
|
272 |
+
|
273 |
+
setInstallProgress({
|
274 |
+
status: data.status,
|
275 |
+
progress: data.completed && data.total ? (data.completed / data.total) * 100 : 0,
|
276 |
+
downloadedSize: formatBytes(data.completed || 0),
|
277 |
+
totalSize: data.total ? formatBytes(data.total) : 'Calculating...',
|
278 |
+
speed: formatSpeed(speed),
|
279 |
+
});
|
280 |
+
|
281 |
+
lastTime = currentTime;
|
282 |
+
lastBytes = data.completed || 0;
|
283 |
+
}
|
284 |
+
} catch (err) {
|
285 |
+
console.error('Error parsing progress:', err);
|
286 |
+
}
|
287 |
+
}
|
288 |
+
}
|
289 |
+
|
290 |
+
toast('Successfully installed ' + modelToInstall + '. The model list will refresh automatically.');
|
291 |
+
|
292 |
+
// Ensure we call onModelInstalled after successful installation
|
293 |
+
setTimeout(() => {
|
294 |
+
onModelInstalled();
|
295 |
+
}, 1000);
|
296 |
+
} catch (err) {
|
297 |
+
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
|
298 |
+
console.error(`Error installing ${modelToInstall}:`, errorMessage);
|
299 |
+
toast(`Failed to install ${modelToInstall}. ${errorMessage}`);
|
300 |
+
} finally {
|
301 |
+
setIsInstalling(false);
|
302 |
+
setInstallProgress(null);
|
303 |
+
}
|
304 |
+
};
|
305 |
+
|
306 |
+
const handleUpdateModel = async (modelToUpdate: string) => {
|
307 |
+
try {
|
308 |
+
setModels((prev) => prev.map((m) => (m.name === modelToUpdate ? { ...m, status: 'updating' } : m)));
|
309 |
+
|
310 |
+
const response = await fetch(`${baseUrl}/api/pull`, {
|
311 |
+
method: 'POST',
|
312 |
+
headers: {
|
313 |
+
'Content-Type': 'application/json',
|
314 |
+
},
|
315 |
+
body: JSON.stringify({ name: modelToUpdate }),
|
316 |
+
});
|
317 |
+
|
318 |
+
if (!response.ok) {
|
319 |
+
throw new Error(`HTTP error! status: ${response.status}`);
|
320 |
+
}
|
321 |
+
|
322 |
+
const reader = response.body?.getReader();
|
323 |
+
|
324 |
+
if (!reader) {
|
325 |
+
throw new Error('Failed to get response reader');
|
326 |
+
}
|
327 |
+
|
328 |
+
let lastTime = Date.now();
|
329 |
+
let lastBytes = 0;
|
330 |
+
|
331 |
+
while (true) {
|
332 |
+
const { done, value } = await reader.read();
|
333 |
+
|
334 |
+
if (done) {
|
335 |
+
break;
|
336 |
+
}
|
337 |
+
|
338 |
+
const text = new TextDecoder().decode(value);
|
339 |
+
const lines = text.split('\n').filter(Boolean);
|
340 |
+
|
341 |
+
for (const line of lines) {
|
342 |
+
try {
|
343 |
+
const data = JSON.parse(line);
|
344 |
+
|
345 |
+
if ('status' in data) {
|
346 |
+
const currentTime = Date.now();
|
347 |
+
const timeDiff = (currentTime - lastTime) / 1000;
|
348 |
+
const bytesDiff = (data.completed || 0) - lastBytes;
|
349 |
+
const speed = bytesDiff / timeDiff;
|
350 |
+
|
351 |
+
setInstallProgress({
|
352 |
+
status: data.status,
|
353 |
+
progress: data.completed && data.total ? (data.completed / data.total) * 100 : 0,
|
354 |
+
downloadedSize: formatBytes(data.completed || 0),
|
355 |
+
totalSize: data.total ? formatBytes(data.total) : 'Calculating...',
|
356 |
+
speed: formatSpeed(speed),
|
357 |
+
});
|
358 |
+
|
359 |
+
lastTime = currentTime;
|
360 |
+
lastBytes = data.completed || 0;
|
361 |
+
}
|
362 |
+
} catch (err) {
|
363 |
+
console.error('Error parsing progress:', err);
|
364 |
+
}
|
365 |
+
}
|
366 |
+
}
|
367 |
+
|
368 |
+
toast('Successfully updated ' + modelToUpdate);
|
369 |
+
|
370 |
+
// Refresh model list after update
|
371 |
+
await checkInstalledModels();
|
372 |
+
} catch (err) {
|
373 |
+
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
|
374 |
+
console.error(`Error updating ${modelToUpdate}:`, errorMessage);
|
375 |
+
toast(`Failed to update ${modelToUpdate}. ${errorMessage}`);
|
376 |
+
setModels((prev) => prev.map((m) => (m.name === modelToUpdate ? { ...m, status: 'error' } : m)));
|
377 |
+
} finally {
|
378 |
+
setInstallProgress(null);
|
379 |
+
}
|
380 |
+
};
|
381 |
+
|
382 |
+
const allTags = Array.from(new Set(POPULAR_MODELS.flatMap((model) => model.tags)));
|
383 |
+
|
384 |
+
return (
|
385 |
+
<div className="space-y-6">
|
386 |
+
<div className="flex items-center justify-between pt-6">
|
387 |
+
<div className="flex items-center gap-3">
|
388 |
+
<OllamaIcon className="w-8 h-8 text-purple-500" />
|
389 |
+
<div>
|
390 |
+
<h3 className="text-lg font-semibold text-bolt-elements-textPrimary">Ollama Models</h3>
|
391 |
+
<p className="text-sm text-bolt-elements-textSecondary mt-1">Install and manage your Ollama models</p>
|
392 |
+
</div>
|
393 |
+
</div>
|
394 |
+
<motion.button
|
395 |
+
onClick={handleCheckUpdates}
|
396 |
+
disabled={isChecking}
|
397 |
+
className={classNames(
|
398 |
+
'px-4 py-2 rounded-lg',
|
399 |
+
'bg-purple-500/10 text-purple-500',
|
400 |
+
'hover:bg-purple-500/20',
|
401 |
+
'transition-all duration-200',
|
402 |
+
'flex items-center gap-2',
|
403 |
+
)}
|
404 |
+
whileHover={{ scale: 1.02 }}
|
405 |
+
whileTap={{ scale: 0.98 }}
|
406 |
+
>
|
407 |
+
{isChecking ? (
|
408 |
+
<div className="i-ph:spinner-gap-bold animate-spin" />
|
409 |
+
) : (
|
410 |
+
<div className="i-ph:arrows-clockwise" />
|
411 |
+
)}
|
412 |
+
Check Updates
|
413 |
+
</motion.button>
|
414 |
+
</div>
|
415 |
+
|
416 |
+
<div className="flex gap-4">
|
417 |
+
<div className="flex-1">
|
418 |
+
<div className="space-y-1">
|
419 |
+
<input
|
420 |
+
type="text"
|
421 |
+
className={classNames(
|
422 |
+
'w-full px-4 py-3 rounded-xl',
|
423 |
+
'bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor',
|
424 |
+
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
|
425 |
+
'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
|
426 |
+
'transition-all duration-200',
|
427 |
+
)}
|
428 |
+
placeholder="Search models or enter custom model name..."
|
429 |
+
value={searchQuery || modelString}
|
430 |
+
onChange={(e) => {
|
431 |
+
const value = e.target.value;
|
432 |
+
setSearchQuery(value);
|
433 |
+
setModelString(value);
|
434 |
+
}}
|
435 |
+
disabled={isInstalling}
|
436 |
+
/>
|
437 |
+
<p className="text-sm text-bolt-elements-textSecondary px-1">
|
438 |
+
Browse models at{' '}
|
439 |
+
<a
|
440 |
+
href="https://ollama.com/library"
|
441 |
+
target="_blank"
|
442 |
+
rel="noopener noreferrer"
|
443 |
+
className="text-purple-500 hover:underline inline-flex items-center gap-1 text-base font-medium"
|
444 |
+
>
|
445 |
+
ollama.com/library
|
446 |
+
<div className="i-ph:arrow-square-out text-sm" />
|
447 |
+
</a>{' '}
|
448 |
+
and copy model names to install
|
449 |
+
</p>
|
450 |
+
</div>
|
451 |
+
</div>
|
452 |
+
<motion.button
|
453 |
+
onClick={() => handleInstallModel(modelString)}
|
454 |
+
disabled={!modelString || isInstalling}
|
455 |
+
className={classNames(
|
456 |
+
'rounded-lg px-4 py-2',
|
457 |
+
'bg-purple-500 text-white text-sm',
|
458 |
+
'hover:bg-purple-600',
|
459 |
+
'transition-all duration-200',
|
460 |
+
'flex items-center gap-2',
|
461 |
+
{ 'opacity-50 cursor-not-allowed': !modelString || isInstalling },
|
462 |
+
)}
|
463 |
+
whileHover={{ scale: 1.02 }}
|
464 |
+
whileTap={{ scale: 0.98 }}
|
465 |
+
>
|
466 |
+
{isInstalling ? (
|
467 |
+
<div className="flex items-center gap-2">
|
468 |
+
<div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
|
469 |
+
<span>Installing...</span>
|
470 |
+
</div>
|
471 |
+
) : (
|
472 |
+
<div className="flex items-center gap-2">
|
473 |
+
<OllamaIcon className="w-4 h-4" />
|
474 |
+
<span>Install Model</span>
|
475 |
+
</div>
|
476 |
+
)}
|
477 |
+
</motion.button>
|
478 |
+
</div>
|
479 |
+
|
480 |
+
<div className="flex flex-wrap gap-2">
|
481 |
+
{allTags.map((tag) => (
|
482 |
+
<button
|
483 |
+
key={tag}
|
484 |
+
onClick={() => {
|
485 |
+
setSelectedTags((prev) => (prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]));
|
486 |
+
}}
|
487 |
+
className={classNames(
|
488 |
+
'px-3 py-1 rounded-full text-xs font-medium transition-all duration-200',
|
489 |
+
selectedTags.includes(tag)
|
490 |
+
? 'bg-purple-500 text-white'
|
491 |
+
: 'bg-bolt-elements-background-depth-3 text-bolt-elements-textSecondary hover:bg-bolt-elements-background-depth-4',
|
492 |
+
)}
|
493 |
+
>
|
494 |
+
{tag}
|
495 |
+
</button>
|
496 |
+
))}
|
497 |
+
</div>
|
498 |
+
|
499 |
+
<div className="grid grid-cols-1 gap-2">
|
500 |
+
{filteredModels.map((model) => (
|
501 |
+
<motion.div
|
502 |
+
key={model.name}
|
503 |
+
className={classNames(
|
504 |
+
'flex items-start gap-2 p-3 rounded-lg',
|
505 |
+
'bg-bolt-elements-background-depth-3',
|
506 |
+
'hover:bg-bolt-elements-background-depth-4',
|
507 |
+
'transition-all duration-200',
|
508 |
+
'relative group',
|
509 |
+
)}
|
510 |
+
>
|
511 |
+
<OllamaIcon className="w-5 h-5 text-purple-500 mt-0.5 flex-shrink-0" />
|
512 |
+
<div className="flex-1 space-y-1.5">
|
513 |
+
<div className="flex items-start justify-between">
|
514 |
+
<div>
|
515 |
+
<p className="text-bolt-elements-textPrimary font-mono text-sm">{model.name}</p>
|
516 |
+
<p className="text-xs text-bolt-elements-textSecondary mt-0.5">{model.desc}</p>
|
517 |
+
</div>
|
518 |
+
<div className="text-right">
|
519 |
+
<span className="text-xs text-bolt-elements-textTertiary">{model.size}</span>
|
520 |
+
{model.installedVersion && (
|
521 |
+
<div className="mt-0.5 flex flex-col items-end gap-0.5">
|
522 |
+
<span className="text-xs text-bolt-elements-textTertiary">v{model.installedVersion}</span>
|
523 |
+
{model.needsUpdate && model.latestVersion && (
|
524 |
+
<span className="text-xs text-purple-500">v{model.latestVersion} available</span>
|
525 |
+
)}
|
526 |
+
</div>
|
527 |
+
)}
|
528 |
+
</div>
|
529 |
+
</div>
|
530 |
+
<div className="flex items-center justify-between">
|
531 |
+
<div className="flex flex-wrap gap-1">
|
532 |
+
{model.tags.map((tag) => (
|
533 |
+
<span
|
534 |
+
key={tag}
|
535 |
+
className="px-1.5 py-0.5 rounded-full text-[10px] bg-bolt-elements-background-depth-4 text-bolt-elements-textTertiary"
|
536 |
+
>
|
537 |
+
{tag}
|
538 |
+
</span>
|
539 |
+
))}
|
540 |
+
</div>
|
541 |
+
<div className="flex gap-2">
|
542 |
+
{model.installedVersion ? (
|
543 |
+
model.needsUpdate ? (
|
544 |
+
<motion.button
|
545 |
+
onClick={() => handleUpdateModel(model.name)}
|
546 |
+
className={classNames(
|
547 |
+
'px-2 py-0.5 rounded-lg text-xs',
|
548 |
+
'bg-purple-500 text-white',
|
549 |
+
'hover:bg-purple-600',
|
550 |
+
'transition-all duration-200',
|
551 |
+
'flex items-center gap-1',
|
552 |
+
)}
|
553 |
+
whileHover={{ scale: 1.02 }}
|
554 |
+
whileTap={{ scale: 0.98 }}
|
555 |
+
>
|
556 |
+
<div className="i-ph:arrows-clockwise text-xs" />
|
557 |
+
Update
|
558 |
+
</motion.button>
|
559 |
+
) : (
|
560 |
+
<span className="px-2 py-0.5 rounded-lg text-xs text-green-500 bg-green-500/10">Up to date</span>
|
561 |
+
)
|
562 |
+
) : (
|
563 |
+
<motion.button
|
564 |
+
onClick={() => handleInstallModel(model.name)}
|
565 |
+
className={classNames(
|
566 |
+
'px-2 py-0.5 rounded-lg text-xs',
|
567 |
+
'bg-purple-500 text-white',
|
568 |
+
'hover:bg-purple-600',
|
569 |
+
'transition-all duration-200',
|
570 |
+
'flex items-center gap-1',
|
571 |
+
)}
|
572 |
+
whileHover={{ scale: 1.02 }}
|
573 |
+
whileTap={{ scale: 0.98 }}
|
574 |
+
>
|
575 |
+
<div className="i-ph:download text-xs" />
|
576 |
+
Install
|
577 |
+
</motion.button>
|
578 |
+
)}
|
579 |
+
</div>
|
580 |
+
</div>
|
581 |
+
</div>
|
582 |
+
</motion.div>
|
583 |
+
))}
|
584 |
+
</div>
|
585 |
+
|
586 |
+
{installProgress && (
|
587 |
+
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} className="space-y-2">
|
588 |
+
<div className="flex justify-between text-sm">
|
589 |
+
<span className="text-bolt-elements-textSecondary">{installProgress.status}</span>
|
590 |
+
<div className="flex items-center gap-4">
|
591 |
+
<span className="text-bolt-elements-textTertiary">
|
592 |
+
{installProgress.downloadedSize} / {installProgress.totalSize}
|
593 |
+
</span>
|
594 |
+
<span className="text-bolt-elements-textTertiary">{installProgress.speed}</span>
|
595 |
+
<span className="text-bolt-elements-textSecondary">{Math.round(installProgress.progress)}%</span>
|
596 |
+
</div>
|
597 |
+
</div>
|
598 |
+
<Progress value={installProgress.progress} className="h-1" />
|
599 |
+
</motion.div>
|
600 |
+
)}
|
601 |
+
</div>
|
602 |
+
);
|
603 |
+
}
|
app/components/@settings/tabs/providers/service-status/ServiceStatusTab.tsx
ADDED
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { useState, useEffect } from 'react';
|
2 |
+
import type { ServiceStatus } from './types';
|
3 |
+
import { ProviderStatusCheckerFactory } from './provider-factory';
|
4 |
+
|
5 |
+
export default function ServiceStatusTab() {
|
6 |
+
const [serviceStatuses, setServiceStatuses] = useState<ServiceStatus[]>([]);
|
7 |
+
const [loading, setLoading] = useState(true);
|
8 |
+
const [error, setError] = useState<string | null>(null);
|
9 |
+
|
10 |
+
useEffect(() => {
|
11 |
+
const checkAllProviders = async () => {
|
12 |
+
try {
|
13 |
+
setLoading(true);
|
14 |
+
setError(null);
|
15 |
+
|
16 |
+
const providers = ProviderStatusCheckerFactory.getProviderNames();
|
17 |
+
const statuses: ServiceStatus[] = [];
|
18 |
+
|
19 |
+
for (const provider of providers) {
|
20 |
+
try {
|
21 |
+
const checker = ProviderStatusCheckerFactory.getChecker(provider);
|
22 |
+
const result = await checker.checkStatus();
|
23 |
+
|
24 |
+
statuses.push({
|
25 |
+
provider,
|
26 |
+
...result,
|
27 |
+
lastChecked: new Date().toISOString(),
|
28 |
+
});
|
29 |
+
} catch (err) {
|
30 |
+
console.error(`Error checking ${provider} status:`, err);
|
31 |
+
statuses.push({
|
32 |
+
provider,
|
33 |
+
status: 'degraded',
|
34 |
+
message: 'Unable to check service status',
|
35 |
+
incidents: ['Error checking service status'],
|
36 |
+
lastChecked: new Date().toISOString(),
|
37 |
+
});
|
38 |
+
}
|
39 |
+
}
|
40 |
+
|
41 |
+
setServiceStatuses(statuses);
|
42 |
+
} catch (err) {
|
43 |
+
console.error('Error checking provider statuses:', err);
|
44 |
+
setError('Failed to check service statuses');
|
45 |
+
} finally {
|
46 |
+
setLoading(false);
|
47 |
+
}
|
48 |
+
};
|
49 |
+
|
50 |
+
checkAllProviders();
|
51 |
+
|
52 |
+
// Set up periodic checks every 5 minutes
|
53 |
+
const interval = setInterval(checkAllProviders, 5 * 60 * 1000);
|
54 |
+
|
55 |
+
return () => clearInterval(interval);
|
56 |
+
}, []);
|
57 |
+
|
58 |
+
const getStatusColor = (status: ServiceStatus['status']) => {
|
59 |
+
switch (status) {
|
60 |
+
case 'operational':
|
61 |
+
return 'text-green-500 dark:text-green-400';
|
62 |
+
case 'degraded':
|
63 |
+
return 'text-yellow-500 dark:text-yellow-400';
|
64 |
+
case 'down':
|
65 |
+
return 'text-red-500 dark:text-red-400';
|
66 |
+
default:
|
67 |
+
return 'text-gray-500 dark:text-gray-400';
|
68 |
+
}
|
69 |
+
};
|
70 |
+
|
71 |
+
const getStatusIcon = (status: ServiceStatus['status']) => {
|
72 |
+
switch (status) {
|
73 |
+
case 'operational':
|
74 |
+
return 'i-ph:check-circle';
|
75 |
+
case 'degraded':
|
76 |
+
return 'i-ph:warning';
|
77 |
+
case 'down':
|
78 |
+
return 'i-ph:x-circle';
|
79 |
+
default:
|
80 |
+
return 'i-ph:question';
|
81 |
+
}
|
82 |
+
};
|
83 |
+
|
84 |
+
if (loading) {
|
85 |
+
return (
|
86 |
+
<div className="flex items-center justify-center h-full">
|
87 |
+
<div className="animate-spin i-ph:circle-notch w-8 h-8 text-purple-500" />
|
88 |
+
</div>
|
89 |
+
);
|
90 |
+
}
|
91 |
+
|
92 |
+
if (error) {
|
93 |
+
return (
|
94 |
+
<div className="flex flex-col items-center justify-center h-full text-red-500 dark:text-red-400">
|
95 |
+
<div className="i-ph:warning w-8 h-8 mb-2" />
|
96 |
+
<p>{error}</p>
|
97 |
+
</div>
|
98 |
+
);
|
99 |
+
}
|
100 |
+
|
101 |
+
return (
|
102 |
+
<div className="space-y-6">
|
103 |
+
<div className="grid grid-cols-1 gap-4">
|
104 |
+
{serviceStatuses.map((service) => (
|
105 |
+
<div
|
106 |
+
key={service.provider}
|
107 |
+
className="p-4 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700"
|
108 |
+
>
|
109 |
+
<div className="flex items-center justify-between mb-2">
|
110 |
+
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">{service.provider}</h3>
|
111 |
+
<div className={`flex items-center ${getStatusColor(service.status)}`}>
|
112 |
+
<div className={`${getStatusIcon(service.status)} w-5 h-5 mr-2`} />
|
113 |
+
<span className="capitalize">{service.status}</span>
|
114 |
+
</div>
|
115 |
+
</div>
|
116 |
+
<p className="text-gray-600 dark:text-gray-300 mb-2">{service.message}</p>
|
117 |
+
{service.incidents && service.incidents.length > 0 && (
|
118 |
+
<div className="mt-2">
|
119 |
+
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-1">Recent Incidents:</h4>
|
120 |
+
<ul className="text-sm text-gray-600 dark:text-gray-400 space-y-1">
|
121 |
+
{service.incidents.map((incident, index) => (
|
122 |
+
<li key={index}>{incident}</li>
|
123 |
+
))}
|
124 |
+
</ul>
|
125 |
+
</div>
|
126 |
+
)}
|
127 |
+
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
128 |
+
Last checked: {new Date(service.lastChecked).toLocaleString()}
|
129 |
+
</div>
|
130 |
+
</div>
|
131 |
+
))}
|
132 |
+
</div>
|
133 |
+
</div>
|
134 |
+
);
|
135 |
+
}
|
app/components/@settings/tabs/providers/service-status/base-provider.ts
ADDED
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import type { ProviderConfig, StatusCheckResult, ApiResponse } from './types';
|
2 |
+
|
3 |
+
export abstract class BaseProviderChecker {
|
4 |
+
protected config: ProviderConfig;
|
5 |
+
|
6 |
+
constructor(config: ProviderConfig) {
|
7 |
+
this.config = config;
|
8 |
+
}
|
9 |
+
|
10 |
+
protected async checkApiEndpoint(
|
11 |
+
url: string,
|
12 |
+
headers?: Record<string, string>,
|
13 |
+
testModel?: string,
|
14 |
+
): Promise<{ ok: boolean; status: number | string; message?: string; responseTime: number }> {
|
15 |
+
try {
|
16 |
+
const controller = new AbortController();
|
17 |
+
const timeoutId = setTimeout(() => controller.abort(), 10000);
|
18 |
+
|
19 |
+
const startTime = performance.now();
|
20 |
+
|
21 |
+
// Add common headers
|
22 |
+
const processedHeaders = {
|
23 |
+
'Content-Type': 'application/json',
|
24 |
+
...headers,
|
25 |
+
};
|
26 |
+
|
27 |
+
const response = await fetch(url, {
|
28 |
+
method: 'GET',
|
29 |
+
headers: processedHeaders,
|
30 |
+
signal: controller.signal,
|
31 |
+
});
|
32 |
+
|
33 |
+
const endTime = performance.now();
|
34 |
+
const responseTime = endTime - startTime;
|
35 |
+
|
36 |
+
clearTimeout(timeoutId);
|
37 |
+
|
38 |
+
const data = (await response.json()) as ApiResponse;
|
39 |
+
|
40 |
+
if (!response.ok) {
|
41 |
+
let errorMessage = `API returned status: ${response.status}`;
|
42 |
+
|
43 |
+
if (data.error?.message) {
|
44 |
+
errorMessage = data.error.message;
|
45 |
+
} else if (data.message) {
|
46 |
+
errorMessage = data.message;
|
47 |
+
}
|
48 |
+
|
49 |
+
return {
|
50 |
+
ok: false,
|
51 |
+
status: response.status,
|
52 |
+
message: errorMessage,
|
53 |
+
responseTime,
|
54 |
+
};
|
55 |
+
}
|
56 |
+
|
57 |
+
// Different providers have different model list formats
|
58 |
+
let models: string[] = [];
|
59 |
+
|
60 |
+
if (Array.isArray(data)) {
|
61 |
+
models = data.map((model: { id?: string; name?: string }) => model.id || model.name || '');
|
62 |
+
} else if (data.data && Array.isArray(data.data)) {
|
63 |
+
models = data.data.map((model) => model.id || model.name || '');
|
64 |
+
} else if (data.models && Array.isArray(data.models)) {
|
65 |
+
models = data.models.map((model) => model.id || model.name || '');
|
66 |
+
} else if (data.model) {
|
67 |
+
models = [data.model];
|
68 |
+
}
|
69 |
+
|
70 |
+
if (!testModel || models.length > 0) {
|
71 |
+
return {
|
72 |
+
ok: true,
|
73 |
+
status: response.status,
|
74 |
+
responseTime,
|
75 |
+
message: 'API key is valid',
|
76 |
+
};
|
77 |
+
}
|
78 |
+
|
79 |
+
if (testModel && !models.includes(testModel)) {
|
80 |
+
return {
|
81 |
+
ok: true,
|
82 |
+
status: 'model_not_found',
|
83 |
+
message: `API key is valid (test model ${testModel} not found in ${models.length} available models)`,
|
84 |
+
responseTime,
|
85 |
+
};
|
86 |
+
}
|
87 |
+
|
88 |
+
return {
|
89 |
+
ok: true,
|
90 |
+
status: response.status,
|
91 |
+
message: 'API key is valid',
|
92 |
+
responseTime,
|
93 |
+
};
|
94 |
+
} catch (error) {
|
95 |
+
console.error(`Error checking API endpoint ${url}:`, error);
|
96 |
+
return {
|
97 |
+
ok: false,
|
98 |
+
status: error instanceof Error ? error.message : 'Unknown error',
|
99 |
+
message: error instanceof Error ? `Connection failed: ${error.message}` : 'Connection failed',
|
100 |
+
responseTime: 0,
|
101 |
+
};
|
102 |
+
}
|
103 |
+
}
|
104 |
+
|
105 |
+
protected async checkEndpoint(url: string): Promise<'reachable' | 'unreachable'> {
|
106 |
+
try {
|
107 |
+
const response = await fetch(url, {
|
108 |
+
mode: 'no-cors',
|
109 |
+
headers: {
|
110 |
+
Accept: 'text/html',
|
111 |
+
},
|
112 |
+
});
|
113 |
+
return response.type === 'opaque' ? 'reachable' : 'unreachable';
|
114 |
+
} catch (error) {
|
115 |
+
console.error(`Error checking ${url}:`, error);
|
116 |
+
return 'unreachable';
|
117 |
+
}
|
118 |
+
}
|
119 |
+
|
120 |
+
abstract checkStatus(): Promise<StatusCheckResult>;
|
121 |
+
}
|
app/components/@settings/tabs/providers/service-status/provider-factory.ts
ADDED
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import type { ProviderName, ProviderConfig, StatusCheckResult } from './types';
|
2 |
+
import { BaseProviderChecker } from './base-provider';
|
3 |
+
|
4 |
+
import { AmazonBedrockStatusChecker } from './providers/amazon-bedrock';
|
5 |
+
import { CohereStatusChecker } from './providers/cohere';
|
6 |
+
import { DeepseekStatusChecker } from './providers/deepseek';
|
7 |
+
import { GoogleStatusChecker } from './providers/google';
|
8 |
+
import { GroqStatusChecker } from './providers/groq';
|
9 |
+
import { HuggingFaceStatusChecker } from './providers/huggingface';
|
10 |
+
import { HyperbolicStatusChecker } from './providers/hyperbolic';
|
11 |
+
import { MistralStatusChecker } from './providers/mistral';
|
12 |
+
import { OpenRouterStatusChecker } from './providers/openrouter';
|
13 |
+
import { PerplexityStatusChecker } from './providers/perplexity';
|
14 |
+
import { TogetherStatusChecker } from './providers/together';
|
15 |
+
import { XAIStatusChecker } from './providers/xai';
|
16 |
+
|
17 |
+
export class ProviderStatusCheckerFactory {
|
18 |
+
private static _providerConfigs: Record<ProviderName, ProviderConfig> = {
|
19 |
+
AmazonBedrock: {
|
20 |
+
statusUrl: 'https://health.aws.amazon.com/health/status',
|
21 |
+
apiUrl: 'https://bedrock.us-east-1.amazonaws.com/models',
|
22 |
+
headers: {},
|
23 |
+
testModel: 'anthropic.claude-3-sonnet-20240229-v1:0',
|
24 |
+
},
|
25 |
+
Cohere: {
|
26 |
+
statusUrl: 'https://status.cohere.com/',
|
27 |
+
apiUrl: 'https://api.cohere.ai/v1/models',
|
28 |
+
headers: {},
|
29 |
+
testModel: 'command',
|
30 |
+
},
|
31 |
+
Deepseek: {
|
32 |
+
statusUrl: 'https://status.deepseek.com/',
|
33 |
+
apiUrl: 'https://api.deepseek.com/v1/models',
|
34 |
+
headers: {},
|
35 |
+
testModel: 'deepseek-chat',
|
36 |
+
},
|
37 |
+
Google: {
|
38 |
+
statusUrl: 'https://status.cloud.google.com/',
|
39 |
+
apiUrl: 'https://generativelanguage.googleapis.com/v1/models',
|
40 |
+
headers: {},
|
41 |
+
testModel: 'gemini-pro',
|
42 |
+
},
|
43 |
+
Groq: {
|
44 |
+
statusUrl: 'https://groqstatus.com/',
|
45 |
+
apiUrl: 'https://api.groq.com/v1/models',
|
46 |
+
headers: {},
|
47 |
+
testModel: 'mixtral-8x7b-32768',
|
48 |
+
},
|
49 |
+
HuggingFace: {
|
50 |
+
statusUrl: 'https://status.huggingface.co/',
|
51 |
+
apiUrl: 'https://api-inference.huggingface.co/models',
|
52 |
+
headers: {},
|
53 |
+
testModel: 'mistralai/Mixtral-8x7B-Instruct-v0.1',
|
54 |
+
},
|
55 |
+
Hyperbolic: {
|
56 |
+
statusUrl: 'https://status.hyperbolic.ai/',
|
57 |
+
apiUrl: 'https://api.hyperbolic.ai/v1/models',
|
58 |
+
headers: {},
|
59 |
+
testModel: 'hyperbolic-1',
|
60 |
+
},
|
61 |
+
Mistral: {
|
62 |
+
statusUrl: 'https://status.mistral.ai/',
|
63 |
+
apiUrl: 'https://api.mistral.ai/v1/models',
|
64 |
+
headers: {},
|
65 |
+
testModel: 'mistral-tiny',
|
66 |
+
},
|
67 |
+
OpenRouter: {
|
68 |
+
statusUrl: 'https://status.openrouter.ai/',
|
69 |
+
apiUrl: 'https://openrouter.ai/api/v1/models',
|
70 |
+
headers: {},
|
71 |
+
testModel: 'anthropic/claude-3-sonnet',
|
72 |
+
},
|
73 |
+
Perplexity: {
|
74 |
+
statusUrl: 'https://status.perplexity.com/',
|
75 |
+
apiUrl: 'https://api.perplexity.ai/v1/models',
|
76 |
+
headers: {},
|
77 |
+
testModel: 'pplx-7b-chat',
|
78 |
+
},
|
79 |
+
Together: {
|
80 |
+
statusUrl: 'https://status.together.ai/',
|
81 |
+
apiUrl: 'https://api.together.xyz/v1/models',
|
82 |
+
headers: {},
|
83 |
+
testModel: 'mistralai/Mixtral-8x7B-Instruct-v0.1',
|
84 |
+
},
|
85 |
+
XAI: {
|
86 |
+
statusUrl: 'https://status.x.ai/',
|
87 |
+
apiUrl: 'https://api.x.ai/v1/models',
|
88 |
+
headers: {},
|
89 |
+
testModel: 'grok-1',
|
90 |
+
},
|
91 |
+
};
|
92 |
+
|
93 |
+
static getChecker(provider: ProviderName): BaseProviderChecker {
|
94 |
+
const config = this._providerConfigs[provider];
|
95 |
+
|
96 |
+
if (!config) {
|
97 |
+
throw new Error(`No configuration found for provider: ${provider}`);
|
98 |
+
}
|
99 |
+
|
100 |
+
switch (provider) {
|
101 |
+
case 'AmazonBedrock':
|
102 |
+
return new AmazonBedrockStatusChecker(config);
|
103 |
+
case 'Cohere':
|
104 |
+
return new CohereStatusChecker(config);
|
105 |
+
case 'Deepseek':
|
106 |
+
return new DeepseekStatusChecker(config);
|
107 |
+
case 'Google':
|
108 |
+
return new GoogleStatusChecker(config);
|
109 |
+
case 'Groq':
|
110 |
+
return new GroqStatusChecker(config);
|
111 |
+
case 'HuggingFace':
|
112 |
+
return new HuggingFaceStatusChecker(config);
|
113 |
+
case 'Hyperbolic':
|
114 |
+
return new HyperbolicStatusChecker(config);
|
115 |
+
case 'Mistral':
|
116 |
+
return new MistralStatusChecker(config);
|
117 |
+
case 'OpenRouter':
|
118 |
+
return new OpenRouterStatusChecker(config);
|
119 |
+
case 'Perplexity':
|
120 |
+
return new PerplexityStatusChecker(config);
|
121 |
+
case 'Together':
|
122 |
+
return new TogetherStatusChecker(config);
|
123 |
+
case 'XAI':
|
124 |
+
return new XAIStatusChecker(config);
|
125 |
+
default:
|
126 |
+
return new (class extends BaseProviderChecker {
|
127 |
+
async checkStatus(): Promise<StatusCheckResult> {
|
128 |
+
const endpointStatus = await this.checkEndpoint(this.config.statusUrl);
|
129 |
+
const apiStatus = await this.checkEndpoint(this.config.apiUrl);
|
130 |
+
|
131 |
+
return {
|
132 |
+
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
|
133 |
+
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
|
134 |
+
incidents: ['Note: Limited status information due to CORS restrictions'],
|
135 |
+
};
|
136 |
+
}
|
137 |
+
})(config);
|
138 |
+
}
|
139 |
+
}
|
140 |
+
|
141 |
+
static getProviderNames(): ProviderName[] {
|
142 |
+
return Object.keys(this._providerConfigs) as ProviderName[];
|
143 |
+
}
|
144 |
+
|
145 |
+
static getProviderConfig(provider: ProviderName): ProviderConfig {
|
146 |
+
const config = this._providerConfigs[provider];
|
147 |
+
|
148 |
+
if (!config) {
|
149 |
+
throw new Error(`Unknown provider: ${provider}`);
|
150 |
+
}
|
151 |
+
|
152 |
+
return config;
|
153 |
+
}
|
154 |
+
}
|
app/components/@settings/tabs/providers/service-status/providers/amazon-bedrock.ts
ADDED
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
|
2 |
+
import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
|
3 |
+
|
4 |
+
export class AmazonBedrockStatusChecker extends BaseProviderChecker {
|
5 |
+
async checkStatus(): Promise<StatusCheckResult> {
|
6 |
+
try {
|
7 |
+
// Check AWS health status page
|
8 |
+
const statusPageResponse = await fetch('https://health.aws.amazon.com/health/status');
|
9 |
+
const text = await statusPageResponse.text();
|
10 |
+
|
11 |
+
// Check for Bedrock and general AWS status
|
12 |
+
const hasBedrockIssues =
|
13 |
+
text.includes('Amazon Bedrock') &&
|
14 |
+
(text.includes('Service is experiencing elevated error rates') ||
|
15 |
+
text.includes('Service disruption') ||
|
16 |
+
text.includes('Degraded Service'));
|
17 |
+
|
18 |
+
const hasGeneralIssues = text.includes('Service disruption') || text.includes('Multiple services affected');
|
19 |
+
|
20 |
+
// Extract incidents
|
21 |
+
const incidents: string[] = [];
|
22 |
+
const incidentMatches = text.matchAll(/(\d{4}-\d{2}-\d{2})\s+(.*?)\s+Impact:(.*?)(?=\n|$)/g);
|
23 |
+
|
24 |
+
for (const match of incidentMatches) {
|
25 |
+
const [, date, title, impact] = match;
|
26 |
+
|
27 |
+
if (title.includes('Bedrock') || title.includes('AWS')) {
|
28 |
+
incidents.push(`${date}: ${title.trim()} - Impact: ${impact.trim()}`);
|
29 |
+
}
|
30 |
+
}
|
31 |
+
|
32 |
+
let status: StatusCheckResult['status'] = 'operational';
|
33 |
+
let message = 'All services operational';
|
34 |
+
|
35 |
+
if (hasBedrockIssues) {
|
36 |
+
status = 'degraded';
|
37 |
+
message = 'Amazon Bedrock service issues reported';
|
38 |
+
} else if (hasGeneralIssues) {
|
39 |
+
status = 'degraded';
|
40 |
+
message = 'AWS experiencing general issues';
|
41 |
+
}
|
42 |
+
|
43 |
+
// If status page check fails, fallback to endpoint check
|
44 |
+
if (!statusPageResponse.ok) {
|
45 |
+
const endpointStatus = await this.checkEndpoint('https://health.aws.amazon.com/health/status');
|
46 |
+
const apiEndpoint = 'https://bedrock.us-east-1.amazonaws.com/models';
|
47 |
+
const apiStatus = await this.checkEndpoint(apiEndpoint);
|
48 |
+
|
49 |
+
return {
|
50 |
+
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
|
51 |
+
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
|
52 |
+
incidents: ['Note: Limited status information due to CORS restrictions'],
|
53 |
+
};
|
54 |
+
}
|
55 |
+
|
56 |
+
return {
|
57 |
+
status,
|
58 |
+
message,
|
59 |
+
incidents: incidents.slice(0, 5),
|
60 |
+
};
|
61 |
+
} catch (error) {
|
62 |
+
console.error('Error checking Amazon Bedrock status:', error);
|
63 |
+
|
64 |
+
// Fallback to basic endpoint check
|
65 |
+
const endpointStatus = await this.checkEndpoint('https://health.aws.amazon.com/health/status');
|
66 |
+
const apiEndpoint = 'https://bedrock.us-east-1.amazonaws.com/models';
|
67 |
+
const apiStatus = await this.checkEndpoint(apiEndpoint);
|
68 |
+
|
69 |
+
return {
|
70 |
+
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
|
71 |
+
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
|
72 |
+
incidents: ['Note: Limited status information due to CORS restrictions'],
|
73 |
+
};
|
74 |
+
}
|
75 |
+
}
|
76 |
+
}
|
app/components/@settings/tabs/providers/service-status/providers/anthropic.ts
ADDED
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
|
2 |
+
import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
|
3 |
+
|
4 |
+
export class AnthropicStatusChecker extends BaseProviderChecker {
|
5 |
+
async checkStatus(): Promise<StatusCheckResult> {
|
6 |
+
try {
|
7 |
+
// Check status page
|
8 |
+
const statusPageResponse = await fetch('https://status.anthropic.com/');
|
9 |
+
const text = await statusPageResponse.text();
|
10 |
+
|
11 |
+
// Check for specific Anthropic status indicators
|
12 |
+
const isOperational = text.includes('All Systems Operational');
|
13 |
+
const hasDegradedPerformance = text.includes('Degraded Performance');
|
14 |
+
const hasPartialOutage = text.includes('Partial Outage');
|
15 |
+
const hasMajorOutage = text.includes('Major Outage');
|
16 |
+
|
17 |
+
// Extract incidents
|
18 |
+
const incidents: string[] = [];
|
19 |
+
const incidentSection = text.match(/Past Incidents(.*?)(?=\n\n)/s);
|
20 |
+
|
21 |
+
if (incidentSection) {
|
22 |
+
const incidentLines = incidentSection[1]
|
23 |
+
.split('\n')
|
24 |
+
.map((line) => line.trim())
|
25 |
+
.filter((line) => line && line.includes('202')); // Only get dated incidents
|
26 |
+
|
27 |
+
incidents.push(...incidentLines.slice(0, 5));
|
28 |
+
}
|
29 |
+
|
30 |
+
let status: StatusCheckResult['status'] = 'operational';
|
31 |
+
let message = 'All systems operational';
|
32 |
+
|
33 |
+
if (hasMajorOutage) {
|
34 |
+
status = 'down';
|
35 |
+
message = 'Major service outage';
|
36 |
+
} else if (hasPartialOutage) {
|
37 |
+
status = 'down';
|
38 |
+
message = 'Partial service outage';
|
39 |
+
} else if (hasDegradedPerformance) {
|
40 |
+
status = 'degraded';
|
41 |
+
message = 'Service experiencing degraded performance';
|
42 |
+
} else if (!isOperational) {
|
43 |
+
status = 'degraded';
|
44 |
+
message = 'Service status unknown';
|
45 |
+
}
|
46 |
+
|
47 |
+
// If status page check fails, fallback to endpoint check
|
48 |
+
if (!statusPageResponse.ok) {
|
49 |
+
const endpointStatus = await this.checkEndpoint('https://status.anthropic.com/');
|
50 |
+
const apiEndpoint = 'https://api.anthropic.com/v1/messages';
|
51 |
+
const apiStatus = await this.checkEndpoint(apiEndpoint);
|
52 |
+
|
53 |
+
return {
|
54 |
+
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
|
55 |
+
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
|
56 |
+
incidents: ['Note: Limited status information due to CORS restrictions'],
|
57 |
+
};
|
58 |
+
}
|
59 |
+
|
60 |
+
return {
|
61 |
+
status,
|
62 |
+
message,
|
63 |
+
incidents,
|
64 |
+
};
|
65 |
+
} catch (error) {
|
66 |
+
console.error('Error checking Anthropic status:', error);
|
67 |
+
|
68 |
+
// Fallback to basic endpoint check
|
69 |
+
const endpointStatus = await this.checkEndpoint('https://status.anthropic.com/');
|
70 |
+
const apiEndpoint = 'https://api.anthropic.com/v1/messages';
|
71 |
+
const apiStatus = await this.checkEndpoint(apiEndpoint);
|
72 |
+
|
73 |
+
return {
|
74 |
+
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
|
75 |
+
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
|
76 |
+
incidents: ['Note: Limited status information due to CORS restrictions'],
|
77 |
+
};
|
78 |
+
}
|
79 |
+
}
|
80 |
+
}
|
app/components/@settings/tabs/providers/service-status/providers/cohere.ts
ADDED
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
|
2 |
+
import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
|
3 |
+
|
4 |
+
export class CohereStatusChecker extends BaseProviderChecker {
|
5 |
+
async checkStatus(): Promise<StatusCheckResult> {
|
6 |
+
try {
|
7 |
+
// Check status page
|
8 |
+
const statusPageResponse = await fetch('https://status.cohere.com/');
|
9 |
+
const text = await statusPageResponse.text();
|
10 |
+
|
11 |
+
// Check for specific Cohere status indicators
|
12 |
+
const isOperational = text.includes('All Systems Operational');
|
13 |
+
const hasIncidents = text.includes('Active Incidents');
|
14 |
+
const hasDegradation = text.includes('Degraded Performance');
|
15 |
+
const hasOutage = text.includes('Service Outage');
|
16 |
+
|
17 |
+
// Extract incidents
|
18 |
+
const incidents: string[] = [];
|
19 |
+
const incidentSection = text.match(/Past Incidents(.*?)(?=\n\n)/s);
|
20 |
+
|
21 |
+
if (incidentSection) {
|
22 |
+
const incidentLines = incidentSection[1]
|
23 |
+
.split('\n')
|
24 |
+
.map((line) => line.trim())
|
25 |
+
.filter((line) => line && line.includes('202')); // Only get dated incidents
|
26 |
+
|
27 |
+
incidents.push(...incidentLines.slice(0, 5));
|
28 |
+
}
|
29 |
+
|
30 |
+
// Check specific services
|
31 |
+
const services = {
|
32 |
+
api: {
|
33 |
+
operational: text.includes('API Service') && text.includes('Operational'),
|
34 |
+
degraded: text.includes('API Service') && text.includes('Degraded Performance'),
|
35 |
+
outage: text.includes('API Service') && text.includes('Service Outage'),
|
36 |
+
},
|
37 |
+
generation: {
|
38 |
+
operational: text.includes('Generation Service') && text.includes('Operational'),
|
39 |
+
degraded: text.includes('Generation Service') && text.includes('Degraded Performance'),
|
40 |
+
outage: text.includes('Generation Service') && text.includes('Service Outage'),
|
41 |
+
},
|
42 |
+
};
|
43 |
+
|
44 |
+
let status: StatusCheckResult['status'] = 'operational';
|
45 |
+
let message = 'All systems operational';
|
46 |
+
|
47 |
+
if (services.api.outage || services.generation.outage || hasOutage) {
|
48 |
+
status = 'down';
|
49 |
+
message = 'Service outage detected';
|
50 |
+
} else if (services.api.degraded || services.generation.degraded || hasDegradation || hasIncidents) {
|
51 |
+
status = 'degraded';
|
52 |
+
message = 'Service experiencing issues';
|
53 |
+
} else if (!isOperational) {
|
54 |
+
status = 'degraded';
|
55 |
+
message = 'Service status unknown';
|
56 |
+
}
|
57 |
+
|
58 |
+
// If status page check fails, fallback to endpoint check
|
59 |
+
if (!statusPageResponse.ok) {
|
60 |
+
const endpointStatus = await this.checkEndpoint('https://status.cohere.com/');
|
61 |
+
const apiEndpoint = 'https://api.cohere.ai/v1/models';
|
62 |
+
const apiStatus = await this.checkEndpoint(apiEndpoint);
|
63 |
+
|
64 |
+
return {
|
65 |
+
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
|
66 |
+
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
|
67 |
+
incidents: ['Note: Limited status information due to CORS restrictions'],
|
68 |
+
};
|
69 |
+
}
|
70 |
+
|
71 |
+
return {
|
72 |
+
status,
|
73 |
+
message,
|
74 |
+
incidents,
|
75 |
+
};
|
76 |
+
} catch (error) {
|
77 |
+
console.error('Error checking Cohere status:', error);
|
78 |
+
|
79 |
+
// Fallback to basic endpoint check
|
80 |
+
const endpointStatus = await this.checkEndpoint('https://status.cohere.com/');
|
81 |
+
const apiEndpoint = 'https://api.cohere.ai/v1/models';
|
82 |
+
const apiStatus = await this.checkEndpoint(apiEndpoint);
|
83 |
+
|
84 |
+
return {
|
85 |
+
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
|
86 |
+
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
|
87 |
+
incidents: ['Note: Limited status information due to CORS restrictions'],
|
88 |
+
};
|
89 |
+
}
|
90 |
+
}
|
91 |
+
}
|
app/components/@settings/tabs/providers/service-status/providers/deepseek.ts
ADDED
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
|
2 |
+
import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
|
3 |
+
|
4 |
+
export class DeepseekStatusChecker extends BaseProviderChecker {
|
5 |
+
async checkStatus(): Promise<StatusCheckResult> {
|
6 |
+
try {
|
7 |
+
/*
|
8 |
+
* Check status page - Note: Deepseek doesn't have a public status page yet
|
9 |
+
* so we'll check their API endpoint directly
|
10 |
+
*/
|
11 |
+
const apiEndpoint = 'https://api.deepseek.com/v1/models';
|
12 |
+
const apiStatus = await this.checkEndpoint(apiEndpoint);
|
13 |
+
|
14 |
+
// Check their website as a secondary indicator
|
15 |
+
const websiteStatus = await this.checkEndpoint('https://deepseek.com');
|
16 |
+
|
17 |
+
let status: StatusCheckResult['status'] = 'operational';
|
18 |
+
let message = 'All systems operational';
|
19 |
+
|
20 |
+
if (apiStatus !== 'reachable' || websiteStatus !== 'reachable') {
|
21 |
+
status = apiStatus !== 'reachable' ? 'down' : 'degraded';
|
22 |
+
message = apiStatus !== 'reachable' ? 'API appears to be down' : 'Service may be experiencing issues';
|
23 |
+
}
|
24 |
+
|
25 |
+
return {
|
26 |
+
status,
|
27 |
+
message,
|
28 |
+
incidents: [], // No public incident tracking available yet
|
29 |
+
};
|
30 |
+
} catch (error) {
|
31 |
+
console.error('Error checking Deepseek status:', error);
|
32 |
+
|
33 |
+
return {
|
34 |
+
status: 'degraded',
|
35 |
+
message: 'Unable to determine service status',
|
36 |
+
incidents: ['Note: Limited status information available'],
|
37 |
+
};
|
38 |
+
}
|
39 |
+
}
|
40 |
+
}
|
app/components/@settings/tabs/providers/service-status/providers/google.ts
ADDED
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
|
2 |
+
import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
|
3 |
+
|
4 |
+
export class GoogleStatusChecker extends BaseProviderChecker {
|
5 |
+
async checkStatus(): Promise<StatusCheckResult> {
|
6 |
+
try {
|
7 |
+
// Check status page
|
8 |
+
const statusPageResponse = await fetch('https://status.cloud.google.com/');
|
9 |
+
const text = await statusPageResponse.text();
|
10 |
+
|
11 |
+
// Check for Vertex AI and general cloud status
|
12 |
+
const hasVertexAIIssues =
|
13 |
+
text.includes('Vertex AI') &&
|
14 |
+
(text.includes('Incident') ||
|
15 |
+
text.includes('Disruption') ||
|
16 |
+
text.includes('Outage') ||
|
17 |
+
text.includes('degraded'));
|
18 |
+
|
19 |
+
const hasGeneralIssues = text.includes('Major Incidents') || text.includes('Service Disruption');
|
20 |
+
|
21 |
+
// Extract incidents
|
22 |
+
const incidents: string[] = [];
|
23 |
+
const incidentMatches = text.matchAll(/(\d{4}-\d{2}-\d{2})\s+(.*?)\s+Impact:(.*?)(?=\n|$)/g);
|
24 |
+
|
25 |
+
for (const match of incidentMatches) {
|
26 |
+
const [, date, title, impact] = match;
|
27 |
+
|
28 |
+
if (title.includes('Vertex AI') || title.includes('Cloud')) {
|
29 |
+
incidents.push(`${date}: ${title.trim()} - Impact: ${impact.trim()}`);
|
30 |
+
}
|
31 |
+
}
|
32 |
+
|
33 |
+
let status: StatusCheckResult['status'] = 'operational';
|
34 |
+
let message = 'All services operational';
|
35 |
+
|
36 |
+
if (hasVertexAIIssues) {
|
37 |
+
status = 'degraded';
|
38 |
+
message = 'Vertex AI service issues reported';
|
39 |
+
} else if (hasGeneralIssues) {
|
40 |
+
status = 'degraded';
|
41 |
+
message = 'Google Cloud experiencing issues';
|
42 |
+
}
|
43 |
+
|
44 |
+
// If status page check fails, fallback to endpoint check
|
45 |
+
if (!statusPageResponse.ok) {
|
46 |
+
const endpointStatus = await this.checkEndpoint('https://status.cloud.google.com/');
|
47 |
+
const apiEndpoint = 'https://generativelanguage.googleapis.com/v1/models';
|
48 |
+
const apiStatus = await this.checkEndpoint(apiEndpoint);
|
49 |
+
|
50 |
+
return {
|
51 |
+
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
|
52 |
+
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
|
53 |
+
incidents: ['Note: Limited status information due to CORS restrictions'],
|
54 |
+
};
|
55 |
+
}
|
56 |
+
|
57 |
+
return {
|
58 |
+
status,
|
59 |
+
message,
|
60 |
+
incidents: incidents.slice(0, 5),
|
61 |
+
};
|
62 |
+
} catch (error) {
|
63 |
+
console.error('Error checking Google status:', error);
|
64 |
+
|
65 |
+
// Fallback to basic endpoint check
|
66 |
+
const endpointStatus = await this.checkEndpoint('https://status.cloud.google.com/');
|
67 |
+
const apiEndpoint = 'https://generativelanguage.googleapis.com/v1/models';
|
68 |
+
const apiStatus = await this.checkEndpoint(apiEndpoint);
|
69 |
+
|
70 |
+
return {
|
71 |
+
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
|
72 |
+
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
|
73 |
+
incidents: ['Note: Limited status information due to CORS restrictions'],
|
74 |
+
};
|
75 |
+
}
|
76 |
+
}
|
77 |
+
}
|
app/components/@settings/tabs/providers/service-status/providers/groq.ts
ADDED
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
|
2 |
+
import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
|
3 |
+
|
4 |
+
export class GroqStatusChecker extends BaseProviderChecker {
|
5 |
+
async checkStatus(): Promise<StatusCheckResult> {
|
6 |
+
try {
|
7 |
+
// Check status page
|
8 |
+
const statusPageResponse = await fetch('https://groqstatus.com/');
|
9 |
+
const text = await statusPageResponse.text();
|
10 |
+
|
11 |
+
const isOperational = text.includes('All Systems Operational');
|
12 |
+
const hasIncidents = text.includes('Active Incidents');
|
13 |
+
const hasDegradation = text.includes('Degraded Performance');
|
14 |
+
const hasOutage = text.includes('Service Outage');
|
15 |
+
|
16 |
+
// Extract incidents
|
17 |
+
const incidents: string[] = [];
|
18 |
+
const incidentMatches = text.matchAll(/(\d{4}-\d{2}-\d{2})\s+(.*?)\s+Status:(.*?)(?=\n|$)/g);
|
19 |
+
|
20 |
+
for (const match of incidentMatches) {
|
21 |
+
const [, date, title, status] = match;
|
22 |
+
incidents.push(`${date}: ${title.trim()} - ${status.trim()}`);
|
23 |
+
}
|
24 |
+
|
25 |
+
let status: StatusCheckResult['status'] = 'operational';
|
26 |
+
let message = 'All systems operational';
|
27 |
+
|
28 |
+
if (hasOutage) {
|
29 |
+
status = 'down';
|
30 |
+
message = 'Service outage detected';
|
31 |
+
} else if (hasDegradation || hasIncidents) {
|
32 |
+
status = 'degraded';
|
33 |
+
message = 'Service experiencing issues';
|
34 |
+
} else if (!isOperational) {
|
35 |
+
status = 'degraded';
|
36 |
+
message = 'Service status unknown';
|
37 |
+
}
|
38 |
+
|
39 |
+
// If status page check fails, fallback to endpoint check
|
40 |
+
if (!statusPageResponse.ok) {
|
41 |
+
const endpointStatus = await this.checkEndpoint('https://groqstatus.com/');
|
42 |
+
const apiEndpoint = 'https://api.groq.com/v1/models';
|
43 |
+
const apiStatus = await this.checkEndpoint(apiEndpoint);
|
44 |
+
|
45 |
+
return {
|
46 |
+
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
|
47 |
+
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
|
48 |
+
incidents: ['Note: Limited status information due to CORS restrictions'],
|
49 |
+
};
|
50 |
+
}
|
51 |
+
|
52 |
+
return {
|
53 |
+
status,
|
54 |
+
message,
|
55 |
+
incidents: incidents.slice(0, 5),
|
56 |
+
};
|
57 |
+
} catch (error) {
|
58 |
+
console.error('Error checking Groq status:', error);
|
59 |
+
|
60 |
+
// Fallback to basic endpoint check
|
61 |
+
const endpointStatus = await this.checkEndpoint('https://groqstatus.com/');
|
62 |
+
const apiEndpoint = 'https://api.groq.com/v1/models';
|
63 |
+
const apiStatus = await this.checkEndpoint(apiEndpoint);
|
64 |
+
|
65 |
+
return {
|
66 |
+
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
|
67 |
+
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
|
68 |
+
incidents: ['Note: Limited status information due to CORS restrictions'],
|
69 |
+
};
|
70 |
+
}
|
71 |
+
}
|
72 |
+
}
|
app/components/@settings/tabs/providers/service-status/providers/huggingface.ts
ADDED
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
|
2 |
+
import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
|
3 |
+
|
4 |
+
export class HuggingFaceStatusChecker extends BaseProviderChecker {
|
5 |
+
async checkStatus(): Promise<StatusCheckResult> {
|
6 |
+
try {
|
7 |
+
// Check status page
|
8 |
+
const statusPageResponse = await fetch('https://status.huggingface.co/');
|
9 |
+
const text = await statusPageResponse.text();
|
10 |
+
|
11 |
+
// Check for "All services are online" message
|
12 |
+
const allServicesOnline = text.includes('All services are online');
|
13 |
+
|
14 |
+
// Get last update time
|
15 |
+
const lastUpdateMatch = text.match(/Last updated on (.*?)(EST|PST|GMT)/);
|
16 |
+
const lastUpdate = lastUpdateMatch ? `${lastUpdateMatch[1]}${lastUpdateMatch[2]}` : '';
|
17 |
+
|
18 |
+
// Check individual services and their uptime percentages
|
19 |
+
const services = {
|
20 |
+
'Huggingface Hub': {
|
21 |
+
operational: text.includes('Huggingface Hub') && text.includes('Operational'),
|
22 |
+
uptime: text.match(/Huggingface Hub[\s\S]*?(\d+\.\d+)%\s*uptime/)?.[1],
|
23 |
+
},
|
24 |
+
'Git Hosting and Serving': {
|
25 |
+
operational: text.includes('Git Hosting and Serving') && text.includes('Operational'),
|
26 |
+
uptime: text.match(/Git Hosting and Serving[\s\S]*?(\d+\.\d+)%\s*uptime/)?.[1],
|
27 |
+
},
|
28 |
+
'Inference API': {
|
29 |
+
operational: text.includes('Inference API') && text.includes('Operational'),
|
30 |
+
uptime: text.match(/Inference API[\s\S]*?(\d+\.\d+)%\s*uptime/)?.[1],
|
31 |
+
},
|
32 |
+
'HF Endpoints': {
|
33 |
+
operational: text.includes('HF Endpoints') && text.includes('Operational'),
|
34 |
+
uptime: text.match(/HF Endpoints[\s\S]*?(\d+\.\d+)%\s*uptime/)?.[1],
|
35 |
+
},
|
36 |
+
Spaces: {
|
37 |
+
operational: text.includes('Spaces') && text.includes('Operational'),
|
38 |
+
uptime: text.match(/Spaces[\s\S]*?(\d+\.\d+)%\s*uptime/)?.[1],
|
39 |
+
},
|
40 |
+
};
|
41 |
+
|
42 |
+
// Create service status messages with uptime
|
43 |
+
const serviceMessages = Object.entries(services).map(([name, info]) => {
|
44 |
+
if (info.uptime) {
|
45 |
+
return `${name}: ${info.uptime}% uptime`;
|
46 |
+
}
|
47 |
+
|
48 |
+
return `${name}: ${info.operational ? 'Operational' : 'Issues detected'}`;
|
49 |
+
});
|
50 |
+
|
51 |
+
// Determine overall status
|
52 |
+
let status: StatusCheckResult['status'] = 'operational';
|
53 |
+
let message = allServicesOnline
|
54 |
+
? `All services are online (Last updated on ${lastUpdate})`
|
55 |
+
: 'Checking individual services';
|
56 |
+
|
57 |
+
// Only mark as degraded if we explicitly detect issues
|
58 |
+
const hasIssues = Object.values(services).some((service) => !service.operational);
|
59 |
+
|
60 |
+
if (hasIssues) {
|
61 |
+
status = 'degraded';
|
62 |
+
message = `Service issues detected (Last updated on ${lastUpdate})`;
|
63 |
+
}
|
64 |
+
|
65 |
+
// If status page check fails, fallback to endpoint check
|
66 |
+
if (!statusPageResponse.ok) {
|
67 |
+
const endpointStatus = await this.checkEndpoint('https://status.huggingface.co/');
|
68 |
+
const apiEndpoint = 'https://api-inference.huggingface.co/models';
|
69 |
+
const apiStatus = await this.checkEndpoint(apiEndpoint);
|
70 |
+
|
71 |
+
return {
|
72 |
+
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
|
73 |
+
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
|
74 |
+
incidents: ['Note: Limited status information due to CORS restrictions'],
|
75 |
+
};
|
76 |
+
}
|
77 |
+
|
78 |
+
return {
|
79 |
+
status,
|
80 |
+
message,
|
81 |
+
incidents: serviceMessages,
|
82 |
+
};
|
83 |
+
} catch (error) {
|
84 |
+
console.error('Error checking HuggingFace status:', error);
|
85 |
+
|
86 |
+
// Fallback to basic endpoint check
|
87 |
+
const endpointStatus = await this.checkEndpoint('https://status.huggingface.co/');
|
88 |
+
const apiEndpoint = 'https://api-inference.huggingface.co/models';
|
89 |
+
const apiStatus = await this.checkEndpoint(apiEndpoint);
|
90 |
+
|
91 |
+
return {
|
92 |
+
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
|
93 |
+
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
|
94 |
+
incidents: ['Note: Limited status information due to CORS restrictions'],
|
95 |
+
};
|
96 |
+
}
|
97 |
+
}
|
98 |
+
}
|
app/components/@settings/tabs/providers/service-status/providers/hyperbolic.ts
ADDED
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
|
2 |
+
import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
|
3 |
+
|
4 |
+
export class HyperbolicStatusChecker extends BaseProviderChecker {
|
5 |
+
async checkStatus(): Promise<StatusCheckResult> {
|
6 |
+
try {
|
7 |
+
/*
|
8 |
+
* Check API endpoint directly since Hyperbolic is a newer provider
|
9 |
+
* and may not have a public status page yet
|
10 |
+
*/
|
11 |
+
const apiEndpoint = 'https://api.hyperbolic.ai/v1/models';
|
12 |
+
const apiStatus = await this.checkEndpoint(apiEndpoint);
|
13 |
+
|
14 |
+
// Check their website as a secondary indicator
|
15 |
+
const websiteStatus = await this.checkEndpoint('https://hyperbolic.ai');
|
16 |
+
|
17 |
+
let status: StatusCheckResult['status'] = 'operational';
|
18 |
+
let message = 'All systems operational';
|
19 |
+
|
20 |
+
if (apiStatus !== 'reachable' || websiteStatus !== 'reachable') {
|
21 |
+
status = apiStatus !== 'reachable' ? 'down' : 'degraded';
|
22 |
+
message = apiStatus !== 'reachable' ? 'API appears to be down' : 'Service may be experiencing issues';
|
23 |
+
}
|
24 |
+
|
25 |
+
return {
|
26 |
+
status,
|
27 |
+
message,
|
28 |
+
incidents: [], // No public incident tracking available yet
|
29 |
+
};
|
30 |
+
} catch (error) {
|
31 |
+
console.error('Error checking Hyperbolic status:', error);
|
32 |
+
|
33 |
+
return {
|
34 |
+
status: 'degraded',
|
35 |
+
message: 'Unable to determine service status',
|
36 |
+
incidents: ['Note: Limited status information available'],
|
37 |
+
};
|
38 |
+
}
|
39 |
+
}
|
40 |
+
}
|