├── .github
└── workflows
│ └── build.yml
├── .gitignore
├── DEVELOPMENT.md
├── LICENSE
├── README.md
├── assets
├── NebulaGraph-Desktop
│ └── docker-compose.yml
├── app_icon.png
├── portal.png
├── screenshot.png
└── studio.png
├── electron
├── main
│ ├── main.ts
│ ├── preload.ts
│ ├── services
│ │ ├── docker-checker.ts
│ │ └── docker-service.ts
│ ├── test-docker.ts
│ └── utils
│ │ └── logger.ts
├── preload
│ ├── index.ts
│ └── preload.ts
└── tsconfig.json
├── package-lock.json
├── package.json
├── renderer
├── .gitignore
├── README.md
├── eslint.config.mjs
├── next.config.js
├── next.config.ts
├── package-lock.json
├── package.json
├── postcss.config.mjs
├── public
│ ├── dashboard-light.png
│ ├── docker-desktop.png
│ ├── docker-running.png
│ ├── docker-verify.png
│ ├── file.svg
│ ├── globe.svg
│ ├── nebula-logo.png
│ ├── nebula_arch.mp4
│ ├── next.svg
│ ├── vercel.svg
│ └── window.svg
├── src
│ ├── app
│ │ ├── favicon.ico
│ │ ├── globals.css
│ │ ├── layout.tsx
│ │ ├── metadata.ts
│ │ └── page.tsx
│ ├── components
│ │ ├── blocks
│ │ │ ├── feature-section.tsx
│ │ │ └── hero-section-dark.tsx
│ │ ├── features
│ │ │ ├── docker
│ │ │ │ └── docker-setup-guide.tsx
│ │ │ └── services
│ │ │ │ ├── nebula-service-card.tsx
│ │ │ │ ├── service-logs.tsx
│ │ │ │ ├── service-metrics.tsx
│ │ │ │ ├── services-grid.tsx
│ │ │ │ └── services-skeleton.tsx
│ │ ├── theme-provider.tsx
│ │ └── ui
│ │ │ ├── button.tsx
│ │ │ ├── spinner.tsx
│ │ │ └── theme-toggle.tsx
│ ├── hooks
│ │ └── use-click-away.ts
│ ├── lib
│ │ └── utils.ts
│ └── types
│ │ └── docker.ts
├── tailwind.config.ts
└── tsconfig.json
└── scripts
└── prepare-images.js
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | name: Build and Release
2 |
3 | on:
4 | push:
5 | tags:
6 | - 'v*'
7 | workflow_dispatch:
8 | inputs:
9 | draft:
10 | description: 'Create draft release'
11 | type: boolean
12 | default: true
13 |
14 | permissions:
15 | contents: write
16 | packages: write
17 | issues: write
18 |
19 | env:
20 | NODE_VERSION: '18'
21 |
22 | jobs:
23 | prepare-images:
24 | permissions:
25 | contents: read
26 | strategy:
27 | matrix:
28 | include:
29 | - arch: amd64
30 | runner: ubuntu-latest
31 | - arch: arm64
32 | runner: ubuntu-24.04-arm
33 | runs-on: ${{ matrix.runner }}
34 | steps:
35 | - name: Checkout code
36 | uses: actions/checkout@v4
37 |
38 | - name: Set up Node.js
39 | uses: actions/setup-node@v4
40 | with:
41 | node-version: ${{ env.NODE_VERSION }}
42 | cache: 'npm'
43 |
44 | - name: Install dependencies
45 | run: npm ci
46 |
47 | - name: Set up Docker
48 | run: |
49 | docker info
50 | docker version
51 |
52 | - name: Pull and save Docker images
53 | env:
54 | ARCH: ${{ matrix.arch }}
55 | run: |
56 | # Use native architecture to pull and save images
57 | npm run prepare-images
58 |
59 | - name: Verify images and manifest
60 | run: |
61 | if [ ! -f "assets/NebulaGraph-Desktop/images/manifest.json" ]; then
62 | echo "❌ Manifest file not found"
63 | exit 1
64 | fi
65 | for img in graphd metad storaged studio; do
66 | if [ ! -f "assets/NebulaGraph-Desktop/images/${img}.tar" ]; then
67 | echo "❌ Image file ${img}.tar not found"
68 | exit 1
69 | fi
70 | done
71 | echo "✅ All image files verified"
72 |
73 | - name: Upload Docker images
74 | uses: actions/upload-artifact@v4
75 | with:
76 | name: nebula-docker-images-${{ matrix.arch }}
77 | path: assets/NebulaGraph-Desktop/images/
78 | compression-level: 9
79 | retention-days: 1
80 |
81 | build:
82 | needs: prepare-images
83 | permissions:
84 | contents: write
85 | strategy:
86 | fail-fast: false
87 | matrix:
88 | include:
89 | - os: macos-latest
90 | artifact_name: "*.dmg"
91 | platform: mac-arm64
92 | arch: arm64
93 | runner: macos-latest
94 | # TODO: Enable macOS x64 build, now does not work
95 | # - os: macos-latest
96 | # artifact_name: "*.dmg"
97 | # platform: mac-x64
98 | # arch: amd64
99 | # runner: macos-13
100 | - os: windows-latest
101 | artifact_name: "*.exe"
102 | platform: win-x64
103 | arch: amd64
104 | runner: windows-latest
105 |
106 | runs-on: ${{ matrix.runner }}
107 |
108 | steps:
109 | - name: Checkout code
110 | uses: actions/checkout@v4
111 |
112 | - name: Set up Node.js
113 | uses: actions/setup-node@v4
114 | with:
115 | node-version: ${{ env.NODE_VERSION }}
116 | cache: 'npm'
117 |
118 | - name: Install dependencies
119 | run: |
120 | # Install root project dependencies
121 | npm ci
122 | npm install -g typescript cross-env
123 |
124 | # Install renderer dependencies
125 | cd renderer
126 | npm ci
127 | cd ..
128 |
129 | # Verify installations
130 | echo "TypeScript version:"
131 | tsc --version
132 | echo "Node version:"
133 | node --version
134 | echo "NPM version:"
135 | npm --version
136 |
137 | - name: Download Docker images
138 | uses: actions/download-artifact@v4
139 | with:
140 | name: nebula-docker-images-${{ matrix.arch }}
141 | path: assets/NebulaGraph-Desktop/images/
142 |
143 | - name: Verify downloaded images
144 | shell: bash
145 | run: |
146 | echo "📂 Checking images directory structure..."
147 | ls -la assets/NebulaGraph-Desktop/images/
148 |
149 | if [ ! -f "assets/NebulaGraph-Desktop/images/manifest.json" ]; then
150 | echo "❌ Manifest file not found after download"
151 | exit 1
152 | fi
153 |
154 | # Verify each required image file
155 | for img in graphd metad storaged studio console; do
156 | if [ ! -f "assets/NebulaGraph-Desktop/images/${img}.tar" ]; then
157 | echo "❌ Image file ${img}.tar not found"
158 | exit 1
159 | fi
160 | # Verify file is not empty
161 | if [ ! -s "assets/NebulaGraph-Desktop/images/${img}.tar" ]; then
162 | echo "❌ Image file ${img}.tar is empty"
163 | exit 1
164 | fi
165 | echo "✅ Verified ${img}.tar"
166 | done
167 |
168 | # Ensure files are readable
169 | chmod 644 assets/NebulaGraph-Desktop/images/*.tar
170 | chmod 644 assets/NebulaGraph-Desktop/images/manifest.json
171 |
172 | echo "✅ All image files verified and properly set up"
173 |
174 | - name: Build Electron app
175 | run: |
176 | # Build Electron part
177 | npm run build:electron
178 |
179 | # Build Next.js part
180 | cd renderer && npm run build
181 | env:
182 | NODE_ENV: production
183 |
184 | - name: Create distribution (Windows)
185 | if: matrix.os == 'windows-latest'
186 | shell: pwsh
187 | run: |
188 | if ($env:matrix_platform -eq "win-x64") {
189 | $env:ELECTRON_ARCH = "x64"
190 | }
191 | npm run dist
192 | env:
193 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
194 | CSC_IDENTITY_AUTO_DISCOVERY: false
195 | matrix_platform: ${{ matrix.platform }}
196 |
197 | - name: Create distribution (macOS)
198 | if: startsWith(matrix.os, 'macos')
199 | shell: bash
200 | run: |
201 | # Set architecture
202 | if [ "$matrix_platform" = "mac-arm64" ]; then
203 | export ELECTRON_ARCH="arm64"
204 | elif [ "$matrix_platform" = "mac-x64" ]; then
205 | export ELECTRON_ARCH="x64"
206 | fi
207 |
208 | # Clean any existing DMG files
209 | rm -rf release/*.dmg
210 |
211 | # Create build directory with appropriate permissions
212 | sudo mkdir -p /tmp/build
213 | sudo chmod 777 /tmp/build
214 |
215 | # Set environment variables for electron-builder
216 | export DEBUG=electron-builder
217 | export ELECTRON_BUILDER_TMP=/tmp/build
218 |
219 | # Run the build with verbose logging
220 | npm run dist --verbose
221 |
222 | if [ ! -f "release/"*.dmg ]; then
223 | echo "❌ DMG file not created"
224 | echo "📂 Release directory contents:"
225 | ls -la release/
226 | exit 1
227 | fi
228 | echo "✅ DMG creation successful"
229 | env:
230 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
231 | CSC_IDENTITY_AUTO_DISCOVERY: false
232 | matrix_platform: ${{ matrix.platform }}
233 |
234 | - name: Upload artifacts
235 | uses: actions/upload-artifact@v4
236 | with:
237 | name: nebula-desktop-${{ matrix.platform }}
238 | path: |
239 | release/${{ matrix.artifact_name }}
240 | assets/NebulaGraph-Desktop/images/manifest.json
241 | compression-level: 9
242 | retention-days: 5
243 |
244 | release:
245 | needs: build
246 | permissions:
247 | contents: write
248 | runs-on: ubuntu-latest
249 | if: startsWith(github.ref, 'refs/tags/') || github.event.inputs.draft
250 |
251 | steps:
252 | - name: Download all artifacts
253 | uses: actions/download-artifact@v4
254 | with:
255 | path: artifacts
256 | pattern: nebula-desktop-*
257 | merge-multiple: true
258 |
259 | - name: Display structure of downloaded files
260 | run: ls -R artifacts/
261 |
262 | - name: Rename files to user-friendly names
263 | run: |
264 | # Create release directory if it doesn't exist
265 | mkdir -p artifacts/release
266 | # Rename Windows exe files
267 | for f in artifacts/release/*.exe; do
268 | if [ -f "$f" ]; then
269 | mv "$f" "${f%.exe}-windows.exe"
270 | fi
271 | done
272 | # Rename macOS dmg files
273 | for f in artifacts/release/*.dmg; do
274 | if [ -f "$f" ]; then
275 | mv "$f" "${f%.dmg}-macOS.dmg"
276 | fi
277 | done
278 |
279 | - name: Set release tag
280 | id: tag
281 | run: |
282 | if [[ ${{ startsWith(github.ref, 'refs/tags/') }} == 'true' ]]; then
283 | echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
284 | else
285 | echo "tag=v0.1.0-dev" >> $GITHUB_OUTPUT
286 | fi
287 |
288 | - name: Create Release
289 | uses: softprops/action-gh-release@v1
290 | with:
291 | files: |
292 | artifacts/release/*-windows.exe
293 | artifacts/release/*-macOS.dmg
294 | tag_name: ${{ steps.tag.outputs.tag }}
295 | draft: ${{ github.event.inputs.draft || true }}
296 | prerelease: ${{ !startsWith(github.ref, 'refs/tags/') }}
297 | generate_release_notes: true
298 | fail_on_unmatched_files: true
299 | env:
300 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
301 |
302 | cleanup-on-failure:
303 | needs: [prepare-images, build]
304 | if: failure()
305 | runs-on: ubuntu-latest
306 | steps:
307 | - name: Delete all artifacts
308 | uses: geekyeggo/delete-artifact@v2
309 | with:
310 | name: |
311 | nebula-docker-images-*
312 | nebula-desktop-*
313 | failOnError: false
314 |
315 | cleanup-success:
316 | needs: release
317 | if: success()
318 | runs-on: ubuntu-latest
319 | steps:
320 | - name: Delete Docker image artifacts
321 | uses: geekyeggo/delete-artifact@v2
322 | with:
323 | name: nebula-docker-images-*
324 | failOnError: false
325 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Dependencies
2 | node_modules/
3 | /.pnp
4 | .pnp.js
5 |
6 | # Testing
7 | /coverage
8 |
9 | # Next.js
10 | /renderer/.next/
11 | /renderer/out/
12 |
13 | # Electron
14 | /dist/
15 | /release/
16 |
17 | # Production
18 | /build
19 |
20 | # Debug
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
25 | # Local env files
26 | .env*.local
27 | .env
28 |
29 | # IDE
30 | .vscode/*
31 | !.vscode/extensions.json
32 | !.vscode/settings.json
33 | .idea/
34 | *.sublime-workspace
35 | *.sublime-project
36 |
37 | # OS
38 | .DS_Store
39 | Thumbs.db
40 |
41 | # Optional npm cache directory
42 | .npm
43 |
44 | # Optional eslint cache
45 | .eslintcache
46 |
47 | # Misc
48 | *.log
49 | .vercel
50 | .next
51 | *.pem
52 |
53 | # NebulaGraph Docker
54 |
55 | assets/NebulaGraph-Desktop/data/
56 | assets/NebulaGraph-Desktop/logs/
57 | assets/NebulaGraph-Desktop/images/
--------------------------------------------------------------------------------
/DEVELOPMENT.md:
--------------------------------------------------------------------------------
1 | # NebulaGraph Desktop Development Progress
2 |
3 | ## Project Overview
4 |
5 | - **Project Name**: NebulaGraph Desktop
6 | - **Description**: Cross-platform desktop application to run NebulaGraph using Docker, featuring a modern Electron app with Next.js (TypeScript) frontend and Shadcn UI components.
7 | - **Target Audience**: Developers and users wanting to run NebulaGraph without Linux expertise or complex setup.
8 | - **Primary Goal**: Deliver an accessible, visually appealing desktop app for running NebulaGraph on Windows and macOS.
9 |
10 | ## Current State (Milestone 1)
11 |
12 | ### ✅ Completed Features
13 |
14 | #### Portal
15 |
16 | - Modern, responsive landing page with hero section
17 | - Smooth transition to dashboard
18 | - Professional branding and messaging
19 | - Video/animation integration for architecture visualization
20 |
21 | #### Dashboard/Console
22 |
23 | - Real-time service status monitoring
24 | - Individual service controls (start/stop/restart)
25 | - Health status indicators
26 | - Resource usage metrics (CPU, Memory, Network)
27 | - Dark/Light theme support
28 | - Docker system status integration
29 | - Basic logging functionality
30 |
31 | #### Core Functionality
32 |
33 | - Docker service integration
34 | - Health check implementation
35 | - Service status detection
36 | - Basic error handling
37 | - Cross-service dependency management
38 |
39 | ### 🔄 In Progress
40 |
41 | #### UI/UX Improvements
42 |
43 | - [x] Enhanced logging interface
44 | - Better log formatting
45 | - Log level filtering
46 | - Timestamp localization
47 | - Search/filter capabilities
48 | - [x] Service card interactions refinement
49 | - [x] Loading states and transitions
50 | - [x] Error message presentation
51 |
52 | #### Technical Debt
53 |
54 | - [x] Docker compose path handling best practices
55 | - [x] Windows compatibility for Docker commands
56 | - [x] Build script for Docker image management
57 | - [x] Service startup sequence optimization
58 | - [ ] Ensure won't pull image before docker images being loaded
59 |
60 | ## Technical Implementation
61 |
62 | ### Docker Configuration
63 |
64 | #### Images Used
65 |
66 | - vesoft/nebula-graphd:v3.8.0
67 | - vesoft/nebula-metad:v3.8.0
68 | - vesoft/nebula-storaged:v3.8.0
69 | - vesoft/nebula-graph-studio:v3.10.0
70 | - vesoft/nebula-console:nightly
71 |
72 | ### Architecture
73 |
74 | #### Current Implementation
75 |
76 | - Electron + Next.js for cross-platform support
77 | - Docker for service management
78 | - TypeScript for type safety
79 | - Tailwind CSS + Shadcn for styling
80 | - React for UI components
81 |
82 | #### Key Components
83 |
84 | - Main Process (Electron)
85 | - Docker service management
86 | - IPC communication
87 | - System tray integration
88 | - Renderer Process (Next.js)
89 | - Modern UI with Shadcn components
90 | - Real-time status updates
91 | - Service controls
92 |
93 | ## Next Steps (Milestone 2)
94 |
95 | ### 📦 Packaging & Distribution
96 |
97 | - [x] Create build pipeline for all platforms
98 | - [x] Embed required Docker images
99 | - Save images as tar files
100 | - Implement loading mechanism
101 | - [x] Implement image loading/verification on first start
102 | - [ ] Add auto-update mechanism
103 | - [ ] Create installers for all platforms
104 | - Windows (NSIS installer)
105 | - macOS (DMG package)
106 | - Linux (AppImage)
107 |
108 | ### 🛠 Infrastructure
109 |
110 | - [ ] Improve error handling and recovery
111 | - [ ] Add telemetry (opt-in)
112 | - [ ] Implement crash reporting
113 | - [ ] Add system requirements checker
114 | - [ ] Improve Docker Desktop detection and integration
115 |
116 | ### 💻 Developer Experience
117 |
118 | - [ ] Add development documentation
119 | - [ ] Create contribution guidelines
120 | - [x] Set up CI/CD pipeline
121 | - [ ] Add test coverage
122 | - Unit tests for core functionality
123 | - Integration tests for Docker operations
124 | - E2E tests for UI flows
125 |
126 | ### 🎨 UI/UX Enhancements
127 |
128 | - [ ] Add onboarding experience
129 | - [ ] Implement guided first-time setup
130 | - [ ] Add tooltips and help documentation
131 | - [ ] Create advanced configuration UI
132 | - [ ] Improve service logs presentation
133 |
134 | ## Known Issues
135 |
136 | 1. Logging UI needs improvement for better readability and interaction
137 | 2. Docker commands may need adjustment for Windows compatibility
138 | 3. Docker compose path handling could be more robust
139 | 4. Service startup sequence could be optimized
140 | 5. Need proper handling of Docker image embedding and loading
141 |
142 | ## Future Considerations
143 |
144 | - Consider adding a proper logging framework
145 | - Evaluate alternative Docker management approaches
146 | - Plan for plugin architecture
147 | - Consider adding a database for settings/state persistence
148 | - Explore WSL 2 integration improvements for Windows
149 |
150 | ## Development Setup
151 |
152 | ```bash
153 | # Clone the repository
154 | git clone https://github.com/wey-gu/nebulagraph-desktop.git
155 |
156 | # Install dependencies
157 | npm install
158 |
159 | # Start development
160 | npm run dev
161 | ```
162 |
163 | ## Building
164 |
165 | ```bash
166 | # Build for production
167 | npm run build
168 |
169 | # Create distribution
170 | npm run dist
171 | ```
172 |
173 | ## Testing
174 |
175 | ```bash
176 | # Run Docker service tests
177 | npm run test:docker
178 |
179 | # Run UI tests (to be implemented)
180 | npm run test:ui
181 | ```
182 |
183 | ## Contributing
184 |
185 | We welcome contributions!
186 |
187 | Please check our issues page for current tasks or suggest new improvements.
188 |
189 | ## License
190 |
191 | Apache License 2.0
192 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | APPENDIX: How to apply the Apache License to your work.
179 |
180 | To apply the Apache License to your work, attach the following
181 | boilerplate notice, with the fields enclosed by brackets "[]"
182 | replaced with your own identifying information. (Don't include
183 | the brackets!) The text should be enclosed in the appropriate
184 | comment syntax for the file format. We also recommend that a
185 | file or class name and description of purpose be included on the
186 | same "printed page" as the copyright notice for easier
187 | identification within third-party archives.
188 |
189 | Copyright [yyyy] [name of copyright owner]
190 |
191 | Licensed under the Apache License, Version 2.0 (the "License");
192 | you may not use this file except in compliance with the License.
193 | You may obtain a copy of the License at
194 |
195 | http://www.apache.org/licenses/LICENSE-2.0
196 |
197 | Unless required by applicable law or agreed to in writing, software
198 | distributed under the License is distributed on an "AS IS" BASIS,
199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 | See the License for the specific language governing permissions and
201 | limitations under the License.
202 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # NebulaGraph Desktop
2 |
3 |
4 | [](https://github.com/wey-gu/nebulagraph-desktop/releases) [](https://github.com/wey-gu/NebulaGraph-Desktop/actions/workflows/build.yml) [](https://github.com/wey-gu/NebulaGraph-Desktop/blob/main/LICENSE)
5 |
6 |
7 | [](https://github.com/wey-gu/nebulagraph-desktop/releases) [](https://github.com/wey-gu/nebulagraph-desktop/releases)
8 |
9 |
10 | [](https://github.com/vesoft-inc/nebula) [](https://www.electronjs.org/) [](https://www.typescriptlang.org/) [](https://www.docker.com/get-started)
11 |
12 | A modern, cross-platform desktop version of NebulaGraph.
13 |
14 | 
15 |
16 | ## Features
17 |
18 | - 🚀 Modern, intuitive interface for managing NebulaGraph services
19 | - 🔄 Real-time service monitoring and health checks
20 | - 📊 Resource usage metrics (CPU, Memory, Network)
21 | - 🔧 Individual service controls
22 | - 📝 Service logs viewer
23 | - 🎨 Beautiful, responsive UI
24 | - 🌐 Offline mode support (no Docker Hub image pull needed)
25 |
26 | ## Quick Start
27 |
28 | 1. Install [Docker](https://www.docker.com/get-started) on your system
29 |
30 | 2. Download NebulaGraph Desktop from the [releases page](https://github.com/wey-gu/nebulagraph-desktop/releases)
31 |
32 | - for macOS, you need to install the `dmg` file, and do one extra step as below.
33 | - for Windows, you need to install the `exe` file
34 |
35 | 3. Install and launch the application
36 |
37 | 4. Click "Start All" to launch NebulaGraph services
38 |
39 | 5. Open Studio in your browser to start working with NebulaGraph
40 |
41 | Note: fill in `graphd` as "IP Address" and `9669` as "Port", user and password: `root`/`nebula`
42 |
43 | ### macOS extra step
44 |
45 | > copied from [OpenAI Translator](https://github.com/openai-translator/openai-translator/)
46 |
47 | This step is to fix the error: "NebulaGraph Desktop can't be opened because the developer cannot be verified." or "This app is damaged and suggested to be moved to Trash."
48 |
49 |
50 |
51 |
52 |
53 | - Click the `Cancel` button, then go to the `Settings` -> `Privacy and Security` page, click the `Still Open` button, and then click the `Open` button in the pop-up window. After that, there will be no more pop-up warnings when opening `NebulaGraph Desktop`. 🎉
54 |
55 |
56 |
57 |
58 | - If you cannot find the above options in `Privacy & Security`, or get error prompts such as broken files with Apple Silicon machines. Open `Terminal.app` and enter the following command (you may need to enter a password halfway through), then restart `NebulaGraph Desktop`:
59 |
60 | ```sh
61 | sudo xattr -d com.apple.quarantine /Applications/NebulaGraph\ Desktop.app
62 | ```
63 |
64 | ## System Requirements
65 |
66 | - Windows 10/11, macOS 10.15+
67 | - Docker Desktop installed and running
68 | - 8GB RAM minimum (16GB recommended)
69 | - 10GB free disk space
70 |
71 | ## Development
72 |
73 | See [DEVELOPMENT.md](./DEVELOPMENT.md) for detailed dev instructions and progress.
74 |
75 | ## License
76 |
77 | Apache License 2.0
78 |
--------------------------------------------------------------------------------
/assets/NebulaGraph-Desktop/docker-compose.yml:
--------------------------------------------------------------------------------
1 | services:
2 | metad:
3 | image: vesoft/nebula-metad:v3.8.0
4 | environment:
5 | USER: root
6 | command:
7 | - --meta_server_addrs=metad:9559
8 | - --local_ip=metad
9 | - --ws_ip=metad
10 | - --port=9559
11 | - --data_path=/data/meta
12 | # - --log_dir=/logs
13 | - --logtostderr=true
14 | - --redirect_stdout=false
15 | - --v=0
16 | - --minloglevel=0
17 | healthcheck:
18 | test: ["CMD", "curl", "-sf", "http://metad:19559/status"]
19 | interval: 30s
20 | timeout: 10s
21 | retries: 3
22 | start_period: 20s
23 | ports:
24 | - 9559:9559
25 | - 19559:19559
26 | - 19560
27 | volumes:
28 | - ./data/meta:/data/meta:rw
29 |
30 | storaged:
31 | image: vesoft/nebula-storaged:v3.8.0
32 | environment:
33 | USER: root
34 | command:
35 | - --meta_server_addrs=metad:9559
36 | - --local_ip=storaged
37 | - --ws_ip=storaged
38 | - --port=9779
39 | - --data_path=/data/storage
40 | # - --log_dir=/logs
41 | - --logtostderr=true
42 | - --redirect_stdout=false
43 | - --v=0
44 | - --minloglevel=0
45 | depends_on:
46 | - metad
47 | healthcheck:
48 | test: ["CMD", "curl", "-sf", "http://storaged:19779/status"]
49 | interval: 30s
50 | timeout: 10s
51 | retries: 3
52 | start_period: 20s
53 | ports:
54 | - 9779:9779
55 | - 19779:19779
56 | - 19780
57 | volumes:
58 | - ./data/storage:/data/storage:rw
59 |
60 | graphd:
61 | image: vesoft/nebula-graphd:v3.8.0
62 | environment:
63 | USER: root
64 | command:
65 | - --local_ip=graphd
66 | - --ws_ip=graphd
67 | - --meta_server_addrs=metad:9559
68 | - --port=9669
69 | # - --log_dir=/logs
70 | - --logtostderr=true
71 | - --redirect_stdout=false
72 | - --v=0
73 | - --minloglevel=0
74 | depends_on:
75 | - storaged
76 | healthcheck:
77 | test: ["CMD", "curl", "-sf", "http://graphd:19669/status"]
78 | interval: 30s
79 | timeout: 10s
80 | retries: 3
81 | start_period: 20s
82 | ports:
83 | - 9669:9669
84 | - 19669:19669
85 | - 19670
86 |
87 | studio:
88 | image: vesoft/nebula-graph-studio:v3.10.0
89 | environment:
90 | USER: root
91 | ports:
92 | - "7001:7001"
93 | depends_on:
94 | - graphd
95 | healthcheck:
96 | test: ["CMD", "wget", "--spider", "-q", "http://localhost:7001/"]
97 | interval: 30s
98 | timeout: 10s
99 | retries: 3
100 | start_period: 30s
101 | storage-activator:
102 | # This is just a script to activate storaged for the first time run by calling nebula-console
103 | # Refer to https://docs.nebula-graph.io/master/4.deployment-and-installation/manage-storage-host/#activate-storaged
104 | # If you like to call console via docker, run:
105 |
106 | # docker run --rm -ti --network host vesoft/nebula-console:nightly -addr 127.0.0.1 -port 9669 -u root -p nebula
107 |
108 | image: docker.io/vesoft/nebula-console:nightly
109 | entrypoint: ""
110 | environment:
111 | ACTIVATOR_RETRY: ${ACTIVATOR_RETRY:-30}
112 | command:
113 | - sh
114 | - -c
115 | - |
116 | for i in `seq 1 $$ACTIVATOR_RETRY`; do
117 | nebula-console -addr graphd -port 9669 -u root -p nebula -e 'ADD HOSTS "storaged":9779' 1>/dev/null 2>/dev/null;
118 | if [[ $$? == 0 ]]; then
119 | echo "✔️ Storage activated successfully.";
120 | break;
121 | else
122 | output=$$(nebula-console -addr graphd -port 9669 -u root -p nebula -e 'ADD HOSTS "storaged":9779' 2>&1);
123 | if echo "$$output" | grep -q "Existed"; then
124 | echo "✔️ Storage already activated, Exiting...";
125 | break;
126 | fi
127 | fi;
128 | if [[ $$i -lt $$ACTIVATOR_RETRY ]]; then
129 | echo "⏳ Attempting to activate storaged, attempt $$i/$$ACTIVATOR_RETRY... It's normal to take some attempts before storaged is ready. Please wait.";
130 | else
131 | echo "❌ Failed to activate storaged after $$ACTIVATOR_RETRY attempts. Please check MetaD, StorageD logs. Or restart the storage-activator service to continue retry.";
132 | echo "ℹ️ Error during storage activation:"
133 | echo "=============================================================="
134 | echo "$$output"
135 | echo "=============================================================="
136 | break;
137 | fi;
138 | sleep 5;
139 | done && tail -f /dev/null;
140 |
141 | depends_on:
142 | - graphd
143 |
144 | volumes:
145 | meta:
146 | storage:
147 |
--------------------------------------------------------------------------------
/assets/app_icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wey-gu/NebulaGraph-Desktop/69e25569e7d730d691d64bbecfa7b8237c9908a4/assets/app_icon.png
--------------------------------------------------------------------------------
/assets/portal.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wey-gu/NebulaGraph-Desktop/69e25569e7d730d691d64bbecfa7b8237c9908a4/assets/portal.png
--------------------------------------------------------------------------------
/assets/screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wey-gu/NebulaGraph-Desktop/69e25569e7d730d691d64bbecfa7b8237c9908a4/assets/screenshot.png
--------------------------------------------------------------------------------
/assets/studio.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wey-gu/NebulaGraph-Desktop/69e25569e7d730d691d64bbecfa7b8237c9908a4/assets/studio.png
--------------------------------------------------------------------------------
/electron/main/main.ts:
--------------------------------------------------------------------------------
1 | import { app, BrowserWindow, ipcMain, protocol } from 'electron';
2 | import * as path from 'path';
3 | import * as fs from 'fs';
4 | import { DockerService } from './services/docker-service';
5 | import { logger } from './utils/logger';
6 |
7 | // Simple development mode check
8 | const isDev = process.env.NODE_ENV === 'development' || process.env.DEBUG_PROD === 'true';
9 |
10 | let mainWindow: BrowserWindow | null = null;
11 | const dockerService = new DockerService();
12 |
13 | function createWindow() {
14 | if (!isDev) {
15 | // Register protocol handler for http scheme
16 | protocol.handle('http', (request) => {
17 | if (request.url.startsWith('http://localhost/')) {
18 | const filePath = request.url.replace('http://localhost/', '');
19 | logger.info('🔍 Looking for resource:', filePath);
20 |
21 | // Try multiple possible paths
22 | const possiblePaths = [
23 | // Try the direct app path first
24 | path.join(process.resourcesPath, 'app', filePath),
25 | // Try with public directory
26 | path.join(process.resourcesPath, 'app/public', filePath),
27 | // Try without public prefix
28 | path.join(process.resourcesPath, 'app', filePath.replace('public/', '')),
29 | // Try direct path for root files
30 | path.join(process.resourcesPath, 'app', path.basename(filePath)),
31 | // Try in the root app directory
32 | path.join(app.getAppPath(), filePath),
33 | // Try in the public directory of app path
34 | path.join(app.getAppPath(), 'public', filePath),
35 | // Try in the app's parent directory
36 | path.join(path.dirname(process.resourcesPath), 'app', filePath),
37 | // Try in the app's parent public directory
38 | path.join(path.dirname(process.resourcesPath), 'app/public', filePath),
39 | // Try in the resources directory
40 | path.join(process.resourcesPath, filePath),
41 | // Try in the resources public directory
42 | path.join(process.resourcesPath, 'public', filePath)
43 | ];
44 |
45 | // Log all paths we're going to check
46 | logger.info('📂 Will check these paths:');
47 | possiblePaths.forEach((p, i) => logger.info(` ${i + 1}. ${p}`));
48 |
49 | // First verify the app directory exists and show its contents
50 | const appDir = path.join(process.resourcesPath, 'app');
51 | try {
52 | logger.info('\n📁 App directory contents:', appDir);
53 | const contents = fs.readdirSync(appDir);
54 | contents.forEach(item => {
55 | const stat = fs.statSync(path.join(appDir, item));
56 | logger.info(` ${stat.isDirectory() ? '📂' : '📄'} ${item}`);
57 | });
58 |
59 | // Also show public directory contents if it exists
60 | const publicDir = path.join(appDir, 'public');
61 | if (fs.existsSync(publicDir)) {
62 | logger.info('\n📁 Public directory contents:', publicDir);
63 | const publicContents = fs.readdirSync(publicDir);
64 | publicContents.forEach(item => {
65 | const stat = fs.statSync(path.join(publicDir, item));
66 | logger.info(` ${stat.isDirectory() ? '📂' : '📄'} ${item}`);
67 | });
68 | }
69 | } catch (error) {
70 | logger.error('❌ Failed to read directories:', error);
71 | }
72 |
73 | // Try each path
74 | for (const fullPath of possiblePaths) {
75 | logger.info('\n🔍 Checking:', fullPath);
76 | try {
77 | if (fs.existsSync(fullPath)) {
78 | // Read file and determine content type
79 | const fileContent = fs.readFileSync(fullPath);
80 | const ext = path.extname(fullPath).toLowerCase();
81 | let contentType = 'application/octet-stream';
82 |
83 | // Map common extensions to content types
84 | const contentTypes: Record = {
85 | '.html': 'text/html',
86 | '.js': 'text/javascript',
87 | '.css': 'text/css',
88 | '.json': 'application/json',
89 | '.png': 'image/png',
90 | '.jpg': 'image/jpeg',
91 | '.jpeg': 'image/jpeg',
92 | '.gif': 'image/gif',
93 | '.svg': 'image/svg+xml',
94 | '.mp4': 'video/mp4',
95 | '.webm': 'video/webm',
96 | '.ico': 'image/x-icon',
97 | '.woff': 'font/woff',
98 | '.woff2': 'font/woff2',
99 | '.ttf': 'font/ttf',
100 | '.eot': 'font/eot'
101 | };
102 |
103 | if (ext in contentTypes) {
104 | contentType = contentTypes[ext];
105 | }
106 |
107 | logger.info('✅ Found resource!');
108 | logger.info(' Path:', fullPath);
109 | logger.info(' Type:', contentType);
110 | logger.info(' Size:', fileContent.length, 'bytes');
111 |
112 | return new Response(fileContent, {
113 | headers: {
114 | 'Content-Type': contentType,
115 | 'Cache-Control': 'public, max-age=31536000'
116 | }
117 | });
118 | }
119 | } catch (error) {
120 | logger.error('❌ Error checking path:', fullPath, error);
121 | }
122 | }
123 |
124 | logger.error('❌ Resource not found:', filePath);
125 | logger.error(' Tried all paths:', possiblePaths);
126 | }
127 | return new Response(null, { status: 404 });
128 | });
129 | }
130 |
131 | mainWindow = new BrowserWindow({
132 | width: 1200,
133 | height: 800,
134 | webPreferences: {
135 | nodeIntegration: false,
136 | contextIsolation: true,
137 | sandbox: false,
138 | preload: path.join(__dirname, '..', 'preload', 'preload.js'),
139 | webSecurity: true
140 | }
141 | });
142 |
143 | // Set the window for the logger
144 | logger.setWindow(mainWindow);
145 |
146 | // Load the app
147 | if (isDev) {
148 | mainWindow.loadURL('http://localhost:3000');
149 | mainWindow.webContents.openDevTools();
150 | } else {
151 | // In production, load from the app bundle
152 | const indexPath = path.join(process.resourcesPath, 'app/index.html');
153 | logger.info('Loading production index from:', indexPath);
154 |
155 | mainWindow.loadFile(indexPath).catch(err => {
156 | logger.error('Failed to load index.html:', err);
157 |
158 | // Log all available paths
159 | const paths = {
160 | resourcesPath: process.resourcesPath,
161 | appPath: app.getAppPath(),
162 | userData: app.getPath('userData'),
163 | exe: app.getPath('exe'),
164 | cwd: process.cwd()
165 | };
166 |
167 | logger.info('Available paths:', paths);
168 |
169 | // List directory contents for debugging
170 | try {
171 | const appDir = path.join(process.resourcesPath, 'app');
172 | logger.info('App directory contents:', fs.readdirSync(appDir));
173 |
174 | // Also check the parent directory
175 | const parentDir = path.dirname(appDir);
176 | logger.info('Parent directory contents:', fs.readdirSync(parentDir));
177 | } catch (error) {
178 | logger.error('Failed to read directories:', error);
179 | }
180 | });
181 | }
182 |
183 | mainWindow?.on('closed', () => {
184 | mainWindow = null;
185 | });
186 | }
187 |
188 | // IPC Handlers
189 | ipcMain.handle('docker:status', async () => {
190 | return dockerService.checkDockerStatus();
191 | });
192 |
193 | ipcMain.handle('browser:openExternal', async (_, url: string) => {
194 | const { shell } = require('electron');
195 | await shell.openExternal(url);
196 | return true;
197 | });
198 |
199 | ipcMain.handle('docker:systemStatus', async () => {
200 | return dockerService.getSystemStatus();
201 | });
202 |
203 | ipcMain.handle('docker:toggle', async (_, start: boolean) => {
204 | if (start) {
205 | await dockerService.startServices();
206 | } else {
207 | await dockerService.stopServices();
208 | }
209 | return dockerService.checkDockerStatus();
210 | });
211 |
212 | ipcMain.handle('docker:start', async () => {
213 | return dockerService.startServices();
214 | });
215 |
216 | ipcMain.handle('docker:stop', async () => {
217 | return dockerService.stopServices();
218 | });
219 |
220 | ipcMain.handle('docker:getServices', async () => {
221 | return dockerService.getServicesStatus();
222 | });
223 |
224 | ipcMain.handle('docker:getLogs', async (_, serviceName: string) => {
225 | return dockerService.getServiceLogs(serviceName);
226 | });
227 |
228 | ipcMain.handle('docker:startService', async (_, serviceName: string) => {
229 | return dockerService.startService(serviceName);
230 | });
231 |
232 | ipcMain.handle('docker:stopService', async (_, serviceName: string) => {
233 | return dockerService.stopService(serviceName);
234 | });
235 |
236 | ipcMain.handle('docker:restartService', async (_, serviceName: string) => {
237 | return dockerService.restartService(serviceName);
238 | });
239 |
240 | // Add handler for image loading progress
241 | ipcMain.handle('docker:getImageLoadingProgress', () => {
242 | return dockerService.getImageLoadingProgress();
243 | });
244 |
245 | // Add handler for ensuring images are loaded
246 | ipcMain.handle('docker:ensureImagesLoaded', async () => {
247 | return dockerService.ensureImagesLoaded();
248 | });
249 |
250 | // Ensure app is ready before creating window
251 | app.whenReady().then(() => {
252 | createWindow();
253 |
254 | app.on('activate', () => {
255 | if (mainWindow === null) {
256 | createWindow();
257 | }
258 | });
259 | });
260 |
261 | app.on('window-all-closed', () => {
262 | if (process.platform !== 'darwin') {
263 | app.quit();
264 | }
265 | });
--------------------------------------------------------------------------------
/electron/main/preload.ts:
--------------------------------------------------------------------------------
1 | import { contextBridge, ipcRenderer } from 'electron';
2 |
3 | // Expose protected methods that allow the renderer process to use
4 | // the ipcRenderer without exposing the entire object
5 | contextBridge.exposeInMainWorld(
6 | 'electronAPI',
7 | {
8 | docker: {
9 | start: () => ipcRenderer.invoke('docker:start'),
10 | stop: () => ipcRenderer.invoke('docker:stop'),
11 | status: () => ipcRenderer.invoke('docker:status'),
12 | systemStatus: () => ipcRenderer.invoke('docker:systemStatus'),
13 | toggle: (start: boolean) => ipcRenderer.invoke('docker:toggle', start),
14 | getServices: () => ipcRenderer.invoke('docker:getServices'),
15 | getLogs: (serviceName: string) => ipcRenderer.invoke('docker:getLogs', serviceName),
16 | clearLogs: (serviceName: string) => ipcRenderer.invoke('docker:clearLogs', serviceName),
17 | startService: (serviceName: string) => ipcRenderer.invoke('docker:startService', serviceName),
18 | stopService: (serviceName: string) => ipcRenderer.invoke('docker:stopService', serviceName),
19 | restartService: (serviceName: string) => ipcRenderer.invoke('docker:restartService', serviceName),
20 | }
21 | }
22 | );
--------------------------------------------------------------------------------
/electron/main/services/docker-checker.ts:
--------------------------------------------------------------------------------
1 | import { exec as execCallback } from 'child_process';
2 | import { promisify } from 'util';
3 | import * as path from 'path';
4 | import * as fs from 'fs/promises';
5 | import { app } from 'electron';
6 | import { logger } from '../utils/logger';
7 |
8 | interface ExecResult {
9 | stdout: string;
10 | stderr: string;
11 | }
12 |
13 | const exec = promisify(execCallback) as (command: string, options?: { env?: NodeJS.ProcessEnv }) => Promise;
14 |
15 | // Common Docker binary locations
16 | const DOCKER_PATHS: Record = {
17 | darwin: [
18 | '/usr/local/bin/docker',
19 | '/opt/homebrew/bin/docker',
20 | '/Applications/Docker.app/Contents/Resources/bin/docker'
21 | ],
22 | linux: [
23 | '/usr/bin/docker',
24 | '/usr/local/bin/docker'
25 | ],
26 | win32: [
27 | 'C:\\Program Files\\Docker\\Docker\\resources\\bin\\docker.exe',
28 | 'C:\\Program Files\\Docker\\Docker\\resources\\docker.exe'
29 | ],
30 | aix: [],
31 | android: [],
32 | freebsd: [],
33 | haiku: [],
34 | openbsd: [],
35 | sunos: [],
36 | cygwin: [],
37 | netbsd: []
38 | };
39 |
40 | interface DockerComposeStatus {
41 | isInstalled: boolean;
42 | version?: string;
43 | }
44 |
45 | interface DockerSystemStatus {
46 | isInstalled: boolean;
47 | isRunning: boolean;
48 | version?: string;
49 | compose?: DockerComposeStatus;
50 | error?: string;
51 | }
52 |
53 | interface ImageConfig {
54 | name: string;
55 | tag: string;
56 | size?: string;
57 | checksum?: string;
58 | }
59 |
60 | export class DockerChecker {
61 | private resourcesPath: string;
62 | private imagesPath: string;
63 | private manifestPath: string;
64 | private dockerPath: string | null = null;
65 | private customEnv: NodeJS.ProcessEnv;
66 |
67 | constructor() {
68 | // In development, use the assets directory directly
69 | // In production, use the app's resources path
70 | const isDev = process.env.NODE_ENV === 'development';
71 | const basePath = isDev
72 | ? path.join(process.cwd(), 'assets')
73 | : path.join(process.resourcesPath, 'resources');
74 |
75 | this.resourcesPath = path.join(basePath, 'NebulaGraph-Desktop');
76 | this.imagesPath = path.join(this.resourcesPath, 'images');
77 | this.manifestPath = path.join(this.imagesPath, 'manifest.json');
78 |
79 | // Initialize environment with additional paths
80 | this.customEnv = {
81 | ...process.env,
82 | PATH: this.getEnhancedPath()
83 | };
84 |
85 | logger.info('🔧 Environment PATH:', this.customEnv.PATH);
86 | logger.info('🐳 Docker resources path:', this.resourcesPath);
87 | }
88 |
89 | private getEnhancedPath(): string {
90 | const platform = process.platform;
91 | const currentPath = process.env.PATH || '';
92 | const additionalPaths = [];
93 |
94 | // Add platform-specific paths
95 | if (platform === 'darwin') {
96 | additionalPaths.push(
97 | '/usr/local/bin',
98 | '/opt/homebrew/bin',
99 | '/Applications/Docker.app/Contents/Resources/bin',
100 | '/usr/bin'
101 | );
102 | } else if (platform === 'linux') {
103 | additionalPaths.push(
104 | '/usr/bin',
105 | '/usr/local/bin'
106 | );
107 | } else if (platform === 'win32') {
108 | additionalPaths.push(
109 | 'C:\\Program Files\\Docker\\Docker\\resources\\bin',
110 | 'C:\\Program Files\\Docker\\Docker\\resources'
111 | );
112 | }
113 |
114 | return [...new Set([...additionalPaths, ...currentPath.split(path.delimiter)])].join(path.delimiter);
115 | }
116 |
117 | private async findDockerPath(): Promise {
118 | if (this.dockerPath) return this.dockerPath;
119 |
120 | const platform = process.platform;
121 | const paths = DOCKER_PATHS[platform] || [];
122 |
123 | for (const dockerPath of paths) {
124 | try {
125 | await fs.access(dockerPath);
126 | logger.info('✅ Found Docker binary at:', dockerPath);
127 | this.dockerPath = dockerPath;
128 | return dockerPath;
129 | } catch {
130 | logger.info('❌ Docker not found at:', dockerPath);
131 | continue;
132 | }
133 | }
134 |
135 | // If we couldn't find Docker in known locations, try PATH
136 | try {
137 | const { stdout } = await exec('which docker', { env: this.customEnv });
138 | const pathFromWhich = stdout.trim();
139 | if (pathFromWhich) {
140 | logger.info('✅ Found Docker binary through PATH at:', pathFromWhich);
141 | this.dockerPath = pathFromWhich;
142 | return pathFromWhich;
143 | }
144 | } catch (error) {
145 | logger.warn('⚠️ Could not find Docker through PATH');
146 | }
147 |
148 | return null;
149 | }
150 |
151 | private getContainerName(serviceName: string): string {
152 | return `nebulagraph-desktop-${serviceName}-1`;
153 | }
154 |
155 | async checkDockerSystem(): Promise {
156 | logger.info('🔍 Starting Docker system check...');
157 |
158 | try {
159 | // Check Docker CLI
160 | logger.info('🐳 Checking Docker CLI...');
161 | const dockerVersion = await this.execCommand('docker --version');
162 | logger.info('✓ Docker CLI version:', dockerVersion);
163 |
164 | // Check Docker daemon
165 | logger.info('🔄 Checking Docker daemon...');
166 | try {
167 | const dockerInfo = await this.execCommand('docker info');
168 | logger.info('✓ Docker daemon is running');
169 | logger.info('📊 Docker info highlights:', this.parseDockerInfo(dockerInfo));
170 | } catch (error) {
171 | logger.error('❌ Docker daemon check failed:', error);
172 | return {
173 | isInstalled: true,
174 | isRunning: false,
175 | version: dockerVersion,
176 | error: 'Docker daemon is not running'
177 | };
178 | }
179 |
180 | // Check Docker Compose
181 | logger.info('🔄 Checking Docker Compose...');
182 | let composeVersion;
183 | try {
184 | // Try Docker Compose V2 first
185 | composeVersion = await this.execCommand('docker compose version');
186 | logger.info('✓ Docker Compose V2 found:', composeVersion);
187 | } catch (error) {
188 | logger.warn('⚠️ Docker Compose V2 not found, trying legacy version...');
189 | try {
190 | // Try legacy docker-compose
191 | composeVersion = await this.execCommand('docker-compose --version');
192 | logger.info('✓ Legacy Docker Compose found:', composeVersion);
193 | } catch (composeError) {
194 | logger.error('❌ No Docker Compose installation found');
195 | return {
196 | isInstalled: true,
197 | isRunning: true,
198 | version: dockerVersion,
199 | compose: {
200 | isInstalled: false,
201 | version: undefined
202 | },
203 | error: 'Docker Compose not found'
204 | };
205 | }
206 | }
207 |
208 | logger.info('✅ All Docker system checks passed');
209 | return {
210 | isInstalled: true,
211 | isRunning: true,
212 | version: dockerVersion,
213 | compose: {
214 | isInstalled: true,
215 | version: composeVersion
216 | }
217 | };
218 | } catch (error) {
219 | logger.error('❌ Docker system check failed:', error);
220 | return {
221 | isInstalled: false,
222 | isRunning: false,
223 | error: error instanceof Error ? error.message : 'Unknown error checking Docker system'
224 | };
225 | }
226 | }
227 |
228 | private parseDockerInfo(info: string): object {
229 | const highlights: any = {};
230 | const lines = info.split('\n');
231 |
232 | for (const line of lines) {
233 | if (line.includes('Server Version:')) highlights.serverVersion = line.split(':')[1].trim();
234 | if (line.includes('OS/Arch:')) highlights.osArch = line.split(':')[1].trim();
235 | if (line.includes('Kernel Version:')) highlights.kernelVersion = line.split(':')[1].trim();
236 | }
237 |
238 | return highlights;
239 | }
240 |
241 | async execCommand(command: string, workingDir?: string): Promise {
242 | try {
243 | logger.info('🔄 Executing command:', command);
244 |
245 | // If command starts with 'docker', try to use full path
246 | if (command.startsWith('docker ')) {
247 | const dockerPath = await this.findDockerPath();
248 | if (dockerPath) {
249 | command = command.replace('docker ', `"${dockerPath}" `);
250 | }
251 | }
252 |
253 | const execOptions: { env?: NodeJS.ProcessEnv; cwd?: string } = { env: this.customEnv };
254 | if (workingDir) {
255 | logger.info('📂 Using working directory:', workingDir);
256 | execOptions.cwd = workingDir;
257 | }
258 |
259 | const { stdout, stderr } = await exec(command, execOptions);
260 | if (stderr) {
261 | logger.warn('⚠️ Command stderr:', stderr);
262 | }
263 | return stdout.trim();
264 | } catch (error) {
265 | logger.error('❌ Command failed:', command);
266 | logger.error('Error details:', error);
267 | throw error;
268 | }
269 | }
270 |
271 | async checkDocker(): Promise {
272 | return this.checkDockerSystem();
273 | }
274 |
275 | async checkRequiredImages(): Promise {
276 | try {
277 | // Read manifest
278 | const manifestContent = await fs.readFile(this.manifestPath, 'utf-8');
279 | const manifest = JSON.parse(manifestContent);
280 |
281 | // Check each image
282 | for (const [key, config] of Object.entries(manifest.images)) {
283 | const fullImageName = `${config.name}:${config.tag}`;
284 | try {
285 | const { stdout } = await exec(`docker image inspect ${fullImageName}`);
286 | logger.info(`✓ Image ${fullImageName} exists`);
287 | } catch (error) {
288 | logger.error(`✕ Image ${fullImageName} not found`);
289 | return false;
290 | }
291 | }
292 |
293 | return true;
294 | } catch (error) {
295 | logger.error('Failed to check images:', error);
296 | return false;
297 | }
298 | }
299 |
300 | async loadImages(
301 | progressCallback?: (current: number, total: number, imageName: string) => void
302 | ): Promise {
303 | try {
304 | // Check Docker system status first
305 | const dockerStatus = await this.checkDockerSystem();
306 | if (!dockerStatus.isInstalled || !dockerStatus.isRunning) {
307 | logger.info('Docker is not ready:', dockerStatus.error);
308 | return false;
309 | }
310 |
311 | logger.info('📦 Starting to load Docker images...');
312 | const manifestContent = await fs.readFile(this.manifestPath, 'utf-8');
313 | const manifest = JSON.parse(manifestContent);
314 | const images = Object.entries(manifest.images);
315 | const total = images.length;
316 | logger.info(`Found ${total} images to load`);
317 |
318 | for (const [index, [key, config]] of images.entries()) {
319 | const imagePath = path.join(this.imagesPath, `${key}.tar`);
320 | const fullImageName = `${config.name}:${config.tag}`;
321 |
322 | progressCallback?.(index + 1, total, fullImageName);
323 | logger.info(`[${index + 1}/${total}] Loading image ${fullImageName}...`);
324 | const startTime = Date.now();
325 |
326 | try {
327 | logger.info(`Reading image file: ${key}.tar`);
328 | await exec(`docker load -i "${imagePath}"`);
329 | const duration = ((Date.now() - startTime) / 1000).toFixed(1);
330 | logger.info(`✅ Loaded ${fullImageName} (took ${duration}s)`);
331 | } catch (error) {
332 | logger.error(`Failed to load ${fullImageName}:`, error);
333 | return false;
334 | }
335 | }
336 |
337 | logger.info('✅ All images loaded successfully');
338 | return true;
339 | } catch (error) {
340 | logger.error('Failed to load images:', error);
341 | return false;
342 | }
343 | }
344 |
345 | async ensureImagesLoaded(
346 | progressCallback?: (current: number, total: number, imageName: string) => void
347 | ): Promise {
348 | // Check Docker system status first
349 | const dockerStatus = await this.checkDockerSystem();
350 | if (!dockerStatus.isInstalled || !dockerStatus.isRunning) {
351 | logger.error('Docker is not ready:', dockerStatus.error);
352 | return false;
353 | }
354 |
355 | const hasImages = await this.checkRequiredImages();
356 | if (!hasImages) {
357 | logger.info('Some images are missing, loading from resources...');
358 | return this.loadImages(progressCallback);
359 | }
360 | return true;
361 | }
362 |
363 | async getResourcesPath(): Promise {
364 | return this.resourcesPath;
365 | }
366 |
367 | private async getServiceHealth(serviceName: string): Promise<'healthy' | 'unhealthy' | 'starting' | 'unknown'> {
368 | try {
369 | const containerName = this.getContainerName(serviceName);
370 | logger.info(`🏥 Checking health for ${serviceName} (${containerName})`);
371 |
372 | // First try to get container state
373 | const stateCmd = process.platform === 'win32'
374 | ? `docker inspect --format "{{if .State}}{{.State.Status}}{{end}}" ${containerName}`
375 | : `docker inspect --format '{{if .State}}{{.State.Status}}{{end}}' ${containerName}`;
376 |
377 | try {
378 | const state = await this.execCommand(stateCmd);
379 | const containerState = state.trim().toLowerCase();
380 | logger.info(`Container state for ${serviceName}:`, containerState);
381 |
382 | if (containerState === 'running') {
383 | // If container is running, check health status
384 | const healthCmd = process.platform === 'win32'
385 | ? `docker inspect --format "{{if .State.Health}}{{.State.Health.Status}}{{end}}" ${containerName}`
386 | : `docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{end}}' ${containerName}`;
387 |
388 | const healthStatus = await this.execCommand(healthCmd);
389 | const status = healthStatus.trim().toLowerCase();
390 | logger.info(`Health check result for ${serviceName}:`, status);
391 |
392 | if (status === 'healthy') return 'healthy';
393 | if (status === 'unhealthy') return 'unhealthy';
394 | if (status === 'starting') return 'starting';
395 |
396 | // For containers without health checks or still initializing
397 | return 'starting';
398 | }
399 | } catch (error) {
400 | logger.warn(`Container inspection failed for ${serviceName}:`, error);
401 | // Try fallback to ps command
402 | try {
403 | const psCmd = process.platform === 'win32'
404 | ? `docker ps --filter "name=${containerName}" --format "{{.Status}}"`
405 | : `docker ps --filter name=${containerName} --format "{{.Status}}"`;
406 |
407 | logger.info('Trying fallback ps command:', psCmd);
408 | const psStatus = await this.execCommand(psCmd);
409 | if (psStatus.includes('Up')) {
410 | return 'starting';
411 | }
412 | } catch (psError) {
413 | logger.error('Fallback ps command also failed:', psError);
414 | }
415 | }
416 |
417 | return 'unknown';
418 | } catch (error) {
419 | logger.error(`❌ Health check error for ${serviceName}:`, error);
420 | return 'unknown';
421 | }
422 | }
423 | }
--------------------------------------------------------------------------------
/electron/main/test-docker.ts:
--------------------------------------------------------------------------------
1 | import { DockerService } from './services/docker-service';
2 |
3 | async function testDockerService() {
4 | const docker = new DockerService();
5 |
6 | console.log('Checking Docker status...');
7 | const isDockerRunning = await docker.checkDockerStatus();
8 | console.log('Docker running:', isDockerRunning);
9 |
10 | if (!isDockerRunning) {
11 | console.error('Docker is not running. Please start Docker Desktop first.');
12 | return;
13 | }
14 |
15 | console.log('\nStarting NebulaGraph services...');
16 | const startResult = await docker.startServices();
17 | console.log('Start result:', startResult);
18 |
19 | if (startResult.success) {
20 | console.log('\nWaiting 10 seconds before checking service status...');
21 | await new Promise(resolve => setTimeout(resolve, 10000));
22 |
23 | console.log('\nGetting services status...');
24 | const status = await docker.getServicesStatus();
25 | console.log('Services status:', JSON.stringify(status, null, 2));
26 |
27 | console.log('\nGetting service logs...');
28 | const logs = await docker.getServiceLogs('graphd');
29 | console.log('Graph service logs:', logs.slice(0, 5));
30 |
31 | console.log('\nStopping services...');
32 | const stopResult = await docker.stopServices();
33 | console.log('Stop result:', stopResult);
34 | }
35 | }
36 |
37 | testDockerService().catch(console.error);
--------------------------------------------------------------------------------
/electron/main/utils/logger.ts:
--------------------------------------------------------------------------------
1 | import { BrowserWindow } from 'electron';
2 |
3 | class Logger {
4 | private mainWindow: BrowserWindow | null = null;
5 |
6 | setWindow(window: BrowserWindow) {
7 | this.mainWindow = window;
8 | }
9 |
10 | private sendToRenderer(level: string, ...args: any[]) {
11 | if (this.mainWindow && !this.mainWindow.isDestroyed()) {
12 | this.mainWindow.webContents.send('log', {
13 | level,
14 | message: args.map(arg =>
15 | typeof arg === 'object' ? JSON.stringify(arg, null, 2) : String(arg)
16 | ).join(' '),
17 | timestamp: new Date().toISOString()
18 | });
19 | }
20 | }
21 |
22 | info(...args: any[]) {
23 | console.log(...args);
24 | this.sendToRenderer('info', ...args);
25 | }
26 |
27 | warn(...args: any[]) {
28 | console.warn(...args);
29 | this.sendToRenderer('warn', ...args);
30 | }
31 |
32 | error(...args: any[]) {
33 | console.error(...args);
34 | this.sendToRenderer('error', ...args);
35 | }
36 | }
37 |
38 | export const logger = new Logger();
--------------------------------------------------------------------------------
/electron/preload/index.ts:
--------------------------------------------------------------------------------
1 | import { contextBridge, ipcRenderer } from 'electron'
2 |
3 | const docker = {
4 | status: () => ipcRenderer.invoke('docker:status'),
5 | systemStatus: () => ipcRenderer.invoke('docker:systemStatus'),
6 | toggle: (start: boolean) => ipcRenderer.invoke('docker:toggle', start),
7 | }
--------------------------------------------------------------------------------
/electron/preload/preload.ts:
--------------------------------------------------------------------------------
1 | import { contextBridge, ipcRenderer } from 'electron';
2 |
3 | console.log('🔄 Preload script starting...');
4 |
5 | // Type declarations for error handling
6 | interface ElectronError extends Error {
7 | message: string;
8 | }
9 |
10 | interface ServiceStatus {
11 | name: string;
12 | status: 'running' | 'stopped' | 'error';
13 | health: {
14 | status: 'healthy' | 'unhealthy' | 'starting' | 'unknown';
15 | lastCheck: string;
16 | failureCount: number;
17 | };
18 | metrics: {
19 | cpu: string;
20 | memory: string;
21 | network: string;
22 | uptime?: number;
23 | connections?: number;
24 | } | null;
25 | ports: string[];
26 | logs: string[];
27 | }
28 |
29 | interface LogEntry {
30 | timestamp: string;
31 | message: string;
32 | level: string;
33 | }
34 |
35 | interface DockerSystemStatus {
36 | isInstalled: boolean;
37 | isRunning: boolean;
38 | version?: string;
39 | compose?: {
40 | isInstalled: boolean;
41 | version?: string;
42 | };
43 | error?: string;
44 | }
45 |
46 | interface DockerAPI {
47 | status: () => Promise;
48 | systemStatus: () => Promise;
49 | start: () => Promise<{ success: boolean; error?: string }>;
50 | stop: () => Promise<{ success: boolean; error?: string }>;
51 | getServices: () => Promise>;
52 | startService: (serviceName: string) => Promise<{ success: boolean; error?: string }>;
53 | stopService: (serviceName: string) => Promise<{ success: boolean; error?: string }>;
54 | restartService: (serviceName: string) => Promise<{ success: boolean; error?: string }>;
55 | getLogs: (serviceName: string) => Promise>;
56 | getImageLoadingProgress: () => Promise;
57 | ensureImagesLoaded: () => Promise;
58 | }
59 |
60 | // Create the API object with error handling and logging
61 | const docker: DockerAPI = {
62 | status: async () => {
63 | try {
64 | return await ipcRenderer.invoke('docker:status');
65 | } catch (error) {
66 | console.error('Docker status error:', error);
67 | return false;
68 | }
69 | },
70 | systemStatus: async () => {
71 | try {
72 | return await ipcRenderer.invoke('docker:systemStatus');
73 | } catch (error) {
74 | console.error('Docker system status error:', error);
75 | return {
76 | isInstalled: false,
77 | isRunning: false,
78 | error: error instanceof Error ? error.message : 'Unknown error'
79 | };
80 | }
81 | },
82 | start: async () => {
83 | try {
84 | return await ipcRenderer.invoke('docker:start');
85 | } catch (error) {
86 | console.error('Docker start error:', error);
87 | return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
88 | }
89 | },
90 | stop: async () => {
91 | try {
92 | return await ipcRenderer.invoke('docker:stop');
93 | } catch (error) {
94 | console.error('Docker stop error:', error);
95 | return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
96 | }
97 | },
98 | getServices: async () => {
99 | try {
100 | return await ipcRenderer.invoke('docker:getServices');
101 | } catch (error) {
102 | console.error('Get services error:', error);
103 | return {};
104 | }
105 | },
106 | startService: async (serviceName: string) => {
107 | try {
108 | return await ipcRenderer.invoke('docker:startService', serviceName);
109 | } catch (error) {
110 | console.error('Start service error:', error);
111 | return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
112 | }
113 | },
114 | stopService: async (serviceName: string) => {
115 | try {
116 | return await ipcRenderer.invoke('docker:stopService', serviceName);
117 | } catch (error) {
118 | console.error('Stop service error:', error);
119 | return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
120 | }
121 | },
122 | restartService: async (serviceName: string) => {
123 | try {
124 | return await ipcRenderer.invoke('docker:restartService', serviceName);
125 | } catch (error) {
126 | console.error('Restart service error:', error);
127 | return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
128 | }
129 | },
130 | getImageLoadingProgress: async () => {
131 | try {
132 | return await ipcRenderer.invoke('docker:getImageLoadingProgress');
133 | } catch (error) {
134 | console.error('Get image loading progress error:', error);
135 | return null;
136 | }
137 | },
138 | ensureImagesLoaded: async () => {
139 | try {
140 | return await ipcRenderer.invoke('docker:ensureImagesLoaded');
141 | } catch (error) {
142 | console.error('Ensure images loaded error:', error);
143 | return false;
144 | }
145 | },
146 | getLogs: async (serviceName: string) => {
147 | try {
148 | return await ipcRenderer.invoke('docker:getLogs', serviceName);
149 | } catch (error) {
150 | console.error('Get logs error:', error);
151 | return [];
152 | }
153 | }
154 | };
155 |
156 | // Add log handling
157 | ipcRenderer.on('log', (_, log) => {
158 | const { level, message, timestamp } = log;
159 | const formattedMessage = `${message}`;
160 |
161 | switch (level) {
162 | case 'error':
163 | console.error(formattedMessage);
164 | break;
165 | case 'warn':
166 | console.warn(formattedMessage);
167 | break;
168 | default:
169 | console.log(formattedMessage);
170 | }
171 | });
172 |
173 | // Expose the API to the renderer process
174 | try {
175 | console.log('🔌 Exposing Electron API...');
176 | contextBridge.exposeInMainWorld('electronAPI', {
177 | docker: docker,
178 | browser: {
179 | openExternal: (url: string) => ipcRenderer.invoke('browser:openExternal', url)
180 | },
181 | logs: {
182 | subscribe: (callback: (log: any) => void) => {
183 | const subscription = (_: any, log: any) => callback(log);
184 | ipcRenderer.on('log', subscription);
185 | return () => ipcRenderer.removeListener('log', subscription);
186 | }
187 | }
188 | });
189 | console.log('✓ Electron API exposed successfully');
190 | } catch (error) {
191 | console.error('✕ Failed to expose Electron API:', error);
192 | }
--------------------------------------------------------------------------------
/electron/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2020",
4 | "module": "commonjs",
5 | "lib": ["dom", "es2020"],
6 | "strict": true,
7 | "esModuleInterop": true,
8 | "skipLibCheck": true,
9 | "forceConsistentCasingInFileNames": true,
10 | "moduleResolution": "node",
11 | "resolveJsonModule": true,
12 | "isolatedModules": true,
13 | "noEmit": false,
14 | "outDir": "../dist",
15 | "baseUrl": ".",
16 | "paths": {
17 | "*": ["node_modules/*"]
18 | }
19 | },
20 | "include": [
21 | "main/**/*",
22 | "preload/**/*"
23 | ],
24 | "exclude": ["node_modules"]
25 | }
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "nebulagraph-desktop",
3 | "version": "0.3.5",
4 | "description": "Desktop version of NebulaGraph",
5 | "main": "dist/main/main.js",
6 | "scripts": {
7 | "dev": "concurrently \"cd renderer && npm run dev\" \"cross-env NODE_ENV=development npm run dev:electron\"",
8 | "dev:electron": "tsc -p electron/tsconfig.json && electron .",
9 | "build:electron": "tsc -p electron/tsconfig.json",
10 | "build:next": "cd renderer && npm run build",
11 | "build": "cross-env NODE_ENV=production npm run build:electron && npm run build:next",
12 | "start": "cross-env NODE_ENV=production electron .",
13 | "pack": "electron-builder --dir",
14 | "dist": "electron-builder",
15 | "postinstall": "cd renderer && npm install",
16 | "test:docker": "tsc -p electron/tsconfig.json && node dist/main/test-docker.js",
17 | "prepare-images": "node scripts/prepare-images.js"
18 | },
19 | "build": {
20 | "appId": "com.nebulagraph.desktop",
21 | "productName": "NebulaGraph Desktop",
22 | "icon": "assets/app_icon.png",
23 | "files": [
24 | "dist/**/*"
25 | ],
26 | "asar": true,
27 | "asarUnpack": [
28 | "node_modules/**/*"
29 | ],
30 | "extraResources": [
31 | {
32 | "from": "assets/NebulaGraph-Desktop",
33 | "to": "resources/NebulaGraph-Desktop",
34 | "filter": [
35 | "**/*"
36 | ]
37 | },
38 | {
39 | "from": "renderer/out",
40 | "to": "app",
41 | "filter": [
42 | "**/*"
43 | ]
44 | },
45 | {
46 | "from": "renderer/public",
47 | "to": "app/public",
48 | "filter": [
49 | "**/*"
50 | ]
51 | }
52 | ],
53 | "directories": {
54 | "output": "release",
55 | "buildResources": "assets"
56 | },
57 | "mac": {
58 | "category": "public.app-category.developer-tools",
59 | "target": [
60 | "dmg"
61 | ],
62 | "hardenedRuntime": true,
63 | "gatekeeperAssess": false,
64 | "icon": "assets/app_icon.png"
65 | },
66 | "win": {
67 | "target": [
68 | "nsis"
69 | ],
70 | "icon": "assets/app_icon.png"
71 | },
72 | "linux": {
73 | "target": [
74 | "AppImage"
75 | ],
76 | "category": "Development",
77 | "icon": "assets/app_icon.png"
78 | },
79 | "nsis": {
80 | "oneClick": false,
81 | "allowToChangeInstallationDirectory": true,
82 | "createDesktopShortcut": true,
83 | "runAfterFinish": true
84 | }
85 | },
86 | "author": "NebulaGraph Community",
87 | "license": "Apache-2.0",
88 | "private": true,
89 | "devDependencies": {
90 | "@types/electron": "^1.4.38",
91 | "@types/node": "^22.13.5",
92 | "concurrently": "^9.1.2",
93 | "electron": "^34.2.0",
94 | "electron-builder": "^25.1.8",
95 | "typescript": "^5.7.3"
96 | },
97 | "dependencies": {
98 | "lucide-react": "^0.475.0"
99 | }
100 | }
101 |
--------------------------------------------------------------------------------
/renderer/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.*
7 | .yarn/*
8 | !.yarn/patches
9 | !.yarn/plugins
10 | !.yarn/releases
11 | !.yarn/versions
12 |
13 | # testing
14 | /coverage
15 |
16 | # next.js
17 | /.next/
18 | /out/
19 |
20 | # production
21 | /build
22 |
23 | # misc
24 | .DS_Store
25 | *.pem
26 |
27 | # debug
28 | npm-debug.log*
29 | yarn-debug.log*
30 | yarn-error.log*
31 | .pnpm-debug.log*
32 |
33 | # env files (can opt-in for committing if needed)
34 | .env*
35 |
36 | # vercel
37 | .vercel
38 |
39 | # typescript
40 | *.tsbuildinfo
41 | next-env.d.ts
42 |
--------------------------------------------------------------------------------
/renderer/README.md:
--------------------------------------------------------------------------------
1 | This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
2 |
3 | ## Getting Started
4 |
5 | First, run the development server:
6 |
7 | ```bash
8 | npm run dev
9 | # or
10 | yarn dev
11 | # or
12 | pnpm dev
13 | # or
14 | bun dev
15 | ```
16 |
17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
18 |
19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
20 |
21 | This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
22 |
23 | ## Learn More
24 |
25 | To learn more about Next.js, take a look at the following resources:
26 |
27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
29 |
30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
31 |
32 | ## Deploy on Vercel
33 |
34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
35 |
36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
37 |
--------------------------------------------------------------------------------
/renderer/eslint.config.mjs:
--------------------------------------------------------------------------------
1 | import { dirname } from "path";
2 | import { fileURLToPath } from "url";
3 | import { FlatCompat } from "@eslint/eslintrc";
4 |
5 | const __filename = fileURLToPath(import.meta.url);
6 | const __dirname = dirname(__filename);
7 |
8 | const compat = new FlatCompat({
9 | baseDirectory: __dirname,
10 | });
11 |
12 | const eslintConfig = [
13 | ...compat.extends("next/core-web-vitals", "next/typescript"),
14 | ];
15 |
16 | export default eslintConfig;
17 |
--------------------------------------------------------------------------------
/renderer/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | output: 'export',
4 | images: {
5 | unoptimized: true
6 | },
7 | assetPrefix: process.env.NODE_ENV === 'production' ? 'http://localhost' : '',
8 | basePath: '',
9 | distDir: 'out',
10 | trailingSlash: true,
11 | }
12 |
13 | module.exports = nextConfig
--------------------------------------------------------------------------------
/renderer/next.config.ts:
--------------------------------------------------------------------------------
1 | import type { NextConfig } from "next";
2 |
3 | const nextConfig: NextConfig = {
4 | /* config options here */
5 | };
6 |
7 | export default nextConfig;
8 |
--------------------------------------------------------------------------------
/renderer/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "renderer",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "@radix-ui/react-dialog": "^1.1.6",
13 | "@radix-ui/react-progress": "^1.1.2",
14 | "@radix-ui/react-slot": "^1.1.2",
15 | "class-variance-authority": "^0.7.1",
16 | "clsx": "^2.1.1",
17 | "date-fns": "^4.1.0",
18 | "framer-motion": "^12.4.7",
19 | "geist": "^1.3.1",
20 | "lucide-react": "^0.475.0",
21 | "next": "15.1.7",
22 | "next-themes": "^0.4.4",
23 | "react": "^19.0.0",
24 | "react-dom": "^19.0.0",
25 | "sonner": "^2.0.1",
26 | "tailwind-merge": "^3.0.1"
27 | },
28 | "devDependencies": {
29 | "@eslint/eslintrc": "^3",
30 | "@types/node": "^20",
31 | "@types/react": "^19",
32 | "@types/react-dom": "^19",
33 | "cross-env": "^7.0.3",
34 | "eslint": "^9",
35 | "eslint-config-next": "15.1.7",
36 | "postcss": "^8",
37 | "tailwindcss": "^3.4.1",
38 | "typescript": "^5"
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/renderer/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('postcss-load-config').Config} */
2 | const config = {
3 | plugins: {
4 | tailwindcss: {},
5 | },
6 | };
7 |
8 | export default config;
9 |
--------------------------------------------------------------------------------
/renderer/public/dashboard-light.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wey-gu/NebulaGraph-Desktop/69e25569e7d730d691d64bbecfa7b8237c9908a4/renderer/public/dashboard-light.png
--------------------------------------------------------------------------------
/renderer/public/docker-desktop.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wey-gu/NebulaGraph-Desktop/69e25569e7d730d691d64bbecfa7b8237c9908a4/renderer/public/docker-desktop.png
--------------------------------------------------------------------------------
/renderer/public/docker-running.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wey-gu/NebulaGraph-Desktop/69e25569e7d730d691d64bbecfa7b8237c9908a4/renderer/public/docker-running.png
--------------------------------------------------------------------------------
/renderer/public/docker-verify.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wey-gu/NebulaGraph-Desktop/69e25569e7d730d691d64bbecfa7b8237c9908a4/renderer/public/docker-verify.png
--------------------------------------------------------------------------------
/renderer/public/file.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/renderer/public/globe.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/renderer/public/nebula-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wey-gu/NebulaGraph-Desktop/69e25569e7d730d691d64bbecfa7b8237c9908a4/renderer/public/nebula-logo.png
--------------------------------------------------------------------------------
/renderer/public/nebula_arch.mp4:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wey-gu/NebulaGraph-Desktop/69e25569e7d730d691d64bbecfa7b8237c9908a4/renderer/public/nebula_arch.mp4
--------------------------------------------------------------------------------
/renderer/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/renderer/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/renderer/public/window.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/renderer/src/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wey-gu/NebulaGraph-Desktop/69e25569e7d730d691d64bbecfa7b8237c9908a4/renderer/src/app/favicon.ico
--------------------------------------------------------------------------------
/renderer/src/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | :root {
6 | --background: #ffffff;
7 | --foreground: #171717;
8 | }
9 |
10 | @media (prefers-color-scheme: dark) {
11 | :root {
12 | --background: #0a0a0a;
13 | --foreground: #ededed;
14 | }
15 | }
16 |
17 | body {
18 | color: var(--foreground);
19 | background: var(--background);
20 | font-family: Arial, Helvetica, sans-serif;
21 | }
22 |
--------------------------------------------------------------------------------
/renderer/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { GeistSans } from "geist/font/sans";
4 | import { ThemeProvider } from "@/components/theme-provider";
5 | import { Toaster } from 'sonner'
6 | import "./globals.css";
7 |
8 | export default function RootLayout({
9 | children,
10 | }: {
11 | children: React.ReactNode
12 | }) {
13 | return (
14 |
15 |
16 |
23 | {children}
24 |
25 |
26 |
27 |
28 | );
29 | }
30 |
--------------------------------------------------------------------------------
/renderer/src/app/metadata.ts:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next";
2 |
3 | export const metadata: Metadata = {
4 | title: "NebulaGraph",
5 | description: "Modern Distributed Graph Database",
6 | };
--------------------------------------------------------------------------------
/renderer/src/app/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useEffect, useState, useRef, useCallback } from 'react'
4 | import { ServicesGrid } from '@/components/features/services/services-grid'
5 | import { DockerAPI, ServiceStatus, MainProcessLog } from '@/types/docker'
6 | import { HeroSection } from '@/components/blocks/hero-section-dark'
7 | import { Spinner } from '@/components/ui/spinner'
8 | import { ThemeToggle } from '@/components/ui/theme-toggle'
9 | import { cn } from '@/lib/utils'
10 | import { Globe, ArrowUpRight, Info } from 'lucide-react'
11 | import { toast } from 'sonner'
12 | import { ServicesSkeleton } from '@/components/features/services/services-skeleton'
13 | import { DockerSetupGuide } from '@/components/features/docker/docker-setup-guide'
14 |
15 | declare global {
16 | interface Window {
17 | electronAPI: {
18 | docker: DockerAPI;
19 | browser: {
20 | openExternal: (url: string) => Promise;
21 | };
22 | logs: {
23 | subscribe: (callback: (log: MainProcessLog) => void) => () => void;
24 | };
25 | };
26 | }
27 | }
28 |
29 | export default function Home() {
30 | const [isDockerRunning, setIsDockerRunning] = useState(false)
31 | const [status, setStatus] = useState('Checking Docker status...')
32 | const [services, setServices] = useState>({})
33 | const [isLoading, setIsLoading] = useState(false)
34 | const [showDashboard, setShowDashboard] = useState(false)
35 | const [isInitialLoading, setIsInitialLoading] = useState(true)
36 | const [showDockerSetup, setShowDockerSetup] = useState(false)
37 | const [imageLoadingProgress, setImageLoadingProgress] = useState<{ current: number; total: number; status: string } | null>(null)
38 |
39 | // Add service status polling interval ref
40 | const statusPollingRef = useRef(null)
41 | const isFirstLoad = useRef(true)
42 |
43 | // Add image loading progress polling interval ref
44 | const imageLoadingPollingRef = useRef(null)
45 |
46 | // Optimize service status polling
47 | const pollServicesStatus = useCallback(async () => {
48 | try {
49 | const services = await window.electronAPI.docker.getServices()
50 | setServices(services)
51 |
52 | // Check if any service is still starting
53 | const hasStartingServices = Object.values(services).some(
54 | s => s.health.status === 'starting'
55 | )
56 |
57 | // Check service states and set appropriate status
58 | const runningCount = Object.values(services).filter(s => s.health.status === 'healthy').length
59 | const totalServices = Object.keys(services).length
60 | const errorCount = Object.values(services).filter(s => s.health.status === 'unhealthy').length
61 | const notCreatedCount = Object.values(services).filter(s => s.status === 'not_created' || s.health.status === 'not_created').length
62 |
63 | if (hasStartingServices) {
64 | setStatus('Services are starting...')
65 | } else if (errorCount > 0) {
66 | setStatus(`${errorCount} service${errorCount > 1 ? 's' : ''} in error state`)
67 | } else if (notCreatedCount === totalServices) {
68 | setStatus('Services not created yet')
69 | } else if (runningCount === 0) {
70 | setStatus('All services are stopped')
71 | } else if (runningCount === totalServices) {
72 | setStatus('All services are running')
73 | } else {
74 | setStatus(`${runningCount} of ${totalServices} services running`)
75 | }
76 |
77 | // Adjust polling interval based on service state
78 | if (statusPollingRef.current) {
79 | clearInterval(statusPollingRef.current)
80 | }
81 |
82 | statusPollingRef.current = setInterval(() => {
83 | pollServicesStatus()
84 | }, hasStartingServices ? 2000 : 5000) // Poll faster when services are starting
85 | } catch (error) {
86 | console.error('Failed to poll services:', error)
87 | setStatus('Error checking service status')
88 | }
89 | }, [])
90 |
91 | // Poll image loading progress
92 | const pollImageLoadingProgress = useCallback(async () => {
93 | try {
94 | const progress = await window.electronAPI.docker.getImageLoadingProgress();
95 | setImageLoadingProgress(progress);
96 |
97 | // If still loading, continue polling
98 | if (progress) {
99 | imageLoadingPollingRef.current = setTimeout(pollImageLoadingProgress, 1000);
100 | }
101 | } catch (error) {
102 | console.error('Failed to poll image loading progress:', error);
103 | }
104 | }, []);
105 |
106 | // Optimize initial load
107 | const initializeApp = useCallback(async () => {
108 | try {
109 | // Quick Docker status check first
110 | const result = await window.electronAPI.docker.status()
111 | setIsDockerRunning(result)
112 |
113 | if (!result) {
114 | setStatus('Docker is not running')
115 | setShowDockerSetup(true)
116 | setIsInitialLoading(false)
117 | return
118 | }
119 |
120 | // Start polling service status immediately
121 | await pollServicesStatus()
122 |
123 | // Only show setup guide if needed
124 | const systemStatus = await window.electronAPI.docker.systemStatus()
125 | if (!systemStatus.isInstalled || !systemStatus.compose?.isInstalled) {
126 | setShowDockerSetup(true)
127 | }
128 | } catch (error) {
129 | console.error('Error initializing app:', error)
130 | setStatus('Error checking Docker status')
131 | setShowDockerSetup(true)
132 | } finally {
133 | setIsInitialLoading(false)
134 | }
135 | }, [pollServicesStatus])
136 |
137 | // Handle cleanup
138 | useEffect(() => {
139 | return () => {
140 | if (statusPollingRef.current) {
141 | clearInterval(statusPollingRef.current)
142 | }
143 | }
144 | }, [])
145 |
146 | // Initialize app
147 | useEffect(() => {
148 | if (isFirstLoad.current) {
149 | isFirstLoad.current = false
150 | initializeApp()
151 | // Start polling image loading progress
152 | pollImageLoadingProgress()
153 | }
154 | }, [initializeApp, pollImageLoadingProgress])
155 |
156 | // Cleanup polling
157 | useEffect(() => {
158 | return () => {
159 | if (imageLoadingPollingRef.current) {
160 | clearTimeout(imageLoadingPollingRef.current)
161 | }
162 | }
163 | }, [])
164 |
165 | const startNebulaGraph = async () => {
166 | setIsLoading(true)
167 | setStatus('Starting NebulaGraph services...')
168 |
169 | try {
170 | const result = await window.electronAPI.docker.start()
171 |
172 | if (result.success) {
173 | setStatus('NebulaGraph services started successfully')
174 | await pollServicesStatus()
175 | toast.success('NebulaGraph services started')
176 | } else {
177 | setStatus(`Failed to start services: ${result.error}`)
178 | toast.error('Failed to start services', {
179 | description: result.error || 'An unknown error occurred.'
180 | })
181 | }
182 | } catch (error) {
183 | console.error('Start error:', error)
184 | setStatus('Error starting services')
185 | toast.error('Error starting services')
186 | } finally {
187 | setIsLoading(false)
188 | }
189 | }
190 |
191 | const stopNebulaGraph = async () => {
192 | if (!window.electronAPI?.docker) return
193 |
194 | console.log('⏹️ Stopping NebulaGraph services...')
195 | setIsLoading(true)
196 | setStatus('Stopping NebulaGraph services...')
197 | try {
198 | const result = await window.electronAPI.docker.stop()
199 | console.log('✓ Stop result:', result)
200 |
201 | if (result.success) {
202 | setStatus('NebulaGraph services stopped successfully')
203 | await pollServicesStatus()
204 | console.log('✓ Services after stop:', services)
205 | toast.success('NebulaGraph services stopped', {
206 | description: 'All services have been stopped.'
207 | })
208 | } else {
209 | console.error('✕ Stop failed:', result.error)
210 | setStatus(`Failed to stop services: ${result.error}`)
211 | toast.error('Failed to stop services', {
212 | description: result.error || 'An unknown error occurred.'
213 | })
214 | }
215 | } catch (error) {
216 | console.error('✕ Stop error:', error)
217 | setStatus('Error stopping services')
218 | toast.error('Error stopping services', {
219 | description: 'Please try again or check the logs for more details.'
220 | })
221 | }
222 | setIsLoading(false)
223 | }
224 |
225 | const openStudio = () => {
226 | if (!window.electronAPI?.docker) return
227 |
228 | console.log('🌐 Opening NebulaGraph Studio...')
229 | if (!Object.values(services).some(s => s.name === 'studio' && s.health.status === 'healthy')) {
230 | console.warn('⚠️ Studio is not running')
231 | toast.error('Studio is not available', {
232 | description: 'Please make sure all services are running first.'
233 | })
234 | return
235 | }
236 |
237 | window.electronAPI.browser.openExternal('http://localhost:7001')
238 | .then(() => {
239 | toast.success('Opening NebulaGraph Studio', {
240 | description: 'Please note the IP address for graphd is `graphd`'
241 | })
242 | })
243 | .catch(error => {
244 | console.error('Failed to open Studio:', error)
245 | toast.error('Failed to open Studio', {
246 | description: 'Please try opening http://localhost:7001 manually in your browser.'
247 | })
248 | })
249 | }
250 |
251 | return (
252 |
253 | {showDockerSetup && (
254 |
255 | )}
256 | {!showDashboard ? (
257 |
{
278 | // First show the dashboard
279 | setShowDashboard(true);
280 | // Then check and load images if needed
281 | const result = await window.electronAPI.docker.ensureImagesLoaded();
282 | if (!result) {
283 | toast.error('Failed to load Docker images', {
284 | description: 'Please make sure Docker is running and try again.'
285 | });
286 | }
287 | }}
288 | className="animate-fade-in"
289 | />
290 | ) : isInitialLoading ? (
291 |
292 | ) : (
293 |
294 | {/* Loading Overlay */}
295 | {imageLoadingProgress && (
296 |
297 |
298 |
299 |
Preparing NebulaGraph
300 |
301 | {imageLoadingProgress.status}
302 |
303 |
309 |
310 | {imageLoadingProgress.current} of {imageLoadingProgress.total} images
311 |
312 |
313 |
314 |
315 | )}
316 |
317 |
318 |
319 |
setShowDashboard(false)}
321 | className="group flex items-center gap-2 text-gray-600 dark:text-gray-400 hover:text-black dark:hover:text-white transition-all"
322 | >
323 | ←
324 | {/* */}
325 |
326 |
327 |
328 |
329 | Console
330 |
331 |
332 |
333 | Manage NebulaGraph Desktop services
334 |
335 |
336 |
337 |
338 |
346 |
347 | {isLoading && }
348 | {status}
349 |
350 |
351 |
352 |
353 |
354 |
355 |
356 |
357 |
358 |
359 |
Active Services
360 |
361 | {Object.values(services).filter(s => s.health.status === 'healthy').length}
362 | / {Object.keys(services).length}
363 |
364 |
365 |
366 |
s.health.status === 'healthy').length / Object.keys(services).length) * 100)}%` }}
369 | />
370 |
371 |
372 | {Object.keys(services).length === 0 ? '0%' : `${Math.round((Object.values(services).filter(s => s.health.status === 'healthy').length / Object.keys(services).length) * 100)}%`}
373 |
374 |
375 |
376 |
377 |
378 |
379 |
380 |
Docker
381 |
382 | {isDockerRunning ? 'Active' : 'Inactive'}
383 |
384 |
385 |
389 |
390 | {isDockerRunning ? 'System ready' : 'System offline'}
391 |
392 |
393 |
394 |
395 |
396 |
397 |
398 |
Health
399 |
400 | {Object.values(services).filter(s => s.health.status === 'unhealthy').length === 0 ? 'Good' : 'Warning'}
401 |
402 |
403 |
s.health.status === 'unhealthy').length === 0 ? "bg-green-500" : "bg-yellow-500"
406 | )} />
407 |
408 | {Object.values(services).filter(s => s.health.status === 'unhealthy').length === 0 ? 'All systems normal' : `${Object.values(services).filter(s => s.health.status === 'unhealthy').length} issues found`}
409 |
410 |
411 |
412 |
413 |
414 |
415 |
416 |
Studio
417 |
418 | {Object.values(services).some(s => s.name === 'studio' && s.health.status === 'healthy') ? 'Ready' : 'Offline'}
419 |
420 |
421 |
s.name === 'studio' && s.health.status === 'healthy') ? "bg-green-500" : "bg-gray-500"
424 | )} />
425 |
426 | {Object.values(services).some(s => s.name === 'studio' && s.health.status === 'healthy') ? 'Web console ready' : 'Console unavailable'}
427 |
428 |
429 |
430 |
431 |
432 |
433 |
434 |
435 |
436 |
437 | Controls
438 |
439 |
445 |
446 |
450 | {isDockerRunning ? "Docker Ready" : "Docker Offline"}
451 |
452 |
453 |
454 |
455 |
475 | {isLoading ? (
476 |
477 |
478 |
479 |
480 | ) : (
481 |
482 |
485 |
Start All
486 |
487 | )}
488 |
489 |
490 |
511 | {isLoading ? (
512 |
513 |
514 |
515 |
516 | ) : (
517 |
518 |
521 |
Stop All
522 |
523 | )}
524 |
525 |
526 |
527 |
528 |
529 |
530 |
531 |
532 |
533 | Studio
534 |
535 |
s.name === 'studio' && s.health.status === 'healthy')
538 | ? "bg-green-100/10 text-green-400 group-hover:bg-green-100/20"
539 | : "bg-gray-100/10 text-gray-400 group-hover:bg-gray-100/20"
540 | )}>
541 |
542 |
s.name === 'studio' && s.health.status === 'healthy')
545 | ? "bg-green-400 animate-pulse"
546 | : "bg-gray-400"
547 | )} />
548 | :7001
549 |
550 |
551 |
552 |
553 |
s.name === 'studio' && s.health.status === 'healthy')}
556 | className={cn(
557 | "w-full sm:w-auto px-8 py-6 transition-all duration-300",
558 | "rounded-xl text-sm font-medium select-none",
559 | "bg-gradient-to-tr from-zinc-300/20 via-blue-400/30 to-transparent dark:from-zinc-300/5 dark:via-blue-400/20",
560 | "text-gray-700 dark:text-gray-200",
561 | "border border-gray-200 dark:border-gray-800",
562 | "hover:bg-gradient-to-tr hover:from-zinc-300/30 hover:via-blue-400/40 hover:to-transparent dark:hover:from-zinc-300/10 dark:hover:via-blue-400/30",
563 | "hover:text-gray-900 dark:hover:text-white",
564 | "hover:scale-[1.02] hover:z-10",
565 | "active:scale-[0.98] active:duration-200",
566 | "disabled:opacity-50",
567 | "disabled:hover:bg-none disabled:hover:scale-100",
568 | "disabled:hover:text-gray-500 dark:disabled:hover:text-gray-400",
569 | "group/btn relative"
570 | )}
571 | >
572 |
573 |
574 |
Launch Studio
575 |
576 |
577 |
578 |
579 | {Object.values(services).some(s => s.name === 'studio' && s.health.status === 'healthy')
580 | ? IP address: `graphd`
581 | : ''}
582 |
583 |
584 |
585 |
586 |
587 |
588 |
593 |
594 |
595 |
596 | )}
597 |
598 | )
599 | }
600 |
--------------------------------------------------------------------------------
/renderer/src/components/blocks/feature-section.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import React, { useState, useEffect } from "react"
4 | import { motion, AnimatePresence } from "framer-motion"
5 | import Image from "next/image"
6 | import { cn } from "@/lib/utils"
7 |
8 | interface Feature {
9 | step: string
10 | title?: string
11 | content: string
12 | image: string
13 | }
14 |
15 | interface FeatureStepsProps {
16 | features: Feature[]
17 | className?: string
18 | title?: string
19 | autoPlayInterval?: number
20 | }
21 |
22 | export function FeatureSteps({
23 | features,
24 | className,
25 | title = "How to get Started",
26 | autoPlayInterval = 3000,
27 | }: FeatureStepsProps) {
28 | const [currentFeature, setCurrentFeature] = useState(0)
29 | const [progress, setProgress] = useState(0)
30 |
31 | useEffect(() => {
32 | const timer = setInterval(() => {
33 | if (progress < 100) {
34 | setProgress((prev) => prev + 100 / (autoPlayInterval / 100))
35 | } else {
36 | setCurrentFeature((prev) => (prev + 1) % features.length)
37 | setProgress(0)
38 | }
39 | }, 100)
40 |
41 | return () => clearInterval(timer)
42 | }, [progress, features.length, autoPlayInterval])
43 |
44 | return (
45 |
46 |
47 |
48 | {title}
49 |
50 |
51 |
52 |
53 | {features.map((feature, index) => (
54 |
61 |
69 | {index <= currentFeature ? (
70 | ✓
71 | ) : (
72 | {index + 1}
73 | )}
74 |
75 |
76 |
77 |
78 | {feature.title || feature.step}
79 |
80 |
81 | {feature.content}
82 |
83 |
84 |
85 | ))}
86 |
87 |
88 |
93 |
94 | {features.map(
95 | (feature, index) =>
96 | index === currentFeature && (
97 |
105 |
112 |
113 |
114 | ),
115 | )}
116 |
117 |
118 |
119 |
120 |
121 | )
122 | }
--------------------------------------------------------------------------------
/renderer/src/components/blocks/hero-section-dark.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { cn } from "@/lib/utils"
3 | import { ChevronRight } from "lucide-react"
4 |
5 | interface HeroSectionProps extends React.HTMLAttributes
{
6 | title?: string
7 | subtitle?: {
8 | regular: string
9 | gradient: string
10 | }
11 | description?: string
12 | ctaText?: string
13 | ctaHref?: string
14 | bottomImage?: {
15 | light: string
16 | dark: string
17 | }
18 | gridOptions?: {
19 | angle?: number
20 | cellSize?: number
21 | opacity?: number
22 | lightLineColor?: string
23 | darkLineColor?: string
24 | }
25 | onClick?: () => void
26 | }
27 |
28 | const RetroGrid = ({
29 | angle = 65,
30 | cellSize = 60,
31 | opacity = 0.5,
32 | lightLineColor = "gray",
33 | darkLineColor = "gray",
34 | }) => {
35 | const gridStyles = {
36 | "--grid-angle": `${angle}deg`,
37 | "--cell-size": `${cellSize}px`,
38 | "--opacity": opacity,
39 | "--light-line": lightLineColor,
40 | "--dark-line": darkLineColor,
41 | } as React.CSSProperties
42 |
43 | return (
44 |
56 | )
57 | }
58 |
59 | const HeroSection = React.forwardRef(
60 | (
61 | {
62 | className,
63 | title = "",
64 | subtitle = {
65 | regular: "Designing your projects faster with ",
66 | gradient: "the largest figma UI kit.",
67 | },
68 | description = "A desktop application for managing NebulaGraph databases",
69 | ctaText = "Get Started",
70 | ctaHref = "#",
71 | bottomImage = {
72 | light: './nebula_arch.mp4',
73 | dark: './nebula_arch.mp4'
74 | },
75 | gridOptions,
76 | onClick,
77 | ...props
78 | },
79 | ref,
80 | ) => {
81 | return (
82 |
83 |
84 |
85 |
86 |
87 |
88 |
94 |
95 | {title}
96 |
97 |
98 |
99 |
100 | {subtitle.regular}
101 |
102 | {subtitle.gradient}
103 |
104 |
105 |
106 | {description}
107 |
108 |
109 |
110 |
111 |
112 |
116 | {ctaText}
117 |
118 |
119 |
120 |
121 |
122 |
134 |
135 |
136 |
137 | )
138 | },
139 | )
140 | HeroSection.displayName = "HeroSection"
141 |
142 | export { HeroSection }
--------------------------------------------------------------------------------
/renderer/src/components/features/docker/docker-setup-guide.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { FeatureSteps } from "@/components/blocks/feature-section"
4 | import { useEffect, useState } from 'react'
5 | import { cn } from '@/lib/utils'
6 |
7 | const dockerSetupFeatures = [
8 | {
9 | step: 'Step 1',
10 | title: 'Docker Desktop Installed',
11 | content: 'Download and install the latest version of Docker Desktop from docker.com. This will provide you with Docker Engine and Docker Compose v2 for container management.',
12 | image: './docker-desktop.png'
13 | },
14 | {
15 | step: 'Step 2',
16 | title: 'Docker Desktop Running',
17 | content: 'Start Docker Desktop and verify it is running properly. You should see the Docker whale icon in your system tray indicating the engine is active.',
18 | image: './docker-running.png'
19 | },
20 | {
21 | step: 'Step 3',
22 | title: 'Compose v2 Installed',
23 | content: 'Open a terminal and run these commands to verify the installation: "docker --version" to check Docker CLI and "docker compose version" to confirm Docker Compose v2 is installed correctly.',
24 | image: './docker-verify.png'
25 | }
26 | ]
27 |
28 | interface Log {
29 | level: 'info' | 'warn' | 'error';
30 | message: string;
31 | timestamp: string;
32 | }
33 |
34 | interface DockerSetupGuideProps {
35 | onComplete?: () => void
36 | className?: string
37 | }
38 |
39 | export function DockerSetupGuide({ onComplete, className }: DockerSetupGuideProps) {
40 | const [logs, setLogs] = useState([]);
41 |
42 | useEffect(() => {
43 | // Subscribe to logs from the main process
44 | const unsubscribe = window.electronAPI.logs.subscribe((log: Log) => {
45 | setLogs(prev => [...prev, log]);
46 | });
47 |
48 | return () => {
49 | unsubscribe();
50 | };
51 | }, []);
52 |
53 | return (
54 |
55 |
56 |
57 |
58 |
65 |
66 | {/* Log Viewer */}
67 |
68 |
69 | Diagnostic Information
70 |
71 |
72 | {logs.map((log, index) => (
73 |
84 | [{new Date(log.timestamp).toLocaleTimeString()}] {' '}
85 | {log.message}
86 |
87 | ))}
88 |
89 |
90 |
91 |
92 |
96 | Check again
97 |
98 |
99 |
100 |
101 |
102 |
103 | )
104 | }
--------------------------------------------------------------------------------
/renderer/src/components/features/services/nebula-service-card.tsx:
--------------------------------------------------------------------------------
1 | import { ServiceStatus } from '@/types/docker';
2 | import { cn } from '@/lib/utils';
3 | import {
4 | Activity, Globe,
5 | Server, Database, Terminal, Cpu,
6 | RotateCw, Square, Play, Loader2
7 | } from 'lucide-react';
8 | import { Button } from '@/components/ui/button';
9 | import { useState } from 'react';
10 | import { ServiceLogs } from './service-logs';
11 | import { toast } from 'sonner';
12 | import { AnimatePresence } from 'framer-motion';
13 |
14 | interface NebulaServiceCardProps {
15 | service: ServiceStatus;
16 | isLoading?: boolean;
17 | onServiceUpdate: () => Promise;
18 | }
19 |
20 | export function NebulaServiceCard({ service, isLoading, onServiceUpdate }: NebulaServiceCardProps) {
21 | const [showLogs, setShowLogs] = useState(false);
22 | const [loadingAction, setLoadingAction] = useState(null);
23 |
24 | const handleServiceAction = async (action: 'start' | 'stop' | 'restart') => {
25 | setLoadingAction(action);
26 | try {
27 | let result;
28 | switch (action) {
29 | case 'start':
30 | result = await window.electronAPI.docker.startService(service.name);
31 | break;
32 | case 'stop':
33 | result = await window.electronAPI.docker.stopService(service.name);
34 | break;
35 | case 'restart':
36 | result = await window.electronAPI.docker.restartService(service.name);
37 | break;
38 | }
39 |
40 | if (result.success) {
41 | await onServiceUpdate();
42 | toast.success(`${action.charAt(0).toUpperCase() + action.slice(1)}ed ${getServiceDisplayName(service.name)}`);
43 | console.log('Service status after action:', service.name, service.status, service.health);
44 | } else if (result.error) {
45 | toast.error(`Failed to ${action} service`, {
46 | description: result.error
47 | });
48 | }
49 | } catch (error) {
50 | toast.error(`Failed to ${action} service`);
51 | console.error('Failed to start service:', error);
52 | }
53 | setLoadingAction(null);
54 | };
55 |
56 | const getServiceDisplayName = (name: string) => {
57 | switch (name) {
58 | case 'studio':
59 | return 'NebulaGraph Studio';
60 | case 'metad':
61 | return 'Meta Service';
62 | case 'storaged':
63 | return 'Storage Service';
64 | case 'graphd':
65 | return 'Graph Service';
66 | default:
67 | return name;
68 | }
69 | };
70 |
71 | const getServiceIcon = () => {
72 | switch (service.name) {
73 | case 'studio':
74 | return ;
75 | case 'metad':
76 | return ;
77 | case 'storaged':
78 | return ;
79 | case 'graphd':
80 | return ;
81 | default:
82 | return ;
83 | }
84 | };
85 |
86 | const formatMemory = (memory: number) => {
87 | if (memory < 1024) return `${Math.round(memory)} MB`;
88 | return `${(memory / 1024).toFixed(1)} GB`;
89 | };
90 |
91 | // const formatUptime = (seconds: number) => {
92 | // const hours = Math.floor(seconds / 3600);
93 | // const minutes = Math.floor((seconds % 3600) / 60);
94 | // if (hours > 0) return `${hours}h ${minutes}m`;
95 | // return `${minutes}m`;
96 | // };
97 |
98 | const isServiceNotCreated = service.status === 'not_created';
99 | const isServiceRunning = service.status === 'running' && service.health.status === 'healthy';
100 |
101 | return (
102 | <>
103 |
114 |
115 | {/* Header */}
116 |
117 |
118 |
127 |
128 | {getServiceIcon()}
129 | {isServiceRunning && (
130 |
134 | )}
135 |
136 |
137 |
138 |
139 |
140 | {getServiceDisplayName(service.name)}
141 |
142 | {!isServiceNotCreated && service.ports && service.ports.length > 0 && (
143 |
179 | )}
180 |
181 |
182 |
192 |
193 | {isServiceNotCreated ? 'Stopped' :
194 | service.health.status === 'healthy' ? 'Healthy' :
195 | service.health.status === 'unhealthy' ? 'Unhealthy' :
196 | service.health.status === 'starting' ? 'Starting' :
197 | 'Unknown'}
198 |
199 |
200 |
201 |
202 |
203 | {/* Action Buttons */}
204 |
205 |
setShowLogs(true)}
216 | disabled={isServiceNotCreated}
217 | >
218 |
219 | View logs
220 |
221 | {/* Show start button if service exists but is not running */}
222 | {(!service.status || service.status === 'stopped') && !isServiceNotCreated ? (
223 |
handleServiceAction('start')}
234 | >
235 | {loadingAction === 'start' ? (
236 |
237 | ) : (
238 |
239 | )}
240 |
241 | ) : service.status === 'running' ? (
242 | <>
243 |
handleServiceAction('stop')}
254 | >
255 | {loadingAction === 'stop' ? (
256 |
257 | ) : (
258 |
259 | )}
260 |
261 |
handleServiceAction('restart')}
272 | >
273 | {loadingAction === 'restart' ? (
274 |
275 | ) : (
276 |
277 | )}
278 |
279 | >
280 | ) : null}
281 |
282 |
283 |
284 | {/* Metrics */}
285 | {isServiceRunning && service.metrics && (
286 |
287 | {/* CPU Usage */}
288 |
289 |
290 |
291 |
292 |
80 ? "bg-red-500" :
296 | parseFloat(service.metrics.cpu) > 60 ? "bg-yellow-500" :
297 | "bg-blue-500"
298 | )}
299 | style={{ width: `${Math.min(100, parseFloat(service.metrics.cpu))}%` }}
300 | />
301 |
302 |
303 | {service.metrics.cpu}
304 |
305 |
306 |
CPU Usage
307 |
308 |
309 | {/* Memory Usage */}
310 |
311 |
312 |
313 |
314 |
1024 ? "bg-red-500" :
318 | parseFloat(service.metrics.memory) > 512 ? "bg-yellow-500" :
319 | "bg-blue-500"
320 | )}
321 | style={{ width: `${Math.min(100, (parseFloat(service.metrics.memory) / 1024) * 100)}%` }}
322 | />
323 |
324 |
325 | {formatMemory(parseFloat(service.metrics.memory))}
326 |
327 |
328 |
Memory
329 |
330 |
331 | {/* Network I/O */}
332 |
333 |
334 |
335 |
336 |
337 |
338 | {service.metrics.network}
339 |
340 |
341 |
342 |
343 |
Network I/O
344 |
345 |
346 | )}
347 |
348 | {/* Not Created Message */}
349 | {isServiceNotCreated && (
350 |
351 |
352 |
353 |
354 | Service will be created/started when clicking `Start All`
355 |
356 |
357 |
358 | )}
359 |
360 |
361 |
362 | {/* Render logs portal outside the card to prevent layout issues */}
363 |
364 | {showLogs && (
365 | setShowLogs(false)}
370 | />
371 | )}
372 |
373 | >
374 | );
375 | }
--------------------------------------------------------------------------------
/renderer/src/components/features/services/service-logs.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useState, useEffect, useRef, useCallback } from 'react'
4 | import { Button } from '@/components/ui/button'
5 | import { cn } from '@/lib/utils'
6 | import { Terminal, X, Maximize2, Minimize2, Copy, Download, ArrowDown } from 'lucide-react'
7 | import { toast } from 'sonner'
8 | import { LogEntry } from '@/types/docker'
9 | import { useClickAway } from '@/hooks/use-click-away'
10 | import { motion } from 'framer-motion'
11 | import { createPortal } from 'react-dom'
12 |
13 | interface ServiceLogsProps {
14 | serviceName: string
15 | isOpen: boolean
16 | onClose: () => void
17 | }
18 |
19 | export function ServiceLogs({ serviceName, isOpen, onClose }: ServiceLogsProps) {
20 | const [logs, setLogs] = useState
([])
21 | const [isLoading, setIsLoading] = useState(false)
22 | const [isExpanded, setIsExpanded] = useState(false)
23 | const [autoScroll, setAutoScroll] = useState(true)
24 | const [filter, setFilter] = useState<'all' | 'error' | 'warn' | 'info'>('all')
25 | const [mounted, setMounted] = useState(false)
26 |
27 | const logsEndRef = useRef(null)
28 | const containerRef = useRef(null)
29 | const contentRef = useRef(null)
30 | const scrollTimeoutRef = useRef(null)
31 | const resizeObserverRef = useRef(null)
32 |
33 | useClickAway(containerRef as React.RefObject, () => {
34 | if (isOpen) onClose()
35 | })
36 |
37 | // Handle mounting for portal
38 | useEffect(() => {
39 | setMounted(true)
40 | return () => setMounted(false)
41 | }, [])
42 |
43 | const fetchLogs = useCallback(async (): Promise => {
44 | if (!isOpen) return
45 |
46 | setIsLoading(true)
47 | try {
48 | const newLogs = await window.electronAPI.docker.getLogs(serviceName)
49 | setLogs(newLogs)
50 | } catch (error) {
51 | toast.error('Failed to fetch logs', {
52 | description: 'Could not retrieve service logs. Please try again.'
53 | })
54 | console.error('Failed to fetch logs:', error);
55 | } finally {
56 | setIsLoading(false)
57 | }
58 | }, [isOpen, serviceName])
59 |
60 | // Handle log polling
61 | useEffect(() => {
62 | let interval: NodeJS.Timeout | undefined
63 |
64 | if (isOpen) {
65 | void fetchLogs()
66 | interval = setInterval(fetchLogs, 5000)
67 | }
68 |
69 | return () => {
70 | if (interval) clearInterval(interval)
71 | }
72 | }, [isOpen, fetchLogs])
73 |
74 | // Handle auto-scrolling with debounce
75 | useEffect(() => {
76 | if (autoScroll && logsEndRef.current) {
77 | if (scrollTimeoutRef.current) {
78 | clearTimeout(scrollTimeoutRef.current)
79 | }
80 |
81 | scrollTimeoutRef.current = setTimeout(() => {
82 | logsEndRef.current?.scrollIntoView({ behavior: 'smooth' })
83 | }, 100)
84 | }
85 |
86 | return () => {
87 | if (scrollTimeoutRef.current) {
88 | clearTimeout(scrollTimeoutRef.current)
89 | }
90 | }
91 | }, [logs, autoScroll])
92 |
93 | // Handle keyboard events
94 | useEffect(() => {
95 | if (isOpen) {
96 | const handleKeyDown = (e: KeyboardEvent) => {
97 | if (e.key === 'Escape') {
98 | e.preventDefault()
99 | onClose()
100 | }
101 | }
102 | window.addEventListener('keydown', handleKeyDown)
103 | return () => window.removeEventListener('keydown', handleKeyDown)
104 | }
105 | }, [isOpen, onClose])
106 |
107 | // Handle resize observer for content height
108 | useEffect(() => {
109 | if (contentRef.current) {
110 | resizeObserverRef.current = new ResizeObserver(() => {
111 | if (autoScroll) {
112 | logsEndRef.current?.scrollIntoView({ behavior: 'smooth' })
113 | }
114 | })
115 |
116 | resizeObserverRef.current.observe(contentRef.current)
117 | }
118 |
119 | return () => {
120 | if (resizeObserverRef.current) {
121 | resizeObserverRef.current.disconnect()
122 | }
123 | }
124 | }, [autoScroll])
125 |
126 | const copyLogs = () => {
127 | const filteredLogs = logs
128 | .filter(log => filter === 'all' || log.level === filter)
129 | .map(log => `[${new Date(log.timestamp).toLocaleTimeString()}] ${log.message}`)
130 | .join('\n')
131 |
132 | navigator.clipboard.writeText(filteredLogs)
133 | toast.success('Logs copied to clipboard')
134 | }
135 |
136 | const downloadLogs = () => {
137 | const filteredLogs = logs
138 | .filter(log => filter === 'all' || log.level === filter)
139 | .map(log => `[${new Date(log.timestamp).toLocaleTimeString()}] ${log.message}`)
140 | .join('\n')
141 |
142 | const blob = new Blob([filteredLogs], { type: 'text/plain' })
143 | const url = window.URL.createObjectURL(blob)
144 | const a = document.createElement('a')
145 | a.href = url
146 | a.download = `${serviceName.toLowerCase()}-logs.txt`
147 | document.body.appendChild(a)
148 | a.click()
149 | document.body.removeChild(a)
150 | window.URL.revokeObjectURL(url)
151 | toast.success('Logs downloaded successfully')
152 | }
153 |
154 | const filteredLogs = logs.filter(log => filter === 'all' || log.level === filter)
155 | const errorCount = logs.filter(log => log.level === 'error').length
156 | const warnCount = logs.filter(log => log.level === 'warn').length
157 | const infoCount = logs.filter(log => log.level === 'info').length
158 |
159 | if (!mounted || !isOpen) return null
160 |
161 | const content = (
162 |
163 | {/* Backdrop */}
164 |
171 |
172 | {/* Modal */}
173 |
174 |
186 | {/* Header */}
187 |
188 |
189 |
190 |
191 |
192 |
193 |
194 | {serviceName} Logs
195 |
196 |
197 | Live service logs and events
198 |
199 |
200 |
201 |
202 | {/* Filters */}
203 |
204 | setFilter('all')}
207 | count={logs.length}
208 | label="All"
209 | />
210 | setFilter('error')}
213 | count={errorCount}
214 | label="Errors"
215 | variant="error"
216 | />
217 | setFilter('warn')}
220 | count={warnCount}
221 | label="Warnings"
222 | variant="warning"
223 | />
224 | setFilter('info')}
227 | count={infoCount}
228 | label="Info"
229 | variant="info"
230 | />
231 |
232 |
233 |
234 |
239 |
244 |
setIsExpanded(!isExpanded)}
247 | label={isExpanded ? "Minimize" : "Maximize"}
248 | />
249 |
254 |
255 |
256 |
257 | {/* Content */}
258 | {
262 | const { scrollTop, scrollHeight, clientHeight } = e.currentTarget
263 | const isAtBottom = Math.abs(scrollHeight - scrollTop - clientHeight) < 10
264 | setAutoScroll(isAtBottom)
265 | }}
266 | >
267 | {isLoading && logs.length === 0 ? (
268 |
269 | ) : logs.length === 0 ? (
270 |
271 | ) : (
272 | } />
273 | )}
274 |
275 | {/* Auto-scroll indicator */}
276 | {!autoScroll && (
277 | {
279 | setAutoScroll(true)
280 | logsEndRef.current?.scrollIntoView({ behavior: 'smooth' })
281 | }}
282 | />
283 | )}
284 |
285 |
286 |
287 |
288 | )
289 |
290 | return createPortal(content, document.body)
291 | }
292 |
293 | // Helper Components
294 | function FilterButton({ active, onClick, count, label, variant }: {
295 | active: boolean
296 | onClick: () => void
297 | count: number
298 | label: string
299 | variant?: 'error' | 'warning' | 'info'
300 | }) {
301 | return (
302 |
313 | {label} ({count})
314 |
315 | )
316 | }
317 |
318 | function ActionButton({ icon: Icon, onClick, label }: {
319 | icon: typeof Copy
320 | onClick: () => void
321 | label: string
322 | }) {
323 | return (
324 |
330 |
331 | {label}
332 |
333 | )
334 | }
335 |
336 | function LoadingState() {
337 | return (
338 |
341 | )
342 | }
343 |
344 | function EmptyState() {
345 | return (
346 |
347 | No logs available
348 |
349 | )
350 | }
351 |
352 | function LogsList({ logs, logsEndRef }: {
353 | logs: LogEntry[],
354 | logsEndRef: React.RefObject
355 | }) {
356 | return (
357 |
358 | {logs.map((log, index) => (
359 |
370 |
371 | [{new Date(log.timestamp).toLocaleTimeString()}]
372 | {' '}
373 | {log.message}
374 |
375 | ))}
376 |
377 |
378 | )
379 | }
380 |
381 | function ScrollToBottomButton({ onClick }: { onClick: () => void }) {
382 | return (
383 |
395 |
396 | Scroll to Bottom
397 |
398 | )
399 | }
--------------------------------------------------------------------------------
/renderer/src/components/features/services/service-metrics.tsx:
--------------------------------------------------------------------------------
1 | import { ServiceStatus } from '@/types/docker'
2 | import { cn } from '@/lib/utils'
3 | import { Activity, Cpu, Database, Users } from 'lucide-react'
4 | import { formatDistanceToNow } from 'date-fns'
5 |
6 | interface ServiceMetricsProps {
7 | service: ServiceStatus
8 | }
9 |
10 | export function ServiceMetrics({ service }: ServiceMetricsProps) {
11 | if (!service.metrics || !service.health) return null
12 |
13 | const formatMemory = (memory: number) => {
14 | if (memory < 1024) return `${Math.round(memory)} MB`
15 | return `${(memory / 1024).toFixed(1)} GB`
16 | }
17 |
18 | const formatUptime = (seconds: number) => {
19 | const hours = Math.floor(seconds / 3600)
20 | const minutes = Math.floor((seconds % 3600) / 60)
21 | if (hours > 0) return `${hours}h ${minutes}m`
22 | return `${minutes}m`
23 | }
24 |
25 | return (
26 |
27 |
28 |
29 | Health Status
30 |
31 |
32 |
40 |
48 | {service.health.status.charAt(0).toUpperCase() + service.health.status.slice(1)}
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 | CPU Usage
58 |
59 |
60 |
66 |
67 | {parseFloat(service.metrics.cpu).toFixed(1)}%
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 | Memory
76 |
77 |
78 | {formatMemory(parseFloat(service.metrics.memory))}
79 |
80 |
81 |
82 |
83 |
87 |
88 | {service.metrics.uptime ? formatUptime(service.metrics.uptime) : 'N/A'}
89 |
90 |
91 |
92 |
93 |
94 |
95 | Connections
96 |
97 |
98 | {service.metrics.connections}
99 |
100 |
101 |
102 |
103 |
104 | Last check: {formatDistanceToNow(new Date(service.health.lastCheck))} ago
105 | {service.health.failureCount > 0 && (
106 |
107 | {service.health.failureCount} failure{service.health.failureCount === 1 ? '' : 's'}
108 |
109 | )}
110 |
111 |
112 | )
113 | }
--------------------------------------------------------------------------------
/renderer/src/components/features/services/services-grid.tsx:
--------------------------------------------------------------------------------
1 | import { ServiceStatus } from '@/types/docker'
2 | import { NebulaServiceCard } from './nebula-service-card'
3 |
4 | interface ServicesGridProps {
5 | services: Record
6 | isLoading?: boolean
7 | onServiceUpdate: () => Promise
8 | }
9 |
10 | export function ServicesGrid({ services, isLoading, onServiceUpdate }: ServicesGridProps) {
11 | // Sort services in the correct order: metad -> storaged -> graphd -> studio
12 | const serviceOrder = ['metad', 'storaged', 'graphd', 'studio'];
13 | const sortedServices = Object.entries(services).sort(([a], [b]) => {
14 | return serviceOrder.indexOf(a) - serviceOrder.indexOf(b);
15 | });
16 |
17 | // Calculate service counts
18 | const totalServices = Object.keys(services).length;
19 | const notCreatedCount = Object.values(services).filter(s =>
20 | s.status === 'not_created' || s.health.status === 'not_created'
21 | ).length;
22 | const runningCount = Object.values(services).filter(s =>
23 | s.status === 'running' && s.health.status === 'healthy'
24 | ).length;
25 |
26 | // Get the status message
27 | const getStatusMessage = () => {
28 | if (notCreatedCount === totalServices) {
29 | return `${totalServices} services ready to be created`;
30 | }
31 | if (runningCount === totalServices) {
32 | return `All ${totalServices} services running`;
33 | }
34 | if (runningCount > 0) {
35 | return `${runningCount} of ${totalServices} services running`;
36 | }
37 | return `${totalServices} services`;
38 | };
39 |
40 | return (
41 |
42 |
43 |
44 | Services
45 |
46 |
47 | {getStatusMessage()}
48 |
49 |
50 |
51 | {sortedServices.map(([id, service]) => (
52 |
58 | ))}
59 |
60 |
61 | );
62 | }
--------------------------------------------------------------------------------
/renderer/src/components/features/services/services-skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils"
2 |
3 | export function ServicesSkeleton() {
4 | return (
5 |
6 |
19 |
20 |
21 |
22 | {[...Array(4)].map((_, i) => (
23 |
39 | ))}
40 |
41 |
42 |
54 |
55 |
67 |
68 |
69 |
70 |
74 |
75 | {[...Array(4)].map((_, i) => (
76 |
80 | ))}
81 |
82 |
83 |
84 |
85 |
86 | )
87 | }
--------------------------------------------------------------------------------
/renderer/src/components/theme-provider.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import { ThemeProvider as NextThemesProvider } from "next-themes"
5 | import { type ThemeProviderProps } from "next-themes"
6 |
7 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
8 | return {children}
9 | }
--------------------------------------------------------------------------------
/renderer/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Slot } from "@radix-ui/react-slot"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 | import { cn } from "@/lib/utils"
5 |
6 | const buttonVariants = cva(
7 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 select-none relative",
8 | {
9 | variants: {
10 | variant: {
11 | default:
12 | "bg-primary text-primary-foreground shadow hover:bg-primary/90 disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed",
13 | destructive:
14 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90 disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed",
15 | outline:
16 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed",
17 | secondary:
18 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80 disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed",
19 | ghost: "hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed",
20 | link: "text-primary underline-offset-4 hover:underline disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed",
21 | },
22 | size: {
23 | default: "h-9 px-4 py-2",
24 | sm: "h-8 rounded-md px-3 text-xs",
25 | lg: "h-10 rounded-md px-8",
26 | icon: "h-9 w-9",
27 | },
28 | },
29 | defaultVariants: {
30 | variant: "default",
31 | size: "default",
32 | },
33 | }
34 | )
35 |
36 | export interface ButtonProps
37 | extends React.ButtonHTMLAttributes,
38 | VariantProps {
39 | asChild?: boolean
40 | }
41 |
42 | const Button = React.forwardRef(
43 | ({ className, variant, size, asChild = false, ...props }, ref) => {
44 | const Comp = asChild ? Slot : "button"
45 | return (
46 |
51 | )
52 | }
53 | )
54 | Button.displayName = "Button"
55 |
56 | export { Button, buttonVariants }
--------------------------------------------------------------------------------
/renderer/src/components/ui/spinner.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils"
2 |
3 | interface SpinnerProps {
4 | className?: string
5 | size?: "sm" | "md" | "lg"
6 | }
7 |
8 | export function Spinner({ className, size = "md" }: SpinnerProps) {
9 | return (
10 |
23 | Loading...
24 |
25 | )
26 | }
--------------------------------------------------------------------------------
/renderer/src/components/ui/theme-toggle.tsx:
--------------------------------------------------------------------------------
1 | import { Moon, Sun } from "lucide-react"
2 | import { useTheme } from "next-themes"
3 | import { Button } from "./button"
4 |
5 | export function ThemeToggle() {
6 | const { theme, setTheme } = useTheme()
7 |
8 | return (
9 | setTheme(theme === "light" ? "dark" : "light")}
14 | >
15 |
16 |
17 | Toggle theme
18 |
19 | )
20 | }
--------------------------------------------------------------------------------
/renderer/src/hooks/use-click-away.ts:
--------------------------------------------------------------------------------
1 | import { RefObject, useEffect } from 'react'
2 |
3 | export function useClickAway(
4 | ref: RefObject,
5 | handler: (event: MouseEvent | TouchEvent) => void
6 | ) {
7 | useEffect(() => {
8 | const listener = (event: MouseEvent | TouchEvent) => {
9 | const el = ref?.current
10 | if (!el || el.contains((event?.target as Node) || null)) {
11 | return
12 | }
13 |
14 | handler(event)
15 | }
16 |
17 | document.addEventListener('mousedown', listener)
18 | document.addEventListener('touchstart', listener)
19 |
20 | return () => {
21 | document.removeEventListener('mousedown', listener)
22 | document.removeEventListener('touchstart', listener)
23 | }
24 | }, [ref, handler])
25 | }
--------------------------------------------------------------------------------
/renderer/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from "clsx"
2 | import { twMerge } from "tailwind-merge"
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
--------------------------------------------------------------------------------
/renderer/src/types/docker.ts:
--------------------------------------------------------------------------------
1 | export interface ServiceStatus {
2 | name: string;
3 | status: 'running' | 'stopped' | 'error' | 'not_created';
4 | health: {
5 | status: 'healthy' | 'unhealthy' | 'starting' | 'unknown' | 'not_created';
6 | lastCheck: string;
7 | failureCount: number;
8 | };
9 | metrics: {
10 | cpu: string;
11 | memory: string;
12 | network: string;
13 | uptime?: number;
14 | connections?: number;
15 | } | null;
16 | ports: string[];
17 | logs: string[];
18 | }
19 |
20 | export interface LogEntry {
21 | timestamp: string;
22 | message: string;
23 | level: 'info' | 'error' | 'warn';
24 | }
25 |
26 | export interface DockerSystemStatus {
27 | isInstalled: boolean;
28 | isRunning: boolean;
29 | version?: string;
30 | compose?: {
31 | isInstalled: boolean;
32 | version?: string;
33 | };
34 | error?: string;
35 | }
36 |
37 | export interface DockerStatus {
38 | isRunning: boolean;
39 | services: Record;
40 | error?: string;
41 | }
42 |
43 | export interface DockerAPI {
44 | status: () => Promise;
45 | systemStatus: () => Promise;
46 | start: () => Promise<{ success: boolean; error?: string }>;
47 | stop: () => Promise<{ success: boolean; error?: string }>;
48 | getServices: () => Promise>;
49 | startService: (serviceName: string) => Promise<{ success: boolean; error?: string }>;
50 | stopService: (serviceName: string) => Promise<{ success: boolean; error?: string }>;
51 | restartService: (serviceName: string) => Promise<{ success: boolean; error?: string }>;
52 | getLogs: (serviceName: string) => Promise>;
53 | getImageLoadingProgress: () => Promise<{ current: number; total: number; status: string } | null>;
54 | ensureImagesLoaded: () => Promise;
55 | }
56 |
57 | export interface MainProcessLog {
58 | level: 'info' | 'warn' | 'error';
59 | message: string;
60 | timestamp: string;
61 | }
62 |
63 | declare global {
64 | interface Window {
65 | electronAPI: {
66 | docker: DockerAPI;
67 | browser: {
68 | openExternal: (url: string) => Promise;
69 | };
70 | logs: {
71 | subscribe: (callback: (log: MainProcessLog) => void) => () => void;
72 | };
73 | };
74 | }
75 | }
--------------------------------------------------------------------------------
/renderer/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss";
2 |
3 | const config: Config = {
4 | content: [
5 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
6 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}",
7 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}",
8 | ],
9 | theme: {
10 | extend: {
11 | fontFamily: {
12 | sans: ['var(--font-geist-sans)'],
13 | },
14 | colors: {
15 | background: "var(--background)",
16 | foreground: "var(--foreground)",
17 | },
18 | animation: {
19 | grid: 'grid 15s linear infinite',
20 | 'fade-in': 'fade-in 0.5s ease-out',
21 | 'slide-up': 'slide-up 0.5s ease-out',
22 | 'slide-down': 'slide-down 0.5s ease-out',
23 | 'scale-in': 'scale-in 0.2s ease-out',
24 | 'shimmer': 'shimmer 2s linear infinite',
25 | 'pulse-scale': 'pulse-scale 2s ease-in-out infinite',
26 | 'glow': 'glow 2s ease-in-out infinite',
27 | 'gradient-x': 'gradient-x 3s ease-in-out infinite',
28 | 'gradient-y': 'gradient-y 3s ease-in-out infinite',
29 | 'gradient-xy': 'gradient-xy 3s ease-in-out infinite',
30 | 'slide-in-from-bottom': 'slide-in-from-bottom 0.3s ease-out',
31 | },
32 | keyframes: {
33 | grid: {
34 | '0%, 100%': { transform: 'rotate(0deg)' },
35 | '50%': { transform: 'rotate(3deg)' },
36 | },
37 | 'fade-in': {
38 | '0%': { opacity: '0' },
39 | '100%': { opacity: '1' },
40 | },
41 | 'slide-up': {
42 | '0%': { transform: 'translateY(10px)', opacity: '0' },
43 | '100%': { transform: 'translateY(0)', opacity: '1' },
44 | },
45 | 'slide-down': {
46 | '0%': { transform: 'translateY(-10px)', opacity: '0' },
47 | '100%': { transform: 'translateY(0)', opacity: '1' },
48 | },
49 | 'scale-in': {
50 | '0%': { transform: 'scale(0.95)', opacity: '0' },
51 | '100%': { transform: 'scale(1)', opacity: '1' },
52 | },
53 | shimmer: {
54 | '100%': { transform: 'translateX(100%)' },
55 | },
56 | 'pulse-scale': {
57 | '0%, 100%': { transform: 'scale(1)' },
58 | '50%': { transform: 'scale(1.05)' },
59 | },
60 | glow: {
61 | '0%, 100%': { opacity: '1' },
62 | '50%': { opacity: '0.5' },
63 | },
64 | 'gradient-x': {
65 | '0%, 100%': {
66 | 'background-size': '200% 200%',
67 | 'background-position': 'left center',
68 | },
69 | '50%': {
70 | 'background-size': '200% 200%',
71 | 'background-position': 'right center',
72 | },
73 | },
74 | 'gradient-y': {
75 | '0%, 100%': {
76 | 'background-size': '200% 200%',
77 | 'background-position': 'center top',
78 | },
79 | '50%': {
80 | 'background-size': '200% 200%',
81 | 'background-position': 'center bottom',
82 | },
83 | },
84 | 'gradient-xy': {
85 | '0%, 100%': {
86 | 'background-size': '200% 200%',
87 | 'background-position': 'left top',
88 | },
89 | '50%': {
90 | 'background-size': '200% 200%',
91 | 'background-position': 'right bottom',
92 | },
93 | },
94 | 'slide-in-from-bottom': {
95 | '0%': { transform: 'translateY(100%)', opacity: '0' },
96 | '100%': { transform: 'translateY(0)', opacity: '1' },
97 | },
98 | },
99 | transitionTimingFunction: {
100 | 'bounce-in': 'cubic-bezier(0.68, -0.55, 0.265, 1.55)',
101 | },
102 | },
103 | },
104 | darkMode: 'class',
105 | plugins: [],
106 | } satisfies Config;
107 |
108 | export default config;
109 |
--------------------------------------------------------------------------------
/renderer/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2017",
4 | "lib": [
5 | "dom",
6 | "dom.iterable",
7 | "esnext"
8 | ],
9 | "allowJs": true,
10 | "skipLibCheck": true,
11 | "strict": true,
12 | "noEmit": true,
13 | "esModuleInterop": true,
14 | "module": "esnext",
15 | "moduleResolution": "bundler",
16 | "resolveJsonModule": true,
17 | "isolatedModules": true,
18 | "jsx": "preserve",
19 | "incremental": true,
20 | "plugins": [
21 | {
22 | "name": "next"
23 | }
24 | ],
25 | "paths": {
26 | "@/*": [
27 | "./src/*"
28 | ]
29 | }
30 | },
31 | "include": [
32 | "**/*.ts",
33 | "**/*.tsx",
34 | ".next/types/**/*.ts",
35 | "next-env.d.ts",
36 | "out/types/**/*.ts"
37 | ],
38 | "exclude": [
39 | "node_modules"
40 | ]
41 | }
42 |
--------------------------------------------------------------------------------
/scripts/prepare-images.js:
--------------------------------------------------------------------------------
1 | const { exec } = require('child_process');
2 | const { promisify } = require('util');
3 | const path = require('path');
4 | const fs = require('fs/promises');
5 |
6 | const execAsync = promisify(exec);
7 |
8 | const IMAGES = {
9 | graphd: {
10 | name: 'vesoft/nebula-graphd',
11 | tag: 'v3.8.0'
12 | },
13 | metad: {
14 | name: 'vesoft/nebula-metad',
15 | tag: 'v3.8.0'
16 | },
17 | storaged: {
18 | name: 'vesoft/nebula-storaged',
19 | tag: 'v3.8.0'
20 | },
21 | studio: {
22 | name: 'vesoft/nebula-graph-studio',
23 | tag: 'v3.10.0'
24 | },
25 | console: {
26 | name: 'vesoft/nebula-console',
27 | tag: 'nightly'
28 | }
29 | };
30 |
31 | const IMAGES_DIR = path.join('assets', 'NebulaGraph-Desktop', 'images');
32 |
33 | async function getImageSize(image) {
34 | try {
35 | const { stdout } = await execAsync(`docker image ls ${image} --format "{{.Size}}"`);
36 | return stdout.trim();
37 | } catch (error) {
38 | console.error(`Failed to get size for ${image}:`, error);
39 | return 'unknown';
40 | }
41 | }
42 |
43 | async function generateChecksum(filePath) {
44 | try {
45 | const { stdout } = await execAsync(`shasum -a 256 "${filePath}"`);
46 | return stdout.split(' ')[0];
47 | } catch (error) {
48 | console.error(`Failed to generate checksum for ${filePath}:`, error);
49 | return 'unknown';
50 | }
51 | }
52 |
53 | async function main() {
54 | try {
55 | // Ensure images directory exists
56 | await fs.mkdir(IMAGES_DIR, { recursive: true });
57 |
58 | console.log(`🐳 Preparing NebulaGraph Docker images...\n`);
59 |
60 | const results = {};
61 |
62 | for (const [key, config] of Object.entries(IMAGES)) {
63 | const fullImageName = `${config.name}:${config.tag}`;
64 | console.log(`📥 Processing ${fullImageName}...`);
65 |
66 | try {
67 | // Pull image
68 | console.log(` Pulling image...`);
69 | await execAsync(`docker pull ${fullImageName}`);
70 |
71 | // Get image size
72 | const size = await getImageSize(fullImageName);
73 | console.log(` Image size: ${size}`);
74 |
75 | // Save image
76 | const tarFile = path.join(IMAGES_DIR, `${key}.tar`);
77 | console.log(` Saving to ${tarFile}...`);
78 | await execAsync(`docker save -o "${tarFile}" ${fullImageName}`);
79 |
80 | // Generate checksum
81 | const checksum = await generateChecksum(tarFile);
82 | console.log(` Checksum: ${checksum}`);
83 |
84 | results[key] = {
85 | ...config,
86 | size,
87 | checksum
88 | };
89 |
90 | console.log(`✅ Successfully processed ${fullImageName}\n`);
91 | } catch (error) {
92 | console.error(`❌ Failed to process ${fullImageName}:`, error);
93 | process.exit(1);
94 | }
95 | }
96 |
97 | // Generate manifest
98 | const manifest = {
99 | version: '1.0.0',
100 | timestamp: new Date().toISOString(),
101 | platform: process.platform,
102 | images: results
103 | };
104 |
105 | const manifestPath = path.join(IMAGES_DIR, 'manifest.json');
106 | await fs.writeFile(
107 | manifestPath,
108 | JSON.stringify(manifest, null, 2)
109 | );
110 |
111 | console.log('📝 Generated manifest:', manifestPath);
112 | console.log('\n✨ All images prepared successfully!');
113 |
114 | // Print summary
115 | console.log('\n📊 Summary:');
116 | for (const [key, config] of Object.entries(results)) {
117 | console.log(` ${key}: ${config.name}:${config.tag} (${config.size})`);
118 | }
119 | } catch (error) {
120 | console.error('❌ Failed to prepare images:', error);
121 | process.exit(1);
122 | }
123 | }
124 |
125 | main().catch(console.error);
--------------------------------------------------------------------------------