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:
Karmaa
2025-08-28 01:00:47 -05:00
committed by GitHub
29 changed files with 1381 additions and 1205 deletions

2
.env
View File

@@ -1 +1 @@
VERSION=1.3.0 VERSION=1.3.1

View File

@@ -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

View File

@@ -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

View File

@@ -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
View File

@@ -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"

View File

@@ -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"
} }
} }

View File

@@ -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,32 +90,20 @@ 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]"
aria-hidden="true"
>
<div className="absolute inset-0 opacity-20">
<div className="absolute inset-0" style={{ <div className="absolute inset-0" style={{
backgroundImage: `repeating-linear-gradient( backgroundImage: `linear-gradient(
45deg, 135deg,
transparent, transparent 0%,
transparent 20px, transparent 49%,
hsl(var(--primary) / 0.4) 20px, rgba(255, 255, 255, 0.03) 49%,
hsl(var(--primary) / 0.4) 40px rgba(255, 255, 255, 0.03) 51%,
)` transparent 51%,
transparent 100%
)`,
backgroundSize: '80px 80px'
}} /> }} />
</div> </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>
)} )}
{!isAuthenticated && !authLoading && ( {!isAuthenticated && !authLoading && (

View File

@@ -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) {
} }

View File

@@ -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,
}); });

View File

