From 464de9b2fbb5128f8f4f565f1b4c667cc4c82bb8 Mon Sep 17 00:00:00 2001 From: Ken Lippold Date: Tue, 31 Mar 2026 15:17:17 -0700 Subject: [PATCH 001/166] Initial Tauri scaffold --- .gitignore | 33 +- .vscode/extensions.json | 7 + Dockerfile | 26 - index.html | 14 + package-lock.json | 3368 ++++++++++ package.json | 32 + public/tauri.svg | 6 + public/vite.svg | 1 + pyi-hooks/hook-orderedmultidict.py | 5 - requirements.txt | 9 - sidecar/alembic.ini | 149 + sidecar/alembic/README | 1 + sidecar/alembic/env.py | 58 + sidecar/alembic/script.py.mako | 28 + .../versions/cb5e7dc71a88_initial_schema.py | 75 + sidecar/database.py | 25 + sidecar/main.py | 33 + sidecar/models.py | 115 + sidecar/requirements.txt | 8 + sidecar/routes/__init__.py | 0 sidecar/routes/connections.py | 100 + sidecar/routes/runs.py | 26 + sidecar/routes/tasks.py | 165 + sidecar/scheduler.py | 189 + src-tauri/.gitignore | 7 + src-tauri/Cargo.lock | 5553 +++++++++++++++++ src-tauri/Cargo.toml | 26 + src-tauri/build.rs | 3 + src-tauri/capabilities/default.json | 12 + src-tauri/icons/128x128.png | Bin 0 -> 3512 bytes src-tauri/icons/128x128@2x.png | Bin 0 -> 7012 bytes src-tauri/icons/32x32.png | Bin 0 -> 974 bytes src-tauri/icons/Square107x107Logo.png | Bin 0 -> 2863 bytes src-tauri/icons/Square142x142Logo.png | Bin 0 -> 3858 bytes src-tauri/icons/Square150x150Logo.png | Bin 0 -> 3966 bytes src-tauri/icons/Square284x284Logo.png | Bin 0 -> 7737 bytes src-tauri/icons/Square30x30Logo.png | Bin 0 -> 903 bytes src-tauri/icons/Square310x310Logo.png | Bin 0 -> 8591 bytes src-tauri/icons/Square44x44Logo.png | Bin 0 -> 1299 bytes src-tauri/icons/Square71x71Logo.png | Bin 0 -> 2011 bytes src-tauri/icons/Square89x89Logo.png | Bin 0 -> 2468 bytes src-tauri/icons/StoreLogo.png | Bin 0 -> 1523 bytes src-tauri/icons/icon.icns | Bin 0 -> 98451 bytes src-tauri/icons/icon.ico | Bin 0 -> 86642 bytes src-tauri/icons/icon.png | Bin 0 -> 14183 bytes src-tauri/src/lib.rs | 102 + src-tauri/src/main.rs | 6 + src-tauri/tauri.conf.json | 43 + src/App.vue | 91 + src/api/index.ts | 138 + src/app.py | 570 -- src/assets/connected.png | Bin 218 -> 0 bytes src/assets/database.png | Bin 276 -> 0 bytes src/assets/description.png | Bin 225 -> 0 bytes src/assets/disconnected.png | Bin 289 -> 0 bytes src/assets/exit.png | Bin 307 -> 0 bytes src/assets/main.css | 4 + src/assets/pause.png | Bin 145 -> 0 bytes src/assets/resume.png | Bin 207 -> 0 bytes src/assets/vue.svg | 1 + src/main.ts | 10 + src/package/macos/settings.py | 9 - src/router/index.ts | 16 + src/scheduler.py | 216 - src/views/ConnectionsView.vue | 340 + src/views/TaskDetailView.vue | 567 ++ src/views/TasksView.vue | 532 ++ src/vite-env.d.ts | 7 + tsconfig.json | 30 + tsconfig.node.json | 10 + vite.config.ts | 35 + 71 files changed, 11961 insertions(+), 840 deletions(-) create mode 100644 .vscode/extensions.json delete mode 100644 Dockerfile create mode 100644 index.html create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 public/tauri.svg create mode 100644 public/vite.svg delete mode 100644 pyi-hooks/hook-orderedmultidict.py delete mode 100644 requirements.txt create mode 100644 sidecar/alembic.ini create mode 100644 sidecar/alembic/README create mode 100644 sidecar/alembic/env.py create mode 100644 sidecar/alembic/script.py.mako create mode 100644 sidecar/alembic/versions/cb5e7dc71a88_initial_schema.py create mode 100644 sidecar/database.py create mode 100644 sidecar/main.py create mode 100644 sidecar/models.py create mode 100644 sidecar/requirements.txt create mode 100644 sidecar/routes/__init__.py create mode 100644 sidecar/routes/connections.py create mode 100644 sidecar/routes/runs.py create mode 100644 sidecar/routes/tasks.py create mode 100644 sidecar/scheduler.py create mode 100644 src-tauri/.gitignore create mode 100644 src-tauri/Cargo.lock create mode 100644 src-tauri/Cargo.toml create mode 100644 src-tauri/build.rs create mode 100644 src-tauri/capabilities/default.json create mode 100644 src-tauri/icons/128x128.png create mode 100644 src-tauri/icons/128x128@2x.png create mode 100644 src-tauri/icons/32x32.png create mode 100644 src-tauri/icons/Square107x107Logo.png create mode 100644 src-tauri/icons/Square142x142Logo.png create mode 100644 src-tauri/icons/Square150x150Logo.png create mode 100644 src-tauri/icons/Square284x284Logo.png create mode 100644 src-tauri/icons/Square30x30Logo.png create mode 100644 src-tauri/icons/Square310x310Logo.png create mode 100644 src-tauri/icons/Square44x44Logo.png create mode 100644 src-tauri/icons/Square71x71Logo.png create mode 100644 src-tauri/icons/Square89x89Logo.png create mode 100644 src-tauri/icons/StoreLogo.png create mode 100644 src-tauri/icons/icon.icns create mode 100644 src-tauri/icons/icon.ico create mode 100644 src-tauri/icons/icon.png create mode 100644 src-tauri/src/lib.rs create mode 100644 src-tauri/src/main.rs create mode 100644 src-tauri/tauri.conf.json create mode 100644 src/App.vue create mode 100644 src/api/index.ts delete mode 100644 src/app.py delete mode 100644 src/assets/connected.png delete mode 100644 src/assets/database.png delete mode 100644 src/assets/description.png delete mode 100644 src/assets/disconnected.png delete mode 100644 src/assets/exit.png create mode 100644 src/assets/main.css delete mode 100644 src/assets/pause.png delete mode 100644 src/assets/resume.png create mode 100644 src/assets/vue.svg create mode 100644 src/main.ts delete mode 100644 src/package/macos/settings.py create mode 100644 src/router/index.ts delete mode 100644 src/scheduler.py create mode 100644 src/views/ConnectionsView.vue create mode 100644 src/views/TaskDetailView.vue create mode 100644 src/views/TasksView.vue create mode 100644 src/vite-env.d.ts create mode 100644 tsconfig.json create mode 100644 tsconfig.node.json create mode 100644 vite.config.ts diff --git a/.gitignore b/.gitignore index dda6ac6..907ae36 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,31 @@ -build -dist -.DS_Store -.idea *.dmg *.pyc *.spec -__pycache__/ \ No newline at end of file +__pycache__/ +src-tauri/binaries/ + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +build +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..cf4385b --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,7 @@ +{ + "recommendations": [ + "Vue.volar", + "tauri-apps.tauri-vscode", + "rust-lang.rust-analyzer" + ] +} diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 768672e..0000000 --- a/Dockerfile +++ /dev/null @@ -1,26 +0,0 @@ -FROM python:3.9-slim - -# Set the working directory in the container -WORKDIR /app - -# Copy only requirements first to leverage Docker cache -COPY requirements.txt ./ -RUN pip install --no-cache-dir -r requirements.txt - -# Copy the rest of the application code -COPY . ./ - -# Set environment variables for defaults (optional ones) -ENV HYDRO_SERVICE_URL=https://playground.hydroserver.org -ENV LOG_FILE=/app/logs/hydroloader.log - -# Expose the logs directory (useful for debugging in container setups) -VOLUME ["/app/logs"] - -# Ensure the logs directory exists -RUN mkdir -p /app/logs - -# Define the default command to run the application -CMD [ \ - "python", "main.py" \ -] diff --git a/index.html b/index.html new file mode 100644 index 0000000..99f203f --- /dev/null +++ b/index.html @@ -0,0 +1,14 @@ + + + + + + + Tauri + Vue + Typescript App + + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..426d1c9 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3368 @@ +{ + "name": "streaming-data-loader", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "streaming-data-loader", + "version": "0.1.0", + "dependencies": { + "@tauri-apps/api": "^2", + "@tauri-apps/plugin-opener": "^2", + "axios": "^1.13.6", + "pinia": "^3.0.4", + "vue": "^3.5.13", + "vue-router": "^5.0.3" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.2.1", + "@tauri-apps/cli": "^2.10.1", + "@vitejs/plugin-vue": "^5.2.1", + "autoprefixer": "^10.4.27", + "daisyui": "^5.5.19", + "postcss": "^8.5.8", + "tailwindcss": "^4.2.1", + "typescript": "~5.6.2", + "vite": "^6.0.3", + "vue-tsc": "^2.1.10" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz", + "integrity": "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.31.1", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.1" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.1.tgz", + "integrity": "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.1", + "@tailwindcss/oxide-darwin-arm64": "4.2.1", + "@tailwindcss/oxide-darwin-x64": "4.2.1", + "@tailwindcss/oxide-freebsd-x64": "4.2.1", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", + "@tailwindcss/oxide-linux-x64-musl": "4.2.1", + "@tailwindcss/oxide-wasm32-wasi": "4.2.1", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.1.tgz", + "integrity": "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.1.tgz", + "integrity": "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.1.tgz", + "integrity": "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.1.tgz", + "integrity": "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.1.tgz", + "integrity": "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.1.tgz", + "integrity": "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.1.tgz", + "integrity": "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.1.tgz", + "integrity": "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.1.tgz", + "integrity": "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.1.tgz", + "integrity": "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz", + "integrity": "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.1.tgz", + "integrity": "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.1.tgz", + "integrity": "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.2.1", + "@tailwindcss/oxide": "4.2.1", + "tailwindcss": "4.2.1" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7" + } + }, + "node_modules/@tauri-apps/api": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz", + "integrity": "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==", + "license": "Apache-2.0 OR MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + } + }, + "node_modules/@tauri-apps/cli": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.10.1.tgz", + "integrity": "sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g==", + "dev": true, + "license": "Apache-2.0 OR MIT", + "bin": { + "tauri": "tauri.js" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + }, + "optionalDependencies": { + "@tauri-apps/cli-darwin-arm64": "2.10.1", + "@tauri-apps/cli-darwin-x64": "2.10.1", + "@tauri-apps/cli-linux-arm-gnueabihf": "2.10.1", + "@tauri-apps/cli-linux-arm64-gnu": "2.10.1", + "@tauri-apps/cli-linux-arm64-musl": "2.10.1", + "@tauri-apps/cli-linux-riscv64-gnu": "2.10.1", + "@tauri-apps/cli-linux-x64-gnu": "2.10.1", + "@tauri-apps/cli-linux-x64-musl": "2.10.1", + "@tauri-apps/cli-win32-arm64-msvc": "2.10.1", + "@tauri-apps/cli-win32-ia32-msvc": "2.10.1", + "@tauri-apps/cli-win32-x64-msvc": "2.10.1" + } + }, + "node_modules/@tauri-apps/cli-darwin-arm64": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.10.1.tgz", + "integrity": "sha512-Z2OjCXiZ+fbYZy7PmP3WRnOpM9+Fy+oonKDEmUE6MwN4IGaYqgceTjwHucc/kEEYZos5GICve35f7ZiizgqEnQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-darwin-x64": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.10.1.tgz", + "integrity": "sha512-V/irQVvjPMGOTQqNj55PnQPVuH4VJP8vZCN7ajnj+ZS8Kom1tEM2hR3qbbIRoS3dBKs5mbG8yg1WC+97dq17Pw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.10.1.tgz", + "integrity": "sha512-Hyzwsb4VnCWKGfTw+wSt15Z2pLw2f0JdFBfq2vHBOBhvg7oi6uhKiF87hmbXOBXUZaGkyRDkCHsdzJcIfoJC2w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-gnu": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.10.1.tgz", + "integrity": "sha512-OyOYs2t5GkBIvyWjA1+h4CZxTcdz1OZPCWAPz5DYEfB0cnWHERTnQ/SLayQzncrT0kwRoSfSz9KxenkyJoTelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-musl": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.10.1.tgz", + "integrity": "sha512-MIj78PDDGjkg3NqGptDOGgfXks7SYJwhiMh8SBoZS+vfdz7yP5jN18bNaLnDhsVIPARcAhE1TlsZe/8Yxo2zqg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.10.1.tgz", + "integrity": "sha512-X0lvOVUg8PCVaoEtEAnpxmnkwlE1gcMDTqfhbefICKDnOTJ5Est3qL0SrWxizDackIOKBcvtpejrSiVpuJI1kw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-gnu": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.10.1.tgz", + "integrity": "sha512-2/12bEzsJS9fAKybxgicCDFxYD1WEI9kO+tlDwX5znWG2GwMBaiWcmhGlZ8fi+DMe9CXlcVarMTYc0L3REIRxw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-musl": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.10.1.tgz", + "integrity": "sha512-Y8J0ZzswPz50UcGOFuXGEMrxbjwKSPgXftx5qnkuMs2rmwQB5ssvLb6tn54wDSYxe7S6vlLob9vt0VKuNOaCIQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-arm64-msvc": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.10.1.tgz", + "integrity": "sha512-iSt5B86jHYAPJa/IlYw++SXtFPGnWtFJriHn7X0NFBVunF6zu9+/zOn8OgqIWSl8RgzhLGXQEEtGBdR4wzpVgg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-ia32-msvc": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.10.1.tgz", + "integrity": "sha512-gXyxgEzsFegmnWywYU5pEBURkcFN/Oo45EAwvZrHMh+zUSEAvO5E8TXsgPADYm31d1u7OQU3O3HsYfVBf2moHw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-x64-msvc": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.10.1.tgz", + "integrity": "sha512-6Cn7YpPFwzChy0ERz6djKEmUehWrYlM+xTaNzGPgZocw3BD7OfwfWHKVWxXzdjEW2KfKkHddfdxK1XXTYqBRLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/plugin-opener": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.3.tgz", + "integrity": "sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz", + "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.15" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.15.tgz", + "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.15.tgz", + "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue-macros/common": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@vue-macros/common/-/common-3.1.2.tgz", + "integrity": "sha512-h9t4ArDdniO9ekYHAD95t9AZcAbb19lEGK+26iAjUODOIJKmObDNBSe4+6ELQAA3vtYiFPPBtHh7+cQCKi3Dng==", + "license": "MIT", + "dependencies": { + "@vue/compiler-sfc": "^3.5.22", + "ast-kit": "^2.1.2", + "local-pkg": "^1.1.2", + "magic-string-ast": "^1.0.2", + "unplugin-utils": "^0.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/vue-macros" + }, + "peerDependencies": { + "vue": "^2.7.0 || ^3.2.25" + }, + "peerDependenciesMeta": { + "vue": { + "optional": true + } + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.30.tgz", + "integrity": "sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/shared": "3.5.30", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.30.tgz", + "integrity": "sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.30", + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.30.tgz", + "integrity": "sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/compiler-core": "3.5.30", + "@vue/compiler-dom": "3.5.30", + "@vue/compiler-ssr": "3.5.30", + "@vue/shared": "3.5.30", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.8", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.30.tgz", + "integrity": "sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.30", + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", + "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/@vue/devtools-api": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz", + "integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==", + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^7.7.9" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz", + "integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==", + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^7.7.9", + "birpc": "^2.3.0", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^1.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz", + "integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==", + "license": "MIT", + "dependencies": { + "rfdc": "^1.4.1" + } + }, + "node_modules/@vue/language-core": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.2.12.tgz", + "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^1.0.3", + "minimatch": "^9.0.3", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.30.tgz", + "integrity": "sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.30.tgz", + "integrity": "sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.30", + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.30.tgz", + "integrity": "sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.30", + "@vue/runtime-core": "3.5.30", + "@vue/shared": "3.5.30", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.30.tgz", + "integrity": "sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.30", + "@vue/shared": "3.5.30" + }, + "peerDependencies": { + "vue": "3.5.30" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.30.tgz", + "integrity": "sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ==", + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/alien-signals": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz", + "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ast-kit": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.2.0.tgz", + "integrity": "sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "pathe": "^2.0.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/ast-walker-scope": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/ast-walker-scope/-/ast-walker-scope-0.8.3.tgz", + "integrity": "sha512-cbdCP0PGOBq0ASG+sjnKIoYkWMKhhz+F/h9pRexUdX2Hd38+WOlBkRKlqkGOSm0YQpcFMQBJeK4WspUAkwsEdg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.4", + "ast-kit": "^2.1.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.4.27", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", + "integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001774", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.7", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.7.tgz", + "integrity": "sha512-1ghYO3HnxGec0TCGBXiDLVns4eCSx4zJpxnHrlqFQajmhfKMQBzUGDdkMK7fUW7PTHTeLf+j87aTuKuuwWzMGw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001778", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001778.tgz", + "integrity": "sha512-PN7uxFL+ExFJO61aVmP1aIEG4i9whQd4eoSCebav62UwDyp5OHh06zN4jqKSMePVgxHifCw1QJxdRkA1Pisekg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "license": "MIT" + }, + "node_modules/copy-anything": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", + "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==", + "license": "MIT", + "dependencies": { + "is-what": "^5.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/daisyui": { + "version": "5.5.19", + "resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.5.19.tgz", + "integrity": "sha512-pbFAkl1VCEh/MPCeclKL61I/MqRIFFhNU7yiXoDDRapXN4/qNCoMxeCCswyxEEhqL5eiTTfwHvucFtOE71C9sA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/saadeghi/daisyui?sponsor=1" + } + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.313", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.313.tgz", + "integrity": "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.20.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz", + "integrity": "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "license": "MIT" + }, + "node_modules/is-what": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", + "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", + "integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.31.1", + "lightningcss-darwin-arm64": "1.31.1", + "lightningcss-darwin-x64": "1.31.1", + "lightningcss-freebsd-x64": "1.31.1", + "lightningcss-linux-arm-gnueabihf": "1.31.1", + "lightningcss-linux-arm64-gnu": "1.31.1", + "lightningcss-linux-arm64-musl": "1.31.1", + "lightningcss-linux-x64-gnu": "1.31.1", + "lightningcss-linux-x64-musl": "1.31.1", + "lightningcss-win32-arm64-msvc": "1.31.1", + "lightningcss-win32-x64-msvc": "1.31.1" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz", + "integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz", + "integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz", + "integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz", + "integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz", + "integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz", + "integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz", + "integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz", + "integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz", + "integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz", + "integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz", + "integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/local-pkg": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.2.tgz", + "integrity": "sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==", + "license": "MIT", + "dependencies": { + "mlly": "^1.7.4", + "pkg-types": "^2.3.0", + "quansync": "^0.2.11" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magic-string-ast": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/magic-string-ast/-/magic-string-ast-1.0.3.tgz", + "integrity": "sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==", + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.19" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, + "node_modules/mlly": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.1.tgz", + "integrity": "sha512-SnL6sNutTwRWWR/vcmCYHSADjiEesp5TGQQ0pXyLhW5IoeibRlF/CbSLailbB3CNqJUk9cVJ9dUDnbD7GrcHBQ==", + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/mlly/node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "license": "MIT" + }, + "node_modules/mlly/node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pinia": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz", + "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^7.7.7" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.5.0", + "vue": "^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/pkg-types": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", + "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", + "license": "MIT", + "dependencies": { + "confbox": "^0.2.2", + "exsolve": "^1.0.7", + "pathe": "^2.0.3" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/quansync": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", + "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/antfu" + }, + { + "type": "individual", + "url": "https://github.com/sponsors/sxzz" + } + ], + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/scule": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz", + "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/speakingurl": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", + "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/superjson": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", + "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", + "license": "MIT", + "dependencies": { + "copy-anything": "^4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tailwindcss": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz", + "integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", + "license": "MIT" + }, + "node_modules/unplugin": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.0.0.tgz", + "integrity": "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "picomatch": "^4.0.3", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/unplugin-utils": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/unplugin-utils/-/unplugin-utils-0.3.1.tgz", + "integrity": "sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==", + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.30.tgz", + "integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.30", + "@vue/compiler-sfc": "3.5.30", + "@vue/runtime-dom": "3.5.30", + "@vue/server-renderer": "3.5.30", + "@vue/shared": "3.5.30" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.0.3.tgz", + "integrity": "sha512-nG1c7aAFac7NYj8Hluo68WyWfc41xkEjaR0ViLHCa3oDvTQ/nIuLJlXJX1NUPw/DXzx/8+OKMng045HHQKQKWw==", + "license": "MIT", + "dependencies": { + "@babel/generator": "^7.28.6", + "@vue-macros/common": "^3.1.1", + "@vue/devtools-api": "^8.0.6", + "ast-walker-scope": "^0.8.3", + "chokidar": "^5.0.0", + "json5": "^2.2.3", + "local-pkg": "^1.1.2", + "magic-string": "^0.30.21", + "mlly": "^1.8.0", + "muggle-string": "^0.4.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "scule": "^1.3.0", + "tinyglobby": "^0.2.15", + "unplugin": "^3.0.0", + "unplugin-utils": "^0.3.1", + "yaml": "^2.8.2" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "@pinia/colada": ">=0.21.2", + "@vue/compiler-sfc": "^3.5.17", + "pinia": "^3.0.4", + "vue": "^3.5.0" + }, + "peerDependenciesMeta": { + "@pinia/colada": { + "optional": true + }, + "@vue/compiler-sfc": { + "optional": true + }, + "pinia": { + "optional": true + } + } + }, + "node_modules/vue-router/node_modules/@vue/devtools-api": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-8.0.7.tgz", + "integrity": "sha512-tc1TXAxclsn55JblLkFVcIRG7MeSJC4fWsPjfM7qu/IcmPUYnQ5Q8vzWwBpyDY24ZjmZTUCCwjRSNbx58IhlAA==", + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^8.0.7" + } + }, + "node_modules/vue-router/node_modules/@vue/devtools-kit": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-8.0.7.tgz", + "integrity": "sha512-H6esJGHGl5q0E9iV3m2EoBQHJ+V83WMW83A0/+Fn95eZ2iIvdsq4+UCS6yT/Fdd4cGZSchx/MdWDreM3WqMsDw==", + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^8.0.7", + "birpc": "^2.6.1", + "hookable": "^5.5.3", + "perfect-debounce": "^2.0.0" + } + }, + "node_modules/vue-router/node_modules/@vue/devtools-shared": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.0.7.tgz", + "integrity": "sha512-CgAb9oJH5NUmbQRdYDj/1zMiaICYSLtm+B1kxcP72LBrifGAjUmt8bx52dDH1gWRPlQgxGPqpAMKavzVirAEhA==", + "license": "MIT" + }, + "node_modules/vue-router/node_modules/perfect-debounce": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", + "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", + "license": "MIT" + }, + "node_modules/vue-tsc": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.2.12.tgz", + "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.15", + "@vue/language-core": "2.2.12" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", + "license": "MIT" + }, + "node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..8d105b1 --- /dev/null +++ b/package.json @@ -0,0 +1,32 @@ +{ + "name": "streaming-data-loader", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc --noEmit && vite build", + "preview": "vite preview", + "tauri": "tauri" + }, + "dependencies": { + "@tauri-apps/api": "^2", + "@tauri-apps/plugin-opener": "^2", + "axios": "^1.13.6", + "pinia": "^3.0.4", + "vue": "^3.5.13", + "vue-router": "^5.0.3" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.2.1", + "@tauri-apps/cli": "^2.10.1", + "@vitejs/plugin-vue": "^5.2.1", + "autoprefixer": "^10.4.27", + "daisyui": "^5.5.19", + "postcss": "^8.5.8", + "tailwindcss": "^4.2.1", + "typescript": "~5.6.2", + "vite": "^6.0.3", + "vue-tsc": "^2.1.10" + } +} diff --git a/public/tauri.svg b/public/tauri.svg new file mode 100644 index 0000000..31b62c9 --- /dev/null +++ b/public/tauri.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/vite.svg b/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pyi-hooks/hook-orderedmultidict.py b/pyi-hooks/hook-orderedmultidict.py deleted file mode 100644 index 1023a8d..0000000 --- a/pyi-hooks/hook-orderedmultidict.py +++ /dev/null @@ -1,5 +0,0 @@ -from PyInstaller.utils.hooks import collect_data_files - -module_collection_mode = "py+pyz" -hiddenimports = ["orderedmultidict.__version__", "demjson3"] -datas = collect_data_files('orderedmultidict') diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index df62288..0000000 --- a/requirements.txt +++ /dev/null @@ -1,9 +0,0 @@ -PySide6==6.6.1 -appdirs==1.4.4 -hydroserverpy==1.8.0 -pandas==2.2.3 -numpy==2.2.4 -APScheduler==3.10.1 -pytz>=2023.3 -requests >= 2.27.1 -Pillow==9.5.0 diff --git a/sidecar/alembic.ini b/sidecar/alembic.ini new file mode 100644 index 0000000..a943cc8 --- /dev/null +++ b/sidecar/alembic.ini @@ -0,0 +1,149 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s +# Or organize into date-based subdirectories (requires recursive_version_locations = true) +# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the tzdata library which can be installed by adding +# `alembic[tz]` to the pip requirements. +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +sqlalchemy.url = sqlite:///placeholder.db + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/sidecar/alembic/README b/sidecar/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/sidecar/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/sidecar/alembic/env.py b/sidecar/alembic/env.py new file mode 100644 index 0000000..6340812 --- /dev/null +++ b/sidecar/alembic/env.py @@ -0,0 +1,58 @@ +import sys +from pathlib import Path +from logging.config import fileConfig +from platformdirs import user_data_dir +from sqlalchemy import engine_from_config, pool +from alembic import context + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from models import Base # noqa + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# Point Alembic at your models for autogenerate support +target_metadata = Base.metadata + +# Override the URL with the real platform-specific DB path +DB_DIR = Path(user_data_dir("streaming-data-loader", appauthor=False)) +DB_PATH = DB_DIR / "data.db" +DB_URL = f"sqlite:///{DB_PATH}" + + +def run_migrations_offline() -> None: + context.configure( + url=DB_URL, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + DB_DIR.mkdir(parents=True, exist_ok=True) + configuration = config.get_section(config.config_ini_section, {}) + configuration["sqlalchemy.url"] = DB_URL + connectable = engine_from_config( + configuration, + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + ) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/sidecar/alembic/script.py.mako b/sidecar/alembic/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/sidecar/alembic/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/sidecar/alembic/versions/cb5e7dc71a88_initial_schema.py b/sidecar/alembic/versions/cb5e7dc71a88_initial_schema.py new file mode 100644 index 0000000..8ee1549 --- /dev/null +++ b/sidecar/alembic/versions/cb5e7dc71a88_initial_schema.py @@ -0,0 +1,75 @@ +"""initial schema + +Revision ID: cb5e7dc71a88 +Revises: +Create Date: 2026-03-11 18:12:53.933847 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'cb5e7dc71a88' +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('hydroserver_connection', + sa.Column('id', sa.String(), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('host', sa.String(length=255), nullable=False), + sa.Column('auth_type', sa.String(length=50), nullable=False), + sa.Column('api_key', sa.String(), nullable=True), + sa.Column('username', sa.String(length=255), nullable=True), + sa.Column('password', sa.String(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('task', + sa.Column('id', sa.String(), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('connection_id', sa.String(), nullable=False), + sa.Column('schedule', sa.JSON(), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('source_type', sa.String(length=50), nullable=False), + sa.Column('file_path', sa.Text(), nullable=False), + sa.Column('csv_delimiter', sa.String(length=10), nullable=False), + sa.Column('csv_header_row', sa.Integer(), nullable=False), + sa.Column('csv_timestamp_column', sa.String(length=255), nullable=False), + sa.Column('csv_timestamp_format', sa.String(length=255), nullable=False), + sa.Column('column_mappings', sa.JSON(), nullable=True), + sa.ForeignKeyConstraint(['connection_id'], ['hydroserver_connection.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('task_run', + sa.Column('id', sa.String(), nullable=False), + sa.Column('task_id', sa.String(), nullable=False), + sa.Column('status', sa.String(length=50), nullable=False), + sa.Column('started_at', sa.DateTime(), nullable=False), + sa.Column('completed_at', sa.DateTime(), nullable=True), + sa.Column('error_message', sa.Text(), nullable=True), + sa.Column('success_count', sa.Integer(), nullable=True), + sa.Column('failure_count', sa.Integer(), nullable=True), + sa.Column('skipped_count', sa.Integer(), nullable=True), + sa.Column('values_loaded_total', sa.Integer(), nullable=True), + sa.Column('earliest_timestamp', sa.DateTime(), nullable=True), + sa.Column('latest_timestamp', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['task_id'], ['task.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('task_run') + op.drop_table('task') + op.drop_table('hydroserver_connection') + # ### end Alembic commands ### diff --git a/sidecar/database.py b/sidecar/database.py new file mode 100644 index 0000000..7088b7b --- /dev/null +++ b/sidecar/database.py @@ -0,0 +1,25 @@ +from pathlib import Path +from contextlib import contextmanager +from platformdirs import user_data_dir +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, Session + +DB_DIR = Path(user_data_dir("streaming-data-loader", appauthor=False)) +DB_PATH = DB_DIR / "data.db" +DB_URL = f"sqlite:///{DB_PATH}" + +engine = create_engine(DB_URL, connect_args={"check_same_thread": False}) +SessionLocal = sessionmaker(bind=engine) + + +@contextmanager +def get_session(): + session: Session = SessionLocal() + try: + yield session + session.commit() + except Exception: + session.rollback() + raise + finally: + session.close() diff --git a/sidecar/main.py b/sidecar/main.py new file mode 100644 index 0000000..3218a24 --- /dev/null +++ b/sidecar/main.py @@ -0,0 +1,33 @@ +import alembic.config +from flask import Flask, jsonify +from flask_cors import CORS +from database import DB_DIR +from scheduler import scheduler, load_all_tasks +from routes.connections import bp as connections_bp +from routes.tasks import bp as tasks_bp +from routes.runs import bp as runs_bp + +app = Flask(__name__) +CORS(app, origins=["http://localhost:1420", "tauri://localhost"]) + +app.register_blueprint(connections_bp) +app.register_blueprint(tasks_bp) +app.register_blueprint(runs_bp) + + +def run_migrations() -> None: + DB_DIR.mkdir(parents=True, exist_ok=True) + alembic_args = ["--raiseerr", "upgrade", "head"] + alembic.config.main(argv=alembic_args) + + +@app.get("/health") +def health(): + return jsonify({"status": "ok"}) + + +if __name__ == "__main__": + run_migrations() + scheduler.start() + load_all_tasks() + app.run(host="127.0.0.1", port=5321, debug=False, use_reloader=False) diff --git a/sidecar/models.py b/sidecar/models.py new file mode 100644 index 0000000..757b2a8 --- /dev/null +++ b/sidecar/models.py @@ -0,0 +1,115 @@ +from datetime import datetime +from typing import Optional +from sqlalchemy import ( + Boolean, DateTime, ForeignKey, + Integer, JSON, String, Text +) +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship + + +# To update models: +# alembic revision --autogenerate -m "description of change" + + +class Base(DeclarativeBase): + pass + + +class HydroServerConnection(Base): + __tablename__ = "hydroserver_connection" + + id: Mapped[str] = mapped_column(String, primary_key=True) + name: Mapped[str] = mapped_column(String(255), nullable=False) + host: Mapped[str] = mapped_column(String(255), nullable=False) + auth_type: Mapped[str] = mapped_column(String(50), nullable=False) # "apikey" | "userpass" + api_key: Mapped[Optional[str]] = mapped_column(String, nullable=True) + username: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) + password: Mapped[Optional[str]] = mapped_column(String, nullable=True) + + tasks: Mapped[list["Task"]] = relationship(back_populates="connection") + + def to_dict(self) -> dict: + return { + "id": self.id, + "name": self.name, + "host": self.host, + "auth_type": self.auth_type, + "api_key": self.api_key, + "username": self.username, + "password": self.password, + } + + +class Task(Base): + __tablename__ = "task" + + id: Mapped[str] = mapped_column(String, primary_key=True) + name: Mapped[str] = mapped_column(String(255), nullable=False) + connection_id: Mapped[str] = mapped_column( + String, ForeignKey("hydroserver_connection.id"), nullable=False + ) + schedule: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + source_type: Mapped[str] = mapped_column(String(50), nullable=False) # "http" | "local" + file_path: Mapped[str] = mapped_column(Text, nullable=False) + csv_delimiter: Mapped[str] = mapped_column(String(10), default=",", nullable=False) + csv_header_row: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + csv_timestamp_column: Mapped[str] = mapped_column(String(255), nullable=False) + csv_timestamp_format: Mapped[str] = mapped_column(String(255), nullable=False) + column_mappings: Mapped[Optional[list]] = mapped_column(JSON, nullable=True) + + connection: Mapped["HydroServerConnection"] = relationship(back_populates="tasks") + runs: Mapped[list["TaskRun"]] = relationship(back_populates="task") + + def to_dict(self) -> dict: + return { + "id": self.id, + "name": self.name, + "connection_id": self.connection_id, + "schedule": self.schedule, + "is_active": self.is_active, + "source_type": self.source_type, + "file_path": self.file_path, + "csv_delimiter": self.csv_delimiter, + "csv_header_row": self.csv_header_row, + "csv_timestamp_column": self.csv_timestamp_column, + "csv_timestamp_format": self.csv_timestamp_format, + "column_mappings": self.column_mappings, + } + + +class TaskRun(Base): + __tablename__ = "task_run" + + id: Mapped[str] = mapped_column(String, primary_key=True) + task_id: Mapped[str] = mapped_column( + String, ForeignKey("task.id"), nullable=False + ) + status: Mapped[str] = mapped_column(String(50), nullable=False) # "started" | "success" | "failure" + started_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + completed_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) + error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + success_count: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + failure_count: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + skipped_count: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + values_loaded_total: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + earliest_timestamp: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) + latest_timestamp: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) + + task: Mapped["Task"] = relationship(back_populates="runs") + + def to_dict(self) -> dict: + return { + "id": self.id, + "task_id": self.task_id, + "status": self.status, + "started_at": self.started_at.isoformat() if self.started_at else None, + "completed_at": self.completed_at.isoformat() if self.completed_at else None, + "error_message": self.error_message, + "success_count": self.success_count, + "failure_count": self.failure_count, + "skipped_count": self.skipped_count, + "values_loaded_total": self.values_loaded_total, + "earliest_timestamp": self.earliest_timestamp.isoformat() if self.earliest_timestamp else None, + "latest_timestamp": self.latest_timestamp.isoformat() if self.latest_timestamp else None, + } diff --git a/sidecar/requirements.txt b/sidecar/requirements.txt new file mode 100644 index 0000000..c009145 --- /dev/null +++ b/sidecar/requirements.txt @@ -0,0 +1,8 @@ +flask==3.1.3 +flask-cors==6.0.2 +pyinstaller==6.19.0 +APScheduler==3.11.2 +SQLAlchemy==2.0.4 +alembic==1.18.4 +platformdirs==4.9.4 +hydroserverpy==1.9.0 \ No newline at end of file diff --git a/sidecar/routes/__init__.py b/sidecar/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sidecar/routes/connections.py b/sidecar/routes/connections.py new file mode 100644 index 0000000..14f45e8 --- /dev/null +++ b/sidecar/routes/connections.py @@ -0,0 +1,100 @@ +import uuid +from flask import Blueprint, jsonify, request +from database import get_session +from models import HydroServerConnection + +bp = Blueprint("connections", __name__, url_prefix="/connections") + + +@bp.get("/") +def list_connections(): + with get_session() as session: + connections = session.query(HydroServerConnection).all() + return jsonify([c.to_dict() for c in connections]) + + +@bp.get("/") +def get_connection(connection_id: str): + with get_session() as session: + connection = session.get(HydroServerConnection, connection_id) + if not connection: + return jsonify({"error": "Connection not found"}), 404 + return jsonify(connection.to_dict()) + + +@bp.post("/") +def create_connection(): + data = request.get_json() + if not data: + return jsonify({"error": "No data provided"}), 400 + + errors = _validate_connection(data) + if errors: + return jsonify({"error": errors}), 422 + + with get_session() as session: + connection = HydroServerConnection( + id=str(uuid.uuid4()), + name=data["name"], + host=data["host"], + auth_type=data["auth_type"], + api_key=data.get("api_key"), + username=data.get("username"), + password=data.get("password"), + ) + session.add(connection) + session.commit() + session.refresh(connection) + return jsonify(connection.to_dict()), 201 + + +@bp.put("/") +def update_connection(connection_id: str): + data = request.get_json() + if not data: + return jsonify({"error": "No data provided"}), 400 + + errors = _validate_connection(data) + if errors: + return jsonify({"error": errors}), 422 + + with get_session() as session: + connection = session.get(HydroServerConnection, connection_id) + if not connection: + return jsonify({"error": "Connection not found"}), 404 + + connection.name = data["name"] + connection.host = data["host"] + connection.auth_type = data["auth_type"] + connection.api_key = data.get("api_key") + connection.username = data.get("username") + connection.password = data.get("password") + + session.commit() + session.refresh(connection) + return jsonify(connection.to_dict()) + + +@bp.delete("/") +def delete_connection(connection_id: str): + with get_session() as session: + connection = session.get(HydroServerConnection, connection_id) + if not connection: + return jsonify({"error": "Connection not found"}), 404 + session.delete(connection) + session.commit() + return jsonify({"ok": True}) + + +def _validate_connection(data: dict) -> str | None: + if not data.get("name"): + return "name is required" + if not data.get("host"): + return "host is required" + if data.get("auth_type") not in ("apikey", "userpass"): + return "auth_type must be 'apikey' or 'userpass'" + if data["auth_type"] == "apikey" and not data.get("api_key"): + return "api_key is required when auth_type is 'apikey'" + if data["auth_type"] == "userpass" and not (data.get("username") and data.get("password")): + return "username and password are required when auth_type is 'userpass'" + return None diff --git a/sidecar/routes/runs.py b/sidecar/routes/runs.py new file mode 100644 index 0000000..5bc41da --- /dev/null +++ b/sidecar/routes/runs.py @@ -0,0 +1,26 @@ +from flask import Blueprint, jsonify, request +from database import get_session +from models import TaskRun + +bp = Blueprint("runs", __name__, url_prefix="/runs") + + +@bp.get("/") +def list_runs(): + """List all runs, optionally filtered by task_id.""" + task_id = request.args.get("task_id") + with get_session() as session: + query = session.query(TaskRun).order_by(TaskRun.started_at.desc()) + if task_id: + query = query.filter(TaskRun.task_id == task_id) + runs = query.all() + return jsonify([r.to_dict() for r in runs]) + + +@bp.get("/") +def get_run(run_id: str): + with get_session() as session: + run = session.get(TaskRun, run_id) + if not run: + return jsonify({"error": "Run not found"}), 404 + return jsonify(run.to_dict()) diff --git a/sidecar/routes/tasks.py b/sidecar/routes/tasks.py new file mode 100644 index 0000000..f4d1de7 --- /dev/null +++ b/sidecar/routes/tasks.py @@ -0,0 +1,165 @@ +import uuid +from flask import Blueprint, jsonify, request +from sqlalchemy.orm import joinedload +from database import get_session +from models import Task, TaskRun +from scheduler import register_task, remove_task, execute_task + +bp = Blueprint("tasks", __name__, url_prefix="/tasks") + + +@bp.get("/") +def list_tasks(): + with get_session() as session: + tasks = ( + session.query(Task) + .options(joinedload(Task.runs)) + .all() + ) + result = [] + for task in tasks: + d = task.to_dict() + latest_run = ( + sorted(task.runs, key=lambda r: r.started_at, reverse=True)[0] + if task.runs else None + ) + d["latest_run"] = latest_run.to_dict() if latest_run else None + result.append(d) + return jsonify(result) + + +@bp.get("/") +def get_task(task_id: str): + with get_session() as session: + task = session.get(Task, task_id) + if not task: + return jsonify({"error": "Task not found"}), 404 + return jsonify(task.to_dict()) + + +@bp.post("/") +def create_task(): + data = request.get_json() + if not data: + return jsonify({"error": "No data provided"}), 400 + + error = _validate_task(data) + if error: + return jsonify({"error": error}), 422 + + with get_session() as session: + from models import HydroServerConnection + if not session.get(HydroServerConnection, data["connection_id"]): + return jsonify({"error": "Connection not found"}), 404 + + task = Task( + id=str(uuid.uuid4()), + name=data["name"], + connection_id=data["connection_id"], + schedule=data.get("schedule"), + is_active=data.get("is_active", True), + source_type=data["source_type"], + file_path=data["file_path"], + csv_delimiter=data.get("csv_delimiter", ","), + csv_header_row=data.get("csv_header_row", 0), + csv_timestamp_column=data["csv_timestamp_column"], + csv_timestamp_format=data["csv_timestamp_format"], + column_mappings=data.get("column_mappings", []), + ) + session.add(task) + session.commit() + session.refresh(task) + task_dict = task.to_dict() + + register_task(task_dict) + return jsonify(task_dict), 201 + + +@bp.put("/") +def update_task(task_id: str): + data = request.get_json() + if not data: + return jsonify({"error": "No data provided"}), 400 + + error = _validate_task(data) + if error: + return jsonify({"error": error}), 422 + + with get_session() as session: + task = session.get(Task, task_id) + if not task: + return jsonify({"error": "Task not found"}), 404 + + task.name = data["name"] + task.connection_id = data["connection_id"] + task.schedule = data.get("schedule") + task.is_active = data.get("is_active", True) + task.source_type = data["source_type"] + task.file_path = data["file_path"] + task.csv_delimiter = data.get("csv_delimiter", ",") + task.csv_header_row = data.get("csv_header_row", 0) + task.csv_timestamp_column = data["csv_timestamp_column"] + task.csv_timestamp_format = data["csv_timestamp_format"] + task.column_mappings = data.get("column_mappings", []) + + session.commit() + session.refresh(task) + task_dict = task.to_dict() + + register_task(task_dict) + return jsonify(task_dict) + + +@bp.delete("/") +def delete_task(task_id: str): + with get_session() as session: + task = session.get(Task, task_id) + if not task: + return jsonify({"error": "Task not found"}), 404 + session.delete(task) + session.commit() + + remove_task(task_id) + return jsonify({"ok": True}) + + +@bp.post("//run") +def run_task_now(task_id: str): + """Trigger an immediate out-of-schedule run.""" + with get_session() as session: + task = session.get(Task, task_id) + if not task: + return jsonify({"error": "Task not found"}), 404 + + # Run in the scheduler's thread pool so it doesn't block the request + from apscheduler.triggers.date import DateTrigger + from datetime import datetime + from scheduler import scheduler + scheduler.add_job( + execute_task, + trigger=DateTrigger(run_date=datetime.utcnow()), + args=[task_id], + id=f"manual_{task_id}_{uuid.uuid4().hex[:8]}", + ) + return jsonify({"ok": True, "message": "Run triggered"}) + + +def _validate_task(data: dict) -> str | None: + if not data.get("name"): + return "name is required" + if not data.get("connection_id"): + return "connection_id is required" + if data.get("source_type") not in ("http", "local"): + return "source_type must be 'http' or 'local'" + if not data.get("file_path"): + return "file_path is required" + if not data.get("csv_timestamp_column"): + return "csv_timestamp_column is required" + if not data.get("csv_timestamp_format"): + return "csv_timestamp_format is required" + if schedule := data.get("schedule"): + if schedule.get("period") not in ("days", "hours", "minutes"): + return "schedule.period must be 'days', 'hours', or 'minutes'" + if not isinstance(schedule.get("interval"), (int, float)) or schedule["interval"] <= 0: + return "schedule.interval must be a positive number" + return None diff --git a/sidecar/scheduler.py b/sidecar/scheduler.py new file mode 100644 index 0000000..c26b4d3 --- /dev/null +++ b/sidecar/scheduler.py @@ -0,0 +1,189 @@ +import uuid +import logging +from datetime import datetime, time +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.interval import IntervalTrigger + +log = logging.getLogger(__name__) + +scheduler = BackgroundScheduler(daemon=True) + + +def build_trigger(schedule: dict) -> IntervalTrigger: + """Build an IntervalTrigger from a schedule dict.""" + period = schedule["period"] # "days" | "hours" | "minutes" + interval = int(schedule["interval"]) + start_time_str = schedule.get("start_time") # "HH:MM" + + # Parse start_time into a datetime for the trigger's start_date + if start_time_str: + now = datetime.now() + parsed = time.fromisoformat(start_time_str) + start_date = now.replace( + hour=parsed.hour, + minute=parsed.minute, + second=0, + microsecond=0, + ) + # If the start time has already passed today, begin from the next interval + if start_date < now: + from datetime import timedelta + if period == "days": + start_date += timedelta(days=interval) + elif period == "hours": + start_date += timedelta(hours=interval) + elif period == "minutes": + start_date += timedelta(minutes=interval) + else: + start_date = None + + kwargs = {"start_date": start_date} if start_date else {} + + if period == "days": + return IntervalTrigger(days=interval, **kwargs) + elif period == "hours": + return IntervalTrigger(hours=interval, **kwargs) + elif period == "minutes": + return IntervalTrigger(minutes=interval, **kwargs) + else: + raise ValueError(f"Unknown period: {period}") + + +def execute_task(task_id: str) -> None: + """Run a task and record the result as a TaskRun.""" + from database import get_session + from models import Task, TaskRun + + log.info(f"Executing task {task_id}") + + with get_session() as session: + task = session.get(Task, task_id) + if not task: + log.error(f"Task {task_id} not found") + return + + run = TaskRun( + id=str(uuid.uuid4()), + task_id=task_id, + status="started", + started_at=datetime.utcnow(), + ) + session.add(run) + session.commit() + run_id = run.id + + try: + result = _run_etl(task_id) + + with get_session() as session: + run = session.get(TaskRun, run_id) + run.status = "success" + run.completed_at = datetime.utcnow() + run.success_count = result.get("success_count") + run.failure_count = result.get("failure_count") + run.skipped_count = result.get("skipped_count") + run.values_loaded_total = result.get("values_loaded_total") + run.earliest_timestamp = result.get("earliest_timestamp") + run.latest_timestamp = result.get("latest_timestamp") + session.commit() + + except Exception as e: + log.exception(f"Task {task_id} failed: {e}") + with get_session() as session: + run = session.get(TaskRun, run_id) + run.status = "failure" + run.completed_at = datetime.utcnow() + run.error_message = str(e) + session.commit() + + +def _run_etl(task_id: str) -> dict: + """ + Placeholder for hydroserverpy ETL execution. + Replace this with real hydroserverpy calls when ready. + """ + from database import get_session + from models import Task + + with get_session() as session: + task = session.get(Task, task_id) + connection = task.connection + + # TODO: wire up hydroserverpy here, e.g.: + # from hydroserverpy import ETLPipeline + # pipeline = ETLPipeline( + # source_type=task.source_type, + # file_path=task.file_path, + # csv_delimiter=task.csv_delimiter, + # csv_header_row=task.csv_header_row, + # csv_timestamp_column=task.csv_timestamp_column, + # csv_timestamp_format=task.csv_timestamp_format, + # column_mappings=task.column_mappings, + # connection_host=connection.host, + # auth_type=connection.auth_type, + # api_key=connection.api_key, + # username=connection.username, + # password=connection.password, + # ) + # return pipeline.run() + + log.info(f"ETL placeholder for task '{task.name}' — hydroserverpy not yet wired") + return { + "success_count": 0, + "failure_count": 0, + "skipped_count": 0, + "values_loaded_total": 0, + "earliest_timestamp": None, + "latest_timestamp": None, + } + + +def register_task(task: dict) -> None: + """Add or replace a task's job in the scheduler.""" + if not task.get("schedule") or not task.get("is_active"): + remove_task(task["id"]) + return + + try: + trigger = build_trigger(task["schedule"]) + except (KeyError, ValueError) as e: + log.error(f"Invalid schedule for task {task['id']}: {e}") + return + + job_id = f"task_{task['id']}" + + if scheduler.get_job(job_id): + scheduler.reschedule_job(job_id, trigger=trigger) + log.info(f"Rescheduled job {job_id}") + else: + scheduler.add_job( + execute_task, + trigger=trigger, + id=job_id, + args=[task["id"]], + replace_existing=True, + ) + log.info(f"Registered job {job_id}") + + +def remove_task(task_id: str) -> None: + """Remove a task's job from the scheduler if it exists.""" + job_id = f"task_{task_id}" + if scheduler.get_job(job_id): + scheduler.remove_job(job_id) + log.info(f"Removed job {job_id}") + + +def load_all_tasks() -> None: + """On startup, reload all active scheduled tasks from the DB.""" + from database import get_session + from models import Task + + with get_session() as session: + tasks = session.query(Task).filter(Task.is_active == True).all() + task_dicts = [t.to_dict() for t in tasks] + + for task_dict in task_dicts: + if task_dict.get("schedule"): + register_task(task_dict) + log.info(f"Loaded {len(task_dicts)} active tasks from database") diff --git a/src-tauri/.gitignore b/src-tauri/.gitignore new file mode 100644 index 0000000..b21bd68 --- /dev/null +++ b/src-tauri/.gitignore @@ -0,0 +1,7 @@ +# Generated by Cargo +# will have compiled files and executables +/target/ + +# Generated by Tauri +# will have schema files for capabilities auto-completion +/gen/schemas diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock new file mode 100644 index 0000000..17034ff --- /dev/null +++ b/src-tauri/Cargo.lock @@ -0,0 +1,5553 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-signal" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "brotli" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.11.0", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "cargo_toml" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +dependencies = [ + "serde", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "cc" +version = "1.2.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link 0.2.1", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.11.0", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.11.0", + "core-foundation", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.29.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f93d03419cb5950ccfd3daf3ff1c7a36ace64609a1a8746d493df1ca0afde0fa" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "matches", + "phf 0.10.1", + "proc-macro2", + "quote", + "smallvec", + "syn 1.0.109", +] + +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf 0.13.1", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ctor" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "derive_more" +version = "0.99.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.11.0", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dom_query" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d9c2e7f1d22d0f2ce07626d259b8a55f4a47cb0938d4006dd8ae037f17d585e" +dependencies = [ + "bit-set", + "cssparser 0.36.0", + "foldhash 0.2.0", + "html5ever 0.36.1", + "precomputed-hash", + "selectors 0.35.0", + "tendril", +] + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "embed-resource" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55a075fc573c64510038d7ee9abc7990635863992f83ebc52c8b433b8411a02e" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 0.9.12+spec-1.1.0", + "vswhom", + "winreg", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.9.0+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.11.0", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "html5ever" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c" +dependencies = [ + "log", + "mac", + "markup5ever 0.14.1", + "match_token", +] + +[[package]] +name = "html5ever" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6452c4751a24e1b99c3260d505eaeee76a050573e61f30ac2c924ddc7236f01e" +dependencies = [ + "log", + "markup5ever 0.36.1", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +dependencies = [ + "byteorder", + "png 0.17.16", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png 0.18.1", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.11.0", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "kuchikiki" +version = "0.8.8-speedreader" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2" +dependencies = [ + "cssparser 0.29.6", + "html5ever 0.29.1", + "indexmap 2.13.0", + "selectors 0.24.0", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libredox" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + +[[package]] +name = "markup5ever" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18" +dependencies = [ + "log", + "phf 0.11.3", + "phf_codegen 0.11.3", + "string_cache 0.8.9", + "string_cache_codegen 0.5.4", + "tendril", +] + +[[package]] +name = "markup5ever" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c3294c4d74d0742910f8c7b466f44dda9eb2d5742c1e430138df290a1e8451c" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "match_token" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "matches" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.61.2", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "muda" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c1738382f66ed56b3b9c8119e794a2e23148ac8ea214eda86622d4cb9d415a" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png 0.17.16", + "serde", + "thiserror 2.0.18", + "windows-sys 0.60.2", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.11.0", + "jni-sys", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nodrop" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.11.0", + "block2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.11.0", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.11.0", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.11.0", + "block2", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.11.0", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "open" +version = "5.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" +dependencies = [ + "dunce", + "is-wsl", + "libc", + "pathdiff", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" +dependencies = [ + "phf_shared 0.8.0", +] + +[[package]] +name = "phf" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" +dependencies = [ + "phf_macros 0.10.0", + "phf_shared 0.10.0", + "proc-macro-hack", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros 0.13.1", + "phf_shared 0.13.1", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbffee61585b0411840d3ece935cce9cb6321f01c45477d30066498cd5e1a815" +dependencies = [ + "phf_generator 0.8.0", + "phf_shared 0.8.0", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_generator" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17367f0cc86f2d25802b2c26ee58a7b23faeccf78a396094c13dced0d0182526" +dependencies = [ + "phf_shared 0.8.0", + "rand 0.7.3", +] + +[[package]] +name = "phf_generator" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" +dependencies = [ + "phf_shared 0.10.0", + "rand 0.8.5", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.5", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_macros" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0" +dependencies = [ + "phf_generator 0.10.0", + "phf_shared 0.10.0", + "proc-macro-hack", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_shared" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c00cf8b9eafe68dde5e9eaa2cef8ee84a9336a47d566ec55ca16589633b65af7" +dependencies = [ + "siphasher 0.3.11", +] + +[[package]] +name = "phf_shared" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" +dependencies = [ + "siphasher 0.3.11", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher 1.0.2", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher 1.0.2", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "plist" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" +dependencies = [ + "base64 0.22.1", + "indexmap 2.13.0", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.11.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.4+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro-hack" +version = "0.5.20+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pxfm" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d" + +[[package]] +name = "quick-xml" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" +dependencies = [ + "getrandom 0.1.16", + "libc", + "rand_chacha 0.2.2", + "rand_core 0.5.1", + "rand_hc", + "rand_pcg", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" +dependencies = [ + "ppv-lite86", + "rand_core 0.5.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" +dependencies = [ + "getrandom 0.1.16", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_hc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "rand_pcg" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16abd0c1b639e9eb4d7c50c0b8100b0d0f849be2349829c740fe8e6eb4816429" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.117", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "selectors" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c37578180969d00692904465fb7f6b3d50b9a2b952b87c23d0e2e5cb5013416" +dependencies = [ + "bitflags 1.3.2", + "cssparser 0.29.6", + "derive_more 0.99.20", + "fxhash", + "log", + "phf 0.8.0", + "phf_codegen 0.8.0", + "precomputed-hash", + "servo_arc 0.2.0", + "smallvec", +] + +[[package]] +name = "selectors" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fdfed56cd634f04fe8b9ddf947ae3dc493483e819593d2ba17df9ad05db8b2" +dependencies = [ + "bitflags 2.11.0", + "cssparser 0.36.0", + "derive_more 2.1.1", + "log", + "new_debug_unreachable", + "phf 0.13.1", + "phf_codegen 0.13.1", + "precomputed-hash", + "rustc-hash", + "servo_arc 0.4.3", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_with" +version = "3.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381b283ce7bc6b476d903296fb59d0d36633652b633b27f64db4fb46dcbfc3b9" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.13.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6d4e30573c8cb306ed6ab1dca8423eec9a463ea0e155f45399455e0368b27e0" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "servo_arc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52aa42f8fdf0fed91e5ce7f23d8138441002fa31dca008acf47e6fd4721f741" +dependencies = [ + "nodrop", + "stable_deref_trait", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shared_child" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e362d9935bc50f019969e2f9ecd66786612daae13e8f277be7bfb66e8bed3f7" +dependencies = [ + "libc", + "sigchld", + "windows-sys 0.60.2", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "sigchld" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47106eded3c154e70176fc83df9737335c94ce22f821c32d17ed1db1f83badb1" +dependencies = [ + "libc", + "os_pipe", + "signal-hook", +] + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "redox_syscall", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "streaming-data-loader" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "tauri", + "tauri-build", + "tauri-plugin-opener", + "tauri-plugin-shell", +] + +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.11.3", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.13.1", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "swift-rs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.34.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e06d52c379e63da659a483a958110bbde891695a0ecb53e48cc7786d5eda7bb" +dependencies = [ + "bitflags 2.11.0", + "block2", + "core-foundation", + "core-graphics", + "crossbeam-channel", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "once_cell", + "parking_lot", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da77cc00fb9028caf5b5d4650f75e31f1ef3693459dfca7f7e506d1ecef0ba2d" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "image", + "jni", + "libc", + "log", + "mime", + "muda", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.18", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows", +] + +[[package]] +name = "tauri-build" +version = "2.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bbc990d1dbf57a8e1c7fa2327f2a614d8b757805603c1b9ba5c81bade09fd4d" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "toml 0.9.12+spec-1.1.0", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a24476afd977c5d5d169f72425868613d82747916dd29e0a357c84c4bd6d29" +dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png 0.17.16", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2", + "syn 2.0.117", + "tauri-utils", + "thiserror 2.0.18", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d39b349a98dadaffebb73f0a40dcd1f23c999211e5a2e744403db384d0c33de7" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-plugin" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddde7d51c907b940fb573006cdda9a642d6a7c8153657e88f8a5c3c9290cd4aa" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "toml 0.9.12+spec-1.1.0", + "walkdir", +] + +[[package]] +name = "tauri-plugin-opener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc624469b06f59f5a29f874bbc61a2ed737c0f9c23ef09855a292c389c42e83f" +dependencies = [ + "dunce", + "glob", + "objc2-app-kit", + "objc2-foundation", + "open", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "url", + "windows", + "zbus", +] + +[[package]] +name = "tauri-plugin-shell" +version = "2.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8457dbf9e2bab1edd8df22bb2c20857a59a9868e79cb3eac5ed639eec4d0c73b" +dependencies = [ + "encoding_rs", + "log", + "open", + "os_pipe", + "regex", + "schemars 0.8.22", + "serde", + "serde_json", + "shared_child", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "tokio", +] + +[[package]] +name = "tauri-runtime" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2826d79a3297ed08cd6ea7f412644ef58e32969504bc4fbd8d7dbeabc4445ea2" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni", + "objc2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webview2-com", + "windows", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11ea2e6f801d275fdd890d6c9603736012742a1c33b96d0db788c9cdebf7f9e" +dependencies = [ + "gtk", + "http", + "jni", + "log", + "objc2", + "objc2-app-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219a1f983a2af3653f75b5747f76733b0da7ff03069c7a41901a5eb3ace4557d" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dunce", + "glob", + "html5ever 0.29.1", + "http", + "infer", + "json-patch", + "kuchikiki", + "log", + "memchr", + "phf 0.11.3", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.18", + "toml 0.9.12+spec-1.1.0", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1087b111fe2b005e42dbdc1990fc18593234238d47453b0c99b7de1c9ab2c1e0" +dependencies = [ + "dunce", + "embed-resource", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.13.0", + "serde_core", + "serde_spanned 1.0.4", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.0.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.13.0", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.13.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.4+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2" +dependencies = [ + "indexmap 2.13.0", + "toml_datetime 1.0.0+spec-1.1.0", + "toml_parser", + "winnow 0.7.15", +] + +[[package]] +name = "toml_parser" +version = "1.0.9+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" +dependencies = [ + "winnow 0.7.15", +] + +[[package]] +name = "toml_writer" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags 2.11.0", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tray-icon" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png 0.17.16", + "serde", + "thiserror 2.0.18", + "windows-sys 0.60.2", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "uds_windows" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51b70b87d15e91f553711b40df3048faf27a7a04e01e0ddc0cf9309f0af7c2ca" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.9.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.13.0", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.15.5", + "indexmap 2.13.0", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57a9779e9f04d2ac1ce317aee707aa2f6b773afba7b931222bff6983843b1576" +dependencies = [ + "phf 0.13.1", + "phf_codegen 0.13.1", + "string_cache 0.9.0", + "string_cache_codegen 0.6.1", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webview2-com" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows", + "windows-core 0.61.2", + "windows-implement", + "windows-interface", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +dependencies = [ + "thiserror 2.0.18", + "windows", + "windows-core 0.61.2", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "windows-sys 0.59.0", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap 2.13.0", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.0", + "indexmap 2.13.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.13.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "wry" +version = "0.54.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a24eda84b5d488f99344e54b807138896cee8df0b2d16c793f1f6b80e6d8df1f" +dependencies = [ + "base64 0.22.1", + "block2", + "cookie", + "crossbeam-channel", + "dirs", + "dom_query", + "dpi", + "dunce", + "gdkx11", + "gtk", + "http", + "javascriptcore-rs", + "jni", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca82f95dbd3943a40a53cfded6c2d0a2ca26192011846a1810c4256ef92c60bc" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 0.7.15", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897e79616e84aac4b2c46e9132a4f63b93105d54fe8c0e8f6bffc21fa8d49222" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" +dependencies = [ + "serde", + "winnow 0.7.15", + "zvariant", +] + +[[package]] +name = "zerocopy" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zvariant" +version = "5.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5708299b21903bbe348e94729f22c49c55d04720a004aa350f1f9c122fd2540b" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 0.7.15", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.117", + "winnow 0.7.15", +] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml new file mode 100644 index 0000000..de51702 --- /dev/null +++ b/src-tauri/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "streaming-data-loader" +version = "0.1.0" +description = "A Tauri App" +authors = ["you"] +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[lib] +# The `_lib` suffix may seem redundant but it is necessary +# to make the lib name unique and wouldn't conflict with the bin name. +# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519 +name = "streaming_data_loader_lib" +crate-type = ["staticlib", "cdylib", "rlib"] + +[build-dependencies] +tauri-build = { version = "2", features = [] } + +[dependencies] +tauri = { version = "2", features = ["tray-icon", "image-png"] } +tauri-plugin-opener = "2" +tauri-plugin-shell = "2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" + diff --git a/src-tauri/build.rs b/src-tauri/build.rs new file mode 100644 index 0000000..d860e1e --- /dev/null +++ b/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json new file mode 100644 index 0000000..0cda957 --- /dev/null +++ b/src-tauri/capabilities/default.json @@ -0,0 +1,12 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "Default capabilities", + "windows": ["main"], + "permissions": [ + "core:default", + "opener:default", + "shell:allow-execute", + "shell:allow-spawn" + ] +} \ No newline at end of file diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png new file mode 100644 index 0000000000000000000000000000000000000000..6be5e50e9b9ae84d9e2ee433f32ef446495eaf3b GIT binary patch literal 3512 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 literal 0 HcmV?d00001 diff --git a/src-tauri/icons/128x128@2x.png b/src-tauri/icons/128x128@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..e81becee571e96f76aa5667f9324c05e5e7a4479 GIT binary patch literal 7012 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 Sh@|Ye~|0>}wMPLT8jm@Y!H33Sz}5aFI6 zM9Lzqz|;A*0sGs=2A1uU!1nk2dGF7knQwr99SAFen)x(eCO;F8y2C~0FD1YxRTPcy zPWVxkUYmeuz}Tv?7&Fe-!UE{)ZW)Mb;H)^#eHDv$`dkZGguJz@^MA!ZNGAUqt{|0H zpZ7Ch9S`q5!>R%}>}62!+(T^evyO+ImSo2wpu)su4^3nw5(%)KD%gbSev^*HZZ&3( z#&c@Z0gH|}Ck)w6fh0&NBJ62ib%R}(3@$VFl*_#l2W$wQ-~4RmZZAt5O*^2Q5}Xr8Hy@c`#pM?kc?hFWxRXr*mUfUCXf4ka5DD~ zat6d85COB05l#(P9*cQZ3EC8fVdS~?&vN#rce(aF9@xp80O2{{FBvU+{X>Hoh;xI` z{$e^Nw1y*VbO8wv`8|-m?NwNaKGTGaF{P^JLB^DbOYWIbn%eT`*!^C1H36=O8Z-M> zkD~88ry`eSo`tEBN4>w7OWZwUzlh{WM1m8R6zepqGcGMaV7vWY9b?K4b6~|HVG)ec wi>I@ws#sZo7or4_*4M>7;p5{nr2pZ?Uu4>Krr0kU)&Kwi07*qoM6N<$f)&@lf&c&j literal 0 HcmV?d00001 diff --git a/src-tauri/icons/Square107x107Logo.png b/src-tauri/icons/Square107x107Logo.png new file mode 100644 index 0000000000000000000000000000000000000000..0ca4f27198838968bd60ed7d371bfa23496b7fe5 GIT binary patch literal 2863 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 Y2S8W#vM)6=T>t<807*qoM6N<$f*y@n<^TWy literal 0 HcmV?d00001 diff --git a/src-tauri/icons/Square284x284Logo.png b/src-tauri/icons/Square284x284Logo.png new file mode 100644 index 0000000000000000000000000000000000000000..c021d2ba76619c08969ab688db3b27f29257aa6f GIT binary patch literal 7737 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 literal 0 HcmV?d00001 diff --git a/src-tauri/icons/Square30x30Logo.png b/src-tauri/icons/Square30x30Logo.png new file mode 100644 index 0000000000000000000000000000000000000000..621970023096ed9f494ba18ace15421a45cd65fa GIT binary patch literal 903 zcmV;219<$2P)2 z+CUKPMqaqGiH;zb!R4$B-WXS^YzQr=@UH>k4?*L)&R=zYjBrZenKdc9|JlS$SO*RJ zKt8FSTDAdk1g_WPAO!p^V!AuL;Lm;uQyV;zKq)J3i(;q*;k+pD%f3eltU`PYdy9(k0&%` zuWAPcV6|-y?|?7O1W!KSK}pbk8#~!|FA@(VJkt^V@0lio{afoAeo*f&$W2s6${5!1eKvAGD2$GZwSB98L2ZVS- zKn8ENRkZ*sb!@QugOrQNK3(sy1v%J#m|rpB+h|Nkqa3FRT>74xSs{#&saU2Lf!_Iq zKmuKAESh`gs!fneGWn+nf}l?7jE$HW!Af&vE5=G!QU)U2v&HLIBGXKk4nQx{hsHjL zLPMAo5=*uInFbq7(aa`Y2VX5wCmaeqvECOFv)a>0t>ZaEb*cJccER=BB?KFZhV$c^ znL*l8x*UYZv4WK|j?~Jt6~~F%{pk~z5A*>^M`?r5m9@RJ_x|uEtX(6Vk@Y()MVto* z93wr)%3m%|#OZ~srm>zF(JvDuTq*@;d&^>_BJm5hOU`3FjG70L#Vzv9I?`<7$T@

jU?lMi@tgxr7CqX_r3uw^y4tVU3Pm0sw;|1WSUO%?=bG`*Kmz6u4{#ti;T7AWIBAEh!(Y zz>O01&#X?Ds@L)Sb{CkG#Yz4$3o d@96)?#cz^xWoA}>B$xmI002ovPDHLkV1l3&k#zt7 literal 0 HcmV?d00001 diff --git a/src-tauri/icons/Square310x310Logo.png b/src-tauri/icons/Square310x310Logo.png new file mode 100644 index 0000000000000000000000000000000000000000..f9bc04839491e66c07b16ab03743c0c53b4109cc GIT binary patch literal 8591 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 JPDHLkV1lm|RXG3v literal 0 HcmV?d00001 diff --git a/src-tauri/icons/Square71x71Logo.png b/src-tauri/icons/Square71x71Logo.png new file mode 100644 index 0000000000000000000000000000000000000000..63440d7984936a9caa89275928d8dce97e4d033b GIT binary patch literal 2011 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∓t$dRJGIadE?vc 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 Kx8Vjl{{H|h@<;Lj literal 0 HcmV?d00001 diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..b3636e4b22ba65db9061cd60a77b02c92022dfd6 GIT binary patch literal 86642 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 AKmY&$ literal 0 HcmV?d00001 diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..e1cd2619e0b5ec089cbba5ec7b03ddf2b1dfceb6 GIT binary patch literal 14183 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

5l#v3A#xI3*z~1~fvo9Q(N^(==!|_FZ z*duZ=+M1~)8E|otX8KNZlr?qels#x_1Xq@9IIw~@9uAREJVH)Xw^}UclF6327}E42 zT)E&?U%TK?(+K7%R!`H5oX0i)4Qn5??Iw3p5J~6_u+aWehY{DSn}3V2p$bgjnAu?o)v@iC254fXeMv50$9YrpU`N?u@QIWs)T?SP|fa}(|9 zqAX+!7`cx=4)cCBg5h~pu(?@9`)aCr#oyz$ld=#RFxYCNZCZls@4v2~*e-t6PEVvV z&bbK3b3wt(Coc!ufAbXXC<**#HQ%J9k`New6iG<5RjtO4XVO?dCvwxD{kJ#tfQr(X zg^NTwF-FwAeS_{V4bfel8l`~NbfrTR2s!G>WduFWxH(t~aK4q=6rEE^$+Uox>gJO2 z{L<;6Q6nHa5#ZEM>H58not!)z(6*_=^~8}jWf*IG$AUKVWOZ4?)GfF z+BM#*wKKmLFD7E~W3U!$IVm$k_k1f&Kz6WV8@55P?r~bcg-Za-!rvW?ns&)KOGT2~ zlkAyqhQj=P$Eg3w#K~}zH@J5bo-BfHjInKSz$@?+Z)NPD4pHj^_Qxmi`UqoTy=`sV zLVxrXGuBr=QRm|}wg75yetQQK4fY3#P_~J}zEfPnb2C4Wo!E(d*(cA;b?7$g2in<( zPn)ghX}nzJPmb6(3Dpeg_GW~Hc}Lt=lgsSZz z!5QXyz7KaR;D`3Ee}d`af{H>WWZ|Io1QI3~4Ll_`g1(cRnhLK73Ro)7zPCd={1W2x zRp%Xlvv4>!<2@}$hz|!V{T}_eHx2xkLl^hQoZTCnsjCl|W_@5Fx2(+j0ogy&Y+;L- z<)G$*CiN7hOm^s!{U>1F7U=iNk{+u~dAC!eDz%=|glFW0jEZU1&o(G_c#wTxUjnG} z#cg3>jEpUi#Mlq@t?Msg_#geK^Lx@DyHWf7=AS5vVyM7YOjvUVCfcpVR<(+5!H?9- zySI6s>o3m&*zr||=wcPGyBkQV`EWJl@bH8qobjOp+sXL*)=&yX)8aAbf~tGv?a2SN zu^Ddo-z?DWk9h9Yz#5p^NU#x~wYSd?H@w@!2Gb4G)6-utEMV~~M85Br5ff(v5O1|T z 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 + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + tauri::Builder::default() + .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_shell::init()) + .manage(SidecarState(Mutex::new(None))) + .setup(|app| { + // start_sidecar(app)?; + setup_tray(app)?; + Ok(()) + }) + .on_window_event(|window, event| { + if let tauri::WindowEvent::CloseRequested { api, .. } = event { + window.hide().unwrap(); + api.prevent_close(); + } + }) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} + +fn start_sidecar(app: &mut tauri::App) -> Result<(), Box> { + use tauri::Manager; + + let sidecar = app.shell().sidecar("sidecar")?; + let (_rx, child) = sidecar.spawn()?; + + *app.state::().0.lock().unwrap() = Some(child); + + Ok(()) +} + +fn setup_tray(app: &mut tauri::App) -> Result<(), Box> { + use tauri::{ + image::Image, + menu::{Menu, MenuItem}, + tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, + Manager, + }; + + let show = MenuItem::with_id(app, "show", "Show Window", true, None::<&str>)?; + let hide = MenuItem::with_id(app, "hide", "Hide Window", true, None::<&str>)?; + let quit = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?; + let menu = Menu::with_items(app, &[&show, &hide, &quit])?; + + TrayIconBuilder::new() + .icon(Image::from_path("icons/32x32.png")?) + .menu(&menu) + .show_menu_on_left_click(false) + .on_menu_event(|app, event| match event.id.as_ref() { + "show" => { + if let Some(window) = app.get_webview_window("main") { + window.show().unwrap(); + window.set_focus().unwrap(); + } + } + "hide" => { + if let Some(window) = app.get_webview_window("main") { + window.hide().unwrap(); + } + } + "quit" => { + // Kill the sidecar before quitting + if let Some(child) = app.state::().0.lock().unwrap().take() { + let _ = child.kill(); + } + app.exit(0); + } + _ => {} + }) + .on_tray_icon_event(|tray, event| { + if let TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + .. + } = event + { + let app = tray.app_handle(); + if let Some(window) = app.get_webview_window("main") { + if window.is_visible().unwrap_or(false) { + window.hide().unwrap(); + } else { + window.show().unwrap(); + window.set_focus().unwrap(); + } + } + } + }) + .build(app)?; + + if let Some(window) = app.get_webview_window("main") { + window.show().unwrap(); + } + + Ok(()) +} \ No newline at end of file diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs new file mode 100644 index 0000000..a30a369 --- /dev/null +++ b/src-tauri/src/main.rs @@ -0,0 +1,6 @@ +// Prevents additional console window on Windows in release, DO NOT REMOVE!! +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + streaming_data_loader_lib::run() +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json new file mode 100644 index 0000000..6f00230 --- /dev/null +++ b/src-tauri/tauri.conf.json @@ -0,0 +1,43 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "streaming-data-loader", + "version": "0.1.0", + "identifier": "com.streaming-data-loader.app", + "build": { + "beforeDevCommand": "npm run dev", + "devUrl": "http://localhost:1420", + "beforeBuildCommand": "npm run build", + "frontendDist": "../dist" + }, + "app": { + "windows": [ + { + "title": "streaming-data-loader", + "width": 800, + "height": 600, + "visible": false + } + ], + "security": { + "csp": null + }, + "trayIcon": { + "iconPath": "icons/32x32.png", + "iconAsTemplate": true + } + }, + "bundle": { + "active": true, + "targets": "all", + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ], + "externalBin": [ + "binaries/sidecar" + ] + } +} diff --git a/src/App.vue b/src/App.vue new file mode 100644 index 0000000..1eadd83 --- /dev/null +++ b/src/App.vue @@ -0,0 +1,91 @@ + + + \ No newline at end of file diff --git a/src/api/index.ts b/src/api/index.ts new file mode 100644 index 0000000..13f2acd --- /dev/null +++ b/src/api/index.ts @@ -0,0 +1,138 @@ +import axios from "axios" + +const client = axios.create({ + baseURL: "http://127.0.0.1:5321", + headers: { "Content-Type": "application/json" }, +}) + +// ── Types ───────────────────────────────────────────────────────────────────── + +export interface HydroServerConnection { + id: string + name: string + host: string + auth_type: "apikey" | "userpass" + api_key: string | null + username: string | null + password: string | null +} + +export interface ConnectionPayload { + name: string + host: string + auth_type: "apikey" | "userpass" + api_key?: string | null + username?: string | null + password?: string | null +} + +export interface Schedule { + period: "days" | "hours" | "minutes" + interval: number + start_time: string +} + +export interface ColumnMapping { + csv_column: string + datastream_id: string +} + +export interface Task { + id: string + name: string + connection_id: string + schedule: Schedule | null + is_active: boolean + source_type: "http" | "local" + file_path: string + csv_delimiter: string + csv_header_row: number + csv_timestamp_column: string + csv_timestamp_format: string + column_mappings: ColumnMapping[] + latest_run?: TaskRun | null +} + +export interface TaskPayload { + name: string + connection_id: string + schedule?: Schedule | null + is_active?: boolean + source_type: "http" | "local" + file_path: string + csv_delimiter?: string + csv_header_row?: number + csv_timestamp_column: string + csv_timestamp_format: string + column_mappings?: ColumnMapping[] +} + +export interface TaskRun { + id: string + task_id: string + status: "started" | "success" | "failure" + started_at: string + completed_at: string | null + error_message: string | null + success_count: number | null + failure_count: number | null + skipped_count: number | null + values_loaded_total: number | null + earliest_timestamp: string | null + latest_timestamp: string | null +} + +// ── Connections ─────────────────────────────────────────────────────────────── + +export const api = { + connections: { + list: () => + client.get("/connections/").then(r => r.data), + + get: (id: string) => + client.get(`/connections/${id}`).then(r => r.data), + + create: (payload: ConnectionPayload) => + client.post("/connections/", payload).then(r => r.data), + + update: (id: string, payload: ConnectionPayload) => + client.put(`/connections/${id}`, payload).then(r => r.data), + + delete: (id: string) => + client.delete(`/connections/${id}`).then(r => r.data), + }, + + // ── Tasks ────────────────────────────────────────────────────────────────── + + tasks: { + list: () => + client.get("/tasks/").then(r => r.data), + + get: (id: string) => + client.get(`/tasks/${id}`).then(r => r.data), + + create: (payload: TaskPayload) => + client.post("/tasks/", payload).then(r => r.data), + + update: (id: string, payload: TaskPayload) => + client.put(`/tasks/${id}`, payload).then(r => r.data), + + delete: (id: string) => + client.delete(`/tasks/${id}`).then(r => r.data), + + runNow: (id: string) => + client.post(`/tasks/${id}/run`).then(r => r.data), + }, + + // ── Runs ─────────────────────────────────────────────────────────────────── + + runs: { + list: (taskId?: string) => + client.get("/runs/", { + params: taskId ? { task_id: taskId } : undefined, + }).then(r => r.data), + + get: (id: string) => + client.get(`/runs/${id}`).then(r => r.data), + }, +} diff --git a/src/app.py b/src/app.py deleted file mode 100644 index 85f33cb..0000000 --- a/src/app.py +++ /dev/null @@ -1,570 +0,0 @@ -import os -import json -import sys -import logging -import webbrowser -import subprocess -import hydroserverpy -from scheduler import DataLoaderScheduler -from logging.handlers import RotatingFileHandler -from appdirs import user_data_dir -from PySide6.QtCore import Qt -from PySide6.QtGui import QAction, QIcon, QPixmap -from PySide6.QtWidgets import QApplication, QMainWindow, QSystemTrayIcon, QMenu, QWidget, QVBoxLayout, QLabel, \ - QLineEdit, QHBoxLayout, QPushButton, QMessageBox, QCheckBox - - -class StreamingDataLoader(QMainWindow): - - def __init__(self): - super(StreamingDataLoader, self).__init__() - - self.service = None - self.scheduler = None - - self.instance_name = None - self.workspace_name = None - self.hydroserver_url = None - self.hydroserver_api_key = None - self.hydroserver_username = None - self.hydroserver_password = None - self.connected = False - self.paused = False - - self.status_action = None - self.connection_action = None - self.dashboard_action = None - self.logging_action = None - self.pause_action = None - self.quit_action = None - - self.url_input = None - self.workspace_input = None - self.instance_input = None - self.api_key_input = None - self.email_input = None - self.password_input = None - self.auth_toggle_checkbox = None - - self.api_key_input_widget = None - self.basic_auth_input_widget = None - - self.assets_path = getattr(sys, '_MEIPASS', 'assets') - self.app_dir = user_data_dir('Streaming Data Loader', 'CIROH') - self.app_version = 'dev' - - if not os.path.exists(self.app_dir): - os.makedirs(self.app_dir) - - try: - with open(os.path.join(self.assets_path, 'version.txt')) as f: - self.app_version = f.read().strip() - except FileNotFoundError: - pass - - self.init_ui() - self.get_settings() - - data_loader = self.connect_to_hydroserver() - - self.update_gui() - - if self.connected: - self.scheduler = DataLoaderScheduler( - hs_api=self.service, - data_loader=data_loader - ) - - if not self.connected: - self.show() - - def init_ui(self): - """Builds the app UI including system tray menu and connection window""" - - # System Tray Icon - tray_icon = QSystemTrayIcon(self) - tray_icon_image = QIcon(os.path.join(self.assets_path, "app_icon.png")) - tray_icon_image.setIsMask(True) - tray_icon.setIcon(tray_icon_image) - - # System Tray Menu - tray_menu = QMenu(self) - self.setup_tray_menu_status(tray_menu) - tray_menu.addSeparator() - self.setup_tray_menu_actions(tray_menu) - tray_menu.addSeparator() - self.setup_tray_menu_controls(tray_menu) - tray_icon.setContextMenu(tray_menu) - tray_icon.show() - - # HydroServer Connection Window - self.setWindowTitle(f'Streaming Data Loader ({self.app_version})') - self.setGeometry(300, 300, 550, 550) - self.setFixedSize(550, 550) - central_widget = QWidget(self) - self.setCentralWidget(central_widget) - layout = QVBoxLayout(central_widget) - self.setup_connection_dialog(layout) - - def setup_tray_menu_status(self, tray_menu): - """Components to build menu status""" - - # System Tray Menu Status - self.status_action = QAction(self) - self.status_action.setEnabled(False) - tray_menu.addAction(self.status_action) - - def setup_tray_menu_actions(self, tray_menu): - """Components to build menu actions""" - - # System Tray Menu Open Connection Window - self.connection_action = QAction('HydroServer Connection', self) - self.connection_action.triggered.connect(lambda: self.show()) - tray_menu.addAction(self.connection_action) - - # System Tray Menu View Tasks - self.dashboard_action = QAction('View Tasks', self) - dashboard_icon = QIcon(os.path.join(self.assets_path, 'database.png')) - dashboard_icon.setIsMask(True) - self.dashboard_action.setIcon(dashboard_icon) - self.dashboard_action.triggered.connect(self.open_orchestration_dashboard) - tray_menu.addAction(self.dashboard_action) - - # System Tray Menu View Logs - self.logging_action = QAction('View Log Output', self) - logging_icon = QIcon(os.path.join(self.assets_path, 'description.png')) - logging_icon.setIsMask(True) - self.logging_action.setIcon(logging_icon) - self.logging_action.triggered.connect(self.open_logs) - tray_menu.addAction(self.logging_action) - - def setup_tray_menu_controls(self, tray_menu): - """Components to build menu controls""" - - # System Tray Menu Pause/Resume App - self.pause_action = QAction('Pause', self) - self.pause_action.triggered.connect(self.toggle_paused) - tray_menu.addAction(self.pause_action) - - # System Tray Menu Shut Down App - self.quit_action = QAction('Shut Down', self) - quit_icon = QIcon(os.path.join(self.assets_path, 'exit.png')) - quit_icon.setIsMask(True) - self.quit_action.setIcon(quit_icon) - self.quit_action.triggered.connect(app.quit) - tray_menu.addAction(self.quit_action) - - def setup_connection_dialog(self, layout): - """Components to build connection window""" - - # HydroServer Logo - logo_label = QLabel(self) - logo_label.setPixmap( - QPixmap(os.path.join(self.assets_path, 'setup_icon.png')).scaledToWidth(500, Qt.SmoothTransformation) - ) - logo_layout = QVBoxLayout() - logo_layout.addWidget(logo_label, alignment=Qt.AlignCenter) - logo_layout.setContentsMargins(10, 10, 10, 10) - layout.addLayout(logo_layout) - - # Window Settings - label_width = 150 - input_layout = QVBoxLayout() - input_layout.setContentsMargins(20, 20, 20, 20) - - # HydroServer URL Input - url_box_layout = QHBoxLayout() - url_label = QLabel(f'HydroServer URL:', self) - url_label.setFixedWidth(label_width) - url_box_layout.addWidget(url_label, alignment=Qt.AlignRight) - self.url_input = QLineEdit(self) - self.url_input.setStyleSheet('padding: 5px;') - self.url_input.setPlaceholderText('Enter the HydroServer URL to connect to.') - url_box_layout.addWidget(self.url_input) - layout.addLayout(url_box_layout) - - # Workspace Name Input - workspace_box_layout = QHBoxLayout() - workspace_label = QLabel(f'Workspace Name:', self) - workspace_label.setFixedWidth(label_width) - workspace_box_layout.addWidget(workspace_label, alignment=Qt.AlignRight) - self.workspace_input = QLineEdit(self) - self.workspace_input.setStyleSheet('padding: 5px;') - self.workspace_input.setPlaceholderText('Enter the name of the workspace to use.') - workspace_box_layout.addWidget(self.workspace_input) - layout.addLayout(workspace_box_layout) - - # Instance Name Input - instance_box_layout = QHBoxLayout() - instance_label = QLabel(f'Instance Name:', self) - instance_label.setFixedWidth(label_width) - instance_box_layout.addWidget(instance_label, alignment=Qt.AlignRight) - self.instance_input = QLineEdit(self) - self.instance_input.setStyleSheet('padding: 5px;') - self.instance_input.setPlaceholderText('Enter a name for this streaming data loader.') - instance_box_layout.addWidget(self.instance_input) - layout.addLayout(instance_box_layout) - - # API Key Authentication Input - self.api_key_input_widget = QWidget(self) - api_key_input_layout = QVBoxLayout() - self.api_key_input_widget.setLayout(api_key_input_layout) - api_key_input_layout.setContentsMargins(0, 0, 0, 0) - - api_key_box_layout = QHBoxLayout() - api_key_label = QLabel('HydroServer API Key:', self) - api_key_label.setFixedWidth(label_width) - api_key_box_layout.addWidget(api_key_label, alignment=Qt.AlignRight) - self.api_key_input = QLineEdit(self) - self.api_key_input.setStyleSheet('padding: 5px;') - self.api_key_input.setEchoMode(getattr(QLineEdit, 'Password')) - self.api_key_input.setPlaceholderText('Enter your HydroServer API key.') - api_key_box_layout.addWidget(self.api_key_input) - api_key_input_layout.addLayout(api_key_box_layout) - - layout.addWidget(self.api_key_input_widget, alignment=Qt.AlignTop) - - # Basic Authentication Input - self.basic_auth_input_widget = QWidget(self) - basic_auth_input_layout = QVBoxLayout() - self.basic_auth_input_widget.setLayout(basic_auth_input_layout) - basic_auth_input_layout.setContentsMargins(0, 0, 0, 0) - - email_box_layout = QHBoxLayout() - email_label = QLabel('HydroServer Email:', self) - email_label.setFixedWidth(label_width) - email_box_layout.addWidget(email_label, alignment=Qt.AlignRight) - self.email_input = QLineEdit(self) - self.email_input.setStyleSheet('padding: 5px;') - self.email_input.setPlaceholderText('Enter your HydroServer Email.') - email_box_layout.addWidget(self.email_input) - basic_auth_input_layout.addLayout(email_box_layout) - - password_box_layout = QHBoxLayout() - password_label = QLabel('HydroServer Password:', self) - password_label.setFixedWidth(label_width) - password_box_layout.addWidget(password_label, alignment=Qt.AlignRight) - self.password_input = QLineEdit(self) - self.password_input.setStyleSheet('padding: 5px;') - self.password_input.setEchoMode(getattr(QLineEdit, 'Password')) - self.password_input.setPlaceholderText('Enter your HydroServer Password.') - password_box_layout.addWidget(self.password_input) - basic_auth_input_layout.addLayout(password_box_layout) - - layout.addWidget(self.basic_auth_input_widget) - - # Authentication Mode Toggle - self.auth_toggle_checkbox = QCheckBox("Authenticate with username and password", self) - self.auth_toggle_checkbox.setStyleSheet('padding: 5px;') - self.auth_toggle_checkbox.stateChanged.connect(lambda: self.toggle_auth_input()) - layout.addWidget(self.auth_toggle_checkbox) - - # Window Actions Settings - actions_layout = QHBoxLayout() - actions_layout.setContentsMargins(0, 0, 20, 20) - actions_layout.addStretch(1) - - # Confirm Button - confirm_button = QPushButton('Confirm', self) - confirm_button.clicked.connect(lambda: self.confirm_settings()) - confirm_button.setStyleSheet( - 'background-color: #007BFF; color: white; border: 1px solid #007BFF; border-radius: 8px; padding: 8px;' - 'hover { background-color: #0056b3; }' - ) - confirm_button.setCursor(Qt.PointingHandCursor) - confirm_button.setFixedSize(80, 30) - actions_layout.addWidget(confirm_button) - - # Cancel Button - cancel_button = QPushButton('Cancel', self) - cancel_button.clicked.connect(lambda: self.hide()) - cancel_button.setStyleSheet( - 'border: 1px solid #707070; border-radius: 8px; padding: 8px;' - 'hover { background-color: #e0e0e0; }' - ) - cancel_button.setCursor(Qt.PointingHandCursor) - cancel_button.setFixedSize(80, 30) - actions_layout.addWidget(cancel_button) - - layout.addLayout(actions_layout) - - def toggle_auth_input(self): - """Switches between API key and email/password authentication inputs.""" - - if self.auth_toggle_checkbox.isChecked(): - self.api_key_input_widget.setVisible(False) - self.basic_auth_input_widget.setVisible(True) - else: - self.basic_auth_input_widget.setVisible(False) - self.api_key_input_widget.setVisible(True) - - def open_orchestration_dashboard(self): - """Opens user's Orchestration Dashboard in a browser window""" - - webbrowser.open(f'{self.hydroserver_url}/orchestration') - - def open_logs(self): - """Opens app log file in a text viewer""" - - subprocess.call(['open', os.path.join(self.app_dir, 'streaming_data_loader.log')]) - - def toggle_paused(self): - """Toggles whether the app is paused or not""" - - self.paused = not self.paused - if self.connected and self.paused is True: - self.scheduler.pause() - elif self.connected and self.paused is False: - self.scheduler.resume() - self.update_gui() - - def connect_to_hydroserver(self): - """Uses connection settings to register app on HydroServer""" - - if not all([ - self.hydroserver_url, self.workspace_name, self.instance_name - ]) or ( - not (self.hydroserver_username and self.hydroserver_password) and not self.hydroserver_api_key - ): - self.connected = False - return 'Missing required connection parameters.' - - try: - if self.hydroserver_api_key: - self.service = hydroserverpy.HydroServer( - host=self.hydroserver_url, - apikey=self.hydroserver_api_key - ) - else: - self.service = hydroserverpy.HydroServer( - host=self.hydroserver_url, - email=self.hydroserver_username, - password=self.hydroserver_password - ) - except: - self.connected = False - return 'Failed to connect to HydroServer.' - - workspaces = self.service.workspaces.list(is_associated=True, fetch_all=True) - workspace = next((workspace for workspace in workspaces.items if workspace.name == self.workspace_name), None) - - orchestration_systems = self.service.orchestrationsystems.list(workspace=workspace, fetch_all=True) - orchestration_system = next(( - orchestration_system for orchestration_system in orchestration_systems.items - if orchestration_system.name == self.instance_name - ), None) - - if not workspace: - self.connected = False - return 'The provided workspace was not found.' - - if not orchestration_system: - try: - orchestration_system = self.service.orchestrationsystems.create( - name=self.instance_name, - workspace=workspace, - orchestration_system_type="SDL" - ) - except (Exception,) as e: - print(e) - return 'Failed to register Streaming Data Loader instance.' - - self.connected = True - - return orchestration_system - - def get_settings(self): - """Get settings from settings file""" - - settings_path = os.path.join(self.app_dir, 'settings.json') - if os.path.exists(settings_path): - with open(settings_path, 'r') as settings_file: - settings = json.loads(settings_file.read() or 'null') or {} - self.hydroserver_url = settings.get('url') - self.hydroserver_api_key = settings.get('apikey') - self.hydroserver_username = settings.get('username') - self.hydroserver_password = settings.get('password') - self.workspace_name = settings.get('workspace') - self.instance_name = settings.get('name') - self.paused = settings.get('paused') - - def update_settings( - self, - hydroserver_url=None, - instance_name=None, - workspace_name=None, - hydroserver_api_key=None, - hydroserver_username=None, - hydroserver_password=None, - use_api_key=True, - paused=None - ): - """Update settings file with new settings""" - - if use_api_key is True: - api_key = hydroserver_api_key if hydroserver_api_key is not None else self.hydroserver_api_key - username = None - password = None - else: - api_key = None - username = hydroserver_username if hydroserver_username is not None else self.hydroserver_username - password = hydroserver_password if hydroserver_password is not None else self.hydroserver_password - - settings_path = os.path.join(self.app_dir, 'settings.json') - with open(settings_path, 'w') as settings_file: - settings_file.write(json.dumps({ - 'url': hydroserver_url if hydroserver_url is not None else self.hydroserver_url, - 'name': instance_name if instance_name is not None else self.instance_name, - 'workspace': workspace_name if workspace_name is not None else self.workspace_name, - 'apikey': api_key, - 'username': username, - 'password': password, - 'paused': paused if paused is not None else self.paused - })) - self.get_settings() - - def confirm_settings(self): - """Handle the user updating connection settings""" - - if not all([ - self.url_input.text(), self.workspace_input.text(), self.instance_input.text() - ]) or ( - not (self.email_input.text() and self.password_input.text()) and not self.api_key_input.text() - ): - return self.show_message( - title='Missing Required Fields', - message='All fields are required to register the Streaming Data Loader app on HydroServer.' - ) - - self.update_settings( - hydroserver_url=self.url_input.text(), - instance_name=self.instance_input.text(), - workspace_name=self.workspace_input.text(), - hydroserver_api_key=self.api_key_input.text(), - hydroserver_username=self.email_input.text(), - hydroserver_password=self.password_input.text(), - use_api_key=not self.auth_toggle_checkbox.isChecked(), - ) - - connection_message = self.connect_to_hydroserver() - self.update_gui() - - if self.connected is False: - return self.show_message( - title='Connection Failed', - message=connection_message - ) - - if self.scheduler: - self.scheduler.terminate() - - self.scheduler = DataLoaderScheduler( - hs_api=self.service, - data_loader=connection_message - ) - - if self.paused is True: - self.scheduler.pause() - - self.show_message( - title='Streaming Data Loader Setup Complete', - message='The Streaming Data Loader has been successfully registered and is now running.' - ) - - self.hide() - - @staticmethod - def show_message(title, message): - """Show a message window to the user""" - - message_box = QMessageBox() - message_box.setWindowTitle(title) - message_box.setText(message) - message_box.exec_() - - def update_gui(self): - """Update UI elements when settings/state changes""" - - if self.paused: - pause_action_text = 'Resume' - pause_action_icon = 'resume.png' - else: - pause_action_text = 'Pause' - pause_action_icon = 'pause.png' - - if self.connected and not self.paused: - status = 'Running' - connection_icon = 'connected.png' - data_sources_enabled = True - elif self.connected and self.paused: - status = 'Paused' - connection_icon = 'connected.png' - data_sources_enabled = True - else: - status = 'Not Connected' - connection_icon = 'disconnected.png' - data_sources_enabled = False - - self.status_action.setText(f'Status: {status}') - - connected_icon = QIcon(os.path.join(self.assets_path, connection_icon)) - connected_icon.setIsMask(True) - self.connection_action.setIcon(connected_icon) - self.dashboard_action.setEnabled(data_sources_enabled) - - self.pause_action.setText(pause_action_text) - pause_icon = QIcon(os.path.join(self.assets_path, pause_action_icon)) - pause_icon.setIsMask(True) - self.pause_action.setIcon(pause_icon) - - if self.isHidden(): - self.url_input.setText(self.hydroserver_url if self.hydroserver_url else 'https://www.hydroserver.org') - self.instance_input.setText(self.instance_name if self.instance_name else '') - self.workspace_input.setText(self.workspace_name if self.workspace_name else '') - self.api_key_input.setText(self.hydroserver_api_key if self.hydroserver_api_key else '') - self.email_input.setText(self.hydroserver_username if self.hydroserver_username else '') - self.password_input.setText(self.hydroserver_password if self.hydroserver_password else '') - self.auth_toggle_checkbox.setChecked(bool(self.email_input.text())) - self.api_key_input_widget.setVisible(not bool(self.email_input.text())) - self.basic_auth_input_widget.setVisible(bool(self.email_input.text())) - - -if __name__ == '__main__': - - hydroloader_logger = logging.getLogger('hydroloader') - scheduler_logger = logging.getLogger('scheduler') - - stream_handler = logging.StreamHandler() - hydroloader_logger.addHandler(stream_handler) - scheduler_logger.addHandler(stream_handler) - - user_dir = user_data_dir('Streaming Data Loader', 'CIROH') - - if not os.path.exists(user_dir): - os.makedirs(user_dir) - - log_path = os.path.join(user_dir, 'streaming_data_loader.log') - - log_handler = RotatingFileHandler( - filename=log_path, - mode='a', - maxBytes=20 * 1024 * 1024, - backupCount=3 - ) - hydroloader_logger.addHandler(log_handler) - scheduler_logger.addHandler(log_handler) - - logging.basicConfig( - format='%(asctime)s %(levelname)-8s %(message)s', - level=logging.INFO, - datefmt='%Y-%m-%d %H:%M:%S', - force=True, - handlers=[ - log_handler, stream_handler - ] - ) - - app = QApplication(sys.argv) - app.setQuitOnLastWindowClosed(False) - window = StreamingDataLoader() - sys.exit(app.exec_()) diff --git a/src/assets/connected.png b/src/assets/connected.png deleted file mode 100644 index 1a9d700080d3f782fd5c15b96285f90ecbff8b3e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 218 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM1|%Pp+x`GjjKx9jP7LeL$-D$|rg*wIhIn|t zof6B}pup37`s|mKS0}%uz2foyb6bPo#peb$=d8LdJ**<@0wUx?8LqwfR40-BUx@4R z0>(3TuNZh3Qod#}8od2~@|VVYzBH#8R@Xh}SzkQeRhI!3w7K{FxEP52H!YA2r zbJ5C>(lEMDXf>*h3Fg)Z^kW}67 Rstt4>gQu&X%Q~loCIBxDRgM4v diff --git a/src/assets/database.png b/src/assets/database.png deleted file mode 100644 index 528df37b520a7bc589e70fdb035b94d214f81add..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 276 zcmV+v0qg#WP)Px#&PhZ;R7gwhl~EGHAPhv)lc^_DPo|zseGXwlB*;*i{xm;yXu8X861vFbSSI7k zrUb{N?rc=*9$R-2d8Buj$cdT)KmfI1Jx*TVNnb>sfCPKu9$=qyO6orQp5Qica!-JD zkW3>etnv!*9TjV$Nua2K+VPujEj7@);Yi`u=vil3D a1$hDC?>QMG#GsJ?0000i2^}D>~pO;zMmQS|=I0f7Vp1bo&F!(T3=pHTNJ9mw# z_(0UeKc}*lEEBeeu{Lz>^mY@>Wc?v?I$6nPx#+et)0R7gv`m1`D)KnR6#W!B2Fl~pS%uSYzHFie5*rz1J@@w^UnvJEHSPk#kG zi0CzEG^woK>$48V^Y4!Hp$icK0J|$l2>=%(@|yu+e<}k`s|D#TomnF3j55)K_gu@0 z--CHm^b6$r4FK#-X=#4jZv(;oN zAD-xjkVs9_kO+XtJqR`z|5*aLW!wYMLdb_v9+)MNWdLloCo2min*l4V3+V-TQzZQi nL2tI5s047YcHfO^z#-EQ?W8{$aEn!F00000NkvXXu0mjfl@D@{ diff --git a/src/assets/exit.png b/src/assets/exit.png deleted file mode 100644 index 8d36c64bcdbbc4fdaee0ab25581a09c532909a62..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 307 zcmV-30nGl1P)Px#?MXyIR7gwJl>rLFKnMgcKWkpr_GN8f*0SIR&)j+z5@-Vr}@e%zl?ip~=It9tofdG<)ks1kA=T`(6T2rkbv=6NrJ-cve;x6?FYm zJ?QFKiEiVG^N<4MxD_BQUpl6Ulb%cglH)yF73w^Y1H~u{p2Neb0KQak1jsM57#QDZ zGu=hrfYP%Tq$(@_1FmjBxHa+qQY+eLQvnnPNSpjW-~-U|L>XX^n0^2N002ovPDHLk FV1fuCeR}`^ diff --git a/src/assets/main.css b/src/assets/main.css new file mode 100644 index 0000000..388ac92 --- /dev/null +++ b/src/assets/main.css @@ -0,0 +1,4 @@ +@import "tailwindcss"; +@plugin "daisyui" { + themes: light, dark; +} \ No newline at end of file diff --git a/src/assets/pause.png b/src/assets/pause.png deleted file mode 100644 index 4e745f14229fd43edbbf0b8f3137ed0c963d94c6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 145 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM1|%Pp+x`GjjKx9jP7LeL$-D$|yggkULp;2b zQx+H|EZO*zXR(+7PhySZ7kOXvT~SV-65cKrQ)pMZc0q!7(M1KPlUa);c^BVN3b`gQ rN&d&Cj)Xv;o~DaZy$Ko@Z8{kIT4W^c{kGHsjb-q3^>bP0l+XkKwm&Z% diff --git a/src/assets/resume.png b/src/assets/resume.png deleted file mode 100644 index cefc7e96d69c78cf17656ee784316a61736a86db..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 207 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM1|%Pp+x`GjjKx9jP7LeL$-D$|Iz3$+Lp;3S zUUB4WFyLTu_+?VROg}h&S%S&M=G2?hqCI<-8

+
+
HydroServer
+

Streaming Data Loader

+

+ Tauri shell with a minimal vanilla TypeScript frontend. +

+ +
+ + Checking sidecar status... +
+ +
+ +
+ +
+
+
Frontend
+
TypeScript + HTML + CSS
+
+
+
Rust Source
+
src/
+
+
+
Legacy Reference
+
legacy-reference/
+
+
+
+
+` + +const sidecarText = document.querySelector("#sidecar-text") +const sidecarDot = document.querySelector("#sidecar-dot") +const refreshButton = document.querySelector("#refresh-status") + +async function refreshSidecarStatus(): Promise { + if (!sidecarText || !sidecarDot) { + return + } + + sidecarText.textContent = "Checking sidecar status..." + sidecarDot.dataset.state = "pending" + + try { + const response = await fetch("http://127.0.0.1:5321/health") + if (!response.ok) { + throw new Error(`Unexpected status: ${response.status}`) + } + + sidecarText.textContent = "Sidecar online" + sidecarDot.dataset.state = "online" + } catch { + sidecarText.textContent = "Sidecar offline" + sidecarDot.dataset.state = "offline" + } +} + +refreshButton?.addEventListener("click", () => { + void refreshSidecarStatus() +}) + +void refreshSidecarStatus() diff --git a/frontend/styles.css b/frontend/styles.css new file mode 100644 index 0000000..9049072 --- /dev/null +++ b/frontend/styles.css @@ -0,0 +1,162 @@ +:root { + color-scheme: light dark; + font-family: "SF Pro Text", "Segoe UI", sans-serif; + background: + radial-gradient(circle at top, rgba(54, 94, 255, 0.18), transparent 36%), + linear-gradient(180deg, #0f172a, #111827 58%, #0b1220); + color: #e5eefc; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + min-height: 100%; +} + +body { + min-height: 100vh; +} + +button, +input, +textarea, +select { + font: inherit; +} + +code { + font-family: "SF Mono", "Cascadia Code", "JetBrains Mono", monospace; +} + +.shell { + min-height: 100vh; + display: grid; + place-items: center; + padding: 32px; +} + +.panel { + width: min(100%, 720px); + padding: 32px; + border: 1px solid rgba(148, 163, 184, 0.24); + border-radius: 24px; + background: rgba(15, 23, 42, 0.82); + box-shadow: 0 24px 80px rgba(0, 0, 0, 0.3); + backdrop-filter: blur(12px); +} + +.eyebrow { + margin-bottom: 12px; + color: #93c5fd; + font-size: 0.8rem; + font-weight: 700; + letter-spacing: 0.16em; + text-transform: uppercase; +} + +h1 { + margin: 0; + font-size: clamp(2rem, 4vw, 3.2rem); + line-height: 0.98; +} + +.lede { + margin: 16px 0 24px; + max-width: 48ch; + color: #cbd5e1; + font-size: 1rem; + line-height: 1.6; +} + +.status-row { + display: inline-flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + border-radius: 999px; + background: rgba(15, 23, 42, 0.72); + border: 1px solid rgba(148, 163, 184, 0.2); +} + +.status-dot { + width: 12px; + height: 12px; + border-radius: 999px; + background: #64748b; + box-shadow: 0 0 0 6px rgba(100, 116, 139, 0.16); +} + +.status-dot[data-state="pending"] { + background: #f59e0b; + box-shadow: 0 0 0 6px rgba(245, 158, 11, 0.16); +} + +.status-dot[data-state="online"] { + background: #22c55e; + box-shadow: 0 0 0 6px rgba(34, 197, 94, 0.16); +} + +.status-dot[data-state="offline"] { + background: #ef4444; + box-shadow: 0 0 0 6px rgba(239, 68, 68, 0.16); +} + +.actions { + margin-top: 18px; +} + +button { + border: 0; + border-radius: 999px; + padding: 10px 16px; + background: linear-gradient(135deg, #38bdf8, #2563eb); + color: white; + cursor: pointer; +} + +button:hover { + filter: brightness(1.05); +} + +.facts { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 16px; + margin: 28px 0 0; +} + +.facts div { + padding: 16px; + border-radius: 16px; + background: rgba(30, 41, 59, 0.7); + border: 1px solid rgba(148, 163, 184, 0.18); +} + +.facts dt { + margin-bottom: 8px; + color: #93c5fd; + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.facts dd { + margin: 0; + color: #e2e8f0; +} + +@media (max-width: 640px) { + .shell { + padding: 20px; + } + + .panel { + padding: 24px; + border-radius: 20px; + } +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..3b95b28 --- /dev/null +++ b/index.html @@ -0,0 +1,12 @@ + + + + + + Streaming Data Loader + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..bbc08eb --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1337 @@ +{ + "name": "streaming-data-loader", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "streaming-data-loader", + "version": "0.1.0", + "devDependencies": { + "@tauri-apps/cli": "^2.10.1", + "typescript": "^5.6.3", + "vite": "^6.0.3" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tauri-apps/cli": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.10.1.tgz", + "integrity": "sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g==", + "dev": true, + "license": "Apache-2.0 OR MIT", + "bin": { + "tauri": "tauri.js" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + }, + "optionalDependencies": { + "@tauri-apps/cli-darwin-arm64": "2.10.1", + "@tauri-apps/cli-darwin-x64": "2.10.1", + "@tauri-apps/cli-linux-arm-gnueabihf": "2.10.1", + "@tauri-apps/cli-linux-arm64-gnu": "2.10.1", + "@tauri-apps/cli-linux-arm64-musl": "2.10.1", + "@tauri-apps/cli-linux-riscv64-gnu": "2.10.1", + "@tauri-apps/cli-linux-x64-gnu": "2.10.1", + "@tauri-apps/cli-linux-x64-musl": "2.10.1", + "@tauri-apps/cli-win32-arm64-msvc": "2.10.1", + "@tauri-apps/cli-win32-ia32-msvc": "2.10.1", + "@tauri-apps/cli-win32-x64-msvc": "2.10.1" + } + }, + "node_modules/@tauri-apps/cli-darwin-arm64": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.10.1.tgz", + "integrity": "sha512-Z2OjCXiZ+fbYZy7PmP3WRnOpM9+Fy+oonKDEmUE6MwN4IGaYqgceTjwHucc/kEEYZos5GICve35f7ZiizgqEnQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-darwin-x64": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.10.1.tgz", + "integrity": "sha512-V/irQVvjPMGOTQqNj55PnQPVuH4VJP8vZCN7ajnj+ZS8Kom1tEM2hR3qbbIRoS3dBKs5mbG8yg1WC+97dq17Pw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.10.1.tgz", + "integrity": "sha512-Hyzwsb4VnCWKGfTw+wSt15Z2pLw2f0JdFBfq2vHBOBhvg7oi6uhKiF87hmbXOBXUZaGkyRDkCHsdzJcIfoJC2w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-gnu": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.10.1.tgz", + "integrity": "sha512-OyOYs2t5GkBIvyWjA1+h4CZxTcdz1OZPCWAPz5DYEfB0cnWHERTnQ/SLayQzncrT0kwRoSfSz9KxenkyJoTelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-musl": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.10.1.tgz", + "integrity": "sha512-MIj78PDDGjkg3NqGptDOGgfXks7SYJwhiMh8SBoZS+vfdz7yP5jN18bNaLnDhsVIPARcAhE1TlsZe/8Yxo2zqg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.10.1.tgz", + "integrity": "sha512-X0lvOVUg8PCVaoEtEAnpxmnkwlE1gcMDTqfhbefICKDnOTJ5Est3qL0SrWxizDackIOKBcvtpejrSiVpuJI1kw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-gnu": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.10.1.tgz", + "integrity": "sha512-2/12bEzsJS9fAKybxgicCDFxYD1WEI9kO+tlDwX5znWG2GwMBaiWcmhGlZ8fi+DMe9CXlcVarMTYc0L3REIRxw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-musl": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.10.1.tgz", + "integrity": "sha512-Y8J0ZzswPz50UcGOFuXGEMrxbjwKSPgXftx5qnkuMs2rmwQB5ssvLb6tn54wDSYxe7S6vlLob9vt0VKuNOaCIQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-arm64-msvc": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.10.1.tgz", + "integrity": "sha512-iSt5B86jHYAPJa/IlYw++SXtFPGnWtFJriHn7X0NFBVunF6zu9+/zOn8OgqIWSl8RgzhLGXQEEtGBdR4wzpVgg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-ia32-msvc": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.10.1.tgz", + "integrity": "sha512-gXyxgEzsFegmnWywYU5pEBURkcFN/Oo45EAwvZrHMh+zUSEAvO5E8TXsgPADYm31d1u7OQU3O3HsYfVBf2moHw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-x64-msvc": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.10.1.tgz", + "integrity": "sha512-6Cn7YpPFwzChy0ERz6djKEmUehWrYlM+xTaNzGPgZocw3BD7OfwfWHKVWxXzdjEW2KfKkHddfdxK1XXTYqBRLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..66419d7 --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "streaming-data-loader", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview", + "tauri": "tauri" + }, + "devDependencies": { + "@tauri-apps/cli": "^2.10.1", + "typescript": "^5.6.3", + "vite": "^6.0.3" + } +} diff --git a/tauri.conf.json b/tauri.conf.json index fd7aa07..ff098e4 100644 --- a/tauri.conf.json +++ b/tauri.conf.json @@ -4,10 +4,10 @@ "version": "0.1.0", "identifier": "com.streaming-data-loader.app", "build": { - "beforeDevCommand": "npm --prefix legacy-reference run dev", + "beforeDevCommand": "npm run dev", "devUrl": "http://localhost:1420", - "beforeBuildCommand": "npm --prefix legacy-reference run build", - "frontendDist": "legacy-reference/dist" + "beforeBuildCommand": "npm run build", + "frontendDist": "dist" }, "app": { "windows": [ diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..b216bd3 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "Bundler", + "allowImportingTsExtensions": false, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true + }, + "include": ["frontend/**/*.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..5255e44 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "vite" + +export default defineConfig({ + clearScreen: false, + server: { + port: 1420, + strictPort: true, + watch: { + ignored: ["**/target/**"], + }, + }, +}) From f7962392bd6d352d4829ab99abe8db1a8611e7b6 Mon Sep 17 00:00:00 2001 From: Daniel Slaugh Date: Thu, 2 Apr 2026 10:41:29 -0600 Subject: [PATCH 004/166] Initial code to get Tauri app running locally --- .env.development | 6 + .gitignore | 5 + .vscode/extensions.json | 9 + .vscode/launch.json | 23 + .vscode/settings.json | 25 + .vscode/tasks.json | 148 +++++ README.md | 32 +- frontend/api.ts | 164 +++++ frontend/config.ts | 2 + frontend/main.ts | 687 +++++++++++++++++++-- frontend/router.ts | 42 ++ frontend/styles.css | 342 +++++++---- frontend/time.ts | 50 ++ frontend/vite-env.d.ts | 9 + index.html | 59 +- package-lock.json | 1056 +++++++++++++++++++++++++++++++- package.json | 12 +- scripts/bootstrap-frontend.mjs | 40 ++ scripts/bootstrap-sidecar.mjs | 78 +++ scripts/run-sidecar.mjs | 61 ++ scripts/run-tauri.mjs | 53 ++ scripts/tauri-dev.mjs | 99 +++ sidecar/__init__.py | 1 + sidecar/api/__init__.py | 1 + sidecar/api/models.py | 143 +++++ sidecar/api/routes.py | 273 +++++++++ sidecar/core/__init__.py | 1 + sidecar/core/config.py | 84 +++ sidecar/core/hydroserver.py | 90 +++ sidecar/core/loader.py | 112 ++++ sidecar/core/runtime.py | 30 + sidecar/core/scheduler.py | 17 + sidecar/core/state.py | 62 ++ sidecar/main.py | 58 ++ sidecar/requirements.txt | 6 + sidecar/sdl.spec | 29 + src/lib.rs | 87 ++- tauri.conf.json | 2 +- tsconfig.json | 3 +- tsconfig.node.json | 12 + vite.config.ts | 44 +- 41 files changed, 3821 insertions(+), 236 deletions(-) create mode 100644 .env.development create mode 100644 .vscode/extensions.json create mode 100644 .vscode/launch.json create mode 100644 .vscode/settings.json create mode 100644 .vscode/tasks.json create mode 100644 frontend/api.ts create mode 100644 frontend/config.ts create mode 100644 frontend/router.ts create mode 100644 frontend/time.ts create mode 100644 frontend/vite-env.d.ts create mode 100644 scripts/bootstrap-frontend.mjs create mode 100644 scripts/bootstrap-sidecar.mjs create mode 100644 scripts/run-sidecar.mjs create mode 100644 scripts/run-tauri.mjs create mode 100644 scripts/tauri-dev.mjs create mode 100644 sidecar/__init__.py create mode 100644 sidecar/api/__init__.py create mode 100644 sidecar/api/models.py create mode 100644 sidecar/api/routes.py create mode 100644 sidecar/core/__init__.py create mode 100644 sidecar/core/config.py create mode 100644 sidecar/core/hydroserver.py create mode 100644 sidecar/core/loader.py create mode 100644 sidecar/core/runtime.py create mode 100644 sidecar/core/scheduler.py create mode 100644 sidecar/core/state.py create mode 100644 sidecar/main.py create mode 100644 sidecar/requirements.txt create mode 100644 sidecar/sdl.spec create mode 100644 tsconfig.node.json diff --git a/.env.development b/.env.development new file mode 100644 index 0000000..aebe440 --- /dev/null +++ b/.env.development @@ -0,0 +1,6 @@ +SDL_FRONTEND_HOST=localhost +SDL_FRONTEND_PORT=1420 +SDL_SIDECAR_HOST=127.0.0.1 +SDL_SIDECAR_PORT=5321 +SDL_CONFIG_DIR=.local/sidecar-dev +VITE_API_BASE_URL=/api diff --git a/.gitignore b/.gitignore index 3312dc9..b01b766 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,11 @@ /node_modules/ /dist/ *.local +/.cache/ +/.venv/ +/frontend/generated.css +*.pyc +__pycache__/ # Legacy reference build artifacts /legacy-reference/node_modules/ diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..8a1257e --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,9 @@ +{ + "recommendations": [ + "ms-python.python", + "ms-python.debugpy", + "bradlc.vscode-tailwindcss", + "tauri-apps.tauri-vscode", + "rust-lang.rust-analyzer" + ] +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..de13d13 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,23 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "SDL: Dev", + "type": "debugpy", + "request": "launch", + "module": "sidecar.main", + "cwd": "${workspaceFolder}", + "console": "integratedTerminal", + "justMyCode": true, + "preLaunchTask": "SDL: Dev Services", + "envFile": "${workspaceFolder}/.env.development", + "python": "${workspaceFolder}/.venv/bin/python", + "windows": { + "python": "${workspaceFolder}\\.venv\\Scripts\\python.exe" + }, + "env": { + "PYTHONUNBUFFERED": "1" + } + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..fb08e1b --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,25 @@ +{ + "files.exclude": { + "**/.cache": true, + "**/.venv": true, + "**/__pycache__": true, + "dist": true, + "node_modules": true + }, + "files.watcherExclude": { + "**/.cache/**": true, + "**/.venv/**": true, + "**/dist/**": true, + "**/node_modules/**": true + }, + "search.exclude": { + "**/.cache/**": true, + "**/.venv/**": true, + "**/dist/**": true, + "**/node_modules/**": true + }, + "python.analysis.exclude": [ + ".venv/**" + ], + "python.terminal.activateEnvironment": true +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..98c08e3 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,148 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "SDL: Bootstrap Frontend", + "type": "shell", + "command": "node", + "args": [ + "./scripts/bootstrap-frontend.mjs" + ], + "options": { + "cwd": "${workspaceFolder}" + }, + "presentation": { + "reveal": "always", + "panel": "dedicated", + "group": "sdl-bootstrap" + }, + "problemMatcher": [] + }, + { + "label": "SDL: Bootstrap Sidecar", + "type": "shell", + "command": "node", + "args": [ + "./scripts/bootstrap-sidecar.mjs" + ], + "options": { + "cwd": "${workspaceFolder}" + }, + "presentation": { + "reveal": "always", + "panel": "dedicated", + "group": "sdl-bootstrap" + }, + "problemMatcher": [] + }, + { + "label": "SDL: Tailwind", + "type": "shell", + "command": "npm", + "args": [ + "run", + "tailwind:watch" + ], + "options": { + "cwd": "${workspaceFolder}" + }, + "isBackground": true, + "presentation": { + "reveal": "always", + "panel": "dedicated", + "group": "sdl-dev" + }, + "problemMatcher": { + "owner": "tailwind", + "pattern": { + "regexp": ".+" + }, + "background": { + "activeOnStart": true, + "beginsPattern": ".", + "endsPattern": "Done in .*" + } + } + }, + { + "label": "SDL: Frontend", + "type": "shell", + "command": "npm", + "args": [ + "run", + "dev" + ], + "options": { + "cwd": "${workspaceFolder}" + }, + "isBackground": true, + "presentation": { + "reveal": "always", + "panel": "dedicated", + "group": "sdl-dev" + }, + "problemMatcher": { + "owner": "vite", + "pattern": { + "regexp": ".+" + }, + "background": { + "activeOnStart": true, + "beginsPattern": ".", + "endsPattern": "Local:.*" + } + } + }, + { + "label": "SDL: Sidecar", + "type": "shell", + "command": "node", + "args": [ + "./scripts/run-sidecar.mjs" + ], + "options": { + "cwd": "${workspaceFolder}" + }, + "isBackground": true, + "presentation": { + "reveal": "always", + "panel": "dedicated", + "group": "sdl-dev" + }, + "problemMatcher": { + "owner": "uvicorn", + "pattern": { + "regexp": ".+" + }, + "background": { + "activeOnStart": true, + "beginsPattern": ".", + "endsPattern": "Uvicorn running on .*" + } + } + }, + { + "label": "SDL: Dev Services", + "dependsOrder": "sequence", + "dependsOn": [ + "SDL: Bootstrap Frontend", + "SDL: Bootstrap Sidecar", + "SDL: Tailwind", + "SDL: Frontend" + ], + "problemMatcher": [] + }, + { + "label": "SDL: Dev", + "dependsOrder": "sequence", + "dependsOn": [ + "SDL: Bootstrap Frontend", + "SDL: Bootstrap Sidecar", + "SDL: Tailwind", + "SDL: Frontend", + "SDL: Sidecar" + ], + "problemMatcher": [] + } + ] +} diff --git a/README.md b/README.md index 68c0c09..8419a0c 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,24 @@ # Streaming Data Loader -This repository is now organized as a Tauri-first desktop app with a minimal vanilla TypeScript frontend. +This repository is now organized as a Tauri-first app with a browser-first local +development loop. -## Layout +## Active layout -- `src/`: Rust source for the Tauri application -- `frontend/`: vanilla TypeScript and CSS for the webview UI -- `index.html`: Vite entry HTML -- `icons/`, `capabilities/`, `tauri.conf.json`, `Cargo.toml`: root Tauri project files -- `legacy-reference/`: archived SDL implementation kept as reference only +- `src/`: Rust source for the Tauri shell +- `frontend/`: plain TypeScript, HTML, and Tailwind-authored CSS +- `sidecar/`: active FastAPI sidecar for SDL v2 +- `.vscode/`: committed VS Code tasks, launch configs, and workspace settings +- `legacy-reference/`: archived SDL implementation kept as a reference only -## Frontend +## Local development -The active frontend uses: +- `Run Task -> SDL: Dev` starts the sidecar, Tailwind watcher, and Vite preview +- `Run and Debug -> SDL: Dev` starts Tailwind + Vite, then launches the FastAPI + sidecar under the Python debugger +- `npm run tauri dev` now starts the desktop window plus the Tailwind watcher, + Vite dev server, and, in dev mode, auto-starts the root sidecar if the + configured sidecar port is not already in use -- Vite -- TypeScript -- plain HTML -- plain CSS - -No Vue runtime or Vue build plugins are used by the active Tauri app. +The browser opens automatically from the Vite dev server, and the root frontend +uses `/api` plus Vite proxying instead of hardcoding the sidecar port. diff --git a/frontend/api.ts b/frontend/api.ts new file mode 100644 index 0000000..e8df4d8 --- /dev/null +++ b/frontend/api.ts @@ -0,0 +1,164 @@ +import { apiBaseUrl } from "./config" + +export type ConnectionState = "not_configured" | "configured" | "connected" | "error" +export type JobStatus = "healthy" | "warning" | "error" | "disabled" | "pending" | "running" + +export interface ServerConfig { + url: string + api_key: string +} + +export interface FileConfig { + header_row: number + data_start_row: number + delimiter: string + timestamp_column: string + timestamp_format: string + timezone: string +} + +export interface ColumnMapping { + csv_column: string + datastream_id: string + datastream_name: string +} + +export interface JobConfig { + id: string + name: string + enabled: boolean + file_path: string + schedule_minutes: number + file_config: FileConfig + column_mappings: ColumnMapping[] +} + +export interface AppConfig { + version: number + server: ServerConfig + jobs: JobConfig[] +} + +export interface ConnectionStatus { + state: ConnectionState + message: string +} + +export interface HealthResponse { + status: "ok" + version: string + config_dir: string + server_configured: boolean + connection: ConnectionStatus +} + +export interface ConnectionTestResponse { + ok: boolean + state: ConnectionState + message: string + instance_name: string | null +} + +export interface JobSummary extends JobConfig { + status: JobStatus + status_message: string + last_pushed_timestamp: string | null + last_run_at: string | null + last_error: string | null +} + +export interface JobLogEntry { + timestamp: string + level: "info" | "warning" | "error" + message: string +} + +export interface JobDetail extends JobSummary { + recent_logs: JobLogEntry[] +} + +export interface ActionResponse { + ok: boolean + message: string +} + +function buildApiUrl(path: string): string { + return `${apiBaseUrl.replace(/\/$/, "")}${path}` +} + +async function request(path: string, init?: RequestInit): Promise { + const response = await fetch(buildApiUrl(path), { + headers: { + "Content-Type": "application/json", + ...(init?.headers ?? {}), + }, + ...init, + }) + + if (!response.ok) { + let detail = `Request failed with status ${response.status}` + + try { + const payload = (await response.json()) as { detail?: string } + if (payload.detail) { + detail = payload.detail + } + } catch { + // Ignore JSON parsing errors for non-JSON error responses. + } + + throw new Error(detail) + } + + return (await response.json()) as T +} + +export function getHealth(): Promise { + return request("/health") +} + +export function getConfig(): Promise { + return request("/config") +} + +export function updateServerConfig(server: ServerConfig): Promise { + return request("/config/server", { + method: "PUT", + body: JSON.stringify(server), + }) +} + +export function testConnection(server: ServerConfig): Promise { + return request("/connection/test", { + method: "POST", + body: JSON.stringify(server), + }) +} + +export function listJobs(): Promise { + return request("/jobs") +} + +export function runJob(jobId: string): Promise { + return request(`/jobs/${jobId}/run`, { + method: "POST", + }) +} + +export function enableJob(jobId: string): Promise { + return request(`/jobs/${jobId}/enable`, { + method: "POST", + }) +} + +export function disableJob(jobId: string): Promise { + return request(`/jobs/${jobId}/disable`, { + method: "POST", + }) +} + +export function deleteJob(jobId: string): Promise { + return request(`/jobs/${jobId}`, { + method: "DELETE", + }) +} diff --git a/frontend/config.ts b/frontend/config.ts new file mode 100644 index 0000000..156cd58 --- /dev/null +++ b/frontend/config.ts @@ -0,0 +1,2 @@ +export const apiBaseUrl = + (import.meta.env.VITE_API_BASE_URL as string | undefined)?.trim() || "/api" diff --git a/frontend/main.ts b/frontend/main.ts index 4666db7..4402171 100644 --- a/frontend/main.ts +++ b/frontend/main.ts @@ -1,75 +1,668 @@ -import "./styles.css" +import "./generated.css" -const app = document.querySelector("#app") +import { + deleteJob, + disableJob, + enableJob, + getConfig, + getHealth, + listJobs, + runJob, + testConnection, + updateServerConfig, + type AppConfig, + type ConnectionState, + type ConnectionTestResponse, + type HealthResponse, + type JobSummary, + type ServerConfig, +} from "./api" +import { getRouteFromHash, navigate, routeHref, type AppRoute } from "./router" +import { formatRelativeTime, formatSchedule, shortenPath } from "./time" -if (!app) { - throw new Error("App root not found") +type Feedback = { + tone: "success" | "error" | "info" + message: string +} | null + +type UiState = { + route: AppRoute + health: HealthResponse | null + config: AppConfig | null + jobs: JobSummary[] + loading: boolean + bootstrapError: string | null + settingsFeedback: Feedback + welcomeFeedback: Feedback + welcomeStep: 1 | 2 + lastConnectionState: ConnectionState | null } -app.innerHTML = ` -
-` + ` +} + +function renderFatalError(): string { + return ` +
+
+

Sidecar error

+

The background process is unavailable

+

${escapeHtml(state.bootstrapError ?? "SDL could not reach the local sidecar.")}

+ +
+
+ ` +} -const sidecarText = document.querySelector("#sidecar-text") -const sidecarDot = document.querySelector("#sidecar-dot") -const refreshButton = document.querySelector("#refresh-status") +function render(): void { + state.route = getRouteFromHash() -async function refreshSidecarStatus(): Promise { - if (!sidecarText || !sidecarDot) { + if (!state.loading && !state.bootstrapError && !state.config?.server.url && state.route !== "settings" && state.route !== "welcome") { + navigate("welcome") + state.route = "welcome" + } + + const currentRoute = getRouteFromHash() + const showSidebar = currentRoute !== "welcome" && !state.bootstrapError + sidebar.classList.toggle("hidden", !showSidebar) + + jobsLink.className = currentRoute === "dashboard" ? "nav-item nav-item-active" : "nav-item" + settingsLink.className = currentRoute === "settings" ? "nav-item nav-item-active" : "nav-item" + + const connectionState = sidebarConnectionState() + connectionDot.className = connectionState.className + connectionDot.title = connectionState.label + + if (state.loading) { + mainContent.innerHTML = ` +
+
+

Starting SDL

+

Loading local configuration

+

Connecting the browser preview to the FastAPI sidecar.

+
+
+ ` + return + } + + if (state.bootstrapError) { + mainContent.innerHTML = renderFatalError() return } - sidecarText.textContent = "Checking sidecar status..." - sidecarDot.dataset.state = "pending" + if (currentRoute === "settings") { + mainContent.innerHTML = renderSettings() + return + } - try { - const response = await fetch("http://127.0.0.1:5321/health") - if (!response.ok) { - throw new Error(`Unexpected status: ${response.status}`) + if (currentRoute === "welcome") { + mainContent.innerHTML = renderWelcome() + return + } + + if (currentRoute === "jobs-new") { + mainContent.innerHTML = renderJobsPlaceholder() + return + } + + mainContent.innerHTML = renderDashboard() +} + +function readServerForm(form: HTMLFormElement): ServerConfig { + const data = new FormData(form) + return { + url: String(data.get("url") ?? "").trim(), + api_key: String(data.get("api_key") ?? "").trim(), + } +} + +function sleep(ms: number): Promise { + return new Promise(resolve => window.setTimeout(resolve, ms)) +} + +function isTransientBootstrapError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false + } + + const message = error.message.toLowerCase() + return ( + message.includes("failed to fetch") || + message.includes("networkerror") || + message.includes("status 500") || + message.includes("status 502") || + message.includes("status 503") || + message.includes("status 504") + ) +} + +async function loadInitialStateWithRetry(): Promise<{ + health: HealthResponse + config: AppConfig + jobs: JobSummary[] +}> { + let lastError: unknown = null + + for (let attempt = 1; attempt <= STARTUP_RETRY_ATTEMPTS; attempt += 1) { + try { + const [health, config, jobs] = await Promise.all([getHealth(), getConfig(), listJobs()]) + return { health, config, jobs } + } catch (error) { + lastError = error + + if (attempt === STARTUP_RETRY_ATTEMPTS || !isTransientBootstrapError(error)) { + throw error + } + + await sleep(STARTUP_RETRY_DELAY_MS) } + } + + throw lastError instanceof Error ? lastError : new Error("Failed to load SDL.") +} + +async function bootstrap(): Promise { + state.loading = true + state.bootstrapError = null + render() + + try { + const { health, config, jobs } = await loadInitialStateWithRetry() + state.health = health + state.config = config + state.jobs = jobs + state.lastConnectionState = health.connection.state + } catch (error) { + state.bootstrapError = error instanceof Error ? error.message : "Failed to load SDL." + } finally { + state.loading = false + render() + } +} - sidecarText.textContent = "Sidecar online" - sidecarDot.dataset.state = "online" +async function refreshJobs(): Promise { + if (state.bootstrapError || state.loading) { + return + } + + try { + state.jobs = await listJobs() + render() } catch { - sidecarText.textContent = "Sidecar offline" - sidecarDot.dataset.state = "offline" + // Keep the existing dashboard state if polling fails. } } -refreshButton?.addEventListener("click", () => { - void refreshSidecarStatus() +window.addEventListener("hashchange", () => { + state.settingsFeedback = null + render() +}) + +mainContent.addEventListener("submit", event => { + const target = event.target + if (!(target instanceof HTMLFormElement)) { + return + } + + if (target.id === "settings-form") { + event.preventDefault() + void saveSettings(target) + } + + if (target.id === "welcome-form") { + event.preventDefault() + void connectWelcome(target) + } +}) + +mainContent.addEventListener("input", event => { + const target = event.target + if (!(target instanceof HTMLInputElement)) { + return + } + + if (target.form?.id === "settings-form") { + state.settingsFeedback = null + } + + if (target.form?.id === "welcome-form") { + state.welcomeFeedback = null + } +}) + +mainContent.addEventListener("click", event => { + const target = event.target + if (!(target instanceof HTMLElement)) { + return + } + + const action = target.closest("[data-action]")?.dataset.action + const jobId = target.closest("[data-job-id]")?.dataset.jobId + + if (!action) { + return + } + + if (action === "retry-bootstrap") { + void bootstrap() + return + } + + if (action === "test-connection") { + const form = document.querySelector("#settings-form") + if (form) { + void testSettingsConnection(form) + } + return + } + + if (!jobId) { + return + } + + if (action === "run-job") { + void handleRunJob(jobId) + return + } + + if (action === "toggle-job") { + void handleToggleJob(jobId) + return + } + + if (action === "delete-job") { + void handleDeleteJob(jobId) + } }) -void refreshSidecarStatus() +async function saveSettings(form: HTMLFormElement): Promise { + const payload = readServerForm(form) + + try { + state.config = await updateServerConfig(payload) + state.settingsFeedback = { tone: "success", message: "Settings saved." } + state.lastConnectionState = "configured" + } catch (error) { + state.settingsFeedback = { + tone: "error", + message: error instanceof Error ? error.message : "Failed to save settings.", + } + } + + render() +} + +async function testSettingsConnection(form: HTMLFormElement): Promise { + const payload = readServerForm(form) + + try { + const result = await testConnection(payload) + applyConnectionFeedback(result, "settings") + } catch (error) { + state.settingsFeedback = { + tone: "error", + message: error instanceof Error ? error.message : "Couldn't test the HydroServer connection.", + } + state.lastConnectionState = "error" + } + + render() +} + +async function connectWelcome(form: HTMLFormElement): Promise { + const payload = readServerForm(form) + + try { + const result = await testConnection(payload) + if (!result.ok) { + applyConnectionFeedback(result, "welcome") + render() + return + } + + state.config = await updateServerConfig(payload) + state.lastConnectionState = "connected" + state.welcomeFeedback = { tone: "success", message: result.message } + state.welcomeStep = 2 + render() + } catch (error) { + state.welcomeFeedback = { + tone: "error", + message: error instanceof Error ? error.message : "Couldn't save the HydroServer connection.", + } + state.lastConnectionState = "error" + render() + } +} + +function applyConnectionFeedback(result: ConnectionTestResponse, context: "settings" | "welcome"): void { + const feedback: Feedback = { + tone: result.ok ? "success" : "error", + message: result.message, + } + + if (context === "settings") { + state.settingsFeedback = feedback + } else { + state.welcomeFeedback = feedback + } + + state.lastConnectionState = result.state +} + +async function handleRunJob(jobId: string): Promise { + try { + await runJob(jobId) + await refreshJobs() + } catch { + // Dashboard cards already show persistent error state from the sidecar. + } +} + +async function handleToggleJob(jobId: string): Promise { + const job = state.jobs.find(item => item.id === jobId) + if (!job) { + return + } + + try { + if (job.enabled) { + await disableJob(jobId) + } else { + await enableJob(jobId) + } + await refreshJobs() + } catch { + // Ignore and keep the current UI state. + } +} + +async function handleDeleteJob(jobId: string): Promise { + const confirmed = window.confirm("Delete this job?") + if (!confirmed) { + return + } + + try { + await deleteJob(jobId) + await refreshJobs() + } catch { + // Ignore and keep the current UI state. + } +} + +void bootstrap() diff --git a/frontend/router.ts b/frontend/router.ts new file mode 100644 index 0000000..780f44a --- /dev/null +++ b/frontend/router.ts @@ -0,0 +1,42 @@ +export type AppRoute = "dashboard" | "settings" | "welcome" | "jobs-new" + +const DEFAULT_ROUTE: AppRoute = "dashboard" + +export function getRouteFromHash(hash = window.location.hash): AppRoute { + const normalized = hash.replace(/^#/, "").trim() + + switch (normalized) { + case "settings": + return "settings" + case "welcome": + return "welcome" + case "jobs/new": + return "jobs-new" + case "dashboard": + case "": + return DEFAULT_ROUTE + default: + return DEFAULT_ROUTE + } +} + +export function routeHref(route: AppRoute): string { + switch (route) { + case "settings": + return "#settings" + case "welcome": + return "#welcome" + case "jobs-new": + return "#jobs/new" + case "dashboard": + default: + return "#dashboard" + } +} + +export function navigate(route: AppRoute): void { + const nextHref = routeHref(route) + if (window.location.hash !== nextHref) { + window.location.hash = nextHref + } +} diff --git a/frontend/styles.css b/frontend/styles.css index 9049072..52a0052 100644 --- a/frontend/styles.css +++ b/frontend/styles.css @@ -1,162 +1,238 @@ -:root { - color-scheme: light dark; - font-family: "SF Pro Text", "Segoe UI", sans-serif; - background: - radial-gradient(circle at top, rgba(54, 94, 255, 0.18), transparent 36%), - linear-gradient(180deg, #0f172a, #111827 58%, #0b1220); - color: #e5eefc; -} +@import url("https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=DM+Sans:wght@400;500;600;700&display=swap"); +@import "tailwindcss"; + +@theme { + --color-brand-50: #f0f9ff; + --color-brand-100: #e0f2fe; + --color-brand-400: #38bdf8; + --color-brand-500: #0ea5e9; + --color-brand-600: #0284c7; + --color-brand-700: #0369a1; + --color-success: #10b981; + --color-warning: #f59e0b; + --color-danger: #ef4444; + --color-surface: #ffffff; + --color-surface-secondary: #f8fafc; + --color-surface-border: #e2e8f0; + --font-sans: "DM Sans", system-ui, sans-serif; + --font-mono: "DM Mono", ui-monospace, monospace; + --shadow-card: 0 1px 3px 0 rgb(0 0 0 / 0.06), 0 1px 2px -1px rgb(0 0 0 / 0.06); + --shadow-card-hover: 0 10px 30px -14px rgb(14 116 144 / 0.16); + --animate-fade-in: fadeIn 180ms ease-out; +} + +@keyframes fadeIn { + 0% { + opacity: 0; + transform: translateY(6px); + } -* { - box-sizing: border-box; + 100% { + opacity: 1; + transform: translateY(0); + } } -html, -body { - margin: 0; - min-height: 100%; -} +@layer base { + html { + -webkit-font-smoothing: antialiased; + } -body { - min-height: 100vh; -} + body { + min-height: 100vh; + margin: 0; + background: + radial-gradient(circle at top right, rgb(125 211 252 / 0.18), transparent 24%), + linear-gradient(180deg, #f8fafc 0%, #eef6fb 100%); + color: #1e293b; + font-family: var(--font-sans); + } -button, -input, -textarea, -select { - font: inherit; -} + button, + input, + select, + textarea { + font: inherit; + } -code { - font-family: "SF Mono", "Cascadia Code", "JetBrains Mono", monospace; + code { + font-family: var(--font-mono); + } } -.shell { - min-height: 100vh; - display: grid; - place-items: center; - padding: 32px; -} +@layer components { + .nav-item { + @apply flex h-10 w-10 items-center justify-center rounded-xl text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600; + } -.panel { - width: min(100%, 720px); - padding: 32px; - border: 1px solid rgba(148, 163, 184, 0.24); - border-radius: 24px; - background: rgba(15, 23, 42, 0.82); - box-shadow: 0 24px 80px rgba(0, 0, 0, 0.3); - backdrop-filter: blur(12px); -} + .nav-item-active { + @apply bg-brand-50 text-brand-600; + } -.eyebrow { - margin-bottom: 12px; - color: #93c5fd; - font-size: 0.8rem; - font-weight: 700; - letter-spacing: 0.16em; - text-transform: uppercase; -} + .btn-primary { + @apply inline-flex items-center justify-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-brand-700; + } -h1 { - margin: 0; - font-size: clamp(2rem, 4vw, 3.2rem); - line-height: 0.98; -} + .btn-ghost { + @apply inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100; + } -.lede { - margin: 16px 0 24px; - max-width: 48ch; - color: #cbd5e1; - font-size: 1rem; - line-height: 1.6; -} + .btn-danger { + @apply inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-medium text-red-600 transition-colors hover:bg-red-50; + } -.status-row { - display: inline-flex; - align-items: center; - gap: 12px; - padding: 12px 16px; - border-radius: 999px; - background: rgba(15, 23, 42, 0.72); - border: 1px solid rgba(148, 163, 184, 0.2); -} + .status-dot { + @apply inline-block h-2.5 w-2.5 rounded-full; + } -.status-dot { - width: 12px; - height: 12px; - border-radius: 999px; - background: #64748b; - box-shadow: 0 0 0 6px rgba(100, 116, 139, 0.16); -} + .page-shell { + @apply mx-auto flex min-h-screen w-full max-w-6xl flex-col gap-8 px-8 py-10; + } -.status-dot[data-state="pending"] { - background: #f59e0b; - box-shadow: 0 0 0 6px rgba(245, 158, 11, 0.16); -} + .page-header { + @apply flex flex-col gap-5 md:flex-row md:items-end md:justify-between; + } -.status-dot[data-state="online"] { - background: #22c55e; - box-shadow: 0 0 0 6px rgba(34, 197, 94, 0.16); -} + .page-title { + @apply text-3xl font-semibold tracking-tight text-slate-900; + } -.status-dot[data-state="offline"] { - background: #ef4444; - box-shadow: 0 0 0 6px rgba(239, 68, 68, 0.16); -} + .page-copy { + @apply mt-2 max-w-2xl text-sm leading-6 text-slate-600; + } -.actions { - margin-top: 18px; -} + .eyebrow { + @apply text-xs font-semibold uppercase tracking-[0.24em] text-brand-600; + } -button { - border: 0; - border-radius: 999px; - padding: 10px 16px; - background: linear-gradient(135deg, #38bdf8, #2563eb); - color: white; - cursor: pointer; -} + .settings-card, + .welcome-card, + .job-card, + .empty-panel { + border-color: var(--color-surface-border); + box-shadow: var(--shadow-card); + @apply rounded-2xl border bg-white; + } -button:hover { - filter: brightness(1.05); -} + .settings-card { + @apply max-w-3xl overflow-hidden; + } -.facts { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); - gap: 16px; - margin: 28px 0 0; -} + .card-section { + @apply flex flex-col gap-4 border-b border-slate-200 px-6 py-6 last:border-b-0; + } -.facts div { - padding: 16px; - border-radius: 16px; - background: rgba(30, 41, 59, 0.7); - border: 1px solid rgba(148, 163, 184, 0.18); -} + .muted-section { + @apply bg-slate-50/60; + } -.facts dt { - margin-bottom: 8px; - color: #93c5fd; - font-size: 0.78rem; - font-weight: 700; - letter-spacing: 0.12em; - text-transform: uppercase; -} + .section-title { + @apply text-sm font-semibold text-slate-800; + } -.facts dd { - margin: 0; - color: #e2e8f0; -} + .section-copy { + @apply text-sm leading-6 text-slate-600; + } + + .field { + @apply flex flex-col gap-1.5; + } + + .label { + @apply text-xs font-medium uppercase tracking-wide text-slate-500; + } + + .input { + @apply w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-sky-100; + } + + .button-row { + @apply flex flex-wrap items-center gap-3 pt-2; + } + + .notice-success, + .notice-error, + .notice-info { + @apply rounded-lg px-4 py-3 text-sm; + } + + .notice-success { + @apply bg-emerald-50 text-emerald-700; + } + + .notice-error { + @apply bg-red-50 text-red-700; + } + + .notice-info { + @apply bg-brand-50 text-brand-700; + } + + .welcome-shell { + @apply flex min-h-screen items-center justify-center px-6 py-10; + } + + .welcome-card { + @apply w-full max-w-2xl p-8 md:p-10; + } + + .empty-panel { + @apply flex min-h-[18rem] flex-col items-center justify-center gap-4 p-8 text-center; + } + + .empty-icon { + @apply flex h-14 w-14 items-center justify-center rounded-2xl bg-brand-50 font-mono text-lg font-semibold text-brand-700; + } + + .card-stack { + @apply flex flex-col gap-4; + } + + .job-card { + @apply p-5 transition-shadow hover:shadow-[var(--shadow-card-hover)]; + } + + .job-card-top { + @apply flex flex-col gap-4 md:flex-row md:items-start md:justify-between; + } + + .job-card-title-row { + @apply flex items-center gap-3; + } + + .job-meta { + @apply mt-2 text-sm text-slate-500; + } + + .job-card-actions { + @apply mt-5 flex flex-wrap gap-2 border-t border-slate-100 pt-4; + } + + .pill-success, + .pill-warning, + .pill-danger, + .pill-muted, + .pill-info { + @apply inline-flex items-center rounded-full px-3 py-1 text-xs font-medium; + } + + .pill-success { + @apply bg-emerald-50 text-emerald-700; + } + + .pill-warning { + @apply bg-amber-50 text-amber-700; + } + + .pill-danger { + @apply bg-red-50 text-red-700; + } -@media (max-width: 640px) { - .shell { - padding: 20px; + .pill-muted { + @apply bg-slate-100 text-slate-500; } - .panel { - padding: 24px; - border-radius: 20px; + .pill-info { + @apply bg-brand-50 text-brand-700; } } diff --git a/frontend/time.ts b/frontend/time.ts new file mode 100644 index 0000000..e0a4789 --- /dev/null +++ b/frontend/time.ts @@ -0,0 +1,50 @@ +export function formatRelativeTime(value: string | null): string { + if (!value) { + return "Never run" + } + + const timestamp = new Date(value) + const deltaSeconds = Math.max(0, Math.floor((Date.now() - timestamp.getTime()) / 1000)) + + if (deltaSeconds < 60) { + return "Just now" + } + + const deltaMinutes = Math.floor(deltaSeconds / 60) + if (deltaMinutes < 60) { + return `${deltaMinutes} min ago` + } + + const deltaHours = Math.floor(deltaMinutes / 60) + if (deltaHours < 24) { + return deltaHours === 1 ? "1 hour ago" : `${deltaHours} hours ago` + } + + if (deltaHours < 48) { + return "Yesterday" + } + + const deltaDays = Math.floor(deltaHours / 24) + return `${deltaDays} days ago` +} + +export function formatSchedule(minutes: number): string { + if (minutes < 60) { + return `Every ${minutes} min` + } + + const hours = minutes / 60 + if (Number.isInteger(hours)) { + return `Every ${hours} hour${hours === 1 ? "" : "s"}` + } + + return `Every ${minutes} min` +} + +export function shortenPath(path: string): string { + const segments = path.split(/[\\/]/).filter(Boolean) + if (segments.length <= 2) { + return path + } + return `${segments[segments.length - 2]} / ${segments[segments.length - 1]}` +} diff --git a/frontend/vite-env.d.ts b/frontend/vite-env.d.ts new file mode 100644 index 0000000..17aa348 --- /dev/null +++ b/frontend/vite-env.d.ts @@ -0,0 +1,9 @@ +/// + +interface ImportMetaEnv { + readonly VITE_API_BASE_URL?: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} diff --git a/index.html b/index.html index 3b95b28..1144c1d 100644 --- a/index.html +++ b/index.html @@ -1,4 +1,4 @@ - + @@ -6,7 +6,60 @@ Streaming Data Loader -
+
+ + +
+
- + diff --git a/package-lock.json b/package-lock.json index bbc08eb..d9a4353 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,10 @@ "name": "streaming-data-loader", "version": "0.1.0", "devDependencies": { + "@tailwindcss/cli": "^4.1.4", "@tauri-apps/cli": "^2.10.1", + "@types/node": "^22.13.10", + "tailwindcss": "^4.1.4", "typescript": "^5.6.3", "vite": "^6.0.3" } @@ -455,6 +458,365 @@ "node": ">=18" } }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@parcel/watcher": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", + "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.6", + "@parcel/watcher-darwin-arm64": "2.5.6", + "@parcel/watcher-darwin-x64": "2.5.6", + "@parcel/watcher-freebsd-x64": "2.5.6", + "@parcel/watcher-linux-arm-glibc": "2.5.6", + "@parcel/watcher-linux-arm-musl": "2.5.6", + "@parcel/watcher-linux-arm64-glibc": "2.5.6", + "@parcel/watcher-linux-arm64-musl": "2.5.6", + "@parcel/watcher-linux-x64-glibc": "2.5.6", + "@parcel/watcher-linux-x64-musl": "2.5.6", + "@parcel/watcher-win32-arm64": "2.5.6", + "@parcel/watcher-win32-ia32": "2.5.6", + "@parcel/watcher-win32-x64": "2.5.6" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz", + "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz", + "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz", + "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz", + "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz", + "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz", + "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz", + "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz", + "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz", + "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz", + "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz", + "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz", + "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz", + "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.60.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", @@ -773,28 +1135,301 @@ "dev": true, "license": "MIT", "optional": true, - "os": [ - "win32" - ] + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/cli": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.2.2.tgz", + "integrity": "sha512-iJS+8kAFZ8HPqnh0O5DHCLjo4L6dD97DBQEkrhfSO4V96xeefUus2jqsBs1dUMt3OU9Ks4qIkiY0mpL5UW+4LQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@parcel/watcher": "^2.5.1", + "@tailwindcss/node": "4.2.2", + "@tailwindcss/oxide": "4.2.2", + "enhanced-resolve": "^5.19.0", + "mri": "^1.2.0", + "picocolors": "^1.1.1", + "tailwindcss": "4.2.2" + }, + "bin": { + "tailwindcss": "dist/index.mjs" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", + "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", + "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-x64": "4.2.2", + "@tailwindcss/oxide-freebsd-x64": "4.2.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-x64-musl": "4.2.2", + "@tailwindcss/oxide-wasm32-wasi": "4.2.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", + "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", + "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", + "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", + "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", + "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", + "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", + "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", + "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", + "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", + "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", - "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", + "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==", "cpu": [ - "x64" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "win32" - ] + ], + "engines": { + "node": ">= 20" + } }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", - "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", + "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==", "cpu": [ "x64" ], @@ -803,7 +1438,10 @@ "optional": true, "os": [ "win32" - ] + ], + "engines": { + "node": ">= 20" + } }, "node_modules/@tauri-apps/cli": { "version": "2.10.1", @@ -1029,6 +1667,40 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "22.19.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", + "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -1104,6 +1776,327 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -1123,6 +2116,13 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1227,6 +2227,27 @@ "node": ">=0.10.0" } }, + "node_modules/tailwindcss": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", + "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz", + "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -1258,6 +2279,13 @@ "node": ">=14.17" } }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, "node_modules/vite": { "version": "6.4.1", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", diff --git a/package.json b/package.json index 66419d7..f283354 100644 --- a/package.json +++ b/package.json @@ -4,13 +4,21 @@ "version": "0.1.0", "type": "module", "scripts": { + "bootstrap:frontend": "node ./scripts/bootstrap-frontend.mjs", + "bootstrap:sidecar": "node ./scripts/bootstrap-sidecar.mjs", "dev": "vite", - "build": "tsc --noEmit && vite build", + "dev:tauri": "node ./scripts/tauri-dev.mjs", + "build": "npm run tailwind:build && tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.node.json && vite build", "preview": "vite preview", - "tauri": "tauri" + "tailwind:watch": "tailwindcss -i ./frontend/styles.css -o ./frontend/generated.css --watch", + "tailwind:build": "tailwindcss -i ./frontend/styles.css -o ./frontend/generated.css --minify", + "tauri": "node ./scripts/run-tauri.mjs" }, "devDependencies": { + "@tailwindcss/cli": "^4.1.4", "@tauri-apps/cli": "^2.10.1", + "@types/node": "^22.13.10", + "tailwindcss": "^4.1.4", "typescript": "^5.6.3", "vite": "^6.0.3" } diff --git a/scripts/bootstrap-frontend.mjs b/scripts/bootstrap-frontend.mjs new file mode 100644 index 0000000..f9ccdfc --- /dev/null +++ b/scripts/bootstrap-frontend.mjs @@ -0,0 +1,40 @@ +import { createHash } from "node:crypto" +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs" +import { dirname, resolve } from "node:path" +import { fileURLToPath } from "node:url" +import { spawnSync } from "node:child_process" + +const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), "..") +const cacheDir = resolve(rootDir, ".cache") +const stampPath = resolve(cacheDir, "frontend-package-lock.sha256") +const lockfilePath = resolve(rootDir, "package-lock.json") +const nodeModulesPath = resolve(rootDir, "node_modules") + +function run(command, args) { + const result = spawnSync(command, args, { + cwd: rootDir, + stdio: "inherit", + shell: process.platform === "win32", + }) + + if (result.status !== 0) { + process.exit(result.status ?? 1) + } +} + +function sha256(path) { + return createHash("sha256").update(readFileSync(path)).digest("hex") +} + +mkdirSync(cacheDir, { recursive: true }) + +const currentHash = sha256(lockfilePath) +const storedHash = existsSync(stampPath) ? readFileSync(stampPath, "utf8") : "" +const needsInstall = !existsSync(nodeModulesPath) || storedHash !== currentHash + +if (needsInstall) { + run("npm", ["install"]) + writeFileSync(stampPath, currentHash) +} + +run("npm", ["run", "tailwind:build"]) diff --git a/scripts/bootstrap-sidecar.mjs b/scripts/bootstrap-sidecar.mjs new file mode 100644 index 0000000..156bb42 --- /dev/null +++ b/scripts/bootstrap-sidecar.mjs @@ -0,0 +1,78 @@ +import { createHash } from "node:crypto" +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs" +import { dirname, resolve } from "node:path" +import { fileURLToPath } from "node:url" +import { spawnSync } from "node:child_process" + +const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), "..") +const cacheDir = resolve(rootDir, ".cache") +const requirementsPath = resolve(rootDir, "sidecar", "requirements.txt") +const stampPath = resolve(cacheDir, "sidecar-requirements.sha256") +const venvDir = resolve(rootDir, ".venv") + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: rootDir, + stdio: "inherit", + shell: process.platform === "win32", + ...options, + }) + + if (result.status !== 0) { + process.exit(result.status ?? 1) + } +} + +function sha256(path) { + return createHash("sha256").update(readFileSync(path)).digest("hex") +} + +function findSystemPython() { + const candidates = + process.platform === "win32" + ? [ + ["py", ["-3"]], + ["python", []], + ] + : [ + ["python3", []], + ["python", []], + ] + + for (const [command, args] of candidates) { + const result = spawnSync(command, [...args, "--version"], { + cwd: rootDir, + stdio: "ignore", + shell: process.platform === "win32", + }) + + if (result.status === 0) { + return { command, args } + } + } + + throw new Error("Python 3 was not found on PATH.") +} + +function venvPythonPath() { + return process.platform === "win32" + ? resolve(venvDir, "Scripts", "python.exe") + : resolve(venvDir, "bin", "python") +} + +mkdirSync(cacheDir, { recursive: true }) + +if (!existsSync(venvPythonPath())) { + const python = findSystemPython() + run(python.command, [...python.args, "-m", "venv", ".venv"]) +} + +const currentHash = sha256(requirementsPath) +const storedHash = existsSync(stampPath) ? readFileSync(stampPath, "utf8") : "" + +if (storedHash !== currentHash) { + const python = venvPythonPath() + run(python, ["-m", "pip", "install", "--upgrade", "pip"]) + run(python, ["-m", "pip", "install", "-r", "sidecar/requirements.txt"]) + writeFileSync(stampPath, currentHash) +} diff --git a/scripts/run-sidecar.mjs b/scripts/run-sidecar.mjs new file mode 100644 index 0000000..0b976f3 --- /dev/null +++ b/scripts/run-sidecar.mjs @@ -0,0 +1,61 @@ +import { existsSync, readFileSync } from "node:fs" +import { dirname, resolve } from "node:path" +import { fileURLToPath } from "node:url" +import { spawnSync } from "node:child_process" + +const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), "..") +const pythonPath = + process.platform === "win32" + ? resolve(rootDir, ".venv", "Scripts", "python.exe") + : resolve(rootDir, ".venv", "bin", "python") + +if (!existsSync(pythonPath)) { + console.error("Sidecar virtual environment is missing. Run the sidecar bootstrap first.") + process.exit(1) +} + +const envPath = resolve(rootDir, ".env.development") +const env = { ...process.env } + +if (existsSync(envPath)) { + for (const line of readFileSync(envPath, "utf8").split(/\r?\n/)) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith("#")) { + continue + } + + const separatorIndex = trimmed.indexOf("=") + if (separatorIndex === -1) { + continue + } + + const key = trimmed.slice(0, separatorIndex).trim() + const value = trimmed.slice(separatorIndex + 1).trim() + env[key] = value + } +} + +const configDir = env.SDL_CONFIG_DIR + ? resolve(rootDir, env.SDL_CONFIG_DIR) + : resolve(rootDir, ".local", "sidecar-dev") + +const result = spawnSync( + pythonPath, + [ + "-m", + "sidecar.main", + "--host", + env.SDL_SIDECAR_HOST || "127.0.0.1", + "--port", + env.SDL_SIDECAR_PORT || "5321", + "--config-dir", + configDir, + ], + { + cwd: rootDir, + stdio: "inherit", + env, + } +) + +process.exit(result.status ?? 1) diff --git a/scripts/run-tauri.mjs b/scripts/run-tauri.mjs new file mode 100644 index 0000000..2100e17 --- /dev/null +++ b/scripts/run-tauri.mjs @@ -0,0 +1,53 @@ +import { spawnSync } from "node:child_process" +import { dirname, resolve } from "node:path" +import { fileURLToPath } from "node:url" + +const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), "..") +const tauriArgs = process.argv.slice(2) + +function run(command, args, options = {}) { + return spawnSync(command, args, { + cwd: rootDir, + stdio: "inherit", + shell: process.platform === "win32", + ...options, + }) +} + +function commandExists(command, args = ["--version"]) { + const result = spawnSync(command, args, { + cwd: rootDir, + stdio: "ignore", + shell: process.platform === "win32", + }) + + return result.status === 0 +} + +if (!commandExists("cargo")) { + console.error("") + console.error("Tauri desktop preview requires the Rust toolchain, but `cargo` is not installed or not on PATH.") + console.error("") + console.error("Install it with one of these:") + console.error(" 1. curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh") + console.error(" 2. brew install rustup-init && rustup-init") + console.error("") + console.error("Then restart your terminal and verify:") + console.error(" cargo --version") + console.error("") + console.error("After that, run:") + console.error(" npm run tauri dev") + console.error("") + process.exit(1) +} + +if (!commandExists("rustc")) { + console.error("") + console.error("Rust appears to be partially installed: `cargo` exists but `rustc` does not.") + console.error("Run `rustup default stable` and try again.") + console.error("") + process.exit(1) +} + +const result = run("npx", ["--no-install", "tauri", ...tauriArgs]) +process.exit(result.status ?? 1) diff --git a/scripts/tauri-dev.mjs b/scripts/tauri-dev.mjs new file mode 100644 index 0000000..188775d --- /dev/null +++ b/scripts/tauri-dev.mjs @@ -0,0 +1,99 @@ +import { dirname, resolve } from "node:path" +import { fileURLToPath } from "node:url" +import { spawn } from "node:child_process" +import { existsSync, readFileSync } from "node:fs" +import net from "node:net" + +const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), "..") +const children = [] + +function spawnTask(command, args) { + const child = spawn(command, args, { + cwd: rootDir, + stdio: "inherit", + shell: process.platform === "win32", + }) + + children.push(child) + + child.on("exit", code => { + if (shuttingDown) { + return + } + + shuttingDown = true + shutdown(code ?? 0) + }) + + return child +} + +let shuttingDown = false + +function shutdown(exitCode) { + for (const child of children) { + if (!child.killed) { + child.kill("SIGTERM") + } + } + + setTimeout(() => process.exit(exitCode), 150) +} + +process.on("SIGINT", () => shutdown(0)) +process.on("SIGTERM", () => shutdown(0)) + +function loadEnvFile() { + const envPath = resolve(rootDir, ".env.development") + + if (!existsSync(envPath)) { + return {} + } + + const env = {} + + for (const line of readFileSync(envPath, "utf8").split(/\r?\n/)) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith("#")) { + continue + } + + const separatorIndex = trimmed.indexOf("=") + if (separatorIndex === -1) { + continue + } + + const key = trimmed.slice(0, separatorIndex).trim() + const value = trimmed.slice(separatorIndex + 1).trim() + env[key] = value + } + + return env +} + +function isPortOpen(host, port) { + return new Promise(resolvePromise => { + const socket = net.connect({ host, port: Number(port) }) + + socket.once("connect", () => { + socket.end() + resolvePromise(true) + }) + + socket.once("error", () => { + resolvePromise(false) + }) + }) +} + +const devEnv = loadEnvFile() +const frontendHost = devEnv.SDL_FRONTEND_HOST || "localhost" +const frontendPort = devEnv.SDL_FRONTEND_PORT || "1420" + +spawnTask("npm", ["run", "tailwind:watch"]) + +if (await isPortOpen(frontendHost, frontendPort)) { + console.log(`Vite dev server already detected on ${frontendHost}:${frontendPort}; reusing it for Tauri.`) +} else { + spawnTask("npm", ["run", "dev"]) +} diff --git a/sidecar/__init__.py b/sidecar/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/sidecar/__init__.py @@ -0,0 +1 @@ + diff --git a/sidecar/api/__init__.py b/sidecar/api/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/sidecar/api/__init__.py @@ -0,0 +1 @@ + diff --git a/sidecar/api/models.py b/sidecar/api/models.py new file mode 100644 index 0000000..111bdea --- /dev/null +++ b/sidecar/api/models.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, Field + + +JobStatus = Literal["healthy", "warning", "error", "disabled", "pending", "running"] +ConnectionState = Literal["not_configured", "configured", "connected", "error"] +LogLevel = Literal["info", "warning", "error"] + + +class ServerConfig(BaseModel): + url: str = "" + api_key: str = "" + + +class FileConfig(BaseModel): + header_row: int = 3 + data_start_row: int = 4 + delimiter: str = "," + timestamp_column: str = "Timestamp" + timestamp_format: str = "%Y-%m-%d %H:%M:%S" + timezone: str = "America/Denver" + + +class ColumnMapping(BaseModel): + csv_column: str + datastream_id: str + datastream_name: str + + +class JobConfig(BaseModel): + id: str + name: str + enabled: bool = True + file_path: str + schedule_minutes: int = 15 + file_config: FileConfig = Field(default_factory=FileConfig) + column_mappings: list[ColumnMapping] = Field(default_factory=list) + + +class AppConfig(BaseModel): + version: int = 1 + server: ServerConfig = Field(default_factory=ServerConfig) + jobs: list[JobConfig] = Field(default_factory=list) + + +class JobCursor(BaseModel): + last_pushed_timestamp: datetime | None = None + last_pushed_row_index: int | None = None + last_run_at: datetime | None = None + last_error: str | None = None + + +class JobLogEntry(BaseModel): + timestamp: datetime + level: LogLevel + message: str + + +class AppStateFile(BaseModel): + cursors: dict[str, JobCursor] = Field(default_factory=dict) + logs: dict[str, list[JobLogEntry]] = Field(default_factory=dict) + + +class ConnectionStatus(BaseModel): + state: ConnectionState + message: str + + +class HealthResponse(BaseModel): + status: Literal["ok"] = "ok" + version: str + config_dir: str + server_configured: bool + connection: ConnectionStatus + + +class ServerConfigUpdate(BaseModel): + url: str + api_key: str + + +class ConnectionTestRequest(BaseModel): + url: str + api_key: str + + +class ConnectionTestResponse(BaseModel): + ok: bool + state: ConnectionState + message: str + instance_name: str | None = None + + +class ActionResponse(BaseModel): + ok: bool = True + message: str + + +class DatastreamSummary(BaseModel): + id: str + name: str + + +class JobStatusSummary(BaseModel): + id: str + name: str + enabled: bool + file_path: str + schedule_minutes: int + file_config: FileConfig + column_mappings: list[ColumnMapping] + status: JobStatus + status_message: str + last_pushed_timestamp: datetime | None = None + last_run_at: datetime | None = None + last_error: str | None = None + + +class JobDetail(JobStatusSummary): + recent_logs: list[JobLogEntry] = Field(default_factory=list) + + +class JobUpsertRequest(BaseModel): + name: str + enabled: bool = True + file_path: str + schedule_minutes: int = Field(default=15, ge=1) + file_config: FileConfig = Field(default_factory=FileConfig) + column_mappings: list[ColumnMapping] = Field(default_factory=list) + + +class CsvPreviewResponse(BaseModel): + raw_lines: list[str] + parsed_rows: list[list[str]] + detected_header_row: int | None + detected_data_start_row: int | None + detected_delimiter: str + total_lines: int + encoding: str diff --git a/sidecar/api/routes.py b/sidecar/api/routes.py new file mode 100644 index 0000000..05ba27f --- /dev/null +++ b/sidecar/api/routes.py @@ -0,0 +1,273 @@ +from __future__ import annotations + +import logging +import time +from contextlib import asynccontextmanager +from datetime import datetime, timedelta, timezone +from pathlib import Path + +from fastapi import BackgroundTasks, Depends, FastAPI, HTTPException, Query, Request +from fastapi.middleware.cors import CORSMiddleware + +from sidecar.api.models import ( + ActionResponse, + AppConfig, + ConnectionStatus, + ConnectionTestRequest, + ConnectionTestResponse, + CsvPreviewResponse, + DatastreamSummary, + HealthResponse, + JobConfig, + JobCursor, + JobDetail, + JobLogEntry, + JobStatusSummary, + JobUpsertRequest, + ServerConfig, + ServerConfigUpdate, +) +from sidecar.core.loader import preview_csv +from sidecar.core.runtime import AppRuntime + + +log = logging.getLogger(__name__) + + +def create_app(runtime: AppRuntime) -> FastAPI: + @asynccontextmanager + async def lifespan(app: FastAPI): + runtime.config_store.ensure() + runtime.state_store.ensure() + runtime.scheduler.start() + runtime.scheduler.sync_jobs([job.id for job in runtime.config_store.list_jobs()]) + app.state.runtime = runtime + try: + yield + finally: + runtime.scheduler.shutdown() + + app = FastAPI( + title="HydroServer Streaming Data Loader", + version=runtime.settings.version, + lifespan=lifespan, + ) + app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost:1420", "tauri://localhost"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + @app.get("/health", response_model=HealthResponse, tags=["health"]) + def health(runtime: AppRuntime = Depends(get_runtime)) -> HealthResponse: + config = runtime.config_store.load() + connection = _connection_status(config.server) + return HealthResponse( + version=runtime.settings.version, + config_dir=str(runtime.settings.config_dir), + server_configured=bool(config.server.url and config.server.api_key), + connection=connection, + ) + + @app.get("/config", response_model=AppConfig, tags=["config"]) + def get_config(runtime: AppRuntime = Depends(get_runtime)) -> AppConfig: + return runtime.config_store.load() + + @app.put("/config/server", response_model=AppConfig, tags=["config"]) + def update_server_config( + payload: ServerConfigUpdate, + runtime: AppRuntime = Depends(get_runtime), + ) -> AppConfig: + config = runtime.config_store.update_server(payload) + return config + + @app.post("/connection/test", response_model=ConnectionTestResponse, tags=["connection"]) + def test_connection( + payload: ConnectionTestRequest, + runtime: AppRuntime = Depends(get_runtime), + ) -> ConnectionTestResponse: + result = runtime.hydroserver.test_connection(ServerConfig(**payload.model_dump())) + return ConnectionTestResponse( + ok=result.ok, + state=result.state, # type: ignore[arg-type] + message=result.message, + instance_name=result.instance_name, + ) + + @app.get("/jobs", response_model=list[JobStatusSummary], tags=["jobs"]) + def list_jobs(runtime: AppRuntime = Depends(get_runtime)) -> list[JobStatusSummary]: + return [_build_job_summary(runtime, job) for job in runtime.config_store.list_jobs()] + + @app.post("/jobs", response_model=JobDetail, tags=["jobs"]) + def create_job( + payload: JobUpsertRequest, + runtime: AppRuntime = Depends(get_runtime), + ) -> JobDetail: + job = runtime.config_store.create_job(payload) + runtime.state_store.append_log(job.id, "Job created") + runtime.scheduler.sync_jobs([item.id for item in runtime.config_store.list_jobs()]) + return _build_job_detail(runtime, job) + + @app.get("/jobs/{job_id}", response_model=JobDetail, tags=["jobs"]) + def get_job(job_id: str, runtime: AppRuntime = Depends(get_runtime)) -> JobDetail: + job = runtime.config_store.get_job(job_id) + if job is None: + raise HTTPException(status_code=404, detail="That job could not be found.") + return _build_job_detail(runtime, job) + + @app.put("/jobs/{job_id}", response_model=JobDetail, tags=["jobs"]) + def update_job( + job_id: str, + payload: JobUpsertRequest, + runtime: AppRuntime = Depends(get_runtime), + ) -> JobDetail: + job = runtime.config_store.update_job(job_id, payload) + if job is None: + raise HTTPException(status_code=404, detail="That job could not be found.") + runtime.state_store.append_log(job.id, "Job updated") + runtime.scheduler.sync_jobs([item.id for item in runtime.config_store.list_jobs()]) + return _build_job_detail(runtime, job) + + @app.delete("/jobs/{job_id}", response_model=ActionResponse, tags=["jobs"]) + def delete_job(job_id: str, runtime: AppRuntime = Depends(get_runtime)) -> ActionResponse: + deleted = runtime.config_store.delete_job(job_id) + if not deleted: + raise HTTPException(status_code=404, detail="That job could not be found.") + runtime.state_store.delete_job(job_id) + runtime.scheduler.sync_jobs([item.id for item in runtime.config_store.list_jobs()]) + return ActionResponse(message="Job deleted.") + + @app.post("/jobs/{job_id}/run", response_model=ActionResponse, tags=["jobs"]) + def run_job_now( + job_id: str, + background_tasks: BackgroundTasks, + runtime: AppRuntime = Depends(get_runtime), + ) -> ActionResponse: + job = runtime.config_store.get_job(job_id) + if job is None: + raise HTTPException(status_code=404, detail="That job could not be found.") + if job_id in runtime.running_jobs: + return ActionResponse(message="Job is already running.") + runtime.running_jobs.add(job_id) + runtime.state_store.append_log(job_id, "Manual run started") + background_tasks.add_task(_simulate_job_run, runtime, job) + return ActionResponse(message="Job started.") + + @app.post("/jobs/{job_id}/enable", response_model=ActionResponse, tags=["jobs"]) + def enable_job(job_id: str, runtime: AppRuntime = Depends(get_runtime)) -> ActionResponse: + job = runtime.config_store.set_job_enabled(job_id, True) + if job is None: + raise HTTPException(status_code=404, detail="That job could not be found.") + runtime.state_store.append_log(job_id, "Job enabled") + return ActionResponse(message="Job enabled.") + + @app.post("/jobs/{job_id}/disable", response_model=ActionResponse, tags=["jobs"]) + def disable_job(job_id: str, runtime: AppRuntime = Depends(get_runtime)) -> ActionResponse: + job = runtime.config_store.set_job_enabled(job_id, False) + if job is None: + raise HTTPException(status_code=404, detail="That job could not be found.") + runtime.state_store.append_log(job_id, "Job disabled", level="warning") + return ActionResponse(message="Job disabled.") + + @app.get("/jobs/{job_id}/logs", response_model=list[JobLogEntry], tags=["jobs"]) + def get_job_logs(job_id: str, runtime: AppRuntime = Depends(get_runtime)) -> list[JobLogEntry]: + if runtime.config_store.get_job(job_id) is None: + raise HTTPException(status_code=404, detail="That job could not be found.") + return runtime.state_store.logs_for(job_id) + + @app.get("/datastreams", response_model=list[DatastreamSummary], tags=["hydroserver"]) + def get_datastreams(runtime: AppRuntime = Depends(get_runtime)) -> list[DatastreamSummary]: + config = runtime.config_store.load() + try: + return runtime.hydroserver.list_datastreams(config.server) + except Exception as exc: + log.warning("Failed to load datastreams: %s", exc) + raise HTTPException( + status_code=502, + detail="Couldn't load datastreams from HydroServer right now.", + ) from exc + + @app.get("/csv/preview", response_model=CsvPreviewResponse, tags=["csv"]) + def get_csv_preview( + path: str = Query(..., description="Absolute path to the CSV file"), + rows: int = Query(default=60, ge=1, le=200), + ) -> CsvPreviewResponse: + try: + return preview_csv(path=path, rows=rows) + except FileNotFoundError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except UnicodeDecodeError as exc: + raise HTTPException( + status_code=400, + detail="Couldn't read the file encoding. Try exporting as UTF-8.", + ) from exc + + return app + + +def get_runtime(request: Request) -> AppRuntime: + return request.app.state.runtime # type: ignore[no-any-return] + + +def _build_job_summary(runtime: AppRuntime, job: JobConfig) -> JobStatusSummary: + cursor = runtime.state_store.cursor_for(job.id) + status, status_message = _derive_job_status(job, cursor, job.id in runtime.running_jobs) + return JobStatusSummary( + **job.model_dump(), + status=status, + status_message=status_message, + last_pushed_timestamp=cursor.last_pushed_timestamp, + last_run_at=cursor.last_run_at, + last_error=cursor.last_error, + ) + + +def _build_job_detail(runtime: AppRuntime, job: JobConfig) -> JobDetail: + summary = _build_job_summary(runtime, job) + return JobDetail(**summary.model_dump(), recent_logs=runtime.state_store.logs_for(job.id)) + + +def _derive_job_status(job: JobConfig, cursor: JobCursor, is_running: bool) -> tuple[str, str]: + if is_running: + return "running", "Running now" + if not job.enabled: + return "disabled", "Paused" + if cursor.last_error: + return "error", cursor.last_error + if cursor.last_pushed_timestamp is None: + return "pending", "Ready for the first run" + if cursor.last_run_at is not None: + stale_after = timedelta(minutes=max(job.schedule_minutes * 2, 1)) + if datetime.now(timezone.utc) - cursor.last_run_at > stale_after: + return "warning", "This job looks stale" + return "healthy", "Last push succeeded" + + +def _connection_status(server: ServerConfig) -> ConnectionStatus: + if not server.url.strip() or not server.api_key.strip(): + return ConnectionStatus(state="not_configured", message="HydroServer not configured") + return ConnectionStatus(state="configured", message="HydroServer configured") + + +def _simulate_job_run(runtime: AppRuntime, job: JobConfig) -> None: + try: + time.sleep(1.2) + cursor = runtime.state_store.cursor_for(job.id) + now = datetime.now(timezone.utc) + updated_cursor = cursor.model_copy( + update={ + "last_run_at": now, + "last_pushed_timestamp": now, + "last_pushed_row_index": (cursor.last_pushed_row_index or 0) + 25, + "last_error": None, + } + ) + runtime.state_store.update_cursor(job.id, updated_cursor) + runtime.state_store.append_log( + job.id, + "Stub run completed. Real file loading and HydroServer push are the next implementation phase.", + ) + finally: + runtime.running_jobs.discard(job.id) diff --git a/sidecar/core/__init__.py b/sidecar/core/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/sidecar/core/__init__.py @@ -0,0 +1 @@ + diff --git a/sidecar/core/config.py b/sidecar/core/config.py new file mode 100644 index 0000000..d587f7b --- /dev/null +++ b/sidecar/core/config.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import json +import threading +from pathlib import Path +from uuid import uuid4 + +from sidecar.api.models import AppConfig, JobConfig, JobUpsertRequest, ServerConfig, ServerConfigUpdate + + +class ConfigStore: + def __init__(self, config_dir: Path) -> None: + self.config_dir = config_dir + self.config_path = config_dir / "config.json" + self._lock = threading.Lock() + + def ensure(self) -> None: + self.config_dir.mkdir(parents=True, exist_ok=True) + if not self.config_path.exists(): + self._write(AppConfig()) + + def load(self) -> AppConfig: + self.ensure() + with self._lock: + return AppConfig.model_validate_json(self.config_path.read_text(encoding="utf-8")) + + def save(self, config: AppConfig) -> AppConfig: + self.config_dir.mkdir(parents=True, exist_ok=True) + self._write(config) + return config + + def _write(self, config: AppConfig) -> None: + payload = json.dumps(config.model_dump(mode="json"), indent=2) + with self._lock: + self.config_path.write_text(f"{payload}\n", encoding="utf-8") + + def update_server(self, update: ServerConfigUpdate) -> AppConfig: + config = self.load() + config.server = ServerConfig(url=update.url.strip(), api_key=update.api_key.strip()) + return self.save(config) + + def list_jobs(self) -> list[JobConfig]: + return self.load().jobs + + def get_job(self, job_id: str) -> JobConfig | None: + for job in self.load().jobs: + if job.id == job_id: + return job + return None + + def create_job(self, request: JobUpsertRequest) -> JobConfig: + config = self.load() + job = JobConfig(id=str(uuid4()), **request.model_dump()) + config.jobs.append(job) + self.save(config) + return job + + def update_job(self, job_id: str, request: JobUpsertRequest) -> JobConfig | None: + config = self.load() + for index, existing in enumerate(config.jobs): + if existing.id == job_id: + updated = JobConfig(id=job_id, **request.model_dump()) + config.jobs[index] = updated + self.save(config) + return updated + return None + + def delete_job(self, job_id: str) -> bool: + config = self.load() + next_jobs = [job for job in config.jobs if job.id != job_id] + if len(next_jobs) == len(config.jobs): + return False + config.jobs = next_jobs + self.save(config) + return True + + def set_job_enabled(self, job_id: str, enabled: bool) -> JobConfig | None: + config = self.load() + for index, job in enumerate(config.jobs): + if job.id == job_id: + config.jobs[index] = job.model_copy(update={"enabled": enabled}) + self.save(config) + return config.jobs[index] + return None diff --git a/sidecar/core/hydroserver.py b/sidecar/core/hydroserver.py new file mode 100644 index 0000000..76046a3 --- /dev/null +++ b/sidecar/core/hydroserver.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Iterable +from urllib.parse import urlparse + +import requests + +from sidecar.api.models import DatastreamSummary, ServerConfig + +try: + from hydroserverpy import HydroServer +except ImportError: # pragma: no cover - bootstrap handles installation + HydroServer = None # type: ignore[assignment] + + +@dataclass +class HydroServerCheck: + ok: bool + message: str + state: str + instance_name: str | None = None + + +class HydroServerService: + def test_connection(self, server: ServerConfig) -> HydroServerCheck: + if not server.url.strip() or not server.api_key.strip(): + return HydroServerCheck( + ok=False, + state="not_configured", + message="Enter both the HydroServer URL and API key.", + ) + + try: + client = self._build_client(server) + client.datastreams.list(page_size=1) + instance_name = self._instance_name(server.url) + return HydroServerCheck( + ok=True, + state="connected", + message=f"Connected to {instance_name}.", + instance_name=instance_name, + ) + except requests.ConnectionError: + return HydroServerCheck( + ok=False, + state="error", + message="Couldn't reach HydroServer. Check the server URL and try again.", + ) + except requests.HTTPError as exc: + status_code = getattr(getattr(exc, "response", None), "status_code", None) + if status_code in {401, 403}: + return HydroServerCheck( + ok=False, + state="error", + message="Invalid API key. Double-check the key in your HydroServer account settings.", + ) + return HydroServerCheck( + ok=False, + state="error", + message="HydroServer returned an error while testing the connection. Try again in a moment.", + ) + except Exception: + return HydroServerCheck( + ok=False, + state="error", + message="Couldn't complete the HydroServer connection test.", + ) + + def list_datastreams(self, server: ServerConfig) -> list[DatastreamSummary]: + if not server.url.strip() or not server.api_key.strip(): + return [] + + client = self._build_client(server) + datastreams = client.datastreams.list(page_size=100) + return [self._to_summary(item) for item in datastreams] + + def _build_client(self, server: ServerConfig): + if HydroServer is None: + raise RuntimeError("hydroserverpy is not installed.") + return HydroServer(host=server.url, apikey=server.api_key) + + def _to_summary(self, item: object) -> DatastreamSummary: + datastream_id = getattr(item, "uid", None) or getattr(item, "id", "") + name = getattr(item, "name", "Unnamed datastream") + return DatastreamSummary(id=str(datastream_id), name=str(name)) + + def _instance_name(self, url: str) -> str: + parsed = urlparse(url) + return parsed.netloc or url diff --git a/sidecar/core/loader.py b/sidecar/core/loader.py new file mode 100644 index 0000000..6858bab --- /dev/null +++ b/sidecar/core/loader.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import csv +from datetime import datetime +from pathlib import Path + +from sidecar.api.models import CsvPreviewResponse + + +DELIMITER_CANDIDATES = [",", "\t", ";"] +TIMESTAMP_FORMATS = [ + "%Y-%m-%d %H:%M:%S", + "%Y-%m-%dT%H:%M:%S", + "%m/%d/%Y %H:%M", +] + + +def preview_csv(path: str, rows: int = 60) -> CsvPreviewResponse: + file_path = Path(path).expanduser() + if not file_path.exists(): + raise FileNotFoundError("Can't find the data file. It may have been moved or renamed.") + + raw_text, encoding = _read_text(file_path) + raw_lines = raw_text.splitlines() + delimiter = _detect_delimiter(raw_lines[:rows]) + parsed_rows = [_parse_line(line, delimiter) for line in raw_lines[:rows] if line.strip()] + header_index = _detect_header_row(parsed_rows) + data_start_index = _detect_data_start_row(parsed_rows, header_index) + + return CsvPreviewResponse( + raw_lines=raw_lines[:rows], + parsed_rows=parsed_rows[header_index:] if header_index is not None else parsed_rows, + detected_header_row=header_index + 1 if header_index is not None else None, + detected_data_start_row=data_start_index + 1 if data_start_index is not None else None, + detected_delimiter=delimiter, + total_lines=len(raw_lines), + encoding=encoding, + ) + + +def _read_text(path: Path) -> tuple[str, str]: + for encoding in ("utf-8", "utf-8-sig", "latin-1"): + try: + return path.read_text(encoding=encoding), encoding + except UnicodeDecodeError: + continue + raise UnicodeDecodeError("utf-8", b"", 0, 1, "Unsupported file encoding") + + +def _detect_delimiter(lines: list[str]) -> str: + best_delimiter = "," + best_score = -1 + + for delimiter in DELIMITER_CANDIDATES: + counts = [len(_parse_line(line, delimiter)) for line in lines if line.strip()] + if not counts: + continue + mode_count = max(set(counts), key=counts.count) + score = counts.count(mode_count) * mode_count + if score > best_score: + best_score = score + best_delimiter = delimiter + + return best_delimiter + + +def _parse_line(line: str, delimiter: str) -> list[str]: + return next(csv.reader([line], delimiter=delimiter)) + + +def _detect_header_row(rows: list[list[str]]) -> int | None: + for index, row in enumerate(rows): + cleaned = [cell.strip() for cell in row if cell.strip()] + if len(cleaned) < 3: + continue + if all(not _looks_numeric_or_timestamp(cell) for cell in cleaned): + return index + return 0 if rows else None + + +def _detect_data_start_row(rows: list[list[str]], header_index: int | None) -> int | None: + if header_index is None: + return None + + expected_columns = len(rows[header_index]) if rows else 0 + for index in range(header_index + 1, len(rows)): + row = [cell.strip() for cell in rows[index]] + if len(row) != expected_columns: + continue + meaningful = [cell for cell in row if cell] + if len(meaningful) < 2: + continue + if sum(1 for cell in meaningful if _looks_numeric_or_timestamp(cell)) >= max(2, len(meaningful) // 2): + return index + return None + + +def _looks_numeric_or_timestamp(value: str) -> bool: + try: + float(value) + return True + except ValueError: + pass + + for timestamp_format in TIMESTAMP_FORMATS: + try: + datetime.strptime(value, timestamp_format) + return True + except ValueError: + continue + + return False diff --git a/sidecar/core/runtime.py b/sidecar/core/runtime.py new file mode 100644 index 0000000..f4affd0 --- /dev/null +++ b/sidecar/core/runtime.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path + +from sidecar.core.config import ConfigStore +from sidecar.core.hydroserver import HydroServerService +from sidecar.core.scheduler import SchedulerService +from sidecar.core.state import StateStore + + +APP_VERSION = "0.1.0" + + +@dataclass +class RuntimeSettings: + host: str + port: int + config_dir: Path + version: str = APP_VERSION + + +@dataclass +class AppRuntime: + settings: RuntimeSettings + config_store: ConfigStore + state_store: StateStore + hydroserver: HydroServerService + scheduler: SchedulerService + running_jobs: set[str] = field(default_factory=set) diff --git a/sidecar/core/scheduler.py b/sidecar/core/scheduler.py new file mode 100644 index 0000000..673e03a --- /dev/null +++ b/sidecar/core/scheduler.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +import logging + + +log = logging.getLogger(__name__) + + +class SchedulerService: + def start(self) -> None: + log.info("Scheduler placeholder started") + + def sync_jobs(self, job_ids: list[str]) -> None: + log.info("Scheduler placeholder synced %s jobs", len(job_ids)) + + def shutdown(self) -> None: + log.info("Scheduler placeholder stopped") diff --git a/sidecar/core/state.py b/sidecar/core/state.py new file mode 100644 index 0000000..cc0ea59 --- /dev/null +++ b/sidecar/core/state.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import json +import threading +from datetime import datetime, timezone +from pathlib import Path + +from sidecar.api.models import AppStateFile, JobCursor, JobLogEntry, LogLevel + + +class StateStore: + def __init__(self, config_dir: Path) -> None: + self.config_dir = config_dir + self.state_path = config_dir / "state.json" + self._lock = threading.Lock() + + def ensure(self) -> None: + self.config_dir.mkdir(parents=True, exist_ok=True) + if not self.state_path.exists(): + self._write(AppStateFile()) + + def load(self) -> AppStateFile: + self.ensure() + with self._lock: + return AppStateFile.model_validate_json(self.state_path.read_text(encoding="utf-8")) + + def save(self, state: AppStateFile) -> AppStateFile: + self.config_dir.mkdir(parents=True, exist_ok=True) + self._write(state) + return state + + def _write(self, state: AppStateFile) -> None: + payload = json.dumps(state.model_dump(mode="json"), indent=2) + with self._lock: + self.state_path.write_text(f"{payload}\n", encoding="utf-8") + + def cursor_for(self, job_id: str) -> JobCursor: + return self.load().cursors.get(job_id, JobCursor()) + + def logs_for(self, job_id: str, limit: int = 50) -> list[JobLogEntry]: + logs = self.load().logs.get(job_id, []) + return logs[-limit:] + + def update_cursor(self, job_id: str, cursor: JobCursor) -> JobCursor: + state = self.load() + state.cursors[job_id] = cursor + self.save(state) + return cursor + + def append_log(self, job_id: str, message: str, level: LogLevel = "info") -> JobLogEntry: + state = self.load() + entry = JobLogEntry(timestamp=datetime.now(timezone.utc), level=level, message=message) + state.logs.setdefault(job_id, []).append(entry) + state.logs[job_id] = state.logs[job_id][-50:] + self.save(state) + return entry + + def delete_job(self, job_id: str) -> None: + state = self.load() + state.cursors.pop(job_id, None) + state.logs.pop(job_id, None) + self.save(state) diff --git a/sidecar/main.py b/sidecar/main.py new file mode 100644 index 0000000..8202705 --- /dev/null +++ b/sidecar/main.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import argparse +import logging +import os +from pathlib import Path + +import platformdirs +import uvicorn + +from sidecar.api.routes import create_app +from sidecar.core.config import ConfigStore +from sidecar.core.hydroserver import HydroServerService +from sidecar.core.runtime import AppRuntime, RuntimeSettings +from sidecar.core.scheduler import SchedulerService +from sidecar.core.state import StateStore + + +def default_config_dir() -> Path: + return Path(platformdirs.user_data_dir("com.hydroserver.sdl")) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--host", default=os.environ.get("SDL_SIDECAR_HOST", "127.0.0.1")) + parser.add_argument("--port", type=int, default=int(os.environ.get("SDL_SIDECAR_PORT", "8765"))) + parser.add_argument( + "--config-dir", + default=os.environ.get("SDL_CONFIG_DIR", str(default_config_dir())), + ) + return parser.parse_args() + + +def build_runtime() -> AppRuntime: + args = parse_args() + config_dir = Path(args.config_dir).expanduser() + if not config_dir.is_absolute(): + config_dir = (Path.cwd() / config_dir).resolve() + + settings = RuntimeSettings(host=args.host, port=args.port, config_dir=config_dir) + return AppRuntime( + settings=settings, + config_store=ConfigStore(config_dir), + state_store=StateStore(config_dir), + hydroserver=HydroServerService(), + scheduler=SchedulerService(), + ) + + +def run() -> None: + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") + runtime = build_runtime() + app = create_app(runtime) + uvicorn.run(app, host=runtime.settings.host, port=runtime.settings.port, reload=False) + + +if __name__ == "__main__": + run() diff --git a/sidecar/requirements.txt b/sidecar/requirements.txt new file mode 100644 index 0000000..08b1248 --- /dev/null +++ b/sidecar/requirements.txt @@ -0,0 +1,6 @@ +fastapi==0.115.12 +uvicorn==0.34.0 +APScheduler==3.11.0 +platformdirs==4.3.8 +hydroserverpy==1.9.0 +eval_type_backport==0.2.2 diff --git a/sidecar/sdl.spec b/sidecar/sdl.spec new file mode 100644 index 0000000..f7a1895 --- /dev/null +++ b/sidecar/sdl.spec @@ -0,0 +1,29 @@ +# Placeholder PyInstaller spec for the future packaging phase. + +a = Analysis( + ["main.py"], + pathex=[], + binaries=[], + datas=[], + hiddenimports=[ + "uvicorn.logging", + "uvicorn.loops", + "uvicorn.loops.auto", + "uvicorn.protocols", + "uvicorn.protocols.http", + "uvicorn.protocols.http.auto", + "uvicorn.lifespan", + "uvicorn.lifespan.on", + ], +) +pyz = PYZ(a.pure) +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.zipfiles, + a.datas, + name="hydroloader", + debug=False, + console=False, +) diff --git a/src/lib.rs b/src/lib.rs index b2a6440..780311d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,17 +1,22 @@ -use std::sync::Mutex; -use tauri_plugin_shell::process::CommandChild; -use tauri_plugin_shell::ShellExt; +use std::{ + collections::HashMap, + fs, + net::{TcpStream, ToSocketAddrs}, + path::{Path, PathBuf}, + process::{Child, Command, Stdio}, + sync::Mutex, + time::Duration, +}; -struct SidecarState(Mutex>); +struct SidecarState(Mutex>); #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_opener::init()) - .plugin(tauri_plugin_shell::init()) .manage(SidecarState(Mutex::new(None))) .setup(|app| { - // start_sidecar(app)?; + start_sidecar(app)?; setup_tray(app)?; Ok(()) }) @@ -28,14 +33,76 @@ pub fn run() { fn start_sidecar(app: &mut tauri::App) -> Result<(), Box> { use tauri::Manager; - let sidecar = app.shell().sidecar("sidecar")?; - let (_rx, child) = sidecar.spawn()?; + if !cfg!(debug_assertions) { + return Ok(()); + } + + let env_vars = read_env_file(&workspace_root().join(".env.development"))?; + let host = env_vars + .get("SDL_SIDECAR_HOST") + .cloned() + .unwrap_or_else(|| "127.0.0.1".to_string()); + let port = env_vars + .get("SDL_SIDECAR_PORT") + .and_then(|value| value.parse::().ok()) + .unwrap_or(5321); + + if port_is_in_use(&host, port) { + return Ok(()); + } + + let child = Command::new("node") + .arg("./scripts/run-sidecar.mjs") + .current_dir(workspace_root()) + .envs(env_vars) + .env("SDL_TAURI_MANAGED", "1") + .stdin(Stdio::null()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .spawn()?; *app.state::().0.lock().unwrap() = Some(child); Ok(()) } +fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) +} + +fn read_env_file(path: &Path) -> Result, Box> { + let mut env_vars = HashMap::new(); + + if !path.exists() { + return Ok(env_vars); + } + + for line in fs::read_to_string(path)?.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + + if let Some((key, value)) = trimmed.split_once('=') { + env_vars.insert(key.trim().to_string(), value.trim().to_string()); + } + } + + Ok(env_vars) +} + +fn port_is_in_use(host: &str, port: u16) -> bool { + let address = format!("{host}:{port}"); + address + .to_socket_addrs() + .map(|addresses| { + addresses.into_iter().any(|socket| { + TcpStream::connect_timeout(&socket, Duration::from_millis(200)).is_ok() + }) + }) + .unwrap_or(false) +} + fn setup_tray(app: &mut tauri::App) -> Result<(), Box> { use tauri::{ image::Image, @@ -67,7 +134,7 @@ fn setup_tray(app: &mut tauri::App) -> Result<(), Box> { } "quit" => { // Kill the sidecar before quitting - if let Some(child) = app.state::().0.lock().unwrap().take() { + if let Some(mut child) = app.state::().0.lock().unwrap().take() { let _ = child.kill(); } app.exit(0); @@ -99,4 +166,4 @@ fn setup_tray(app: &mut tauri::App) -> Result<(), Box> { } Ok(()) -} \ No newline at end of file +} diff --git a/tauri.conf.json b/tauri.conf.json index ff098e4..79a8d5c 100644 --- a/tauri.conf.json +++ b/tauri.conf.json @@ -4,7 +4,7 @@ "version": "0.1.0", "identifier": "com.streaming-data-loader.app", "build": { - "beforeDevCommand": "npm run dev", + "beforeDevCommand": "npm run dev:tauri", "devUrl": "http://localhost:1420", "beforeBuildCommand": "npm run build", "frontendDist": "dist" diff --git a/tsconfig.json b/tsconfig.json index b216bd3..d51ef04 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,7 +6,6 @@ "lib": ["ES2020", "DOM", "DOM.Iterable"], "skipLibCheck": true, "moduleResolution": "Bundler", - "allowImportingTsExtensions": false, "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, @@ -14,5 +13,5 @@ "noUnusedLocals": true, "noUnusedParameters": true }, - "include": ["frontend/**/*.ts"] + "include": ["frontend/**/*.ts", "frontend/**/*.d.ts"] } diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..3efc26c --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "types": ["node"], + "noEmit": true, + "strict": true + }, + "include": ["vite.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts index 5255e44..a5e4bc0 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,12 +1,38 @@ -import { defineConfig } from "vite" +import { defineConfig, loadEnv } from "vite" -export default defineConfig({ - clearScreen: false, - server: { - port: 1420, - strictPort: true, - watch: { - ignored: ["**/target/**"], +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), "") + const frontendHost = env.SDL_FRONTEND_HOST || "localhost" + const frontendPort = Number(env.SDL_FRONTEND_PORT || "1420") + const sidecarHost = env.SDL_SIDECAR_HOST || "127.0.0.1" + const sidecarPort = Number(env.SDL_SIDECAR_PORT || "5321") + const sidecarOrigin = `http://${sidecarHost}:${sidecarPort}` + + return { + clearScreen: false, + server: { + host: frontendHost, + open: true, + port: frontendPort, + strictPort: true, + proxy: { + "/api": { + target: sidecarOrigin, + changeOrigin: false, + rewrite: path => path.replace(/^\/api/, ""), + }, + "/docs": { + target: sidecarOrigin, + changeOrigin: false, + }, + "/openapi.json": { + target: sidecarOrigin, + changeOrigin: false, + }, + }, + watch: { + ignored: ["**/target/**", "**/.venv/**"], + }, }, - }, + } }) From 0e5e9858ab5b1c662841e7ffb61fe91f0b16b4e4 Mon Sep 17 00:00:00 2001 From: Daniel Slaugh Date: Thu, 2 Apr 2026 15:30:39 -0600 Subject: [PATCH 005/166] Add onboarding login page --- Cargo.toml | 2 +- capabilities/default.json | 3 +- frontend/api.ts | 41 + frontend/main.ts | 1576 ++++++++++++++++++++++++++++++----- frontend/styles.css | 250 +++++- icons/icon-color.svg | 1 + index.html | 2 +- package-lock.json | 22 + package.json | 3 + scripts/run-sidecar.mjs | 92 +- sidecar/api/models.py | 61 +- sidecar/api/routes.py | 15 +- sidecar/core/config.py | 8 +- sidecar/core/hydroserver.py | 74 +- sidecar/main.py | 22 + src/lib.rs | 28 +- 16 files changed, 1919 insertions(+), 281 deletions(-) create mode 100644 icons/icon-color.svg diff --git a/Cargo.toml b/Cargo.toml index de51702..2e45ff5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,8 +19,8 @@ tauri-build = { version = "2", features = [] } [dependencies] tauri = { version = "2", features = ["tray-icon", "image-png"] } +tauri-plugin-dialog = "2" tauri-plugin-opener = "2" tauri-plugin-shell = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" - diff --git a/capabilities/default.json b/capabilities/default.json index 0cda957..2e05bd0 100644 --- a/capabilities/default.json +++ b/capabilities/default.json @@ -5,8 +5,9 @@ "windows": ["main"], "permissions": [ "core:default", + "dialog:default", "opener:default", "shell:allow-execute", "shell:allow-spawn" ] -} \ No newline at end of file +} diff --git a/frontend/api.ts b/frontend/api.ts index e8df4d8..fda7c7a 100644 --- a/frontend/api.ts +++ b/frontend/api.ts @@ -2,10 +2,14 @@ import { apiBaseUrl } from "./config" export type ConnectionState = "not_configured" | "configured" | "connected" | "error" export type JobStatus = "healthy" | "warning" | "error" | "disabled" | "pending" | "running" +export type AuthType = "apikey" | "userpass" export interface ServerConfig { + auth_type: AuthType url: string api_key: string + username: string + password: string } export interface FileConfig { @@ -57,6 +61,24 @@ export interface ConnectionTestResponse { state: ConnectionState message: string instance_name: string | null + workspace_count: number + datastream_count: number + permissions_ok: boolean +} + +export interface DatastreamSummary { + id: string + name: string +} + +export interface CsvPreviewResponse { + raw_lines: string[] + parsed_rows: string[][] + detected_header_row: number | null + detected_data_start_row: number | null + detected_delimiter: string + total_lines: number + encoding: string } export interface JobSummary extends JobConfig { @@ -139,6 +161,25 @@ export function listJobs(): Promise { return request("/jobs") } +export function createJob(job: Omit): Promise { + return request("/jobs", { + method: "POST", + body: JSON.stringify(job), + }) +} + +export function getDatastreams(): Promise { + return request("/datastreams") +} + +export function getCsvPreview(path: string, rows = 60): Promise { + const params = new URLSearchParams({ + path, + rows: String(rows), + }) + return request(`/csv/preview?${params.toString()}`) +} + export function runJob(jobId: string): Promise { return request(`/jobs/${jobId}/run`, { method: "POST", diff --git a/frontend/main.ts b/frontend/main.ts index 4402171..dcfdf72 100644 --- a/frontend/main.ts +++ b/frontend/main.ts @@ -1,18 +1,25 @@ import "./generated.css" +import appIconUrl from "../icons/icon-color.svg" import { + createJob, deleteJob, disableJob, enableJob, getConfig, + getCsvPreview, + getDatastreams, getHealth, listJobs, runJob, testConnection, updateServerConfig, type AppConfig, + type AuthType, type ConnectionState, type ConnectionTestResponse, + type CsvPreviewResponse, + type DatastreamSummary, type HealthResponse, type JobSummary, type ServerConfig, @@ -20,22 +27,65 @@ import { import { getRouteFromHash, navigate, routeHref, type AppRoute } from "./router" import { formatRelativeTime, formatSchedule, shortenPath } from "./time" +const API_KEY_DOCS_URL = + "https://hydroserver2.github.io/hydroserver/tutorials/creating-your-first-orchestration-system#create-an-api-key" +const APP_NAME = "HydroServer Streaming Data Loader" +const STARTUP_RETRY_ATTEMPTS = 12 +const STARTUP_RETRY_DELAY_MS = 350 + type Feedback = { tone: "success" | "error" | "info" message: string } | null +type AuthFieldName = "url" | "api_key" | "username" | "password" + +type FieldValidationState = { + state: "idle" | "checking" | "valid" | "invalid" + message: string | null +} + +type PipelineMappingDraft = { + csvColumn: string + datastreamId: string +} + +type PipelineFormState = { + name: string + filePath: string + scheduleMinutes: number + headerRow: number + dataStartRow: number + delimiter: string + timestampColumn: string + timestampFormat: string + timezone: string + mappings: PipelineMappingDraft[] +} + type UiState = { route: AppRoute health: HealthResponse | null config: AppConfig | null jobs: JobSummary[] + datastreams: DatastreamSummary[] + connectionSummary: ConnectionTestResponse | null loading: boolean bootstrapError: string | null settingsFeedback: Feedback welcomeFeedback: Feedback - welcomeStep: 1 | 2 + pipelineFeedback: Feedback lastConnectionState: ConnectionState | null + settingsEditMode: boolean + pipelineForm: PipelineFormState + pipelinePreview: CsvPreviewResponse | null + pipelineErrors: string[] + datastreamsError: string | null + authDraft: ServerConfig + authFieldStates: Record + authSubmitting: boolean + lastAuthValidationServer: ServerConfig | null + lastAuthValidationResult: ConnectionTestResponse | null } const shellElements = { @@ -46,27 +96,74 @@ const shellElements = { connectionDot: document.querySelector("#connection-status-dot"), } -if (!shellElements.sidebar || !shellElements.mainContent || !shellElements.jobsLink || !shellElements.settingsLink || !shellElements.connectionDot) { +if ( + !shellElements.sidebar || + !shellElements.mainContent || + !shellElements.jobsLink || + !shellElements.settingsLink || + !shellElements.connectionDot +) { throw new Error("App shell is missing required elements.") } const { sidebar, mainContent, jobsLink, settingsLink, connectionDot } = shellElements +function createEmptyPipelineForm(): PipelineFormState { + return { + name: "", + filePath: "", + scheduleMinutes: 15, + headerRow: 3, + dataStartRow: 4, + delimiter: ",", + timestampColumn: "Timestamp", + timestampFormat: "%Y-%m-%d %H:%M:%S", + timezone: "America/Denver", + mappings: [], + } +} + const state: UiState = { route: getRouteFromHash(), health: null, config: null, jobs: [], + datastreams: [], + connectionSummary: null, loading: true, bootstrapError: null, settingsFeedback: null, welcomeFeedback: null, - welcomeStep: 1, + pipelineFeedback: null, lastConnectionState: null, + settingsEditMode: false, + pipelineForm: createEmptyPipelineForm(), + pipelinePreview: null, + pipelineErrors: [], + datastreamsError: null, + authDraft: emptyServerConfig(), + authFieldStates: { + url: { state: "idle", message: null }, + api_key: { state: "idle", message: null }, + username: { state: "idle", message: null }, + password: { state: "idle", message: null }, + }, + authSubmitting: false, + lastAuthValidationServer: null, + lastAuthValidationResult: null, } -const STARTUP_RETRY_ATTEMPTS = 12 -const STARTUP_RETRY_DELAY_MS = 350 +let authValidationRequestId = 0 + +function emptyServerConfig(): ServerConfig { + return { + auth_type: "apikey", + url: "", + api_key: "", + username: "", + password: "", + } +} window.setInterval(() => { void refreshJobs() @@ -97,19 +194,369 @@ function feedbackMarkup(feedback: Feedback): string { return `
${escapeHtml(feedback.message)}
` } -function sidebarConnectionState(): { label: string; className: string } { - if (!state.config?.server.url || !state.config.server.api_key) { +function basename(path: string): string { + const segments = path.split(/[\\/]/).filter(Boolean) + return segments.at(-1) ?? path +} + +function connected(): boolean { + return state.connectionSummary?.ok === true && state.lastConnectionState === "connected" +} + +function currentServerConfig(): ServerConfig { + return state.authDraft +} + +function emptyFieldValidationState(): FieldValidationState { + return { state: "idle", message: null } +} + +function resetAuthFieldStates(authType: AuthType): void { + state.authFieldStates.url = emptyFieldValidationState() + state.authFieldStates.api_key = emptyFieldValidationState() + state.authFieldStates.username = emptyFieldValidationState() + state.authFieldStates.password = emptyFieldValidationState() + + if (authType === "apikey") { + state.authFieldStates.username = emptyFieldValidationState() + state.authFieldStates.password = emptyFieldValidationState() + } else { + state.authFieldStates.api_key = emptyFieldValidationState() + } +} + +function serverConfigured(server: ServerConfig | null | undefined): boolean { + if (!server?.url.trim()) { + return false + } + + if (server.auth_type === "userpass") { + return Boolean(server.username.trim() && server.password.trim()) + } + + return Boolean(server.api_key.trim()) +} + +function readServerConfigForm( + form: HTMLFormElement, + base: ServerConfig = currentServerConfig() +): ServerConfig { + const data = new FormData(form) + const authType = data.get("auth_type") === "userpass" ? "userpass" : "apikey" + + return { + auth_type: authType, + url: String(data.get("url") ?? "").trim(), + api_key: authType === "apikey" ? String(data.get("api_key") ?? "").trim() : base.api_key, + username: + authType === "userpass" ? String(data.get("username") ?? "").trim() : base.username, + password: + authType === "userpass" ? String(data.get("password") ?? "").trim() : base.password, + } +} + +function setServerDraft(server: ServerConfig): void { + state.authDraft = { ...server } +} + +function sameServerConfig(left: ServerConfig | null, right: ServerConfig): boolean { + if (!left) { + return false + } + + return ( + left.auth_type === right.auth_type && + left.url === right.url && + left.api_key === right.api_key && + left.username === right.username && + left.password === right.password + ) +} + +function markField( + field: AuthFieldName, + nextState: FieldValidationState["state"], + message: string | null = null +): void { + state.authFieldStates[field] = { state: nextState, message } +} + +function credentialFields(authType: AuthType): AuthFieldName[] { + return authType === "userpass" ? ["username", "password"] : ["api_key"] +} + +function authFieldStateMarkup(field: AuthFieldName): string { + const fieldState = state.authFieldStates[field] + + if (fieldState.state === "valid") { + return '' + } + + if (fieldState.state === "invalid") { + return '' + } + + if (fieldState.state === "checking") { + return '' + } + + return "" +} + +function authFieldErrorMarkup(field: AuthFieldName): string { + const fieldState = state.authFieldStates[field] + if (fieldState.state !== "invalid" || !fieldState.message) { + return "" + } + + return `

${escapeHtml(fieldState.message)}

` +} + +function renderAuthInputField(params: { + label: string + name: AuthFieldName + type: "url" | "text" | "password" + value: string + placeholder: string + helpText?: string +}): string { + const { label, name, type, value, placeholder, helpText } = params + + return ` + + ` +} + +function fieldFormFeedbackTarget(formId: string): "welcomeFeedback" | "settingsFeedback" { + return formId === "welcome-form" ? "welcomeFeedback" : "settingsFeedback" +} + +function clearAuthFormFeedback(formId: string): void { + state[fieldFormFeedbackTarget(formId)] = null +} + +function clearAuthValidationCache(): void { + state.lastAuthValidationServer = null + state.lastAuthValidationResult = null +} + +function setAuthFieldLoading(server: ServerConfig): void { + markField("url", "checking") + for (const field of credentialFields(server.auth_type)) { + markField(field, "checking") + } +} + +function isValidHttpUrl(value: string): boolean { + try { + const parsed = new URL(value) + return parsed.protocol === "http:" || parsed.protocol === "https:" + } catch { + return false + } +} + +function applyConnectionValidationResult(server: ServerConfig, result: ConnectionTestResponse): void { + markField("url", "valid") + + if (result.ok) { + for (const field of credentialFields(server.auth_type)) { + markField(field, "valid") + } + return + } + + const message = result.message + const isUrlError = + result.message.includes("Couldn't reach HydroServer") || + result.message.includes("HydroServer returned an error") + + if (isUrlError) { + markField("url", "invalid", message) + for (const field of credentialFields(server.auth_type)) { + markField(field, "idle") + } + return + } + + for (const field of credentialFields(server.auth_type)) { + markField(field, "invalid", message) + } +} + +async function validateAuthField(form: HTMLFormElement, field: AuthFieldName): Promise { + const server = readServerConfigForm(form) + const requestId = ++authValidationRequestId + setServerDraft(server) + + if (field === "url") { + if (!server.url) { + markField("url", "invalid", "Enter the HydroServer URL.") + render() + return + } + + if (!isValidHttpUrl(server.url)) { + markField("url", "invalid", "Enter a full http:// or https:// URL.") + render() + return + } + } + + if (field === "api_key" && server.auth_type === "apikey" && !server.api_key) { + markField("api_key", "invalid", "Enter the API key.") + render() + return + } + + if (field === "username" && server.auth_type === "userpass" && !server.username) { + markField("username", "invalid", "Enter the username.") + render() + return + } + + if (field === "password" && server.auth_type === "userpass" && !server.password) { + markField("password", "invalid", "Enter the password.") + render() + return + } + + if (field === "url") { + markField("url", "valid") + } else { + markField(field, "checking") + } + + const requiredFieldsReady = + server.auth_type === "apikey" + ? Boolean(server.url && isValidHttpUrl(server.url) && server.api_key) + : Boolean(server.url && isValidHttpUrl(server.url) && server.username && server.password) + + if (!requiredFieldsReady) { + render() + return + } + + for (const name of credentialFields(server.auth_type)) { + markField(name, "checking") + } + markField("url", "checking") + render() + + try { + const result = await testConnection(server) + + if (requestId !== authValidationRequestId) { + return + } + + state.lastAuthValidationServer = server + state.lastAuthValidationResult = result + applyConnectionValidationResult(server, result) + } catch (error) { + if (requestId !== authValidationRequestId) { + return + } + + clearAuthValidationCache() + const message = + error instanceof Error ? error.message : "Couldn't test the HydroServer connection." + const isUrlError = + message.includes("Request failed with status 500") || + message.includes("Failed to fetch") || + message.includes("Couldn't test the HydroServer connection.") + + if (isUrlError) { + markField("url", "invalid", message) + for (const name of credentialFields(server.auth_type)) { + markField(name, "idle") + } + } else { + markField("url", "valid") + for (const name of credentialFields(server.auth_type)) { + markField(name, "invalid", message) + } + } + } + + render() +} + +function previewHeaders(): string[] { + return state.pipelinePreview?.parsed_rows[0] ?? [] +} + +function pipelineMappingsByColumn(): Map { + return new Map(state.pipelineForm.mappings.map(mapping => [mapping.csvColumn, mapping.datastreamId])) +} + +function previewColumnClass(columnName: string): string { + if (columnName === state.pipelineForm.timestampColumn) { + return "preview-col-timestamp" + } + + const mapped = state.pipelineForm.mappings.find( + mapping => mapping.csvColumn === columnName && mapping.datastreamId + ) + return mapped ? "preview-col-mapped" : "" +} + +function initializeMappings(headers: string[]): void { + const existing = pipelineMappingsByColumn() + state.pipelineForm.mappings = headers + .filter(header => header !== state.pipelineForm.timestampColumn) + .map(header => ({ + csvColumn: header, + datastreamId: existing.get(header) ?? "", + })) +} + +function applyPreview(path: string, preview: CsvPreviewResponse): void { + state.pipelinePreview = preview + state.pipelineForm.filePath = path + state.pipelineForm.headerRow = preview.detected_header_row ?? state.pipelineForm.headerRow + state.pipelineForm.dataStartRow = preview.detected_data_start_row ?? state.pipelineForm.dataStartRow + state.pipelineForm.delimiter = preview.detected_delimiter || state.pipelineForm.delimiter + + const headers = preview.parsed_rows[0] ?? [] + if (headers.length > 0) { + const preferredTimestamp = + headers.find(header => header.toLowerCase().includes("time")) ?? headers[0] + state.pipelineForm.timestampColumn = headers.includes(state.pipelineForm.timestampColumn) + ? state.pipelineForm.timestampColumn + : preferredTimestamp + } + + if (!state.pipelineForm.name.trim()) { + const inferred = basename(path).replace(/\.[^.]+$/, "") + state.pipelineForm.name = inferred + } + + initializeMappings(headers) +} + +function connectionIndicator(): { label: string; className: string } { + if (!serverConfigured(state.config?.server)) { return { label: "HydroServer not configured", className: "status-dot bg-slate-300" } } - switch (state.lastConnectionState) { - case "connected": - return { label: "Connected to HydroServer", className: "status-dot bg-emerald-500" } - case "error": - return { label: "HydroServer connection error", className: "status-dot bg-rose-500" } - default: - return { label: "HydroServer configured", className: "status-dot bg-sky-500" } + if (connected()) { + return { label: "Connected to HydroServer", className: "status-dot bg-emerald-500" } + } + + if (state.lastConnectionState === "error") { + return { label: "HydroServer authentication error", className: "status-dot bg-rose-500" } } + + return { label: "HydroServer configured", className: "status-dot bg-sky-500" } } function statusPill(job: JobSummary): string { @@ -125,6 +572,156 @@ function statusPill(job: JobSummary): string { return `${escapeHtml(job.status_message)}` } +function renderConnectedCard(showActions: boolean): string { + if (!connected() || !state.connectionSummary) { + return "" + } + + const datastreamText = + state.connectionSummary.datastream_count === 1 + ? "1 datastream available" + : `${state.connectionSummary.datastream_count} datastreams available` + + return ` +
+
+

Authenticated

+

${escapeHtml( + state.connectionSummary.instance_name ?? "HydroServer" + )}

+

${escapeHtml(state.connectionSummary.message)}

+
+ Connected + ${escapeHtml(datastreamText)} +
+
+ ${ + showActions + ? ` +
+ + ${ + state.jobs.length === 0 + ? `Create first pipeline` + : "" + } +
+ ` + : "" + } +
+ ` +} + +function renderAuthForm( + formId: "welcome-form" | "settings-form", + feedback: Feedback, + submitLabel: string, + secondaryAction: string +): string { + const server = currentServerConfig() + const usingUserPass = server.auth_type === "userpass" + const authToggleLabel = usingUserPass + ? "Connect with an API key" + : "Connect with username and password" + const submitDisabled = state.authSubmitting ? "disabled" : "" + const submitLabelText = state.authSubmitting ? "Connecting..." : submitLabel + + return ` +
+
+
+ HydroServer Streaming Data Loader icon +

Connect to your HydroServer instance

+
+ + ${feedbackMarkup(feedback)} + + + ${renderAuthInputField({ + label: "Host URL", + name: "url", + type: "url", + value: server.url, + placeholder: "https://playground.hydroserver.org", + })} + + ${ + usingUserPass + ? ` + ${renderAuthInputField({ + label: "Username", + name: "username", + type: "text", + value: server.username, + placeholder: "name@example.com", + })} + ${renderAuthInputField({ + label: "Password", + name: "password", + type: "password", + value: server.password, + placeholder: "Enter your HydroServer password", + })} + ` + : ` + ${renderAuthInputField({ + label: "API key", + name: "api_key", + type: "password", + value: server.api_key, + placeholder: "KaTz74swGqHn__I2VY6ceIzrIxC04oDhUrLLgBTH9ACxYIunmkrdmqk", + })} + + + How to create an API key + + ` + } + +
+ or + + +
+ +
+ ${secondaryAction} + +
+
+
+ ` +} + +function renderWelcome(): string { + return ` +
+ ${renderAuthForm("welcome-form", state.welcomeFeedback, "Connect to HydroServer", "")} +
+ ` +} + +function renderSettings(): string { + const showForm = !connected() || state.settingsEditMode + + return ` +
+ + + ${showForm ? renderAuthForm("settings-form", state.settingsFeedback, "Save and verify", connected() ? '' : "") : renderConnectedCard(true)} +
+ ` +} + function renderDashboard(): string { if (state.jobs.length === 0) { return ` @@ -133,17 +730,10 @@ function renderDashboard(): string {

Dashboard

Jobs

-

This loader stores its own local job definitions and pushes directly to HydroServer.

+

Finish the onboarding flow by creating your first pipeline. ${APP_NAME} will use that saved local configuration from then on.

- Add job + Create first pipeline - -
-
CSV
-

No data sources yet

-

Connect to HydroServer first, then add a watched CSV file and map its columns to datastreams.

- Add your first job -
` } @@ -187,129 +777,328 @@ function renderDashboard(): string {
${cards}
` } -function renderSettings(): string { - const server = state.config?.server ?? { url: "", api_key: "" } +function renderPipelinePreview(): string { + if (!state.pipelinePreview) { + return ` +
+
+
CSV
+

Preview a source file

+

Choose a CSV file path, then load the preview to detect headers and map source columns to HydroServer datastreams.

+
+
+ ` + } + + const headers = previewHeaders() + const parsedRows = state.pipelinePreview.parsed_rows.slice(1, 7) + const rawRows = state.pipelinePreview.raw_lines + .map((line, index) => { + const lineNumber = index + 1 + const rowClass = + lineNumber === state.pipelineForm.headerRow + ? "preview-raw-line preview-raw-line-header" + : lineNumber === state.pipelineForm.dataStartRow + ? "preview-raw-line preview-raw-line-data" + : "preview-raw-line" + + return ` +
+ ${lineNumber} + ${escapeHtml(line)} +
+ ` + }) + .join("") + + const headerCells = headers + .map( + header => + `${escapeHtml(header)}` + ) + .join("") + + const tableRows = parsedRows + .map( + row => ` + + ${row + .map((cell, index) => { + const columnName = headers[index] ?? "" + return `${escapeHtml(cell)}` + }) + .join("")} + + ` + ) + .join("") return ` -
- +
+ Header row ${state.pipelineForm.headerRow} + Data starts ${state.pipelineForm.dataStartRow} + ${escapeHtml(state.pipelinePreview.encoding)} +
+ -
-
-

HydroServer connection

+
${rawRows}
+ +
+ + + ${headerCells} + + + ${tableRows} + +
+
- +
+ Showing the first ${Math.min(state.pipelinePreview.total_lines, state.pipelinePreview.raw_lines.length)} of ${state.pipelinePreview.total_lines} lines +
+ + ` +} - +function renderPipelineMappings(): string { + const availableMappings = state.pipelineForm.mappings -
- - -
+ if (!state.pipelinePreview || availableMappings.length === 0) { + return ` +
+

Column mappings

+

Load a CSV preview first so HydroServer Streaming Data Loader can list the available source columns.

+
+ ` + } - ${feedbackMarkup(state.settingsFeedback)} -
+ const rows = availableMappings + .map(mapping => { + const options = [ + ``, + ...state.datastreams.map( + datastream => + `` + ), + ].join("") -
-

Preferences

-

Launch-at-login and tray controls arrive in the desktop integration phase.

-
+ return ` +
+
+

${escapeHtml(mapping.csvColumn)}

+

Source column

+
+ +
+ ` + }) + .join("") -
-

About

-

SDL version ${escapeHtml(state.health?.version ?? "0.1.0")}

-

HydroServer Streaming Data Loader

-
-
-
+ return ` +
+

Column mappings

+

Map each source column to a HydroServer datastream. Leave any unused source columns as “Not mapped.”

+
${rows}
+
` } -function renderWelcome(): string { - const server = state.config?.server ?? { url: "", api_key: "" } +function renderPipelineEditor(): string { + if (!connected()) { + return renderWelcome() + } - if (state.welcomeStep === 2) { + if (state.datastreamsError) { return ` -
-
-

Connected

-

HydroServer is ready

-

The next step in the implementation order is the job editor and CSV preview. Your HydroServer credentials are already saved locally.

- +
+ + + ${renderConnectedCard(true)} +
${escapeHtml(state.datastreamsError)}
` } - return ` -
-
-

Welcome

-

Connect to your HydroServer instance

-

SDL now manages its own local job definitions, then authenticates with HydroServer only when it needs to discover datastreams or push new observations.

- - - - - -
- - Open settings + if (state.datastreams.length === 0) { + return ` +
+ - ${feedbackMarkup(state.welcomeFeedback)} - -
- ` -} + ${renderConnectedCard(true)} + + Open the HydroServer 101 tutorial + +
+ ` + } + + const timestampOptions = previewHeaders() + .map( + header => + `` + ) + .join("") + + const pipelineErrorMarkup = + state.pipelineErrors.length > 0 + ? ` +
+

Fix these issues before saving

+
    + ${state.pipelineErrors.map(error => `
  • ${escapeHtml(error)}
  • `).join("")} +
+
+ ` + : "" -function renderJobsPlaceholder(): string { return `
-
-
1
-

Foundation is in place

-

Use the Settings page to connect to HydroServer, then the next pass will add the actual file picker, preview surface, and mapping UI.

- Back to dashboard -
+ + ${renderConnectedCard(true)} + +
+
+
+

Pipeline details

+ + + + + +
+ + +
+ + +
+ +
+

File structure

+ +
+ + + +
+ +
+ + + +
+ + + + +
+ + ${renderPipelineMappings()} + ${pipelineErrorMarkup} + ${feedbackMarkup(state.pipelineFeedback)} + +
+ +
+
+ + ${renderPipelinePreview()} +
` } @@ -320,7 +1109,7 @@ function renderFatalError(): string {

Sidecar error

The background process is unavailable

-

${escapeHtml(state.bootstrapError ?? "SDL could not reach the local sidecar.")}

+

${escapeHtml(state.bootstrapError ?? `${APP_NAME} could not reach the local background service.`)}

@@ -330,29 +1119,35 @@ function renderFatalError(): string { function render(): void { state.route = getRouteFromHash() - if (!state.loading && !state.bootstrapError && !state.config?.server.url && state.route !== "settings" && state.route !== "welcome") { - navigate("welcome") - state.route = "welcome" + let currentRoute = getRouteFromHash() + + if (!state.loading && !state.bootstrapError) { + if (!connected() && currentRoute !== "settings" && currentRoute !== "welcome") { + navigate("welcome") + currentRoute = "welcome" + } else if (connected() && state.jobs.length === 0 && (currentRoute === "dashboard" || currentRoute === "welcome")) { + navigate("jobs-new") + currentRoute = "jobs-new" + } } - const currentRoute = getRouteFromHash() const showSidebar = currentRoute !== "welcome" && !state.bootstrapError sidebar.classList.toggle("hidden", !showSidebar) jobsLink.className = currentRoute === "dashboard" ? "nav-item nav-item-active" : "nav-item" settingsLink.className = currentRoute === "settings" ? "nav-item nav-item-active" : "nav-item" - const connectionState = sidebarConnectionState() - connectionDot.className = connectionState.className - connectionDot.title = connectionState.label + const status = connectionIndicator() + connectionDot.className = status.className + connectionDot.title = status.label if (state.loading) { mainContent.innerHTML = `
-

Starting SDL

+

Starting Up

Loading local configuration

-

Connecting the browser preview to the FastAPI sidecar.

+

Connecting ${APP_NAME} to its local background service and validating your HydroServer configuration.

` @@ -375,21 +1170,13 @@ function render(): void { } if (currentRoute === "jobs-new") { - mainContent.innerHTML = renderJobsPlaceholder() + mainContent.innerHTML = renderPipelineEditor() return } mainContent.innerHTML = renderDashboard() } -function readServerForm(form: HTMLFormElement): ServerConfig { - const data = new FormData(form) - return { - url: String(data.get("url") ?? "").trim(), - api_key: String(data.get("api_key") ?? "").trim(), - } -} - function sleep(ms: number): Promise { return new Promise(resolve => window.setTimeout(resolve, ms)) } @@ -432,7 +1219,42 @@ async function loadInitialStateWithRetry(): Promise<{ } } - throw lastError instanceof Error ? lastError : new Error("Failed to load SDL.") + throw lastError instanceof Error ? lastError : new Error(`Failed to load ${APP_NAME}.`) +} + +async function syncAuthenticationStatus( + server: ServerConfig, + context: "bootstrap" | "welcome" | "settings" +): Promise { + const result = await testConnection(server) + state.lastAuthValidationServer = server + state.lastAuthValidationResult = result + state.connectionSummary = result + state.lastConnectionState = result.state + + if (result.ok) { + await loadDatastreams() + } else { + state.datastreams = [] + state.datastreamsError = null + } + + if (context === "bootstrap" && !result.ok) { + state.welcomeFeedback = { tone: "error", message: result.message } + } + + return result +} + +async function loadDatastreams(): Promise { + try { + state.datastreams = await getDatastreams() + state.datastreamsError = null + } catch (error) { + state.datastreams = [] + state.datastreamsError = + error instanceof Error ? error.message : "Couldn't load HydroServer datastreams." + } } async function bootstrap(): Promise { @@ -446,8 +1268,12 @@ async function bootstrap(): Promise { state.config = config state.jobs = jobs state.lastConnectionState = health.connection.state + + if (serverConfigured(config.server)) { + await syncAuthenticationStatus(config.server, "bootstrap") + } } catch (error) { - state.bootstrapError = error instanceof Error ? error.message : "Failed to load SDL." + state.bootstrapError = error instanceof Error ? error.message : `Failed to load ${APP_NAME}.` } finally { state.loading = false render() @@ -463,8 +1289,277 @@ async function refreshJobs(): Promise { state.jobs = await listJobs() render() } catch { - // Keep the existing dashboard state if polling fails. + // Keep existing UI state on polling failure. + } +} + +function updatePipelineField(name: string, value: string): void { + switch (name) { + case "pipeline_name": + state.pipelineForm.name = value + break + case "file_path": + state.pipelineForm.filePath = value + break + case "schedule_minutes": + state.pipelineForm.scheduleMinutes = Number(value) || 15 + break + case "header_row": + state.pipelineForm.headerRow = Number(value) || 1 + break + case "data_start_row": + state.pipelineForm.dataStartRow = Number(value) || 1 + break + case "delimiter": + state.pipelineForm.delimiter = value || "," + break + case "timestamp_column": + state.pipelineForm.timestampColumn = value + initializeMappings(previewHeaders()) + render() + break + case "timestamp_format": + state.pipelineForm.timestampFormat = value + break + case "timezone": + state.pipelineForm.timezone = value + break + default: + break + } +} + +function validatePipeline(): string[] { + const errors: string[] = [] + const headers = previewHeaders() + const selectedMappings = state.pipelineForm.mappings.filter(mapping => mapping.datastreamId) + const datastreamIds = new Set(state.datastreams.map(datastream => datastream.id)) + const seenTargets = new Set() + + if (!connected()) { + errors.push("Connect to HydroServer before saving a pipeline.") + } + + if (!state.pipelineForm.name.trim()) { + errors.push("Give the pipeline a name.") + } + + if (!state.pipelineForm.filePath.trim()) { + errors.push(`Choose the CSV file ${APP_NAME} should watch.`) + } + + if (!state.pipelinePreview) { + errors.push("Load a CSV preview before saving the pipeline.") + } + + if (state.pipelineForm.headerRow < 1) { + errors.push("Header row must be 1 or greater.") + } + + if (state.pipelineForm.dataStartRow <= state.pipelineForm.headerRow) { + errors.push("Data start row must come after the header row.") + } + + if (headers.length > 0 && !headers.includes(state.pipelineForm.timestampColumn)) { + errors.push("Choose a timestamp column that exists in the previewed CSV header.") + } + + if (selectedMappings.length === 0) { + errors.push("Map at least one source column to a HydroServer datastream.") + } + + for (const mapping of selectedMappings) { + if (!datastreamIds.has(mapping.datastreamId)) { + errors.push(`The selected target for ${mapping.csvColumn} is not a valid HydroServer datastream.`) + } + + if (seenTargets.has(mapping.datastreamId)) { + errors.push("Each target datastream can only be mapped once in this first-run flow.") + } + + seenTargets.add(mapping.datastreamId) + } + + return errors +} + +async function loadPipelinePreview(path: string): Promise { + if (!path.trim()) { + state.pipelineFeedback = { tone: "error", message: "Enter or choose a CSV file path first." } + render() + return + } + + try { + const preview = await getCsvPreview(path.trim()) + applyPreview(path.trim(), preview) + state.pipelineErrors = [] + state.pipelineFeedback = { + tone: "success", + message: "Preview loaded. Review the detected structure and map the source columns.", + } + } catch (error) { + state.pipelinePreview = null + state.pipelineFeedback = { + tone: "error", + message: error instanceof Error ? error.message : "Couldn't preview that CSV file.", + } + } + + render() +} + +async function browseForCsvPath(): Promise { + try { + const dialog = await import("@tauri-apps/plugin-dialog") + const selection = await dialog.open({ + directory: false, + multiple: false, + filters: [{ name: "CSV files", extensions: ["csv", "txt"] }], + }) + + if (typeof selection !== "string" || !selection) { + return + } + + state.pipelineForm.filePath = selection + if (!state.pipelineForm.name.trim()) { + state.pipelineForm.name = basename(selection).replace(/\.[^.]+$/, "") + } + + await loadPipelinePreview(selection) + } catch { + state.pipelineFeedback = { + tone: "info", + message: + "The native file picker is only available in the desktop app. Enter the CSV path manually if you're using the browser preview.", + } + render() + } +} + +async function saveAuthenticatedServerConfig( + form: HTMLFormElement, + context: "welcome" | "settings" +): Promise { + if (state.authSubmitting) { + return + } + + const payload = readServerConfigForm(form) + setServerDraft(payload) + + const feedbackKey = context === "welcome" ? "welcomeFeedback" : "settingsFeedback" + const canReuseValidation = + sameServerConfig(state.lastAuthValidationServer, payload) && + state.lastAuthValidationResult?.ok === true + + try { + state.authSubmitting = true + setAuthFieldLoading(payload) + render() + + const result = canReuseValidation + ? state.lastAuthValidationResult! + : await syncAuthenticationStatus(payload, context) + + if (canReuseValidation) { + state.connectionSummary = result + state.lastConnectionState = result.state + await loadDatastreams() + } + + applyConnectionValidationResult(payload, result) + if (!result.ok) { + state[feedbackKey] = { tone: "error", message: result.message } + render() + return + } + + state.config = await updateServerConfig(payload) + state.authDraft = { + ...emptyServerConfig(), + ...state.config.server, + } + state[feedbackKey] = { tone: "success", message: result.message } + state.settingsEditMode = false + + if (state.jobs.length === 0) { + navigate("jobs-new") + } else { + navigate("dashboard") + } + } catch (error) { + clearAuthValidationCache() + state[feedbackKey] = { + tone: "error", + message: + error instanceof Error + ? error.message + : "Couldn't verify the HydroServer connection.", + } + state.lastConnectionState = "error" + } finally { + state.authSubmitting = false + } + + render() +} + +async function savePipeline(): Promise { + state.pipelineErrors = validatePipeline() + + if (state.pipelineErrors.length > 0) { + state.pipelineFeedback = { + tone: "error", + message: `${APP_NAME} needs a little more information before it can save this pipeline.`, + } + render() + return + } + + const mappedColumns = state.pipelineForm.mappings + .filter(mapping => mapping.datastreamId) + .map(mapping => { + const datastream = state.datastreams.find(item => item.id === mapping.datastreamId) + return { + csv_column: mapping.csvColumn, + datastream_id: mapping.datastreamId, + datastream_name: datastream?.name ?? mapping.datastreamId, + } + }) + + try { + const created = await createJob({ + name: state.pipelineForm.name.trim(), + enabled: true, + file_path: state.pipelineForm.filePath.trim(), + schedule_minutes: state.pipelineForm.scheduleMinutes, + file_config: { + header_row: state.pipelineForm.headerRow, + data_start_row: state.pipelineForm.dataStartRow, + delimiter: state.pipelineForm.delimiter, + timestamp_column: state.pipelineForm.timestampColumn, + timestamp_format: state.pipelineForm.timestampFormat, + timezone: state.pipelineForm.timezone, + }, + column_mappings: mappedColumns, + }) + + state.jobs = [...state.jobs, created] + state.pipelineForm = createEmptyPipelineForm() + state.pipelinePreview = null + state.pipelineErrors = [] + state.pipelineFeedback = { tone: "success", message: "Pipeline saved." } + navigate("dashboard") + } catch (error) { + state.pipelineFeedback = { + tone: "error", + message: error instanceof Error ? error.message : "Couldn't save that pipeline.", + } } + + render() } window.addEventListener("hashchange", () => { @@ -478,30 +1573,93 @@ mainContent.addEventListener("submit", event => { return } + event.preventDefault() + + if (target.id === "welcome-form") { + void saveAuthenticatedServerConfig(target, "welcome") + return + } + if (target.id === "settings-form") { - event.preventDefault() - void saveSettings(target) + void saveAuthenticatedServerConfig(target, "settings") + return } - if (target.id === "welcome-form") { - event.preventDefault() - void connectWelcome(target) + if (target.id === "pipeline-form") { + void savePipeline() } }) mainContent.addEventListener("input", event => { const target = event.target - if (!(target instanceof HTMLInputElement)) { + + if ( + !( + target instanceof HTMLInputElement || + target instanceof HTMLSelectElement || + target instanceof HTMLTextAreaElement + ) + ) { + return + } + + if (target.form?.id === "welcome-form" || target.form?.id === "settings-form") { + const form = target.form + setServerDraft(readServerConfigForm(form)) + clearAuthFormFeedback(form.id) + clearAuthValidationCache() + + if ( + target instanceof HTMLInputElement && + (target.name === "url" || + target.name === "api_key" || + target.name === "username" || + target.name === "password") + ) { + markField(target.name, "idle") + } + return + } + + if (target.form?.id !== "pipeline-form") { + return + } + + state.pipelineFeedback = null + state.pipelineErrors = [] + + const mappingColumn = target.dataset.mappingColumn + if (mappingColumn) { + const mapping = state.pipelineForm.mappings.find(item => item.csvColumn === mappingColumn) + if (mapping) { + mapping.datastreamId = target.value + } + return + } + + updatePipelineField(target.name, target.value) +}) + +mainContent.addEventListener("focusout", event => { + const target = event.target + if (!(target instanceof HTMLInputElement) || !target.form) { return } - if (target.form?.id === "settings-form") { - state.settingsFeedback = null + if (target.form.id !== "welcome-form" && target.form.id !== "settings-form") { + return } - if (target.form?.id === "welcome-form") { - state.welcomeFeedback = null + if ( + target.name !== "url" && + target.name !== "api_key" && + target.name !== "username" && + target.name !== "password" + ) { + return } + + void validateAuthField(target.form, target.name) }) mainContent.addEventListener("click", event => { @@ -522,114 +1680,83 @@ mainContent.addEventListener("click", event => { return } - if (action === "test-connection") { - const form = document.querySelector("#settings-form") - if (form) { - void testSettingsConnection(form) + if (action === "toggle-auth-mode") { + const form = target.closest("form") + if (!form) { + return } - return - } - if (!jobId) { + const nextServer = readServerConfigForm(form) + const nextAuthType: AuthType = nextServer.auth_type === "apikey" ? "userpass" : "apikey" + setServerDraft({ + ...nextServer, + auth_type: nextAuthType, + }) + resetAuthFieldStates(nextAuthType) + + clearAuthFormFeedback(form.id) + clearAuthValidationCache() + + render() return } - if (action === "run-job") { - void handleRunJob(jobId) + if (action === "change-credentials") { + state.authDraft = { + ...emptyServerConfig(), + ...(state.config?.server ?? {}), + } + state.settingsEditMode = true + navigate("settings") + render() return } - if (action === "toggle-job") { - void handleToggleJob(jobId) + if (action === "cancel-credential-edit") { + state.authDraft = { + ...emptyServerConfig(), + ...(state.config?.server ?? {}), + } + state.settingsEditMode = false + render() return } - if (action === "delete-job") { - void handleDeleteJob(jobId) + if (action === "browse-csv") { + void browseForCsvPath() + return } -}) -async function saveSettings(form: HTMLFormElement): Promise { - const payload = readServerForm(form) - - try { - state.config = await updateServerConfig(payload) - state.settingsFeedback = { tone: "success", message: "Settings saved." } - state.lastConnectionState = "configured" - } catch (error) { - state.settingsFeedback = { - tone: "error", - message: error instanceof Error ? error.message : "Failed to save settings.", - } + if (action === "load-preview") { + void loadPipelinePreview(state.pipelineForm.filePath) + return } - render() -} - -async function testSettingsConnection(form: HTMLFormElement): Promise { - const payload = readServerForm(form) - - try { - const result = await testConnection(payload) - applyConnectionFeedback(result, "settings") - } catch (error) { - state.settingsFeedback = { - tone: "error", - message: error instanceof Error ? error.message : "Couldn't test the HydroServer connection.", - } - state.lastConnectionState = "error" + if (!jobId) { + return } - render() -} - -async function connectWelcome(form: HTMLFormElement): Promise { - const payload = readServerForm(form) - - try { - const result = await testConnection(payload) - if (!result.ok) { - applyConnectionFeedback(result, "welcome") - render() - return - } - - state.config = await updateServerConfig(payload) - state.lastConnectionState = "connected" - state.welcomeFeedback = { tone: "success", message: result.message } - state.welcomeStep = 2 - render() - } catch (error) { - state.welcomeFeedback = { - tone: "error", - message: error instanceof Error ? error.message : "Couldn't save the HydroServer connection.", - } - state.lastConnectionState = "error" - render() + if (action === "run-job") { + void handleRunJob(jobId) + return } -} -function applyConnectionFeedback(result: ConnectionTestResponse, context: "settings" | "welcome"): void { - const feedback: Feedback = { - tone: result.ok ? "success" : "error", - message: result.message, + if (action === "toggle-job") { + void handleToggleJob(jobId) + return } - if (context === "settings") { - state.settingsFeedback = feedback - } else { - state.welcomeFeedback = feedback + if (action === "delete-job") { + void handleDeleteJob(jobId) } - - state.lastConnectionState = result.state -} +}) async function handleRunJob(jobId: string): Promise { try { await runJob(jobId) await refreshJobs() } catch { - // Dashboard cards already show persistent error state from the sidecar. + // Keep dashboard state unchanged on action failure. } } @@ -645,14 +1772,15 @@ async function handleToggleJob(jobId: string): Promise { } else { await enableJob(jobId) } + await refreshJobs() } catch { - // Ignore and keep the current UI state. + // Keep dashboard state unchanged on action failure. } } async function handleDeleteJob(jobId: string): Promise { - const confirmed = window.confirm("Delete this job?") + const confirmed = window.confirm("Delete this pipeline?") if (!confirmed) { return } @@ -661,7 +1789,7 @@ async function handleDeleteJob(jobId: string): Promise { await deleteJob(jobId) await refreshJobs() } catch { - // Ignore and keep the current UI state. + // Keep dashboard state unchanged on action failure. } } diff --git a/frontend/styles.css b/frontend/styles.css index 52a0052..816aeb0 100644 --- a/frontend/styles.css +++ b/frontend/styles.css @@ -33,6 +33,16 @@ } } +@keyframes spin { + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } +} + @layer base { html { -webkit-font-smoothing: antialiased; @@ -70,7 +80,7 @@ } .btn-primary { - @apply inline-flex items-center justify-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-brand-700; + @apply inline-flex items-center justify-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-brand-700 disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:bg-brand-600; } .btn-ghost { @@ -107,6 +117,11 @@ .settings-card, .welcome-card, + .auth-card, + .summary-card, + .pipeline-form, + .preview-card, + .pipeline-subcard, .job-card, .empty-panel { border-color: var(--color-surface-border); @@ -114,10 +129,52 @@ @apply rounded-2xl border bg-white; } - .settings-card { + .settings-card, + .auth-card { @apply max-w-3xl overflow-hidden; } + .auth-header { + @apply flex flex-col items-center; + } + + .auth-app-icon { + @apply mb-5 h-11 w-11 shrink-0; + filter: drop-shadow(0 8px 18px rgb(14 116 144 / 0.14)); + } + + .auth-header .page-title { + @apply text-[1.5rem] font-semibold leading-tight; + } + + .auth-intro { + @apply grid gap-4 rounded-xl border border-slate-200 bg-slate-50/80 p-4 md:grid-cols-2; + } + + .auth-intro-block { + @apply flex flex-col gap-2; + } + + .auth-intro-label { + @apply text-xs font-semibold uppercase tracking-[0.18em] text-slate-500; + } + + .summary-card { + @apply flex flex-col gap-5 p-6 md:flex-row md:items-center md:justify-between; + } + + .summary-card-copy { + @apply flex flex-col gap-2; + } + + .summary-inline { + @apply mt-1 flex flex-wrap items-center gap-3; + } + + .summary-meta { + @apply text-sm text-slate-500; + } + .card-section { @apply flex flex-col gap-4 border-b border-slate-200 px-6 py-6 last:border-b-0; } @@ -138,6 +195,14 @@ @apply flex flex-col gap-1.5; } + .field-control { + @apply relative block; + } + + .field-hint { + @apply text-xs leading-5 text-slate-500; + } + .label { @apply text-xs font-medium uppercase tracking-wide text-slate-500; } @@ -146,10 +211,79 @@ @apply w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-sky-100; } + .input-with-status { + @apply pr-10; + } + + .input-status { + @apply pointer-events-none absolute inset-y-0 right-3 inline-flex items-center text-sm font-semibold; + } + + .input-status-valid { + @apply text-emerald-600; + } + + .input-status-invalid { + @apply text-red-600; + } + + .input-status-checking { + @apply text-slate-400; + } + + .input-spinner { + @apply h-4 w-4 rounded-full border-2 border-slate-300 border-t-brand-600; + animation: spin 650ms linear infinite; + } + + .field-error { + @apply text-sm text-red-600; + } + .button-row { @apply flex flex-wrap items-center gap-3 pt-2; } + .button-row-between { + @apply justify-between; + } + + .button-row-tight { + @apply gap-2 pt-0; + } + + .button-row-end { + @apply justify-end; + } + + .btn-link { + @apply inline-flex items-center gap-2 text-sm font-medium text-brand-700 hover:text-brand-600; + } + + .text-link { + @apply inline-flex text-sm font-medium text-brand-700 underline underline-offset-3 hover:text-brand-600; + } + + .auth-toggle-group { + @apply flex flex-col items-center gap-1 py-1; + } + + .auth-divider-label { + @apply text-xs text-slate-400; + } + + .auth-toggle { + @apply mx-auto w-fit cursor-pointer appearance-none border-0 bg-transparent p-0 text-sm text-slate-500 hover:text-slate-700; + } + + .onboarding-list { + @apply m-0 flex list-decimal flex-col gap-2 pl-5 text-sm leading-6 text-slate-600; + } + + .onboarding-list-compact { + @apply gap-1.5; + } + .notice-success, .notice-error, .notice-info { @@ -176,6 +310,118 @@ @apply w-full max-w-2xl p-8 md:p-10; } + .pipeline-layout { + @apply grid gap-6 xl:grid-cols-[minmax(0,28rem)_minmax(0,1fr)]; + } + + .pipeline-form { + @apply flex flex-col gap-4 p-5; + } + + .pipeline-subcard { + @apply rounded-xl border border-slate-200 bg-slate-50/40 p-4; + } + + .split-fields { + @apply grid gap-4 sm:grid-cols-2; + } + + .mapping-grid { + @apply mt-4 flex flex-col gap-3; + } + + .mapping-row { + @apply grid items-center gap-3 rounded-xl border border-slate-200 bg-white p-3 md:grid-cols-[minmax(0,12rem)_minmax(0,1fr)]; + } + + .mapping-source { + @apply text-sm font-medium text-slate-800; + } + + .mapping-help { + @apply mt-1 text-xs text-slate-500; + } + + .preview-card { + @apply flex min-h-[32rem] flex-col overflow-hidden p-5; + } + + .preview-header { + @apply flex flex-col gap-4 border-b border-slate-100 pb-4; + } + + .preview-summary { + @apply flex flex-wrap gap-2; + } + + .preview-raw { + @apply mt-4 rounded-xl border border-slate-200 bg-slate-950 p-3 text-xs text-slate-200; + } + + .preview-raw-line { + @apply grid grid-cols-[2.5rem_minmax(0,1fr)] gap-3 border-b border-slate-800/80 px-1 py-1.5 last:border-b-0; + } + + .preview-raw-line-header { + @apply bg-sky-950/60; + } + + .preview-raw-line-data { + @apply bg-emerald-950/40; + } + + .preview-line-number { + @apply font-mono text-slate-400; + } + + .preview-table-shell { + @apply mt-4 overflow-auto rounded-xl border border-slate-200; + } + + .preview-table { + @apply min-w-full border-collapse text-left text-xs; + } + + .preview-table thead { + @apply bg-slate-100; + } + + .preview-table tbody tr:nth-child(odd) { + @apply bg-white; + } + + .preview-table tbody tr:nth-child(even) { + @apply bg-slate-50/70; + } + + .preview-cell { + @apply border-b border-slate-200 px-3 py-2 font-mono text-slate-700; + } + + .preview-col-timestamp { + @apply bg-brand-50 text-brand-700; + } + + .preview-col-mapped { + @apply bg-emerald-50 text-emerald-700; + } + + .preview-footer { + @apply mt-4 text-xs text-slate-500; + } + + .preview-placeholder { + @apply flex h-full min-h-[20rem] flex-col items-center justify-center gap-4 text-center; + } + + .validation-panel { + @apply rounded-xl border border-red-200 bg-red-50 p-4; + } + + .validation-list { + @apply mt-3 list-disc pl-5 text-sm leading-6 text-red-700; + } + .empty-panel { @apply flex min-h-[18rem] flex-col items-center justify-center gap-4 p-8 text-center; } diff --git a/icons/icon-color.svg b/icons/icon-color.svg new file mode 100644 index 0000000..50d0dc1 --- /dev/null +++ b/icons/icon-color.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/index.html b/index.html index 1144c1d..cf79088 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,7 @@ - Streaming Data Loader + HydroServer Streaming Data Loader
diff --git a/package-lock.json b/package-lock.json index d9a4353..2a6a084 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,9 @@ "": { "name": "streaming-data-loader", "version": "0.1.0", + "dependencies": { + "@tauri-apps/plugin-dialog": "^2.0.0" + }, "devDependencies": { "@tailwindcss/cli": "^4.1.4", "@tauri-apps/cli": "^2.10.1", @@ -1443,6 +1446,16 @@ "node": ">= 20" } }, + "node_modules/@tauri-apps/api": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz", + "integrity": "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==", + "license": "Apache-2.0 OR MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + } + }, "node_modules/@tauri-apps/cli": { "version": "2.10.1", "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.10.1.tgz", @@ -1660,6 +1673,15 @@ "node": ">= 10" } }, + "node_modules/@tauri-apps/plugin-dialog": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz", + "integrity": "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", diff --git a/package.json b/package.json index f283354..f72a4e6 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,9 @@ "private": true, "version": "0.1.0", "type": "module", + "dependencies": { + "@tauri-apps/plugin-dialog": "^2.0.0" + }, "scripts": { "bootstrap:frontend": "node ./scripts/bootstrap-frontend.mjs", "bootstrap:sidecar": "node ./scripts/bootstrap-sidecar.mjs", diff --git a/scripts/run-sidecar.mjs b/scripts/run-sidecar.mjs index 0b976f3..8fe12a1 100644 --- a/scripts/run-sidecar.mjs +++ b/scripts/run-sidecar.mjs @@ -39,15 +39,21 @@ const configDir = env.SDL_CONFIG_DIR ? resolve(rootDir, env.SDL_CONFIG_DIR) : resolve(rootDir, ".local", "sidecar-dev") +const host = env.SDL_SIDECAR_HOST || "127.0.0.1" +const port = env.SDL_SIDECAR_PORT || "5321" + +ensureFreshSidecarProcess({ port, configDir }) + const result = spawnSync( pythonPath, [ "-m", "sidecar.main", + "--reload", "--host", - env.SDL_SIDECAR_HOST || "127.0.0.1", + host, "--port", - env.SDL_SIDECAR_PORT || "5321", + port, "--config-dir", configDir, ], @@ -59,3 +65,85 @@ const result = spawnSync( ) process.exit(result.status ?? 1) + +function ensureFreshSidecarProcess({ port, configDir }) { + const currentPid = String(process.pid) + const listenerPids = findListeningPids(port) + + for (const pid of listenerPids) { + if (pid === currentPid) { + continue + } + + const command = readCommand(pid) + if (!command.includes("sidecar.main")) { + continue + } + + if (!command.includes(configDir)) { + continue + } + + terminatePid(pid) + } + + waitForPortRelease(port) +} + +function findListeningPids(port) { + if (process.platform === "win32") { + return [] + } + + const result = spawnSync("lsof", ["-tiTCP:" + port, "-sTCP:LISTEN"], { + cwd: rootDir, + encoding: "utf8", + }) + + if (result.status !== 0 && !result.stdout) { + return [] + } + + return result.stdout + .split(/\r?\n/) + .map(value => value.trim()) + .filter(Boolean) +} + +function readCommand(pid) { + if (process.platform === "win32") { + return "" + } + + const result = spawnSync("ps", ["-p", pid, "-o", "command="], { + cwd: rootDir, + encoding: "utf8", + }) + + return result.stdout.trim() +} + +function terminatePid(pid) { + if (process.platform === "win32") { + return + } + + spawnSync("kill", ["-TERM", pid], { + cwd: rootDir, + encoding: "utf8", + }) +} + +function waitForPortRelease(port) { + if (process.platform === "win32") { + return + } + + for (let attempt = 0; attempt < 20; attempt += 1) { + if (findListeningPids(port).length === 0) { + return + } + + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50) + } +} diff --git a/sidecar/api/models.py b/sidecar/api/models.py index 111bdea..5943da4 100644 --- a/sidecar/api/models.py +++ b/sidecar/api/models.py @@ -3,32 +3,36 @@ from datetime import datetime from typing import Literal -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_validator JobStatus = Literal["healthy", "warning", "error", "disabled", "pending", "running"] ConnectionState = Literal["not_configured", "configured", "connected", "error"] LogLevel = Literal["info", "warning", "error"] +AuthType = Literal["apikey", "userpass"] class ServerConfig(BaseModel): + auth_type: AuthType = "apikey" url: str = "" api_key: str = "" + username: str = "" + password: str = "" class FileConfig(BaseModel): - header_row: int = 3 - data_start_row: int = 4 - delimiter: str = "," - timestamp_column: str = "Timestamp" - timestamp_format: str = "%Y-%m-%d %H:%M:%S" - timezone: str = "America/Denver" + header_row: int = Field(default=3, ge=1) + data_start_row: int = Field(default=4, ge=1) + delimiter: str = Field(default=",", min_length=1, max_length=2) + timestamp_column: str = Field(default="Timestamp", min_length=1) + timestamp_format: str = Field(default="%Y-%m-%d %H:%M:%S", min_length=1) + timezone: str = Field(default="America/Denver", min_length=1) class ColumnMapping(BaseModel): - csv_column: str - datastream_id: str - datastream_name: str + csv_column: str = Field(min_length=1) + datastream_id: str = Field(min_length=1) + datastream_name: str = Field(min_length=1) class JobConfig(BaseModel): @@ -79,13 +83,43 @@ class HealthResponse(BaseModel): class ServerConfigUpdate(BaseModel): + auth_type: AuthType = "apikey" url: str api_key: str + username: str = "" + password: str = "" + + @model_validator(mode="after") + def validate_auth_fields(self) -> "ServerConfigUpdate": + if not self.url.strip(): + raise ValueError("Host URL is required.") + if self.auth_type == "apikey" and not self.api_key.strip(): + raise ValueError("API key is required.") + if self.auth_type == "userpass" and ( + not self.username.strip() or not self.password.strip() + ): + raise ValueError("Username and password are required.") + return self class ConnectionTestRequest(BaseModel): + auth_type: AuthType = "apikey" url: str api_key: str + username: str = "" + password: str = "" + + @model_validator(mode="after") + def validate_auth_fields(self) -> "ConnectionTestRequest": + if not self.url.strip(): + raise ValueError("Host URL is required.") + if self.auth_type == "apikey" and not self.api_key.strip(): + raise ValueError("API key is required.") + if self.auth_type == "userpass" and ( + not self.username.strip() or not self.password.strip() + ): + raise ValueError("Username and password are required.") + return self class ConnectionTestResponse(BaseModel): @@ -93,6 +127,9 @@ class ConnectionTestResponse(BaseModel): state: ConnectionState message: str instance_name: str | None = None + workspace_count: int = 0 + datastream_count: int = 0 + permissions_ok: bool = False class ActionResponse(BaseModel): @@ -125,9 +162,9 @@ class JobDetail(JobStatusSummary): class JobUpsertRequest(BaseModel): - name: str + name: str = Field(min_length=1) enabled: bool = True - file_path: str + file_path: str = Field(min_length=1) schedule_minutes: int = Field(default=15, ge=1) file_config: FileConfig = Field(default_factory=FileConfig) column_mappings: list[ColumnMapping] = Field(default_factory=list) diff --git a/sidecar/api/routes.py b/sidecar/api/routes.py index 05ba27f..da14c95 100644 --- a/sidecar/api/routes.py +++ b/sidecar/api/routes.py @@ -67,7 +67,7 @@ def health(runtime: AppRuntime = Depends(get_runtime)) -> HealthResponse: return HealthResponse( version=runtime.settings.version, config_dir=str(runtime.settings.config_dir), - server_configured=bool(config.server.url and config.server.api_key), + server_configured=_server_is_configured(config.server), connection=connection, ) @@ -94,6 +94,9 @@ def test_connection( state=result.state, # type: ignore[arg-type] message=result.message, instance_name=result.instance_name, + workspace_count=result.workspace_count, + datastream_count=result.datastream_count, + permissions_ok=result.permissions_ok, ) @app.get("/jobs", response_model=list[JobStatusSummary], tags=["jobs"]) @@ -246,11 +249,19 @@ def _derive_job_status(job: JobConfig, cursor: JobCursor, is_running: bool) -> t def _connection_status(server: ServerConfig) -> ConnectionStatus: - if not server.url.strip() or not server.api_key.strip(): + if not _server_is_configured(server): return ConnectionStatus(state="not_configured", message="HydroServer not configured") return ConnectionStatus(state="configured", message="HydroServer configured") +def _server_is_configured(server: ServerConfig) -> bool: + if not server.url.strip(): + return False + if server.auth_type == "userpass": + return bool(server.username.strip() and server.password.strip()) + return bool(server.api_key.strip()) + + def _simulate_job_run(runtime: AppRuntime, job: JobConfig) -> None: try: time.sleep(1.2) diff --git a/sidecar/core/config.py b/sidecar/core/config.py index d587f7b..6ad490e 100644 --- a/sidecar/core/config.py +++ b/sidecar/core/config.py @@ -36,7 +36,13 @@ def _write(self, config: AppConfig) -> None: def update_server(self, update: ServerConfigUpdate) -> AppConfig: config = self.load() - config.server = ServerConfig(url=update.url.strip(), api_key=update.api_key.strip()) + config.server = ServerConfig( + auth_type=update.auth_type, + url=update.url.strip(), + api_key=update.api_key.strip() if update.auth_type == "apikey" else "", + username=update.username.strip() if update.auth_type == "userpass" else "", + password=update.password.strip() if update.auth_type == "userpass" else "", + ) return self.save(config) def list_jobs(self) -> list[JobConfig]: diff --git a/sidecar/core/hydroserver.py b/sidecar/core/hydroserver.py index 76046a3..238b750 100644 --- a/sidecar/core/hydroserver.py +++ b/sidecar/core/hydroserver.py @@ -1,7 +1,6 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Iterable from urllib.parse import urlparse import requests @@ -20,26 +19,60 @@ class HydroServerCheck: message: str state: str instance_name: str | None = None + workspace_count: int = 0 + datastream_count: int = 0 + permissions_ok: bool = False class HydroServerService: def test_connection(self, server: ServerConfig) -> HydroServerCheck: - if not server.url.strip() or not server.api_key.strip(): + if not self._is_configured(server): return HydroServerCheck( ok=False, state="not_configured", - message="Enter both the HydroServer URL and API key.", + message="Enter the HydroServer URL and a valid set of credentials.", ) try: client = self._build_client(server) - client.datastreams.list(page_size=1) + workspaces = client.workspaces.list(page_size=25) + datastreams = client.datastreams.list(page_size=100) + client.orchestrationsystems.list(page_size=25) + + workspace_count = self._collection_count(workspaces) + datastream_count = self._collection_count(datastreams) + + if workspace_count == 0: + return HydroServerCheck( + ok=False, + state="error", + message="This API key is not attached to any accessible workspace. Check the key permissions and try again.", + instance_name=self._instance_name(server.url), + workspace_count=workspace_count, + datastream_count=datastream_count, + permissions_ok=False, + ) + + if datastream_count == 0: + return HydroServerCheck( + ok=False, + state="error", + message="No datastreams are available to this API key. Create a datastream in HydroServer or update the key permissions, then try again.", + instance_name=self._instance_name(server.url), + workspace_count=workspace_count, + datastream_count=datastream_count, + permissions_ok=False, + ) + instance_name = self._instance_name(server.url) return HydroServerCheck( ok=True, state="connected", - message=f"Connected to {instance_name}.", + message=f"Connected to {instance_name}. {datastream_count} datastreams are available for mapping.", instance_name=instance_name, + workspace_count=workspace_count, + datastream_count=datastream_count, + permissions_ok=True, ) except requests.ConnectionError: return HydroServerCheck( @@ -53,7 +86,7 @@ def test_connection(self, server: ServerConfig) -> HydroServerCheck: return HydroServerCheck( ok=False, state="error", - message="Invalid API key. Double-check the key in your HydroServer account settings.", + message="These credentials are invalid or do not have the permissions the loader needs. Make sure they can access workspaces, datastreams, and orchestration systems.", ) return HydroServerCheck( ok=False, @@ -68,18 +101,31 @@ def test_connection(self, server: ServerConfig) -> HydroServerCheck: ) def list_datastreams(self, server: ServerConfig) -> list[DatastreamSummary]: - if not server.url.strip() or not server.api_key.strip(): + if not self._is_configured(server): return [] client = self._build_client(server) datastreams = client.datastreams.list(page_size=100) - return [self._to_summary(item) for item in datastreams] + return [self._to_summary(item) for item in self._collection_items(datastreams)] def _build_client(self, server: ServerConfig): if HydroServer is None: raise RuntimeError("hydroserverpy is not installed.") + if server.auth_type == "userpass": + return HydroServer( + host=server.url, + email=server.username, + password=server.password, + ) return HydroServer(host=server.url, apikey=server.api_key) + def _is_configured(self, server: ServerConfig) -> bool: + if not server.url.strip(): + return False + if server.auth_type == "userpass": + return bool(server.username.strip() and server.password.strip()) + return bool(server.api_key.strip()) + def _to_summary(self, item: object) -> DatastreamSummary: datastream_id = getattr(item, "uid", None) or getattr(item, "id", "") name = getattr(item, "name", "Unnamed datastream") @@ -88,3 +134,15 @@ def _to_summary(self, item: object) -> DatastreamSummary: def _instance_name(self, url: str) -> str: parsed = urlparse(url) return parsed.netloc or url + + def _collection_count(self, collection: object) -> int: + total_count = getattr(collection, "total_count", None) + if isinstance(total_count, int): + return total_count + return len(self._collection_items(collection)) + + def _collection_items(self, collection: object) -> list[object]: + items = getattr(collection, "items", None) + if isinstance(items, list): + return items + return [] diff --git a/sidecar/main.py b/sidecar/main.py index 8202705..5f518b9 100644 --- a/sidecar/main.py +++ b/sidecar/main.py @@ -24,6 +24,11 @@ def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser() parser.add_argument("--host", default=os.environ.get("SDL_SIDECAR_HOST", "127.0.0.1")) parser.add_argument("--port", type=int, default=int(os.environ.get("SDL_SIDECAR_PORT", "8765"))) + parser.add_argument( + "--reload", + action="store_true", + default=os.environ.get("SDL_SIDECAR_RELOAD", "").lower() in {"1", "true", "yes"}, + ) parser.add_argument( "--config-dir", default=os.environ.get("SDL_CONFIG_DIR", str(default_config_dir())), @@ -49,10 +54,27 @@ def build_runtime() -> AppRuntime: def run() -> None: logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") + args = parse_args() + + if args.reload: + uvicorn.run( + "sidecar.main:create_dev_app", + host=args.host, + port=args.port, + reload=True, + factory=True, + ) + return + runtime = build_runtime() app = create_app(runtime) uvicorn.run(app, host=runtime.settings.host, port=runtime.settings.port, reload=False) +def create_dev_app(): + runtime = build_runtime() + return create_app(runtime) + + if __name__ == "__main__": run() diff --git a/src/lib.rs b/src/lib.rs index 780311d..0c31898 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,11 +1,9 @@ use std::{ collections::HashMap, fs, - net::{TcpStream, ToSocketAddrs}, path::{Path, PathBuf}, process::{Child, Command, Stdio}, sync::Mutex, - time::Duration, }; struct SidecarState(Mutex>); @@ -13,6 +11,7 @@ struct SidecarState(Mutex>); #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() + .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_opener::init()) .manage(SidecarState(Mutex::new(None))) .setup(|app| { @@ -38,19 +37,6 @@ fn start_sidecar(app: &mut tauri::App) -> Result<(), Box> } let env_vars = read_env_file(&workspace_root().join(".env.development"))?; - let host = env_vars - .get("SDL_SIDECAR_HOST") - .cloned() - .unwrap_or_else(|| "127.0.0.1".to_string()); - let port = env_vars - .get("SDL_SIDECAR_PORT") - .and_then(|value| value.parse::().ok()) - .unwrap_or(5321); - - if port_is_in_use(&host, port) { - return Ok(()); - } - let child = Command::new("node") .arg("./scripts/run-sidecar.mjs") .current_dir(workspace_root()) @@ -91,18 +77,6 @@ fn read_env_file(path: &Path) -> Result, Box bool { - let address = format!("{host}:{port}"); - address - .to_socket_addrs() - .map(|addresses| { - addresses.into_iter().any(|socket| { - TcpStream::connect_timeout(&socket, Duration::from_millis(200)).is_ok() - }) - }) - .unwrap_or(false) -} - fn setup_tray(app: &mut tauri::App) -> Result<(), Box> { use tauri::{ image::Image, From 2abc0d92014675846eee7234b934373979214143 Mon Sep 17 00:00:00 2001 From: Daniel Slaugh Date: Thu, 2 Apr 2026 15:37:49 -0600 Subject: [PATCH 006/166] Update login style --- frontend/main.ts | 13 +++++++------ frontend/styles.css | 10 +++++++++- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/frontend/main.ts b/frontend/main.ts index dcfdf72..58878bb 100644 --- a/frontend/main.ts +++ b/frontend/main.ts @@ -319,12 +319,16 @@ function renderAuthInputField(params: { value: string placeholder: string helpText?: string + labelAction?: string }): string { - const { label, name, type, value, placeholder, helpText } = params + const { label, name, type, value, placeholder, helpText, labelAction } = params return ` - ` + `; } -function fieldFormFeedbackTarget(formId: string): "welcomeFeedback" | "settingsFeedback" { - return formId === "welcome-form" ? "welcomeFeedback" : "settingsFeedback" +function fieldFormFeedbackTarget( + formId: string +): "welcomeFeedback" | "settingsFeedback" { + return formId === "welcome-form" ? "welcomeFeedback" : "settingsFeedback"; } function clearAuthFormFeedback(formId: string): void { - state[fieldFormFeedbackTarget(formId)] = null + state[fieldFormFeedbackTarget(formId)] = null; } function clearAuthValidationCache(): void { - state.lastAuthValidationServer = null - state.lastAuthValidationResult = null + state.lastAuthValidationServer = null; + state.lastAuthValidationResult = null; } function setAuthFieldLoading(server: ServerConfig): void { - markField("url", "checking") + markField("url", "checking"); for (const field of credentialFields(server.auth_type)) { - markField(field, "checking") + markField(field, "checking"); } } function isValidHttpUrl(value: string): boolean { try { - const parsed = new URL(value) - return parsed.protocol === "http:" || parsed.protocol === "https:" + const parsed = new URL(value); + return parsed.protocol === "http:" || parsed.protocol === "https:"; } catch { - return false + return false; } } -function applyConnectionValidationResult(server: ServerConfig, result: ConnectionTestResponse): void { - markField("url", "valid") +function applyConnectionValidationResult( + server: ServerConfig, + result: ConnectionTestResponse +): void { + markField("url", "valid"); if (result.ok) { for (const field of credentialFields(server.auth_type)) { - markField(field, "valid") + markField(field, "valid"); } - return + return; } - const message = result.message + const message = result.message; const isUrlError = result.message.includes("Couldn't reach HydroServer") || - result.message.includes("HydroServer returned an error") + result.message.includes("HydroServer returned an error"); if (isUrlError) { - markField("url", "invalid", message) + markField("url", "invalid", message); for (const field of credentialFields(server.auth_type)) { - markField(field, "idle") + markField(field, "idle"); } - return + return; } for (const field of credentialFields(server.auth_type)) { - markField(field, "invalid", message) + markField(field, "invalid", message); } } -async function validateAuthField(form: HTMLFormElement, field: AuthFieldName): Promise { - const server = readServerConfigForm(form) - const requestId = ++authValidationRequestId - setServerDraft(server) +async function validateAuthField( + form: HTMLFormElement, + field: AuthFieldName +): Promise { + const server = readServerConfigForm(form); + const requestId = ++authValidationRequestId; + setServerDraft(server); if (field === "url") { if (!server.url) { - markField("url", "invalid", "Enter the HydroServer URL.") - render() - return + markField("url", "invalid", "Enter the HydroServer URL."); + render(); + return; } if (!isValidHttpUrl(server.url)) { - markField("url", "invalid", "Enter a full http:// or https:// URL.") - render() - return + markField("url", "invalid", "Enter a full http:// or https:// URL."); + render(); + return; } } if (field === "api_key" && server.auth_type === "apikey" && !server.api_key) { - markField("api_key", "invalid", "Enter the API key.") - render() - return + markField("api_key", "invalid", "Enter the API key."); + render(); + return; } - if (field === "username" && server.auth_type === "userpass" && !server.username) { - markField("username", "invalid", "Enter the username.") - render() - return + if ( + field === "username" && + server.auth_type === "userpass" && + !server.username + ) { + markField("username", "invalid", "Enter the username."); + render(); + return; } - if (field === "password" && server.auth_type === "userpass" && !server.password) { - markField("password", "invalid", "Enter the password.") - render() - return + if ( + field === "password" && + server.auth_type === "userpass" && + !server.password + ) { + markField("password", "invalid", "Enter the password."); + render(); + return; } if (field === "url") { - markField("url", "valid") + markField("url", "valid"); } else { - markField(field, "checking") + markField(field, "checking"); } const requiredFieldsReady = server.auth_type === "apikey" ? Boolean(server.url && isValidHttpUrl(server.url) && server.api_key) - : Boolean(server.url && isValidHttpUrl(server.url) && server.username && server.password) + : Boolean( + server.url && + isValidHttpUrl(server.url) && + server.username && + server.password + ); if (!requiredFieldsReady) { - render() - return + render(); + return; } for (const name of credentialFields(server.auth_type)) { - markField(name, "checking") + markField(name, "checking"); } - markField("url", "checking") - render() + markField("url", "checking"); + render(); try { - const result = await testConnection(server) + const result = await testConnection(server); if (requestId !== authValidationRequestId) { - return + return; } - state.lastAuthValidationServer = server - state.lastAuthValidationResult = result - applyConnectionValidationResult(server, result) + state.lastAuthValidationServer = server; + state.lastAuthValidationResult = result; + applyConnectionValidationResult(server, result); } catch (error) { if (requestId !== authValidationRequestId) { - return + return; } - clearAuthValidationCache() + clearAuthValidationCache(); const message = - error instanceof Error ? error.message : "Couldn't test the HydroServer connection." + error instanceof Error + ? error.message + : "Couldn't test the HydroServer connection."; const isUrlError = message.includes("Request failed with status 500") || message.includes("Failed to fetch") || - message.includes("Couldn't test the HydroServer connection.") + message.includes("Couldn't test the HydroServer connection."); if (isUrlError) { - markField("url", "invalid", message) + markField("url", "invalid", message); for (const name of credentialFields(server.auth_type)) { - markField(name, "idle") + markField(name, "idle"); } } else { - markField("url", "valid") + markField("url", "valid"); for (const name of credentialFields(server.auth_type)) { - markField(name, "invalid", message) + markField(name, "invalid", message); } } } - render() + render(); } function previewHeaders(): string[] { - return state.pipelinePreview?.parsed_rows[0] ?? [] + return state.pipelinePreview?.parsed_rows[0] ?? []; } function pipelineMappingsByColumn(): Map { - return new Map(state.pipelineForm.mappings.map(mapping => [mapping.csvColumn, mapping.datastreamId])) + return new Map( + state.pipelineForm.mappings.map((mapping) => [ + mapping.csvColumn, + mapping.datastreamId, + ]) + ); } function previewColumnClass(columnName: string): string { if (columnName === state.pipelineForm.timestampColumn) { - return "preview-col-timestamp" + return "preview-col-timestamp"; } const mapped = state.pipelineForm.mappings.find( - mapping => mapping.csvColumn === columnName && mapping.datastreamId - ) - return mapped ? "preview-col-mapped" : "" + (mapping) => mapping.csvColumn === columnName && mapping.datastreamId + ); + return mapped ? "preview-col-mapped" : ""; } function initializeMappings(headers: string[]): void { - const existing = pipelineMappingsByColumn() + const existing = pipelineMappingsByColumn(); state.pipelineForm.mappings = headers - .filter(header => header !== state.pipelineForm.timestampColumn) - .map(header => ({ + .filter((header) => header !== state.pipelineForm.timestampColumn) + .map((header) => ({ csvColumn: header, datastreamId: existing.get(header) ?? "", - })) + })); } function applyPreview(path: string, preview: CsvPreviewResponse): void { - state.pipelinePreview = preview - state.pipelineForm.filePath = path - state.pipelineForm.headerRow = preview.detected_header_row ?? state.pipelineForm.headerRow - state.pipelineForm.dataStartRow = preview.detected_data_start_row ?? state.pipelineForm.dataStartRow - state.pipelineForm.delimiter = preview.detected_delimiter || state.pipelineForm.delimiter - - const headers = preview.parsed_rows[0] ?? [] + state.pipelinePreview = preview; + state.pipelineForm.filePath = path; + state.pipelineForm.headerRow = + preview.detected_header_row ?? state.pipelineForm.headerRow; + state.pipelineForm.dataStartRow = + preview.detected_data_start_row ?? state.pipelineForm.dataStartRow; + state.pipelineForm.delimiter = + preview.detected_delimiter || state.pipelineForm.delimiter; + + const headers = preview.parsed_rows[0] ?? []; if (headers.length > 0) { const preferredTimestamp = - headers.find(header => header.toLowerCase().includes("time")) ?? headers[0] - state.pipelineForm.timestampColumn = headers.includes(state.pipelineForm.timestampColumn) + headers.find((header) => header.toLowerCase().includes("time")) ?? + headers[0]; + state.pipelineForm.timestampColumn = headers.includes( + state.pipelineForm.timestampColumn + ) ? state.pipelineForm.timestampColumn - : preferredTimestamp + : preferredTimestamp; } if (!state.pipelineForm.name.trim()) { - const inferred = basename(path).replace(/\.[^.]+$/, "") - state.pipelineForm.name = inferred + const inferred = basename(path).replace(/\.[^.]+$/, ""); + state.pipelineForm.name = inferred; } - initializeMappings(headers) + initializeMappings(headers); } function connectionIndicator(): { label: string; className: string } { if (!serverConfigured(state.config?.server)) { - return { label: "HydroServer not configured", className: "status-dot bg-slate-300" } + return { + label: "HydroServer not configured", + className: "status-dot bg-slate-300", + }; } if (connected()) { - return { label: "Connected to HydroServer", className: "status-dot bg-emerald-500" } + return { + label: "Connected to HydroServer", + className: "status-dot bg-emerald-500", + }; } if (state.lastConnectionState === "error") { - return { label: "HydroServer authentication error", className: "status-dot bg-rose-500" } + return { + label: "HydroServer authentication error", + className: "status-dot bg-rose-500", + }; } - return { label: "HydroServer configured", className: "status-dot bg-sky-500" } + return { + label: "HydroServer configured", + className: "status-dot bg-sky-500", + }; } function statusPill(job: JobSummary): string { @@ -571,20 +639,22 @@ function statusPill(job: JobSummary): string { disabled: "pill-muted", pending: "pill-info", running: "pill-info", - } + }; - return `${escapeHtml(job.status_message)}` + return `${escapeHtml( + job.status_message + )}`; } function renderConnectedCard(showActions: boolean): string { if (!connected() || !state.connectionSummary) { - return "" + return ""; } const datastreamText = state.connectionSummary.datastream_count === 1 ? "1 datastream available" - : `${state.connectionSummary.datastream_count} datastreams available` + : `${state.connectionSummary.datastream_count} datastreams available`; return `
@@ -593,7 +663,9 @@ function renderConnectedCard(showActions: boolean): string {

${escapeHtml( state.connectionSummary.instance_name ?? "HydroServer" )}

-

${escapeHtml(state.connectionSummary.message)}

+

${escapeHtml( + state.connectionSummary.message + )}

Connected ${escapeHtml(datastreamText)} @@ -603,10 +675,13 @@ function renderConnectedCard(showActions: boolean): string { showActions ? `
+ ${ state.jobs.length === 0 - ? `Create first pipeline` + ? `Create first pipeline` : "" }
@@ -614,7 +689,7 @@ function renderConnectedCard(showActions: boolean): string { : "" }
- ` + `; } function renderAuthForm( @@ -623,20 +698,20 @@ function renderAuthForm( submitLabel: string, secondaryAction: string ): string { - const server = currentServerConfig() - const usingUserPass = server.auth_type === "userpass" + const server = currentServerConfig(); + const usingUserPass = server.auth_type === "userpass"; const authToggleLabel = usingUserPass ? "Connect with an API key" - : "Connect with username and password" - const submitDisabled = state.authSubmitting ? "disabled" : "" - const submitLabelText = state.authSubmitting ? "Connecting..." : submitLabel + : "Connect with username and password"; + const submitDisabled = state.authSubmitting ? "disabled" : ""; + const submitLabelText = state.authSubmitting ? "Connecting..." : submitLabel; return `
HydroServer Streaming Data Loader icon -

Connect to your HydroServer instance

+

Connect to HydroServer

${feedbackMarkup(feedback)} @@ -674,7 +749,8 @@ function renderAuthForm( name: "api_key", type: "password", value: server.api_key, - placeholder: "KaTz74swGqHn__I2VY6ceIzrIxC04oDhUrLLgBTH9ACxYIunmkrdmqk", + placeholder: + "KaTz74swGqHn__I2VY6ceIzrIxC04oDhUrLLgBTH9ACxYIunmkrdmqk", labelAction: `How to create an API key →`, })} ` @@ -690,23 +766,30 @@ function renderAuthForm(
${secondaryAction} - +
- ` + `; } function renderWelcome(): string { return `
- ${renderAuthForm("welcome-form", state.welcomeFeedback, "Connect to HydroServer", "")} + ${renderAuthForm( + "welcome-form", + state.welcomeFeedback, + "Connect to HydroServer", + "" + )}
- ` + `; } function renderSettings(): string { - const showForm = !connected() || state.settingsEditMode + const showForm = !connected() || state.settingsEditMode; return `
@@ -718,9 +801,20 @@ function renderSettings(): string {
- ${showForm ? renderAuthForm("settings-form", state.settingsFeedback, "Save and verify", connected() ? '' : "") : renderConnectedCard(true)} + ${ + showForm + ? renderAuthForm( + "settings-form", + state.settingsFeedback, + "Save and verify", + connected() + ? '' + : "" + ) + : renderConnectedCard(true) + } - ` + `; } function renderDashboard(): string { @@ -733,45 +827,67 @@ function renderDashboard(): string {

Jobs

Finish the onboarding flow by creating your first pipeline. ${APP_NAME} will use that saved local configuration from then on.

- Create first pipeline + Create first pipeline - ` + `; } const cards = state.jobs - .map(job => { + .map((job) => { const lastLine = job.last_error ? `Failed ${formatRelativeTime(job.last_run_at)}` - : `Last pushed ${formatRelativeTime(job.last_pushed_timestamp)}` + : `Last pushed ${formatRelativeTime(job.last_pushed_timestamp)}`; return `
- +

${escapeHtml(job.name)}

-

${escapeHtml(shortenPath(job.file_path))}

-

- ${escapeHtml(lastLine)} · ${escapeHtml(formatSchedule(job.schedule_minutes))} +

${escapeHtml( + shortenPath(job.file_path) + )}

+

+ ${escapeHtml(lastLine)} · ${escapeHtml( + formatSchedule(job.schedule_minutes) + )}

${statusPill(job)}
- - + - +
- ` + `; }) - .join("") + .join(""); return `
@@ -785,7 +901,7 @@ function renderDashboard(): string {
${cards}
- ` + `; } function renderPipelinePreview(): string { @@ -798,63 +914,75 @@ function renderPipelinePreview(): string {

Choose a CSV file path, then load the preview to detect headers and map source columns to HydroServer datastreams.

- ` + `; } - const headers = previewHeaders() - const parsedRows = state.pipelinePreview.parsed_rows.slice(1, 7) + const headers = previewHeaders(); + const parsedRows = state.pipelinePreview.parsed_rows.slice(1, 7); const rawRows = state.pipelinePreview.raw_lines .map((line, index) => { - const lineNumber = index + 1 + const lineNumber = index + 1; const rowClass = lineNumber === state.pipelineForm.headerRow ? "preview-raw-line preview-raw-line-header" : lineNumber === state.pipelineForm.dataStartRow - ? "preview-raw-line preview-raw-line-data" - : "preview-raw-line" + ? "preview-raw-line preview-raw-line-data" + : "preview-raw-line"; return `
${lineNumber} ${escapeHtml(line)}
- ` + `; }) - .join("") + .join(""); const headerCells = headers .map( - header => - `${escapeHtml(header)}` + (header) => + `${escapeHtml( + header + )}` ) - .join("") + .join(""); const tableRows = parsedRows .map( - row => ` + (row) => ` ${row .map((cell, index) => { - const columnName = headers[index] ?? "" - return `${escapeHtml(cell)}` + const columnName = headers[index] ?? ""; + return `${escapeHtml(cell)}`; }) .join("")} ` ) - .join("") + .join(""); return `

Preview

-

${escapeHtml(basename(state.pipelineForm.filePath))}

+

${escapeHtml( + basename(state.pipelineForm.filePath) + )}

- Header row ${state.pipelineForm.headerRow} - Data starts ${state.pipelineForm.dataStartRow} - ${escapeHtml(state.pipelinePreview.encoding)} + Header row ${ + state.pipelineForm.headerRow + } + Data starts ${ + state.pipelineForm.dataStartRow + } + ${escapeHtml( + state.pipelinePreview.encoding + )}
@@ -872,14 +1000,17 @@ function renderPipelinePreview(): string {
- Showing the first ${Math.min(state.pipelinePreview.total_lines, state.pipelinePreview.raw_lines.length)} of ${state.pipelinePreview.total_lines} lines + Showing the first ${Math.min( + state.pipelinePreview.total_lines, + state.pipelinePreview.raw_lines.length + )} of ${state.pipelinePreview.total_lines} lines
- ` + `; } function renderPipelineMappings(): string { - const availableMappings = state.pipelineForm.mappings + const availableMappings = state.pipelineForm.mappings; if (!state.pipelinePreview || availableMappings.length === 0) { return ` @@ -887,20 +1018,20 @@ function renderPipelineMappings(): string {

Column mappings

Load a CSV preview first so HydroServer Streaming Data Loader can list the available source columns.

- ` + `; } const rows = availableMappings - .map(mapping => { + .map((mapping) => { const options = [ ``, ...state.datastreams.map( - datastream => + (datastream) => `` ), - ].join("") + ].join(""); return `
@@ -908,13 +1039,15 @@ function renderPipelineMappings(): string {

${escapeHtml(mapping.csvColumn)}

Source column

- ${options} - ` + `; }) - .join("") + .join(""); return `
@@ -922,12 +1055,12 @@ function renderPipelineMappings(): string {

Map each source column to a HydroServer datastream. Leave any unused source columns as “Not mapped.”

${rows}
- ` + `; } function renderPipelineEditor(): string { if (!connected()) { - return renderWelcome() + return renderWelcome(); } if (state.datastreamsError) { @@ -944,7 +1077,7 @@ function renderPipelineEditor(): string { ${renderConnectedCard(true)}
${escapeHtml(state.datastreamsError)}
- ` + `; } if (state.datastreams.length === 0) { @@ -963,17 +1096,17 @@ function renderPipelineEditor(): string { Open the HydroServer 101 tutorial - ` + `; } const timestampOptions = previewHeaders() .map( - header => + (header) => `` ) - .join("") + .join(""); const pipelineErrorMarkup = state.pipelineErrors.length > 0 @@ -981,11 +1114,13 @@ function renderPipelineEditor(): string {

Fix these issues before saving

    - ${state.pipelineErrors.map(error => `
  • ${escapeHtml(error)}
  • `).join("")} + ${state.pipelineErrors + .map((error) => `
  • ${escapeHtml(error)}
  • `) + .join("")}
` - : "" + : ""; return `
@@ -1029,10 +1164,15 @@ function renderPipelineEditor(): string { @@ -1045,12 +1185,16 @@ function renderPipelineEditor(): string {
@@ -1101,7 +1245,7 @@ function renderPipelineEditor(): string { ${renderPipelinePreview()}
- ` + `; } function renderFatalError(): string { @@ -1110,37 +1254,50 @@ function renderFatalError(): string {

Sidecar error

The background process is unavailable

-

${escapeHtml(state.bootstrapError ?? `${APP_NAME} could not reach the local background service.`)}

+

${escapeHtml( + state.bootstrapError ?? + `${APP_NAME} could not reach the local background service.` + )}

- ` + `; } function render(): void { - state.route = getRouteFromHash() + state.route = getRouteFromHash(); - let currentRoute = getRouteFromHash() + let currentRoute = getRouteFromHash(); if (!state.loading && !state.bootstrapError) { - if (!connected() && currentRoute !== "settings" && currentRoute !== "welcome") { - navigate("welcome") - currentRoute = "welcome" - } else if (connected() && state.jobs.length === 0 && (currentRoute === "dashboard" || currentRoute === "welcome")) { - navigate("jobs-new") - currentRoute = "jobs-new" + if ( + !connected() && + currentRoute !== "settings" && + currentRoute !== "welcome" + ) { + navigate("welcome"); + currentRoute = "welcome"; + } else if ( + connected() && + state.jobs.length === 0 && + (currentRoute === "dashboard" || currentRoute === "welcome") + ) { + navigate("jobs-new"); + currentRoute = "jobs-new"; } } - const showSidebar = currentRoute !== "welcome" && !state.bootstrapError - sidebar.classList.toggle("hidden", !showSidebar) + const showSidebar = currentRoute !== "welcome" && !state.bootstrapError; + sidebar.classList.toggle("hidden", !showSidebar); - jobsLink.className = currentRoute === "dashboard" ? "nav-item nav-item-active" : "nav-item" - settingsLink.className = currentRoute === "settings" ? "nav-item nav-item-active" : "nav-item" + jobsLink.className = + currentRoute === "dashboard" ? "nav-item nav-item-active" : "nav-item"; + settingsLink.className = + currentRoute === "settings" ? "nav-item nav-item-active" : "nav-item"; - const status = connectionIndicator() - connectionDot.className = status.className - connectionDot.title = status.label + const status = connectionIndicator(); + connectionDot.className = status.className; + connectionDot.title = status.label; if (state.loading) { mainContent.innerHTML = ` @@ -1151,43 +1308,43 @@ function render(): void {

Connecting ${APP_NAME} to its local background service and validating your HydroServer configuration.

- ` - return + `; + return; } if (state.bootstrapError) { - mainContent.innerHTML = renderFatalError() - return + mainContent.innerHTML = renderFatalError(); + return; } if (currentRoute === "settings") { - mainContent.innerHTML = renderSettings() - return + mainContent.innerHTML = renderSettings(); + return; } if (currentRoute === "welcome") { - mainContent.innerHTML = renderWelcome() - return + mainContent.innerHTML = renderWelcome(); + return; } if (currentRoute === "jobs-new") { - mainContent.innerHTML = renderPipelineEditor() - return + mainContent.innerHTML = renderPipelineEditor(); + return; } - mainContent.innerHTML = renderDashboard() + mainContent.innerHTML = renderDashboard(); } function sleep(ms: number): Promise { - return new Promise(resolve => window.setTimeout(resolve, ms)) + return new Promise((resolve) => window.setTimeout(resolve, ms)); } function isTransientBootstrapError(error: unknown): boolean { if (!(error instanceof Error)) { - return false + return false; } - const message = error.message.toLowerCase() + const message = error.message.toLowerCase(); return ( message.includes("failed to fetch") || message.includes("networkerror") || @@ -1195,100 +1352,112 @@ function isTransientBootstrapError(error: unknown): boolean { message.includes("status 502") || message.includes("status 503") || message.includes("status 504") - ) + ); } async function loadInitialStateWithRetry(): Promise<{ - health: HealthResponse - config: AppConfig - jobs: JobSummary[] + health: HealthResponse; + config: AppConfig; + jobs: JobSummary[]; }> { - let lastError: unknown = null + let lastError: unknown = null; for (let attempt = 1; attempt <= STARTUP_RETRY_ATTEMPTS; attempt += 1) { try { - const [health, config, jobs] = await Promise.all([getHealth(), getConfig(), listJobs()]) - return { health, config, jobs } + const [health, config, jobs] = await Promise.all([ + getHealth(), + getConfig(), + listJobs(), + ]); + return { health, config, jobs }; } catch (error) { - lastError = error + lastError = error; - if (attempt === STARTUP_RETRY_ATTEMPTS || !isTransientBootstrapError(error)) { - throw error + if ( + attempt === STARTUP_RETRY_ATTEMPTS || + !isTransientBootstrapError(error) + ) { + throw error; } - await sleep(STARTUP_RETRY_DELAY_MS) + await sleep(STARTUP_RETRY_DELAY_MS); } } - throw lastError instanceof Error ? lastError : new Error(`Failed to load ${APP_NAME}.`) + throw lastError instanceof Error + ? lastError + : new Error(`Failed to load ${APP_NAME}.`); } async function syncAuthenticationStatus( server: ServerConfig, context: "bootstrap" | "welcome" | "settings" ): Promise { - const result = await testConnection(server) - state.lastAuthValidationServer = server - state.lastAuthValidationResult = result - state.connectionSummary = result - state.lastConnectionState = result.state + const result = await testConnection(server); + state.lastAuthValidationServer = server; + state.lastAuthValidationResult = result; + state.connectionSummary = result; + state.lastConnectionState = result.state; if (result.ok) { - await loadDatastreams() + await loadDatastreams(); } else { - state.datastreams = [] - state.datastreamsError = null + state.datastreams = []; + state.datastreamsError = null; } if (context === "bootstrap" && !result.ok) { - state.welcomeFeedback = { tone: "error", message: result.message } + state.welcomeFeedback = { tone: "error", message: result.message }; } - return result + return result; } async function loadDatastreams(): Promise { try { - state.datastreams = await getDatastreams() - state.datastreamsError = null + state.datastreams = await getDatastreams(); + state.datastreamsError = null; } catch (error) { - state.datastreams = [] + state.datastreams = []; state.datastreamsError = - error instanceof Error ? error.message : "Couldn't load HydroServer datastreams." + error instanceof Error + ? error.message + : "Couldn't load HydroServer datastreams."; } } async function bootstrap(): Promise { - state.loading = true - state.bootstrapError = null - render() + state.loading = true; + state.bootstrapError = null; + render(); try { - const { health, config, jobs } = await loadInitialStateWithRetry() - state.health = health - state.config = config - state.jobs = jobs - state.lastConnectionState = health.connection.state + const { health, config, jobs } = await loadInitialStateWithRetry(); + state.health = health; + state.config = config; + state.jobs = jobs; + state.lastConnectionState = health.connection.state; if (serverConfigured(config.server)) { - await syncAuthenticationStatus(config.server, "bootstrap") + await syncAuthenticationStatus(config.server, "bootstrap"); } } catch (error) { - state.bootstrapError = error instanceof Error ? error.message : `Failed to load ${APP_NAME}.` + state.bootstrapError = + error instanceof Error ? error.message : `Failed to load ${APP_NAME}.`; } finally { - state.loading = false - render() + state.loading = false; + render(); } } async function refreshJobs(): Promise { if (state.bootstrapError || state.loading) { - return + return; } try { - state.jobs = await listJobs() - render() + state.jobs = await listJobs(); + render(); } catch { // Keep existing UI state on polling failure. } @@ -1297,145 +1466,165 @@ async function refreshJobs(): Promise { function updatePipelineField(name: string, value: string): void { switch (name) { case "pipeline_name": - state.pipelineForm.name = value - break + state.pipelineForm.name = value; + break; case "file_path": - state.pipelineForm.filePath = value - break + state.pipelineForm.filePath = value; + break; case "schedule_minutes": - state.pipelineForm.scheduleMinutes = Number(value) || 15 - break + state.pipelineForm.scheduleMinutes = Number(value) || 15; + break; case "header_row": - state.pipelineForm.headerRow = Number(value) || 1 - break + state.pipelineForm.headerRow = Number(value) || 1; + break; case "data_start_row": - state.pipelineForm.dataStartRow = Number(value) || 1 - break + state.pipelineForm.dataStartRow = Number(value) || 1; + break; case "delimiter": - state.pipelineForm.delimiter = value || "," - break + state.pipelineForm.delimiter = value || ","; + break; case "timestamp_column": - state.pipelineForm.timestampColumn = value - initializeMappings(previewHeaders()) - render() - break + state.pipelineForm.timestampColumn = value; + initializeMappings(previewHeaders()); + render(); + break; case "timestamp_format": - state.pipelineForm.timestampFormat = value - break + state.pipelineForm.timestampFormat = value; + break; case "timezone": - state.pipelineForm.timezone = value - break + state.pipelineForm.timezone = value; + break; default: - break + break; } } function validatePipeline(): string[] { - const errors: string[] = [] - const headers = previewHeaders() - const selectedMappings = state.pipelineForm.mappings.filter(mapping => mapping.datastreamId) - const datastreamIds = new Set(state.datastreams.map(datastream => datastream.id)) - const seenTargets = new Set() + const errors: string[] = []; + const headers = previewHeaders(); + const selectedMappings = state.pipelineForm.mappings.filter( + (mapping) => mapping.datastreamId + ); + const datastreamIds = new Set( + state.datastreams.map((datastream) => datastream.id) + ); + const seenTargets = new Set(); if (!connected()) { - errors.push("Connect to HydroServer before saving a pipeline.") + errors.push("Connect to HydroServer before saving a pipeline."); } if (!state.pipelineForm.name.trim()) { - errors.push("Give the pipeline a name.") + errors.push("Give the pipeline a name."); } if (!state.pipelineForm.filePath.trim()) { - errors.push(`Choose the CSV file ${APP_NAME} should watch.`) + errors.push(`Choose the CSV file ${APP_NAME} should watch.`); } if (!state.pipelinePreview) { - errors.push("Load a CSV preview before saving the pipeline.") + errors.push("Load a CSV preview before saving the pipeline."); } if (state.pipelineForm.headerRow < 1) { - errors.push("Header row must be 1 or greater.") + errors.push("Header row must be 1 or greater."); } if (state.pipelineForm.dataStartRow <= state.pipelineForm.headerRow) { - errors.push("Data start row must come after the header row.") + errors.push("Data start row must come after the header row."); } - if (headers.length > 0 && !headers.includes(state.pipelineForm.timestampColumn)) { - errors.push("Choose a timestamp column that exists in the previewed CSV header.") + if ( + headers.length > 0 && + !headers.includes(state.pipelineForm.timestampColumn) + ) { + errors.push( + "Choose a timestamp column that exists in the previewed CSV header." + ); } if (selectedMappings.length === 0) { - errors.push("Map at least one source column to a HydroServer datastream.") + errors.push("Map at least one source column to a HydroServer datastream."); } for (const mapping of selectedMappings) { if (!datastreamIds.has(mapping.datastreamId)) { - errors.push(`The selected target for ${mapping.csvColumn} is not a valid HydroServer datastream.`) + errors.push( + `The selected target for ${mapping.csvColumn} is not a valid HydroServer datastream.` + ); } if (seenTargets.has(mapping.datastreamId)) { - errors.push("Each target datastream can only be mapped once in this first-run flow.") + errors.push( + "Each target datastream can only be mapped once in this first-run flow." + ); } - seenTargets.add(mapping.datastreamId) + seenTargets.add(mapping.datastreamId); } - return errors + return errors; } async function loadPipelinePreview(path: string): Promise { if (!path.trim()) { - state.pipelineFeedback = { tone: "error", message: "Enter or choose a CSV file path first." } - render() - return + state.pipelineFeedback = { + tone: "error", + message: "Enter or choose a CSV file path first.", + }; + render(); + return; } try { - const preview = await getCsvPreview(path.trim()) - applyPreview(path.trim(), preview) - state.pipelineErrors = [] + const preview = await getCsvPreview(path.trim()); + applyPreview(path.trim(), preview); + state.pipelineErrors = []; state.pipelineFeedback = { tone: "success", - message: "Preview loaded. Review the detected structure and map the source columns.", - } + message: + "Preview loaded. Review the detected structure and map the source columns.", + }; } catch (error) { - state.pipelinePreview = null + state.pipelinePreview = null; state.pipelineFeedback = { tone: "error", - message: error instanceof Error ? error.message : "Couldn't preview that CSV file.", - } + message: + error instanceof Error + ? error.message + : "Couldn't preview that CSV file.", + }; } - render() + render(); } async function browseForCsvPath(): Promise { try { - const dialog = await import("@tauri-apps/plugin-dialog") + const dialog = await import("@tauri-apps/plugin-dialog"); const selection = await dialog.open({ directory: false, multiple: false, filters: [{ name: "CSV files", extensions: ["csv", "txt"] }], - }) + }); if (typeof selection !== "string" || !selection) { - return + return; } - state.pipelineForm.filePath = selection + state.pipelineForm.filePath = selection; if (!state.pipelineForm.name.trim()) { - state.pipelineForm.name = basename(selection).replace(/\.[^.]+$/, "") + state.pipelineForm.name = basename(selection).replace(/\.[^.]+$/, ""); } - await loadPipelinePreview(selection) + await loadPipelinePreview(selection); } catch { state.pipelineFeedback = { tone: "info", message: "The native file picker is only available in the desktop app. Enter the CSV path manually if you're using the browser preview.", - } - render() + }; + render(); } } @@ -1444,91 +1633,121 @@ async function saveAuthenticatedServerConfig( context: "welcome" | "settings" ): Promise { if (state.authSubmitting) { - return + return; } - const payload = readServerConfigForm(form) - setServerDraft(payload) + const payload = readServerConfigForm(form); + setServerDraft(payload); - const feedbackKey = context === "welcome" ? "welcomeFeedback" : "settingsFeedback" + const feedbackKey = + context === "welcome" ? "welcomeFeedback" : "settingsFeedback"; const canReuseValidation = sameServerConfig(state.lastAuthValidationServer, payload) && - state.lastAuthValidationResult?.ok === true + state.lastAuthValidationResult?.ok === true; try { - state.authSubmitting = true - setAuthFieldLoading(payload) - render() + state.authSubmitting = true; + setAuthFieldLoading(payload); + render(); const result = canReuseValidation ? state.lastAuthValidationResult! - : await syncAuthenticationStatus(payload, context) + : await syncAuthenticationStatus(payload, context); if (canReuseValidation) { - state.connectionSummary = result - state.lastConnectionState = result.state - await loadDatastreams() + state.connectionSummary = result; + state.lastConnectionState = result.state; + await loadDatastreams(); } - applyConnectionValidationResult(payload, result) + applyConnectionValidationResult(payload, result); if (!result.ok) { - state[feedbackKey] = { tone: "error", message: result.message } - render() - return + state[feedbackKey] = { tone: "error", message: result.message }; + render(); + return; } - state.config = await updateServerConfig(payload) + state.config = await updateServerConfig(payload); state.authDraft = { ...emptyServerConfig(), ...state.config.server, - } - state[feedbackKey] = { tone: "success", message: result.message } - state.settingsEditMode = false + }; + state[feedbackKey] = { tone: "success", message: result.message }; + state.settingsEditMode = false; if (state.jobs.length === 0) { - navigate("jobs-new") + navigate("jobs-new"); } else { - navigate("dashboard") + navigate("dashboard"); } } catch (error) { - clearAuthValidationCache() + clearAuthValidationCache(); state[feedbackKey] = { tone: "error", message: error instanceof Error ? error.message : "Couldn't verify the HydroServer connection.", - } - state.lastConnectionState = "error" + }; + state.lastConnectionState = "error"; } finally { - state.authSubmitting = false + state.authSubmitting = false; + } + + render(); +} + +async function disconnectHydroServer(): Promise { + try { + state.config = await clearServerConfig(); + state.authDraft = emptyServerConfig(); + state.connectionSummary = null; + state.lastConnectionState = "not_configured"; + state.datastreams = []; + state.datastreamsError = null; + state.welcomeFeedback = null; + state.settingsFeedback = null; + state.settingsEditMode = false; + resetAuthFieldStates("apikey"); + clearAuthValidationCache(); + navigate("welcome"); + } catch (error) { + state.settingsFeedback = { + tone: "error", + message: + error instanceof Error + ? error.message + : "Couldn't disconnect from HydroServer right now.", + }; } - render() + render(); } async function savePipeline(): Promise { - state.pipelineErrors = validatePipeline() + state.pipelineErrors = validatePipeline(); if (state.pipelineErrors.length > 0) { state.pipelineFeedback = { tone: "error", message: `${APP_NAME} needs a little more information before it can save this pipeline.`, - } - render() - return + }; + render(); + return; } const mappedColumns = state.pipelineForm.mappings - .filter(mapping => mapping.datastreamId) - .map(mapping => { - const datastream = state.datastreams.find(item => item.id === mapping.datastreamId) + .filter((mapping) => mapping.datastreamId) + .map((mapping) => { + const datastream = state.datastreams.find( + (item) => item.id === mapping.datastreamId + ); return { csv_column: mapping.csvColumn, datastream_id: mapping.datastreamId, datastream_name: datastream?.name ?? mapping.datastreamId, - } - }) + }; + }); try { const created = await createJob({ @@ -1545,70 +1764,74 @@ async function savePipeline(): Promise { timezone: state.pipelineForm.timezone, }, column_mappings: mappedColumns, - }) - - state.jobs = [...state.jobs, created] - state.pipelineForm = createEmptyPipelineForm() - state.pipelinePreview = null - state.pipelineErrors = [] - state.pipelineFeedback = { tone: "success", message: "Pipeline saved." } - navigate("dashboard") + }); + + state.jobs = [...state.jobs, created]; + state.pipelineForm = createEmptyPipelineForm(); + state.pipelinePreview = null; + state.pipelineErrors = []; + state.pipelineFeedback = { tone: "success", message: "Pipeline saved." }; + navigate("dashboard"); } catch (error) { state.pipelineFeedback = { tone: "error", - message: error instanceof Error ? error.message : "Couldn't save that pipeline.", - } + message: + error instanceof Error ? error.message : "Couldn't save that pipeline.", + }; } - render() + render(); } window.addEventListener("hashchange", () => { - state.settingsFeedback = null - render() -}) + state.settingsFeedback = null; + render(); +}); -mainContent.addEventListener("submit", event => { - const target = event.target +mainContent.addEventListener("submit", (event) => { + const target = event.target; if (!(target instanceof HTMLFormElement)) { - return + return; } - event.preventDefault() + event.preventDefault(); if (target.id === "welcome-form") { - void saveAuthenticatedServerConfig(target, "welcome") - return + void saveAuthenticatedServerConfig(target, "welcome"); + return; } if (target.id === "settings-form") { - void saveAuthenticatedServerConfig(target, "settings") - return + void saveAuthenticatedServerConfig(target, "settings"); + return; } if (target.id === "pipeline-form") { - void savePipeline() + void savePipeline(); } -}) +}); -mainContent.addEventListener("input", event => { - const target = event.target +mainContent.addEventListener("input", (event) => { + const target = event.target; if ( !( target instanceof HTMLInputElement || target instanceof HTMLSelectElement || target instanceof HTMLTextAreaElement - ) + ) ) { - return + return; } - if (target.form?.id === "welcome-form" || target.form?.id === "settings-form") { - const form = target.form - setServerDraft(readServerConfigForm(form)) - clearAuthFormFeedback(form.id) - clearAuthValidationCache() + if ( + target.form?.id === "welcome-form" || + target.form?.id === "settings-form" + ) { + const form = target.form; + setServerDraft(readServerConfigForm(form)); + clearAuthFormFeedback(form.id); + clearAuthValidationCache(); if ( target instanceof HTMLInputElement && @@ -1617,38 +1840,40 @@ mainContent.addEventListener("input", event => { target.name === "username" || target.name === "password") ) { - markField(target.name, "idle") + markField(target.name, "idle"); } - return + return; } if (target.form?.id !== "pipeline-form") { - return + return; } - state.pipelineFeedback = null - state.pipelineErrors = [] + state.pipelineFeedback = null; + state.pipelineErrors = []; - const mappingColumn = target.dataset.mappingColumn + const mappingColumn = target.dataset.mappingColumn; if (mappingColumn) { - const mapping = state.pipelineForm.mappings.find(item => item.csvColumn === mappingColumn) + const mapping = state.pipelineForm.mappings.find( + (item) => item.csvColumn === mappingColumn + ); if (mapping) { - mapping.datastreamId = target.value + mapping.datastreamId = target.value; } - return + return; } - updatePipelineField(target.name, target.value) -}) + updatePipelineField(target.name, target.value); +}); -mainContent.addEventListener("focusout", event => { - const target = event.target +mainContent.addEventListener("focusout", (event) => { + const target = event.target; if (!(target instanceof HTMLInputElement) || !target.form) { - return + return; } if (target.form.id !== "welcome-form" && target.form.id !== "settings-form") { - return + return; } if ( @@ -1657,141 +1882,147 @@ mainContent.addEventListener("focusout", event => { target.name !== "username" && target.name !== "password" ) { - return + return; } - void validateAuthField(target.form, target.name) -}) + void validateAuthField(target.form, target.name); +}); -mainContent.addEventListener("click", event => { - const target = event.target +mainContent.addEventListener("click", (event) => { + const target = event.target; if (!(target instanceof HTMLElement)) { - return + return; } - const action = target.closest("[data-action]")?.dataset.action - const jobId = target.closest("[data-job-id]")?.dataset.jobId + const action = target.closest("[data-action]")?.dataset.action; + const jobId = target.closest("[data-job-id]")?.dataset.jobId; if (!action) { - return + return; } if (action === "retry-bootstrap") { - void bootstrap() - return + void bootstrap(); + return; } if (action === "toggle-auth-mode") { - const form = target.closest("form") + const form = target.closest("form"); if (!form) { - return + return; } - const nextServer = readServerConfigForm(form) - const nextAuthType: AuthType = nextServer.auth_type === "apikey" ? "userpass" : "apikey" + const nextServer = readServerConfigForm(form); + const nextAuthType: AuthType = + nextServer.auth_type === "apikey" ? "userpass" : "apikey"; setServerDraft({ ...nextServer, auth_type: nextAuthType, - }) - resetAuthFieldStates(nextAuthType) + }); + resetAuthFieldStates(nextAuthType); - clearAuthFormFeedback(form.id) - clearAuthValidationCache() + clearAuthFormFeedback(form.id); + clearAuthValidationCache(); - render() - return + render(); + return; + } + + if (action === "disconnect") { + void disconnectHydroServer(); + return; } if (action === "change-credentials") { state.authDraft = { ...emptyServerConfig(), ...(state.config?.server ?? {}), - } - state.settingsEditMode = true - navigate("settings") - render() - return + }; + state.settingsEditMode = true; + navigate("settings"); + render(); + return; } if (action === "cancel-credential-edit") { state.authDraft = { ...emptyServerConfig(), ...(state.config?.server ?? {}), - } - state.settingsEditMode = false - render() - return + }; + state.settingsEditMode = false; + render(); + return; } if (action === "browse-csv") { - void browseForCsvPath() - return + void browseForCsvPath(); + return; } if (action === "load-preview") { - void loadPipelinePreview(state.pipelineForm.filePath) - return + void loadPipelinePreview(state.pipelineForm.filePath); + return; } if (!jobId) { - return + return; } if (action === "run-job") { - void handleRunJob(jobId) - return + void handleRunJob(jobId); + return; } if (action === "toggle-job") { - void handleToggleJob(jobId) - return + void handleToggleJob(jobId); + return; } if (action === "delete-job") { - void handleDeleteJob(jobId) + void handleDeleteJob(jobId); } -}) +}); async function handleRunJob(jobId: string): Promise { try { - await runJob(jobId) - await refreshJobs() + await runJob(jobId); + await refreshJobs(); } catch { // Keep dashboard state unchanged on action failure. } } async function handleToggleJob(jobId: string): Promise { - const job = state.jobs.find(item => item.id === jobId) + const job = state.jobs.find((item) => item.id === jobId); if (!job) { - return + return; } try { if (job.enabled) { - await disableJob(jobId) + await disableJob(jobId); } else { - await enableJob(jobId) + await enableJob(jobId); } - await refreshJobs() + await refreshJobs(); } catch { // Keep dashboard state unchanged on action failure. } } async function handleDeleteJob(jobId: string): Promise { - const confirmed = window.confirm("Delete this pipeline?") + const confirmed = window.confirm("Delete this pipeline?"); if (!confirmed) { - return + return; } try { - await deleteJob(jobId) - await refreshJobs() + await deleteJob(jobId); + await refreshJobs(); } catch { // Keep dashboard state unchanged on action failure. } } -void bootstrap() +void bootstrap(); diff --git a/sidecar/api/routes.py b/sidecar/api/routes.py index da14c95..188a19c 100644 --- a/sidecar/api/routes.py +++ b/sidecar/api/routes.py @@ -83,6 +83,10 @@ def update_server_config( config = runtime.config_store.update_server(payload) return config + @app.delete("/config/server", response_model=AppConfig, tags=["config"]) + def clear_server_config(runtime: AppRuntime = Depends(get_runtime)) -> AppConfig: + return runtime.config_store.clear_server() + @app.post("/connection/test", response_model=ConnectionTestResponse, tags=["connection"]) def test_connection( payload: ConnectionTestRequest, diff --git a/sidecar/core/config.py b/sidecar/core/config.py index 6ad490e..dec9bc6 100644 --- a/sidecar/core/config.py +++ b/sidecar/core/config.py @@ -45,6 +45,11 @@ def update_server(self, update: ServerConfigUpdate) -> AppConfig: ) return self.save(config) + def clear_server(self) -> AppConfig: + config = self.load() + config.server = ServerConfig() + return self.save(config) + def list_jobs(self) -> list[JobConfig]: return self.load().jobs From 2234b01dfe194dee81b27c8c541d4292b4132be2 Mon Sep 17 00:00:00 2001 From: Daniel Slaugh Date: Thu, 2 Apr 2026 15:55:39 -0600 Subject: [PATCH 008/166] Make login full width --- frontend/styles.css | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/frontend/styles.css b/frontend/styles.css index adc0953..e210225 100644 --- a/frontend/styles.css +++ b/frontend/styles.css @@ -51,9 +51,7 @@ body { min-height: 100vh; margin: 0; - background: - radial-gradient(circle at top right, rgb(125 211 252 / 0.18), transparent 24%), - linear-gradient(180deg, #f8fafc 0%, #eef6fb 100%); + background: #edf1f5; color: #1e293b; font-family: var(--font-sans); } @@ -131,7 +129,7 @@ .settings-card, .auth-card { - @apply max-w-3xl overflow-hidden; + @apply w-full max-w-3xl overflow-hidden; } .auth-header { @@ -311,13 +309,25 @@ } .welcome-shell { - @apply flex min-h-screen items-center justify-center px-6 py-10; + @apply flex min-h-screen w-full items-stretch bg-[#edf1f5]; } .welcome-card { @apply w-full max-w-2xl p-8 md:p-10; } + .welcome-shell .auth-card { + @apply flex w-full max-w-none flex-col justify-center rounded-none border-0 bg-transparent shadow-none; + } + + .welcome-shell .card-section { + @apply mx-auto w-full max-w-[42rem] gap-5 border-0 px-10 py-0; + } + + .welcome-shell .card-section + .card-section { + @apply mt-8; + } + .pipeline-layout { @apply grid gap-6 xl:grid-cols-[minmax(0,28rem)_minmax(0,1fr)]; } From 5ecf152ef107f18e75a6ac6d170a403b139c5d2b Mon Sep 17 00:00:00 2001 From: Daniel Slaugh Date: Thu, 2 Apr 2026 16:12:37 -0600 Subject: [PATCH 009/166] Update styling and better submit state --- frontend/api.ts | 48 ++++++++++++++++++++++++++++++--- frontend/main.ts | 47 ++++++++++++++++++++++++++++++++ frontend/styles.css | 65 ++++++++++++++++++++++++++++++++++++++++++--- tauri.conf.json | 4 +-- 4 files changed, 155 insertions(+), 9 deletions(-) diff --git a/frontend/api.ts b/frontend/api.ts index 813c604..23c4b1e 100644 --- a/frontend/api.ts +++ b/frontend/api.ts @@ -108,6 +108,47 @@ function buildApiUrl(path: string): string { return `${apiBaseUrl.replace(/\/$/, "")}${path}` } +function formatErrorDetail(detail: unknown): string | null { + if (typeof detail === "string" && detail.trim()) { + return detail + } + + if (Array.isArray(detail)) { + const firstMessage = detail + .map(item => { + if (typeof item === "string") { + return item + } + if ( + item && + typeof item === "object" && + "msg" in item && + typeof item.msg === "string" + ) { + return item.msg + } + return null + }) + .find(Boolean) + + return firstMessage ?? null + } + + if (detail && typeof detail === "object") { + if ("msg" in detail && typeof detail.msg === "string") { + return detail.msg + } + + try { + return JSON.stringify(detail) + } catch { + return null + } + } + + return null +} + async function request(path: string, init?: RequestInit): Promise { const response = await fetch(buildApiUrl(path), { headers: { @@ -121,9 +162,10 @@ async function request(path: string, init?: RequestInit): Promise { let detail = `Request failed with status ${response.status}` try { - const payload = (await response.json()) as { detail?: string } - if (payload.detail) { - detail = payload.detail + const payload = (await response.json()) as { detail?: unknown } + const formattedDetail = formatErrorDetail(payload.detail) + if (formattedDetail) { + detail = formattedDetail } } catch { // Ignore JSON parsing errors for non-JSON error responses. diff --git a/frontend/main.ts b/frontend/main.ts index 75ee685..c27e55b 100644 --- a/frontend/main.ts +++ b/frontend/main.ts @@ -423,6 +423,45 @@ function applyConnectionValidationResult( } } +function validateAuthFieldsForSubmit(server: ServerConfig): boolean { + let valid = true; + + if (!server.url) { + markField("url", "invalid", "Enter the HydroServer URL."); + valid = false; + } else if (!isValidHttpUrl(server.url)) { + markField("url", "invalid", "Enter a full http:// or https:// URL."); + valid = false; + } else { + markField("url", "valid"); + } + + if (server.auth_type === "apikey") { + if (!server.api_key) { + markField("api_key", "invalid", "Enter the API key."); + valid = false; + } else { + markField("api_key", "valid"); + } + } else { + if (!server.username) { + markField("username", "invalid", "Enter the username."); + valid = false; + } else { + markField("username", "valid"); + } + + if (!server.password) { + markField("password", "invalid", "Enter the password."); + valid = false; + } else { + markField("password", "valid"); + } + } + + return valid; +} + async function validateAuthField( form: HTMLFormElement, field: AuthFieldName @@ -1645,6 +1684,14 @@ async function saveAuthenticatedServerConfig( sameServerConfig(state.lastAuthValidationServer, payload) && state.lastAuthValidationResult?.ok === true; + state[feedbackKey] = null; + resetAuthFieldStates(payload.auth_type); + + if (!validateAuthFieldsForSubmit(payload)) { + render(); + return; + } + try { state.authSubmitting = true; setAuthFieldLoading(payload); diff --git a/frontend/styles.css b/frontend/styles.css index e210225..4ebbae2 100644 --- a/frontend/styles.css +++ b/frontend/styles.css @@ -218,15 +218,15 @@ } .input-status { - @apply pointer-events-none absolute inset-y-0 right-3 inline-flex items-center text-sm font-semibold; + @apply pointer-events-none absolute right-3 top-1/2 inline-flex -translate-y-1/2 items-center justify-center text-sm font-semibold; } .input-status-valid { - @apply text-emerald-600; + @apply h-5 w-5 rounded-full border border-emerald-500 bg-emerald-50 text-[11px] text-emerald-600; } .input-status-invalid { - @apply text-red-600; + @apply h-5 w-5 rounded-full border border-red-500 bg-red-50 text-[11px] text-red-600; } .input-status-checking { @@ -309,7 +309,7 @@ } .welcome-shell { - @apply flex min-h-screen w-full items-stretch bg-[#edf1f5]; + @apply flex min-h-screen w-full items-stretch bg-[#2f3133]; } .welcome-card { @@ -328,6 +328,63 @@ @apply mt-8; } + .welcome-shell .auth-app-icon { + @apply h-32 w-32; + filter: drop-shadow(0 10px 24px rgb(0 0 0 / 0.24)); + } + + .welcome-shell .page-title { + @apply text-slate-50; + } + + .welcome-shell .label { + @apply text-slate-200; + } + + .welcome-shell .label-link { + @apply text-slate-400 hover:text-slate-200; + } + + .welcome-shell .auth-divider-label { + @apply text-slate-500; + } + + .welcome-shell .auth-toggle { + @apply text-brand-400 hover:text-brand-500 hover:decoration-brand-400; + } + + .welcome-shell .input { + @apply border-slate-700 bg-[#1f2022] text-slate-100 placeholder:text-slate-500 focus:border-brand-500 focus:ring-sky-950; + } + + .welcome-shell .input-status-checking { + @apply text-slate-500; + } + + .welcome-shell .input-spinner { + @apply border-slate-600 border-t-brand-500; + } + + .welcome-shell .field-error { + @apply text-red-400; + } + + .welcome-shell .notice-error { + @apply bg-red-950/40 text-red-200; + } + + .welcome-shell .notice-info { + @apply bg-sky-950/40 text-sky-200; + } + + .welcome-shell .notice-success { + @apply bg-emerald-950/40 text-emerald-200; + } + + .welcome-shell .btn-ghost { + @apply text-slate-300 hover:bg-white/6 hover:text-slate-100; + } + .pipeline-layout { @apply grid gap-6 xl:grid-cols-[minmax(0,28rem)_minmax(0,1fr)]; } diff --git a/tauri.conf.json b/tauri.conf.json index 79a8d5c..c8b341c 100644 --- a/tauri.conf.json +++ b/tauri.conf.json @@ -1,6 +1,6 @@ { "$schema": "https://schema.tauri.app/config/2", - "productName": "streaming-data-loader", + "productName": "Streaming Data Loader", "version": "0.1.0", "identifier": "com.streaming-data-loader.app", "build": { @@ -12,7 +12,7 @@ "app": { "windows": [ { - "title": "streaming-data-loader", + "title": "Streaming Data Loader", "width": 800, "height": 600, "visible": false From b2aeef02b6c097f1f4d479f749097545fb8aff24 Mon Sep 17 00:00:00 2001 From: Daniel Slaugh Date: Thu, 2 Apr 2026 16:22:18 -0600 Subject: [PATCH 010/166] Fix repaint jitter --- frontend/main.ts | 50 ++++++++++++++++++++++----------------------- frontend/styles.css | 11 ++++++++++ 2 files changed, 36 insertions(+), 25 deletions(-) diff --git a/frontend/main.ts b/frontend/main.ts index c27e55b..80bc1ba 100644 --- a/frontend/main.ts +++ b/frontend/main.ts @@ -114,6 +114,8 @@ if ( const { sidebar, mainContent, jobsLink, settingsLink, connectionDot } = shellElements; +let lastRenderedMarkup = ""; + function createEmptyPipelineForm(): PipelineFormState { return { name: "", @@ -173,7 +175,6 @@ function emptyServerConfig(): ServerConfig { window.setInterval(() => { void refreshJobs(); - render(); }, 30_000); function escapeHtml(value: string): string { @@ -816,7 +817,7 @@ function renderAuthForm( function renderWelcome(): string { return ` -
+
${renderAuthForm( "welcome-form", state.welcomeFeedback, @@ -1289,7 +1290,7 @@ function renderPipelineEditor(): string { function renderFatalError(): string { return ` -
+

Sidecar error

The background process is unavailable

@@ -1327,7 +1328,12 @@ function render(): void { } const showSidebar = currentRoute !== "welcome" && !state.bootstrapError; + const useWelcomeSurface = Boolean( + state.loading || state.bootstrapError || currentRoute === "welcome" + ); sidebar.classList.toggle("hidden", !showSidebar); + mainContent.classList.toggle("main-content-welcome", useWelcomeSurface); + document.body.classList.toggle("app-surface-welcome", useWelcomeSurface); jobsLink.className = currentRoute === "dashboard" ? "nav-item nav-item-active" : "nav-item"; @@ -1338,8 +1344,10 @@ function render(): void { connectionDot.className = status.className; connectionDot.title = status.label; + let nextMarkup = ""; + if (state.loading) { - mainContent.innerHTML = ` + nextMarkup = `

Starting Up

@@ -1348,30 +1356,22 @@ function render(): void {
`; - return; - } - - if (state.bootstrapError) { - mainContent.innerHTML = renderFatalError(); - return; - } - - if (currentRoute === "settings") { - mainContent.innerHTML = renderSettings(); - return; - } - - if (currentRoute === "welcome") { - mainContent.innerHTML = renderWelcome(); - return; + } else if (state.bootstrapError) { + nextMarkup = renderFatalError(); + } else if (currentRoute === "settings") { + nextMarkup = renderSettings(); + } else if (currentRoute === "welcome") { + nextMarkup = renderWelcome(); + } else if (currentRoute === "jobs-new") { + nextMarkup = renderPipelineEditor(); + } else { + nextMarkup = renderDashboard(); } - if (currentRoute === "jobs-new") { - mainContent.innerHTML = renderPipelineEditor(); - return; + if (nextMarkup !== lastRenderedMarkup) { + mainContent.innerHTML = nextMarkup; + lastRenderedMarkup = nextMarkup; } - - mainContent.innerHTML = renderDashboard(); } function sleep(ms: number): Promise { diff --git a/frontend/styles.css b/frontend/styles.css index 4ebbae2..3c231de 100644 --- a/frontend/styles.css +++ b/frontend/styles.css @@ -54,6 +54,7 @@ background: #edf1f5; color: #1e293b; font-family: var(--font-sans); + transition: background-color 140ms ease-out; } button, @@ -66,6 +67,16 @@ code { font-family: var(--font-mono); } + + #main-content { + min-height: 100vh; + background: #edf1f5; + } + + body.app-surface-welcome, + #main-content.main-content-welcome { + background: #2f3133; + } } @layer components { From 6a9b37e610fc9223f206bd67864829af9103a672 Mon Sep 17 00:00:00 2001 From: Daniel Slaugh Date: Thu, 2 Apr 2026 16:31:24 -0600 Subject: [PATCH 011/166] Make title bar transparent --- tauri.conf.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tauri.conf.json b/tauri.conf.json index c8b341c..38e0c94 100644 --- a/tauri.conf.json +++ b/tauri.conf.json @@ -15,6 +15,9 @@ "title": "Streaming Data Loader", "width": 800, "height": 600, + "theme": "Dark", + "titleBarStyle": "Transparent", + "backgroundColor": "#2f3133", "visible": false } ], From 4e7a7074f83130b967738dab0e0d5fa2cf7be82e Mon Sep 17 00:00:00 2001 From: Daniel Slaugh Date: Thu, 2 Apr 2026 16:37:09 -0600 Subject: [PATCH 012/166] Better form validation --- frontend/main.ts | 65 ++++++++++++++++++++++++++++++++++-------------- 1 file changed, 47 insertions(+), 18 deletions(-) diff --git a/frontend/main.ts b/frontend/main.ts index 80bc1ba..f3d3053 100644 --- a/frontend/main.ts +++ b/frontend/main.ts @@ -463,6 +463,18 @@ function validateAuthFieldsForSubmit(server: ServerConfig): boolean { return valid; } +function authFieldsReadyForConnectionTest(server: ServerConfig): boolean { + if (!server.url || !isValidHttpUrl(server.url)) { + return false; + } + + if (server.auth_type === "apikey") { + return Boolean(server.api_key); + } + + return Boolean(server.username && server.password); +} + async function validateAuthField( form: HTMLFormElement, field: AuthFieldName @@ -473,7 +485,12 @@ async function validateAuthField( if (field === "url") { if (!server.url) { - markField("url", "invalid", "Enter the HydroServer URL."); + markField("url", "idle"); + for (const name of credentialFields(server.auth_type)) { + if (state.authFieldStates[name].state === "checking") { + markField(name, "idle"); + } + } render(); return; } @@ -483,6 +500,17 @@ async function validateAuthField( render(); return; } + + if (!authFieldsReadyForConnectionTest(server)) { + markField("url", "idle"); + for (const name of credentialFields(server.auth_type)) { + if (state.authFieldStates[name].state === "checking") { + markField(name, "idle"); + } + } + render(); + return; + } } if (field === "api_key" && server.auth_type === "apikey" && !server.api_key) { @@ -511,25 +539,26 @@ async function validateAuthField( return; } - if (field === "url") { - markField("url", "valid"); - } else { - markField(field, "checking"); - } + if (field !== "url") { + if (!server.url) { + markField("url", "idle"); + markField(field, "idle"); + render(); + return; + } - const requiredFieldsReady = - server.auth_type === "apikey" - ? Boolean(server.url && isValidHttpUrl(server.url) && server.api_key) - : Boolean( - server.url && - isValidHttpUrl(server.url) && - server.username && - server.password - ); + if (!isValidHttpUrl(server.url)) { + markField("url", "invalid", "Enter a full http:// or https:// URL."); + markField(field, "idle"); + render(); + return; + } - if (!requiredFieldsReady) { - render(); - return; + if (!authFieldsReadyForConnectionTest(server)) { + markField(field, "idle"); + render(); + return; + } } for (const name of credentialFields(server.auth_type)) { From b43bbd2f54e2dfbe3ef599e515a308f33096ead6 Mon Sep 17 00:00:00 2001 From: Daniel Slaugh Date: Fri, 3 Apr 2026 10:26:41 -0600 Subject: [PATCH 013/166] Work on onboarding flow --- frontend/api.ts | 13 +- frontend/main.ts | 652 ++++++++++++++++++++---------------- frontend/styles.css | 246 ++++++++++++-- sidecar/api/models.py | 6 + sidecar/api/routes.py | 15 +- sidecar/core/hydroserver.py | 102 ++++++ sidecar/core/loader.py | 2 +- 7 files changed, 722 insertions(+), 314 deletions(-) diff --git a/frontend/api.ts b/frontend/api.ts index 23c4b1e..2920cb3 100644 --- a/frontend/api.ts +++ b/frontend/api.ts @@ -66,6 +66,12 @@ export interface ConnectionTestResponse { permissions_ok: boolean } +export interface ServerUrlValidationResponse { + ok: boolean + message: string + instance_name: string | null +} + export interface DatastreamSummary { id: string name: string @@ -205,6 +211,11 @@ export function testConnection(server: ServerConfig): Promise { + const params = new URLSearchParams({ url }) + return request(`/connection/validate-url?${params.toString()}`) +} + export function listJobs(): Promise { return request("/jobs") } @@ -220,7 +231,7 @@ export function getDatastreams(): Promise { return request("/datastreams") } -export function getCsvPreview(path: string, rows = 60): Promise { +export function getCsvPreview(path: string, rows = 50): Promise { const params = new URLSearchParams({ path, rows: String(rows), diff --git a/frontend/main.ts b/frontend/main.ts index f3d3053..169649a 100644 --- a/frontend/main.ts +++ b/frontend/main.ts @@ -15,6 +15,7 @@ import { runJob, testConnection, updateServerConfig, + validateServerUrl, type AppConfig, type AuthType, type ConnectionState, @@ -64,6 +65,12 @@ type PipelineFormState = { mappings: PipelineMappingDraft[]; }; +type PreviewSelectionTarget = + | "header-row" + | "data-start-row" + | "timestamp-column" + | null; + type UiState = { route: AppRoute; health: HealthResponse | null; @@ -87,6 +94,7 @@ type UiState = { authSubmitting: boolean; lastAuthValidationServer: ServerConfig | null; lastAuthValidationResult: ConnectionTestResponse | null; + pipelineSelectionTarget: PreviewSelectionTarget; }; const shellElements = { @@ -159,10 +167,9 @@ const state: UiState = { authSubmitting: false, lastAuthValidationServer: null, lastAuthValidationResult: null, + pipelineSelectionTarget: null, }; -let authValidationRequestId = 0; - function emptyServerConfig(): ServerConfig { return { auth_type: "apikey", @@ -206,6 +213,57 @@ function basename(path: string): string { return segments.at(-1) ?? path; } +function parseDelimitedLine(line: string, delimiter: string): string[] { + if (!delimiter) { + return [line]; + } + + const cells: string[] = []; + let current = ""; + let inQuotes = false; + + for (let index = 0; index < line.length; index += 1) { + const character = line[index]; + + if (character === '"') { + if (inQuotes && line[index + 1] === '"') { + current += '"'; + index += 1; + } else { + inQuotes = !inQuotes; + } + continue; + } + + if (!inQuotes && line.startsWith(delimiter, index)) { + cells.push(current); + current = ""; + index += delimiter.length - 1; + continue; + } + + current += character; + } + + cells.push(current); + return cells; +} + +function normalizePreviewHeaderName(value: string, index: number): string { + const cleaned = value.trim(); + return cleaned || `Column ${index + 1}`; +} + +function parsedPreviewRows(): string[][] { + if (!state.pipelinePreview) { + return []; + } + + return state.pipelinePreview.raw_lines.map((line) => + parseDelimitedLine(line, state.pipelineForm.delimiter) + ); +} + function connected(): boolean { return ( state.connectionSummary?.ok === true && @@ -276,23 +334,6 @@ function setServerDraft(server: ServerConfig): void { state.authDraft = { ...server }; } -function sameServerConfig( - left: ServerConfig | null, - right: ServerConfig -): boolean { - if (!left) { - return false; - } - - return ( - left.auth_type === right.auth_type && - left.url === right.url && - left.api_key === right.api_key && - left.username === right.username && - left.password === right.password - ); -} - function markField( field: AuthFieldName, nextState: FieldValidationState["state"], @@ -305,24 +346,6 @@ function credentialFields(authType: AuthType): AuthFieldName[] { return authType === "userpass" ? ["username", "password"] : ["api_key"]; } -function authFieldStateMarkup(field: AuthFieldName): string { - const fieldState = state.authFieldStates[field]; - - if (fieldState.state === "valid") { - return ''; - } - - if (fieldState.state === "invalid") { - return ''; - } - - if (fieldState.state === "checking") { - return ''; - } - - return ""; -} - function authFieldErrorMarkup(field: AuthFieldName): string { const fieldState = state.authFieldStates[field]; if (fieldState.state !== "invalid" || !fieldState.message) { @@ -350,12 +373,9 @@ function renderAuthInputField(params: { ${escapeHtml(label)} ${labelAction ?? ""} - - - ${authFieldStateMarkup(name)} - + ${helpText ? `

${escapeHtml(helpText)}

` : ""} ${authFieldErrorMarkup(name)} @@ -377,13 +397,6 @@ function clearAuthValidationCache(): void { state.lastAuthValidationResult = null; } -function setAuthFieldLoading(server: ServerConfig): void { - markField("url", "checking"); - for (const field of credentialFields(server.auth_type)) { - markField(field, "checking"); - } -} - function isValidHttpUrl(value: string): boolean { try { const parsed = new URL(value); @@ -427,6 +440,8 @@ function applyConnectionValidationResult( function validateAuthFieldsForSubmit(server: ServerConfig): boolean { let valid = true; + resetAuthFieldStates(server.auth_type); + if (!server.url) { markField("url", "invalid", "Enter the HydroServer URL."); valid = false; @@ -463,153 +478,12 @@ function validateAuthFieldsForSubmit(server: ServerConfig): boolean { return valid; } -function authFieldsReadyForConnectionTest(server: ServerConfig): boolean { - if (!server.url || !isValidHttpUrl(server.url)) { - return false; - } - - if (server.auth_type === "apikey") { - return Boolean(server.api_key); - } - - return Boolean(server.username && server.password); -} - -async function validateAuthField( - form: HTMLFormElement, - field: AuthFieldName -): Promise { - const server = readServerConfigForm(form); - const requestId = ++authValidationRequestId; - setServerDraft(server); - - if (field === "url") { - if (!server.url) { - markField("url", "idle"); - for (const name of credentialFields(server.auth_type)) { - if (state.authFieldStates[name].state === "checking") { - markField(name, "idle"); - } - } - render(); - return; - } - - if (!isValidHttpUrl(server.url)) { - markField("url", "invalid", "Enter a full http:// or https:// URL."); - render(); - return; - } - - if (!authFieldsReadyForConnectionTest(server)) { - markField("url", "idle"); - for (const name of credentialFields(server.auth_type)) { - if (state.authFieldStates[name].state === "checking") { - markField(name, "idle"); - } - } - render(); - return; - } - } - - if (field === "api_key" && server.auth_type === "apikey" && !server.api_key) { - markField("api_key", "invalid", "Enter the API key."); - render(); - return; - } - - if ( - field === "username" && - server.auth_type === "userpass" && - !server.username - ) { - markField("username", "invalid", "Enter the username."); - render(); - return; - } - - if ( - field === "password" && - server.auth_type === "userpass" && - !server.password - ) { - markField("password", "invalid", "Enter the password."); - render(); - return; - } - - if (field !== "url") { - if (!server.url) { - markField("url", "idle"); - markField(field, "idle"); - render(); - return; - } - - if (!isValidHttpUrl(server.url)) { - markField("url", "invalid", "Enter a full http:// or https:// URL."); - markField(field, "idle"); - render(); - return; - } - - if (!authFieldsReadyForConnectionTest(server)) { - markField(field, "idle"); - render(); - return; - } - } - - for (const name of credentialFields(server.auth_type)) { - markField(name, "checking"); - } - markField("url", "checking"); - render(); - - try { - const result = await testConnection(server); - - if (requestId !== authValidationRequestId) { - return; - } - - state.lastAuthValidationServer = server; - state.lastAuthValidationResult = result; - applyConnectionValidationResult(server, result); - } catch (error) { - if (requestId !== authValidationRequestId) { - return; - } - - clearAuthValidationCache(); - const message = - error instanceof Error - ? error.message - : "Couldn't test the HydroServer connection."; - const isUrlError = - message.includes("Request failed with status 500") || - message.includes("Failed to fetch") || - message.includes("Couldn't test the HydroServer connection."); - - if (isUrlError) { - markField("url", "invalid", message); - for (const name of credentialFields(server.auth_type)) { - markField(name, "idle"); - } - } else { - markField("url", "valid"); - for (const name of credentialFields(server.auth_type)) { - markField(name, "invalid", message); - } - } - } - - render(); -} - function previewHeaders(): string[] { - return state.pipelinePreview?.parsed_rows[0] ?? []; + const rows = parsedPreviewRows(); + const headerRow = rows[state.pipelineForm.headerRow - 1] ?? []; + return headerRow.map((cell, index) => + normalizePreviewHeaderName(cell, index) + ); } function pipelineMappingsByColumn(): Map { @@ -632,6 +506,75 @@ function previewColumnClass(columnName: string): string { return mapped ? "preview-col-mapped" : ""; } +function previewPickerButtonClass( + target: Exclude +): string { + const active = state.pipelineSelectionTarget === target; + const toneClass = + target === "header-row" + ? "field-picker-header" + : target === "data-start-row" + ? "field-picker-data" + : "field-picker-timestamp"; + + return active + ? `field-picker field-picker-active ${toneClass}` + : "field-picker"; +} + +function previewFieldClass( + target: Exclude +): string { + const active = state.pipelineSelectionTarget === target; + const toneClass = + target === "header-row" + ? "preview-bound-field-header" + : target === "data-start-row" + ? "preview-bound-field-data" + : "preview-bound-field-timestamp"; + + return active + ? `field preview-bound-field preview-bound-field-active ${toneClass}` + : "field preview-bound-field"; +} + +function previewGuidanceText(): string { + if (state.pipelineSelectionTarget === "header-row") { + return "Click a raw line to set the header row."; + } + + if (state.pipelineSelectionTarget === "data-start-row") { + return "Click the first data line in the raw preview."; + } + + if (state.pipelineSelectionTarget === "timestamp-column") { + return "Click a column header to set the timestamp column."; + } + + return "Use the picker controls on the left, or click a column header to set the timestamp column directly."; +} + +function syncPipelineSelectionsWithPreview(): void { + const headers = previewHeaders(); + + if (headers.length === 0) { + state.pipelineForm.mappings = []; + return; + } + + const preferredTimestamp = + headers.find((header) => header.toLowerCase().includes("time")) ?? + headers[0]; + + state.pipelineForm.timestampColumn = headers.includes( + state.pipelineForm.timestampColumn + ) + ? state.pipelineForm.timestampColumn + : preferredTimestamp; + + initializeMappings(headers); +} + function initializeMappings(headers: string[]): void { const existing = pipelineMappingsByColumn(); state.pipelineForm.mappings = headers @@ -651,25 +594,63 @@ function applyPreview(path: string, preview: CsvPreviewResponse): void { preview.detected_data_start_row ?? state.pipelineForm.dataStartRow; state.pipelineForm.delimiter = preview.detected_delimiter || state.pipelineForm.delimiter; - - const headers = preview.parsed_rows[0] ?? []; - if (headers.length > 0) { - const preferredTimestamp = - headers.find((header) => header.toLowerCase().includes("time")) ?? - headers[0]; - state.pipelineForm.timestampColumn = headers.includes( - state.pipelineForm.timestampColumn - ) - ? state.pipelineForm.timestampColumn - : preferredTimestamp; - } + state.pipelineSelectionTarget = null; if (!state.pipelineForm.name.trim()) { const inferred = basename(path).replace(/\.[^.]+$/, ""); state.pipelineForm.name = inferred; } - initializeMappings(headers); + syncPipelineSelectionsWithPreview(); +} + +function updateHeaderRowFromPreview(lineNumber: number): void { + state.pipelineForm.headerRow = lineNumber; + if (state.pipelineForm.dataStartRow <= lineNumber) { + state.pipelineForm.dataStartRow = lineNumber + 1; + } + syncPipelineSelectionsWithPreview(); +} + +function updateDataStartRowFromPreview(lineNumber: number): void { + state.pipelineForm.dataStartRow = Math.max(2, lineNumber); + if (state.pipelineForm.headerRow >= state.pipelineForm.dataStartRow) { + state.pipelineForm.headerRow = state.pipelineForm.dataStartRow - 1; + } + syncPipelineSelectionsWithPreview(); +} + +function applyPreviewLineSelection(lineNumber: number): void { + if (state.pipelineSelectionTarget === "header-row") { + updateHeaderRowFromPreview(lineNumber); + state.pipelineSelectionTarget = null; + render(); + return; + } + + if (state.pipelineSelectionTarget === "data-start-row") { + updateDataStartRowFromPreview(lineNumber); + state.pipelineSelectionTarget = null; + render(); + } +} + +function applyPreviewColumnSelection(columnName: string): void { + if ( + state.pipelineSelectionTarget && + state.pipelineSelectionTarget !== "timestamp-column" + ) { + return; + } + + state.pipelineForm.timestampColumn = columnName; + initializeMappings(previewHeaders()); + state.pipelineSelectionTarget = null; + render(); +} + +function onboardingRoute(route: AppRoute): boolean { + return route === "welcome" || (route === "jobs-new" && state.jobs.length === 0); } function connectionIndicator(): { label: string; className: string } { @@ -980,14 +961,21 @@ function renderPipelinePreview(): string {
CSV

Preview a source file

-

Choose a CSV file path, then load the preview to detect headers and map source columns to HydroServer datastreams.

+

Choose a CSV file path, then load the preview to inspect the first 50 lines and map the source structure into HydroServer.

`; } const headers = previewHeaders(); - const parsedRows = state.pipelinePreview.parsed_rows.slice(1, 7); + const parsedRows = parsedPreviewRows() + .slice(Math.max(state.pipelineForm.dataStartRow - 1, 0)) + .map((row, index) => ({ + lineNumber: state.pipelineForm.dataStartRow + index, + row, + })) + .filter(({ row }) => row.some((cell) => cell.trim())) + .slice(0, 8); const rawRows = state.pipelinePreview.raw_lines .map((line, index) => { const lineNumber = index + 1; @@ -998,11 +986,21 @@ function renderPipelinePreview(): string { ? "preview-raw-line preview-raw-line-data" : "preview-raw-line"; + const rowTag = + lineNumber === state.pipelineForm.headerRow + ? 'Header' + : lineNumber === state.pipelineForm.dataStartRow + ? 'Data start' + : ""; + return ` -
- ${lineNumber} +
+ `; }) .join(""); @@ -1010,16 +1008,21 @@ function renderPipelinePreview(): string { const headerCells = headers .map( (header) => - `${escapeHtml( - header - )}` + ` + + ` ) .join(""); const tableRows = parsedRows .map( - (row) => ` + ({ lineNumber, row }) => ` + ${lineNumber} ${row .map((cell, index) => { const columnName = headers[index] ?? ""; @@ -1041,6 +1044,7 @@ function renderPipelinePreview(): string {

${escapeHtml( basename(state.pipelineForm.filePath) )}

+

${escapeHtml(previewGuidanceText())}

Header row ${ @@ -1060,7 +1064,10 @@ function renderPipelinePreview(): string {
- ${headerCells} + + + ${headerCells} + ${tableRows} @@ -1072,7 +1079,7 @@ function renderPipelinePreview(): string { Showing the first ${Math.min( state.pipelinePreview.total_lines, state.pipelinePreview.raw_lines.length - )} of ${state.pipelinePreview.total_lines} lines + )} lines of ${state.pipelinePreview.total_lines} `; @@ -1128,13 +1135,18 @@ function renderPipelineMappings(): string { } function renderPipelineEditor(): string { + const firstRunOnboarding = state.jobs.length === 0; + const shellClass = firstRunOnboarding + ? "page-shell onboarding-shell animate-fade-in" + : "page-shell animate-fade-in"; + if (!connected()) { return renderWelcome(); } if (state.datastreamsError) { return ` -
+
` ) .join(""); @@ -1224,13 +1422,40 @@ function renderPipelinePreview(): string { const tableRows = parsedRows .map( ({ lineNumber, row }) => ` - - - ${row - .map((cell, index) => { - const columnName = headers[index] ?? ""; + + + ${headers + .map((columnName, index) => { + const cell = row[index] ?? ""; return ``; }) .join("")} @@ -1261,8 +1486,6 @@ function renderPipelinePreview(): string { File has a header row -
${rawRows}
-
Line
- + ` +
+ ${renderTimestampHandle(header)} + +
${lineNumber}
+
+ ${ + state.pipelineForm.hasHeaderRow + ? renderPreviewHandle("header-row", lineNumber) + : "" + } + ${renderPreviewHandle("data-start-row", lineNumber)} + +
+
${escapeHtml(cell)}
@@ -1547,18 +1770,6 @@ function renderPipelineEditor(): string {
-
${ previewHeaders().length > 0 @@ -1567,7 +1778,7 @@ function renderPipelineEditor(): string { state.pipelineForm.timestampColumn )}" placeholder="Timestamp" />` } - Click the matching header in the preview table to bind it. + Drag the amber TIMESTAMP handle in the preview, or click the matching header.
+ ` + ) + .join(""); + + const tableRows = parsedRows + .map(({ lineNumber, row }) => { + const rowClasses = [ + "preview-table-row", + state.pipelineForm.hasHeaderRow && lineNumber === headerLine + ? "preview-table-row-header" + : "", + lineNumber === dataStartLine ? "preview-table-row-data" : "", + ] + .filter(Boolean) + .join(" "); + + const dataCells = headers + .map( + (columnName, i) => ` + + ` + ) + .join(""); + + return ` + + + ${dataCells} + + `; + }) + .join(""); + + const shownLines = state.pipelinePreview.raw_lines.length; + const remaining = Math.max(state.pipelinePreview.total_lines - shownLines, 0); + const nextPageSize = Math.min(PREVIEW_PAGE_SIZE, remaining); + const showMoreButton = canShowMorePreviewLines() + ? `` + : ""; + + return ` +
+
+
+

Preview

+

${escapeHtml(basename(state.pipelineForm.filePath))}

+

${escapeHtml(previewGuidanceText())}

+
+
+ + + +
+
+
+ ${renderTimestampHandle(header)} + +
+
${escapeHtml(row[i] ?? "")}
+
+ ${state.pipelineForm.hasHeaderRow ? renderPreviewHandle("header-row", lineNumber) : ""} + ${renderPreviewHandle("data-start-row", lineNumber)} + +
+
+ + + + ${headerCells} + + + ${tableRows} +
Line
+
+ +
+ Showing ${shownLines} of ${state.pipelinePreview.total_lines} lines + ${showMoreButton} +
+ + `; +} + +// ── DOM collection helpers ───────────────────────────────────────────────── +function collectRowButtons( + root: HTMLElement +): Map { + return new Map( + Array.from( + root.querySelectorAll( + '[data-action="pick-preview-line"][data-preview-line]' + ) + ) + .map((btn) => { + const n = Number(btn.dataset.previewLine); + return Number.isFinite(n) ? ([n, btn] as [number, HTMLButtonElement]) : null; + }) + .filter((e): e is [number, HTMLButtonElement] => e !== null) + ); +} + +function collectRowElements( + root: HTMLElement +): Map { + return new Map( + Array.from(root.querySelectorAll("[data-preview-line-row]")) + .map((row) => { + const n = Number(row.dataset.previewLineRow); + return Number.isFinite(n) + ? ([n, row] as [number, HTMLTableRowElement]) + : null; + }) + .filter((e): e is [number, HTMLTableRowElement] => e !== null) + ); +} + +function collectRowCenters( + rowButtons: Map +): Array<{ lineNumber: number; centerY: number }> { + return Array.from(rowButtons.entries()).map(([lineNumber, btn]) => { + const rect = btn.getBoundingClientRect(); + return { lineNumber, centerY: rect.top + rect.height / 2 }; + }); +} + +function nearestLineNumber( + clientY: number, + rowCenters: Array<{ lineNumber: number; centerY: number }> +): number | null { + if (rowCenters.length === 0) return null; + return rowCenters.reduce((best, entry) => + Math.abs(clientY - entry.centerY) < Math.abs(clientY - best.centerY) + ? entry + : best + ).lineNumber; +} + +function collectHeaderButtons( + root: HTMLElement +): Map { + return new Map( + Array.from( + root.querySelectorAll( + '[data-action="pick-preview-column"][data-preview-column]' + ) + ) + .map((btn) => { + const col = btn.dataset.previewColumn ?? ""; + return col ? ([col, btn] as [string, HTMLButtonElement]) : null; + }) + .filter((e): e is [string, HTMLButtonElement] => e !== null) + ); +} + +function collectColumnCells(root: HTMLElement): Map { + const cells = new Map(); + root.querySelectorAll("[data-preview-column-cell]").forEach((el) => { + const col = el.dataset.previewColumnCell ?? ""; + if (!col) return; + const bucket = cells.get(col) ?? []; + bucket.push(el); + cells.set(col, bucket); + }); + return cells; +} + +function collectHeaderCenters( + headerButtons: Map +): Array<{ columnName: string; centerX: number }> { + return Array.from(headerButtons.entries()).map(([columnName, btn]) => { + const rect = btn.getBoundingClientRect(); + return { columnName, centerX: rect.left + rect.width / 2 }; + }); +} + +function nearestColumnName( + clientX: number, + headerCenters: Array<{ columnName: string; centerX: number }> +): string | null { + if (headerCenters.length === 0) return null; + return headerCenters.reduce((best, entry) => + Math.abs(clientX - entry.centerX) < Math.abs(clientX - best.centerX) + ? entry + : best + ).columnName; +} + +// ── Row drag visual ──────────────────────────────────────────────────────── +function applyRowDragClasses(): void { + if (!state.pipelineDrag || !_rowDragVisual) return; + + const headerLine = + state.pipelineDrag.target === "header-row" + ? state.pipelineDrag.lineNumber + : previewCommittedHandleLine("header-row"); + const dataLine = + state.pipelineDrag.target === "data-start-row" + ? state.pipelineDrag.lineNumber + : previewCommittedHandleLine("data-start-row"); + + for (const [n, btn] of _rowDragVisual.rowButtons.entries()) { + btn.classList.toggle( + "preview-line-button-header", + state.pipelineForm.hasHeaderRow && headerLine === n + ); + btn.classList.toggle("preview-line-button-data", dataLine === n); + } + for (const [n, row] of _rowDragVisual.rowElements.entries()) { + row.classList.toggle( + "preview-table-row-header", + state.pipelineForm.hasHeaderRow && headerLine === n + ); + row.classList.toggle("preview-table-row-data", dataLine === n); + } +} + +function flushRowDragVisual(): void { + if (!state.pipelineDrag || !_rowDragVisual) return; + _rowDragVisual.frameRequested = false; + const offset = _rowDragVisual.currentClientY - _rowDragVisual.startClientY; + _rowDragVisual.handle.style.setProperty("--preview-handle-offset", `${offset}px`); + applyRowDragClasses(); +} + +function scheduleRowDragVisual(): void { + if (!_rowDragVisual || _rowDragVisual.frameRequested) return; + _rowDragVisual.frameRequested = true; + window.requestAnimationFrame(flushRowDragVisual); +} + +function beginRowDragVisual(root: HTMLElement, clientY: number): void { + if (!state.pipelineDrag) return; + const { target, lineNumber } = state.pipelineDrag; + const handle = root.querySelector( + `[data-preview-handle-target="${target}"][data-preview-line="${lineNumber}"]` + ); + if (!handle) return; + + const rowButtons = collectRowButtons(root); + _rowDragVisual = { + handle, + startClientY: clientY, + currentClientY: clientY, + rowButtons, + rowElements: collectRowElements(root), + rowCenters: collectRowCenters(rowButtons), + frameRequested: false, + }; + + root + .querySelectorAll(".preview-row-handle-active") + .forEach((el) => el.classList.remove("preview-row-handle-active")); + handle.classList.add("preview-row-handle-active", "preview-row-handle-dragging"); + handle.style.setProperty("--preview-handle-offset", "0px"); + applyRowDragClasses(); +} + +function endRowDragVisual(): void { + if (!_rowDragVisual) return; + if ( + state.pipelineDrag && + typeof _rowDragVisual.handle.releasePointerCapture === "function" && + _rowDragVisual.handle.hasPointerCapture(state.pipelineDrag.pointerId) + ) { + _rowDragVisual.handle.releasePointerCapture(state.pipelineDrag.pointerId); + } + _rowDragVisual.handle.classList.remove("preview-row-handle-dragging"); + _rowDragVisual.handle.style.removeProperty("--preview-handle-offset"); + _rowDragVisual = null; +} + +// ── Column drag visual ───────────────────────────────────────────────────── +function applyColDragClasses(): void { + if (!state.pipelineColumnDrag || !_colDragVisual) return; + for (const [col, cells] of _colDragVisual.columnCells.entries()) { + const active = col === state.pipelineColumnDrag.columnName; + for (const cell of cells) cell.classList.toggle("preview-col-timestamp", active); + } +} + +function flushColDragVisual(): void { + if (!state.pipelineColumnDrag || !_colDragVisual) return; + _colDragVisual.frameRequested = false; + const offset = _colDragVisual.currentClientX - _colDragVisual.startClientX; + _colDragVisual.handle.style.setProperty( + "--preview-column-handle-offset", + `${offset}px` + ); + applyColDragClasses(); +} + +function scheduleColDragVisual(): void { + if (!_colDragVisual || _colDragVisual.frameRequested) return; + _colDragVisual.frameRequested = true; + window.requestAnimationFrame(flushColDragVisual); +} + +function beginColDragVisual(root: HTMLElement, clientX: number): void { + if (!state.pipelineColumnDrag) return; + const { columnName } = state.pipelineColumnDrag; + const handle = + Array.from(root.querySelectorAll("[data-preview-column-handle]")).find( + (el) => el.dataset.previewColumnHandle === columnName + ) ?? null; + if (!handle) return; + + const headerButtons = collectHeaderButtons(root); + _colDragVisual = { + handle, + startClientX: clientX, + currentClientX: clientX, + headerButtons, + columnCells: collectColumnCells(root), + headerCenters: collectHeaderCenters(headerButtons), + frameRequested: false, + }; + + handle.classList.add("preview-column-handle-dragging"); + handle.style.setProperty("--preview-column-handle-offset", "0px"); + applyColDragClasses(); +} + +function endColDragVisual(): void { + if (!_colDragVisual) return; + if ( + state.pipelineColumnDrag && + typeof _colDragVisual.handle.releasePointerCapture === "function" && + _colDragVisual.handle.hasPointerCapture(state.pipelineColumnDrag.pointerId) + ) { + _colDragVisual.handle.releasePointerCapture(state.pipelineColumnDrag.pointerId); + } + _colDragVisual.handle.classList.remove("preview-column-handle-dragging"); + _colDragVisual.handle.style.removeProperty("--preview-column-handle-offset"); + _colDragVisual = null; +} + +// ── Public event initializer ─────────────────────────────────────────────── +// Sets up all pointer events needed for drag-to-select on the preview table. +// Call once after the DOM shell is ready. +export function initPreviewDragEvents( + mainContent: HTMLElement, + render: () => void +): void { + // ── Pointer down: start a drag ───────────────────────────────────────── + mainContent.addEventListener("pointerdown", (event) => { + const target = event.target; + if (!(target instanceof HTMLElement)) return; + + // Row handle drag (header-row or data-start-row) + const handle = target.closest("[data-preview-handle-target]"); + if (handle) { + const pickerTarget = handle.dataset.previewHandleTarget; + if (pickerTarget !== "header-row" && pickerTarget !== "data-start-row") return; + const lineNumber = Number(handle.dataset.previewLine); + if (!Number.isFinite(lineNumber) || lineNumber < 1) return; + + state.pipelineSelectionTarget = pickerTarget; + state.pipelineDrag = { + target: pickerTarget, + lineNumber, + pointerId: event.pointerId, + moved: false, + }; + _suppressHandleClick = false; + if (typeof handle.setPointerCapture === "function") { + handle.setPointerCapture(event.pointerId); + } + beginRowDragVisual(mainContent, event.clientY); + event.preventDefault(); + return; + } + + // Column handle drag (timestamp-column) + const colHandle = target.closest("[data-preview-column-handle]"); + if (!colHandle) return; + const columnName = colHandle.dataset.previewColumnHandle ?? ""; + if (!columnName) return; + + state.pipelineSelectionTarget = "timestamp-column"; + state.pipelineColumnDrag = { + columnName, + pointerId: event.pointerId, + moved: false, + }; + if (typeof colHandle.setPointerCapture === "function") { + colHandle.setPointerCapture(event.pointerId); + } + beginColDragVisual(mainContent, event.clientX); + event.preventDefault(); + }); + + // ── Pointer move: update drag position ──────────────────────────────── + window.addEventListener("pointermove", (event) => { + if (state.pipelineDrag?.pointerId === event.pointerId) { + if (!_rowDragVisual) return; + _rowDragVisual.currentClientY = event.clientY; + const lineNumber = nearestLineNumber(event.clientY, _rowDragVisual.rowCenters); + if (lineNumber === null) { + scheduleRowDragVisual(); + return; + } + if (lineNumber !== state.pipelineDrag.lineNumber) { + state.pipelineDrag = { ...state.pipelineDrag, lineNumber, moved: true }; + } + scheduleRowDragVisual(); + return; + } + + if ( + !state.pipelineColumnDrag || + state.pipelineColumnDrag.pointerId !== event.pointerId + ) { + return; + } + if (!_colDragVisual) return; + _colDragVisual.currentClientX = event.clientX; + const columnName = nearestColumnName(event.clientX, _colDragVisual.headerCenters); + if (columnName && columnName !== state.pipelineColumnDrag.columnName) { + state.pipelineColumnDrag = { ...state.pipelineColumnDrag, columnName, moved: true }; + } + scheduleColDragVisual(); + }); + + // ── Pointer up: commit drag ──────────────────────────────────────────── + window.addEventListener("pointerup", (event) => { + if (state.pipelineDrag?.pointerId === event.pointerId) { + const drag = state.pipelineDrag; + endRowDragVisual(); + state.pipelineDrag = null; + + if (drag.moved) { + if (drag.target === "header-row") { + updateHeaderRowFromPreview(drag.lineNumber); + } else { + updateDataStartRowFromPreview(drag.lineNumber); + } + state.pipelineSelectionTarget = null; + _suppressHandleClick = true; + } else { + state.pipelineSelectionTarget = drag.target; + _suppressHandleClick = false; + } + render(); + return; + } + + if ( + !state.pipelineColumnDrag || + state.pipelineColumnDrag.pointerId !== event.pointerId + ) { + return; + } + const drag = state.pipelineColumnDrag; + endColDragVisual(); + state.pipelineColumnDrag = null; + + if (drag.moved) { + state.pipelineForm.timestampColumn = drag.columnName; + initializeMappings(previewHeaders()); + state.pipelineSelectionTarget = null; + } else { + state.pipelineSelectionTarget = "timestamp-column"; + } + render(); + }); + + // ── Pointer cancel: abort drag ───────────────────────────────────────── + window.addEventListener("pointercancel", (event) => { + if (state.pipelineDrag?.pointerId === event.pointerId) { + endRowDragVisual(); + state.pipelineDrag = null; + _suppressHandleClick = false; + render(); + return; + } + if ( + !state.pipelineColumnDrag || + state.pipelineColumnDrag.pointerId !== event.pointerId + ) { + return; + } + endColDragVisual(); + state.pipelineColumnDrag = null; + state.pipelineSelectionTarget = null; + render(); + }); +} diff --git a/frontend/components/dashboard.ts b/frontend/components/dashboard.ts new file mode 100644 index 0000000..67a6eaf --- /dev/null +++ b/frontend/components/dashboard.ts @@ -0,0 +1,104 @@ +import { state } from "../state"; +import { routeHref } from "../router"; +import { formatRelativeTime, formatSchedule, shortenPath } from "../time"; +import { escapeHtml } from "./helpers"; +import type { JobSummary } from "../api"; + +function statusPill(job: JobSummary): string { + const classes: Record = { + healthy: "pill-success", + warning: "pill-warning", + error: "pill-danger", + disabled: "pill-muted", + pending: "pill-info", + running: "pill-info", + }; + return `${escapeHtml(job.status_message)}`; +} + +function jobStatusDotClass(status: JobSummary["status"]): string { + switch (status) { + case "error": + return "bg-rose-500"; + case "warning": + return "bg-amber-500"; + case "disabled": + return "bg-slate-300"; + default: + return "bg-emerald-500"; + } +} + +function renderJobCard(job: JobSummary): string { + const lastLine = job.last_error + ? `Failed ${formatRelativeTime(job.last_run_at)}` + : `Last pushed ${formatRelativeTime(job.last_pushed_timestamp)}`; + + return ` +
+
+
+
+ +

${escapeHtml(job.name)}

+
+

${escapeHtml(shortenPath(job.file_path))}

+

+ ${escapeHtml(lastLine)} · ${escapeHtml(formatSchedule(job.schedule_minutes))} +

+
+ ${statusPill(job)} +
+ +
+ + + +
+
+ `; +} + +export function renderDashboard(): string { + if (state.jobs.length === 0) { + return ` +
+ +
+ `; + } + + return ` +
+ +
+ ${state.jobs.map(renderJobCard).join("")} +
+
+ `; +} diff --git a/frontend/components/helpers.ts b/frontend/components/helpers.ts new file mode 100644 index 0000000..2622d98 --- /dev/null +++ b/frontend/components/helpers.ts @@ -0,0 +1,59 @@ +export type Feedback = { + tone: "success" | "error" | "info"; + message: string; +} | null; + +export const APP_NAME = "HydroServer Streaming Data Loader"; + +export function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +export function feedbackMarkup(feedback: Feedback): string { + if (!feedback) return ""; + const cls = + feedback.tone === "success" + ? "notice-success" + : feedback.tone === "error" + ? "notice-error" + : "notice-info"; + return `
${escapeHtml(feedback.message)}
`; +} + +export function basename(path: string): string { + const segments = path.split(/[\\/]/).filter(Boolean); + return segments.at(-1) ?? path; +} + +export function parseDelimitedLine(line: string, delimiter: string): string[] { + if (!delimiter) return [line]; + const cells: string[] = []; + let current = ""; + let inQuotes = false; + for (let i = 0; i < line.length; i++) { + const char = line[i]; + if (char === '"') { + if (inQuotes && line[i + 1] === '"') { + current += '"'; + i++; + } else { + inQuotes = !inQuotes; + } + continue; + } + if (!inQuotes && line.startsWith(delimiter, i)) { + cells.push(current); + current = ""; + i += delimiter.length - 1; + continue; + } + current += char; + } + cells.push(current); + return cells; +} diff --git a/frontend/components/onboarding-file.ts b/frontend/components/onboarding-file.ts new file mode 100644 index 0000000..7f2d693 --- /dev/null +++ b/frontend/components/onboarding-file.ts @@ -0,0 +1,233 @@ +import { state, previewHeaders } from "../state"; +import { formatSchedule } from "../time"; +import { escapeHtml, feedbackMarkup } from "./helpers"; +import { renderPipelinePreview, previewFieldClass } from "./csv-preview"; + +const SCHEDULE_OPTIONS = [5, 15, 30, 60] as const; + +function connectionBadge(): string { + if (!state.connectionSummary?.instance_name) return ""; + return ` + + + ${escapeHtml(state.connectionSummary.instance_name)} + + `; +} + +function renderFileSection(): string { + return ` +
+

Source file

+ + + + + +
+ +
+
+ `; +} + +function renderStructureSection(): string { + const headers = previewHeaders(); + + const timestampOptions = headers + .map( + (h) => + `` + ) + .join(""); + + const scheduleOptions = SCHEDULE_OPTIONS.map( + (minutes) => + `` + ).join(""); + + return ` +
+

File structure

+ +
+ ${ + state.pipelineForm.hasHeaderRow + ? ` +
+
+ +
+ + + Drag the blue HEADER handle or enter a row number. + +
+ ` + : ` +
+ Header row + + Using generated labels: Column 1, Column 2, Column 3… + +
+ ` + } + +
+
+ +
+ + + Drag the green DATA START handle or enter a row number. + +
+
+ +
+ + + +
+ +
+
+ +
+ ${ + headers.length > 0 + ? `` + : `` + } + + Drag the amber TIMESTAMP handle or click the matching column header. + +
+ + +
+ +
+

Schedule

+ +
+ +
+ +
+ `; +} + +export function renderOnboardingFile(): string { + const hasPreview = state.pipelinePreview !== null; + const isFirstPipeline = state.jobs.length === 0; + + return ` +
+
+
+

${isFirstPipeline ? "Step 1 of 2" : "New pipeline"}

+

Configure your data source

+
+ ${connectionBadge()} +
+ + ${feedbackMarkup(state.pipelineFeedback)} + +
+
+ ${renderFileSection()} + ${hasPreview ? renderStructureSection() : ""} +
+ + ${renderPipelinePreview()} +
+
+ `; +} diff --git a/frontend/components/onboarding-mapping.ts b/frontend/components/onboarding-mapping.ts new file mode 100644 index 0000000..7739ec5 --- /dev/null +++ b/frontend/components/onboarding-mapping.ts @@ -0,0 +1,117 @@ +import { state } from "../state"; +import { escapeHtml, feedbackMarkup } from "./helpers"; + +function connectionBadge(): string { + if (!state.connectionSummary?.instance_name) return ""; + return ` + + + ${escapeHtml(state.connectionSummary.instance_name)} + + `; +} + +function renderMappingRow(csvColumn: string, datastreamId: string): string { + const isMapped = Boolean(datastreamId); + + const options = [ + ``, + ...state.datastreams.map( + (ds) => + `` + ), + ].join(""); + + return ` +
+ ${escapeHtml(csvColumn)} + + +
+ `; +} + +function renderValidationErrors(): string { + if (state.pipelineErrors.length === 0) return ""; + return ` +
+

Fix these before saving

+
    + ${state.pipelineErrors.map((e) => `
  • ${escapeHtml(e)}
  • `).join("")} +
+
+ `; +} + +export function renderOnboardingMapping(): string { + const { mappings } = state.pipelineForm; + const isFirstPipeline = state.jobs.length === 0; + const mappedCount = mappings.filter((m) => m.datastreamId).length; + + return ` +
+
+
+

${isFirstPipeline ? "Step 2 of 2" : "New pipeline"}

+

Map columns to datastreams

+

+ Connect each CSV source column to a HydroServer datastream. + Leave unused columns as "Not mapped." +

+
+ ${connectionBadge()} +
+ +
+
+ Source column + + HydroServer datastream +
+ + ${ + mappings.length > 0 + ? `
+ ${mappings + .map((m) => renderMappingRow(m.csvColumn, m.datastreamId)) + .join("")} +
` + : `

+ No source columns found. + +

` + } + + ${ + mappings.length > 0 + ? `

+ ${mappedCount} of ${mappings.length} column${mappings.length === 1 ? "" : "s"} mapped +

` + : "" + } +
+ + ${renderValidationErrors()} + ${feedbackMarkup(state.pipelineFeedback)} + +
+ + +
+
+ `; +} diff --git a/frontend/main.ts b/frontend/main.ts index bd1f578..471142b 100644 --- a/frontend/main.ts +++ b/frontend/main.ts @@ -1,2442 +1,85 @@ import "./generated.css"; -import appIconUrl from "../icons/icon-color.svg"; import { - clearServerConfig, - createJob, - deleteJob, - disableJob, - enableJob, - getConfig, - getCsvPreview, - getDatastreams, - getHealth, - listJobs, - runJob, - testConnection, - updateServerConfig, - validateServerUrl, - type AppConfig, - type AuthType, - type ConnectionState, - type ConnectionTestResponse, - type CsvPreviewResponse, - type DatastreamSummary, - type HealthResponse, - type JobSummary, - type ServerConfig, -} from "./api"; + state, + emptyServerConfig, + setServerDraft, + readServerConfigForm, + markField, + resetStateAuthFieldStates, + clearAuthValidationCache, + clearAuthFormFeedback, + updatePipelineField, + setPipelineHasHeaderRow, + applyPreviewLineSelection, + applyPreviewColumnSelection, + PREVIEW_PAGE_SIZE, +} from "./state"; +import { initRenderer, render } from "./render"; +import { initPreviewDragEvents, getSuppressHandleClick, clearSuppressHandleClick } from "./components/csv-preview"; import { - applyConnectionValidationResult, - createAuthFieldStates, - fieldFormFeedbackTarget, - resetAuthFieldStates, - runAuthSubmission, - validateAuthFieldsForSubmit, - type AuthFieldName, - type Feedback, - type FieldValidationState, -} from "./auth-submit"; -import { getRouteFromHash, navigate, routeHref, type AppRoute } from "./router"; -import { formatRelativeTime, formatSchedule, shortenPath } from "./time"; - -const API_KEY_DOCS_URL = - "https://hydroserver2.github.io/hydroserver/tutorials/creating-your-first-orchestration-system#create-an-api-key"; -const APP_NAME = "HydroServer Streaming Data Loader"; -const STARTUP_RETRY_ATTEMPTS = 12; -const STARTUP_RETRY_DELAY_MS = 350; -const PREVIEW_PAGE_SIZE = 50; - -type PipelineMappingDraft = { - csvColumn: string; - datastreamId: string; -}; - -type PipelineFormState = { - name: string; - filePath: string; - scheduleMinutes: number; - hasHeaderRow: boolean; - headerRow: number; - dataStartRow: number; - delimiter: string; - timestampColumn: string; - timestampFormat: string; - timezone: string; - mappings: PipelineMappingDraft[]; -}; - -type PreviewSelectionTarget = - | "header-row" - | "data-start-row" - | "timestamp-column" - | null; - -type PreviewRowSelectionTarget = Exclude< - PreviewSelectionTarget, - "timestamp-column" | null ->; - -type PreviewDragState = { - target: PreviewRowSelectionTarget; - lineNumber: number; - pointerId: number; - moved: boolean; -}; - -type PreviewColumnDragState = { - columnName: string; - pointerId: number; - moved: boolean; -}; - -type PreviewDragVisualState = { - handle: HTMLElement; - startClientY: number; - currentClientY: number; - rowButtons: Map; - rowElements: Map; - rowCenters: Array<{ lineNumber: number; centerY: number }>; - frameRequested: boolean; -}; - -type PreviewColumnDragVisualState = { - handle: HTMLElement; - startClientX: number; - currentClientX: number; - headerButtons: Map; - columnCells: Map; - headerCenters: Array<{ columnName: string; centerX: number }>; - frameRequested: boolean; -}; - -type UiState = { - route: AppRoute; - health: HealthResponse | null; - config: AppConfig | null; - jobs: JobSummary[]; - datastreams: DatastreamSummary[]; - connectionSummary: ConnectionTestResponse | null; - loading: boolean; - bootstrapError: string | null; - settingsFeedback: Feedback; - welcomeFeedback: Feedback; - pipelineFeedback: Feedback; - lastConnectionState: ConnectionState | null; - settingsEditMode: boolean; - pipelineForm: PipelineFormState; - pipelinePreview: CsvPreviewResponse | null; - pipelineErrors: string[]; - datastreamsError: string | null; - authDraft: ServerConfig; - authFieldStates: Record; - authSubmitting: boolean; - lastAuthValidationServer: ServerConfig | null; - lastAuthValidationResult: ConnectionTestResponse | null; - pipelineSelectionTarget: PreviewSelectionTarget; - pipelineDrag: PreviewDragState | null; - pipelineColumnDrag: PreviewColumnDragState | null; - pipelinePreviewRowsRequested: number; -}; - -const shellElements = { - sidebar: document.querySelector("#app-sidebar"), - mainContent: document.querySelector("#main-content"), - jobsLink: document.querySelector( - '[data-route="dashboard"]' - ), - settingsLink: document.querySelector( - '[data-route="settings"]' - ), - connectionDot: document.querySelector("#connection-status-dot"), -}; - -if ( - !shellElements.sidebar || - !shellElements.mainContent || - !shellElements.jobsLink || - !shellElements.settingsLink || - !shellElements.connectionDot -) { - throw new Error("App shell is missing required elements."); -} - -const { sidebar, mainContent, jobsLink, settingsLink, connectionDot } = - shellElements; - -let lastRenderedMarkup = ""; -let suppressPreviewHandleClick = false; -let previewDragVisual: PreviewDragVisualState | null = null; -let previewColumnDragVisual: PreviewColumnDragVisualState | null = null; - -function createEmptyPipelineForm(): PipelineFormState { - return { - name: "", - filePath: "", - scheduleMinutes: 15, - hasHeaderRow: true, - headerRow: 3, - dataStartRow: 4, - delimiter: ",", - timestampColumn: "Timestamp", - timestampFormat: "%Y-%m-%d %H:%M:%S", - timezone: "America/Denver", - mappings: [], - }; -} - -const state: UiState = { - route: getRouteFromHash(), - health: null, - config: null, - jobs: [], - datastreams: [], - connectionSummary: null, - loading: true, - bootstrapError: null, - settingsFeedback: null, - welcomeFeedback: null, - pipelineFeedback: null, - lastConnectionState: null, - settingsEditMode: false, - pipelineForm: createEmptyPipelineForm(), - pipelinePreview: null, - pipelineErrors: [], - datastreamsError: null, - authDraft: emptyServerConfig(), - authFieldStates: createAuthFieldStates(), - authSubmitting: false, - lastAuthValidationServer: null, - lastAuthValidationResult: null, - pipelineSelectionTarget: null, - pipelineDrag: null, - pipelineColumnDrag: null, - pipelinePreviewRowsRequested: PREVIEW_PAGE_SIZE, -}; - -function emptyServerConfig(): ServerConfig { - return { - auth_type: "apikey", - url: "", - api_key: "", - username: "", - password: "", - workspace_id: "", - }; -} - -window.setInterval(() => { - void refreshJobs(); -}, 30_000); - -function escapeHtml(value: string): string { - return value - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); -} - -function feedbackMarkup(feedback: Feedback): string { - if (!feedback) { - return ""; - } - - const toneClass = - feedback.tone === "success" - ? "notice-success" - : feedback.tone === "error" - ? "notice-error" - : "notice-info"; - - return `
${escapeHtml(feedback.message)}
`; -} - -function basename(path: string): string { - const segments = path.split(/[\\/]/).filter(Boolean); - return segments.at(-1) ?? path; -} - -function parseDelimitedLine(line: string, delimiter: string): string[] { - if (!delimiter) { - return [line]; - } - - const cells: string[] = []; - let current = ""; - let inQuotes = false; - - for (let index = 0; index < line.length; index += 1) { - const character = line[index]; - - if (character === '"') { - if (inQuotes && line[index + 1] === '"') { - current += '"'; - index += 1; - } else { - inQuotes = !inQuotes; - } - continue; - } - - if (!inQuotes && line.startsWith(delimiter, index)) { - cells.push(current); - current = ""; - index += delimiter.length - 1; - continue; - } - - current += character; - } - - cells.push(current); - return cells; -} - -function normalizePreviewHeaderName(value: string, index: number): string { - const cleaned = value.trim(); - return cleaned || `Column ${index + 1}`; -} - -function parsedPreviewRows(): string[][] { - if (!state.pipelinePreview) { - return []; - } - - return state.pipelinePreview.raw_lines.map((line) => - parseDelimitedLine(line, state.pipelineForm.delimiter) - ); -} - -function connected(): boolean { - return ( - state.connectionSummary?.ok === true && - state.lastConnectionState === "connected" - ); -} - -function currentServerConfig(): ServerConfig { - return state.authDraft; -} - -function resetStateAuthFieldStates(authType: AuthType): void { - resetAuthFieldStates(state.authFieldStates, authType); -} - -function serverConfigured(server: ServerConfig | null | undefined): boolean { - if (!server?.url.trim()) { - return false; - } - - if (server.auth_type === "userpass") { - return Boolean(server.username.trim() && server.password.trim()); - } - - return Boolean(server.api_key.trim()); -} - -function readServerConfigForm( - form: HTMLFormElement, - base: ServerConfig = currentServerConfig() -): ServerConfig { - const data = new FormData(form); - const authType = data.get("auth_type") === "userpass" ? "userpass" : "apikey"; - - return { - auth_type: authType, - url: String(data.get("url") ?? "").trim(), - api_key: - authType === "apikey" - ? String(data.get("api_key") ?? "").trim() - : base.api_key, - username: - authType === "userpass" - ? String(data.get("username") ?? "").trim() - : base.username, - password: - authType === "userpass" - ? String(data.get("password") ?? "").trim() - : base.password, - workspace_id: "", - }; -} - -function setServerDraft(server: ServerConfig): void { - state.authDraft = { ...server }; -} - -function markField( - field: AuthFieldName, - nextState: FieldValidationState["state"], - message: string | null = null -): void { - state.authFieldStates[field] = { state: nextState, message }; -} - -function authFieldErrorMarkup(field: AuthFieldName): string { - const fieldState = state.authFieldStates[field]; - if (fieldState.state !== "invalid" || !fieldState.message) { - return ""; - } - - return `

${escapeHtml(fieldState.message)}

`; -} - -function renderAuthInputField(params: { - label: string; - name: AuthFieldName; - type: "url" | "text" | "password"; - value: string; - placeholder: string; - helpText?: string; - labelAction?: string; -}): string { - const { label, name, type, value, placeholder, helpText, labelAction } = - params; - - return ` - - `; -} - -function clearAuthFormFeedback(formId: string): void { - state[fieldFormFeedbackTarget(formId)] = null; -} - -function clearAuthValidationCache(): void { - state.lastAuthValidationServer = null; - state.lastAuthValidationResult = null; -} - -function previewHeaders(): string[] { - const rows = parsedPreviewRows(); - const columnCount = rows.reduce((max, row) => Math.max(max, row.length), 0); - - if (!state.pipelineForm.hasHeaderRow) { - const dataRows = rows.slice(Math.max(state.pipelineForm.dataStartRow - 1, 0)); - const dataColumnCount = (dataRows.length > 0 ? dataRows : rows).reduce( - (max, row) => Math.max(max, row.length), - 0 - ); - return Array.from( - { length: dataColumnCount }, - (_, index) => `Column ${index + 1}` - ); - } - - const headerRow = rows[state.pipelineForm.headerRow - 1] ?? []; - return Array.from({ length: columnCount }, (_, index) => - normalizePreviewHeaderName(headerRow[index] ?? "", index) - ); -} - -function activePreviewRowTarget(): PreviewRowSelectionTarget | null { - if (state.pipelineDrag) { - return state.pipelineDrag.target; - } - - return state.pipelineSelectionTarget === "header-row" || - state.pipelineSelectionTarget === "data-start-row" - ? state.pipelineSelectionTarget - : null; -} - -function previewHandleLine( - target: PreviewRowSelectionTarget -): number | null { - if (state.pipelineDrag?.target === target) { - return state.pipelineDrag.lineNumber; - } - - if (target === "header-row") { - return state.pipelineForm.hasHeaderRow ? state.pipelineForm.headerRow : null; - } - - return state.pipelineForm.dataStartRow; -} - -function setPreviewRowSelectionTarget( - target: PreviewRowSelectionTarget, - lineNumber: number -): void { - if (target === "header-row") { - updateHeaderRowFromPreview(lineNumber); - return; - } - - updateDataStartRowFromPreview(lineNumber); -} - -function previewCommittedHandleLine( - target: PreviewRowSelectionTarget -): number | null { - if (target === "header-row") { - return state.pipelineForm.hasHeaderRow ? state.pipelineForm.headerRow : null; - } - - return state.pipelineForm.dataStartRow; -} - -function previewDragHandleSelector( - target: PreviewRowSelectionTarget, - lineNumber: number -): string { - return `[data-preview-handle-target="${target}"][data-preview-line="${lineNumber}"]`; -} - -function findPreviewHandleElement( - target: PreviewRowSelectionTarget, - lineNumber: number -): HTMLElement | null { - return mainContent.querySelector( - previewDragHandleSelector(target, lineNumber) - ); -} - -function collectPreviewRowButtons(): Map { - return new Map( - Array.from( - mainContent.querySelectorAll( - '[data-action="pick-preview-line"][data-preview-line]' - ) - ) - .map((button) => { - const lineNumber = Number(button.dataset.previewLine); - return Number.isFinite(lineNumber) ? [lineNumber, button] : null; - }) - .filter( - (entry): entry is [number, HTMLButtonElement] => entry !== null - ) - ); -} - -function collectPreviewRowElements(): Map { - return new Map( - Array.from( - mainContent.querySelectorAll("[data-preview-line-row]") - ) - .map((row) => { - const lineNumber = Number(row.dataset.previewLineRow); - return Number.isFinite(lineNumber) ? [lineNumber, row] : null; - }) - .filter( - (entry): entry is [number, HTMLTableRowElement] => entry !== null - ) - ); -} - -function collectPreviewRowCenters( - rowButtons: Map -): Array<{ lineNumber: number; centerY: number }> { - return Array.from(rowButtons.entries()).map(([lineNumber, button]) => { - const rect = button.getBoundingClientRect(); - return { lineNumber, centerY: rect.top + rect.height / 2 }; - }); -} - -function nearestPreviewLineNumber( - clientY: number, - rowCenters: Array<{ lineNumber: number; centerY: number }> -): number | null { - if (rowCenters.length === 0) { - return null; - } - - let bestLine = rowCenters[0].lineNumber; - let bestDistance = Math.abs(clientY - rowCenters[0].centerY); - - for (const row of rowCenters.slice(1)) { - const distance = Math.abs(clientY - row.centerY); - if (distance < bestDistance) { - bestDistance = distance; - bestLine = row.lineNumber; - } - } - - return bestLine; -} - -function activeTimestampColumn(): string { - return state.pipelineColumnDrag?.columnName ?? state.pipelineForm.timestampColumn; -} - -function findPreviewColumnHandleElement(columnName: string): HTMLElement | null { - return ( - Array.from( - mainContent.querySelectorAll("[data-preview-column-handle]") - ).find((element) => element.dataset.previewColumnHandle === columnName) ?? null - ); -} - -function collectPreviewHeaderButtons(): Map { - return new Map( - Array.from( - mainContent.querySelectorAll( - '[data-action="pick-preview-column"][data-preview-column]' - ) - ) - .map((button) => { - const columnName = button.dataset.previewColumn ?? ""; - return columnName ? [columnName, button] : null; - }) - .filter( - (entry): entry is [string, HTMLButtonElement] => entry !== null - ) - ); -} - -function collectPreviewColumnCells(): Map { - const cells = new Map(); - mainContent - .querySelectorAll("[data-preview-column-cell]") - .forEach((element) => { - const columnName = element.dataset.previewColumnCell ?? ""; - if (!columnName) { - return; - } - - const columnEntries = cells.get(columnName) ?? []; - columnEntries.push(element); - cells.set(columnName, columnEntries); - }); - return cells; -} - -function collectPreviewHeaderCenters( - headerButtons: Map -): Array<{ columnName: string; centerX: number }> { - return Array.from(headerButtons.entries()).map(([columnName, button]) => { - const rect = button.getBoundingClientRect(); - return { columnName, centerX: rect.left + rect.width / 2 }; - }); -} - -function nearestPreviewColumnName( - clientX: number, - headerCenters: Array<{ columnName: string; centerX: number }> -): string | null { - if (headerCenters.length === 0) { - return null; - } - - let bestColumn = headerCenters[0].columnName; - let bestDistance = Math.abs(clientX - headerCenters[0].centerX); - - for (const column of headerCenters.slice(1)) { - const distance = Math.abs(clientX - column.centerX); - if (distance < bestDistance) { - bestDistance = distance; - bestColumn = column.columnName; - } - } - - return bestColumn; -} - -function applyPreviewDragClasses(): void { - if (!state.pipelineDrag || !previewDragVisual) { - return; - } - - const headerLine = - state.pipelineDrag.target === "header-row" - ? state.pipelineDrag.lineNumber - : previewCommittedHandleLine("header-row"); - const dataLine = - state.pipelineDrag.target === "data-start-row" - ? state.pipelineDrag.lineNumber - : previewCommittedHandleLine("data-start-row"); - - for (const [lineNumber, button] of previewDragVisual.rowButtons.entries()) { - button.classList.toggle( - "preview-line-button-header", - state.pipelineForm.hasHeaderRow && headerLine === lineNumber - ); - button.classList.toggle("preview-line-button-data", dataLine === lineNumber); - } - - for (const [lineNumber, row] of previewDragVisual.rowElements.entries()) { - row.classList.toggle( - "preview-table-row-header", - state.pipelineForm.hasHeaderRow && headerLine === lineNumber - ); - row.classList.toggle("preview-table-row-data", dataLine === lineNumber); - } -} - -function flushPreviewDragVisual(): void { - if (!state.pipelineDrag || !previewDragVisual) { - return; - } - - previewDragVisual.frameRequested = false; - const offset = previewDragVisual.currentClientY - previewDragVisual.startClientY; - previewDragVisual.handle.style.setProperty( - "--preview-handle-offset", - `${offset}px` - ); - applyPreviewDragClasses(); -} - -function schedulePreviewDragVisual(): void { - if (!previewDragVisual || previewDragVisual.frameRequested) { - return; - } - - previewDragVisual.frameRequested = true; - window.requestAnimationFrame(flushPreviewDragVisual); -} - -function beginPreviewDragVisual(pointerClientY: number): void { - if (!state.pipelineDrag) { - return; - } - - const handle = findPreviewHandleElement( - state.pipelineDrag.target, - state.pipelineDrag.lineNumber - ); - if (!handle) { - return; - } - - const rowButtons = collectPreviewRowButtons(); - previewDragVisual = { - handle, - startClientY: pointerClientY, - currentClientY: pointerClientY, - rowButtons, - rowElements: collectPreviewRowElements(), - rowCenters: collectPreviewRowCenters(rowButtons), - frameRequested: false, - }; - - mainContent - .querySelectorAll(".preview-row-handle-active") - .forEach((element) => - element.classList.remove("preview-row-handle-active") - ); - handle.classList.add("preview-row-handle-active"); - handle.classList.add("preview-row-handle-dragging"); - handle.style.setProperty("--preview-handle-offset", "0px"); - applyPreviewDragClasses(); -} - -function endPreviewDragVisual(): void { - if (!previewDragVisual) { - return; - } - - if ( - state.pipelineDrag && - typeof previewDragVisual.handle.releasePointerCapture === "function" && - previewDragVisual.handle.hasPointerCapture(state.pipelineDrag.pointerId) - ) { - previewDragVisual.handle.releasePointerCapture(state.pipelineDrag.pointerId); - } - - previewDragVisual.handle.classList.remove("preview-row-handle-dragging"); - previewDragVisual.handle.style.removeProperty("--preview-handle-offset"); - previewDragVisual = null; -} - -function applyPreviewColumnDragClasses(): void { - if (!state.pipelineColumnDrag || !previewColumnDragVisual) { - return; - } - - for (const [columnName, cells] of previewColumnDragVisual.columnCells.entries()) { - const active = columnName === state.pipelineColumnDrag.columnName; - for (const cell of cells) { - cell.classList.toggle("preview-col-timestamp", active); - } - } -} - -function flushPreviewColumnDragVisual(): void { - if (!state.pipelineColumnDrag || !previewColumnDragVisual) { - return; - } - - previewColumnDragVisual.frameRequested = false; - const offset = - previewColumnDragVisual.currentClientX - previewColumnDragVisual.startClientX; - previewColumnDragVisual.handle.style.setProperty( - "--preview-column-handle-offset", - `${offset}px` - ); - applyPreviewColumnDragClasses(); -} - -function schedulePreviewColumnDragVisual(): void { - if (!previewColumnDragVisual || previewColumnDragVisual.frameRequested) { - return; - } - - previewColumnDragVisual.frameRequested = true; - window.requestAnimationFrame(flushPreviewColumnDragVisual); -} - -function beginPreviewColumnDragVisual(pointerClientX: number): void { - if (!state.pipelineColumnDrag) { - return; - } - - const handle = findPreviewColumnHandleElement(state.pipelineColumnDrag.columnName); - if (!handle) { - return; - } - - const headerButtons = collectPreviewHeaderButtons(); - previewColumnDragVisual = { - handle, - startClientX: pointerClientX, - currentClientX: pointerClientX, - headerButtons, - columnCells: collectPreviewColumnCells(), - headerCenters: collectPreviewHeaderCenters(headerButtons), - frameRequested: false, - }; - - handle.classList.add("preview-column-handle-dragging"); - handle.style.setProperty("--preview-column-handle-offset", "0px"); - applyPreviewColumnDragClasses(); -} - -function endPreviewColumnDragVisual(): void { - if (!previewColumnDragVisual) { - return; - } - - if ( - state.pipelineColumnDrag && - typeof previewColumnDragVisual.handle.releasePointerCapture === "function" && - previewColumnDragVisual.handle.hasPointerCapture( - state.pipelineColumnDrag.pointerId - ) - ) { - previewColumnDragVisual.handle.releasePointerCapture( - state.pipelineColumnDrag.pointerId - ); - } - - previewColumnDragVisual.handle.classList.remove( - "preview-column-handle-dragging" - ); - previewColumnDragVisual.handle.style.removeProperty( - "--preview-column-handle-offset" - ); - previewColumnDragVisual = null; -} - -function pipelineMappingsByColumn(): Map { - return new Map( - state.pipelineForm.mappings.map((mapping) => [ - mapping.csvColumn, - mapping.datastreamId, - ]) - ); -} - -function previewColumnClass(columnName: string): string { - if (columnName === state.pipelineForm.timestampColumn) { - return "preview-col-timestamp"; - } - - const mapped = state.pipelineForm.mappings.find( - (mapping) => mapping.csvColumn === columnName && mapping.datastreamId - ); - return mapped ? "preview-col-mapped" : ""; -} - -function previewFieldClass( - target: Exclude -): string { - const active = - target === "timestamp-column" - ? state.pipelineSelectionTarget === target || state.pipelineColumnDrag !== null - : activePreviewRowTarget() === target; - const toneClass = - target === "header-row" - ? "preview-bound-field-header" - : target === "data-start-row" - ? "preview-bound-field-data" - : "preview-bound-field-timestamp"; - - return active - ? `field preview-bound-field preview-bound-field-active ${toneClass}` - : "field preview-bound-field"; -} - -function previewGuidanceText(): string { - const activeTarget = activePreviewRowTarget(); - - if (activeTarget === "header-row") { - return "Drag the HEADER handle, or click a row to place it."; - } - - if (activeTarget === "data-start-row") { - return "Drag the DATA START handle, or click the first data row."; - } - - if ( - state.pipelineSelectionTarget === "timestamp-column" || - state.pipelineColumnDrag - ) { - return "Drag the TIMESTAMP handle, or click a column header to place it."; - } - - return state.pipelineForm.hasHeaderRow - ? "Drag the HEADER, DATA START, and TIMESTAMP handles, or click a row or column to place them." - : "Drag the DATA START and TIMESTAMP handles, or click a row or column to place them."; -} - -function syncPipelineSelectionsWithPreview(): void { - const headers = previewHeaders(); - - if (headers.length === 0) { - state.pipelineForm.mappings = []; - return; - } - - const preferredTimestamp = - headers.find((header) => header.toLowerCase().includes("time")) ?? - headers[0]; - - state.pipelineForm.timestampColumn = headers.includes( - state.pipelineForm.timestampColumn - ) - ? state.pipelineForm.timestampColumn - : preferredTimestamp; - - initializeMappings(headers); -} - -function initializeMappings(headers: string[]): void { - const existing = pipelineMappingsByColumn(); - state.pipelineForm.mappings = headers - .filter((header) => header !== state.pipelineForm.timestampColumn) - .map((header) => ({ - csvColumn: header, - datastreamId: existing.get(header) ?? "", - })); -} - -function applyPreview(path: string, preview: CsvPreviewResponse): void { - state.pipelinePreview = preview; - state.pipelineForm.filePath = path; - state.pipelineForm.hasHeaderRow = preview.detected_header_row !== null; - state.pipelineForm.headerRow = - preview.detected_header_row ?? state.pipelineForm.headerRow; - state.pipelineForm.dataStartRow = - preview.detected_data_start_row ?? state.pipelineForm.dataStartRow; - state.pipelineForm.delimiter = - preview.detected_delimiter || state.pipelineForm.delimiter; - state.pipelineSelectionTarget = null; - state.pipelineDrag = null; - state.pipelineColumnDrag = null; - - if (!state.pipelineForm.name.trim()) { - const inferred = basename(path).replace(/\.[^.]+$/, ""); - state.pipelineForm.name = inferred; - } - - syncPipelineSelectionsWithPreview(); -} - -function canShowMorePreviewLines(): boolean { - if (!state.pipelinePreview) { - return false; - } - - return state.pipelinePreview.raw_lines.length < state.pipelinePreview.total_lines; -} - -function updateHeaderRowFromPreview(lineNumber: number): void { - state.pipelineForm.hasHeaderRow = true; - state.pipelineForm.headerRow = lineNumber; - if (state.pipelineForm.dataStartRow <= lineNumber) { - state.pipelineForm.dataStartRow = lineNumber + 1; - } - syncPipelineSelectionsWithPreview(); -} - -function updateDataStartRowFromPreview(lineNumber: number): void { - state.pipelineForm.dataStartRow = Math.max( - state.pipelineForm.hasHeaderRow ? 2 : 1, - lineNumber - ); - if ( - state.pipelineForm.hasHeaderRow && - state.pipelineForm.headerRow >= state.pipelineForm.dataStartRow - ) { - state.pipelineForm.headerRow = state.pipelineForm.dataStartRow - 1; - } - syncPipelineSelectionsWithPreview(); -} - -function setPipelineHasHeaderRow(enabled: boolean): void { - state.pipelineForm.hasHeaderRow = enabled; - - if (!enabled && state.pipelineSelectionTarget === "header-row") { - state.pipelineSelectionTarget = null; - } - - if (!enabled && state.pipelineDrag?.target === "header-row") { - state.pipelineDrag = null; - } - - if ( - enabled && - state.pipelineForm.headerRow >= state.pipelineForm.dataStartRow - ) { - state.pipelineForm.headerRow = Math.max( - 1, - state.pipelineForm.dataStartRow - 1 - ); - } - - syncPipelineSelectionsWithPreview(); -} - -function applyPreviewLineSelection(lineNumber: number): void { - if (state.pipelineSelectionTarget === "header-row") { - setPreviewRowSelectionTarget("header-row", lineNumber); - state.pipelineSelectionTarget = null; - render(); - return; - } - - if (state.pipelineSelectionTarget === "data-start-row") { - setPreviewRowSelectionTarget("data-start-row", lineNumber); - state.pipelineSelectionTarget = null; - render(); - } -} - -function applyPreviewColumnSelection(columnName: string): void { - if ( - state.pipelineSelectionTarget && - state.pipelineSelectionTarget !== "timestamp-column" - ) { - return; - } - - state.pipelineForm.timestampColumn = columnName; - initializeMappings(previewHeaders()); - state.pipelineSelectionTarget = null; - state.pipelineColumnDrag = null; - render(); -} - -function onboardingRoute(route: AppRoute): boolean { - return ( - route === "welcome" || (route === "jobs-new" && state.jobs.length === 0) - ); -} - -function connectionIndicator(): { label: string; className: string } { - if (!serverConfigured(state.config?.server)) { - return { - label: "HydroServer not configured", - className: "status-dot bg-slate-300", - }; - } - - if (connected()) { - return { - label: "Connected to HydroServer", - className: "status-dot bg-emerald-500", - }; - } - - if (state.lastConnectionState === "error") { - return { - label: "HydroServer authentication error", - className: "status-dot bg-rose-500", - }; - } - - return { - label: "HydroServer configured", - className: "status-dot bg-sky-500", - }; -} - -function statusPill(job: JobSummary): string { - const classes: Record = { - healthy: "pill-success", - warning: "pill-warning", - error: "pill-danger", - disabled: "pill-muted", - pending: "pill-info", - running: "pill-info", - }; - - return `${escapeHtml( - job.status_message - )}`; -} - -function renderConnectedCard(showActions: boolean): string { - if (!connected() || !state.connectionSummary) { - return ""; - } - - const datastreamText = - state.connectionSummary.datastream_count === 1 - ? "1 datastream available" - : `${state.connectionSummary.datastream_count} datastreams available`; - - return ` -
-
-

Authenticated

-

${escapeHtml( - state.connectionSummary.instance_name ?? "HydroServer" - )}

-

${escapeHtml( - state.connectionSummary.message - )}

-
- Connected - ${escapeHtml(datastreamText)} -
-
- ${ - showActions - ? ` -
- - - ${ - state.jobs.length === 0 - ? `Create first pipeline` - : "" - } -
- ` - : "" - } -
- `; -} - -function renderAuthForm( - formId: "welcome-form" | "settings-form", - submitLabel: string, - secondaryAction: string -): string { - const server = currentServerConfig(); - const usingUserPass = server.auth_type === "userpass"; - const authToggleLabel = usingUserPass - ? "Connect with an API key" - : "Connect with username and password"; - const submitDisabled = state.authSubmitting ? "disabled" : ""; - const submitLabelText = state.authSubmitting ? "Connecting..." : submitLabel; - - return ` -
-
-
- HydroServer Streaming Data Loader icon -

Connect to HydroServer

-
- - - ${renderAuthInputField({ - label: "Host URL", - name: "url", - type: "url", - value: server.url, - placeholder: "https://playground.hydroserver.org", - })} - - ${ - usingUserPass - ? ` - ${renderAuthInputField({ - label: "Username", - name: "username", - type: "text", - value: server.username, - placeholder: "name@example.com", - })} - ${renderAuthInputField({ - label: "Password", - name: "password", - type: "password", - value: server.password, - placeholder: "Enter your HydroServer password", - })} - ` - : ` - ${renderAuthInputField({ - label: "API key", - name: "api_key", - type: "password", - value: server.api_key, - placeholder: - "KaTz74swGqHn__I2VY6ceIzrIxC04oDhUrLLgBTH9ACxYIunmkrdmqk", - labelAction: `How to create an API key →`, - })} - ` - } - -
- or - - -
- -
- ${secondaryAction} - -
-
-
- `; -} - -function renderWelcome(): string { - return ` -
- ${renderAuthForm("welcome-form", "Connect to HydroServer", "")} -
- `; -} - -function renderSettings(): string { - const showForm = !connected() || state.settingsEditMode; - - return ` -
- - - ${ - showForm - ? renderAuthForm( - "settings-form", - "Save and verify", - connected() - ? '' - : "" - ) - : renderConnectedCard(true) - } -
- `; -} - -function renderDashboard(): string { - if (state.jobs.length === 0) { - return ` -
- -
- `; - } - - const cards = state.jobs - .map((job) => { - const lastLine = job.last_error - ? `Failed ${formatRelativeTime(job.last_run_at)}` - : `Last pushed ${formatRelativeTime(job.last_pushed_timestamp)}`; - - return ` -
-
-
-
- -

${escapeHtml(job.name)}

-
-

${escapeHtml( - shortenPath(job.file_path) - )}

-

- ${escapeHtml(lastLine)} · ${escapeHtml( - formatSchedule(job.schedule_minutes) - )} -

-
- ${statusPill(job)} -
- -
- - - -
-
- `; - }) - .join(""); - - return ` -
- -
${cards}
-
- `; -} - -function renderPreviewHandle( - target: PreviewRowSelectionTarget, - lineNumber: number -): string { - const handleLine = previewHandleLine(target); - if (handleLine !== lineNumber) { - return ""; - } - - const active = activePreviewRowTarget() === target; - const label = target === "header-row" ? "HEADER" : "DATA START"; - const className = - target === "header-row" - ? "preview-row-handle preview-row-handle-header" - : "preview-row-handle preview-row-handle-data"; - - return ` - - `; -} - -function renderTimestampHandle(columnName: string): string { - if (columnName !== activeTimestampColumn()) { - return ""; - } - - const active = - state.pipelineSelectionTarget === "timestamp-column" || - state.pipelineColumnDrag !== null; - - return ` - - `; -} - -function renderPipelinePreview(): string { - if (!state.pipelinePreview) { - return ` -
-
-
CSV
-

Preview a source file

-

Choose a CSV file path, then load the preview to inspect the first 50 lines and map the source structure into HydroServer.

-
-
- `; - } - - const headers = previewHeaders(); - const parsedRows = parsedPreviewRows().map((row, index) => ({ - lineNumber: index + 1, - row, - })); - const headerLine = previewHandleLine("header-row"); - const dataStartLine = previewHandleLine("data-start-row"); - - const headerCells = headers - .map( - (header) => - ` -
- ${renderTimestampHandle(header)} - -
- ` - ) - .join(""); - - const tableRows = parsedRows - .map( - ({ lineNumber, row }) => ` - - -
- ${ - state.pipelineForm.hasHeaderRow - ? renderPreviewHandle("header-row", lineNumber) - : "" - } - ${renderPreviewHandle("data-start-row", lineNumber)} - -
- - ${headers - .map((columnName, index) => { - const cell = row[index] ?? ""; - return `${escapeHtml(cell)}`; - }) - .join("")} - - ` - ) - .join(""); - const shownLines = state.pipelinePreview.raw_lines.length; - const remainingLines = Math.max(state.pipelinePreview.total_lines - shownLines, 0); - const nextPageSize = Math.min(PREVIEW_PAGE_SIZE, remainingLines); - const showMoreButton = canShowMorePreviewLines() - ? ` - - ` - : ""; - - return ` -
-
-
-

Preview

-

${escapeHtml( - basename(state.pipelineForm.filePath) - )}

-

${escapeHtml(previewGuidanceText())}

-
-
- - - -
- - - - - ${headerCells} - - - - ${tableRows} - -
Line
-
- -
- - Showing the first ${shownLines} lines of ${state.pipelinePreview.total_lines} - - ${showMoreButton} -
-
- `; -} - -function renderPipelineMappings(): string { - const availableMappings = state.pipelineForm.mappings; - - if (!state.pipelinePreview || availableMappings.length === 0) { - return ` -
-

Column mappings

-

Load a CSV preview first so HydroServer Streaming Data Loader can list the available source columns.

-
- `; - } - - const rows = availableMappings - .map((mapping) => { - const options = [ - ``, - ...state.datastreams.map( - (datastream) => - `` - ), - ].join(""); - - return ` -
-
-

${escapeHtml(mapping.csvColumn)}

-

Source column

-
- -
- `; - }) - .join(""); - - return ` -
-

Column mappings

-

Map each source column to a HydroServer datastream. Leave any unused source columns as “Not mapped.”

-
${rows}
-
- `; -} - -function renderFirstPipelineOnboarding(): string { - return ` -
-
- - -
- -
-
- - ${feedbackMarkup(state.pipelineFeedback)} - ${state.pipelinePreview ? renderPipelinePreview() : ""} -
- `; -} - -function renderPipelineEditor(): string { - const firstRunOnboarding = state.jobs.length === 0; - const shellClass = firstRunOnboarding - ? "page-shell onboarding-shell animate-fade-in" - : "page-shell animate-fade-in"; - - if (!connected()) { - return renderWelcome(); - } - - if (firstRunOnboarding) { - return renderFirstPipelineOnboarding(); - } - - if (state.datastreamsError) { - return ` -
- - - ${renderConnectedCard(true)} -
${escapeHtml(state.datastreamsError)}
-
- `; - } - - if (state.datastreams.length === 0) { - return ` -
- - - ${renderConnectedCard(true)} - - Open the HydroServer 101 tutorial - -
- `; - } - - const timestampOptions = previewHeaders() - .map( - (header) => - `` - ) - .join(""); - - const pipelineErrorMarkup = - state.pipelineErrors.length > 0 - ? ` -
-

Fix these issues before saving

-
    - ${state.pipelineErrors - .map((error) => `
  • ${escapeHtml(error)}
  • `) - .join("")} -
-
- ` - : ""; - - return ` -
- - - ${renderConnectedCard(true)} - -
-
-
-

Pipeline details

- - - - - -
- -
- - -
- -
-

File structure

- -
- ${ - state.pipelineForm.hasHeaderRow - ? ` -
-
- -
- - Drag the blue HEADER handle in the preview or enter a row number. -
- ` - : ` -
- Header row - This file is using generated column labels: Column 1, Column 2, Column 3... -
- ` - } - -
-
- -
- - Drag the green DATA START handle in the preview or enter a row number. -
-
- -
- - - -
- -
-
- -
- ${ - previewHeaders().length > 0 - ? `` - : `` - } - Drag the amber TIMESTAMP handle in the preview, or click the matching header. -
- - -
- - ${renderPipelineMappings()} - ${pipelineErrorMarkup} - ${feedbackMarkup(state.pipelineFeedback)} - -
- -
-
- - ${renderPipelinePreview()} -
-
- `; -} - -function renderFatalError(): string { - return ` -
-
-

Sidecar error

-

The background process is unavailable

-

${escapeHtml( - state.bootstrapError ?? - `${APP_NAME} could not reach the local background service.` - )}

- -
-
- `; -} - -function render(): void { - state.route = getRouteFromHash(); - - let currentRoute = getRouteFromHash(); - - if (!state.loading && !state.bootstrapError) { - if ( - !connected() && - currentRoute !== "settings" && - currentRoute !== "welcome" - ) { - navigate("welcome"); - currentRoute = "welcome"; - } else if ( - connected() && - state.jobs.length === 0 && - (currentRoute === "dashboard" || currentRoute === "welcome") - ) { - navigate("jobs-new"); - currentRoute = "jobs-new"; - } - } - - const inOnboardingRoute = onboardingRoute(currentRoute); - const showSidebar = !inOnboardingRoute && !state.bootstrapError; - const useWelcomeSurface = Boolean( - state.loading || state.bootstrapError || inOnboardingRoute - ); - sidebar.classList.toggle("hidden", !showSidebar); - mainContent.classList.toggle("main-content-welcome", useWelcomeSurface); - document.body.classList.toggle("app-surface-welcome", useWelcomeSurface); - - jobsLink.className = - currentRoute === "dashboard" ? "nav-item nav-item-active" : "nav-item"; - settingsLink.className = - currentRoute === "settings" ? "nav-item nav-item-active" : "nav-item"; - - const status = connectionIndicator(); - connectionDot.className = status.className; - connectionDot.title = status.label; - - let nextMarkup = ""; - - if (state.loading) { - nextMarkup = ` -
- -
- `; - } else if (state.bootstrapError) { - nextMarkup = renderFatalError(); - } else if (currentRoute === "settings") { - nextMarkup = renderSettings(); - } else if (currentRoute === "welcome") { - nextMarkup = renderWelcome(); - } else if (currentRoute === "jobs-new") { - nextMarkup = renderPipelineEditor(); - } else { - nextMarkup = renderDashboard(); - } - - if (nextMarkup !== lastRenderedMarkup) { - mainContent.innerHTML = nextMarkup; - lastRenderedMarkup = nextMarkup; - } -} - -function sleep(ms: number): Promise { - return new Promise((resolve) => window.setTimeout(resolve, ms)); -} - -function isTransientBootstrapError(error: unknown): boolean { - if (!(error instanceof Error)) { - return false; - } - - const message = error.message.toLowerCase(); - return ( - message.includes("failed to fetch") || - message.includes("networkerror") || - message.includes("status 500") || - message.includes("status 502") || - message.includes("status 503") || - message.includes("status 504") - ); -} - -async function loadInitialStateWithRetry(): Promise<{ - health: HealthResponse; - config: AppConfig; - jobs: JobSummary[]; -}> { - let lastError: unknown = null; - - for (let attempt = 1; attempt <= STARTUP_RETRY_ATTEMPTS; attempt += 1) { - try { - const [health, config, jobs] = await Promise.all([ - getHealth(), - getConfig(), - listJobs(), - ]); - return { health, config, jobs }; - } catch (error) { - lastError = error; - - if ( - attempt === STARTUP_RETRY_ATTEMPTS || - !isTransientBootstrapError(error) - ) { - throw error; - } - - await sleep(STARTUP_RETRY_DELAY_MS); - } - } - - throw lastError instanceof Error - ? lastError - : new Error(`Failed to load ${APP_NAME}.`); -} - -async function syncAuthenticationStatus( - server: ServerConfig -): Promise { - const result = await testConnection(server); - state.lastAuthValidationServer = server; - state.lastAuthValidationResult = result; - state.connectionSummary = result; - state.lastConnectionState = result.state; - - if (result.ok && result.workspace_id) { - if (state.config) { - state.config.server.workspace_id = result.workspace_id; - } - state.authDraft.workspace_id = result.workspace_id; - } - - if (!result.ok) { - state.datastreams = []; - state.datastreamsError = null; - } - - return result; -} - -async function loadDatastreams(): Promise { - try { - state.datastreams = await getDatastreams(); - state.datastreamsError = null; - } catch (error) { - state.datastreams = []; - state.datastreamsError = - error instanceof Error - ? error.message - : "Couldn't load HydroServer datastreams."; - } -} - -async function bootstrap(): Promise { - state.loading = true; - state.bootstrapError = null; - state.welcomeFeedback = null; - state.settingsFeedback = null; - render(); - - try { - const { health, config, jobs } = await loadInitialStateWithRetry(); - state.health = health; - state.config = config; - state.authDraft = { - ...emptyServerConfig(), - ...config.server, - }; - state.jobs = jobs; - state.lastConnectionState = health.connection.state; - - if (serverConfigured(config.server)) { - const result = await syncAuthenticationStatus(config.server); - if (result.ok) { - await loadDatastreams(); - } - } - } catch (error) { - state.bootstrapError = - error instanceof Error ? error.message : `Failed to load ${APP_NAME}.`; - } finally { - state.loading = false; - render(); - } -} - -async function refreshJobs(): Promise { - if (state.bootstrapError || state.loading) { - return; - } - - try { - state.jobs = await listJobs(); - render(); - } catch { - // Keep existing UI state on polling failure. - } -} - -function updatePipelineField(name: string, value: string): void { - switch (name) { - case "pipeline_name": - state.pipelineForm.name = value; - break; - case "file_path": - state.pipelineForm.filePath = value; - state.pipelinePreview = null; - state.pipelineErrors = []; - state.pipelineSelectionTarget = null; - state.pipelineDrag = null; - state.pipelineColumnDrag = null; - state.pipelinePreviewRowsRequested = PREVIEW_PAGE_SIZE; - break; - case "schedule_minutes": - state.pipelineForm.scheduleMinutes = Number(value) || 15; - break; - case "header_row": - state.pipelineForm.headerRow = Number(value) || 1; - syncPipelineSelectionsWithPreview(); - break; - case "data_start_row": - state.pipelineForm.dataStartRow = Number(value) || 1; - syncPipelineSelectionsWithPreview(); - break; - case "delimiter": - state.pipelineForm.delimiter = value || ","; - syncPipelineSelectionsWithPreview(); - break; - case "timestamp_column": - state.pipelineForm.timestampColumn = value; - initializeMappings(previewHeaders()); - state.pipelineColumnDrag = null; - render(); - break; - case "timestamp_format": - state.pipelineForm.timestampFormat = value; - break; - case "timezone": - state.pipelineForm.timezone = value; - break; - default: - break; - } -} - -function validatePipeline(): string[] { - const errors: string[] = []; - const headers = previewHeaders(); - const selectedMappings = state.pipelineForm.mappings.filter( - (mapping) => mapping.datastreamId - ); - const datastreamIds = new Set( - state.datastreams.map((datastream) => datastream.id) - ); - const seenTargets = new Set(); - - if (!connected()) { - errors.push("Connect to HydroServer before saving a pipeline."); - } - - if (!state.pipelineForm.name.trim()) { - errors.push("Give the pipeline a name."); - } - - if (!state.pipelineForm.filePath.trim()) { - errors.push(`Choose the CSV file ${APP_NAME} should watch.`); - } - - if (!state.pipelinePreview) { - errors.push("Load a CSV preview before saving the pipeline."); - } - - if (state.pipelineForm.hasHeaderRow && state.pipelineForm.headerRow < 1) { - errors.push("Header row must be 1 or greater."); - } - - if ( - state.pipelineForm.hasHeaderRow && - state.pipelineForm.dataStartRow <= state.pipelineForm.headerRow - ) { - errors.push("Data start row must come after the header row."); - } - - if (!state.pipelineForm.hasHeaderRow && state.pipelineForm.dataStartRow < 1) { - errors.push("Data start row must be 1 or greater."); - } - - if ( - headers.length > 0 && - !headers.includes(state.pipelineForm.timestampColumn) - ) { - errors.push( - "Choose a timestamp column that exists in the previewed CSV header." - ); - } - - if (selectedMappings.length === 0) { - errors.push("Map at least one source column to a HydroServer datastream."); - } - - for (const mapping of selectedMappings) { - if (!datastreamIds.has(mapping.datastreamId)) { - errors.push( - `The selected target for ${mapping.csvColumn} is not a valid HydroServer datastream.` - ); - } - - if (seenTargets.has(mapping.datastreamId)) { - errors.push( - "Each target datastream can only be mapped once in this first-run flow." - ); - } - - seenTargets.add(mapping.datastreamId); - } - - return errors; -} - -async function loadPipelinePreview( - path: string, - rows = PREVIEW_PAGE_SIZE -): Promise { - if (!path.trim()) { - state.pipelineFeedback = { - tone: "error", - message: "Enter or choose a CSV file path first.", - }; - render(); - return; - } - - try { - const preview = await getCsvPreview(path.trim(), rows); - applyPreview(path.trim(), preview); - state.pipelinePreviewRowsRequested = rows; - state.pipelineErrors = []; - state.pipelineFeedback = null; - } catch (error) { - state.pipelinePreview = null; - state.pipelineSelectionTarget = null; - state.pipelineDrag = null; - state.pipelineColumnDrag = null; - state.pipelinePreviewRowsRequested = PREVIEW_PAGE_SIZE; - state.pipelineFeedback = { - tone: "error", - message: - error instanceof Error - ? error.message - : "Couldn't preview that CSV file.", - }; - } - - render(); -} - -async function browseForCsvPath(): Promise { - try { - const dialog = await import("@tauri-apps/plugin-dialog"); - const selection = await dialog.open({ - directory: false, - multiple: false, - filters: [{ name: "CSV files", extensions: ["csv", "txt"] }], - }); - - if (typeof selection !== "string" || !selection) { - return; - } - - state.pipelineForm.filePath = selection; - if (!state.pipelineForm.name.trim()) { - state.pipelineForm.name = basename(selection).replace(/\.[^.]+$/, ""); - } - - await loadPipelinePreview(selection); - } catch { - state.pipelineFeedback = { - tone: "info", - message: - "The native file picker is only available in the desktop app. Enter the CSV path manually if you're using the browser preview.", - }; - render(); - } -} - -async function saveAuthenticatedServerConfig( - form: HTMLFormElement -): Promise { - if (state.authSubmitting) { - return; - } - - const payload = readServerConfigForm(form); - setServerDraft(payload); - - const feedbackKey = fieldFormFeedbackTarget(form.id); - - state[feedbackKey] = null; - resetStateAuthFieldStates(payload.auth_type); - - if (!validateAuthFieldsForSubmit(payload, markField)) { - render(); - return; - } - - try { - await runAuthSubmission({ - render, - setSubmitting: (value) => { - state.authSubmitting = value; - }, - action: async () => { - const urlValidation = await validateServerUrl(payload.url); - if (!urlValidation.ok) { - clearAuthValidationCache(); - markField("url", "invalid", urlValidation.message); - state[feedbackKey] = { - tone: "error", - message: urlValidation.message, - }; - return; - } - - markField("url", "valid"); - - const result = await syncAuthenticationStatus(payload); - applyConnectionValidationResult(payload, result, markField); - if (!result.ok) { - state[feedbackKey] = { tone: "error", message: result.message }; - return; - } - - state.config = await updateServerConfig(payload); - state.authDraft = { - ...emptyServerConfig(), - ...state.config.server, - }; - await syncAuthenticationStatus(state.config.server); - await loadDatastreams(); - state[feedbackKey] = { tone: "success", message: result.message }; - state.settingsEditMode = false; - - if (state.jobs.length === 0) { - navigate("jobs-new"); - } else { - navigate("dashboard"); - } - }, - }); - } catch (error) { - clearAuthValidationCache(); - state[feedbackKey] = { - tone: "error", - message: - error instanceof Error - ? error.message - : "Couldn't verify the HydroServer connection.", - }; - state.lastConnectionState = "error"; - render(); - } -} - -async function disconnectHydroServer(): Promise { - try { - state.config = await clearServerConfig(); - state.authDraft = emptyServerConfig(); - state.connectionSummary = null; - state.lastConnectionState = "not_configured"; - state.datastreams = []; - state.datastreamsError = null; - state.welcomeFeedback = null; - state.settingsFeedback = null; - state.settingsEditMode = false; - resetStateAuthFieldStates("apikey"); - clearAuthValidationCache(); - navigate("welcome"); - } catch (error) { - state.settingsFeedback = { - tone: "error", - message: - error instanceof Error - ? error.message - : "Couldn't disconnect from HydroServer right now.", - }; - } + bootstrap, + refreshJobs, + loadPipelinePreview, + browseForCsvPath, + saveAuthenticatedServerConfig, + disconnectHydroServer, + savePipeline, + handleRunJob, + handleToggleJob, + handleDeleteJob, +} from "./actions"; +import type { AuthType } from "./api"; +import { navigate } from "./router"; + +// ── App shell elements ───────────────────────────────────────────────────── +const shellElements = { + sidebar: document.querySelector("#app-sidebar"), + mainContent: document.querySelector("#main-content"), + jobsLink: document.querySelector('[data-route="dashboard"]'), + settingsLink: document.querySelector('[data-route="settings"]'), + connectionDot: document.querySelector("#connection-status-dot"), +}; - render(); +if ( + !shellElements.sidebar || + !shellElements.mainContent || + !shellElements.jobsLink || + !shellElements.settingsLink || + !shellElements.connectionDot +) { + throw new Error("App shell is missing required elements."); } -async function savePipeline(): Promise { - state.pipelineErrors = validatePipeline(); - - if (state.pipelineErrors.length > 0) { - state.pipelineFeedback = { - tone: "error", - message: `${APP_NAME} needs a little more information before it can save this pipeline.`, - }; - render(); - return; - } - - const mappedColumns = state.pipelineForm.mappings - .filter((mapping) => mapping.datastreamId) - .map((mapping) => { - const datastream = state.datastreams.find( - (item) => item.id === mapping.datastreamId - ); - return { - csv_column: mapping.csvColumn, - datastream_id: mapping.datastreamId, - datastream_name: datastream?.name ?? mapping.datastreamId, - }; - }); - - try { - const created = await createJob({ - name: state.pipelineForm.name.trim(), - enabled: true, - file_path: state.pipelineForm.filePath.trim(), - schedule_minutes: state.pipelineForm.scheduleMinutes, - file_config: { - header_row: state.pipelineForm.hasHeaderRow - ? state.pipelineForm.headerRow - : 0, - data_start_row: state.pipelineForm.dataStartRow, - delimiter: state.pipelineForm.delimiter, - timestamp_column: state.pipelineForm.timestampColumn, - timestamp_format: state.pipelineForm.timestampFormat, - timezone: state.pipelineForm.timezone, - }, - column_mappings: mappedColumns, - }); +const { sidebar, mainContent, jobsLink, settingsLink, connectionDot } = shellElements; - state.jobs = [...state.jobs, created]; - state.pipelineForm = createEmptyPipelineForm(); - state.pipelinePreview = null; - state.pipelineSelectionTarget = null; - state.pipelineDrag = null; - state.pipelineColumnDrag = null; - state.pipelinePreviewRowsRequested = PREVIEW_PAGE_SIZE; - state.pipelineErrors = []; - state.pipelineFeedback = { tone: "success", message: "Pipeline saved." }; - navigate("dashboard"); - } catch (error) { - state.pipelineFeedback = { - tone: "error", - message: - error instanceof Error ? error.message : "Couldn't save that pipeline.", - }; - } +// ── Initialize renderer and drag events ─────────────────────────────────── +initRenderer({ sidebar, mainContent, jobsLink, settingsLink, connectionDot }); +initPreviewDragEvents(mainContent, render); - render(); -} +// ── Background polling ───────────────────────────────────────────────────── +window.setInterval(() => void refreshJobs(), 30_000); +// ── Route changes ────────────────────────────────────────────────────────── window.addEventListener("hashchange", () => { state.settingsFeedback = null; render(); }); +// ── Form submission ──────────────────────────────────────────────────────── mainContent.addEventListener("submit", (event) => { - const target = event.target; - if (!(target instanceof HTMLFormElement)) { - return; - } - + const form = event.target; + if (!(form instanceof HTMLFormElement)) return; event.preventDefault(); - if (target.id === "welcome-form") { - void saveAuthenticatedServerConfig(target); - return; - } - - if (target.id === "settings-form") { - void saveAuthenticatedServerConfig(target); - return; - } - - if (target.id === "pipeline-form") { - if (!state.pipelinePreview) { - void loadPipelinePreview(state.pipelineForm.filePath); - return; - } - - void savePipeline(); + if (form.id === "welcome-form" || form.id === "settings-form") { + void saveAuthenticatedServerConfig(form); } }); +// ── Live input updates ───────────────────────────────────────────────────── mainContent.addEventListener("input", (event) => { const target = event.target; - if ( !( target instanceof HTMLInputElement || @@ -2447,15 +90,12 @@ mainContent.addEventListener("input", (event) => { return; } - if ( - target.form?.id === "welcome-form" || - target.form?.id === "settings-form" - ) { + // Auth forms: keep draft in sync and clear stale validation. + if (target.form?.id === "welcome-form" || target.form?.id === "settings-form") { const form = target.form; setServerDraft(readServerConfigForm(form)); clearAuthFormFeedback(form.id); clearAuthValidationCache(); - if ( target instanceof HTMLInputElement && (target.name === "url" || @@ -2463,32 +103,29 @@ mainContent.addEventListener("input", (event) => { target.name === "username" || target.name === "password") ) { - markField(target.name, "idle"); + markField(target.name as "url" | "api_key" | "username" | "password", "idle"); } return; } - if (target.form?.id !== "pipeline-form") { - return; - } + if (target.form?.id !== "pipeline-form") return; state.pipelineFeedback = null; state.pipelineErrors = []; - const mappingColumn = target.dataset.mappingColumn; + // Mapping dropdown: update the specific mapping entry. + const mappingColumn = (target as HTMLElement).dataset.mappingColumn; if (mappingColumn) { const mapping = state.pipelineForm.mappings.find( - (item) => item.csvColumn === mappingColumn + (m) => m.csvColumn === mappingColumn ); - if (mapping) { - mapping.datastreamId = target.value; - } + if (mapping) mapping.datastreamId = target.value; render(); return; } + // Pipeline form fields: update state, re-render for structural changes. updatePipelineField(target.name, target.value); - if ( target.name === "header_row" || target.name === "data_start_row" || @@ -2499,9 +136,9 @@ mainContent.addEventListener("input", (event) => { } }); +// ── Change events ────────────────────────────────────────────────────────── mainContent.addEventListener("change", (event) => { const target = event.target; - if ( !( target instanceof HTMLInputElement || @@ -2518,405 +155,138 @@ mainContent.addEventListener("change", (event) => { return; } - if (target.form?.id !== "pipeline-form" || target.name !== "file_path") { - return; - } - - void loadPipelinePreview(target.value); -}); - -mainContent.addEventListener("pointerdown", (event) => { - const target = event.target; - if (!(target instanceof HTMLElement)) { - return; - } - - const handle = target.closest("[data-preview-handle-target]"); - if (handle) { - const pickerTarget = handle.dataset.previewHandleTarget; - if (pickerTarget !== "header-row" && pickerTarget !== "data-start-row") { - return; - } - - const lineNumber = Number(handle.dataset.previewLine); - if (!Number.isFinite(lineNumber) || lineNumber < 1) { - return; - } - - state.pipelineSelectionTarget = pickerTarget; - state.pipelineDrag = { - target: pickerTarget, - lineNumber, - pointerId: event.pointerId, - moved: false, - }; - suppressPreviewHandleClick = false; - if (typeof handle.setPointerCapture === "function") { - handle.setPointerCapture(event.pointerId); - } - beginPreviewDragVisual(event.clientY); - event.preventDefault(); - return; - } - - const columnHandle = target.closest("[data-preview-column-handle]"); - if (!columnHandle) { - return; - } - - const columnName = columnHandle.dataset.previewColumnHandle ?? ""; - if (!columnName) { - return; - } - - state.pipelineSelectionTarget = "timestamp-column"; - state.pipelineColumnDrag = { - columnName, - pointerId: event.pointerId, - moved: false, - }; - if (typeof columnHandle.setPointerCapture === "function") { - columnHandle.setPointerCapture(event.pointerId); - } - beginPreviewColumnDragVisual(event.clientX); - event.preventDefault(); -}); - -window.addEventListener("pointermove", (event) => { - if (state.pipelineDrag?.pointerId === event.pointerId) { - if (!previewDragVisual) { - return; - } - - previewDragVisual.currentClientY = event.clientY; - const lineNumber = nearestPreviewLineNumber( - event.clientY, - previewDragVisual.rowCenters - ); - if (!lineNumber) { - schedulePreviewDragVisual(); - return; - } - - if (lineNumber === state.pipelineDrag.lineNumber) { - schedulePreviewDragVisual(); - return; - } - - state.pipelineDrag = { - ...state.pipelineDrag, - lineNumber, - moved: true, - }; - schedulePreviewDragVisual(); - return; - } - - if ( - !state.pipelineColumnDrag || - state.pipelineColumnDrag.pointerId !== event.pointerId - ) { - return; - } - - if (!previewColumnDragVisual) { - return; - } - - previewColumnDragVisual.currentClientX = event.clientX; - const columnName = nearestPreviewColumnName( - event.clientX, - previewColumnDragVisual.headerCenters - ); - if (!columnName) { - schedulePreviewColumnDragVisual(); - return; - } - - if (columnName === state.pipelineColumnDrag.columnName) { - schedulePreviewColumnDragVisual(); - return; - } - - state.pipelineColumnDrag = { - ...state.pipelineColumnDrag, - columnName, - moved: true, - }; - schedulePreviewColumnDragVisual(); -}); - -window.addEventListener("pointerup", (event) => { - if (state.pipelineDrag?.pointerId === event.pointerId) { - const drag = state.pipelineDrag; - endPreviewDragVisual(); - state.pipelineDrag = null; - - if (drag.moved) { - setPreviewRowSelectionTarget(drag.target, drag.lineNumber); - state.pipelineSelectionTarget = null; - suppressPreviewHandleClick = true; - } else { - state.pipelineSelectionTarget = drag.target; - suppressPreviewHandleClick = false; - } - - render(); - return; - } - - if ( - !state.pipelineColumnDrag || - state.pipelineColumnDrag.pointerId !== event.pointerId - ) { - return; - } - - const drag = state.pipelineColumnDrag; - endPreviewColumnDragVisual(); - state.pipelineColumnDrag = null; - - if (drag.moved) { - state.pipelineForm.timestampColumn = drag.columnName; - initializeMappings(previewHeaders()); - state.pipelineSelectionTarget = null; - } else { - state.pipelineSelectionTarget = "timestamp-column"; - } - - render(); -}); - -window.addEventListener("pointercancel", (event) => { - if (state.pipelineDrag?.pointerId === event.pointerId) { - endPreviewDragVisual(); - state.pipelineDrag = null; - suppressPreviewHandleClick = false; - render(); - return; - } - - if ( - !state.pipelineColumnDrag || - state.pipelineColumnDrag.pointerId !== event.pointerId - ) { - return; + if (target.form?.id === "pipeline-form" && target.name === "file_path") { + void loadPipelinePreview(target.value); } - - endPreviewColumnDragVisual(); - state.pipelineColumnDrag = null; - state.pipelineSelectionTarget = null; - render(); }); +// ── Click delegation ─────────────────────────────────────────────────────── mainContent.addEventListener("click", (event) => { const target = event.target; - if (!(target instanceof HTMLElement)) { - return; - } + if (!(target instanceof HTMLElement)) return; const action = target.closest("[data-action]")?.dataset.action; const jobId = target.closest("[data-job-id]")?.dataset.jobId; - if (!action) { - return; - } - - if (action === "retry-bootstrap") { - void bootstrap(); - return; - } - - if (action === "toggle-auth-mode") { - const form = target.closest("form"); - if (!form) { - return; - } - - const nextServer = readServerConfigForm(form); - const nextAuthType: AuthType = - nextServer.auth_type === "apikey" ? "userpass" : "apikey"; - setServerDraft({ - ...nextServer, - auth_type: nextAuthType, - }); - resetStateAuthFieldStates(nextAuthType); - - clearAuthFormFeedback(form.id); - clearAuthValidationCache(); - - render(); - return; - } - - if (action === "disconnect") { - void disconnectHydroServer(); - return; - } - - if (action === "change-credentials") { - state.authDraft = { - ...emptyServerConfig(), - ...(state.config?.server ?? {}), - }; - state.settingsEditMode = true; - navigate("settings"); - render(); - return; - } - - if (action === "cancel-credential-edit") { - state.authDraft = { - ...emptyServerConfig(), - ...(state.config?.server ?? {}), - }; - state.settingsEditMode = false; - render(); - return; - } + if (!action) return; - if (action === "browse-csv") { - void browseForCsvPath(); - return; - } + switch (action) { + // ── Bootstrap ───────────────────────────────────────────────────────── + case "retry-bootstrap": + void bootstrap(); + break; - if (action === "show-more-preview-lines") { - if (!state.pipelinePreview) { - return; + // ── Auth ─────────────────────────────────────────────────────────────── + case "toggle-auth-mode": { + const form = target.closest("form"); + if (!form) break; + const current = readServerConfigForm(form); + const nextAuthType: AuthType = + current.auth_type === "apikey" ? "userpass" : "apikey"; + setServerDraft({ ...current, auth_type: nextAuthType }); + resetStateAuthFieldStates(nextAuthType); + clearAuthFormFeedback(form.id); + clearAuthValidationCache(); + render(); + break; } - const nextRows = Math.min( - state.pipelinePreviewRowsRequested + PREVIEW_PAGE_SIZE, - state.pipelinePreview.total_lines - ); - void loadPipelinePreview(state.pipelineForm.filePath, nextRows); - return; - } + case "disconnect": + void disconnectHydroServer(); + break; - if (action === "activate-preview-handle") { - if (suppressPreviewHandleClick) { - suppressPreviewHandleClick = false; - return; - } + case "change-credentials": + state.authDraft = { ...emptyServerConfig(), ...(state.config?.server ?? {}) }; + state.settingsEditMode = true; + navigate("settings"); + render(); + break; - const pickerTarget = target.closest("[data-preview-handle-target]") - ?.dataset.previewHandleTarget; - if (pickerTarget !== "header-row" && pickerTarget !== "data-start-row") { - return; - } + case "cancel-credential-edit": + state.authDraft = { ...emptyServerConfig(), ...(state.config?.server ?? {}) }; + state.settingsEditMode = false; + render(); + break; - state.pipelineSelectionTarget = pickerTarget; - render(); - return; - } + // ── Pipeline wizard ──────────────────────────────────────────────────── + case "browse-csv": + void browseForCsvPath(); + break; - if (action === "toggle-preview-picker") { - if (!state.pipelinePreview) { - state.pipelineFeedback = { - tone: "info", - message: "Load a CSV preview first.", - }; + case "advance-to-mapping": + state.onboardingStep = "column-mapping"; + state.pipelineErrors = []; render(); - return; - } + break; - const pickerTarget = target.closest("[data-picker-target]") - ?.dataset.pickerTarget; - if ( - pickerTarget !== "header-row" && - pickerTarget !== "data-start-row" && - pickerTarget !== "timestamp-column" - ) { - return; - } + case "back-to-file-config": + state.onboardingStep = "file-config"; + state.pipelineErrors = []; + render(); + break; - state.pipelineSelectionTarget = - state.pipelineSelectionTarget === pickerTarget ? null : pickerTarget; - render(); - return; - } + case "save-pipeline": + void savePipeline(); + break; - if (action === "pick-preview-line") { - const lineNumber = Number( - target.closest("[data-preview-line]")?.dataset.previewLine - ); + // ── Preview pagination ───────────────────────────────────────────────── + case "show-more-preview-lines": + if (state.pipelinePreview) { + const nextRows = Math.min( + state.pipelinePreviewRowsRequested + PREVIEW_PAGE_SIZE, + state.pipelinePreview.total_lines + ); + void loadPipelinePreview(state.pipelineForm.filePath, nextRows); + } + break; - if (Number.isFinite(lineNumber)) { - applyPreviewLineSelection(lineNumber); + // ── Preview handle click (fires after a pointer-up, may need suppression) + case "activate-preview-handle": { + if (getSuppressHandleClick()) { + clearSuppressHandleClick(); + break; + } + const pickerTarget = target.closest( + "[data-preview-handle-target]" + )?.dataset.previewHandleTarget; + if (pickerTarget === "header-row" || pickerTarget === "data-start-row") { + state.pipelineSelectionTarget = pickerTarget; + render(); + } + break; } - return; - } - - if (action === "pick-preview-column") { - const columnName = - target.closest("[data-preview-column]")?.dataset - .previewColumn ?? ""; - if (columnName) { - applyPreviewColumnSelection(columnName); + // ── Preview row / column selection via click ─────────────────────────── + case "pick-preview-line": { + const lineNumber = Number( + target.closest("[data-preview-line]")?.dataset.previewLine + ); + if (Number.isFinite(lineNumber)) { + applyPreviewLineSelection(lineNumber); + render(); + } + break; } - return; - } - - if (!jobId) { - return; - } - - if (action === "run-job") { - void handleRunJob(jobId); - return; - } - - if (action === "toggle-job") { - void handleToggleJob(jobId); - return; - } - - if (action === "delete-job") { - void handleDeleteJob(jobId); - } -}); - -async function handleRunJob(jobId: string): Promise { - try { - await runJob(jobId); - await refreshJobs(); - } catch { - // Keep dashboard state unchanged on action failure. - } -} - -async function handleToggleJob(jobId: string): Promise { - const job = state.jobs.find((item) => item.id === jobId); - if (!job) { - return; - } - try { - if (job.enabled) { - await disableJob(jobId); - } else { - await enableJob(jobId); + case "pick-preview-column": { + const columnName = + target.closest("[data-preview-column]")?.dataset + .previewColumn ?? ""; + if (columnName) { + applyPreviewColumnSelection(columnName); + render(); + } + break; } - await refreshJobs(); - } catch { - // Keep dashboard state unchanged on action failure. - } -} - -async function handleDeleteJob(jobId: string): Promise { - const confirmed = window.confirm("Delete this pipeline?"); - if (!confirmed) { - return; - } - - try { - await deleteJob(jobId); - await refreshJobs(); - } catch { - // Keep dashboard state unchanged on action failure. + // ── Dashboard job actions ────────────────────────────────────────────── + default: + if (!jobId) break; + if (action === "run-job") void handleRunJob(jobId); + else if (action === "toggle-job") void handleToggleJob(jobId); + else if (action === "delete-job") void handleDeleteJob(jobId); } -} +}); +// ── Start ────────────────────────────────────────────────────────────────── void bootstrap(); diff --git a/frontend/render.ts b/frontend/render.ts new file mode 100644 index 0000000..c055592 --- /dev/null +++ b/frontend/render.ts @@ -0,0 +1,96 @@ +import { state, connected, onboardingRoute } from "./state"; +import { getRouteFromHash, navigate } from "./router"; +import { + connectionIndicator, + renderWelcome, + renderSettings, + renderFatalError, + renderLoading, +} from "./components/auth"; +import { renderDashboard } from "./components/dashboard"; +import { renderOnboardingFile } from "./components/onboarding-file"; +import { renderOnboardingMapping } from "./components/onboarding-mapping"; + +type ShellElements = { + sidebar: HTMLElement; + mainContent: HTMLElement; + jobsLink: HTMLAnchorElement; + settingsLink: HTMLAnchorElement; + connectionDot: HTMLElement; +}; + +let _elements: ShellElements | null = null; +let _lastMarkup = ""; + +export function initRenderer(elements: ShellElements): void { + _elements = elements; +} + +export function render(): void { + if (!_elements) throw new Error("Renderer not initialized."); + const { sidebar, mainContent, jobsLink, settingsLink, connectionDot } = _elements; + + state.route = getRouteFromHash(); + let currentRoute = state.route; + + // Route guards: redirect to the correct page based on auth/job state. + if (!state.loading && !state.bootstrapError) { + if (!connected() && currentRoute !== "settings" && currentRoute !== "welcome") { + navigate("welcome"); + currentRoute = "welcome"; + } else if ( + connected() && + state.jobs.length === 0 && + (currentRoute === "dashboard" || currentRoute === "welcome") + ) { + navigate("jobs-new"); + currentRoute = "jobs-new"; + } + } + + // Shell surface: full-page welcome surface hides the sidebar. + const inOnboarding = onboardingRoute(currentRoute); + const showSidebar = !inOnboarding && !state.bootstrapError; + const welcomeSurface = Boolean( + state.loading || state.bootstrapError || inOnboarding + ); + sidebar.classList.toggle("hidden", !showSidebar); + mainContent.classList.toggle("main-content-welcome", welcomeSurface); + document.body.classList.toggle("app-surface-welcome", welcomeSurface); + + // Nav active state. + jobsLink.className = + currentRoute === "dashboard" ? "nav-item nav-item-active" : "nav-item"; + settingsLink.className = + currentRoute === "settings" ? "nav-item nav-item-active" : "nav-item"; + + // Connection dot. + const status = connectionIndicator(); + connectionDot.className = status.className; + connectionDot.title = status.label; + + // Content. + let markup: string; + if (state.loading) { + markup = renderLoading(); + } else if (state.bootstrapError) { + markup = renderFatalError(); + } else if (currentRoute === "settings") { + markup = renderSettings(); + } else if (currentRoute === "welcome") { + markup = renderWelcome(); + } else if (currentRoute === "jobs-new") { + markup = + state.onboardingStep === "file-config" + ? renderOnboardingFile() + : renderOnboardingMapping(); + } else { + markup = renderDashboard(); + } + + // Only write to the DOM when something changed. + if (markup !== _lastMarkup) { + mainContent.innerHTML = markup; + _lastMarkup = markup; + } +} diff --git a/frontend/state.ts b/frontend/state.ts new file mode 100644 index 0000000..5527d00 --- /dev/null +++ b/frontend/state.ts @@ -0,0 +1,503 @@ +import type { + ConnectionState, + AuthType, + ServerConfig, + AppConfig, + HealthResponse, + DatastreamSummary, + CsvPreviewResponse, + JobSummary, + ConnectionTestResponse, +} from "./api"; +import { + createAuthFieldStates, + resetAuthFieldStates, + type AuthFieldName, + type AuthFieldStates, + type FieldValidationState, +} from "./auth-submit"; +import { getRouteFromHash, type AppRoute } from "./router"; +import { parseDelimitedLine, basename, type Feedback } from "./components/helpers"; + +// ── Wizard step ──────────────────────────────────────────────────────────── +export type OnboardingStep = "file-config" | "column-mapping"; + +// ── Pipeline form ────────────────────────────────────────────────────────── +export type PipelineMappingDraft = { + csvColumn: string; + datastreamId: string; +}; + +export type PipelineFormState = { + name: string; + filePath: string; + scheduleMinutes: number; + hasHeaderRow: boolean; + headerRow: number; + dataStartRow: number; + delimiter: string; + timestampColumn: string; + timestampFormat: string; + timezone: string; + mappings: PipelineMappingDraft[]; +}; + +// ── Preview drag types ───────────────────────────────────────────────────── +export type PreviewSelectionTarget = + | "header-row" + | "data-start-row" + | "timestamp-column" + | null; + +export type PreviewRowSelectionTarget = Exclude< + PreviewSelectionTarget, + "timestamp-column" | null +>; + +export type PreviewDragState = { + target: PreviewRowSelectionTarget; + lineNumber: number; + pointerId: number; + moved: boolean; +}; + +export type PreviewColumnDragState = { + columnName: string; + pointerId: number; + moved: boolean; +}; + +// ── Global UI state ──────────────────────────────────────────────────────── +export type UiState = { + route: AppRoute; + health: HealthResponse | null; + config: AppConfig | null; + jobs: JobSummary[]; + datastreams: DatastreamSummary[]; + connectionSummary: ConnectionTestResponse | null; + loading: boolean; + bootstrapError: string | null; + settingsFeedback: Feedback; + welcomeFeedback: Feedback; + pipelineFeedback: Feedback; + lastConnectionState: ConnectionState | null; + settingsEditMode: boolean; + onboardingStep: OnboardingStep; + pipelineForm: PipelineFormState; + pipelinePreview: CsvPreviewResponse | null; + pipelineErrors: string[]; + datastreamsError: string | null; + authDraft: ServerConfig; + authFieldStates: AuthFieldStates; + authSubmitting: boolean; + lastAuthValidationServer: ServerConfig | null; + lastAuthValidationResult: ConnectionTestResponse | null; + pipelineSelectionTarget: PreviewSelectionTarget; + pipelineDrag: PreviewDragState | null; + pipelineColumnDrag: PreviewColumnDragState | null; + pipelinePreviewRowsRequested: number; +}; + +// ── Constants ────────────────────────────────────────────────────────────── +export const PREVIEW_PAGE_SIZE = 50; + +// ── Factories ───────────────────────────────────────────────────────────── +export function emptyServerConfig(): ServerConfig { + return { + auth_type: "apikey", + url: "", + api_key: "", + username: "", + password: "", + workspace_id: "", + }; +} + +export function createEmptyPipelineForm(): PipelineFormState { + return { + name: "", + filePath: "", + scheduleMinutes: 15, + hasHeaderRow: true, + headerRow: 3, + dataStartRow: 4, + delimiter: ",", + timestampColumn: "Timestamp", + timestampFormat: "%Y-%m-%d %H:%M:%S", + timezone: "America/Denver", + mappings: [], + }; +} + +// ── Singleton state ──────────────────────────────────────────────────────── +export const state: UiState = { + route: getRouteFromHash(), + health: null, + config: null, + jobs: [], + datastreams: [], + connectionSummary: null, + loading: true, + bootstrapError: null, + settingsFeedback: null, + welcomeFeedback: null, + pipelineFeedback: null, + lastConnectionState: null, + settingsEditMode: false, + onboardingStep: "file-config", + pipelineForm: createEmptyPipelineForm(), + pipelinePreview: null, + pipelineErrors: [], + datastreamsError: null, + authDraft: emptyServerConfig(), + authFieldStates: createAuthFieldStates(), + authSubmitting: false, + lastAuthValidationServer: null, + lastAuthValidationResult: null, + pipelineSelectionTarget: null, + pipelineDrag: null, + pipelineColumnDrag: null, + pipelinePreviewRowsRequested: PREVIEW_PAGE_SIZE, +}; + +// ── Computed selectors ───────────────────────────────────────────────────── +export function connected(): boolean { + return ( + state.connectionSummary?.ok === true && + state.lastConnectionState === "connected" + ); +} + +export function serverConfigured(server: ServerConfig | null | undefined): boolean { + if (!server?.url.trim()) return false; + if (server.auth_type === "userpass") { + return Boolean(server.username.trim() && server.password.trim()); + } + return Boolean(server.api_key.trim()); +} + +export function onboardingRoute(route: AppRoute): boolean { + return route === "welcome" || (route === "jobs-new" && state.jobs.length === 0); +} + +function normalizePreviewHeaderName(value: string, index: number): string { + return value.trim() || `Column ${index + 1}`; +} + +export function parsedPreviewRows(): string[][] { + if (!state.pipelinePreview) return []; + return state.pipelinePreview.raw_lines.map((line) => + parseDelimitedLine(line, state.pipelineForm.delimiter) + ); +} + +export function previewHeaders(): string[] { + const rows = parsedPreviewRows(); + const columnCount = rows.reduce((max, row) => Math.max(max, row.length), 0); + + if (!state.pipelineForm.hasHeaderRow) { + const dataRows = rows.slice(Math.max(state.pipelineForm.dataStartRow - 1, 0)); + const dataColumnCount = (dataRows.length > 0 ? dataRows : rows).reduce( + (max, row) => Math.max(max, row.length), + 0 + ); + return Array.from({ length: dataColumnCount }, (_, i) => `Column ${i + 1}`); + } + + const headerRow = rows[state.pipelineForm.headerRow - 1] ?? []; + return Array.from({ length: columnCount }, (_, i) => + normalizePreviewHeaderName(headerRow[i] ?? "", i) + ); +} + +export function pipelineMappingsByColumn(): Map { + return new Map(state.pipelineForm.mappings.map((m) => [m.csvColumn, m.datastreamId])); +} + +export function activeTimestampColumn(): string { + return state.pipelineColumnDrag?.columnName ?? state.pipelineForm.timestampColumn; +} + +export function previewHandleLine(target: PreviewRowSelectionTarget): number | null { + if (state.pipelineDrag?.target === target) return state.pipelineDrag.lineNumber; + if (target === "header-row") { + return state.pipelineForm.hasHeaderRow ? state.pipelineForm.headerRow : null; + } + return state.pipelineForm.dataStartRow; +} + +export function activePreviewRowTarget(): PreviewRowSelectionTarget | null { + if (state.pipelineDrag) return state.pipelineDrag.target; + return state.pipelineSelectionTarget === "header-row" || + state.pipelineSelectionTarget === "data-start-row" + ? state.pipelineSelectionTarget + : null; +} + +export function previewCommittedHandleLine(target: PreviewRowSelectionTarget): number | null { + if (target === "header-row") { + return state.pipelineForm.hasHeaderRow ? state.pipelineForm.headerRow : null; + } + return state.pipelineForm.dataStartRow; +} + +export function canShowMorePreviewLines(): boolean { + if (!state.pipelinePreview) return false; + return state.pipelinePreview.raw_lines.length < state.pipelinePreview.total_lines; +} + +// ── Auth mutations ───────────────────────────────────────────────────────── +export function setServerDraft(server: ServerConfig): void { + state.authDraft = { ...server }; +} + +export function markField( + field: AuthFieldName, + nextState: FieldValidationState["state"], + message: string | null = null +): void { + state.authFieldStates[field] = { state: nextState, message }; +} + +export function resetStateAuthFieldStates(authType: AuthType): void { + resetAuthFieldStates(state.authFieldStates, authType); +} + +export function clearAuthValidationCache(): void { + state.lastAuthValidationServer = null; + state.lastAuthValidationResult = null; +} + +export function clearAuthFormFeedback(formId: string): void { + if (formId === "welcome-form") { + state.welcomeFeedback = null; + } else { + state.settingsFeedback = null; + } +} + +export function readServerConfigForm(form: HTMLFormElement): ServerConfig { + const data = new FormData(form); + const authType = data.get("auth_type") === "userpass" ? "userpass" : "apikey"; + return { + auth_type: authType, + url: String(data.get("url") ?? "").trim(), + api_key: + authType === "apikey" + ? String(data.get("api_key") ?? "").trim() + : state.authDraft.api_key, + username: + authType === "userpass" + ? String(data.get("username") ?? "").trim() + : state.authDraft.username, + password: + authType === "userpass" + ? String(data.get("password") ?? "").trim() + : state.authDraft.password, + workspace_id: "", + }; +} + +// ── Pipeline mutations ───────────────────────────────────────────────────── +export function initializeMappings(headers: string[]): void { + const existing = pipelineMappingsByColumn(); + state.pipelineForm.mappings = headers + .filter((h) => h !== state.pipelineForm.timestampColumn) + .map((h) => ({ csvColumn: h, datastreamId: existing.get(h) ?? "" })); +} + +export function syncPipelineSelectionsWithPreview(): void { + const headers = previewHeaders(); + if (headers.length === 0) { + state.pipelineForm.mappings = []; + return; + } + const preferred = + headers.find((h) => h.toLowerCase().includes("time")) ?? headers[0]; + state.pipelineForm.timestampColumn = headers.includes( + state.pipelineForm.timestampColumn + ) + ? state.pipelineForm.timestampColumn + : preferred; + initializeMappings(headers); +} + +export function applyPreview(path: string, preview: CsvPreviewResponse): void { + state.pipelinePreview = preview; + state.pipelineForm.filePath = path; + state.pipelineForm.hasHeaderRow = preview.detected_header_row !== null; + state.pipelineForm.headerRow = + preview.detected_header_row ?? state.pipelineForm.headerRow; + state.pipelineForm.dataStartRow = + preview.detected_data_start_row ?? state.pipelineForm.dataStartRow; + state.pipelineForm.delimiter = + preview.detected_delimiter || state.pipelineForm.delimiter; + state.pipelineSelectionTarget = null; + state.pipelineDrag = null; + state.pipelineColumnDrag = null; + + if (!state.pipelineForm.name.trim()) { + state.pipelineForm.name = basename(path).replace(/\.[^.]+$/, ""); + } + + syncPipelineSelectionsWithPreview(); +} + +export function updateHeaderRowFromPreview(lineNumber: number): void { + state.pipelineForm.hasHeaderRow = true; + state.pipelineForm.headerRow = lineNumber; + if (state.pipelineForm.dataStartRow <= lineNumber) { + state.pipelineForm.dataStartRow = lineNumber + 1; + } + syncPipelineSelectionsWithPreview(); +} + +export function updateDataStartRowFromPreview(lineNumber: number): void { + state.pipelineForm.dataStartRow = Math.max( + state.pipelineForm.hasHeaderRow ? 2 : 1, + lineNumber + ); + if ( + state.pipelineForm.hasHeaderRow && + state.pipelineForm.headerRow >= state.pipelineForm.dataStartRow + ) { + state.pipelineForm.headerRow = state.pipelineForm.dataStartRow - 1; + } + syncPipelineSelectionsWithPreview(); +} + +export function setPipelineHasHeaderRow(enabled: boolean): void { + state.pipelineForm.hasHeaderRow = enabled; + if (!enabled && state.pipelineSelectionTarget === "header-row") { + state.pipelineSelectionTarget = null; + } + if (!enabled && state.pipelineDrag?.target === "header-row") { + state.pipelineDrag = null; + } + if (enabled && state.pipelineForm.headerRow >= state.pipelineForm.dataStartRow) { + state.pipelineForm.headerRow = Math.max(1, state.pipelineForm.dataStartRow - 1); + } + syncPipelineSelectionsWithPreview(); +} + +export function applyPreviewLineSelection(lineNumber: number): void { + if (state.pipelineSelectionTarget === "header-row") { + updateHeaderRowFromPreview(lineNumber); + state.pipelineSelectionTarget = null; + } else if (state.pipelineSelectionTarget === "data-start-row") { + updateDataStartRowFromPreview(lineNumber); + state.pipelineSelectionTarget = null; + } +} + +export function applyPreviewColumnSelection(columnName: string): void { + if ( + state.pipelineSelectionTarget && + state.pipelineSelectionTarget !== "timestamp-column" + ) { + return; + } + state.pipelineForm.timestampColumn = columnName; + initializeMappings(previewHeaders()); + state.pipelineSelectionTarget = null; + state.pipelineColumnDrag = null; +} + +export function updatePipelineField(name: string, value: string): void { + switch (name) { + case "pipeline_name": + state.pipelineForm.name = value; + break; + case "file_path": + state.pipelineForm.filePath = value; + state.pipelinePreview = null; + state.pipelineErrors = []; + state.pipelineSelectionTarget = null; + state.pipelineDrag = null; + state.pipelineColumnDrag = null; + state.pipelinePreviewRowsRequested = PREVIEW_PAGE_SIZE; + break; + case "schedule_minutes": + state.pipelineForm.scheduleMinutes = Number(value) || 15; + break; + case "header_row": + state.pipelineForm.headerRow = Number(value) || 1; + syncPipelineSelectionsWithPreview(); + break; + case "data_start_row": + state.pipelineForm.dataStartRow = Number(value) || 1; + syncPipelineSelectionsWithPreview(); + break; + case "delimiter": + state.pipelineForm.delimiter = value || ","; + syncPipelineSelectionsWithPreview(); + break; + case "timestamp_column": + state.pipelineForm.timestampColumn = value; + initializeMappings(previewHeaders()); + state.pipelineColumnDrag = null; + break; + case "timestamp_format": + state.pipelineForm.timestampFormat = value; + break; + case "timezone": + state.pipelineForm.timezone = value; + break; + } +} + +export function resetPipelineState(): void { + state.pipelineForm = createEmptyPipelineForm(); + state.pipelinePreview = null; + state.pipelineSelectionTarget = null; + state.pipelineDrag = null; + state.pipelineColumnDrag = null; + state.pipelinePreviewRowsRequested = PREVIEW_PAGE_SIZE; + state.pipelineErrors = []; + state.pipelineFeedback = null; + state.onboardingStep = "file-config"; +} + +export function validatePipeline(): string[] { + const errors: string[] = []; + const headers = previewHeaders(); + const selectedMappings = state.pipelineForm.mappings.filter((m) => m.datastreamId); + const datastreamIds = new Set(state.datastreams.map((d) => d.id)); + const seenTargets = new Set(); + + if (!connected()) errors.push("Connect to HydroServer before saving a pipeline."); + if (!state.pipelineForm.name.trim()) errors.push("Give the pipeline a name."); + if (!state.pipelineForm.filePath.trim()) errors.push("Choose the CSV file to watch."); + if (!state.pipelinePreview) errors.push("Load a CSV preview before saving."); + if (state.pipelineForm.hasHeaderRow && state.pipelineForm.headerRow < 1) { + errors.push("Header row must be 1 or greater."); + } + if ( + state.pipelineForm.hasHeaderRow && + state.pipelineForm.dataStartRow <= state.pipelineForm.headerRow + ) { + errors.push("Data start row must come after the header row."); + } + if (!state.pipelineForm.hasHeaderRow && state.pipelineForm.dataStartRow < 1) { + errors.push("Data start row must be 1 or greater."); + } + if (headers.length > 0 && !headers.includes(state.pipelineForm.timestampColumn)) { + errors.push("Choose a timestamp column that exists in the CSV header."); + } + if (selectedMappings.length === 0) { + errors.push("Map at least one source column to a HydroServer datastream."); + } + for (const mapping of selectedMappings) { + if (!datastreamIds.has(mapping.datastreamId)) { + errors.push( + `The selected target for "${mapping.csvColumn}" is not a valid datastream.` + ); + } + if (seenTargets.has(mapping.datastreamId)) { + errors.push("Each datastream can only be mapped to one source column."); + } + seenTargets.add(mapping.datastreamId); + } + + return errors; +} From f520b3ccc887b59f605607d750c2b8b2798b8e51 Mon Sep 17 00:00:00 2001 From: Daniel Slaugh Date: Tue, 7 Apr 2026 12:38:21 -0600 Subject: [PATCH 021/166] Simplify UI --- frontend/actions.ts | 4 - frontend/components/csv-preview.ts | 30 +-- frontend/components/onboarding-file.ts | 294 +++++++++++----------- frontend/components/onboarding-mapping.ts | 32 +-- frontend/styles.css | 64 ++++- 5 files changed, 224 insertions(+), 200 deletions(-) diff --git a/frontend/actions.ts b/frontend/actions.ts index ecd7d0c..e105a69 100644 --- a/frontend/actions.ts +++ b/frontend/actions.ts @@ -26,18 +26,14 @@ import { render } from "./render"; import { state, emptyServerConfig, - createEmptyPipelineForm, applyPreview, resetPipelineState, readServerConfigForm, validatePipeline, - previewHeaders, - initializeMappings, setServerDraft, markField, resetStateAuthFieldStates, clearAuthValidationCache, - connected, serverConfigured, PREVIEW_PAGE_SIZE, } from "./state"; diff --git a/frontend/components/csv-preview.ts b/frontend/components/csv-preview.ts index 4dbef10..b9782b2 100644 --- a/frontend/components/csv-preview.ts +++ b/frontend/components/csv-preview.ts @@ -9,8 +9,6 @@ import { canShowMorePreviewLines, updateHeaderRowFromPreview, updateDataStartRowFromPreview, - applyPreviewLineSelection, - applyPreviewColumnSelection, initializeMappings, PREVIEW_PAGE_SIZE, type PreviewRowSelectionTarget, @@ -135,15 +133,13 @@ function renderTimestampHandle(columnName: string): string { export function renderPipelinePreview(): string { if (!state.pipelinePreview) { return ` -
-
-
CSV
-

Preview a source file

-

- Choose a CSV file to inspect the first 50 lines and configure - the source structure. -

-
+
+
CSV
+

Preview a source file

+

+ Choose a CSV file to inspect the first 50 lines and configure + the source structure. +

`; } @@ -231,13 +227,11 @@ export function renderPipelinePreview(): string { return `
-
-
-

Preview

-

${escapeHtml(basename(state.pipelineForm.filePath))}

-

${escapeHtml(previewGuidanceText())}

-
-
+
+

Preview

+

${escapeHtml(basename(state.pipelineForm.filePath))}

+

${escapeHtml(previewGuidanceText())}

+
+

Source file

+ + + + + + `; } @@ -76,131 +72,126 @@ function renderStructureSection(): string { ).join(""); return ` -
-

File structure

- -
- ${ - state.pipelineForm.hasHeaderRow - ? ` -
-
- -
- - - Drag the blue HEADER handle or enter a row number. - -
- ` - : ` -
- Header row - - Using generated labels: Column 1, Column 2, Column 3… - -
- ` - } - -
-
- -
- - - Drag the green DATA START handle or enter a row number. - -
-
- -
- - - -
- -
-
- -
- ${ - headers.length > 0 - ? `` - : ` +

File structure

+ +
+ ${ + state.pipelineForm.hasHeaderRow + ? ` + + ` + : ` +
+ Header row + + Using generated labels: Column 1, Column 2, Column 3… + +
+ ` + } + +
+ +
+
-
-
-

Schedule

-
- -
+ + + + +
+

Schedule

+ + + + `; } @@ -211,16 +202,17 @@ export function renderOnboardingFile(): string { return `
-
-

${isFirstPipeline ? "Step 1 of 2" : "New pipeline"}

-

Configure your data source

-
+

${isFirstPipeline ? "Step 1 of 2" : "New pipeline"}

+

Configure your data source

+

+ Choose a CSV file, inspect the preview, and mark the rows and column the loader should use. +

${connectionBadge()}
${feedbackMarkup(state.pipelineFeedback)} -
+
${renderFileSection()} ${hasPreview ? renderStructureSection() : ""} diff --git a/frontend/components/onboarding-mapping.ts b/frontend/components/onboarding-mapping.ts index 7739ec5..56bcce4 100644 --- a/frontend/components/onboarding-mapping.ts +++ b/frontend/components/onboarding-mapping.ts @@ -27,10 +27,7 @@ function renderMappingRow(csvColumn: string, datastreamId: string): string { return `
${escapeHtml(csvColumn)} - + Source file - - - - - - +
+

Source file

+ + + + + +
+ +
+
`; } @@ -72,126 +76,131 @@ function renderStructureSection(): string { ).join(""); return ` -
-

File structure

- -
- ${ - state.pipelineForm.hasHeaderRow - ? ` -
-
+
+
+

Schedule

- - - - -
-

Schedule

- - - - +
+ +
`; } @@ -202,17 +211,16 @@ export function renderOnboardingFile(): string { return `
-

${isFirstPipeline ? "Step 1 of 2" : "New pipeline"}

-

Configure your data source

-

- Choose a CSV file, inspect the preview, and mark the rows and column the loader should use. -

+
+

${isFirstPipeline ? "Step 1 of 2" : "New pipeline"}

+

Configure your data source

+
${connectionBadge()}
${feedbackMarkup(state.pipelineFeedback)} -
+
${renderFileSection()} ${hasPreview ? renderStructureSection() : ""} diff --git a/frontend/components/onboarding-mapping.ts b/frontend/components/onboarding-mapping.ts index 56bcce4..7739ec5 100644 --- a/frontend/components/onboarding-mapping.ts +++ b/frontend/components/onboarding-mapping.ts @@ -27,7 +27,10 @@ function renderMappingRow(csvColumn: string, datastreamId: string): string { return `
${escapeHtml(csvColumn)} - + - ${helpText ? `

${escapeHtml(helpText)}

` : ""} - ${authFieldErrorMarkup(name)} - - `; -} - -// ── Auth form ────────────────────────────────────────────────────────────── -export function renderAuthForm( - formId: "welcome-form" | "settings-form", - submitLabel: string, - secondaryAction: string -): string { - const server = state.authDraft; - const usingUserPass = server.auth_type === "userpass"; - const authToggleLabel = usingUserPass - ? "Connect with an API key" - : "Connect with username and password"; - const submitDisabled = state.authSubmitting ? "disabled" : ""; - const submitLabelText = state.authSubmitting ? "Connecting…" : submitLabel; - - return ` - -
-
- ${APP_NAME} icon -

Connect to HydroServer

-
- - - - ${renderAuthInputField({ - label: "Host URL", - name: "url", - type: "url", - value: server.url, - placeholder: "https://playground.hydroserver.org", - })} - - ${ - usingUserPass - ? ` - ${renderAuthInputField({ - label: "Username", - name: "username", - type: "text", - value: server.username, - placeholder: "name@example.com", - })} - ${renderAuthInputField({ - label: "Password", - name: "password", - type: "password", - value: server.password, - placeholder: "Enter your HydroServer password", - })} - ` - : ` - ${renderAuthInputField({ - label: "API key", - name: "api_key", - type: "password", - value: server.api_key, - placeholder: - "KaTz74swGqHn__I2VY6ceIzrIxC04oDhUrLLgBTH9ACxYIunmkrdmqk", - labelAction: `How to create an API key →`, - })} - ` - } - -
- or - -
- -
- ${secondaryAction} - -
-
- - `; -} - -// ── Pages ────────────────────────────────────────────────────────────────── -export function renderWelcome(): string { - return ` -
- ${renderAuthForm("welcome-form", "Connect to HydroServer", "")} -
- `; -} - -export function renderSettings(): string { - const showForm = !connected() || state.settingsEditMode; - - return ` -
- - - ${feedbackMarkup(state.settingsFeedback)} - - ${ - showForm - ? renderAuthForm( - "settings-form", - "Save and verify", - connected() - ? `` - : "" - ) - : renderConnectedCard(true) - } -
- `; -} - -export function renderConnectedCard(showActions: boolean): string { - if (!connected() || !state.connectionSummary) return ""; - - const datastreamText = - state.connectionSummary.datastream_count === 1 - ? "1 datastream available" - : `${state.connectionSummary.datastream_count} datastreams available`; - - return ` -
-
-

Authenticated

-

- ${escapeHtml(state.connectionSummary.instance_name ?? "HydroServer")} -

-

${escapeHtml(state.connectionSummary.message)}

-
- Connected - ${escapeHtml(datastreamText)} -
-
- ${ - showActions - ? ` -
- - - ${ - state.jobs.length === 0 - ? `Create first pipeline` - : "" - } -
- ` - : "" - } -
- `; -} - -export function renderFatalError(): string { - return ` -
-
-

Sidecar error

-

The background process is unavailable

-

- ${escapeHtml( - state.bootstrapError ?? - `${APP_NAME} could not reach the local background service.` - )} -

- -
-
- `; -} - -export function renderLoading(): string { - return ` -
- -
- `; -} diff --git a/frontend/components/csv-preview.ts b/frontend/components/csv-preview.ts deleted file mode 100644 index 4dbef10..0000000 --- a/frontend/components/csv-preview.ts +++ /dev/null @@ -1,675 +0,0 @@ -import { - state, - previewHeaders, - parsedPreviewRows, - previewHandleLine, - activePreviewRowTarget, - activeTimestampColumn, - previewCommittedHandleLine, - canShowMorePreviewLines, - updateHeaderRowFromPreview, - updateDataStartRowFromPreview, - applyPreviewLineSelection, - applyPreviewColumnSelection, - initializeMappings, - PREVIEW_PAGE_SIZE, - type PreviewRowSelectionTarget, -} from "../state"; -import { escapeHtml, basename } from "./helpers"; - -// ── Module-level drag visual state ───────────────────────────────────────── -// These track live DOM positions during a drag gesture. They are not render -// state and must not be stored in UiState. - -type PreviewDragVisualState = { - handle: HTMLElement; - startClientY: number; - currentClientY: number; - rowButtons: Map; - rowElements: Map; - rowCenters: Array<{ lineNumber: number; centerY: number }>; - frameRequested: boolean; -}; - -type PreviewColumnDragVisualState = { - handle: HTMLElement; - startClientX: number; - currentClientX: number; - headerButtons: Map; - columnCells: Map; - headerCenters: Array<{ columnName: string; centerX: number }>; - frameRequested: boolean; -}; - -let _suppressHandleClick = false; -let _rowDragVisual: PreviewDragVisualState | null = null; -let _colDragVisual: PreviewColumnDragVisualState | null = null; - -export function getSuppressHandleClick(): boolean { - return _suppressHandleClick; -} -export function clearSuppressHandleClick(): void { - _suppressHandleClick = false; -} - -// ── Rendering helpers ────────────────────────────────────────────────────── -export function previewColumnClass(columnName: string): string { - if (columnName === state.pipelineForm.timestampColumn) return "preview-col-timestamp"; - const mapped = state.pipelineForm.mappings.find( - (m) => m.csvColumn === columnName && m.datastreamId - ); - return mapped ? "preview-col-mapped" : ""; -} - -export function previewFieldClass( - target: Exclude -): string { - const active = - target === "timestamp-column" - ? state.pipelineSelectionTarget === target || state.pipelineColumnDrag !== null - : activePreviewRowTarget() === target; - const toneClass = - target === "header-row" - ? "preview-bound-field-header" - : target === "data-start-row" - ? "preview-bound-field-data" - : "preview-bound-field-timestamp"; - return active - ? `field preview-bound-field preview-bound-field-active ${toneClass}` - : "field preview-bound-field"; -} - -function previewGuidanceText(): string { - const activeTarget = activePreviewRowTarget(); - if (activeTarget === "header-row") { - return "Drag the HEADER handle, or click a row to place it."; - } - if (activeTarget === "data-start-row") { - return "Drag the DATA START handle, or click the first data row."; - } - if (state.pipelineSelectionTarget === "timestamp-column" || state.pipelineColumnDrag) { - return "Drag the TIMESTAMP handle, or click a column header to place it."; - } - return state.pipelineForm.hasHeaderRow - ? "Drag the HEADER, DATA START, and TIMESTAMP handles, or click a row or column to place them." - : "Drag the DATA START and TIMESTAMP handles, or click a row or column to place them."; -} - -function renderPreviewHandle( - target: PreviewRowSelectionTarget, - lineNumber: number -): string { - if (previewHandleLine(target) !== lineNumber) return ""; - const active = activePreviewRowTarget() === target; - const label = target === "header-row" ? "HEADER" : "DATA START"; - const base = - target === "header-row" - ? "preview-row-handle preview-row-handle-header" - : "preview-row-handle preview-row-handle-data"; - return ` - - `; -} - -function renderTimestampHandle(columnName: string): string { - if (columnName !== activeTimestampColumn()) return ""; - const active = - state.pipelineSelectionTarget === "timestamp-column" || - state.pipelineColumnDrag !== null; - return ` - - `; -} - -// ── Public render function ───────────────────────────────────────────────── -export function renderPipelinePreview(): string { - if (!state.pipelinePreview) { - return ` -
-
-
CSV
-

Preview a source file

-

- Choose a CSV file to inspect the first 50 lines and configure - the source structure. -

-
-
- `; - } - - const headers = previewHeaders(); - const parsedRows = parsedPreviewRows().map((row, i) => ({ - lineNumber: i + 1, - row, - })); - const headerLine = previewHandleLine("header-row"); - const dataStartLine = previewHandleLine("data-start-row"); - - const headerCells = headers - .map( - (header) => ` - -
- ${renderTimestampHandle(header)} - -
- - ` - ) - .join(""); - - const tableRows = parsedRows - .map(({ lineNumber, row }) => { - const rowClasses = [ - "preview-table-row", - state.pipelineForm.hasHeaderRow && lineNumber === headerLine - ? "preview-table-row-header" - : "", - lineNumber === dataStartLine ? "preview-table-row-data" : "", - ] - .filter(Boolean) - .join(" "); - - const dataCells = headers - .map( - (columnName, i) => ` - ${escapeHtml(row[i] ?? "")} - ` - ) - .join(""); - - return ` - - -
- ${state.pipelineForm.hasHeaderRow ? renderPreviewHandle("header-row", lineNumber) : ""} - ${renderPreviewHandle("data-start-row", lineNumber)} - -
- - ${dataCells} - - `; - }) - .join(""); - - const shownLines = state.pipelinePreview.raw_lines.length; - const remaining = Math.max(state.pipelinePreview.total_lines - shownLines, 0); - const nextPageSize = Math.min(PREVIEW_PAGE_SIZE, remaining); - const showMoreButton = canShowMorePreviewLines() - ? `` - : ""; - - return ` -
-
-
-

Preview

-

${escapeHtml(basename(state.pipelineForm.filePath))}

-

${escapeHtml(previewGuidanceText())}

-
-
- - - -
- - - - - ${headerCells} - - - ${tableRows} -
Line
-
- -
- Showing ${shownLines} of ${state.pipelinePreview.total_lines} lines - ${showMoreButton} -
-
- `; -} - -// ── DOM collection helpers ───────────────────────────────────────────────── -function collectRowButtons( - root: HTMLElement -): Map { - return new Map( - Array.from( - root.querySelectorAll( - '[data-action="pick-preview-line"][data-preview-line]' - ) - ) - .map((btn) => { - const n = Number(btn.dataset.previewLine); - return Number.isFinite(n) ? ([n, btn] as [number, HTMLButtonElement]) : null; - }) - .filter((e): e is [number, HTMLButtonElement] => e !== null) - ); -} - -function collectRowElements( - root: HTMLElement -): Map { - return new Map( - Array.from(root.querySelectorAll("[data-preview-line-row]")) - .map((row) => { - const n = Number(row.dataset.previewLineRow); - return Number.isFinite(n) - ? ([n, row] as [number, HTMLTableRowElement]) - : null; - }) - .filter((e): e is [number, HTMLTableRowElement] => e !== null) - ); -} - -function collectRowCenters( - rowButtons: Map -): Array<{ lineNumber: number; centerY: number }> { - return Array.from(rowButtons.entries()).map(([lineNumber, btn]) => { - const rect = btn.getBoundingClientRect(); - return { lineNumber, centerY: rect.top + rect.height / 2 }; - }); -} - -function nearestLineNumber( - clientY: number, - rowCenters: Array<{ lineNumber: number; centerY: number }> -): number | null { - if (rowCenters.length === 0) return null; - return rowCenters.reduce((best, entry) => - Math.abs(clientY - entry.centerY) < Math.abs(clientY - best.centerY) - ? entry - : best - ).lineNumber; -} - -function collectHeaderButtons( - root: HTMLElement -): Map { - return new Map( - Array.from( - root.querySelectorAll( - '[data-action="pick-preview-column"][data-preview-column]' - ) - ) - .map((btn) => { - const col = btn.dataset.previewColumn ?? ""; - return col ? ([col, btn] as [string, HTMLButtonElement]) : null; - }) - .filter((e): e is [string, HTMLButtonElement] => e !== null) - ); -} - -function collectColumnCells(root: HTMLElement): Map { - const cells = new Map(); - root.querySelectorAll("[data-preview-column-cell]").forEach((el) => { - const col = el.dataset.previewColumnCell ?? ""; - if (!col) return; - const bucket = cells.get(col) ?? []; - bucket.push(el); - cells.set(col, bucket); - }); - return cells; -} - -function collectHeaderCenters( - headerButtons: Map -): Array<{ columnName: string; centerX: number }> { - return Array.from(headerButtons.entries()).map(([columnName, btn]) => { - const rect = btn.getBoundingClientRect(); - return { columnName, centerX: rect.left + rect.width / 2 }; - }); -} - -function nearestColumnName( - clientX: number, - headerCenters: Array<{ columnName: string; centerX: number }> -): string | null { - if (headerCenters.length === 0) return null; - return headerCenters.reduce((best, entry) => - Math.abs(clientX - entry.centerX) < Math.abs(clientX - best.centerX) - ? entry - : best - ).columnName; -} - -// ── Row drag visual ──────────────────────────────────────────────────────── -function applyRowDragClasses(): void { - if (!state.pipelineDrag || !_rowDragVisual) return; - - const headerLine = - state.pipelineDrag.target === "header-row" - ? state.pipelineDrag.lineNumber - : previewCommittedHandleLine("header-row"); - const dataLine = - state.pipelineDrag.target === "data-start-row" - ? state.pipelineDrag.lineNumber - : previewCommittedHandleLine("data-start-row"); - - for (const [n, btn] of _rowDragVisual.rowButtons.entries()) { - btn.classList.toggle( - "preview-line-button-header", - state.pipelineForm.hasHeaderRow && headerLine === n - ); - btn.classList.toggle("preview-line-button-data", dataLine === n); - } - for (const [n, row] of _rowDragVisual.rowElements.entries()) { - row.classList.toggle( - "preview-table-row-header", - state.pipelineForm.hasHeaderRow && headerLine === n - ); - row.classList.toggle("preview-table-row-data", dataLine === n); - } -} - -function flushRowDragVisual(): void { - if (!state.pipelineDrag || !_rowDragVisual) return; - _rowDragVisual.frameRequested = false; - const offset = _rowDragVisual.currentClientY - _rowDragVisual.startClientY; - _rowDragVisual.handle.style.setProperty("--preview-handle-offset", `${offset}px`); - applyRowDragClasses(); -} - -function scheduleRowDragVisual(): void { - if (!_rowDragVisual || _rowDragVisual.frameRequested) return; - _rowDragVisual.frameRequested = true; - window.requestAnimationFrame(flushRowDragVisual); -} - -function beginRowDragVisual(root: HTMLElement, clientY: number): void { - if (!state.pipelineDrag) return; - const { target, lineNumber } = state.pipelineDrag; - const handle = root.querySelector( - `[data-preview-handle-target="${target}"][data-preview-line="${lineNumber}"]` - ); - if (!handle) return; - - const rowButtons = collectRowButtons(root); - _rowDragVisual = { - handle, - startClientY: clientY, - currentClientY: clientY, - rowButtons, - rowElements: collectRowElements(root), - rowCenters: collectRowCenters(rowButtons), - frameRequested: false, - }; - - root - .querySelectorAll(".preview-row-handle-active") - .forEach((el) => el.classList.remove("preview-row-handle-active")); - handle.classList.add("preview-row-handle-active", "preview-row-handle-dragging"); - handle.style.setProperty("--preview-handle-offset", "0px"); - applyRowDragClasses(); -} - -function endRowDragVisual(): void { - if (!_rowDragVisual) return; - if ( - state.pipelineDrag && - typeof _rowDragVisual.handle.releasePointerCapture === "function" && - _rowDragVisual.handle.hasPointerCapture(state.pipelineDrag.pointerId) - ) { - _rowDragVisual.handle.releasePointerCapture(state.pipelineDrag.pointerId); - } - _rowDragVisual.handle.classList.remove("preview-row-handle-dragging"); - _rowDragVisual.handle.style.removeProperty("--preview-handle-offset"); - _rowDragVisual = null; -} - -// ── Column drag visual ───────────────────────────────────────────────────── -function applyColDragClasses(): void { - if (!state.pipelineColumnDrag || !_colDragVisual) return; - for (const [col, cells] of _colDragVisual.columnCells.entries()) { - const active = col === state.pipelineColumnDrag.columnName; - for (const cell of cells) cell.classList.toggle("preview-col-timestamp", active); - } -} - -function flushColDragVisual(): void { - if (!state.pipelineColumnDrag || !_colDragVisual) return; - _colDragVisual.frameRequested = false; - const offset = _colDragVisual.currentClientX - _colDragVisual.startClientX; - _colDragVisual.handle.style.setProperty( - "--preview-column-handle-offset", - `${offset}px` - ); - applyColDragClasses(); -} - -function scheduleColDragVisual(): void { - if (!_colDragVisual || _colDragVisual.frameRequested) return; - _colDragVisual.frameRequested = true; - window.requestAnimationFrame(flushColDragVisual); -} - -function beginColDragVisual(root: HTMLElement, clientX: number): void { - if (!state.pipelineColumnDrag) return; - const { columnName } = state.pipelineColumnDrag; - const handle = - Array.from(root.querySelectorAll("[data-preview-column-handle]")).find( - (el) => el.dataset.previewColumnHandle === columnName - ) ?? null; - if (!handle) return; - - const headerButtons = collectHeaderButtons(root); - _colDragVisual = { - handle, - startClientX: clientX, - currentClientX: clientX, - headerButtons, - columnCells: collectColumnCells(root), - headerCenters: collectHeaderCenters(headerButtons), - frameRequested: false, - }; - - handle.classList.add("preview-column-handle-dragging"); - handle.style.setProperty("--preview-column-handle-offset", "0px"); - applyColDragClasses(); -} - -function endColDragVisual(): void { - if (!_colDragVisual) return; - if ( - state.pipelineColumnDrag && - typeof _colDragVisual.handle.releasePointerCapture === "function" && - _colDragVisual.handle.hasPointerCapture(state.pipelineColumnDrag.pointerId) - ) { - _colDragVisual.handle.releasePointerCapture(state.pipelineColumnDrag.pointerId); - } - _colDragVisual.handle.classList.remove("preview-column-handle-dragging"); - _colDragVisual.handle.style.removeProperty("--preview-column-handle-offset"); - _colDragVisual = null; -} - -// ── Public event initializer ─────────────────────────────────────────────── -// Sets up all pointer events needed for drag-to-select on the preview table. -// Call once after the DOM shell is ready. -export function initPreviewDragEvents( - mainContent: HTMLElement, - render: () => void -): void { - // ── Pointer down: start a drag ───────────────────────────────────────── - mainContent.addEventListener("pointerdown", (event) => { - const target = event.target; - if (!(target instanceof HTMLElement)) return; - - // Row handle drag (header-row or data-start-row) - const handle = target.closest("[data-preview-handle-target]"); - if (handle) { - const pickerTarget = handle.dataset.previewHandleTarget; - if (pickerTarget !== "header-row" && pickerTarget !== "data-start-row") return; - const lineNumber = Number(handle.dataset.previewLine); - if (!Number.isFinite(lineNumber) || lineNumber < 1) return; - - state.pipelineSelectionTarget = pickerTarget; - state.pipelineDrag = { - target: pickerTarget, - lineNumber, - pointerId: event.pointerId, - moved: false, - }; - _suppressHandleClick = false; - if (typeof handle.setPointerCapture === "function") { - handle.setPointerCapture(event.pointerId); - } - beginRowDragVisual(mainContent, event.clientY); - event.preventDefault(); - return; - } - - // Column handle drag (timestamp-column) - const colHandle = target.closest("[data-preview-column-handle]"); - if (!colHandle) return; - const columnName = colHandle.dataset.previewColumnHandle ?? ""; - if (!columnName) return; - - state.pipelineSelectionTarget = "timestamp-column"; - state.pipelineColumnDrag = { - columnName, - pointerId: event.pointerId, - moved: false, - }; - if (typeof colHandle.setPointerCapture === "function") { - colHandle.setPointerCapture(event.pointerId); - } - beginColDragVisual(mainContent, event.clientX); - event.preventDefault(); - }); - - // ── Pointer move: update drag position ──────────────────────────────── - window.addEventListener("pointermove", (event) => { - if (state.pipelineDrag?.pointerId === event.pointerId) { - if (!_rowDragVisual) return; - _rowDragVisual.currentClientY = event.clientY; - const lineNumber = nearestLineNumber(event.clientY, _rowDragVisual.rowCenters); - if (lineNumber === null) { - scheduleRowDragVisual(); - return; - } - if (lineNumber !== state.pipelineDrag.lineNumber) { - state.pipelineDrag = { ...state.pipelineDrag, lineNumber, moved: true }; - } - scheduleRowDragVisual(); - return; - } - - if ( - !state.pipelineColumnDrag || - state.pipelineColumnDrag.pointerId !== event.pointerId - ) { - return; - } - if (!_colDragVisual) return; - _colDragVisual.currentClientX = event.clientX; - const columnName = nearestColumnName(event.clientX, _colDragVisual.headerCenters); - if (columnName && columnName !== state.pipelineColumnDrag.columnName) { - state.pipelineColumnDrag = { ...state.pipelineColumnDrag, columnName, moved: true }; - } - scheduleColDragVisual(); - }); - - // ── Pointer up: commit drag ──────────────────────────────────────────── - window.addEventListener("pointerup", (event) => { - if (state.pipelineDrag?.pointerId === event.pointerId) { - const drag = state.pipelineDrag; - endRowDragVisual(); - state.pipelineDrag = null; - - if (drag.moved) { - if (drag.target === "header-row") { - updateHeaderRowFromPreview(drag.lineNumber); - } else { - updateDataStartRowFromPreview(drag.lineNumber); - } - state.pipelineSelectionTarget = null; - _suppressHandleClick = true; - } else { - state.pipelineSelectionTarget = drag.target; - _suppressHandleClick = false; - } - render(); - return; - } - - if ( - !state.pipelineColumnDrag || - state.pipelineColumnDrag.pointerId !== event.pointerId - ) { - return; - } - const drag = state.pipelineColumnDrag; - endColDragVisual(); - state.pipelineColumnDrag = null; - - if (drag.moved) { - state.pipelineForm.timestampColumn = drag.columnName; - initializeMappings(previewHeaders()); - state.pipelineSelectionTarget = null; - } else { - state.pipelineSelectionTarget = "timestamp-column"; - } - render(); - }); - - // ── Pointer cancel: abort drag ───────────────────────────────────────── - window.addEventListener("pointercancel", (event) => { - if (state.pipelineDrag?.pointerId === event.pointerId) { - endRowDragVisual(); - state.pipelineDrag = null; - _suppressHandleClick = false; - render(); - return; - } - if ( - !state.pipelineColumnDrag || - state.pipelineColumnDrag.pointerId !== event.pointerId - ) { - return; - } - endColDragVisual(); - state.pipelineColumnDrag = null; - state.pipelineSelectionTarget = null; - render(); - }); -} diff --git a/frontend/components/dashboard.ts b/frontend/components/dashboard.ts deleted file mode 100644 index 67a6eaf..0000000 --- a/frontend/components/dashboard.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { state } from "../state"; -import { routeHref } from "../router"; -import { formatRelativeTime, formatSchedule, shortenPath } from "../time"; -import { escapeHtml } from "./helpers"; -import type { JobSummary } from "../api"; - -function statusPill(job: JobSummary): string { - const classes: Record = { - healthy: "pill-success", - warning: "pill-warning", - error: "pill-danger", - disabled: "pill-muted", - pending: "pill-info", - running: "pill-info", - }; - return `${escapeHtml(job.status_message)}`; -} - -function jobStatusDotClass(status: JobSummary["status"]): string { - switch (status) { - case "error": - return "bg-rose-500"; - case "warning": - return "bg-amber-500"; - case "disabled": - return "bg-slate-300"; - default: - return "bg-emerald-500"; - } -} - -function renderJobCard(job: JobSummary): string { - const lastLine = job.last_error - ? `Failed ${formatRelativeTime(job.last_run_at)}` - : `Last pushed ${formatRelativeTime(job.last_pushed_timestamp)}`; - - return ` -
-
-
-
- -

${escapeHtml(job.name)}

-
-

${escapeHtml(shortenPath(job.file_path))}

-

- ${escapeHtml(lastLine)} · ${escapeHtml(formatSchedule(job.schedule_minutes))} -

-
- ${statusPill(job)} -
- -
- - - -
-
- `; -} - -export function renderDashboard(): string { - if (state.jobs.length === 0) { - return ` -
- -
- `; - } - - return ` -
- -
- ${state.jobs.map(renderJobCard).join("")} -
-
- `; -} diff --git a/frontend/components/helpers.ts b/frontend/components/helpers.ts deleted file mode 100644 index 2622d98..0000000 --- a/frontend/components/helpers.ts +++ /dev/null @@ -1,59 +0,0 @@ -export type Feedback = { - tone: "success" | "error" | "info"; - message: string; -} | null; - -export const APP_NAME = "HydroServer Streaming Data Loader"; - -export function escapeHtml(value: string): string { - return value - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); -} - -export function feedbackMarkup(feedback: Feedback): string { - if (!feedback) return ""; - const cls = - feedback.tone === "success" - ? "notice-success" - : feedback.tone === "error" - ? "notice-error" - : "notice-info"; - return `
${escapeHtml(feedback.message)}
`; -} - -export function basename(path: string): string { - const segments = path.split(/[\\/]/).filter(Boolean); - return segments.at(-1) ?? path; -} - -export function parseDelimitedLine(line: string, delimiter: string): string[] { - if (!delimiter) return [line]; - const cells: string[] = []; - let current = ""; - let inQuotes = false; - for (let i = 0; i < line.length; i++) { - const char = line[i]; - if (char === '"') { - if (inQuotes && line[i + 1] === '"') { - current += '"'; - i++; - } else { - inQuotes = !inQuotes; - } - continue; - } - if (!inQuotes && line.startsWith(delimiter, i)) { - cells.push(current); - current = ""; - i += delimiter.length - 1; - continue; - } - current += char; - } - cells.push(current); - return cells; -} diff --git a/frontend/components/onboarding-file.ts b/frontend/components/onboarding-file.ts deleted file mode 100644 index 7f2d693..0000000 --- a/frontend/components/onboarding-file.ts +++ /dev/null @@ -1,233 +0,0 @@ -import { state, previewHeaders } from "../state"; -import { formatSchedule } from "../time"; -import { escapeHtml, feedbackMarkup } from "./helpers"; -import { renderPipelinePreview, previewFieldClass } from "./csv-preview"; - -const SCHEDULE_OPTIONS = [5, 15, 30, 60] as const; - -function connectionBadge(): string { - if (!state.connectionSummary?.instance_name) return ""; - return ` - - - ${escapeHtml(state.connectionSummary.instance_name)} - - `; -} - -function renderFileSection(): string { - return ` -
-

Source file

- - - - - -
- -
-
- `; -} - -function renderStructureSection(): string { - const headers = previewHeaders(); - - const timestampOptions = headers - .map( - (h) => - `` - ) - .join(""); - - const scheduleOptions = SCHEDULE_OPTIONS.map( - (minutes) => - `` - ).join(""); - - return ` -
-

File structure

- -
- ${ - state.pipelineForm.hasHeaderRow - ? ` -
-
- -
- - - Drag the blue HEADER handle or enter a row number. - -
- ` - : ` -
- Header row - - Using generated labels: Column 1, Column 2, Column 3… - -
- ` - } - -
-
- -
- - - Drag the green DATA START handle or enter a row number. - -
-
- -
- - - -
- -
-
- -
- ${ - headers.length > 0 - ? `` - : `` - } - - Drag the amber TIMESTAMP handle or click the matching column header. - -
- - -
- -
-

Schedule

- -
- -
- -
- `; -} - -export function renderOnboardingFile(): string { - const hasPreview = state.pipelinePreview !== null; - const isFirstPipeline = state.jobs.length === 0; - - return ` -
-
-
-

${isFirstPipeline ? "Step 1 of 2" : "New pipeline"}

-

Configure your data source

-
- ${connectionBadge()} -
- - ${feedbackMarkup(state.pipelineFeedback)} - -
-
- ${renderFileSection()} - ${hasPreview ? renderStructureSection() : ""} -
- - ${renderPipelinePreview()} -
-
- `; -} diff --git a/frontend/components/onboarding-mapping.ts b/frontend/components/onboarding-mapping.ts deleted file mode 100644 index 7739ec5..0000000 --- a/frontend/components/onboarding-mapping.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { state } from "../state"; -import { escapeHtml, feedbackMarkup } from "./helpers"; - -function connectionBadge(): string { - if (!state.connectionSummary?.instance_name) return ""; - return ` - - - ${escapeHtml(state.connectionSummary.instance_name)} - - `; -} - -function renderMappingRow(csvColumn: string, datastreamId: string): string { - const isMapped = Boolean(datastreamId); - - const options = [ - ``, - ...state.datastreams.map( - (ds) => - `` - ), - ].join(""); - - return ` -
- ${escapeHtml(csvColumn)} - - -
- `; -} - -function renderValidationErrors(): string { - if (state.pipelineErrors.length === 0) return ""; - return ` -
-

Fix these before saving

-
    - ${state.pipelineErrors.map((e) => `
  • ${escapeHtml(e)}
  • `).join("")} -
-
- `; -} - -export function renderOnboardingMapping(): string { - const { mappings } = state.pipelineForm; - const isFirstPipeline = state.jobs.length === 0; - const mappedCount = mappings.filter((m) => m.datastreamId).length; - - return ` -
-
-
-

${isFirstPipeline ? "Step 2 of 2" : "New pipeline"}

-

Map columns to datastreams

-

- Connect each CSV source column to a HydroServer datastream. - Leave unused columns as "Not mapped." -

-
- ${connectionBadge()} -
- -
-
- Source column - - HydroServer datastream -
- - ${ - mappings.length > 0 - ? `
- ${mappings - .map((m) => renderMappingRow(m.csvColumn, m.datastreamId)) - .join("")} -
` - : `

- No source columns found. - -

` - } - - ${ - mappings.length > 0 - ? `

- ${mappedCount} of ${mappings.length} column${mappings.length === 1 ? "" : "s"} mapped -

` - : "" - } -
- - ${renderValidationErrors()} - ${feedbackMarkup(state.pipelineFeedback)} - -
- - -
-
- `; -} diff --git a/frontend/main.ts b/frontend/main.ts index 471142b..bd1f578 100644 --- a/frontend/main.ts +++ b/frontend/main.ts @@ -1,85 +1,2442 @@ import "./generated.css"; +import appIconUrl from "../icons/icon-color.svg"; import { - state, - emptyServerConfig, - setServerDraft, - readServerConfigForm, - markField, - resetStateAuthFieldStates, - clearAuthValidationCache, - clearAuthFormFeedback, - updatePipelineField, - setPipelineHasHeaderRow, - applyPreviewLineSelection, - applyPreviewColumnSelection, - PREVIEW_PAGE_SIZE, -} from "./state"; -import { initRenderer, render } from "./render"; -import { initPreviewDragEvents, getSuppressHandleClick, clearSuppressHandleClick } from "./components/csv-preview"; + clearServerConfig, + createJob, + deleteJob, + disableJob, + enableJob, + getConfig, + getCsvPreview, + getDatastreams, + getHealth, + listJobs, + runJob, + testConnection, + updateServerConfig, + validateServerUrl, + type AppConfig, + type AuthType, + type ConnectionState, + type ConnectionTestResponse, + type CsvPreviewResponse, + type DatastreamSummary, + type HealthResponse, + type JobSummary, + type ServerConfig, +} from "./api"; import { - bootstrap, - refreshJobs, - loadPipelinePreview, - browseForCsvPath, - saveAuthenticatedServerConfig, - disconnectHydroServer, - savePipeline, - handleRunJob, - handleToggleJob, - handleDeleteJob, -} from "./actions"; -import type { AuthType } from "./api"; -import { navigate } from "./router"; - -// ── App shell elements ───────────────────────────────────────────────────── + applyConnectionValidationResult, + createAuthFieldStates, + fieldFormFeedbackTarget, + resetAuthFieldStates, + runAuthSubmission, + validateAuthFieldsForSubmit, + type AuthFieldName, + type Feedback, + type FieldValidationState, +} from "./auth-submit"; +import { getRouteFromHash, navigate, routeHref, type AppRoute } from "./router"; +import { formatRelativeTime, formatSchedule, shortenPath } from "./time"; + +const API_KEY_DOCS_URL = + "https://hydroserver2.github.io/hydroserver/tutorials/creating-your-first-orchestration-system#create-an-api-key"; +const APP_NAME = "HydroServer Streaming Data Loader"; +const STARTUP_RETRY_ATTEMPTS = 12; +const STARTUP_RETRY_DELAY_MS = 350; +const PREVIEW_PAGE_SIZE = 50; + +type PipelineMappingDraft = { + csvColumn: string; + datastreamId: string; +}; + +type PipelineFormState = { + name: string; + filePath: string; + scheduleMinutes: number; + hasHeaderRow: boolean; + headerRow: number; + dataStartRow: number; + delimiter: string; + timestampColumn: string; + timestampFormat: string; + timezone: string; + mappings: PipelineMappingDraft[]; +}; + +type PreviewSelectionTarget = + | "header-row" + | "data-start-row" + | "timestamp-column" + | null; + +type PreviewRowSelectionTarget = Exclude< + PreviewSelectionTarget, + "timestamp-column" | null +>; + +type PreviewDragState = { + target: PreviewRowSelectionTarget; + lineNumber: number; + pointerId: number; + moved: boolean; +}; + +type PreviewColumnDragState = { + columnName: string; + pointerId: number; + moved: boolean; +}; + +type PreviewDragVisualState = { + handle: HTMLElement; + startClientY: number; + currentClientY: number; + rowButtons: Map; + rowElements: Map; + rowCenters: Array<{ lineNumber: number; centerY: number }>; + frameRequested: boolean; +}; + +type PreviewColumnDragVisualState = { + handle: HTMLElement; + startClientX: number; + currentClientX: number; + headerButtons: Map; + columnCells: Map; + headerCenters: Array<{ columnName: string; centerX: number }>; + frameRequested: boolean; +}; + +type UiState = { + route: AppRoute; + health: HealthResponse | null; + config: AppConfig | null; + jobs: JobSummary[]; + datastreams: DatastreamSummary[]; + connectionSummary: ConnectionTestResponse | null; + loading: boolean; + bootstrapError: string | null; + settingsFeedback: Feedback; + welcomeFeedback: Feedback; + pipelineFeedback: Feedback; + lastConnectionState: ConnectionState | null; + settingsEditMode: boolean; + pipelineForm: PipelineFormState; + pipelinePreview: CsvPreviewResponse | null; + pipelineErrors: string[]; + datastreamsError: string | null; + authDraft: ServerConfig; + authFieldStates: Record; + authSubmitting: boolean; + lastAuthValidationServer: ServerConfig | null; + lastAuthValidationResult: ConnectionTestResponse | null; + pipelineSelectionTarget: PreviewSelectionTarget; + pipelineDrag: PreviewDragState | null; + pipelineColumnDrag: PreviewColumnDragState | null; + pipelinePreviewRowsRequested: number; +}; + const shellElements = { sidebar: document.querySelector("#app-sidebar"), mainContent: document.querySelector("#main-content"), - jobsLink: document.querySelector('[data-route="dashboard"]'), - settingsLink: document.querySelector('[data-route="settings"]'), + jobsLink: document.querySelector( + '[data-route="dashboard"]' + ), + settingsLink: document.querySelector( + '[data-route="settings"]' + ), connectionDot: document.querySelector("#connection-status-dot"), }; -if ( - !shellElements.sidebar || - !shellElements.mainContent || - !shellElements.jobsLink || - !shellElements.settingsLink || - !shellElements.connectionDot -) { - throw new Error("App shell is missing required elements."); +if ( + !shellElements.sidebar || + !shellElements.mainContent || + !shellElements.jobsLink || + !shellElements.settingsLink || + !shellElements.connectionDot +) { + throw new Error("App shell is missing required elements."); +} + +const { sidebar, mainContent, jobsLink, settingsLink, connectionDot } = + shellElements; + +let lastRenderedMarkup = ""; +let suppressPreviewHandleClick = false; +let previewDragVisual: PreviewDragVisualState | null = null; +let previewColumnDragVisual: PreviewColumnDragVisualState | null = null; + +function createEmptyPipelineForm(): PipelineFormState { + return { + name: "", + filePath: "", + scheduleMinutes: 15, + hasHeaderRow: true, + headerRow: 3, + dataStartRow: 4, + delimiter: ",", + timestampColumn: "Timestamp", + timestampFormat: "%Y-%m-%d %H:%M:%S", + timezone: "America/Denver", + mappings: [], + }; +} + +const state: UiState = { + route: getRouteFromHash(), + health: null, + config: null, + jobs: [], + datastreams: [], + connectionSummary: null, + loading: true, + bootstrapError: null, + settingsFeedback: null, + welcomeFeedback: null, + pipelineFeedback: null, + lastConnectionState: null, + settingsEditMode: false, + pipelineForm: createEmptyPipelineForm(), + pipelinePreview: null, + pipelineErrors: [], + datastreamsError: null, + authDraft: emptyServerConfig(), + authFieldStates: createAuthFieldStates(), + authSubmitting: false, + lastAuthValidationServer: null, + lastAuthValidationResult: null, + pipelineSelectionTarget: null, + pipelineDrag: null, + pipelineColumnDrag: null, + pipelinePreviewRowsRequested: PREVIEW_PAGE_SIZE, +}; + +function emptyServerConfig(): ServerConfig { + return { + auth_type: "apikey", + url: "", + api_key: "", + username: "", + password: "", + workspace_id: "", + }; +} + +window.setInterval(() => { + void refreshJobs(); +}, 30_000); + +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function feedbackMarkup(feedback: Feedback): string { + if (!feedback) { + return ""; + } + + const toneClass = + feedback.tone === "success" + ? "notice-success" + : feedback.tone === "error" + ? "notice-error" + : "notice-info"; + + return `
${escapeHtml(feedback.message)}
`; +} + +function basename(path: string): string { + const segments = path.split(/[\\/]/).filter(Boolean); + return segments.at(-1) ?? path; +} + +function parseDelimitedLine(line: string, delimiter: string): string[] { + if (!delimiter) { + return [line]; + } + + const cells: string[] = []; + let current = ""; + let inQuotes = false; + + for (let index = 0; index < line.length; index += 1) { + const character = line[index]; + + if (character === '"') { + if (inQuotes && line[index + 1] === '"') { + current += '"'; + index += 1; + } else { + inQuotes = !inQuotes; + } + continue; + } + + if (!inQuotes && line.startsWith(delimiter, index)) { + cells.push(current); + current = ""; + index += delimiter.length - 1; + continue; + } + + current += character; + } + + cells.push(current); + return cells; +} + +function normalizePreviewHeaderName(value: string, index: number): string { + const cleaned = value.trim(); + return cleaned || `Column ${index + 1}`; +} + +function parsedPreviewRows(): string[][] { + if (!state.pipelinePreview) { + return []; + } + + return state.pipelinePreview.raw_lines.map((line) => + parseDelimitedLine(line, state.pipelineForm.delimiter) + ); +} + +function connected(): boolean { + return ( + state.connectionSummary?.ok === true && + state.lastConnectionState === "connected" + ); +} + +function currentServerConfig(): ServerConfig { + return state.authDraft; +} + +function resetStateAuthFieldStates(authType: AuthType): void { + resetAuthFieldStates(state.authFieldStates, authType); +} + +function serverConfigured(server: ServerConfig | null | undefined): boolean { + if (!server?.url.trim()) { + return false; + } + + if (server.auth_type === "userpass") { + return Boolean(server.username.trim() && server.password.trim()); + } + + return Boolean(server.api_key.trim()); +} + +function readServerConfigForm( + form: HTMLFormElement, + base: ServerConfig = currentServerConfig() +): ServerConfig { + const data = new FormData(form); + const authType = data.get("auth_type") === "userpass" ? "userpass" : "apikey"; + + return { + auth_type: authType, + url: String(data.get("url") ?? "").trim(), + api_key: + authType === "apikey" + ? String(data.get("api_key") ?? "").trim() + : base.api_key, + username: + authType === "userpass" + ? String(data.get("username") ?? "").trim() + : base.username, + password: + authType === "userpass" + ? String(data.get("password") ?? "").trim() + : base.password, + workspace_id: "", + }; +} + +function setServerDraft(server: ServerConfig): void { + state.authDraft = { ...server }; +} + +function markField( + field: AuthFieldName, + nextState: FieldValidationState["state"], + message: string | null = null +): void { + state.authFieldStates[field] = { state: nextState, message }; +} + +function authFieldErrorMarkup(field: AuthFieldName): string { + const fieldState = state.authFieldStates[field]; + if (fieldState.state !== "invalid" || !fieldState.message) { + return ""; + } + + return `

${escapeHtml(fieldState.message)}

`; +} + +function renderAuthInputField(params: { + label: string; + name: AuthFieldName; + type: "url" | "text" | "password"; + value: string; + placeholder: string; + helpText?: string; + labelAction?: string; +}): string { + const { label, name, type, value, placeholder, helpText, labelAction } = + params; + + return ` + + `; +} + +function clearAuthFormFeedback(formId: string): void { + state[fieldFormFeedbackTarget(formId)] = null; +} + +function clearAuthValidationCache(): void { + state.lastAuthValidationServer = null; + state.lastAuthValidationResult = null; +} + +function previewHeaders(): string[] { + const rows = parsedPreviewRows(); + const columnCount = rows.reduce((max, row) => Math.max(max, row.length), 0); + + if (!state.pipelineForm.hasHeaderRow) { + const dataRows = rows.slice(Math.max(state.pipelineForm.dataStartRow - 1, 0)); + const dataColumnCount = (dataRows.length > 0 ? dataRows : rows).reduce( + (max, row) => Math.max(max, row.length), + 0 + ); + return Array.from( + { length: dataColumnCount }, + (_, index) => `Column ${index + 1}` + ); + } + + const headerRow = rows[state.pipelineForm.headerRow - 1] ?? []; + return Array.from({ length: columnCount }, (_, index) => + normalizePreviewHeaderName(headerRow[index] ?? "", index) + ); +} + +function activePreviewRowTarget(): PreviewRowSelectionTarget | null { + if (state.pipelineDrag) { + return state.pipelineDrag.target; + } + + return state.pipelineSelectionTarget === "header-row" || + state.pipelineSelectionTarget === "data-start-row" + ? state.pipelineSelectionTarget + : null; +} + +function previewHandleLine( + target: PreviewRowSelectionTarget +): number | null { + if (state.pipelineDrag?.target === target) { + return state.pipelineDrag.lineNumber; + } + + if (target === "header-row") { + return state.pipelineForm.hasHeaderRow ? state.pipelineForm.headerRow : null; + } + + return state.pipelineForm.dataStartRow; +} + +function setPreviewRowSelectionTarget( + target: PreviewRowSelectionTarget, + lineNumber: number +): void { + if (target === "header-row") { + updateHeaderRowFromPreview(lineNumber); + return; + } + + updateDataStartRowFromPreview(lineNumber); +} + +function previewCommittedHandleLine( + target: PreviewRowSelectionTarget +): number | null { + if (target === "header-row") { + return state.pipelineForm.hasHeaderRow ? state.pipelineForm.headerRow : null; + } + + return state.pipelineForm.dataStartRow; +} + +function previewDragHandleSelector( + target: PreviewRowSelectionTarget, + lineNumber: number +): string { + return `[data-preview-handle-target="${target}"][data-preview-line="${lineNumber}"]`; +} + +function findPreviewHandleElement( + target: PreviewRowSelectionTarget, + lineNumber: number +): HTMLElement | null { + return mainContent.querySelector( + previewDragHandleSelector(target, lineNumber) + ); +} + +function collectPreviewRowButtons(): Map { + return new Map( + Array.from( + mainContent.querySelectorAll( + '[data-action="pick-preview-line"][data-preview-line]' + ) + ) + .map((button) => { + const lineNumber = Number(button.dataset.previewLine); + return Number.isFinite(lineNumber) ? [lineNumber, button] : null; + }) + .filter( + (entry): entry is [number, HTMLButtonElement] => entry !== null + ) + ); +} + +function collectPreviewRowElements(): Map { + return new Map( + Array.from( + mainContent.querySelectorAll("[data-preview-line-row]") + ) + .map((row) => { + const lineNumber = Number(row.dataset.previewLineRow); + return Number.isFinite(lineNumber) ? [lineNumber, row] : null; + }) + .filter( + (entry): entry is [number, HTMLTableRowElement] => entry !== null + ) + ); +} + +function collectPreviewRowCenters( + rowButtons: Map +): Array<{ lineNumber: number; centerY: number }> { + return Array.from(rowButtons.entries()).map(([lineNumber, button]) => { + const rect = button.getBoundingClientRect(); + return { lineNumber, centerY: rect.top + rect.height / 2 }; + }); +} + +function nearestPreviewLineNumber( + clientY: number, + rowCenters: Array<{ lineNumber: number; centerY: number }> +): number | null { + if (rowCenters.length === 0) { + return null; + } + + let bestLine = rowCenters[0].lineNumber; + let bestDistance = Math.abs(clientY - rowCenters[0].centerY); + + for (const row of rowCenters.slice(1)) { + const distance = Math.abs(clientY - row.centerY); + if (distance < bestDistance) { + bestDistance = distance; + bestLine = row.lineNumber; + } + } + + return bestLine; +} + +function activeTimestampColumn(): string { + return state.pipelineColumnDrag?.columnName ?? state.pipelineForm.timestampColumn; +} + +function findPreviewColumnHandleElement(columnName: string): HTMLElement | null { + return ( + Array.from( + mainContent.querySelectorAll("[data-preview-column-handle]") + ).find((element) => element.dataset.previewColumnHandle === columnName) ?? null + ); +} + +function collectPreviewHeaderButtons(): Map { + return new Map( + Array.from( + mainContent.querySelectorAll( + '[data-action="pick-preview-column"][data-preview-column]' + ) + ) + .map((button) => { + const columnName = button.dataset.previewColumn ?? ""; + return columnName ? [columnName, button] : null; + }) + .filter( + (entry): entry is [string, HTMLButtonElement] => entry !== null + ) + ); +} + +function collectPreviewColumnCells(): Map { + const cells = new Map(); + mainContent + .querySelectorAll("[data-preview-column-cell]") + .forEach((element) => { + const columnName = element.dataset.previewColumnCell ?? ""; + if (!columnName) { + return; + } + + const columnEntries = cells.get(columnName) ?? []; + columnEntries.push(element); + cells.set(columnName, columnEntries); + }); + return cells; +} + +function collectPreviewHeaderCenters( + headerButtons: Map +): Array<{ columnName: string; centerX: number }> { + return Array.from(headerButtons.entries()).map(([columnName, button]) => { + const rect = button.getBoundingClientRect(); + return { columnName, centerX: rect.left + rect.width / 2 }; + }); +} + +function nearestPreviewColumnName( + clientX: number, + headerCenters: Array<{ columnName: string; centerX: number }> +): string | null { + if (headerCenters.length === 0) { + return null; + } + + let bestColumn = headerCenters[0].columnName; + let bestDistance = Math.abs(clientX - headerCenters[0].centerX); + + for (const column of headerCenters.slice(1)) { + const distance = Math.abs(clientX - column.centerX); + if (distance < bestDistance) { + bestDistance = distance; + bestColumn = column.columnName; + } + } + + return bestColumn; +} + +function applyPreviewDragClasses(): void { + if (!state.pipelineDrag || !previewDragVisual) { + return; + } + + const headerLine = + state.pipelineDrag.target === "header-row" + ? state.pipelineDrag.lineNumber + : previewCommittedHandleLine("header-row"); + const dataLine = + state.pipelineDrag.target === "data-start-row" + ? state.pipelineDrag.lineNumber + : previewCommittedHandleLine("data-start-row"); + + for (const [lineNumber, button] of previewDragVisual.rowButtons.entries()) { + button.classList.toggle( + "preview-line-button-header", + state.pipelineForm.hasHeaderRow && headerLine === lineNumber + ); + button.classList.toggle("preview-line-button-data", dataLine === lineNumber); + } + + for (const [lineNumber, row] of previewDragVisual.rowElements.entries()) { + row.classList.toggle( + "preview-table-row-header", + state.pipelineForm.hasHeaderRow && headerLine === lineNumber + ); + row.classList.toggle("preview-table-row-data", dataLine === lineNumber); + } +} + +function flushPreviewDragVisual(): void { + if (!state.pipelineDrag || !previewDragVisual) { + return; + } + + previewDragVisual.frameRequested = false; + const offset = previewDragVisual.currentClientY - previewDragVisual.startClientY; + previewDragVisual.handle.style.setProperty( + "--preview-handle-offset", + `${offset}px` + ); + applyPreviewDragClasses(); +} + +function schedulePreviewDragVisual(): void { + if (!previewDragVisual || previewDragVisual.frameRequested) { + return; + } + + previewDragVisual.frameRequested = true; + window.requestAnimationFrame(flushPreviewDragVisual); +} + +function beginPreviewDragVisual(pointerClientY: number): void { + if (!state.pipelineDrag) { + return; + } + + const handle = findPreviewHandleElement( + state.pipelineDrag.target, + state.pipelineDrag.lineNumber + ); + if (!handle) { + return; + } + + const rowButtons = collectPreviewRowButtons(); + previewDragVisual = { + handle, + startClientY: pointerClientY, + currentClientY: pointerClientY, + rowButtons, + rowElements: collectPreviewRowElements(), + rowCenters: collectPreviewRowCenters(rowButtons), + frameRequested: false, + }; + + mainContent + .querySelectorAll(".preview-row-handle-active") + .forEach((element) => + element.classList.remove("preview-row-handle-active") + ); + handle.classList.add("preview-row-handle-active"); + handle.classList.add("preview-row-handle-dragging"); + handle.style.setProperty("--preview-handle-offset", "0px"); + applyPreviewDragClasses(); +} + +function endPreviewDragVisual(): void { + if (!previewDragVisual) { + return; + } + + if ( + state.pipelineDrag && + typeof previewDragVisual.handle.releasePointerCapture === "function" && + previewDragVisual.handle.hasPointerCapture(state.pipelineDrag.pointerId) + ) { + previewDragVisual.handle.releasePointerCapture(state.pipelineDrag.pointerId); + } + + previewDragVisual.handle.classList.remove("preview-row-handle-dragging"); + previewDragVisual.handle.style.removeProperty("--preview-handle-offset"); + previewDragVisual = null; +} + +function applyPreviewColumnDragClasses(): void { + if (!state.pipelineColumnDrag || !previewColumnDragVisual) { + return; + } + + for (const [columnName, cells] of previewColumnDragVisual.columnCells.entries()) { + const active = columnName === state.pipelineColumnDrag.columnName; + for (const cell of cells) { + cell.classList.toggle("preview-col-timestamp", active); + } + } +} + +function flushPreviewColumnDragVisual(): void { + if (!state.pipelineColumnDrag || !previewColumnDragVisual) { + return; + } + + previewColumnDragVisual.frameRequested = false; + const offset = + previewColumnDragVisual.currentClientX - previewColumnDragVisual.startClientX; + previewColumnDragVisual.handle.style.setProperty( + "--preview-column-handle-offset", + `${offset}px` + ); + applyPreviewColumnDragClasses(); +} + +function schedulePreviewColumnDragVisual(): void { + if (!previewColumnDragVisual || previewColumnDragVisual.frameRequested) { + return; + } + + previewColumnDragVisual.frameRequested = true; + window.requestAnimationFrame(flushPreviewColumnDragVisual); +} + +function beginPreviewColumnDragVisual(pointerClientX: number): void { + if (!state.pipelineColumnDrag) { + return; + } + + const handle = findPreviewColumnHandleElement(state.pipelineColumnDrag.columnName); + if (!handle) { + return; + } + + const headerButtons = collectPreviewHeaderButtons(); + previewColumnDragVisual = { + handle, + startClientX: pointerClientX, + currentClientX: pointerClientX, + headerButtons, + columnCells: collectPreviewColumnCells(), + headerCenters: collectPreviewHeaderCenters(headerButtons), + frameRequested: false, + }; + + handle.classList.add("preview-column-handle-dragging"); + handle.style.setProperty("--preview-column-handle-offset", "0px"); + applyPreviewColumnDragClasses(); +} + +function endPreviewColumnDragVisual(): void { + if (!previewColumnDragVisual) { + return; + } + + if ( + state.pipelineColumnDrag && + typeof previewColumnDragVisual.handle.releasePointerCapture === "function" && + previewColumnDragVisual.handle.hasPointerCapture( + state.pipelineColumnDrag.pointerId + ) + ) { + previewColumnDragVisual.handle.releasePointerCapture( + state.pipelineColumnDrag.pointerId + ); + } + + previewColumnDragVisual.handle.classList.remove( + "preview-column-handle-dragging" + ); + previewColumnDragVisual.handle.style.removeProperty( + "--preview-column-handle-offset" + ); + previewColumnDragVisual = null; +} + +function pipelineMappingsByColumn(): Map { + return new Map( + state.pipelineForm.mappings.map((mapping) => [ + mapping.csvColumn, + mapping.datastreamId, + ]) + ); +} + +function previewColumnClass(columnName: string): string { + if (columnName === state.pipelineForm.timestampColumn) { + return "preview-col-timestamp"; + } + + const mapped = state.pipelineForm.mappings.find( + (mapping) => mapping.csvColumn === columnName && mapping.datastreamId + ); + return mapped ? "preview-col-mapped" : ""; +} + +function previewFieldClass( + target: Exclude +): string { + const active = + target === "timestamp-column" + ? state.pipelineSelectionTarget === target || state.pipelineColumnDrag !== null + : activePreviewRowTarget() === target; + const toneClass = + target === "header-row" + ? "preview-bound-field-header" + : target === "data-start-row" + ? "preview-bound-field-data" + : "preview-bound-field-timestamp"; + + return active + ? `field preview-bound-field preview-bound-field-active ${toneClass}` + : "field preview-bound-field"; +} + +function previewGuidanceText(): string { + const activeTarget = activePreviewRowTarget(); + + if (activeTarget === "header-row") { + return "Drag the HEADER handle, or click a row to place it."; + } + + if (activeTarget === "data-start-row") { + return "Drag the DATA START handle, or click the first data row."; + } + + if ( + state.pipelineSelectionTarget === "timestamp-column" || + state.pipelineColumnDrag + ) { + return "Drag the TIMESTAMP handle, or click a column header to place it."; + } + + return state.pipelineForm.hasHeaderRow + ? "Drag the HEADER, DATA START, and TIMESTAMP handles, or click a row or column to place them." + : "Drag the DATA START and TIMESTAMP handles, or click a row or column to place them."; +} + +function syncPipelineSelectionsWithPreview(): void { + const headers = previewHeaders(); + + if (headers.length === 0) { + state.pipelineForm.mappings = []; + return; + } + + const preferredTimestamp = + headers.find((header) => header.toLowerCase().includes("time")) ?? + headers[0]; + + state.pipelineForm.timestampColumn = headers.includes( + state.pipelineForm.timestampColumn + ) + ? state.pipelineForm.timestampColumn + : preferredTimestamp; + + initializeMappings(headers); +} + +function initializeMappings(headers: string[]): void { + const existing = pipelineMappingsByColumn(); + state.pipelineForm.mappings = headers + .filter((header) => header !== state.pipelineForm.timestampColumn) + .map((header) => ({ + csvColumn: header, + datastreamId: existing.get(header) ?? "", + })); +} + +function applyPreview(path: string, preview: CsvPreviewResponse): void { + state.pipelinePreview = preview; + state.pipelineForm.filePath = path; + state.pipelineForm.hasHeaderRow = preview.detected_header_row !== null; + state.pipelineForm.headerRow = + preview.detected_header_row ?? state.pipelineForm.headerRow; + state.pipelineForm.dataStartRow = + preview.detected_data_start_row ?? state.pipelineForm.dataStartRow; + state.pipelineForm.delimiter = + preview.detected_delimiter || state.pipelineForm.delimiter; + state.pipelineSelectionTarget = null; + state.pipelineDrag = null; + state.pipelineColumnDrag = null; + + if (!state.pipelineForm.name.trim()) { + const inferred = basename(path).replace(/\.[^.]+$/, ""); + state.pipelineForm.name = inferred; + } + + syncPipelineSelectionsWithPreview(); +} + +function canShowMorePreviewLines(): boolean { + if (!state.pipelinePreview) { + return false; + } + + return state.pipelinePreview.raw_lines.length < state.pipelinePreview.total_lines; +} + +function updateHeaderRowFromPreview(lineNumber: number): void { + state.pipelineForm.hasHeaderRow = true; + state.pipelineForm.headerRow = lineNumber; + if (state.pipelineForm.dataStartRow <= lineNumber) { + state.pipelineForm.dataStartRow = lineNumber + 1; + } + syncPipelineSelectionsWithPreview(); +} + +function updateDataStartRowFromPreview(lineNumber: number): void { + state.pipelineForm.dataStartRow = Math.max( + state.pipelineForm.hasHeaderRow ? 2 : 1, + lineNumber + ); + if ( + state.pipelineForm.hasHeaderRow && + state.pipelineForm.headerRow >= state.pipelineForm.dataStartRow + ) { + state.pipelineForm.headerRow = state.pipelineForm.dataStartRow - 1; + } + syncPipelineSelectionsWithPreview(); +} + +function setPipelineHasHeaderRow(enabled: boolean): void { + state.pipelineForm.hasHeaderRow = enabled; + + if (!enabled && state.pipelineSelectionTarget === "header-row") { + state.pipelineSelectionTarget = null; + } + + if (!enabled && state.pipelineDrag?.target === "header-row") { + state.pipelineDrag = null; + } + + if ( + enabled && + state.pipelineForm.headerRow >= state.pipelineForm.dataStartRow + ) { + state.pipelineForm.headerRow = Math.max( + 1, + state.pipelineForm.dataStartRow - 1 + ); + } + + syncPipelineSelectionsWithPreview(); +} + +function applyPreviewLineSelection(lineNumber: number): void { + if (state.pipelineSelectionTarget === "header-row") { + setPreviewRowSelectionTarget("header-row", lineNumber); + state.pipelineSelectionTarget = null; + render(); + return; + } + + if (state.pipelineSelectionTarget === "data-start-row") { + setPreviewRowSelectionTarget("data-start-row", lineNumber); + state.pipelineSelectionTarget = null; + render(); + } +} + +function applyPreviewColumnSelection(columnName: string): void { + if ( + state.pipelineSelectionTarget && + state.pipelineSelectionTarget !== "timestamp-column" + ) { + return; + } + + state.pipelineForm.timestampColumn = columnName; + initializeMappings(previewHeaders()); + state.pipelineSelectionTarget = null; + state.pipelineColumnDrag = null; + render(); +} + +function onboardingRoute(route: AppRoute): boolean { + return ( + route === "welcome" || (route === "jobs-new" && state.jobs.length === 0) + ); +} + +function connectionIndicator(): { label: string; className: string } { + if (!serverConfigured(state.config?.server)) { + return { + label: "HydroServer not configured", + className: "status-dot bg-slate-300", + }; + } + + if (connected()) { + return { + label: "Connected to HydroServer", + className: "status-dot bg-emerald-500", + }; + } + + if (state.lastConnectionState === "error") { + return { + label: "HydroServer authentication error", + className: "status-dot bg-rose-500", + }; + } + + return { + label: "HydroServer configured", + className: "status-dot bg-sky-500", + }; +} + +function statusPill(job: JobSummary): string { + const classes: Record = { + healthy: "pill-success", + warning: "pill-warning", + error: "pill-danger", + disabled: "pill-muted", + pending: "pill-info", + running: "pill-info", + }; + + return `${escapeHtml( + job.status_message + )}`; +} + +function renderConnectedCard(showActions: boolean): string { + if (!connected() || !state.connectionSummary) { + return ""; + } + + const datastreamText = + state.connectionSummary.datastream_count === 1 + ? "1 datastream available" + : `${state.connectionSummary.datastream_count} datastreams available`; + + return ` +
+
+

Authenticated

+

${escapeHtml( + state.connectionSummary.instance_name ?? "HydroServer" + )}

+

${escapeHtml( + state.connectionSummary.message + )}

+
+ Connected + ${escapeHtml(datastreamText)} +
+
+ ${ + showActions + ? ` +
+ + + ${ + state.jobs.length === 0 + ? `Create first pipeline` + : "" + } +
+ ` + : "" + } +
+ `; +} + +function renderAuthForm( + formId: "welcome-form" | "settings-form", + submitLabel: string, + secondaryAction: string +): string { + const server = currentServerConfig(); + const usingUserPass = server.auth_type === "userpass"; + const authToggleLabel = usingUserPass + ? "Connect with an API key" + : "Connect with username and password"; + const submitDisabled = state.authSubmitting ? "disabled" : ""; + const submitLabelText = state.authSubmitting ? "Connecting..." : submitLabel; + + return ` +
+
+
+ HydroServer Streaming Data Loader icon +

Connect to HydroServer

+
+ + + ${renderAuthInputField({ + label: "Host URL", + name: "url", + type: "url", + value: server.url, + placeholder: "https://playground.hydroserver.org", + })} + + ${ + usingUserPass + ? ` + ${renderAuthInputField({ + label: "Username", + name: "username", + type: "text", + value: server.username, + placeholder: "name@example.com", + })} + ${renderAuthInputField({ + label: "Password", + name: "password", + type: "password", + value: server.password, + placeholder: "Enter your HydroServer password", + })} + ` + : ` + ${renderAuthInputField({ + label: "API key", + name: "api_key", + type: "password", + value: server.api_key, + placeholder: + "KaTz74swGqHn__I2VY6ceIzrIxC04oDhUrLLgBTH9ACxYIunmkrdmqk", + labelAction: `How to create an API key →`, + })} + ` + } + +
+ or + + +
+ +
+ ${secondaryAction} + +
+
+
+ `; +} + +function renderWelcome(): string { + return ` +
+ ${renderAuthForm("welcome-form", "Connect to HydroServer", "")} +
+ `; +} + +function renderSettings(): string { + const showForm = !connected() || state.settingsEditMode; + + return ` +
+ + + ${ + showForm + ? renderAuthForm( + "settings-form", + "Save and verify", + connected() + ? '' + : "" + ) + : renderConnectedCard(true) + } +
+ `; +} + +function renderDashboard(): string { + if (state.jobs.length === 0) { + return ` +
+ +
+ `; + } + + const cards = state.jobs + .map((job) => { + const lastLine = job.last_error + ? `Failed ${formatRelativeTime(job.last_run_at)}` + : `Last pushed ${formatRelativeTime(job.last_pushed_timestamp)}`; + + return ` +
+
+
+
+ +

${escapeHtml(job.name)}

+
+

${escapeHtml( + shortenPath(job.file_path) + )}

+

+ ${escapeHtml(lastLine)} · ${escapeHtml( + formatSchedule(job.schedule_minutes) + )} +

+
+ ${statusPill(job)} +
+ +
+ + + +
+
+ `; + }) + .join(""); + + return ` +
+ +
${cards}
+
+ `; +} + +function renderPreviewHandle( + target: PreviewRowSelectionTarget, + lineNumber: number +): string { + const handleLine = previewHandleLine(target); + if (handleLine !== lineNumber) { + return ""; + } + + const active = activePreviewRowTarget() === target; + const label = target === "header-row" ? "HEADER" : "DATA START"; + const className = + target === "header-row" + ? "preview-row-handle preview-row-handle-header" + : "preview-row-handle preview-row-handle-data"; + + return ` + + `; +} + +function renderTimestampHandle(columnName: string): string { + if (columnName !== activeTimestampColumn()) { + return ""; + } + + const active = + state.pipelineSelectionTarget === "timestamp-column" || + state.pipelineColumnDrag !== null; + + return ` + + `; +} + +function renderPipelinePreview(): string { + if (!state.pipelinePreview) { + return ` +
+
+
CSV
+

Preview a source file

+

Choose a CSV file path, then load the preview to inspect the first 50 lines and map the source structure into HydroServer.

+
+
+ `; + } + + const headers = previewHeaders(); + const parsedRows = parsedPreviewRows().map((row, index) => ({ + lineNumber: index + 1, + row, + })); + const headerLine = previewHandleLine("header-row"); + const dataStartLine = previewHandleLine("data-start-row"); + + const headerCells = headers + .map( + (header) => + ` +
+ ${renderTimestampHandle(header)} + +
+ ` + ) + .join(""); + + const tableRows = parsedRows + .map( + ({ lineNumber, row }) => ` + + +
+ ${ + state.pipelineForm.hasHeaderRow + ? renderPreviewHandle("header-row", lineNumber) + : "" + } + ${renderPreviewHandle("data-start-row", lineNumber)} + +
+ + ${headers + .map((columnName, index) => { + const cell = row[index] ?? ""; + return `${escapeHtml(cell)}`; + }) + .join("")} + + ` + ) + .join(""); + const shownLines = state.pipelinePreview.raw_lines.length; + const remainingLines = Math.max(state.pipelinePreview.total_lines - shownLines, 0); + const nextPageSize = Math.min(PREVIEW_PAGE_SIZE, remainingLines); + const showMoreButton = canShowMorePreviewLines() + ? ` + + ` + : ""; + + return ` +
+
+
+

Preview

+

${escapeHtml( + basename(state.pipelineForm.filePath) + )}

+

${escapeHtml(previewGuidanceText())}

+
+
+ + + +
+ + + + + ${headerCells} + + + + ${tableRows} + +
Line
+
+ +
+ + Showing the first ${shownLines} lines of ${state.pipelinePreview.total_lines} + + ${showMoreButton} +
+
+ `; +} + +function renderPipelineMappings(): string { + const availableMappings = state.pipelineForm.mappings; + + if (!state.pipelinePreview || availableMappings.length === 0) { + return ` +
+

Column mappings

+

Load a CSV preview first so HydroServer Streaming Data Loader can list the available source columns.

+
+ `; + } + + const rows = availableMappings + .map((mapping) => { + const options = [ + ``, + ...state.datastreams.map( + (datastream) => + `` + ), + ].join(""); + + return ` +
+
+

${escapeHtml(mapping.csvColumn)}

+

Source column

+
+ +
+ `; + }) + .join(""); + + return ` +
+

Column mappings

+

Map each source column to a HydroServer datastream. Leave any unused source columns as “Not mapped.”

+
${rows}
+
+ `; +} + +function renderFirstPipelineOnboarding(): string { + return ` +
+
+ + +
+ +
+
+ + ${feedbackMarkup(state.pipelineFeedback)} + ${state.pipelinePreview ? renderPipelinePreview() : ""} +
+ `; +} + +function renderPipelineEditor(): string { + const firstRunOnboarding = state.jobs.length === 0; + const shellClass = firstRunOnboarding + ? "page-shell onboarding-shell animate-fade-in" + : "page-shell animate-fade-in"; + + if (!connected()) { + return renderWelcome(); + } + + if (firstRunOnboarding) { + return renderFirstPipelineOnboarding(); + } + + if (state.datastreamsError) { + return ` +
+ + + ${renderConnectedCard(true)} +
${escapeHtml(state.datastreamsError)}
+
+ `; + } + + if (state.datastreams.length === 0) { + return ` +
+ + + ${renderConnectedCard(true)} + + Open the HydroServer 101 tutorial + +
+ `; + } + + const timestampOptions = previewHeaders() + .map( + (header) => + `` + ) + .join(""); + + const pipelineErrorMarkup = + state.pipelineErrors.length > 0 + ? ` +
+

Fix these issues before saving

+
    + ${state.pipelineErrors + .map((error) => `
  • ${escapeHtml(error)}
  • `) + .join("")} +
+
+ ` + : ""; + + return ` +
+ + + ${renderConnectedCard(true)} + +
+
+
+

Pipeline details

+ + + + + +
+ +
+ + +
+ +
+

File structure

+ +
+ ${ + state.pipelineForm.hasHeaderRow + ? ` +
+
+ +
+ + Drag the blue HEADER handle in the preview or enter a row number. +
+ ` + : ` +
+ Header row + This file is using generated column labels: Column 1, Column 2, Column 3... +
+ ` + } + +
+
+ +
+ + Drag the green DATA START handle in the preview or enter a row number. +
+
+ +
+ + + +
+ +
+
+ +
+ ${ + previewHeaders().length > 0 + ? `` + : `` + } + Drag the amber TIMESTAMP handle in the preview, or click the matching header. +
+ + +
+ + ${renderPipelineMappings()} + ${pipelineErrorMarkup} + ${feedbackMarkup(state.pipelineFeedback)} + +
+ +
+
+ + ${renderPipelinePreview()} +
+
+ `; +} + +function renderFatalError(): string { + return ` +
+
+

Sidecar error

+

The background process is unavailable

+

${escapeHtml( + state.bootstrapError ?? + `${APP_NAME} could not reach the local background service.` + )}

+ +
+
+ `; +} + +function render(): void { + state.route = getRouteFromHash(); + + let currentRoute = getRouteFromHash(); + + if (!state.loading && !state.bootstrapError) { + if ( + !connected() && + currentRoute !== "settings" && + currentRoute !== "welcome" + ) { + navigate("welcome"); + currentRoute = "welcome"; + } else if ( + connected() && + state.jobs.length === 0 && + (currentRoute === "dashboard" || currentRoute === "welcome") + ) { + navigate("jobs-new"); + currentRoute = "jobs-new"; + } + } + + const inOnboardingRoute = onboardingRoute(currentRoute); + const showSidebar = !inOnboardingRoute && !state.bootstrapError; + const useWelcomeSurface = Boolean( + state.loading || state.bootstrapError || inOnboardingRoute + ); + sidebar.classList.toggle("hidden", !showSidebar); + mainContent.classList.toggle("main-content-welcome", useWelcomeSurface); + document.body.classList.toggle("app-surface-welcome", useWelcomeSurface); + + jobsLink.className = + currentRoute === "dashboard" ? "nav-item nav-item-active" : "nav-item"; + settingsLink.className = + currentRoute === "settings" ? "nav-item nav-item-active" : "nav-item"; + + const status = connectionIndicator(); + connectionDot.className = status.className; + connectionDot.title = status.label; + + let nextMarkup = ""; + + if (state.loading) { + nextMarkup = ` +
+ +
+ `; + } else if (state.bootstrapError) { + nextMarkup = renderFatalError(); + } else if (currentRoute === "settings") { + nextMarkup = renderSettings(); + } else if (currentRoute === "welcome") { + nextMarkup = renderWelcome(); + } else if (currentRoute === "jobs-new") { + nextMarkup = renderPipelineEditor(); + } else { + nextMarkup = renderDashboard(); + } + + if (nextMarkup !== lastRenderedMarkup) { + mainContent.innerHTML = nextMarkup; + lastRenderedMarkup = nextMarkup; + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => window.setTimeout(resolve, ms)); +} + +function isTransientBootstrapError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + + const message = error.message.toLowerCase(); + return ( + message.includes("failed to fetch") || + message.includes("networkerror") || + message.includes("status 500") || + message.includes("status 502") || + message.includes("status 503") || + message.includes("status 504") + ); +} + +async function loadInitialStateWithRetry(): Promise<{ + health: HealthResponse; + config: AppConfig; + jobs: JobSummary[]; +}> { + let lastError: unknown = null; + + for (let attempt = 1; attempt <= STARTUP_RETRY_ATTEMPTS; attempt += 1) { + try { + const [health, config, jobs] = await Promise.all([ + getHealth(), + getConfig(), + listJobs(), + ]); + return { health, config, jobs }; + } catch (error) { + lastError = error; + + if ( + attempt === STARTUP_RETRY_ATTEMPTS || + !isTransientBootstrapError(error) + ) { + throw error; + } + + await sleep(STARTUP_RETRY_DELAY_MS); + } + } + + throw lastError instanceof Error + ? lastError + : new Error(`Failed to load ${APP_NAME}.`); +} + +async function syncAuthenticationStatus( + server: ServerConfig +): Promise { + const result = await testConnection(server); + state.lastAuthValidationServer = server; + state.lastAuthValidationResult = result; + state.connectionSummary = result; + state.lastConnectionState = result.state; + + if (result.ok && result.workspace_id) { + if (state.config) { + state.config.server.workspace_id = result.workspace_id; + } + state.authDraft.workspace_id = result.workspace_id; + } + + if (!result.ok) { + state.datastreams = []; + state.datastreamsError = null; + } + + return result; +} + +async function loadDatastreams(): Promise { + try { + state.datastreams = await getDatastreams(); + state.datastreamsError = null; + } catch (error) { + state.datastreams = []; + state.datastreamsError = + error instanceof Error + ? error.message + : "Couldn't load HydroServer datastreams."; + } +} + +async function bootstrap(): Promise { + state.loading = true; + state.bootstrapError = null; + state.welcomeFeedback = null; + state.settingsFeedback = null; + render(); + + try { + const { health, config, jobs } = await loadInitialStateWithRetry(); + state.health = health; + state.config = config; + state.authDraft = { + ...emptyServerConfig(), + ...config.server, + }; + state.jobs = jobs; + state.lastConnectionState = health.connection.state; + + if (serverConfigured(config.server)) { + const result = await syncAuthenticationStatus(config.server); + if (result.ok) { + await loadDatastreams(); + } + } + } catch (error) { + state.bootstrapError = + error instanceof Error ? error.message : `Failed to load ${APP_NAME}.`; + } finally { + state.loading = false; + render(); + } +} + +async function refreshJobs(): Promise { + if (state.bootstrapError || state.loading) { + return; + } + + try { + state.jobs = await listJobs(); + render(); + } catch { + // Keep existing UI state on polling failure. + } +} + +function updatePipelineField(name: string, value: string): void { + switch (name) { + case "pipeline_name": + state.pipelineForm.name = value; + break; + case "file_path": + state.pipelineForm.filePath = value; + state.pipelinePreview = null; + state.pipelineErrors = []; + state.pipelineSelectionTarget = null; + state.pipelineDrag = null; + state.pipelineColumnDrag = null; + state.pipelinePreviewRowsRequested = PREVIEW_PAGE_SIZE; + break; + case "schedule_minutes": + state.pipelineForm.scheduleMinutes = Number(value) || 15; + break; + case "header_row": + state.pipelineForm.headerRow = Number(value) || 1; + syncPipelineSelectionsWithPreview(); + break; + case "data_start_row": + state.pipelineForm.dataStartRow = Number(value) || 1; + syncPipelineSelectionsWithPreview(); + break; + case "delimiter": + state.pipelineForm.delimiter = value || ","; + syncPipelineSelectionsWithPreview(); + break; + case "timestamp_column": + state.pipelineForm.timestampColumn = value; + initializeMappings(previewHeaders()); + state.pipelineColumnDrag = null; + render(); + break; + case "timestamp_format": + state.pipelineForm.timestampFormat = value; + break; + case "timezone": + state.pipelineForm.timezone = value; + break; + default: + break; + } +} + +function validatePipeline(): string[] { + const errors: string[] = []; + const headers = previewHeaders(); + const selectedMappings = state.pipelineForm.mappings.filter( + (mapping) => mapping.datastreamId + ); + const datastreamIds = new Set( + state.datastreams.map((datastream) => datastream.id) + ); + const seenTargets = new Set(); + + if (!connected()) { + errors.push("Connect to HydroServer before saving a pipeline."); + } + + if (!state.pipelineForm.name.trim()) { + errors.push("Give the pipeline a name."); + } + + if (!state.pipelineForm.filePath.trim()) { + errors.push(`Choose the CSV file ${APP_NAME} should watch.`); + } + + if (!state.pipelinePreview) { + errors.push("Load a CSV preview before saving the pipeline."); + } + + if (state.pipelineForm.hasHeaderRow && state.pipelineForm.headerRow < 1) { + errors.push("Header row must be 1 or greater."); + } + + if ( + state.pipelineForm.hasHeaderRow && + state.pipelineForm.dataStartRow <= state.pipelineForm.headerRow + ) { + errors.push("Data start row must come after the header row."); + } + + if (!state.pipelineForm.hasHeaderRow && state.pipelineForm.dataStartRow < 1) { + errors.push("Data start row must be 1 or greater."); + } + + if ( + headers.length > 0 && + !headers.includes(state.pipelineForm.timestampColumn) + ) { + errors.push( + "Choose a timestamp column that exists in the previewed CSV header." + ); + } + + if (selectedMappings.length === 0) { + errors.push("Map at least one source column to a HydroServer datastream."); + } + + for (const mapping of selectedMappings) { + if (!datastreamIds.has(mapping.datastreamId)) { + errors.push( + `The selected target for ${mapping.csvColumn} is not a valid HydroServer datastream.` + ); + } + + if (seenTargets.has(mapping.datastreamId)) { + errors.push( + "Each target datastream can only be mapped once in this first-run flow." + ); + } + + seenTargets.add(mapping.datastreamId); + } + + return errors; +} + +async function loadPipelinePreview( + path: string, + rows = PREVIEW_PAGE_SIZE +): Promise { + if (!path.trim()) { + state.pipelineFeedback = { + tone: "error", + message: "Enter or choose a CSV file path first.", + }; + render(); + return; + } + + try { + const preview = await getCsvPreview(path.trim(), rows); + applyPreview(path.trim(), preview); + state.pipelinePreviewRowsRequested = rows; + state.pipelineErrors = []; + state.pipelineFeedback = null; + } catch (error) { + state.pipelinePreview = null; + state.pipelineSelectionTarget = null; + state.pipelineDrag = null; + state.pipelineColumnDrag = null; + state.pipelinePreviewRowsRequested = PREVIEW_PAGE_SIZE; + state.pipelineFeedback = { + tone: "error", + message: + error instanceof Error + ? error.message + : "Couldn't preview that CSV file.", + }; + } + + render(); +} + +async function browseForCsvPath(): Promise { + try { + const dialog = await import("@tauri-apps/plugin-dialog"); + const selection = await dialog.open({ + directory: false, + multiple: false, + filters: [{ name: "CSV files", extensions: ["csv", "txt"] }], + }); + + if (typeof selection !== "string" || !selection) { + return; + } + + state.pipelineForm.filePath = selection; + if (!state.pipelineForm.name.trim()) { + state.pipelineForm.name = basename(selection).replace(/\.[^.]+$/, ""); + } + + await loadPipelinePreview(selection); + } catch { + state.pipelineFeedback = { + tone: "info", + message: + "The native file picker is only available in the desktop app. Enter the CSV path manually if you're using the browser preview.", + }; + render(); + } +} + +async function saveAuthenticatedServerConfig( + form: HTMLFormElement +): Promise { + if (state.authSubmitting) { + return; + } + + const payload = readServerConfigForm(form); + setServerDraft(payload); + + const feedbackKey = fieldFormFeedbackTarget(form.id); + + state[feedbackKey] = null; + resetStateAuthFieldStates(payload.auth_type); + + if (!validateAuthFieldsForSubmit(payload, markField)) { + render(); + return; + } + + try { + await runAuthSubmission({ + render, + setSubmitting: (value) => { + state.authSubmitting = value; + }, + action: async () => { + const urlValidation = await validateServerUrl(payload.url); + if (!urlValidation.ok) { + clearAuthValidationCache(); + markField("url", "invalid", urlValidation.message); + state[feedbackKey] = { + tone: "error", + message: urlValidation.message, + }; + return; + } + + markField("url", "valid"); + + const result = await syncAuthenticationStatus(payload); + applyConnectionValidationResult(payload, result, markField); + if (!result.ok) { + state[feedbackKey] = { tone: "error", message: result.message }; + return; + } + + state.config = await updateServerConfig(payload); + state.authDraft = { + ...emptyServerConfig(), + ...state.config.server, + }; + await syncAuthenticationStatus(state.config.server); + await loadDatastreams(); + state[feedbackKey] = { tone: "success", message: result.message }; + state.settingsEditMode = false; + + if (state.jobs.length === 0) { + navigate("jobs-new"); + } else { + navigate("dashboard"); + } + }, + }); + } catch (error) { + clearAuthValidationCache(); + state[feedbackKey] = { + tone: "error", + message: + error instanceof Error + ? error.message + : "Couldn't verify the HydroServer connection.", + }; + state.lastConnectionState = "error"; + render(); + } +} + +async function disconnectHydroServer(): Promise { + try { + state.config = await clearServerConfig(); + state.authDraft = emptyServerConfig(); + state.connectionSummary = null; + state.lastConnectionState = "not_configured"; + state.datastreams = []; + state.datastreamsError = null; + state.welcomeFeedback = null; + state.settingsFeedback = null; + state.settingsEditMode = false; + resetStateAuthFieldStates("apikey"); + clearAuthValidationCache(); + navigate("welcome"); + } catch (error) { + state.settingsFeedback = { + tone: "error", + message: + error instanceof Error + ? error.message + : "Couldn't disconnect from HydroServer right now.", + }; + } + + render(); } -const { sidebar, mainContent, jobsLink, settingsLink, connectionDot } = shellElements; +async function savePipeline(): Promise { + state.pipelineErrors = validatePipeline(); + + if (state.pipelineErrors.length > 0) { + state.pipelineFeedback = { + tone: "error", + message: `${APP_NAME} needs a little more information before it can save this pipeline.`, + }; + render(); + return; + } + + const mappedColumns = state.pipelineForm.mappings + .filter((mapping) => mapping.datastreamId) + .map((mapping) => { + const datastream = state.datastreams.find( + (item) => item.id === mapping.datastreamId + ); + return { + csv_column: mapping.csvColumn, + datastream_id: mapping.datastreamId, + datastream_name: datastream?.name ?? mapping.datastreamId, + }; + }); + + try { + const created = await createJob({ + name: state.pipelineForm.name.trim(), + enabled: true, + file_path: state.pipelineForm.filePath.trim(), + schedule_minutes: state.pipelineForm.scheduleMinutes, + file_config: { + header_row: state.pipelineForm.hasHeaderRow + ? state.pipelineForm.headerRow + : 0, + data_start_row: state.pipelineForm.dataStartRow, + delimiter: state.pipelineForm.delimiter, + timestamp_column: state.pipelineForm.timestampColumn, + timestamp_format: state.pipelineForm.timestampFormat, + timezone: state.pipelineForm.timezone, + }, + column_mappings: mappedColumns, + }); -// ── Initialize renderer and drag events ─────────────────────────────────── -initRenderer({ sidebar, mainContent, jobsLink, settingsLink, connectionDot }); -initPreviewDragEvents(mainContent, render); + state.jobs = [...state.jobs, created]; + state.pipelineForm = createEmptyPipelineForm(); + state.pipelinePreview = null; + state.pipelineSelectionTarget = null; + state.pipelineDrag = null; + state.pipelineColumnDrag = null; + state.pipelinePreviewRowsRequested = PREVIEW_PAGE_SIZE; + state.pipelineErrors = []; + state.pipelineFeedback = { tone: "success", message: "Pipeline saved." }; + navigate("dashboard"); + } catch (error) { + state.pipelineFeedback = { + tone: "error", + message: + error instanceof Error ? error.message : "Couldn't save that pipeline.", + }; + } -// ── Background polling ───────────────────────────────────────────────────── -window.setInterval(() => void refreshJobs(), 30_000); + render(); +} -// ── Route changes ────────────────────────────────────────────────────────── window.addEventListener("hashchange", () => { state.settingsFeedback = null; render(); }); -// ── Form submission ──────────────────────────────────────────────────────── mainContent.addEventListener("submit", (event) => { - const form = event.target; - if (!(form instanceof HTMLFormElement)) return; + const target = event.target; + if (!(target instanceof HTMLFormElement)) { + return; + } + event.preventDefault(); - if (form.id === "welcome-form" || form.id === "settings-form") { - void saveAuthenticatedServerConfig(form); + if (target.id === "welcome-form") { + void saveAuthenticatedServerConfig(target); + return; + } + + if (target.id === "settings-form") { + void saveAuthenticatedServerConfig(target); + return; + } + + if (target.id === "pipeline-form") { + if (!state.pipelinePreview) { + void loadPipelinePreview(state.pipelineForm.filePath); + return; + } + + void savePipeline(); } }); -// ── Live input updates ───────────────────────────────────────────────────── mainContent.addEventListener("input", (event) => { const target = event.target; + if ( !( target instanceof HTMLInputElement || @@ -90,12 +2447,15 @@ mainContent.addEventListener("input", (event) => { return; } - // Auth forms: keep draft in sync and clear stale validation. - if (target.form?.id === "welcome-form" || target.form?.id === "settings-form") { + if ( + target.form?.id === "welcome-form" || + target.form?.id === "settings-form" + ) { const form = target.form; setServerDraft(readServerConfigForm(form)); clearAuthFormFeedback(form.id); clearAuthValidationCache(); + if ( target instanceof HTMLInputElement && (target.name === "url" || @@ -103,29 +2463,32 @@ mainContent.addEventListener("input", (event) => { target.name === "username" || target.name === "password") ) { - markField(target.name as "url" | "api_key" | "username" | "password", "idle"); + markField(target.name, "idle"); } return; } - if (target.form?.id !== "pipeline-form") return; + if (target.form?.id !== "pipeline-form") { + return; + } state.pipelineFeedback = null; state.pipelineErrors = []; - // Mapping dropdown: update the specific mapping entry. - const mappingColumn = (target as HTMLElement).dataset.mappingColumn; + const mappingColumn = target.dataset.mappingColumn; if (mappingColumn) { const mapping = state.pipelineForm.mappings.find( - (m) => m.csvColumn === mappingColumn + (item) => item.csvColumn === mappingColumn ); - if (mapping) mapping.datastreamId = target.value; + if (mapping) { + mapping.datastreamId = target.value; + } render(); return; } - // Pipeline form fields: update state, re-render for structural changes. updatePipelineField(target.name, target.value); + if ( target.name === "header_row" || target.name === "data_start_row" || @@ -136,9 +2499,9 @@ mainContent.addEventListener("input", (event) => { } }); -// ── Change events ────────────────────────────────────────────────────────── mainContent.addEventListener("change", (event) => { const target = event.target; + if ( !( target instanceof HTMLInputElement || @@ -155,138 +2518,405 @@ mainContent.addEventListener("change", (event) => { return; } - if (target.form?.id === "pipeline-form" && target.name === "file_path") { - void loadPipelinePreview(target.value); + if (target.form?.id !== "pipeline-form" || target.name !== "file_path") { + return; + } + + void loadPipelinePreview(target.value); +}); + +mainContent.addEventListener("pointerdown", (event) => { + const target = event.target; + if (!(target instanceof HTMLElement)) { + return; + } + + const handle = target.closest("[data-preview-handle-target]"); + if (handle) { + const pickerTarget = handle.dataset.previewHandleTarget; + if (pickerTarget !== "header-row" && pickerTarget !== "data-start-row") { + return; + } + + const lineNumber = Number(handle.dataset.previewLine); + if (!Number.isFinite(lineNumber) || lineNumber < 1) { + return; + } + + state.pipelineSelectionTarget = pickerTarget; + state.pipelineDrag = { + target: pickerTarget, + lineNumber, + pointerId: event.pointerId, + moved: false, + }; + suppressPreviewHandleClick = false; + if (typeof handle.setPointerCapture === "function") { + handle.setPointerCapture(event.pointerId); + } + beginPreviewDragVisual(event.clientY); + event.preventDefault(); + return; + } + + const columnHandle = target.closest("[data-preview-column-handle]"); + if (!columnHandle) { + return; + } + + const columnName = columnHandle.dataset.previewColumnHandle ?? ""; + if (!columnName) { + return; + } + + state.pipelineSelectionTarget = "timestamp-column"; + state.pipelineColumnDrag = { + columnName, + pointerId: event.pointerId, + moved: false, + }; + if (typeof columnHandle.setPointerCapture === "function") { + columnHandle.setPointerCapture(event.pointerId); + } + beginPreviewColumnDragVisual(event.clientX); + event.preventDefault(); +}); + +window.addEventListener("pointermove", (event) => { + if (state.pipelineDrag?.pointerId === event.pointerId) { + if (!previewDragVisual) { + return; + } + + previewDragVisual.currentClientY = event.clientY; + const lineNumber = nearestPreviewLineNumber( + event.clientY, + previewDragVisual.rowCenters + ); + if (!lineNumber) { + schedulePreviewDragVisual(); + return; + } + + if (lineNumber === state.pipelineDrag.lineNumber) { + schedulePreviewDragVisual(); + return; + } + + state.pipelineDrag = { + ...state.pipelineDrag, + lineNumber, + moved: true, + }; + schedulePreviewDragVisual(); + return; + } + + if ( + !state.pipelineColumnDrag || + state.pipelineColumnDrag.pointerId !== event.pointerId + ) { + return; + } + + if (!previewColumnDragVisual) { + return; + } + + previewColumnDragVisual.currentClientX = event.clientX; + const columnName = nearestPreviewColumnName( + event.clientX, + previewColumnDragVisual.headerCenters + ); + if (!columnName) { + schedulePreviewColumnDragVisual(); + return; + } + + if (columnName === state.pipelineColumnDrag.columnName) { + schedulePreviewColumnDragVisual(); + return; + } + + state.pipelineColumnDrag = { + ...state.pipelineColumnDrag, + columnName, + moved: true, + }; + schedulePreviewColumnDragVisual(); +}); + +window.addEventListener("pointerup", (event) => { + if (state.pipelineDrag?.pointerId === event.pointerId) { + const drag = state.pipelineDrag; + endPreviewDragVisual(); + state.pipelineDrag = null; + + if (drag.moved) { + setPreviewRowSelectionTarget(drag.target, drag.lineNumber); + state.pipelineSelectionTarget = null; + suppressPreviewHandleClick = true; + } else { + state.pipelineSelectionTarget = drag.target; + suppressPreviewHandleClick = false; + } + + render(); + return; + } + + if ( + !state.pipelineColumnDrag || + state.pipelineColumnDrag.pointerId !== event.pointerId + ) { + return; + } + + const drag = state.pipelineColumnDrag; + endPreviewColumnDragVisual(); + state.pipelineColumnDrag = null; + + if (drag.moved) { + state.pipelineForm.timestampColumn = drag.columnName; + initializeMappings(previewHeaders()); + state.pipelineSelectionTarget = null; + } else { + state.pipelineSelectionTarget = "timestamp-column"; + } + + render(); +}); + +window.addEventListener("pointercancel", (event) => { + if (state.pipelineDrag?.pointerId === event.pointerId) { + endPreviewDragVisual(); + state.pipelineDrag = null; + suppressPreviewHandleClick = false; + render(); + return; + } + + if ( + !state.pipelineColumnDrag || + state.pipelineColumnDrag.pointerId !== event.pointerId + ) { + return; } + + endPreviewColumnDragVisual(); + state.pipelineColumnDrag = null; + state.pipelineSelectionTarget = null; + render(); }); -// ── Click delegation ─────────────────────────────────────────────────────── mainContent.addEventListener("click", (event) => { const target = event.target; - if (!(target instanceof HTMLElement)) return; + if (!(target instanceof HTMLElement)) { + return; + } const action = target.closest("[data-action]")?.dataset.action; const jobId = target.closest("[data-job-id]")?.dataset.jobId; - if (!action) return; + if (!action) { + return; + } - switch (action) { - // ── Bootstrap ───────────────────────────────────────────────────────── - case "retry-bootstrap": - void bootstrap(); - break; + if (action === "retry-bootstrap") { + void bootstrap(); + return; + } - // ── Auth ─────────────────────────────────────────────────────────────── - case "toggle-auth-mode": { - const form = target.closest("form"); - if (!form) break; - const current = readServerConfigForm(form); - const nextAuthType: AuthType = - current.auth_type === "apikey" ? "userpass" : "apikey"; - setServerDraft({ ...current, auth_type: nextAuthType }); - resetStateAuthFieldStates(nextAuthType); - clearAuthFormFeedback(form.id); - clearAuthValidationCache(); - render(); - break; + if (action === "toggle-auth-mode") { + const form = target.closest("form"); + if (!form) { + return; } - case "disconnect": - void disconnectHydroServer(); - break; + const nextServer = readServerConfigForm(form); + const nextAuthType: AuthType = + nextServer.auth_type === "apikey" ? "userpass" : "apikey"; + setServerDraft({ + ...nextServer, + auth_type: nextAuthType, + }); + resetStateAuthFieldStates(nextAuthType); - case "change-credentials": - state.authDraft = { ...emptyServerConfig(), ...(state.config?.server ?? {}) }; - state.settingsEditMode = true; - navigate("settings"); - render(); - break; + clearAuthFormFeedback(form.id); + clearAuthValidationCache(); - case "cancel-credential-edit": - state.authDraft = { ...emptyServerConfig(), ...(state.config?.server ?? {}) }; - state.settingsEditMode = false; - render(); - break; + render(); + return; + } - // ── Pipeline wizard ──────────────────────────────────────────────────── - case "browse-csv": - void browseForCsvPath(); - break; + if (action === "disconnect") { + void disconnectHydroServer(); + return; + } - case "advance-to-mapping": - state.onboardingStep = "column-mapping"; - state.pipelineErrors = []; - render(); - break; + if (action === "change-credentials") { + state.authDraft = { + ...emptyServerConfig(), + ...(state.config?.server ?? {}), + }; + state.settingsEditMode = true; + navigate("settings"); + render(); + return; + } - case "back-to-file-config": - state.onboardingStep = "file-config"; - state.pipelineErrors = []; - render(); - break; + if (action === "cancel-credential-edit") { + state.authDraft = { + ...emptyServerConfig(), + ...(state.config?.server ?? {}), + }; + state.settingsEditMode = false; + render(); + return; + } - case "save-pipeline": - void savePipeline(); - break; + if (action === "browse-csv") { + void browseForCsvPath(); + return; + } - // ── Preview pagination ───────────────────────────────────────────────── - case "show-more-preview-lines": - if (state.pipelinePreview) { - const nextRows = Math.min( - state.pipelinePreviewRowsRequested + PREVIEW_PAGE_SIZE, - state.pipelinePreview.total_lines - ); - void loadPipelinePreview(state.pipelineForm.filePath, nextRows); - } - break; + if (action === "show-more-preview-lines") { + if (!state.pipelinePreview) { + return; + } - // ── Preview handle click (fires after a pointer-up, may need suppression) - case "activate-preview-handle": { - if (getSuppressHandleClick()) { - clearSuppressHandleClick(); - break; - } - const pickerTarget = target.closest( - "[data-preview-handle-target]" - )?.dataset.previewHandleTarget; - if (pickerTarget === "header-row" || pickerTarget === "data-start-row") { - state.pipelineSelectionTarget = pickerTarget; - render(); - } - break; + const nextRows = Math.min( + state.pipelinePreviewRowsRequested + PREVIEW_PAGE_SIZE, + state.pipelinePreview.total_lines + ); + void loadPipelinePreview(state.pipelineForm.filePath, nextRows); + return; + } + + if (action === "activate-preview-handle") { + if (suppressPreviewHandleClick) { + suppressPreviewHandleClick = false; + return; } - // ── Preview row / column selection via click ─────────────────────────── - case "pick-preview-line": { - const lineNumber = Number( - target.closest("[data-preview-line]")?.dataset.previewLine - ); - if (Number.isFinite(lineNumber)) { - applyPreviewLineSelection(lineNumber); - render(); - } - break; + const pickerTarget = target.closest("[data-preview-handle-target]") + ?.dataset.previewHandleTarget; + if (pickerTarget !== "header-row" && pickerTarget !== "data-start-row") { + return; } - case "pick-preview-column": { - const columnName = - target.closest("[data-preview-column]")?.dataset - .previewColumn ?? ""; - if (columnName) { - applyPreviewColumnSelection(columnName); - render(); - } - break; + state.pipelineSelectionTarget = pickerTarget; + render(); + return; + } + + if (action === "toggle-preview-picker") { + if (!state.pipelinePreview) { + state.pipelineFeedback = { + tone: "info", + message: "Load a CSV preview first.", + }; + render(); + return; } - // ── Dashboard job actions ────────────────────────────────────────────── - default: - if (!jobId) break; - if (action === "run-job") void handleRunJob(jobId); - else if (action === "toggle-job") void handleToggleJob(jobId); - else if (action === "delete-job") void handleDeleteJob(jobId); + const pickerTarget = target.closest("[data-picker-target]") + ?.dataset.pickerTarget; + if ( + pickerTarget !== "header-row" && + pickerTarget !== "data-start-row" && + pickerTarget !== "timestamp-column" + ) { + return; + } + + state.pipelineSelectionTarget = + state.pipelineSelectionTarget === pickerTarget ? null : pickerTarget; + render(); + return; + } + + if (action === "pick-preview-line") { + const lineNumber = Number( + target.closest("[data-preview-line]")?.dataset.previewLine + ); + + if (Number.isFinite(lineNumber)) { + applyPreviewLineSelection(lineNumber); + } + return; + } + + if (action === "pick-preview-column") { + const columnName = + target.closest("[data-preview-column]")?.dataset + .previewColumn ?? ""; + + if (columnName) { + applyPreviewColumnSelection(columnName); + } + return; + } + + if (!jobId) { + return; + } + + if (action === "run-job") { + void handleRunJob(jobId); + return; + } + + if (action === "toggle-job") { + void handleToggleJob(jobId); + return; + } + + if (action === "delete-job") { + void handleDeleteJob(jobId); } }); -// ── Start ────────────────────────────────────────────────────────────────── +async function handleRunJob(jobId: string): Promise { + try { + await runJob(jobId); + await refreshJobs(); + } catch { + // Keep dashboard state unchanged on action failure. + } +} + +async function handleToggleJob(jobId: string): Promise { + const job = state.jobs.find((item) => item.id === jobId); + if (!job) { + return; + } + + try { + if (job.enabled) { + await disableJob(jobId); + } else { + await enableJob(jobId); + } + + await refreshJobs(); + } catch { + // Keep dashboard state unchanged on action failure. + } +} + +async function handleDeleteJob(jobId: string): Promise { + const confirmed = window.confirm("Delete this pipeline?"); + if (!confirmed) { + return; + } + + try { + await deleteJob(jobId); + await refreshJobs(); + } catch { + // Keep dashboard state unchanged on action failure. + } +} + void bootstrap(); diff --git a/frontend/render.ts b/frontend/render.ts deleted file mode 100644 index c055592..0000000 --- a/frontend/render.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { state, connected, onboardingRoute } from "./state"; -import { getRouteFromHash, navigate } from "./router"; -import { - connectionIndicator, - renderWelcome, - renderSettings, - renderFatalError, - renderLoading, -} from "./components/auth"; -import { renderDashboard } from "./components/dashboard"; -import { renderOnboardingFile } from "./components/onboarding-file"; -import { renderOnboardingMapping } from "./components/onboarding-mapping"; - -type ShellElements = { - sidebar: HTMLElement; - mainContent: HTMLElement; - jobsLink: HTMLAnchorElement; - settingsLink: HTMLAnchorElement; - connectionDot: HTMLElement; -}; - -let _elements: ShellElements | null = null; -let _lastMarkup = ""; - -export function initRenderer(elements: ShellElements): void { - _elements = elements; -} - -export function render(): void { - if (!_elements) throw new Error("Renderer not initialized."); - const { sidebar, mainContent, jobsLink, settingsLink, connectionDot } = _elements; - - state.route = getRouteFromHash(); - let currentRoute = state.route; - - // Route guards: redirect to the correct page based on auth/job state. - if (!state.loading && !state.bootstrapError) { - if (!connected() && currentRoute !== "settings" && currentRoute !== "welcome") { - navigate("welcome"); - currentRoute = "welcome"; - } else if ( - connected() && - state.jobs.length === 0 && - (currentRoute === "dashboard" || currentRoute === "welcome") - ) { - navigate("jobs-new"); - currentRoute = "jobs-new"; - } - } - - // Shell surface: full-page welcome surface hides the sidebar. - const inOnboarding = onboardingRoute(currentRoute); - const showSidebar = !inOnboarding && !state.bootstrapError; - const welcomeSurface = Boolean( - state.loading || state.bootstrapError || inOnboarding - ); - sidebar.classList.toggle("hidden", !showSidebar); - mainContent.classList.toggle("main-content-welcome", welcomeSurface); - document.body.classList.toggle("app-surface-welcome", welcomeSurface); - - // Nav active state. - jobsLink.className = - currentRoute === "dashboard" ? "nav-item nav-item-active" : "nav-item"; - settingsLink.className = - currentRoute === "settings" ? "nav-item nav-item-active" : "nav-item"; - - // Connection dot. - const status = connectionIndicator(); - connectionDot.className = status.className; - connectionDot.title = status.label; - - // Content. - let markup: string; - if (state.loading) { - markup = renderLoading(); - } else if (state.bootstrapError) { - markup = renderFatalError(); - } else if (currentRoute === "settings") { - markup = renderSettings(); - } else if (currentRoute === "welcome") { - markup = renderWelcome(); - } else if (currentRoute === "jobs-new") { - markup = - state.onboardingStep === "file-config" - ? renderOnboardingFile() - : renderOnboardingMapping(); - } else { - markup = renderDashboard(); - } - - // Only write to the DOM when something changed. - if (markup !== _lastMarkup) { - mainContent.innerHTML = markup; - _lastMarkup = markup; - } -} diff --git a/frontend/state.ts b/frontend/state.ts deleted file mode 100644 index 5527d00..0000000 --- a/frontend/state.ts +++ /dev/null @@ -1,503 +0,0 @@ -import type { - ConnectionState, - AuthType, - ServerConfig, - AppConfig, - HealthResponse, - DatastreamSummary, - CsvPreviewResponse, - JobSummary, - ConnectionTestResponse, -} from "./api"; -import { - createAuthFieldStates, - resetAuthFieldStates, - type AuthFieldName, - type AuthFieldStates, - type FieldValidationState, -} from "./auth-submit"; -import { getRouteFromHash, type AppRoute } from "./router"; -import { parseDelimitedLine, basename, type Feedback } from "./components/helpers"; - -// ── Wizard step ──────────────────────────────────────────────────────────── -export type OnboardingStep = "file-config" | "column-mapping"; - -// ── Pipeline form ────────────────────────────────────────────────────────── -export type PipelineMappingDraft = { - csvColumn: string; - datastreamId: string; -}; - -export type PipelineFormState = { - name: string; - filePath: string; - scheduleMinutes: number; - hasHeaderRow: boolean; - headerRow: number; - dataStartRow: number; - delimiter: string; - timestampColumn: string; - timestampFormat: string; - timezone: string; - mappings: PipelineMappingDraft[]; -}; - -// ── Preview drag types ───────────────────────────────────────────────────── -export type PreviewSelectionTarget = - | "header-row" - | "data-start-row" - | "timestamp-column" - | null; - -export type PreviewRowSelectionTarget = Exclude< - PreviewSelectionTarget, - "timestamp-column" | null ->; - -export type PreviewDragState = { - target: PreviewRowSelectionTarget; - lineNumber: number; - pointerId: number; - moved: boolean; -}; - -export type PreviewColumnDragState = { - columnName: string; - pointerId: number; - moved: boolean; -}; - -// ── Global UI state ──────────────────────────────────────────────────────── -export type UiState = { - route: AppRoute; - health: HealthResponse | null; - config: AppConfig | null; - jobs: JobSummary[]; - datastreams: DatastreamSummary[]; - connectionSummary: ConnectionTestResponse | null; - loading: boolean; - bootstrapError: string | null; - settingsFeedback: Feedback; - welcomeFeedback: Feedback; - pipelineFeedback: Feedback; - lastConnectionState: ConnectionState | null; - settingsEditMode: boolean; - onboardingStep: OnboardingStep; - pipelineForm: PipelineFormState; - pipelinePreview: CsvPreviewResponse | null; - pipelineErrors: string[]; - datastreamsError: string | null; - authDraft: ServerConfig; - authFieldStates: AuthFieldStates; - authSubmitting: boolean; - lastAuthValidationServer: ServerConfig | null; - lastAuthValidationResult: ConnectionTestResponse | null; - pipelineSelectionTarget: PreviewSelectionTarget; - pipelineDrag: PreviewDragState | null; - pipelineColumnDrag: PreviewColumnDragState | null; - pipelinePreviewRowsRequested: number; -}; - -// ── Constants ────────────────────────────────────────────────────────────── -export const PREVIEW_PAGE_SIZE = 50; - -// ── Factories ───────────────────────────────────────────────────────────── -export function emptyServerConfig(): ServerConfig { - return { - auth_type: "apikey", - url: "", - api_key: "", - username: "", - password: "", - workspace_id: "", - }; -} - -export function createEmptyPipelineForm(): PipelineFormState { - return { - name: "", - filePath: "", - scheduleMinutes: 15, - hasHeaderRow: true, - headerRow: 3, - dataStartRow: 4, - delimiter: ",", - timestampColumn: "Timestamp", - timestampFormat: "%Y-%m-%d %H:%M:%S", - timezone: "America/Denver", - mappings: [], - }; -} - -// ── Singleton state ──────────────────────────────────────────────────────── -export const state: UiState = { - route: getRouteFromHash(), - health: null, - config: null, - jobs: [], - datastreams: [], - connectionSummary: null, - loading: true, - bootstrapError: null, - settingsFeedback: null, - welcomeFeedback: null, - pipelineFeedback: null, - lastConnectionState: null, - settingsEditMode: false, - onboardingStep: "file-config", - pipelineForm: createEmptyPipelineForm(), - pipelinePreview: null, - pipelineErrors: [], - datastreamsError: null, - authDraft: emptyServerConfig(), - authFieldStates: createAuthFieldStates(), - authSubmitting: false, - lastAuthValidationServer: null, - lastAuthValidationResult: null, - pipelineSelectionTarget: null, - pipelineDrag: null, - pipelineColumnDrag: null, - pipelinePreviewRowsRequested: PREVIEW_PAGE_SIZE, -}; - -// ── Computed selectors ───────────────────────────────────────────────────── -export function connected(): boolean { - return ( - state.connectionSummary?.ok === true && - state.lastConnectionState === "connected" - ); -} - -export function serverConfigured(server: ServerConfig | null | undefined): boolean { - if (!server?.url.trim()) return false; - if (server.auth_type === "userpass") { - return Boolean(server.username.trim() && server.password.trim()); - } - return Boolean(server.api_key.trim()); -} - -export function onboardingRoute(route: AppRoute): boolean { - return route === "welcome" || (route === "jobs-new" && state.jobs.length === 0); -} - -function normalizePreviewHeaderName(value: string, index: number): string { - return value.trim() || `Column ${index + 1}`; -} - -export function parsedPreviewRows(): string[][] { - if (!state.pipelinePreview) return []; - return state.pipelinePreview.raw_lines.map((line) => - parseDelimitedLine(line, state.pipelineForm.delimiter) - ); -} - -export function previewHeaders(): string[] { - const rows = parsedPreviewRows(); - const columnCount = rows.reduce((max, row) => Math.max(max, row.length), 0); - - if (!state.pipelineForm.hasHeaderRow) { - const dataRows = rows.slice(Math.max(state.pipelineForm.dataStartRow - 1, 0)); - const dataColumnCount = (dataRows.length > 0 ? dataRows : rows).reduce( - (max, row) => Math.max(max, row.length), - 0 - ); - return Array.from({ length: dataColumnCount }, (_, i) => `Column ${i + 1}`); - } - - const headerRow = rows[state.pipelineForm.headerRow - 1] ?? []; - return Array.from({ length: columnCount }, (_, i) => - normalizePreviewHeaderName(headerRow[i] ?? "", i) - ); -} - -export function pipelineMappingsByColumn(): Map { - return new Map(state.pipelineForm.mappings.map((m) => [m.csvColumn, m.datastreamId])); -} - -export function activeTimestampColumn(): string { - return state.pipelineColumnDrag?.columnName ?? state.pipelineForm.timestampColumn; -} - -export function previewHandleLine(target: PreviewRowSelectionTarget): number | null { - if (state.pipelineDrag?.target === target) return state.pipelineDrag.lineNumber; - if (target === "header-row") { - return state.pipelineForm.hasHeaderRow ? state.pipelineForm.headerRow : null; - } - return state.pipelineForm.dataStartRow; -} - -export function activePreviewRowTarget(): PreviewRowSelectionTarget | null { - if (state.pipelineDrag) return state.pipelineDrag.target; - return state.pipelineSelectionTarget === "header-row" || - state.pipelineSelectionTarget === "data-start-row" - ? state.pipelineSelectionTarget - : null; -} - -export function previewCommittedHandleLine(target: PreviewRowSelectionTarget): number | null { - if (target === "header-row") { - return state.pipelineForm.hasHeaderRow ? state.pipelineForm.headerRow : null; - } - return state.pipelineForm.dataStartRow; -} - -export function canShowMorePreviewLines(): boolean { - if (!state.pipelinePreview) return false; - return state.pipelinePreview.raw_lines.length < state.pipelinePreview.total_lines; -} - -// ── Auth mutations ───────────────────────────────────────────────────────── -export function setServerDraft(server: ServerConfig): void { - state.authDraft = { ...server }; -} - -export function markField( - field: AuthFieldName, - nextState: FieldValidationState["state"], - message: string | null = null -): void { - state.authFieldStates[field] = { state: nextState, message }; -} - -export function resetStateAuthFieldStates(authType: AuthType): void { - resetAuthFieldStates(state.authFieldStates, authType); -} - -export function clearAuthValidationCache(): void { - state.lastAuthValidationServer = null; - state.lastAuthValidationResult = null; -} - -export function clearAuthFormFeedback(formId: string): void { - if (formId === "welcome-form") { - state.welcomeFeedback = null; - } else { - state.settingsFeedback = null; - } -} - -export function readServerConfigForm(form: HTMLFormElement): ServerConfig { - const data = new FormData(form); - const authType = data.get("auth_type") === "userpass" ? "userpass" : "apikey"; - return { - auth_type: authType, - url: String(data.get("url") ?? "").trim(), - api_key: - authType === "apikey" - ? String(data.get("api_key") ?? "").trim() - : state.authDraft.api_key, - username: - authType === "userpass" - ? String(data.get("username") ?? "").trim() - : state.authDraft.username, - password: - authType === "userpass" - ? String(data.get("password") ?? "").trim() - : state.authDraft.password, - workspace_id: "", - }; -} - -// ── Pipeline mutations ───────────────────────────────────────────────────── -export function initializeMappings(headers: string[]): void { - const existing = pipelineMappingsByColumn(); - state.pipelineForm.mappings = headers - .filter((h) => h !== state.pipelineForm.timestampColumn) - .map((h) => ({ csvColumn: h, datastreamId: existing.get(h) ?? "" })); -} - -export function syncPipelineSelectionsWithPreview(): void { - const headers = previewHeaders(); - if (headers.length === 0) { - state.pipelineForm.mappings = []; - return; - } - const preferred = - headers.find((h) => h.toLowerCase().includes("time")) ?? headers[0]; - state.pipelineForm.timestampColumn = headers.includes( - state.pipelineForm.timestampColumn - ) - ? state.pipelineForm.timestampColumn - : preferred; - initializeMappings(headers); -} - -export function applyPreview(path: string, preview: CsvPreviewResponse): void { - state.pipelinePreview = preview; - state.pipelineForm.filePath = path; - state.pipelineForm.hasHeaderRow = preview.detected_header_row !== null; - state.pipelineForm.headerRow = - preview.detected_header_row ?? state.pipelineForm.headerRow; - state.pipelineForm.dataStartRow = - preview.detected_data_start_row ?? state.pipelineForm.dataStartRow; - state.pipelineForm.delimiter = - preview.detected_delimiter || state.pipelineForm.delimiter; - state.pipelineSelectionTarget = null; - state.pipelineDrag = null; - state.pipelineColumnDrag = null; - - if (!state.pipelineForm.name.trim()) { - state.pipelineForm.name = basename(path).replace(/\.[^.]+$/, ""); - } - - syncPipelineSelectionsWithPreview(); -} - -export function updateHeaderRowFromPreview(lineNumber: number): void { - state.pipelineForm.hasHeaderRow = true; - state.pipelineForm.headerRow = lineNumber; - if (state.pipelineForm.dataStartRow <= lineNumber) { - state.pipelineForm.dataStartRow = lineNumber + 1; - } - syncPipelineSelectionsWithPreview(); -} - -export function updateDataStartRowFromPreview(lineNumber: number): void { - state.pipelineForm.dataStartRow = Math.max( - state.pipelineForm.hasHeaderRow ? 2 : 1, - lineNumber - ); - if ( - state.pipelineForm.hasHeaderRow && - state.pipelineForm.headerRow >= state.pipelineForm.dataStartRow - ) { - state.pipelineForm.headerRow = state.pipelineForm.dataStartRow - 1; - } - syncPipelineSelectionsWithPreview(); -} - -export function setPipelineHasHeaderRow(enabled: boolean): void { - state.pipelineForm.hasHeaderRow = enabled; - if (!enabled && state.pipelineSelectionTarget === "header-row") { - state.pipelineSelectionTarget = null; - } - if (!enabled && state.pipelineDrag?.target === "header-row") { - state.pipelineDrag = null; - } - if (enabled && state.pipelineForm.headerRow >= state.pipelineForm.dataStartRow) { - state.pipelineForm.headerRow = Math.max(1, state.pipelineForm.dataStartRow - 1); - } - syncPipelineSelectionsWithPreview(); -} - -export function applyPreviewLineSelection(lineNumber: number): void { - if (state.pipelineSelectionTarget === "header-row") { - updateHeaderRowFromPreview(lineNumber); - state.pipelineSelectionTarget = null; - } else if (state.pipelineSelectionTarget === "data-start-row") { - updateDataStartRowFromPreview(lineNumber); - state.pipelineSelectionTarget = null; - } -} - -export function applyPreviewColumnSelection(columnName: string): void { - if ( - state.pipelineSelectionTarget && - state.pipelineSelectionTarget !== "timestamp-column" - ) { - return; - } - state.pipelineForm.timestampColumn = columnName; - initializeMappings(previewHeaders()); - state.pipelineSelectionTarget = null; - state.pipelineColumnDrag = null; -} - -export function updatePipelineField(name: string, value: string): void { - switch (name) { - case "pipeline_name": - state.pipelineForm.name = value; - break; - case "file_path": - state.pipelineForm.filePath = value; - state.pipelinePreview = null; - state.pipelineErrors = []; - state.pipelineSelectionTarget = null; - state.pipelineDrag = null; - state.pipelineColumnDrag = null; - state.pipelinePreviewRowsRequested = PREVIEW_PAGE_SIZE; - break; - case "schedule_minutes": - state.pipelineForm.scheduleMinutes = Number(value) || 15; - break; - case "header_row": - state.pipelineForm.headerRow = Number(value) || 1; - syncPipelineSelectionsWithPreview(); - break; - case "data_start_row": - state.pipelineForm.dataStartRow = Number(value) || 1; - syncPipelineSelectionsWithPreview(); - break; - case "delimiter": - state.pipelineForm.delimiter = value || ","; - syncPipelineSelectionsWithPreview(); - break; - case "timestamp_column": - state.pipelineForm.timestampColumn = value; - initializeMappings(previewHeaders()); - state.pipelineColumnDrag = null; - break; - case "timestamp_format": - state.pipelineForm.timestampFormat = value; - break; - case "timezone": - state.pipelineForm.timezone = value; - break; - } -} - -export function resetPipelineState(): void { - state.pipelineForm = createEmptyPipelineForm(); - state.pipelinePreview = null; - state.pipelineSelectionTarget = null; - state.pipelineDrag = null; - state.pipelineColumnDrag = null; - state.pipelinePreviewRowsRequested = PREVIEW_PAGE_SIZE; - state.pipelineErrors = []; - state.pipelineFeedback = null; - state.onboardingStep = "file-config"; -} - -export function validatePipeline(): string[] { - const errors: string[] = []; - const headers = previewHeaders(); - const selectedMappings = state.pipelineForm.mappings.filter((m) => m.datastreamId); - const datastreamIds = new Set(state.datastreams.map((d) => d.id)); - const seenTargets = new Set(); - - if (!connected()) errors.push("Connect to HydroServer before saving a pipeline."); - if (!state.pipelineForm.name.trim()) errors.push("Give the pipeline a name."); - if (!state.pipelineForm.filePath.trim()) errors.push("Choose the CSV file to watch."); - if (!state.pipelinePreview) errors.push("Load a CSV preview before saving."); - if (state.pipelineForm.hasHeaderRow && state.pipelineForm.headerRow < 1) { - errors.push("Header row must be 1 or greater."); - } - if ( - state.pipelineForm.hasHeaderRow && - state.pipelineForm.dataStartRow <= state.pipelineForm.headerRow - ) { - errors.push("Data start row must come after the header row."); - } - if (!state.pipelineForm.hasHeaderRow && state.pipelineForm.dataStartRow < 1) { - errors.push("Data start row must be 1 or greater."); - } - if (headers.length > 0 && !headers.includes(state.pipelineForm.timestampColumn)) { - errors.push("Choose a timestamp column that exists in the CSV header."); - } - if (selectedMappings.length === 0) { - errors.push("Map at least one source column to a HydroServer datastream."); - } - for (const mapping of selectedMappings) { - if (!datastreamIds.has(mapping.datastreamId)) { - errors.push( - `The selected target for "${mapping.csvColumn}" is not a valid datastream.` - ); - } - if (seenTargets.has(mapping.datastreamId)) { - errors.push("Each datastream can only be mapped to one source column."); - } - seenTargets.add(mapping.datastreamId); - } - - return errors; -} From 7668a0d8b88f58e7e53e419793be00ad7a47e842 Mon Sep 17 00:00:00 2001 From: Daniel Slaugh Date: Tue, 7 Apr 2026 13:46:41 -0600 Subject: [PATCH 024/166] Port to Vue 3 --- frontend/App.vue | 83 + frontend/components/AuthForm.vue | 142 ++ frontend/components/ConnectedCard.vue | 53 + frontend/components/CsvPreview.vue | 492 ++++ frontend/components/FeedbackBanner.vue | 31 + frontend/components/PipelineMappings.vue | 55 + frontend/composables/useAppModel.ts | 1194 +++++++++ frontend/main.ts | 2923 +--------------------- frontend/views/DashboardView.vue | 107 + frontend/views/FatalErrorView.vue | 20 + frontend/views/LoadingView.vue | 5 + frontend/views/PipelineEditorView.vue | 301 +++ frontend/views/SettingsView.vue | 51 + frontend/views/WelcomeView.vue | 15 + frontend/vite-env.d.ts | 7 + index.html | 55 +- package-lock.json | 227 +- package.json | 4 +- vite.config.ts | 2 + 19 files changed, 2785 insertions(+), 2982 deletions(-) create mode 100644 frontend/App.vue create mode 100644 frontend/components/AuthForm.vue create mode 100644 frontend/components/ConnectedCard.vue create mode 100644 frontend/components/CsvPreview.vue create mode 100644 frontend/components/FeedbackBanner.vue create mode 100644 frontend/components/PipelineMappings.vue create mode 100644 frontend/composables/useAppModel.ts create mode 100644 frontend/views/DashboardView.vue create mode 100644 frontend/views/FatalErrorView.vue create mode 100644 frontend/views/LoadingView.vue create mode 100644 frontend/views/PipelineEditorView.vue create mode 100644 frontend/views/SettingsView.vue create mode 100644 frontend/views/WelcomeView.vue diff --git a/frontend/App.vue b/frontend/App.vue new file mode 100644 index 0000000..659806a --- /dev/null +++ b/frontend/App.vue @@ -0,0 +1,83 @@ + + + diff --git a/frontend/components/AuthForm.vue b/frontend/components/AuthForm.vue new file mode 100644 index 0000000..f9071d0 --- /dev/null +++ b/frontend/components/AuthForm.vue @@ -0,0 +1,142 @@ + + + diff --git a/frontend/components/ConnectedCard.vue b/frontend/components/ConnectedCard.vue new file mode 100644 index 0000000..dfb783c --- /dev/null +++ b/frontend/components/ConnectedCard.vue @@ -0,0 +1,53 @@ + + + diff --git a/frontend/components/CsvPreview.vue b/frontend/components/CsvPreview.vue new file mode 100644 index 0000000..28f0fef --- /dev/null +++ b/frontend/components/CsvPreview.vue @@ -0,0 +1,492 @@ + + + diff --git a/frontend/components/FeedbackBanner.vue b/frontend/components/FeedbackBanner.vue new file mode 100644 index 0000000..647e1aa --- /dev/null +++ b/frontend/components/FeedbackBanner.vue @@ -0,0 +1,31 @@ + + + diff --git a/frontend/components/PipelineMappings.vue b/frontend/components/PipelineMappings.vue new file mode 100644 index 0000000..7447c27 --- /dev/null +++ b/frontend/components/PipelineMappings.vue @@ -0,0 +1,55 @@ + + + diff --git a/frontend/composables/useAppModel.ts b/frontend/composables/useAppModel.ts new file mode 100644 index 0000000..2aeb40e --- /dev/null +++ b/frontend/composables/useAppModel.ts @@ -0,0 +1,1194 @@ +import { computed, reactive } from "vue" + +import { + clearServerConfig, + createJob, + deleteJob, + disableJob, + enableJob, + getConfig, + getCsvPreview, + getDatastreams, + getHealth, + listJobs, + runJob, + testConnection, + updateServerConfig, + validateServerUrl, + type AppConfig, + type AuthType, + type ConnectionState, + type ConnectionTestResponse, + type CsvPreviewResponse, + type DatastreamSummary, + type HealthResponse, + type JobSummary, + type ServerConfig, +} from "../api" +import { + applyConnectionValidationResult, + createAuthFieldStates, + fieldFormFeedbackTarget, + resetAuthFieldStates, + runAuthSubmission, + validateAuthFieldsForSubmit, + type AuthFieldName, + type Feedback, + type FieldValidationState, +} from "../auth-submit" +import { getRouteFromHash, navigate, type AppRoute } from "../router" + +export const API_KEY_DOCS_URL = + "https://hydroserver2.github.io/hydroserver/tutorials/creating-your-first-orchestration-system#create-an-api-key" +export const APP_NAME = "HydroServer Streaming Data Loader" +export const PREVIEW_PAGE_SIZE = 50 + +const STARTUP_RETRY_ATTEMPTS = 12 +const STARTUP_RETRY_DELAY_MS = 350 + +export type PipelineMappingDraft = { + csvColumn: string + datastreamId: string +} + +export type PipelineFormState = { + name: string + filePath: string + scheduleMinutes: number + hasHeaderRow: boolean + headerRow: number + dataStartRow: number + delimiter: string + timestampColumn: string + timestampFormat: string + timezone: string + mappings: PipelineMappingDraft[] +} + +export type PreviewSelectionTarget = + | "header-row" + | "data-start-row" + | "timestamp-column" + | null + +export type PreviewRowSelectionTarget = Exclude< + PreviewSelectionTarget, + "timestamp-column" | null +> + +type UiState = { + route: AppRoute + health: HealthResponse | null + config: AppConfig | null + jobs: JobSummary[] + datastreams: DatastreamSummary[] + connectionSummary: ConnectionTestResponse | null + loading: boolean + bootstrapError: string | null + settingsFeedback: Feedback + welcomeFeedback: Feedback + pipelineFeedback: Feedback + lastConnectionState: ConnectionState | null + settingsEditMode: boolean + pipelineForm: PipelineFormState + pipelinePreview: CsvPreviewResponse | null + pipelineErrors: string[] + datastreamsError: string | null + authDraft: ServerConfig + authFieldStates: Record + authSubmitting: boolean + lastAuthValidationServer: ServerConfig | null + lastAuthValidationResult: ConnectionTestResponse | null + pipelineSelectionTarget: PreviewSelectionTarget + pipelinePreviewRowsRequested: number +} + +function createEmptyPipelineForm(): PipelineFormState { + return { + name: "", + filePath: "", + scheduleMinutes: 15, + hasHeaderRow: true, + headerRow: 3, + dataStartRow: 4, + delimiter: ",", + timestampColumn: "Timestamp", + timestampFormat: "%Y-%m-%d %H:%M:%S", + timezone: "America/Denver", + mappings: [], + } +} + +function emptyServerConfig(): ServerConfig { + return { + auth_type: "apikey", + url: "", + api_key: "", + username: "", + password: "", + workspace_id: "", + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => window.setTimeout(resolve, ms)) +} + +function isTransientBootstrapError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false + } + + const message = error.message.toLowerCase() + return ( + message.includes("failed to fetch") || + message.includes("networkerror") || + message.includes("status 500") || + message.includes("status 502") || + message.includes("status 503") || + message.includes("status 504") + ) +} + +function basename(path: string): string { + const segments = path.split(/[\\/]/).filter(Boolean) + return segments.at(-1) ?? path +} + +function parseDelimitedLine(line: string, delimiter: string): string[] { + if (!delimiter) { + return [line] + } + + const cells: string[] = [] + let current = "" + let inQuotes = false + + for (let index = 0; index < line.length; index += 1) { + const character = line[index] + + if (character === '"') { + if (inQuotes && line[index + 1] === '"') { + current += '"' + index += 1 + } else { + inQuotes = !inQuotes + } + continue + } + + if (!inQuotes && line.startsWith(delimiter, index)) { + cells.push(current) + current = "" + index += delimiter.length - 1 + continue + } + + current += character + } + + cells.push(current) + return cells +} + +function normalizePreviewHeaderName(value: string, index: number): string { + const cleaned = value.trim() + return cleaned || `Column ${index + 1}` +} + +const state = reactive({ + route: getRouteFromHash(), + health: null, + config: null, + jobs: [], + datastreams: [], + connectionSummary: null, + loading: true, + bootstrapError: null, + settingsFeedback: null, + welcomeFeedback: null, + pipelineFeedback: null, + lastConnectionState: null, + settingsEditMode: false, + pipelineForm: createEmptyPipelineForm(), + pipelinePreview: null, + pipelineErrors: [], + datastreamsError: null, + authDraft: emptyServerConfig(), + authFieldStates: createAuthFieldStates(), + authSubmitting: false, + lastAuthValidationServer: null, + lastAuthValidationResult: null, + pipelineSelectionTarget: null, + pipelinePreviewRowsRequested: PREVIEW_PAGE_SIZE, +}) + +const parsedPreviewRows = computed(() => { + if (!state.pipelinePreview) { + return [] + } + + return state.pipelinePreview.raw_lines.map((line) => + parseDelimitedLine(line, state.pipelineForm.delimiter) + ) +}) + +const previewHeaders = computed(() => { + const rows = parsedPreviewRows.value + const columnCount = rows.reduce((max, row) => Math.max(max, row.length), 0) + + if (!state.pipelineForm.hasHeaderRow) { + const dataRows = rows.slice(Math.max(state.pipelineForm.dataStartRow - 1, 0)) + const dataColumnCount = (dataRows.length > 0 ? dataRows : rows).reduce( + (max, row) => Math.max(max, row.length), + 0 + ) + return Array.from( + { length: dataColumnCount }, + (_, index) => `Column ${index + 1}` + ) + } + + const headerRow = rows[state.pipelineForm.headerRow - 1] ?? [] + return Array.from({ length: columnCount }, (_, index) => + normalizePreviewHeaderName(headerRow[index] ?? "", index) + ) +}) + +const isConnected = computed( + () => + state.connectionSummary?.ok === true && + state.lastConnectionState === "connected" +) + +function onboardingRoute(route: AppRoute): boolean { + return route === "welcome" || (route === "jobs-new" && state.jobs.length === 0) +} + +const showSidebar = computed( + () => !state.loading && !onboardingRoute(state.route) && !state.bootstrapError +) + +const useWelcomeSurface = computed( + () => Boolean(state.loading || state.bootstrapError || onboardingRoute(state.route)) +) + +function connectionIndicator(): { label: string; className: string } { + if (!serverConfigured(state.config?.server)) { + return { + label: "HydroServer not configured", + className: "status-dot bg-slate-300", + } + } + + if (isConnected.value) { + return { + label: "Connected to HydroServer", + className: "status-dot bg-emerald-500", + } + } + + if (state.lastConnectionState === "error") { + return { + label: "HydroServer authentication error", + className: "status-dot bg-rose-500", + } + } + + return { + label: "HydroServer configured", + className: "status-dot bg-sky-500", + } +} + +function serverConfigured(server: ServerConfig | null | undefined): boolean { + if (!server?.url.trim()) { + return false + } + + if (server.auth_type === "userpass") { + return Boolean(server.username.trim() && server.password.trim()) + } + + return Boolean(server.api_key.trim()) +} + +function resetStateAuthFieldStates(authType: AuthType): void { + resetAuthFieldStates(state.authFieldStates, authType) +} + +function markField( + field: AuthFieldName, + nextState: FieldValidationState["state"], + message: string | null = null +): void { + state.authFieldStates[field] = { state: nextState, message } +} + +function clearAuthFormFeedback(formId: "welcome-form" | "settings-form"): void { + state[fieldFormFeedbackTarget(formId)] = null +} + +function clearAuthValidationCache(): void { + state.lastAuthValidationServer = null + state.lastAuthValidationResult = null +} + +function setServerDraft(server: ServerConfig): void { + state.authDraft = { ...server } +} + +function normalizeServerDraft(): ServerConfig { + const server = state.authDraft + return { + auth_type: server.auth_type, + url: server.url.trim(), + api_key: + server.auth_type === "apikey" ? server.api_key.trim() : server.api_key, + username: + server.auth_type === "userpass" ? server.username.trim() : server.username, + password: + server.auth_type === "userpass" ? server.password.trim() : server.password, + workspace_id: "", + } +} + +function updateAuthDraftField( + formId: "welcome-form" | "settings-form", + field: AuthFieldName, + value: string +): void { + state.authDraft[field] = value + clearAuthFormFeedback(formId) + clearAuthValidationCache() + markField(field, "idle") +} + +function toggleAuthMode(formId: "welcome-form" | "settings-form"): void { + const nextAuthType: AuthType = + state.authDraft.auth_type === "apikey" ? "userpass" : "apikey" + setServerDraft({ + ...state.authDraft, + auth_type: nextAuthType, + }) + resetStateAuthFieldStates(nextAuthType) + clearAuthFormFeedback(formId) + clearAuthValidationCache() +} + +function activePreviewRowTarget(): PreviewRowSelectionTarget | null { + return state.pipelineSelectionTarget === "header-row" || + state.pipelineSelectionTarget === "data-start-row" + ? state.pipelineSelectionTarget + : null +} + +function previewHandleLine(target: PreviewRowSelectionTarget): number | null { + if (target === "header-row") { + return state.pipelineForm.hasHeaderRow ? state.pipelineForm.headerRow : null + } + + return state.pipelineForm.dataStartRow +} + +function activeTimestampColumn(): string { + return state.pipelineForm.timestampColumn +} + +function previewColumnClass(columnName: string): string { + if (columnName === state.pipelineForm.timestampColumn) { + return "preview-col-timestamp" + } + + const mapped = state.pipelineForm.mappings.find( + (mapping) => mapping.csvColumn === columnName && mapping.datastreamId + ) + return mapped ? "preview-col-mapped" : "" +} + +function previewFieldClass(target: Exclude): string { + const active = + target === "timestamp-column" + ? state.pipelineSelectionTarget === target + : activePreviewRowTarget() === target + + const toneClass = + target === "header-row" + ? "preview-bound-field-header" + : target === "data-start-row" + ? "preview-bound-field-data" + : "preview-bound-field-timestamp" + + return active + ? `field preview-bound-field preview-bound-field-active ${toneClass}` + : "field preview-bound-field" +} + +function previewGuidanceText(): string { + const activeTarget = activePreviewRowTarget() + + if (activeTarget === "header-row") { + return "Drag the HEADER handle, or click a row to place it." + } + + if (activeTarget === "data-start-row") { + return "Drag the DATA START handle, or click the first data row." + } + + if (state.pipelineSelectionTarget === "timestamp-column") { + return "Drag the TIMESTAMP handle, or click a column header to place it." + } + + return state.pipelineForm.hasHeaderRow + ? "Drag the HEADER, DATA START, and TIMESTAMP handles, or click a row or column to place them." + : "Drag the DATA START and TIMESTAMP handles, or click a row or column to place them." +} + +function pipelineMappingsByColumn(): Map { + return new Map( + state.pipelineForm.mappings.map((mapping) => [ + mapping.csvColumn, + mapping.datastreamId, + ]) + ) +} + +function initializeMappings(headers: string[]): void { + const existing = pipelineMappingsByColumn() + state.pipelineForm.mappings = headers + .filter((header) => header !== state.pipelineForm.timestampColumn) + .map((header) => ({ + csvColumn: header, + datastreamId: existing.get(header) ?? "", + })) +} + +function syncPipelineSelectionsWithPreview(): void { + const headers = previewHeaders.value + + if (headers.length === 0) { + state.pipelineForm.mappings = [] + return + } + + const preferredTimestamp = + headers.find((header) => header.toLowerCase().includes("time")) ?? headers[0] + + state.pipelineForm.timestampColumn = headers.includes( + state.pipelineForm.timestampColumn + ) + ? state.pipelineForm.timestampColumn + : preferredTimestamp + + initializeMappings(headers) +} + +function applyPreview(path: string, preview: CsvPreviewResponse): void { + state.pipelinePreview = preview + state.pipelineForm.filePath = path + state.pipelineForm.hasHeaderRow = preview.detected_header_row !== null + state.pipelineForm.headerRow = + preview.detected_header_row ?? state.pipelineForm.headerRow + state.pipelineForm.dataStartRow = + preview.detected_data_start_row ?? state.pipelineForm.dataStartRow + state.pipelineForm.delimiter = + preview.detected_delimiter || state.pipelineForm.delimiter + state.pipelineSelectionTarget = null + + if (!state.pipelineForm.name.trim()) { + const inferred = basename(path).replace(/\.[^.]+$/, "") + state.pipelineForm.name = inferred + } + + syncPipelineSelectionsWithPreview() +} + +function updateHeaderRowFromPreview(lineNumber: number): void { + state.pipelineForm.hasHeaderRow = true + state.pipelineForm.headerRow = lineNumber + if (state.pipelineForm.dataStartRow <= lineNumber) { + state.pipelineForm.dataStartRow = lineNumber + 1 + } + syncPipelineSelectionsWithPreview() +} + +function updateDataStartRowFromPreview(lineNumber: number): void { + state.pipelineForm.dataStartRow = Math.max( + state.pipelineForm.hasHeaderRow ? 2 : 1, + lineNumber + ) + if ( + state.pipelineForm.hasHeaderRow && + state.pipelineForm.headerRow >= state.pipelineForm.dataStartRow + ) { + state.pipelineForm.headerRow = state.pipelineForm.dataStartRow - 1 + } + syncPipelineSelectionsWithPreview() +} + +function setPipelineHasHeaderRow(enabled: boolean): void { + state.pipelineForm.hasHeaderRow = enabled + + if (!enabled && state.pipelineSelectionTarget === "header-row") { + state.pipelineSelectionTarget = null + } + + if (enabled && state.pipelineForm.headerRow >= state.pipelineForm.dataStartRow) { + state.pipelineForm.headerRow = Math.max(1, state.pipelineForm.dataStartRow - 1) + } + + syncPipelineSelectionsWithPreview() +} + +function applyPreviewLineSelection(lineNumber: number): void { + if (state.pipelineSelectionTarget === "header-row") { + updateHeaderRowFromPreview(lineNumber) + state.pipelineSelectionTarget = null + return + } + + if (state.pipelineSelectionTarget === "data-start-row") { + updateDataStartRowFromPreview(lineNumber) + state.pipelineSelectionTarget = null + } +} + +function applyPreviewColumnSelection(columnName: string): void { + if ( + state.pipelineSelectionTarget && + state.pipelineSelectionTarget !== "timestamp-column" + ) { + return + } + + state.pipelineForm.timestampColumn = columnName + initializeMappings(previewHeaders.value) + state.pipelineSelectionTarget = null +} + +function canShowMorePreviewLines(): boolean { + if (!state.pipelinePreview) { + return false + } + + return state.pipelinePreview.raw_lines.length < state.pipelinePreview.total_lines +} + +function updatePipelineField(name: string, value: string): void { + state.pipelineFeedback = null + state.pipelineErrors = [] + + switch (name) { + case "pipeline_name": + state.pipelineForm.name = value + break + case "file_path": + state.pipelineForm.filePath = value + state.pipelinePreview = null + state.pipelineSelectionTarget = null + state.pipelinePreviewRowsRequested = PREVIEW_PAGE_SIZE + break + case "schedule_minutes": + state.pipelineForm.scheduleMinutes = Number(value) || 15 + break + case "header_row": + state.pipelineForm.headerRow = Number(value) || 1 + syncPipelineSelectionsWithPreview() + break + case "data_start_row": + state.pipelineForm.dataStartRow = Number(value) || 1 + syncPipelineSelectionsWithPreview() + break + case "delimiter": + state.pipelineForm.delimiter = value || "," + syncPipelineSelectionsWithPreview() + break + case "timestamp_column": + state.pipelineForm.timestampColumn = value + initializeMappings(previewHeaders.value) + break + case "timestamp_format": + state.pipelineForm.timestampFormat = value + break + case "timezone": + state.pipelineForm.timezone = value + break + default: + break + } +} + +function updateMapping(csvColumn: string, datastreamId: string): void { + state.pipelineFeedback = null + state.pipelineErrors = [] + const mapping = state.pipelineForm.mappings.find( + (item) => item.csvColumn === csvColumn + ) + if (mapping) { + mapping.datastreamId = datastreamId + } +} + +function validatePipeline(): string[] { + const errors: string[] = [] + const headers = previewHeaders.value + const selectedMappings = state.pipelineForm.mappings.filter( + (mapping) => mapping.datastreamId + ) + const datastreamIds = new Set(state.datastreams.map((datastream) => datastream.id)) + const seenTargets = new Set() + + if (!isConnected.value) { + errors.push("Connect to HydroServer before saving a pipeline.") + } + + if (!state.pipelineForm.name.trim()) { + errors.push("Give the pipeline a name.") + } + + if (!state.pipelineForm.filePath.trim()) { + errors.push(`Choose the CSV file ${APP_NAME} should watch.`) + } + + if (!state.pipelinePreview) { + errors.push("Load a CSV preview before saving the pipeline.") + } + + if (state.pipelineForm.hasHeaderRow && state.pipelineForm.headerRow < 1) { + errors.push("Header row must be 1 or greater.") + } + + if ( + state.pipelineForm.hasHeaderRow && + state.pipelineForm.dataStartRow <= state.pipelineForm.headerRow + ) { + errors.push("Data start row must come after the header row.") + } + + if (!state.pipelineForm.hasHeaderRow && state.pipelineForm.dataStartRow < 1) { + errors.push("Data start row must be 1 or greater.") + } + + if (headers.length > 0 && !headers.includes(state.pipelineForm.timestampColumn)) { + errors.push("Choose a timestamp column that exists in the previewed CSV header.") + } + + if (selectedMappings.length === 0) { + errors.push("Map at least one source column to a HydroServer datastream.") + } + + for (const mapping of selectedMappings) { + if (!datastreamIds.has(mapping.datastreamId)) { + errors.push( + `The selected target for ${mapping.csvColumn} is not a valid HydroServer datastream.` + ) + } + + if (seenTargets.has(mapping.datastreamId)) { + errors.push("Each target datastream can only be mapped once in this flow.") + } + + seenTargets.add(mapping.datastreamId) + } + + return errors +} + +async function loadInitialStateWithRetry(): Promise<{ + health: HealthResponse + config: AppConfig + jobs: JobSummary[] +}> { + let lastError: unknown = null + + for (let attempt = 1; attempt <= STARTUP_RETRY_ATTEMPTS; attempt += 1) { + try { + const [health, config, jobs] = await Promise.all([ + getHealth(), + getConfig(), + listJobs(), + ]) + return { health, config, jobs } + } catch (error) { + lastError = error + + if (attempt === STARTUP_RETRY_ATTEMPTS || !isTransientBootstrapError(error)) { + throw error + } + + await sleep(STARTUP_RETRY_DELAY_MS) + } + } + + throw lastError instanceof Error + ? lastError + : new Error(`Failed to load ${APP_NAME}.`) +} + +async function syncAuthenticationStatus( + server: ServerConfig +): Promise { + const result = await testConnection(server) + state.lastAuthValidationServer = server + state.lastAuthValidationResult = result + state.connectionSummary = result + state.lastConnectionState = result.state + + if (result.ok && result.workspace_id) { + if (state.config) { + state.config.server.workspace_id = result.workspace_id + } + state.authDraft.workspace_id = result.workspace_id + } + + if (!result.ok) { + state.datastreams = [] + state.datastreamsError = null + } + + return result +} + +async function loadDatastreams(): Promise { + try { + state.datastreams = await getDatastreams() + state.datastreamsError = null + } catch (error) { + state.datastreams = [] + state.datastreamsError = + error instanceof Error ? error.message : "Couldn't load HydroServer datastreams." + } +} + +function syncRouteState(): void { + let currentRoute = getRouteFromHash() + + if (!state.loading && !state.bootstrapError) { + if (!isConnected.value && currentRoute !== "settings" && currentRoute !== "welcome") { + navigate("welcome") + currentRoute = "welcome" + } else if ( + isConnected.value && + state.jobs.length === 0 && + (currentRoute === "dashboard" || currentRoute === "welcome") + ) { + navigate("jobs-new") + currentRoute = "jobs-new" + } + } + + state.route = currentRoute +} + +async function bootstrap(): Promise { + state.loading = true + state.bootstrapError = null + state.welcomeFeedback = null + state.settingsFeedback = null + syncRouteState() + + try { + const { health, config, jobs } = await loadInitialStateWithRetry() + state.health = health + state.config = config + state.authDraft = { + ...emptyServerConfig(), + ...config.server, + } + state.jobs = jobs + state.lastConnectionState = health.connection.state + + if (serverConfigured(config.server)) { + const result = await syncAuthenticationStatus(config.server) + if (result.ok) { + await loadDatastreams() + } + } + } catch (error) { + state.bootstrapError = + error instanceof Error ? error.message : `Failed to load ${APP_NAME}.` + } finally { + state.loading = false + syncRouteState() + } +} + +async function refreshJobs(): Promise { + if (state.bootstrapError || state.loading) { + return + } + + try { + state.jobs = await listJobs() + syncRouteState() + } catch { + // Keep existing UI state on polling failure. + } +} + +async function loadPipelinePreview( + path: string, + rows = PREVIEW_PAGE_SIZE +): Promise { + if (!path.trim()) { + state.pipelineFeedback = { + tone: "error", + message: "Enter or choose a CSV file path first.", + } + return + } + + try { + const preview = await getCsvPreview(path.trim(), rows) + applyPreview(path.trim(), preview) + state.pipelinePreviewRowsRequested = rows + state.pipelineErrors = [] + state.pipelineFeedback = null + } catch (error) { + state.pipelinePreview = null + state.pipelineSelectionTarget = null + state.pipelinePreviewRowsRequested = PREVIEW_PAGE_SIZE + state.pipelineFeedback = { + tone: "error", + message: + error instanceof Error ? error.message : "Couldn't preview that CSV file.", + } + } +} + +async function showMorePreviewLines(): Promise { + if (!state.pipelinePreview) { + return + } + + const nextRows = Math.min( + state.pipelinePreviewRowsRequested + PREVIEW_PAGE_SIZE, + state.pipelinePreview.total_lines + ) + await loadPipelinePreview(state.pipelineForm.filePath, nextRows) +} + +async function browseForCsvPath(): Promise { + try { + const dialog = await import("@tauri-apps/plugin-dialog") + const selection = await dialog.open({ + directory: false, + multiple: false, + filters: [{ name: "CSV files", extensions: ["csv", "txt"] }], + }) + + if (typeof selection !== "string" || !selection) { + return + } + + updatePipelineField("file_path", selection) + if (!state.pipelineForm.name.trim()) { + state.pipelineForm.name = basename(selection).replace(/\.[^.]+$/, "") + } + + await loadPipelinePreview(selection) + } catch { + state.pipelineFeedback = { + tone: "info", + message: + "The native file picker is only available in the desktop app. Enter the CSV path manually if you're using the browser preview.", + } + } +} + +async function submitAuthConfig(formId: "welcome-form" | "settings-form"): Promise { + if (state.authSubmitting) { + return + } + + const payload = normalizeServerDraft() + setServerDraft(payload) + + const feedbackKey = fieldFormFeedbackTarget(formId) + + state[feedbackKey] = null + resetStateAuthFieldStates(payload.auth_type) + + if (!validateAuthFieldsForSubmit(payload, markField)) { + return + } + + try { + await runAuthSubmission({ + render: () => undefined, + setSubmitting: (value) => { + state.authSubmitting = value + }, + action: async () => { + const urlValidation = await validateServerUrl(payload.url) + if (!urlValidation.ok) { + clearAuthValidationCache() + markField("url", "invalid", urlValidation.message) + state[feedbackKey] = { + tone: "error", + message: urlValidation.message, + } + return + } + + markField("url", "valid") + + const result = await syncAuthenticationStatus(payload) + applyConnectionValidationResult(payload, result, markField) + if (!result.ok) { + state[feedbackKey] = { tone: "error", message: result.message } + return + } + + state.config = await updateServerConfig(payload) + state.authDraft = { + ...emptyServerConfig(), + ...state.config.server, + } + await syncAuthenticationStatus(state.config.server) + await loadDatastreams() + state[feedbackKey] = { tone: "success", message: result.message } + state.settingsEditMode = false + + if (state.jobs.length === 0) { + navigate("jobs-new") + } else { + navigate("dashboard") + } + syncRouteState() + }, + }) + } catch (error) { + clearAuthValidationCache() + state[feedbackKey] = { + tone: "error", + message: + error instanceof Error + ? error.message + : "Couldn't verify the HydroServer connection.", + } + state.lastConnectionState = "error" + } +} + +async function disconnectHydroServer(): Promise { + try { + state.config = await clearServerConfig() + state.authDraft = emptyServerConfig() + state.connectionSummary = null + state.lastConnectionState = "not_configured" + state.datastreams = [] + state.datastreamsError = null + state.welcomeFeedback = null + state.settingsFeedback = null + state.settingsEditMode = false + resetStateAuthFieldStates("apikey") + clearAuthValidationCache() + navigate("welcome") + syncRouteState() + } catch (error) { + state.settingsFeedback = { + tone: "error", + message: + error instanceof Error + ? error.message + : "Couldn't disconnect from HydroServer right now.", + } + } +} + +function resetPipelineEditorState(): void { + state.pipelineForm = createEmptyPipelineForm() + state.pipelinePreview = null + state.pipelineSelectionTarget = null + state.pipelinePreviewRowsRequested = PREVIEW_PAGE_SIZE + state.pipelineErrors = [] +} + +async function submitPipeline(): Promise { + if (!state.pipelinePreview) { + await loadPipelinePreview(state.pipelineForm.filePath) + return + } + + state.pipelineErrors = validatePipeline() + + if (state.pipelineErrors.length > 0) { + state.pipelineFeedback = { + tone: "error", + message: `${APP_NAME} needs a little more information before it can save this pipeline.`, + } + return + } + + const mappedColumns = state.pipelineForm.mappings + .filter((mapping) => mapping.datastreamId) + .map((mapping) => { + const datastream = state.datastreams.find( + (item) => item.id === mapping.datastreamId + ) + return { + csv_column: mapping.csvColumn, + datastream_id: mapping.datastreamId, + datastream_name: datastream?.name ?? mapping.datastreamId, + } + }) + + try { + const created = await createJob({ + name: state.pipelineForm.name.trim(), + enabled: true, + file_path: state.pipelineForm.filePath.trim(), + schedule_minutes: state.pipelineForm.scheduleMinutes, + file_config: { + header_row: state.pipelineForm.hasHeaderRow ? state.pipelineForm.headerRow : 0, + data_start_row: state.pipelineForm.dataStartRow, + delimiter: state.pipelineForm.delimiter, + timestamp_column: state.pipelineForm.timestampColumn, + timestamp_format: state.pipelineForm.timestampFormat, + timezone: state.pipelineForm.timezone, + }, + column_mappings: mappedColumns, + }) + + state.jobs = [...state.jobs, created] + resetPipelineEditorState() + state.pipelineFeedback = { tone: "success", message: "Pipeline saved." } + navigate("dashboard") + syncRouteState() + } catch (error) { + state.pipelineFeedback = { + tone: "error", + message: + error instanceof Error ? error.message : "Couldn't save that pipeline.", + } + } +} + +function changeCredentials(): void { + state.authDraft = { + ...emptyServerConfig(), + ...(state.config?.server ?? {}), + } + state.settingsEditMode = true + navigate("settings") + syncRouteState() +} + +function cancelCredentialEdit(): void { + state.authDraft = { + ...emptyServerConfig(), + ...(state.config?.server ?? {}), + } + state.settingsEditMode = false +} + +async function handleRunJob(jobId: string): Promise { + try { + await runJob(jobId) + await refreshJobs() + } catch { + // Keep dashboard state unchanged on action failure. + } +} + +async function handleToggleJob(jobId: string): Promise { + const job = state.jobs.find((item) => item.id === jobId) + if (!job) { + return + } + + try { + if (job.enabled) { + await disableJob(jobId) + } else { + await enableJob(jobId) + } + + await refreshJobs() + } catch { + // Keep dashboard state unchanged on action failure. + } +} + +async function handleDeleteJob(jobId: string): Promise { + if (!window.confirm("Delete this pipeline?")) { + return + } + + try { + await deleteJob(jobId) + await refreshJobs() + } catch { + // Keep dashboard state unchanged on action failure. + } +} + +let initialized = false + +function init(): void { + if (initialized) { + return + } + + initialized = true + + window.addEventListener("hashchange", () => { + state.settingsFeedback = null + syncRouteState() + }) + + window.setInterval(() => { + void refreshJobs() + }, 30_000) + + syncRouteState() + void bootstrap() +} + +const model = { + state, + APP_NAME, + API_KEY_DOCS_URL, + PREVIEW_PAGE_SIZE, + init, + bootstrap, + isConnected, + showSidebar, + useWelcomeSurface, + previewHeaders, + parsedPreviewRows, + activePreviewRowTarget, + previewHandleLine, + activeTimestampColumn, + previewColumnClass, + previewFieldClass, + previewGuidanceText, + canShowMorePreviewLines, + connectionIndicator, + onboardingRoute, + updateAuthDraftField, + toggleAuthMode, + submitAuthConfig, + disconnectHydroServer, + changeCredentials, + cancelCredentialEdit, + updatePipelineField, + updateMapping, + setPipelineHasHeaderRow, + applyPreviewLineSelection, + applyPreviewColumnSelection, + updateHeaderRowFromPreview, + updateDataStartRowFromPreview, + loadPipelinePreview, + showMorePreviewLines, + browseForCsvPath, + submitPipeline, + handleRunJob, + handleToggleJob, + handleDeleteJob, +} as const + +export function useAppModel() { + return model +} diff --git a/frontend/main.ts b/frontend/main.ts index bd1f578..9812955 100644 --- a/frontend/main.ts +++ b/frontend/main.ts @@ -1,2922 +1,7 @@ -import "./generated.css"; -import appIconUrl from "../icons/icon-color.svg"; +import "./generated.css" -import { - clearServerConfig, - createJob, - deleteJob, - disableJob, - enableJob, - getConfig, - getCsvPreview, - getDatastreams, - getHealth, - listJobs, - runJob, - testConnection, - updateServerConfig, - validateServerUrl, - type AppConfig, - type AuthType, - type ConnectionState, - type ConnectionTestResponse, - type CsvPreviewResponse, - type DatastreamSummary, - type HealthResponse, - type JobSummary, - type ServerConfig, -} from "./api"; -import { - applyConnectionValidationResult, - createAuthFieldStates, - fieldFormFeedbackTarget, - resetAuthFieldStates, - runAuthSubmission, - validateAuthFieldsForSubmit, - type AuthFieldName, - type Feedback, - type FieldValidationState, -} from "./auth-submit"; -import { getRouteFromHash, navigate, routeHref, type AppRoute } from "./router"; -import { formatRelativeTime, formatSchedule, shortenPath } from "./time"; +import { createApp } from "vue" -const API_KEY_DOCS_URL = - "https://hydroserver2.github.io/hydroserver/tutorials/creating-your-first-orchestration-system#create-an-api-key"; -const APP_NAME = "HydroServer Streaming Data Loader"; -const STARTUP_RETRY_ATTEMPTS = 12; -const STARTUP_RETRY_DELAY_MS = 350; -const PREVIEW_PAGE_SIZE = 50; +import App from "./App.vue" -type PipelineMappingDraft = { - csvColumn: string; - datastreamId: string; -}; - -type PipelineFormState = { - name: string; - filePath: string; - scheduleMinutes: number; - hasHeaderRow: boolean; - headerRow: number; - dataStartRow: number; - delimiter: string; - timestampColumn: string; - timestampFormat: string; - timezone: string; - mappings: PipelineMappingDraft[]; -}; - -type PreviewSelectionTarget = - | "header-row" - | "data-start-row" - | "timestamp-column" - | null; - -type PreviewRowSelectionTarget = Exclude< - PreviewSelectionTarget, - "timestamp-column" | null ->; - -type PreviewDragState = { - target: PreviewRowSelectionTarget; - lineNumber: number; - pointerId: number; - moved: boolean; -}; - -type PreviewColumnDragState = { - columnName: string; - pointerId: number; - moved: boolean; -}; - -type PreviewDragVisualState = { - handle: HTMLElement; - startClientY: number; - currentClientY: number; - rowButtons: Map; - rowElements: Map; - rowCenters: Array<{ lineNumber: number; centerY: number }>; - frameRequested: boolean; -}; - -type PreviewColumnDragVisualState = { - handle: HTMLElement; - startClientX: number; - currentClientX: number; - headerButtons: Map; - columnCells: Map; - headerCenters: Array<{ columnName: string; centerX: number }>; - frameRequested: boolean; -}; - -type UiState = { - route: AppRoute; - health: HealthResponse | null; - config: AppConfig | null; - jobs: JobSummary[]; - datastreams: DatastreamSummary[]; - connectionSummary: ConnectionTestResponse | null; - loading: boolean; - bootstrapError: string | null; - settingsFeedback: Feedback; - welcomeFeedback: Feedback; - pipelineFeedback: Feedback; - lastConnectionState: ConnectionState | null; - settingsEditMode: boolean; - pipelineForm: PipelineFormState; - pipelinePreview: CsvPreviewResponse | null; - pipelineErrors: string[]; - datastreamsError: string | null; - authDraft: ServerConfig; - authFieldStates: Record; - authSubmitting: boolean; - lastAuthValidationServer: ServerConfig | null; - lastAuthValidationResult: ConnectionTestResponse | null; - pipelineSelectionTarget: PreviewSelectionTarget; - pipelineDrag: PreviewDragState | null; - pipelineColumnDrag: PreviewColumnDragState | null; - pipelinePreviewRowsRequested: number; -}; - -const shellElements = { - sidebar: document.querySelector("#app-sidebar"), - mainContent: document.querySelector("#main-content"), - jobsLink: document.querySelector( - '[data-route="dashboard"]' - ), - settingsLink: document.querySelector( - '[data-route="settings"]' - ), - connectionDot: document.querySelector("#connection-status-dot"), -}; - -if ( - !shellElements.sidebar || - !shellElements.mainContent || - !shellElements.jobsLink || - !shellElements.settingsLink || - !shellElements.connectionDot -) { - throw new Error("App shell is missing required elements."); -} - -const { sidebar, mainContent, jobsLink, settingsLink, connectionDot } = - shellElements; - -let lastRenderedMarkup = ""; -let suppressPreviewHandleClick = false; -let previewDragVisual: PreviewDragVisualState | null = null; -let previewColumnDragVisual: PreviewColumnDragVisualState | null = null; - -function createEmptyPipelineForm(): PipelineFormState { - return { - name: "", - filePath: "", - scheduleMinutes: 15, - hasHeaderRow: true, - headerRow: 3, - dataStartRow: 4, - delimiter: ",", - timestampColumn: "Timestamp", - timestampFormat: "%Y-%m-%d %H:%M:%S", - timezone: "America/Denver", - mappings: [], - }; -} - -const state: UiState = { - route: getRouteFromHash(), - health: null, - config: null, - jobs: [], - datastreams: [], - connectionSummary: null, - loading: true, - bootstrapError: null, - settingsFeedback: null, - welcomeFeedback: null, - pipelineFeedback: null, - lastConnectionState: null, - settingsEditMode: false, - pipelineForm: createEmptyPipelineForm(), - pipelinePreview: null, - pipelineErrors: [], - datastreamsError: null, - authDraft: emptyServerConfig(), - authFieldStates: createAuthFieldStates(), - authSubmitting: false, - lastAuthValidationServer: null, - lastAuthValidationResult: null, - pipelineSelectionTarget: null, - pipelineDrag: null, - pipelineColumnDrag: null, - pipelinePreviewRowsRequested: PREVIEW_PAGE_SIZE, -}; - -function emptyServerConfig(): ServerConfig { - return { - auth_type: "apikey", - url: "", - api_key: "", - username: "", - password: "", - workspace_id: "", - }; -} - -window.setInterval(() => { - void refreshJobs(); -}, 30_000); - -function escapeHtml(value: string): string { - return value - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); -} - -function feedbackMarkup(feedback: Feedback): string { - if (!feedback) { - return ""; - } - - const toneClass = - feedback.tone === "success" - ? "notice-success" - : feedback.tone === "error" - ? "notice-error" - : "notice-info"; - - return `
${escapeHtml(feedback.message)}
`; -} - -function basename(path: string): string { - const segments = path.split(/[\\/]/).filter(Boolean); - return segments.at(-1) ?? path; -} - -function parseDelimitedLine(line: string, delimiter: string): string[] { - if (!delimiter) { - return [line]; - } - - const cells: string[] = []; - let current = ""; - let inQuotes = false; - - for (let index = 0; index < line.length; index += 1) { - const character = line[index]; - - if (character === '"') { - if (inQuotes && line[index + 1] === '"') { - current += '"'; - index += 1; - } else { - inQuotes = !inQuotes; - } - continue; - } - - if (!inQuotes && line.startsWith(delimiter, index)) { - cells.push(current); - current = ""; - index += delimiter.length - 1; - continue; - } - - current += character; - } - - cells.push(current); - return cells; -} - -function normalizePreviewHeaderName(value: string, index: number): string { - const cleaned = value.trim(); - return cleaned || `Column ${index + 1}`; -} - -function parsedPreviewRows(): string[][] { - if (!state.pipelinePreview) { - return []; - } - - return state.pipelinePreview.raw_lines.map((line) => - parseDelimitedLine(line, state.pipelineForm.delimiter) - ); -} - -function connected(): boolean { - return ( - state.connectionSummary?.ok === true && - state.lastConnectionState === "connected" - ); -} - -function currentServerConfig(): ServerConfig { - return state.authDraft; -} - -function resetStateAuthFieldStates(authType: AuthType): void { - resetAuthFieldStates(state.authFieldStates, authType); -} - -function serverConfigured(server: ServerConfig | null | undefined): boolean { - if (!server?.url.trim()) { - return false; - } - - if (server.auth_type === "userpass") { - return Boolean(server.username.trim() && server.password.trim()); - } - - return Boolean(server.api_key.trim()); -} - -function readServerConfigForm( - form: HTMLFormElement, - base: ServerConfig = currentServerConfig() -): ServerConfig { - const data = new FormData(form); - const authType = data.get("auth_type") === "userpass" ? "userpass" : "apikey"; - - return { - auth_type: authType, - url: String(data.get("url") ?? "").trim(), - api_key: - authType === "apikey" - ? String(data.get("api_key") ?? "").trim() - : base.api_key, - username: - authType === "userpass" - ? String(data.get("username") ?? "").trim() - : base.username, - password: - authType === "userpass" - ? String(data.get("password") ?? "").trim() - : base.password, - workspace_id: "", - }; -} - -function setServerDraft(server: ServerConfig): void { - state.authDraft = { ...server }; -} - -function markField( - field: AuthFieldName, - nextState: FieldValidationState["state"], - message: string | null = null -): void { - state.authFieldStates[field] = { state: nextState, message }; -} - -function authFieldErrorMarkup(field: AuthFieldName): string { - const fieldState = state.authFieldStates[field]; - if (fieldState.state !== "invalid" || !fieldState.message) { - return ""; - } - - return `

${escapeHtml(fieldState.message)}

`; -} - -function renderAuthInputField(params: { - label: string; - name: AuthFieldName; - type: "url" | "text" | "password"; - value: string; - placeholder: string; - helpText?: string; - labelAction?: string; -}): string { - const { label, name, type, value, placeholder, helpText, labelAction } = - params; - - return ` - - `; -} - -function clearAuthFormFeedback(formId: string): void { - state[fieldFormFeedbackTarget(formId)] = null; -} - -function clearAuthValidationCache(): void { - state.lastAuthValidationServer = null; - state.lastAuthValidationResult = null; -} - -function previewHeaders(): string[] { - const rows = parsedPreviewRows(); - const columnCount = rows.reduce((max, row) => Math.max(max, row.length), 0); - - if (!state.pipelineForm.hasHeaderRow) { - const dataRows = rows.slice(Math.max(state.pipelineForm.dataStartRow - 1, 0)); - const dataColumnCount = (dataRows.length > 0 ? dataRows : rows).reduce( - (max, row) => Math.max(max, row.length), - 0 - ); - return Array.from( - { length: dataColumnCount }, - (_, index) => `Column ${index + 1}` - ); - } - - const headerRow = rows[state.pipelineForm.headerRow - 1] ?? []; - return Array.from({ length: columnCount }, (_, index) => - normalizePreviewHeaderName(headerRow[index] ?? "", index) - ); -} - -function activePreviewRowTarget(): PreviewRowSelectionTarget | null { - if (state.pipelineDrag) { - return state.pipelineDrag.target; - } - - return state.pipelineSelectionTarget === "header-row" || - state.pipelineSelectionTarget === "data-start-row" - ? state.pipelineSelectionTarget - : null; -} - -function previewHandleLine( - target: PreviewRowSelectionTarget -): number | null { - if (state.pipelineDrag?.target === target) { - return state.pipelineDrag.lineNumber; - } - - if (target === "header-row") { - return state.pipelineForm.hasHeaderRow ? state.pipelineForm.headerRow : null; - } - - return state.pipelineForm.dataStartRow; -} - -function setPreviewRowSelectionTarget( - target: PreviewRowSelectionTarget, - lineNumber: number -): void { - if (target === "header-row") { - updateHeaderRowFromPreview(lineNumber); - return; - } - - updateDataStartRowFromPreview(lineNumber); -} - -function previewCommittedHandleLine( - target: PreviewRowSelectionTarget -): number | null { - if (target === "header-row") { - return state.pipelineForm.hasHeaderRow ? state.pipelineForm.headerRow : null; - } - - return state.pipelineForm.dataStartRow; -} - -function previewDragHandleSelector( - target: PreviewRowSelectionTarget, - lineNumber: number -): string { - return `[data-preview-handle-target="${target}"][data-preview-line="${lineNumber}"]`; -} - -function findPreviewHandleElement( - target: PreviewRowSelectionTarget, - lineNumber: number -): HTMLElement | null { - return mainContent.querySelector( - previewDragHandleSelector(target, lineNumber) - ); -} - -function collectPreviewRowButtons(): Map { - return new Map( - Array.from( - mainContent.querySelectorAll( - '[data-action="pick-preview-line"][data-preview-line]' - ) - ) - .map((button) => { - const lineNumber = Number(button.dataset.previewLine); - return Number.isFinite(lineNumber) ? [lineNumber, button] : null; - }) - .filter( - (entry): entry is [number, HTMLButtonElement] => entry !== null - ) - ); -} - -function collectPreviewRowElements(): Map { - return new Map( - Array.from( - mainContent.querySelectorAll("[data-preview-line-row]") - ) - .map((row) => { - const lineNumber = Number(row.dataset.previewLineRow); - return Number.isFinite(lineNumber) ? [lineNumber, row] : null; - }) - .filter( - (entry): entry is [number, HTMLTableRowElement] => entry !== null - ) - ); -} - -function collectPreviewRowCenters( - rowButtons: Map -): Array<{ lineNumber: number; centerY: number }> { - return Array.from(rowButtons.entries()).map(([lineNumber, button]) => { - const rect = button.getBoundingClientRect(); - return { lineNumber, centerY: rect.top + rect.height / 2 }; - }); -} - -function nearestPreviewLineNumber( - clientY: number, - rowCenters: Array<{ lineNumber: number; centerY: number }> -): number | null { - if (rowCenters.length === 0) { - return null; - } - - let bestLine = rowCenters[0].lineNumber; - let bestDistance = Math.abs(clientY - rowCenters[0].centerY); - - for (const row of rowCenters.slice(1)) { - const distance = Math.abs(clientY - row.centerY); - if (distance < bestDistance) { - bestDistance = distance; - bestLine = row.lineNumber; - } - } - - return bestLine; -} - -function activeTimestampColumn(): string { - return state.pipelineColumnDrag?.columnName ?? state.pipelineForm.timestampColumn; -} - -function findPreviewColumnHandleElement(columnName: string): HTMLElement | null { - return ( - Array.from( - mainContent.querySelectorAll("[data-preview-column-handle]") - ).find((element) => element.dataset.previewColumnHandle === columnName) ?? null - ); -} - -function collectPreviewHeaderButtons(): Map { - return new Map( - Array.from( - mainContent.querySelectorAll( - '[data-action="pick-preview-column"][data-preview-column]' - ) - ) - .map((button) => { - const columnName = button.dataset.previewColumn ?? ""; - return columnName ? [columnName, button] : null; - }) - .filter( - (entry): entry is [string, HTMLButtonElement] => entry !== null - ) - ); -} - -function collectPreviewColumnCells(): Map { - const cells = new Map(); - mainContent - .querySelectorAll("[data-preview-column-cell]") - .forEach((element) => { - const columnName = element.dataset.previewColumnCell ?? ""; - if (!columnName) { - return; - } - - const columnEntries = cells.get(columnName) ?? []; - columnEntries.push(element); - cells.set(columnName, columnEntries); - }); - return cells; -} - -function collectPreviewHeaderCenters( - headerButtons: Map -): Array<{ columnName: string; centerX: number }> { - return Array.from(headerButtons.entries()).map(([columnName, button]) => { - const rect = button.getBoundingClientRect(); - return { columnName, centerX: rect.left + rect.width / 2 }; - }); -} - -function nearestPreviewColumnName( - clientX: number, - headerCenters: Array<{ columnName: string; centerX: number }> -): string | null { - if (headerCenters.length === 0) { - return null; - } - - let bestColumn = headerCenters[0].columnName; - let bestDistance = Math.abs(clientX - headerCenters[0].centerX); - - for (const column of headerCenters.slice(1)) { - const distance = Math.abs(clientX - column.centerX); - if (distance < bestDistance) { - bestDistance = distance; - bestColumn = column.columnName; - } - } - - return bestColumn; -} - -function applyPreviewDragClasses(): void { - if (!state.pipelineDrag || !previewDragVisual) { - return; - } - - const headerLine = - state.pipelineDrag.target === "header-row" - ? state.pipelineDrag.lineNumber - : previewCommittedHandleLine("header-row"); - const dataLine = - state.pipelineDrag.target === "data-start-row" - ? state.pipelineDrag.lineNumber - : previewCommittedHandleLine("data-start-row"); - - for (const [lineNumber, button] of previewDragVisual.rowButtons.entries()) { - button.classList.toggle( - "preview-line-button-header", - state.pipelineForm.hasHeaderRow && headerLine === lineNumber - ); - button.classList.toggle("preview-line-button-data", dataLine === lineNumber); - } - - for (const [lineNumber, row] of previewDragVisual.rowElements.entries()) { - row.classList.toggle( - "preview-table-row-header", - state.pipelineForm.hasHeaderRow && headerLine === lineNumber - ); - row.classList.toggle("preview-table-row-data", dataLine === lineNumber); - } -} - -function flushPreviewDragVisual(): void { - if (!state.pipelineDrag || !previewDragVisual) { - return; - } - - previewDragVisual.frameRequested = false; - const offset = previewDragVisual.currentClientY - previewDragVisual.startClientY; - previewDragVisual.handle.style.setProperty( - "--preview-handle-offset", - `${offset}px` - ); - applyPreviewDragClasses(); -} - -function schedulePreviewDragVisual(): void { - if (!previewDragVisual || previewDragVisual.frameRequested) { - return; - } - - previewDragVisual.frameRequested = true; - window.requestAnimationFrame(flushPreviewDragVisual); -} - -function beginPreviewDragVisual(pointerClientY: number): void { - if (!state.pipelineDrag) { - return; - } - - const handle = findPreviewHandleElement( - state.pipelineDrag.target, - state.pipelineDrag.lineNumber - ); - if (!handle) { - return; - } - - const rowButtons = collectPreviewRowButtons(); - previewDragVisual = { - handle, - startClientY: pointerClientY, - currentClientY: pointerClientY, - rowButtons, - rowElements: collectPreviewRowElements(), - rowCenters: collectPreviewRowCenters(rowButtons), - frameRequested: false, - }; - - mainContent - .querySelectorAll(".preview-row-handle-active") - .forEach((element) => - element.classList.remove("preview-row-handle-active") - ); - handle.classList.add("preview-row-handle-active"); - handle.classList.add("preview-row-handle-dragging"); - handle.style.setProperty("--preview-handle-offset", "0px"); - applyPreviewDragClasses(); -} - -function endPreviewDragVisual(): void { - if (!previewDragVisual) { - return; - } - - if ( - state.pipelineDrag && - typeof previewDragVisual.handle.releasePointerCapture === "function" && - previewDragVisual.handle.hasPointerCapture(state.pipelineDrag.pointerId) - ) { - previewDragVisual.handle.releasePointerCapture(state.pipelineDrag.pointerId); - } - - previewDragVisual.handle.classList.remove("preview-row-handle-dragging"); - previewDragVisual.handle.style.removeProperty("--preview-handle-offset"); - previewDragVisual = null; -} - -function applyPreviewColumnDragClasses(): void { - if (!state.pipelineColumnDrag || !previewColumnDragVisual) { - return; - } - - for (const [columnName, cells] of previewColumnDragVisual.columnCells.entries()) { - const active = columnName === state.pipelineColumnDrag.columnName; - for (const cell of cells) { - cell.classList.toggle("preview-col-timestamp", active); - } - } -} - -function flushPreviewColumnDragVisual(): void { - if (!state.pipelineColumnDrag || !previewColumnDragVisual) { - return; - } - - previewColumnDragVisual.frameRequested = false; - const offset = - previewColumnDragVisual.currentClientX - previewColumnDragVisual.startClientX; - previewColumnDragVisual.handle.style.setProperty( - "--preview-column-handle-offset", - `${offset}px` - ); - applyPreviewColumnDragClasses(); -} - -function schedulePreviewColumnDragVisual(): void { - if (!previewColumnDragVisual || previewColumnDragVisual.frameRequested) { - return; - } - - previewColumnDragVisual.frameRequested = true; - window.requestAnimationFrame(flushPreviewColumnDragVisual); -} - -function beginPreviewColumnDragVisual(pointerClientX: number): void { - if (!state.pipelineColumnDrag) { - return; - } - - const handle = findPreviewColumnHandleElement(state.pipelineColumnDrag.columnName); - if (!handle) { - return; - } - - const headerButtons = collectPreviewHeaderButtons(); - previewColumnDragVisual = { - handle, - startClientX: pointerClientX, - currentClientX: pointerClientX, - headerButtons, - columnCells: collectPreviewColumnCells(), - headerCenters: collectPreviewHeaderCenters(headerButtons), - frameRequested: false, - }; - - handle.classList.add("preview-column-handle-dragging"); - handle.style.setProperty("--preview-column-handle-offset", "0px"); - applyPreviewColumnDragClasses(); -} - -function endPreviewColumnDragVisual(): void { - if (!previewColumnDragVisual) { - return; - } - - if ( - state.pipelineColumnDrag && - typeof previewColumnDragVisual.handle.releasePointerCapture === "function" && - previewColumnDragVisual.handle.hasPointerCapture( - state.pipelineColumnDrag.pointerId - ) - ) { - previewColumnDragVisual.handle.releasePointerCapture( - state.pipelineColumnDrag.pointerId - ); - } - - previewColumnDragVisual.handle.classList.remove( - "preview-column-handle-dragging" - ); - previewColumnDragVisual.handle.style.removeProperty( - "--preview-column-handle-offset" - ); - previewColumnDragVisual = null; -} - -function pipelineMappingsByColumn(): Map { - return new Map( - state.pipelineForm.mappings.map((mapping) => [ - mapping.csvColumn, - mapping.datastreamId, - ]) - ); -} - -function previewColumnClass(columnName: string): string { - if (columnName === state.pipelineForm.timestampColumn) { - return "preview-col-timestamp"; - } - - const mapped = state.pipelineForm.mappings.find( - (mapping) => mapping.csvColumn === columnName && mapping.datastreamId - ); - return mapped ? "preview-col-mapped" : ""; -} - -function previewFieldClass( - target: Exclude -): string { - const active = - target === "timestamp-column" - ? state.pipelineSelectionTarget === target || state.pipelineColumnDrag !== null - : activePreviewRowTarget() === target; - const toneClass = - target === "header-row" - ? "preview-bound-field-header" - : target === "data-start-row" - ? "preview-bound-field-data" - : "preview-bound-field-timestamp"; - - return active - ? `field preview-bound-field preview-bound-field-active ${toneClass}` - : "field preview-bound-field"; -} - -function previewGuidanceText(): string { - const activeTarget = activePreviewRowTarget(); - - if (activeTarget === "header-row") { - return "Drag the HEADER handle, or click a row to place it."; - } - - if (activeTarget === "data-start-row") { - return "Drag the DATA START handle, or click the first data row."; - } - - if ( - state.pipelineSelectionTarget === "timestamp-column" || - state.pipelineColumnDrag - ) { - return "Drag the TIMESTAMP handle, or click a column header to place it."; - } - - return state.pipelineForm.hasHeaderRow - ? "Drag the HEADER, DATA START, and TIMESTAMP handles, or click a row or column to place them." - : "Drag the DATA START and TIMESTAMP handles, or click a row or column to place them."; -} - -function syncPipelineSelectionsWithPreview(): void { - const headers = previewHeaders(); - - if (headers.length === 0) { - state.pipelineForm.mappings = []; - return; - } - - const preferredTimestamp = - headers.find((header) => header.toLowerCase().includes("time")) ?? - headers[0]; - - state.pipelineForm.timestampColumn = headers.includes( - state.pipelineForm.timestampColumn - ) - ? state.pipelineForm.timestampColumn - : preferredTimestamp; - - initializeMappings(headers); -} - -function initializeMappings(headers: string[]): void { - const existing = pipelineMappingsByColumn(); - state.pipelineForm.mappings = headers - .filter((header) => header !== state.pipelineForm.timestampColumn) - .map((header) => ({ - csvColumn: header, - datastreamId: existing.get(header) ?? "", - })); -} - -function applyPreview(path: string, preview: CsvPreviewResponse): void { - state.pipelinePreview = preview; - state.pipelineForm.filePath = path; - state.pipelineForm.hasHeaderRow = preview.detected_header_row !== null; - state.pipelineForm.headerRow = - preview.detected_header_row ?? state.pipelineForm.headerRow; - state.pipelineForm.dataStartRow = - preview.detected_data_start_row ?? state.pipelineForm.dataStartRow; - state.pipelineForm.delimiter = - preview.detected_delimiter || state.pipelineForm.delimiter; - state.pipelineSelectionTarget = null; - state.pipelineDrag = null; - state.pipelineColumnDrag = null; - - if (!state.pipelineForm.name.trim()) { - const inferred = basename(path).replace(/\.[^.]+$/, ""); - state.pipelineForm.name = inferred; - } - - syncPipelineSelectionsWithPreview(); -} - -function canShowMorePreviewLines(): boolean { - if (!state.pipelinePreview) { - return false; - } - - return state.pipelinePreview.raw_lines.length < state.pipelinePreview.total_lines; -} - -function updateHeaderRowFromPreview(lineNumber: number): void { - state.pipelineForm.hasHeaderRow = true; - state.pipelineForm.headerRow = lineNumber; - if (state.pipelineForm.dataStartRow <= lineNumber) { - state.pipelineForm.dataStartRow = lineNumber + 1; - } - syncPipelineSelectionsWithPreview(); -} - -function updateDataStartRowFromPreview(lineNumber: number): void { - state.pipelineForm.dataStartRow = Math.max( - state.pipelineForm.hasHeaderRow ? 2 : 1, - lineNumber - ); - if ( - state.pipelineForm.hasHeaderRow && - state.pipelineForm.headerRow >= state.pipelineForm.dataStartRow - ) { - state.pipelineForm.headerRow = state.pipelineForm.dataStartRow - 1; - } - syncPipelineSelectionsWithPreview(); -} - -function setPipelineHasHeaderRow(enabled: boolean): void { - state.pipelineForm.hasHeaderRow = enabled; - - if (!enabled && state.pipelineSelectionTarget === "header-row") { - state.pipelineSelectionTarget = null; - } - - if (!enabled && state.pipelineDrag?.target === "header-row") { - state.pipelineDrag = null; - } - - if ( - enabled && - state.pipelineForm.headerRow >= state.pipelineForm.dataStartRow - ) { - state.pipelineForm.headerRow = Math.max( - 1, - state.pipelineForm.dataStartRow - 1 - ); - } - - syncPipelineSelectionsWithPreview(); -} - -function applyPreviewLineSelection(lineNumber: number): void { - if (state.pipelineSelectionTarget === "header-row") { - setPreviewRowSelectionTarget("header-row", lineNumber); - state.pipelineSelectionTarget = null; - render(); - return; - } - - if (state.pipelineSelectionTarget === "data-start-row") { - setPreviewRowSelectionTarget("data-start-row", lineNumber); - state.pipelineSelectionTarget = null; - render(); - } -} - -function applyPreviewColumnSelection(columnName: string): void { - if ( - state.pipelineSelectionTarget && - state.pipelineSelectionTarget !== "timestamp-column" - ) { - return; - } - - state.pipelineForm.timestampColumn = columnName; - initializeMappings(previewHeaders()); - state.pipelineSelectionTarget = null; - state.pipelineColumnDrag = null; - render(); -} - -function onboardingRoute(route: AppRoute): boolean { - return ( - route === "welcome" || (route === "jobs-new" && state.jobs.length === 0) - ); -} - -function connectionIndicator(): { label: string; className: string } { - if (!serverConfigured(state.config?.server)) { - return { - label: "HydroServer not configured", - className: "status-dot bg-slate-300", - }; - } - - if (connected()) { - return { - label: "Connected to HydroServer", - className: "status-dot bg-emerald-500", - }; - } - - if (state.lastConnectionState === "error") { - return { - label: "HydroServer authentication error", - className: "status-dot bg-rose-500", - }; - } - - return { - label: "HydroServer configured", - className: "status-dot bg-sky-500", - }; -} - -function statusPill(job: JobSummary): string { - const classes: Record = { - healthy: "pill-success", - warning: "pill-warning", - error: "pill-danger", - disabled: "pill-muted", - pending: "pill-info", - running: "pill-info", - }; - - return `${escapeHtml( - job.status_message - )}`; -} - -function renderConnectedCard(showActions: boolean): string { - if (!connected() || !state.connectionSummary) { - return ""; - } - - const datastreamText = - state.connectionSummary.datastream_count === 1 - ? "1 datastream available" - : `${state.connectionSummary.datastream_count} datastreams available`; - - return ` -
-
-

Authenticated

-

${escapeHtml( - state.connectionSummary.instance_name ?? "HydroServer" - )}

-

${escapeHtml( - state.connectionSummary.message - )}

-
- Connected - ${escapeHtml(datastreamText)} -
-
- ${ - showActions - ? ` -
- - - ${ - state.jobs.length === 0 - ? `Create first pipeline` - : "" - } -
- ` - : "" - } -
- `; -} - -function renderAuthForm( - formId: "welcome-form" | "settings-form", - submitLabel: string, - secondaryAction: string -): string { - const server = currentServerConfig(); - const usingUserPass = server.auth_type === "userpass"; - const authToggleLabel = usingUserPass - ? "Connect with an API key" - : "Connect with username and password"; - const submitDisabled = state.authSubmitting ? "disabled" : ""; - const submitLabelText = state.authSubmitting ? "Connecting..." : submitLabel; - - return ` -
-
-
- HydroServer Streaming Data Loader icon -

Connect to HydroServer

-
- - - ${renderAuthInputField({ - label: "Host URL", - name: "url", - type: "url", - value: server.url, - placeholder: "https://playground.hydroserver.org", - })} - - ${ - usingUserPass - ? ` - ${renderAuthInputField({ - label: "Username", - name: "username", - type: "text", - value: server.username, - placeholder: "name@example.com", - })} - ${renderAuthInputField({ - label: "Password", - name: "password", - type: "password", - value: server.password, - placeholder: "Enter your HydroServer password", - })} - ` - : ` - ${renderAuthInputField({ - label: "API key", - name: "api_key", - type: "password", - value: server.api_key, - placeholder: - "KaTz74swGqHn__I2VY6ceIzrIxC04oDhUrLLgBTH9ACxYIunmkrdmqk", - labelAction: `How to create an API key →`, - })} - ` - } - -
- or - - -
- -
- ${secondaryAction} - -
-
-
- `; -} - -function renderWelcome(): string { - return ` -
- ${renderAuthForm("welcome-form", "Connect to HydroServer", "")} -
- `; -} - -function renderSettings(): string { - const showForm = !connected() || state.settingsEditMode; - - return ` -
- - - ${ - showForm - ? renderAuthForm( - "settings-form", - "Save and verify", - connected() - ? '' - : "" - ) - : renderConnectedCard(true) - } -
- `; -} - -function renderDashboard(): string { - if (state.jobs.length === 0) { - return ` -
- -
- `; - } - - const cards = state.jobs - .map((job) => { - const lastLine = job.last_error - ? `Failed ${formatRelativeTime(job.last_run_at)}` - : `Last pushed ${formatRelativeTime(job.last_pushed_timestamp)}`; - - return ` -
-
-
-
- -

${escapeHtml(job.name)}

-
-

${escapeHtml( - shortenPath(job.file_path) - )}

-

- ${escapeHtml(lastLine)} · ${escapeHtml( - formatSchedule(job.schedule_minutes) - )} -

-
- ${statusPill(job)} -
- -
- - - -
-
- `; - }) - .join(""); - - return ` -
- -
${cards}
-
- `; -} - -function renderPreviewHandle( - target: PreviewRowSelectionTarget, - lineNumber: number -): string { - const handleLine = previewHandleLine(target); - if (handleLine !== lineNumber) { - return ""; - } - - const active = activePreviewRowTarget() === target; - const label = target === "header-row" ? "HEADER" : "DATA START"; - const className = - target === "header-row" - ? "preview-row-handle preview-row-handle-header" - : "preview-row-handle preview-row-handle-data"; - - return ` - - `; -} - -function renderTimestampHandle(columnName: string): string { - if (columnName !== activeTimestampColumn()) { - return ""; - } - - const active = - state.pipelineSelectionTarget === "timestamp-column" || - state.pipelineColumnDrag !== null; - - return ` - - `; -} - -function renderPipelinePreview(): string { - if (!state.pipelinePreview) { - return ` -
-
-
CSV
-

Preview a source file

-

Choose a CSV file path, then load the preview to inspect the first 50 lines and map the source structure into HydroServer.

-
-
- `; - } - - const headers = previewHeaders(); - const parsedRows = parsedPreviewRows().map((row, index) => ({ - lineNumber: index + 1, - row, - })); - const headerLine = previewHandleLine("header-row"); - const dataStartLine = previewHandleLine("data-start-row"); - - const headerCells = headers - .map( - (header) => - ` -
- ${renderTimestampHandle(header)} - -
- ` - ) - .join(""); - - const tableRows = parsedRows - .map( - ({ lineNumber, row }) => ` - - -
- ${ - state.pipelineForm.hasHeaderRow - ? renderPreviewHandle("header-row", lineNumber) - : "" - } - ${renderPreviewHandle("data-start-row", lineNumber)} - -
- - ${headers - .map((columnName, index) => { - const cell = row[index] ?? ""; - return `${escapeHtml(cell)}`; - }) - .join("")} - - ` - ) - .join(""); - const shownLines = state.pipelinePreview.raw_lines.length; - const remainingLines = Math.max(state.pipelinePreview.total_lines - shownLines, 0); - const nextPageSize = Math.min(PREVIEW_PAGE_SIZE, remainingLines); - const showMoreButton = canShowMorePreviewLines() - ? ` - - ` - : ""; - - return ` -
-
-
-

Preview

-

${escapeHtml( - basename(state.pipelineForm.filePath) - )}

-

${escapeHtml(previewGuidanceText())}

-
-
- - - -
- - - - - ${headerCells} - - - - ${tableRows} - -
Line
-
- -
- - Showing the first ${shownLines} lines of ${state.pipelinePreview.total_lines} - - ${showMoreButton} -
-
- `; -} - -function renderPipelineMappings(): string { - const availableMappings = state.pipelineForm.mappings; - - if (!state.pipelinePreview || availableMappings.length === 0) { - return ` -
-

Column mappings

-

Load a CSV preview first so HydroServer Streaming Data Loader can list the available source columns.

-
- `; - } - - const rows = availableMappings - .map((mapping) => { - const options = [ - ``, - ...state.datastreams.map( - (datastream) => - `` - ), - ].join(""); - - return ` -
-
-

${escapeHtml(mapping.csvColumn)}

-

Source column

-
- -
- `; - }) - .join(""); - - return ` -
-

Column mappings

-

Map each source column to a HydroServer datastream. Leave any unused source columns as “Not mapped.”

-
${rows}
-
- `; -} - -function renderFirstPipelineOnboarding(): string { - return ` -
-
- - -
- -
-
- - ${feedbackMarkup(state.pipelineFeedback)} - ${state.pipelinePreview ? renderPipelinePreview() : ""} -
- `; -} - -function renderPipelineEditor(): string { - const firstRunOnboarding = state.jobs.length === 0; - const shellClass = firstRunOnboarding - ? "page-shell onboarding-shell animate-fade-in" - : "page-shell animate-fade-in"; - - if (!connected()) { - return renderWelcome(); - } - - if (firstRunOnboarding) { - return renderFirstPipelineOnboarding(); - } - - if (state.datastreamsError) { - return ` -
- - - ${renderConnectedCard(true)} -
${escapeHtml(state.datastreamsError)}
-
- `; - } - - if (state.datastreams.length === 0) { - return ` -
- - - ${renderConnectedCard(true)} - - Open the HydroServer 101 tutorial - -
- `; - } - - const timestampOptions = previewHeaders() - .map( - (header) => - `` - ) - .join(""); - - const pipelineErrorMarkup = - state.pipelineErrors.length > 0 - ? ` -
-

Fix these issues before saving

-
    - ${state.pipelineErrors - .map((error) => `
  • ${escapeHtml(error)}
  • `) - .join("")} -
-
- ` - : ""; - - return ` -
- - - ${renderConnectedCard(true)} - -
-
-
-

Pipeline details

- - - - - -
- -
- - -
- -
-

File structure

- -
- ${ - state.pipelineForm.hasHeaderRow - ? ` -
-
- -
- - Drag the blue HEADER handle in the preview or enter a row number. -
- ` - : ` -
- Header row - This file is using generated column labels: Column 1, Column 2, Column 3... -
- ` - } - -
-
- -
- - Drag the green DATA START handle in the preview or enter a row number. -
-
- -
- - - -
- -
-
- -
- ${ - previewHeaders().length > 0 - ? `` - : `` - } - Drag the amber TIMESTAMP handle in the preview, or click the matching header. -
- - -
- - ${renderPipelineMappings()} - ${pipelineErrorMarkup} - ${feedbackMarkup(state.pipelineFeedback)} - -
- -
-
- - ${renderPipelinePreview()} -
-
- `; -} - -function renderFatalError(): string { - return ` -
-
-

Sidecar error

-

The background process is unavailable

-

${escapeHtml( - state.bootstrapError ?? - `${APP_NAME} could not reach the local background service.` - )}

- -
-
- `; -} - -function render(): void { - state.route = getRouteFromHash(); - - let currentRoute = getRouteFromHash(); - - if (!state.loading && !state.bootstrapError) { - if ( - !connected() && - currentRoute !== "settings" && - currentRoute !== "welcome" - ) { - navigate("welcome"); - currentRoute = "welcome"; - } else if ( - connected() && - state.jobs.length === 0 && - (currentRoute === "dashboard" || currentRoute === "welcome") - ) { - navigate("jobs-new"); - currentRoute = "jobs-new"; - } - } - - const inOnboardingRoute = onboardingRoute(currentRoute); - const showSidebar = !inOnboardingRoute && !state.bootstrapError; - const useWelcomeSurface = Boolean( - state.loading || state.bootstrapError || inOnboardingRoute - ); - sidebar.classList.toggle("hidden", !showSidebar); - mainContent.classList.toggle("main-content-welcome", useWelcomeSurface); - document.body.classList.toggle("app-surface-welcome", useWelcomeSurface); - - jobsLink.className = - currentRoute === "dashboard" ? "nav-item nav-item-active" : "nav-item"; - settingsLink.className = - currentRoute === "settings" ? "nav-item nav-item-active" : "nav-item"; - - const status = connectionIndicator(); - connectionDot.className = status.className; - connectionDot.title = status.label; - - let nextMarkup = ""; - - if (state.loading) { - nextMarkup = ` -
- -
- `; - } else if (state.bootstrapError) { - nextMarkup = renderFatalError(); - } else if (currentRoute === "settings") { - nextMarkup = renderSettings(); - } else if (currentRoute === "welcome") { - nextMarkup = renderWelcome(); - } else if (currentRoute === "jobs-new") { - nextMarkup = renderPipelineEditor(); - } else { - nextMarkup = renderDashboard(); - } - - if (nextMarkup !== lastRenderedMarkup) { - mainContent.innerHTML = nextMarkup; - lastRenderedMarkup = nextMarkup; - } -} - -function sleep(ms: number): Promise { - return new Promise((resolve) => window.setTimeout(resolve, ms)); -} - -function isTransientBootstrapError(error: unknown): boolean { - if (!(error instanceof Error)) { - return false; - } - - const message = error.message.toLowerCase(); - return ( - message.includes("failed to fetch") || - message.includes("networkerror") || - message.includes("status 500") || - message.includes("status 502") || - message.includes("status 503") || - message.includes("status 504") - ); -} - -async function loadInitialStateWithRetry(): Promise<{ - health: HealthResponse; - config: AppConfig; - jobs: JobSummary[]; -}> { - let lastError: unknown = null; - - for (let attempt = 1; attempt <= STARTUP_RETRY_ATTEMPTS; attempt += 1) { - try { - const [health, config, jobs] = await Promise.all([ - getHealth(), - getConfig(), - listJobs(), - ]); - return { health, config, jobs }; - } catch (error) { - lastError = error; - - if ( - attempt === STARTUP_RETRY_ATTEMPTS || - !isTransientBootstrapError(error) - ) { - throw error; - } - - await sleep(STARTUP_RETRY_DELAY_MS); - } - } - - throw lastError instanceof Error - ? lastError - : new Error(`Failed to load ${APP_NAME}.`); -} - -async function syncAuthenticationStatus( - server: ServerConfig -): Promise { - const result = await testConnection(server); - state.lastAuthValidationServer = server; - state.lastAuthValidationResult = result; - state.connectionSummary = result; - state.lastConnectionState = result.state; - - if (result.ok && result.workspace_id) { - if (state.config) { - state.config.server.workspace_id = result.workspace_id; - } - state.authDraft.workspace_id = result.workspace_id; - } - - if (!result.ok) { - state.datastreams = []; - state.datastreamsError = null; - } - - return result; -} - -async function loadDatastreams(): Promise { - try { - state.datastreams = await getDatastreams(); - state.datastreamsError = null; - } catch (error) { - state.datastreams = []; - state.datastreamsError = - error instanceof Error - ? error.message - : "Couldn't load HydroServer datastreams."; - } -} - -async function bootstrap(): Promise { - state.loading = true; - state.bootstrapError = null; - state.welcomeFeedback = null; - state.settingsFeedback = null; - render(); - - try { - const { health, config, jobs } = await loadInitialStateWithRetry(); - state.health = health; - state.config = config; - state.authDraft = { - ...emptyServerConfig(), - ...config.server, - }; - state.jobs = jobs; - state.lastConnectionState = health.connection.state; - - if (serverConfigured(config.server)) { - const result = await syncAuthenticationStatus(config.server); - if (result.ok) { - await loadDatastreams(); - } - } - } catch (error) { - state.bootstrapError = - error instanceof Error ? error.message : `Failed to load ${APP_NAME}.`; - } finally { - state.loading = false; - render(); - } -} - -async function refreshJobs(): Promise { - if (state.bootstrapError || state.loading) { - return; - } - - try { - state.jobs = await listJobs(); - render(); - } catch { - // Keep existing UI state on polling failure. - } -} - -function updatePipelineField(name: string, value: string): void { - switch (name) { - case "pipeline_name": - state.pipelineForm.name = value; - break; - case "file_path": - state.pipelineForm.filePath = value; - state.pipelinePreview = null; - state.pipelineErrors = []; - state.pipelineSelectionTarget = null; - state.pipelineDrag = null; - state.pipelineColumnDrag = null; - state.pipelinePreviewRowsRequested = PREVIEW_PAGE_SIZE; - break; - case "schedule_minutes": - state.pipelineForm.scheduleMinutes = Number(value) || 15; - break; - case "header_row": - state.pipelineForm.headerRow = Number(value) || 1; - syncPipelineSelectionsWithPreview(); - break; - case "data_start_row": - state.pipelineForm.dataStartRow = Number(value) || 1; - syncPipelineSelectionsWithPreview(); - break; - case "delimiter": - state.pipelineForm.delimiter = value || ","; - syncPipelineSelectionsWithPreview(); - break; - case "timestamp_column": - state.pipelineForm.timestampColumn = value; - initializeMappings(previewHeaders()); - state.pipelineColumnDrag = null; - render(); - break; - case "timestamp_format": - state.pipelineForm.timestampFormat = value; - break; - case "timezone": - state.pipelineForm.timezone = value; - break; - default: - break; - } -} - -function validatePipeline(): string[] { - const errors: string[] = []; - const headers = previewHeaders(); - const selectedMappings = state.pipelineForm.mappings.filter( - (mapping) => mapping.datastreamId - ); - const datastreamIds = new Set( - state.datastreams.map((datastream) => datastream.id) - ); - const seenTargets = new Set(); - - if (!connected()) { - errors.push("Connect to HydroServer before saving a pipeline."); - } - - if (!state.pipelineForm.name.trim()) { - errors.push("Give the pipeline a name."); - } - - if (!state.pipelineForm.filePath.trim()) { - errors.push(`Choose the CSV file ${APP_NAME} should watch.`); - } - - if (!state.pipelinePreview) { - errors.push("Load a CSV preview before saving the pipeline."); - } - - if (state.pipelineForm.hasHeaderRow && state.pipelineForm.headerRow < 1) { - errors.push("Header row must be 1 or greater."); - } - - if ( - state.pipelineForm.hasHeaderRow && - state.pipelineForm.dataStartRow <= state.pipelineForm.headerRow - ) { - errors.push("Data start row must come after the header row."); - } - - if (!state.pipelineForm.hasHeaderRow && state.pipelineForm.dataStartRow < 1) { - errors.push("Data start row must be 1 or greater."); - } - - if ( - headers.length > 0 && - !headers.includes(state.pipelineForm.timestampColumn) - ) { - errors.push( - "Choose a timestamp column that exists in the previewed CSV header." - ); - } - - if (selectedMappings.length === 0) { - errors.push("Map at least one source column to a HydroServer datastream."); - } - - for (const mapping of selectedMappings) { - if (!datastreamIds.has(mapping.datastreamId)) { - errors.push( - `The selected target for ${mapping.csvColumn} is not a valid HydroServer datastream.` - ); - } - - if (seenTargets.has(mapping.datastreamId)) { - errors.push( - "Each target datastream can only be mapped once in this first-run flow." - ); - } - - seenTargets.add(mapping.datastreamId); - } - - return errors; -} - -async function loadPipelinePreview( - path: string, - rows = PREVIEW_PAGE_SIZE -): Promise { - if (!path.trim()) { - state.pipelineFeedback = { - tone: "error", - message: "Enter or choose a CSV file path first.", - }; - render(); - return; - } - - try { - const preview = await getCsvPreview(path.trim(), rows); - applyPreview(path.trim(), preview); - state.pipelinePreviewRowsRequested = rows; - state.pipelineErrors = []; - state.pipelineFeedback = null; - } catch (error) { - state.pipelinePreview = null; - state.pipelineSelectionTarget = null; - state.pipelineDrag = null; - state.pipelineColumnDrag = null; - state.pipelinePreviewRowsRequested = PREVIEW_PAGE_SIZE; - state.pipelineFeedback = { - tone: "error", - message: - error instanceof Error - ? error.message - : "Couldn't preview that CSV file.", - }; - } - - render(); -} - -async function browseForCsvPath(): Promise { - try { - const dialog = await import("@tauri-apps/plugin-dialog"); - const selection = await dialog.open({ - directory: false, - multiple: false, - filters: [{ name: "CSV files", extensions: ["csv", "txt"] }], - }); - - if (typeof selection !== "string" || !selection) { - return; - } - - state.pipelineForm.filePath = selection; - if (!state.pipelineForm.name.trim()) { - state.pipelineForm.name = basename(selection).replace(/\.[^.]+$/, ""); - } - - await loadPipelinePreview(selection); - } catch { - state.pipelineFeedback = { - tone: "info", - message: - "The native file picker is only available in the desktop app. Enter the CSV path manually if you're using the browser preview.", - }; - render(); - } -} - -async function saveAuthenticatedServerConfig( - form: HTMLFormElement -): Promise { - if (state.authSubmitting) { - return; - } - - const payload = readServerConfigForm(form); - setServerDraft(payload); - - const feedbackKey = fieldFormFeedbackTarget(form.id); - - state[feedbackKey] = null; - resetStateAuthFieldStates(payload.auth_type); - - if (!validateAuthFieldsForSubmit(payload, markField)) { - render(); - return; - } - - try { - await runAuthSubmission({ - render, - setSubmitting: (value) => { - state.authSubmitting = value; - }, - action: async () => { - const urlValidation = await validateServerUrl(payload.url); - if (!urlValidation.ok) { - clearAuthValidationCache(); - markField("url", "invalid", urlValidation.message); - state[feedbackKey] = { - tone: "error", - message: urlValidation.message, - }; - return; - } - - markField("url", "valid"); - - const result = await syncAuthenticationStatus(payload); - applyConnectionValidationResult(payload, result, markField); - if (!result.ok) { - state[feedbackKey] = { tone: "error", message: result.message }; - return; - } - - state.config = await updateServerConfig(payload); - state.authDraft = { - ...emptyServerConfig(), - ...state.config.server, - }; - await syncAuthenticationStatus(state.config.server); - await loadDatastreams(); - state[feedbackKey] = { tone: "success", message: result.message }; - state.settingsEditMode = false; - - if (state.jobs.length === 0) { - navigate("jobs-new"); - } else { - navigate("dashboard"); - } - }, - }); - } catch (error) { - clearAuthValidationCache(); - state[feedbackKey] = { - tone: "error", - message: - error instanceof Error - ? error.message - : "Couldn't verify the HydroServer connection.", - }; - state.lastConnectionState = "error"; - render(); - } -} - -async function disconnectHydroServer(): Promise { - try { - state.config = await clearServerConfig(); - state.authDraft = emptyServerConfig(); - state.connectionSummary = null; - state.lastConnectionState = "not_configured"; - state.datastreams = []; - state.datastreamsError = null; - state.welcomeFeedback = null; - state.settingsFeedback = null; - state.settingsEditMode = false; - resetStateAuthFieldStates("apikey"); - clearAuthValidationCache(); - navigate("welcome"); - } catch (error) { - state.settingsFeedback = { - tone: "error", - message: - error instanceof Error - ? error.message - : "Couldn't disconnect from HydroServer right now.", - }; - } - - render(); -} - -async function savePipeline(): Promise { - state.pipelineErrors = validatePipeline(); - - if (state.pipelineErrors.length > 0) { - state.pipelineFeedback = { - tone: "error", - message: `${APP_NAME} needs a little more information before it can save this pipeline.`, - }; - render(); - return; - } - - const mappedColumns = state.pipelineForm.mappings - .filter((mapping) => mapping.datastreamId) - .map((mapping) => { - const datastream = state.datastreams.find( - (item) => item.id === mapping.datastreamId - ); - return { - csv_column: mapping.csvColumn, - datastream_id: mapping.datastreamId, - datastream_name: datastream?.name ?? mapping.datastreamId, - }; - }); - - try { - const created = await createJob({ - name: state.pipelineForm.name.trim(), - enabled: true, - file_path: state.pipelineForm.filePath.trim(), - schedule_minutes: state.pipelineForm.scheduleMinutes, - file_config: { - header_row: state.pipelineForm.hasHeaderRow - ? state.pipelineForm.headerRow - : 0, - data_start_row: state.pipelineForm.dataStartRow, - delimiter: state.pipelineForm.delimiter, - timestamp_column: state.pipelineForm.timestampColumn, - timestamp_format: state.pipelineForm.timestampFormat, - timezone: state.pipelineForm.timezone, - }, - column_mappings: mappedColumns, - }); - - state.jobs = [...state.jobs, created]; - state.pipelineForm = createEmptyPipelineForm(); - state.pipelinePreview = null; - state.pipelineSelectionTarget = null; - state.pipelineDrag = null; - state.pipelineColumnDrag = null; - state.pipelinePreviewRowsRequested = PREVIEW_PAGE_SIZE; - state.pipelineErrors = []; - state.pipelineFeedback = { tone: "success", message: "Pipeline saved." }; - navigate("dashboard"); - } catch (error) { - state.pipelineFeedback = { - tone: "error", - message: - error instanceof Error ? error.message : "Couldn't save that pipeline.", - }; - } - - render(); -} - -window.addEventListener("hashchange", () => { - state.settingsFeedback = null; - render(); -}); - -mainContent.addEventListener("submit", (event) => { - const target = event.target; - if (!(target instanceof HTMLFormElement)) { - return; - } - - event.preventDefault(); - - if (target.id === "welcome-form") { - void saveAuthenticatedServerConfig(target); - return; - } - - if (target.id === "settings-form") { - void saveAuthenticatedServerConfig(target); - return; - } - - if (target.id === "pipeline-form") { - if (!state.pipelinePreview) { - void loadPipelinePreview(state.pipelineForm.filePath); - return; - } - - void savePipeline(); - } -}); - -mainContent.addEventListener("input", (event) => { - const target = event.target; - - if ( - !( - target instanceof HTMLInputElement || - target instanceof HTMLSelectElement || - target instanceof HTMLTextAreaElement - ) - ) { - return; - } - - if ( - target.form?.id === "welcome-form" || - target.form?.id === "settings-form" - ) { - const form = target.form; - setServerDraft(readServerConfigForm(form)); - clearAuthFormFeedback(form.id); - clearAuthValidationCache(); - - if ( - target instanceof HTMLInputElement && - (target.name === "url" || - target.name === "api_key" || - target.name === "username" || - target.name === "password") - ) { - markField(target.name, "idle"); - } - return; - } - - if (target.form?.id !== "pipeline-form") { - return; - } - - state.pipelineFeedback = null; - state.pipelineErrors = []; - - const mappingColumn = target.dataset.mappingColumn; - if (mappingColumn) { - const mapping = state.pipelineForm.mappings.find( - (item) => item.csvColumn === mappingColumn - ); - if (mapping) { - mapping.datastreamId = target.value; - } - render(); - return; - } - - updatePipelineField(target.name, target.value); - - if ( - target.name === "header_row" || - target.name === "data_start_row" || - target.name === "delimiter" || - target.name === "timestamp_column" - ) { - render(); - } -}); - -mainContent.addEventListener("change", (event) => { - const target = event.target; - - if ( - !( - target instanceof HTMLInputElement || - target instanceof HTMLSelectElement || - target instanceof HTMLTextAreaElement - ) - ) { - return; - } - - if (target.name === "has_header_row" && target instanceof HTMLInputElement) { - setPipelineHasHeaderRow(target.checked); - render(); - return; - } - - if (target.form?.id !== "pipeline-form" || target.name !== "file_path") { - return; - } - - void loadPipelinePreview(target.value); -}); - -mainContent.addEventListener("pointerdown", (event) => { - const target = event.target; - if (!(target instanceof HTMLElement)) { - return; - } - - const handle = target.closest("[data-preview-handle-target]"); - if (handle) { - const pickerTarget = handle.dataset.previewHandleTarget; - if (pickerTarget !== "header-row" && pickerTarget !== "data-start-row") { - return; - } - - const lineNumber = Number(handle.dataset.previewLine); - if (!Number.isFinite(lineNumber) || lineNumber < 1) { - return; - } - - state.pipelineSelectionTarget = pickerTarget; - state.pipelineDrag = { - target: pickerTarget, - lineNumber, - pointerId: event.pointerId, - moved: false, - }; - suppressPreviewHandleClick = false; - if (typeof handle.setPointerCapture === "function") { - handle.setPointerCapture(event.pointerId); - } - beginPreviewDragVisual(event.clientY); - event.preventDefault(); - return; - } - - const columnHandle = target.closest("[data-preview-column-handle]"); - if (!columnHandle) { - return; - } - - const columnName = columnHandle.dataset.previewColumnHandle ?? ""; - if (!columnName) { - return; - } - - state.pipelineSelectionTarget = "timestamp-column"; - state.pipelineColumnDrag = { - columnName, - pointerId: event.pointerId, - moved: false, - }; - if (typeof columnHandle.setPointerCapture === "function") { - columnHandle.setPointerCapture(event.pointerId); - } - beginPreviewColumnDragVisual(event.clientX); - event.preventDefault(); -}); - -window.addEventListener("pointermove", (event) => { - if (state.pipelineDrag?.pointerId === event.pointerId) { - if (!previewDragVisual) { - return; - } - - previewDragVisual.currentClientY = event.clientY; - const lineNumber = nearestPreviewLineNumber( - event.clientY, - previewDragVisual.rowCenters - ); - if (!lineNumber) { - schedulePreviewDragVisual(); - return; - } - - if (lineNumber === state.pipelineDrag.lineNumber) { - schedulePreviewDragVisual(); - return; - } - - state.pipelineDrag = { - ...state.pipelineDrag, - lineNumber, - moved: true, - }; - schedulePreviewDragVisual(); - return; - } - - if ( - !state.pipelineColumnDrag || - state.pipelineColumnDrag.pointerId !== event.pointerId - ) { - return; - } - - if (!previewColumnDragVisual) { - return; - } - - previewColumnDragVisual.currentClientX = event.clientX; - const columnName = nearestPreviewColumnName( - event.clientX, - previewColumnDragVisual.headerCenters - ); - if (!columnName) { - schedulePreviewColumnDragVisual(); - return; - } - - if (columnName === state.pipelineColumnDrag.columnName) { - schedulePreviewColumnDragVisual(); - return; - } - - state.pipelineColumnDrag = { - ...state.pipelineColumnDrag, - columnName, - moved: true, - }; - schedulePreviewColumnDragVisual(); -}); - -window.addEventListener("pointerup", (event) => { - if (state.pipelineDrag?.pointerId === event.pointerId) { - const drag = state.pipelineDrag; - endPreviewDragVisual(); - state.pipelineDrag = null; - - if (drag.moved) { - setPreviewRowSelectionTarget(drag.target, drag.lineNumber); - state.pipelineSelectionTarget = null; - suppressPreviewHandleClick = true; - } else { - state.pipelineSelectionTarget = drag.target; - suppressPreviewHandleClick = false; - } - - render(); - return; - } - - if ( - !state.pipelineColumnDrag || - state.pipelineColumnDrag.pointerId !== event.pointerId - ) { - return; - } - - const drag = state.pipelineColumnDrag; - endPreviewColumnDragVisual(); - state.pipelineColumnDrag = null; - - if (drag.moved) { - state.pipelineForm.timestampColumn = drag.columnName; - initializeMappings(previewHeaders()); - state.pipelineSelectionTarget = null; - } else { - state.pipelineSelectionTarget = "timestamp-column"; - } - - render(); -}); - -window.addEventListener("pointercancel", (event) => { - if (state.pipelineDrag?.pointerId === event.pointerId) { - endPreviewDragVisual(); - state.pipelineDrag = null; - suppressPreviewHandleClick = false; - render(); - return; - } - - if ( - !state.pipelineColumnDrag || - state.pipelineColumnDrag.pointerId !== event.pointerId - ) { - return; - } - - endPreviewColumnDragVisual(); - state.pipelineColumnDrag = null; - state.pipelineSelectionTarget = null; - render(); -}); - -mainContent.addEventListener("click", (event) => { - const target = event.target; - if (!(target instanceof HTMLElement)) { - return; - } - - const action = target.closest("[data-action]")?.dataset.action; - const jobId = target.closest("[data-job-id]")?.dataset.jobId; - - if (!action) { - return; - } - - if (action === "retry-bootstrap") { - void bootstrap(); - return; - } - - if (action === "toggle-auth-mode") { - const form = target.closest("form"); - if (!form) { - return; - } - - const nextServer = readServerConfigForm(form); - const nextAuthType: AuthType = - nextServer.auth_type === "apikey" ? "userpass" : "apikey"; - setServerDraft({ - ...nextServer, - auth_type: nextAuthType, - }); - resetStateAuthFieldStates(nextAuthType); - - clearAuthFormFeedback(form.id); - clearAuthValidationCache(); - - render(); - return; - } - - if (action === "disconnect") { - void disconnectHydroServer(); - return; - } - - if (action === "change-credentials") { - state.authDraft = { - ...emptyServerConfig(), - ...(state.config?.server ?? {}), - }; - state.settingsEditMode = true; - navigate("settings"); - render(); - return; - } - - if (action === "cancel-credential-edit") { - state.authDraft = { - ...emptyServerConfig(), - ...(state.config?.server ?? {}), - }; - state.settingsEditMode = false; - render(); - return; - } - - if (action === "browse-csv") { - void browseForCsvPath(); - return; - } - - if (action === "show-more-preview-lines") { - if (!state.pipelinePreview) { - return; - } - - const nextRows = Math.min( - state.pipelinePreviewRowsRequested + PREVIEW_PAGE_SIZE, - state.pipelinePreview.total_lines - ); - void loadPipelinePreview(state.pipelineForm.filePath, nextRows); - return; - } - - if (action === "activate-preview-handle") { - if (suppressPreviewHandleClick) { - suppressPreviewHandleClick = false; - return; - } - - const pickerTarget = target.closest("[data-preview-handle-target]") - ?.dataset.previewHandleTarget; - if (pickerTarget !== "header-row" && pickerTarget !== "data-start-row") { - return; - } - - state.pipelineSelectionTarget = pickerTarget; - render(); - return; - } - - if (action === "toggle-preview-picker") { - if (!state.pipelinePreview) { - state.pipelineFeedback = { - tone: "info", - message: "Load a CSV preview first.", - }; - render(); - return; - } - - const pickerTarget = target.closest("[data-picker-target]") - ?.dataset.pickerTarget; - if ( - pickerTarget !== "header-row" && - pickerTarget !== "data-start-row" && - pickerTarget !== "timestamp-column" - ) { - return; - } - - state.pipelineSelectionTarget = - state.pipelineSelectionTarget === pickerTarget ? null : pickerTarget; - render(); - return; - } - - if (action === "pick-preview-line") { - const lineNumber = Number( - target.closest("[data-preview-line]")?.dataset.previewLine - ); - - if (Number.isFinite(lineNumber)) { - applyPreviewLineSelection(lineNumber); - } - return; - } - - if (action === "pick-preview-column") { - const columnName = - target.closest("[data-preview-column]")?.dataset - .previewColumn ?? ""; - - if (columnName) { - applyPreviewColumnSelection(columnName); - } - return; - } - - if (!jobId) { - return; - } - - if (action === "run-job") { - void handleRunJob(jobId); - return; - } - - if (action === "toggle-job") { - void handleToggleJob(jobId); - return; - } - - if (action === "delete-job") { - void handleDeleteJob(jobId); - } -}); - -async function handleRunJob(jobId: string): Promise { - try { - await runJob(jobId); - await refreshJobs(); - } catch { - // Keep dashboard state unchanged on action failure. - } -} - -async function handleToggleJob(jobId: string): Promise { - const job = state.jobs.find((item) => item.id === jobId); - if (!job) { - return; - } - - try { - if (job.enabled) { - await disableJob(jobId); - } else { - await enableJob(jobId); - } - - await refreshJobs(); - } catch { - // Keep dashboard state unchanged on action failure. - } -} - -async function handleDeleteJob(jobId: string): Promise { - const confirmed = window.confirm("Delete this pipeline?"); - if (!confirmed) { - return; - } - - try { - await deleteJob(jobId); - await refreshJobs(); - } catch { - // Keep dashboard state unchanged on action failure. - } -} - -void bootstrap(); +createApp(App).mount("#app") diff --git a/frontend/views/DashboardView.vue b/frontend/views/DashboardView.vue new file mode 100644 index 0000000..740fc59 --- /dev/null +++ b/frontend/views/DashboardView.vue @@ -0,0 +1,107 @@ + + + diff --git a/frontend/views/FatalErrorView.vue b/frontend/views/FatalErrorView.vue new file mode 100644 index 0000000..89365e7 --- /dev/null +++ b/frontend/views/FatalErrorView.vue @@ -0,0 +1,20 @@ + + + diff --git a/frontend/views/LoadingView.vue b/frontend/views/LoadingView.vue new file mode 100644 index 0000000..71a6a09 --- /dev/null +++ b/frontend/views/LoadingView.vue @@ -0,0 +1,5 @@ + diff --git a/frontend/views/PipelineEditorView.vue b/frontend/views/PipelineEditorView.vue new file mode 100644 index 0000000..da1ca98 --- /dev/null +++ b/frontend/views/PipelineEditorView.vue @@ -0,0 +1,301 @@ + + + diff --git a/frontend/views/SettingsView.vue b/frontend/views/SettingsView.vue new file mode 100644 index 0000000..076f378 --- /dev/null +++ b/frontend/views/SettingsView.vue @@ -0,0 +1,51 @@ + + + diff --git a/frontend/views/WelcomeView.vue b/frontend/views/WelcomeView.vue new file mode 100644 index 0000000..880fd0d --- /dev/null +++ b/frontend/views/WelcomeView.vue @@ -0,0 +1,15 @@ + + + diff --git a/frontend/vite-env.d.ts b/frontend/vite-env.d.ts index 17aa348..fcd5093 100644 --- a/frontend/vite-env.d.ts +++ b/frontend/vite-env.d.ts @@ -7,3 +7,10 @@ interface ImportMetaEnv { interface ImportMeta { readonly env: ImportMetaEnv } + +declare module "*.vue" { + import type { DefineComponent } from "vue" + + const component: DefineComponent, Record, unknown> + export default component +} diff --git a/index.html b/index.html index cf79088..7bb52c2 100644 --- a/index.html +++ b/index.html @@ -6,60 +6,7 @@ HydroServer Streaming Data Loader -
- - -
-
+
diff --git a/package-lock.json b/package-lock.json index 7719e9f..b0311e2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,18 +8,66 @@ "name": "streaming-data-loader", "version": "0.1.0", "dependencies": { - "@tauri-apps/plugin-dialog": "^2.0.0" + "@tauri-apps/plugin-dialog": "^2.0.0", + "vue": "^3.5.32" }, "devDependencies": { "@tailwindcss/cli": "^4.1.4", "@tauri-apps/cli": "^2.10.1", "@types/node": "^22.13.10", + "@vitejs/plugin-vue": "^6.0.1", "tailwindcss": "^4.1.4", "tsx": "^4.21.0", "typescript": "^5.6.3", "vite": "^6.0.3" } }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -498,7 +546,6 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { @@ -821,6 +868,13 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz", + "integrity": "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==", + "dev": true, + "license": "MIT" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.60.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", @@ -1700,6 +1754,129 @@ "undici-types": "~6.21.0" } }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.5.tgz", + "integrity": "sha512-bL3AxKuQySfk1iGcBsQnoRVexTPJq0Z/ixFVM8OhVJAP6ZXXXLtM7NFKWhLl30Kg7uTBqIaPXbh+nuQCuBDedg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.2" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.32.tgz", + "integrity": "sha512-4x74Tbtqnda8s/NSD6e1Dr5p1c8HdMU5RWSjMSUzb8RTcUQqevDCxVAitcLBKT+ie3o0Dl9crc/S/opJM7qBGQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/shared": "3.5.32", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.32.tgz", + "integrity": "sha512-ybHAu70NtiEI1fvAUz3oXZqkUYEe5J98GjMDpTGl5iHb0T15wQYLR4wE3h9xfuTNA+Cm2f4czfe8B4s+CCH57Q==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.32.tgz", + "integrity": "sha512-8UYUYo71cP/0YHMO814TRZlPuUUw3oifHuMR7Wp9SNoRSrxRQnhMLNlCeaODNn6kNTJsjFoQ/kqIj4qGvya4Xg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/compiler-core": "3.5.32", + "@vue/compiler-dom": "3.5.32", + "@vue/compiler-ssr": "3.5.32", + "@vue/shared": "3.5.32", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.8", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.32.tgz", + "integrity": "sha512-Gp4gTs22T3DgRotZ8aA/6m2jMR+GMztvBXUBEUOYOcST+giyGWJ4WvFd7QLHBkzTxkfOt8IELKNdpzITLbA2rw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.32.tgz", + "integrity": "sha512-/ORasxSGvZ6MN5gc+uE364SxFdJ0+WqVG0CENXaGW58TOCdrAW76WWaplDtECeS1qphvtBZtR+3/o1g1zL4xPQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.32.tgz", + "integrity": "sha512-pDrXCejn4UpFDFmMd27AcJEbHaLemaE5o4pbb7sLk79SRIhc6/t34BQA7SGNgYtbMnvbF/HHOftYBgFJtUoJUQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.32.tgz", + "integrity": "sha512-1CDVv7tv/IV13V8Nip1k/aaObVbWqRlVCVezTwx3K07p7Vxossp5JU1dcPNhJk3w347gonIUT9jQOGutyJrSVQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.32", + "@vue/runtime-core": "3.5.32", + "@vue/shared": "3.5.32", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.32.tgz", + "integrity": "sha512-IOjm2+JQwRFS7W28HNuJeXQle9KdZbODFY7hFGVtnnghF51ta20EWAZJHX+zLGtsHhaU6uC9BGPV52KVpYryMQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.32", + "@vue/shared": "3.5.32" + }, + "peerDependencies": { + "vue": "3.5.32" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.32.tgz", + "integrity": "sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1724,6 +1901,18 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -1766,6 +1955,12 @@ "@esbuild/win32-x64": "0.25.12" } }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2117,7 +2312,6 @@ "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" @@ -2137,7 +2331,6 @@ "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, "funding": [ { "type": "github", @@ -2163,7 +2356,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { @@ -2183,7 +2375,6 @@ "version": "8.5.8", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", - "dev": true, "funding": [ { "type": "opencollective", @@ -2267,7 +2458,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -2819,7 +3009,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -2910,6 +3100,27 @@ "optional": true } } + }, + "node_modules/vue": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.32.tgz", + "integrity": "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.32", + "@vue/compiler-sfc": "3.5.32", + "@vue/runtime-dom": "3.5.32", + "@vue/server-renderer": "3.5.32", + "@vue/shared": "3.5.32" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index 5a487bb..094ad39 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,8 @@ "version": "0.1.0", "type": "module", "dependencies": { - "@tauri-apps/plugin-dialog": "^2.0.0" + "@tauri-apps/plugin-dialog": "^2.0.0", + "vue": "^3.5.32" }, "scripts": { "bootstrap:frontend": "node ./scripts/bootstrap-frontend.mjs", @@ -21,6 +22,7 @@ "tauri": "node ./scripts/run-tauri.mjs" }, "devDependencies": { + "@vitejs/plugin-vue": "^6.0.1", "@tailwindcss/cli": "^4.1.4", "@tauri-apps/cli": "^2.10.1", "@types/node": "^22.13.10", diff --git a/vite.config.ts b/vite.config.ts index a5e4bc0..c503567 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,3 +1,4 @@ +import vue from "@vitejs/plugin-vue" import { defineConfig, loadEnv } from "vite" export default defineConfig(({ mode }) => { @@ -10,6 +11,7 @@ export default defineConfig(({ mode }) => { return { clearScreen: false, + plugins: [vue()], server: { host: frontendHost, open: true, From d8556a0d72a9bb0164f7a45fc518c21895fe23cb Mon Sep 17 00:00:00 2001 From: Daniel Slaugh Date: Tue, 7 Apr 2026 14:09:01 -0600 Subject: [PATCH 025/166] Split useAppModel --- frontend/components/CsvPreview.vue | 288 +++--- frontend/composables/state.ts | 135 +++ frontend/composables/useAppModel.ts | 1186 +++---------------------- frontend/composables/useAuth.ts | 205 +++++ frontend/composables/useJobs.ts | 44 + frontend/composables/usePipeline.ts | 491 ++++++++++ frontend/views/PipelineEditorView.vue | 235 +++-- 7 files changed, 1272 insertions(+), 1312 deletions(-) create mode 100644 frontend/composables/state.ts create mode 100644 frontend/composables/useAuth.ts create mode 100644 frontend/composables/useJobs.ts create mode 100644 frontend/composables/usePipeline.ts diff --git a/frontend/components/CsvPreview.vue b/frontend/components/CsvPreview.vue index 28f0fef..e73d4ce 100644 --- a/frontend/components/CsvPreview.vue +++ b/frontend/components/CsvPreview.vue @@ -1,5 +1,5 @@
- +
model.state.jobs.length === 0) class="input" type="number" :min="model.state.pipelineForm.hasHeaderRow ? 2 : 1" - @input="model.updatePipelineField('data_start_row', ($event.target as HTMLInputElement).value)" + @input=" + model.updatePipelineField( + 'data_start_row', + ($event.target as HTMLInputElement).value + ) + " /> - Drag the green DATA START handle in the preview or enter a row number. + Drag the green DATA START handle in the preview or enter a row + number.
@@ -218,7 +284,12 @@ const isFirstRunOnboarding = computed(() => model.state.jobs.length === 0) class="input" type="text" maxlength="2" - @input="model.updatePipelineField('delimiter', ($event.target as HTMLInputElement).value)" + @input=" + model.updatePipelineField( + 'delimiter', + ($event.target as HTMLInputElement).value + ) + " /> @@ -228,41 +299,59 @@ const isFirstRunOnboarding = computed(() => model.state.jobs.length === 0) :value="model.state.pipelineForm.timezone" class="input" type="text" - @input="model.updatePipelineField('timezone', ($event.target as HTMLInputElement).value)" + @input=" + model.updatePipelineField( + 'timezone', + ($event.target as HTMLInputElement).value + ) + " />
- +
- - + {{ header }} + + + - Drag the amber TIMESTAMP handle in the preview, or click the matching header. + Drag the amber TIMESTAMP handle in the preview, or click the + matching header.
@@ -273,17 +362,27 @@ const isFirstRunOnboarding = computed(() => model.state.jobs.length === 0) class="input" type="text" placeholder="%Y-%m-%d %H:%M:%S" - @input="model.updatePipelineField('timestamp_format', ($event.target as HTMLInputElement).value)" + @input=" + model.updatePipelineField( + 'timestamp_format', + ($event.target as HTMLInputElement).value + ) + " />
-
+

Fix these issues before saving

    -
  • {{ error }}
  • +
  • + {{ error }} +
From da470853f54310f0a01f7481337fb29a26f26574 Mon Sep 17 00:00:00 2001 From: Daniel Slaugh Date: Tue, 7 Apr 2026 14:20:07 -0600 Subject: [PATCH 026/166] Simplify dynamic titles --- frontend/components/CsvPreview.vue | 39 +++++++++------- frontend/composables/useAppModel.ts | 12 ----- frontend/composables/usePipeline.ts | 67 --------------------------- frontend/styles.css | 56 ---------------------- frontend/views/PipelineEditorView.vue | 18 +++---- 5 files changed, 30 insertions(+), 162 deletions(-) diff --git a/frontend/components/CsvPreview.vue b/frontend/components/CsvPreview.vue index e73d4ce..fa20fdd 100644 --- a/frontend/components/CsvPreview.vue +++ b/frontend/components/CsvPreview.vue @@ -49,15 +49,17 @@ const nextPageSize = computed(() => { const displayHeaderLine = computed(() => rowDrag.value?.target === "header-row" ? rowDrag.value.lineNumber - : model.previewHandleLine("header-row") + : model.state.pipelineForm.hasHeaderRow + ? model.state.pipelineForm.headerRow + : null ) const displayDataStartLine = computed(() => rowDrag.value?.target === "data-start-row" ? rowDrag.value.lineNumber - : model.previewHandleLine("data-start-row") + : model.state.pipelineForm.dataStartRow ) const displayTimestampColumn = computed( - () => columnDrag.value?.columnName ?? model.activeTimestampColumn() + () => columnDrag.value?.columnName ?? model.state.pipelineForm.timestampColumn ) const previewFileName = computed( () => model.state.pipelineForm.filePath.split(/[\\/]/).filter(Boolean).at(-1) ?? "" @@ -163,31 +165,33 @@ function lineButtonClass(lineNumber: number): string { } function rowHandleClass(target: PreviewRowSelectionTarget): string { - const active = - model.state.pipelineSelectionTarget === target || rowDrag.value?.target === target const dragging = rowDrag.value?.target === target const base = target === "header-row" ? "preview-row-handle preview-row-handle-header" : "preview-row-handle preview-row-handle-data" - return [base, active && "preview-row-handle-active", dragging && "preview-row-handle-dragging"] + return [base, dragging && "preview-row-handle-dragging"] .filter(Boolean).join(" ") } function timestampHandleClass(columnName: string): string { - const active = - displayTimestampColumn.value === columnName && - (model.state.pipelineSelectionTarget === "timestamp-column" || columnDrag.value !== null) const dragging = columnDrag.value?.originColumnName === columnName return [ "preview-column-handle", - active && "preview-column-handle-active", dragging && "preview-column-handle-dragging", ].filter(Boolean).join(" ") } function cellClass(columnName: string): string { - return ["preview-cell", model.previewColumnClass(columnName)].filter(Boolean).join(" ") + const isTimestamp = columnName === model.state.pipelineForm.timestampColumn + const isMapped = model.state.pipelineForm.mappings.some( + (mapping) => mapping.csvColumn === columnName && mapping.datastreamId + ) + return [ + "preview-cell", + isTimestamp && "preview-col-timestamp", + !isTimestamp && isMapped && "preview-col-mapped", + ].filter(Boolean).join(" ") } // ── Pointer event handlers ───────────────────────────────────────────────── @@ -306,7 +310,10 @@ onBeforeUnmount(() => {

Preview

{{ previewFileName }}

-

{{ model.previewGuidanceText() }}

+

+ Use this preview to set the header row, the first data row, and the + timestamp column. +

@@ -329,7 +336,7 @@ onBeforeUnmount(() => { v-for="header in headers" :key="header" class="preview-cell" - :class="model.previewColumnClass(header)" + :class="cellClass(header)" >
-
-
HydroServer
-

Streaming Data Loader

-

- Tauri shell with a minimal vanilla TypeScript frontend. -

+const shellElements = { + sidebar: document.querySelector("#app-sidebar"), + mainContent: document.querySelector("#main-content"), + jobsLink: document.querySelector('[data-route="dashboard"]'), + settingsLink: document.querySelector('[data-route="settings"]'), + connectionDot: document.querySelector("#connection-status-dot"), +} -
- - Checking sidecar status... -
+if (!shellElements.sidebar || !shellElements.mainContent || !shellElements.jobsLink || !shellElements.settingsLink || !shellElements.connectionDot) { + throw new Error("App shell is missing required elements.") +} -
- -
+const { sidebar, mainContent, jobsLink, settingsLink, connectionDot } = shellElements + +const state: UiState = { + route: getRouteFromHash(), + health: null, + config: null, + jobs: [], + loading: true, + bootstrapError: null, + settingsFeedback: null, + welcomeFeedback: null, + welcomeStep: 1, + lastConnectionState: null, +} + +const STARTUP_RETRY_ATTEMPTS = 12 +const STARTUP_RETRY_DELAY_MS = 350 + +window.setInterval(() => { + void refreshJobs() + render() +}, 30_000) + +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'") +} + +function feedbackMarkup(feedback: Feedback): string { + if (!feedback) { + return "" + } + + const toneClass = + feedback.tone === "success" + ? "notice-success" + : feedback.tone === "error" + ? "notice-error" + : "notice-info" + + return `
${escapeHtml(feedback.message)}
` +} + +function sidebarConnectionState(): { label: string; className: string } { + if (!state.config?.server.url || !state.config.server.api_key) { + return { label: "HydroServer not configured", className: "status-dot bg-slate-300" } + } + + switch (state.lastConnectionState) { + case "connected": + return { label: "Connected to HydroServer", className: "status-dot bg-emerald-500" } + case "error": + return { label: "HydroServer connection error", className: "status-dot bg-rose-500" } + default: + return { label: "HydroServer configured", className: "status-dot bg-sky-500" } + } +} + +function statusPill(job: JobSummary): string { + const classes: Record = { + healthy: "pill-success", + warning: "pill-warning", + error: "pill-danger", + disabled: "pill-muted", + pending: "pill-info", + running: "pill-info", + } + + return `${escapeHtml(job.status_message)}` +} -
+function renderDashboard(): string { + if (state.jobs.length === 0) { + return ` +
+ + +
+
CSV
+

No data sources yet

+

Connect to HydroServer first, then add a watched CSV file and map its columns to datastreams.

+ Add your first job +
+
+ ` + } + + const cards = state.jobs + .map(job => { + const lastLine = job.last_error + ? `Failed ${formatRelativeTime(job.last_run_at)}` + : `Last pushed ${formatRelativeTime(job.last_pushed_timestamp)}` + + return ` +
+
+
+
+ +

${escapeHtml(job.name)}

+
+

${escapeHtml(shortenPath(job.file_path))}

+

+ ${escapeHtml(lastLine)} · ${escapeHtml(formatSchedule(job.schedule_minutes))} +

+
+ ${statusPill(job)} +
+ +
+ + + +
+
+ ` + }) + .join("") + + return ` +
+ +
${cards}
+
+ ` +} + +function renderSettings(): string { + const server = state.config?.server ?? { url: "", api_key: "" } + + return ` +
+ + +
+
+

HydroServer connection

+ + + + + +
+ + +
+ + ${feedbackMarkup(state.settingsFeedback)} +
+ +
+

Preferences

+

Launch-at-login and tray controls arrive in the desktop integration phase.

+
+ +
+

About

+

SDL version ${escapeHtml(state.health?.version ?? "0.1.0")}

+

HydroServer Streaming Data Loader

+
+
+
+ ` +} + +function renderWelcome(): string { + const server = state.config?.server ?? { url: "", api_key: "" } + + if (state.welcomeStep === 2) { + return ` +
+
+

Connected

+

HydroServer is ready

+

The next step in the implementation order is the job editor and CSV preview. Your HydroServer credentials are already saved locally.

+ +
+
+ ` + } + + return ` +
+
+

Welcome

+

Connect to your HydroServer instance

+

SDL now manages its own local job definitions, then authenticates with HydroServer only when it needs to discover datastreams or push new observations.

+ + + + + +
+ + Open settings +
+ + ${feedbackMarkup(state.welcomeFeedback)} +
+
+ ` +} + +function renderJobsPlaceholder(): string { + return ` +
+
+ +
+
1
+

Foundation is in place

+

Use the Settings page to connect to HydroServer, then the next pass will add the actual file picker, preview surface, and mapping UI.

+ Back to dashboard +
-