Merge pull request #114 from LukeGus/dev-1.3.1
Dev 1.3.1
This commit was merged in pull request #114.
This commit is contained in:
8
.github/workflows/docker-image.yml
vendored
8
.github/workflows/docker-image.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ jobs:
|
|||||||
network=host
|
network=host
|
||||||
|
|
||||||
- name: Cache npm dependencies
|
- name: Cache npm dependencies
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.npm
|
~/.npm
|
||||||
@@ -48,7 +48,7 @@ jobs:
|
|||||||
${{ runner.os }}-node-
|
${{ runner.os }}-node-
|
||||||
|
|
||||||
- name: Cache Docker layers
|
- name: Cache Docker layers
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: /tmp/.buildx-cache
|
path: /tmp/.buildx-cache
|
||||||
key: ${{ runner.os }}-buildx-${{ github.ref_name }}-${{ hashFiles('docker/Dockerfile') }}
|
key: ${{ runner.os }}-buildx-${{ github.ref_name }}-${{ hashFiles('docker/Dockerfile') }}
|
||||||
@@ -78,7 +78,7 @@ jobs:
|
|||||||
echo "IMAGE_TAG=$IMAGE_TAG" >> $GITHUB_ENV
|
echo "IMAGE_TAG=$IMAGE_TAG" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Build and Push Multi-Arch Docker Image
|
- name: Build and Push Multi-Arch Docker Image
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./docker/Dockerfile
|
file: ./docker/Dockerfile
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Stage 1: Install dependencies and build frontend
|
# Stage 1: Install dependencies and build frontend
|
||||||
FROM node:22-alpine AS deps
|
FROM node:24-alpine AS deps
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apk add --no-cache python3 make g++
|
RUN apk add --no-cache python3 make g++
|
||||||
@@ -26,7 +26,7 @@ COPY . .
|
|||||||
RUN npm run build:backend
|
RUN npm run build:backend
|
||||||
|
|
||||||
# Stage 4: Production dependencies
|
# Stage 4: Production dependencies
|
||||||
FROM node:22-alpine AS production-deps
|
FROM node:24-alpine AS production-deps
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
@@ -35,7 +35,7 @@ RUN npm ci --only=production --ignore-scripts --force && \
|
|||||||
npm cache clean --force
|
npm cache clean --force
|
||||||
|
|
||||||
# Stage 5: Build native modules
|
# Stage 5: Build native modules
|
||||||
FROM node:22-alpine AS native-builder
|
FROM node:24-alpine AS native-builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apk add --no-cache python3 make g++
|
RUN apk add --no-cache python3 make g++
|
||||||
@@ -46,7 +46,7 @@ RUN npm ci --only=production bcryptjs better-sqlite3 --force && \
|
|||||||
npm cache clean --force
|
npm cache clean --force
|
||||||
|
|
||||||
# Stage 6: Final image
|
# Stage 6: Final image
|
||||||
FROM node:22-alpine
|
FROM node:24-alpine
|
||||||
ENV DATA_DIR=/app/data \
|
ENV DATA_DIR=/app/data \
|
||||||
PORT=8080 \
|
PORT=8080 \
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
|
|||||||
@@ -71,6 +71,14 @@ http {
|
|||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_cache_bypass $http_upgrade;
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
proxy_send_timeout 300s;
|
||||||
|
proxy_connect_timeout 75s;
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_request_buffering off;
|
||||||
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
@@ -85,7 +93,6 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
# File manager recent, pinned, shortcuts (handled by SSH service)
|
|
||||||
location /ssh/file_manager/recent {
|
location /ssh/file_manager/recent {
|
||||||
proxy_pass http://127.0.0.1:8081;
|
proxy_pass http://127.0.0.1:8081;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
@@ -113,7 +120,6 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
# SSH file manager operations (handled by file manager service)
|
|
||||||
location /ssh/file_manager/ssh/ {
|
location /ssh/file_manager/ssh/ {
|
||||||
proxy_pass http://127.0.0.1:8084;
|
proxy_pass http://127.0.0.1:8084;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|||||||
291
package-lock.json
generated
291
package-lock.json
generated
@@ -72,27 +72,27 @@
|
|||||||
"zod": "^4.0.5"
|
"zod": "^4.0.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.30.1",
|
"@eslint/js": "^9.34.0",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/cors": "^2.8.19",
|
"@types/cors": "^2.8.19",
|
||||||
"@types/express": "^5.0.3",
|
"@types/express": "^5.0.3",
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/node": "^24.0.13",
|
"@types/node": "^24.3.0",
|
||||||
"@types/react": "^19.1.8",
|
"@types/react": "^19.1.8",
|
||||||
"@types/react-dom": "^19.1.6",
|
"@types/react-dom": "^19.1.6",
|
||||||
"@types/ssh2": "^1.15.5",
|
"@types/ssh2": "^1.15.5",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"@vitejs/plugin-react-swc": "^3.10.2",
|
"@vitejs/plugin-react-swc": "^3.10.2",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
"eslint": "^9.30.1",
|
"eslint": "^9.34.0",
|
||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.20",
|
"eslint-plugin-react-refresh": "^0.4.20",
|
||||||
"globals": "^16.3.0",
|
"globals": "^16.3.0",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"tw-animate-css": "^1.3.5",
|
"tw-animate-css": "^1.3.5",
|
||||||
"typescript": "~5.8.3",
|
"typescript": "~5.9.2",
|
||||||
"typescript-eslint": "^8.35.1",
|
"typescript-eslint": "^8.40.0",
|
||||||
"vite": "^7.0.4"
|
"vite": "^7.1.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@ampproject/remapping": {
|
"node_modules/@ampproject/remapping": {
|
||||||
@@ -1023,9 +1023,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@eslint/config-helpers": {
|
"node_modules/@eslint/config-helpers": {
|
||||||
"version": "0.3.0",
|
"version": "0.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.1.tgz",
|
||||||
"integrity": "sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw==",
|
"integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -1033,9 +1033,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@eslint/core": {
|
"node_modules/@eslint/core": {
|
||||||
"version": "0.15.1",
|
"version": "0.15.2",
|
||||||
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz",
|
||||||
"integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==",
|
"integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -1083,9 +1083,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@eslint/js": {
|
"node_modules/@eslint/js": {
|
||||||
"version": "9.31.0",
|
"version": "9.34.0",
|
||||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.31.0.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.34.0.tgz",
|
||||||
"integrity": "sha512-LOm5OVt7D4qiKCqoiPbA7LWmI+tbw1VbTUowBcUMgQSuM6poJufkFkYDcQpo5KfgD39TnNySV26QjOh7VFpSyw==",
|
"integrity": "sha512-EoyvqQnBNsV1CWaEJ559rxXL4c8V92gxirbawSmVUOWXlsRxxQXl6LmCpdUblgxgSkDIqKnhzba2SjRTI/A5Rw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -1106,13 +1106,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@eslint/plugin-kit": {
|
"node_modules/@eslint/plugin-kit": {
|
||||||
"version": "0.3.3",
|
"version": "0.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz",
|
||||||
"integrity": "sha512-1+WqvgNMhmlAambTvT3KPtCl/Ibr68VldY2XY40SL1CE0ZXiakFR/cbTspaF5HsnpDMvcYYoJHfl4980NBjGag==",
|
"integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint/core": "^0.15.1",
|
"@eslint/core": "^0.15.2",
|
||||||
"levn": "^0.4.1"
|
"levn": "^0.4.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -3525,6 +3525,60 @@
|
|||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
|
||||||
|
"version": "1.4.3",
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/wasi-threads": "1.0.2",
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||||
|
"version": "1.4.3",
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
|
||||||
|
"version": "0.2.11",
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/core": "^1.4.3",
|
||||||
|
"@emnapi/runtime": "^1.4.3",
|
||||||
|
"@tybys/wasm-util": "^0.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
|
||||||
|
"version": "0.9.0",
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
|
||||||
|
"version": "2.8.0",
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "0BSD",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||||
"version": "4.1.11",
|
"version": "4.1.11",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.11.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.11.tgz",
|
||||||
@@ -3720,12 +3774,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "24.0.13",
|
"version": "24.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.13.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.0.tgz",
|
||||||
"integrity": "sha512-Qm9OYVOFHFYg3wJoTSrz80hoec5Lia/dPp84do3X7dZvLikQvM1YpmvTBEdIr/e+U8HTkFjLHLnl78K/qjf+jQ==",
|
"integrity": "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.8.0"
|
"undici-types": "~7.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/qs": {
|
"node_modules/@types/qs": {
|
||||||
@@ -3819,17 +3873,17 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.37.0",
|
"version": "8.40.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.37.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.40.0.tgz",
|
||||||
"integrity": "sha512-jsuVWeIkb6ggzB+wPCsR4e6loj+rM72ohW6IBn2C+5NCvfUVY8s33iFPySSVXqtm5Hu29Ne/9bnA0JmyLmgenA==",
|
"integrity": "sha512-w/EboPlBwnmOBtRbiOvzjD+wdiZdgFeo17lkltrtn7X37vagKKWJABvyfsJXTlHe6XBzugmYgd4A4nW+k8Mixw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/regexpp": "^4.10.0",
|
"@eslint-community/regexpp": "^4.10.0",
|
||||||
"@typescript-eslint/scope-manager": "8.37.0",
|
"@typescript-eslint/scope-manager": "8.40.0",
|
||||||
"@typescript-eslint/type-utils": "8.37.0",
|
"@typescript-eslint/type-utils": "8.40.0",
|
||||||
"@typescript-eslint/utils": "8.37.0",
|
"@typescript-eslint/utils": "8.40.0",
|
||||||
"@typescript-eslint/visitor-keys": "8.37.0",
|
"@typescript-eslint/visitor-keys": "8.40.0",
|
||||||
"graphemer": "^1.4.0",
|
"graphemer": "^1.4.0",
|
||||||
"ignore": "^7.0.0",
|
"ignore": "^7.0.0",
|
||||||
"natural-compare": "^1.4.0",
|
"natural-compare": "^1.4.0",
|
||||||
@@ -3843,9 +3897,9 @@
|
|||||||
"url": "https://opencollective.com/typescript-eslint"
|
"url": "https://opencollective.com/typescript-eslint"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@typescript-eslint/parser": "^8.37.0",
|
"@typescript-eslint/parser": "^8.40.0",
|
||||||
"eslint": "^8.57.0 || ^9.0.0",
|
"eslint": "^8.57.0 || ^9.0.0",
|
||||||
"typescript": ">=4.8.4 <5.9.0"
|
"typescript": ">=4.8.4 <6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
|
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
|
||||||
@@ -3859,16 +3913,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/parser": {
|
"node_modules/@typescript-eslint/parser": {
|
||||||
"version": "8.37.0",
|
"version": "8.40.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.37.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.40.0.tgz",
|
||||||
"integrity": "sha512-kVIaQE9vrN9RLCQMQ3iyRlVJpTiDUY6woHGb30JDkfJErqrQEmtdWH3gV0PBAfGZgQXoqzXOO0T3K6ioApbbAA==",
|
"integrity": "sha512-jCNyAuXx8dr5KJMkecGmZ8KI61KBUhkCob+SD+C+I5+Y1FWI2Y3QmY4/cxMCC5WAsZqoEtEETVhUiUMIGCf6Bw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.37.0",
|
"@typescript-eslint/scope-manager": "8.40.0",
|
||||||
"@typescript-eslint/types": "8.37.0",
|
"@typescript-eslint/types": "8.40.0",
|
||||||
"@typescript-eslint/typescript-estree": "8.37.0",
|
"@typescript-eslint/typescript-estree": "8.40.0",
|
||||||
"@typescript-eslint/visitor-keys": "8.37.0",
|
"@typescript-eslint/visitor-keys": "8.40.0",
|
||||||
"debug": "^4.3.4"
|
"debug": "^4.3.4"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -3880,18 +3934,18 @@
|
|||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"eslint": "^8.57.0 || ^9.0.0",
|
"eslint": "^8.57.0 || ^9.0.0",
|
||||||
"typescript": ">=4.8.4 <5.9.0"
|
"typescript": ">=4.8.4 <6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/project-service": {
|
"node_modules/@typescript-eslint/project-service": {
|
||||||
"version": "8.37.0",
|
"version": "8.40.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.37.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.40.0.tgz",
|
||||||
"integrity": "sha512-BIUXYsbkl5A1aJDdYJCBAo8rCEbAvdquQ8AnLb6z5Lp1u3x5PNgSSx9A/zqYc++Xnr/0DVpls8iQ2cJs/izTXA==",
|
"integrity": "sha512-/A89vz7Wf5DEXsGVvcGdYKbVM9F7DyFXj52lNYUDS1L9yJfqjW/fIp5PgMuEJL/KeqVTe2QSbXAGUZljDUpArw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/tsconfig-utils": "^8.37.0",
|
"@typescript-eslint/tsconfig-utils": "^8.40.0",
|
||||||
"@typescript-eslint/types": "^8.37.0",
|
"@typescript-eslint/types": "^8.40.0",
|
||||||
"debug": "^4.3.4"
|
"debug": "^4.3.4"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -3902,18 +3956,18 @@
|
|||||||
"url": "https://opencollective.com/typescript-eslint"
|
"url": "https://opencollective.com/typescript-eslint"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": ">=4.8.4 <5.9.0"
|
"typescript": ">=4.8.4 <6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/scope-manager": {
|
"node_modules/@typescript-eslint/scope-manager": {
|
||||||
"version": "8.37.0",
|
"version": "8.40.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.37.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.40.0.tgz",
|
||||||
"integrity": "sha512-0vGq0yiU1gbjKob2q691ybTg9JX6ShiVXAAfm2jGf3q0hdP6/BruaFjL/ManAR/lj05AvYCH+5bbVo0VtzmjOA==",
|
"integrity": "sha512-y9ObStCcdCiZKzwqsE8CcpyuVMwRouJbbSrNuThDpv16dFAj429IkM6LNb1dZ2m7hK5fHyzNcErZf7CEeKXR4w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/types": "8.37.0",
|
"@typescript-eslint/types": "8.40.0",
|
||||||
"@typescript-eslint/visitor-keys": "8.37.0"
|
"@typescript-eslint/visitor-keys": "8.40.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
@@ -3924,9 +3978,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/tsconfig-utils": {
|
"node_modules/@typescript-eslint/tsconfig-utils": {
|
||||||
"version": "8.37.0",
|
"version": "8.40.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.37.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.40.0.tgz",
|
||||||
"integrity": "sha512-1/YHvAVTimMM9mmlPvTec9NP4bobA1RkDbMydxG8omqwJJLEW/Iy2C4adsAESIXU3WGLXFHSZUU+C9EoFWl4Zg==",
|
"integrity": "sha512-jtMytmUaG9d/9kqSl/W3E3xaWESo4hFDxAIHGVW/WKKtQhesnRIJSAJO6XckluuJ6KDB5woD1EiqknriCtAmcw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -3937,19 +3991,19 @@
|
|||||||
"url": "https://opencollective.com/typescript-eslint"
|
"url": "https://opencollective.com/typescript-eslint"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": ">=4.8.4 <5.9.0"
|
"typescript": ">=4.8.4 <6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/type-utils": {
|
"node_modules/@typescript-eslint/type-utils": {
|
||||||
"version": "8.37.0",
|
"version": "8.40.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.37.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.40.0.tgz",
|
||||||
"integrity": "sha512-SPkXWIkVZxhgwSwVq9rqj/4VFo7MnWwVaRNznfQDc/xPYHjXnPfLWn+4L6FF1cAz6e7dsqBeMawgl7QjUMj4Ow==",
|
"integrity": "sha512-eE60cK4KzAc6ZrzlJnflXdrMqOBaugeukWICO2rB0KNvwdIMaEaYiywwHMzA1qFpTxrLhN9Lp4E/00EgWcD3Ow==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/types": "8.37.0",
|
"@typescript-eslint/types": "8.40.0",
|
||||||
"@typescript-eslint/typescript-estree": "8.37.0",
|
"@typescript-eslint/typescript-estree": "8.40.0",
|
||||||
"@typescript-eslint/utils": "8.37.0",
|
"@typescript-eslint/utils": "8.40.0",
|
||||||
"debug": "^4.3.4",
|
"debug": "^4.3.4",
|
||||||
"ts-api-utils": "^2.1.0"
|
"ts-api-utils": "^2.1.0"
|
||||||
},
|
},
|
||||||
@@ -3962,13 +4016,13 @@
|
|||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"eslint": "^8.57.0 || ^9.0.0",
|
"eslint": "^8.57.0 || ^9.0.0",
|
||||||
"typescript": ">=4.8.4 <5.9.0"
|
"typescript": ">=4.8.4 <6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/types": {
|
"node_modules/@typescript-eslint/types": {
|
||||||
"version": "8.37.0",
|
"version": "8.40.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.37.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.40.0.tgz",
|
||||||
"integrity": "sha512-ax0nv7PUF9NOVPs+lmQ7yIE7IQmAf8LGcXbMvHX5Gm+YJUYNAl340XkGnrimxZ0elXyoQJuN5sbg6C4evKA4SQ==",
|
"integrity": "sha512-ETdbFlgbAmXHyFPwqUIYrfc12ArvpBhEVgGAxVYSwli26dn8Ko+lIo4Su9vI9ykTZdJn+vJprs/0eZU0YMAEQg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -3980,16 +4034,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/typescript-estree": {
|
"node_modules/@typescript-eslint/typescript-estree": {
|
||||||
"version": "8.37.0",
|
"version": "8.40.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.37.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.40.0.tgz",
|
||||||
"integrity": "sha512-zuWDMDuzMRbQOM+bHyU4/slw27bAUEcKSKKs3hcv2aNnc/tvE/h7w60dwVw8vnal2Pub6RT1T7BI8tFZ1fE+yg==",
|
"integrity": "sha512-k1z9+GJReVVOkc1WfVKs1vBrR5MIKKbdAjDTPvIK3L8De6KbFfPFt6BKpdkdk7rZS2GtC/m6yI5MYX+UsuvVYQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/project-service": "8.37.0",
|
"@typescript-eslint/project-service": "8.40.0",
|
||||||
"@typescript-eslint/tsconfig-utils": "8.37.0",
|
"@typescript-eslint/tsconfig-utils": "8.40.0",
|
||||||
"@typescript-eslint/types": "8.37.0",
|
"@typescript-eslint/types": "8.40.0",
|
||||||
"@typescript-eslint/visitor-keys": "8.37.0",
|
"@typescript-eslint/visitor-keys": "8.40.0",
|
||||||
"debug": "^4.3.4",
|
"debug": "^4.3.4",
|
||||||
"fast-glob": "^3.3.2",
|
"fast-glob": "^3.3.2",
|
||||||
"is-glob": "^4.0.3",
|
"is-glob": "^4.0.3",
|
||||||
@@ -4005,7 +4059,7 @@
|
|||||||
"url": "https://opencollective.com/typescript-eslint"
|
"url": "https://opencollective.com/typescript-eslint"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": ">=4.8.4 <5.9.0"
|
"typescript": ">=4.8.4 <6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
||||||
@@ -4035,16 +4089,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/utils": {
|
"node_modules/@typescript-eslint/utils": {
|
||||||
"version": "8.37.0",
|
"version": "8.40.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.37.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.40.0.tgz",
|
||||||
"integrity": "sha512-TSFvkIW6gGjN2p6zbXo20FzCABbyUAuq6tBvNRGsKdsSQ6a7rnV6ADfZ7f4iI3lIiXc4F4WWvtUfDw9CJ9pO5A==",
|
"integrity": "sha512-Cgzi2MXSZyAUOY+BFwGs17s7ad/7L+gKt6Y8rAVVWS+7o6wrjeFN4nVfTpbE25MNcxyJ+iYUXflbs2xR9h4UBg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.7.0",
|
"@eslint-community/eslint-utils": "^4.7.0",
|
||||||
"@typescript-eslint/scope-manager": "8.37.0",
|
"@typescript-eslint/scope-manager": "8.40.0",
|
||||||
"@typescript-eslint/types": "8.37.0",
|
"@typescript-eslint/types": "8.40.0",
|
||||||
"@typescript-eslint/typescript-estree": "8.37.0"
|
"@typescript-eslint/typescript-estree": "8.40.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
@@ -4055,17 +4109,17 @@
|
|||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"eslint": "^8.57.0 || ^9.0.0",
|
"eslint": "^8.57.0 || ^9.0.0",
|
||||||
"typescript": ">=4.8.4 <5.9.0"
|
"typescript": ">=4.8.4 <6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/visitor-keys": {
|
"node_modules/@typescript-eslint/visitor-keys": {
|
||||||
"version": "8.37.0",
|
"version": "8.40.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.37.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.40.0.tgz",
|
||||||
"integrity": "sha512-YzfhzcTnZVPiLfP/oeKtDp2evwvHLMe0LOy7oe+hb9KKIumLNohYS9Hgp1ifwpu42YWxhZE8yieggz6JpqO/1w==",
|
"integrity": "sha512-8CZ47QwalyRjsypfwnbI3hKy5gJDPmrkLjkgMxhi0+DZZ2QNx2naS6/hWoVYUHU7LU2zleF68V9miaVZvhFfTA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/types": "8.37.0",
|
"@typescript-eslint/types": "8.40.0",
|
||||||
"eslint-visitor-keys": "^4.2.1"
|
"eslint-visitor-keys": "^4.2.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -5402,20 +5456,20 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint": {
|
"node_modules/eslint": {
|
||||||
"version": "9.31.0",
|
"version": "9.34.0",
|
||||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.31.0.tgz",
|
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.34.0.tgz",
|
||||||
"integrity": "sha512-QldCVh/ztyKJJZLr4jXNUByx3gR+TDYZCRXEktiZoUR3PGy4qCmSbkxcIle8GEwGpb5JBZazlaJ/CxLidXdEbQ==",
|
"integrity": "sha512-RNCHRX5EwdrESy3Jc9o8ie8Bog+PeYvvSR8sDGoZxNFTvZ4dlxUB3WzQ3bQMztFrSRODGrLLj8g6OFuGY/aiQg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.2.0",
|
"@eslint-community/eslint-utils": "^4.2.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
"@eslint/config-array": "^0.21.0",
|
"@eslint/config-array": "^0.21.0",
|
||||||
"@eslint/config-helpers": "^0.3.0",
|
"@eslint/config-helpers": "^0.3.1",
|
||||||
"@eslint/core": "^0.15.0",
|
"@eslint/core": "^0.15.2",
|
||||||
"@eslint/eslintrc": "^3.3.1",
|
"@eslint/eslintrc": "^3.3.1",
|
||||||
"@eslint/js": "9.31.0",
|
"@eslint/js": "9.34.0",
|
||||||
"@eslint/plugin-kit": "^0.3.1",
|
"@eslint/plugin-kit": "^0.3.5",
|
||||||
"@humanfs/node": "^0.16.6",
|
"@humanfs/node": "^0.16.6",
|
||||||
"@humanwhocodes/module-importer": "^1.0.1",
|
"@humanwhocodes/module-importer": "^1.0.1",
|
||||||
"@humanwhocodes/retry": "^0.4.2",
|
"@humanwhocodes/retry": "^0.4.2",
|
||||||
@@ -8247,9 +8301,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "5.8.3",
|
"version": "5.9.2",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz",
|
||||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -8261,16 +8315,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/typescript-eslint": {
|
"node_modules/typescript-eslint": {
|
||||||
"version": "8.37.0",
|
"version": "8.40.0",
|
||||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.37.0.tgz",
|
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.40.0.tgz",
|
||||||
"integrity": "sha512-TnbEjzkE9EmcO0Q2zM+GE8NQLItNAJpMmED1BdgoBMYNdqMhzlbqfdSwiRlAzEK2pA9UzVW0gzaaIzXWg2BjfA==",
|
"integrity": "sha512-Xvd2l+ZmFDPEt4oj1QEXzA4A2uUK6opvKu3eGN9aGjB8au02lIVcLyi375w94hHyejTOmzIU77L8ol2sRg9n7Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/eslint-plugin": "8.37.0",
|
"@typescript-eslint/eslint-plugin": "8.40.0",
|
||||||
"@typescript-eslint/parser": "8.37.0",
|
"@typescript-eslint/parser": "8.40.0",
|
||||||
"@typescript-eslint/typescript-estree": "8.37.0",
|
"@typescript-eslint/typescript-estree": "8.40.0",
|
||||||
"@typescript-eslint/utils": "8.37.0"
|
"@typescript-eslint/utils": "8.40.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
@@ -8281,13 +8335,13 @@
|
|||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"eslint": "^8.57.0 || ^9.0.0",
|
"eslint": "^8.57.0 || ^9.0.0",
|
||||||
"typescript": ">=4.8.4 <5.9.0"
|
"typescript": ">=4.8.4 <6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "7.8.0",
|
"version": "7.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz",
|
||||||
"integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==",
|
"integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/unpipe": {
|
"node_modules/unpipe": {
|
||||||
@@ -8424,16 +8478,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "7.0.4",
|
"version": "7.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.3.tgz",
|
||||||
"integrity": "sha512-SkaSguuS7nnmV7mfJ8l81JGBFV7Gvzp8IzgE8A8t23+AxuNX61Q5H1Tpz5efduSN7NHC8nQXD3sKQKZAu5mNEA==",
|
"integrity": "sha512-OOUi5zjkDxYrKhTV3V7iKsoS37VUM7v40+HuwEmcrsf11Cdx9y3DIr2Px6liIcZFwt3XSRpQvFpL3WVy7ApkGw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.4.6",
|
"fdir": "^6.5.0",
|
||||||
"picomatch": "^4.0.2",
|
"picomatch": "^4.0.3",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"rollup": "^4.40.0",
|
"rollup": "^4.43.0",
|
||||||
"tinyglobby": "^0.2.14"
|
"tinyglobby": "^0.2.14"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -8498,10 +8552,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite/node_modules/fdir": {
|
"node_modules/vite/node_modules/fdir": {
|
||||||
"version": "6.4.6",
|
"version": "6.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz",
|
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||||
"integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==",
|
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"picomatch": "^3 || ^4"
|
"picomatch": "^3 || ^4"
|
||||||
},
|
},
|
||||||
@@ -8512,9 +8569,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite/node_modules/picomatch": {
|
"node_modules/vite/node_modules/picomatch": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||||
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
|
|||||||
12
package.json
12
package.json
@@ -76,26 +76,26 @@
|
|||||||
"zod": "^4.0.5"
|
"zod": "^4.0.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.30.1",
|
"@eslint/js": "^9.34.0",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/cors": "^2.8.19",
|
"@types/cors": "^2.8.19",
|
||||||
"@types/express": "^5.0.3",
|
"@types/express": "^5.0.3",
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/node": "^24.0.13",
|
"@types/node": "^24.3.0",
|
||||||
"@types/react": "^19.1.8",
|
"@types/react": "^19.1.8",
|
||||||
"@types/react-dom": "^19.1.6",
|
"@types/react-dom": "^19.1.6",
|
||||||
"@types/ssh2": "^1.15.5",
|
"@types/ssh2": "^1.15.5",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"@vitejs/plugin-react-swc": "^3.10.2",
|
"@vitejs/plugin-react-swc": "^3.10.2",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
"eslint": "^9.30.1",
|
"eslint": "^9.34.0",
|
||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.20",
|
"eslint-plugin-react-refresh": "^0.4.20",
|
||||||
"globals": "^16.3.0",
|
"globals": "^16.3.0",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"tw-animate-css": "^1.3.5",
|
"tw-animate-css": "^1.3.5",
|
||||||
"typescript": "~5.8.3",
|
"typescript": "~5.9.2",
|
||||||
"typescript-eslint": "^8.35.1",
|
"typescript-eslint": "^8.40.0",
|
||||||
"vite": "^7.0.4"
|
"vite": "^7.1.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
49
src/App.tsx
49
src/App.tsx
@@ -4,13 +4,10 @@ import {Homepage} from "@/ui/Homepage/Homepage.tsx"
|
|||||||
import {AppView} from "@/ui/Navigation/AppView.tsx"
|
import {AppView} from "@/ui/Navigation/AppView.tsx"
|
||||||
import {HostManager} from "@/ui/apps/Host Manager/HostManager.tsx"
|
import {HostManager} from "@/ui/apps/Host Manager/HostManager.tsx"
|
||||||
import {TabProvider, useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx"
|
import {TabProvider, useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx"
|
||||||
import axios from "axios"
|
|
||||||
import {TopNavbar} from "@/ui/Navigation/TopNavbar.tsx";
|
import {TopNavbar} from "@/ui/Navigation/TopNavbar.tsx";
|
||||||
import { AdminSettings } from "@/ui/Admin/AdminSettings";
|
import { AdminSettings } from "@/ui/Admin/AdminSettings";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
|
import { getUserInfo } from "@/ui/main-axios.ts";
|
||||||
const apiBase = import.meta.env.DEV ? "http://localhost:8081/users" : "/users";
|
|
||||||
const API = axios.create({baseURL: apiBase});
|
|
||||||
|
|
||||||
function getCookie(name: string) {
|
function getCookie(name: string) {
|
||||||
return document.cookie.split('; ').reduce((r, v) => {
|
return document.cookie.split('; ').reduce((r, v) => {
|
||||||
@@ -39,11 +36,11 @@ function AppContent() {
|
|||||||
const jwt = getCookie("jwt");
|
const jwt = getCookie("jwt");
|
||||||
if (jwt) {
|
if (jwt) {
|
||||||
setAuthLoading(true);
|
setAuthLoading(true);
|
||||||
API.get("/me", {headers: {Authorization: `Bearer ${jwt}`}})
|
getUserInfo()
|
||||||
.then((meRes) => {
|
.then((meRes) => {
|
||||||
setIsAuthenticated(true);
|
setIsAuthenticated(true);
|
||||||
setIsAdmin(!!meRes.data.is_admin);
|
setIsAdmin(!!meRes.is_admin);
|
||||||
setUsername(meRes.data.username || null);
|
setUsername(meRes.username || null);
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
setIsAuthenticated(false);
|
setIsAuthenticated(false);
|
||||||
@@ -93,31 +90,19 @@ function AppContent() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{!isAuthenticated && !authLoading && (
|
{!isAuthenticated && !authLoading && (
|
||||||
<div
|
<div>
|
||||||
className="fixed inset-0 bg-gradient-to-br from-background via-muted/20 to-background z-[9999]"
|
<div className="absolute inset-0" style={{
|
||||||
aria-hidden="true"
|
backgroundImage: `linear-gradient(
|
||||||
>
|
135deg,
|
||||||
<div className="absolute inset-0 opacity-20">
|
transparent 0%,
|
||||||
<div className="absolute inset-0" style={{
|
transparent 49%,
|
||||||
backgroundImage: `repeating-linear-gradient(
|
rgba(255, 255, 255, 0.03) 49%,
|
||||||
45deg,
|
rgba(255, 255, 255, 0.03) 51%,
|
||||||
transparent,
|
transparent 51%,
|
||||||
transparent 20px,
|
transparent 100%
|
||||||
hsl(var(--primary) / 0.4) 20px,
|
)`,
|
||||||
hsl(var(--primary) / 0.4) 40px
|
backgroundSize: '80px 80px'
|
||||||
)`
|
}} />
|
||||||
}} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="absolute inset-0 opacity-10">
|
|
||||||
<div className="absolute inset-0" style={{
|
|
||||||
backgroundImage: `linear-gradient(hsl(var(--border) / 0.3) 1px, transparent 1px),
|
|
||||||
linear-gradient(90deg, hsl(var(--border) / 0.3) 1px, transparent 1px)`,
|
|
||||||
backgroundSize: '40px 40px'
|
|
||||||
}} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-background/80 via-transparent to-background/60" />
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -405,7 +405,6 @@ const migrateSchema = () => {
|
|||||||
addColumnIfNotExists('users', 'token_url', 'TEXT');
|
addColumnIfNotExists('users', 'token_url', 'TEXT');
|
||||||
try {
|
try {
|
||||||
sqlite.prepare(`ALTER TABLE users DROP COLUMN redirect_uri`).run();
|
sqlite.prepare(`ALTER TABLE users DROP COLUMN redirect_uri`).run();
|
||||||
logger.info('Removed redirect_uri column from users table');
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -79,7 +79,12 @@ async function verifyOIDCToken(idToken: string, issuerUrl: string, clientId: str
|
|||||||
const key = await importJWK(publicKey);
|
const key = await importJWK(publicKey);
|
||||||
|
|
||||||
const {payload} = await jwtVerify(idToken, key, {
|
const {payload} = await jwtVerify(idToken, key, {
|
||||||
issuer: issuerUrl,
|
issuer: [
|
||||||
|
issuerUrl,
|
||||||
|
normalizedIssuerUrl,
|
||||||
|
issuerUrl.replace(/\/application\/o\/[^\/]+$/, ''),
|
||||||
|
normalizedIssuerUrl.replace(/\/application\/o\/[^\/]+$/, '')
|
||||||
|
],
|
||||||
audience: clientId,
|
audience: clientId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ interface SSHSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sshSessions: Record<string, SSHSession> = {};
|
const sshSessions: Record<string, SSHSession> = {};
|
||||||
const SESSION_TIMEOUT_MS = 10 * 60 * 1000;
|
|
||||||
|
|
||||||
function cleanupSession(sessionId: string) {
|
function cleanupSession(sessionId: string) {
|
||||||
const session = sshSessions[sessionId];
|
const session = sshSessions[sessionId];
|
||||||
@@ -66,25 +65,26 @@ function scheduleSessionCleanup(sessionId: string) {
|
|||||||
const session = sshSessions[sessionId];
|
const session = sshSessions[sessionId];
|
||||||
if (session) {
|
if (session) {
|
||||||
if (session.timeout) clearTimeout(session.timeout);
|
if (session.timeout) clearTimeout(session.timeout);
|
||||||
session.timeout = setTimeout(() => cleanupSession(sessionId), SESSION_TIMEOUT_MS);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
app.post('/ssh/file_manager/ssh/connect', (req, res) => {
|
app.post('/ssh/file_manager/ssh/connect', async (req, res) => {
|
||||||
const {sessionId, ip, port, username, password, sshKey, keyPassword} = req.body;
|
const {sessionId, ip, port, username, password, sshKey, keyPassword} = req.body;
|
||||||
if (!sessionId || !ip || !username || !port) {
|
if (!sessionId || !ip || !username || !port) {
|
||||||
return res.status(400).json({error: 'Missing SSH connection parameters'});
|
return res.status(400).json({error: 'Missing SSH connection parameters'});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sshSessions[sessionId]?.isConnected) cleanupSession(sessionId);
|
if (sshSessions[sessionId]?.isConnected) {
|
||||||
|
cleanupSession(sessionId);
|
||||||
|
}
|
||||||
const client = new SSHClient();
|
const client = new SSHClient();
|
||||||
const config: any = {
|
const config: any = {
|
||||||
host: ip,
|
host: ip,
|
||||||
port: port || 22,
|
port: port || 22,
|
||||||
username,
|
username,
|
||||||
readyTimeout: 20000,
|
readyTimeout: 0,
|
||||||
keepaliveInterval: 10000,
|
keepaliveInterval: 30000,
|
||||||
keepaliveCountMax: 3,
|
keepaliveCountMax: 0,
|
||||||
algorithms: {
|
algorithms: {
|
||||||
kex: [
|
kex: [
|
||||||
'diffie-hellman-group14-sha256',
|
'diffie-hellman-group14-sha256',
|
||||||
@@ -122,8 +122,22 @@ app.post('/ssh/file_manager/ssh/connect', (req, res) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (sshKey && sshKey.trim()) {
|
if (sshKey && sshKey.trim()) {
|
||||||
config.privateKey = sshKey;
|
try {
|
||||||
if (keyPassword) config.passphrase = keyPassword;
|
if (!sshKey.includes('-----BEGIN') || !sshKey.includes('-----END')) {
|
||||||
|
throw new Error('Invalid private key format');
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanKey = sshKey.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||||
|
|
||||||
|
config.privateKey = Buffer.from(cleanKey, 'utf8');
|
||||||
|
|
||||||
|
if (keyPassword) config.passphrase = keyPassword;
|
||||||
|
|
||||||
|
logger.info('SSH key authentication configured successfully for file manager');
|
||||||
|
} catch (keyError) {
|
||||||
|
logger.error('SSH key format error: ' + keyError.message);
|
||||||
|
return res.status(400).json({error: 'Invalid SSH key format'});
|
||||||
|
}
|
||||||
} else if (password && password.trim()) {
|
} else if (password && password.trim()) {
|
||||||
config.password = password;
|
config.password = password;
|
||||||
} else {
|
} else {
|
||||||
@@ -136,7 +150,6 @@ app.post('/ssh/file_manager/ssh/connect', (req, res) => {
|
|||||||
if (responseSent) return;
|
if (responseSent) return;
|
||||||
responseSent = true;
|
responseSent = true;
|
||||||
sshSessions[sessionId] = {client, isConnected: true, lastActive: Date.now()};
|
sshSessions[sessionId] = {client, isConnected: true, lastActive: Date.now()};
|
||||||
scheduleSessionCleanup(sessionId);
|
|
||||||
res.json({status: 'success', message: 'SSH connection established'});
|
res.json({status: 'success', message: 'SSH connection established'});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -181,7 +194,7 @@ app.get('/ssh/file_manager/ssh/listFiles', (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sshConn.lastActive = Date.now();
|
sshConn.lastActive = Date.now();
|
||||||
scheduleSessionCleanup(sessionId);
|
|
||||||
|
|
||||||
const escapedPath = sshPath.replace(/'/g, "'\"'\"'");
|
const escapedPath = sshPath.replace(/'/g, "'\"'\"'");
|
||||||
sshConn.client.exec(`ls -la '${escapedPath}'`, (err, stream) => {
|
sshConn.client.exec(`ls -la '${escapedPath}'`, (err, stream) => {
|
||||||
@@ -251,7 +264,7 @@ app.get('/ssh/file_manager/ssh/readFile', (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sshConn.lastActive = Date.now();
|
sshConn.lastActive = Date.now();
|
||||||
scheduleSessionCleanup(sessionId);
|
|
||||||
|
|
||||||
const escapedPath = filePath.replace(/'/g, "'\"'\"'");
|
const escapedPath = filePath.replace(/'/g, "'\"'\"'");
|
||||||
sshConn.client.exec(`cat '${escapedPath}'`, (err, stream) => {
|
sshConn.client.exec(`cat '${escapedPath}'`, (err, stream) => {
|
||||||
@@ -303,14 +316,6 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sshConn.lastActive = Date.now();
|
sshConn.lastActive = Date.now();
|
||||||
scheduleSessionCleanup(sessionId);
|
|
||||||
|
|
||||||
const commandTimeout = setTimeout(() => {
|
|
||||||
logger.error(`SSH writeFile command timed out for session: ${sessionId}`);
|
|
||||||
if (!res.headersSent) {
|
|
||||||
res.status(500).json({error: 'SSH command timed out'});
|
|
||||||
}
|
|
||||||
}, 60000);
|
|
||||||
|
|
||||||
const trySFTP = () => {
|
const trySFTP = () => {
|
||||||
try {
|
try {
|
||||||
@@ -331,7 +336,6 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
|
|||||||
fileBuffer = Buffer.from(content);
|
fileBuffer = Buffer.from(content);
|
||||||
}
|
}
|
||||||
} catch (bufferErr) {
|
} catch (bufferErr) {
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
logger.error('Buffer conversion error:', bufferErr);
|
logger.error('Buffer conversion error:', bufferErr);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
return res.status(500).json({error: 'Invalid file content format'});
|
return res.status(500).json({error: 'Invalid file content format'});
|
||||||
@@ -354,7 +358,6 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
|
|||||||
writeStream.on('finish', () => {
|
writeStream.on('finish', () => {
|
||||||
if (hasError || hasFinished) return;
|
if (hasError || hasFinished) return;
|
||||||
hasFinished = true;
|
hasFinished = true;
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
logger.success(`File written successfully via SFTP: ${filePath}`);
|
logger.success(`File written successfully via SFTP: ${filePath}`);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.json({message: 'File written successfully', path: filePath});
|
res.json({message: 'File written successfully', path: filePath});
|
||||||
@@ -364,7 +367,6 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
|
|||||||
writeStream.on('close', () => {
|
writeStream.on('close', () => {
|
||||||
if (hasError || hasFinished) return;
|
if (hasError || hasFinished) return;
|
||||||
hasFinished = true;
|
hasFinished = true;
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
logger.success(`File written successfully via SFTP: ${filePath}`);
|
logger.success(`File written successfully via SFTP: ${filePath}`);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.json({message: 'File written successfully', path: filePath});
|
res.json({message: 'File written successfully', path: filePath});
|
||||||
@@ -396,7 +398,7 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
|
|||||||
|
|
||||||
sshConn.client.exec(writeCommand, (err, stream) => {
|
sshConn.client.exec(writeCommand, (err, stream) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
logger.error('Fallback write command failed:', err);
|
logger.error('Fallback write command failed:', err);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
return res.status(500).json({error: `Write failed: ${err.message}`});
|
return res.status(500).json({error: `Write failed: ${err.message}`});
|
||||||
@@ -416,7 +418,7 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
stream.on('close', (code) => {
|
stream.on('close', (code) => {
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
|
|
||||||
if (outputData.includes('SUCCESS')) {
|
if (outputData.includes('SUCCESS')) {
|
||||||
logger.success(`File written successfully via fallback: ${filePath}`);
|
logger.success(`File written successfully via fallback: ${filePath}`);
|
||||||
@@ -432,7 +434,7 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
stream.on('error', (streamErr) => {
|
stream.on('error', (streamErr) => {
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
logger.error('Fallback write stream error:', streamErr);
|
logger.error('Fallback write stream error:', streamErr);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.status(500).json({error: `Write stream error: ${streamErr.message}`});
|
res.status(500).json({error: `Write stream error: ${streamErr.message}`});
|
||||||
@@ -440,7 +442,7 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
} catch (fallbackErr) {
|
} catch (fallbackErr) {
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
logger.error('Fallback method failed:', fallbackErr);
|
logger.error('Fallback method failed:', fallbackErr);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.status(500).json({error: `All write methods failed: ${fallbackErr.message}`});
|
res.status(500).json({error: `All write methods failed: ${fallbackErr.message}`});
|
||||||
@@ -468,16 +470,11 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sshConn.lastActive = Date.now();
|
sshConn.lastActive = Date.now();
|
||||||
scheduleSessionCleanup(sessionId);
|
|
||||||
|
|
||||||
const fullPath = filePath.endsWith('/') ? filePath + fileName : filePath + '/' + fileName;
|
const fullPath = filePath.endsWith('/') ? filePath + fileName : filePath + '/' + fileName;
|
||||||
|
|
||||||
const commandTimeout = setTimeout(() => {
|
|
||||||
logger.error(`SSH uploadFile command timed out for session: ${sessionId}`);
|
|
||||||
if (!res.headersSent) {
|
|
||||||
res.status(500).json({error: 'SSH command timed out'});
|
|
||||||
}
|
|
||||||
}, 60000);
|
|
||||||
|
|
||||||
const trySFTP = () => {
|
const trySFTP = () => {
|
||||||
try {
|
try {
|
||||||
@@ -498,7 +495,7 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
|||||||
fileBuffer = Buffer.from(content);
|
fileBuffer = Buffer.from(content);
|
||||||
}
|
}
|
||||||
} catch (bufferErr) {
|
} catch (bufferErr) {
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
logger.error('Buffer conversion error:', bufferErr);
|
logger.error('Buffer conversion error:', bufferErr);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
return res.status(500).json({error: 'Invalid file content format'});
|
return res.status(500).json({error: 'Invalid file content format'});
|
||||||
@@ -521,7 +518,7 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
|||||||
writeStream.on('finish', () => {
|
writeStream.on('finish', () => {
|
||||||
if (hasError || hasFinished) return;
|
if (hasError || hasFinished) return;
|
||||||
hasFinished = true;
|
hasFinished = true;
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
logger.success(`File uploaded successfully via SFTP: ${fullPath}`);
|
logger.success(`File uploaded successfully via SFTP: ${fullPath}`);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.json({message: 'File uploaded successfully', path: fullPath});
|
res.json({message: 'File uploaded successfully', path: fullPath});
|
||||||
@@ -531,7 +528,7 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
|||||||
writeStream.on('close', () => {
|
writeStream.on('close', () => {
|
||||||
if (hasError || hasFinished) return;
|
if (hasError || hasFinished) return;
|
||||||
hasFinished = true;
|
hasFinished = true;
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
logger.success(`File uploaded successfully via SFTP: ${fullPath}`);
|
logger.success(`File uploaded successfully via SFTP: ${fullPath}`);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.json({message: 'File uploaded successfully', path: fullPath});
|
res.json({message: 'File uploaded successfully', path: fullPath});
|
||||||
@@ -573,7 +570,7 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
|||||||
|
|
||||||
sshConn.client.exec(writeCommand, (err, stream) => {
|
sshConn.client.exec(writeCommand, (err, stream) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
logger.error('Fallback upload command failed:', err);
|
logger.error('Fallback upload command failed:', err);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
return res.status(500).json({error: `Upload failed: ${err.message}`});
|
return res.status(500).json({error: `Upload failed: ${err.message}`});
|
||||||
@@ -593,7 +590,7 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
stream.on('close', (code) => {
|
stream.on('close', (code) => {
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
|
|
||||||
if (outputData.includes('SUCCESS')) {
|
if (outputData.includes('SUCCESS')) {
|
||||||
logger.success(`File uploaded successfully via fallback: ${fullPath}`);
|
logger.success(`File uploaded successfully via fallback: ${fullPath}`);
|
||||||
@@ -609,7 +606,7 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
stream.on('error', (streamErr) => {
|
stream.on('error', (streamErr) => {
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
logger.error('Fallback upload stream error:', streamErr);
|
logger.error('Fallback upload stream error:', streamErr);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.status(500).json({error: `Upload stream error: ${streamErr.message}`});
|
res.status(500).json({error: `Upload stream error: ${streamErr.message}`});
|
||||||
@@ -631,7 +628,7 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
|||||||
|
|
||||||
sshConn.client.exec(writeCommand, (err, stream) => {
|
sshConn.client.exec(writeCommand, (err, stream) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
logger.error('Chunked fallback upload failed:', err);
|
logger.error('Chunked fallback upload failed:', err);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
return res.status(500).json({error: `Chunked upload failed: ${err.message}`});
|
return res.status(500).json({error: `Chunked upload failed: ${err.message}`});
|
||||||
@@ -651,7 +648,7 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
stream.on('close', (code) => {
|
stream.on('close', (code) => {
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
|
|
||||||
if (outputData.includes('SUCCESS')) {
|
if (outputData.includes('SUCCESS')) {
|
||||||
logger.success(`File uploaded successfully via chunked fallback: ${fullPath}`);
|
logger.success(`File uploaded successfully via chunked fallback: ${fullPath}`);
|
||||||
@@ -667,7 +664,6 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
stream.on('error', (streamErr) => {
|
stream.on('error', (streamErr) => {
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
logger.error('Chunked fallback upload stream error:', streamErr);
|
logger.error('Chunked fallback upload stream error:', streamErr);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.status(500).json({error: `Chunked upload stream error: ${streamErr.message}`});
|
res.status(500).json({error: `Chunked upload stream error: ${streamErr.message}`});
|
||||||
@@ -676,7 +672,6 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (fallbackErr) {
|
} catch (fallbackErr) {
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
logger.error('Fallback method failed:', fallbackErr);
|
logger.error('Fallback method failed:', fallbackErr);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.status(500).json({error: `All upload methods failed: ${fallbackErr.message}`});
|
res.status(500).json({error: `All upload methods failed: ${fallbackErr.message}`});
|
||||||
@@ -704,23 +699,14 @@ app.post('/ssh/file_manager/ssh/createFile', (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sshConn.lastActive = Date.now();
|
sshConn.lastActive = Date.now();
|
||||||
scheduleSessionCleanup(sessionId);
|
|
||||||
|
|
||||||
const fullPath = filePath.endsWith('/') ? filePath + fileName : filePath + '/' + fileName;
|
const fullPath = filePath.endsWith('/') ? filePath + fileName : filePath + '/' + fileName;
|
||||||
const escapedPath = fullPath.replace(/'/g, "'\"'\"'");
|
const escapedPath = fullPath.replace(/'/g, "'\"'\"'");
|
||||||
|
|
||||||
const commandTimeout = setTimeout(() => {
|
|
||||||
logger.error(`SSH createFile command timed out for session: ${sessionId}`);
|
|
||||||
if (!res.headersSent) {
|
|
||||||
res.status(500).json({error: 'SSH command timed out'});
|
|
||||||
}
|
|
||||||
}, 15000);
|
|
||||||
|
|
||||||
const createCommand = `touch '${escapedPath}' && echo "SUCCESS" && exit 0`;
|
const createCommand = `touch '${escapedPath}' && echo "SUCCESS" && exit 0`;
|
||||||
|
|
||||||
sshConn.client.exec(createCommand, (err, stream) => {
|
sshConn.client.exec(createCommand, (err, stream) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
logger.error('SSH createFile error:', err);
|
logger.error('SSH createFile error:', err);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
return res.status(500).json({error: err.message});
|
return res.status(500).json({error: err.message});
|
||||||
@@ -739,7 +725,6 @@ app.post('/ssh/file_manager/ssh/createFile', (req, res) => {
|
|||||||
errorData += chunk.toString();
|
errorData += chunk.toString();
|
||||||
|
|
||||||
if (chunk.toString().includes('Permission denied')) {
|
if (chunk.toString().includes('Permission denied')) {
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
logger.error(`Permission denied creating file: ${fullPath}`);
|
logger.error(`Permission denied creating file: ${fullPath}`);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
@@ -751,8 +736,6 @@ app.post('/ssh/file_manager/ssh/createFile', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
stream.on('close', (code) => {
|
stream.on('close', (code) => {
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
|
|
||||||
if (outputData.includes('SUCCESS')) {
|
if (outputData.includes('SUCCESS')) {
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.json({message: 'File created successfully', path: fullPath});
|
res.json({message: 'File created successfully', path: fullPath});
|
||||||
@@ -774,7 +757,6 @@ app.post('/ssh/file_manager/ssh/createFile', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
stream.on('error', (streamErr) => {
|
stream.on('error', (streamErr) => {
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
logger.error('SSH createFile stream error:', streamErr);
|
logger.error('SSH createFile stream error:', streamErr);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.status(500).json({error: `Stream error: ${streamErr.message}`});
|
res.status(500).json({error: `Stream error: ${streamErr.message}`});
|
||||||
@@ -800,23 +782,15 @@ app.post('/ssh/file_manager/ssh/createFolder', (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sshConn.lastActive = Date.now();
|
sshConn.lastActive = Date.now();
|
||||||
scheduleSessionCleanup(sessionId);
|
|
||||||
|
|
||||||
const fullPath = folderPath.endsWith('/') ? folderPath + folderName : folderPath + '/' + folderName;
|
const fullPath = folderPath.endsWith('/') ? folderPath + folderName : folderPath + '/' + folderName;
|
||||||
const escapedPath = fullPath.replace(/'/g, "'\"'\"'");
|
const escapedPath = fullPath.replace(/'/g, "'\"'\"'");
|
||||||
|
|
||||||
const commandTimeout = setTimeout(() => {
|
|
||||||
logger.error(`SSH createFolder command timed out for session: ${sessionId}`);
|
|
||||||
if (!res.headersSent) {
|
|
||||||
res.status(500).json({error: 'SSH command timed out'});
|
|
||||||
}
|
|
||||||
}, 15000);
|
|
||||||
|
|
||||||
const createCommand = `mkdir -p '${escapedPath}' && echo "SUCCESS" && exit 0`;
|
const createCommand = `mkdir -p '${escapedPath}' && echo "SUCCESS" && exit 0`;
|
||||||
|
|
||||||
sshConn.client.exec(createCommand, (err, stream) => {
|
sshConn.client.exec(createCommand, (err, stream) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
logger.error('SSH createFolder error:', err);
|
logger.error('SSH createFolder error:', err);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
return res.status(500).json({error: err.message});
|
return res.status(500).json({error: err.message});
|
||||||
@@ -835,7 +809,6 @@ app.post('/ssh/file_manager/ssh/createFolder', (req, res) => {
|
|||||||
errorData += chunk.toString();
|
errorData += chunk.toString();
|
||||||
|
|
||||||
if (chunk.toString().includes('Permission denied')) {
|
if (chunk.toString().includes('Permission denied')) {
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
logger.error(`Permission denied creating folder: ${fullPath}`);
|
logger.error(`Permission denied creating folder: ${fullPath}`);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
@@ -847,8 +820,6 @@ app.post('/ssh/file_manager/ssh/createFolder', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
stream.on('close', (code) => {
|
stream.on('close', (code) => {
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
|
|
||||||
if (outputData.includes('SUCCESS')) {
|
if (outputData.includes('SUCCESS')) {
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.json({message: 'Folder created successfully', path: fullPath});
|
res.json({message: 'Folder created successfully', path: fullPath});
|
||||||
@@ -870,7 +841,6 @@ app.post('/ssh/file_manager/ssh/createFolder', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
stream.on('error', (streamErr) => {
|
stream.on('error', (streamErr) => {
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
logger.error('SSH createFolder stream error:', streamErr);
|
logger.error('SSH createFolder stream error:', streamErr);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.status(500).json({error: `Stream error: ${streamErr.message}`});
|
res.status(500).json({error: `Stream error: ${streamErr.message}`});
|
||||||
@@ -896,24 +866,14 @@ app.delete('/ssh/file_manager/ssh/deleteItem', (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sshConn.lastActive = Date.now();
|
sshConn.lastActive = Date.now();
|
||||||
scheduleSessionCleanup(sessionId);
|
|
||||||
|
|
||||||
const escapedPath = itemPath.replace(/'/g, "'\"'\"'");
|
const escapedPath = itemPath.replace(/'/g, "'\"'\"'");
|
||||||
|
|
||||||
const commandTimeout = setTimeout(() => {
|
|
||||||
logger.error(`SSH deleteItem command timed out for session: ${sessionId}`);
|
|
||||||
if (!res.headersSent) {
|
|
||||||
res.status(500).json({error: 'SSH command timed out'});
|
|
||||||
}
|
|
||||||
}, 15000);
|
|
||||||
|
|
||||||
const deleteCommand = isDirectory
|
const deleteCommand = isDirectory
|
||||||
? `rm -rf '${escapedPath}' && echo "SUCCESS" && exit 0`
|
? `rm -rf '${escapedPath}' && echo "SUCCESS" && exit 0`
|
||||||
: `rm -f '${escapedPath}' && echo "SUCCESS" && exit 0`;
|
: `rm -f '${escapedPath}' && echo "SUCCESS" && exit 0`;
|
||||||
|
|
||||||
sshConn.client.exec(deleteCommand, (err, stream) => {
|
sshConn.client.exec(deleteCommand, (err, stream) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
logger.error('SSH deleteItem error:', err);
|
logger.error('SSH deleteItem error:', err);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
return res.status(500).json({error: err.message});
|
return res.status(500).json({error: err.message});
|
||||||
@@ -932,7 +892,6 @@ app.delete('/ssh/file_manager/ssh/deleteItem', (req, res) => {
|
|||||||
errorData += chunk.toString();
|
errorData += chunk.toString();
|
||||||
|
|
||||||
if (chunk.toString().includes('Permission denied')) {
|
if (chunk.toString().includes('Permission denied')) {
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
logger.error(`Permission denied deleting: ${itemPath}`);
|
logger.error(`Permission denied deleting: ${itemPath}`);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
@@ -944,8 +903,6 @@ app.delete('/ssh/file_manager/ssh/deleteItem', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
stream.on('close', (code) => {
|
stream.on('close', (code) => {
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
|
|
||||||
if (outputData.includes('SUCCESS')) {
|
if (outputData.includes('SUCCESS')) {
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.json({message: 'Item deleted successfully', path: itemPath});
|
res.json({message: 'Item deleted successfully', path: itemPath});
|
||||||
@@ -967,7 +924,6 @@ app.delete('/ssh/file_manager/ssh/deleteItem', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
stream.on('error', (streamErr) => {
|
stream.on('error', (streamErr) => {
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
logger.error('SSH deleteItem stream error:', streamErr);
|
logger.error('SSH deleteItem stream error:', streamErr);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.status(500).json({error: `Stream error: ${streamErr.message}`});
|
res.status(500).json({error: `Stream error: ${streamErr.message}`});
|
||||||
@@ -993,25 +949,16 @@ app.put('/ssh/file_manager/ssh/renameItem', (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sshConn.lastActive = Date.now();
|
sshConn.lastActive = Date.now();
|
||||||
scheduleSessionCleanup(sessionId);
|
|
||||||
|
|
||||||
const oldDir = oldPath.substring(0, oldPath.lastIndexOf('/') + 1);
|
const oldDir = oldPath.substring(0, oldPath.lastIndexOf('/') + 1);
|
||||||
const newPath = oldDir + newName;
|
const newPath = oldDir + newName;
|
||||||
const escapedOldPath = oldPath.replace(/'/g, "'\"'\"'");
|
const escapedOldPath = oldPath.replace(/'/g, "'\"'\"'");
|
||||||
const escapedNewPath = newPath.replace(/'/g, "'\"'\"'");
|
const escapedNewPath = newPath.replace(/'/g, "'\"'\"'");
|
||||||
|
|
||||||
const commandTimeout = setTimeout(() => {
|
|
||||||
logger.error(`SSH renameItem command timed out for session: ${sessionId}`);
|
|
||||||
if (!res.headersSent) {
|
|
||||||
res.status(500).json({error: 'SSH command timed out'});
|
|
||||||
}
|
|
||||||
}, 15000);
|
|
||||||
|
|
||||||
const renameCommand = `mv '${escapedOldPath}' '${escapedNewPath}' && echo "SUCCESS" && exit 0`;
|
const renameCommand = `mv '${escapedOldPath}' '${escapedNewPath}' && echo "SUCCESS" && exit 0`;
|
||||||
|
|
||||||
sshConn.client.exec(renameCommand, (err, stream) => {
|
sshConn.client.exec(renameCommand, (err, stream) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
logger.error('SSH renameItem error:', err);
|
logger.error('SSH renameItem error:', err);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
return res.status(500).json({error: err.message});
|
return res.status(500).json({error: err.message});
|
||||||
@@ -1030,7 +977,6 @@ app.put('/ssh/file_manager/ssh/renameItem', (req, res) => {
|
|||||||
errorData += chunk.toString();
|
errorData += chunk.toString();
|
||||||
|
|
||||||
if (chunk.toString().includes('Permission denied')) {
|
if (chunk.toString().includes('Permission denied')) {
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
logger.error(`Permission denied renaming: ${oldPath}`);
|
logger.error(`Permission denied renaming: ${oldPath}`);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
@@ -1042,8 +988,6 @@ app.put('/ssh/file_manager/ssh/renameItem', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
stream.on('close', (code) => {
|
stream.on('close', (code) => {
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
|
|
||||||
if (outputData.includes('SUCCESS')) {
|
if (outputData.includes('SUCCESS')) {
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.json({message: 'Item renamed successfully', oldPath, newPath});
|
res.json({message: 'Item renamed successfully', oldPath, newPath});
|
||||||
@@ -1065,7 +1009,6 @@ app.put('/ssh/file_manager/ssh/renameItem', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
stream.on('error', (streamErr) => {
|
stream.on('error', (streamErr) => {
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
logger.error('SSH renameItem stream error:', streamErr);
|
logger.error('SSH renameItem stream error:', streamErr);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.status(500).json({error: `Stream error: ${streamErr.message}`});
|
res.status(500).json({error: `Stream error: ${streamErr.message}`});
|
||||||
|
|||||||
@@ -115,10 +115,27 @@ function buildSshConfig(host: HostRecord): ConnectConfig {
|
|||||||
(base as any).password = host.password || '';
|
(base as any).password = host.password || '';
|
||||||
} else if (host.authType === 'key') {
|
} else if (host.authType === 'key') {
|
||||||
if (host.key) {
|
if (host.key) {
|
||||||
(base as any).privateKey = Buffer.from(host.key, 'utf8');
|
try {
|
||||||
}
|
if (!host.key.includes('-----BEGIN') || !host.key.includes('-----END')) {
|
||||||
if (host.keyPassword) {
|
throw new Error('Invalid private key format');
|
||||||
(base as any).passphrase = host.keyPassword;
|
}
|
||||||
|
|
||||||
|
const cleanKey = host.key.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||||
|
|
||||||
|
(base as any).privateKey = Buffer.from(cleanKey, 'utf8');
|
||||||
|
|
||||||
|
if (host.keyPassword) {
|
||||||
|
(base as any).passphrase = host.keyPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (keyError) {
|
||||||
|
logger.error(`SSH key format error for host ${host.ip}: ${keyError.message}`);
|
||||||
|
if (host.password) {
|
||||||
|
(base as any).password = host.password;
|
||||||
|
} else {
|
||||||
|
throw new Error(`Invalid SSH key format for host ${host.ip}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return base;
|
return base;
|
||||||
@@ -278,15 +295,27 @@ async function collectMetrics(host: HostRecord): Promise<{
|
|||||||
let usedHuman: string | null = null;
|
let usedHuman: string | null = null;
|
||||||
let totalHuman: string | null = null;
|
let totalHuman: string | null = null;
|
||||||
try {
|
try {
|
||||||
const diskOut = await execCommand(client, 'df -h -P / | tail -n +2');
|
// Get both human-readable and bytes format for accurate calculation
|
||||||
const line = diskOut.stdout.split('\n').map(l => l.trim()).filter(Boolean)[0] || '';
|
const diskOutHuman = await execCommand(client, 'df -h -P / | tail -n +2');
|
||||||
const parts = line.split(/\s+/);
|
const diskOutBytes = await execCommand(client, 'df -B1 -P / | tail -n +2');
|
||||||
if (parts.length >= 6) {
|
|
||||||
totalHuman = parts[1] || null;
|
const humanLine = diskOutHuman.stdout.split('\n').map(l => l.trim()).filter(Boolean)[0] || '';
|
||||||
usedHuman = parts[2] || null;
|
const bytesLine = diskOutBytes.stdout.split('\n').map(l => l.trim()).filter(Boolean)[0] || '';
|
||||||
const pctStr = (parts[4] || '').replace('%', '');
|
|
||||||
const pctNum = Number(pctStr);
|
const humanParts = humanLine.split(/\s+/);
|
||||||
diskPercent = Number.isFinite(pctNum) ? pctNum : null;
|
const bytesParts = bytesLine.split(/\s+/);
|
||||||
|
|
||||||
|
if (humanParts.length >= 6 && bytesParts.length >= 6) {
|
||||||
|
totalHuman = humanParts[1] || null;
|
||||||
|
usedHuman = humanParts[2] || null;
|
||||||
|
|
||||||
|
// Calculate our own percentage using bytes for accuracy
|
||||||
|
const totalBytes = Number(bytesParts[1]);
|
||||||
|
const usedBytes = Number(bytesParts[2]);
|
||||||
|
|
||||||
|
if (Number.isFinite(totalBytes) && Number.isFinite(usedBytes) && totalBytes > 0) {
|
||||||
|
diskPercent = Math.max(0, Math.min(100, (usedBytes / totalBytes) * 100));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
diskPercent = null;
|
diskPercent = null;
|
||||||
@@ -414,8 +443,3 @@ app.listen(PORT, async () => {
|
|||||||
logger.error('Initial poll failed', err);
|
logger.error('Initial poll failed', err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
setInterval(() => {
|
|
||||||
pollStatusesOnce().catch(err => logger.error('Background poll failed', err));
|
|
||||||
}, 60_000);
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import chalk from 'chalk';
|
|||||||
|
|
||||||
const wss = new WebSocketServer({port: 8082});
|
const wss = new WebSocketServer({port: 8082});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const sshIconSymbol = '🖥️';
|
const sshIconSymbol = '🖥️';
|
||||||
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||||
const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => {
|
const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => {
|
||||||
@@ -30,16 +33,22 @@ const logger = {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
wss.on('connection', (ws: WebSocket) => {
|
wss.on('connection', (ws: WebSocket) => {
|
||||||
let sshConn: Client | null = null;
|
let sshConn: Client | null = null;
|
||||||
let sshStream: ClientChannel | null = null;
|
let sshStream: ClientChannel | null = null;
|
||||||
let pingInterval: NodeJS.Timeout | null = null;
|
let pingInterval: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
ws.on('close', () => {
|
ws.on('close', () => {
|
||||||
cleanupSSH();
|
cleanupSSH();
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.on('message', (msg: RawData) => {
|
ws.on('message', (msg: RawData) => {
|
||||||
|
|
||||||
|
|
||||||
let parsed: any;
|
let parsed: any;
|
||||||
try {
|
try {
|
||||||
parsed = JSON.parse(msg.toString());
|
parsed = JSON.parse(msg.toString());
|
||||||
@@ -132,34 +141,13 @@ wss.on('connection', (ws: WebSocket) => {
|
|||||||
|
|
||||||
sshConn.on('ready', () => {
|
sshConn.on('ready', () => {
|
||||||
clearTimeout(connectionTimeout);
|
clearTimeout(connectionTimeout);
|
||||||
const pseudoTtyOpts: PseudoTtyOptions = {
|
|
||||||
term: 'xterm-256color',
|
|
||||||
cols,
|
|
||||||
rows,
|
|
||||||
modes: {
|
|
||||||
ECHO: 1,
|
|
||||||
ECHOCTL: 0,
|
|
||||||
ICANON: 1,
|
|
||||||
ISIG: 1,
|
|
||||||
ICRNL: 1,
|
|
||||||
IXON: 1,
|
|
||||||
IXOFF: 0,
|
|
||||||
ISTRIP: 0,
|
|
||||||
OPOST: 1,
|
|
||||||
ONLCR: 1,
|
|
||||||
OCRNL: 0,
|
|
||||||
ONOCR: 0,
|
|
||||||
ONLRET: 0,
|
|
||||||
CS7: 0,
|
|
||||||
CS8: 1,
|
|
||||||
PARENB: 0,
|
|
||||||
PARODD: 0,
|
|
||||||
TTY_OP_ISPEED: 38400,
|
|
||||||
TTY_OP_OSPEED: 38400,
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
sshConn!.shell(pseudoTtyOpts, (err, stream) => {
|
|
||||||
|
sshConn!.shell({
|
||||||
|
rows: data.rows,
|
||||||
|
cols: data.cols,
|
||||||
|
term: 'xterm-256color'
|
||||||
|
} as PseudoTtyOptions, (err, stream) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
logger.error('Shell error: ' + err.message);
|
logger.error('Shell error: ' + err.message);
|
||||||
ws.send(JSON.stringify({type: 'error', message: 'Shell error: ' + err.message}));
|
ws.send(JSON.stringify({type: 'error', message: 'Shell error: ' + err.message}));
|
||||||
@@ -168,34 +156,18 @@ wss.on('connection', (ws: WebSocket) => {
|
|||||||
|
|
||||||
sshStream = stream;
|
sshStream = stream;
|
||||||
|
|
||||||
stream.on('data', (chunk: Buffer) => {
|
stream.on('data', (data: Buffer) => {
|
||||||
let data: string;
|
ws.send(JSON.stringify({type: 'data', data: data.toString()}));
|
||||||
try {
|
|
||||||
data = chunk.toString('utf8');
|
|
||||||
} catch (e) {
|
|
||||||
data = chunk.toString('binary');
|
|
||||||
}
|
|
||||||
|
|
||||||
ws.send(JSON.stringify({type: 'data', data}));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
stream.on('close', () => {
|
stream.on('close', () => {
|
||||||
cleanupSSH(connectionTimeout);
|
|
||||||
|
ws.send(JSON.stringify({type: 'disconnected', message: 'Connection lost'}));
|
||||||
});
|
});
|
||||||
|
|
||||||
stream.on('error', (err: Error) => {
|
stream.on('error', (err: Error) => {
|
||||||
logger.error('SSH stream error: ' + err.message);
|
logger.error('SSH stream error: ' + err.message);
|
||||||
|
ws.send(JSON.stringify({type: 'error', message: 'SSH stream error: ' + err.message}));
|
||||||
const isConnectionError = err.message.includes('ECONNRESET') ||
|
|
||||||
err.message.includes('EPIPE') ||
|
|
||||||
err.message.includes('ENOTCONN') ||
|
|
||||||
err.message.includes('ETIMEDOUT');
|
|
||||||
|
|
||||||
if (isConnectionError) {
|
|
||||||
ws.send(JSON.stringify({type: 'disconnected', message: 'Connection lost'}));
|
|
||||||
} else {
|
|
||||||
ws.send(JSON.stringify({type: 'error', message: 'SSH stream error: ' + err.message}));
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
setupPingInterval();
|
setupPingInterval();
|
||||||
@@ -233,9 +205,12 @@ wss.on('connection', (ws: WebSocket) => {
|
|||||||
|
|
||||||
sshConn.on('close', () => {
|
sshConn.on('close', () => {
|
||||||
clearTimeout(connectionTimeout);
|
clearTimeout(connectionTimeout);
|
||||||
|
|
||||||
cleanupSSH(connectionTimeout);
|
cleanupSSH(connectionTimeout);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const connectConfig: any = {
|
const connectConfig: any = {
|
||||||
host: ip,
|
host: ip,
|
||||||
port,
|
port,
|
||||||
@@ -245,6 +220,7 @@ wss.on('connection', (ws: WebSocket) => {
|
|||||||
readyTimeout: 10000,
|
readyTimeout: 10000,
|
||||||
tcpKeepAlive: true,
|
tcpKeepAlive: true,
|
||||||
tcpKeepAliveInitialDelay: 30000,
|
tcpKeepAliveInitialDelay: 30000,
|
||||||
|
|
||||||
env: {
|
env: {
|
||||||
TERM: 'xterm-256color',
|
TERM: 'xterm-256color',
|
||||||
LANG: 'en_US.UTF-8',
|
LANG: 'en_US.UTF-8',
|
||||||
@@ -294,12 +270,26 @@ wss.on('connection', (ws: WebSocket) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
if (authType === 'key' && key) {
|
if (authType === 'key' && key) {
|
||||||
connectConfig.privateKey = key;
|
try {
|
||||||
if (keyPassword) {
|
if (!key.includes('-----BEGIN') || !key.includes('-----END')) {
|
||||||
connectConfig.passphrase = keyPassword;
|
throw new Error('Invalid private key format');
|
||||||
}
|
}
|
||||||
if (keyType && keyType !== 'auto') {
|
|
||||||
connectConfig.privateKeyType = keyType;
|
const cleanKey = key.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||||
|
|
||||||
|
connectConfig.privateKey = Buffer.from(cleanKey, 'utf8');
|
||||||
|
|
||||||
|
if (keyPassword) {
|
||||||
|
connectConfig.passphrase = keyPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keyType && keyType !== 'auto') {
|
||||||
|
connectConfig.privateKeyType = keyType;
|
||||||
|
}
|
||||||
|
} catch (keyError) {
|
||||||
|
logger.error('SSH key format error: ' + keyError.message);
|
||||||
|
ws.send(JSON.stringify({type: 'error', message: 'SSH key format error: Invalid private key format'}));
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
} else if (authType === 'key') {
|
} else if (authType === 'key') {
|
||||||
logger.error('SSH key authentication requested but no key provided');
|
logger.error('SSH key authentication requested but no key provided');
|
||||||
@@ -360,4 +350,6 @@ wss.on('connection', (ws: WebSocket) => {
|
|||||||
}
|
}
|
||||||
}, 60000);
|
}, 60000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -197,7 +197,8 @@ function classifyError(errorMessage: string): ErrorType {
|
|||||||
|
|
||||||
if (message.includes("connect etimedout") ||
|
if (message.includes("connect etimedout") ||
|
||||||
message.includes("timeout") ||
|
message.includes("timeout") ||
|
||||||
message.includes("timed out")) {
|
message.includes("timed out") ||
|
||||||
|
message.includes("keepalive timeout")) {
|
||||||
return ERROR_TYPES.TIMEOUT;
|
return ERROR_TYPES.TIMEOUT;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,7 +268,8 @@ function cleanupTunnelResources(tunnelName: string): void {
|
|||||||
tunnelName,
|
tunnelName,
|
||||||
`${tunnelName}_confirm`,
|
`${tunnelName}_confirm`,
|
||||||
`${tunnelName}_retry`,
|
`${tunnelName}_retry`,
|
||||||
`${tunnelName}_verify_retry`
|
`${tunnelName}_verify_retry`,
|
||||||
|
`${tunnelName}_ping`
|
||||||
];
|
];
|
||||||
|
|
||||||
timerKeys.forEach(key => {
|
timerKeys.forEach(key => {
|
||||||
@@ -302,7 +304,7 @@ function resetRetryState(tunnelName: string): void {
|
|||||||
countdownIntervals.delete(tunnelName);
|
countdownIntervals.delete(tunnelName);
|
||||||
}
|
}
|
||||||
|
|
||||||
['', '_confirm', '_retry', '_verify_retry'].forEach(suffix => {
|
['', '_confirm', '_retry', '_verify_retry', '_ping'].forEach(suffix => {
|
||||||
const timerKey = `${tunnelName}${suffix}`;
|
const timerKey = `${tunnelName}${suffix}`;
|
||||||
if (verificationTimers.has(timerKey)) {
|
if (verificationTimers.has(timerKey)) {
|
||||||
clearTimeout(verificationTimers.get(timerKey)!);
|
clearTimeout(verificationTimers.get(timerKey)!);
|
||||||
@@ -353,7 +355,8 @@ function handleDisconnect(tunnelName: string, tunnelConfig: TunnelConfig | null,
|
|||||||
const maxRetries = tunnelConfig.maxRetries || 3;
|
const maxRetries = tunnelConfig.maxRetries || 3;
|
||||||
const retryInterval = tunnelConfig.retryInterval || 5000;
|
const retryInterval = tunnelConfig.retryInterval || 5000;
|
||||||
|
|
||||||
let retryCount = (retryCounters.get(tunnelName) || 0) + 1;
|
let retryCount = retryCounters.get(tunnelName) || 0;
|
||||||
|
retryCount = retryCount + 1;
|
||||||
|
|
||||||
if (retryCount > maxRetries) {
|
if (retryCount > maxRetries) {
|
||||||
logger.error(`All ${maxRetries} retries failed for ${tunnelName}`);
|
logger.error(`All ${maxRetries} retries failed for ${tunnelName}`);
|
||||||
@@ -420,7 +423,6 @@ function handleDisconnect(tunnelName: string, tunnelConfig: TunnelConfig | null,
|
|||||||
|
|
||||||
if (!manualDisconnects.has(tunnelName)) {
|
if (!manualDisconnects.has(tunnelName)) {
|
||||||
activeTunnels.delete(tunnelName);
|
activeTunnels.delete(tunnelName);
|
||||||
|
|
||||||
connectSSHTunnel(tunnelConfig, retryCount);
|
connectSSHTunnel(tunnelConfig, retryCount);
|
||||||
}
|
}
|
||||||
}, retryInterval);
|
}, retryInterval);
|
||||||
@@ -438,264 +440,43 @@ function handleDisconnect(tunnelName: string, tunnelConfig: TunnelConfig | null,
|
|||||||
}
|
}
|
||||||
|
|
||||||
function verifyTunnelConnection(tunnelName: string, tunnelConfig: TunnelConfig, isPeriodic = false): void {
|
function verifyTunnelConnection(tunnelName: string, tunnelConfig: TunnelConfig, isPeriodic = false): void {
|
||||||
if (manualDisconnects.has(tunnelName) || !activeTunnels.has(tunnelName)) {
|
if (isPeriodic) {
|
||||||
return;
|
if (!activeTunnels.has(tunnelName)) {
|
||||||
}
|
|
||||||
|
|
||||||
if (tunnelVerifications.has(tunnelName)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const conn = activeTunnels.get(tunnelName);
|
|
||||||
if (!conn) return;
|
|
||||||
|
|
||||||
broadcastTunnelStatus(tunnelName, {
|
|
||||||
connected: false,
|
|
||||||
status: CONNECTION_STATES.VERIFYING
|
|
||||||
});
|
|
||||||
|
|
||||||
const verificationConn = new Client();
|
|
||||||
tunnelVerifications.set(tunnelName, {
|
|
||||||
conn: verificationConn,
|
|
||||||
timeout: setTimeout(() => {
|
|
||||||
logger.error(`Verification timeout for '${tunnelName}'`);
|
|
||||||
cleanupVerification(false, "Verification timeout");
|
|
||||||
}, 10000)
|
|
||||||
});
|
|
||||||
|
|
||||||
function cleanupVerification(isSuccessful: boolean, failureReason = "Unknown verification failure") {
|
|
||||||
const verification = tunnelVerifications.get(tunnelName);
|
|
||||||
if (verification) {
|
|
||||||
clearTimeout(verification.timeout);
|
|
||||||
try {
|
|
||||||
verification.conn.end();
|
|
||||||
} catch (e) {
|
|
||||||
}
|
|
||||||
tunnelVerifications.delete(tunnelName);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isSuccessful) {
|
|
||||||
broadcastTunnelStatus(tunnelName, {
|
broadcastTunnelStatus(tunnelName, {
|
||||||
connected: true,
|
connected: false,
|
||||||
status: CONNECTION_STATES.CONNECTED
|
status: CONNECTION_STATES.DISCONNECTED,
|
||||||
|
reason: 'Tunnel connection lost'
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!isPeriodic) {
|
|
||||||
setupPingInterval(tunnelName, tunnelConfig);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logger.warn(`Verification failed for '${tunnelName}': ${failureReason}`);
|
|
||||||
|
|
||||||
if (failureReason.includes('command failed') || failureReason.includes('connection error') || failureReason.includes('timeout')) {
|
|
||||||
if (!manualDisconnects.has(tunnelName)) {
|
|
||||||
broadcastTunnelStatus(tunnelName, {
|
|
||||||
connected: false,
|
|
||||||
status: CONNECTION_STATES.FAILED,
|
|
||||||
reason: failureReason
|
|
||||||
});
|
|
||||||
}
|
|
||||||
activeTunnels.delete(tunnelName);
|
|
||||||
handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName));
|
|
||||||
} else {
|
|
||||||
logger.info(`Assuming tunnel '${tunnelName}' is working despite verification warning: ${failureReason}`);
|
|
||||||
cleanupVerification(true);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function attemptVerification() {
|
|
||||||
const testCmd = `timeout 3 bash -c 'nc -z ${tunnelConfig.endpointIP} ${tunnelConfig.endpointPort}'`;
|
|
||||||
|
|
||||||
verificationConn.exec(testCmd, (err, stream) => {
|
|
||||||
if (err) {
|
|
||||||
logger.error(`Verification command failed for '${tunnelName}': ${err.message}`);
|
|
||||||
cleanupVerification(false, `Verification command failed: ${err.message}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let output = '';
|
|
||||||
let errorOutput = '';
|
|
||||||
|
|
||||||
stream.on('data', (data: Buffer) => {
|
|
||||||
output += data.toString();
|
|
||||||
});
|
|
||||||
|
|
||||||
stream.stderr?.on('data', (data: Buffer) => {
|
|
||||||
errorOutput += data.toString();
|
|
||||||
});
|
|
||||||
|
|
||||||
stream.on('close', (code: number) => {
|
|
||||||
if (code === 0) {
|
|
||||||
cleanupVerification(true);
|
|
||||||
} else {
|
|
||||||
const isTimeout = errorOutput.includes('timeout') || errorOutput.includes('Connection timed out');
|
|
||||||
const isConnectionRefused = errorOutput.includes('Connection refused') || errorOutput.includes('No route to host');
|
|
||||||
|
|
||||||
let failureReason = `Cannot connect to ${tunnelConfig.endpointIP}:${tunnelConfig.endpointPort}`;
|
|
||||||
if (isTimeout) {
|
|
||||||
failureReason = `Tunnel verification timeout - cannot reach ${tunnelConfig.endpointIP}:${tunnelConfig.endpointPort}`;
|
|
||||||
} else if (isConnectionRefused) {
|
|
||||||
failureReason = `Connection refused to ${tunnelConfig.endpointIP}:${tunnelConfig.endpointPort} - tunnel may not be established`;
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanupVerification(false, failureReason);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
stream.on('error', (err: Error) => {
|
|
||||||
logger.error(`Verification stream error for '${tunnelName}': ${err.message}`);
|
|
||||||
cleanupVerification(false, `Verification stream error: ${err.message}`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
verificationConn.on('ready', () => {
|
|
||||||
setTimeout(() => {
|
|
||||||
attemptVerification();
|
|
||||||
}, 2000);
|
|
||||||
});
|
|
||||||
|
|
||||||
verificationConn.on('error', (err: Error) => {
|
|
||||||
cleanupVerification(false, `Verification connection error: ${err.message}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
verificationConn.on('close', () => {
|
|
||||||
if (tunnelVerifications.has(tunnelName)) {
|
|
||||||
cleanupVerification(false, "Verification connection closed");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const connOptions: any = {
|
|
||||||
host: tunnelConfig.sourceIP,
|
|
||||||
port: tunnelConfig.sourceSSHPort,
|
|
||||||
username: tunnelConfig.sourceUsername,
|
|
||||||
readyTimeout: 10000,
|
|
||||||
keepaliveInterval: 30000,
|
|
||||||
keepaliveCountMax: 3,
|
|
||||||
tcpKeepAlive: true,
|
|
||||||
tcpKeepAliveInitialDelay: 30000,
|
|
||||||
algorithms: {
|
|
||||||
kex: [
|
|
||||||
'diffie-hellman-group14-sha256',
|
|
||||||
'diffie-hellman-group14-sha1',
|
|
||||||
'diffie-hellman-group1-sha1',
|
|
||||||
'diffie-hellman-group-exchange-sha256',
|
|
||||||
'diffie-hellman-group-exchange-sha1',
|
|
||||||
'ecdh-sha2-nistp256',
|
|
||||||
'ecdh-sha2-nistp384',
|
|
||||||
'ecdh-sha2-nistp521'
|
|
||||||
],
|
|
||||||
cipher: [
|
|
||||||
'aes128-ctr',
|
|
||||||
'aes192-ctr',
|
|
||||||
'aes256-ctr',
|
|
||||||
'aes128-gcm@openssh.com',
|
|
||||||
'aes256-gcm@openssh.com',
|
|
||||||
'aes128-cbc',
|
|
||||||
'aes192-cbc',
|
|
||||||
'aes256-cbc',
|
|
||||||
'3des-cbc'
|
|
||||||
],
|
|
||||||
hmac: [
|
|
||||||
'hmac-sha2-256',
|
|
||||||
'hmac-sha2-512',
|
|
||||||
'hmac-sha1',
|
|
||||||
'hmac-md5'
|
|
||||||
],
|
|
||||||
compress: [
|
|
||||||
'none',
|
|
||||||
'zlib@openssh.com',
|
|
||||||
'zlib'
|
|
||||||
]
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (tunnelConfig.sourceAuthMethod === "key" && tunnelConfig.sourceSSHKey) {
|
|
||||||
connOptions.privateKey = tunnelConfig.sourceSSHKey;
|
|
||||||
if (tunnelConfig.sourceKeyPassword) {
|
|
||||||
connOptions.passphrase = tunnelConfig.sourceKeyPassword;
|
|
||||||
}
|
|
||||||
if (tunnelConfig.sourceKeyType && tunnelConfig.sourceKeyType !== 'auto') {
|
|
||||||
connOptions.privateKeyType = tunnelConfig.sourceKeyType;
|
|
||||||
}
|
|
||||||
} else if (tunnelConfig.sourceAuthMethod === "key") {
|
|
||||||
logger.error(`SSH key authentication requested but no key provided for tunnel '${tunnelName}'`);
|
|
||||||
broadcastTunnelStatus(tunnelName, {
|
|
||||||
connected: false,
|
|
||||||
status: CONNECTION_STATES.FAILED,
|
|
||||||
reason: "SSH key authentication requested but no key provided"
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
connOptions.password = tunnelConfig.sourcePassword;
|
|
||||||
}
|
|
||||||
|
|
||||||
verificationConn.connect(connOptions);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupPingInterval(tunnelName: string, tunnelConfig: TunnelConfig): void {
|
function setupPingInterval(tunnelName: string, tunnelConfig: TunnelConfig): void {
|
||||||
|
const pingKey = `${tunnelName}_ping`;
|
||||||
|
if (verificationTimers.has(pingKey)) {
|
||||||
|
clearInterval(verificationTimers.get(pingKey)!);
|
||||||
|
verificationTimers.delete(pingKey);
|
||||||
|
}
|
||||||
|
|
||||||
const pingInterval = setInterval(() => {
|
const pingInterval = setInterval(() => {
|
||||||
if (!activeTunnels.has(tunnelName) || manualDisconnects.has(tunnelName)) {
|
const currentStatus = connectionStatus.get(tunnelName);
|
||||||
clearInterval(pingInterval);
|
if (currentStatus?.status === CONNECTION_STATES.CONNECTED) {
|
||||||
return;
|
if (!activeTunnels.has(tunnelName)) {
|
||||||
}
|
broadcastTunnelStatus(tunnelName, {
|
||||||
|
connected: false,
|
||||||
const conn = activeTunnels.get(tunnelName);
|
status: CONNECTION_STATES.DISCONNECTED,
|
||||||
if (!conn) {
|
reason: 'Tunnel connection lost'
|
||||||
clearInterval(pingInterval);
|
});
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
conn.exec('echo "ping"', (err, stream) => {
|
|
||||||
if (err) {
|
|
||||||
clearInterval(pingInterval);
|
clearInterval(pingInterval);
|
||||||
|
verificationTimers.delete(pingKey);
|
||||||
if (!manualDisconnects.has(tunnelName)) {
|
|
||||||
broadcastTunnelStatus(tunnelName, {
|
|
||||||
connected: false,
|
|
||||||
status: CONNECTION_STATES.UNSTABLE,
|
|
||||||
reason: "Ping failed"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
activeTunnels.delete(tunnelName);
|
|
||||||
handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName));
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
clearInterval(pingInterval);
|
||||||
|
verificationTimers.delete(pingKey);
|
||||||
|
}
|
||||||
|
}, 120000);
|
||||||
|
|
||||||
stream.on('close', (code: number) => {
|
verificationTimers.set(pingKey, pingInterval);
|
||||||
if (code !== 0) {
|
|
||||||
clearInterval(pingInterval);
|
|
||||||
|
|
||||||
if (!manualDisconnects.has(tunnelName)) {
|
|
||||||
broadcastTunnelStatus(tunnelName, {
|
|
||||||
connected: false,
|
|
||||||
status: CONNECTION_STATES.UNSTABLE,
|
|
||||||
reason: "Ping command failed"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
activeTunnels.delete(tunnelName);
|
|
||||||
handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
stream.on('error', (err: Error) => {
|
|
||||||
clearInterval(pingInterval);
|
|
||||||
|
|
||||||
if (!manualDisconnects.has(tunnelName)) {
|
|
||||||
broadcastTunnelStatus(tunnelName, {
|
|
||||||
connected: false,
|
|
||||||
status: CONNECTION_STATES.UNSTABLE,
|
|
||||||
reason: "Ping stream error"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
activeTunnels.delete(tunnelName);
|
|
||||||
handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}, 60000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
||||||
@@ -751,7 +532,7 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
|||||||
handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName));
|
handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, 15000);
|
}, 60000);
|
||||||
|
|
||||||
conn.on("error", (err) => {
|
conn.on("error", (err) => {
|
||||||
clearTimeout(connectionTimeout);
|
clearTimeout(connectionTimeout);
|
||||||
@@ -779,6 +560,8 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
|||||||
errorType === ERROR_TYPES.PERMISSION ||
|
errorType === ERROR_TYPES.PERMISSION ||
|
||||||
manualDisconnects.has(tunnelName);
|
manualDisconnects.has(tunnelName);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
handleDisconnect(tunnelName, tunnelConfig, !shouldNotRetry);
|
handleDisconnect(tunnelName, tunnelConfig, !shouldNotRetry);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -841,7 +624,11 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
|||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (!manualDisconnects.has(tunnelName) && activeTunnels.has(tunnelName)) {
|
if (!manualDisconnects.has(tunnelName) && activeTunnels.has(tunnelName)) {
|
||||||
verifyTunnelConnection(tunnelName, tunnelConfig, false);
|
broadcastTunnelStatus(tunnelName, {
|
||||||
|
connected: true,
|
||||||
|
status: CONNECTION_STATES.CONNECTED
|
||||||
|
});
|
||||||
|
setupPingInterval(tunnelName, tunnelConfig);
|
||||||
}
|
}
|
||||||
}, 2000);
|
}, 2000);
|
||||||
|
|
||||||
@@ -901,7 +688,6 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
|||||||
|
|
||||||
stream.stderr.on("data", (data) => {
|
stream.stderr.on("data", (data) => {
|
||||||
const errorMsg = data.toString().trim();
|
const errorMsg = data.toString().trim();
|
||||||
logger.debug(`Tunnel stderr for '${tunnelName}': ${errorMsg}`);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -912,9 +698,9 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
|||||||
username: tunnelConfig.sourceUsername,
|
username: tunnelConfig.sourceUsername,
|
||||||
keepaliveInterval: 30000,
|
keepaliveInterval: 30000,
|
||||||
keepaliveCountMax: 3,
|
keepaliveCountMax: 3,
|
||||||
readyTimeout: 10000,
|
readyTimeout: 60000,
|
||||||
tcpKeepAlive: true,
|
tcpKeepAlive: true,
|
||||||
tcpKeepAliveInitialDelay: 30000,
|
tcpKeepAliveInitialDelay: 15000,
|
||||||
algorithms: {
|
algorithms: {
|
||||||
kex: [
|
kex: [
|
||||||
'diffie-hellman-group14-sha256',
|
'diffie-hellman-group14-sha256',
|
||||||
@@ -952,8 +738,8 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (tunnelConfig.sourceAuthMethod === "key" && tunnelConfig.sourceSSHKey) {
|
if (tunnelConfig.sourceAuthMethod === "key" && tunnelConfig.sourceSSHKey) {
|
||||||
if (!tunnelConfig.sourceSSHKey.includes('-----BEGIN')) {
|
if (!tunnelConfig.sourceSSHKey.includes('-----BEGIN') || !tunnelConfig.sourceSSHKey.includes('-----END')) {
|
||||||
logger.error(`Invalid SSH key format for tunnel '${tunnelName}'. Key should start with '-----BEGIN'`);
|
logger.error(`Invalid SSH key format for tunnel '${tunnelName}'. Key should contain both BEGIN and END markers`);
|
||||||
broadcastTunnelStatus(tunnelName, {
|
broadcastTunnelStatus(tunnelName, {
|
||||||
connected: false,
|
connected: false,
|
||||||
status: CONNECTION_STATES.FAILED,
|
status: CONNECTION_STATES.FAILED,
|
||||||
@@ -962,7 +748,8 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
connOptions.privateKey = tunnelConfig.sourceSSHKey;
|
const cleanKey = tunnelConfig.sourceSSHKey.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||||
|
connOptions.privateKey = Buffer.from(cleanKey, 'utf8');
|
||||||
if (tunnelConfig.sourceKeyPassword) {
|
if (tunnelConfig.sourceKeyPassword) {
|
||||||
connOptions.passphrase = tunnelConfig.sourceKeyPassword;
|
connOptions.passphrase = tunnelConfig.sourceKeyPassword;
|
||||||
}
|
}
|
||||||
@@ -981,43 +768,16 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
|||||||
connOptions.password = tunnelConfig.sourcePassword;
|
connOptions.password = tunnelConfig.sourcePassword;
|
||||||
}
|
}
|
||||||
|
|
||||||
const testSocket = new net.Socket();
|
const finalStatus = connectionStatus.get(tunnelName);
|
||||||
testSocket.setTimeout(5000);
|
if (!finalStatus || finalStatus.status !== CONNECTION_STATES.WAITING) {
|
||||||
|
|
||||||
testSocket.on('connect', () => {
|
|
||||||
testSocket.destroy();
|
|
||||||
|
|
||||||
const currentStatus = connectionStatus.get(tunnelName);
|
|
||||||
if (!currentStatus || currentStatus.status !== CONNECTION_STATES.WAITING) {
|
|
||||||
broadcastTunnelStatus(tunnelName, {
|
|
||||||
connected: false,
|
|
||||||
status: CONNECTION_STATES.CONNECTING,
|
|
||||||
retryCount: retryAttempt > 0 ? retryAttempt : undefined
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
conn.connect(connOptions);
|
|
||||||
});
|
|
||||||
|
|
||||||
testSocket.on('timeout', () => {
|
|
||||||
testSocket.destroy();
|
|
||||||
broadcastTunnelStatus(tunnelName, {
|
broadcastTunnelStatus(tunnelName, {
|
||||||
connected: false,
|
connected: false,
|
||||||
status: CONNECTION_STATES.FAILED,
|
status: CONNECTION_STATES.CONNECTING,
|
||||||
reason: "Network connectivity test failed - server not reachable"
|
retryCount: retryAttempt > 0 ? retryAttempt : undefined
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
|
|
||||||
testSocket.on('error', (err: any) => {
|
conn.connect(connOptions);
|
||||||
testSocket.destroy();
|
|
||||||
broadcastTunnelStatus(tunnelName, {
|
|
||||||
connected: false,
|
|
||||||
status: CONNECTION_STATES.FAILED,
|
|
||||||
reason: `Network connectivity test failed - ${err.message}`
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
testSocket.connect(tunnelConfig.sourceSSHPort, tunnelConfig.sourceIP);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function killRemoteTunnelByMarker(tunnelConfig: TunnelConfig, tunnelName: string, callback: (err?: Error) => void) {
|
function killRemoteTunnelByMarker(tunnelConfig: TunnelConfig, tunnelName: string, callback: (err?: Error) => void) {
|
||||||
@@ -1029,9 +789,9 @@ function killRemoteTunnelByMarker(tunnelConfig: TunnelConfig, tunnelName: string
|
|||||||
username: tunnelConfig.sourceUsername,
|
username: tunnelConfig.sourceUsername,
|
||||||
keepaliveInterval: 30000,
|
keepaliveInterval: 30000,
|
||||||
keepaliveCountMax: 3,
|
keepaliveCountMax: 3,
|
||||||
readyTimeout: 10000,
|
readyTimeout: 60000,
|
||||||
tcpKeepAlive: true,
|
tcpKeepAlive: true,
|
||||||
tcpKeepAliveInitialDelay: 30000,
|
tcpKeepAliveInitialDelay: 15000,
|
||||||
algorithms: {
|
algorithms: {
|
||||||
kex: [
|
kex: [
|
||||||
'diffie-hellman-group14-sha256',
|
'diffie-hellman-group14-sha256',
|
||||||
@@ -1068,7 +828,13 @@ function killRemoteTunnelByMarker(tunnelConfig: TunnelConfig, tunnelName: string
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
if (tunnelConfig.sourceAuthMethod === "key" && tunnelConfig.sourceSSHKey) {
|
if (tunnelConfig.sourceAuthMethod === "key" && tunnelConfig.sourceSSHKey) {
|
||||||
connOptions.privateKey = tunnelConfig.sourceSSHKey;
|
if (!tunnelConfig.sourceSSHKey.includes('-----BEGIN') || !tunnelConfig.sourceSSHKey.includes('-----END')) {
|
||||||
|
callback(new Error('Invalid SSH key format'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanKey = tunnelConfig.sourceSSHKey.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||||
|
connOptions.privateKey = Buffer.from(cleanKey, 'utf8');
|
||||||
if (tunnelConfig.sourceKeyPassword) {
|
if (tunnelConfig.sourceKeyPassword) {
|
||||||
connOptions.passphrase = tunnelConfig.sourceKeyPassword;
|
connOptions.passphrase = tunnelConfig.sourceKeyPassword;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,10 +16,16 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table.tsx";
|
} from "@/components/ui/table.tsx";
|
||||||
import {Shield, Trash2, Users} from "lucide-react";
|
import {Shield, Trash2, Users} from "lucide-react";
|
||||||
import axios from "axios";
|
import {
|
||||||
|
getOIDCConfig,
|
||||||
const apiBase = import.meta.env.DEV ? "http://localhost:8081/users" : "/users";
|
getRegistrationAllowed,
|
||||||
const API = axios.create({baseURL: apiBase});
|
getUserList,
|
||||||
|
updateRegistrationAllowed,
|
||||||
|
updateOIDCConfig,
|
||||||
|
makeUserAdmin,
|
||||||
|
removeAdminStatus,
|
||||||
|
deleteUser
|
||||||
|
} from "@/ui/main-axios.ts";
|
||||||
|
|
||||||
function getCookie(name: string) {
|
function getCookie(name: string) {
|
||||||
return document.cookie.split('; ').reduce((r, v) => {
|
return document.cookie.split('; ').reduce((r, v) => {
|
||||||
@@ -67,9 +73,9 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
|||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const jwt = getCookie("jwt");
|
const jwt = getCookie("jwt");
|
||||||
if (!jwt) return;
|
if (!jwt) return;
|
||||||
API.get("/oidc-config", {headers: {Authorization: `Bearer ${jwt}`}})
|
getOIDCConfig()
|
||||||
.then(res => {
|
.then(res => {
|
||||||
if (res.data) setOidcConfig(res.data);
|
if (res) setOidcConfig(res);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
});
|
});
|
||||||
@@ -77,10 +83,10 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
API.get("/registration-allowed")
|
getRegistrationAllowed()
|
||||||
.then(res => {
|
.then(res => {
|
||||||
if (typeof res?.data?.allowed === 'boolean') {
|
if (typeof res?.allowed === 'boolean') {
|
||||||
setAllowRegistration(res.data.allowed);
|
setAllowRegistration(res.allowed);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
@@ -92,8 +98,8 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
|||||||
if (!jwt) return;
|
if (!jwt) return;
|
||||||
setUsersLoading(true);
|
setUsersLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await API.get("/list", {headers: {Authorization: `Bearer ${jwt}`}});
|
const response = await getUserList();
|
||||||
setUsers(response.data.users);
|
setUsers(response.users);
|
||||||
} finally {
|
} finally {
|
||||||
setUsersLoading(false);
|
setUsersLoading(false);
|
||||||
}
|
}
|
||||||
@@ -103,7 +109,7 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
|||||||
setRegLoading(true);
|
setRegLoading(true);
|
||||||
const jwt = getCookie("jwt");
|
const jwt = getCookie("jwt");
|
||||||
try {
|
try {
|
||||||
await API.patch("/registration-allowed", {allowed: checked}, {headers: {Authorization: `Bearer ${jwt}`}});
|
await updateRegistrationAllowed(checked);
|
||||||
setAllowRegistration(checked);
|
setAllowRegistration(checked);
|
||||||
} finally {
|
} finally {
|
||||||
setRegLoading(false);
|
setRegLoading(false);
|
||||||
@@ -126,7 +132,7 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
|||||||
|
|
||||||
const jwt = getCookie("jwt");
|
const jwt = getCookie("jwt");
|
||||||
try {
|
try {
|
||||||
await API.post("/oidc-config", oidcConfig, {headers: {Authorization: `Bearer ${jwt}`}});
|
await updateOIDCConfig(oidcConfig);
|
||||||
setOidcSuccess("OIDC configuration updated successfully!");
|
setOidcSuccess("OIDC configuration updated successfully!");
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setOidcError(err?.response?.data?.error || "Failed to update OIDC configuration");
|
setOidcError(err?.response?.data?.error || "Failed to update OIDC configuration");
|
||||||
@@ -147,7 +153,7 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
|||||||
setMakeAdminSuccess(null);
|
setMakeAdminSuccess(null);
|
||||||
const jwt = getCookie("jwt");
|
const jwt = getCookie("jwt");
|
||||||
try {
|
try {
|
||||||
await API.post("/make-admin", {username: newAdminUsername.trim()}, {headers: {Authorization: `Bearer ${jwt}`}});
|
await makeUserAdmin(newAdminUsername.trim());
|
||||||
setMakeAdminSuccess(`User ${newAdminUsername} is now an admin`);
|
setMakeAdminSuccess(`User ${newAdminUsername} is now an admin`);
|
||||||
setNewAdminUsername("");
|
setNewAdminUsername("");
|
||||||
fetchUsers();
|
fetchUsers();
|
||||||
@@ -162,7 +168,7 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
|||||||
if (!confirm(`Remove admin status from ${username}?`)) return;
|
if (!confirm(`Remove admin status from ${username}?`)) return;
|
||||||
const jwt = getCookie("jwt");
|
const jwt = getCookie("jwt");
|
||||||
try {
|
try {
|
||||||
await API.post("/remove-admin", {username}, {headers: {Authorization: `Bearer ${jwt}`}});
|
await removeAdminStatus(username);
|
||||||
fetchUsers();
|
fetchUsers();
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
@@ -172,7 +178,7 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
|||||||
if (!confirm(`Delete user ${username}? This cannot be undone.`)) return;
|
if (!confirm(`Delete user ${username}? This cannot be undone.`)) return;
|
||||||
const jwt = getCookie("jwt");
|
const jwt = getCookie("jwt");
|
||||||
try {
|
try {
|
||||||
await API.delete("/delete-user", {headers: {Authorization: `Bearer ${jwt}`}, data: {username}});
|
await deleteUser(username);
|
||||||
fetchUsers();
|
fetchUsers();
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import React, {useEffect, useState} from "react";
|
import React, {useEffect, useState} from "react";
|
||||||
import {HomepageAuth} from "@/ui/Homepage/HomepageAuth.tsx";
|
import {HomepageAuth} from "@/ui/Homepage/HomepageAuth.tsx";
|
||||||
import axios from "axios";
|
|
||||||
import {HomepageUpdateLog} from "@/ui/Homepage/HompageUpdateLog.tsx";
|
import {HomepageUpdateLog} from "@/ui/Homepage/HompageUpdateLog.tsx";
|
||||||
import {HomepageAlertManager} from "@/ui/Homepage/HomepageAlertManager.tsx";
|
import {HomepageAlertManager} from "@/ui/Homepage/HomepageAlertManager.tsx";
|
||||||
import {Button} from "@/components/ui/button.tsx";
|
import {Button} from "@/components/ui/button.tsx";
|
||||||
|
import { getUserInfo, getDatabaseHealth } from "@/ui/main-axios.ts";
|
||||||
|
|
||||||
interface HomepageProps {
|
interface HomepageProps {
|
||||||
onSelectView: (view: string) => void;
|
onSelectView: (view: string) => void;
|
||||||
@@ -25,12 +25,6 @@ function setCookie(name: string, value: string, days = 7) {
|
|||||||
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`;
|
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiBase = import.meta.env.DEV ? "http://localhost:8081/users" : "/users";
|
|
||||||
|
|
||||||
const API = axios.create({
|
|
||||||
baseURL: apiBase,
|
|
||||||
});
|
|
||||||
|
|
||||||
export function Homepage({
|
export function Homepage({
|
||||||
onSelectView,
|
onSelectView,
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
@@ -53,13 +47,13 @@ export function Homepage({
|
|||||||
const jwt = getCookie("jwt");
|
const jwt = getCookie("jwt");
|
||||||
if (jwt) {
|
if (jwt) {
|
||||||
Promise.all([
|
Promise.all([
|
||||||
API.get("/me", {headers: {Authorization: `Bearer ${jwt}`}}),
|
getUserInfo(),
|
||||||
API.get("/db-health")
|
getDatabaseHealth()
|
||||||
])
|
])
|
||||||
.then(([meRes]) => {
|
.then(([meRes]) => {
|
||||||
setIsAdmin(!!meRes.data.is_admin);
|
setIsAdmin(!!meRes.is_admin);
|
||||||
setUsername(meRes.data.username || null);
|
setUsername(meRes.username || null);
|
||||||
setUserId(meRes.data.userId || null);
|
setUserId(meRes.userId || null);
|
||||||
setDbError(null);
|
setDbError(null);
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
@@ -78,76 +72,82 @@ export function Homepage({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`w-full min-h-svh grid place-items-center relative transition-[padding-top] duration-200 ease-linear ${
|
className={`w-full min-h-svh relative transition-[padding-top] duration-200 ease-linear ${
|
||||||
isTopbarOpen ? 'pt-[66px]' : 'pt-2'
|
isTopbarOpen ? 'pt-[66px]' : 'pt-2'
|
||||||
}`}>
|
}`}>
|
||||||
<div className="flex flex-row items-center justify-center gap-8 relative z-[10000]">
|
{!loggedIn ? (
|
||||||
<HomepageAuth
|
<div className="absolute top-[66px] left-0 w-full h-[calc(100%-66px)] flex items-center justify-center">
|
||||||
setLoggedIn={setLoggedIn}
|
<HomepageAuth
|
||||||
setIsAdmin={setIsAdmin}
|
setLoggedIn={setLoggedIn}
|
||||||
setUsername={setUsername}
|
setIsAdmin={setIsAdmin}
|
||||||
setUserId={setUserId}
|
setUsername={setUsername}
|
||||||
loggedIn={loggedIn}
|
setUserId={setUserId}
|
||||||
authLoading={authLoading}
|
loggedIn={loggedIn}
|
||||||
dbError={dbError}
|
authLoading={authLoading}
|
||||||
setDbError={setDbError}
|
dbError={dbError}
|
||||||
onAuthSuccess={onAuthSuccess}
|
setDbError={setDbError}
|
||||||
/>
|
onAuthSuccess={onAuthSuccess}
|
||||||
|
/>
|
||||||
<div className="flex flex-row items-center justify-center gap-8">
|
</div>
|
||||||
{loggedIn && (
|
) : (
|
||||||
<div className="flex flex-col items-center gap-4 w-[350px]">
|
<div className="absolute top-[66px] left-0 w-full h-[calc(100%-66px)] flex items-center justify-center">
|
||||||
|
<div className="flex flex-row items-center justify-center gap-8 relative z-[10000]">
|
||||||
|
<div className="flex flex-col items-center gap-6 w-[400px]">
|
||||||
<div
|
<div
|
||||||
className="my-2 text-center bg-muted/50 border-2 border-[#303032] rounded-lg p-4 w-full">
|
className="text-center bg-[#18181b] border-2 border-[#303032] rounded-lg p-6 w-full shadow-lg">
|
||||||
<h3 className="text-lg font-semibold mb-2">Logged in!</h3>
|
<h3 className="text-xl font-bold mb-3 text-white">Logged in!</h3>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-gray-300 leading-relaxed">
|
||||||
You are logged in! Use the sidebar to access all available tools. To get started,
|
You are logged in! Use the sidebar to access all available tools. To get started,
|
||||||
create an SSH Host in the SSH Manager tab. Once created, you can connect to that
|
create an SSH Host in the SSH Manager tab. Once created, you can connect to that
|
||||||
host using the other apps in the sidebar.
|
host using the other apps in the sidebar.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-row items-center gap-2">
|
<div className="flex flex-row items-center gap-3">
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="outline"
|
||||||
className="text-sm"
|
size="sm"
|
||||||
|
className="text-sm border-[#303032] text-gray-300 hover:text-white hover:bg-[#18181b] transition-colors"
|
||||||
onClick={() => window.open('https://github.com/LukeGus/Termix', '_blank')}
|
onClick={() => window.open('https://github.com/LukeGus/Termix', '_blank')}
|
||||||
>
|
>
|
||||||
GitHub
|
GitHub
|
||||||
</Button>
|
</Button>
|
||||||
<div className="w-px h-4 bg-border"></div>
|
<div className="w-px h-4 bg-[#303032]"></div>
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="outline"
|
||||||
className="text-sm"
|
size="sm"
|
||||||
|
className="text-sm border-[#303032] text-gray-300 hover:text-white hover:bg-[#18181b] transition-colors"
|
||||||
onClick={() => window.open('https://github.com/LukeGus/Termix/issues/new', '_blank')}
|
onClick={() => window.open('https://github.com/LukeGus/Termix/issues/new', '_blank')}
|
||||||
>
|
>
|
||||||
Feedback
|
Feedback
|
||||||
</Button>
|
</Button>
|
||||||
<div className="w-px h-4 bg-border"></div>
|
<div className="w-px h-4 bg-[#303032]"></div>
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="outline"
|
||||||
className="text-sm"
|
size="sm"
|
||||||
|
className="text-sm border-[#303032] text-gray-300 hover:text-white hover:bg-[#18181b] transition-colors"
|
||||||
onClick={() => window.open('https://discord.com/invite/jVQGdvHDrf', '_blank')}
|
onClick={() => window.open('https://discord.com/invite/jVQGdvHDrf', '_blank')}
|
||||||
>
|
>
|
||||||
Discord
|
Discord
|
||||||
</Button>
|
</Button>
|
||||||
<div className="w-px h-4 bg-border"></div>
|
<div className="w-px h-4 bg-[#303032]"></div>
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="outline"
|
||||||
className="text-sm"
|
size="sm"
|
||||||
|
className="text-sm border-[#303032] text-gray-300 hover:text-white hover:bg-[#18181b] transition-colors"
|
||||||
onClick={() => window.open('https://github.com/sponsors/LukeGus', '_blank')}
|
onClick={() => window.open('https://github.com/sponsors/LukeGus', '_blank')}
|
||||||
>
|
>
|
||||||
Donate
|
Donate
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<HomepageUpdateLog
|
<HomepageUpdateLog
|
||||||
loggedIn={loggedIn}
|
loggedIn={loggedIn}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
<HomepageAlertManager
|
<HomepageAlertManager
|
||||||
userId={userId}
|
userId={userId}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, {useEffect, useState} from "react";
|
import React, {useEffect, useState} from "react";
|
||||||
import {HomepageAlertCard} from "./HomepageAlertCard.tsx";
|
import {HomepageAlertCard} from "./HomepageAlertCard.tsx";
|
||||||
import {Button} from "@/components/ui/button.tsx";
|
import {Button} from "@/components/ui/button.tsx";
|
||||||
import axios from "axios";
|
import { getUserAlerts, dismissAlert } from "@/ui/main-axios.ts";
|
||||||
|
|
||||||
interface TermixAlert {
|
interface TermixAlert {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -19,12 +19,6 @@ interface AlertManagerProps {
|
|||||||
loggedIn: boolean;
|
loggedIn: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiBase = import.meta.env.DEV ? "http://localhost:8081/alerts" : "/alerts";
|
|
||||||
|
|
||||||
const API = axios.create({
|
|
||||||
baseURL: apiBase,
|
|
||||||
});
|
|
||||||
|
|
||||||
export function HomepageAlertManager({userId, loggedIn}: AlertManagerProps): React.ReactElement {
|
export function HomepageAlertManager({userId, loggedIn}: AlertManagerProps): React.ReactElement {
|
||||||
const [alerts, setAlerts] = useState<TermixAlert[]>([]);
|
const [alerts, setAlerts] = useState<TermixAlert[]>([]);
|
||||||
const [currentAlertIndex, setCurrentAlertIndex] = useState(0);
|
const [currentAlertIndex, setCurrentAlertIndex] = useState(0);
|
||||||
@@ -44,9 +38,9 @@ export function HomepageAlertManager({userId, loggedIn}: AlertManagerProps): Rea
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await API.get(`/user/${userId}`);
|
const response = await getUserAlerts(userId);
|
||||||
|
|
||||||
const userAlerts = response.data.alerts || [];
|
const userAlerts = response.alerts || [];
|
||||||
|
|
||||||
const sortedAlerts = userAlerts.sort((a: TermixAlert, b: TermixAlert) => {
|
const sortedAlerts = userAlerts.sort((a: TermixAlert, b: TermixAlert) => {
|
||||||
const priorityOrder = {critical: 4, high: 3, medium: 2, low: 1};
|
const priorityOrder = {critical: 4, high: 3, medium: 2, low: 1};
|
||||||
@@ -73,10 +67,7 @@ export function HomepageAlertManager({userId, loggedIn}: AlertManagerProps): Rea
|
|||||||
if (!userId) return;
|
if (!userId) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await API.post('/dismiss', {
|
await dismissAlert(userId, alertId);
|
||||||
userId,
|
|
||||||
alertId
|
|
||||||
});
|
|
||||||
|
|
||||||
setAlerts(prev => {
|
setAlerts(prev => {
|
||||||
const newAlerts = prev.filter(alert => alert.id !== alertId);
|
const newAlerts = prev.filter(alert => alert.id !== alertId);
|
||||||
|
|||||||
@@ -1,10 +1,21 @@
|
|||||||
import React, {useState, useEffect} from "react";
|
import React, {useState, useEffect} from "react";
|
||||||
import {cn} from "@/lib/utils.ts";
|
import {cn} from "../../lib/utils.ts";
|
||||||
import {Button} from "@/components/ui/button.tsx";
|
import {Button} from "../../components/ui/button.tsx";
|
||||||
import {Input} from "@/components/ui/input.tsx";
|
import {Input} from "../../components/ui/input.tsx";
|
||||||
import {Label} from "@/components/ui/label.tsx";
|
import {Label} from "../../components/ui/label.tsx";
|
||||||
import {Alert, AlertTitle, AlertDescription} from "@/components/ui/alert.tsx";
|
import {Alert, AlertTitle, AlertDescription} from "../../components/ui/alert.tsx";
|
||||||
import axios from "axios";
|
import {
|
||||||
|
registerUser,
|
||||||
|
loginUser,
|
||||||
|
getUserInfo,
|
||||||
|
getRegistrationAllowed,
|
||||||
|
getOIDCConfig,
|
||||||
|
getUserCount,
|
||||||
|
initiatePasswordReset,
|
||||||
|
verifyPasswordResetCode,
|
||||||
|
completePasswordReset,
|
||||||
|
getOIDCAuthorizeUrl
|
||||||
|
} from "../main-axios.ts";
|
||||||
|
|
||||||
function setCookie(name: string, value: string, days = 7) {
|
function setCookie(name: string, value: string, days = 7) {
|
||||||
const expires = new Date(Date.now() + days * 864e5).toUTCString();
|
const expires = new Date(Date.now() + days * 864e5).toUTCString();
|
||||||
@@ -18,11 +29,7 @@ function getCookie(name: string) {
|
|||||||
}, "");
|
}, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiBase = import.meta.env.DEV ? "http://localhost:8081/users" : "/users";
|
|
||||||
|
|
||||||
const API = axios.create({
|
|
||||||
baseURL: apiBase,
|
|
||||||
});
|
|
||||||
|
|
||||||
interface HomepageAuthProps extends React.ComponentProps<"div"> {
|
interface HomepageAuthProps extends React.ComponentProps<"div"> {
|
||||||
setLoggedIn: (loggedIn: boolean) => void;
|
setLoggedIn: (loggedIn: boolean) => void;
|
||||||
@@ -74,14 +81,14 @@ export function HomepageAuth({
|
|||||||
}, [loggedIn]);
|
}, [loggedIn]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
API.get("/registration-allowed").then(res => {
|
getRegistrationAllowed().then(res => {
|
||||||
setRegistrationAllowed(res.data.allowed);
|
setRegistrationAllowed(res.allowed);
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
API.get("/oidc-config").then((response) => {
|
getOIDCConfig().then((response) => {
|
||||||
if (response.data) {
|
if (response) {
|
||||||
setOidcConfigured(true);
|
setOidcConfigured(true);
|
||||||
} else {
|
} else {
|
||||||
setOidcConfigured(false);
|
setOidcConfigured(false);
|
||||||
@@ -96,8 +103,8 @@ export function HomepageAuth({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
API.get("/count").then(res => {
|
getUserCount().then(res => {
|
||||||
if (res.data.count === 0) {
|
if (res.count === 0) {
|
||||||
setFirstUser(true);
|
setFirstUser(true);
|
||||||
setTab("signup");
|
setTab("signup");
|
||||||
} else {
|
} else {
|
||||||
@@ -123,7 +130,7 @@ export function HomepageAuth({
|
|||||||
try {
|
try {
|
||||||
let res, meRes;
|
let res, meRes;
|
||||||
if (tab === "login") {
|
if (tab === "login") {
|
||||||
res = await API.post("/login", {username: localUsername, password});
|
res = await loginUser(localUsername, password);
|
||||||
} else {
|
} else {
|
||||||
if (password !== signupConfirmPassword) {
|
if (password !== signupConfirmPassword) {
|
||||||
setError("Passwords do not match");
|
setError("Passwords do not match");
|
||||||
@@ -135,31 +142,37 @@ export function HomepageAuth({
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await API.post("/create", {username: localUsername, password});
|
|
||||||
res = await API.post("/login", {username: localUsername, password});
|
await registerUser(localUsername, password);
|
||||||
|
res = await loginUser(localUsername, password);
|
||||||
}
|
}
|
||||||
setCookie("jwt", res.data.token);
|
|
||||||
|
if (!res || !res.token) {
|
||||||
|
throw new Error('No token received from login');
|
||||||
|
}
|
||||||
|
|
||||||
|
setCookie("jwt", res.token);
|
||||||
[meRes] = await Promise.all([
|
[meRes] = await Promise.all([
|
||||||
API.get("/me", {headers: {Authorization: `Bearer ${res.data.token}`}}),
|
getUserInfo(),
|
||||||
API.get("/db-health")
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
setInternalLoggedIn(true);
|
setInternalLoggedIn(true);
|
||||||
setLoggedIn(true);
|
setLoggedIn(true);
|
||||||
setIsAdmin(!!meRes.data.is_admin);
|
setIsAdmin(!!meRes.is_admin);
|
||||||
setUsername(meRes.data.username || null);
|
setUsername(meRes.username || null);
|
||||||
setUserId(meRes.data.id || null);
|
setUserId(meRes.userId || null);
|
||||||
setDbError(null);
|
setDbError(null);
|
||||||
onAuthSuccess({
|
onAuthSuccess({
|
||||||
isAdmin: !!meRes.data.is_admin,
|
isAdmin: !!meRes.is_admin,
|
||||||
username: meRes.data.username || null,
|
username: meRes.username || null,
|
||||||
userId: meRes.data.id || null
|
userId: meRes.userId || null
|
||||||
});
|
});
|
||||||
setInternalLoggedIn(true);
|
setInternalLoggedIn(true);
|
||||||
if (tab === "signup") {
|
if (tab === "signup") {
|
||||||
setSignupConfirmPassword("");
|
setSignupConfirmPassword("");
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err?.response?.data?.error || "Unknown error");
|
setError(err?.response?.data?.error || err?.message || "Unknown error");
|
||||||
setInternalLoggedIn(false);
|
setInternalLoggedIn(false);
|
||||||
setLoggedIn(false);
|
setLoggedIn(false);
|
||||||
setIsAdmin(false);
|
setIsAdmin(false);
|
||||||
@@ -176,29 +189,26 @@ export function HomepageAuth({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function initiatePasswordReset() {
|
async function handleInitiatePasswordReset() {
|
||||||
setError(null);
|
setError(null);
|
||||||
setResetLoading(true);
|
setResetLoading(true);
|
||||||
try {
|
try {
|
||||||
await API.post("/initiate-reset", {username: localUsername});
|
const result = await initiatePasswordReset(localUsername);
|
||||||
setResetStep("verify");
|
setResetStep("verify");
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err?.response?.data?.error || "Failed to initiate password reset");
|
setError(err?.response?.data?.error || err?.message || "Failed to initiate password reset");
|
||||||
} finally {
|
} finally {
|
||||||
setResetLoading(false);
|
setResetLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function verifyResetCode() {
|
async function handleVerifyResetCode() {
|
||||||
setError(null);
|
setError(null);
|
||||||
setResetLoading(true);
|
setResetLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await API.post("/verify-reset-code", {
|
const response = await verifyPasswordResetCode(localUsername, resetCode);
|
||||||
username: localUsername,
|
setTempToken(response.tempToken);
|
||||||
resetCode: resetCode
|
|
||||||
});
|
|
||||||
setTempToken(response.data.tempToken);
|
|
||||||
setResetStep("newPassword");
|
setResetStep("newPassword");
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -208,7 +218,7 @@ export function HomepageAuth({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function completePasswordReset() {
|
async function handleCompletePasswordReset() {
|
||||||
setError(null);
|
setError(null);
|
||||||
setResetLoading(true);
|
setResetLoading(true);
|
||||||
|
|
||||||
@@ -225,11 +235,7 @@ export function HomepageAuth({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await API.post("/complete-reset", {
|
await completePasswordReset(localUsername, tempToken, newPassword);
|
||||||
username: localUsername,
|
|
||||||
tempToken: tempToken,
|
|
||||||
newPassword: newPassword
|
|
||||||
});
|
|
||||||
|
|
||||||
setResetStep("initiate");
|
setResetStep("initiate");
|
||||||
setResetCode("");
|
setResetCode("");
|
||||||
@@ -267,8 +273,8 @@ export function HomepageAuth({
|
|||||||
setError(null);
|
setError(null);
|
||||||
setOidcLoading(true);
|
setOidcLoading(true);
|
||||||
try {
|
try {
|
||||||
const authResponse = await API.get("/oidc/authorize");
|
const authResponse = await getOIDCAuthorizeUrl();
|
||||||
const {auth_url: authUrl} = authResponse.data;
|
const {auth_url: authUrl} = authResponse;
|
||||||
|
|
||||||
if (!authUrl || authUrl === 'undefined') {
|
if (!authUrl || authUrl === 'undefined') {
|
||||||
throw new Error('Invalid authorization URL received from backend');
|
throw new Error('Invalid authorization URL received from backend');
|
||||||
@@ -299,18 +305,18 @@ export function HomepageAuth({
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
setCookie("jwt", token);
|
setCookie("jwt", token);
|
||||||
API.get("/me", {headers: {Authorization: `Bearer ${token}`}})
|
getUserInfo()
|
||||||
.then(meRes => {
|
.then(meRes => {
|
||||||
setInternalLoggedIn(true);
|
setInternalLoggedIn(true);
|
||||||
setLoggedIn(true);
|
setLoggedIn(true);
|
||||||
setIsAdmin(!!meRes.data.is_admin);
|
setIsAdmin(!!meRes.is_admin);
|
||||||
setUsername(meRes.data.username || null);
|
setUsername(meRes.username || null);
|
||||||
setUserId(meRes.data.id || null);
|
setUserId(meRes.id || null);
|
||||||
setDbError(null);
|
setDbError(null);
|
||||||
onAuthSuccess({
|
onAuthSuccess({
|
||||||
isAdmin: !!meRes.data.is_admin,
|
isAdmin: !!meRes.is_admin,
|
||||||
username: meRes.data.username || null,
|
username: meRes.username || null,
|
||||||
userId: meRes.data.id || null
|
userId: meRes.id || null
|
||||||
});
|
});
|
||||||
setInternalLoggedIn(true);
|
setInternalLoggedIn(true);
|
||||||
window.history.replaceState({}, document.title, window.location.pathname);
|
window.history.replaceState({}, document.title, window.location.pathname);
|
||||||
@@ -340,7 +346,7 @@ export function HomepageAuth({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`w-[420px] max-w-full p-6 flex flex-col ${className || ''}`}
|
className={`w-[420px] max-w-full p-6 flex flex-col bg-[#18181b] border-2 border-[#303032] rounded-md ${className || ''}`}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{dbError && (
|
{dbError && (
|
||||||
@@ -486,7 +492,7 @@ export function HomepageAuth({
|
|||||||
type="button"
|
type="button"
|
||||||
className="w-full h-11 text-base font-semibold"
|
className="w-full h-11 text-base font-semibold"
|
||||||
disabled={resetLoading || !localUsername.trim()}
|
disabled={resetLoading || !localUsername.trim()}
|
||||||
onClick={initiatePasswordReset}
|
onClick={handleInitiatePasswordReset}
|
||||||
>
|
>
|
||||||
{resetLoading ? Spinner : "Send Reset Code"}
|
{resetLoading ? Spinner : "Send Reset Code"}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -519,7 +525,7 @@ export function HomepageAuth({
|
|||||||
type="button"
|
type="button"
|
||||||
className="w-full h-11 text-base font-semibold"
|
className="w-full h-11 text-base font-semibold"
|
||||||
disabled={resetLoading || resetCode.length !== 6}
|
disabled={resetLoading || resetCode.length !== 6}
|
||||||
onClick={verifyResetCode}
|
onClick={handleVerifyResetCode}
|
||||||
>
|
>
|
||||||
{resetLoading ? Spinner : "Verify Code"}
|
{resetLoading ? Spinner : "Verify Code"}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -598,7 +604,7 @@ export function HomepageAuth({
|
|||||||
type="button"
|
type="button"
|
||||||
className="w-full h-11 text-base font-semibold"
|
className="w-full h-11 text-base font-semibold"
|
||||||
disabled={resetLoading || !newPassword || !confirmPassword}
|
disabled={resetLoading || !newPassword || !confirmPassword}
|
||||||
onClick={completePasswordReset}
|
onClick={handleCompletePasswordReset}
|
||||||
>
|
>
|
||||||
{resetLoading ? Spinner : "Reset Password"}
|
{resetLoading ? Spinner : "Reset Password"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, {useEffect, useState} from "react";
|
|||||||
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert.tsx";
|
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert.tsx";
|
||||||
import {Button} from "@/components/ui/button.tsx";
|
import {Button} from "@/components/ui/button.tsx";
|
||||||
import {Separator} from "@/components/ui/separator.tsx";
|
import {Separator} from "@/components/ui/separator.tsx";
|
||||||
import axios from "axios";
|
import { getReleasesRSS, getVersionInfo } from "@/ui/main-axios.ts";
|
||||||
|
|
||||||
interface HomepageUpdateLogProps extends React.ComponentProps<"div"> {
|
interface HomepageUpdateLogProps extends React.ComponentProps<"div"> {
|
||||||
loggedIn: boolean;
|
loggedIn: boolean;
|
||||||
@@ -50,12 +50,6 @@ interface VersionResponse {
|
|||||||
cache_age?: number;
|
cache_age?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiBase = import.meta.env.DEV ? "http://localhost:8081" : "";
|
|
||||||
|
|
||||||
const API = axios.create({
|
|
||||||
baseURL: apiBase,
|
|
||||||
});
|
|
||||||
|
|
||||||
export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) {
|
export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) {
|
||||||
const [releases, setReleases] = useState<RSSResponse | null>(null);
|
const [releases, setReleases] = useState<RSSResponse | null>(null);
|
||||||
const [versionInfo, setVersionInfo] = useState<VersionResponse | null>(null);
|
const [versionInfo, setVersionInfo] = useState<VersionResponse | null>(null);
|
||||||
@@ -66,12 +60,12 @@ export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) {
|
|||||||
if (loggedIn) {
|
if (loggedIn) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
Promise.all([
|
Promise.all([
|
||||||
API.get('/releases/rss?per_page=100'),
|
getReleasesRSS(100),
|
||||||
API.get('/version/')
|
getVersionInfo()
|
||||||
])
|
])
|
||||||
.then(([releasesRes, versionRes]) => {
|
.then(([releasesRes, versionRes]) => {
|
||||||
setReleases(releasesRes.data);
|
setReleases(releasesRes);
|
||||||
setVersionInfo(versionRes.data);
|
setVersionInfo(versionRes);
|
||||||
setError(null);
|
setError(null);
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
@@ -95,70 +89,63 @@ export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-[400px] h-[600px] flex flex-col border-2 border-border rounded-lg bg-card p-4">
|
<div className="w-[400px] h-[600px] flex flex-col border-2 border-[#303032] rounded-lg bg-[#18181b] p-4 shadow-lg">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold mb-3">Updates & Releases</h3>
|
<h3 className="text-lg font-bold mb-3 text-white">Updates & Releases</h3>
|
||||||
|
|
||||||
<Separator className="p-0.25 mt-3 mb-3"/>
|
<Separator className="p-0.25 mt-3 mb-3 bg-[#303032]"/>
|
||||||
|
|
||||||
{versionInfo && versionInfo.status === 'requires_update' && (
|
{versionInfo && versionInfo.status === 'requires_update' && (
|
||||||
<Alert>
|
<Alert className="bg-[#0e0e10] border-[#303032] text-white">
|
||||||
<AlertTitle>Update Available</AlertTitle>
|
<AlertTitle className="text-white">Update Available</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription className="text-gray-300">
|
||||||
A new version ({versionInfo.version}) is available.
|
A new version ({versionInfo.version}) is available.
|
||||||
<Button
|
|
||||||
variant="link"
|
|
||||||
className="p-0 h-auto underline ml-1"
|
|
||||||
onClick={() => window.open("https://docs.termix.site/docs", '_blank')}
|
|
||||||
>
|
|
||||||
Update now
|
|
||||||
</Button>
|
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{versionInfo && versionInfo.status === 'requires_update' && (
|
{versionInfo && versionInfo.status === 'requires_update' && (
|
||||||
<Separator className="p-0.25 mt-3 mb-3"/>
|
<Separator className="p-0.25 mt-3 mb-3 bg-[#303032]"/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto space-y-3">
|
<div className="flex-1 overflow-y-auto space-y-3 pr-2">
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="flex items-center justify-center h-32">
|
<div className="flex items-center justify-center h-32">
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive" className="bg-red-900/20 border-red-500 text-red-300">
|
||||||
<AlertTitle>Error</AlertTitle>
|
<AlertTitle className="text-red-300">Error</AlertTitle>
|
||||||
<AlertDescription>{error}</AlertDescription>
|
<AlertDescription className="text-red-300">{error}</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{releases?.items.map((release) => (
|
{releases?.items.map((release) => (
|
||||||
<div
|
<div
|
||||||
key={release.id}
|
key={release.id}
|
||||||
className="border border-border rounded-lg p-3 hover:bg-accent transition-colors cursor-pointer"
|
className="border border-[#303032] rounded-lg p-3 hover:bg-[#0e0e10] transition-colors cursor-pointer bg-[#0e0e10]/50"
|
||||||
onClick={() => window.open(release.link, '_blank')}
|
onClick={() => window.open(release.link, '_blank')}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between mb-2">
|
<div className="flex items-start justify-between mb-2">
|
||||||
<h4 className="font-medium text-sm leading-tight flex-1">
|
<h4 className="font-semibold text-sm leading-tight flex-1 text-white">
|
||||||
{release.title}
|
{release.title}
|
||||||
</h4>
|
</h4>
|
||||||
{release.isPrerelease && (
|
{release.isPrerelease && (
|
||||||
<span
|
<span
|
||||||
className="text-xs bg-yellow-100 text-yellow-800 px-2 py-1 rounded ml-2 flex-shrink-0">
|
className="text-xs bg-yellow-600 text-yellow-100 px-2 py-1 rounded ml-2 flex-shrink-0 font-medium">
|
||||||
Pre-release
|
Pre-release
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-xs text-muted-foreground mb-2 leading-relaxed">
|
<p className="text-xs text-gray-300 mb-2 leading-relaxed">
|
||||||
{formatDescription(release.description)}
|
{formatDescription(release.description)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex items-center text-xs text-muted-foreground">
|
<div className="flex items-center text-xs text-gray-400">
|
||||||
<span>{new Date(release.pubDate).toLocaleDateString()}</span>
|
<span>{new Date(release.pubDate).toLocaleDateString()}</span>
|
||||||
{release.assets.length > 0 && (
|
{release.assets.length > 0 && (
|
||||||
<>
|
<>
|
||||||
@@ -171,9 +158,9 @@ export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) {
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
{releases && releases.items.length === 0 && !loading && (
|
{releases && releases.items.length === 0 && !loading && (
|
||||||
<Alert>
|
<Alert className="bg-[#0e0e10] border-[#303032] text-gray-300">
|
||||||
<AlertTitle>No Releases</AlertTitle>
|
<AlertTitle className="text-gray-300">No Releases</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription className="text-gray-400">
|
||||||
No releases found.
|
No releases found.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, {useEffect, useRef, useState} from "react";
|
import React, {useEffect, useRef, useState} from "react";
|
||||||
import {TerminalComponent} from "@/ui/apps/Terminal/TerminalComponent.tsx";
|
import {Terminal} from "@/ui/apps/Terminal/Terminal.tsx";
|
||||||
import {Server as ServerView} from "@/ui/apps/Server/Server.tsx";
|
import {Server as ServerView} from "@/ui/apps/Server/Server.tsx";
|
||||||
import {FileManager} from "@/ui/apps/File Manager/FileManager.tsx";
|
import {FileManager} from "@/ui/apps/File Manager/FileManager.tsx";
|
||||||
import {useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx";
|
import {useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx";
|
||||||
@@ -108,12 +108,13 @@ export function AppView({isTopbarOpen = true}: TerminalViewProps): React.ReactEl
|
|||||||
const layoutTabs = [mainTab, ...splitTabs.filter((t: any) => t && t.id !== (mainTab && (mainTab as any).id))].filter(Boolean) as any[];
|
const layoutTabs = [mainTab, ...splitTabs.filter((t: any) => t && t.id !== (mainTab && (mainTab as any).id))].filter(Boolean) as any[];
|
||||||
|
|
||||||
if (allSplitScreenTab.length === 0 && mainTab) {
|
if (allSplitScreenTab.length === 0 && mainTab) {
|
||||||
|
const isFileManagerTab = mainTab.type === 'file_manager';
|
||||||
styles[mainTab.id] = {
|
styles[mainTab.id] = {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 2,
|
top: isFileManagerTab ? 0 : 2,
|
||||||
left: 2,
|
left: isFileManagerTab ? 0 : 2,
|
||||||
right: 2,
|
right: isFileManagerTab ? 0 : 2,
|
||||||
bottom: 2,
|
bottom: isFileManagerTab ? 0 : 2,
|
||||||
zIndex: 20,
|
zIndex: 20,
|
||||||
display: 'block',
|
display: 'block',
|
||||||
pointerEvents: 'auto',
|
pointerEvents: 'auto',
|
||||||
@@ -154,9 +155,9 @@ export function AppView({isTopbarOpen = true}: TerminalViewProps): React.ReactEl
|
|||||||
const effectiveVisible = isVisible && ready;
|
const effectiveVisible = isVisible && ready;
|
||||||
return (
|
return (
|
||||||
<div key={t.id} style={finalStyle}>
|
<div key={t.id} style={finalStyle}>
|
||||||
<div className="absolute inset-0 rounded-md" style={{background: '#18181b'}}>
|
<div className="absolute inset-0 rounded-md bg-[#18181b]">
|
||||||
{t.type === 'terminal' ? (
|
{t.type === 'terminal' ? (
|
||||||
<TerminalComponent
|
<Terminal
|
||||||
ref={t.terminalRef}
|
ref={t.terminalRef}
|
||||||
hostConfig={t.hostConfig}
|
hostConfig={t.hostConfig}
|
||||||
isVisible={effectiveVisible}
|
isVisible={effectiveVisible}
|
||||||
@@ -523,6 +524,10 @@ export function AppView({isTopbarOpen = true}: TerminalViewProps): React.ReactEl
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const currentTabData = tabs.find((tab: any) => tab.id === currentTab);
|
||||||
|
const isFileManager = currentTabData?.type === 'file_manager';
|
||||||
|
const isSplitScreen = allSplitScreenTab.length > 0;
|
||||||
|
|
||||||
const topMarginPx = isTopbarOpen ? 74 : 26;
|
const topMarginPx = isTopbarOpen ? 74 : 26;
|
||||||
const leftMarginPx = sidebarState === 'collapsed' ? 26 : 8;
|
const leftMarginPx = sidebarState === 'collapsed' ? 26 : 8;
|
||||||
const bottomMarginPx = 8;
|
const bottomMarginPx = 8;
|
||||||
@@ -533,7 +538,7 @@ export function AppView({isTopbarOpen = true}: TerminalViewProps): React.ReactEl
|
|||||||
className="border-2 border-[#303032] rounded-lg overflow-hidden overflow-x-hidden"
|
className="border-2 border-[#303032] rounded-lg overflow-hidden overflow-x-hidden"
|
||||||
style={{
|
style={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
background: '#18181b',
|
background: (isFileManager && !isSplitScreen) ? '#09090b' : '#18181b',
|
||||||
marginLeft: leftMarginPx,
|
marginLeft: leftMarginPx,
|
||||||
marginRight: 17,
|
marginRight: 17,
|
||||||
marginTop: topMarginPx,
|
marginTop: topMarginPx,
|
||||||
|
|||||||
@@ -45,11 +45,18 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table.tsx";
|
} from "@/components/ui/table.tsx";
|
||||||
import axios from "axios";
|
|
||||||
import {Card} from "@/components/ui/card.tsx";
|
import {Card} from "@/components/ui/card.tsx";
|
||||||
import {FolderCard} from "@/ui/Navigation/Hosts/FolderCard.tsx";
|
import {FolderCard} from "@/ui/Navigation/Hosts/FolderCard.tsx";
|
||||||
import {getSSHHosts} from "@/ui/main-axios.ts";
|
import {getSSHHosts} from "@/ui/main-axios.ts";
|
||||||
import {useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx";
|
import {useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx";
|
||||||
|
import {
|
||||||
|
getOIDCConfig,
|
||||||
|
getUserList,
|
||||||
|
makeUserAdmin,
|
||||||
|
removeAdminStatus,
|
||||||
|
deleteUser,
|
||||||
|
deleteAccount
|
||||||
|
} from "@/ui/main-axios.ts";
|
||||||
|
|
||||||
interface SSHHost {
|
interface SSHHost {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -95,11 +102,7 @@ function getCookie(name: string) {
|
|||||||
}, "");
|
}, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiBase = import.meta.env.DEV ? "http://localhost:8081/users" : "/users";
|
|
||||||
|
|
||||||
const API = axios.create({
|
|
||||||
baseURL: apiBase,
|
|
||||||
});
|
|
||||||
|
|
||||||
export function LeftSidebar({
|
export function LeftSidebar({
|
||||||
onSelectView,
|
onSelectView,
|
||||||
@@ -162,9 +165,9 @@ export function LeftSidebar({
|
|||||||
if (adminSheetOpen) {
|
if (adminSheetOpen) {
|
||||||
const jwt = getCookie("jwt");
|
const jwt = getCookie("jwt");
|
||||||
if (jwt && isAdmin) {
|
if (jwt && isAdmin) {
|
||||||
API.get("/oidc-config").then(res => {
|
getOIDCConfig().then(res => {
|
||||||
if (res.data) {
|
if (res) {
|
||||||
setOidcConfig(res.data);
|
setOidcConfig(res);
|
||||||
}
|
}
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
});
|
});
|
||||||
@@ -235,7 +238,7 @@ export function LeftSidebar({
|
|||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
fetchHosts();
|
fetchHosts();
|
||||||
const interval = setInterval(fetchHosts, 10000);
|
const interval = setInterval(fetchHosts, 300000); // 5 minutes instead of 10 seconds
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [fetchHosts]);
|
}, [fetchHosts]);
|
||||||
|
|
||||||
@@ -308,10 +311,7 @@ export function LeftSidebar({
|
|||||||
|
|
||||||
const jwt = getCookie("jwt");
|
const jwt = getCookie("jwt");
|
||||||
try {
|
try {
|
||||||
await API.delete("/delete-account", {
|
await deleteAccount(deletePassword);
|
||||||
headers: {Authorization: `Bearer ${jwt}`},
|
|
||||||
data: {password: deletePassword}
|
|
||||||
});
|
|
||||||
|
|
||||||
handleLogout();
|
handleLogout();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -329,12 +329,10 @@ export function LeftSidebar({
|
|||||||
|
|
||||||
setUsersLoading(true);
|
setUsersLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await API.get("/list", {
|
const response = await getUserList();
|
||||||
headers: {Authorization: `Bearer ${jwt}`}
|
setUsers(response.users);
|
||||||
});
|
|
||||||
setUsers(response.data.users);
|
|
||||||
|
|
||||||
const adminUsers = response.data.users.filter((user: any) => user.is_admin);
|
const adminUsers = response.users.filter((user: any) => user.is_admin);
|
||||||
setAdminCount(adminUsers.length);
|
setAdminCount(adminUsers.length);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
} finally {
|
} finally {
|
||||||
@@ -350,10 +348,8 @@ export function LeftSidebar({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await API.get("/list", {
|
const response = await getUserList();
|
||||||
headers: {Authorization: `Bearer ${jwt}`}
|
const adminUsers = response.users.filter((user: any) => user.is_admin);
|
||||||
});
|
|
||||||
const adminUsers = response.data.users.filter((user: any) => user.is_admin);
|
|
||||||
setAdminCount(adminUsers.length);
|
setAdminCount(adminUsers.length);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
}
|
}
|
||||||
@@ -373,10 +369,7 @@ export function LeftSidebar({
|
|||||||
|
|
||||||
const jwt = getCookie("jwt");
|
const jwt = getCookie("jwt");
|
||||||
try {
|
try {
|
||||||
await API.post("/make-admin",
|
await makeUserAdmin(newAdminUsername.trim());
|
||||||
{username: newAdminUsername.trim()},
|
|
||||||
{headers: {Authorization: `Bearer ${jwt}`}}
|
|
||||||
);
|
|
||||||
setMakeAdminSuccess(`User ${newAdminUsername} is now an admin`);
|
setMakeAdminSuccess(`User ${newAdminUsername} is now an admin`);
|
||||||
setNewAdminUsername("");
|
setNewAdminUsername("");
|
||||||
fetchUsers();
|
fetchUsers();
|
||||||
@@ -396,10 +389,7 @@ export function LeftSidebar({
|
|||||||
|
|
||||||
const jwt = getCookie("jwt");
|
const jwt = getCookie("jwt");
|
||||||
try {
|
try {
|
||||||
await API.post("/remove-admin",
|
await removeAdminStatus(username);
|
||||||
{username},
|
|
||||||
{headers: {Authorization: `Bearer ${jwt}`}}
|
|
||||||
);
|
|
||||||
fetchUsers();
|
fetchUsers();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
}
|
}
|
||||||
@@ -414,10 +404,7 @@ export function LeftSidebar({
|
|||||||
|
|
||||||
const jwt = getCookie("jwt");
|
const jwt = getCookie("jwt");
|
||||||
try {
|
try {
|
||||||
await API.delete("/delete-user", {
|
await deleteUser(username);
|
||||||
headers: {Authorization: `Bearer ${jwt}`},
|
|
||||||
data: {username}
|
|
||||||
});
|
|
||||||
fetchUsers();
|
fetchUsers();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export function TabProvider({children}: TabProviderProps) {
|
|||||||
const usedNumbers = new Set<number>();
|
const usedNumbers = new Set<number>();
|
||||||
let rootUsed = false;
|
let rootUsed = false;
|
||||||
tabs.forEach(t => {
|
tabs.forEach(t => {
|
||||||
if (t.type !== tabType || !t.title) return;
|
if (!t.title) return;
|
||||||
if (t.title === root) {
|
if (t.title === root) {
|
||||||
rootUsed = true;
|
rootUsed = true;
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {Button} from '@/components/ui/button.tsx';
|
|||||||
import {FIleManagerTopNavbar} from "@/ui/apps/File Manager/FIleManagerTopNavbar.tsx";
|
import {FIleManagerTopNavbar} from "@/ui/apps/File Manager/FIleManagerTopNavbar.tsx";
|
||||||
import {cn} from '@/lib/utils.ts';
|
import {cn} from '@/lib/utils.ts';
|
||||||
import {Save, RefreshCw, Settings, Trash2} from 'lucide-react';
|
import {Save, RefreshCw, Settings, Trash2} from 'lucide-react';
|
||||||
|
import {Separator} from '@/components/ui/separator.tsx';
|
||||||
import {toast} from 'sonner';
|
import {toast} from 'sonner';
|
||||||
import {
|
import {
|
||||||
getFileManagerRecent,
|
getFileManagerRecent,
|
||||||
@@ -489,7 +490,7 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
|
|||||||
|
|
||||||
if (!currentHost) {
|
if (!currentHost) {
|
||||||
return (
|
return (
|
||||||
<div style={{position: 'relative', width: '100%', height: '100%', overflow: 'hidden'}}>
|
<div style={{position: 'absolute', inset: 0, overflow: 'hidden'}} className="rounded-md">
|
||||||
<div style={{position: 'absolute', top: 0, left: 0, width: 256, height: '100%', zIndex: 20}}>
|
<div style={{position: 'absolute', top: 0, left: 0, width: 256, height: '100%', zIndex: 20}}>
|
||||||
<FileManagerLeftSidebar
|
<FileManagerLeftSidebar
|
||||||
onSelectView={onSelectView || (() => {
|
onSelectView={onSelectView || (() => {
|
||||||
@@ -525,7 +526,7 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{position: 'relative', width: '100%', height: '100%', overflow: 'hidden'}}>
|
<div style={{position: 'absolute', inset: 0, overflow: 'hidden'}} className="rounded-md">
|
||||||
<div style={{position: 'absolute', top: 0, left: 0, width: 256, height: '100%', zIndex: 20}}>
|
<div style={{position: 'absolute', top: 0, left: 0, width: 256, height: '100%', zIndex: 20}}>
|
||||||
<FileManagerLeftSidebar
|
<FileManagerLeftSidebar
|
||||||
onSelectView={onSelectView || (() => {
|
onSelectView={onSelectView || (() => {
|
||||||
@@ -570,6 +571,7 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
|
|||||||
>
|
>
|
||||||
<Settings className="h-4 w-4"/>
|
<Settings className="h-4 w-4"/>
|
||||||
</Button>
|
</Button>
|
||||||
|
<div className="p-0.25 w-px h-[30px] bg-[#303032]"></div>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -599,9 +601,9 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column'
|
flexDirection: 'column'
|
||||||
}}>
|
}}>
|
||||||
{activeTab === 'home' ? (
|
<div className="flex h-full">
|
||||||
<div className="flex h-full">
|
<div className="flex-1">
|
||||||
<div className="flex-1">
|
{activeTab === 'home' ? (
|
||||||
<FileManagerHomeView
|
<FileManagerHomeView
|
||||||
recent={recent}
|
recent={recent}
|
||||||
pinned={pinned}
|
pinned={pinned}
|
||||||
@@ -614,36 +616,36 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
|
|||||||
onRemoveShortcut={handleRemoveShortcut}
|
onRemoveShortcut={handleRemoveShortcut}
|
||||||
onAddShortcut={handleAddShortcut}
|
onAddShortcut={handleAddShortcut}
|
||||||
/>
|
/>
|
||||||
</div>
|
) : (
|
||||||
{showOperations && (
|
(() => {
|
||||||
<div className="w-80 border-l-2 border-[#303032] bg-[#09090b] overflow-y-auto">
|
const tab = tabs.find(t => t.id === activeTab);
|
||||||
<FileManagerOperations
|
if (!tab) return null;
|
||||||
currentPath={currentPath}
|
return (
|
||||||
sshSessionId={currentHost?.id.toString() || null}
|
<div className="flex flex-col h-full" style={{flex: 1, minHeight: 0}}>
|
||||||
onOperationComplete={handleOperationComplete}
|
<div className="flex-1 min-h-0">
|
||||||
onError={handleError}
|
<FileManagerFileEditor
|
||||||
onSuccess={handleSuccess}
|
content={tab.content}
|
||||||
/>
|
fileName={tab.fileName}
|
||||||
</div>
|
onContentChange={content => setTabContent(tab.id, content)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
{showOperations && (
|
||||||
(() => {
|
<div className="w-80 border-l-2 border-[#303032] bg-[#09090b] overflow-y-auto">
|
||||||
const tab = tabs.find(t => t.id === activeTab);
|
<FileManagerOperations
|
||||||
if (!tab) return null;
|
currentPath={currentPath}
|
||||||
return (
|
sshSessionId={currentHost?.id.toString() || null}
|
||||||
<div className="flex flex-col h-full" style={{flex: 1, minHeight: 0}}>
|
onOperationComplete={handleOperationComplete}
|
||||||
<div className="flex-1 min-h-0">
|
onError={handleError}
|
||||||
<FileManagerFileEditor
|
onSuccess={handleSuccess}
|
||||||
content={tab.content}
|
/>
|
||||||
fileName={tab.fileName}
|
</div>
|
||||||
onContentChange={content => setTabContent(tab.id, content)}
|
)}
|
||||||
/>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{deletingItem && (
|
{deletingItem && (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, {useState, useRef} from 'react';
|
import React, {useState, useRef, useEffect} from 'react';
|
||||||
import {Button} from '@/components/ui/button.tsx';
|
import {Button} from '@/components/ui/button.tsx';
|
||||||
import {Input} from '@/components/ui/input.tsx';
|
import {Input} from '@/components/ui/input.tsx';
|
||||||
import {Card} from '@/components/ui/card.tsx';
|
import {Card} from '@/components/ui/card.tsx';
|
||||||
@@ -48,7 +48,29 @@ export function FileManagerOperations({
|
|||||||
const [newName, setNewName] = useState('');
|
const [newName, setNewName] = useState('');
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [showTextLabels, setShowTextLabels] = useState(true);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const checkContainerWidth = () => {
|
||||||
|
if (containerRef.current) {
|
||||||
|
const width = containerRef.current.offsetWidth;
|
||||||
|
setShowTextLabels(width > 240);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkContainerWidth();
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(checkContainerWidth);
|
||||||
|
if (containerRef.current) {
|
||||||
|
resizeObserver.observe(containerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleFileUpload = async () => {
|
const handleFileUpload = async () => {
|
||||||
if (!uploadFile || !sshSessionId) return;
|
if (!uploadFile || !sshSessionId) return;
|
||||||
@@ -186,113 +208,121 @@ export function FileManagerOperations({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 space-y-4">
|
<div ref={containerRef} className="p-4 space-y-4">
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setShowUpload(true)}
|
onClick={() => setShowUpload(true)}
|
||||||
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]"
|
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]"
|
||||||
|
title="Upload File"
|
||||||
>
|
>
|
||||||
<Upload className="w-4 h-4 mr-2"/>
|
<Upload className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")}/>
|
||||||
Upload File
|
{showTextLabels && <span className="truncate">Upload File</span>}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setShowCreateFile(true)}
|
onClick={() => setShowCreateFile(true)}
|
||||||
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]"
|
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]"
|
||||||
|
title="New File"
|
||||||
>
|
>
|
||||||
<FilePlus className="w-4 h-4 mr-2"/>
|
<FilePlus className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")}/>
|
||||||
New File
|
{showTextLabels && <span className="truncate">New File</span>}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setShowCreateFolder(true)}
|
onClick={() => setShowCreateFolder(true)}
|
||||||
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]"
|
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]"
|
||||||
|
title="New Folder"
|
||||||
>
|
>
|
||||||
<FolderPlus className="w-4 h-4 mr-2"/>
|
<FolderPlus className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")}/>
|
||||||
New Folder
|
{showTextLabels && <span className="truncate">New Folder</span>}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setShowRename(true)}
|
onClick={() => setShowRename(true)}
|
||||||
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]"
|
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]"
|
||||||
|
title="Rename"
|
||||||
>
|
>
|
||||||
<Edit3 className="w-4 h-4 mr-2"/>
|
<Edit3 className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")}/>
|
||||||
Rename
|
{showTextLabels && <span className="truncate">Rename</span>}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setShowDelete(true)}
|
onClick={() => setShowDelete(true)}
|
||||||
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30] col-span-2"
|
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30] col-span-2"
|
||||||
|
title="Delete Item"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4 mr-2"/>
|
<Trash2 className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")}/>
|
||||||
Delete Item
|
{showTextLabels && <span className="truncate">Delete Item</span>}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-[#18181b] border-2 border-[#303032] rounded-lg p-3">
|
<div className="bg-[#141416] border-2 border-[#373739] rounded-md p-3">
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-start gap-2 text-sm">
|
||||||
<Folder className="w-4 h-4 text-blue-400"/>
|
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0 mt-0.5"/>
|
||||||
<span className="text-muted-foreground">Current Path:</span>
|
<div className="flex-1 min-w-0">
|
||||||
<span className="text-white font-mono truncate">{currentPath}</span>
|
<span className="text-muted-foreground block mb-1">Current Path:</span>
|
||||||
|
<span className="text-white font-mono text-xs break-all leading-relaxed">{currentPath}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator className="p-0.25 bg-[#303032]"/>
|
<Separator className="p-0.25 bg-[#303032]"/>
|
||||||
|
|
||||||
{showUpload && (
|
{showUpload && (
|
||||||
<Card className="bg-[#18181b] border-2 border-[#303032] p-4">
|
<Card className="bg-[#18181b] border-2 border-[#303032] p-3 sm:p-4">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-start justify-between mb-3">
|
||||||
<div>
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
<h3 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2 mb-1">
|
||||||
<Upload className="w-5 h-5"/>
|
<Upload className="w-5 h-5 sm:w-6 sm:h-6 flex-shrink-0"/>
|
||||||
Upload File
|
<span className="break-words">Upload File</span>
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
<p className="text-xs text-muted-foreground break-words">
|
||||||
Maximum file size: 100MB (JSON) / 200MB (Binary)
|
Max: 100MB (JSON) / 200MB (Binary)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setShowUpload(false)}
|
onClick={() => setShowUpload(false)}
|
||||||
className="h-8 w-8 p-0"
|
className="h-8 w-8 p-0 flex-shrink-0 ml-2"
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4"/>
|
<X className="w-4 h-4"/>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-3">
|
||||||
<div className="border-2 border-dashed border-[#434345] rounded-lg p-6 text-center">
|
<div className="border-2 border-dashed border-[#434345] rounded-lg p-4 text-center">
|
||||||
{uploadFile ? (
|
{uploadFile ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-3">
|
||||||
<FileText className="w-8 h-8 text-blue-400 mx-auto"/>
|
<FileText className="w-12 h-12 text-blue-400 mx-auto"/>
|
||||||
<p className="text-white font-medium">{uploadFile.name}</p>
|
<p className="text-white font-medium text-sm break-words px-2">{uploadFile.name}</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{(uploadFile.size / 1024).toFixed(2)} KB
|
{(uploadFile.size / 1024).toFixed(2)} KB
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setUploadFile(null)}
|
onClick={() => setUploadFile(null)}
|
||||||
className="mt-2"
|
className="w-full text-sm h-8"
|
||||||
>
|
>
|
||||||
Remove File
|
Remove File
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-3">
|
||||||
<Upload className="w-8 h-8 text-muted-foreground mx-auto"/>
|
<Upload className="w-12 h-12 text-muted-foreground mx-auto"/>
|
||||||
<p className="text-white">Click to select a file</p>
|
<p className="text-white text-sm break-words px-2">Click to select a file</p>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={openFileDialog}
|
onClick={openFileDialog}
|
||||||
|
className="w-full text-sm h-8"
|
||||||
>
|
>
|
||||||
Choose File
|
Choose File
|
||||||
</Button>
|
</Button>
|
||||||
@@ -308,11 +338,11 @@ export function FileManagerOperations({
|
|||||||
accept="*/*"
|
accept="*/*"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Button
|
<Button
|
||||||
onClick={handleFileUpload}
|
onClick={handleFileUpload}
|
||||||
disabled={!uploadFile || isLoading}
|
disabled={!uploadFile || isLoading}
|
||||||
className="flex-1"
|
className="w-full text-sm h-9"
|
||||||
>
|
>
|
||||||
{isLoading ? 'Uploading...' : 'Upload File'}
|
{isLoading ? 'Uploading...' : 'Upload File'}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -320,6 +350,7 @@ export function FileManagerOperations({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setShowUpload(false)}
|
onClick={() => setShowUpload(false)}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
|
className="w-full text-sm h-9"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
@@ -329,23 +360,25 @@ export function FileManagerOperations({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{showCreateFile && (
|
{showCreateFile && (
|
||||||
<Card className="bg-[#18181b] border-2 border-[#303032] p-4">
|
<Card className="bg-[#18181b] border-2 border-[#303032] p-3 sm:p-4">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-start justify-between mb-3">
|
||||||
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
<div className="flex-1 min-w-0">
|
||||||
<FilePlus className="w-5 h-5"/>
|
<h3 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2">
|
||||||
Create New File
|
<FilePlus className="w-5 h-5 sm:w-6 sm:h-6 flex-shrink-0"/>
|
||||||
</h3>
|
<span className="break-words">Create New File</span>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setShowCreateFile(false)}
|
onClick={() => setShowCreateFile(false)}
|
||||||
className="h-8 w-8 p-0"
|
className="h-8 w-8 p-0 flex-shrink-0 ml-2"
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4"/>
|
<X className="w-4 h-4"/>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium text-white mb-2 block">
|
<label className="text-sm font-medium text-white mb-2 block">
|
||||||
File Name
|
File Name
|
||||||
@@ -354,16 +387,16 @@ export function FileManagerOperations({
|
|||||||
value={newFileName}
|
value={newFileName}
|
||||||
onChange={(e) => setNewFileName(e.target.value)}
|
onChange={(e) => setNewFileName(e.target.value)}
|
||||||
placeholder="Enter file name (e.g., example.txt)"
|
placeholder="Enter file name (e.g., example.txt)"
|
||||||
className="bg-[#23232a] border-2 border-[#434345] text-white"
|
className="bg-[#23232a] border-2 border-[#434345] text-white text-sm"
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleCreateFile()}
|
onKeyDown={(e) => e.key === 'Enter' && handleCreateFile()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Button
|
<Button
|
||||||
onClick={handleCreateFile}
|
onClick={handleCreateFile}
|
||||||
disabled={!newFileName.trim() || isLoading}
|
disabled={!newFileName.trim() || isLoading}
|
||||||
className="flex-1"
|
className="w-full text-sm h-9"
|
||||||
>
|
>
|
||||||
{isLoading ? 'Creating...' : 'Create File'}
|
{isLoading ? 'Creating...' : 'Create File'}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -371,6 +404,7 @@ export function FileManagerOperations({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setShowCreateFile(false)}
|
onClick={() => setShowCreateFile(false)}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
|
className="w-full text-sm h-9"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
@@ -380,23 +414,25 @@ export function FileManagerOperations({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{showCreateFolder && (
|
{showCreateFolder && (
|
||||||
<Card className="bg-[#18181b] border-2 border-[#303032] p-4">
|
<Card className="bg-[#18181b] border-2 border-[#303032] p-3">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-start justify-between mb-3">
|
||||||
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
<div className="flex-1 min-w-0">
|
||||||
<FolderPlus className="w-5 h-5"/>
|
<h3 className="text-base font-semibold text-white flex items-center gap-2">
|
||||||
Create New Folder
|
<FolderPlus className="w-6 h-6 flex-shrink-0"/>
|
||||||
</h3>
|
<span className="break-words">Create New Folder</span>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setShowCreateFolder(false)}
|
onClick={() => setShowCreateFolder(false)}
|
||||||
className="h-8 w-8 p-0"
|
className="h-8 w-8 p-0 flex-shrink-0 ml-2"
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4"/>
|
<X className="w-4 h-4"/>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium text-white mb-2 block">
|
<label className="text-sm font-medium text-white mb-2 block">
|
||||||
Folder Name
|
Folder Name
|
||||||
@@ -405,16 +441,16 @@ export function FileManagerOperations({
|
|||||||
value={newFolderName}
|
value={newFolderName}
|
||||||
onChange={(e) => setNewFolderName(e.target.value)}
|
onChange={(e) => setNewFolderName(e.target.value)}
|
||||||
placeholder="Enter folder name"
|
placeholder="Enter folder name"
|
||||||
className="bg-[#23232a] border-2 border-[#434345] text-white"
|
className="bg-[#23232a] border-2 border-[#434345] text-white text-sm"
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleCreateFolder()}
|
onKeyDown={(e) => e.key === 'Enter' && handleCreateFolder()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Button
|
<Button
|
||||||
onClick={handleCreateFolder}
|
onClick={handleCreateFolder}
|
||||||
disabled={!newFolderName.trim() || isLoading}
|
disabled={!newFolderName.trim() || isLoading}
|
||||||
className="flex-1"
|
className="w-full text-sm h-9"
|
||||||
>
|
>
|
||||||
{isLoading ? 'Creating...' : 'Create Folder'}
|
{isLoading ? 'Creating...' : 'Create Folder'}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -422,6 +458,7 @@ export function FileManagerOperations({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setShowCreateFolder(false)}
|
onClick={() => setShowCreateFolder(false)}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
|
className="w-full text-sm h-9"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
@@ -431,27 +468,29 @@ export function FileManagerOperations({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{showDelete && (
|
{showDelete && (
|
||||||
<Card className="bg-[#18181b] border-2 border-[#303032] p-4">
|
<Card className="bg-[#18181b] border-2 border-[#303032] p-3">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-start justify-between mb-3">
|
||||||
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
<div className="flex-1 min-w-0">
|
||||||
<Trash2 className="w-5 h-5 text-red-400"/>
|
<h3 className="text-base font-semibold text-white flex items-center gap-2">
|
||||||
Delete Item
|
<Trash2 className="w-6 h-6 text-red-400 flex-shrink-0"/>
|
||||||
</h3>
|
<span className="break-words">Delete Item</span>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setShowDelete(false)}
|
onClick={() => setShowDelete(false)}
|
||||||
className="h-8 w-8 p-0"
|
className="h-8 w-8 p-0 flex-shrink-0 ml-2"
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4"/>
|
<X className="w-4 h-4"/>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-3">
|
||||||
<div className="bg-red-900/20 border border-red-500/30 rounded-lg p-3">
|
<div className="bg-red-900/20 border border-red-500/30 rounded-lg p-3">
|
||||||
<div className="flex items-center gap-2 text-red-300">
|
<div className="flex items-start gap-2 text-red-300">
|
||||||
<AlertCircle className="w-4 h-4"/>
|
<AlertCircle className="w-5 h-5 flex-shrink-0"/>
|
||||||
<span className="text-sm font-medium">Warning: This action cannot be undone</span>
|
<span className="text-sm font-medium break-words">Warning: This action cannot be undone</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -462,30 +501,30 @@ export function FileManagerOperations({
|
|||||||
<Input
|
<Input
|
||||||
value={deletePath}
|
value={deletePath}
|
||||||
onChange={(e) => setDeletePath(e.target.value)}
|
onChange={(e) => setDeletePath(e.target.value)}
|
||||||
placeholder="Enter full path to item (e.g., /path/to/file.txt)"
|
placeholder="Enter full path to item"
|
||||||
className="bg-[#23232a] border-2 border-[#434345] text-white"
|
className="bg-[#23232a] border-2 border-[#434345] text-white text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
id="deleteIsDirectory"
|
id="deleteIsDirectory"
|
||||||
checked={deleteIsDirectory}
|
checked={deleteIsDirectory}
|
||||||
onChange={(e) => setDeleteIsDirectory(e.target.checked)}
|
onChange={(e) => setDeleteIsDirectory(e.target.checked)}
|
||||||
className="rounded border-[#434345] bg-[#23232a]"
|
className="rounded border-[#434345] bg-[#23232a] mt-0.5 flex-shrink-0"
|
||||||
/>
|
/>
|
||||||
<label htmlFor="deleteIsDirectory" className="text-sm text-white">
|
<label htmlFor="deleteIsDirectory" className="text-sm text-white break-words">
|
||||||
This is a directory (will delete recursively)
|
This is a directory (will delete recursively)
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Button
|
<Button
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
disabled={!deletePath || isLoading}
|
disabled={!deletePath || isLoading}
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
className="flex-1"
|
className="w-full text-sm h-9"
|
||||||
>
|
>
|
||||||
{isLoading ? 'Deleting...' : 'Delete Item'}
|
{isLoading ? 'Deleting...' : 'Delete Item'}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -493,6 +532,7 @@ export function FileManagerOperations({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setShowDelete(false)}
|
onClick={() => setShowDelete(false)}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
|
className="w-full text-sm h-9"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
@@ -502,23 +542,25 @@ export function FileManagerOperations({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{showRename && (
|
{showRename && (
|
||||||
<Card className="bg-[#18181b] border-2 border-[#303032] p-4">
|
<Card className="bg-[#18181b] border-2 border-[#303032] p-3">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-start justify-between mb-3">
|
||||||
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
<div className="flex-1 min-w-0">
|
||||||
<Edit3 className="w-5 h-5"/>
|
<h3 className="text-base font-semibold text-white flex items-center gap-2">
|
||||||
Rename Item
|
<Edit3 className="w-6 h-6 flex-shrink-0"/>
|
||||||
</h3>
|
<span className="break-words">Rename Item</span>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setShowRename(false)}
|
onClick={() => setShowRename(false)}
|
||||||
className="h-8 w-8 p-0"
|
className="h-8 w-8 p-0 flex-shrink-0 ml-2"
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4"/>
|
<X className="w-4 h-4"/>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium text-white mb-2 block">
|
<label className="text-sm font-medium text-white mb-2 block">
|
||||||
Current Path
|
Current Path
|
||||||
@@ -527,7 +569,7 @@ export function FileManagerOperations({
|
|||||||
value={renamePath}
|
value={renamePath}
|
||||||
onChange={(e) => setRenamePath(e.target.value)}
|
onChange={(e) => setRenamePath(e.target.value)}
|
||||||
placeholder="Enter current path to item"
|
placeholder="Enter current path to item"
|
||||||
className="bg-[#23232a] border-2 border-[#434345] text-white"
|
className="bg-[#23232a] border-2 border-[#434345] text-white text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -539,29 +581,29 @@ export function FileManagerOperations({
|
|||||||
value={newName}
|
value={newName}
|
||||||
onChange={(e) => setNewName(e.target.value)}
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
placeholder="Enter new name"
|
placeholder="Enter new name"
|
||||||
className="bg-[#23232a] border-2 border-[#434345] text-white"
|
className="bg-[#23232a] border-2 border-[#434345] text-white text-sm"
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleRename()}
|
onKeyDown={(e) => e.key === 'Enter' && handleRename()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
id="renameIsDirectory"
|
id="renameIsDirectory"
|
||||||
checked={renameIsDirectory}
|
checked={renameIsDirectory}
|
||||||
onChange={(e) => setRenameIsDirectory(e.target.checked)}
|
onChange={(e) => setRenameIsDirectory(e.target.checked)}
|
||||||
className="rounded border-[#434345] bg-[#23232a]"
|
className="rounded border-[#434345] bg-[#23232a] mt-0.5 flex-shrink-0"
|
||||||
/>
|
/>
|
||||||
<label htmlFor="renameIsDirectory" className="text-sm text-white">
|
<label htmlFor="renameIsDirectory" className="text-sm text-white break-words">
|
||||||
This is a directory
|
This is a directory
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Button
|
<Button
|
||||||
onClick={handleRename}
|
onClick={handleRename}
|
||||||
disabled={!renamePath || !newName.trim() || isLoading}
|
disabled={!renamePath || !newName.trim() || isLoading}
|
||||||
className="flex-1"
|
className="w-full text-sm h-9"
|
||||||
>
|
>
|
||||||
{isLoading ? 'Renaming...' : 'Rename Item'}
|
{isLoading ? 'Renaming...' : 'Rename Item'}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -569,6 +611,7 @@ export function FileManagerOperations({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setShowRename(false)}
|
onClick={() => setShowRename(false)}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
|
className="w-full text-sm h-9"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export function FileManagerTabList({tabs, activeTab, setActiveTab, closeTab, onH
|
|||||||
<Button
|
<Button
|
||||||
onClick={onHomeClick}
|
onClick={onHomeClick}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={`h-8 rounded-md flex items-center !px-2 border-1 border-[#303032] ${activeTab === 'home' ? '!bg-[#1d1d1f] !text-white !border-[#2d2d30] !hover:bg-[#1d1d1f] !active:bg-[#1d1d1f] !focus:bg-[#1d1d1f] !hover:text-white !active:text-white !focus:text-white' : ''}`}
|
className={`ml-1 h-8 rounded-md flex items-center !px-2 border-1 border-[#303032] ${activeTab === 'home' ? '!bg-[#1d1d1f] !text-white !border-[#2d2d30] !hover:bg-[#1d1d1f] !active:bg-[#1d1d1f] !focus:bg-[#1d1d1f] !hover:text-white !active:text-white !focus:text-white' : ''}`}
|
||||||
>
|
>
|
||||||
<Home className="w-4 h-4"/>
|
<Home className="w-4 h-4"/>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -582,11 +582,11 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
|||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="password"
|
name="password"
|
||||||
render={({field}) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Password</FormLabel>
|
<FormLabel>Password</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="password" {...field} />
|
<Input type="password" placeholder="password" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -677,7 +677,7 @@ EXAMPLE STRUCTURE:
|
|||||||
{host.tags && host.tags.length > 0 && (
|
{host.tags && host.tags.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{host.tags.slice(0, 6).map((tag, index) => (
|
{host.tags.slice(0, 6).map((tag, index) => (
|
||||||
<Badge key={index} variant="secondary"
|
<Badge key={index} variant="outline"
|
||||||
className="text-xs px-1 py-0">
|
className="text-xs px-1 py-0">
|
||||||
<Tag className="h-2 w-2 mr-0.5"/>
|
<Tag className="h-2 w-2 mr-0.5"/>
|
||||||
{tag}
|
{tag}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export function Server({
|
|||||||
embedded = false
|
embedded = false
|
||||||
}: ServerProps): React.ReactElement {
|
}: ServerProps): React.ReactElement {
|
||||||
const {state: sidebarState} = useSidebar();
|
const {state: sidebarState} = useSidebar();
|
||||||
const {addTab} = useTabs() as any;
|
const {addTab, tabs} = useTabs() as any;
|
||||||
const [serverStatus, setServerStatus] = React.useState<'online' | 'offline'>('offline');
|
const [serverStatus, setServerStatus] = React.useState<'online' | 'offline'>('offline');
|
||||||
const [metrics, setMetrics] = React.useState<ServerMetrics | null>(null);
|
const [metrics, setMetrics] = React.useState<ServerMetrics | null>(null);
|
||||||
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
|
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
|
||||||
@@ -94,25 +94,37 @@ export function Server({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (currentHostConfig?.id) {
|
if (currentHostConfig?.id && isVisible) {
|
||||||
fetchStatus();
|
fetchStatus();
|
||||||
fetchMetrics();
|
fetchMetrics();
|
||||||
|
// Only poll when component is visible to reduce unnecessary connections
|
||||||
intervalId = window.setInterval(() => {
|
intervalId = window.setInterval(() => {
|
||||||
fetchStatus();
|
if (isVisible) {
|
||||||
fetchMetrics();
|
fetchStatus();
|
||||||
}, 10_000);
|
fetchMetrics();
|
||||||
|
}
|
||||||
|
}, 300_000); // 5 minutes instead of 10 seconds
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
if (intervalId) window.clearInterval(intervalId);
|
if (intervalId) window.clearInterval(intervalId);
|
||||||
};
|
};
|
||||||
}, [currentHostConfig?.id]);
|
}, [currentHostConfig?.id, isVisible]);
|
||||||
|
|
||||||
const topMarginPx = isTopbarOpen ? 74 : 16;
|
const topMarginPx = isTopbarOpen ? 74 : 16;
|
||||||
const leftMarginPx = sidebarState === 'collapsed' ? 16 : 8;
|
const leftMarginPx = sidebarState === 'collapsed' ? 16 : 8;
|
||||||
const bottomMarginPx = 8;
|
const bottomMarginPx = 8;
|
||||||
|
|
||||||
|
// Check if a file manager tab for this host is already open
|
||||||
|
const isFileManagerAlreadyOpen = React.useMemo(() => {
|
||||||
|
if (!currentHostConfig) return false;
|
||||||
|
return tabs.some((tab: any) =>
|
||||||
|
tab.type === 'file_manager' &&
|
||||||
|
tab.hostConfig?.id === currentHostConfig.id
|
||||||
|
);
|
||||||
|
}, [tabs, currentHostConfig]);
|
||||||
|
|
||||||
const wrapperStyle: React.CSSProperties = embedded
|
const wrapperStyle: React.CSSProperties = embedded
|
||||||
? {opacity: isVisible ? 1 : 0, height: '100%', width: '100%'}
|
? {opacity: isVisible ? 1 : 0, height: '100%', width: '100%'}
|
||||||
: {
|
: {
|
||||||
@@ -142,13 +154,34 @@ export function Server({
|
|||||||
<StatusIndicator/>
|
<StatusIndicator/>
|
||||||
</Status>
|
</Status>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={async () => {
|
||||||
|
if (currentHostConfig?.id) {
|
||||||
|
try {
|
||||||
|
const res = await getServerStatusById(currentHostConfig.id);
|
||||||
|
setServerStatus(res?.status === 'online' ? 'online' : 'offline');
|
||||||
|
const data = await getServerMetricsById(currentHostConfig.id);
|
||||||
|
setMetrics(data);
|
||||||
|
} catch {
|
||||||
|
setServerStatus('offline');
|
||||||
|
setMetrics(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title="Refresh status and metrics"
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
{currentHostConfig?.enableFileManager && (
|
{currentHostConfig?.enableFileManager && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="font-semibold"
|
className="font-semibold"
|
||||||
|
disabled={isFileManagerAlreadyOpen}
|
||||||
|
title={isFileManagerAlreadyOpen ? "File Manager already open for this host" : "Open File Manager"}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!currentHostConfig) return;
|
if (!currentHostConfig || isFileManagerAlreadyOpen) return;
|
||||||
const titleBase = currentHostConfig?.name && currentHostConfig.name.trim() !== ''
|
const titleBase = currentHostConfig?.name && currentHostConfig.name.trim() !== ''
|
||||||
? currentHostConfig.name.trim()
|
? currentHostConfig.name.trim()
|
||||||
: `${currentHostConfig.username}@${currentHostConfig.ip}`;
|
: `${currentHostConfig.username}@${currentHostConfig.ip}`;
|
||||||
@@ -210,7 +243,7 @@ export function Server({
|
|||||||
|
|
||||||
<Separator className="p-0.5 self-stretch" orientation="vertical"/>
|
<Separator className="p-0.5 self-stretch" orientation="vertical"/>
|
||||||
|
|
||||||
{/* HDD */}
|
{/* Root Storage */}
|
||||||
<div className="flex-1 min-w-0 px-2 py-2">
|
<div className="flex-1 min-w-0 px-2 py-2">
|
||||||
<h1 className="font-bold xt-lg flex flex-row gap-2 mb-2">
|
<h1 className="font-bold xt-lg flex flex-row gap-2 mb-2">
|
||||||
<HardDrive/>
|
<HardDrive/>
|
||||||
@@ -221,7 +254,7 @@ export function Server({
|
|||||||
const pctText = (typeof pct === 'number') ? `${pct}%` : 'N/A';
|
const pctText = (typeof pct === 'number') ? `${pct}%` : 'N/A';
|
||||||
const usedText = used ?? 'N/A';
|
const usedText = used ?? 'N/A';
|
||||||
const totalText = total ?? 'N/A';
|
const totalText = total ?? 'N/A';
|
||||||
return `HDD Space - ${pctText} (${usedText} of ${totalText})`;
|
return `Root Storage Space - ${pctText} (${usedText} of ${totalText})`;
|
||||||
})()}
|
})()}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ interface SSHTerminalProps {
|
|||||||
splitScreen?: boolean;
|
splitScreen?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||||
{hostConfig, isVisible, splitScreen = false},
|
{hostConfig, isVisible, splitScreen = false},
|
||||||
ref
|
ref
|
||||||
) {
|
) {
|
||||||
@@ -26,6 +26,7 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
|
|||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
const isVisibleRef = useRef<boolean>(false);
|
const isVisibleRef = useRef<boolean>(false);
|
||||||
|
|
||||||
|
|
||||||
const lastSentSizeRef = useRef<{ cols: number; rows: number } | null>(null);
|
const lastSentSizeRef = useRef<{ cols: number; rows: number } | null>(null);
|
||||||
const pendingSizeRef = useRef<{ cols: number; rows: number } | null>(null);
|
const pendingSizeRef = useRef<{ cols: number; rows: number } | null>(null);
|
||||||
const notifyTimerRef = useRef<NodeJS.Timeout | null>(null);
|
const notifyTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
@@ -115,6 +116,50 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
|
|||||||
return getCookie("rightClickCopyPaste") === "true"
|
return getCookie("rightClickCopyPaste") === "true"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
function setupWebSocketListeners(ws: WebSocket, cols: number, rows: number) {
|
||||||
|
ws.addEventListener('open', () => {
|
||||||
|
|
||||||
|
ws.send(JSON.stringify({type: 'connectToHost', data: {cols, rows, hostConfig}}));
|
||||||
|
terminal.onData((data) => {
|
||||||
|
ws.send(JSON.stringify({type: 'input', data}));
|
||||||
|
});
|
||||||
|
|
||||||
|
pingIntervalRef.current = setInterval(() => {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({type: 'ping'}));
|
||||||
|
}
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.addEventListener('message', (event) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(event.data);
|
||||||
|
if (msg.type === 'data') terminal.write(msg.data);
|
||||||
|
else if (msg.type === 'error') terminal.writeln(`\r\n[ERROR] ${msg.message}`);
|
||||||
|
else if (msg.type === 'connected') {
|
||||||
|
} else if (msg.type === 'disconnected') {
|
||||||
|
wasDisconnectedBySSH.current = true;
|
||||||
|
terminal.writeln(`\r\n[${msg.message || 'Disconnected'}]`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.addEventListener('close', () => {
|
||||||
|
if (!wasDisconnectedBySSH.current) {
|
||||||
|
terminal.writeln('\r\n[Connection closed]');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.addEventListener('error', () => {
|
||||||
|
terminal.writeln('\r\n[Connection error]');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function writeTextToClipboard(text: string): Promise<void> {
|
async function writeTextToClipboard(text: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
@@ -222,48 +267,26 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
|
|||||||
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||||
hardRefresh();
|
hardRefresh();
|
||||||
setVisible(true);
|
setVisible(true);
|
||||||
|
if (terminal && !splitScreen) {
|
||||||
|
terminal.focus();
|
||||||
|
}
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
const cols = terminal.cols;
|
const cols = terminal.cols;
|
||||||
const rows = terminal.rows;
|
const rows = terminal.rows;
|
||||||
const wsUrl = window.location.hostname === 'localhost' ? 'ws://localhost:8082' : `${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}/ssh/websocket/`;
|
|
||||||
|
const isDev = process.env.NODE_ENV === 'development' &&
|
||||||
|
(window.location.port === '3000' || window.location.port === '5173' || window.location.port === '');
|
||||||
|
|
||||||
|
const wsUrl = isDev
|
||||||
|
? 'ws://localhost:8082'
|
||||||
|
: `${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}/ssh/websocket/`;
|
||||||
|
|
||||||
const ws = new WebSocket(wsUrl);
|
const ws = new WebSocket(wsUrl);
|
||||||
webSocketRef.current = ws;
|
webSocketRef.current = ws;
|
||||||
wasDisconnectedBySSH.current = false;
|
wasDisconnectedBySSH.current = false;
|
||||||
|
|
||||||
ws.addEventListener('open', () => {
|
setupWebSocketListeners(ws, cols, rows);
|
||||||
ws.send(JSON.stringify({type: 'connectToHost', data: {cols, rows, hostConfig}}));
|
|
||||||
terminal.onData((data) => {
|
|
||||||
ws.send(JSON.stringify({type: 'input', data}));
|
|
||||||
});
|
|
||||||
pingIntervalRef.current = setInterval(() => {
|
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
|
||||||
ws.send(JSON.stringify({type: 'ping'}));
|
|
||||||
}
|
|
||||||
}, 30000);
|
|
||||||
});
|
|
||||||
|
|
||||||
ws.addEventListener('message', (event) => {
|
|
||||||
try {
|
|
||||||
const msg = JSON.parse(event.data);
|
|
||||||
if (msg.type === 'data') terminal.write(msg.data);
|
|
||||||
else if (msg.type === 'error') terminal.writeln(`\r\n[ERROR] ${msg.message}`);
|
|
||||||
else if (msg.type === 'connected') {
|
|
||||||
} else if (msg.type === 'disconnected') {
|
|
||||||
wasDisconnectedBySSH.current = true;
|
|
||||||
terminal.writeln(`\r\n[${msg.message || 'Disconnected'}]`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
ws.addEventListener('close', () => {
|
|
||||||
if (!wasDisconnectedBySSH.current) terminal.writeln('\r\n[Connection closed]');
|
|
||||||
});
|
|
||||||
ws.addEventListener('error', () => {
|
|
||||||
terminal.writeln('\r\n[Connection error]');
|
|
||||||
});
|
|
||||||
}, 300);
|
}, 300);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -286,9 +309,18 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
|
|||||||
fitAddonRef.current?.fit();
|
fitAddonRef.current?.fit();
|
||||||
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||||
hardRefresh();
|
hardRefresh();
|
||||||
|
if (terminal && !splitScreen) {
|
||||||
|
terminal.focus();
|
||||||
|
}
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
|
if (terminal && !splitScreen) {
|
||||||
|
setTimeout(() => {
|
||||||
|
terminal.focus();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [isVisible]);
|
}, [isVisible, splitScreen, terminal]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!fitAddonRef.current) return;
|
if (!fitAddonRef.current) return;
|
||||||
@@ -296,12 +328,23 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
|
|||||||
fitAddonRef.current?.fit();
|
fitAddonRef.current?.fit();
|
||||||
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||||
hardRefresh();
|
hardRefresh();
|
||||||
|
if (terminal && !splitScreen && isVisible) {
|
||||||
|
terminal.focus();
|
||||||
|
}
|
||||||
}, 0);
|
}, 0);
|
||||||
}, [splitScreen]);
|
}, [splitScreen, isVisible, terminal]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={xtermRef} className="h-full w-full m-1"
|
<div
|
||||||
style={{opacity: visible && isVisible ? 1 : 0, overflow: 'hidden'}}/>
|
ref={xtermRef}
|
||||||
|
className="h-full w-full m-1"
|
||||||
|
style={{opacity: visible && isVisible ? 1 : 0, overflow: 'hidden'}}
|
||||||
|
onClick={() => {
|
||||||
|
if (terminal && !splitScreen) {
|
||||||
|
terminal.focus();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
import axios from 'axios';
|
import axios, { AxiosError, type AxiosInstance } from 'axios';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TYPES & INTERFACES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
interface SSHHostData {
|
interface SSHHostData {
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -93,47 +97,70 @@ interface FileManagerShortcut {
|
|||||||
path: string;
|
path: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface FileManagerOperation {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
isSSH: boolean;
|
||||||
|
sshSessionId?: string;
|
||||||
|
hostId: number;
|
||||||
|
}
|
||||||
|
|
||||||
export type ServerStatus = {
|
export type ServerStatus = {
|
||||||
status: 'online' | 'offline';
|
status: 'online' | 'offline';
|
||||||
lastChecked: string;
|
lastChecked: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface CpuMetrics {
|
||||||
|
percent: number | null;
|
||||||
|
cores: number | null;
|
||||||
|
load: [number, number, number] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MemoryMetrics {
|
||||||
|
percent: number | null;
|
||||||
|
usedGiB: number | null;
|
||||||
|
totalGiB: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DiskMetrics {
|
||||||
|
percent: number | null;
|
||||||
|
usedHuman: string | null;
|
||||||
|
totalHuman: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export type ServerMetrics = {
|
export type ServerMetrics = {
|
||||||
cpu: { percent: number | null; cores: number | null; load: [number, number, number] | null };
|
cpu: CpuMetrics;
|
||||||
memory: { percent: number | null; usedGiB: number | null; totalGiB: number | null };
|
memory: MemoryMetrics;
|
||||||
disk: { percent: number | null; usedHuman: string | null; totalHuman: string | null };
|
disk: DiskMetrics;
|
||||||
lastChecked: string;
|
lastChecked: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
|
interface AuthResponse {
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
const sshHostApi = axios.create({
|
interface UserInfo {
|
||||||
baseURL: isLocalhost ? 'http://localhost:8081' : '',
|
id: string;
|
||||||
headers: {
|
username: string;
|
||||||
'Content-Type': 'application/json',
|
is_admin: boolean;
|
||||||
},
|
}
|
||||||
});
|
|
||||||
|
|
||||||
const tunnelApi = axios.create({
|
interface UserCount {
|
||||||
baseURL: isLocalhost ? 'http://localhost:8083' : '',
|
count: number;
|
||||||
headers: {
|
}
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const fileManagerApi = axios.create({
|
interface OIDCAuthorize {
|
||||||
baseURL: isLocalhost ? 'http://localhost:8084' : '',
|
auth_url: string;
|
||||||
headers: {
|
}
|
||||||
'Content-Type': 'application/json',
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const statsApi = axios.create({
|
// ============================================================================
|
||||||
baseURL: isLocalhost ? 'http://localhost:8085' : '',
|
// UTILITY FUNCTIONS
|
||||||
headers: {
|
// ============================================================================
|
||||||
'Content-Type': 'application/json',
|
|
||||||
}
|
function setCookie(name: string, value: string, days = 7): void {
|
||||||
})
|
const expires = new Date(Date.now() + days * 864e5).toUTCString();
|
||||||
|
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`;
|
||||||
|
}
|
||||||
|
|
||||||
function getCookie(name: string): string | undefined {
|
function getCookie(name: string): string | undefined {
|
||||||
const value = `; ${document.cookie}`;
|
const value = `; ${document.cookie}`;
|
||||||
@@ -141,44 +168,116 @@ function getCookie(name: string): string | undefined {
|
|||||||
if (parts.length === 2) return parts.pop()?.split(';').shift();
|
if (parts.length === 2) return parts.pop()?.split(';').shift();
|
||||||
}
|
}
|
||||||
|
|
||||||
sshHostApi.interceptors.request.use((config) => {
|
function createApiInstance(baseURL: string): AxiosInstance {
|
||||||
const token = getCookie('jwt');
|
const instance = axios.create({
|
||||||
if (token) {
|
baseURL,
|
||||||
config.headers.Authorization = `Bearer ${token}`;
|
headers: { 'Content-Type': 'application/json' },
|
||||||
}
|
timeout: 30000,
|
||||||
return config;
|
});
|
||||||
});
|
|
||||||
|
|
||||||
statsApi.interceptors.request.use((config) => {
|
instance.interceptors.request.use((config) => {
|
||||||
const token = getCookie('jwt');
|
const token = getCookie('jwt');
|
||||||
if (token) {
|
if (token) {
|
||||||
config.headers.Authorization = `Bearer ${token}`;
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
return config;
|
return config;
|
||||||
});
|
});
|
||||||
|
|
||||||
tunnelApi.interceptors.request.use((config) => {
|
instance.interceptors.response.use(
|
||||||
const token = getCookie('jwt');
|
(response) => response,
|
||||||
if (token) {
|
(error: AxiosError) => {
|
||||||
config.headers.Authorization = `Bearer ${token}`;
|
if (error.response?.status === 401) {
|
||||||
}
|
document.cookie = 'jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||||
return config;
|
}
|
||||||
});
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
fileManagerApi.interceptors.request.use((config) => {
|
return instance;
|
||||||
const token = getCookie('jwt');
|
}
|
||||||
if (token) {
|
|
||||||
config.headers.Authorization = `Bearer ${token}`;
|
// ============================================================================
|
||||||
|
// API INSTANCES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const isDev = process.env.NODE_ENV === 'development' &&
|
||||||
|
(window.location.port === '3000' || window.location.port === '5173' || window.location.port === '');
|
||||||
|
|
||||||
|
// SSH Host Management API (port 8081)
|
||||||
|
export const sshHostApi = createApiInstance(
|
||||||
|
isDev ? 'http://localhost:8081/ssh' : '/ssh'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Tunnel Management API (port 8083)
|
||||||
|
export const tunnelApi = createApiInstance(
|
||||||
|
isDev ? 'http://localhost:8083/ssh' : '/ssh'
|
||||||
|
);
|
||||||
|
|
||||||
|
// File Manager Operations API (port 8084) - SSH file operations
|
||||||
|
export const fileManagerApi = createApiInstance(
|
||||||
|
isDev ? 'http://localhost:8084/ssh/file_manager' : '/ssh/file_manager'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Server Statistics API (port 8085)
|
||||||
|
export const statsApi = createApiInstance(
|
||||||
|
isDev ? 'http://localhost:8085' : ''
|
||||||
|
);
|
||||||
|
|
||||||
|
// Authentication API (port 8081) - includes users, alerts, version, releases
|
||||||
|
export const authApi = createApiInstance(
|
||||||
|
isDev ? 'http://localhost:8081' : ''
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ERROR HANDLING
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public status?: number,
|
||||||
|
public code?: string
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ApiError';
|
||||||
}
|
}
|
||||||
return config;
|
}
|
||||||
});
|
|
||||||
|
function handleApiError(error: unknown, operation: string): never {
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
const status = error.response?.status;
|
||||||
|
const message = error.response?.data?.error || error.message;
|
||||||
|
|
||||||
|
if (status === 401) {
|
||||||
|
throw new ApiError('Authentication required', 401);
|
||||||
|
} else if (status === 403) {
|
||||||
|
throw new ApiError('Access denied', 403);
|
||||||
|
} else if (status === 404) {
|
||||||
|
throw new ApiError('Resource not found', 404);
|
||||||
|
} else if (status && status >= 500) {
|
||||||
|
throw new ApiError('Server error occurred', status);
|
||||||
|
} else {
|
||||||
|
throw new ApiError(message || `Failed to ${operation}`, status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof ApiError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ApiError(`Unexpected error during ${operation}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SSH HOST MANAGEMENT
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
export async function getSSHHosts(): Promise<SSHHost[]> {
|
export async function getSSHHosts(): Promise<SSHHost[]> {
|
||||||
try {
|
try {
|
||||||
const response = await sshHostApi.get('/ssh/db/host');
|
const response = await sshHostApi.get('/db/host');
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error;
|
handleApiError(error, 'fetch SSH hosts');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,23 +315,20 @@ export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
|
|||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('key', hostData.key);
|
formData.append('key', hostData.key);
|
||||||
|
|
||||||
const dataWithoutFile = {...submitData};
|
const dataWithoutFile = { ...submitData };
|
||||||
delete dataWithoutFile.key;
|
delete dataWithoutFile.key;
|
||||||
formData.append('data', JSON.stringify(dataWithoutFile));
|
formData.append('data', JSON.stringify(dataWithoutFile));
|
||||||
|
|
||||||
const response = await sshHostApi.post('/ssh/db/host', formData, {
|
const response = await sshHostApi.post('/db/host', formData, {
|
||||||
headers: {
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
'Content-Type': 'multipart/form-data',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return response.data;
|
return response.data;
|
||||||
} else {
|
} else {
|
||||||
const response = await sshHostApi.post('/ssh/db/host', submitData);
|
const response = await sshHostApi.post('/db/host', submitData);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error;
|
handleApiError(error, 'create SSH host');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,23 +365,20 @@ export async function updateSSHHost(hostId: number, hostData: SSHHostData): Prom
|
|||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('key', hostData.key);
|
formData.append('key', hostData.key);
|
||||||
|
|
||||||
const dataWithoutFile = {...submitData};
|
const dataWithoutFile = { ...submitData };
|
||||||
delete dataWithoutFile.key;
|
delete dataWithoutFile.key;
|
||||||
formData.append('data', JSON.stringify(dataWithoutFile));
|
formData.append('data', JSON.stringify(dataWithoutFile));
|
||||||
|
|
||||||
const response = await sshHostApi.put(`/ssh/db/host/${hostId}`, formData, {
|
const response = await sshHostApi.put(`/db/host/${hostId}`, formData, {
|
||||||
headers: {
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
'Content-Type': 'multipart/form-data',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return response.data;
|
return response.data;
|
||||||
} else {
|
} else {
|
||||||
const response = await sshHostApi.put(`/ssh/db/host/${hostId}`, submitData);
|
const response = await sshHostApi.put(`/db/host/${hostId}`, submitData);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error;
|
handleApiError(error, 'update SSH host');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,37 +389,41 @@ export async function bulkImportSSHHosts(hosts: SSHHostData[]): Promise<{
|
|||||||
errors: string[];
|
errors: string[];
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
const response = await sshHostApi.post('/ssh/bulk-import', {hosts});
|
const response = await sshHostApi.post('/bulk-import', { hosts });
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error;
|
handleApiError(error, 'bulk import SSH hosts');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteSSHHost(hostId: number): Promise<any> {
|
export async function deleteSSHHost(hostId: number): Promise<any> {
|
||||||
try {
|
try {
|
||||||
const response = await sshHostApi.delete(`/ssh/db/host/${hostId}`);
|
const response = await sshHostApi.delete(`/db/host/${hostId}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error;
|
handleApiError(error, 'delete SSH host');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getSSHHostById(hostId: number): Promise<SSHHost> {
|
export async function getSSHHostById(hostId: number): Promise<SSHHost> {
|
||||||
try {
|
try {
|
||||||
const response = await sshHostApi.get(`/ssh/db/host/${hostId}`);
|
const response = await sshHostApi.get(`/db/host/${hostId}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error;
|
handleApiError(error, 'fetch SSH host');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TUNNEL MANAGEMENT
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
export async function getTunnelStatuses(): Promise<Record<string, TunnelStatus>> {
|
export async function getTunnelStatuses(): Promise<Record<string, TunnelStatus>> {
|
||||||
try {
|
try {
|
||||||
const response = await tunnelApi.get('/ssh/tunnel/status');
|
const response = await tunnelApi.get('/tunnel/status');
|
||||||
return response.data || {};
|
return response.data || {};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error;
|
handleApiError(error, 'fetch tunnel statuses');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,148 +434,120 @@ export async function getTunnelStatusByName(tunnelName: string): Promise<TunnelS
|
|||||||
|
|
||||||
export async function connectTunnel(tunnelConfig: TunnelConfig): Promise<any> {
|
export async function connectTunnel(tunnelConfig: TunnelConfig): Promise<any> {
|
||||||
try {
|
try {
|
||||||
const response = await tunnelApi.post('/ssh/tunnel/connect', tunnelConfig);
|
const response = await tunnelApi.post('/tunnel/connect', tunnelConfig);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error;
|
handleApiError(error, 'connect tunnel');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function disconnectTunnel(tunnelName: string): Promise<any> {
|
export async function disconnectTunnel(tunnelName: string): Promise<any> {
|
||||||
try {
|
try {
|
||||||
const response = await tunnelApi.post('/ssh/tunnel/disconnect', {tunnelName});
|
const response = await tunnelApi.post('/tunnel/disconnect', { tunnelName });
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error;
|
handleApiError(error, 'disconnect tunnel');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cancelTunnel(tunnelName: string): Promise<any> {
|
export async function cancelTunnel(tunnelName: string): Promise<any> {
|
||||||
try {
|
try {
|
||||||
const response = await tunnelApi.post('/ssh/tunnel/cancel', {tunnelName});
|
const response = await tunnelApi.post('/tunnel/cancel', { tunnelName });
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error;
|
handleApiError(error, 'cancel tunnel');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// FILE MANAGER METADATA (Recent, Pinned, Shortcuts)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
export async function getFileManagerRecent(hostId: number): Promise<FileManagerFile[]> {
|
export async function getFileManagerRecent(hostId: number): Promise<FileManagerFile[]> {
|
||||||
try {
|
try {
|
||||||
const response = await sshHostApi.get(`/ssh/file_manager/recent?hostId=${hostId}`);
|
const response = await sshHostApi.get(`/file_manager/recent?hostId=${hostId}`);
|
||||||
return response.data || [];
|
return response.data || [];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addFileManagerRecent(file: {
|
export async function addFileManagerRecent(file: FileManagerOperation): Promise<any> {
|
||||||
name: string;
|
|
||||||
path: string;
|
|
||||||
isSSH: boolean;
|
|
||||||
sshSessionId?: string;
|
|
||||||
hostId: number
|
|
||||||
}): Promise<any> {
|
|
||||||
try {
|
try {
|
||||||
const response = await sshHostApi.post('/ssh/file_manager/recent', file);
|
const response = await sshHostApi.post('/file_manager/recent', file);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error;
|
handleApiError(error, 'add recent file');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function removeFileManagerRecent(file: {
|
export async function removeFileManagerRecent(file: FileManagerOperation): Promise<any> {
|
||||||
name: string;
|
|
||||||
path: string;
|
|
||||||
isSSH: boolean;
|
|
||||||
sshSessionId?: string;
|
|
||||||
hostId: number
|
|
||||||
}): Promise<any> {
|
|
||||||
try {
|
try {
|
||||||
const response = await sshHostApi.delete('/ssh/file_manager/recent', {data: file});
|
const response = await sshHostApi.delete('/file_manager/recent', { data: file });
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error;
|
handleApiError(error, 'remove recent file');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getFileManagerPinned(hostId: number): Promise<FileManagerFile[]> {
|
export async function getFileManagerPinned(hostId: number): Promise<FileManagerFile[]> {
|
||||||
try {
|
try {
|
||||||
const response = await sshHostApi.get(`/ssh/file_manager/pinned?hostId=${hostId}`);
|
const response = await sshHostApi.get(`/file_manager/pinned?hostId=${hostId}`);
|
||||||
return response.data || [];
|
return response.data || [];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addFileManagerPinned(file: {
|
export async function addFileManagerPinned(file: FileManagerOperation): Promise<any> {
|
||||||
name: string;
|
|
||||||
path: string;
|
|
||||||
isSSH: boolean;
|
|
||||||
sshSessionId?: string;
|
|
||||||
hostId: number
|
|
||||||
}): Promise<any> {
|
|
||||||
try {
|
try {
|
||||||
const response = await sshHostApi.post('/ssh/file_manager/pinned', file);
|
const response = await sshHostApi.post('/file_manager/pinned', file);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error;
|
handleApiError(error, 'add pinned file');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function removeFileManagerPinned(file: {
|
export async function removeFileManagerPinned(file: FileManagerOperation): Promise<any> {
|
||||||
name: string;
|
|
||||||
path: string;
|
|
||||||
isSSH: boolean;
|
|
||||||
sshSessionId?: string;
|
|
||||||
hostId: number
|
|
||||||
}): Promise<any> {
|
|
||||||
try {
|
try {
|
||||||
const response = await sshHostApi.delete('/ssh/file_manager/pinned', {data: file});
|
const response = await sshHostApi.delete('/file_manager/pinned', { data: file });
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error;
|
handleApiError(error, 'remove pinned file');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getFileManagerShortcuts(hostId: number): Promise<FileManagerShortcut[]> {
|
export async function getFileManagerShortcuts(hostId: number): Promise<FileManagerShortcut[]> {
|
||||||
try {
|
try {
|
||||||
const response = await sshHostApi.get(`/ssh/file_manager/shortcuts?hostId=${hostId}`);
|
const response = await sshHostApi.get(`/file_manager/shortcuts?hostId=${hostId}`);
|
||||||
return response.data || [];
|
return response.data || [];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addFileManagerShortcut(shortcut: {
|
export async function addFileManagerShortcut(shortcut: FileManagerOperation): Promise<any> {
|
||||||
name: string;
|
|
||||||
path: string;
|
|
||||||
isSSH: boolean;
|
|
||||||
sshSessionId?: string;
|
|
||||||
hostId: number
|
|
||||||
}): Promise<any> {
|
|
||||||
try {
|
try {
|
||||||
const response = await sshHostApi.post('/ssh/file_manager/shortcuts', shortcut);
|
const response = await sshHostApi.post('/file_manager/shortcuts', shortcut);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error;
|
handleApiError(error, 'add shortcut');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function removeFileManagerShortcut(shortcut: {
|
export async function removeFileManagerShortcut(shortcut: FileManagerOperation): Promise<any> {
|
||||||
name: string;
|
|
||||||
path: string;
|
|
||||||
isSSH: boolean;
|
|
||||||
sshSessionId?: string;
|
|
||||||
hostId: number
|
|
||||||
}): Promise<any> {
|
|
||||||
try {
|
try {
|
||||||
const response = await sshHostApi.delete('/ssh/file_manager/shortcuts', {data: shortcut});
|
const response = await sshHostApi.delete('/file_manager/shortcuts', { data: shortcut });
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error;
|
handleApiError(error, 'remove shortcut');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SSH FILE OPERATIONS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
export async function connectSSH(sessionId: string, config: {
|
export async function connectSSH(sessionId: string, config: {
|
||||||
ip: string;
|
ip: string;
|
||||||
port: number;
|
port: number;
|
||||||
@@ -488,61 +557,61 @@ export async function connectSSH(sessionId: string, config: {
|
|||||||
keyPassword?: string;
|
keyPassword?: string;
|
||||||
}): Promise<any> {
|
}): Promise<any> {
|
||||||
try {
|
try {
|
||||||
const response = await fileManagerApi.post('/ssh/file_manager/ssh/connect', {
|
const response = await fileManagerApi.post('/ssh/connect', {
|
||||||
sessionId,
|
sessionId,
|
||||||
...config
|
...config
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error;
|
handleApiError(error, 'connect SSH');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function disconnectSSH(sessionId: string): Promise<any> {
|
export async function disconnectSSH(sessionId: string): Promise<any> {
|
||||||
try {
|
try {
|
||||||
const response = await fileManagerApi.post('/ssh/file_manager/ssh/disconnect', {sessionId});
|
const response = await fileManagerApi.post('/ssh/disconnect', { sessionId });
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error;
|
handleApiError(error, 'disconnect SSH');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getSSHStatus(sessionId: string): Promise<{ connected: boolean }> {
|
export async function getSSHStatus(sessionId: string): Promise<{ connected: boolean }> {
|
||||||
try {
|
try {
|
||||||
const response = await fileManagerApi.get('/ssh/file_manager/ssh/status', {
|
const response = await fileManagerApi.get('/ssh/status', {
|
||||||
params: {sessionId}
|
params: { sessionId }
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error;
|
handleApiError(error, 'get SSH status');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listSSHFiles(sessionId: string, path: string): Promise<any[]> {
|
export async function listSSHFiles(sessionId: string, path: string): Promise<any[]> {
|
||||||
try {
|
try {
|
||||||
const response = await fileManagerApi.get('/ssh/file_manager/ssh/listFiles', {
|
const response = await fileManagerApi.get('/ssh/listFiles', {
|
||||||
params: {sessionId, path}
|
params: { sessionId, path }
|
||||||
});
|
});
|
||||||
return response.data || [];
|
return response.data || [];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error;
|
handleApiError(error, 'list SSH files');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function readSSHFile(sessionId: string, path: string): Promise<{ content: string; path: string }> {
|
export async function readSSHFile(sessionId: string, path: string): Promise<{ content: string; path: string }> {
|
||||||
try {
|
try {
|
||||||
const response = await fileManagerApi.get('/ssh/file_manager/ssh/readFile', {
|
const response = await fileManagerApi.get('/ssh/readFile', {
|
||||||
params: {sessionId, path}
|
params: { sessionId, path }
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error;
|
handleApiError(error, 'read SSH file');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function writeSSHFile(sessionId: string, path: string, content: string): Promise<any> {
|
export async function writeSSHFile(sessionId: string, path: string, content: string): Promise<any> {
|
||||||
try {
|
try {
|
||||||
const response = await fileManagerApi.post('/ssh/file_manager/ssh/writeFile', {
|
const response = await fileManagerApi.post('/ssh/writeFile', {
|
||||||
sessionId,
|
sessionId,
|
||||||
path,
|
path,
|
||||||
content
|
content
|
||||||
@@ -554,13 +623,13 @@ export async function writeSSHFile(sessionId: string, path: string, content: str
|
|||||||
throw new Error('File write operation did not return success status');
|
throw new Error('File write operation did not return success status');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error;
|
handleApiError(error, 'write SSH file');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function uploadSSHFile(sessionId: string, path: string, fileName: string, content: string): Promise<any> {
|
export async function uploadSSHFile(sessionId: string, path: string, fileName: string, content: string): Promise<any> {
|
||||||
try {
|
try {
|
||||||
const response = await fileManagerApi.post('/ssh/file_manager/ssh/uploadFile', {
|
const response = await fileManagerApi.post('/ssh/uploadFile', {
|
||||||
sessionId,
|
sessionId,
|
||||||
path,
|
path,
|
||||||
fileName,
|
fileName,
|
||||||
@@ -568,13 +637,13 @@ export async function uploadSSHFile(sessionId: string, path: string, fileName: s
|
|||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error;
|
handleApiError(error, 'upload SSH file');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createSSHFile(sessionId: string, path: string, fileName: string, content: string = ''): Promise<any> {
|
export async function createSSHFile(sessionId: string, path: string, fileName: string, content: string = ''): Promise<any> {
|
||||||
try {
|
try {
|
||||||
const response = await fileManagerApi.post('/ssh/file_manager/ssh/createFile', {
|
const response = await fileManagerApi.post('/ssh/createFile', {
|
||||||
sessionId,
|
sessionId,
|
||||||
path,
|
path,
|
||||||
fileName,
|
fileName,
|
||||||
@@ -582,26 +651,26 @@ export async function createSSHFile(sessionId: string, path: string, fileName: s
|
|||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error;
|
handleApiError(error, 'create SSH file');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createSSHFolder(sessionId: string, path: string, folderName: string): Promise<any> {
|
export async function createSSHFolder(sessionId: string, path: string, folderName: string): Promise<any> {
|
||||||
try {
|
try {
|
||||||
const response = await fileManagerApi.post('/ssh/file_manager/ssh/createFolder', {
|
const response = await fileManagerApi.post('/ssh/createFolder', {
|
||||||
sessionId,
|
sessionId,
|
||||||
path,
|
path,
|
||||||
folderName
|
folderName
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error;
|
handleApiError(error, 'create SSH folder');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteSSHItem(sessionId: string, path: string, isDirectory: boolean): Promise<any> {
|
export async function deleteSSHItem(sessionId: string, path: string, isDirectory: boolean): Promise<any> {
|
||||||
try {
|
try {
|
||||||
const response = await fileManagerApi.delete('/ssh/file_manager/ssh/deleteItem', {
|
const response = await fileManagerApi.delete('/ssh/deleteItem', {
|
||||||
data: {
|
data: {
|
||||||
sessionId,
|
sessionId,
|
||||||
path,
|
path,
|
||||||
@@ -610,31 +679,33 @@ export async function deleteSSHItem(sessionId: string, path: string, isDirectory
|
|||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error;
|
handleApiError(error, 'delete SSH item');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function renameSSHItem(sessionId: string, oldPath: string, newName: string): Promise<any> {
|
export async function renameSSHItem(sessionId: string, oldPath: string, newName: string): Promise<any> {
|
||||||
try {
|
try {
|
||||||
const response = await fileManagerApi.put('/ssh/file_manager/ssh/renameItem', {
|
const response = await fileManagerApi.put('/ssh/renameItem', {
|
||||||
sessionId,
|
sessionId,
|
||||||
oldPath,
|
oldPath,
|
||||||
newName
|
newName
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error;
|
handleApiError(error, 'rename SSH item');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export {sshHostApi, tunnelApi, fileManagerApi};
|
// ============================================================================
|
||||||
|
// SERVER STATISTICS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
export async function getAllServerStatuses(): Promise<Record<number, ServerStatus>> {
|
export async function getAllServerStatuses(): Promise<Record<number, ServerStatus>> {
|
||||||
try {
|
try {
|
||||||
const response = await statsApi.get('/status');
|
const response = await statsApi.get('/status');
|
||||||
return response.data || {};
|
return response.data || {};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error;
|
handleApiError(error, 'fetch server statuses');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -643,7 +714,7 @@ export async function getServerStatusById(id: number): Promise<ServerStatus> {
|
|||||||
const response = await statsApi.get(`/status/${id}`);
|
const response = await statsApi.get(`/status/${id}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error;
|
handleApiError(error, 'fetch server status');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -652,6 +723,231 @@ export async function getServerMetricsById(id: number): Promise<ServerMetrics> {
|
|||||||
const response = await statsApi.get(`/metrics/${id}`);
|
const response = await statsApi.get(`/metrics/${id}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error;
|
handleApiError(error, 'fetch server metrics');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// AUTHENTICATION
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export async function registerUser(username: string, password: string): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.post('/users/create', { username, password });
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'register user');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loginUser(username: string, password: string): Promise<AuthResponse> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.post('/users/login', { username, password });
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'login user');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserInfo(): Promise<UserInfo> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.get('/users/me');
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'fetch user info');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRegistrationAllowed(): Promise<{ allowed: boolean }> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.get('/users/registration-allowed');
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'check registration status');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOIDCConfig(): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.get('/users/oidc-config');
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'fetch OIDC config');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserCount(): Promise<UserCount> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.get('/users/count');
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'fetch user count');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initiatePasswordReset(username: string): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.post('/users/initiate-reset', { username });
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'initiate password reset');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyPasswordResetCode(username: string, resetCode: string): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.post('/users/verify-reset-code', { username, resetCode });
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'verify reset code');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function completePasswordReset(username: string, tempToken: string, newPassword: string): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.post('/users/complete-reset', { username, tempToken, newPassword });
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'complete password reset');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOIDCAuthorizeUrl(): Promise<OIDCAuthorize> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.get('/users/oidc/authorize');
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'get OIDC authorize URL');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// USER MANAGEMENT
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export async function getUserList(): Promise<{ users: UserInfo[] }> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.get('/users/list');
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'fetch user list');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function makeUserAdmin(username: string): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.post('/users/make-admin', { username });
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'make user admin');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeAdminStatus(username: string): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.post('/users/remove-admin', { username });
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'remove admin status');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUser(username: string): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.delete('/users/delete-user', { data: { username } });
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'delete user');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAccount(password: string): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.delete('/users/delete-account', { data: { password } });
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'delete account');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateRegistrationAllowed(allowed: boolean): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.patch('/users/registration-allowed', { allowed });
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'update registration allowed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateOIDCConfig(config: any): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.post('/users/oidc-config', config);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'update OIDC config');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ALERTS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export async function getUserAlerts(userId: string): Promise<{ alerts: any[] }> {
|
||||||
|
try {
|
||||||
|
const apiInstance = createApiInstance(isDev ? 'http://localhost:8081' : '');
|
||||||
|
const response = await apiInstance.get(`/alerts/user/${userId}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'fetch user alerts');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dismissAlert(userId: string, alertId: string): Promise<any> {
|
||||||
|
try {
|
||||||
|
// Use the general API instance since alerts endpoint is at root level
|
||||||
|
const apiInstance = createApiInstance(isDev ? 'http://localhost:8081' : '');
|
||||||
|
const response = await apiInstance.post('/alerts/dismiss', { userId, alertId });
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'dismiss alert');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// UPDATES & RELEASES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export async function getReleasesRSS(perPage: number = 100): Promise<any> {
|
||||||
|
try {
|
||||||
|
// Use the general API instance since releases endpoint is at root level
|
||||||
|
const apiInstance = createApiInstance(isDev ? 'http://localhost:8081' : '');
|
||||||
|
const response = await apiInstance.get(`/releases/rss?per_page=${perPage}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'fetch releases RSS');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getVersionInfo(): Promise<any> {
|
||||||
|
try {
|
||||||
|
// Use the general API instance since version endpoint is at root level
|
||||||
|
const apiInstance = createApiInstance(isDev ? 'http://localhost:8081' : '');
|
||||||
|
const response = await apiInstance.get('/version/');
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'fetch version info');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// DATABASE HEALTH
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export async function getDatabaseHealth(): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.get('/users/db-health');
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'check database health');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user