├── .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 | [![GitHub release](https://img.shields.io/github/v/release/wey-gu/NebulaGraph-Desktop?label=Version&style=flat-square)](https://github.com/wey-gu/nebulagraph-desktop/releases) [![Build Status](https://img.shields.io/github/actions/workflow/status/wey-gu/NebulaGraph-Desktop/build.yml?style=flat-square&logo=github-actions&logoColor=white)](https://github.com/wey-gu/NebulaGraph-Desktop/actions/workflows/build.yml) [![License](https://img.shields.io/badge/License-Apache_2.0-blue?style=flat-square&logo=apache&logoColor=white)](https://github.com/wey-gu/NebulaGraph-Desktop/blob/main/LICENSE) 5 | 6 | 7 | [![Windows](https://img.shields.io/badge/Windows-Download-0078D6?style=flat-square&logo=windows&logoColor=white)](https://github.com/wey-gu/nebulagraph-desktop/releases) [![macOS](https://img.shields.io/badge/macOS-Download-000000?style=flat-square&logo=apple&logoColor=white)](https://github.com/wey-gu/nebulagraph-desktop/releases) 8 | 9 | 10 | [![NebulaGraph](https://img.shields.io/badge/Powered_by-NebulaGraph-blue?style=flat-square&logo=graph&logoColor=white)](https://github.com/vesoft-inc/nebula) [![Electron](https://img.shields.io/badge/Built_with-Electron-47848F?style=flat-square&logo=electron&logoColor=white)](https://www.electronjs.org/) [![TypeScript](https://img.shields.io/badge/TypeScript-007ACC?style=flat-square&logo=typescript&logoColor=white)](https://www.typescriptlang.org/) [![Docker](https://img.shields.io/badge/Requires-Docker-2496ED?style=flat-square&logo=docker&logoColor=white)](https://www.docker.com/get-started) 11 | 12 | A modern, cross-platform desktop version of NebulaGraph. 13 | 14 | ![NebulaGraph Desktop](./assets/screenshot.png) 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 | App is damaged 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 | Open Studio Open Studio 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 |
304 |
308 |
309 |

310 | {imageLoadingProgress.current} of {imageLoadingProgress.total} images 311 |

312 |
313 |
314 |
315 | )} 316 | 317 |
318 |
319 | 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 | 489 | 490 | 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 | 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 | {feature.step} 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 |
51 |
52 |
53 |
54 |
55 |
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 | 118 |
119 |
120 |
121 |
122 |
123 |
124 |
133 |
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 | 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 |
131 |
132 |
133 |
134 | )} 135 |
136 |
137 |
138 |
139 |

140 | {getServiceDisplayName(service.name)} 141 |

142 | {!isServiceNotCreated && service.ports && service.ports.length > 0 && ( 143 |
144 | {service.ports.map((port) => ( 145 | service.name === 'studio' ? ( 146 | 153 | 154 | 155 | 156 | {port} 157 | 158 | 159 | ) : ( 160 | 170 | 171 | 172 | 173 | {port} 174 | 175 | 176 | ) 177 | ))} 178 |
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 | 221 | {/* Show start button if service exists but is not running */} 222 | {(!service.status || service.status === 'stopped') && !isServiceNotCreated ? ( 223 | 241 | ) : service.status === 'running' ? ( 242 | <> 243 | 261 | 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 | 315 | ) 316 | } 317 | 318 | function ActionButton({ icon: Icon, onClick, label }: { 319 | icon: typeof Copy 320 | onClick: () => void 321 | label: string 322 | }) { 323 | return ( 324 | 333 | ) 334 | } 335 | 336 | function LoadingState() { 337 | return ( 338 |
339 |
340 |
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 | 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 |
61 |
65 |
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 |
84 | 85 | Uptime 86 |
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 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | 20 |
21 |
22 | {[...Array(4)].map((_, i) => ( 23 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 | ))} 40 |
41 | 42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 | 55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 | 68 |
69 |
70 |
71 |
72 |
73 |
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 | 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); --------------------------------------------------------------------------------