@@ -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 (!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; 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}`});

View File

@@ -115,11 +115,28 @@ 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')) {
throw new Error('Invalid private key format');
} }
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) { if (host.keyPassword) {
(base as any).passphrase = 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);

View File

@@ -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);
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})); 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,13 +270,27 @@ wss.on('connection', (ws: WebSocket) => {
} }
}; };
if (authType === 'key' && key) { if (authType === 'key' && key) {
connectConfig.privateKey = key; try {
if (!key.includes('-----BEGIN') || !key.includes('-----END')) {
throw new Error('Invalid private key format');
}
const cleanKey = key.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n');
connectConfig.privateKey = Buffer.from(cleanKey, 'utf8');
if (keyPassword) { if (keyPassword) {
connectConfig.passphrase = keyPassword; connectConfig.passphrase = keyPassword;
} }
if (keyType && keyType !== 'auto') { if (keyType && keyType !== 'auto') {
connectConfig.privateKeyType = keyType; 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');
ws.send(JSON.stringify({type: 'error', message: 'SSH key authentication requested but no key provided'})); ws.send(JSON.stringify({type: 'error', message: 'SSH key authentication requested but no key provided'}));
@@ -360,4 +350,6 @@ wss.on('connection', (ws: WebSocket) => {
} }
}, 60000); }, 60000);
} }
}); });

View File

@@ -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, { broadcastTunnelStatus(tunnelName, {
connected: false, connected: false,
status: CONNECTION_STATES.VERIFYING status: CONNECTION_STATES.DISCONNECTED,
}); reason: 'Tunnel connection lost'
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, {
connected: true,
status: CONNECTION_STATES.CONNECTED
});
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)) {
}
const conn = activeTunnels.get(tunnelName);
if (!conn) {
clearInterval(pingInterval);
return;
}
conn.exec('echo "ping"', (err, stream) => {
if (err) {
clearInterval(pingInterval);
if (!manualDisconnects.has(tunnelName)) {
broadcastTunnelStatus(tunnelName, { broadcastTunnelStatus(tunnelName, {
connected: false, connected: false,
status: CONNECTION_STATES.UNSTABLE, status: CONNECTION_STATES.DISCONNECTED,
reason: "Ping failed" reason: 'Tunnel connection lost'
}); });
}
activeTunnels.delete(tunnelName);
handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName));
return;
}
stream.on('close', (code: number) => {
if (code !== 0) {
clearInterval(pingInterval); clearInterval(pingInterval);
verificationTimers.delete(pingKey);
if (!manualDisconnects.has(tunnelName)) {
broadcastTunnelStatus(tunnelName, {
connected: false,
status: CONNECTION_STATES.UNSTABLE,
reason: "Ping command failed"
});
} }
} else {
activeTunnels.delete(tunnelName);
handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName));
}
});
stream.on('error', (err: Error) => {
clearInterval(pingInterval); clearInterval(pingInterval);
verificationTimers.delete(pingKey);
if (!manualDisconnects.has(tunnelName)) {
broadcastTunnelStatus(tunnelName, {
connected: false,
status: CONNECTION_STATES.UNSTABLE,
reason: "Ping stream error"
});
} }
}, 120000);
activeTunnels.delete(tunnelName); verificationTimers.set(pingKey, pingInterval);
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,14 +768,8 @@ 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, { broadcastTunnelStatus(tunnelName, {
connected: false, connected: false,
status: CONNECTION_STATES.CONNECTING, status: CONNECTION_STATES.CONNECTING,
@@ -997,27 +778,6 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
} }
conn.connect(connOptions); conn.connect(connOptions);
});
testSocket.on('timeout', () => {
testSocket.destroy();
broadcastTunnelStatus(tunnelName, {
connected: false,
status: CONNECTION_STATES.FAILED,
reason: "Network connectivity test failed - server not reachable"
});
});
testSocket.on('error', (err: any) => {
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;
} }

View File

@@ -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 {
} }

View File

@@ -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,10 +72,11 @@ 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 ? (
<div className="absolute top-[66px] left-0 w-full h-[calc(100%-66px)] flex items-center justify-center">
<HomepageAuth <HomepageAuth
setLoggedIn={setLoggedIn} setLoggedIn={setLoggedIn}
setIsAdmin={setIsAdmin} setIsAdmin={setIsAdmin}
@@ -93,61 +88,66 @@ export function Homepage({
setDbError={setDbError} setDbError={setDbError}
onAuthSuccess={onAuthSuccess} onAuthSuccess={onAuthSuccess}
/> />
</div>
<div className="flex flex-row items-center justify-center gap-8"> ) : (
{loggedIn && ( <div className="absolute top-[66px] left-0 w-full h-[calc(100%-66px)] flex items-center justify-center">
<div className="flex flex-col items-center gap-4 w-[350px]"> <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}

View File

@@ -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);

View File

@@ -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>

View File

@@ -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>

View File

@@ -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,

View File

@@ -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) {
} }

View File

@@ -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;

View File

@@ -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,19 +616,6 @@ 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">
<FileManagerOperations
currentPath={currentPath}
sshSessionId={currentHost?.id.toString() || null}
onOperationComplete={handleOperationComplete}
onError={handleError}
onSuccess={handleSuccess}
/>
</div>
)}
</div>
) : ( ) : (
(() => { (() => {
const tab = tabs.find(t => t.id === activeTab); const tab = tabs.find(t => t.id === activeTab);
@@ -645,6 +634,19 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
})() })()
)} )}
</div> </div>
{showOperations && (
<div className="w-80 border-l-2 border-[#303032] bg-[#09090b] overflow-y-auto">
<FileManagerOperations
currentPath={currentPath}
sshSessionId={currentHost?.id.toString() || null}
onOperationComplete={handleOperationComplete}
onError={handleError}
onSuccess={handleSuccess}
/>
</div>
)}
</div>
</div>
{deletingItem && ( {deletingItem && (
<div className="fixed inset-0 z-[99999]"> <div className="fixed inset-0 z-[99999]">

View File

@@ -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"/>
<span className="break-words">Create New File</span>
</h3> </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"/>
<span className="break-words">Create New Folder</span>
</h3> </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"/>
<span className="break-words">Delete Item</span>
</h3> </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"/>
<span className="break-words">Rename Item</span>
</h3> </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>

View File

@@ -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>

View File

@@ -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>
)} )}

View File

@@ -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}

View File

@@ -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(() => {
if (isVisible) {
fetchStatus(); fetchStatus();
fetchMetrics(); fetchMetrics();
}, 10_000); }
}, 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>

View File

@@ -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();
}, 0); if (terminal && !splitScreen) {
terminal.focus();
} }
}, [isVisible]); }, 0);
if (terminal && !splitScreen) {
setTimeout(() => {
terminal.focus();
}, 100);
}
}
}, [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();
}
}}
/>
); );
}); });

View File

@@ -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');
} }
} }